diff --git a/ts/ConversationController.ts b/ts/ConversationController.ts index c77ec5c0f..42ff09139 100644 --- a/ts/ConversationController.ts +++ b/ts/ConversationController.ts @@ -1,4 +1,4 @@ -// Copyright 2020-2021 Signal Messenger, LLC +// Copyright 2020-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { debounce, uniq, without } from 'lodash'; @@ -14,8 +14,8 @@ import type { ConversationModel } from './models/conversations'; import { getContactId } from './messages/helpers'; import { maybeDeriveGroupV2Id } from './groups'; import { assert } from './util/assert'; -import { map, reduce } from './util/iterables'; import { isGroupV1, isGroupV2 } from './util/whatTypeOfConversation'; +import { getConversationUnreadCountForAppBadge } from './util/getConversationUnreadCountForAppBadge'; import { UUID, isValidUuid } from './types/UUID'; import { Address } from './types/Address'; import { QualifiedAddress } from './types/QualifiedAddress'; @@ -53,7 +53,10 @@ export function start(): void { 1000 ); - this.on('add remove change:unreadCount', debouncedUpdateUnreadCount); + 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 @@ -70,32 +73,16 @@ export function start(): void { } }, updateUnreadCount() { - const canCountMutedConversations = window.storage.get( - 'badge-count-muted-conversations' - ); + const canCountMutedConversations = + window.storage.get('badge-count-muted-conversations') || false; - const canCount = (m: ConversationModel) => - !m.isMuted() || canCountMutedConversations; - - const getUnreadCount = (m: ConversationModel) => { - const unreadCount = m.get('unreadCount'); - - if (unreadCount) { - return unreadCount; - } - - if (m.get('markedUnread')) { - return 1; - } - - return 0; - }; - - const newUnreadCount = reduce( - map(this, (m: ConversationModel) => - canCount(m) ? getUnreadCount(m) : 0 - ), - (item: number, memo: number) => (item || 0) + memo, + const newUnreadCount = this.reduce( + (result: number, conversation: ConversationModel) => + result + + getConversationUnreadCountForAppBadge( + conversation.attributes, + canCountMutedConversations + ), 0 ); window.storage.put('unreadCount', newUnreadCount); diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index ab8a3e540..89265dc1b 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -4278,8 +4278,6 @@ export class ConversationModel extends window.Backbone if (Boolean(previousMarkedUnread) !== Boolean(markedUnread)) { this.captureChange('markedUnread'); } - - window.Whisper.events.trigger('updateUnreadCount'); } async refreshGroupLink(): Promise { diff --git a/ts/test-both/util/getConversationUnreadCountForAppBadge_test.ts b/ts/test-both/util/getConversationUnreadCountForAppBadge_test.ts new file mode 100644 index 000000000..b8e04a0c7 --- /dev/null +++ b/ts/test-both/util/getConversationUnreadCountForAppBadge_test.ts @@ -0,0 +1,100 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import { getConversationUnreadCountForAppBadge } from '../../util/getConversationUnreadCountForAppBadge'; + +describe('getConversationUnreadCountForAppBadge', () => { + const getCount = getConversationUnreadCountForAppBadge; + + const mutedTimestamp = (): number => Date.now() + 12345; + const oldMutedTimestamp = (): number => Date.now() - 1000; + + 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 }, + ]; + for (const conversation of archivedConversations) { + assert.strictEqual(getCount(conversation, true), 0); + assert.strictEqual(getCount(conversation, false), 0); + } + }); + + 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 }, + ]; + for (const conversation of mutedConversations) { + assert.strictEqual(getCount(conversation, false), 0); + } + }); + + it('returns the unread count if nonzero (and not archived)', () => { + const conversationsWithUnreadCount = [ + { unreadCount: 9, markedUnread: false }, + { unreadCount: 9, markedUnread: true }, + { + unreadCount: 9, + markedUnread: false, + muteExpiresAt: oldMutedTimestamp(), + }, + { unreadCount: 9, markedUnread: false, isArchived: false }, + ]; + for (const conversation of conversationsWithUnreadCount) { + assert.strictEqual(getCount(conversation, false), 9); + assert.strictEqual(getCount(conversation, true), 9); + } + + const mutedWithUnreads = { + unreadCount: 123, + markedUnread: false, + muteExpiresAt: mutedTimestamp(), + }; + assert.strictEqual(getCount(mutedWithUnreads, true), 123); + }); + + it('returns 1 if the conversation is marked unread', () => { + const conversationsMarkedUnread = [ + { markedUnread: true }, + { markedUnread: true, unreadCount: 0 }, + { markedUnread: true, muteExpiresAt: oldMutedTimestamp() }, + { + markedUnread: true, + muteExpiresAt: oldMutedTimestamp(), + isArchived: false, + }, + ]; + for (const conversation of conversationsMarkedUnread) { + assert.strictEqual(getCount(conversation, false), 1); + assert.strictEqual(getCount(conversation, true), 1); + } + + const mutedConversationsMarkedUnread = [ + { markedUnread: true, muteExpiresAt: mutedTimestamp() }, + { markedUnread: true, muteExpiresAt: mutedTimestamp(), unreadCount: 0 }, + ]; + for (const conversation of mutedConversationsMarkedUnread) { + assert.strictEqual(getCount(conversation, true), 1); + } + }); + + it('returns 0 if the conversation is read', () => { + const readConversations = [ + { markedUnread: false }, + { markedUnread: false, unreadCount: 0 }, + { markedUnread: false, mutedTimestamp: mutedTimestamp() }, + { markedUnread: false, mutedTimestamp: oldMutedTimestamp() }, + ]; + for (const conversation of readConversations) { + assert.strictEqual(getCount(conversation, false), 0); + assert.strictEqual(getCount(conversation, true), 0); + } + }); +}); diff --git a/ts/util/getConversationUnreadCountForAppBadge.ts b/ts/util/getConversationUnreadCountForAppBadge.ts new file mode 100644 index 000000000..ab53f8d44 --- /dev/null +++ b/ts/util/getConversationUnreadCountForAppBadge.ts @@ -0,0 +1,35 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { ConversationAttributesType } from '../model-types.d'; +import { isConversationMuted } from './isConversationMuted'; + +export function getConversationUnreadCountForAppBadge( + conversation: Readonly< + Pick< + ConversationAttributesType, + 'isArchived' | 'markedUnread' | 'muteExpiresAt' | 'unreadCount' + > + >, + canCountMutedConversations: boolean +): number { + const { isArchived, markedUnread, unreadCount } = conversation; + + if (isArchived) { + return 0; + } + + if (!canCountMutedConversations && isConversationMuted(conversation)) { + return 0; + } + + if (unreadCount) { + return unreadCount; + } + + if (markedUnread) { + return 1; + } + + return 0; +}