Universal Disappearing Messages
This commit is contained in:
parent
c63871d71b
commit
19f8042cd3
50 changed files with 1224 additions and 191 deletions
|
@ -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
|
||||
|
|
31
ts/components/Select.stories.tsx
Normal file
31
ts/components/Select.stories.tsx
Normal 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
39
ts/components/Select.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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')}
|
||||
/>
|
||||
);
|
||||
});
|
||||
});
|
124
ts/components/conversation/DisappearingTimeDialog.tsx
Normal file
124
ts/components/conversation/DisappearingTimeDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -298,6 +298,9 @@ const renderItem = (id: string) => (
|
|||
conversationId=""
|
||||
conversationAccepted
|
||||
renderContact={() => '*ContactName*'}
|
||||
renderUniversalTimerNotification={() => (
|
||||
<div>*UniversalTimerNotification*</div>
|
||||
)}
|
||||
renderAudioAttachment={() => <div>*AudioAttachment*</div>}
|
||||
{...actions()}
|
||||
/>
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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} />;
|
||||
});
|
||||
});
|
28
ts/components/conversation/UniversalTimerNotification.tsx
Normal file
28
ts/components/conversation/UniversalTimerNotification.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue