Render disappearing message timers generically

This commit is contained in:
Evan Hahn 2021-05-03 18:24:40 -05:00 committed by GitHub
parent c1730e055f
commit 736075322c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 372 additions and 307 deletions

View file

@ -19,10 +19,7 @@ import { InContactsIcon } from '../InContactsIcon';
import { LocalizerType } from '../../types/Util';
import { ConversationType } from '../../state/ducks/conversations';
import { MuteOption, getMuteOptions } from '../../util/getMuteOptions';
import {
ExpirationTimerOptions,
TimerOption,
} from '../../util/ExpirationTimerOptions';
import * as expirationTimer from '../../util/expirationTimer';
import { isMuted } from '../../util/isMuted';
import { missingCaseError } from '../../util/missingCaseError';
@ -219,16 +216,13 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
private renderExpirationLength(): ReactNode {
const { i18n, expireTimer } = this.props;
const expirationSettingName = expireTimer
? ExpirationTimerOptions.getAbbreviated(i18n, expireTimer)
: undefined;
if (!expirationSettingName) {
if (!expireTimer) {
return null;
}
return (
<div className="module-ConversationHeader__header__info__subtitle__expiration">
{expirationSettingName}
{expirationTimer.format(i18n, expireTimer)}
</div>
);
}
@ -434,16 +428,18 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
<ContextMenu id={triggerId}>
{disableTimerChanges ? null : (
<SubMenu title={disappearingTitle}>
{ExpirationTimerOptions.map((item: typeof TimerOption) => (
<MenuItem
key={item.get('seconds')}
onClick={() => {
onSetDisappearingMessages(item.get('seconds'));
}}
>
{item.getName(i18n)}
</MenuItem>
))}
{expirationTimer.DEFAULT_DURATIONS_IN_SECONDS.map(
(seconds: number) => (
<MenuItem
key={seconds}
onClick={() => {
onSetDisappearingMessages(seconds);
}}
>
{expirationTimer.format(i18n, seconds)}
</MenuItem>
)
)}
</SubMenu>
)}
<SubMenu title={muteTitle}>

View file

@ -1,9 +1,10 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import * as moment from 'moment';
import { storiesOf } from '@storybook/react';
import { boolean, select, text } from '@storybook/addon-knobs';
import { boolean, number, select, text } from '@storybook/addon-knobs';
import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json';
@ -30,60 +31,69 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
text('profileName', overrideProps.profileName || '') || undefined,
title: text('title', overrideProps.title || ''),
name: text('name', overrideProps.name || '') || undefined,
disabled: boolean('disabled', overrideProps.disabled || false),
timespan: text('timespan', overrideProps.timespan || ''),
...(boolean('disabled', overrideProps.disabled || false)
? {
disabled: true,
}
: {
disabled: false,
expireTimer: number(
'expireTimer',
('expireTimer' in overrideProps ? overrideProps.expireTimer : 0) || 0
),
}),
});
story.add('Set By Other', () => {
const props = createProps({
expireTimer: moment.duration(1, 'hour').asSeconds(),
type: 'fromOther',
phoneNumber: '(202) 555-1000',
profileName: 'Mr. Fire',
title: 'Mr. Fire',
timespan: '1 hour',
});
return (
<>
<TimerNotification {...props} />
<div style={{ padding: '1em' }} />
<TimerNotification {...props} disabled timespan="Off" />
<TimerNotification {...props} disabled />
</>
);
});
story.add('Set By You', () => {
const props = createProps({
expireTimer: moment.duration(1, 'hour').asSeconds(),
type: 'fromMe',
phoneNumber: '(202) 555-1000',
profileName: 'Mr. Fire',
title: 'Mr. Fire',
timespan: '1 hour',
});
return (
<>
<TimerNotification {...props} />
<div style={{ padding: '1em' }} />
<TimerNotification {...props} disabled timespan="Off" />
<TimerNotification {...props} disabled />
</>
);
});
story.add('Set By Sync', () => {
const props = createProps({
expireTimer: moment.duration(1, 'hour').asSeconds(),
type: 'fromSync',
phoneNumber: '(202) 555-1000',
profileName: 'Mr. Fire',
title: 'Mr. Fire',
timespan: '1 hour',
});
return (
<>
<TimerNotification {...props} />
<div style={{ padding: '1em' }} />
<TimerNotification {...props} disabled timespan="Off" />
<TimerNotification {...props} disabled />
</>
);
});

View file

@ -1,12 +1,13 @@
// Copyright 2018-2020 Signal Messenger, LLC
// Copyright 2018-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import React, { FunctionComponent, ReactNode } from 'react';
import classNames from 'classnames';
import { ContactName } from './ContactName';
import { Intl } from '../Intl';
import { LocalizerType } from '../../types/Util';
import * as expirationTimer from '../../util/expirationTimer';
export type TimerNotificationType =
| 'fromOther'
@ -14,15 +15,22 @@ export type TimerNotificationType =
| 'fromSync'
| 'fromMember';
// We can't always use destructuring assignment because of the complexity of this props
// type.
/* eslint-disable react/destructuring-assignment */
export type PropsData = {
type: TimerNotificationType;
phoneNumber?: string;
profileName?: string;
title: string;
name?: string;
disabled: boolean;
timespan: string;
};
} & (
| { disabled: true }
| {
disabled: false;
expireTimer: number;
}
);
type PropsHousekeeping = {
i18n: LocalizerType;
@ -30,82 +38,74 @@ type PropsHousekeeping = {
export type Props = PropsData & PropsHousekeeping;
export class TimerNotification extends React.Component<Props> {
public renderContents(): JSX.Element | string | null {
const {
i18n,
name,
phoneNumber,
profileName,
title,
timespan,
type,
disabled,
} = this.props;
const changeKey = disabled
? 'disabledDisappearingMessages'
: 'theyChangedTheTimer';
export const TimerNotification: FunctionComponent<Props> = props => {
const { disabled, i18n, name, phoneNumber, profileName, title, type } = props;
switch (type) {
case 'fromOther':
return (
<Intl
i18n={i18n}
id={changeKey}
components={{
name: (
<ContactName
key="external-1"
phoneNumber={phoneNumber}
profileName={profileName}
title={title}
name={name}
i18n={i18n}
/>
),
time: timespan,
}}
/>
);
case 'fromMe':
return disabled
? i18n('youDisabledDisappearingMessages')
: i18n('youChangedTheTimer', [timespan]);
case 'fromSync':
return disabled
? i18n('disappearingMessagesDisabled')
: i18n('timerSetOnSync', [timespan]);
case 'fromMember':
return disabled
? i18n('disappearingMessagesDisabledByMember')
: i18n('timerSetByMember', [timespan]);
default:
window.log.warn('TimerNotification: unsupported type provided:', type);
return null;
}
let changeKey: string;
let timespan: string;
if (props.disabled) {
changeKey = 'disabledDisappearingMessages';
timespan = ''; // Set to the empty string to satisfy types
} else {
changeKey = 'theyChangedTheTimer';
timespan = expirationTimer.format(i18n, props.expireTimer);
}
public render(): JSX.Element {
const { timespan, disabled } = this.props;
let message: ReactNode;
switch (type) {
case 'fromOther':
message = (
<Intl
i18n={i18n}
id={changeKey}
components={{
name: (
<ContactName
key="external-1"
phoneNumber={phoneNumber}
profileName={profileName}
title={title}
name={name}
i18n={i18n}
/>
),
time: timespan,
}}
/>
);
break;
case 'fromMe':
message = disabled
? i18n('youDisabledDisappearingMessages')
: i18n('youChangedTheTimer', [timespan]);
break;
case 'fromSync':
message = disabled
? i18n('disappearingMessagesDisabled')
: i18n('timerSetOnSync', [timespan]);
break;
case 'fromMember':
message = disabled
? i18n('disappearingMessagesDisabledByMember')
: i18n('timerSetByMember', [timespan]);
break;
default:
window.log.warn('TimerNotification: unsupported type provided:', type);
break;
}
return (
<div className="module-timer-notification">
<div className="module-timer-notification__icon-container">
<div
className={classNames(
'module-timer-notification__icon',
disabled ? 'module-timer-notification__icon--disabled' : null
)}
/>
<div className="module-timer-notification__icon-label">
{timespan}
</div>
</div>
<div className="module-timer-notification__message">
{this.renderContents()}
</div>
return (
<div className="module-timer-notification">
<div className="module-timer-notification__icon-container">
<div
className={classNames(
'module-timer-notification__icon',
disabled ? 'module-timer-notification__icon--disabled' : null
)}
/>
<div className="module-timer-notification__icon-label">{timespan}</div>
</div>
);
}
}
<div className="module-timer-notification__message">{message}</div>
</div>
);
};

View file

@ -5,10 +5,8 @@ import React, { useState, ReactNode } from 'react';
import { ConversationType } from '../../../state/ducks/conversations';
import { assert } from '../../../util/assert';
import {
ExpirationTimerOptions,
TimerOption,
} from '../../../util/ExpirationTimerOptions';
import * as expirationTimer from '../../../util/expirationTimer';
import { LocalizerType } from '../../../types/Util';
import { MediaItemType } from '../../LightboxGallery';
import { missingCaseError } from '../../../util/missingCaseError';
@ -228,15 +226,20 @@ export const ConversationDetails: React.ComponentType<Props> = ({
onChange={updateExpireTimer}
value={conversation.expireTimer || 0}
>
{ExpirationTimerOptions.map((item: typeof TimerOption) => (
<option
value={item.get('seconds')}
key={item.get('seconds')}
aria-label={item.getName(i18n)}
>
{item.getName(i18n)}
</option>
))}
{expirationTimer.DEFAULT_DURATIONS_IN_SECONDS.map(
(seconds: number) => {
const label = expirationTimer.format(i18n, seconds);
return (
<option
value={seconds}
key={seconds}
aria-label={label}
>
{label}
</option>
);
}
)}
</select>
</div>
}

View file

@ -21,7 +21,7 @@ import {
} from '../components/conversation/Message';
import { OwnProps as SmartMessageDetailPropsType } from '../state/smart/MessageDetail';
import { CallbackResultType } from '../textsecure/SendMessage';
import { ExpirationTimerOptions } from '../util/ExpirationTimerOptions';
import * as expirationTimer from '../util/expirationTimer';
import { missingCaseError } from '../util/missingCaseError';
import { ColorType } from '../types/Colors';
import { CallMode } from '../types/Calling';
@ -629,10 +629,6 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
}
const { expireTimer, fromSync, source, sourceUuid } = timerUpdate;
const timespan = ExpirationTimerOptions.getName(
window.i18n,
expireTimer || 0
);
const disabled = !expireTimer;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@ -645,9 +641,9 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
const basicProps = {
...formattedContact,
type: 'fromOther' as TimerNotificationType,
timespan,
disabled,
expireTimer,
type: 'fromOther' as TimerNotificationType,
};
if (fromSync) {
@ -1552,7 +1548,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
return {
text: window.i18n('timerSetTo', [
ExpirationTimerOptions.getAbbreviated(window.i18n, expireTimer || 0),
expirationTimer.format(window.i18n, expireTimer),
]),
};
}

View file

@ -82,7 +82,16 @@ export function getEmptyState(): UserStateType {
platform: 'missing',
interactionMode: 'mouse',
theme: ThemeType.light,
i18n: () => 'missing',
i18n: Object.assign(
() => {
throw new Error('i18n not yet set up');
},
{
getLocale() {
throw new Error('i18n not yet set up');
},
}
),
};
}

View file

@ -0,0 +1,132 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import * as moment from 'moment';
import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json';
import esMessages from '../../../_locales/es/messages.json';
import nbMessages from '../../../_locales/nb/messages.json';
import nnMessages from '../../../_locales/nn/messages.json';
import ptBrMessages from '../../../_locales/pt_BR/messages.json';
import zhCnMessages from '../../../_locales/zh_CN/messages.json';
import * as expirationTimer from '../../util/expirationTimer';
describe('expiration timer utilities', () => {
const i18n = setupI18n('en', enMessages);
describe('DEFAULT_DURATIONS_IN_SECONDS', () => {
const { DEFAULT_DURATIONS_IN_SECONDS } = expirationTimer;
it('includes at least 3 durations', () => {
assert.isAtLeast(DEFAULT_DURATIONS_IN_SECONDS.length, 3);
});
it('includes 1 hour as seconds', () => {
const oneHour = moment.duration(1, 'hour').asSeconds();
assert.include(DEFAULT_DURATIONS_IN_SECONDS, oneHour);
});
});
describe('format', () => {
const { format } = expirationTimer;
it('handles an undefined duration', () => {
assert.strictEqual(format(i18n, undefined), 'off');
});
it('handles no duration', () => {
assert.strictEqual(format(i18n, 0), 'off');
});
it('formats durations', () => {
new Map<number, string>([
[1, '1 second'],
[2, '2 seconds'],
[30, '30 seconds'],
[59, '59 seconds'],
[moment.duration(1, 'm').asSeconds(), '1 minute'],
[moment.duration(5, 'm').asSeconds(), '5 minutes'],
[moment.duration(1, 'h').asSeconds(), '1 hour'],
[moment.duration(8, 'h').asSeconds(), '8 hours'],
[moment.duration(1, 'd').asSeconds(), '1 day'],
[moment.duration(6, 'd').asSeconds(), '6 days'],
[moment.duration(8, 'd').asSeconds(), '8 days'],
[moment.duration(30, 'd').asSeconds(), '30 days'],
[moment.duration(365, 'd').asSeconds(), '365 days'],
[moment.duration(1, 'w').asSeconds(), '1 week'],
[moment.duration(3, 'w').asSeconds(), '3 weeks'],
[moment.duration(52, 'w').asSeconds(), '52 weeks'],
]).forEach((expected, input) => {
assert.strictEqual(format(i18n, input), expected);
});
});
it('formats other languages successfully', () => {
const esI18n = setupI18n('es', esMessages);
assert.strictEqual(format(esI18n, 120), '2 minutos');
const zhCnI18n = setupI18n('zh_CN', zhCnMessages);
assert.strictEqual(format(zhCnI18n, 60), '1 分钟');
// The underlying library supports the "pt" locale, not the "pt_BR" locale. That's
// what we're testing here.
const ptBrI18n = setupI18n('pt_BR', ptBrMessages);
assert.strictEqual(
format(ptBrI18n, moment.duration(5, 'days').asSeconds()),
'5 dias'
);
// The underlying library supports the Norwegian language, which is a macrolanguage
// for Bokmål and Nynorsk.
[setupI18n('nb', nbMessages), setupI18n('nn', nnMessages)].forEach(
norwegianI18n => {
assert.strictEqual(
format(norwegianI18n, moment.duration(6, 'hours').asSeconds()),
'6 timer'
);
}
);
});
it('falls back to English if the locale is not supported', () => {
const badI18n = setupI18n('bogus', {});
assert.strictEqual(format(badI18n, 120), '2 minutes');
});
it('handles a "mix" of units gracefully', () => {
// We don't expect there to be a "mix" of units, but we shouldn't choke if a bad
// client gives us an unexpected timestamp.
const mix = moment
.duration(6, 'days')
.add(moment.duration(2, 'hours'))
.asSeconds();
assert.strictEqual(format(i18n, mix), '6 days, 2 hours');
});
it('handles negative numbers gracefully', () => {
// The proto helps enforce non-negative numbers by specifying a u32, but because
// JavaScript lacks such a type, we test it here.
assert.strictEqual(format(i18n, -1), '1 second');
assert.strictEqual(format(i18n, -120), '2 minutes');
assert.strictEqual(format(i18n, -0), 'off');
});
it('handles fractional seconds gracefully', () => {
// The proto helps enforce integer numbers by specifying a u32, but this function
// shouldn't choke if bad data is passed somehow.
assert.strictEqual(format(i18n, 4.2), '4 seconds');
assert.strictEqual(format(i18n, 4.8), '4 seconds');
assert.strictEqual(format(i18n, 0.2), '1 second');
assert.strictEqual(format(i18n, 0.8), '1 second');
// If multiple things go wrong and we pass a fractional negative number, we still
// shouldn't explode.
assert.strictEqual(format(i18n, -4.2), '4 seconds');
assert.strictEqual(format(i18n, -4.8), '4 seconds');
assert.strictEqual(format(i18n, -0.2), '1 second');
assert.strictEqual(format(i18n, -0.8), '1 second');
});
});
});

View file

@ -67,7 +67,7 @@ describe('MentionCompletion', () => {
};
const options: MentionCompletionOptions = {
i18n: sinon.stub(),
i18n: Object.assign(sinon.stub(), { getLocale: sinon.stub() }),
me,
memberRepositoryRef,
setMentionPickerElement: sinon.stub(),

View file

@ -20,10 +20,10 @@ export type ReplacementValuesType = {
[key: string]: string | undefined;
};
export type LocalizerType = (
key: string,
values?: Array<string | null> | ReplacementValuesType
) => string;
export type LocalizerType = {
(key: string, values?: Array<string | null> | ReplacementValuesType): string;
getLocale(): string;
};
export enum ThemeType {
'light' = 'light',

View file

@ -1,77 +0,0 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as Backbone from 'backbone';
import * as moment from 'moment';
import { LocalizerType } from '../types/Util';
type ExpirationTime = [
number,
(
| 'second'
| 'seconds'
| 'minute'
| 'minutes'
| 'hour'
| 'hours'
| 'day'
| 'week'
)
];
const EXPIRATION_TIMES: Array<ExpirationTime> = [
[0, 'seconds'],
[5, 'seconds'],
[10, 'seconds'],
[30, 'seconds'],
[1, 'minute'],
[5, 'minutes'],
[30, 'minutes'],
[1, 'hour'],
[6, 'hours'],
[12, 'hours'],
[1, 'day'],
[1, 'week'],
];
export const TimerOption = Backbone.Model.extend({
getName(i18n: LocalizerType) {
return (
i18n(['timerOption', this.get('time'), this.get('unit')].join('_')) ||
moment.duration(this.get('time'), this.get('unit')).humanize()
);
},
getAbbreviated(i18n: LocalizerType) {
return i18n(
['timerOption', this.get('time'), this.get('unit'), 'abbreviated'].join(
'_'
)
);
},
});
export const ExpirationTimerOptions = new (Backbone.Collection.extend({
model: TimerOption,
getName(i18n: LocalizerType, seconds = 0) {
const o = this.findWhere({ seconds });
if (o) {
return o.getName(i18n);
}
return [seconds, 'seconds'].join(' ');
},
getAbbreviated(i18n: LocalizerType, seconds = 0) {
const o = this.findWhere({ seconds });
if (o) {
return o.getAbbreviated(i18n);
}
return [seconds, 's'].join('');
},
}))(
EXPIRATION_TIMES.map(o => {
const duration = moment.duration(o[0], o[1]); // 5, 'seconds'
return {
time: o[0],
unit: o[1],
seconds: duration.asSeconds(),
};
})
);

View file

@ -0,0 +1,49 @@
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as moment from 'moment';
import humanizeDuration from 'humanize-duration';
import { LocalizerType } from '../types/Util';
const SECONDS_PER_WEEK = 604800;
export const DEFAULT_DURATIONS_IN_SECONDS = [
0,
5,
10,
30,
moment.duration(1, 'minute').asSeconds(),
moment.duration(5, 'minutes').asSeconds(),
moment.duration(30, 'minutes').asSeconds(),
moment.duration(1, 'hour').asSeconds(),
moment.duration(6, 'hours').asSeconds(),
moment.duration(12, 'hours').asSeconds(),
moment.duration(1, 'day').asSeconds(),
moment.duration(1, 'week').asSeconds(),
];
export function format(i18n: LocalizerType, dirtySeconds?: number): string {
let seconds = Math.abs(dirtySeconds || 0);
if (!seconds) {
return i18n('disappearingMessages__off');
}
seconds = Math.max(Math.floor(seconds), 1);
const locale: string = i18n.getLocale();
const localeWithoutRegion: string = locale.split('_', 1)[0];
const fallbacks: Array<string> = [];
if (localeWithoutRegion !== locale) {
fallbacks.push(localeWithoutRegion);
}
if (localeWithoutRegion === 'nb' || localeWithoutRegion === 'nn') {
fallbacks.push('no');
}
if (localeWithoutRegion !== 'en') {
fallbacks.push('en');
}
return humanizeDuration(seconds * 1000, {
units: seconds % SECONDS_PER_WEEK === 0 ? ['w'] : ['d', 'h', 'm', 's'],
language: locale,
...(fallbacks.length ? { fallbacks } : {}),
});
}

View file

@ -16492,7 +16492,7 @@
"rule": "React-createRef",
"path": "ts/components/conversation/ConversationHeader.js",
"line": " this.menuTriggerRef = react_1.default.createRef();",
"lineNumber": 32,
"lineNumber": 51,
"reasonCategory": "usageTrusted",
"updated": "2020-08-28T16:12:19.904Z",
"reasonDetail": "Used to reference popup menu"
@ -16501,7 +16501,7 @@
"rule": "React-createRef",
"path": "ts/components/conversation/ConversationHeader.tsx",
"line": " this.menuTriggerRef = React.createRef();",
"lineNumber": 112,
"lineNumber": 109,
"reasonCategory": "usageTrusted",
"updated": "2020-05-20T20:10:43.540Z",
"reasonDetail": "Used to reference popup menu"