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

@ -1801,7 +1801,7 @@
},
"disappearingMessages": {
"message": "Disappearing messages",
"description": "Conversation menu option to enable disappearing messages"
"description": "Conversation menu option to enable disappearing messages. Title of the settings section for Disappearing Messages"
},
"disappearingMessagesDisabled": {
"message": "Disappearing messages disabled",
@ -5414,5 +5414,63 @@
"CustomColorEditor__title": {
"message": "Custom Color",
"description": "Modal title for the custom color editor"
},
"customDisappearingTimeOption": {
"message": "Custom time...",
"description": "Text for an option in Disappearing Messages menu and Conversation Details Disappearing Messages setting when no user value is available"
},
"selectedCustomDisappearingTimeOption": {
"message": "Custom time",
"description": "Text for an option in Conversation Details Disappearing Messages setting when user previously selected custom time"
},
"DisappearingTimeDialog__title": {
"message": "Custom Time",
"description": "Title for the custom disappearing message timeout dialog"
},
"DisappearingTimeDialog__body": {
"message": "Choose a custom time for disappearing messages.",
"description": "Body for the custom disappearing message timeout dialog"
},
"DisappearingTimeDialog__set": {
"message": "Set",
"description": "Text for the dialog button confirming the custom disappearing message timeout"
},
"DisappearingTimeDialog__seconds": {
"message": "Seconds",
"description": "Name of the 'seconds' unit select for the custom disappearing message timeout dialog"
},
"DisappearingTimeDialog__minutes": {
"message": "Minutes",
"description": "Name of the 'minutes' unit select for the custom disappearing message timeout dialog"
},
"DisappearingTimeDialog__hours": {
"message": "Hours",
"description": "Name of the 'hours' unit select for the custom disappearing message timeout dialog"
},
"DisappearingTimeDialog__days": {
"message": "Days",
"description": "Name of the 'days' unit select for the custom disappearing message timeout dialog"
},
"DisappearingTimeDialog__weeks": {
"message": "Weeks",
"description": "Name of the 'weeks' unit select for the custom disappearing message timeout dialog"
},
"settings__DisappearingMessages__footer": {
"message": "Set a default disappearing message timer for all new chats started by you.",
"description": "Footer for the Disappearing Messages settings section"
},
"settings__DisappearingMessages__timer__label": {
"message": "Default timer for new chats",
"description": "Label for the Disapearring Messages default timer setting"
},
"UniversalTimerNotification__text": {
"message": "The disappearing message time will be set to $timeValue$ when you message them.",
"description": "A message displayed when default disappearing message timeout is about to be applied",
"placeholders": {
"timeValue": {
"content": "$1",
"example": "1 week"
}
}
}
}

View File

@ -59,6 +59,9 @@ const {
const {
StagedLinkPreview,
} = require('../../ts/components/conversation/StagedLinkPreview');
const {
DisappearingTimeDialog,
} = require('../../ts/components/conversation/DisappearingTimeDialog');
// State
const { createTimeline } = require('../../ts/state/roots/createTimeline');
@ -346,6 +349,7 @@ exports.setup = (options = {}) => {
ProgressModal,
SafetyNumberChangeDialog,
StagedLinkPreview,
DisappearingTimeDialog,
Types: {
Message: MediaGalleryMessage,
},

View File

@ -48,6 +48,7 @@ const getInitialData = async () => ({
isPrimary: await window.isPrimary(),
lastSyncTime: await window.getLastSyncTime(),
universalExpireTimer: await window.getUniversalExpireTimer(),
});
window.initialRequest = getInitialData();

View File

@ -12,6 +12,12 @@
window.Whisper = window.Whisper || {};
const { Settings } = window.Signal.Types;
const {
DEFAULT_DURATIONS_IN_SECONDS,
DEFAULT_DURATIONS_SET,
format: formatExpirationTimer,
} = window.Signal.Util.expirationTimer;
const CheckboxView = Whisper.View.extend({
initialize(options) {
this.name = options.name;
@ -70,6 +76,106 @@
},
});
const DisappearingMessagesView = Whisper.View.extend({
template: () => $('#disappearingMessagesSettings').html(),
initialize(options) {
this.timeDialog = null;
this.value = options.value || 0;
this.render();
},
render_attributes() {
const isCustomValue = this.isCustomValue();
return {
title: i18n('disappearingMessages'),
timerValues: DEFAULT_DURATIONS_IN_SECONDS.map(seconds => {
const text = formatExpirationTimer(i18n, seconds, {
capitalizeOff: true,
});
return {
selected: seconds === this.value ? 'selected' : undefined,
value: seconds,
text,
};
}),
customSelected: isCustomValue ? 'selected' : undefined,
customText: i18n(
isCustomValue
? 'selectedCustomDisappearingTimeOption'
: 'customDisappearingTimeOption'
),
customInfo: isCustomValue
? {
text: formatExpirationTimer(i18n, this.value),
}
: undefined,
timerLabel: i18n('settings__DisappearingMessages__timer__label'),
footer: i18n('settings__DisappearingMessages__footer'),
};
},
events: {
change: 'change',
},
change(e) {
const value = parseInt(e.target.value, 10);
if (value === -1) {
this.showDialog();
return;
}
this.updateValue(value);
window.log.info('disappearing-messages-timer changed to', this.value);
},
isCustomValue() {
return this.value && !DEFAULT_DURATIONS_SET.has(this.value);
},
showDialog() {
this.closeDialog();
this.timeDialog = new window.Whisper.ReactWrapperView({
className: 'disappearing-time-dialog-wrapper',
Component: window.Signal.Components.DisappearingTimeDialog,
props: {
i18n,
initialValue: this.value,
onSubmit: newValue => {
this.updateValue(newValue);
this.closeDialog();
window.log.info(
'disappearing-messages-timer changed to custom value',
this.value
);
},
onClose: () => {
this.closeDialog();
},
},
});
},
closeDialog() {
if (this.timeDialog) {
this.timeDialog.remove();
}
this.timeDialog = null;
},
updateValue(newValue) {
this.value = newValue;
window.setUniversalExpireTimer(newValue);
this.render();
},
});
const RadioButtonGroupView = Whisper.View.extend({
initialize(options) {
this.name = options.name;
@ -202,6 +308,15 @@
value: window.initialData.mediaCameraPermissions,
setFn: window.setMediaCameraPermissions,
});
const disappearingMessagesView = new DisappearingMessagesView({
value: window.initialData.universalExpireTimer,
name: 'disappearing-messages-setting',
});
this.$('.disappearing-messages-setting').append(
disappearingMessagesView.el
);
if (!window.initialData.isPrimary) {
const syncView = new SyncView().render();
this.$('.sync-setting').append(syncView.el);

View File

@ -1697,6 +1697,8 @@ installSettingsGetter('is-primary');
installSettingsGetter('sync-request');
installSettingsGetter('sync-time');
installSettingsSetter('sync-time');
installSettingsGetter('universal-expire-timer');
installSettingsSetter('universal-expire-timer');
ipc.on('delete-all-data', () => {
if (mainWindow && mainWindow.webContents) {

View File

@ -367,6 +367,8 @@ try {
installGetter('sync-request', 'getSyncRequest');
installGetter('sync-time', 'getLastSyncTime');
installSetter('sync-time', 'setLastSyncTime');
installGetter('universal-expire-timer', 'getUniversalExpireTimer');
installSetter('universal-expire-timer', 'setUniversalExpireTimer');
ipc.on('delete-all-data', async () => {
const { deleteAllData } = window.Events;

View File

@ -129,5 +129,6 @@ message AccountRecord {
optional PhoneNumberSharingMode phoneNumberSharingMode = 12;
optional bool notDiscoverableByPhoneNumber = 13;
repeated PinnedConversation pinnedConversations = 14;
optional uint32 universalExpireTimer = 17;
optional bool primarySendsSms = 18;
}

View File

@ -1,4 +1,4 @@
<!-- Copyright 2018-2020 Signal Messenger, LLC -->
<!-- Copyright 2018-2021 Signal Messenger, LLC -->
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
<html>
@ -38,6 +38,42 @@
</p>
</div>
</script>
<script type='text/x-tmpl-mustache' id='disappearingMessagesSettings'>
<h3>{{ title }}</h3>
<div class="disappearing-messages-setting__timer {{#customInfo}}disappearing-messages-setting__timer--with-info{{/customInfo}}">
<label
class="disappearing-messages-setting__timer__label"
for='disappearing-messages-timer'
>
{{ timerLabel }}
</label>
<div class="disappearing-messages-setting__timer__right">
<div class="module-select">
<select
name='disappearing-messages-timer'
id='disappearing-messages-timer'
>
{{#timerValues}}
<option value="{{value}}" {{selected}}>
{{ text }}
</option>
{{/timerValues}}
<option value="-1" {{customSelected}}>
{{customText}}
</option>
</select>
</div>
{{#customInfo}}
<div class="disappearing-messages-setting__timer__right__info">
{{text}}
</div>
{{/customInfo}}
</div>
</div>
<div class='disappearing-messages-setting__footer'>
{{ footer }}
</div>
</script>
<script type='text/x-tmpl-mustache' id='settings'>
<div class='content'>
<a class='x close' alt='close settings' href='#'></a>
@ -163,6 +199,9 @@
</div>
<div class='sync-setting'></div>
<hr>
<div class='disappearing-messages-setting'>
</div>
<hr>
<div class='clear-data-settings'>
<h3>{{ clearDataHeader }}</h3>
<div>
@ -174,6 +213,7 @@
</script>
<script type='text/javascript' src='js/components.js'></script>
<script type='text/javascript' src='ts/backboneJquery.js'></script>
<script type='text/javascript' src='js/views/react_wrapper_view.js'></script>
<script type='text/javascript' src='js/views/settings_view.js'></script>
<script type='text/javascript' src='js/settings_start.js'></script>
</html>

View File

@ -98,6 +98,8 @@ window.isPrimary = makeGetter('is-primary');
window.makeSyncRequest = makeGetter('sync-request');
window.getLastSyncTime = makeGetter('sync-time');
window.setLastSyncTime = makeSetter('sync-time');
window.getUniversalExpireTimer = makeGetter('universal-expire-timer');
window.setUniversalExpireTimer = makeSetter('universal-expire-timer');
window.deleteAllData = () => ipcRenderer.send('delete-all-data');
@ -130,6 +132,9 @@ function makeSetter(name) {
}
window.Backbone = require('backbone');
window.React = require('react');
window.ReactDOM = require('react-dom');
require('./ts/backbone/views/whisper_view');
require('./ts/backbone/views/toast_view');
require('./ts/logging/set_up_renderer_logging').initialize();

View File

@ -2461,6 +2461,19 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05',
margin-top: 1px;
}
// Module: Universal Timer Notification
.module-universal-timer-notification {
text-align: center;
@include light-theme {
color: $color-gray-60;
}
@include dark-theme {
color: $color-gray-05;
}
}
.module-notification--with-click-handler {
cursor: pointer;
}
@ -3096,7 +3109,8 @@ button.module-conversation-details__action-button {
margin-right: 12px;
}
&__info {
&__info,
&__right-info {
@include font-body-2;
margin-top: 4px;
@ -3110,7 +3124,17 @@ button.module-conversation-details__action-button {
}
&__right {
position: relative;
color: $color-gray-45;
min-width: 143px;
}
&__right-info {
position: absolute;
@include font-subtitle;
padding-left: 14px;
}
&__actions {
@ -3170,60 +3194,6 @@ button.module-conversation-details__action-button {
@include font-body-1-bold;
}
}
&-select {
position: relative;
select {
@include font-body-2;
-webkit-appearance: none;
border-radius: 4px;
border: 1px solid $color-gray-25;
cursor: pointer;
height: 40px;
min-width: 124px;
outline: 0;
padding: 10px;
padding-left: 12px;
padding-right: 32px;
text-overflow: ellipsis;
width: 100%;
@include dark-theme {
background-color: $color-gray-90;
border-color: $color-gray-60;
color: $color-gray-05;
}
&:focus {
border: 3px solid $color-ultramarine;
line-height: 14px;
padding-left: 10px;
}
}
&::after {
border: 2px solid $color-gray-60;
border-radius: 2px;
border-right: 0;
border-top: 0;
content: ' ';
display: block;
height: 10px;
pointer-events: none;
position: absolute;
right: 15px;
top: 14px;
transform-origin: center;
transform: rotate(-45deg);
width: 10px;
z-index: 2;
@include dark-theme {
border-color: $color-gray-15;
}
}
}
}
// Module: Message Detail

View File

@ -7,6 +7,7 @@
&.modal {
padding: 0;
background-color: transparent;
z-index: 1;
.content {
margin: 0;
@ -77,4 +78,39 @@
@include font-body-2;
color: $color-gray-60;
}
.disappearing-messages-setting {
&__timer {
display: flex;
flex-direction: row;
align-items: center;
&__label {
flex-grow: 1;
margin-right: 20px;
}
margin-bottom: 10px;
&__right {
position: relative;
&__info {
position: absolute;
@include font-subtitle;
padding-left: 14px;
}
}
&--with-info {
margin-bottom: 16px;
}
}
&__footer {
@include font-body-2;
color: $color-gray-60;
}
}
}

View File

@ -2,6 +2,30 @@
// SPDX-License-Identifier: AGPL-3.0-only
.module-ConversationHeader {
@mixin icon-element($icon, $margin-right: 4px) {
display: flex;
align-items: center;
user-select: none;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
&::before {
content: '';
width: 13px;
height: 13px;
display: block;
margin-right: $margin-right;
@include light-theme {
@include color-svg($icon, $color-gray-60);
}
@include dark-theme {
@include color-svg($icon, $color-gray-25);
}
}
}
--button-spacing: 24px;
&.module-ConversationHeader--narrow {
@ -133,37 +157,13 @@
color: $color-gray-25;
}
@mixin subtitle-element($icon) {
display: flex;
align-items: center;
user-select: none;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
&::before {
content: '';
width: 13px;
height: 13px;
display: block;
margin-right: 4px;
@include light-theme {
@include color-svg($icon, $color-gray-60);
}
@include dark-theme {
@include color-svg($icon, $color-gray-25);
}
}
}
&__expiration {
@include subtitle-element('../images/icons/v2/timer-24.svg');
@include icon-element('../images/icons/v2/timer-24.svg');
margin-right: 12px;
}
&__verified {
@include subtitle-element('../images/icons/v2/check-24.svg');
@include icon-element('../images/icons/v2/check-24.svg');
}
}
}
@ -308,4 +308,13 @@
}
}
}
&__disappearing-timer__item {
padding-left: 25px;
&--active {
padding-left: 0px;
@include icon-element('../images/icons/v2/check-24.svg', 12px);
}
}
}

View File

@ -0,0 +1,25 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.module-disappearing-time-dialog {
&__title.module-Modal__title {
margin-bottom: 2px;
}
&__body p {
margin: 0 0 25px 0;
}
&__time-boxes {
display: flex;
flex-direction: row;
.module-select {
flex-grow: 1;
}
&__units {
margin-left: 9px;
}
}
}

View File

@ -0,0 +1,55 @@
.module-select {
position: relative;
select {
@include font-body-2;
-webkit-appearance: none;
border-radius: 4px;
border: 1px solid $color-gray-25;
cursor: pointer;
height: 40px;
min-width: 124px;
outline: 0;
padding: 10px;
padding-left: 12px;
padding-right: 32px;
text-overflow: ellipsis;
width: 100%;
@include dark-theme {
background-color: $color-gray-90;
border-color: $color-gray-60;
color: $color-gray-05;
}
@include keyboard-mode {
&:focus {
border: 3px solid $color-ultramarine;
line-height: 14px;
padding-left: 10px;
}
}
}
&::after {
border: 2px solid $color-gray-60;
border-radius: 2px;
border-right: 0;
border-top: 0;
content: ' ';
display: block;
height: 10px;
pointer-events: none;
position: absolute;
right: 15px;
top: 14px;
transform-origin: center;
transform: rotate(-45deg);
width: 10px;
z-index: 2;
@include dark-theme {
border-color: $color-gray-15;
}
}
}

View File

@ -41,6 +41,7 @@
@import './components/ContactSpoofingReviewDialogPerson.scss';
@import './components/ConversationHeader.scss';
@import './components/CustomColorEditor.scss';
@import './components/DisappearingTimeDialog.scss';
@import './components/EditConversationAttributesModal.scss';
@import './components/ForwardMessageModal.scss';
@import './components/GradientDial.scss';
@ -55,5 +56,6 @@
@import './components/SearchResultsLoadingFakeRow.scss';
@import './components/Slider.scss';
@import './components/Tabs.scss';
@import './components/Select.scss';
@import './components/TimelineWarning.scss';
@import './components/TimelineWarnings.scss';

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;
};