diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md index 752bfe2d2173..35102f518ef5 100644 --- a/ACKNOWLEDGMENTS.md +++ b/ACKNOWLEDGMENTS.md @@ -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 + ## intl-tel-input The MIT License (MIT) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index f0446ceb6ae3..ec5eb8b1a774 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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" diff --git a/package.json b/package.json index 90370c16d363..24efc399cbc5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/ts/components/conversation/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx index e1605b36b74a..f095d09468a0 100644 --- a/ts/components/conversation/ConversationHeader.tsx +++ b/ts/components/conversation/ConversationHeader.tsx @@ -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 { private renderExpirationLength(): ReactNode { const { i18n, expireTimer } = this.props; - const expirationSettingName = expireTimer - ? ExpirationTimerOptions.getAbbreviated(i18n, expireTimer) - : undefined; - if (!expirationSettingName) { + if (!expireTimer) { return null; } return (
- {expirationSettingName} + {expirationTimer.format(i18n, expireTimer)}
); } @@ -434,16 +428,18 @@ export class ConversationHeader extends React.Component { {disableTimerChanges ? null : ( - {ExpirationTimerOptions.map((item: typeof TimerOption) => ( - { - onSetDisappearingMessages(item.get('seconds')); - }} - > - {item.getName(i18n)} - - ))} + {expirationTimer.DEFAULT_DURATIONS_IN_SECONDS.map( + (seconds: number) => ( + { + onSetDisappearingMessages(seconds); + }} + > + {expirationTimer.format(i18n, seconds)} + + ) + )} )} diff --git a/ts/components/conversation/TimerNotification.stories.tsx b/ts/components/conversation/TimerNotification.stories.tsx index 9f9370004ce8..71553137a07b 100644 --- a/ts/components/conversation/TimerNotification.stories.tsx +++ b/ts/components/conversation/TimerNotification.stories.tsx @@ -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 => ({ 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 ( <>
- + ); }); 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 ( <>
- + ); }); 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 ( <>
- + ); }); diff --git a/ts/components/conversation/TimerNotification.tsx b/ts/components/conversation/TimerNotification.tsx index 2402ebc2144c..d358f07ebc74 100644 --- a/ts/components/conversation/TimerNotification.tsx +++ b/ts/components/conversation/TimerNotification.tsx @@ -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 { - 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 => { + const { disabled, i18n, name, phoneNumber, profileName, title, type } = props; - switch (type) { - case 'fromOther': - return ( - - ), - 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 = ( + + ), + 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 ( -
-
-
-
- {timespan} -
-
-
- {this.renderContents()} -
+ return ( +
+
+
+
{timespan}
- ); - } -} +
{message}
+
+ ); +}; diff --git a/ts/components/conversation/conversation-details/ConversationDetails.tsx b/ts/components/conversation/conversation-details/ConversationDetails.tsx index 618c0879fb5c..b221625675be 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.tsx @@ -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 = ({ onChange={updateExpireTimer} value={conversation.expireTimer || 0} > - {ExpirationTimerOptions.map((item: typeof TimerOption) => ( - - ))} + {expirationTimer.DEFAULT_DURATIONS_IN_SECONDS.map( + (seconds: number) => { + const label = expirationTimer.format(i18n, seconds); + return ( + + ); + } + )}
} diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 1a508ddd4d1c..b19e3bfa710b 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -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 { } 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 { 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 { return { text: window.i18n('timerSetTo', [ - ExpirationTimerOptions.getAbbreviated(window.i18n, expireTimer || 0), + expirationTimer.format(window.i18n, expireTimer), ]), }; } diff --git a/ts/state/ducks/user.ts b/ts/state/ducks/user.ts index 186beea9522c..763f081ac056 100644 --- a/ts/state/ducks/user.ts +++ b/ts/state/ducks/user.ts @@ -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'); + }, + } + ), }; } diff --git a/ts/test-both/util/expirationTimer_test.ts b/ts/test-both/util/expirationTimer_test.ts new file mode 100644 index 000000000000..454ce8695328 --- /dev/null +++ b/ts/test-both/util/expirationTimer_test.ts @@ -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([ + [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'); + }); + }); +}); diff --git a/ts/test-electron/quill/mentions/completion_test.tsx b/ts/test-electron/quill/mentions/completion_test.tsx index 34e10d3cbb44..20fcc7a79cd6 100644 --- a/ts/test-electron/quill/mentions/completion_test.tsx +++ b/ts/test-electron/quill/mentions/completion_test.tsx @@ -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(), diff --git a/ts/types/Util.ts b/ts/types/Util.ts index 3b7687c2c28e..3ef77d823d82 100644 --- a/ts/types/Util.ts +++ b/ts/types/Util.ts @@ -20,10 +20,10 @@ export type ReplacementValuesType = { [key: string]: string | undefined; }; -export type LocalizerType = ( - key: string, - values?: Array | ReplacementValuesType -) => string; +export type LocalizerType = { + (key: string, values?: Array | ReplacementValuesType): string; + getLocale(): string; +}; export enum ThemeType { 'light' = 'light', diff --git a/ts/util/ExpirationTimerOptions.ts b/ts/util/ExpirationTimerOptions.ts deleted file mode 100644 index 4242125d52c4..000000000000 --- a/ts/util/ExpirationTimerOptions.ts +++ /dev/null @@ -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 = [ - [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(), - }; - }) -); diff --git a/ts/util/expirationTimer.ts b/ts/util/expirationTimer.ts new file mode 100644 index 000000000000..c2134c166583 --- /dev/null +++ b/ts/util/expirationTimer.ts @@ -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 = []; + 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 } : {}), + }); +} diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 83439e5af3fa..aaeda5c89210 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -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" diff --git a/yarn.lock b/yarn.lock index 90b3ecd7d5ad..4d9d9f251898 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"