Add useSizeObserver and replace most react-measure

This commit is contained in:
Jamie Kyle
2023-07-25 16:56:56 -07:00
committed by GitHub
parent 7267391de4
commit 6c70cd450b
20 changed files with 539 additions and 421 deletions

View File

@@ -3,8 +3,6 @@
import { pick } from 'lodash';
import React, { useCallback } from 'react';
import type { MeasuredComponentProps } from 'react-measure';
import Measure from 'react-measure';
import type { ListRowProps } from 'react-virtualized';
import type { ConversationType } from '../state/ducks/conversations';
@@ -23,6 +21,7 @@ import { useRestoreFocus } from '../hooks/useRestoreFocus';
import { ListView } from './ListView';
import { ListTile } from './ListTile';
import type { ShowToastAction } from '../state/ducks/toast';
import { SizeObserver } from '../hooks/useSizeObserver';
type OwnProps = {
i18n: LocalizerType;
@@ -180,33 +179,26 @@ export function AddUserToAnotherGroupModal({
ref={inputRef}
value={searchTerm}
/>
<Measure bounds>
{({ contentRect, measureRef }: MeasuredComponentProps) => {
// Though `width` and `height` are required properties, we want to be
// careful in case the caller sends bogus data. Notably, react-measure's
// types seem to be inaccurate.
const { width = 100, height = 100 } = contentRect.bounds || {};
if (!width || !height) {
return null;
}
<SizeObserver>
{(ref, size) => {
return (
<div
className="AddUserToAnotherGroupModal__list-wrapper"
ref={measureRef}
ref={ref}
>
<ListView
width={width}
height={height}
rowCount={filteredConversations.length}
calculateRowHeight={handleCalculateRowHeight}
rowRenderer={renderGroupListItem}
/>
{size != null && (
<ListView
width={size.width}
height={size.height}
rowCount={filteredConversations.length}
calculateRowHeight={handleCalculateRowHeight}
rowRenderer={renderGroupListItem}
/>
)}
</div>
);
}}
</Measure>
</SizeObserver>
</div>
</Modal>
)}

View File

@@ -2,14 +2,14 @@
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useState, useCallback, useRef } from 'react';
import type { ContentRect } from 'react-measure';
import Measure from 'react-measure';
import { useComputePeaks } from '../hooks/useComputePeaks';
import type { LocalizerType } from '../types/Util';
import { WaveformScrubber } from './conversation/WaveformScrubber';
import { PlaybackButton } from './PlaybackButton';
import { RecordingComposer } from './RecordingComposer';
import * as log from '../logging/log';
import type { Size } from '../hooks/useSizeObserver';
import { SizeObserver } from '../hooks/useSizeObserver';
type Props = {
i18n: LocalizerType;
@@ -46,8 +46,8 @@ export function CompositionRecordingDraft({
const timeout = useRef<undefined | NodeJS.Timeout>(undefined);
const handleResize = useCallback(
({ bounds }: ContentRect) => {
if (!bounds || bounds.width === state.width) {
(size: Size) => {
if (size.width === state.width) {
return;
}
@@ -59,7 +59,7 @@ export function CompositionRecordingDraft({
clearTimeout(timeout.current);
}
const newWidth = bounds.width;
const newWidth = size.width;
// if mounting, set width immediately
// otherwise debounce
@@ -106,13 +106,13 @@ export function CompositionRecordingDraft({
}
onClick={handlePlaybackClick}
/>
<Measure bounds onResize={handleResize}>
{({ measureRef }) => (
<div ref={measureRef} className="CompositionRecordingDraft__sizer">
<SizeObserver onSizeChange={handleResize}>
{ref => (
<div ref={ref} className="CompositionRecordingDraft__sizer">
{scrubber}
</div>
)}
</Measure>
</SizeObserver>
</RecordingComposer>
);
}

View File

@@ -489,14 +489,11 @@ export function ConversationList({
]
);
// Though `width` and `height` are required properties, we want to be careful in case
// the caller sends bogus data. Notably, react-measure's types seem to be inaccurate.
const { width = 0, height = 0 } = dimensions || {};
if (!width || !height) {
if (dimensions == null) {
return null;
}
const widthBreakpoint = getConversationListWidthBreakpoint(width);
const widthBreakpoint = getConversationListWidthBreakpoint(dimensions.width);
return (
<ListView
@@ -504,8 +501,8 @@ export function ConversationList({
'module-conversation-list',
`module-conversation-list--width-${widthBreakpoint}`
)}
width={width}
height={height}
width={dimensions.width}
height={dimensions.height}
rowCount={rowCount}
calculateRowHeight={calculateRowHeight}
rowRenderer={renderRow}

View File

@@ -9,8 +9,6 @@ import React, {
useState,
Fragment,
} from 'react';
import type { MeasuredComponentProps } from 'react-measure';
import Measure from 'react-measure';
import { AttachmentList } from './conversation/AttachmentList';
import type { AttachmentType } from '../types/Attachment';
import { Button } from './Button';
@@ -42,6 +40,7 @@ import type { HydratedBodyRangesType } from '../types/BodyRange';
import { BodyRange } from '../types/BodyRange';
import { UserText } from './UserText';
import { Modal } from './Modal';
import { SizeObserver } from '../hooks/useSizeObserver';
export type DataPropsType = {
candidateConversations: ReadonlyArray<ConversationType>;
@@ -334,14 +333,14 @@ export function ForwardMessagesModal({
value={searchTerm}
/>
{candidateConversations.length ? (
<Measure bounds>
{({ contentRect, measureRef }: MeasuredComponentProps) => (
<SizeObserver>
{(ref, size) => (
<div
className="module-ForwardMessageModal__list-wrapper"
ref={measureRef}
ref={ref}
>
<ConversationList
dimensions={contentRect.bounds}
dimensions={size ?? undefined}
getPreferredBadge={getPreferredBadge}
getRow={getRow}
i18n={i18n}
@@ -379,7 +378,7 @@ export function ForwardMessagesModal({
/>
</div>
)}
</Measure>
</SizeObserver>
) : (
<div className="module-ForwardMessageModal__no-candidate-contacts">
{i18n('icu:noContactsFound')}

View File

@@ -2,7 +2,6 @@
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useState, useMemo, useEffect } from 'react';
import Measure from 'react-measure';
import { takeWhile, clamp, chunk, maxBy, flatten, noop } from 'lodash';
import type { VideoFrameSource } from '@signalapp/ringrtc';
import { GroupCallRemoteParticipant } from './GroupCallRemoteParticipant';
@@ -25,6 +24,7 @@ import { filter, join } from '../util/iterables';
import * as setUtil from '../util/setUtil';
import * as log from '../logging/log';
import { MAX_FRAME_HEIGHT, MAX_FRAME_WIDTH } from '../calling/constants';
import { SizeObserver } from '../hooks/useSizeObserver';
const MIN_RENDERED_HEIGHT = 180;
const PARTICIPANT_MARGIN = 10;
@@ -398,40 +398,27 @@ export function GroupCallRemoteParticipants({
]);
return (
<Measure
bounds
onResize={({ bounds }) => {
if (!bounds) {
log.error('We should be measuring the bounds');
return;
}
setContainerDimensions(bounds);
<SizeObserver
onSizeChange={size => {
setContainerDimensions(size);
}}
>
{containerMeasure => (
<div
className="module-ongoing-call__participants"
ref={containerMeasure.measureRef}
>
<Measure
bounds
onResize={({ bounds }) => {
if (!bounds) {
log.error('We should be measuring the bounds');
return;
}
setGridDimensions(bounds);
{containerRef => (
<div className="module-ongoing-call__participants" ref={containerRef}>
<SizeObserver
onSizeChange={size => {
setGridDimensions(size);
}}
>
{gridMeasure => (
{gridRef => (
<div
className="module-ongoing-call__participants__grid"
ref={gridMeasure.measureRef}
ref={gridRef}
>
{flatten(rowElements)}
</div>
)}
</Measure>
</SizeObserver>
<GroupCallOverflowArea
getFrameBuffer={getFrameBuffer}
@@ -444,7 +431,7 @@ export function GroupCallRemoteParticipants({
/>
</div>
)}
</Measure>
</SizeObserver>
);
}

View File

@@ -2,8 +2,6 @@
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useEffect, useCallback, useMemo, useState } from 'react';
import type { MeasuredComponentProps } from 'react-measure';
import Measure from 'react-measure';
import classNames from 'classnames';
import { clamp, isNumber, noop } from 'lodash';
@@ -51,6 +49,7 @@ import type {
ReplaceAvatarActionType,
SaveAvatarToDiskActionType,
} from '../types/Avatar';
import { SizeObserver } from '../hooks/useSizeObserver';
export enum LeftPaneMode {
Inbox,
@@ -652,9 +651,9 @@ export function LeftPane({
))}
</div>
{preRowsNode && <React.Fragment key={0}>{preRowsNode}</React.Fragment>}
<Measure bounds>
{({ contentRect, measureRef }: MeasuredComponentProps) => (
<div className="module-left-pane__list--measure" ref={measureRef}>
<SizeObserver>
{(ref, size) => (
<div className="module-left-pane__list--measure" ref={ref}>
<div className="module-left-pane__list--wrapper">
<div
aria-live="polite"
@@ -667,7 +666,7 @@ export function LeftPane({
<ConversationList
dimensions={{
width,
height: contentRect.bounds?.height || 0,
height: size?.height || 0,
}}
getPreferredBadge={getPreferredBadge}
getRow={getRow}
@@ -717,7 +716,7 @@ export function LeftPane({
</div>
</div>
)}
</Measure>
</SizeObserver>
{footerContents && (
<div className="module-left-pane__footer">{footerContents}</div>
)}

View File

@@ -1,7 +1,6 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import Measure from 'react-measure';
import React, { useCallback, useEffect, useState } from 'react';
import classNames from 'classnames';
import { createPortal } from 'react-dom';
@@ -47,6 +46,7 @@ import type { HydratedBodyRangesType } from '../types/BodyRange';
import { MessageBody } from './conversation/MessageBody';
import { RenderLocation } from './conversation/MessageTextRenderer';
import { arrow } from '../util/keyboard';
import { SizeObserver } from '../hooks/useSizeObserver';
export type MediaEditorResultType = Readonly<{
data: Uint8Array;
@@ -911,19 +911,14 @@ export function MediaEditor({
return createPortal(
<div className="MediaEditor">
<div className="MediaEditor__container">
<Measure
bounds
onResize={({ bounds }) => {
if (!bounds) {
log.error('We should be measuring the bounds');
return;
}
setContainerWidth(bounds.width);
setContainerHeight(bounds.height);
<SizeObserver
onSizeChange={size => {
setContainerWidth(size.width);
setContainerHeight(size.height);
}}
>
{({ measureRef }) => (
<div className="MediaEditor__media" ref={measureRef}>
{ref => (
<div className="MediaEditor__media" ref={ref}>
{image && (
<div>
<canvas
@@ -937,7 +932,7 @@ export function MediaEditor({
)}
</div>
)}
</Measure>
</SizeObserver>
</div>
<div className="MediaEditor__toolbar">
{tooling ? (

View File

@@ -3,8 +3,6 @@
import type { ReactElement, ReactNode } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import type { ContentRect, MeasuredComponentProps } from 'react-measure';
import Measure from 'react-measure';
import classNames from 'classnames';
import { noop } from 'lodash';
import { animated } from '@react-spring/web';
@@ -16,8 +14,12 @@ import { assertDev } from '../util/assert';
import { getClassNamesFor } from '../util/getClassNamesFor';
import { useAnimated } from '../hooks/useAnimated';
import { useHasWrapped } from '../hooks/useHasWrapped';
import { useRefMerger } from '../hooks/useRefMerger';
import * as log from '../logging/log';
import {
isOverflowing,
isScrolled,
useScrollObserver,
} from '../hooks/useSizeObserver';
type PropsType = {
children: ReactNode;
@@ -169,24 +171,19 @@ export function ModalPage({
}: ModalPageProps): JSX.Element {
const modalRef = useRef<HTMLDivElement | null>(null);
const refMerger = useRefMerger();
const bodyRef = useRef<HTMLDivElement>(null);
const bodyInnerRef = useRef<HTMLDivElement>(null);
const bodyRef = useRef<HTMLDivElement | null>(null);
const [scrolled, setScrolled] = useState(false);
const [hasOverflow, setHasOverflow] = useState(false);
const hasHeader = Boolean(hasXButton || title || onBackButtonClick);
const getClassName = getClassNamesFor(BASE_CLASS_NAME, moduleClassName);
function handleResize({ scroll }: ContentRect) {
const modalNode = modalRef?.current;
if (!modalNode) {
return;
}
if (scroll) {
setHasOverflow(scroll.height > modalNode.clientHeight);
}
}
useScrollObserver(bodyRef, bodyInnerRef, scroll => {
setScrolled(isScrolled(scroll));
setHasOverflow(isOverflowing(scroll));
});
return (
<>
@@ -249,26 +246,16 @@ export function ModalPage({
)}
</div>
)}
<Measure scroll onResize={handleResize}>
{({ measureRef }: MeasuredComponentProps) => (
<div
className={classNames(
getClassName('__body'),
scrolled ? getClassName('__body--scrolled') : null,
hasOverflow || scrolled
? getClassName('__body--overflow')
: null
)}
onScroll={() => {
const scrollTop = bodyRef.current?.scrollTop || 0;
setScrolled(scrollTop > 2);
}}
ref={refMerger(measureRef, bodyRef)}
>
{children}
</div>
<div
className={classNames(
getClassName('__body'),
scrolled ? getClassName('__body--scrolled') : null,
hasOverflow || scrolled ? getClassName('__body--overflow') : null
)}
</Measure>
ref={bodyRef}
>
<div ref={bodyInnerRef}>{children}</div>
</div>
{modalFooter && <Modal.ButtonFooter>{modalFooter}</Modal.ButtonFooter>}
</div>
</>

View File

@@ -1,10 +1,8 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { MeasuredComponentProps } from 'react-measure';
import type { ReactNode } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import Measure from 'react-measure';
import { noop } from 'lodash';
import type { ConversationType } from '../state/ducks/conversations';
@@ -41,6 +39,7 @@ import { useConfirmDiscard } from '../hooks/useConfirmDiscard';
import { getGroupMemberships } from '../util/getGroupMemberships';
import { strictAssert } from '../util/assert';
import { UserText } from './UserText';
import { SizeObserver } from '../hooks/useSizeObserver';
export type PropsType = {
candidateConversations: Array<ConversationType>;
@@ -1193,14 +1192,11 @@ export function EditDistributionListModal({
</ContactPills>
) : undefined}
{candidateConversations.length ? (
<Measure bounds>
{({ contentRect, measureRef }: MeasuredComponentProps) => (
<div
className="StoriesSettingsModal__conversation-list"
ref={measureRef}
>
<SizeObserver>
{(ref, size) => (
<div className="StoriesSettingsModal__conversation-list" ref={ref}>
<ConversationList
dimensions={contentRect.bounds}
dimensions={size ?? undefined}
getPreferredBadge={getPreferredBadge}
getRow={getRow}
i18n={i18n}
@@ -1228,7 +1224,7 @@ export function EditDistributionListModal({
/>
</div>
)}
</Measure>
</SizeObserver>
) : (
<div className="module-ForwardMessageModal__no-candidate-contacts">
{i18n('icu:noContactsFound')}

View File

@@ -1,7 +1,6 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import Measure from 'react-measure';
import React, { forwardRef, useEffect, useRef, useState } from 'react';
import TextareaAutosize from 'react-textarea-autosize';
import classNames from 'classnames';
@@ -22,6 +21,7 @@ import {
} from '../util/getStoryBackground';
import { SECOND } from '../util/durations';
import { useRefMerger } from '../hooks/useRefMerger';
import { useSizeObserver } from '../hooks/useSizeObserver';
const renderNewLines: RenderTextCallbackType = ({
text: textWithNewLines,
@@ -169,152 +169,142 @@ export const TextAttachment = forwardRef<HTMLTextAreaElement, PropsType>(
background: getBackgroundColor(textAttachment),
};
return (
<Measure bounds>
{({ contentRect, measureRef }) => {
const scaleFactor = (contentRect.bounds?.height || 1) / 1280;
const ref = useRef<HTMLDivElement>(null);
const size = useSizeObserver(ref);
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
className="TextAttachment"
onClick={() => {
if (linkPreviewOffsetTop) {
setLinkPreviewOffsetTop(undefined);
}
onClick?.();
}}
onKeyUp={ev => {
if (ev.key === 'Escape' && linkPreviewOffsetTop) {
setLinkPreviewOffsetTop(undefined);
}
}}
ref={measureRef}
style={isThumbnail ? storyBackgroundColor : undefined}
>
{/*
const scaleFactor = (size?.height || 1) / 1280;
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
className="TextAttachment"
onClick={() => {
if (linkPreviewOffsetTop) {
setLinkPreviewOffsetTop(undefined);
}
onClick?.();
}}
onKeyUp={ev => {
if (ev.key === 'Escape' && linkPreviewOffsetTop) {
setLinkPreviewOffsetTop(undefined);
}
}}
ref={ref}
style={isThumbnail ? storyBackgroundColor : undefined}
>
{/*
The tooltip must be outside of the scaled area, as it should not scale with
the story, but it must be positioned using the scaled offset
*/}
{textAttachment.preview &&
textAttachment.preview.url &&
linkPreviewOffsetTop &&
!isThumbnail && (
<a
className="TextAttachment__preview__tooltip"
href={textAttachment.preview.url}
rel="noreferrer"
style={{
top: linkPreviewOffsetTop * scaleFactor - 89, // minus height of tooltip and some spacing
}}
target="_blank"
>
<div>
<div className="TextAttachment__preview__tooltip__title">
{i18n('icu:TextAttachment__preview__link')}
</div>
<div className="TextAttachment__preview__tooltip__url">
{textAttachment.preview.url}
</div>
</div>
<div className="TextAttachment__preview__tooltip__arrow" />
</a>
)}
<div
className="TextAttachment__story"
style={{
...(isThumbnail ? {} : storyBackgroundColor),
transform: `scale(${scaleFactor})`,
}}
>
{(textContent || onChange) && (
<div
className={classNames('TextAttachment__text', {
'TextAttachment__text--with-bg': Boolean(
textAttachment.textBackgroundColor
),
})}
style={{
backgroundColor: textAttachment.textBackgroundColor
? getHexFromNumber(textAttachment.textBackgroundColor)
: 'transparent',
}}
>
{onChange ? (
<TextareaAutosize
dir="auto"
className="TextAttachment__text__container TextAttachment__text__textarea"
disabled={!isEditingText}
onChange={ev => onChange(ev.currentTarget.value)}
placeholder={i18n('icu:TextAttachment__placeholder')}
ref={refMerger(forwardedTextEditorRef, textEditorRef)}
style={getTextStyles(
textContent,
textAttachment.textForegroundColor,
textAttachment.textStyle,
i18n
)}
value={textContent}
/>
) : (
<div
className="TextAttachment__text__container"
style={getTextStyles(
textContent,
textAttachment.textForegroundColor,
textAttachment.textStyle,
i18n
)}
>
<Emojify
text={textContent}
renderNonEmoji={renderNewLines}
/>
</div>
)}
</div>
)}
{textAttachment.preview && textAttachment.preview.url && (
<div
className={classNames('TextAttachment__preview-container', {
'TextAttachment__preview-container--large': Boolean(
textAttachment.preview.title
),
})}
ref={linkPreview}
onBlur={() => setIsHoveringOverTooltip(false)}
onFocus={showTooltip}
onMouseOut={() => setIsHoveringOverTooltip(false)}
onMouseOver={showTooltip}
>
{onRemoveLinkPreview && (
<div className="TextAttachment__preview__remove">
<button
aria-label={i18n(
'icu:Keyboard--remove-draft-link-preview'
)}
type="button"
onClick={onRemoveLinkPreview}
/>
</div>
)}
<StoryLinkPreview
{...textAttachment.preview}
domain={getDomain(String(textAttachment.preview.url))}
forceCompactMode={
getTextSize(textContent) !== TextSize.Large
}
i18n={i18n}
title={textAttachment.preview.title || undefined}
url={textAttachment.preview.url}
/>
</div>
)}
{textAttachment.preview &&
textAttachment.preview.url &&
linkPreviewOffsetTop &&
!isThumbnail && (
<a
className="TextAttachment__preview__tooltip"
href={textAttachment.preview.url}
rel="noreferrer"
style={{
top: linkPreviewOffsetTop * scaleFactor - 89, // minus height of tooltip and some spacing
}}
target="_blank"
>
<div>
<div className="TextAttachment__preview__tooltip__title">
{i18n('icu:TextAttachment__preview__link')}
</div>
<div className="TextAttachment__preview__tooltip__url">
{textAttachment.preview.url}
</div>
</div>
<div className="TextAttachment__preview__tooltip__arrow" />
</a>
)}
<div
className="TextAttachment__story"
style={{
...(isThumbnail ? {} : storyBackgroundColor),
transform: `scale(${scaleFactor})`,
}}
>
{(textContent || onChange) && (
<div
className={classNames('TextAttachment__text', {
'TextAttachment__text--with-bg': Boolean(
textAttachment.textBackgroundColor
),
})}
style={{
backgroundColor: textAttachment.textBackgroundColor
? getHexFromNumber(textAttachment.textBackgroundColor)
: 'transparent',
}}
>
{onChange ? (
<TextareaAutosize
dir="auto"
className="TextAttachment__text__container TextAttachment__text__textarea"
disabled={!isEditingText}
onChange={ev => onChange(ev.currentTarget.value)}
placeholder={i18n('icu:TextAttachment__placeholder')}
ref={refMerger(forwardedTextEditorRef, textEditorRef)}
style={getTextStyles(
textContent,
textAttachment.textForegroundColor,
textAttachment.textStyle,
i18n
)}
value={textContent}
/>
) : (
<div
className="TextAttachment__text__container"
style={getTextStyles(
textContent,
textAttachment.textForegroundColor,
textAttachment.textStyle,
i18n
)}
>
<Emojify text={textContent} renderNonEmoji={renderNewLines} />
</div>
)}
</div>
);
}}
</Measure>
)}
{textAttachment.preview && textAttachment.preview.url && (
<div
className={classNames('TextAttachment__preview-container', {
'TextAttachment__preview-container--large': Boolean(
textAttachment.preview.title
),
})}
ref={linkPreview}
onBlur={() => setIsHoveringOverTooltip(false)}
onFocus={showTooltip}
onMouseOut={() => setIsHoveringOverTooltip(false)}
onMouseOver={showTooltip}
>
{onRemoveLinkPreview && (
<div className="TextAttachment__preview__remove">
<button
aria-label={i18n('icu:Keyboard--remove-draft-link-preview')}
type="button"
onClick={onRemoveLinkPreview}
/>
</div>
)}
<StoryLinkPreview
{...textAttachment.preview}
domain={getDomain(String(textAttachment.preview.url))}
forceCompactMode={getTextSize(textContent) !== TextSize.Large}
i18n={i18n}
title={textAttachment.preview.title || undefined}
url={textAttachment.preview.url}
/>
</div>
)}
</div>
</div>
);
}
);

View File

@@ -3,7 +3,6 @@
import type { ReactNode } from 'react';
import React from 'react';
import Measure from 'react-measure';
import classNames from 'classnames';
import {
ContextMenu,
@@ -40,6 +39,7 @@ import {
import { PanelType } from '../../types/Panels';
import { UserText } from '../UserText';
import { Alert } from '../Alert';
import { SizeObserver } from '../../hooks/useSizeObserver';
export enum OutgoingCallButtonStyle {
None,
@@ -783,16 +783,12 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
{this.renderDeleteMessagesConfirmationDialog()}
{this.renderLeaveGroupConfirmationDialog()}
{this.renderCannotLeaveGroupBecauseYouAreLastAdminAlert()}
<Measure
bounds
onResize={({ bounds }) => {
if (!bounds || !bounds.width) {
return;
}
this.setState({ isNarrow: bounds.width < 500 });
<SizeObserver
onSizeChange={size => {
this.setState({ isNarrow: size.width < 500 });
}}
>
{({ measureRef }) => (
{measureRef => (
<div
className={classNames('module-ConversationHeader', {
'module-ConversationHeader--narrow': isNarrow,
@@ -821,7 +817,7 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
{this.renderMenu(triggerId)}
</div>
)}
</Measure>
</SizeObserver>
</>
);
}

View File

@@ -4,8 +4,6 @@
import type { ReactChild } from 'react';
import React, { forwardRef, useCallback, useState } from 'react';
import classNames from 'classnames';
import type { ContentRect } from 'react-measure';
import Measure from 'react-measure';
import type { LocalizerType } from '../../types/Util';
import type { DirectionType, MessageStatusType } from './Message';
@@ -17,6 +15,8 @@ import { PanelType } from '../../types/Panels';
import { Spinner } from '../Spinner';
import { ConfirmationDialog } from '../ConfirmationDialog';
import { refMerger } from '../../util/refMerger';
import type { Size } from '../../hooks/useSizeObserver';
import { SizeObserver } from '../../hooks/useSizeObserver';
type PropsType = {
deletedForEveryone?: boolean;
@@ -254,21 +254,21 @@ export const MessageMetadata = forwardRef<HTMLDivElement, Readonly<PropsType>>(
);
const onResize = useCallback(
({ bounds }: ContentRect) => {
onWidthMeasured?.(bounds?.width || 0);
(size: Size) => {
onWidthMeasured?.(size.width);
},
[onWidthMeasured]
);
if (onWidthMeasured) {
return (
<Measure bounds onResize={onResize}>
{({ measureRef }) => (
<SizeObserver onSizeChange={onResize}>
{measureRef => (
<div className={className} ref={refMerger(measureRef, ref)}>
{children}
</div>
)}
</Measure>
</SizeObserver>
);
}

View File

@@ -5,7 +5,6 @@ import { first, get, isNumber, last, throttle } from 'lodash';
import classNames from 'classnames';
import type { ReactChild, ReactNode, RefObject } from 'react';
import React from 'react';
import Measure from 'react-measure';
import type { ReadonlyDeep } from 'type-fest';
import { ScrollDownButton, ScrollDownButtonVariant } from './ScrollDownButton';
@@ -43,6 +42,7 @@ import {
} from '../../util/scrollUtil';
import { LastSeenIndicator } from './LastSeenIndicator';
import { MINUTE } from '../../util/durations';
import { SizeObserver } from '../../hooks/useSizeObserver';
const AT_BOTTOM_THRESHOLD = 15;
const AT_BOTTOM_DETECTOR_STYLE = { height: AT_BOTTOM_THRESHOLD };
@@ -204,7 +204,6 @@ export class Timeline extends React.Component<
private readonly atBottomDetectorRef = React.createRef<HTMLDivElement>();
private readonly lastSeenIndicatorRef = React.createRef<HTMLDivElement>();
private intersectionObserver?: IntersectionObserver;
private intersectionObserverCallbackFrame?: number;
// This is a best guess. It will likely be overridden when the timeline is measured.
private maxVisibleRows = Math.ceil(window.innerHeight / MIN_ROW_HEIGHT);
@@ -340,10 +339,6 @@ export class Timeline extends React.Component<
// this another way, but this approach works.)
this.intersectionObserver?.disconnect();
if (this.intersectionObserverCallbackFrame !== undefined) {
window.cancelAnimationFrame(this.intersectionObserverCallbackFrame);
}
const intersectionRatios = new Map<Element, number>();
const intersectionObserverCallback: IntersectionObserverCallback =
@@ -445,19 +440,12 @@ export class Timeline extends React.Component<
'observer.disconnect() should prevent callbacks from firing'
);
// `react-measure` schedules the callbacks on the next tick and so
// should we because we want other parts of this component to respond
// to resize events before we recalculate what is visible.
this.intersectionObserverCallbackFrame = window.requestAnimationFrame(
() => {
// Observer was updated from under us
if (this.intersectionObserver !== observer) {
return;
}
// Observer was updated from under us
if (this.intersectionObserver !== observer) {
return;
}
intersectionObserverCallback(entries, observer);
}
);
intersectionObserverCallback(entries, observer);
},
{
root: containerEl,
@@ -1002,17 +990,12 @@ export class Timeline extends React.Component<
}
headerElements = (
<Measure
bounds
onResize={({ bounds }) => {
if (!bounds) {
assertDev(false, 'We should be measuring the bounds');
return;
}
this.setState({ lastMeasuredWarningHeight: bounds.height });
<SizeObserver
onSizeChange={size => {
this.setState({ lastMeasuredWarningHeight: size.height });
}}
>
{({ measureRef }) => (
{measureRef => (
<TimelineWarnings ref={measureRef}>
{renderMiniPlayer({ shouldFlow: true })}
{text && (
@@ -1025,7 +1008,7 @@ export class Timeline extends React.Component<
)}
</TimelineWarnings>
)}
</Measure>
</SizeObserver>
);
}
@@ -1061,18 +1044,15 @@ export class Timeline extends React.Component<
return (
<>
<Measure
bounds
onResize={({ bounds }) => {
<SizeObserver
onSizeChange={size => {
const { isNearBottom } = this.props;
strictAssert(bounds, 'We should be measuring the bounds');
this.setState({
widthBreakpoint: getWidthBreakpoint(bounds.width),
widthBreakpoint: getWidthBreakpoint(size.width),
});
this.maxVisibleRows = Math.ceil(bounds.height / MIN_ROW_HEIGHT);
this.maxVisibleRows = Math.ceil(size.height / MIN_ROW_HEIGHT);
const containerEl = this.containerRef.current;
if (containerEl && isNearBottom) {
@@ -1080,7 +1060,7 @@ export class Timeline extends React.Component<
}
}}
>
{({ measureRef }) => (
{ref => (
<div
className={classNames(
'module-timeline',
@@ -1091,7 +1071,7 @@ export class Timeline extends React.Component<
tabIndex={-1}
onBlur={this.handleBlur}
onKeyDown={this.handleKeyDown}
ref={measureRef}
ref={ref}
>
{headerElements}
@@ -1152,7 +1132,7 @@ export class Timeline extends React.Component<
) : null}
</div>
)}
</Measure>
</SizeObserver>
{Boolean(invitedContactsForNewlyCreatedGroup.length) && (
<NewlyCreatedGroupInvitedContactsDialog

View File

@@ -9,8 +9,6 @@ import React, {
useCallback,
} from 'react';
import { omit } from 'lodash';
import type { MeasuredComponentProps } from 'react-measure';
import Measure from 'react-measure';
import type { ListRowProps } from 'react-virtualized';
import type { LocalizerType, ThemeType } from '../../../../types/Util';
@@ -47,6 +45,7 @@ import { SearchInput } from '../../../SearchInput';
import { ListView } from '../../../ListView';
import { UsernameCheckbox } from '../../../conversationList/UsernameCheckbox';
import { PhoneNumberCheckbox } from '../../../conversationList/PhoneNumberCheckbox';
import { SizeObserver } from '../../../../hooks/useSizeObserver';
export type StatePropsType = {
regionCode: string | undefined;
@@ -432,16 +431,8 @@ export function ChooseGroupMembersModal({
</ContactPills>
)}
{rowCount ? (
<Measure bounds>
{({ contentRect, measureRef }: MeasuredComponentProps) => {
// Though `width` and `height` are required properties, we want to be
// careful in case the caller sends bogus data. Notably, react-measure's
// types seem to be inaccurate.
const { width = 100, height = 100 } = contentRect.bounds || {};
if (!width || !height) {
return null;
}
<SizeObserver>
{(ref, size) => {
// We disable this ESLint rule because we're capturing a bubbled keydown
// event. See [this note in the jsx-a11y docs][0].
//
@@ -450,38 +441,40 @@ export function ChooseGroupMembersModal({
return (
<div
className="module-AddGroupMembersModal__list-wrapper"
ref={measureRef}
ref={ref}
onKeyDown={event => {
if (event.key === 'Enter') {
inputRef.current?.focus();
}
}}
>
<ListView
width={width}
height={height}
rowCount={rowCount}
calculateRowHeight={index => {
const row = getRow(index);
if (!row) {
assertDev(false, `Expected a row at index ${index}`);
return 52;
}
switch (row.type) {
case RowType.Header:
return 40;
default:
{size != null && (
<ListView
width={size.width}
height={size.height}
rowCount={rowCount}
calculateRowHeight={index => {
const row = getRow(index);
if (!row) {
assertDev(false, `Expected a row at index ${index}`);
return 52;
}
}}
rowRenderer={renderItem}
/>
}
switch (row.type) {
case RowType.Header:
return 40;
default:
return 52;
}
}}
rowRenderer={renderItem}
/>
)}
</div>
);
/* eslint-enable jsx-a11y/no-static-element-interactions */
}}
</Measure>
</SizeObserver>
) : (
<div className="module-AddGroupMembersModal__no-candidate-contacts">
{i18n('icu:noContactsFound')}

View File

@@ -0,0 +1,188 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { RefObject } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { strictAssert } from '../util/assert';
export type Size = Readonly<{
width: number;
height: number;
}>;
export type SizeChangeHandler = (size: Size) => void;
export function isSameSize(a: Size, b: Size): boolean {
return a.width === b.width && a.height === b.height;
}
export function useSizeObserver<T extends Element = Element>(
ref: RefObject<T>,
/**
* Note: If you provide `onSizeChange`, `useSizeObserver()` will always return `null`
*/
onSizeChange?: SizeChangeHandler
): Size | null {
const [size, setSize] = useState<Size | null>(null);
const sizeRef = useRef<Size | null>(null);
const onSizeChangeRef = useRef<SizeChangeHandler | void>(onSizeChange);
useEffect(() => {
// This means you don't need to wrap `onSizeChange` with `useCallback()`
onSizeChangeRef.current = onSizeChange;
}, [onSizeChange]);
useEffect(() => {
const observer = new ResizeObserver(entries => {
// It's possible that ResizeObserver emit entries after disconnect()
if (ref.current == null) {
return;
}
// We're only ever observing one element, and `ResizeObserver` for some
// reason is an array of exactly one rect (I assume to support wrapped
// inline elements in the future)
const borderBoxSize = entries[0].borderBoxSize[0];
// We are assuming a horizontal writing-mode here, we could call
// `getBoundingClientRect()` here but MDN says not to. In the future if
// we are adding support for a vertical locale we may need to change this
const next: Size = {
width: borderBoxSize.inlineSize,
height: borderBoxSize.blockSize,
};
const prev = sizeRef.current;
if (prev == null || !isSameSize(prev, next)) {
sizeRef.current = next;
if (onSizeChangeRef.current != null) {
onSizeChangeRef.current(next);
} else {
setSize(next);
}
}
});
strictAssert(
ref.current instanceof Element,
'ref must be assigned to an element'
);
observer.observe(ref.current, {
box: 'border-box',
});
return () => {
observer.disconnect();
};
}, [ref]);
return size;
}
// Note we use `any` for ref below because TypeScript doesn't currently have
// good inference for JSX generics and it creates confusing errors. We have
// a better error being reported by the hook.
export type SizeObserverProps = Readonly<{
/**
* Note: If you provide `onSizeChange`, in `children()` the `size` will always be `null`
*/
onSizeChange?: SizeChangeHandler;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
children(ref: RefObject<any>, size: Size | null): JSX.Element;
}>;
export function SizeObserver({
onSizeChange,
children,
}: SizeObserverProps): JSX.Element {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ref = useRef<any>();
const size = useSizeObserver(ref, onSizeChange);
return children(ref, size);
}
export type Scroll = Readonly<{
scrollTop: number;
scrollHeight: number;
clientHeight: number;
}>;
export type ScrollChangeHandler = (scroll: Scroll) => void;
export function isSameScroll(a: Scroll, b: Scroll): boolean {
return (
a.scrollTop === b.scrollTop &&
a.scrollHeight === b.scrollHeight &&
a.clientHeight === b.clientHeight
);
}
export function isOverflowing(scroll: Scroll): boolean {
return scroll.scrollHeight > scroll.clientHeight;
}
export function isScrolled(scroll: Scroll): boolean {
return scroll.scrollTop > 0;
}
export function isScrolledToBottom(scroll: Scroll, threshold = 0): boolean {
const maxScrollTop = scroll.scrollHeight - scroll.clientHeight;
return scroll.scrollTop >= maxScrollTop - threshold;
}
/**
* We need an extra element because there is no ResizeObserver equivalent for
* `scrollHeight`. You need something measuring the scroll container and an
* inner element wrapping all of its children.
*
* ```
* const scrollerRef = useRef()
* const scrollerInnerRef = useRef()
*
* useScrollObserver(scrollerRef, scrollerInnerRef, (scroll) => {
* setIsOverflowing(isOverflowing(scroll));
* setIsScrolled(isScrolled(scroll));
* setAtBottom(isScrolledToBottom(scroll));
* })
*
* <div ref={scrollerRef} style={{ overflow: "auto" }}>
* <div ref={scrollerInnerRef}>
* {children}
* </div>
* </div>
* ```
*/
export function useScrollObserver(
scrollerRef: RefObject<HTMLElement>,
scrollerInnerRef: RefObject<HTMLElement>,
onScrollChange: (scroll: Scroll) => void
): void {
const scrollRef = useRef<Scroll | null>(null);
const onScrollChangeRef = useRef<ScrollChangeHandler>(onScrollChange);
useEffect(() => {
// This means you don't need to wrap `onScrollChange` with `useCallback()`
onScrollChangeRef.current = onScrollChange;
}, [onScrollChange]);
const onUpdate = useCallback(() => {
const target = scrollerRef.current;
strictAssert(
target instanceof Element,
'ref must be assigned to an element'
);
const next: Scroll = {
scrollTop: target.scrollTop,
scrollHeight: target.scrollHeight,
clientHeight: target.clientHeight,
};
const prev = scrollRef.current;
if (prev == null || !isSameScroll(prev, next)) {
scrollRef.current = next;
onScrollChangeRef.current(next);
}
}, [scrollerRef]);
useSizeObserver(scrollerRef, onUpdate);
useSizeObserver(scrollerInnerRef, onUpdate);
useEffect(() => {
strictAssert(
scrollerRef.current instanceof Element,
'ref must be assigned to an element'
);
const target = scrollerRef.current;
target.addEventListener('scroll', onUpdate, { passive: true });
return () => {
target.removeEventListener('scroll', onUpdate);
};
}, [scrollerRef, onUpdate]);
}

View File

@@ -2401,9 +2401,82 @@
{
"rule": "React-useRef",
"path": "ts/components/Modal.tsx",
"line": " const bodyRef = useRef<HTMLDivElement | null>(null);",
"reasonCategory": "usageTrusted",
"updated": "2021-09-21T01:40:08.534Z"
"line": " const bodyRef = useRef<HTMLDivElement>(null);",
"reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted",
"updated": "2023-07-25T21:55:26.191Z",
"reasonDetail": "<optional>"
},
{
"rule": "React-useRef",
"path": "ts/components/Modal.tsx",
"line": " const bodyInnerRef = useRef<HTMLDivElement>(null);",
"reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted",
"updated": "2023-07-25T21:55:26.191Z",
"reasonDetail": "<optional>"
},
{
"rule": "React-useRef",
"path": "ts/components/TextAttachment.tsx",
"line": " const ref = useRef<HTMLDivElement>(null);",
"reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted",
"updated": "2023-07-25T21:55:26.191Z",
"reasonDetail": "<optional>"
},
{
"rule": "React-useRef",
"path": "ts/hooks/useSizeObserver.tsx",
"line": " const sizeRef = useRef<Size | null>(null);",
"reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted",
"updated": "2023-07-25T21:55:26.191Z",
"reasonDetail": "<optional>"
},
{
"rule": "React-useRef",
"path": "ts/hooks/useSizeObserver.tsx",
"line": " const onSizeChangeRef = useRef<SizeChangeHandler | void>(onSizeChange);",
"reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted",
"updated": "2023-07-25T21:55:26.191Z",
"reasonDetail": "<optional>"
},
{
"rule": "React-useRef",
"path": "ts/hooks/useSizeObserver.tsx",
"line": " const ref = useRef<any>();",
"reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted",
"updated": "2023-07-25T21:55:26.191Z",
"reasonDetail": "<optional>"
},
{
"rule": "React-useRef",
"path": "ts/hooks/useSizeObserver.tsx",
"line": " * const scrollerRef = useRef()",
"reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted",
"updated": "2023-07-25T21:55:26.191Z",
"reasonDetail": "<optional>"
},
{
"rule": "React-useRef",
"path": "ts/hooks/useSizeObserver.tsx",
"line": " * const scrollerInnerRef = useRef()",
"reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted",
"updated": "2023-07-25T21:55:26.191Z",
"reasonDetail": "<optional>"
},
{
"rule": "React-useRef",
"path": "ts/hooks/useSizeObserver.tsx",
"line": " const scrollRef = useRef<Scroll | null>(null);",
"reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted",
"updated": "2023-07-25T21:55:26.191Z",
"reasonDetail": "<optional>"
},
{
"rule": "React-useRef",
"path": "ts/hooks/useSizeObserver.tsx",
"line": " const onScrollChangeRef = useRef<ScrollChangeHandler>(onScrollChange);",
"reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted",
"updated": "2023-07-25T21:55:26.191Z",
"reasonDetail": "<optional>"
},
{
"rule": "React-useRef",

View File

@@ -88,7 +88,6 @@ const excludedFilesRegexp = RegExp(
'^node_modules/react-hot-loader/.+',
'^node_modules/react-icon-base/.+',
'^node_modules/react-input-autosize/.+',
'^node_modules/react-measure/.+',
'^node_modules/react-popper/.+',
'^node_modules/react-redux/.+',
'^node_modules/react-router/.+',