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
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 539 additions and 421 deletions

View file

@ -2138,30 +2138,6 @@ Signal Desktop makes use of the following open source projects.
License: BSD-3-Clause 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 ## react-popper
The MIT License (MIT) The MIT License (MIT)

View file

@ -154,7 +154,6 @@
"react-dom": "17.0.2", "react-dom": "17.0.2",
"react-hot-loader": "4.13.0", "react-hot-loader": "4.13.0",
"react-intl": "6.1.1", "react-intl": "6.1.1",
"react-measure": "2.3.0",
"react-popper": "2.3.0", "react-popper": "2.3.0",
"react-quill": "2.0.0-beta.4", "react-quill": "2.0.0-beta.4",
"react-redux": "7.2.8", "react-redux": "7.2.8",
@ -236,7 +235,6 @@
"@types/quill": "1.3.10", "@types/quill": "1.3.10",
"@types/react": "17.0.45", "@types/react": "17.0.45",
"@types/react-dom": "17.0.17", "@types/react-dom": "17.0.17",
"@types/react-measure": "2.0.5",
"@types/react-redux": "7.1.24", "@types/react-redux": "7.1.24",
"@types/react-router-dom": "4.3.4", "@types/react-router-dom": "4.3.4",
"@types/react-virtualized": "9.18.12", "@types/react-virtualized": "9.18.12",

View file

@ -3,8 +3,6 @@
import { pick } from 'lodash'; import { pick } from 'lodash';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import type { MeasuredComponentProps } from 'react-measure';
import Measure from 'react-measure';
import type { ListRowProps } from 'react-virtualized'; import type { ListRowProps } from 'react-virtualized';
import type { ConversationType } from '../state/ducks/conversations'; import type { ConversationType } from '../state/ducks/conversations';
@ -23,6 +21,7 @@ import { useRestoreFocus } from '../hooks/useRestoreFocus';
import { ListView } from './ListView'; import { ListView } from './ListView';
import { ListTile } from './ListTile'; import { ListTile } from './ListTile';
import type { ShowToastAction } from '../state/ducks/toast'; import type { ShowToastAction } from '../state/ducks/toast';
import { SizeObserver } from '../hooks/useSizeObserver';
type OwnProps = { type OwnProps = {
i18n: LocalizerType; i18n: LocalizerType;
@ -180,33 +179,26 @@ export function AddUserToAnotherGroupModal({
ref={inputRef} ref={inputRef}
value={searchTerm} value={searchTerm}
/> />
<SizeObserver>
<Measure bounds> {(ref, size) => {
{({ 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;
}
return ( return (
<div <div
className="AddUserToAnotherGroupModal__list-wrapper" className="AddUserToAnotherGroupModal__list-wrapper"
ref={measureRef} ref={ref}
> >
<ListView {size != null && (
width={width} <ListView
height={height} width={size.width}
rowCount={filteredConversations.length} height={size.height}
calculateRowHeight={handleCalculateRowHeight} rowCount={filteredConversations.length}
rowRenderer={renderGroupListItem} calculateRowHeight={handleCalculateRowHeight}
/> rowRenderer={renderGroupListItem}
/>
)}
</div> </div>
); );
}} }}
</Measure> </SizeObserver>
</div> </div>
</Modal> </Modal>
)} )}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,6 @@
// Copyright 2022 Signal Messenger, LLC // Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import Measure from 'react-measure';
import React, { forwardRef, useEffect, useRef, useState } from 'react'; import React, { forwardRef, useEffect, useRef, useState } from 'react';
import TextareaAutosize from 'react-textarea-autosize'; import TextareaAutosize from 'react-textarea-autosize';
import classNames from 'classnames'; import classNames from 'classnames';
@ -22,6 +21,7 @@ import {
} from '../util/getStoryBackground'; } from '../util/getStoryBackground';
import { SECOND } from '../util/durations'; import { SECOND } from '../util/durations';
import { useRefMerger } from '../hooks/useRefMerger'; import { useRefMerger } from '../hooks/useRefMerger';
import { useSizeObserver } from '../hooks/useSizeObserver';
const renderNewLines: RenderTextCallbackType = ({ const renderNewLines: RenderTextCallbackType = ({
text: textWithNewLines, text: textWithNewLines,
@ -169,152 +169,142 @@ export const TextAttachment = forwardRef<HTMLTextAreaElement, PropsType>(
background: getBackgroundColor(textAttachment), background: getBackgroundColor(textAttachment),
}; };
return ( const ref = useRef<HTMLDivElement>(null);
<Measure bounds> const size = useSizeObserver(ref);
{({ contentRect, measureRef }) => {
const scaleFactor = (contentRect.bounds?.height || 1) / 1280;
return ( const scaleFactor = (size?.height || 1) / 1280;
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div return (
className="TextAttachment" // eslint-disable-next-line jsx-a11y/no-static-element-interactions
onClick={() => { <div
if (linkPreviewOffsetTop) { className="TextAttachment"
setLinkPreviewOffsetTop(undefined); onClick={() => {
} if (linkPreviewOffsetTop) {
onClick?.(); setLinkPreviewOffsetTop(undefined);
}} }
onKeyUp={ev => { onClick?.();
if (ev.key === 'Escape' && linkPreviewOffsetTop) { }}
setLinkPreviewOffsetTop(undefined); onKeyUp={ev => {
} if (ev.key === 'Escape' && linkPreviewOffsetTop) {
}} setLinkPreviewOffsetTop(undefined);
ref={measureRef} }
style={isThumbnail ? storyBackgroundColor : undefined} }}
> ref={ref}
{/* style={isThumbnail ? storyBackgroundColor : undefined}
>
{/*
The tooltip must be outside of the scaled area, as it should not scale with 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 the story, but it must be positioned using the scaled offset
*/} */}
{textAttachment.preview && {textAttachment.preview &&
textAttachment.preview.url && textAttachment.preview.url &&
linkPreviewOffsetTop && linkPreviewOffsetTop &&
!isThumbnail && ( !isThumbnail && (
<a <a
className="TextAttachment__preview__tooltip" className="TextAttachment__preview__tooltip"
href={textAttachment.preview.url} href={textAttachment.preview.url}
rel="noreferrer" rel="noreferrer"
style={{ style={{
top: linkPreviewOffsetTop * scaleFactor - 89, // minus height of tooltip and some spacing top: linkPreviewOffsetTop * scaleFactor - 89, // minus height of tooltip and some spacing
}} }}
target="_blank" target="_blank"
> >
<div> <div>
<div className="TextAttachment__preview__tooltip__title"> <div className="TextAttachment__preview__tooltip__title">
{i18n('icu:TextAttachment__preview__link')} {i18n('icu:TextAttachment__preview__link')}
</div> </div>
<div className="TextAttachment__preview__tooltip__url"> <div className="TextAttachment__preview__tooltip__url">
{textAttachment.preview.url} {textAttachment.preview.url}
</div> </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>
)}
</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> </div>
); )}
}} {textAttachment.preview && textAttachment.preview.url && (
</Measure> <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 type { ReactNode } from 'react';
import React from 'react'; import React from 'react';
import Measure from 'react-measure';
import classNames from 'classnames'; import classNames from 'classnames';
import { import {
ContextMenu, ContextMenu,
@ -40,6 +39,7 @@ import {
import { PanelType } from '../../types/Panels'; import { PanelType } from '../../types/Panels';
import { UserText } from '../UserText'; import { UserText } from '../UserText';
import { Alert } from '../Alert'; import { Alert } from '../Alert';
import { SizeObserver } from '../../hooks/useSizeObserver';
export enum OutgoingCallButtonStyle { export enum OutgoingCallButtonStyle {
None, None,
@ -783,16 +783,12 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
{this.renderDeleteMessagesConfirmationDialog()} {this.renderDeleteMessagesConfirmationDialog()}
{this.renderLeaveGroupConfirmationDialog()} {this.renderLeaveGroupConfirmationDialog()}
{this.renderCannotLeaveGroupBecauseYouAreLastAdminAlert()} {this.renderCannotLeaveGroupBecauseYouAreLastAdminAlert()}
<Measure <SizeObserver
bounds onSizeChange={size => {
onResize={({ bounds }) => { this.setState({ isNarrow: size.width < 500 });
if (!bounds || !bounds.width) {
return;
}
this.setState({ isNarrow: bounds.width < 500 });
}} }}
> >
{({ measureRef }) => ( {measureRef => (
<div <div
className={classNames('module-ConversationHeader', { className={classNames('module-ConversationHeader', {
'module-ConversationHeader--narrow': isNarrow, 'module-ConversationHeader--narrow': isNarrow,
@ -821,7 +817,7 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
{this.renderMenu(triggerId)} {this.renderMenu(triggerId)}
</div> </div>
)} )}
</Measure> </SizeObserver>
</> </>
); );
} }

View file

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

View file

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

View file

@ -9,8 +9,6 @@ import React, {
useCallback, useCallback,
} from 'react'; } from 'react';
import { omit } from 'lodash'; import { omit } from 'lodash';
import type { MeasuredComponentProps } from 'react-measure';
import Measure from 'react-measure';
import type { ListRowProps } from 'react-virtualized'; import type { ListRowProps } from 'react-virtualized';
import type { LocalizerType, ThemeType } from '../../../../types/Util'; import type { LocalizerType, ThemeType } from '../../../../types/Util';
@ -47,6 +45,7 @@ import { SearchInput } from '../../../SearchInput';
import { ListView } from '../../../ListView'; import { ListView } from '../../../ListView';
import { UsernameCheckbox } from '../../../conversationList/UsernameCheckbox'; import { UsernameCheckbox } from '../../../conversationList/UsernameCheckbox';
import { PhoneNumberCheckbox } from '../../../conversationList/PhoneNumberCheckbox'; import { PhoneNumberCheckbox } from '../../../conversationList/PhoneNumberCheckbox';
import { SizeObserver } from '../../../../hooks/useSizeObserver';
export type StatePropsType = { export type StatePropsType = {
regionCode: string | undefined; regionCode: string | undefined;
@ -432,16 +431,8 @@ export function ChooseGroupMembersModal({
</ContactPills> </ContactPills>
)} )}
{rowCount ? ( {rowCount ? (
<Measure bounds> <SizeObserver>
{({ contentRect, measureRef }: MeasuredComponentProps) => { {(ref, size) => {
// 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;
}
// We disable this ESLint rule because we're capturing a bubbled keydown // We disable this ESLint rule because we're capturing a bubbled keydown
// event. See [this note in the jsx-a11y docs][0]. // event. See [this note in the jsx-a11y docs][0].
// //
@ -450,38 +441,40 @@ export function ChooseGroupMembersModal({
return ( return (
<div <div
className="module-AddGroupMembersModal__list-wrapper" className="module-AddGroupMembersModal__list-wrapper"
ref={measureRef} ref={ref}
onKeyDown={event => { onKeyDown={event => {
if (event.key === 'Enter') { if (event.key === 'Enter') {
inputRef.current?.focus(); inputRef.current?.focus();
} }
}} }}
> >
<ListView {size != null && (
width={width} <ListView
height={height} width={size.width}
rowCount={rowCount} height={size.height}
calculateRowHeight={index => { rowCount={rowCount}
const row = getRow(index); calculateRowHeight={index => {
if (!row) { const row = getRow(index);
assertDev(false, `Expected a row at index ${index}`); if (!row) {
return 52; assertDev(false, `Expected a row at index ${index}`);
}
switch (row.type) {
case RowType.Header:
return 40;
default:
return 52; return 52;
} }
}}
rowRenderer={renderItem} switch (row.type) {
/> case RowType.Header:
return 40;
default:
return 52;
}
}}
rowRenderer={renderItem}
/>
)}
</div> </div>
); );
/* eslint-enable jsx-a11y/no-static-element-interactions */ /* eslint-enable jsx-a11y/no-static-element-interactions */
}} }}
</Measure> </SizeObserver>
) : ( ) : (
<div className="module-AddGroupMembersModal__no-candidate-contacts"> <div className="module-AddGroupMembersModal__no-candidate-contacts">
{i18n('icu:noContactsFound')} {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", "rule": "React-useRef",
"path": "ts/components/Modal.tsx", "path": "ts/components/Modal.tsx",
"line": " const bodyRef = useRef<HTMLDivElement | null>(null);", "line": " const bodyRef = useRef<HTMLDivElement>(null);",
"reasonCategory": "usageTrusted", "reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted",
"updated": "2021-09-21T01:40:08.534Z" "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", "rule": "React-useRef",

View file

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

View file

@ -1175,7 +1175,7 @@
dependencies: dependencies:
regenerator-runtime "^0.13.2" 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" version "7.16.3"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.16.3.tgz#b86f0db02a04187a3c17caa77de69840165d42d5" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.16.3.tgz#b86f0db02a04187a3c17caa77de69840165d42d5"
integrity sha512-WBwekcqacdY2e9AF/Q7WLFUWmdJGJTkbjqTjoMDgXkVZ3ZRUvOPsLb5KdwISoQVsbP+DQzVZW4Zhci0DvpbNTQ== integrity sha512-WBwekcqacdY2e9AF/Q7WLFUWmdJGJTkbjqTjoMDgXkVZ3ZRUvOPsLb5KdwISoQVsbP+DQzVZW4Zhci0DvpbNTQ==
@ -4238,13 +4238,6 @@
dependencies: dependencies:
"@types/react" "^17" "@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": "@types/react-redux@7.1.24", "@types/react-redux@^7.1.20":
version "7.1.24" version "7.1.24"
resolved "https://registry.yarnpkg.com/@types/react-redux/-/react-redux-7.1.24.tgz#6caaff1603aba17b27d20f8ad073e4c077e975c0" 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 "^1.0.3"
has-symbols "^1.0.1" 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: get-stdin@^4.0.1:
version "4.0.1" version "4.0.1"
resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-4.0.1.tgz#b968c6b0a04384324902e8bf1a5df32579a450fe" 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" resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== 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: react-merge-refs@^1.0.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/react-merge-refs/-/react-merge-refs-1.1.0.tgz#73d88b892c6c68cbb7a66e0800faa374f4c38b06" 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" resolved "https://registry.yarnpkg.com/reserved-words/-/reserved-words-0.1.2.tgz#00a0940f98cd501aeaaac316411d9adc52b31ab1"
integrity sha1-AKCUD5jNUBrqqsMWQR2a3FKzGrE= 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: resolve-alpn@^1.0.0:
version "1.2.1" version "1.2.1"
resolved "https://registry.yarnpkg.com/resolve-alpn/-/resolve-alpn-1.2.1.tgz#b7adbdac3546aaaec20b45e7d8265927072726f9" resolved "https://registry.yarnpkg.com/resolve-alpn/-/resolve-alpn-1.2.1.tgz#b7adbdac3546aaaec20b45e7d8265927072726f9"