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

@ -1538,6 +1538,33 @@ Signal Desktop makes use of the following open source projects.
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
## humanize-duration
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org/>
## intl-tel-input
The MIT License (MIT)

View File

@ -1657,106 +1657,14 @@
}
}
},
"timerOption_0_seconds": {
"disappearingMessages__off": {
"message": "off",
"description": "Label for option to turn off message expiration in the timer menu"
},
"timerOption_5_seconds": {
"message": "5 seconds",
"description": "Label for a selectable option in the message expiration timer menu"
},
"timerOption_10_seconds": {
"message": "10 seconds",
"description": "Label for a selectable option in the message expiration timer menu"
},
"timerOption_30_seconds": {
"message": "30 seconds",
"description": "Label for a selectable option in the message expiration timer menu"
},
"timerOption_1_minute": {
"message": "1 minute",
"description": "Label for a selectable option in the message expiration timer menu"
},
"timerOption_5_minutes": {
"message": "5 minutes",
"description": "Label for a selectable option in the message expiration timer menu"
},
"timerOption_30_minutes": {
"message": "30 minutes",
"description": "Label for a selectable option in the message expiration timer menu"
},
"timerOption_1_hour": {
"message": "1 hour",
"description": "Label for a selectable option in the message expiration timer menu"
},
"timerOption_6_hours": {
"message": "6 hours",
"description": "Label for a selectable option in the message expiration timer menu"
},
"timerOption_12_hours": {
"message": "12 hours",
"description": "Label for a selectable option in the message expiration timer menu"
},
"timerOption_1_day": {
"message": "1 day",
"description": "Label for a selectable option in the message expiration timer menu"
},
"timerOption_1_week": {
"message": "1 week",
"description": "Label for a selectable option in the message expiration timer menu"
},
"disappearingMessages": {
"message": "Disappearing messages",
"description": "Conversation menu option to enable disappearing messages"
},
"timerOption_0_seconds_abbreviated": {
"message": "off",
"description": "Short format indicating current timer setting in the conversation list snippet"
},
"timerOption_5_seconds_abbreviated": {
"message": "5s",
"description": "Very short format indicating current timer setting in the conversation header"
},
"timerOption_10_seconds_abbreviated": {
"message": "10s",
"description": "Very short format indicating current timer setting in the conversation header"
},
"timerOption_30_seconds_abbreviated": {
"message": "30s",
"description": "Very short format indicating current timer setting in the conversation header"
},
"timerOption_1_minute_abbreviated": {
"message": "1m",
"description": "Very short format indicating current timer setting in the conversation header"
},
"timerOption_5_minutes_abbreviated": {
"message": "5m",
"description": "Very short format indicating current timer setting in the conversation header"
},
"timerOption_30_minutes_abbreviated": {
"message": "30m",
"description": "Very short format indicating current timer setting in the conversation header"
},
"timerOption_1_hour_abbreviated": {
"message": "1h",
"description": "Very short format indicating current timer setting in the conversation header"
},
"timerOption_6_hours_abbreviated": {
"message": "6h",
"description": "Very short format indicating current timer setting in the conversation header"
},
"timerOption_12_hours_abbreviated": {
"message": "12h",
"description": "Very short format indicating current timer setting in the conversation header"
},
"timerOption_1_day_abbreviated": {
"message": "1d",
"description": "Very short format indicating current timer setting in the conversation header"
},
"timerOption_1_week_abbreviated": {
"message": "1w",
"description": "Very short format indicating current timer setting in the conversation header"
},
"disappearingMessagesDisabled": {
"message": "Disappearing messages disabled",
"description": "Displayed in the left pane when the timer is turned off"

View File

@ -97,6 +97,7 @@
"google-libphonenumber": "3.2.17",
"got": "8.3.2",
"history": "4.9.0",
"humanize-duration": "3.26.0",
"intl-tel-input": "12.1.15",
"jquery": "3.5.0",
"js-yaml": "3.13.1",
@ -186,6 +187,7 @@
"@types/google-libphonenumber": "7.4.14",
"@types/got": "9.4.1",
"@types/history": "4.7.2",
"@types/humanize-duration": "^3.18.1",
"@types/jquery": "3.5.0",
"@types/js-yaml": "3.12.0",
"@types/linkify-it": "2.1.0",

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"

View File

@ -2344,6 +2344,11 @@
dependencies:
"@types/node" "*"
"@types/humanize-duration@^3.18.1":
version "3.18.1"
resolved "https://registry.yarnpkg.com/@types/humanize-duration/-/humanize-duration-3.18.1.tgz#10090d596053703e7de0ac43a37b96cd9fc78309"
integrity sha512-MUgbY3CF7hg/a/jogixmAufLjJBQT7WEf8Q+kYJkOc47ytngg1IuZobCngdTjAgY83JWEogippge5O5fplaQlw==
"@types/integer@*":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@types/integer/-/integer-1.0.1.tgz#025d87e30d97f539fcc6087372af7d3672ffbbe6"
@ -9607,6 +9612,11 @@ human-signals@^2.1.0:
resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0"
integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==
humanize-duration@3.26.0:
version "3.26.0"
resolved "https://registry.yarnpkg.com/humanize-duration/-/humanize-duration-3.26.0.tgz#4d77f6b3d2fe0ca1ff14623ccc2b2f8b48ab1aaf"
integrity sha512-SddekX3p5ApvPY6bbAYppGKe874jP6iFZXYtrQToDV4R0j2UpTYPqwTFM2QpXpuw9DhS/eXTUnKYTF9TbXAJ6A==
iconv-corefoundation@^1.1.5:
version "1.1.5"
resolved "https://registry.yarnpkg.com/iconv-corefoundation/-/iconv-corefoundation-1.1.5.tgz#90596d444a579aeb109f5ca113f6bb665a41be2b"