Timeline date headers
This commit is contained in:
parent
0fa069f260
commit
f9440bf594
41 changed files with 1183 additions and 771 deletions
|
@ -1,48 +0,0 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { date, number } from '@storybook/addon-knobs';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
|
||||
import type { Props } from './Countdown';
|
||||
import { Countdown } from './Countdown';
|
||||
|
||||
const defaultDuration = 10 * 1000;
|
||||
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||
duration: number('duration', overrideProps.duration || defaultDuration),
|
||||
expiresAt: date(
|
||||
'expiresAt',
|
||||
overrideProps.expiresAt
|
||||
? new Date(overrideProps.expiresAt)
|
||||
: new Date(Date.now() + defaultDuration)
|
||||
),
|
||||
onComplete: action('onComplete'),
|
||||
});
|
||||
|
||||
const story = storiesOf('Components/Countdown', module);
|
||||
|
||||
story.add('Just Started', () => {
|
||||
const props = createProps();
|
||||
|
||||
return <Countdown {...props} />;
|
||||
});
|
||||
|
||||
story.add('In Progress', () => {
|
||||
const props = createProps({
|
||||
duration: 3 * defaultDuration,
|
||||
expiresAt: Date.now() + defaultDuration,
|
||||
});
|
||||
|
||||
return <Countdown {...props} />;
|
||||
});
|
||||
|
||||
story.add('Done', () => {
|
||||
const props = createProps({
|
||||
expiresAt: Date.now() - defaultDuration,
|
||||
});
|
||||
|
||||
return <Countdown {...props} />;
|
||||
});
|
|
@ -1,110 +0,0 @@
|
|||
// Copyright 2019-2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export type Props = {
|
||||
duration: number;
|
||||
expiresAt: number;
|
||||
onComplete?: () => unknown;
|
||||
};
|
||||
type State = {
|
||||
ratio: number;
|
||||
};
|
||||
|
||||
const CIRCUMFERENCE = 11.013 * 2 * Math.PI;
|
||||
|
||||
export class Countdown extends React.Component<Props, State> {
|
||||
public looping = false;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
const { duration, expiresAt } = this.props;
|
||||
const ratio = getRatio(expiresAt, duration);
|
||||
|
||||
this.state = { ratio };
|
||||
}
|
||||
|
||||
public override componentDidMount(): void {
|
||||
this.startLoop();
|
||||
}
|
||||
|
||||
public override componentDidUpdate(): void {
|
||||
this.startLoop();
|
||||
}
|
||||
|
||||
public override componentWillUnmount(): void {
|
||||
this.stopLoop();
|
||||
}
|
||||
|
||||
public startLoop(): void {
|
||||
if (this.looping) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.looping = true;
|
||||
requestAnimationFrame(this.loop);
|
||||
}
|
||||
|
||||
public stopLoop(): void {
|
||||
this.looping = false;
|
||||
}
|
||||
|
||||
public loop = (): void => {
|
||||
const { onComplete, duration, expiresAt } = this.props;
|
||||
if (!this.looping) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ratio = getRatio(expiresAt, duration);
|
||||
this.setState({ ratio });
|
||||
|
||||
if (ratio === 1) {
|
||||
this.looping = false;
|
||||
if (onComplete) {
|
||||
onComplete();
|
||||
}
|
||||
} else {
|
||||
requestAnimationFrame(this.loop);
|
||||
}
|
||||
};
|
||||
|
||||
public override render(): JSX.Element {
|
||||
const { ratio } = this.state;
|
||||
const strokeDashoffset = ratio * CIRCUMFERENCE;
|
||||
|
||||
return (
|
||||
<div className="module-countdown">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12,1 A11,11,0,1,1,1,12,11.013,11.013,0,0,1,12,1Z"
|
||||
className="module-countdown__back-path"
|
||||
style={{
|
||||
strokeDasharray: `${CIRCUMFERENCE}, ${CIRCUMFERENCE}`,
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d="M12,1 A11,11,0,1,1,1,12,11.013,11.013,0,0,1,12,1Z"
|
||||
className="module-countdown__front-path"
|
||||
style={{
|
||||
strokeDasharray: `${CIRCUMFERENCE}, ${CIRCUMFERENCE}`,
|
||||
strokeDashoffset,
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getRatio(expiresAt: number, duration: number) {
|
||||
const start = expiresAt - duration;
|
||||
const end = expiresAt;
|
||||
|
||||
const now = Date.now();
|
||||
const totalTime = end - start;
|
||||
const elapsed = now - start;
|
||||
|
||||
return Math.min(Math.max(0, elapsed / totalTime), 1);
|
||||
}
|
|
@ -1,11 +1,11 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// Copyright 2021-2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { CSSProperties } from 'react';
|
||||
import React from 'react';
|
||||
import type { ConversationColorType } from '../types/Colors';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import { formatRelativeTime } from '../util/formatRelativeTime';
|
||||
import { formatTime } from '../util/timestamp';
|
||||
|
||||
export type PropsType = {
|
||||
backgroundStyle?: CSSProperties;
|
||||
|
@ -51,7 +51,7 @@ const SampleMessage = ({
|
|||
<span
|
||||
className={`module-message__metadata__date module-message__metadata__date--${direction}`}
|
||||
>
|
||||
{formatRelativeTime(timestamp, { extended: true, i18n })}
|
||||
{formatTime(i18n, timestamp)}
|
||||
</span>
|
||||
{direction === 'outgoing' && (
|
||||
<div
|
||||
|
|
34
ts/components/Time.tsx
Normal file
34
ts/components/Time.tsx
Normal file
|
@ -0,0 +1,34 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { Moment } from 'moment';
|
||||
import type { ReactElement, TimeHTMLAttributes } from 'react';
|
||||
import moment from 'moment';
|
||||
import React from 'react';
|
||||
|
||||
export function Time({
|
||||
children,
|
||||
dateOnly = false,
|
||||
timestamp,
|
||||
...otherProps
|
||||
}: Readonly<
|
||||
{
|
||||
dateOnly?: boolean;
|
||||
timestamp: Readonly<number | Date | Moment>;
|
||||
} & Omit<TimeHTMLAttributes<HTMLElement>, 'dateTime'>
|
||||
>): ReactElement {
|
||||
let dateTime: string;
|
||||
if (dateOnly) {
|
||||
dateTime = moment(timestamp).format('YYYY-MM-DD');
|
||||
} else {
|
||||
const date =
|
||||
typeof timestamp === 'number' ? new Date(timestamp) : timestamp;
|
||||
dateTime = date.toISOString();
|
||||
}
|
||||
|
||||
return (
|
||||
<time dateTime={dateTime} {...otherProps}>
|
||||
{children}
|
||||
</time>
|
||||
);
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// Copyright 2021-2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
|
@ -70,7 +70,7 @@ story.add('Two incoming direct calls back-to-back', () => {
|
|||
<CallingNotification
|
||||
{...getCommonProps()}
|
||||
{...call1}
|
||||
nextItem={{ type: 'callHistory', data: call2 }}
|
||||
nextItem={{ type: 'callHistory', data: call2, timestamp: Date.now() }}
|
||||
/>
|
||||
<CallingNotification {...getCommonProps()} {...call2} />
|
||||
</>
|
||||
|
@ -99,7 +99,7 @@ story.add('Two outgoing direct calls back-to-back', () => {
|
|||
<CallingNotification
|
||||
{...getCommonProps()}
|
||||
{...call1}
|
||||
nextItem={{ type: 'callHistory', data: call2 }}
|
||||
nextItem={{ type: 'callHistory', data: call2, timestamp: Date.now() }}
|
||||
/>
|
||||
<CallingNotification {...getCommonProps()} {...call2} />
|
||||
</>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
// Copyright 2020-2021 Signal Messenger, LLC
|
||||
// Copyright 2020-2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
@ -8,7 +8,7 @@ import { noop } from 'lodash';
|
|||
|
||||
import { SystemMessage } from './SystemMessage';
|
||||
import { Button, ButtonSize, ButtonVariant } from '../Button';
|
||||
import { Timestamp } from './Timestamp';
|
||||
import { MessageTimestamp } from './MessageTimestamp';
|
||||
import type { LocalizerType } from '../../types/Util';
|
||||
import { CallMode } from '../../types/Calling';
|
||||
import type { CallingNotificationType } from '../../util/callingNotification';
|
||||
|
@ -91,9 +91,8 @@ export const CallingNotification: React.FC<PropsType> = React.memo(props => {
|
|||
contents={
|
||||
<>
|
||||
{getCallingNotificationText(props, i18n)} ·{' '}
|
||||
<Timestamp
|
||||
<MessageTimestamp
|
||||
direction="outgoing"
|
||||
extended
|
||||
i18n={i18n}
|
||||
timestamp={timestamp}
|
||||
withImageNoCaption={false}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// Copyright 2021-2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
|
@ -8,7 +8,7 @@ import type { LocalizerType } from '../../types/Util';
|
|||
import { Intl } from '../Intl';
|
||||
|
||||
import { SystemMessage } from './SystemMessage';
|
||||
import { Timestamp } from './Timestamp';
|
||||
import { MessageTimestamp } from './MessageTimestamp';
|
||||
import { Emojify } from './Emojify';
|
||||
|
||||
export type PropsData = {
|
||||
|
@ -37,7 +37,7 @@ export const ChangeNumberNotification: React.FC<Props> = props => {
|
|||
i18n={i18n}
|
||||
/>
|
||||
·
|
||||
<Timestamp i18n={i18n} timestamp={timestamp} />
|
||||
<MessageTimestamp i18n={i18n} timestamp={timestamp} />
|
||||
</>
|
||||
}
|
||||
icon="phone"
|
||||
|
|
|
@ -4,11 +4,11 @@
|
|||
import type { ReactChild, ReactNode } from 'react';
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import { noop } from 'lodash';
|
||||
|
||||
import { Avatar, AvatarSize } from '../Avatar';
|
||||
import { ContactName } from './ContactName';
|
||||
import { Time } from '../Time';
|
||||
import type {
|
||||
Props as MessagePropsType,
|
||||
PropsData as MessagePropsDataType,
|
||||
|
@ -22,7 +22,7 @@ import type { ContactNameColorType } from '../../types/Colors';
|
|||
import { SendStatus } from '../../messages/MessageSendState';
|
||||
import { WidthBreakpoint } from '../_util';
|
||||
import * as log from '../../logging/log';
|
||||
import { Timestamp } from './Timestamp';
|
||||
import { formatDateTimeLong } from '../../util/timestamp';
|
||||
|
||||
export type Contact = Pick<
|
||||
ConversationType,
|
||||
|
@ -194,12 +194,12 @@ export class MessageDetail extends React.Component<Props> {
|
|||
{errorComponent}
|
||||
{unidentifiedDeliveryComponent}
|
||||
{contact.statusTimestamp && (
|
||||
<Timestamp
|
||||
extended
|
||||
i18n={i18n}
|
||||
module="module-message-detail__status-timestamp"
|
||||
<Time
|
||||
className="module-message-detail__status-timestamp"
|
||||
timestamp={contact.statusTimestamp}
|
||||
/>
|
||||
>
|
||||
{formatDateTimeLong(i18n, contact.statusTimestamp)}
|
||||
</Time>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
@ -379,7 +379,9 @@ export class MessageDetail extends React.Component<Props> {
|
|||
<tr>
|
||||
<td className="module-message-detail__label">{i18n('sent')}</td>
|
||||
<td>
|
||||
{moment(sentAt).format('LLLL')}{' '}
|
||||
<Time timestamp={sentAt}>
|
||||
{formatDateTimeLong(i18n, sentAt)}
|
||||
</Time>{' '}
|
||||
<span className="module-message-detail__unix-timestamp">
|
||||
({sentAt})
|
||||
</span>
|
||||
|
@ -391,7 +393,9 @@ export class MessageDetail extends React.Component<Props> {
|
|||
{i18n('received')}
|
||||
</td>
|
||||
<td>
|
||||
{moment(receivedAt).format('LLLL')}{' '}
|
||||
<Time timestamp={receivedAt}>
|
||||
{formatDateTimeLong(i18n, receivedAt)}
|
||||
</Time>{' '}
|
||||
<span className="module-message-detail__unix-timestamp">
|
||||
({receivedAt})
|
||||
</span>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
// Copyright 2018-2021 Signal Messenger, LLC
|
||||
// Copyright 2018-2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { FunctionComponent, ReactChild } from 'react';
|
||||
|
@ -8,7 +8,7 @@ import classNames from 'classnames';
|
|||
import type { LocalizerType } from '../../types/Util';
|
||||
import type { DirectionType, MessageStatusType } from './Message';
|
||||
import { ExpireTimer } from './ExpireTimer';
|
||||
import { Timestamp } from './Timestamp';
|
||||
import { MessageTimestamp } from './MessageTimestamp';
|
||||
import { Spinner } from '../Spinner';
|
||||
|
||||
type PropsType = {
|
||||
|
@ -94,10 +94,9 @@ export const MessageMetadata: FunctionComponent<PropsType> = props => {
|
|||
);
|
||||
} else {
|
||||
timestampNode = (
|
||||
<Timestamp
|
||||
<MessageTimestamp
|
||||
i18n={i18n}
|
||||
timestamp={timestamp}
|
||||
extended
|
||||
direction={metadataDirection}
|
||||
withImageNoCaption={withImageNoCaption}
|
||||
withSticker={isSticker}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// Copyright 2020-2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
|
@ -7,12 +7,12 @@ import { boolean, date, select, text } from '@storybook/addon-knobs';
|
|||
|
||||
import { setupI18n } from '../../util/setupI18n';
|
||||
import enMessages from '../../../_locales/en/messages.json';
|
||||
import type { Props } from './Timestamp';
|
||||
import { Timestamp } from './Timestamp';
|
||||
import type { Props } from './MessageTimestamp';
|
||||
import { MessageTimestamp } from './MessageTimestamp';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const story = storiesOf('Components/Conversation/Timestamp', module);
|
||||
const story = storiesOf('Components/Conversation/MessageTimestamp', module);
|
||||
|
||||
const { now } = Date;
|
||||
const seconds = (n: number) => n * 1000;
|
||||
|
@ -26,14 +26,6 @@ const get1201 = () => {
|
|||
return d.getTime();
|
||||
};
|
||||
|
||||
const getJanuary1201 = () => {
|
||||
const d = new Date();
|
||||
d.setHours(0, 1, 0, 0);
|
||||
d.setMonth(0);
|
||||
d.setDate(1);
|
||||
return d.getTime();
|
||||
};
|
||||
|
||||
const times = (): Array<[string, number]> => [
|
||||
['500ms ago', now() - seconds(0.5)],
|
||||
['30s ago', now() - seconds(30)],
|
||||
|
@ -42,20 +34,14 @@ const times = (): Array<[string, number]> => [
|
|||
['45m ago', now() - minutes(45)],
|
||||
['1h ago', now() - hours(1)],
|
||||
['12:01am today', get1201()],
|
||||
['11:59pm yesterday', get1201() - minutes(2)],
|
||||
['24h ago', now() - hours(24)],
|
||||
['2d ago', now() - days(2)],
|
||||
['7d ago', now() - days(7)],
|
||||
['30d ago', now() - days(30)],
|
||||
['January 1st this year, 12:01am ', getJanuary1201()],
|
||||
['December 31st last year, 11:59pm', getJanuary1201() - minutes(2)],
|
||||
['366d ago', now() - days(366)],
|
||||
];
|
||||
|
||||
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||
i18n,
|
||||
timestamp: overrideProps.timestamp,
|
||||
extended: boolean('extended', overrideProps.extended || false),
|
||||
module: text('module', ''),
|
||||
withImageNoCaption: boolean('withImageNoCaption', false),
|
||||
withSticker: boolean('withSticker', false),
|
||||
|
@ -79,7 +65,7 @@ const createTable = (overrideProps: Partial<Props> = {}) => (
|
|||
<tr key={timestamp}>
|
||||
<td>{description}</td>
|
||||
<td>
|
||||
<Timestamp
|
||||
<MessageTimestamp
|
||||
key={timestamp}
|
||||
{...createProps({ ...overrideProps, timestamp })}
|
||||
/>
|
||||
|
@ -94,14 +80,10 @@ story.add('Normal', () => {
|
|||
return createTable();
|
||||
});
|
||||
|
||||
story.add('Extended', () => {
|
||||
return createTable({ extended: true });
|
||||
});
|
||||
|
||||
story.add('Knobs', () => {
|
||||
const props = createProps({
|
||||
timestamp: date('timestamp', new Date()),
|
||||
});
|
||||
|
||||
return <Timestamp {...props} />;
|
||||
return <MessageTimestamp {...props} />;
|
||||
});
|
|
@ -1,17 +1,16 @@
|
|||
// Copyright 2018-2021 Signal Messenger, LLC
|
||||
// Copyright 2018-2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
|
||||
import { formatRelativeTime } from '../../util/formatRelativeTime';
|
||||
import { formatTime } from '../../util/timestamp';
|
||||
|
||||
import type { LocalizerType } from '../../types/Util';
|
||||
|
||||
export type Props = {
|
||||
timestamp?: number;
|
||||
extended?: boolean;
|
||||
module?: string;
|
||||
withImageNoCaption?: boolean;
|
||||
withSticker?: boolean;
|
||||
|
@ -22,7 +21,7 @@ export type Props = {
|
|||
|
||||
const UPDATE_FREQUENCY = 60 * 1000;
|
||||
|
||||
export class Timestamp extends React.Component<Props> {
|
||||
export class MessageTimestamp extends React.Component<Props> {
|
||||
private interval: NodeJS.Timeout | null;
|
||||
|
||||
constructor(props: Props) {
|
||||
|
@ -57,7 +56,6 @@ export class Timestamp extends React.Component<Props> {
|
|||
withImageNoCaption,
|
||||
withSticker,
|
||||
withTapToViewExpired,
|
||||
extended,
|
||||
} = this.props;
|
||||
const moduleName = module || 'module-timestamp';
|
||||
|
||||
|
@ -78,7 +76,7 @@ export class Timestamp extends React.Component<Props> {
|
|||
)}
|
||||
title={moment(timestamp).format('llll')}
|
||||
>
|
||||
{formatRelativeTime(timestamp, { i18n, extended })}
|
||||
{formatTime(i18n, timestamp)}
|
||||
</span>
|
||||
);
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
// Copyright 2020-2021 Signal Messenger, LLC
|
||||
// Copyright 2020-2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
|
@ -20,7 +20,6 @@ import { ConversationHero } from './ConversationHero';
|
|||
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
|
||||
import { getRandomColor } from '../../test-both/helpers/getRandomColor';
|
||||
import { LastSeenIndicator } from './LastSeenIndicator';
|
||||
import { TimelineLoadingRow } from './TimelineLoadingRow';
|
||||
import { TypingBubble } from './TypingBubble';
|
||||
import { ContactSpoofingType } from '../../util/contactSpoofing';
|
||||
import { ReadStatus } from '../../messages/MessageReadStatus';
|
||||
|
@ -62,6 +61,7 @@ const items: Record<string, TimelineItemType> = {
|
|||
text: '🔥',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
'id-2': {
|
||||
type: 'message',
|
||||
|
@ -82,6 +82,7 @@ const items: Record<string, TimelineItemType> = {
|
|||
text: 'Hello there from the new world! http://somewhere.com',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
'id-2.5': {
|
||||
type: 'unsupportedMessage',
|
||||
|
@ -95,6 +96,7 @@ const items: Record<string, TimelineItemType> = {
|
|||
title: 'Mr. Pig',
|
||||
},
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
'id-3': {
|
||||
type: 'message',
|
||||
|
@ -115,6 +117,7 @@ const items: Record<string, TimelineItemType> = {
|
|||
text: 'Hello there from the new world!',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
'id-4': {
|
||||
type: 'timerNotification',
|
||||
|
@ -124,6 +127,7 @@ const items: Record<string, TimelineItemType> = {
|
|||
title: "It's Me",
|
||||
type: 'fromMe',
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
'id-5': {
|
||||
type: 'timerNotification',
|
||||
|
@ -133,6 +137,7 @@ const items: Record<string, TimelineItemType> = {
|
|||
title: '(202) 555-0000',
|
||||
type: 'fromOther',
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
'id-6': {
|
||||
type: 'safetyNumberNotification',
|
||||
|
@ -143,6 +148,7 @@ const items: Record<string, TimelineItemType> = {
|
|||
},
|
||||
isGroup: true,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
'id-7': {
|
||||
type: 'verificationNotification',
|
||||
|
@ -151,6 +157,7 @@ const items: Record<string, TimelineItemType> = {
|
|||
isLocal: true,
|
||||
type: 'markVerified',
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
'id-8': {
|
||||
type: 'groupNotification',
|
||||
|
@ -180,10 +187,12 @@ const items: Record<string, TimelineItemType> = {
|
|||
isMe: false,
|
||||
}),
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
'id-9': {
|
||||
type: 'resetSessionNotification',
|
||||
data: null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
'id-10': {
|
||||
type: 'message',
|
||||
|
@ -205,6 +214,7 @@ const items: Record<string, TimelineItemType> = {
|
|||
text: '🔥',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
'id-11': {
|
||||
type: 'message',
|
||||
|
@ -226,6 +236,7 @@ const items: Record<string, TimelineItemType> = {
|
|||
text: 'Hello there from the new world! http://somewhere.com',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
'id-12': {
|
||||
type: 'message',
|
||||
|
@ -247,6 +258,7 @@ const items: Record<string, TimelineItemType> = {
|
|||
text: 'Hello there from the new world! 🔥',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
'id-13': {
|
||||
type: 'message',
|
||||
|
@ -268,6 +280,7 @@ const items: Record<string, TimelineItemType> = {
|
|||
text: 'Hello there from the new world! And this is multiple lines of text. Lines and lines and lines.',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
'id-14': {
|
||||
type: 'message',
|
||||
|
@ -289,10 +302,12 @@ const items: Record<string, TimelineItemType> = {
|
|||
text: 'Hello there from the new world! And this is multiple lines of text. Lines and lines and lines.',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
'id-15': {
|
||||
type: 'linkNotification',
|
||||
data: null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -375,14 +390,17 @@ const renderItem = ({
|
|||
messageId,
|
||||
containerElementRef,
|
||||
containerWidthBreakpoint,
|
||||
isOldestTimelineItem,
|
||||
}: {
|
||||
messageId: string;
|
||||
containerElementRef: React.RefObject<HTMLElement>;
|
||||
containerWidthBreakpoint: WidthBreakpoint;
|
||||
isOldestTimelineItem: boolean;
|
||||
}) => (
|
||||
<TimelineItem
|
||||
getPreferredBadge={() => undefined}
|
||||
id=""
|
||||
isOldestTimelineItem={isOldestTimelineItem}
|
||||
isSelected={false}
|
||||
renderEmojiPicker={() => <div />}
|
||||
renderReactionPicker={() => <div />}
|
||||
|
@ -442,7 +460,6 @@ const renderHeroRow = () => {
|
|||
};
|
||||
return <Wrapper />;
|
||||
};
|
||||
const renderLoadingRow = () => <TimelineLoadingRow state="loading" />;
|
||||
const renderTypingBubble = () => (
|
||||
<TypingBubble
|
||||
acceptedMessageRequest
|
||||
|
@ -463,6 +480,7 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
|||
i18n,
|
||||
theme: React.useContext(StorybookThemeContext),
|
||||
|
||||
getTimestampForMessage: Date.now,
|
||||
haveNewest: boolean('haveNewest', overrideProps.haveNewest !== false),
|
||||
haveOldest: boolean('haveOldest', overrideProps.haveOldest !== false),
|
||||
isIncomingMessageRequest: boolean(
|
||||
|
@ -489,7 +507,6 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
|||
renderItem,
|
||||
renderLastSeenIndicator,
|
||||
renderHeroRow,
|
||||
renderLoadingRow,
|
||||
renderTypingBubble,
|
||||
typingContactId: overrideProps.typingContactId,
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright 2019-2021 Signal Messenger, LLC
|
||||
// Copyright 2019-2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { debounce, get, isNumber, pick } from 'lodash';
|
||||
import { debounce, get, isEqual, isNumber, pick } from 'lodash';
|
||||
import classNames from 'classnames';
|
||||
import type { CSSProperties, ReactChild, ReactNode, RefObject } from 'react';
|
||||
import React from 'react';
|
||||
|
@ -38,6 +38,7 @@ import { ContactSpoofingType } from '../../util/contactSpoofing';
|
|||
import { ContactSpoofingReviewDialog } from './ContactSpoofingReviewDialog';
|
||||
import type { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCollisions';
|
||||
import { hasUnacknowledgedCollisions } from '../../util/groupMemberNameCollisions';
|
||||
import { TimelineFloatingHeader } from './TimelineFloatingHeader';
|
||||
|
||||
const AT_BOTTOM_THRESHOLD = 15;
|
||||
const NEAR_BOTTOM_THRESHOLD = 15;
|
||||
|
@ -104,15 +105,19 @@ type PropsHousekeepingType = {
|
|||
warning?: WarningType;
|
||||
contactSpoofingReview?: ContactSpoofingReviewPropType;
|
||||
|
||||
getTimestampForMessage: (_: string) => number;
|
||||
getPreferredBadge: PreferredBadgeSelectorType;
|
||||
i18n: LocalizerType;
|
||||
theme: ThemeType;
|
||||
|
||||
areFloatingDateHeadersEnabled?: boolean;
|
||||
|
||||
renderItem: (props: {
|
||||
actionProps: PropsActionsType;
|
||||
containerElementRef: RefObject<HTMLElement>;
|
||||
containerWidthBreakpoint: WidthBreakpoint;
|
||||
conversationId: string;
|
||||
isOldestTimelineItem: boolean;
|
||||
messageId: string;
|
||||
nextMessageId: undefined | string;
|
||||
onHeightChange: (messageId: string) => unknown;
|
||||
|
@ -125,7 +130,6 @@ type PropsHousekeepingType = {
|
|||
unblurAvatar: () => void,
|
||||
updateSharedGroups: () => unknown
|
||||
) => JSX.Element;
|
||||
renderLoadingRow: (id: string) => JSX.Element;
|
||||
renderTypingBubble: (id: string) => JSX.Element;
|
||||
};
|
||||
|
||||
|
@ -195,23 +199,22 @@ type OnScrollParamsType = {
|
|||
_hasScrolledToRowTarget?: boolean;
|
||||
};
|
||||
|
||||
type VisibleRowsType = {
|
||||
newest?: {
|
||||
id: string;
|
||||
offsetTop: number;
|
||||
row: number;
|
||||
};
|
||||
oldest?: {
|
||||
id: string;
|
||||
offsetTop: number;
|
||||
row: number;
|
||||
};
|
||||
type VisibleRowType = {
|
||||
id: string;
|
||||
offsetTop: number;
|
||||
row: number;
|
||||
};
|
||||
|
||||
type StateType = {
|
||||
atBottom: boolean;
|
||||
atTop: boolean;
|
||||
hasRecentlyScrolled: boolean;
|
||||
oneTimeScrollRow?: number;
|
||||
visibleRows?: {
|
||||
newestFullyVisible?: VisibleRowType;
|
||||
oldestPartiallyVisible?: VisibleRowType;
|
||||
oldestFullyVisible?: VisibleRowType;
|
||||
};
|
||||
|
||||
widthBreakpoint: WidthBreakpoint;
|
||||
|
||||
|
@ -295,26 +298,26 @@ const getActions = createSelector(
|
|||
);
|
||||
|
||||
export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||
public cellSizeCache = new CellMeasurerCache({
|
||||
private cellSizeCache = new CellMeasurerCache({
|
||||
defaultHeight: 64,
|
||||
fixedWidth: true,
|
||||
});
|
||||
|
||||
public mostRecentWidth = 0;
|
||||
private mostRecentWidth = 0;
|
||||
|
||||
public mostRecentHeight = 0;
|
||||
private mostRecentHeight = 0;
|
||||
|
||||
public offsetFromBottom: number | undefined = 0;
|
||||
private offsetFromBottom: number | undefined = 0;
|
||||
|
||||
public resizeFlag = false;
|
||||
private resizeFlag = false;
|
||||
|
||||
private readonly containerRef = React.createRef<HTMLDivElement>();
|
||||
|
||||
private readonly listRef = React.createRef<List>();
|
||||
|
||||
public visibleRows: VisibleRowsType | undefined;
|
||||
private loadCountdownTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
public loadCountdownTimeout: NodeJS.Timeout | null = null;
|
||||
private hasRecentlyScrolledTimeout?: NodeJS.Timeout;
|
||||
|
||||
private containerRefMerger = createRefMerger();
|
||||
|
||||
|
@ -332,6 +335,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
this.state = {
|
||||
atBottom,
|
||||
atTop: false,
|
||||
hasRecentlyScrolled: true,
|
||||
oneTimeScrollRow,
|
||||
propScrollToIndex: scrollToIndex,
|
||||
prevPropScrollToIndex: scrollToIndex,
|
||||
|
@ -364,7 +368,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
return state;
|
||||
}
|
||||
|
||||
public getList = (): List | null => {
|
||||
private getList = (): List | null => {
|
||||
if (!this.listRef) {
|
||||
return null;
|
||||
}
|
||||
|
@ -374,7 +378,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
return current;
|
||||
};
|
||||
|
||||
public getGrid = (): Grid | undefined => {
|
||||
private getGrid = (): Grid | undefined => {
|
||||
const list = this.getList();
|
||||
if (!list) {
|
||||
return;
|
||||
|
@ -383,9 +387,9 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
return list.Grid;
|
||||
};
|
||||
|
||||
public getScrollContainer = (): HTMLDivElement | undefined => {
|
||||
private getScrollContainer = (): HTMLDivElement | undefined => {
|
||||
// We're using an internal variable (_scrollingContainer)) here,
|
||||
// so cannot rely on the public type.
|
||||
// so cannot rely on the private type.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const grid: any = this.getGrid();
|
||||
if (!grid) {
|
||||
|
@ -395,16 +399,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
return grid._scrollingContainer as HTMLDivElement;
|
||||
};
|
||||
|
||||
public scrollToRow = (row: number): void => {
|
||||
const list = this.getList();
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
|
||||
list.scrollToRow(row);
|
||||
};
|
||||
|
||||
public recomputeRowHeights = (row?: number): void => {
|
||||
private recomputeRowHeights = (row?: number): void => {
|
||||
const list = this.getList();
|
||||
if (!list) {
|
||||
return;
|
||||
|
@ -413,7 +408,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
list.recomputeRowHeights(row);
|
||||
};
|
||||
|
||||
public onHeightOnlyChange = (): void => {
|
||||
private onHeightOnlyChange = (): void => {
|
||||
const grid = this.getGrid();
|
||||
const scrollContainer = this.getScrollContainer();
|
||||
if (!grid || !scrollContainer) {
|
||||
|
@ -438,7 +433,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
});
|
||||
};
|
||||
|
||||
public resize = (row?: number): void => {
|
||||
private resize = (row?: number): void => {
|
||||
this.offsetFromBottom = undefined;
|
||||
this.resizeFlag = false;
|
||||
if (isNumber(row) && row > 0) {
|
||||
|
@ -452,11 +447,11 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
this.recomputeRowHeights(row || 0);
|
||||
};
|
||||
|
||||
public resizeHeroRow = (): void => {
|
||||
private resizeHeroRow = (): void => {
|
||||
this.resize(0);
|
||||
};
|
||||
|
||||
public resizeMessage = (messageId: string): void => {
|
||||
private resizeMessage = (messageId: string): void => {
|
||||
const { items } = this.props;
|
||||
|
||||
if (!items || !items.length) {
|
||||
|
@ -472,7 +467,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
this.resize(row);
|
||||
};
|
||||
|
||||
public onScroll = (data: OnScrollParamsType): void => {
|
||||
private onScroll = (data: OnScrollParamsType): void => {
|
||||
// Ignore scroll events generated as react-virtualized recursively scrolls and
|
||||
// re-measures to get us where we want to go.
|
||||
if (
|
||||
|
@ -492,11 +487,28 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
return;
|
||||
}
|
||||
|
||||
this.setState({ hasRecentlyScrolled: true });
|
||||
if (this.hasRecentlyScrolledTimeout) {
|
||||
clearTimeout(this.hasRecentlyScrolledTimeout);
|
||||
}
|
||||
this.hasRecentlyScrolledTimeout = setTimeout(() => {
|
||||
this.setState({ hasRecentlyScrolled: false });
|
||||
}, 1000);
|
||||
|
||||
this.updateScrollMetrics(data);
|
||||
this.updateWithVisibleRows();
|
||||
};
|
||||
|
||||
public updateScrollMetrics = debounce(
|
||||
private onRowsRendered = (): void => {
|
||||
// React Virtualized doesn't respect `scrollToIndex` in some cases, likely
|
||||
// because it hasn't rendered that row yet.
|
||||
const { oneTimeScrollRow } = this.state;
|
||||
if (isNumber(oneTimeScrollRow)) {
|
||||
this.getList()?.scrollToRow(oneTimeScrollRow);
|
||||
}
|
||||
};
|
||||
|
||||
private updateScrollMetrics = debounce(
|
||||
(data: OnScrollParamsType) => {
|
||||
const { clientHeight, clientWidth, scrollHeight, scrollTop } = data;
|
||||
|
||||
|
@ -576,10 +588,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
{ maxWait: 50 }
|
||||
);
|
||||
|
||||
public updateVisibleRows = (): void => {
|
||||
let newest;
|
||||
let oldest;
|
||||
|
||||
private updateVisibleRows = (): void => {
|
||||
const scrollContainer = this.getScrollContainer();
|
||||
if (!scrollContainer) {
|
||||
return;
|
||||
|
@ -589,15 +598,18 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
return;
|
||||
}
|
||||
|
||||
const visibleTop = scrollContainer.scrollTop;
|
||||
const visibleBottom = visibleTop + scrollContainer.clientHeight;
|
||||
|
||||
const innerScrollContainer = scrollContainer.children[0];
|
||||
if (!innerScrollContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
let newestFullyVisible: undefined | VisibleRowType;
|
||||
let oldestPartiallyVisible: undefined | VisibleRowType;
|
||||
let oldestFullyVisible: undefined | VisibleRowType;
|
||||
|
||||
const { children } = innerScrollContainer;
|
||||
const visibleTop = scrollContainer.scrollTop;
|
||||
const visibleBottom = visibleTop + scrollContainer.clientHeight;
|
||||
|
||||
for (let i = children.length - 1; i >= 0; i -= 1) {
|
||||
const child = children[i] as HTMLDivElement;
|
||||
|
@ -611,7 +623,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
|
||||
if (bottom - AT_BOTTOM_THRESHOLD <= visibleBottom) {
|
||||
const row = parseInt(child.getAttribute('data-row') || '-1', 10);
|
||||
newest = { offsetTop, row, id };
|
||||
newestFullyVisible = { offsetTop, row, id };
|
||||
|
||||
break;
|
||||
}
|
||||
|
@ -620,24 +632,45 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
const max = children.length;
|
||||
for (let i = 0; i < max; i += 1) {
|
||||
const child = children[i] as HTMLDivElement;
|
||||
const { offsetTop, id } = child;
|
||||
const { id, offsetTop, offsetHeight } = child;
|
||||
|
||||
if (!id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (offsetTop + AT_TOP_THRESHOLD >= visibleTop) {
|
||||
const row = parseInt(child.getAttribute('data-row') || '-1', 10);
|
||||
oldest = { offsetTop, row, id };
|
||||
const thisRow = {
|
||||
offsetTop,
|
||||
row: parseInt(child.getAttribute('data-row') || '-1', 10),
|
||||
id,
|
||||
};
|
||||
|
||||
const bottom = offsetTop + offsetHeight;
|
||||
|
||||
if (bottom >= visibleTop && !oldestPartiallyVisible) {
|
||||
oldestPartiallyVisible = thisRow;
|
||||
}
|
||||
|
||||
if (offsetTop + AT_TOP_THRESHOLD >= visibleTop) {
|
||||
oldestFullyVisible = thisRow;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.visibleRows = { newest, oldest };
|
||||
this.setState(oldState => {
|
||||
const visibleRows = {
|
||||
newestFullyVisible,
|
||||
oldestPartiallyVisible,
|
||||
oldestFullyVisible,
|
||||
};
|
||||
|
||||
// This avoids a render loop.
|
||||
return isEqual(oldState.visibleRows, visibleRows)
|
||||
? null
|
||||
: { visibleRows };
|
||||
});
|
||||
};
|
||||
|
||||
public updateWithVisibleRows = debounce(
|
||||
private updateWithVisibleRows = debounce(
|
||||
() => {
|
||||
const {
|
||||
unreadCount,
|
||||
|
@ -654,16 +687,17 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
}
|
||||
|
||||
this.updateVisibleRows();
|
||||
if (!this.visibleRows) {
|
||||
const { visibleRows } = this.state;
|
||||
if (!visibleRows) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { newest, oldest } = this.visibleRows;
|
||||
if (!newest) {
|
||||
const { newestFullyVisible, oldestFullyVisible } = visibleRows;
|
||||
if (!newestFullyVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
markMessageRead(newest.id);
|
||||
markMessageRead(newestFullyVisible.id);
|
||||
|
||||
const newestRow = this.getRowCount() - 1;
|
||||
const oldestRow = this.fromItemIndexToRow(0);
|
||||
|
@ -673,7 +707,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
if (
|
||||
!isLoadingMessages &&
|
||||
!haveNewest &&
|
||||
newest.row > newestRow - LOAD_MORE_THRESHOLD
|
||||
newestFullyVisible.row > newestRow - LOAD_MORE_THRESHOLD
|
||||
) {
|
||||
const lastId = items[items.length - 1];
|
||||
loadNewerMessages(lastId);
|
||||
|
@ -684,8 +718,10 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
// Generally we hid this behind a countdown spinner at the top of the window, but
|
||||
// this is a special-case for the situation where the window is so large and that
|
||||
// all the messages are visible.
|
||||
const oldestVisible = Boolean(oldest && oldestRow === oldest.row);
|
||||
const newestVisible = newestRow === newest.row;
|
||||
const oldestVisible = Boolean(
|
||||
oldestFullyVisible && oldestRow === oldestFullyVisible.row
|
||||
);
|
||||
const newestVisible = newestRow === newestFullyVisible.row;
|
||||
if (oldestVisible && newestVisible && !haveOldest) {
|
||||
this.loadOlderMessages();
|
||||
}
|
||||
|
@ -695,13 +731,13 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
const areUnreadBelowCurrentPosition = Boolean(
|
||||
isNumber(unreadCount) &&
|
||||
unreadCount > 0 &&
|
||||
(!haveNewest || newest.row < lastItemRow)
|
||||
(!haveNewest || newestFullyVisible.row < lastItemRow)
|
||||
);
|
||||
|
||||
const shouldShowScrollDownButton = Boolean(
|
||||
!haveNewest ||
|
||||
areUnreadBelowCurrentPosition ||
|
||||
newest.row < newestRow - SCROLL_DOWN_BUTTON_THRESHOLD
|
||||
newestFullyVisible.row < newestRow - SCROLL_DOWN_BUTTON_THRESHOLD
|
||||
);
|
||||
|
||||
this.setState({
|
||||
|
@ -713,7 +749,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
{ maxWait: 500 }
|
||||
);
|
||||
|
||||
public loadOlderMessages = (): void => {
|
||||
private loadOlderMessages = (): void => {
|
||||
const { haveOldest, isLoadingMessages, items, loadOlderMessages } =
|
||||
this.props;
|
||||
|
||||
|
@ -730,7 +766,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
loadOlderMessages(oldestId);
|
||||
};
|
||||
|
||||
public rowRenderer = ({
|
||||
private rowRenderer = ({
|
||||
index,
|
||||
key,
|
||||
parent,
|
||||
|
@ -743,7 +779,6 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
items,
|
||||
renderItem,
|
||||
renderHeroRow,
|
||||
renderLoadingRow,
|
||||
renderLastSeenIndicator,
|
||||
renderTypingBubble,
|
||||
unblurAvatar,
|
||||
|
@ -774,12 +809,6 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
)}
|
||||
</div>
|
||||
);
|
||||
} else if (!haveOldest && row === 0) {
|
||||
rowContents = (
|
||||
<div data-row={row} style={styleWithWidth} role="row">
|
||||
{renderLoadingRow(id)}
|
||||
</div>
|
||||
);
|
||||
} else if (oldestUnreadRow === row) {
|
||||
rowContents = (
|
||||
<div data-row={row} style={styleWithWidth} role="row">
|
||||
|
@ -824,6 +853,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
containerElementRef: this.containerRef,
|
||||
containerWidthBreakpoint: widthBreakpoint,
|
||||
conversationId: id,
|
||||
isOldestTimelineItem: haveOldest && itemIndex === 0,
|
||||
messageId,
|
||||
nextMessageId,
|
||||
onHeightChange: this.resizeMessage,
|
||||
|
@ -848,62 +878,73 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
);
|
||||
};
|
||||
|
||||
public fromItemIndexToRow(index: number): number {
|
||||
const { oldestUnreadIndex } = this.props;
|
||||
private fromItemIndexToRow(index: number): number {
|
||||
const { haveOldest, oldestUnreadIndex } = this.props;
|
||||
|
||||
// We will always render either the hero row or the loading row
|
||||
let addition = 1;
|
||||
let result = index;
|
||||
|
||||
// Hero row
|
||||
if (haveOldest) {
|
||||
result += 1;
|
||||
}
|
||||
|
||||
// Unread indicator
|
||||
if (isNumber(oldestUnreadIndex) && index >= oldestUnreadIndex) {
|
||||
addition += 1;
|
||||
result += 1;
|
||||
}
|
||||
|
||||
return index + addition;
|
||||
return result;
|
||||
}
|
||||
|
||||
public getRowCount(): number {
|
||||
const { oldestUnreadIndex, typingContactId } = this.props;
|
||||
const { items } = this.props;
|
||||
const itemsCount = items && items.length ? items.length : 0;
|
||||
private getRowCount(): number {
|
||||
const { haveOldest, items, oldestUnreadIndex, typingContactId } =
|
||||
this.props;
|
||||
|
||||
// We will always render either the hero row or the loading row
|
||||
let extraRows = 1;
|
||||
let result = items?.length || 0;
|
||||
|
||||
// Hero row
|
||||
if (haveOldest) {
|
||||
result += 1;
|
||||
}
|
||||
|
||||
// Unread indicator
|
||||
if (isNumber(oldestUnreadIndex)) {
|
||||
extraRows += 1;
|
||||
result += 1;
|
||||
}
|
||||
|
||||
// Typing indicator
|
||||
if (typingContactId) {
|
||||
extraRows += 1;
|
||||
result += 1;
|
||||
}
|
||||
|
||||
return itemsCount + extraRows;
|
||||
return result;
|
||||
}
|
||||
|
||||
public fromRowToItemIndex(
|
||||
row: number,
|
||||
props?: PropsType
|
||||
): number | undefined {
|
||||
const { items } = props || this.props;
|
||||
private fromRowToItemIndex(row: number): number | undefined {
|
||||
const { haveOldest, items } = this.props;
|
||||
|
||||
// We will always render either the hero row or the loading row
|
||||
let subtraction = 1;
|
||||
let result = row;
|
||||
|
||||
// Hero row
|
||||
if (haveOldest) {
|
||||
result -= 1;
|
||||
}
|
||||
|
||||
// Unread indicator
|
||||
const oldestUnreadRow = this.getLastSeenIndicatorRow();
|
||||
if (isNumber(oldestUnreadRow) && row > oldestUnreadRow) {
|
||||
subtraction += 1;
|
||||
result -= 1;
|
||||
}
|
||||
|
||||
const index = row - subtraction;
|
||||
if (index < 0 || index >= items.length) {
|
||||
if (result < 0 || result >= items.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
return index;
|
||||
return result;
|
||||
}
|
||||
|
||||
public getLastSeenIndicatorRow(props?: PropsType): number | undefined {
|
||||
const { oldestUnreadIndex } = props || this.props;
|
||||
private getLastSeenIndicatorRow(): number | undefined {
|
||||
const { oldestUnreadIndex } = this.props;
|
||||
if (!isNumber(oldestUnreadIndex)) {
|
||||
return;
|
||||
}
|
||||
|
@ -911,7 +952,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
return this.fromItemIndexToRow(oldestUnreadIndex) - 1;
|
||||
}
|
||||
|
||||
public getTypingBubbleRow(): number | undefined {
|
||||
private getTypingBubbleRow(): number | undefined {
|
||||
const { items } = this.props;
|
||||
if (!items || items.length < 0) {
|
||||
return;
|
||||
|
@ -922,23 +963,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
return this.fromItemIndexToRow(last) + 1;
|
||||
}
|
||||
|
||||
public onScrollToMessage = (messageId: string): void => {
|
||||
const { isLoadingMessages, items, loadAndScroll } = this.props;
|
||||
const index = items.findIndex(item => item === messageId);
|
||||
|
||||
if (index >= 0) {
|
||||
const row = this.fromItemIndexToRow(index);
|
||||
this.setState({
|
||||
oneTimeScrollRow: row,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isLoadingMessages) {
|
||||
loadAndScroll(messageId);
|
||||
}
|
||||
};
|
||||
|
||||
public scrollToBottom = (setFocus?: boolean): void => {
|
||||
private scrollToBottom = (setFocus?: boolean): void => {
|
||||
const { selectMessage, id, items } = this.props;
|
||||
|
||||
if (setFocus && items && items.length > 0) {
|
||||
|
@ -956,11 +981,11 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
});
|
||||
};
|
||||
|
||||
public onClickScrollDownButton = (): void => {
|
||||
private onClickScrollDownButton = (): void => {
|
||||
this.scrollDown(false);
|
||||
};
|
||||
|
||||
public scrollDown = (setFocus?: boolean): void => {
|
||||
private scrollDown = (setFocus?: boolean): void => {
|
||||
const {
|
||||
haveNewest,
|
||||
id,
|
||||
|
@ -977,7 +1002,8 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
const lastId = items[items.length - 1];
|
||||
const lastSeenIndicatorRow = this.getLastSeenIndicatorRow();
|
||||
|
||||
if (!this.visibleRows) {
|
||||
const { visibleRows } = this.state;
|
||||
if (!visibleRows) {
|
||||
if (haveNewest) {
|
||||
this.scrollToBottom(setFocus);
|
||||
} else if (!isLoadingMessages) {
|
||||
|
@ -987,12 +1013,12 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
return;
|
||||
}
|
||||
|
||||
const { newest } = this.visibleRows;
|
||||
const { newestFullyVisible } = visibleRows;
|
||||
|
||||
if (
|
||||
newest &&
|
||||
newestFullyVisible &&
|
||||
isNumber(lastSeenIndicatorRow) &&
|
||||
newest.row < lastSeenIndicatorRow
|
||||
newestFullyVisible.row < lastSeenIndicatorRow
|
||||
) {
|
||||
if (setFocus && isNumber(oldestUnreadIndex)) {
|
||||
const messageId = items[oldestUnreadIndex];
|
||||
|
@ -1112,8 +1138,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
}
|
||||
|
||||
const newRow = this.fromItemIndexToRow(newFirstIndex);
|
||||
const delta = newFirstIndex - oldFirstIndex;
|
||||
if (delta > 0) {
|
||||
if (newRow > 0) {
|
||||
// We're loading more new messages at the top; we want to stay at the top
|
||||
this.resize();
|
||||
// TODO: DESKTOP-688
|
||||
|
@ -1188,7 +1213,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
this.updateWithVisibleRows();
|
||||
}
|
||||
|
||||
public getScrollTarget = (): number | undefined => {
|
||||
private getScrollTarget = (): number | undefined => {
|
||||
const { oneTimeScrollRow, atBottom, propScrollToIndex } = this.state;
|
||||
|
||||
const rowCount = this.getRowCount();
|
||||
|
@ -1208,7 +1233,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
return scrollToBottom;
|
||||
};
|
||||
|
||||
public handleBlur = (event: React.FocusEvent): void => {
|
||||
private handleBlur = (event: React.FocusEvent): void => {
|
||||
const { clearSelectedMessage } = this.props;
|
||||
|
||||
const { currentTarget } = event;
|
||||
|
@ -1232,7 +1257,9 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
}, 0);
|
||||
};
|
||||
|
||||
public handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
|
||||
private handleKeyDown = (
|
||||
event: React.KeyboardEvent<HTMLDivElement>
|
||||
): void => {
|
||||
const { selectMessage, selectedMessageId, items, id } = this.props;
|
||||
const commandKey = get(window, 'platform') === 'darwin' && event.metaKey;
|
||||
const controlKey = get(window, 'platform') !== 'darwin' && event.ctrlKey;
|
||||
|
@ -1309,15 +1336,19 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
public override render(): JSX.Element | null {
|
||||
const {
|
||||
acknowledgeGroupMemberNameCollisions,
|
||||
areFloatingDateHeadersEnabled = true,
|
||||
areWeAdmin,
|
||||
clearInvitedUuidsForNewlyCreatedGroup,
|
||||
closeContactSpoofingReview,
|
||||
contactSpoofingReview,
|
||||
getPreferredBadge,
|
||||
getTimestampForMessage,
|
||||
haveOldest,
|
||||
i18n,
|
||||
id,
|
||||
invitedContactsForNewlyCreatedGroup,
|
||||
isGroupV1AndDisabled,
|
||||
isLoadingMessages,
|
||||
items,
|
||||
onBlock,
|
||||
onBlockAndReportSpam,
|
||||
|
@ -1332,6 +1363,9 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
const {
|
||||
shouldShowScrollDownButton,
|
||||
areUnreadBelowCurrentPosition,
|
||||
hasRecentlyScrolled,
|
||||
lastMeasuredWarningHeight,
|
||||
visibleRows,
|
||||
widthBreakpoint,
|
||||
} = this.state;
|
||||
|
||||
|
@ -1342,6 +1376,27 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
return null;
|
||||
}
|
||||
|
||||
let floatingHeader: ReactNode;
|
||||
const oldestPartiallyVisibleRow = visibleRows?.oldestPartiallyVisible;
|
||||
if (areFloatingDateHeadersEnabled && oldestPartiallyVisibleRow) {
|
||||
floatingHeader = (
|
||||
<TimelineFloatingHeader
|
||||
i18n={i18n}
|
||||
isLoading={isLoadingMessages}
|
||||
style={
|
||||
lastMeasuredWarningHeight
|
||||
? { marginTop: lastMeasuredWarningHeight }
|
||||
: undefined
|
||||
}
|
||||
timestamp={getTimestampForMessage(oldestPartiallyVisibleRow.id)}
|
||||
visible={
|
||||
(hasRecentlyScrolled || isLoadingMessages) &&
|
||||
(!haveOldest || oldestPartiallyVisibleRow.id !== items[0])
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const autoSizer = (
|
||||
<AutoSizer>
|
||||
{({ height, width }) => {
|
||||
|
@ -1366,6 +1421,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onScroll={this.onScroll as any}
|
||||
overscanRowCount={10}
|
||||
onRowsRendered={this.onRowsRendered}
|
||||
ref={this.listRef}
|
||||
rowCount={rowCount}
|
||||
rowHeight={this.cellSizeCache.rowHeight}
|
||||
|
@ -1549,6 +1605,8 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
>
|
||||
{timelineWarning}
|
||||
|
||||
{floatingHeader}
|
||||
|
||||
{autoSizer}
|
||||
|
||||
{shouldShowScrollDownButton ? (
|
||||
|
|
43
ts/components/conversation/TimelineDateHeader.tsx
Normal file
43
ts/components/conversation/TimelineDateHeader.tsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { ReactElement } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import * as durations from '../../util/durations';
|
||||
import type { LocalizerType } from '../../types/Util';
|
||||
import { formatDate } from '../../util/timestamp';
|
||||
import { Time } from '../Time';
|
||||
|
||||
export function TimelineDateHeader({
|
||||
floating = false,
|
||||
i18n,
|
||||
timestamp,
|
||||
}: Readonly<{
|
||||
floating?: boolean;
|
||||
i18n: LocalizerType;
|
||||
timestamp: number;
|
||||
}>): ReactElement {
|
||||
const [text, setText] = useState(formatDate(i18n, timestamp));
|
||||
useEffect(() => {
|
||||
const update = () => setText(formatDate(i18n, timestamp));
|
||||
update();
|
||||
const interval = setInterval(update, durations.MINUTE);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [i18n, timestamp]);
|
||||
|
||||
return (
|
||||
<Time
|
||||
className={classNames(
|
||||
'TimelineDateHeader',
|
||||
`TimelineDateHeader--${floating ? 'floating' : 'inline'}`
|
||||
)}
|
||||
dateOnly
|
||||
timestamp={timestamp}
|
||||
>
|
||||
{text}
|
||||
</Time>
|
||||
);
|
||||
}
|
43
ts/components/conversation/TimelineFloatingHeader.tsx
Normal file
43
ts/components/conversation/TimelineFloatingHeader.tsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import classNames from 'classnames';
|
||||
import type { CSSProperties, ReactElement } from 'react';
|
||||
import React from 'react';
|
||||
import type { LocalizerType } from '../../types/Util';
|
||||
import { TimelineDateHeader } from './TimelineDateHeader';
|
||||
import { Spinner } from '../Spinner';
|
||||
|
||||
export const TimelineFloatingHeader = ({
|
||||
i18n,
|
||||
isLoading,
|
||||
style,
|
||||
timestamp,
|
||||
visible,
|
||||
}: Readonly<{
|
||||
i18n: LocalizerType;
|
||||
isLoading: boolean;
|
||||
style?: CSSProperties;
|
||||
timestamp: number;
|
||||
visible: boolean;
|
||||
}>): ReactElement => (
|
||||
<div
|
||||
className={classNames(
|
||||
'TimelineFloatingHeader',
|
||||
`TimelineFloatingHeader--${visible ? 'visible' : 'hidden'}`
|
||||
)}
|
||||
style={style}
|
||||
>
|
||||
<TimelineDateHeader floating i18n={i18n} timestamp={timestamp} />
|
||||
<div
|
||||
className={classNames(
|
||||
'TimelineFloatingHeader__spinner-container',
|
||||
`TimelineFloatingHeader__spinner-container--${
|
||||
isLoading ? 'visible' : 'hidden'
|
||||
}`
|
||||
)}
|
||||
>
|
||||
<Spinner direction="on-background" size="20px" svgSize="small" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
|
@ -1,4 +1,4 @@
|
|||
// Copyright 2020-2021 Signal Messenger, LLC
|
||||
// Copyright 2020-2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
|
@ -53,6 +53,7 @@ const getDefaultProps = () => ({
|
|||
conversationId: 'conversation-id',
|
||||
getPreferredBadge: () => undefined,
|
||||
id: 'asdf',
|
||||
isOldestTimelineItem: false,
|
||||
isSelected: false,
|
||||
interactionMode: 'keyboard' as const,
|
||||
theme: ThemeType.light,
|
||||
|
|
|
@ -1,13 +1,15 @@
|
|||
// Copyright 2019-2021 Signal Messenger, LLC
|
||||
// Copyright 2019-2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { RefObject } from 'react';
|
||||
import type { ReactChild, RefObject } from 'react';
|
||||
import React from 'react';
|
||||
import { omit } from 'lodash';
|
||||
import moment from 'moment';
|
||||
|
||||
import type { LocalizerType, ThemeType } from '../../types/Util';
|
||||
|
||||
import type { InteractionModeType } from '../../state/ducks/conversations';
|
||||
import { TimelineDateHeader } from './TimelineDateHeader';
|
||||
import type {
|
||||
Props as AllMessageProps,
|
||||
PropsActions as MessageActionsType,
|
||||
|
@ -120,7 +122,7 @@ type ProfileChangeNotificationType = {
|
|||
data: ProfileChangeNotificationPropsType;
|
||||
};
|
||||
|
||||
export type TimelineItemType =
|
||||
export type TimelineItemType = (
|
||||
| CallHistoryType
|
||||
| ChatSessionRefreshedType
|
||||
| DeliveryIssueType
|
||||
|
@ -136,7 +138,8 @@ export type TimelineItemType =
|
|||
| UniversalTimerNotificationType
|
||||
| ChangeNumberNotificationType
|
||||
| UnsupportedMessageType
|
||||
| VerificationNotificationType;
|
||||
| VerificationNotificationType
|
||||
) & { timestamp: number };
|
||||
|
||||
type PropsLocalType = {
|
||||
containerElementRef: RefObject<HTMLElement>;
|
||||
|
@ -149,6 +152,7 @@ type PropsLocalType = {
|
|||
renderUniversalTimerNotification: () => JSX.Element;
|
||||
i18n: LocalizerType;
|
||||
interactionMode: InteractionModeType;
|
||||
isOldestTimelineItem: boolean;
|
||||
theme: ThemeType;
|
||||
previousItem: undefined | TimelineItemType;
|
||||
nextItem: undefined | TimelineItemType;
|
||||
|
@ -179,12 +183,14 @@ export class TimelineItem extends React.PureComponent<PropsType> {
|
|||
conversationId,
|
||||
getPreferredBadge,
|
||||
id,
|
||||
isOldestTimelineItem,
|
||||
isSelected,
|
||||
item,
|
||||
i18n,
|
||||
theme,
|
||||
messageSizeChanged,
|
||||
nextItem,
|
||||
previousItem,
|
||||
renderContact,
|
||||
renderUniversalTimerNotification,
|
||||
returnToActiveCall,
|
||||
|
@ -198,8 +204,9 @@ export class TimelineItem extends React.PureComponent<PropsType> {
|
|||
return null;
|
||||
}
|
||||
|
||||
let itemContents: ReactChild;
|
||||
if (item.type === 'message') {
|
||||
return (
|
||||
itemContents = (
|
||||
<Message
|
||||
{...omit(this.props, ['item'])}
|
||||
{...item.data}
|
||||
|
@ -210,101 +217,143 @@ export class TimelineItem extends React.PureComponent<PropsType> {
|
|||
renderingContext="conversation/TimelineItem"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let notification;
|
||||
|
||||
if (item.type === 'unsupportedMessage') {
|
||||
notification = (
|
||||
<UnsupportedMessage {...this.props} {...item.data} i18n={i18n} />
|
||||
);
|
||||
} else if (item.type === 'callHistory') {
|
||||
notification = (
|
||||
<CallingNotification
|
||||
conversationId={conversationId}
|
||||
i18n={i18n}
|
||||
messageId={id}
|
||||
messageSizeChanged={messageSizeChanged}
|
||||
nextItem={nextItem}
|
||||
returnToActiveCall={returnToActiveCall}
|
||||
startCallingLobby={startCallingLobby}
|
||||
{...item.data}
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'chatSessionRefreshed') {
|
||||
notification = (
|
||||
<ChatSessionRefreshedNotification
|
||||
{...this.props}
|
||||
{...item.data}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'deliveryIssue') {
|
||||
notification = (
|
||||
<DeliveryIssueNotification {...item.data} {...this.props} i18n={i18n} />
|
||||
);
|
||||
} else if (item.type === 'linkNotification') {
|
||||
notification = <LinkNotification i18n={i18n} />;
|
||||
} else if (item.type === 'timerNotification') {
|
||||
notification = (
|
||||
<TimerNotification {...this.props} {...item.data} i18n={i18n} />
|
||||
);
|
||||
} else if (item.type === 'universalTimerNotification') {
|
||||
notification = renderUniversalTimerNotification();
|
||||
} else if (item.type === 'changeNumberNotification') {
|
||||
notification = (
|
||||
<ChangeNumberNotification {...this.props} {...item.data} i18n={i18n} />
|
||||
);
|
||||
} else if (item.type === 'safetyNumberNotification') {
|
||||
notification = (
|
||||
<SafetyNumberNotification {...this.props} {...item.data} i18n={i18n} />
|
||||
);
|
||||
} else if (item.type === 'verificationNotification') {
|
||||
notification = (
|
||||
<VerificationNotification {...this.props} {...item.data} i18n={i18n} />
|
||||
);
|
||||
} else if (item.type === 'groupNotification') {
|
||||
notification = (
|
||||
<GroupNotification {...this.props} {...item.data} i18n={i18n} />
|
||||
);
|
||||
} else if (item.type === 'groupV2Change') {
|
||||
notification = (
|
||||
<GroupV2Change
|
||||
renderContact={renderContact}
|
||||
{...item.data}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'groupV1Migration') {
|
||||
notification = (
|
||||
<GroupV1Migration {...this.props} {...item.data} i18n={i18n} />
|
||||
);
|
||||
} else if (item.type === 'resetSessionNotification') {
|
||||
notification = (
|
||||
<ResetSessionNotification {...this.props} {...item.data} i18n={i18n} />
|
||||
);
|
||||
} else if (item.type === 'profileChange') {
|
||||
notification = (
|
||||
<ProfileChangeNotification {...this.props} {...item.data} i18n={i18n} />
|
||||
);
|
||||
} else {
|
||||
// Weird, yes, but the idea is to get a compile error when we aren't comprehensive
|
||||
// with our if/else checks above, but also log out the type we don't understand if
|
||||
// we encounter it at runtime.
|
||||
const unknownItem: never = item;
|
||||
const asItem = unknownItem as TimelineItemType;
|
||||
throw new Error(`TimelineItem: Unknown type: ${asItem.type}`);
|
||||
let notification;
|
||||
|
||||
if (item.type === 'unsupportedMessage') {
|
||||
notification = (
|
||||
<UnsupportedMessage {...this.props} {...item.data} i18n={i18n} />
|
||||
);
|
||||
} else if (item.type === 'callHistory') {
|
||||
notification = (
|
||||
<CallingNotification
|
||||
conversationId={conversationId}
|
||||
i18n={i18n}
|
||||
messageId={id}
|
||||
messageSizeChanged={messageSizeChanged}
|
||||
nextItem={nextItem}
|
||||
returnToActiveCall={returnToActiveCall}
|
||||
startCallingLobby={startCallingLobby}
|
||||
{...item.data}
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'chatSessionRefreshed') {
|
||||
notification = (
|
||||
<ChatSessionRefreshedNotification
|
||||
{...this.props}
|
||||
{...item.data}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'deliveryIssue') {
|
||||
notification = (
|
||||
<DeliveryIssueNotification
|
||||
{...item.data}
|
||||
{...this.props}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'linkNotification') {
|
||||
notification = <LinkNotification i18n={i18n} />;
|
||||
} else if (item.type === 'timerNotification') {
|
||||
notification = (
|
||||
<TimerNotification {...this.props} {...item.data} i18n={i18n} />
|
||||
);
|
||||
} else if (item.type === 'universalTimerNotification') {
|
||||
notification = renderUniversalTimerNotification();
|
||||
} else if (item.type === 'changeNumberNotification') {
|
||||
notification = (
|
||||
<ChangeNumberNotification
|
||||
{...this.props}
|
||||
{...item.data}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'safetyNumberNotification') {
|
||||
notification = (
|
||||
<SafetyNumberNotification
|
||||
{...this.props}
|
||||
{...item.data}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'verificationNotification') {
|
||||
notification = (
|
||||
<VerificationNotification
|
||||
{...this.props}
|
||||
{...item.data}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'groupNotification') {
|
||||
notification = (
|
||||
<GroupNotification {...this.props} {...item.data} i18n={i18n} />
|
||||
);
|
||||
} else if (item.type === 'groupV2Change') {
|
||||
notification = (
|
||||
<GroupV2Change
|
||||
renderContact={renderContact}
|
||||
{...item.data}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'groupV1Migration') {
|
||||
notification = (
|
||||
<GroupV1Migration {...this.props} {...item.data} i18n={i18n} />
|
||||
);
|
||||
} else if (item.type === 'resetSessionNotification') {
|
||||
notification = (
|
||||
<ResetSessionNotification
|
||||
{...this.props}
|
||||
{...item.data}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'profileChange') {
|
||||
notification = (
|
||||
<ProfileChangeNotification
|
||||
{...this.props}
|
||||
{...item.data}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
// Weird, yes, but the idea is to get a compile error when we aren't comprehensive
|
||||
// with our if/else checks above, but also log out the type we don't understand
|
||||
// if we encounter it at runtime.
|
||||
const unknownItem: never = item;
|
||||
const asItem = unknownItem as TimelineItemType;
|
||||
throw new Error(`TimelineItem: Unknown type: ${asItem.type}`);
|
||||
}
|
||||
|
||||
itemContents = (
|
||||
<InlineNotificationWrapper
|
||||
id={id}
|
||||
conversationId={conversationId}
|
||||
isSelected={isSelected}
|
||||
selectMessage={selectMessage}
|
||||
>
|
||||
{notification}
|
||||
</InlineNotificationWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<InlineNotificationWrapper
|
||||
id={id}
|
||||
conversationId={conversationId}
|
||||
isSelected={isSelected}
|
||||
selectMessage={selectMessage}
|
||||
>
|
||||
{notification}
|
||||
</InlineNotificationWrapper>
|
||||
);
|
||||
const shouldRenderDateHeader =
|
||||
isOldestTimelineItem ||
|
||||
Boolean(
|
||||
previousItem &&
|
||||
// This comparison avoids strange header behavior for out-of-order messages.
|
||||
item.timestamp > previousItem.timestamp &&
|
||||
!moment(previousItem.timestamp).isSame(item.timestamp, 'day')
|
||||
);
|
||||
if (shouldRenderDateHeader) {
|
||||
return (
|
||||
<>
|
||||
<TimelineDateHeader i18n={i18n} timestamp={item.timestamp} />
|
||||
{itemContents}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return itemContents;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,45 +0,0 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { date, number, select } from '@storybook/addon-knobs';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
import type { Props } from './TimelineLoadingRow';
|
||||
import { TimelineLoadingRow } from './TimelineLoadingRow';
|
||||
|
||||
const story = storiesOf('Components/Conversation/TimelineLoadingRow', module);
|
||||
|
||||
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||
state: select(
|
||||
'state',
|
||||
{ idle: 'idle', countdown: 'countdown', loading: 'loading' },
|
||||
overrideProps.state || 'idle'
|
||||
),
|
||||
duration: number('duration', overrideProps.duration || 0),
|
||||
expiresAt: date('expiresAt', new Date(overrideProps.expiresAt || Date.now())),
|
||||
onComplete: action('onComplete'),
|
||||
});
|
||||
|
||||
story.add('Idle', () => {
|
||||
const props = createProps();
|
||||
|
||||
return <TimelineLoadingRow {...props} />;
|
||||
});
|
||||
|
||||
story.add('Countdown', () => {
|
||||
const props = createProps({
|
||||
state: 'countdown',
|
||||
duration: 40000,
|
||||
expiresAt: Date.now() + 20000,
|
||||
});
|
||||
|
||||
return <TimelineLoadingRow {...props} />;
|
||||
});
|
||||
|
||||
story.add('Loading', () => {
|
||||
const props = createProps({ state: 'loading' });
|
||||
|
||||
return <TimelineLoadingRow {...props} />;
|
||||
});
|
|
@ -1,48 +0,0 @@
|
|||
// Copyright 2019-2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import { isNumber } from 'lodash';
|
||||
|
||||
import { Countdown } from '../Countdown';
|
||||
import { Spinner } from '../Spinner';
|
||||
|
||||
export type STATE_ENUM = 'idle' | 'countdown' | 'loading';
|
||||
|
||||
export type Props = {
|
||||
state: STATE_ENUM;
|
||||
duration?: number;
|
||||
expiresAt?: number;
|
||||
onComplete?: () => unknown;
|
||||
};
|
||||
|
||||
const FAKE_DURATION = 1000;
|
||||
|
||||
export class TimelineLoadingRow extends React.PureComponent<Props> {
|
||||
public renderContents(): JSX.Element {
|
||||
const { state, duration, expiresAt, onComplete } = this.props;
|
||||
|
||||
if (state === 'idle') {
|
||||
const fakeExpiresAt = Date.now() - FAKE_DURATION;
|
||||
|
||||
return <Countdown duration={FAKE_DURATION} expiresAt={fakeExpiresAt} />;
|
||||
}
|
||||
if (state === 'countdown' && isNumber(duration) && isNumber(expiresAt)) {
|
||||
return (
|
||||
<Countdown
|
||||
duration={duration}
|
||||
expiresAt={expiresAt}
|
||||
onComplete={onComplete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <Spinner size="24" svgSize="small" direction="on-background" />;
|
||||
}
|
||||
|
||||
public override render(): JSX.Element {
|
||||
return (
|
||||
<div className="module-timeline-loading-row">{this.renderContents()}</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// Copyright 2021-2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { ReactNode, FunctionComponent } from 'react';
|
||||
|
@ -9,12 +9,12 @@ import { v4 as uuid } from 'uuid';
|
|||
|
||||
import { Avatar, AvatarSize } from '../Avatar';
|
||||
import type { BadgeType } from '../../badges/types';
|
||||
import { Timestamp } from '../conversation/Timestamp';
|
||||
import { isConversationUnread } from '../../util/isConversationUnread';
|
||||
import { cleanId } from '../_util';
|
||||
import type { LocalizerType, ThemeType } from '../../types/Util';
|
||||
import type { ConversationType } from '../../state/ducks/conversations';
|
||||
import { Spinner } from '../Spinner';
|
||||
import { formatDateTimeShort } from '../../util/timestamp';
|
||||
|
||||
const BASE_CLASS_NAME =
|
||||
'module-conversation-list__item--contact-or-conversation';
|
||||
|
@ -24,7 +24,6 @@ const HEADER_CLASS_NAME = `${CONTENT_CLASS_NAME}__header`;
|
|||
export const HEADER_NAME_CLASS_NAME = `${HEADER_CLASS_NAME}__name`;
|
||||
export const HEADER_CONTACT_NAME_CLASS_NAME = `${HEADER_NAME_CLASS_NAME}__contact-name`;
|
||||
export const DATE_CLASS_NAME = `${HEADER_CLASS_NAME}__date`;
|
||||
const TIMESTAMP_CLASS_NAME = `${DATE_CLASS_NAME}__timestamp`;
|
||||
const MESSAGE_CLASS_NAME = `${CONTENT_CLASS_NAME}__message`;
|
||||
export const MESSAGE_TEXT_CLASS_NAME = `${MESSAGE_CLASS_NAME}__text`;
|
||||
const CHECKBOX_CLASS_NAME = `${BASE_CLASS_NAME}__checkbox`;
|
||||
|
@ -175,16 +174,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
|
|||
>
|
||||
<div className={HEADER_CLASS_NAME}>
|
||||
<div className={`${HEADER_CLASS_NAME}__name`}>{headerName}</div>
|
||||
{isNumber(headerDate) && (
|
||||
<div className={DATE_CLASS_NAME}>
|
||||
<Timestamp
|
||||
timestamp={headerDate}
|
||||
extended={false}
|
||||
module={TIMESTAMP_CLASS_NAME}
|
||||
i18n={i18n}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Timestamp timestamp={headerDate} i18n={i18n} />
|
||||
</div>
|
||||
{messageText || isUnread ? (
|
||||
<div className={MESSAGE_CLASS_NAME}>
|
||||
|
@ -258,6 +248,22 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
|
|||
);
|
||||
});
|
||||
|
||||
function Timestamp({
|
||||
i18n,
|
||||
timestamp,
|
||||
}: Readonly<{ i18n: LocalizerType; timestamp?: number }>) {
|
||||
if (!isNumber(timestamp)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = new Date(timestamp);
|
||||
return (
|
||||
<time className={DATE_CLASS_NAME} dateTime={date.toISOString()}>
|
||||
{formatDateTimeShort(i18n, date)}
|
||||
</time>
|
||||
);
|
||||
}
|
||||
|
||||
function UnreadIndicator({
|
||||
count = 0,
|
||||
isUnread,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue