From 6c0acd09dfe51609fdbb4b8ef301c81dd9218a48 Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Fri, 9 Apr 2021 09:19:38 -0700 Subject: [PATCH] Sync mute state --- _locales/en/messages.json | 12 ++++- protos/SignalStorage.proto | 47 +++++++++-------- test/setup-test-node.js | 2 + .../ConversationHeader.stories.tsx | 16 ++++++ .../conversation/ConversationHeader.tsx | 18 +++++-- .../conversationList/ConversationListItem.tsx | 4 +- ts/models/conversations.ts | 36 +++++++++++++ ts/services/storage.ts | 3 +- ts/services/storageRecordOps.ts | 34 +++++++++++++ ts/services/timers.ts | 6 ++- ts/test-both/util/timestampLongUtils_test.ts | 51 +++++++++++++++++++ ts/textsecure.d.ts | 3 ++ ts/util/getMuteOptions.ts | 8 ++- ts/util/timestampLongUtils.ts | 26 ++++++++++ ts/views/conversation_view.ts | 30 ++--------- ts/window.d.ts | 1 + 16 files changed, 236 insertions(+), 61 deletions(-) create mode 100644 ts/test-both/util/timestampLongUtils_test.ts create mode 100644 ts/util/timestampLongUtils.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 7d61d0aa8b..3f791a6ffc 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -3328,6 +3328,10 @@ "message": "Mute for one hour", "description": "Label for muting the conversation" }, + "muteEightHours": { + "message": "Mute for eight hours", + "description": "Label for muting the conversation" + }, "muteDay": { "message": "Mute for one day", "description": "Label for muting the conversation" @@ -3336,14 +3340,18 @@ "message": "Mute for one week", "description": "Label for muting the conversation" }, - "muteYear": { - "message": "Mute for one year", + "muteAlways": { + "message": "Mute always", "description": "Label for muting the conversation" }, "unmute": { "message": "Unmute", "description": "Label for unmuting the conversation" }, + "muteExpirationLabelAlways": { + "message": "Muted always", + "description": "Shown in the mute notifications submenu whenever a conversation has been muted" + }, "muteExpirationLabel": { "message": "Muted until $duration$", "description": "Shown in the mute notifications submenu whenever a conversation has been muted", diff --git a/protos/SignalStorage.proto b/protos/SignalStorage.proto index 9fc49d2b86..b615c2593b 100644 --- a/protos/SignalStorage.proto +++ b/protos/SignalStorage.proto @@ -62,34 +62,37 @@ message ContactRecord { UNVERIFIED = 2; } - optional string serviceUuid = 1; - optional string serviceE164 = 2; - optional bytes profileKey = 3; - optional bytes identityKey = 4; - optional IdentityState identityState = 5; - optional string givenName = 6; - optional string familyName = 7; - optional string username = 8; - optional bool blocked = 9; - optional bool whitelisted = 10; - optional bool archived = 11; - optional bool markedUnread = 12; + optional string serviceUuid = 1; + optional string serviceE164 = 2; + optional bytes profileKey = 3; + optional bytes identityKey = 4; + optional IdentityState identityState = 5; + optional string givenName = 6; + optional string familyName = 7; + optional string username = 8; + optional bool blocked = 9; + optional bool whitelisted = 10; + optional bool archived = 11; + optional bool markedUnread = 12; + optional uint64 mutedUntilTimestamp = 13; } message GroupV1Record { - optional bytes id = 1; - optional bool blocked = 2; - optional bool whitelisted = 3; - optional bool archived = 4; - optional bool markedUnread = 5; + optional bytes id = 1; + optional bool blocked = 2; + optional bool whitelisted = 3; + optional bool archived = 4; + optional bool markedUnread = 5; + optional uint64 mutedUntilTimestamp = 6; } message GroupV2Record { - optional bytes masterKey = 1; - optional bool blocked = 2; - optional bool whitelisted = 3; - optional bool archived = 4; - optional bool markedUnread = 5; + optional bytes masterKey = 1; + optional bool blocked = 2; + optional bool whitelisted = 3; + optional bool archived = 4; + optional bool markedUnread = 5; + optional uint64 mutedUntilTimestamp = 6; } message AccountRecord { diff --git a/test/setup-test-node.js b/test/setup-test-node.js index 393fed3d23..984de53b7e 100644 --- a/test/setup-test-node.js +++ b/test/setup-test-node.js @@ -4,6 +4,7 @@ /* eslint-disable no-console */ const ByteBuffer = require('../components/bytebuffer/dist/ByteBufferAB.js'); +const Long = require('../components/long/dist/Long.js'); const { setEnvironment, Environment } = require('../ts/environment'); setEnvironment(Environment.Test); @@ -18,6 +19,7 @@ global.window = { i18n: key => `i18n(${key})`, dcodeIO: { ByteBuffer, + Long, }, }; diff --git a/ts/components/conversation/ConversationHeader.stories.tsx b/ts/components/conversation/ConversationHeader.stories.tsx index c7d34124cc..c645f1416d 100644 --- a/ts/components/conversation/ConversationHeader.stories.tsx +++ b/ts/components/conversation/ConversationHeader.stories.tsx @@ -239,6 +239,22 @@ const stories: Array = [ outgoingCallButtonStyle: OutgoingCallButtonStyle.Join, }, }, + { + title: 'In a forever muted group', + props: { + ...commonProps, + color: 'signal-blue', + title: 'Way too many messages', + name: 'Way too many messages', + phoneNumber: '', + id: '1', + type: 'group', + expireTimer: 10, + acceptedMessageRequest: true, + outgoingCallButtonStyle: OutgoingCallButtonStyle.JustVideo, + muteExpiresAt: Infinity, + }, + }, ], }, { diff --git a/ts/components/conversation/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx index a63b968b52..f59a9f879d 100644 --- a/ts/components/conversation/ConversationHeader.tsx +++ b/ts/components/conversation/ConversationHeader.tsx @@ -378,14 +378,24 @@ export class ConversationHeader extends React.Component { const muteOptions: Array = []; if (isMuted(muteExpiresAt)) { const expires = moment(muteExpiresAt); - const muteExpirationLabel = moment().isSame(expires, 'day') - ? expires.format('hh:mm A') - : expires.format('M/D/YY, hh:mm A'); + + let muteExpirationLabel: string; + if (Number(muteExpiresAt) >= Number.MAX_SAFE_INTEGER) { + muteExpirationLabel = i18n('muteExpirationLabelAlways'); + } else { + const muteExpirationUntil = moment().isSame(expires, 'day') + ? expires.format('hh:mm A') + : expires.format('M/D/YY, hh:mm A'); + + muteExpirationLabel = i18n('muteExpirationLabel', [ + muteExpirationUntil, + ]); + } muteOptions.push( ...[ { - name: i18n('muteExpirationLabel', [muteExpirationLabel]), + name: muteExpirationLabel, disabled: true, value: 0, }, diff --git a/ts/components/conversationList/ConversationListItem.tsx b/ts/components/conversationList/ConversationListItem.tsx index c6dfdfd23b..18e848d4f6 100644 --- a/ts/components/conversationList/ConversationListItem.tsx +++ b/ts/components/conversationList/ConversationListItem.tsx @@ -121,9 +121,9 @@ export const ConversationListItem: FunctionComponent = React.memo( /* eslint-disable no-nested-ternary */ messageText = ( <> - {muteExpiresAt && Date.now() < muteExpiresAt && ( + {muteExpiresAt && Date.now() < muteExpiresAt ? ( - )} + ) : null} {!acceptedMessageRequest ? ( {i18n('ConversationListItem--message-request')} diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 15c93ebf5e..02e0c3d814 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -5021,6 +5021,42 @@ export class ConversationModel extends window.Backbone.Model< }); } + setMuteExpiration( + muteExpiresAt = 0, + { viaStorageServiceSync = false } = {} + ): void { + const prevExpiration = this.get('muteExpiresAt'); + + if (prevExpiration === muteExpiresAt) { + return; + } + + // we use a timeoutId here so that we can reference the mute that was + // potentially set in the ConversationController. Specifically for a + // scenario where a conversation is already muted and we boot up the app, + // a timeout will be already set. But if we change the mute to a later + // date a new timeout would need to be set and the old one cleared. With + // this ID we can reference the existing timeout. + const timeoutId = this.getMuteTimeoutId(); + window.Signal.Services.removeTimeout(timeoutId); + + if (muteExpiresAt && muteExpiresAt < Number.MAX_SAFE_INTEGER) { + window.Signal.Services.onTimeout( + muteExpiresAt, + () => { + this.setMuteExpiration(0); + }, + timeoutId + ); + } + + this.set({ muteExpiresAt }); + if (!viaStorageServiceSync) { + this.captureChange('mutedUntilTimestamp'); + } + window.Signal.Data.updateConversation(this.attributes); + } + isMuted(): boolean { return isMuted(this.get('muteExpiresAt')); } diff --git a/ts/services/storage.ts b/ts/services/storage.ts index 4fdda71e86..f3e6e8e00e 100644 --- a/ts/services/storage.ts +++ b/ts/services/storage.ts @@ -626,7 +626,8 @@ async function mergeRecord( window.log.error( 'storageService.mergeRecord: Error with', redactStorageID(storageID), - itemType + itemType, + String(err) ); } diff --git a/ts/services/storageRecordOps.ts b/ts/services/storageRecordOps.ts index 1a5ff73981..7d7a44250c 100644 --- a/ts/services/storageRecordOps.ts +++ b/ts/services/storageRecordOps.ts @@ -34,6 +34,10 @@ import { } from '../util/phoneNumberDiscoverability'; import { arePinnedConversationsEqual } from '../util/arePinnedConversationsEqual'; import { ConversationModel } from '../models/conversations'; +import { + getSafeLongFromTimestamp, + getTimestampFromLong, +} from '../util/timestampLongUtils'; const { updateConversation } = dataInterface; @@ -131,6 +135,9 @@ export async function toContactRecord( contactRecord.whitelisted = Boolean(conversation.get('profileSharing')); contactRecord.archived = Boolean(conversation.get('isArchived')); contactRecord.markedUnread = Boolean(conversation.get('markedUnread')); + contactRecord.mutedUntilTimestamp = getSafeLongFromTimestamp( + conversation.get('muteExpiresAt') + ); applyUnknownFields(contactRecord, conversation); @@ -278,6 +285,9 @@ export async function toGroupV1Record( groupV1Record.whitelisted = Boolean(conversation.get('profileSharing')); groupV1Record.archived = Boolean(conversation.get('isArchived')); groupV1Record.markedUnread = Boolean(conversation.get('markedUnread')); + groupV1Record.mutedUntilTimestamp = getSafeLongFromTimestamp( + conversation.get('muteExpiresAt') + ); applyUnknownFields(groupV1Record, conversation); @@ -297,6 +307,9 @@ export async function toGroupV2Record( groupV2Record.whitelisted = Boolean(conversation.get('profileSharing')); groupV2Record.archived = Boolean(conversation.get('isArchived')); groupV2Record.markedUnread = Boolean(conversation.get('markedUnread')); + groupV2Record.mutedUntilTimestamp = getSafeLongFromTimestamp( + conversation.get('muteExpiresAt') + ); applyUnknownFields(groupV2Record, conversation); @@ -522,6 +535,13 @@ export async function mergeGroupV1Record( storageID, }); + conversation.setMuteExpiration( + getTimestampFromLong(groupV1Record.mutedUntilTimestamp), + { + viaStorageServiceSync: true, + } + ); + applyMessageRequestState(groupV1Record, conversation); let hasPendingChanges: boolean; @@ -622,6 +642,13 @@ export async function mergeGroupV2Record( storageID, }); + conversation.setMuteExpiration( + getTimestampFromLong(groupV2Record.mutedUntilTimestamp), + { + viaStorageServiceSync: true, + } + ); + applyMessageRequestState(groupV2Record, conversation); addUnknownFields(groupV2Record, conversation); @@ -731,6 +758,13 @@ export async function mergeContactRecord( storageID, }); + conversation.setMuteExpiration( + getTimestampFromLong(contactRecord.mutedUntilTimestamp), + { + viaStorageServiceSync: true, + } + ); + const hasPendingChanges = doesRecordHavePendingChanges( await toContactRecord(conversation), contactRecord, diff --git a/ts/services/timers.ts b/ts/services/timers.ts index 38a12f718a..63a3298d7d 100644 --- a/ts/services/timers.ts +++ b/ts/services/timers.ts @@ -59,10 +59,12 @@ export function onTimeout( } export function removeTimeout(uuid: string): void { - if (timeoutStore.has(uuid)) { - timeoutStore.delete(uuid); + if (!timeoutStore.has(uuid)) { + return; } + timeoutStore.delete(uuid); + allTimeouts.forEach((timeout: TimeoutType) => { if (uuid === timeout.uuid) { allTimeouts.delete(timeout); diff --git a/ts/test-both/util/timestampLongUtils_test.ts b/ts/test-both/util/timestampLongUtils_test.ts new file mode 100644 index 0000000000..6285bbb629 --- /dev/null +++ b/ts/test-both/util/timestampLongUtils_test.ts @@ -0,0 +1,51 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import { + getSafeLongFromTimestamp, + getTimestampFromLong, +} from '../../util/timestampLongUtils'; + +describe('getSafeLongFromTimestamp', () => { + const { Long } = window.dcodeIO; + + it('returns zero when passed undefined', () => { + assert(getSafeLongFromTimestamp(undefined).isZero()); + }); + + it('returns the number as a Long when passed a "normal" number', () => { + assert(getSafeLongFromTimestamp(0).isZero()); + assert.strictEqual(getSafeLongFromTimestamp(123).toString(), '123'); + assert.strictEqual(getSafeLongFromTimestamp(-456).toString(), '-456'); + }); + + it('returns Long.MAX_VALUE when passed Infinity', () => { + assert(getSafeLongFromTimestamp(Infinity).equals(Long.MAX_VALUE)); + }); + + it("returns Long.MAX_VALUE when passed very large numbers, outside of JavaScript's safely representable range", () => { + assert.equal(getSafeLongFromTimestamp(Number.MAX_VALUE), Long.MAX_VALUE); + }); +}); + +describe('getTimestampFromLong', () => { + const { Long } = window.dcodeIO; + + it('returns zero when passed 0 Long', () => { + assert.equal(getTimestampFromLong(Long.fromNumber(0)), 0); + }); + + it('returns Number.MAX_SAFE_INTEGER when passed Long.MAX_VALUE', () => { + assert.equal(getTimestampFromLong(Long.MAX_VALUE), Number.MAX_SAFE_INTEGER); + }); + + it('returns a normal number', () => { + assert.equal(getTimestampFromLong(Long.fromNumber(16)), 16); + }); + + it('returns 0 for null value', () => { + assert.equal(getTimestampFromLong(null), 0); + }); +}); diff --git a/ts/textsecure.d.ts b/ts/textsecure.d.ts index c11f523133..97834541be 100644 --- a/ts/textsecure.d.ts +++ b/ts/textsecure.d.ts @@ -1057,6 +1057,7 @@ export declare class ContactRecordClass { whitelisted?: boolean | null; archived?: boolean | null; markedUnread?: boolean; + mutedUntilTimestamp?: ProtoBigNumberType; __unknownFields?: ArrayBuffer; } @@ -1073,6 +1074,7 @@ export declare class GroupV1RecordClass { whitelisted?: boolean | null; archived?: boolean | null; markedUnread?: boolean; + mutedUntilTimestamp?: ProtoBigNumberType; __unknownFields?: ArrayBuffer; } @@ -1089,6 +1091,7 @@ export declare class GroupV2RecordClass { whitelisted?: boolean | null; archived?: boolean | null; markedUnread?: boolean; + mutedUntilTimestamp?: ProtoBigNumberType; __unknownFields?: ArrayBuffer; } diff --git a/ts/util/getMuteOptions.ts b/ts/util/getMuteOptions.ts index f5c201c56c..3ce5398fcd 100644 --- a/ts/util/getMuteOptions.ts +++ b/ts/util/getMuteOptions.ts @@ -16,6 +16,10 @@ export function getMuteOptions(i18n: LocalizerType): Array { name: i18n('muteHour'), value: moment.duration(1, 'hour').as('milliseconds'), }, + { + name: i18n('muteEightHours'), + value: moment.duration(8, 'hour').as('milliseconds'), + }, { name: i18n('muteDay'), value: moment.duration(1, 'day').as('milliseconds'), @@ -25,8 +29,8 @@ export function getMuteOptions(i18n: LocalizerType): Array { value: moment.duration(1, 'week').as('milliseconds'), }, { - name: i18n('muteYear'), - value: moment.duration(1, 'year').as('milliseconds'), + name: i18n('muteAlways'), + value: Number.MAX_SAFE_INTEGER, }, ]; } diff --git a/ts/util/timestampLongUtils.ts b/ts/util/timestampLongUtils.ts new file mode 100644 index 0000000000..4c7af086c8 --- /dev/null +++ b/ts/util/timestampLongUtils.ts @@ -0,0 +1,26 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { Long } from '../window.d'; + +export function getSafeLongFromTimestamp(timestamp = 0): Long { + if (timestamp >= Number.MAX_SAFE_INTEGER) { + return window.dcodeIO.Long.MAX_VALUE; + } + + return window.dcodeIO.Long.fromNumber(timestamp); +} + +export function getTimestampFromLong(value: Long | null): number { + if (!value) { + return 0; + } + + const num = value.toNumber(); + + if (num >= Number.MAX_SAFE_INTEGER) { + return Number.MAX_SAFE_INTEGER; + } + + return num; +} diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index 5dbefe487c..4205151501 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -478,7 +478,10 @@ Whisper.ConversationView = Whisper.View.extend({ : this.model.getTitle(); searchInConversation(this.model.id, name); }, - onSetMuteNotifications: (ms: number) => this.setMuteNotifications(ms), + onSetMuteNotifications: (ms: number) => + this.model.setMuteExpiration( + ms >= Number.MAX_SAFE_INTEGER ? ms : Date.now() + ms + ), onSetPin: this.setPin.bind(this), // These are view only and don't update the Conversation model, so they // need a manual update call. @@ -3162,31 +3165,6 @@ Whisper.ConversationView = Whisper.View.extend({ }); }, - setMuteNotifications(ms: number) { - const muteExpiresAt = ms > 0 ? Date.now() + ms : undefined; - - if (muteExpiresAt) { - // we use a timeoutId here so that we can reference the mute that was - // potentially set in the ConversationController. Specifically for a - // scenario where a conversation is already muted and we boot up the app, - // a timeout will be already set. But if we change the mute to a later - // date a new timeout would need to be set and the old one cleared. With - // this ID we can reference the existing timeout. - const timeoutId = this.model.getMuteTimeoutId(); - window.Signal.Services.removeTimeout(timeoutId); - window.Signal.Services.onTimeout( - muteExpiresAt, - () => { - this.setMuteNotifications(0); - }, - timeoutId - ); - } - - this.model.set({ muteExpiresAt }); - this.saveModel(); - }, - async destroyMessages() { window.showConfirmationDialog({ message: window.i18n('deleteConversationConfirmation'), diff --git a/ts/window.d.ts b/ts/window.d.ts index 2ce61f5e7d..f61e967c67 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -573,6 +573,7 @@ export type DCodeIOType = { Long: DCodeIOType['Long']; }; Long: Long & { + MAX_VALUE: Long; equals: (other: Long | number | string) => boolean; fromBits: (low: number, high: number, unsigned: boolean) => number; fromNumber: (value: number, unsigned?: boolean) => Long;