Universal Disappearing Messages

This commit is contained in:
Fedor Indutny 2021-06-01 13:45:43 -07:00 committed by GitHub
parent c63871d71b
commit 19f8042cd3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
50 changed files with 1224 additions and 191 deletions

View file

@ -34,6 +34,7 @@ import {
RetryRequestType,
} from './textsecure/MessageReceiver';
import { connectToServerWithStoredCredentials } from './util/connectToServerWithStoredCredentials';
import * as universalExpireTimer from './util/universalExpireTimer';
const MAX_ATTACHMENT_DOWNLOAD_AGE = 3600 * 72 * 1000;
@ -513,6 +514,15 @@ export async function startApp(): Promise<void> {
getLastSyncTime: () => window.storage.get('synced_at'),
setLastSyncTime: (value: number) =>
window.storage.put('synced_at', value),
getUniversalExpireTimer: (): number | undefined => {
return universalExpireTimer.get();
},
setUniversalExpireTimer: async (
newValue: number | undefined
): Promise<void> => {
await universalExpireTimer.set(newValue);
window.Signal.Services.storageServiceUploadJob();
},
addDarkOverlay: () => {
if ($('.dark-overlay').length) {

View file

@ -14,6 +14,7 @@ export type ActionSpec = {
};
export type OwnProps = {
readonly moduleClassName?: string;
readonly actions?: Array<ActionSpec>;
readonly cancelText?: string;
readonly children?: React.ReactNode;
@ -22,6 +23,7 @@ export type OwnProps = {
readonly onClose: () => unknown;
readonly title?: string | React.ReactNode;
readonly theme?: Theme;
readonly hasXButton?: boolean;
};
export type Props = OwnProps;
@ -48,6 +50,7 @@ function getButtonVariant(
export const ConfirmationDialog = React.memo(
({
moduleClassName,
actions = [],
cancelText,
children,
@ -56,6 +59,7 @@ export const ConfirmationDialog = React.memo(
onClose,
theme,
title,
hasXButton,
}: Props) => {
const cancelAndClose = React.useCallback(() => {
if (onCancel) {
@ -76,7 +80,14 @@ export const ConfirmationDialog = React.memo(
const hasActions = Boolean(actions.length);
return (
<Modal i18n={i18n} onClose={cancelAndClose} title={title} theme={theme}>
<Modal
moduleClassName={moduleClassName}
i18n={i18n}
onClose={cancelAndClose}
title={title}
theme={theme}
hasXButton={hasXButton}
>
{children}
<Modal.ButtonFooter>
<Button

View file

@ -0,0 +1,31 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useState } from 'react';
import { action } from '@storybook/addon-actions';
import { storiesOf } from '@storybook/react';
import { Select } from './Select';
const story = storiesOf('Components/Select', module);
story.add('Normal', () => {
const [value, setValue] = useState(0);
const onChange = action('onChange');
return (
<Select
options={[
{ value: 1, text: '1' },
{ value: 2, text: '2' },
{ value: 3, text: '3' },
]}
value={value}
onChange={newValue => {
onChange(newValue);
setValue(parseInt(newValue, 10));
}}
/>
);
});

39
ts/components/Select.tsx Normal file
View file

@ -0,0 +1,39 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import classNames from 'classnames';
export type Option = Readonly<{
text: string;
value: string | number;
}>;
export type PropsType = Readonly<{
moduleClassName?: string;
options: ReadonlyArray<Option>;
onChange(value: string): void;
value: string | number;
}>;
export function Select(props: PropsType): JSX.Element {
const { moduleClassName, value, options, onChange } = props;
const onSelectChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
onChange(event.target.value);
};
return (
<div className={classNames(['module-select', moduleClassName])}>
<select value={value} onChange={onSelectChange}>
{options.map(({ text, value: optionValue }) => {
return (
<option value={optionValue} key={optionValue} aria-label={text}>
{text}
</option>
);
})}
</select>
</div>
);
}

View file

@ -107,7 +107,7 @@ const stories: Array<ConversationHeaderStory> = [
name: 'Joyrey 🔥 Leppey',
phoneNumber: '(202) 555-0002',
type: 'direct',
id: '2',
id: '3',
acceptedMessageRequest: true,
},
},
@ -119,7 +119,7 @@ const stories: Array<ConversationHeaderStory> = [
isVerified: false,
phoneNumber: '(202) 555-0003',
type: 'direct',
id: '3',
id: '4',
title: '🔥Flames🔥',
profileName: '🔥Flames🔥',
acceptedMessageRequest: true,
@ -132,7 +132,7 @@ const stories: Array<ConversationHeaderStory> = [
title: '(202) 555-0011',
phoneNumber: '(202) 555-0011',
type: 'direct',
id: '11',
id: '5',
acceptedMessageRequest: true,
},
},
@ -145,7 +145,7 @@ const stories: Array<ConversationHeaderStory> = [
phoneNumber: '(202) 555-0004',
title: '(202) 555-0004',
type: 'direct',
id: '4',
id: '6',
acceptedMessageRequest: true,
},
},
@ -157,7 +157,7 @@ const stories: Array<ConversationHeaderStory> = [
title: '(202) 555-0005',
phoneNumber: '(202) 555-0005',
type: 'direct',
id: '5',
id: '7',
expireTimer: 10,
acceptedMessageRequest: true,
},
@ -170,10 +170,11 @@ const stories: Array<ConversationHeaderStory> = [
title: '(202) 555-0005',
phoneNumber: '(202) 555-0005',
type: 'direct',
id: '5',
expireTimer: 60,
id: '8',
expireTimer: 300,
acceptedMessageRequest: true,
isVerified: true,
canChangeTimer: true,
},
},
{
@ -184,7 +185,7 @@ const stories: Array<ConversationHeaderStory> = [
title: '(202) 555-0006',
phoneNumber: '(202) 555-0006',
type: 'direct',
id: '6',
id: '9',
acceptedMessageRequest: true,
muteExpiresAt: new Date('3000-10-18T11:11:11Z').valueOf(),
},
@ -197,7 +198,7 @@ const stories: Array<ConversationHeaderStory> = [
title: '(202) 555-0006',
phoneNumber: '(202) 555-0006',
type: 'direct',
id: '6',
id: '10',
acceptedMessageRequest: true,
isSMSOnly: true,
},
@ -217,7 +218,7 @@ const stories: Array<ConversationHeaderStory> = [
title: 'Typescript support group',
name: 'Typescript support group',
phoneNumber: '',
id: '1',
id: '11',
type: 'group',
expireTimer: 10,
acceptedMessageRequest: true,
@ -232,7 +233,7 @@ const stories: Array<ConversationHeaderStory> = [
title: 'Typescript support group',
name: 'Typescript support group',
phoneNumber: '',
id: '2',
id: '12',
type: 'group',
left: true,
expireTimer: 10,
@ -248,7 +249,7 @@ const stories: Array<ConversationHeaderStory> = [
title: 'Typescript support group',
name: 'Typescript support group',
phoneNumber: '',
id: '1',
id: '13',
type: 'group',
expireTimer: 10,
acceptedMessageRequest: true,
@ -263,7 +264,7 @@ const stories: Array<ConversationHeaderStory> = [
title: 'Way too many messages',
name: 'Way too many messages',
phoneNumber: '',
id: '1',
id: '14',
type: 'group',
expireTimer: 10,
acceptedMessageRequest: true,
@ -284,7 +285,7 @@ const stories: Array<ConversationHeaderStory> = [
color: 'blue',
title: '(202) 555-0007',
phoneNumber: '(202) 555-0007',
id: '7',
id: '15',
type: 'direct',
isMe: true,
acceptedMessageRequest: true,
@ -304,7 +305,7 @@ const stories: Array<ConversationHeaderStory> = [
color: 'blue',
title: '(202) 555-0007',
phoneNumber: '(202) 555-0007',
id: '7',
id: '16',
type: 'direct',
isMe: false,
acceptedMessageRequest: false,

View file

@ -13,6 +13,7 @@ import {
} from 'react-contextmenu';
import { Emojify } from './Emojify';
import { DisappearingTimeDialog } from './DisappearingTimeDialog';
import { Avatar, AvatarSize } from '../Avatar';
import { InContactsIcon } from '../InContactsIcon';
@ -92,10 +93,18 @@ export type PropsType = PropsDataType &
PropsActionsType &
PropsHousekeepingType;
enum ModalState {
NothingOpen,
CustomDisappearingTimeout,
}
type StateType = {
isNarrow: boolean;
modalState: ModalState;
};
const TIMER_ITEM_CLASS = 'module-ConversationHeader__disappearing-timer__item';
export class ConversationHeader extends React.Component<PropsType, StateType> {
private showMenuBound: (event: React.MouseEvent<HTMLButtonElement>) => void;
@ -106,7 +115,7 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
public constructor(props: PropsType) {
super(props);
this.state = { isNarrow: false };
this.state = { isNarrow: false, modalState: ModalState.NothingOpen };
this.menuTriggerRef = React.createRef();
this.showMenuBound = this.showMenu.bind(this);
@ -355,6 +364,7 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
i18n,
acceptedMessageRequest,
canChangeTimer,
expireTimer,
isArchived,
isMe,
isPinned,
@ -427,23 +437,60 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
const hasGV2AdminEnabled = isGroup && groupVersion === 2;
const isActiveExpireTimer = (value: number): boolean => {
if (!expireTimer) {
return value === 0;
}
// Custom time...
if (value === -1) {
return !expirationTimer.DEFAULT_DURATIONS_SET.has(expireTimer);
}
return value === expireTimer;
};
const expireDurations: ReadonlyArray<ReactNode> = [
...expirationTimer.DEFAULT_DURATIONS_IN_SECONDS,
-1,
].map((seconds: number) => {
let text: string;
if (seconds === -1) {
text = i18n('customDisappearingTimeOption');
} else {
text = expirationTimer.format(i18n, seconds, {
capitalizeOff: true,
});
}
const onDurationClick = () => {
if (seconds === -1) {
this.setState({
modalState: ModalState.CustomDisappearingTimeout,
});
} else {
onSetDisappearingMessages(seconds);
}
};
return (
<MenuItem key={seconds} onClick={onDurationClick}>
<div
className={classNames(
TIMER_ITEM_CLASS,
isActiveExpireTimer(seconds) && `${TIMER_ITEM_CLASS}--active`
)}
>
{text}
</div>
</MenuItem>
);
});
return (
<ContextMenu id={triggerId}>
{disableTimerChanges ? null : (
<SubMenu title={disappearingTitle}>
{expirationTimer.DEFAULT_DURATIONS_IN_SECONDS.map(
(seconds: number) => (
<MenuItem
key={seconds}
onClick={() => {
onSetDisappearingMessages(seconds);
}}
>
{expirationTimer.format(i18n, seconds)}
</MenuItem>
)
)}
</SubMenu>
<SubMenu title={disappearingTitle}>{expireDurations}</SubMenu>
)}
<SubMenu title={muteTitle}>
{muteOptions.map(item => (
@ -578,36 +625,64 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
}
public render(): ReactNode {
const { id, isSMSOnly } = this.props;
const { isNarrow } = this.state;
const {
id,
isSMSOnly,
i18n,
onSetDisappearingMessages,
expireTimer,
} = this.props;
const { isNarrow, modalState } = this.state;
const triggerId = `conversation-${id}`;
let modalNode: ReactNode;
if (modalState === ModalState.NothingOpen) {
modalNode = undefined;
} else if (modalState === ModalState.CustomDisappearingTimeout) {
modalNode = (
<DisappearingTimeDialog
i18n={i18n}
initialValue={expireTimer}
onSubmit={value => {
this.setState({ modalState: ModalState.NothingOpen });
onSetDisappearingMessages(value);
}}
onClose={() => this.setState({ modalState: ModalState.NothingOpen })}
/>
);
} else {
throw missingCaseError(modalState);
}
return (
<Measure
bounds
onResize={({ bounds }) => {
if (!bounds || !bounds.width) {
return;
}
this.setState({ isNarrow: bounds.width < 500 });
}}
>
{({ measureRef }) => (
<div
className={classNames('module-ConversationHeader', {
'module-ConversationHeader--narrow': isNarrow,
})}
ref={measureRef}
>
{this.renderBackButton()}
{this.renderHeader()}
{!isSMSOnly && this.renderOutgoingCallButtons()}
{this.renderSearchButton()}
{this.renderMoreButton(triggerId)}
{this.renderMenu(triggerId)}
</div>
)}
</Measure>
<>
{modalNode}
<Measure
bounds
onResize={({ bounds }) => {
if (!bounds || !bounds.width) {
return;
}
this.setState({ isNarrow: bounds.width < 500 });
}}
>
{({ measureRef }) => (
<div
className={classNames('module-ConversationHeader', {
'module-ConversationHeader--narrow': isNarrow,
})}
ref={measureRef}
>
{this.renderBackButton()}
{this.renderHeader()}
{!isSMSOnly && this.renderOutgoingCallButtons()}
{this.renderSearchButton()}
{this.renderMoreButton(triggerId)}
{this.renderMenu(triggerId)}
</div>
)}
</Measure>
</>
);
}
}

View file

@ -0,0 +1,29 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { action } from '@storybook/addon-actions';
import { storiesOf } from '@storybook/react';
import { DisappearingTimeDialog } from './DisappearingTimeDialog';
import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json';
import { EXPIRE_TIMERS } from '../../test-both/util/expireTimers';
const story = storiesOf('Components/DisappearingTimeDialog', module);
const i18n = setupI18n('en', enMessages);
EXPIRE_TIMERS.forEach(({ value, label }) => {
story.add(`Initial value: ${label}`, () => {
return (
<DisappearingTimeDialog
i18n={i18n}
initialValue={value}
onSubmit={action('onSubmit')}
onClose={action('onClose')}
/>
);
});
});

View file

@ -0,0 +1,124 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/* eslint-disable no-restricted-syntax */
import React, { useState } from 'react';
import { ConfirmationDialog } from '../ConfirmationDialog';
import { Select } from '../Select';
import { LocalizerType } from '../../types/Util';
import { Theme } from '../../util/theme';
const CSS_MODULE = 'module-disappearing-time-dialog';
const DEFAULT_VALUE = 60;
export type PropsType = Readonly<{
i18n: LocalizerType;
theme?: Theme;
initialValue?: number;
onSubmit: (value: number) => void;
onClose: () => void;
}>;
const UNITS = ['seconds', 'minutes', 'hours', 'days', 'weeks'];
const UNIT_TO_MS = new Map<string, number>([
['seconds', 1],
['minutes', 60],
['hours', 60 * 60],
['days', 24 * 60 * 60],
['weeks', 7 * 24 * 60 * 60],
]);
const RANGES = new Map<string, [number, number]>([
['seconds', [1, 60]],
['minutes', [1, 60]],
['hours', [1, 24]],
['days', [1, 7]],
['weeks', [1, 5]],
]);
export function DisappearingTimeDialog(props: PropsType): JSX.Element {
const {
i18n,
theme,
initialValue = DEFAULT_VALUE,
onSubmit,
onClose,
} = props;
let initialUnit = 'seconds';
let initialUnitValue = 1;
for (const unit of UNITS) {
const ms = UNIT_TO_MS.get(unit) || 1;
if (initialValue < ms) {
break;
}
initialUnit = unit;
initialUnitValue = Math.floor(initialValue / ms);
}
const [unitValue, setUnitValue] = useState(initialUnitValue);
const [unit, setUnit] = useState(initialUnit);
const range = RANGES.get(unit) || [1, 1];
const values: Array<number> = [];
for (let i = range[0]; i < range[1]; i += 1) {
values.push(i);
}
return (
<ConfirmationDialog
moduleClassName={CSS_MODULE}
i18n={i18n}
theme={theme}
onClose={onClose}
title={i18n('DisappearingTimeDialog__title')}
hasXButton
actions={[
{
text: i18n('DisappearingTimeDialog__set'),
style: 'affirmative',
action() {
onSubmit(unitValue * (UNIT_TO_MS.get(unit) || 1));
},
},
]}
>
<p>{i18n('DisappearingTimeDialog__body')}</p>
<section className={`${CSS_MODULE}__time-boxes`}>
<Select
moduleClassName={`${CSS_MODULE}__time-boxes__value`}
value={unitValue}
onChange={newValue => setUnitValue(parseInt(newValue, 10))}
options={values.map(value => ({ value, text: value.toString() }))}
/>
<Select
moduleClassName={`${CSS_MODULE}__time-boxes__units`}
value={unit}
onChange={newUnit => {
setUnit(newUnit);
const ranges = RANGES.get(newUnit);
if (!ranges) {
return;
}
const [min, max] = ranges;
setUnitValue(Math.max(min, Math.min(max - 1, unitValue)));
}}
options={UNITS.map(unitName => {
return {
value: unitName,
text: i18n(`DisappearingTimeDialog__${unitName}`),
};
})}
/>
</section>
</ConfirmationDialog>
);
}

View file

@ -298,6 +298,9 @@ const renderItem = (id: string) => (
conversationId=""
conversationAccepted
renderContact={() => '*ContactName*'}
renderUniversalTimerNotification={() => (
<div>*UniversalTimerNotification*</div>
)}
renderAudioAttachment={() => <div>*AudioAttachment*</div>}
{...actions()}
/>

View file

@ -10,6 +10,7 @@ import { EmojiPicker } from '../emoji/EmojiPicker';
import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json';
import { PropsType as TimelineItemProps, TimelineItem } from './TimelineItem';
import { UniversalTimerNotification } from './UniversalTimerNotification';
import { CallMode } from '../../types/Calling';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
@ -34,6 +35,10 @@ const renderContact = (conversationId: string) => (
<React.Fragment key={conversationId}>{conversationId}</React.Fragment>
);
const renderUniversalTimerNotification = () => (
<UniversalTimerNotification i18n={i18n} expireTimer={3600} />
);
const getDefaultProps = () => ({
conversationId: 'conversation-id',
conversationAccepted: true,
@ -73,6 +78,7 @@ const getDefaultProps = () => ({
returnToActiveCall: action('returnToActiveCall'),
renderContact,
renderUniversalTimerNotification,
renderEmojiPicker,
renderAudioAttachment: () => <div>*AudioAttachment*</div>,
});
@ -115,6 +121,10 @@ storiesOf('Components/Conversation/TimelineItem', module)
sender: getDefaultConversation(),
},
},
{
type: 'universalTimerNotification',
data: null,
},
{
type: 'callHistory',
data: {

View file

@ -90,6 +90,10 @@ type TimerNotificationType = {
type: 'timerNotification';
data: TimerNotificationProps;
};
type UniversalTimerNotificationType = {
type: 'universalTimerNotification';
data: null;
};
type SafetyNumberNotificationType = {
type: 'safetyNumberNotification';
data: SafetyNumberNotificationProps;
@ -132,6 +136,7 @@ export type TimelineItemType =
| ResetSessionNotificationType
| SafetyNumberNotificationType
| TimerNotificationType
| UniversalTimerNotificationType
| UnsupportedMessageType
| VerificationNotificationType;
@ -143,6 +148,7 @@ type PropsLocalType = {
isSelected: boolean;
selectMessage: (messageId: string, conversationId: string) => unknown;
renderContact: SmartContactRendererType;
renderUniversalTimerNotification: () => JSX.Element;
i18n: LocalizerType;
interactionMode: InteractionModeType;
theme?: ThemeType;
@ -169,6 +175,7 @@ export class TimelineItem extends React.PureComponent<PropsType> {
theme,
messageSizeChanged,
renderContact,
renderUniversalTimerNotification,
returnToActiveCall,
selectMessage,
startCallingLobby,
@ -225,6 +232,8 @@ export class TimelineItem extends React.PureComponent<PropsType> {
notification = (
<TimerNotification {...this.props} {...item.data} i18n={i18n} />
);
} else if (item.type === 'universalTimerNotification') {
notification = renderUniversalTimerNotification();
} else if (item.type === 'safetyNumberNotification') {
notification = (
<SafetyNumberNotification {...this.props} {...item.data} i18n={i18n} />

View file

@ -0,0 +1,21 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { storiesOf } from '@storybook/react';
import { UniversalTimerNotification } from './UniversalTimerNotification';
import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json';
import { EXPIRE_TIMERS } from '../../test-both/util/expireTimers';
const story = storiesOf('Components/UniversalTimerNotification', module);
const i18n = setupI18n('en', enMessages);
EXPIRE_TIMERS.forEach(({ value, label }) => {
story.add(`Initial value: ${label}`, () => {
return <UniversalTimerNotification i18n={i18n} expireTimer={value} />;
});
});

View file

@ -0,0 +1,28 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { LocalizerType } from '../../types/Util';
import * as expirationTimer from '../../util/expirationTimer';
export type Props = {
i18n: LocalizerType;
expireTimer: number;
};
export const UniversalTimerNotification: React.FC<Props> = props => {
const { i18n, expireTimer } = props;
if (!expireTimer) {
return null;
}
return (
<div className="module-universal-timer-notification">
{i18n('UniversalTimerNotification__text', {
timeValue: expirationTimer.format(i18n, expireTimer),
})}
</div>
);
};

View file

@ -29,13 +29,18 @@ const conversation: ConversationType = getDefaultConversation({
conversationColor: 'ultramarine' as const,
});
const createProps = (hasGroupLink = false): Props => ({
const createProps = (hasGroupLink = false, expireTimer?: number): Props => ({
addMembers: async () => {
action('addMembers');
},
canEditGroupInfo: false,
candidateContactsToAdd: times(10, () => getDefaultConversation()),
conversation,
conversation: expireTimer
? {
...conversation,
expireTimer,
}
: conversation,
hasGroupLink,
i18n,
isAdmin: false,
@ -122,6 +127,12 @@ story.add('Group Editable', () => {
return <ConversationDetails {...props} canEditGroupInfo />;
});
story.add('Group Editable with custom disappearing timeout', () => {
const props = createProps(false, 3 * 24 * 60 * 60);
return <ConversationDetails {...props} canEditGroupInfo />;
});
story.add('Group Links On', () => {
const props = createProps(true);

View file

@ -11,6 +11,10 @@ import { LocalizerType } from '../../../types/Util';
import { MediaItemType } from '../../LightboxGallery';
import { missingCaseError } from '../../../util/missingCaseError';
import { Select } from '../../Select';
import { DisappearingTimeDialog } from '../DisappearingTimeDialog';
import { PanelRow } from './PanelRow';
import { PanelSection } from './PanelSection';
import { AddGroupMembersModal } from './AddGroupMembersModal';
@ -34,6 +38,7 @@ enum ModalState {
NothingOpen,
EditingGroupAttributes,
AddingGroupMembers,
CustomDisappearingTimeout,
}
export type StateProps = {
@ -71,10 +76,6 @@ export type StateProps = {
export type Props = StateProps;
const expirationTimerDefaultSet = new Set<number>(
expirationTimer.DEFAULT_DURATIONS_IN_SECONDS
);
export const ConversationDetails: React.ComponentType<Props> = ({
addMembers,
canEditGroupInfo,
@ -111,8 +112,13 @@ export const ConversationDetails: React.ComponentType<Props> = ({
setAddGroupMembersRequestState,
] = useState<RequestState>(RequestState.Inactive);
const updateExpireTimer = (event: React.ChangeEvent<HTMLSelectElement>) => {
setDisappearingMessages(parseInt(event.target.value, 10));
const updateExpireTimer = (value: string) => {
const intValue = parseInt(value, 10);
if (intValue === -1) {
setModalState(ModalState.CustomDisappearingTimeout);
} else {
setDisappearingMessages(intValue);
}
};
if (conversation === undefined) {
@ -204,16 +210,54 @@ export const ConversationDetails: React.ComponentType<Props> = ({
/>
);
break;
case ModalState.CustomDisappearingTimeout:
modalNode = (
<DisappearingTimeDialog
i18n={i18n}
initialValue={conversation.expireTimer}
onSubmit={value => {
setModalState(ModalState.NothingOpen);
setDisappearingMessages(value);
}}
onClose={() => setModalState(ModalState.NothingOpen)}
/>
);
break;
default:
throw missingCaseError(modalState);
}
const expireTimer = conversation.expireTimer || 0;
const expireTimer: number = conversation.expireTimer || 0;
let expirationTimerDurations = expirationTimer.DEFAULT_DURATIONS_IN_SECONDS;
if (!expirationTimerDefaultSet.has(expireTimer)) {
expirationTimerDurations = [...expirationTimerDurations, expireTimer];
}
let expirationTimerOptions: ReadonlyArray<{
readonly value: number;
readonly text: string;
}> = expirationTimer.DEFAULT_DURATIONS_IN_SECONDS.map(seconds => {
const text = expirationTimer.format(i18n, seconds, {
capitalizeOff: true,
});
return {
value: seconds,
text,
};
});
const isCustomTimeSelected = !expirationTimer.DEFAULT_DURATIONS_SET.has(
expireTimer
);
// Custom time...
expirationTimerOptions = [
...expirationTimerOptions,
{
value: -1,
text: i18n(
isCustomTimeSelected
? 'selectedCustomDisappearingTimeOption'
: 'customDisappearingTimeOption'
),
},
];
return (
<div className="conversation-details-panel">
@ -241,18 +285,16 @@ export const ConversationDetails: React.ComponentType<Props> = ({
info={i18n('ConversationDetails--disappearing-messages-info')}
label={i18n('ConversationDetails--disappearing-messages-label')}
right={
<div className="module-conversation-details-select">
<select onChange={updateExpireTimer} value={expireTimer}>
{expirationTimerDurations.map((seconds: number) => {
const label = expirationTimer.format(i18n, seconds);
return (
<option value={seconds} key={seconds} aria-label={label}>
{label}
</option>
);
})}
</select>
</div>
<Select
onChange={updateExpireTimer}
value={isCustomTimeSelected ? -1 : expireTimer}
options={expirationTimerOptions}
/>
}
rightInfo={
isCustomTimeSelected
? expirationTimer.format(i18n, expireTimer)
: undefined
}
/>
) : null}

View file

@ -13,6 +13,7 @@ export type Props = {
label: string | React.ReactNode;
info?: string;
right?: string | React.ReactNode;
rightInfo?: string;
actions?: React.ReactNode;
onClick?: () => void;
};
@ -27,6 +28,7 @@ export const PanelRow: React.ComponentType<Props> = ({
label,
info,
right,
rightInfo,
actions,
onClick,
}) => {
@ -37,7 +39,14 @@ export const PanelRow: React.ComponentType<Props> = ({
<div>{label}</div>
{info !== undefined ? <div className={bem('info')}>{info}</div> : null}
</div>
{right !== undefined ? <div className={bem('right')}>{right}</div> : null}
{right !== undefined ? (
<div className={bem('right')}>
{right}
{rightInfo !== undefined ? (
<div className={bem('right-info')}>{rightInfo}</div>
) : null}
</div>
) : null}
{actions !== undefined ? (
<div className={alwaysShowActions ? '' : bem('actions')}>{actions}</div>
) : null}

View file

@ -47,6 +47,7 @@ import {
getClientZkGroupCipher,
getClientZkProfileOperations,
} from './util/zkgroup';
import * as universalExpireTimer from './util/universalExpireTimer';
import {
arrayBufferToBase64,
arrayBufferToHex,
@ -1675,6 +1676,11 @@ export async function createGroupV2({
window.MessageController.register(model.id, model);
conversation.trigger('newmessage', model);
const expireTimer = universalExpireTimer.get();
if (expireTimer) {
await conversation.updateExpirationTimer(expireTimer);
}
return conversation;
}

2
ts/model-types.d.ts vendored
View file

@ -141,6 +141,7 @@ export type MessageAttributesType = {
| 'outgoing'
| 'profile-change'
| 'timer-notification'
| 'universal-timer-notification'
| 'verified-change';
body: string;
attachments: Array<WhatIsThis>;
@ -254,6 +255,7 @@ export type ConversationAttributesType = {
profileName?: string;
verified?: number;
profileLastFetchedAt?: number;
pendingUniversalTimer?: string;
// Group-only
groupId?: string;

View file

@ -55,6 +55,7 @@ import { getConversationMembers } from '../util/getConversationMembers';
import { sendReadReceiptsFor } from '../util/sendReadReceiptsFor';
import { updateConversationsWithUuidLookup } from '../updateConversationsWithUuidLookup';
import { filter, map, take } from '../util/iterables';
import * as universalExpireTimer from '../util/universalExpireTimer';
/* eslint-disable more/no-then */
window.Whisper = window.Whisper || {};
@ -1069,10 +1070,15 @@ export class ConversationModel extends window.Backbone
);
}
// On successful fetch - mark contact as registered.
if (this.get('uuid')) {
this.setRegistered();
if (!this.get('uuid')) {
return;
}
// On successful fetch - mark contact as registered.
this.setRegistered();
// If we couldn't apply universal timer before - try it again.
this.queueJob(() => this.maybeSetPendingUniversalTimer());
}
isValid(): boolean {
@ -2749,6 +2755,85 @@ export class ConversationModel extends window.Backbone
}
}
async addUniversalTimerNotification(): Promise<string> {
const now = Date.now();
const message = ({
conversationId: this.id,
type: 'universal-timer-notification',
sent_at: now,
received_at: window.Signal.Util.incrementMessageCounter(),
received_at_ms: now,
unread: 0,
// TODO: DESKTOP-722
} as unknown) as typeof window.Whisper.MessageAttributesType;
const id = await window.Signal.Data.saveMessage(message, {
Message: window.Whisper.Message,
});
const model = window.MessageController.register(
id,
new window.Whisper.Message({
...message,
id,
})
);
this.trigger('newmessage', model);
return id;
}
async maybeSetPendingUniversalTimer(): Promise<void> {
if (!this.isPrivate()) {
return;
}
if (this.isSMSOnly()) {
return;
}
if (this.get('pendingUniversalTimer') || this.get('expireTimer')) {
return;
}
const activeAt = this.get('active_at');
if (activeAt) {
return;
}
const expireTimer = universalExpireTimer.get();
if (!expireTimer) {
return;
}
const notificationId = await this.addUniversalTimerNotification();
this.set('pendingUniversalTimer', notificationId);
}
async maybeApplyUniversalTimer(): Promise<void> {
const notificationId = this.get('pendingUniversalTimer');
if (!notificationId) {
return;
}
const message = window.MessageController.getById(notificationId);
if (message) {
message.cleanup();
}
if (this.get('expireTimer')) {
this.set('pendingUniversalTimer', undefined);
return;
}
const expireTimer = universalExpireTimer.get();
if (expireTimer) {
await this.updateExpirationTimer(expireTimer);
}
this.set('pendingUniversalTimer', undefined);
}
async onReadMessage(
message: MessageModel,
readAt?: number
@ -3243,7 +3328,6 @@ export class ConversationModel extends window.Backbone
): Promise<WhatIsThis> {
const timestamp = Date.now();
const outgoingReaction = { ...reaction, ...target };
const expireTimer = this.get('expireTimer');
const reactionModel = window.Whisper.Reactions.add({
...outgoingReaction,
@ -3269,6 +3353,10 @@ export class ConversationModel extends window.Backbone
timestamp
);
await this.maybeApplyUniversalTimer();
const expireTimer = this.get('expireTimer');
const attributes = ({
id: window.getGuid(),
type: 'outgoing',
@ -3447,12 +3535,15 @@ export class ConversationModel extends window.Backbone
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const destination = this.getSendTarget()!;
const expireTimer = this.get('expireTimer');
const recipients = this.getRecipients();
this.queueJob(async () => {
const now = Date.now();
await this.maybeApplyUniversalTimer();
const expireTimer = this.get('expireTimer');
window.log.info(
'Sending message to conversation',
this.idForLogging(),
@ -3655,6 +3746,8 @@ export class ConversationModel extends window.Backbone
return;
}
this.queueJob(() => this.maybeSetPendingUniversalTimer());
const ourConversationId = window.ConversationController.getOurConversationId();
if (!ourConversationId) {
throw new Error('updateLastMessage: Failed to fetch ourConversationId');
@ -3915,8 +4008,8 @@ export class ConversationModel extends window.Backbone
async updateExpirationTimer(
providedExpireTimer: number | undefined,
providedSource: unknown,
receivedAt: number,
providedSource?: unknown,
receivedAt?: number,
options: { fromSync?: unknown; fromGroupUpdate?: unknown } = {}
): Promise<boolean | null | MessageModel | void> {
if (this.isGroupV2()) {
@ -3964,6 +4057,11 @@ export class ConversationModel extends window.Backbone
const timestamp = (receivedAt || Date.now()) - 1;
this.set({ expireTimer });
// This call actually removes universal timer notification and clears
// the pending flags.
await this.maybeApplyUniversalTimer();
window.Signal.Data.updateConversation(this.attributes);
const model = new window.Whisper.Message(({
@ -4677,6 +4775,7 @@ export class ConversationModel extends window.Backbone
lastMessage: null,
timestamp: null,
active_at: null,
pendingUniversalTimer: undefined,
});
window.Signal.Data.updateConversation(this.attributes);

View file

@ -129,6 +129,10 @@ type MessageBubbleProps =
type: 'profileChange';
data: ProfileChangeNotificationPropsType;
}
| {
type: 'universalTimerNotification';
data: null;
}
| {
type: 'chatSessionRefreshed';
data: null;
@ -333,6 +337,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
!this.isKeyChange() &&
!this.isMessageHistoryUnsynced() &&
!this.isProfileChange() &&
!this.isUniversalTimerNotification() &&
!this.isUnsupportedMessage() &&
!this.isVerifiedChange()
);
@ -406,6 +411,12 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
data: this.getPropsForProfileChange(),
};
}
if (this.isUniversalTimerNotification()) {
return {
type: 'universalTimerNotification',
data: null,
};
}
if (this.isChatSessionRefreshed()) {
return {
type: 'chatSessionRefreshed',
@ -600,6 +611,10 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
return this.get('type') === 'profile-change';
}
isUniversalTimerNotification(): boolean {
return this.get('type') === 'universal-timer-notification';
}
// Props for each message type
getPropsForUnsupportedMessage(): PropsForUnsupportedMessage {
const requiredVersion = this.get('requiredProtocolVersion');
@ -1941,6 +1956,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
const isKeyChange = this.isKeyChange();
const isMessageHistoryUnsynced = this.isMessageHistoryUnsynced();
const isProfileChange = this.isProfileChange();
const isUniversalTimerNotification = this.isUniversalTimerNotification();
// Note: not all of these message types go through message.handleDataMessage
@ -1967,7 +1983,8 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
// Locally-generated notifications
isKeyChange ||
isMessageHistoryUnsynced ||
isProfileChange;
isProfileChange ||
isUniversalTimerNotification;
return !hasSomethingToDisplay;
}

View file

@ -38,6 +38,10 @@ import {
getSafeLongFromTimestamp,
getTimestampFromLong,
} from '../util/timestampLongUtils';
import {
get as getUniversalExpireTimer,
set as setUniversalExpireTimer,
} from '../util/universalExpireTimer';
import { ourProfileKeyService } from './ourProfileKey';
const { updateConversation } = dataInterface;
@ -182,6 +186,11 @@ export async function toAccountRecord(
accountRecord.primarySendsSms = Boolean(primarySendsSms);
}
const universalExpireTimer = getUniversalExpireTimer();
if (universalExpireTimer) {
accountRecord.universalExpireTimer = Number(universalExpireTimer);
}
const PHONE_NUMBER_SHARING_MODE_ENUM =
window.textsecure.protobuf.AccountRecord.PhoneNumberSharingMode;
const phoneNumberSharingMode = parsePhoneNumberSharingMode(
@ -811,6 +820,7 @@ export async function mergeAccountRecord(
sealedSenderIndicators,
typingIndicators,
primarySendsSms,
universalExpireTimer,
} = accountRecord;
window.storage.put('read-receipt-setting', readReceipts);
@ -831,6 +841,10 @@ export async function mergeAccountRecord(
window.storage.put('primarySendsSms', primarySendsSms);
}
if (typeof universalExpireTimer === 'number') {
setUniversalExpireTimer(universalExpireTimer);
}
const PHONE_NUMBER_SHARING_MODE_ENUM =
window.textsecure.protobuf.AccountRecord.PhoneNumberSharingMode;
let phoneNumberSharingModeToStore: PhoneNumberSharingMode;

View file

@ -3713,7 +3713,8 @@ async function getLastConversationActivity({
'verified-change',
'message-history-unsynced',
'keychange',
'group-v1-migration'
'group-v1-migration',
'universal-timer-notification'
)
) AND
(
@ -3763,7 +3764,8 @@ async function getLastConversationPreview({
'profile-change',
'verified-change',
'message-history-unsynced',
'group-v1-migration'
'group-v1-migration',
'universal-timer-notification'
)
) AND NOT
(

View file

@ -171,6 +171,7 @@ export type MessageType = {
| 'outgoing'
| 'profile-change'
| 'timer-notification'
| 'universal-timer-notification'
| 'verified-change';
quote?: { author?: string; authorUuid?: string };
received_at: number;

View file

@ -12,6 +12,8 @@ import { CustomColorType } from '../../types/Colors';
// State
export type ItemsStateType = {
readonly universalExpireTimer?: number;
readonly [key: string]: unknown;
readonly customColors?: {
readonly colors: Record<string, CustomColorType>;

View file

@ -3,6 +3,8 @@
import { createSelector } from 'reselect';
import { ITEM_NAME as UNIVERSAL_EXPIRE_TIMER_ITEM } from '../../util/universalExpireTimer';
import { StateType } from '../reducer';
import { ItemsStateType } from '../ducks/items';
@ -18,3 +20,8 @@ export const getPinnedConversationIds = createSelector(
(state: ItemsStateType): Array<string> =>
(state.pinnedConversationIds || []) as Array<string>
);
export const getUniversalExpireTimer = createSelector(
getItems,
(state: ItemsStateType): number => state[UNIVERSAL_EXPIRE_TIMER_ITEM] || 0
);

View file

@ -17,6 +17,7 @@ import {
} from '../selectors/conversations';
import { SmartContactName } from './ContactName';
import { SmartUniversalTimerNotification } from './UniversalTimerNotification';
type ExternalProps = {
id: string;
@ -33,6 +34,10 @@ function renderContact(conversationId: string): JSX.Element {
return <FilteredSmartContactName conversationId={conversationId} />;
}
function renderUniversalTimerNotification(): JSX.Element {
return <SmartUniversalTimerNotification />;
}
const mapStateToProps = (state: StateType, props: ExternalProps) => {
const { id, conversationId } = props;
@ -60,6 +65,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
customColor: conversation?.customColor,
isSelected,
renderContact,
renderUniversalTimerNotification,
i18n: getIntl(state),
interactionMode: getInteractionMode(state),
theme: getTheme(state),

View file

@ -0,0 +1,23 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { connect } from 'react-redux';
import { mapDispatchToProps } from '../actions';
import { UniversalTimerNotification } from '../../components/conversation/UniversalTimerNotification';
import { StateType } from '../reducer';
import { getIntl } from '../selectors/user';
import { getUniversalExpireTimer } from '../selectors/items';
const mapStateToProps = (state: StateType) => {
return {
...state.updates,
i18n: getIntl(state),
expireTimer: getUniversalExpireTimer(state),
};
};
const smart = connect(mapStateToProps, mapDispatchToProps);
export const SmartUniversalTimerNotification = smart(
UniversalTimerNotification
);

View file

@ -0,0 +1,18 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export type TestExpireTimer = Readonly<{ value: number; label: string }>;
const SECOND = 1;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
const WEEK = 7 * DAY;
export const EXPIRE_TIMERS: ReadonlyArray<TestExpireTimer> = [
{ value: 42 * SECOND, label: '42 seconds' },
{ value: 5 * MINUTE, label: '5 minutes' },
{ value: 1 * HOUR, label: '1 hour' },
{ value: 6 * DAY, label: '6 days' },
{ value: 3 * WEEK, label: '3 weeks' },
];

1
ts/textsecure.d.ts vendored
View file

@ -1067,6 +1067,7 @@ export declare class AccountRecordClass {
notDiscoverableByPhoneNumber?: boolean;
pinnedConversations?: PinnedConversationClass[];
noteToSelfMarkedUnread?: boolean;
universalExpireTimer?: number;
primarySendsSms?: boolean;
__unknownFields?: ArrayBuffer;

View file

@ -6,25 +6,33 @@ import humanizeDuration from 'humanize-duration';
import { LocalizerType } from '../types/Util';
const SECONDS_PER_WEEK = 604800;
export const DEFAULT_DURATIONS_IN_SECONDS = [
export const DEFAULT_DURATIONS_IN_SECONDS: ReadonlyArray<number> = [
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(4, 'weeks').asSeconds(),
moment.duration(1, 'week').asSeconds(),
moment.duration(1, 'day').asSeconds(),
moment.duration(8, 'hours').asSeconds(),
moment.duration(1, 'hour').asSeconds(),
moment.duration(5, 'minutes').asSeconds(),
moment.duration(30, 'seconds').asSeconds(),
];
export function format(i18n: LocalizerType, dirtySeconds?: number): string {
export const DEFAULT_DURATIONS_SET: ReadonlySet<number> = new Set<number>(
DEFAULT_DURATIONS_IN_SECONDS
);
export type FormatOptions = {
capitalizeOff?: boolean;
};
export function format(
i18n: LocalizerType,
dirtySeconds?: number,
{ capitalizeOff = false }: FormatOptions = {}
): string {
let seconds = Math.abs(dirtySeconds || 0);
if (!seconds) {
return i18n('disappearingMessages__off');
return i18n(capitalizeOff ? 'off' : 'disappearingMessages__off');
}
seconds = Math.max(Math.floor(seconds), 1);

View file

@ -37,6 +37,7 @@ import { StartupQueue } from './StartupQueue';
import { postLinkExperience } from './postLinkExperience';
import { sendToGroup, sendContentMessageToGroup } from './sendToGroup';
import { RetryPlaceholders } from './retryPlaceholders';
import * as expirationTimer from './expirationTimer';
export {
GoogleChrome,
@ -73,4 +74,5 @@ export {
sleep,
toWebSafeBase64,
zkgroup,
expirationTimer,
};

View file

@ -1233,6 +1233,22 @@
"updated": "2021-05-11T20:38:03.542Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/settings_view.js",
"line": " template: () => $('#disappearingMessagesSettings').html(),",
"reasonCategory": "usageTrusted",
"updated": "2021-05-27T01:33:06.541Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
},
{
"rule": "jQuery-$(",
"path": "js/views/settings_view.js",
"line": " this.$('.disappearing-messages-setting').append(",
"reasonCategory": "usageTrusted",
"updated": "2021-05-27T01:33:06.541Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
},
{
"rule": "jQuery-append(",
"path": "js/views/settings_view.js",
@ -1241,6 +1257,14 @@
"updated": "2020-08-21T11:29:29.636Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
},
{
"rule": "jQuery-append(",
"path": "js/views/settings_view.js",
"line": " this.$('.disappearing-messages-setting').append(",
"reasonCategory": "usageTrusted",
"updated": "2021-05-27T01:33:06.541Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
},
{
"rule": "jQuery-html(",
"path": "js/views/settings_view.js",
@ -1257,6 +1281,14 @@
"updated": "2021-02-26T18:44:56.450Z",
"reasonDetail": "Static selector, read-only access"
},
{
"rule": "jQuery-html(",
"path": "js/views/settings_view.js",
"line": " template: () => $('#disappearingMessagesSettings').html(),",
"reasonCategory": "usageTrusted",
"updated": "2021-05-27T01:33:06.541Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
},
{
"rule": "jQuery-$(",
"path": "js/views/standalone_registration_view.js",

View file

@ -0,0 +1,12 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export const ITEM_NAME = 'universalExpireTimer';
export function get(): number {
return window.storage.get(ITEM_NAME) || 0;
}
export function set(newValue: number | undefined): Promise<void> {
return window.storage.put(ITEM_NAME, newValue || 0);
}

View file

@ -1068,7 +1068,7 @@ Whisper.ConversationView = Whisper.View.extend({
const finish = () => {
resolvePromise();
this.model.inProgressFinish = null;
this.model.inProgressFetch = null;
};
return finish;

3
ts/window.d.ts vendored
View file

@ -102,6 +102,7 @@ import { MessageDetail } from './components/conversation/MessageDetail';
import { ProgressModal } from './components/ProgressModal';
import { Quote } from './components/conversation/Quote';
import { StagedLinkPreview } from './components/conversation/StagedLinkPreview';
import { DisappearingTimeDialog } from './components/conversation/DisappearingTimeDialog';
import { MIMEType } from './types/MIME';
import { ElectronLocaleType } from './util/mapToSupportLocale';
import { SignalProtocolStore } from './SignalProtocolStore';
@ -488,6 +489,7 @@ declare global {
ProgressModal: typeof ProgressModal;
Quote: typeof Quote;
StagedLinkPreview: typeof StagedLinkPreview;
DisappearingTimeDialog: typeof DisappearingTimeDialog;
};
OS: typeof OS;
Workflow: {
@ -796,4 +798,5 @@ export type WhisperType = {
View: typeof Backbone.View & {
Templates: Record<string, string>;
};
DisappearingTimeDialog: typeof window.Whisper.View | undefined;
};