From 6c70cd450b0c6e104ed1ce7deaa332501f4228dd Mon Sep 17 00:00:00 2001 From: Jamie Kyle <113370520+jamiebuilds-signal@users.noreply.github.com> Date: Tue, 25 Jul 2023 16:56:56 -0700 Subject: [PATCH] Add useSizeObserver and replace most react-measure --- ACKNOWLEDGMENTS.md | 24 -- package.json | 2 - ts/components/AddUserToAnotherGroupModal.tsx | 36 +-- ts/components/CompositionRecordingDraft.tsx | 18 +- ts/components/ConversationList.tsx | 11 +- ts/components/ForwardMessagesModal.tsx | 13 +- ts/components/GroupCallRemoteParticipants.tsx | 39 +-- ts/components/LeftPane.tsx | 13 +- ts/components/MediaEditor.tsx | 21 +- ts/components/Modal.tsx | 53 ++-- ts/components/StoriesSettingsModal.tsx | 16 +- ts/components/TextAttachment.tsx | 272 +++++++++--------- .../conversation/ConversationHeader.tsx | 16 +- .../conversation/MessageMetadata.tsx | 14 +- ts/components/conversation/Timeline.tsx | 56 ++-- .../ChooseGroupMembersModal.tsx | 59 ++-- ts/hooks/useSizeObserver.tsx | 188 ++++++++++++ ts/util/lint/exceptions.json | 79 ++++- ts/util/lint/linter.ts | 1 - yarn.lock | 29 +- 20 files changed, 539 insertions(+), 421 deletions(-) create mode 100644 ts/hooks/useSizeObserver.tsx diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md index a1c8e68977b1..5b2e73ffdae8 100644 --- a/ACKNOWLEDGMENTS.md +++ b/ACKNOWLEDGMENTS.md @@ -2138,30 +2138,6 @@ Signal Desktop makes use of the following open source projects. License: BSD-3-Clause -## react-measure - - The MIT License (MIT) - - Copyright (c) 2018 React Measure authors - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. - ## react-popper The MIT License (MIT) diff --git a/package.json b/package.json index 13b43d64048d..6670e7dfa71e 100644 --- a/package.json +++ b/package.json @@ -154,7 +154,6 @@ "react-dom": "17.0.2", "react-hot-loader": "4.13.0", "react-intl": "6.1.1", - "react-measure": "2.3.0", "react-popper": "2.3.0", "react-quill": "2.0.0-beta.4", "react-redux": "7.2.8", @@ -236,7 +235,6 @@ "@types/quill": "1.3.10", "@types/react": "17.0.45", "@types/react-dom": "17.0.17", - "@types/react-measure": "2.0.5", "@types/react-redux": "7.1.24", "@types/react-router-dom": "4.3.4", "@types/react-virtualized": "9.18.12", diff --git a/ts/components/AddUserToAnotherGroupModal.tsx b/ts/components/AddUserToAnotherGroupModal.tsx index 0d44b3b41376..4468bf57877c 100644 --- a/ts/components/AddUserToAnotherGroupModal.tsx +++ b/ts/components/AddUserToAnotherGroupModal.tsx @@ -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} /> - - - {({ 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; - } - + + {(ref, size) => { return (
- + {size != null && ( + + )}
); }} -
+ )} diff --git a/ts/components/CompositionRecordingDraft.tsx b/ts/components/CompositionRecordingDraft.tsx index c9ea003005f1..ae26b10502d7 100644 --- a/ts/components/CompositionRecordingDraft.tsx +++ b/ts/components/CompositionRecordingDraft.tsx @@ -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); 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} /> - - {({ measureRef }) => ( -
+ + {ref => ( +
{scrubber}
)} - +
); } diff --git a/ts/components/ConversationList.tsx b/ts/components/ConversationList.tsx index 64149800b50c..2736512dafa6 100644 --- a/ts/components/ConversationList.tsx +++ b/ts/components/ConversationList.tsx @@ -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 ( ; @@ -334,14 +333,14 @@ export function ForwardMessagesModal({ value={searchTerm} /> {candidateConversations.length ? ( - - {({ contentRect, measureRef }: MeasuredComponentProps) => ( + + {(ref, size) => (
)} -
+ ) : (
{i18n('icu:noContactsFound')} diff --git a/ts/components/GroupCallRemoteParticipants.tsx b/ts/components/GroupCallRemoteParticipants.tsx index 477a79a593ab..8a50997189fa 100644 --- a/ts/components/GroupCallRemoteParticipants.tsx +++ b/ts/components/GroupCallRemoteParticipants.tsx @@ -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 ( - { - if (!bounds) { - log.error('We should be measuring the bounds'); - return; - } - setContainerDimensions(bounds); + { + setContainerDimensions(size); }} > - {containerMeasure => ( -
- { - if (!bounds) { - log.error('We should be measuring the bounds'); - return; - } - setGridDimensions(bounds); + {containerRef => ( +
+ { + setGridDimensions(size); }} > - {gridMeasure => ( + {gridRef => (
{flatten(rowElements)}
)} - +
)} -
+ ); } diff --git a/ts/components/LeftPane.tsx b/ts/components/LeftPane.tsx index c526f2cd0994..c554f9772f59 100644 --- a/ts/components/LeftPane.tsx +++ b/ts/components/LeftPane.tsx @@ -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({ ))}
{preRowsNode && {preRowsNode}} - - {({ contentRect, measureRef }: MeasuredComponentProps) => ( -
+ + {(ref, size) => ( +
)} - + {footerContents && (
{footerContents}
)} diff --git a/ts/components/MediaEditor.tsx b/ts/components/MediaEditor.tsx index a0e4708bfb44..a97495493d41 100644 --- a/ts/components/MediaEditor.tsx +++ b/ts/components/MediaEditor.tsx @@ -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(
- { - if (!bounds) { - log.error('We should be measuring the bounds'); - return; - } - setContainerWidth(bounds.width); - setContainerHeight(bounds.height); + { + setContainerWidth(size.width); + setContainerHeight(size.height); }} > - {({ measureRef }) => ( -
+ {ref => ( +
{image && (
)} - +
{tooling ? ( diff --git a/ts/components/Modal.tsx b/ts/components/Modal.tsx index 4e0250e917bb..19c0b8fe63d4 100644 --- a/ts/components/Modal.tsx +++ b/ts/components/Modal.tsx @@ -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(null); - const refMerger = useRefMerger(); + const bodyRef = useRef(null); + const bodyInnerRef = useRef(null); - const bodyRef = useRef(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({ )}
)} - - {({ measureRef }: MeasuredComponentProps) => ( -
{ - const scrollTop = bodyRef.current?.scrollTop || 0; - setScrolled(scrollTop > 2); - }} - ref={refMerger(measureRef, bodyRef)} - > - {children} -
+
+ ref={bodyRef} + > +
{children}
+
{modalFooter && {modalFooter}}
diff --git a/ts/components/StoriesSettingsModal.tsx b/ts/components/StoriesSettingsModal.tsx index a5350d7d1a68..f2e4d1b8de04 100644 --- a/ts/components/StoriesSettingsModal.tsx +++ b/ts/components/StoriesSettingsModal.tsx @@ -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; @@ -1193,14 +1192,11 @@ export function EditDistributionListModal({ ) : undefined} {candidateConversations.length ? ( - - {({ contentRect, measureRef }: MeasuredComponentProps) => ( -
+ + {(ref, size) => ( +
)} - +
) : (
{i18n('icu:noContactsFound')} diff --git a/ts/components/TextAttachment.tsx b/ts/components/TextAttachment.tsx index f24228df712c..a052edf1ed7e 100644 --- a/ts/components/TextAttachment.tsx +++ b/ts/components/TextAttachment.tsx @@ -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( background: getBackgroundColor(textAttachment), }; - return ( - - {({ contentRect, measureRef }) => { - const scaleFactor = (contentRect.bounds?.height || 1) / 1280; + const ref = useRef(null); + const size = useSizeObserver(ref); - return ( - // eslint-disable-next-line jsx-a11y/no-static-element-interactions -
{ - 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 +
{ + 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 && ( - -
-
- {i18n('icu:TextAttachment__preview__link')} -
-
- {textAttachment.preview.url} -
-
-
- - )} -
- {(textContent || onChange) && ( -
- {onChange ? ( - onChange(ev.currentTarget.value)} - placeholder={i18n('icu:TextAttachment__placeholder')} - ref={refMerger(forwardedTextEditorRef, textEditorRef)} - style={getTextStyles( - textContent, - textAttachment.textForegroundColor, - textAttachment.textStyle, - i18n - )} - value={textContent} - /> - ) : ( -
- -
- )} -
- )} - {textAttachment.preview && textAttachment.preview.url && ( -
setIsHoveringOverTooltip(false)} - onFocus={showTooltip} - onMouseOut={() => setIsHoveringOverTooltip(false)} - onMouseOver={showTooltip} - > - {onRemoveLinkPreview && ( -
-
- )} - -
- )} + {textAttachment.preview && + textAttachment.preview.url && + linkPreviewOffsetTop && + !isThumbnail && ( + +
+
+ {i18n('icu:TextAttachment__preview__link')} +
+
+ {textAttachment.preview.url} +
+
+ + )} +
+ {(textContent || onChange) && ( +
+ {onChange ? ( + onChange(ev.currentTarget.value)} + placeholder={i18n('icu:TextAttachment__placeholder')} + ref={refMerger(forwardedTextEditorRef, textEditorRef)} + style={getTextStyles( + textContent, + textAttachment.textForegroundColor, + textAttachment.textStyle, + i18n + )} + value={textContent} + /> + ) : ( +
+ +
+ )}
- ); - }} - + )} + {textAttachment.preview && textAttachment.preview.url && ( +
setIsHoveringOverTooltip(false)} + onFocus={showTooltip} + onMouseOut={() => setIsHoveringOverTooltip(false)} + onMouseOver={showTooltip} + > + {onRemoveLinkPreview && ( +
+
+ )} + +
+ )} +
+
); } ); diff --git a/ts/components/conversation/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx index aa81055675df..915b019c5598 100644 --- a/ts/components/conversation/ConversationHeader.tsx +++ b/ts/components/conversation/ConversationHeader.tsx @@ -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 { {this.renderDeleteMessagesConfirmationDialog()} {this.renderLeaveGroupConfirmationDialog()} {this.renderCannotLeaveGroupBecauseYouAreLastAdminAlert()} - { - if (!bounds || !bounds.width) { - return; - } - this.setState({ isNarrow: bounds.width < 500 }); + { + this.setState({ isNarrow: size.width < 500 }); }} > - {({ measureRef }) => ( + {measureRef => (
{ {this.renderMenu(triggerId)}
)} -
+ ); } diff --git a/ts/components/conversation/MessageMetadata.tsx b/ts/components/conversation/MessageMetadata.tsx index 9064e49ee70d..6c3cde9e5fbb 100644 --- a/ts/components/conversation/MessageMetadata.tsx +++ b/ts/components/conversation/MessageMetadata.tsx @@ -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>( ); const onResize = useCallback( - ({ bounds }: ContentRect) => { - onWidthMeasured?.(bounds?.width || 0); + (size: Size) => { + onWidthMeasured?.(size.width); }, [onWidthMeasured] ); if (onWidthMeasured) { return ( - - {({ measureRef }) => ( + + {measureRef => (
{children}
)} -
+ ); } diff --git a/ts/components/conversation/Timeline.tsx b/ts/components/conversation/Timeline.tsx index f04c25e5f043..dd62c58bd469 100644 --- a/ts/components/conversation/Timeline.tsx +++ b/ts/components/conversation/Timeline.tsx @@ -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(); private readonly lastSeenIndicatorRef = React.createRef(); 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(); 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 = ( - { - if (!bounds) { - assertDev(false, 'We should be measuring the bounds'); - return; - } - this.setState({ lastMeasuredWarningHeight: bounds.height }); + { + this.setState({ lastMeasuredWarningHeight: size.height }); }} > - {({ measureRef }) => ( + {measureRef => ( {renderMiniPlayer({ shouldFlow: true })} {text && ( @@ -1025,7 +1008,7 @@ export class Timeline extends React.Component< )} )} - + ); } @@ -1061,18 +1044,15 @@ export class Timeline extends React.Component< return ( <> - { + { 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 => (
{headerElements} @@ -1152,7 +1132,7 @@ export class Timeline extends React.Component< ) : null}
)} -
+ {Boolean(invitedContactsForNewlyCreatedGroup.length) && ( )} {rowCount ? ( - - {({ 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; - } - + + {(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 (
{ if (event.key === 'Enter') { inputRef.current?.focus(); } }} > - { - 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 && ( + { + 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} + /> + )}
); /* eslint-enable jsx-a11y/no-static-element-interactions */ }} -
+ ) : (
{i18n('icu:noContactsFound')} diff --git a/ts/hooks/useSizeObserver.tsx b/ts/hooks/useSizeObserver.tsx new file mode 100644 index 000000000000..024730d93f60 --- /dev/null +++ b/ts/hooks/useSizeObserver.tsx @@ -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( + ref: RefObject, + /** + * Note: If you provide `onSizeChange`, `useSizeObserver()` will always return `null` + */ + onSizeChange?: SizeChangeHandler +): Size | null { + const [size, setSize] = useState(null); + const sizeRef = useRef(null); + const onSizeChangeRef = useRef(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, 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(); + 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)); + * }) + * + *
+ *
+ * {children} + *
+ *
+ * ``` + */ +export function useScrollObserver( + scrollerRef: RefObject, + scrollerInnerRef: RefObject, + onScrollChange: (scroll: Scroll) => void +): void { + const scrollRef = useRef(null); + const onScrollChangeRef = useRef(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]); +} diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index b970210b389f..1652eb569695 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -2401,9 +2401,82 @@ { "rule": "React-useRef", "path": "ts/components/Modal.tsx", - "line": " const bodyRef = useRef(null);", - "reasonCategory": "usageTrusted", - "updated": "2021-09-21T01:40:08.534Z" + "line": " const bodyRef = useRef(null);", + "reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted", + "updated": "2023-07-25T21:55:26.191Z", + "reasonDetail": "" + }, + { + "rule": "React-useRef", + "path": "ts/components/Modal.tsx", + "line": " const bodyInnerRef = useRef(null);", + "reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted", + "updated": "2023-07-25T21:55:26.191Z", + "reasonDetail": "" + }, + { + "rule": "React-useRef", + "path": "ts/components/TextAttachment.tsx", + "line": " const ref = useRef(null);", + "reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted", + "updated": "2023-07-25T21:55:26.191Z", + "reasonDetail": "" + }, + { + "rule": "React-useRef", + "path": "ts/hooks/useSizeObserver.tsx", + "line": " const sizeRef = useRef(null);", + "reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted", + "updated": "2023-07-25T21:55:26.191Z", + "reasonDetail": "" + }, + { + "rule": "React-useRef", + "path": "ts/hooks/useSizeObserver.tsx", + "line": " const onSizeChangeRef = useRef(onSizeChange);", + "reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted", + "updated": "2023-07-25T21:55:26.191Z", + "reasonDetail": "" + }, + { + "rule": "React-useRef", + "path": "ts/hooks/useSizeObserver.tsx", + "line": " const ref = useRef();", + "reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted", + "updated": "2023-07-25T21:55:26.191Z", + "reasonDetail": "" + }, + { + "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": "" + }, + { + "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": "" + }, + { + "rule": "React-useRef", + "path": "ts/hooks/useSizeObserver.tsx", + "line": " const scrollRef = useRef(null);", + "reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted", + "updated": "2023-07-25T21:55:26.191Z", + "reasonDetail": "" + }, + { + "rule": "React-useRef", + "path": "ts/hooks/useSizeObserver.tsx", + "line": " const onScrollChangeRef = useRef(onScrollChange);", + "reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted", + "updated": "2023-07-25T21:55:26.191Z", + "reasonDetail": "" }, { "rule": "React-useRef", diff --git a/ts/util/lint/linter.ts b/ts/util/lint/linter.ts index c8eb49c4647b..655178b7c6b1 100644 --- a/ts/util/lint/linter.ts +++ b/ts/util/lint/linter.ts @@ -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/.+', diff --git a/yarn.lock b/yarn.lock index dd7136c217e2..8d1253522ed6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1175,7 +1175,7 @@ dependencies: regenerator-runtime "^0.13.2" -"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.2.0", "@babel/runtime@^7.5.0": +"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.5.0": version "7.16.3" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.16.3.tgz#b86f0db02a04187a3c17caa77de69840165d42d5" integrity sha512-WBwekcqacdY2e9AF/Q7WLFUWmdJGJTkbjqTjoMDgXkVZ3ZRUvOPsLb5KdwISoQVsbP+DQzVZW4Zhci0DvpbNTQ== @@ -4238,13 +4238,6 @@ dependencies: "@types/react" "^17" -"@types/react-measure@2.0.5": - version "2.0.5" - resolved "https://registry.yarnpkg.com/@types/react-measure/-/react-measure-2.0.5.tgz#c1d304e3cab3a1c393342bf377b040628e6c29a8" - integrity sha512-T1Bpt8FlWbDhoInUaNrjTOiVRpRJmrRcqhFJxLGBq1VjaqBLHCvUPapgdKMWEIX4Oqsa1SSKjtNkNJGy6WAAZg== - dependencies: - "@types/react" "*" - "@types/react-redux@7.1.24", "@types/react-redux@^7.1.20": version "7.1.24" resolved "https://registry.yarnpkg.com/@types/react-redux/-/react-redux-7.1.24.tgz#6caaff1603aba17b27d20f8ad073e4c077e975c0" @@ -9932,11 +9925,6 @@ get-intrinsic@^1.1.0, get-intrinsic@^1.1.1: has "^1.0.3" has-symbols "^1.0.1" -get-node-dimensions@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/get-node-dimensions/-/get-node-dimensions-1.2.1.tgz#fb7b4bb57060fb4247dd51c9d690dfbec56b0823" - integrity sha512-2MSPMu7S1iOTL+BOa6K1S62hB2zUAYNF/lV0gSVlOaacd087lc6nR1H1r0e3B1CerTo+RceOmi1iJW+vp21xcQ== - get-stdin@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-4.0.1.tgz#b968c6b0a04384324902e8bf1a5df32579a450fe" @@ -15538,16 +15526,6 @@ react-lifecycles-compat@^3.0.4: resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== -react-measure@2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/react-measure/-/react-measure-2.3.0.tgz#75835d39abec9ae13517f35a819c160997a7a44e" - integrity sha512-dwAvmiOeblj5Dvpnk8Jm7Q8B4THF/f1l1HtKVi0XDecsG6LXwGvzV5R1H32kq3TW6RW64OAf5aoQxpIgLa4z8A== - dependencies: - "@babel/runtime" "^7.2.0" - get-node-dimensions "^1.2.1" - prop-types "^15.6.2" - resize-observer-polyfill "^1.5.0" - react-merge-refs@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/react-merge-refs/-/react-merge-refs-1.1.0.tgz#73d88b892c6c68cbb7a66e0800faa374f4c38b06" @@ -16222,11 +16200,6 @@ reserved-words@^0.1.2: resolved "https://registry.yarnpkg.com/reserved-words/-/reserved-words-0.1.2.tgz#00a0940f98cd501aeaaac316411d9adc52b31ab1" integrity sha1-AKCUD5jNUBrqqsMWQR2a3FKzGrE= -resize-observer-polyfill@^1.5.0: - version "1.5.1" - resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464" - integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg== - resolve-alpn@^1.0.0: version "1.2.1" resolved "https://registry.yarnpkg.com/resolve-alpn/-/resolve-alpn-1.2.1.tgz#b7adbdac3546aaaec20b45e7d8265927072726f9"