diff --git a/ts/ConversationController.ts b/ts/ConversationController.ts index c1b38f910..df3971de6 100644 --- a/ts/ConversationController.ts +++ b/ts/ConversationController.ts @@ -22,6 +22,7 @@ import { QualifiedAddress } from './types/QualifiedAddress'; import * as log from './logging/log'; import { sleep } from './util/sleep'; import { isNotNil } from './util/isNotNil'; +import { SECOND } from './util/durations'; const MAX_MESSAGE_BODY_LENGTH = 64 * 1024; @@ -41,75 +42,6 @@ const { export function start(): void { const conversations = new window.Whisper.ConversationCollection(); - // This class is entirely designed to keep the app title, badge and tray icon updated. - // In the future it could listen to redux changes and do its updates there. - const inboxCollection = new (window.Backbone.Collection.extend({ - hasQueueEmptied: false, - - initialize() { - this.listenTo(conversations, 'add change:active_at', this.addActive); - this.listenTo(conversations, 'reset', () => this.reset([])); - - const debouncedUpdateUnreadCount = debounce( - this.updateUnreadCount.bind(this), - 1000, - { leading: true, maxWait: 1000, trailing: true } - ); - - this.on( - 'add remove change:unreadCount change:markedUnread change:isArchived change:muteExpiresAt', - debouncedUpdateUnreadCount - ); - window.Whisper.events.on('updateUnreadCount', debouncedUpdateUnreadCount); - this.on('add', (model: ConversationModel): void => { - // 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. - model.startMuteTimer(); - }); - }, - onEmpty() { - this.hasQueueEmptied = true; - this.updateUnreadCount(); - }, - addActive(model: ConversationModel) { - if (model.get('active_at')) { - this.add(model); - } else { - this.remove(model); - } - }, - updateUnreadCount() { - if (!this.hasQueueEmptied) { - return; - } - - const canCountMutedConversations = - window.storage.get('badge-count-muted-conversations') || false; - - const newUnreadCount = this.reduce( - (result: number, conversation: ConversationModel) => - result + - getConversationUnreadCountForAppBadge( - conversation.attributes, - canCountMutedConversations - ), - 0 - ); - window.storage.put('unreadCount', newUnreadCount); - - if (newUnreadCount > 0) { - window.setBadgeCount(newUnreadCount); - window.document.title = `${window.getTitle()} (${newUnreadCount})`; - } else { - window.setBadgeCount(0); - window.document.title = window.getTitle(); - } - window.updateTrayIcon(newUnreadCount); - }, - }))(); - - window.getInboxCollection = () => inboxCollection; window.getConversations = () => conversations; window.ConversationController = new ConversationController(conversations); } @@ -121,7 +53,67 @@ export class ConversationController { private _conversationOpenStart = new Map(); - constructor(private _conversations: ConversationModelCollectionType) {} + private _hasQueueEmptied = false; + + constructor(private _conversations: ConversationModelCollectionType) { + const debouncedUpdateUnreadCount = debounce( + this.updateUnreadCount.bind(this), + SECOND, + { + leading: true, + maxWait: SECOND, + trailing: true, + } + ); + + // A few things can cause us to update the app-level unread count + window.Whisper.events.on('updateUnreadCount', debouncedUpdateUnreadCount); + this._conversations.on( + 'add remove change:active_at change:unreadCount change:markedUnread change:isArchived change:muteExpiresAt', + debouncedUpdateUnreadCount + ); + + // 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. + this._conversations.on('add', (model: ConversationModel): void => { + model.startMuteTimer(); + }); + } + + updateUnreadCount(): void { + if (!this._hasQueueEmptied) { + return; + } + + const canCountMutedConversations = + window.storage.get('badge-count-muted-conversations') || false; + + const newUnreadCount = this._conversations.reduce( + (result: number, conversation: ConversationModel) => + result + + getConversationUnreadCountForAppBadge( + conversation.attributes, + canCountMutedConversations + ), + 0 + ); + window.storage.put('unreadCount', newUnreadCount); + + if (newUnreadCount > 0) { + window.setBadgeCount(newUnreadCount); + window.document.title = `${window.getTitle()} (${newUnreadCount})`; + } else { + window.setBadgeCount(0); + window.document.title = window.getTitle(); + } + window.updateTrayIcon(newUnreadCount); + } + + onEmpty(): void { + this._hasQueueEmptied = true; + this.updateUnreadCount(); + } get(id?: string | null): ConversationModel | undefined { if (!this._initialFetchComplete) { diff --git a/ts/background.ts b/ts/background.ts index 87bfd698d..f15d9e86e 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -2307,7 +2307,7 @@ export async function startApp(): Promise { ]); log.info('onEmpty: All outstanding database requests complete'); window.readyForUpdates(); - window.getInboxCollection().onEmpty(); + window.ConversationController.onEmpty(); // Start listeners here, after we get through our queue. RotateSignedPreKeyListener.init(window.Whisper.events, newVersion); diff --git a/ts/test-both/util/getConversationUnreadCountForAppBadge_test.ts b/ts/test-both/util/getConversationUnreadCountForAppBadge_test.ts index b8e04a0c7..7bbbfd3c5 100644 --- a/ts/test-both/util/getConversationUnreadCountForAppBadge_test.ts +++ b/ts/test-both/util/getConversationUnreadCountForAppBadge_test.ts @@ -13,10 +13,25 @@ describe('getConversationUnreadCountForAppBadge', () => { it('returns 0 if the conversation is archived', () => { const archivedConversations = [ - { isArchived: true, markedUnread: false, unreadCount: 0 }, - { isArchived: true, markedUnread: false, unreadCount: 123 }, - { isArchived: true, markedUnread: true, unreadCount: 0 }, - { isArchived: true, markedUnread: true }, + { + active_at: Date.now(), + isArchived: true, + markedUnread: false, + unreadCount: 0, + }, + { + active_at: Date.now(), + isArchived: true, + markedUnread: false, + unreadCount: 123, + }, + { + active_at: Date.now(), + isArchived: true, + markedUnread: true, + unreadCount: 0, + }, + { active_at: Date.now(), isArchived: true, markedUnread: true }, ]; for (const conversation of archivedConversations) { assert.strictEqual(getCount(conversation, true), 0); @@ -26,10 +41,29 @@ describe('getConversationUnreadCountForAppBadge', () => { it("returns 0 if the conversation is muted and the user doesn't want to include those in the result", () => { const mutedConversations = [ - { muteExpiresAt: mutedTimestamp(), markedUnread: false, unreadCount: 0 }, - { muteExpiresAt: mutedTimestamp(), markedUnread: false, unreadCount: 9 }, - { muteExpiresAt: mutedTimestamp(), markedUnread: true, unreadCount: 0 }, - { muteExpiresAt: mutedTimestamp(), markedUnread: true }, + { + active_at: Date.now(), + muteExpiresAt: mutedTimestamp(), + markedUnread: false, + unreadCount: 0, + }, + { + active_at: Date.now(), + muteExpiresAt: mutedTimestamp(), + markedUnread: false, + unreadCount: 9, + }, + { + active_at: Date.now(), + muteExpiresAt: mutedTimestamp(), + markedUnread: true, + unreadCount: 0, + }, + { + active_at: Date.now(), + muteExpiresAt: mutedTimestamp(), + markedUnread: true, + }, ]; for (const conversation of mutedConversations) { assert.strictEqual(getCount(conversation, false), 0); @@ -38,14 +72,20 @@ describe('getConversationUnreadCountForAppBadge', () => { it('returns the unread count if nonzero (and not archived)', () => { const conversationsWithUnreadCount = [ - { unreadCount: 9, markedUnread: false }, - { unreadCount: 9, markedUnread: true }, + { active_at: Date.now(), unreadCount: 9, markedUnread: false }, + { active_at: Date.now(), unreadCount: 9, markedUnread: true }, { + active_at: Date.now(), unreadCount: 9, markedUnread: false, muteExpiresAt: oldMutedTimestamp(), }, - { unreadCount: 9, markedUnread: false, isArchived: false }, + { + active_at: Date.now(), + unreadCount: 9, + markedUnread: false, + isArchived: false, + }, ]; for (const conversation of conversationsWithUnreadCount) { assert.strictEqual(getCount(conversation, false), 9); @@ -53,6 +93,7 @@ describe('getConversationUnreadCountForAppBadge', () => { } const mutedWithUnreads = { + active_at: Date.now(), unreadCount: 123, markedUnread: false, muteExpiresAt: mutedTimestamp(), @@ -62,10 +103,15 @@ describe('getConversationUnreadCountForAppBadge', () => { it('returns 1 if the conversation is marked unread', () => { const conversationsMarkedUnread = [ - { markedUnread: true }, - { markedUnread: true, unreadCount: 0 }, - { markedUnread: true, muteExpiresAt: oldMutedTimestamp() }, + { active_at: Date.now(), markedUnread: true }, + { active_at: Date.now(), markedUnread: true, unreadCount: 0 }, { + active_at: Date.now(), + markedUnread: true, + muteExpiresAt: oldMutedTimestamp(), + }, + { + active_at: Date.now(), markedUnread: true, muteExpiresAt: oldMutedTimestamp(), isArchived: false, @@ -77,8 +123,17 @@ describe('getConversationUnreadCountForAppBadge', () => { } const mutedConversationsMarkedUnread = [ - { markedUnread: true, muteExpiresAt: mutedTimestamp() }, - { markedUnread: true, muteExpiresAt: mutedTimestamp(), unreadCount: 0 }, + { + active_at: Date.now(), + markedUnread: true, + muteExpiresAt: mutedTimestamp(), + }, + { + active_at: Date.now(), + markedUnread: true, + muteExpiresAt: mutedTimestamp(), + unreadCount: 0, + }, ]; for (const conversation of mutedConversationsMarkedUnread) { assert.strictEqual(getCount(conversation, true), 1); @@ -87,10 +142,35 @@ describe('getConversationUnreadCountForAppBadge', () => { it('returns 0 if the conversation is read', () => { const readConversations = [ - { markedUnread: false }, - { markedUnread: false, unreadCount: 0 }, - { markedUnread: false, mutedTimestamp: mutedTimestamp() }, - { markedUnread: false, mutedTimestamp: oldMutedTimestamp() }, + { active_at: Date.now(), markedUnread: false }, + { active_at: Date.now(), markedUnread: false, unreadCount: 0 }, + { + active_at: Date.now(), + markedUnread: false, + mutedTimestamp: mutedTimestamp(), + }, + { + active_at: Date.now(), + markedUnread: false, + mutedTimestamp: oldMutedTimestamp(), + }, + ]; + for (const conversation of readConversations) { + assert.strictEqual(getCount(conversation, false), 0); + assert.strictEqual(getCount(conversation, true), 0); + } + }); + + it('returns 0 if the conversation has falsey active_at', () => { + const readConversations = [ + { active_at: undefined, markedUnread: false, unreadCount: 2 }, + { active_at: null, markedUnread: true, unreadCount: 0 }, + { + active_at: 0, + unreadCount: 2, + markedUnread: false, + mutedTimestamp: oldMutedTimestamp(), + }, ]; for (const conversation of readConversations) { assert.strictEqual(getCount(conversation, false), 0); diff --git a/ts/util/getConversationUnreadCountForAppBadge.ts b/ts/util/getConversationUnreadCountForAppBadge.ts index ab53f8d44..2c911f0c2 100644 --- a/ts/util/getConversationUnreadCountForAppBadge.ts +++ b/ts/util/getConversationUnreadCountForAppBadge.ts @@ -8,13 +8,21 @@ export function getConversationUnreadCountForAppBadge( conversation: Readonly< Pick< ConversationAttributesType, - 'isArchived' | 'markedUnread' | 'muteExpiresAt' | 'unreadCount' + | 'active_at' + | 'isArchived' + | 'markedUnread' + | 'muteExpiresAt' + | 'unreadCount' > >, canCountMutedConversations: boolean ): number { const { isArchived, markedUnread, unreadCount } = conversation; + if (!conversation.active_at) { + return 0; + } + if (isArchived) { return 0; } diff --git a/ts/window.d.ts b/ts/window.d.ts index 7cb3f96a3..5df00531d 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -195,9 +195,6 @@ declare global { getEnvironment: typeof getEnvironment; getExpiration: () => string; getHostName: () => string; - getInboxCollection: () => ConversationModelCollectionType & { - onEmpty: () => void; - }; getInteractionMode: () => 'mouse' | 'keyboard'; getLocale: () => ElectronLocaleType; getMediaCameraPermissions: () => Promise;