From 9510fd1eecc99b0c891185dc7007485eef760245 Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Wed, 30 Sep 2020 20:43:24 -0400 Subject: [PATCH] Cleans up mute state after mute expires --- js/modules/signal.js | 3 ++ ts/ConversationController.ts | 19 +++++++ ts/components/ConversationListItem.tsx | 2 +- ts/models/conversations.ts | 10 +++- ts/services/timers.ts | 68 ++++++++++++++++++++++++++ ts/views/conversation_view.ts | 29 +++++++++-- ts/window.d.ts | 2 + 7 files changed, 125 insertions(+), 8 deletions(-) create mode 100644 ts/services/timers.ts diff --git a/js/modules/signal.js b/js/modules/signal.js index 9092dca0a..cf485cabf 100644 --- a/js/modules/signal.js +++ b/js/modules/signal.js @@ -121,6 +121,7 @@ const { } = require('../../ts/services/updateListener'); const { notify } = require('../../ts/services/notify'); const { calling } = require('../../ts/services/calling'); +const { onTimeout, removeTimeout } = require('../../ts/services/timers'); const { enableStorageService, eraseAllStorageServiceState, @@ -341,7 +342,9 @@ exports.setup = (options = {}) => { initializeGroupCredentialFetcher, initializeNetworkObserver, initializeUpdateListener, + onTimeout, notify, + removeTimeout, runStorageServiceSyncJob, storageServiceUploadJob, }; diff --git a/ts/ConversationController.ts b/ts/ConversationController.ts index 6d6c5855d..2c2e433cb 100644 --- a/ts/ConversationController.ts +++ b/ts/ConversationController.ts @@ -40,6 +40,9 @@ export function start(): void { this.on('add remove change:unreadCount', debouncedUpdateUnreadCount); window.Whisper.events.on('updateUnreadCount', debouncedUpdateUnreadCount); + this.on('add', (model: ConversationModel): void => { + this.initMuteExpirationTimer(model); + }); }, addActive(model: ConversationModel) { if (model.get('active_at')) { @@ -48,6 +51,22 @@ export function start(): void { this.remove(model); } }, + // If the conversation is muted we set a timeout so when the mute expires + // we can reset the mute state on the model. If the mute has already expired + // then we reset the state right away. + initMuteExpirationTimer(model: ConversationModel): void { + if (model.isMuted()) { + window.Signal.Services.onTimeout( + model.get('muteExpiresAt'), + () => { + model.set({ muteExpiresAt: undefined }); + }, + model.getMuteTimeoutId() + ); + } else if (model.get('muteExpiresAt')) { + model.set({ muteExpiresAt: undefined }); + } + }, updateUnreadCount() { const canCountMutedConversations = window.storage.get( 'badge-count-muted-conversations' diff --git a/ts/components/ConversationListItem.tsx b/ts/components/ConversationListItem.tsx index 1aa8b3917..53c57e540 100644 --- a/ts/components/ConversationListItem.tsx +++ b/ts/components/ConversationListItem.tsx @@ -206,7 +206,7 @@ export class ConversationListItem extends React.PureComponent { : null )} > - {muteExpiresAt && ( + {muteExpiresAt && Date.now() < muteExpiresAt && ( )} {!isAccepted ? ( diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 01540671e..2f5dca60c 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -3553,8 +3553,14 @@ export class ConversationModel extends window.Backbone.Model< } isMuted(): boolean { - return (this.get('muteExpiresAt') && - Date.now() < this.get('muteExpiresAt')) as boolean; + return ( + Boolean(this.get('muteExpiresAt')) && + Date.now() < this.get('muteExpiresAt') + ); + } + + getMuteTimeoutId(): string { + return `mute(${this.get('id')})`; } async notify(message: WhatIsThis, reaction?: WhatIsThis): Promise { diff --git a/ts/services/timers.ts b/ts/services/timers.ts new file mode 100644 index 000000000..df527262a --- /dev/null +++ b/ts/services/timers.ts @@ -0,0 +1,68 @@ +import { v4 as getGuid } from 'uuid'; + +type TimeoutType = { + timestamp: number; + uuid: string; +}; + +const timeoutStore: Map void> = new Map(); +const allTimeouts: Set = new Set(); + +setInterval(() => { + if (!allTimeouts.size) { + return; + } + + const now = Date.now(); + + allTimeouts.forEach((timeout: TimeoutType) => { + const { timestamp, uuid } = timeout; + + if (now >= timestamp) { + if (timeoutStore.has(uuid)) { + const callback = timeoutStore.get(uuid); + if (callback) { + callback(); + } + timeoutStore.delete(uuid); + } + + allTimeouts.delete(timeout); + } + }); +}, 100); + +export function onTimeout( + timestamp: number, + callback: () => void, + id?: string +): string { + if (id && timeoutStore.has(id)) { + throw new ReferenceError(`onTimeout: ${id} already exists`); + } + + let uuid = id || getGuid(); + while (timeoutStore.has(uuid)) { + uuid = getGuid(); + } + + timeoutStore.set(uuid, callback); + allTimeouts.add({ + timestamp, + uuid, + }); + + return uuid; +} + +export function removeTimeout(uuid: string): void { + if (timeoutStore.has(uuid)) { + timeoutStore.delete(uuid); + } + + allTimeouts.forEach((timeout: TimeoutType) => { + if (uuid === timeout.uuid) { + allTimeouts.delete(timeout); + } + }); +} diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index 04377ba73..fdacfd16b 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -395,7 +395,7 @@ Whisper.ConversationView = Whisper.View.extend({ getMuteExpirationLabel() { const muteExpiresAt = this.model.get('muteExpiresAt'); - if (!muteExpiresAt) { + if (!this.model.isMuted()) { return; } @@ -2614,10 +2614,29 @@ Whisper.ConversationView = Whisper.View.extend({ } }, - setMuteNotifications(ms: any) { - this.model.set({ - muteExpiresAt: ms > 0 ? Date.now() + ms : undefined, - }); + 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() { diff --git a/ts/window.d.ts b/ts/window.d.ts index 61cb23013..59794c6aa 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -190,6 +190,8 @@ declare global { updates: WhatIsThis, events: WhatIsThis ) => void; + onTimeout: (timestamp: number, cb: () => void, id?: string) => string; + removeTimeout: (uuid: string) => void; runStorageServiceSyncJob: () => Promise; storageServiceUploadJob: () => void; };