Timeline date headers
This commit is contained in:
parent
0fa069f260
commit
f9440bf594
41 changed files with 1183 additions and 771 deletions
|
@ -571,11 +571,11 @@
|
||||||
},
|
},
|
||||||
"today": {
|
"today": {
|
||||||
"message": "Today",
|
"message": "Today",
|
||||||
"description": "Section header in the media gallery"
|
"description": "The string \"today\""
|
||||||
},
|
},
|
||||||
"yesterday": {
|
"yesterday": {
|
||||||
"message": "Yesterday",
|
"message": "Yesterday",
|
||||||
"description": "Section header in the media gallery"
|
"description": "The string \"yesterday\""
|
||||||
},
|
},
|
||||||
"thisWeek": {
|
"thisWeek": {
|
||||||
"message": "This Week",
|
"message": "This Week",
|
||||||
|
@ -2085,6 +2085,14 @@
|
||||||
"message": "MMM D",
|
"message": "MMM D",
|
||||||
"description": "Timestamp format string for displaying month and day (but not the year) of a date within the current year, ex: use 'MMM D' for 'Aug 8', or 'D MMM' for '8 Aug'."
|
"description": "Timestamp format string for displaying month and day (but not the year) of a date within the current year, ex: use 'MMM D' for 'Aug 8', or 'D MMM' for '8 Aug'."
|
||||||
},
|
},
|
||||||
|
"timestampFormat__long__today": {
|
||||||
|
"message": "[Today] LT",
|
||||||
|
"description": "Timestamp format string for displaying \"Today\" and the time"
|
||||||
|
},
|
||||||
|
"timestampFormat__long__yesterday": {
|
||||||
|
"message": "[Yesterday] LT",
|
||||||
|
"description": "Timestamp format string for displaying \"Yesterday\" and the time"
|
||||||
|
},
|
||||||
"messageBodyTooLong": {
|
"messageBodyTooLong": {
|
||||||
"message": "Message body is too long.",
|
"message": "Message body is too long.",
|
||||||
"description": "Shown if the user tries to send more than 64kb of text"
|
"description": "Shown if the user tries to send more than 64kb of text"
|
||||||
|
@ -5941,6 +5949,14 @@
|
||||||
"message": "Continue",
|
"message": "Continue",
|
||||||
"description": "aria-label for the 'next' button in the forward a message modal dialog"
|
"description": "aria-label for the 'next' button in the forward a message modal dialog"
|
||||||
},
|
},
|
||||||
|
"TimelineDateHeader--date-in-last-6-months": {
|
||||||
|
"message": "ddd, MMM D",
|
||||||
|
"description": "Moment.js format for date headers in the message timeline, for dates <6 months old. See https://momentjs.com/docs/#/displaying/format/."
|
||||||
|
},
|
||||||
|
"TimelineDateHeader--date-older-than-6-months": {
|
||||||
|
"message": "MMM D, YYYY",
|
||||||
|
"description": "Moment.js format for date headers in the message timeline, for dates >=6 months old. See https://momentjs.com/docs/#/displaying/format/."
|
||||||
|
},
|
||||||
"MessageRequestWarning__learn-more": {
|
"MessageRequestWarning__learn-more": {
|
||||||
"message": "Learn more",
|
"message": "Learn more",
|
||||||
"description": "Shown on the message request warning. Clicking this button will open a dialog with more information"
|
"description": "Shown on the message request warning. Clicking this button will open a dialog with more information"
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright 2016-2021 Signal Messenger, LLC
|
// Copyright 2016-2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
// Fonts
|
// Fonts
|
||||||
|
@ -686,3 +686,15 @@
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@mixin timeline-floating-header-node {
|
||||||
|
@include rounded-corners;
|
||||||
|
box-shadow: 0 1px 4px $color-black-alpha-20;
|
||||||
|
|
||||||
|
@include light-theme {
|
||||||
|
background: $color-white;
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
background: $color-gray-80;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -4902,25 +4902,19 @@ button.module-image__border-overlay:focus {
|
||||||
}
|
}
|
||||||
|
|
||||||
&__date {
|
&__date {
|
||||||
|
@include font-caption;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
margin-left: 6px;
|
||||||
|
overflow-x: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
&__timestamp {
|
@include light-theme {
|
||||||
flex-shrink: 0;
|
color: $color-gray-60;
|
||||||
margin-left: 6px;
|
}
|
||||||
|
@include dark-theme {
|
||||||
@include font-caption;
|
color: $color-gray-25;
|
||||||
|
|
||||||
overflow-x: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
|
|
||||||
@include light-theme {
|
|
||||||
color: $color-gray-60;
|
|
||||||
}
|
|
||||||
@include dark-theme {
|
|
||||||
color: $color-gray-25;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5460,26 +5454,6 @@ button.module-image__border-overlay:focus {
|
||||||
cursor: col-resize;
|
cursor: col-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Module: Timeline Loading Row
|
|
||||||
|
|
||||||
.module-timeline-loading-row {
|
|
||||||
height: 48px;
|
|
||||||
padding: 12px;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
flex-direction: columns;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
@include light-theme {
|
|
||||||
color: $color-gray-75;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include dark-theme {
|
|
||||||
color: $color-gray-25;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Module: Timeline
|
// Module: Timeline
|
||||||
|
|
||||||
.module-timeline {
|
.module-timeline {
|
||||||
|
@ -6885,41 +6859,6 @@ button.module-image__border-overlay:focus {
|
||||||
@include emoji-size(66px);
|
@include emoji-size(66px);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Module: Countdown
|
|
||||||
|
|
||||||
.module-countdown {
|
|
||||||
display: block;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: the colors here should match the module-spinner's on-background colors
|
|
||||||
.module-countdown__front-path {
|
|
||||||
fill-opacity: 0;
|
|
||||||
stroke-width: 2;
|
|
||||||
|
|
||||||
@include light-theme {
|
|
||||||
stroke: $color-gray-60;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include dark-theme {
|
|
||||||
stroke: $color-gray-25;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.module-countdown__back-path {
|
|
||||||
fill-opacity: 0;
|
|
||||||
stroke-width: 2;
|
|
||||||
|
|
||||||
@include light-theme {
|
|
||||||
stroke: $color-gray-05;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include dark-theme {
|
|
||||||
stroke: $color-gray-75;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Module: CompositionInput
|
// Module: CompositionInput
|
||||||
.module-composition-input {
|
.module-composition-input {
|
||||||
&__quill {
|
&__quill {
|
||||||
|
|
25
stylesheets/components/TimelineDateHeader.scss
Normal file
25
stylesheets/components/TimelineDateHeader.scss
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
.TimelineDateHeader {
|
||||||
|
@include font-body-2;
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
@include light-theme {
|
||||||
|
color: $color-gray-60;
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
color: $color-gray-05;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--inline {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--floating {
|
||||||
|
@include timeline-floating-header-node;
|
||||||
|
padding: 5px 10px;
|
||||||
|
}
|
||||||
|
}
|
38
stylesheets/components/TimelineFloatingHeader.scss
Normal file
38
stylesheets/components/TimelineFloatingHeader.scss
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
.TimelineFloatingHeader {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
left: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
width: 100%;
|
||||||
|
z-index: $z-index-above-base;
|
||||||
|
|
||||||
|
&--visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--hidden {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.5s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__spinner-container {
|
||||||
|
@include timeline-floating-header-node;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 6px;
|
||||||
|
|
||||||
|
&--visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--hidden {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.1s ease-out 0.3s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -98,7 +98,9 @@
|
||||||
@import './components/Slider.scss';
|
@import './components/Slider.scss';
|
||||||
@import './components/SystemMessage.scss';
|
@import './components/SystemMessage.scss';
|
||||||
@import './components/Tabs.scss';
|
@import './components/Tabs.scss';
|
||||||
@import './components/Toast.scss';
|
@import './components/TimelineDateHeader.scss';
|
||||||
|
@import './components/TimelineFloatingHeader.scss';
|
||||||
@import './components/TimelineWarning.scss';
|
@import './components/TimelineWarning.scss';
|
||||||
@import './components/TimelineWarnings.scss';
|
@import './components/TimelineWarnings.scss';
|
||||||
|
@import './components/Toast.scss';
|
||||||
@import './components/WhatsNew.scss';
|
@import './components/WhatsNew.scss';
|
||||||
|
|
|
@ -10,6 +10,8 @@ export type ConfigKeyType =
|
||||||
| 'desktop.announcementGroup'
|
| 'desktop.announcementGroup'
|
||||||
| 'desktop.clientExpiration'
|
| 'desktop.clientExpiration'
|
||||||
| 'desktop.disableGV1'
|
| 'desktop.disableGV1'
|
||||||
|
| 'desktop.floatingDateHeaders.beta'
|
||||||
|
| 'desktop.floatingDateHeaders.production'
|
||||||
| 'desktop.groupCallOutboundRing'
|
| 'desktop.groupCallOutboundRing'
|
||||||
| 'desktop.groupCalling'
|
| 'desktop.groupCalling'
|
||||||
| 'desktop.gv2'
|
| 'desktop.gv2'
|
||||||
|
|
|
@ -949,6 +949,7 @@ export async function startApp(): Promise<void> {
|
||||||
i18n: window.i18n,
|
i18n: window.i18n,
|
||||||
interactionMode: window.getInteractionMode(),
|
interactionMode: window.getInteractionMode(),
|
||||||
theme,
|
theme,
|
||||||
|
version: window.getVersion(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import type { CSSProperties } from 'react';
|
import type { CSSProperties } from 'react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { ConversationColorType } from '../types/Colors';
|
import type { ConversationColorType } from '../types/Colors';
|
||||||
import type { LocalizerType } from '../types/Util';
|
import type { LocalizerType } from '../types/Util';
|
||||||
import { formatRelativeTime } from '../util/formatRelativeTime';
|
import { formatTime } from '../util/timestamp';
|
||||||
|
|
||||||
export type PropsType = {
|
export type PropsType = {
|
||||||
backgroundStyle?: CSSProperties;
|
backgroundStyle?: CSSProperties;
|
||||||
|
@ -51,7 +51,7 @@ const SampleMessage = ({
|
||||||
<span
|
<span
|
||||||
className={`module-message__metadata__date module-message__metadata__date--${direction}`}
|
className={`module-message__metadata__date module-message__metadata__date--${direction}`}
|
||||||
>
|
>
|
||||||
{formatRelativeTime(timestamp, { extended: true, i18n })}
|
{formatTime(i18n, timestamp)}
|
||||||
</span>
|
</span>
|
||||||
{direction === 'outgoing' && (
|
{direction === 'outgoing' && (
|
||||||
<div
|
<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
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
@ -70,7 +70,7 @@ story.add('Two incoming direct calls back-to-back', () => {
|
||||||
<CallingNotification
|
<CallingNotification
|
||||||
{...getCommonProps()}
|
{...getCommonProps()}
|
||||||
{...call1}
|
{...call1}
|
||||||
nextItem={{ type: 'callHistory', data: call2 }}
|
nextItem={{ type: 'callHistory', data: call2, timestamp: Date.now() }}
|
||||||
/>
|
/>
|
||||||
<CallingNotification {...getCommonProps()} {...call2} />
|
<CallingNotification {...getCommonProps()} {...call2} />
|
||||||
</>
|
</>
|
||||||
|
@ -99,7 +99,7 @@ story.add('Two outgoing direct calls back-to-back', () => {
|
||||||
<CallingNotification
|
<CallingNotification
|
||||||
{...getCommonProps()}
|
{...getCommonProps()}
|
||||||
{...call1}
|
{...call1}
|
||||||
nextItem={{ type: 'callHistory', data: call2 }}
|
nextItem={{ type: 'callHistory', data: call2, timestamp: Date.now() }}
|
||||||
/>
|
/>
|
||||||
<CallingNotification {...getCommonProps()} {...call2} />
|
<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
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
@ -8,7 +8,7 @@ import { noop } from 'lodash';
|
||||||
|
|
||||||
import { SystemMessage } from './SystemMessage';
|
import { SystemMessage } from './SystemMessage';
|
||||||
import { Button, ButtonSize, ButtonVariant } from '../Button';
|
import { Button, ButtonSize, ButtonVariant } from '../Button';
|
||||||
import { Timestamp } from './Timestamp';
|
import { MessageTimestamp } from './MessageTimestamp';
|
||||||
import type { LocalizerType } from '../../types/Util';
|
import type { LocalizerType } from '../../types/Util';
|
||||||
import { CallMode } from '../../types/Calling';
|
import { CallMode } from '../../types/Calling';
|
||||||
import type { CallingNotificationType } from '../../util/callingNotification';
|
import type { CallingNotificationType } from '../../util/callingNotification';
|
||||||
|
@ -91,9 +91,8 @@ export const CallingNotification: React.FC<PropsType> = React.memo(props => {
|
||||||
contents={
|
contents={
|
||||||
<>
|
<>
|
||||||
{getCallingNotificationText(props, i18n)} ·{' '}
|
{getCallingNotificationText(props, i18n)} ·{' '}
|
||||||
<Timestamp
|
<MessageTimestamp
|
||||||
direction="outgoing"
|
direction="outgoing"
|
||||||
extended
|
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
timestamp={timestamp}
|
timestamp={timestamp}
|
||||||
withImageNoCaption={false}
|
withImageNoCaption={false}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
// Copyright 2021-2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
@ -8,7 +8,7 @@ import type { LocalizerType } from '../../types/Util';
|
||||||
import { Intl } from '../Intl';
|
import { Intl } from '../Intl';
|
||||||
|
|
||||||
import { SystemMessage } from './SystemMessage';
|
import { SystemMessage } from './SystemMessage';
|
||||||
import { Timestamp } from './Timestamp';
|
import { MessageTimestamp } from './MessageTimestamp';
|
||||||
import { Emojify } from './Emojify';
|
import { Emojify } from './Emojify';
|
||||||
|
|
||||||
export type PropsData = {
|
export type PropsData = {
|
||||||
|
@ -37,7 +37,7 @@ export const ChangeNumberNotification: React.FC<Props> = props => {
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
/>
|
/>
|
||||||
·
|
·
|
||||||
<Timestamp i18n={i18n} timestamp={timestamp} />
|
<MessageTimestamp i18n={i18n} timestamp={timestamp} />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
icon="phone"
|
icon="phone"
|
||||||
|
|
|
@ -4,11 +4,11 @@
|
||||||
import type { ReactChild, ReactNode } from 'react';
|
import type { ReactChild, ReactNode } from 'react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import moment from 'moment';
|
|
||||||
import { noop } from 'lodash';
|
import { noop } from 'lodash';
|
||||||
|
|
||||||
import { Avatar, AvatarSize } from '../Avatar';
|
import { Avatar, AvatarSize } from '../Avatar';
|
||||||
import { ContactName } from './ContactName';
|
import { ContactName } from './ContactName';
|
||||||
|
import { Time } from '../Time';
|
||||||
import type {
|
import type {
|
||||||
Props as MessagePropsType,
|
Props as MessagePropsType,
|
||||||
PropsData as MessagePropsDataType,
|
PropsData as MessagePropsDataType,
|
||||||
|
@ -22,7 +22,7 @@ import type { ContactNameColorType } from '../../types/Colors';
|
||||||
import { SendStatus } from '../../messages/MessageSendState';
|
import { SendStatus } from '../../messages/MessageSendState';
|
||||||
import { WidthBreakpoint } from '../_util';
|
import { WidthBreakpoint } from '../_util';
|
||||||
import * as log from '../../logging/log';
|
import * as log from '../../logging/log';
|
||||||
import { Timestamp } from './Timestamp';
|
import { formatDateTimeLong } from '../../util/timestamp';
|
||||||
|
|
||||||
export type Contact = Pick<
|
export type Contact = Pick<
|
||||||
ConversationType,
|
ConversationType,
|
||||||
|
@ -194,12 +194,12 @@ export class MessageDetail extends React.Component<Props> {
|
||||||
{errorComponent}
|
{errorComponent}
|
||||||
{unidentifiedDeliveryComponent}
|
{unidentifiedDeliveryComponent}
|
||||||
{contact.statusTimestamp && (
|
{contact.statusTimestamp && (
|
||||||
<Timestamp
|
<Time
|
||||||
extended
|
className="module-message-detail__status-timestamp"
|
||||||
i18n={i18n}
|
|
||||||
module="module-message-detail__status-timestamp"
|
|
||||||
timestamp={contact.statusTimestamp}
|
timestamp={contact.statusTimestamp}
|
||||||
/>
|
>
|
||||||
|
{formatDateTimeLong(i18n, contact.statusTimestamp)}
|
||||||
|
</Time>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -379,7 +379,9 @@ export class MessageDetail extends React.Component<Props> {
|
||||||
<tr>
|
<tr>
|
||||||
<td className="module-message-detail__label">{i18n('sent')}</td>
|
<td className="module-message-detail__label">{i18n('sent')}</td>
|
||||||
<td>
|
<td>
|
||||||
{moment(sentAt).format('LLLL')}{' '}
|
<Time timestamp={sentAt}>
|
||||||
|
{formatDateTimeLong(i18n, sentAt)}
|
||||||
|
</Time>{' '}
|
||||||
<span className="module-message-detail__unix-timestamp">
|
<span className="module-message-detail__unix-timestamp">
|
||||||
({sentAt})
|
({sentAt})
|
||||||
</span>
|
</span>
|
||||||
|
@ -391,7 +393,9 @@ export class MessageDetail extends React.Component<Props> {
|
||||||
{i18n('received')}
|
{i18n('received')}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{moment(receivedAt).format('LLLL')}{' '}
|
<Time timestamp={receivedAt}>
|
||||||
|
{formatDateTimeLong(i18n, receivedAt)}
|
||||||
|
</Time>{' '}
|
||||||
<span className="module-message-detail__unix-timestamp">
|
<span className="module-message-detail__unix-timestamp">
|
||||||
({receivedAt})
|
({receivedAt})
|
||||||
</span>
|
</span>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright 2018-2021 Signal Messenger, LLC
|
// Copyright 2018-2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import type { FunctionComponent, ReactChild } from 'react';
|
import type { FunctionComponent, ReactChild } from 'react';
|
||||||
|
@ -8,7 +8,7 @@ import classNames from 'classnames';
|
||||||
import type { LocalizerType } from '../../types/Util';
|
import type { LocalizerType } from '../../types/Util';
|
||||||
import type { DirectionType, MessageStatusType } from './Message';
|
import type { DirectionType, MessageStatusType } from './Message';
|
||||||
import { ExpireTimer } from './ExpireTimer';
|
import { ExpireTimer } from './ExpireTimer';
|
||||||
import { Timestamp } from './Timestamp';
|
import { MessageTimestamp } from './MessageTimestamp';
|
||||||
import { Spinner } from '../Spinner';
|
import { Spinner } from '../Spinner';
|
||||||
|
|
||||||
type PropsType = {
|
type PropsType = {
|
||||||
|
@ -94,10 +94,9 @@ export const MessageMetadata: FunctionComponent<PropsType> = props => {
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
timestampNode = (
|
timestampNode = (
|
||||||
<Timestamp
|
<MessageTimestamp
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
timestamp={timestamp}
|
timestamp={timestamp}
|
||||||
extended
|
|
||||||
direction={metadataDirection}
|
direction={metadataDirection}
|
||||||
withImageNoCaption={withImageNoCaption}
|
withImageNoCaption={withImageNoCaption}
|
||||||
withSticker={isSticker}
|
withSticker={isSticker}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright 2020 Signal Messenger, LLC
|
// Copyright 2020-2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
@ -7,12 +7,12 @@ import { boolean, date, select, text } from '@storybook/addon-knobs';
|
||||||
|
|
||||||
import { setupI18n } from '../../util/setupI18n';
|
import { setupI18n } from '../../util/setupI18n';
|
||||||
import enMessages from '../../../_locales/en/messages.json';
|
import enMessages from '../../../_locales/en/messages.json';
|
||||||
import type { Props } from './Timestamp';
|
import type { Props } from './MessageTimestamp';
|
||||||
import { Timestamp } from './Timestamp';
|
import { MessageTimestamp } from './MessageTimestamp';
|
||||||
|
|
||||||
const i18n = setupI18n('en', enMessages);
|
const i18n = setupI18n('en', enMessages);
|
||||||
|
|
||||||
const story = storiesOf('Components/Conversation/Timestamp', module);
|
const story = storiesOf('Components/Conversation/MessageTimestamp', module);
|
||||||
|
|
||||||
const { now } = Date;
|
const { now } = Date;
|
||||||
const seconds = (n: number) => n * 1000;
|
const seconds = (n: number) => n * 1000;
|
||||||
|
@ -26,14 +26,6 @@ const get1201 = () => {
|
||||||
return d.getTime();
|
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]> => [
|
const times = (): Array<[string, number]> => [
|
||||||
['500ms ago', now() - seconds(0.5)],
|
['500ms ago', now() - seconds(0.5)],
|
||||||
['30s ago', now() - seconds(30)],
|
['30s ago', now() - seconds(30)],
|
||||||
|
@ -42,20 +34,14 @@ const times = (): Array<[string, number]> => [
|
||||||
['45m ago', now() - minutes(45)],
|
['45m ago', now() - minutes(45)],
|
||||||
['1h ago', now() - hours(1)],
|
['1h ago', now() - hours(1)],
|
||||||
['12:01am today', get1201()],
|
['12:01am today', get1201()],
|
||||||
['11:59pm yesterday', get1201() - minutes(2)],
|
|
||||||
['24h ago', now() - hours(24)],
|
['24h ago', now() - hours(24)],
|
||||||
['2d ago', now() - days(2)],
|
|
||||||
['7d ago', now() - days(7)],
|
['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)],
|
['366d ago', now() - days(366)],
|
||||||
];
|
];
|
||||||
|
|
||||||
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
i18n,
|
i18n,
|
||||||
timestamp: overrideProps.timestamp,
|
timestamp: overrideProps.timestamp,
|
||||||
extended: boolean('extended', overrideProps.extended || false),
|
|
||||||
module: text('module', ''),
|
module: text('module', ''),
|
||||||
withImageNoCaption: boolean('withImageNoCaption', false),
|
withImageNoCaption: boolean('withImageNoCaption', false),
|
||||||
withSticker: boolean('withSticker', false),
|
withSticker: boolean('withSticker', false),
|
||||||
|
@ -79,7 +65,7 @@ const createTable = (overrideProps: Partial<Props> = {}) => (
|
||||||
<tr key={timestamp}>
|
<tr key={timestamp}>
|
||||||
<td>{description}</td>
|
<td>{description}</td>
|
||||||
<td>
|
<td>
|
||||||
<Timestamp
|
<MessageTimestamp
|
||||||
key={timestamp}
|
key={timestamp}
|
||||||
{...createProps({ ...overrideProps, timestamp })}
|
{...createProps({ ...overrideProps, timestamp })}
|
||||||
/>
|
/>
|
||||||
|
@ -94,14 +80,10 @@ story.add('Normal', () => {
|
||||||
return createTable();
|
return createTable();
|
||||||
});
|
});
|
||||||
|
|
||||||
story.add('Extended', () => {
|
|
||||||
return createTable({ extended: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
story.add('Knobs', () => {
|
story.add('Knobs', () => {
|
||||||
const props = createProps({
|
const props = createProps({
|
||||||
timestamp: date('timestamp', new Date()),
|
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
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
|
|
||||||
import { formatRelativeTime } from '../../util/formatRelativeTime';
|
import { formatTime } from '../../util/timestamp';
|
||||||
|
|
||||||
import type { LocalizerType } from '../../types/Util';
|
import type { LocalizerType } from '../../types/Util';
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
timestamp?: number;
|
timestamp?: number;
|
||||||
extended?: boolean;
|
|
||||||
module?: string;
|
module?: string;
|
||||||
withImageNoCaption?: boolean;
|
withImageNoCaption?: boolean;
|
||||||
withSticker?: boolean;
|
withSticker?: boolean;
|
||||||
|
@ -22,7 +21,7 @@ export type Props = {
|
||||||
|
|
||||||
const UPDATE_FREQUENCY = 60 * 1000;
|
const UPDATE_FREQUENCY = 60 * 1000;
|
||||||
|
|
||||||
export class Timestamp extends React.Component<Props> {
|
export class MessageTimestamp extends React.Component<Props> {
|
||||||
private interval: NodeJS.Timeout | null;
|
private interval: NodeJS.Timeout | null;
|
||||||
|
|
||||||
constructor(props: Props) {
|
constructor(props: Props) {
|
||||||
|
@ -57,7 +56,6 @@ export class Timestamp extends React.Component<Props> {
|
||||||
withImageNoCaption,
|
withImageNoCaption,
|
||||||
withSticker,
|
withSticker,
|
||||||
withTapToViewExpired,
|
withTapToViewExpired,
|
||||||
extended,
|
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const moduleName = module || 'module-timestamp';
|
const moduleName = module || 'module-timestamp';
|
||||||
|
|
||||||
|
@ -78,7 +76,7 @@ export class Timestamp extends React.Component<Props> {
|
||||||
)}
|
)}
|
||||||
title={moment(timestamp).format('llll')}
|
title={moment(timestamp).format('llll')}
|
||||||
>
|
>
|
||||||
{formatRelativeTime(timestamp, { i18n, extended })}
|
{formatTime(i18n, timestamp)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright 2020-2021 Signal Messenger, LLC
|
// Copyright 2020-2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
@ -20,7 +20,6 @@ import { ConversationHero } from './ConversationHero';
|
||||||
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
|
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
|
||||||
import { getRandomColor } from '../../test-both/helpers/getRandomColor';
|
import { getRandomColor } from '../../test-both/helpers/getRandomColor';
|
||||||
import { LastSeenIndicator } from './LastSeenIndicator';
|
import { LastSeenIndicator } from './LastSeenIndicator';
|
||||||
import { TimelineLoadingRow } from './TimelineLoadingRow';
|
|
||||||
import { TypingBubble } from './TypingBubble';
|
import { TypingBubble } from './TypingBubble';
|
||||||
import { ContactSpoofingType } from '../../util/contactSpoofing';
|
import { ContactSpoofingType } from '../../util/contactSpoofing';
|
||||||
import { ReadStatus } from '../../messages/MessageReadStatus';
|
import { ReadStatus } from '../../messages/MessageReadStatus';
|
||||||
|
@ -62,6 +61,7 @@ const items: Record<string, TimelineItemType> = {
|
||||||
text: '🔥',
|
text: '🔥',
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
},
|
},
|
||||||
|
timestamp: Date.now(),
|
||||||
},
|
},
|
||||||
'id-2': {
|
'id-2': {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
|
@ -82,6 +82,7 @@ const items: Record<string, TimelineItemType> = {
|
||||||
text: 'Hello there from the new world! http://somewhere.com',
|
text: 'Hello there from the new world! http://somewhere.com',
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
},
|
},
|
||||||
|
timestamp: Date.now(),
|
||||||
},
|
},
|
||||||
'id-2.5': {
|
'id-2.5': {
|
||||||
type: 'unsupportedMessage',
|
type: 'unsupportedMessage',
|
||||||
|
@ -95,6 +96,7 @@ const items: Record<string, TimelineItemType> = {
|
||||||
title: 'Mr. Pig',
|
title: 'Mr. Pig',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
timestamp: Date.now(),
|
||||||
},
|
},
|
||||||
'id-3': {
|
'id-3': {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
|
@ -115,6 +117,7 @@ const items: Record<string, TimelineItemType> = {
|
||||||
text: 'Hello there from the new world!',
|
text: 'Hello there from the new world!',
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
},
|
},
|
||||||
|
timestamp: Date.now(),
|
||||||
},
|
},
|
||||||
'id-4': {
|
'id-4': {
|
||||||
type: 'timerNotification',
|
type: 'timerNotification',
|
||||||
|
@ -124,6 +127,7 @@ const items: Record<string, TimelineItemType> = {
|
||||||
title: "It's Me",
|
title: "It's Me",
|
||||||
type: 'fromMe',
|
type: 'fromMe',
|
||||||
},
|
},
|
||||||
|
timestamp: Date.now(),
|
||||||
},
|
},
|
||||||
'id-5': {
|
'id-5': {
|
||||||
type: 'timerNotification',
|
type: 'timerNotification',
|
||||||
|
@ -133,6 +137,7 @@ const items: Record<string, TimelineItemType> = {
|
||||||
title: '(202) 555-0000',
|
title: '(202) 555-0000',
|
||||||
type: 'fromOther',
|
type: 'fromOther',
|
||||||
},
|
},
|
||||||
|
timestamp: Date.now(),
|
||||||
},
|
},
|
||||||
'id-6': {
|
'id-6': {
|
||||||
type: 'safetyNumberNotification',
|
type: 'safetyNumberNotification',
|
||||||
|
@ -143,6 +148,7 @@ const items: Record<string, TimelineItemType> = {
|
||||||
},
|
},
|
||||||
isGroup: true,
|
isGroup: true,
|
||||||
},
|
},
|
||||||
|
timestamp: Date.now(),
|
||||||
},
|
},
|
||||||
'id-7': {
|
'id-7': {
|
||||||
type: 'verificationNotification',
|
type: 'verificationNotification',
|
||||||
|
@ -151,6 +157,7 @@ const items: Record<string, TimelineItemType> = {
|
||||||
isLocal: true,
|
isLocal: true,
|
||||||
type: 'markVerified',
|
type: 'markVerified',
|
||||||
},
|
},
|
||||||
|
timestamp: Date.now(),
|
||||||
},
|
},
|
||||||
'id-8': {
|
'id-8': {
|
||||||
type: 'groupNotification',
|
type: 'groupNotification',
|
||||||
|
@ -180,10 +187,12 @@ const items: Record<string, TimelineItemType> = {
|
||||||
isMe: false,
|
isMe: false,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
timestamp: Date.now(),
|
||||||
},
|
},
|
||||||
'id-9': {
|
'id-9': {
|
||||||
type: 'resetSessionNotification',
|
type: 'resetSessionNotification',
|
||||||
data: null,
|
data: null,
|
||||||
|
timestamp: Date.now(),
|
||||||
},
|
},
|
||||||
'id-10': {
|
'id-10': {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
|
@ -205,6 +214,7 @@ const items: Record<string, TimelineItemType> = {
|
||||||
text: '🔥',
|
text: '🔥',
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
},
|
},
|
||||||
|
timestamp: Date.now(),
|
||||||
},
|
},
|
||||||
'id-11': {
|
'id-11': {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
|
@ -226,6 +236,7 @@ const items: Record<string, TimelineItemType> = {
|
||||||
text: 'Hello there from the new world! http://somewhere.com',
|
text: 'Hello there from the new world! http://somewhere.com',
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
},
|
},
|
||||||
|
timestamp: Date.now(),
|
||||||
},
|
},
|
||||||
'id-12': {
|
'id-12': {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
|
@ -247,6 +258,7 @@ const items: Record<string, TimelineItemType> = {
|
||||||
text: 'Hello there from the new world! 🔥',
|
text: 'Hello there from the new world! 🔥',
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
},
|
},
|
||||||
|
timestamp: Date.now(),
|
||||||
},
|
},
|
||||||
'id-13': {
|
'id-13': {
|
||||||
type: 'message',
|
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.',
|
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(),
|
||||||
},
|
},
|
||||||
|
timestamp: Date.now(),
|
||||||
},
|
},
|
||||||
'id-14': {
|
'id-14': {
|
||||||
type: 'message',
|
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.',
|
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(),
|
||||||
},
|
},
|
||||||
|
timestamp: Date.now(),
|
||||||
},
|
},
|
||||||
'id-15': {
|
'id-15': {
|
||||||
type: 'linkNotification',
|
type: 'linkNotification',
|
||||||
data: null,
|
data: null,
|
||||||
|
timestamp: Date.now(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -375,14 +390,17 @@ const renderItem = ({
|
||||||
messageId,
|
messageId,
|
||||||
containerElementRef,
|
containerElementRef,
|
||||||
containerWidthBreakpoint,
|
containerWidthBreakpoint,
|
||||||
|
isOldestTimelineItem,
|
||||||
}: {
|
}: {
|
||||||
messageId: string;
|
messageId: string;
|
||||||
containerElementRef: React.RefObject<HTMLElement>;
|
containerElementRef: React.RefObject<HTMLElement>;
|
||||||
containerWidthBreakpoint: WidthBreakpoint;
|
containerWidthBreakpoint: WidthBreakpoint;
|
||||||
|
isOldestTimelineItem: boolean;
|
||||||
}) => (
|
}) => (
|
||||||
<TimelineItem
|
<TimelineItem
|
||||||
getPreferredBadge={() => undefined}
|
getPreferredBadge={() => undefined}
|
||||||
id=""
|
id=""
|
||||||
|
isOldestTimelineItem={isOldestTimelineItem}
|
||||||
isSelected={false}
|
isSelected={false}
|
||||||
renderEmojiPicker={() => <div />}
|
renderEmojiPicker={() => <div />}
|
||||||
renderReactionPicker={() => <div />}
|
renderReactionPicker={() => <div />}
|
||||||
|
@ -442,7 +460,6 @@ const renderHeroRow = () => {
|
||||||
};
|
};
|
||||||
return <Wrapper />;
|
return <Wrapper />;
|
||||||
};
|
};
|
||||||
const renderLoadingRow = () => <TimelineLoadingRow state="loading" />;
|
|
||||||
const renderTypingBubble = () => (
|
const renderTypingBubble = () => (
|
||||||
<TypingBubble
|
<TypingBubble
|
||||||
acceptedMessageRequest
|
acceptedMessageRequest
|
||||||
|
@ -463,6 +480,7 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||||
i18n,
|
i18n,
|
||||||
theme: React.useContext(StorybookThemeContext),
|
theme: React.useContext(StorybookThemeContext),
|
||||||
|
|
||||||
|
getTimestampForMessage: Date.now,
|
||||||
haveNewest: boolean('haveNewest', overrideProps.haveNewest !== false),
|
haveNewest: boolean('haveNewest', overrideProps.haveNewest !== false),
|
||||||
haveOldest: boolean('haveOldest', overrideProps.haveOldest !== false),
|
haveOldest: boolean('haveOldest', overrideProps.haveOldest !== false),
|
||||||
isIncomingMessageRequest: boolean(
|
isIncomingMessageRequest: boolean(
|
||||||
|
@ -489,7 +507,6 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||||
renderItem,
|
renderItem,
|
||||||
renderLastSeenIndicator,
|
renderLastSeenIndicator,
|
||||||
renderHeroRow,
|
renderHeroRow,
|
||||||
renderLoadingRow,
|
|
||||||
renderTypingBubble,
|
renderTypingBubble,
|
||||||
typingContactId: overrideProps.typingContactId,
|
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
|
// 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 classNames from 'classnames';
|
||||||
import type { CSSProperties, ReactChild, ReactNode, RefObject } from 'react';
|
import type { CSSProperties, ReactChild, ReactNode, RefObject } from 'react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
@ -38,6 +38,7 @@ import { ContactSpoofingType } from '../../util/contactSpoofing';
|
||||||
import { ContactSpoofingReviewDialog } from './ContactSpoofingReviewDialog';
|
import { ContactSpoofingReviewDialog } from './ContactSpoofingReviewDialog';
|
||||||
import type { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCollisions';
|
import type { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCollisions';
|
||||||
import { hasUnacknowledgedCollisions } from '../../util/groupMemberNameCollisions';
|
import { hasUnacknowledgedCollisions } from '../../util/groupMemberNameCollisions';
|
||||||
|
import { TimelineFloatingHeader } from './TimelineFloatingHeader';
|
||||||
|
|
||||||
const AT_BOTTOM_THRESHOLD = 15;
|
const AT_BOTTOM_THRESHOLD = 15;
|
||||||
const NEAR_BOTTOM_THRESHOLD = 15;
|
const NEAR_BOTTOM_THRESHOLD = 15;
|
||||||
|
@ -104,15 +105,19 @@ type PropsHousekeepingType = {
|
||||||
warning?: WarningType;
|
warning?: WarningType;
|
||||||
contactSpoofingReview?: ContactSpoofingReviewPropType;
|
contactSpoofingReview?: ContactSpoofingReviewPropType;
|
||||||
|
|
||||||
|
getTimestampForMessage: (_: string) => number;
|
||||||
getPreferredBadge: PreferredBadgeSelectorType;
|
getPreferredBadge: PreferredBadgeSelectorType;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
theme: ThemeType;
|
theme: ThemeType;
|
||||||
|
|
||||||
|
areFloatingDateHeadersEnabled?: boolean;
|
||||||
|
|
||||||
renderItem: (props: {
|
renderItem: (props: {
|
||||||
actionProps: PropsActionsType;
|
actionProps: PropsActionsType;
|
||||||
containerElementRef: RefObject<HTMLElement>;
|
containerElementRef: RefObject<HTMLElement>;
|
||||||
containerWidthBreakpoint: WidthBreakpoint;
|
containerWidthBreakpoint: WidthBreakpoint;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
|
isOldestTimelineItem: boolean;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
nextMessageId: undefined | string;
|
nextMessageId: undefined | string;
|
||||||
onHeightChange: (messageId: string) => unknown;
|
onHeightChange: (messageId: string) => unknown;
|
||||||
|
@ -125,7 +130,6 @@ type PropsHousekeepingType = {
|
||||||
unblurAvatar: () => void,
|
unblurAvatar: () => void,
|
||||||
updateSharedGroups: () => unknown
|
updateSharedGroups: () => unknown
|
||||||
) => JSX.Element;
|
) => JSX.Element;
|
||||||
renderLoadingRow: (id: string) => JSX.Element;
|
|
||||||
renderTypingBubble: (id: string) => JSX.Element;
|
renderTypingBubble: (id: string) => JSX.Element;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -195,23 +199,22 @@ type OnScrollParamsType = {
|
||||||
_hasScrolledToRowTarget?: boolean;
|
_hasScrolledToRowTarget?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type VisibleRowsType = {
|
type VisibleRowType = {
|
||||||
newest?: {
|
id: string;
|
||||||
id: string;
|
offsetTop: number;
|
||||||
offsetTop: number;
|
row: number;
|
||||||
row: number;
|
|
||||||
};
|
|
||||||
oldest?: {
|
|
||||||
id: string;
|
|
||||||
offsetTop: number;
|
|
||||||
row: number;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type StateType = {
|
type StateType = {
|
||||||
atBottom: boolean;
|
atBottom: boolean;
|
||||||
atTop: boolean;
|
atTop: boolean;
|
||||||
|
hasRecentlyScrolled: boolean;
|
||||||
oneTimeScrollRow?: number;
|
oneTimeScrollRow?: number;
|
||||||
|
visibleRows?: {
|
||||||
|
newestFullyVisible?: VisibleRowType;
|
||||||
|
oldestPartiallyVisible?: VisibleRowType;
|
||||||
|
oldestFullyVisible?: VisibleRowType;
|
||||||
|
};
|
||||||
|
|
||||||
widthBreakpoint: WidthBreakpoint;
|
widthBreakpoint: WidthBreakpoint;
|
||||||
|
|
||||||
|
@ -295,26 +298,26 @@ const getActions = createSelector(
|
||||||
);
|
);
|
||||||
|
|
||||||
export class Timeline extends React.PureComponent<PropsType, StateType> {
|
export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
public cellSizeCache = new CellMeasurerCache({
|
private cellSizeCache = new CellMeasurerCache({
|
||||||
defaultHeight: 64,
|
defaultHeight: 64,
|
||||||
fixedWidth: true,
|
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 containerRef = React.createRef<HTMLDivElement>();
|
||||||
|
|
||||||
private readonly listRef = React.createRef<List>();
|
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();
|
private containerRefMerger = createRefMerger();
|
||||||
|
|
||||||
|
@ -332,6 +335,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
this.state = {
|
this.state = {
|
||||||
atBottom,
|
atBottom,
|
||||||
atTop: false,
|
atTop: false,
|
||||||
|
hasRecentlyScrolled: true,
|
||||||
oneTimeScrollRow,
|
oneTimeScrollRow,
|
||||||
propScrollToIndex: scrollToIndex,
|
propScrollToIndex: scrollToIndex,
|
||||||
prevPropScrollToIndex: scrollToIndex,
|
prevPropScrollToIndex: scrollToIndex,
|
||||||
|
@ -364,7 +368,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getList = (): List | null => {
|
private getList = (): List | null => {
|
||||||
if (!this.listRef) {
|
if (!this.listRef) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -374,7 +378,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
return current;
|
return current;
|
||||||
};
|
};
|
||||||
|
|
||||||
public getGrid = (): Grid | undefined => {
|
private getGrid = (): Grid | undefined => {
|
||||||
const list = this.getList();
|
const list = this.getList();
|
||||||
if (!list) {
|
if (!list) {
|
||||||
return;
|
return;
|
||||||
|
@ -383,9 +387,9 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
return list.Grid;
|
return list.Grid;
|
||||||
};
|
};
|
||||||
|
|
||||||
public getScrollContainer = (): HTMLDivElement | undefined => {
|
private getScrollContainer = (): HTMLDivElement | undefined => {
|
||||||
// We're using an internal variable (_scrollingContainer)) here,
|
// 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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const grid: any = this.getGrid();
|
const grid: any = this.getGrid();
|
||||||
if (!grid) {
|
if (!grid) {
|
||||||
|
@ -395,16 +399,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
return grid._scrollingContainer as HTMLDivElement;
|
return grid._scrollingContainer as HTMLDivElement;
|
||||||
};
|
};
|
||||||
|
|
||||||
public scrollToRow = (row: number): void => {
|
private recomputeRowHeights = (row?: number): void => {
|
||||||
const list = this.getList();
|
|
||||||
if (!list) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
list.scrollToRow(row);
|
|
||||||
};
|
|
||||||
|
|
||||||
public recomputeRowHeights = (row?: number): void => {
|
|
||||||
const list = this.getList();
|
const list = this.getList();
|
||||||
if (!list) {
|
if (!list) {
|
||||||
return;
|
return;
|
||||||
|
@ -413,7 +408,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
list.recomputeRowHeights(row);
|
list.recomputeRowHeights(row);
|
||||||
};
|
};
|
||||||
|
|
||||||
public onHeightOnlyChange = (): void => {
|
private onHeightOnlyChange = (): void => {
|
||||||
const grid = this.getGrid();
|
const grid = this.getGrid();
|
||||||
const scrollContainer = this.getScrollContainer();
|
const scrollContainer = this.getScrollContainer();
|
||||||
if (!grid || !scrollContainer) {
|
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.offsetFromBottom = undefined;
|
||||||
this.resizeFlag = false;
|
this.resizeFlag = false;
|
||||||
if (isNumber(row) && row > 0) {
|
if (isNumber(row) && row > 0) {
|
||||||
|
@ -452,11 +447,11 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
this.recomputeRowHeights(row || 0);
|
this.recomputeRowHeights(row || 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
public resizeHeroRow = (): void => {
|
private resizeHeroRow = (): void => {
|
||||||
this.resize(0);
|
this.resize(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
public resizeMessage = (messageId: string): void => {
|
private resizeMessage = (messageId: string): void => {
|
||||||
const { items } = this.props;
|
const { items } = this.props;
|
||||||
|
|
||||||
if (!items || !items.length) {
|
if (!items || !items.length) {
|
||||||
|
@ -472,7 +467,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
this.resize(row);
|
this.resize(row);
|
||||||
};
|
};
|
||||||
|
|
||||||
public onScroll = (data: OnScrollParamsType): void => {
|
private onScroll = (data: OnScrollParamsType): void => {
|
||||||
// Ignore scroll events generated as react-virtualized recursively scrolls and
|
// Ignore scroll events generated as react-virtualized recursively scrolls and
|
||||||
// re-measures to get us where we want to go.
|
// re-measures to get us where we want to go.
|
||||||
if (
|
if (
|
||||||
|
@ -492,11 +487,28 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.setState({ hasRecentlyScrolled: true });
|
||||||
|
if (this.hasRecentlyScrolledTimeout) {
|
||||||
|
clearTimeout(this.hasRecentlyScrolledTimeout);
|
||||||
|
}
|
||||||
|
this.hasRecentlyScrolledTimeout = setTimeout(() => {
|
||||||
|
this.setState({ hasRecentlyScrolled: false });
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
this.updateScrollMetrics(data);
|
this.updateScrollMetrics(data);
|
||||||
this.updateWithVisibleRows();
|
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) => {
|
(data: OnScrollParamsType) => {
|
||||||
const { clientHeight, clientWidth, scrollHeight, scrollTop } = data;
|
const { clientHeight, clientWidth, scrollHeight, scrollTop } = data;
|
||||||
|
|
||||||
|
@ -576,10 +588,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
{ maxWait: 50 }
|
{ maxWait: 50 }
|
||||||
);
|
);
|
||||||
|
|
||||||
public updateVisibleRows = (): void => {
|
private updateVisibleRows = (): void => {
|
||||||
let newest;
|
|
||||||
let oldest;
|
|
||||||
|
|
||||||
const scrollContainer = this.getScrollContainer();
|
const scrollContainer = this.getScrollContainer();
|
||||||
if (!scrollContainer) {
|
if (!scrollContainer) {
|
||||||
return;
|
return;
|
||||||
|
@ -589,15 +598,18 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleTop = scrollContainer.scrollTop;
|
|
||||||
const visibleBottom = visibleTop + scrollContainer.clientHeight;
|
|
||||||
|
|
||||||
const innerScrollContainer = scrollContainer.children[0];
|
const innerScrollContainer = scrollContainer.children[0];
|
||||||
if (!innerScrollContainer) {
|
if (!innerScrollContainer) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let newestFullyVisible: undefined | VisibleRowType;
|
||||||
|
let oldestPartiallyVisible: undefined | VisibleRowType;
|
||||||
|
let oldestFullyVisible: undefined | VisibleRowType;
|
||||||
|
|
||||||
const { children } = innerScrollContainer;
|
const { children } = innerScrollContainer;
|
||||||
|
const visibleTop = scrollContainer.scrollTop;
|
||||||
|
const visibleBottom = visibleTop + scrollContainer.clientHeight;
|
||||||
|
|
||||||
for (let i = children.length - 1; i >= 0; i -= 1) {
|
for (let i = children.length - 1; i >= 0; i -= 1) {
|
||||||
const child = children[i] as HTMLDivElement;
|
const child = children[i] as HTMLDivElement;
|
||||||
|
@ -611,7 +623,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
|
|
||||||
if (bottom - AT_BOTTOM_THRESHOLD <= visibleBottom) {
|
if (bottom - AT_BOTTOM_THRESHOLD <= visibleBottom) {
|
||||||
const row = parseInt(child.getAttribute('data-row') || '-1', 10);
|
const row = parseInt(child.getAttribute('data-row') || '-1', 10);
|
||||||
newest = { offsetTop, row, id };
|
newestFullyVisible = { offsetTop, row, id };
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -620,24 +632,45 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
const max = children.length;
|
const max = children.length;
|
||||||
for (let i = 0; i < max; i += 1) {
|
for (let i = 0; i < max; i += 1) {
|
||||||
const child = children[i] as HTMLDivElement;
|
const child = children[i] as HTMLDivElement;
|
||||||
const { offsetTop, id } = child;
|
const { id, offsetTop, offsetHeight } = child;
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (offsetTop + AT_TOP_THRESHOLD >= visibleTop) {
|
const thisRow = {
|
||||||
const row = parseInt(child.getAttribute('data-row') || '-1', 10);
|
offsetTop,
|
||||||
oldest = { offsetTop, row, id };
|
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;
|
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 {
|
const {
|
||||||
unreadCount,
|
unreadCount,
|
||||||
|
@ -654,16 +687,17 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updateVisibleRows();
|
this.updateVisibleRows();
|
||||||
if (!this.visibleRows) {
|
const { visibleRows } = this.state;
|
||||||
|
if (!visibleRows) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { newest, oldest } = this.visibleRows;
|
const { newestFullyVisible, oldestFullyVisible } = visibleRows;
|
||||||
if (!newest) {
|
if (!newestFullyVisible) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
markMessageRead(newest.id);
|
markMessageRead(newestFullyVisible.id);
|
||||||
|
|
||||||
const newestRow = this.getRowCount() - 1;
|
const newestRow = this.getRowCount() - 1;
|
||||||
const oldestRow = this.fromItemIndexToRow(0);
|
const oldestRow = this.fromItemIndexToRow(0);
|
||||||
|
@ -673,7 +707,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
if (
|
if (
|
||||||
!isLoadingMessages &&
|
!isLoadingMessages &&
|
||||||
!haveNewest &&
|
!haveNewest &&
|
||||||
newest.row > newestRow - LOAD_MORE_THRESHOLD
|
newestFullyVisible.row > newestRow - LOAD_MORE_THRESHOLD
|
||||||
) {
|
) {
|
||||||
const lastId = items[items.length - 1];
|
const lastId = items[items.length - 1];
|
||||||
loadNewerMessages(lastId);
|
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
|
// 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
|
// this is a special-case for the situation where the window is so large and that
|
||||||
// all the messages are visible.
|
// all the messages are visible.
|
||||||
const oldestVisible = Boolean(oldest && oldestRow === oldest.row);
|
const oldestVisible = Boolean(
|
||||||
const newestVisible = newestRow === newest.row;
|
oldestFullyVisible && oldestRow === oldestFullyVisible.row
|
||||||
|
);
|
||||||
|
const newestVisible = newestRow === newestFullyVisible.row;
|
||||||
if (oldestVisible && newestVisible && !haveOldest) {
|
if (oldestVisible && newestVisible && !haveOldest) {
|
||||||
this.loadOlderMessages();
|
this.loadOlderMessages();
|
||||||
}
|
}
|
||||||
|
@ -695,13 +731,13 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
const areUnreadBelowCurrentPosition = Boolean(
|
const areUnreadBelowCurrentPosition = Boolean(
|
||||||
isNumber(unreadCount) &&
|
isNumber(unreadCount) &&
|
||||||
unreadCount > 0 &&
|
unreadCount > 0 &&
|
||||||
(!haveNewest || newest.row < lastItemRow)
|
(!haveNewest || newestFullyVisible.row < lastItemRow)
|
||||||
);
|
);
|
||||||
|
|
||||||
const shouldShowScrollDownButton = Boolean(
|
const shouldShowScrollDownButton = Boolean(
|
||||||
!haveNewest ||
|
!haveNewest ||
|
||||||
areUnreadBelowCurrentPosition ||
|
areUnreadBelowCurrentPosition ||
|
||||||
newest.row < newestRow - SCROLL_DOWN_BUTTON_THRESHOLD
|
newestFullyVisible.row < newestRow - SCROLL_DOWN_BUTTON_THRESHOLD
|
||||||
);
|
);
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
|
@ -713,7 +749,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
{ maxWait: 500 }
|
{ maxWait: 500 }
|
||||||
);
|
);
|
||||||
|
|
||||||
public loadOlderMessages = (): void => {
|
private loadOlderMessages = (): void => {
|
||||||
const { haveOldest, isLoadingMessages, items, loadOlderMessages } =
|
const { haveOldest, isLoadingMessages, items, loadOlderMessages } =
|
||||||
this.props;
|
this.props;
|
||||||
|
|
||||||
|
@ -730,7 +766,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
loadOlderMessages(oldestId);
|
loadOlderMessages(oldestId);
|
||||||
};
|
};
|
||||||
|
|
||||||
public rowRenderer = ({
|
private rowRenderer = ({
|
||||||
index,
|
index,
|
||||||
key,
|
key,
|
||||||
parent,
|
parent,
|
||||||
|
@ -743,7 +779,6 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
items,
|
items,
|
||||||
renderItem,
|
renderItem,
|
||||||
renderHeroRow,
|
renderHeroRow,
|
||||||
renderLoadingRow,
|
|
||||||
renderLastSeenIndicator,
|
renderLastSeenIndicator,
|
||||||
renderTypingBubble,
|
renderTypingBubble,
|
||||||
unblurAvatar,
|
unblurAvatar,
|
||||||
|
@ -774,12 +809,6 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (!haveOldest && row === 0) {
|
|
||||||
rowContents = (
|
|
||||||
<div data-row={row} style={styleWithWidth} role="row">
|
|
||||||
{renderLoadingRow(id)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else if (oldestUnreadRow === row) {
|
} else if (oldestUnreadRow === row) {
|
||||||
rowContents = (
|
rowContents = (
|
||||||
<div data-row={row} style={styleWithWidth} role="row">
|
<div data-row={row} style={styleWithWidth} role="row">
|
||||||
|
@ -824,6 +853,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
containerElementRef: this.containerRef,
|
containerElementRef: this.containerRef,
|
||||||
containerWidthBreakpoint: widthBreakpoint,
|
containerWidthBreakpoint: widthBreakpoint,
|
||||||
conversationId: id,
|
conversationId: id,
|
||||||
|
isOldestTimelineItem: haveOldest && itemIndex === 0,
|
||||||
messageId,
|
messageId,
|
||||||
nextMessageId,
|
nextMessageId,
|
||||||
onHeightChange: this.resizeMessage,
|
onHeightChange: this.resizeMessage,
|
||||||
|
@ -848,62 +878,73 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
public fromItemIndexToRow(index: number): number {
|
private fromItemIndexToRow(index: number): number {
|
||||||
const { oldestUnreadIndex } = this.props;
|
const { haveOldest, oldestUnreadIndex } = this.props;
|
||||||
|
|
||||||
// We will always render either the hero row or the loading row
|
let result = index;
|
||||||
let addition = 1;
|
|
||||||
|
|
||||||
|
// Hero row
|
||||||
|
if (haveOldest) {
|
||||||
|
result += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unread indicator
|
||||||
if (isNumber(oldestUnreadIndex) && index >= oldestUnreadIndex) {
|
if (isNumber(oldestUnreadIndex) && index >= oldestUnreadIndex) {
|
||||||
addition += 1;
|
result += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return index + addition;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getRowCount(): number {
|
private getRowCount(): number {
|
||||||
const { oldestUnreadIndex, typingContactId } = this.props;
|
const { haveOldest, items, oldestUnreadIndex, typingContactId } =
|
||||||
const { items } = this.props;
|
this.props;
|
||||||
const itemsCount = items && items.length ? items.length : 0;
|
|
||||||
|
|
||||||
// We will always render either the hero row or the loading row
|
let result = items?.length || 0;
|
||||||
let extraRows = 1;
|
|
||||||
|
|
||||||
|
// Hero row
|
||||||
|
if (haveOldest) {
|
||||||
|
result += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unread indicator
|
||||||
if (isNumber(oldestUnreadIndex)) {
|
if (isNumber(oldestUnreadIndex)) {
|
||||||
extraRows += 1;
|
result += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Typing indicator
|
||||||
if (typingContactId) {
|
if (typingContactId) {
|
||||||
extraRows += 1;
|
result += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return itemsCount + extraRows;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public fromRowToItemIndex(
|
private fromRowToItemIndex(row: number): number | undefined {
|
||||||
row: number,
|
const { haveOldest, items } = this.props;
|
||||||
props?: PropsType
|
|
||||||
): number | undefined {
|
|
||||||
const { items } = props || this.props;
|
|
||||||
|
|
||||||
// We will always render either the hero row or the loading row
|
let result = row;
|
||||||
let subtraction = 1;
|
|
||||||
|
|
||||||
|
// Hero row
|
||||||
|
if (haveOldest) {
|
||||||
|
result -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unread indicator
|
||||||
const oldestUnreadRow = this.getLastSeenIndicatorRow();
|
const oldestUnreadRow = this.getLastSeenIndicatorRow();
|
||||||
if (isNumber(oldestUnreadRow) && row > oldestUnreadRow) {
|
if (isNumber(oldestUnreadRow) && row > oldestUnreadRow) {
|
||||||
subtraction += 1;
|
result -= 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const index = row - subtraction;
|
if (result < 0 || result >= items.length) {
|
||||||
if (index < 0 || index >= items.length) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return index;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getLastSeenIndicatorRow(props?: PropsType): number | undefined {
|
private getLastSeenIndicatorRow(): number | undefined {
|
||||||
const { oldestUnreadIndex } = props || this.props;
|
const { oldestUnreadIndex } = this.props;
|
||||||
if (!isNumber(oldestUnreadIndex)) {
|
if (!isNumber(oldestUnreadIndex)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -911,7 +952,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
return this.fromItemIndexToRow(oldestUnreadIndex) - 1;
|
return this.fromItemIndexToRow(oldestUnreadIndex) - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getTypingBubbleRow(): number | undefined {
|
private getTypingBubbleRow(): number | undefined {
|
||||||
const { items } = this.props;
|
const { items } = this.props;
|
||||||
if (!items || items.length < 0) {
|
if (!items || items.length < 0) {
|
||||||
return;
|
return;
|
||||||
|
@ -922,23 +963,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
return this.fromItemIndexToRow(last) + 1;
|
return this.fromItemIndexToRow(last) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
public onScrollToMessage = (messageId: string): void => {
|
private scrollToBottom = (setFocus?: boolean): 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 => {
|
|
||||||
const { selectMessage, id, items } = this.props;
|
const { selectMessage, id, items } = this.props;
|
||||||
|
|
||||||
if (setFocus && items && items.length > 0) {
|
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);
|
this.scrollDown(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
public scrollDown = (setFocus?: boolean): void => {
|
private scrollDown = (setFocus?: boolean): void => {
|
||||||
const {
|
const {
|
||||||
haveNewest,
|
haveNewest,
|
||||||
id,
|
id,
|
||||||
|
@ -977,7 +1002,8 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
const lastId = items[items.length - 1];
|
const lastId = items[items.length - 1];
|
||||||
const lastSeenIndicatorRow = this.getLastSeenIndicatorRow();
|
const lastSeenIndicatorRow = this.getLastSeenIndicatorRow();
|
||||||
|
|
||||||
if (!this.visibleRows) {
|
const { visibleRows } = this.state;
|
||||||
|
if (!visibleRows) {
|
||||||
if (haveNewest) {
|
if (haveNewest) {
|
||||||
this.scrollToBottom(setFocus);
|
this.scrollToBottom(setFocus);
|
||||||
} else if (!isLoadingMessages) {
|
} else if (!isLoadingMessages) {
|
||||||
|
@ -987,12 +1013,12 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { newest } = this.visibleRows;
|
const { newestFullyVisible } = visibleRows;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
newest &&
|
newestFullyVisible &&
|
||||||
isNumber(lastSeenIndicatorRow) &&
|
isNumber(lastSeenIndicatorRow) &&
|
||||||
newest.row < lastSeenIndicatorRow
|
newestFullyVisible.row < lastSeenIndicatorRow
|
||||||
) {
|
) {
|
||||||
if (setFocus && isNumber(oldestUnreadIndex)) {
|
if (setFocus && isNumber(oldestUnreadIndex)) {
|
||||||
const messageId = items[oldestUnreadIndex];
|
const messageId = items[oldestUnreadIndex];
|
||||||
|
@ -1112,8 +1138,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const newRow = this.fromItemIndexToRow(newFirstIndex);
|
const newRow = this.fromItemIndexToRow(newFirstIndex);
|
||||||
const delta = newFirstIndex - oldFirstIndex;
|
if (newRow > 0) {
|
||||||
if (delta > 0) {
|
|
||||||
// We're loading more new messages at the top; we want to stay at the top
|
// We're loading more new messages at the top; we want to stay at the top
|
||||||
this.resize();
|
this.resize();
|
||||||
// TODO: DESKTOP-688
|
// TODO: DESKTOP-688
|
||||||
|
@ -1188,7 +1213,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
this.updateWithVisibleRows();
|
this.updateWithVisibleRows();
|
||||||
}
|
}
|
||||||
|
|
||||||
public getScrollTarget = (): number | undefined => {
|
private getScrollTarget = (): number | undefined => {
|
||||||
const { oneTimeScrollRow, atBottom, propScrollToIndex } = this.state;
|
const { oneTimeScrollRow, atBottom, propScrollToIndex } = this.state;
|
||||||
|
|
||||||
const rowCount = this.getRowCount();
|
const rowCount = this.getRowCount();
|
||||||
|
@ -1208,7 +1233,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
return scrollToBottom;
|
return scrollToBottom;
|
||||||
};
|
};
|
||||||
|
|
||||||
public handleBlur = (event: React.FocusEvent): void => {
|
private handleBlur = (event: React.FocusEvent): void => {
|
||||||
const { clearSelectedMessage } = this.props;
|
const { clearSelectedMessage } = this.props;
|
||||||
|
|
||||||
const { currentTarget } = event;
|
const { currentTarget } = event;
|
||||||
|
@ -1232,7 +1257,9 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
}, 0);
|
}, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
public handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
|
private handleKeyDown = (
|
||||||
|
event: React.KeyboardEvent<HTMLDivElement>
|
||||||
|
): void => {
|
||||||
const { selectMessage, selectedMessageId, items, id } = this.props;
|
const { selectMessage, selectedMessageId, items, id } = this.props;
|
||||||
const commandKey = get(window, 'platform') === 'darwin' && event.metaKey;
|
const commandKey = get(window, 'platform') === 'darwin' && event.metaKey;
|
||||||
const controlKey = get(window, 'platform') !== 'darwin' && event.ctrlKey;
|
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 {
|
public override render(): JSX.Element | null {
|
||||||
const {
|
const {
|
||||||
acknowledgeGroupMemberNameCollisions,
|
acknowledgeGroupMemberNameCollisions,
|
||||||
|
areFloatingDateHeadersEnabled = true,
|
||||||
areWeAdmin,
|
areWeAdmin,
|
||||||
clearInvitedUuidsForNewlyCreatedGroup,
|
clearInvitedUuidsForNewlyCreatedGroup,
|
||||||
closeContactSpoofingReview,
|
closeContactSpoofingReview,
|
||||||
contactSpoofingReview,
|
contactSpoofingReview,
|
||||||
getPreferredBadge,
|
getPreferredBadge,
|
||||||
|
getTimestampForMessage,
|
||||||
|
haveOldest,
|
||||||
i18n,
|
i18n,
|
||||||
id,
|
id,
|
||||||
invitedContactsForNewlyCreatedGroup,
|
invitedContactsForNewlyCreatedGroup,
|
||||||
isGroupV1AndDisabled,
|
isGroupV1AndDisabled,
|
||||||
|
isLoadingMessages,
|
||||||
items,
|
items,
|
||||||
onBlock,
|
onBlock,
|
||||||
onBlockAndReportSpam,
|
onBlockAndReportSpam,
|
||||||
|
@ -1332,6 +1363,9 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
const {
|
const {
|
||||||
shouldShowScrollDownButton,
|
shouldShowScrollDownButton,
|
||||||
areUnreadBelowCurrentPosition,
|
areUnreadBelowCurrentPosition,
|
||||||
|
hasRecentlyScrolled,
|
||||||
|
lastMeasuredWarningHeight,
|
||||||
|
visibleRows,
|
||||||
widthBreakpoint,
|
widthBreakpoint,
|
||||||
} = this.state;
|
} = this.state;
|
||||||
|
|
||||||
|
@ -1342,6 +1376,27 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
return null;
|
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 = (
|
const autoSizer = (
|
||||||
<AutoSizer>
|
<AutoSizer>
|
||||||
{({ height, width }) => {
|
{({ height, width }) => {
|
||||||
|
@ -1366,6 +1421,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
onScroll={this.onScroll as any}
|
onScroll={this.onScroll as any}
|
||||||
overscanRowCount={10}
|
overscanRowCount={10}
|
||||||
|
onRowsRendered={this.onRowsRendered}
|
||||||
ref={this.listRef}
|
ref={this.listRef}
|
||||||
rowCount={rowCount}
|
rowCount={rowCount}
|
||||||
rowHeight={this.cellSizeCache.rowHeight}
|
rowHeight={this.cellSizeCache.rowHeight}
|
||||||
|
@ -1549,6 +1605,8 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
>
|
>
|
||||||
{timelineWarning}
|
{timelineWarning}
|
||||||
|
|
||||||
|
{floatingHeader}
|
||||||
|
|
||||||
{autoSizer}
|
{autoSizer}
|
||||||
|
|
||||||
{shouldShowScrollDownButton ? (
|
{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
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
@ -53,6 +53,7 @@ const getDefaultProps = () => ({
|
||||||
conversationId: 'conversation-id',
|
conversationId: 'conversation-id',
|
||||||
getPreferredBadge: () => undefined,
|
getPreferredBadge: () => undefined,
|
||||||
id: 'asdf',
|
id: 'asdf',
|
||||||
|
isOldestTimelineItem: false,
|
||||||
isSelected: false,
|
isSelected: false,
|
||||||
interactionMode: 'keyboard' as const,
|
interactionMode: 'keyboard' as const,
|
||||||
theme: ThemeType.light,
|
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
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import type { RefObject } from 'react';
|
import type { ReactChild, RefObject } from 'react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { omit } from 'lodash';
|
import { omit } from 'lodash';
|
||||||
|
import moment from 'moment';
|
||||||
|
|
||||||
import type { LocalizerType, ThemeType } from '../../types/Util';
|
import type { LocalizerType, ThemeType } from '../../types/Util';
|
||||||
|
|
||||||
import type { InteractionModeType } from '../../state/ducks/conversations';
|
import type { InteractionModeType } from '../../state/ducks/conversations';
|
||||||
|
import { TimelineDateHeader } from './TimelineDateHeader';
|
||||||
import type {
|
import type {
|
||||||
Props as AllMessageProps,
|
Props as AllMessageProps,
|
||||||
PropsActions as MessageActionsType,
|
PropsActions as MessageActionsType,
|
||||||
|
@ -120,7 +122,7 @@ type ProfileChangeNotificationType = {
|
||||||
data: ProfileChangeNotificationPropsType;
|
data: ProfileChangeNotificationPropsType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TimelineItemType =
|
export type TimelineItemType = (
|
||||||
| CallHistoryType
|
| CallHistoryType
|
||||||
| ChatSessionRefreshedType
|
| ChatSessionRefreshedType
|
||||||
| DeliveryIssueType
|
| DeliveryIssueType
|
||||||
|
@ -136,7 +138,8 @@ export type TimelineItemType =
|
||||||
| UniversalTimerNotificationType
|
| UniversalTimerNotificationType
|
||||||
| ChangeNumberNotificationType
|
| ChangeNumberNotificationType
|
||||||
| UnsupportedMessageType
|
| UnsupportedMessageType
|
||||||
| VerificationNotificationType;
|
| VerificationNotificationType
|
||||||
|
) & { timestamp: number };
|
||||||
|
|
||||||
type PropsLocalType = {
|
type PropsLocalType = {
|
||||||
containerElementRef: RefObject<HTMLElement>;
|
containerElementRef: RefObject<HTMLElement>;
|
||||||
|
@ -149,6 +152,7 @@ type PropsLocalType = {
|
||||||
renderUniversalTimerNotification: () => JSX.Element;
|
renderUniversalTimerNotification: () => JSX.Element;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
interactionMode: InteractionModeType;
|
interactionMode: InteractionModeType;
|
||||||
|
isOldestTimelineItem: boolean;
|
||||||
theme: ThemeType;
|
theme: ThemeType;
|
||||||
previousItem: undefined | TimelineItemType;
|
previousItem: undefined | TimelineItemType;
|
||||||
nextItem: undefined | TimelineItemType;
|
nextItem: undefined | TimelineItemType;
|
||||||
|
@ -179,12 +183,14 @@ export class TimelineItem extends React.PureComponent<PropsType> {
|
||||||
conversationId,
|
conversationId,
|
||||||
getPreferredBadge,
|
getPreferredBadge,
|
||||||
id,
|
id,
|
||||||
|
isOldestTimelineItem,
|
||||||
isSelected,
|
isSelected,
|
||||||
item,
|
item,
|
||||||
i18n,
|
i18n,
|
||||||
theme,
|
theme,
|
||||||
messageSizeChanged,
|
messageSizeChanged,
|
||||||
nextItem,
|
nextItem,
|
||||||
|
previousItem,
|
||||||
renderContact,
|
renderContact,
|
||||||
renderUniversalTimerNotification,
|
renderUniversalTimerNotification,
|
||||||
returnToActiveCall,
|
returnToActiveCall,
|
||||||
|
@ -198,8 +204,9 @@ export class TimelineItem extends React.PureComponent<PropsType> {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let itemContents: ReactChild;
|
||||||
if (item.type === 'message') {
|
if (item.type === 'message') {
|
||||||
return (
|
itemContents = (
|
||||||
<Message
|
<Message
|
||||||
{...omit(this.props, ['item'])}
|
{...omit(this.props, ['item'])}
|
||||||
{...item.data}
|
{...item.data}
|
||||||
|
@ -210,101 +217,143 @@ export class TimelineItem extends React.PureComponent<PropsType> {
|
||||||
renderingContext="conversation/TimelineItem"
|
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 {
|
} else {
|
||||||
// Weird, yes, but the idea is to get a compile error when we aren't comprehensive
|
let notification;
|
||||||
// with our if/else checks above, but also log out the type we don't understand if
|
|
||||||
// we encounter it at runtime.
|
if (item.type === 'unsupportedMessage') {
|
||||||
const unknownItem: never = item;
|
notification = (
|
||||||
const asItem = unknownItem as TimelineItemType;
|
<UnsupportedMessage {...this.props} {...item.data} i18n={i18n} />
|
||||||
throw new Error(`TimelineItem: Unknown type: ${asItem.type}`);
|
);
|
||||||
|
} 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 (
|
const shouldRenderDateHeader =
|
||||||
<InlineNotificationWrapper
|
isOldestTimelineItem ||
|
||||||
id={id}
|
Boolean(
|
||||||
conversationId={conversationId}
|
previousItem &&
|
||||||
isSelected={isSelected}
|
// This comparison avoids strange header behavior for out-of-order messages.
|
||||||
selectMessage={selectMessage}
|
item.timestamp > previousItem.timestamp &&
|
||||||
>
|
!moment(previousItem.timestamp).isSame(item.timestamp, 'day')
|
||||||
{notification}
|
);
|
||||||
</InlineNotificationWrapper>
|
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
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import type { ReactNode, FunctionComponent } from 'react';
|
import type { ReactNode, FunctionComponent } from 'react';
|
||||||
|
@ -9,12 +9,12 @@ import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
import { Avatar, AvatarSize } from '../Avatar';
|
import { Avatar, AvatarSize } from '../Avatar';
|
||||||
import type { BadgeType } from '../../badges/types';
|
import type { BadgeType } from '../../badges/types';
|
||||||
import { Timestamp } from '../conversation/Timestamp';
|
|
||||||
import { isConversationUnread } from '../../util/isConversationUnread';
|
import { isConversationUnread } from '../../util/isConversationUnread';
|
||||||
import { cleanId } from '../_util';
|
import { cleanId } from '../_util';
|
||||||
import type { LocalizerType, ThemeType } from '../../types/Util';
|
import type { LocalizerType, ThemeType } from '../../types/Util';
|
||||||
import type { ConversationType } from '../../state/ducks/conversations';
|
import type { ConversationType } from '../../state/ducks/conversations';
|
||||||
import { Spinner } from '../Spinner';
|
import { Spinner } from '../Spinner';
|
||||||
|
import { formatDateTimeShort } from '../../util/timestamp';
|
||||||
|
|
||||||
const BASE_CLASS_NAME =
|
const BASE_CLASS_NAME =
|
||||||
'module-conversation-list__item--contact-or-conversation';
|
'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_NAME_CLASS_NAME = `${HEADER_CLASS_NAME}__name`;
|
||||||
export const HEADER_CONTACT_NAME_CLASS_NAME = `${HEADER_NAME_CLASS_NAME}__contact-name`;
|
export const HEADER_CONTACT_NAME_CLASS_NAME = `${HEADER_NAME_CLASS_NAME}__contact-name`;
|
||||||
export const DATE_CLASS_NAME = `${HEADER_CLASS_NAME}__date`;
|
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`;
|
const MESSAGE_CLASS_NAME = `${CONTENT_CLASS_NAME}__message`;
|
||||||
export const MESSAGE_TEXT_CLASS_NAME = `${MESSAGE_CLASS_NAME}__text`;
|
export const MESSAGE_TEXT_CLASS_NAME = `${MESSAGE_CLASS_NAME}__text`;
|
||||||
const CHECKBOX_CLASS_NAME = `${BASE_CLASS_NAME}__checkbox`;
|
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}>
|
||||||
<div className={`${HEADER_CLASS_NAME}__name`}>{headerName}</div>
|
<div className={`${HEADER_CLASS_NAME}__name`}>{headerName}</div>
|
||||||
{isNumber(headerDate) && (
|
<Timestamp timestamp={headerDate} i18n={i18n} />
|
||||||
<div className={DATE_CLASS_NAME}>
|
|
||||||
<Timestamp
|
|
||||||
timestamp={headerDate}
|
|
||||||
extended={false}
|
|
||||||
module={TIMESTAMP_CLASS_NAME}
|
|
||||||
i18n={i18n}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{messageText || isUnread ? (
|
{messageText || isUnread ? (
|
||||||
<div className={MESSAGE_CLASS_NAME}>
|
<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({
|
function UnreadIndicator({
|
||||||
count = 0,
|
count = 0,
|
||||||
isUnread,
|
isUnread,
|
||||||
|
|
|
@ -213,6 +213,9 @@ export class ConversationModel extends window.Backbone
|
||||||
|
|
||||||
private isInReduxBatch = false;
|
private isInReduxBatch = false;
|
||||||
|
|
||||||
|
// This number is recorded as an optimization and may be out of date.
|
||||||
|
private newestReceivedAtMarkedRead?: number;
|
||||||
|
|
||||||
override defaults(): Partial<ConversationAttributesType> {
|
override defaults(): Partial<ConversationAttributesType> {
|
||||||
return {
|
return {
|
||||||
unreadCount: 0,
|
unreadCount: 0,
|
||||||
|
@ -4572,6 +4575,15 @@ export class ConversationModel extends window.Backbone
|
||||||
sendReadReceipts: true,
|
sendReadReceipts: true,
|
||||||
}
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
// This early return is an optimization, not a guarantee.
|
||||||
|
const { newestReceivedAtMarkedRead } = this;
|
||||||
|
if (
|
||||||
|
typeof newestReceivedAtMarkedRead === 'number' &&
|
||||||
|
newestUnreadAt <= newestReceivedAtMarkedRead
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await markConversationRead(this.attributes, newestUnreadAt, options);
|
await markConversationRead(this.attributes, newestUnreadAt, options);
|
||||||
|
|
||||||
const unreadCount = await window.Signal.Data.getTotalUnreadForConversation(
|
const unreadCount = await window.Signal.Data.getTotalUnreadForConversation(
|
||||||
|
@ -4583,6 +4595,8 @@ export class ConversationModel extends window.Backbone
|
||||||
this.set({ unreadCount });
|
this.set({ unreadCount });
|
||||||
window.Signal.Data.updateConversation(this.attributes);
|
window.Signal.Data.updateConversation(this.attributes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.newestReceivedAtMarkedRead = newestUnreadAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
// This is an expensive operation we use to populate the message request hero row. It
|
// This is an expensive operation we use to populate the message request hero row. It
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright 2019-2020 Signal Messenger, LLC
|
// Copyright 2019-2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { trigger } from '../../shims/events';
|
import { trigger } from '../../shims/events';
|
||||||
|
@ -23,6 +23,7 @@ export type UserStateType = {
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
interactionMode: 'mouse' | 'keyboard';
|
interactionMode: 'mouse' | 'keyboard';
|
||||||
theme: ThemeType;
|
theme: ThemeType;
|
||||||
|
version: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
|
@ -98,6 +99,7 @@ export function getEmptyState(): UserStateType {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
version: '0.0.0',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
// Copyright 2019-2021 Signal Messenger, LLC
|
// Copyright 2019-2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
import { isInteger } from 'lodash';
|
import { isInteger } from 'lodash';
|
||||||
|
|
||||||
import { ITEM_NAME as UNIVERSAL_EXPIRE_TIMER_ITEM } from '../../util/universalExpireTimer';
|
import { ITEM_NAME as UNIVERSAL_EXPIRE_TIMER_ITEM } from '../../util/universalExpireTimer';
|
||||||
import type { ConfigMapType } from '../../RemoteConfig';
|
import type { ConfigKeyType, ConfigMapType } from '../../RemoteConfig';
|
||||||
|
|
||||||
import type { StateType } from '../reducer';
|
import type { StateType } from '../reducer';
|
||||||
import type { ItemsStateType } from '../ducks/items';
|
import type { ItemsStateType } from '../ducks/items';
|
||||||
|
@ -15,6 +15,7 @@ import type {
|
||||||
} from '../../types/Colors';
|
} from '../../types/Colors';
|
||||||
import { DEFAULT_CONVERSATION_COLOR } from '../../types/Colors';
|
import { DEFAULT_CONVERSATION_COLOR } from '../../types/Colors';
|
||||||
import { getPreferredReactionEmoji as getPreferredReactionEmojiFromStoredValue } from '../../reactions/preferredReactionEmoji';
|
import { getPreferredReactionEmoji as getPreferredReactionEmojiFromStoredValue } from '../../reactions/preferredReactionEmoji';
|
||||||
|
import { getIsAlpha, getIsBeta } from './user';
|
||||||
|
|
||||||
const DEFAULT_PREFERRED_LEFT_PANE_WIDTH = 320;
|
const DEFAULT_PREFERRED_LEFT_PANE_WIDTH = 320;
|
||||||
|
|
||||||
|
@ -42,15 +43,39 @@ export const getUniversalExpireTimer = createSelector(
|
||||||
(state: ItemsStateType): number => state[UNIVERSAL_EXPIRE_TIMER_ITEM] || 0
|
(state: ItemsStateType): number => state[UNIVERSAL_EXPIRE_TIMER_ITEM] || 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isRemoteConfigFlagEnabled = (
|
||||||
|
config: Readonly<ConfigMapType>,
|
||||||
|
key: ConfigKeyType
|
||||||
|
): boolean => Boolean(config[key]?.enabled);
|
||||||
|
|
||||||
const getRemoteConfig = createSelector(
|
const getRemoteConfig = createSelector(
|
||||||
getItems,
|
getItems,
|
||||||
(state: ItemsStateType): ConfigMapType | undefined => state.remoteConfig
|
(state: ItemsStateType): ConfigMapType => state.remoteConfig || {}
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getUsernamesEnabled = createSelector(
|
export const getUsernamesEnabled = createSelector(
|
||||||
getRemoteConfig,
|
getRemoteConfig,
|
||||||
(remoteConfig?: ConfigMapType): boolean =>
|
(remoteConfig: ConfigMapType): boolean =>
|
||||||
Boolean(remoteConfig?.['desktop.usernames']?.enabled)
|
isRemoteConfigFlagEnabled(remoteConfig, 'desktop.usernames')
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getAreFloatingDateHeadersEnabled = createSelector(
|
||||||
|
getRemoteConfig,
|
||||||
|
getIsAlpha,
|
||||||
|
getIsBeta,
|
||||||
|
(remoteConfig: ConfigMapType, isAlpha, isBeta): boolean => {
|
||||||
|
if (
|
||||||
|
isAlpha ||
|
||||||
|
isRemoteConfigFlagEnabled(remoteConfig, 'desktop.internalUser')
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteConfigKey: ConfigKeyType = isBeta
|
||||||
|
? 'desktop.floatingDateHeaders.beta'
|
||||||
|
: 'desktop.floatingDateHeaders.production';
|
||||||
|
return isRemoteConfigFlagEnabled(remoteConfig, remoteConfigKey);
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getDefaultConversationColor = createSelector(
|
export const getDefaultConversationColor = createSelector(
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
// Copyright 2021-2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -670,6 +670,7 @@ export const getBubblePropsForMessage = createSelectorCreator(memoizeByRoot)(
|
||||||
(_, data): TimelineItemType => ({
|
(_, data): TimelineItemType => ({
|
||||||
type: 'message' as const,
|
type: 'message' as const,
|
||||||
data,
|
data,
|
||||||
|
timestamp: data.timestamp,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -678,94 +679,113 @@ export function getPropsForBubble(
|
||||||
message: MessageWithUIFieldsType,
|
message: MessageWithUIFieldsType,
|
||||||
options: GetPropsForBubbleOptions
|
options: GetPropsForBubbleOptions
|
||||||
): TimelineItemType {
|
): TimelineItemType {
|
||||||
|
// eslint-disable-next-line camelcase
|
||||||
|
const { received_at_ms: receivedAt, timestamp: messageTimestamp } = message;
|
||||||
|
const timestamp = receivedAt || messageTimestamp;
|
||||||
|
|
||||||
if (isUnsupportedMessage(message)) {
|
if (isUnsupportedMessage(message)) {
|
||||||
return {
|
return {
|
||||||
type: 'unsupportedMessage',
|
type: 'unsupportedMessage',
|
||||||
data: getPropsForUnsupportedMessage(message, options),
|
data: getPropsForUnsupportedMessage(message, options),
|
||||||
|
timestamp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (isGroupV2Change(message)) {
|
if (isGroupV2Change(message)) {
|
||||||
return {
|
return {
|
||||||
type: 'groupV2Change',
|
type: 'groupV2Change',
|
||||||
data: getPropsForGroupV2Change(message, options),
|
data: getPropsForGroupV2Change(message, options),
|
||||||
|
timestamp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (isGroupV1Migration(message)) {
|
if (isGroupV1Migration(message)) {
|
||||||
return {
|
return {
|
||||||
type: 'groupV1Migration',
|
type: 'groupV1Migration',
|
||||||
data: getPropsForGroupV1Migration(message, options),
|
data: getPropsForGroupV1Migration(message, options),
|
||||||
|
timestamp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (isMessageHistoryUnsynced(message)) {
|
if (isMessageHistoryUnsynced(message)) {
|
||||||
return {
|
return {
|
||||||
type: 'linkNotification',
|
type: 'linkNotification',
|
||||||
data: null,
|
data: null,
|
||||||
|
timestamp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (isExpirationTimerUpdate(message)) {
|
if (isExpirationTimerUpdate(message)) {
|
||||||
return {
|
return {
|
||||||
type: 'timerNotification',
|
type: 'timerNotification',
|
||||||
data: getPropsForTimerNotification(message, options),
|
data: getPropsForTimerNotification(message, options),
|
||||||
|
timestamp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (isKeyChange(message)) {
|
if (isKeyChange(message)) {
|
||||||
return {
|
return {
|
||||||
type: 'safetyNumberNotification',
|
type: 'safetyNumberNotification',
|
||||||
data: getPropsForSafetyNumberNotification(message, options),
|
data: getPropsForSafetyNumberNotification(message, options),
|
||||||
|
timestamp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (isVerifiedChange(message)) {
|
if (isVerifiedChange(message)) {
|
||||||
return {
|
return {
|
||||||
type: 'verificationNotification',
|
type: 'verificationNotification',
|
||||||
data: getPropsForVerificationNotification(message, options),
|
data: getPropsForVerificationNotification(message, options),
|
||||||
|
timestamp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (isGroupUpdate(message)) {
|
if (isGroupUpdate(message)) {
|
||||||
return {
|
return {
|
||||||
type: 'groupNotification',
|
type: 'groupNotification',
|
||||||
data: getPropsForGroupNotification(message, options),
|
data: getPropsForGroupNotification(message, options),
|
||||||
|
timestamp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (isEndSession(message)) {
|
if (isEndSession(message)) {
|
||||||
return {
|
return {
|
||||||
type: 'resetSessionNotification',
|
type: 'resetSessionNotification',
|
||||||
data: null,
|
data: null,
|
||||||
|
timestamp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (isCallHistory(message)) {
|
if (isCallHistory(message)) {
|
||||||
return {
|
return {
|
||||||
type: 'callHistory',
|
type: 'callHistory',
|
||||||
data: getPropsForCallHistory(message, options),
|
data: getPropsForCallHistory(message, options),
|
||||||
|
timestamp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (isProfileChange(message)) {
|
if (isProfileChange(message)) {
|
||||||
return {
|
return {
|
||||||
type: 'profileChange',
|
type: 'profileChange',
|
||||||
data: getPropsForProfileChange(message, options),
|
data: getPropsForProfileChange(message, options),
|
||||||
|
timestamp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (isUniversalTimerNotification(message)) {
|
if (isUniversalTimerNotification(message)) {
|
||||||
return {
|
return {
|
||||||
type: 'universalTimerNotification',
|
type: 'universalTimerNotification',
|
||||||
data: null,
|
data: null,
|
||||||
|
timestamp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (isChangeNumberNotification(message)) {
|
if (isChangeNumberNotification(message)) {
|
||||||
return {
|
return {
|
||||||
type: 'changeNumberNotification',
|
type: 'changeNumberNotification',
|
||||||
data: getPropsForChangeNumberNotification(message, options),
|
data: getPropsForChangeNumberNotification(message, options),
|
||||||
|
timestamp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (isChatSessionRefreshed(message)) {
|
if (isChatSessionRefreshed(message)) {
|
||||||
return {
|
return {
|
||||||
type: 'chatSessionRefreshed',
|
type: 'chatSessionRefreshed',
|
||||||
data: null,
|
data: null,
|
||||||
|
timestamp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (isDeliveryIssue(message)) {
|
if (isDeliveryIssue(message)) {
|
||||||
return {
|
return {
|
||||||
type: 'deliveryIssue',
|
type: 'deliveryIssue',
|
||||||
data: getPropsForDeliveryIssue(message, options),
|
data: getPropsForDeliveryIssue(message, options),
|
||||||
|
timestamp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright 2019-2020 Signal Messenger, LLC
|
// Copyright 2019-2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
|
@ -9,6 +9,8 @@ import type { UUIDStringType } from '../../types/UUID';
|
||||||
import type { StateType } from '../reducer';
|
import type { StateType } from '../reducer';
|
||||||
import type { UserStateType } from '../ducks/user';
|
import type { UserStateType } from '../ducks/user';
|
||||||
|
|
||||||
|
import { isAlpha, isBeta } from '../../util/version';
|
||||||
|
|
||||||
export const getUser = (state: StateType): UserStateType => state.user;
|
export const getUser = (state: StateType): UserStateType => state.user;
|
||||||
|
|
||||||
export const getUserNumber = createSelector(
|
export const getUserNumber = createSelector(
|
||||||
|
@ -70,3 +72,12 @@ export const getTheme = createSelector(
|
||||||
getUser,
|
getUser,
|
||||||
(state: UserStateType): ThemeType => state.theme
|
(state: UserStateType): ThemeType => state.theme
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const getVersion = createSelector(
|
||||||
|
getUser,
|
||||||
|
(state: UserStateType) => state.version
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getIsAlpha = createSelector(getVersion, isAlpha);
|
||||||
|
|
||||||
|
export const getIsBeta = createSelector(getVersion, isBeta);
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright 2019-2021 Signal Messenger, LLC
|
// Copyright 2019-2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { isEmpty, mapValues, pick } from 'lodash';
|
import { isEmpty, mapValues, pick } from 'lodash';
|
||||||
|
@ -18,6 +18,7 @@ import { Timeline } from '../../components/conversation/Timeline';
|
||||||
import type { StateType } from '../reducer';
|
import type { StateType } from '../reducer';
|
||||||
import type { ConversationType } from '../ducks/conversations';
|
import type { ConversationType } from '../ducks/conversations';
|
||||||
|
|
||||||
|
import { getAreFloatingDateHeadersEnabled } from '../selectors/items';
|
||||||
import { getIntl, getTheme } from '../selectors/user';
|
import { getIntl, getTheme } from '../selectors/user';
|
||||||
import {
|
import {
|
||||||
getConversationByUuidSelector,
|
getConversationByUuidSelector,
|
||||||
|
@ -25,6 +26,7 @@ import {
|
||||||
getConversationSelector,
|
getConversationSelector,
|
||||||
getConversationsByTitleSelector,
|
getConversationsByTitleSelector,
|
||||||
getInvitedContactsForNewlyCreatedGroup,
|
getInvitedContactsForNewlyCreatedGroup,
|
||||||
|
getMessageSelector,
|
||||||
getSelectedMessage,
|
getSelectedMessage,
|
||||||
} from '../selectors/conversations';
|
} from '../selectors/conversations';
|
||||||
|
|
||||||
|
@ -32,13 +34,12 @@ import { SmartTimelineItem } from './TimelineItem';
|
||||||
import { SmartTypingBubble } from './TypingBubble';
|
import { SmartTypingBubble } from './TypingBubble';
|
||||||
import { SmartLastSeenIndicator } from './LastSeenIndicator';
|
import { SmartLastSeenIndicator } from './LastSeenIndicator';
|
||||||
import { SmartHeroRow } from './HeroRow';
|
import { SmartHeroRow } from './HeroRow';
|
||||||
import { SmartTimelineLoadingRow } from './TimelineLoadingRow';
|
|
||||||
import { renderAudioAttachment } from './renderAudioAttachment';
|
import { renderAudioAttachment } from './renderAudioAttachment';
|
||||||
import { renderEmojiPicker } from './renderEmojiPicker';
|
import { renderEmojiPicker } from './renderEmojiPicker';
|
||||||
import { renderReactionPicker } from './renderReactionPicker';
|
import { renderReactionPicker } from './renderReactionPicker';
|
||||||
|
|
||||||
import { getOwn } from '../../util/getOwn';
|
import { getOwn } from '../../util/getOwn';
|
||||||
import { assert } from '../../util/assert';
|
import { assert, strictAssert } from '../../util/assert';
|
||||||
import { missingCaseError } from '../../util/missingCaseError';
|
import { missingCaseError } from '../../util/missingCaseError';
|
||||||
import { getGroupMemberships } from '../../util/getGroupMemberships';
|
import { getGroupMemberships } from '../../util/getGroupMemberships';
|
||||||
import {
|
import {
|
||||||
|
@ -114,6 +115,7 @@ function renderItem({
|
||||||
containerElementRef,
|
containerElementRef,
|
||||||
containerWidthBreakpoint,
|
containerWidthBreakpoint,
|
||||||
conversationId,
|
conversationId,
|
||||||
|
isOldestTimelineItem,
|
||||||
messageId,
|
messageId,
|
||||||
nextMessageId,
|
nextMessageId,
|
||||||
onHeightChange,
|
onHeightChange,
|
||||||
|
@ -123,6 +125,7 @@ function renderItem({
|
||||||
containerElementRef: RefObject<HTMLElement>;
|
containerElementRef: RefObject<HTMLElement>;
|
||||||
containerWidthBreakpoint: WidthBreakpoint;
|
containerWidthBreakpoint: WidthBreakpoint;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
|
isOldestTimelineItem: boolean;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
nextMessageId: undefined | string;
|
nextMessageId: undefined | string;
|
||||||
onHeightChange: (messageId: string) => unknown;
|
onHeightChange: (messageId: string) => unknown;
|
||||||
|
@ -134,6 +137,7 @@ function renderItem({
|
||||||
containerElementRef={containerElementRef}
|
containerElementRef={containerElementRef}
|
||||||
containerWidthBreakpoint={containerWidthBreakpoint}
|
containerWidthBreakpoint={containerWidthBreakpoint}
|
||||||
conversationId={conversationId}
|
conversationId={conversationId}
|
||||||
|
isOldestTimelineItem={isOldestTimelineItem}
|
||||||
messageId={messageId}
|
messageId={messageId}
|
||||||
previousMessageId={previousMessageId}
|
previousMessageId={previousMessageId}
|
||||||
nextMessageId={nextMessageId}
|
nextMessageId={nextMessageId}
|
||||||
|
@ -164,9 +168,6 @@ function renderHeroRow(
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
function renderLoadingRow(id: string): JSX.Element {
|
|
||||||
return <SmartTimelineLoadingRow id={id} />;
|
|
||||||
}
|
|
||||||
function renderTypingBubble(id: string): JSX.Element {
|
function renderTypingBubble(id: string): JSX.Element {
|
||||||
return <SmartTypingBubble id={id} />;
|
return <SmartTypingBubble id={id} />;
|
||||||
}
|
}
|
||||||
|
@ -294,6 +295,13 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
|
||||||
const conversationMessages = getConversationMessagesSelector(state)(id);
|
const conversationMessages = getConversationMessagesSelector(state)(id);
|
||||||
const selectedMessage = getSelectedMessage(state);
|
const selectedMessage = getSelectedMessage(state);
|
||||||
|
|
||||||
|
const messageSelector = getMessageSelector(state);
|
||||||
|
const getTimestampForMessage = (messageId: string): number => {
|
||||||
|
const result = messageSelector(messageId)?.timestamp;
|
||||||
|
strictAssert(result, 'Expected a message');
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
...pick(conversation, [
|
...pick(conversation, [
|
||||||
|
@ -314,13 +322,15 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
|
||||||
warning: getWarning(conversation, state),
|
warning: getWarning(conversation, state),
|
||||||
contactSpoofingReview: getContactSpoofingReview(id, state),
|
contactSpoofingReview: getContactSpoofingReview(id, state),
|
||||||
|
|
||||||
|
areFloatingDateHeadersEnabled: getAreFloatingDateHeadersEnabled(state),
|
||||||
|
|
||||||
|
getTimestampForMessage,
|
||||||
getPreferredBadge: getPreferredBadgeSelector(state),
|
getPreferredBadge: getPreferredBadgeSelector(state),
|
||||||
i18n: getIntl(state),
|
i18n: getIntl(state),
|
||||||
theme: getTheme(state),
|
theme: getTheme(state),
|
||||||
renderItem,
|
renderItem,
|
||||||
renderLastSeenIndicator,
|
renderLastSeenIndicator,
|
||||||
renderHeroRow,
|
renderHeroRow,
|
||||||
renderLoadingRow,
|
|
||||||
renderTypingBubble,
|
renderTypingBubble,
|
||||||
...actions,
|
...actions,
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright 2019-2021 Signal Messenger, LLC
|
// Copyright 2019-2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import type { RefObject } from 'react';
|
import type { RefObject } from 'react';
|
||||||
|
@ -23,6 +23,7 @@ import { SmartUniversalTimerNotification } from './UniversalTimerNotification';
|
||||||
type ExternalProps = {
|
type ExternalProps = {
|
||||||
containerElementRef: RefObject<HTMLElement>;
|
containerElementRef: RefObject<HTMLElement>;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
|
isOldestTimelineItem: boolean;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
nextMessageId: undefined | string;
|
nextMessageId: undefined | string;
|
||||||
previousMessageId: undefined | string;
|
previousMessageId: undefined | string;
|
||||||
|
@ -40,6 +41,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
|
||||||
const {
|
const {
|
||||||
containerElementRef,
|
containerElementRef,
|
||||||
conversationId,
|
conversationId,
|
||||||
|
isOldestTimelineItem,
|
||||||
messageId,
|
messageId,
|
||||||
nextMessageId,
|
nextMessageId,
|
||||||
previousMessageId,
|
previousMessageId,
|
||||||
|
@ -70,6 +72,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
|
||||||
conversationColor: conversation?.conversationColor,
|
conversationColor: conversation?.conversationColor,
|
||||||
customColor: conversation?.customColor,
|
customColor: conversation?.customColor,
|
||||||
getPreferredBadge: getPreferredBadgeSelector(state),
|
getPreferredBadge: getPreferredBadgeSelector(state),
|
||||||
|
isOldestTimelineItem,
|
||||||
isSelected,
|
isSelected,
|
||||||
renderContact,
|
renderContact,
|
||||||
renderUniversalTimerNotification,
|
renderUniversalTimerNotification,
|
||||||
|
|
|
@ -1,56 +0,0 @@
|
||||||
// Copyright 2019-2020 Signal Messenger, LLC
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
|
|
||||||
import { isNumber } from 'lodash';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { mapDispatchToProps } from '../actions';
|
|
||||||
|
|
||||||
import type { STATE_ENUM } from '../../components/conversation/TimelineLoadingRow';
|
|
||||||
import { TimelineLoadingRow } from '../../components/conversation/TimelineLoadingRow';
|
|
||||||
import { LOAD_COUNTDOWN } from '../../components/conversation/Timeline';
|
|
||||||
|
|
||||||
import type { StateType } from '../reducer';
|
|
||||||
import { getIntl } from '../selectors/user';
|
|
||||||
import { getConversationMessagesSelector } from '../selectors/conversations';
|
|
||||||
|
|
||||||
type ExternalProps = {
|
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapStateToProps = (state: StateType, props: ExternalProps) => {
|
|
||||||
const { id } = props;
|
|
||||||
|
|
||||||
const conversation = getConversationMessagesSelector(state)(id);
|
|
||||||
if (!conversation) {
|
|
||||||
throw new Error(`Did not find conversation ${id} in state!`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { isLoadingMessages, loadCountdownStart } = conversation;
|
|
||||||
|
|
||||||
let loadingState: STATE_ENUM;
|
|
||||||
|
|
||||||
if (isLoadingMessages) {
|
|
||||||
loadingState = 'loading';
|
|
||||||
} else if (isNumber(loadCountdownStart)) {
|
|
||||||
loadingState = 'countdown';
|
|
||||||
} else {
|
|
||||||
loadingState = 'idle';
|
|
||||||
}
|
|
||||||
|
|
||||||
const duration = loadingState === 'countdown' ? LOAD_COUNTDOWN : undefined;
|
|
||||||
const expiresAt =
|
|
||||||
loadingState === 'countdown' && loadCountdownStart
|
|
||||||
? loadCountdownStart + LOAD_COUNTDOWN
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return {
|
|
||||||
state: loadingState,
|
|
||||||
duration,
|
|
||||||
expiresAt,
|
|
||||||
i18n: getIntl(state),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const smart = connect(mapStateToProps, mapDispatchToProps);
|
|
||||||
|
|
||||||
export const SmartTimelineLoadingRow = smart(TimelineLoadingRow);
|
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright 2020-2021 Signal Messenger, LLC
|
// Copyright 2020-2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { assert } from 'chai';
|
import { assert } from 'chai';
|
||||||
|
@ -8,6 +8,7 @@ import {
|
||||||
getPinnedConversationIds,
|
getPinnedConversationIds,
|
||||||
getPreferredLeftPaneWidth,
|
getPreferredLeftPaneWidth,
|
||||||
getPreferredReactionEmoji,
|
getPreferredReactionEmoji,
|
||||||
|
getUsernamesEnabled,
|
||||||
} from '../../../state/selectors/items';
|
} from '../../../state/selectors/items';
|
||||||
import type { StateType } from '../../../state/reducer';
|
import type { StateType } from '../../../state/reducer';
|
||||||
import type { ItemsStateType } from '../../../state/ducks/items';
|
import type { ItemsStateType } from '../../../state/ducks/items';
|
||||||
|
@ -143,4 +144,36 @@ describe('both/state/selectors/items', () => {
|
||||||
assert.deepStrictEqual(actual, preferredReactionEmoji);
|
assert.deepStrictEqual(actual, preferredReactionEmoji);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('#getUsernamesEnabled', () => {
|
||||||
|
it('returns false if the flag is missing or disabled', () => {
|
||||||
|
[
|
||||||
|
{},
|
||||||
|
{ remoteConfig: {} },
|
||||||
|
{
|
||||||
|
remoteConfig: {
|
||||||
|
'desktop.usernames': {
|
||||||
|
name: 'desktop.usernames' as const,
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
].forEach(itemsState => {
|
||||||
|
const state = getRootState(itemsState);
|
||||||
|
assert.isFalse(getUsernamesEnabled(state));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true if the flag is enabled', () => {
|
||||||
|
const state = getRootState({
|
||||||
|
remoteConfig: {
|
||||||
|
'desktop.usernames': {
|
||||||
|
name: 'desktop.usernames' as const,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.isTrue(getUsernamesEnabled(state));
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
58
ts/test-both/state/selectors/user_test.ts
Normal file
58
ts/test-both/state/selectors/user_test.ts
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { assert } from 'chai';
|
||||||
|
|
||||||
|
import type { StateType } from '../../../state/reducer';
|
||||||
|
import type { UserStateType } from '../../../state/ducks/user';
|
||||||
|
import { getEmptyState } from '../../../state/ducks/user';
|
||||||
|
|
||||||
|
import { getIsAlpha, getIsBeta } from '../../../state/selectors/user';
|
||||||
|
|
||||||
|
describe('both/state/selectors/user', () => {
|
||||||
|
function getRootState(
|
||||||
|
overrides: Readonly<Partial<UserStateType>>
|
||||||
|
): StateType {
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
...getEmptyState(),
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('#getIsAlpha', () => {
|
||||||
|
it('returns false for beta', () => {
|
||||||
|
const state = getRootState({ version: '1.23.4-beta.5' });
|
||||||
|
assert.isFalse(getIsAlpha(state));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for production', () => {
|
||||||
|
const state = getRootState({ version: '1.23.4' });
|
||||||
|
assert.isFalse(getIsAlpha(state));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true for alpha', () => {
|
||||||
|
const state = getRootState({ version: '1.23.4-alpha.987' });
|
||||||
|
assert.isTrue(getIsAlpha(state));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#getIsBeta', () => {
|
||||||
|
it('returns false for alpha', () => {
|
||||||
|
const state = getRootState({ version: '1.23.4-alpha.987' });
|
||||||
|
assert.isFalse(getIsBeta(state));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for production', () => {
|
||||||
|
const state = getRootState({ version: '1.23.4' });
|
||||||
|
assert.isFalse(getIsBeta(state));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true for beta', () => {
|
||||||
|
const state = getRootState({ version: '1.23.4-beta.5' });
|
||||||
|
assert.isTrue(getIsBeta(state));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,18 +1,232 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
// Copyright 2021-2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { assert } from 'chai';
|
import { assert } from 'chai';
|
||||||
|
import * as sinon from 'sinon';
|
||||||
|
import moment from 'moment';
|
||||||
|
import type { LocalizerType } from '../../types/Util';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
isOlderThan,
|
isOlderThan,
|
||||||
isMoreRecentThan,
|
isMoreRecentThan,
|
||||||
toDayMillis,
|
toDayMillis,
|
||||||
|
formatDate,
|
||||||
|
formatDateTimeLong,
|
||||||
|
formatDateTimeShort,
|
||||||
|
formatTime,
|
||||||
} from '../../util/timestamp';
|
} from '../../util/timestamp';
|
||||||
|
|
||||||
|
const FAKE_NOW = new Date('2020-01-23T04:56:00.000');
|
||||||
const ONE_HOUR = 3600 * 1000;
|
const ONE_HOUR = 3600 * 1000;
|
||||||
const ONE_DAY = 24 * ONE_HOUR;
|
const ONE_DAY = 24 * ONE_HOUR;
|
||||||
|
|
||||||
describe('timestamp', () => {
|
describe('timestamp', () => {
|
||||||
|
function useFakeTimers() {
|
||||||
|
let sandbox: sinon.SinonSandbox;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sandbox = sinon.createSandbox();
|
||||||
|
sandbox.useFakeTimers({ now: FAKE_NOW });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
sandbox.restore();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const i18n = ((key: string, values: Array<string> = []): string => {
|
||||||
|
switch (key) {
|
||||||
|
case 'today':
|
||||||
|
return 'Today';
|
||||||
|
case 'yesterday':
|
||||||
|
return 'Yesterday';
|
||||||
|
case 'TimelineDateHeader--date-in-last-6-months':
|
||||||
|
return '[short] ddd, MMM D';
|
||||||
|
case 'TimelineDateHeader--date-older-than-6-months':
|
||||||
|
return '[long] MMM D, YYYY';
|
||||||
|
case 'timestampFormat__long__today':
|
||||||
|
return '[Today] LT';
|
||||||
|
case 'timestampFormat__long__yesterday':
|
||||||
|
return '[Yesterday] LT';
|
||||||
|
case 'justNow':
|
||||||
|
return 'Now';
|
||||||
|
case 'minutesAgo':
|
||||||
|
return `${values[0]}m`;
|
||||||
|
case 'timestampFormat_M':
|
||||||
|
return 'MMM D';
|
||||||
|
default:
|
||||||
|
throw new Error(`Unexpected key ${key}`);
|
||||||
|
}
|
||||||
|
}) as LocalizerType;
|
||||||
|
|
||||||
|
describe('formatDate', () => {
|
||||||
|
useFakeTimers();
|
||||||
|
|
||||||
|
it('returns "Today" for times today', () => {
|
||||||
|
[moment(), moment().endOf('day'), moment().startOf('day')].forEach(m => {
|
||||||
|
assert.strictEqual(formatDate(i18n, m), 'Today');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "Yesterday" for times yesterday', () => {
|
||||||
|
const minus24Hours = moment().subtract(1, 'day');
|
||||||
|
[
|
||||||
|
minus24Hours,
|
||||||
|
minus24Hours.clone().endOf('day'),
|
||||||
|
minus24Hours.clone().startOf('day'),
|
||||||
|
].forEach(m => {
|
||||||
|
assert.strictEqual(formatDate(i18n, m), 'Yesterday');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a formatted timestamp for dates more recent than six months', () => {
|
||||||
|
const m = moment().subtract(2, 'months');
|
||||||
|
const result = formatDate(i18n, m);
|
||||||
|
assert.include(result, 'short');
|
||||||
|
assert.include(result, m.format('ddd'));
|
||||||
|
assert.include(result, m.format('MMM'));
|
||||||
|
assert.include(result, m.format('D'));
|
||||||
|
assert.notInclude(result, m.format('YYYY'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a formatted timestamp for dates older than six months', () => {
|
||||||
|
assert.strictEqual(
|
||||||
|
formatDate(i18n, moment('2017-03-03')),
|
||||||
|
'long Mar 3, 2017'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a formatted timestamp if the i18n strings are too long', () => {
|
||||||
|
const longI18n = ((_: string) =>
|
||||||
|
Array(50).fill('MMM').join(' ')) as LocalizerType;
|
||||||
|
assert.include(formatDate(longI18n, moment('2017-03-03')), '2017');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatDateTimeLong', () => {
|
||||||
|
useFakeTimers();
|
||||||
|
|
||||||
|
it('includes "Today" and the time for times today', () => {
|
||||||
|
assert.strictEqual(formatDateTimeLong(i18n, FAKE_NOW), 'Today 4:56 AM');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes "Yesterday" and the time for times yesterday', () => {
|
||||||
|
assert.strictEqual(
|
||||||
|
formatDateTimeLong(i18n, moment().subtract(1, 'day')),
|
||||||
|
'Yesterday 4:56 AM'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats month name, day of month, year, and time for other times', () => {
|
||||||
|
[
|
||||||
|
moment().add(1, 'week'),
|
||||||
|
moment().subtract(1, 'week'),
|
||||||
|
moment().subtract(1, 'year'),
|
||||||
|
].forEach(timestamp => {
|
||||||
|
assert.strictEqual(
|
||||||
|
formatDateTimeLong(i18n, timestamp),
|
||||||
|
moment(timestamp).format('lll')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatDateTimeShort', () => {
|
||||||
|
useFakeTimers();
|
||||||
|
|
||||||
|
it('returns "Now" for times within the last minute, including unexpected times in the future', () => {
|
||||||
|
[
|
||||||
|
Date.now(),
|
||||||
|
moment().subtract(1, 'second'),
|
||||||
|
moment().subtract(59, 'seconds'),
|
||||||
|
moment().add(1, 'minute'),
|
||||||
|
moment().add(1, 'year'),
|
||||||
|
].forEach(timestamp => {
|
||||||
|
assert.strictEqual(formatDateTimeShort(i18n, timestamp), 'Now');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "X minutes ago" for times in the last hour, but older than 1 minute', () => {
|
||||||
|
assert.strictEqual(
|
||||||
|
formatDateTimeShort(i18n, moment().subtract(1, 'minute')),
|
||||||
|
'1m'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
formatDateTimeShort(i18n, moment().subtract(30, 'minutes')),
|
||||||
|
'30m'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
formatDateTimeShort(i18n, moment().subtract(59, 'minutes')),
|
||||||
|
'59m'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns hh:mm-like times for times older than 1 hour from now, but still today', () => {
|
||||||
|
const oneHourAgo = new Date('2020-01-23T03:56:00.000');
|
||||||
|
assert.deepEqual(formatDateTimeShort(i18n, oneHourAgo), '3:56 AM');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the day of the week for dates in the last week, but still this month', () => {
|
||||||
|
const yesterday = new Date('2020-01-22T23:56:00.000');
|
||||||
|
assert.deepEqual(formatDateTimeShort(i18n, yesterday), 'Wed');
|
||||||
|
|
||||||
|
const twoDaysAgo = new Date('2020-01-21T05:56:00.000');
|
||||||
|
assert.deepEqual(formatDateTimeShort(i18n, twoDaysAgo), 'Tue');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the month and day for dates older than this week, but still this year', () => {
|
||||||
|
const earlier = new Date('2020-01-03T04:56:00.000');
|
||||||
|
assert.deepEqual(formatDateTimeShort(i18n, earlier), 'Jan 3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the year, month, and day for dates older than a year ago', () => {
|
||||||
|
const longAgo = new Date('1998-11-23T12:34:00.000');
|
||||||
|
assert.deepEqual(formatDateTimeShort(i18n, longAgo), 'Nov 23, 1998');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatTime', () => {
|
||||||
|
useFakeTimers();
|
||||||
|
|
||||||
|
it('returns "Now" for times within the last minute, including unexpected times in the future', () => {
|
||||||
|
[
|
||||||
|
Date.now(),
|
||||||
|
moment().subtract(1, 'second'),
|
||||||
|
moment().subtract(59, 'seconds'),
|
||||||
|
moment().add(1, 'minute'),
|
||||||
|
moment().add(1, 'year'),
|
||||||
|
].forEach(timestamp => {
|
||||||
|
assert.strictEqual(formatTime(i18n, timestamp), 'Now');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "X minutes ago" for times in the last hour, but older than 1 minute', () => {
|
||||||
|
assert.strictEqual(
|
||||||
|
formatTime(i18n, moment().subtract(1, 'minute')),
|
||||||
|
'1m'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
formatTime(i18n, moment().subtract(30, 'minutes')),
|
||||||
|
'30m'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
formatTime(i18n, moment().subtract(59, 'minutes')),
|
||||||
|
'59m'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns hh:mm-like times for times older than 1 hour from now', () => {
|
||||||
|
const oneHourAgo = new Date('2020-01-23T03:56:00.000');
|
||||||
|
assert.deepEqual(formatTime(i18n, oneHourAgo), '3:56 AM');
|
||||||
|
|
||||||
|
const oneDayAgo = new Date('2020-01-22T04:56:00.000');
|
||||||
|
assert.deepEqual(formatTime(i18n, oneDayAgo), '4:56 AM');
|
||||||
|
|
||||||
|
const oneYearAgo = new Date('2019-01-23T04:56:00.000');
|
||||||
|
assert.deepEqual(formatTime(i18n, oneYearAgo), '4:56 AM');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('isOlderThan', () => {
|
describe('isOlderThan', () => {
|
||||||
it('returns false on recent and future timestamps', () => {
|
it('returns false on recent and future timestamps', () => {
|
||||||
assert.isFalse(isOlderThan(Date.now(), ONE_DAY));
|
assert.isFalse(isOlderThan(Date.now(), ONE_DAY));
|
||||||
|
|
|
@ -1,66 +0,0 @@
|
||||||
// Copyright 2018-2020 Signal Messenger, LLC
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
|
|
||||||
import moment from 'moment';
|
|
||||||
import type { LocalizerType } from '../types/Util';
|
|
||||||
|
|
||||||
// Only applies in the english locales, but it ensures that the format
|
|
||||||
// is what we want.
|
|
||||||
function replaceSuffix(time: string) {
|
|
||||||
return time.replace(/ PM$/, 'pm').replace(/ AM$/, 'am');
|
|
||||||
}
|
|
||||||
|
|
||||||
const getExtendedFormats = (i18n: LocalizerType) => ({
|
|
||||||
y: 'lll',
|
|
||||||
M: `${i18n('timestampFormat_M') || 'MMM D'} LT`,
|
|
||||||
d: 'ddd LT',
|
|
||||||
});
|
|
||||||
const getShortFormats = (i18n: LocalizerType) => ({
|
|
||||||
y: 'll',
|
|
||||||
M: i18n('timestampFormat_M') || 'MMM D',
|
|
||||||
d: 'ddd',
|
|
||||||
});
|
|
||||||
|
|
||||||
function isToday(timestamp: moment.Moment) {
|
|
||||||
const today = moment().format('ddd');
|
|
||||||
const targetDay = moment(timestamp).format('ddd');
|
|
||||||
|
|
||||||
return today === targetDay;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isYear(timestamp: moment.Moment) {
|
|
||||||
const year = moment().format('YYYY');
|
|
||||||
const targetYear = moment(timestamp).format('YYYY');
|
|
||||||
|
|
||||||
return year === targetYear;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatRelativeTime(
|
|
||||||
rawTimestamp: number | Date,
|
|
||||||
options: { extended?: boolean; i18n: LocalizerType }
|
|
||||||
): string {
|
|
||||||
const { extended, i18n } = options;
|
|
||||||
|
|
||||||
const formats = extended ? getExtendedFormats(i18n) : getShortFormats(i18n);
|
|
||||||
const timestamp = moment(rawTimestamp);
|
|
||||||
const now = moment();
|
|
||||||
const diff = moment.duration(now.diff(timestamp));
|
|
||||||
|
|
||||||
if (diff.years() >= 1 || !isYear(timestamp)) {
|
|
||||||
return replaceSuffix(timestamp.format(formats.y));
|
|
||||||
}
|
|
||||||
if (diff.months() >= 1 || diff.days() > 6) {
|
|
||||||
return replaceSuffix(timestamp.format(formats.M));
|
|
||||||
}
|
|
||||||
if (diff.days() >= 1 || !isToday(timestamp)) {
|
|
||||||
return replaceSuffix(timestamp.format(formats.d));
|
|
||||||
}
|
|
||||||
if (diff.hours() >= 1) {
|
|
||||||
return i18n('hoursAgo', [String(diff.hours())]);
|
|
||||||
}
|
|
||||||
if (diff.minutes() >= 1) {
|
|
||||||
return i18n('minutesAgo', [String(diff.minutes())]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return i18n('justNow');
|
|
||||||
}
|
|
|
@ -1,7 +1,15 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
// Copyright 2021-2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
const ONE_DAY = 24 * 3600 * 1000;
|
import type { Moment } from 'moment';
|
||||||
|
import moment from 'moment';
|
||||||
|
import type { LocalizerType } from '../types/Util';
|
||||||
|
import * as log from '../logging/log';
|
||||||
|
import { DAY, HOUR, MINUTE, MONTH, WEEK } from './durations';
|
||||||
|
|
||||||
|
const MAX_FORMAT_STRING_LENGTH = 50;
|
||||||
|
|
||||||
|
type RawTimestamp = Readonly<number | Date | Moment>;
|
||||||
|
|
||||||
export function isMoreRecentThan(timestamp: number, delta: number): boolean {
|
export function isMoreRecentThan(timestamp: number, delta: number): boolean {
|
||||||
return timestamp > Date.now() - delta;
|
return timestamp > Date.now() - delta;
|
||||||
|
@ -20,5 +28,124 @@ export function isInFuture(timestamp: number): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toDayMillis(timestamp: number): number {
|
export function toDayMillis(timestamp: number): number {
|
||||||
return timestamp - (timestamp % ONE_DAY);
|
return timestamp - (timestamp % DAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSameDay = (a: RawTimestamp, b: RawTimestamp): boolean =>
|
||||||
|
moment(a).isSame(b, 'day');
|
||||||
|
|
||||||
|
const isToday = (rawTimestamp: RawTimestamp): boolean =>
|
||||||
|
isSameDay(rawTimestamp, Date.now());
|
||||||
|
|
||||||
|
const isYesterday = (rawTimestamp: RawTimestamp): boolean =>
|
||||||
|
isSameDay(rawTimestamp, moment().subtract(1, 'day'));
|
||||||
|
|
||||||
|
// This sanitization is probably unnecessary, but we do it just in case someone translates
|
||||||
|
// a super long format string and causes performance issues.
|
||||||
|
function sanitizeFormatString(
|
||||||
|
rawFormatString: string,
|
||||||
|
fallback: string
|
||||||
|
): string {
|
||||||
|
if (rawFormatString.length > MAX_FORMAT_STRING_LENGTH) {
|
||||||
|
log.error(
|
||||||
|
`Format string ${JSON.stringify(
|
||||||
|
rawFormatString
|
||||||
|
)} is too long. Falling back to ${fallback}`
|
||||||
|
);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return rawFormatString;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTimeShort(
|
||||||
|
i18n: LocalizerType,
|
||||||
|
rawTimestamp: RawTimestamp
|
||||||
|
): string {
|
||||||
|
const timestamp = rawTimestamp.valueOf();
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const diff = now - timestamp;
|
||||||
|
|
||||||
|
if (diff < MINUTE) {
|
||||||
|
return i18n('justNow');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diff < HOUR) {
|
||||||
|
return i18n('minutesAgo', [Math.floor(diff / MINUTE).toString()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const m = moment(timestamp);
|
||||||
|
|
||||||
|
if (isToday(timestamp)) {
|
||||||
|
return m.format('LT');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diff < WEEK && m.isSame(now, 'month')) {
|
||||||
|
return m.format('ddd');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m.isSame(now, 'year')) {
|
||||||
|
return m.format(i18n('timestampFormat_M') || 'MMM D');
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.format('ll');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTimeLong(
|
||||||
|
i18n: LocalizerType,
|
||||||
|
rawTimestamp: RawTimestamp
|
||||||
|
): string {
|
||||||
|
let rawFormatString: string;
|
||||||
|
if (isToday(rawTimestamp)) {
|
||||||
|
rawFormatString = i18n('timestampFormat__long__today');
|
||||||
|
} else if (isYesterday(rawTimestamp)) {
|
||||||
|
rawFormatString = i18n('timestampFormat__long__yesterday');
|
||||||
|
} else {
|
||||||
|
rawFormatString = 'lll';
|
||||||
|
}
|
||||||
|
const formatString = sanitizeFormatString(rawFormatString, 'lll');
|
||||||
|
|
||||||
|
return moment(rawTimestamp).format(formatString);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(
|
||||||
|
i18n: LocalizerType,
|
||||||
|
rawTimestamp: RawTimestamp
|
||||||
|
): string {
|
||||||
|
const timestamp = rawTimestamp.valueOf();
|
||||||
|
const diff = Date.now() - timestamp;
|
||||||
|
|
||||||
|
if (diff < MINUTE) {
|
||||||
|
return i18n('justNow');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diff < HOUR) {
|
||||||
|
return i18n('minutesAgo', [Math.floor(diff / MINUTE).toString()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return moment(timestamp).format('LT');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(
|
||||||
|
i18n: LocalizerType,
|
||||||
|
rawTimestamp: RawTimestamp
|
||||||
|
): string {
|
||||||
|
if (isToday(rawTimestamp)) {
|
||||||
|
return i18n('today');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isYesterday(rawTimestamp)) {
|
||||||
|
return i18n('yesterday');
|
||||||
|
}
|
||||||
|
|
||||||
|
const m = moment(rawTimestamp);
|
||||||
|
|
||||||
|
const formatI18nKey =
|
||||||
|
Math.abs(m.diff(Date.now())) < 6 * MONTH
|
||||||
|
? 'TimelineDateHeader--date-in-last-6-months'
|
||||||
|
: 'TimelineDateHeader--date-older-than-6-months';
|
||||||
|
const rawFormatString = i18n(formatI18nKey);
|
||||||
|
const formatString = sanitizeFormatString(rawFormatString, 'LL');
|
||||||
|
|
||||||
|
return m.format(formatString);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue