Render disappearing message timers generically
This commit is contained in:
parent
c1730e055f
commit
736075322c
16 changed files with 372 additions and 307 deletions
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
},
|
||||
}
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
132
ts/test-both/util/expirationTimer_test.ts
Normal file
132
ts/test-both/util/expirationTimer_test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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(),
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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(),
|
||||
};
|
||||
})
|
||||
);
|
49
ts/util/expirationTimer.ts
Normal file
49
ts/util/expirationTimer.ts
Normal 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 } : {}),
|
||||
});
|
||||
}
|
|
@ -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"
|
||||
|
|
10
yarn.lock
10
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"
|
||||
|
|
Loading…
Reference in a new issue