From a5b90fdca9cee14087064a39a9b952a96f83d61b Mon Sep 17 00:00:00 2001 From: Jamie <113370520+jamiebuilds-signal@users.noreply.github.com> Date: Tue, 28 Oct 2025 12:44:56 -0700 Subject: [PATCH] Fix badge counts for include muted setting --- ts/ConversationController.preload.ts | 8 +- ts/state/ducks/search.preload.ts | 4 +- ts/state/selectors/conversations.dom.ts | 32 +- ts/state/selectors/items.dom.ts | 7 + .../util/countUnreadStats_test.node.ts | 376 ++++++++++++++++++ .../util/countUnreadStats_test.std.ts | 199 --------- ts/util/countUnreadStats.std.ts | 45 ++- ts/util/filterAndSortConversations.std.ts | 10 +- 8 files changed, 452 insertions(+), 229 deletions(-) create mode 100644 ts/test-node/util/countUnreadStats_test.node.ts delete mode 100644 ts/test-node/util/countUnreadStats_test.std.ts diff --git a/ts/ConversationController.preload.ts b/ts/ConversationController.preload.ts index 1426a8fc7c..78406b2e2d 100644 --- a/ts/ConversationController.preload.ts +++ b/ts/ConversationController.preload.ts @@ -369,7 +369,7 @@ export class ConversationController { return; } - const includeMuted = + const badgeCountMutedConversationsSetting = itemStorage.get('badge-count-muted-conversations') || false; const unreadStats = countAllConversationsUnreadStats( @@ -390,7 +390,11 @@ export class ConversationController { }; } ), - { includeMuted } + { + includeMuted: badgeCountMutedConversationsSetting + ? 'setting-on' + : 'setting-off', + } ); drop(itemStorage.put('unreadCount', unreadStats.unreadCount)); diff --git a/ts/state/ducks/search.preload.ts b/ts/state/ducks/search.preload.ts index 325e2d23fc..895d26ec53 100644 --- a/ts/state/ducks/search.preload.ts +++ b/ts/state/ducks/search.preload.ts @@ -333,7 +333,7 @@ function shouldRemoveConversationFromUnreadList( conversation && (selectedConversationId == null || selectedConversationId !== conversation.id) && - !isConversationUnread(conversation, { includeMuted: true }) + !isConversationUnread(conversation, { includeMuted: 'force-exclude' }) ) { return true; } @@ -499,7 +499,7 @@ const doSearch = debounce( selectedConversation && state.search.conversationIds.includes(selectedConversationId) && !isConversationUnread(selectedConversation, { - includeMuted: true, + includeMuted: 'force-include', }) ? selectedConversation : undefined, diff --git a/ts/state/selectors/conversations.dom.ts b/ts/state/selectors/conversations.dom.ts index 20a96d1943..64d908e276 100644 --- a/ts/state/selectors/conversations.dom.ts +++ b/ts/state/selectors/conversations.dom.ts @@ -55,7 +55,10 @@ import { getUserConversationId, getUserNumber, } from './user.std.js'; -import { getPinnedConversationIds } from './items.dom.js'; +import { + getBadgeCountMutedConversations, + getPinnedConversationIds, +} from './items.dom.js'; import { createLogger } from '../../logging/log.std.js'; import { TimelineMessageLoadingState } from '../../util/timelineUtil.std.js'; import { isSignalConversation } from '../../util/isSignalConversation.dom.js'; @@ -687,25 +690,32 @@ export const getAllGroupsWithInviteAccess = createSelector( }) ); -export const getAllConversationsUnreadStats = createSelector( - getAllConversations, - (conversations): UnreadStats => { - return countAllConversationsUnreadStats(conversations, { - includeMuted: false, - }); - } -); +export const getAllConversationsUnreadStats: StateSelector = + createSelector( + getAllConversations, + getBadgeCountMutedConversations, + (conversations, badgeCountMutedConversations) => { + return countAllConversationsUnreadStats(conversations, { + includeMuted: badgeCountMutedConversations + ? 'setting-on' + : 'setting-off', + }); + } + ); export const getAllChatFoldersUnreadStats: StateSelector = createSelector( getCurrentChatFolders, getAllConversations, - (currentChatFolders, allConversations) => { + getBadgeCountMutedConversations, + (currentChatFolders, allConversations, badgeCountMutedConversations) => { return countAllChatFoldersUnreadStats( currentChatFolders, allConversations, { - includeMuted: false, + includeMuted: badgeCountMutedConversations + ? 'setting-on' + : 'setting-off', } ); } diff --git a/ts/state/selectors/items.dom.ts b/ts/state/selectors/items.dom.ts index a3dada7288..3eb2f1fbee 100644 --- a/ts/state/selectors/items.dom.ts +++ b/ts/state/selectors/items.dom.ts @@ -238,6 +238,13 @@ export const getAutoDownloadUpdate = createSelector( } ); +export const getBadgeCountMutedConversations = createSelector( + getItems, + (state: ItemsStateType): boolean => { + return state['badge-count-muted-conversations'] ?? false; + } +); + export const getTextFormattingEnabled = createSelector( getItems, (state: ItemsStateType): boolean => Boolean(state.textFormatting ?? true) diff --git a/ts/test-node/util/countUnreadStats_test.node.ts b/ts/test-node/util/countUnreadStats_test.node.ts new file mode 100644 index 0000000000..72ac734159 --- /dev/null +++ b/ts/test-node/util/countUnreadStats_test.node.ts @@ -0,0 +1,376 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import assert from 'node:assert/strict'; +import { v4 as generateUuid } from 'uuid'; +import { + _canCountConversation, + _countConversation, + _createUnreadStats, + _shouldExcludeMuted, + countAllChatFoldersUnreadStats, + countAllConversationsUnreadStats, + countConversationUnreadStats, + isConversationUnread, +} from '../../util/countUnreadStats.std.js'; +import type { + UnreadStats, + ConversationPropsForUnreadStats, + UnreadStatsIncludeMuted, +} from '../../util/countUnreadStats.std.js'; +import type { CurrentChatFolder } from '../../types/CurrentChatFolders.std.js'; +import { CurrentChatFolders } from '../../types/CurrentChatFolders.std.js'; +import type { ChatFolderId } from '../../types/ChatFolder.std.js'; +import { CHAT_FOLDER_DEFAULTS } from '../../types/ChatFolder.std.js'; + +function getFutureMutedTimestamp() { + return Date.now() + 12345; +} + +function getPastMutedTimestamp() { + return Date.now() - 1000; +} + +type ChatProps = Partial; +type StatsProps = Partial; + +function mockChat(props: ChatProps): ConversationPropsForUnreadStats { + return { + id: generateUuid(), + type: 'direct', + activeAt: Date.now(), + isArchived: false, + markedUnread: false, + unreadCount: 0, + unreadMentionsCount: 0, + muteExpiresAt: undefined, + left: false, + ...props, + }; +} + +function mockStats(props: StatsProps): UnreadStats { + return { + unreadCount: 0, + unreadMentionsCount: 0, + readChatsMarkedUnreadCount: 0, + ...props, + }; +} + +type FolderProps = Partial; + +function mockFolder(props: FolderProps): CurrentChatFolder { + return { + ...CHAT_FOLDER_DEFAULTS, + id: generateUuid() as ChatFolderId, + position: 0, + deletedAtTimestampMs: 0, + storageID: null, + storageVersion: null, + storageNeedsSync: false, + storageUnknownFields: null, + ...props, + } as CurrentChatFolder; +} + +describe('countUnreadStats', () => { + describe('_shouldExcludeMuted', () => { + it('should return the correct results', () => { + assert.equal(_shouldExcludeMuted('force-exclude'), true); + assert.equal(_shouldExcludeMuted('setting-off'), true); + assert.equal(_shouldExcludeMuted('force-include'), false); + assert.equal(_shouldExcludeMuted('setting-on'), false); + }); + }); + + describe('_canCountConversation', () => { + function check( + chat: ChatProps, + expected: boolean, + includeMuted: UnreadStatsIncludeMuted = 'force-include' + ) { + const actual = _canCountConversation(mockChat(chat), { includeMuted }); + assert.equal(actual, expected); + } + + it('should exclude inactive conversations', () => { + check({ activeAt: undefined }, false); + check({ activeAt: 0 }, false); + check({ activeAt: 1 }, true); + check({ activeAt: 100_000_000 }, true); + }); + + it('should exclude archived conversations', () => { + check({ isArchived: undefined }, true); + check({ isArchived: false }, true); + check({ isArchived: true }, false); + }); + + it('should include/exclude muted chats based on option', () => { + const past = getPastMutedTimestamp(); + const future = getFutureMutedTimestamp(); + + check({ muteExpiresAt: undefined }, true, 'force-include'); + check({ muteExpiresAt: past }, true, 'force-include'); + check({ muteExpiresAt: future }, true, 'force-include'); + + check({ muteExpiresAt: undefined }, true, 'force-exclude'); + check({ muteExpiresAt: past }, true, 'force-exclude'); + check({ muteExpiresAt: future }, false, 'force-exclude'); + }); + + it('should exclude left conversations', () => { + check({ left: undefined }, true); + check({ left: false }, true); + check({ left: true }, false); + }); + }); + + describe('_countConversation', () => { + function check(chat: ChatProps, expected: StatsProps) { + const actual = _createUnreadStats(); + _countConversation(actual, mockChat(chat)); + assert.deepEqual(actual, mockStats(expected)); + } + + it('should count unreadCount', () => { + check({ unreadCount: undefined }, { unreadCount: 0 }); + check({ unreadCount: 0 }, { unreadCount: 0 }); + check({ unreadCount: 1 }, { unreadCount: 1 }); + check({ unreadCount: 42 }, { unreadCount: 42 }); + }); + + it('should count unreadMentionsCount', () => { + check({ unreadMentionsCount: undefined }, { unreadMentionsCount: 0 }); + check({ unreadMentionsCount: 0 }, { unreadMentionsCount: 0 }); + check({ unreadMentionsCount: 1 }, { unreadMentionsCount: 1 }); + check({ unreadMentionsCount: 42 }, { unreadMentionsCount: 42 }); + }); + + it('should count readChatsMarkedUnreadCount', () => { + const read = { readChatsMarkedUnreadCount: 1 }; + const unread = { unreadCount: 42, readChatsMarkedUnreadCount: 0 }; + const mentions = { + unreadMentionsCount: 42, + readChatsMarkedUnreadCount: 0, + }; + + check({ unreadCount: undefined, markedUnread: true }, read); + check({ unreadCount: 0, markedUnread: true }, read); + check({ unreadCount: 42, markedUnread: true }, unread); + + check({ unreadMentionsCount: undefined, markedUnread: true }, read); + check({ unreadMentionsCount: 0, markedUnread: true }, read); + check({ unreadMentionsCount: 42, markedUnread: true }, mentions); + }); + }); + + describe('isConversationUnread', () => { + function check( + chat: ChatProps, + expected: boolean, + includeMuted: UnreadStatsIncludeMuted = 'force-exclude' + ) { + const actual = isConversationUnread(mockChat(chat), { includeMuted }); + assert.equal(actual, expected); + } + + it('should count unreadCount', () => { + check({ unreadCount: undefined }, false); + check({ unreadCount: 0 }, false); + check({ unreadCount: 1 }, true); + check({ unreadCount: 42 }, true); + }); + + it('should count unreadMentionsCount', () => { + check({ unreadMentionsCount: undefined }, false); + check({ unreadMentionsCount: 0 }, false); + check({ unreadMentionsCount: 1 }, true); + check({ unreadMentionsCount: 42 }, true); + }); + + it('should count markedUnread', () => { + check({ markedUnread: undefined }, false); + check({ markedUnread: false }, false); + check({ markedUnread: true }, true); + }); + + it('should check if it can count the conversation', () => { + const future = getFutureMutedTimestamp(); + check({ unreadCount: 1, activeAt: 0 }, false); + check({ unreadCount: 1, isArchived: true }, false); + check({ unreadCount: 1, muteExpiresAt: future }, false); + check({ unreadCount: 1, muteExpiresAt: future }, true, 'force-include'); + check({ unreadCount: 1, left: true }, false); + }); + }); + + describe('countConversationUnreadStats', () => { + function check( + chat: ChatProps, + expected: StatsProps, + includeMuted: UnreadStatsIncludeMuted = 'force-exclude' + ) { + const actual = countConversationUnreadStats(mockChat(chat), { + includeMuted, + }); + assert.deepEqual(actual, mockStats(expected)); + } + + it('should count all stats', () => { + check({ unreadCount: 0 }, { unreadCount: 0 }); + check({ unreadCount: 1 }, { unreadCount: 1 }); + check({ unreadMentionsCount: 0 }, { unreadMentionsCount: 0 }); + check({ unreadMentionsCount: 1 }, { unreadMentionsCount: 1 }); + check({ markedUnread: false }, { readChatsMarkedUnreadCount: 0 }); + check({ markedUnread: true }, { readChatsMarkedUnreadCount: 1 }); + }); + + it('should check if it can count the conversation', () => { + const isCounted = { unreadCount: 10 }; + const isNotCounted = { unreadCount: 0 }; + + const unread = { unreadCount: 10 }; + const inactive = { ...unread, activeAt: 0 }; + const archived = { ...unread, isArchived: true }; + const muted = { ...unread, muteExpiresAt: getFutureMutedTimestamp() }; + const left = { ...unread, left: true }; + + check(inactive, isNotCounted); + check(archived, isNotCounted); + check(muted, isNotCounted); + check(muted, isCounted, 'force-include'); + check(left, isNotCounted); + }); + }); + + describe('countAllConversationsUnreadStats', () => { + function check( + chats: ReadonlyArray, + expected: StatsProps, + includeMuted: UnreadStatsIncludeMuted = 'force-exclude' + ) { + const actual = countAllConversationsUnreadStats(chats.map(mockChat), { + includeMuted, + }); + assert.deepEqual(actual, mockStats(expected)); + } + + it('should count all stats', () => { + const read = { unreadCount: 0 }; + const unread = { unreadCount: 10 }; + const mentions = { unreadMentionsCount: 10 }; + const markedUnread = { markedUnread: true }; + const unreadAndMarkedUnread = { ...unread, ...markedUnread }; + + check([read], { unreadCount: 0 }); + check([read, read], { unreadCount: 0 }); + check([read, unread], { unreadCount: 10 }); + + check([unread], { unreadCount: 10 }); + check([unread, unread], { unreadCount: 20 }); + + check([mentions], { unreadMentionsCount: 10 }); + check([mentions, mentions], { unreadMentionsCount: 20 }); + + check([markedUnread], { readChatsMarkedUnreadCount: 1 }); + check([markedUnread, markedUnread], { readChatsMarkedUnreadCount: 2 }); + check([unreadAndMarkedUnread], { + unreadCount: 10, + readChatsMarkedUnreadCount: 0, + }); + }); + + it('should check if each conversation can be counted', () => { + const isCounted = { unreadCount: 20 }; + const isNotCounted = { unreadCount: 10 }; + + const unread = { unreadCount: 10 }; + const inactive = { ...unread, activeAt: 0 }; + const archived = { ...unread, isArchived: true }; + const muted = { ...unread, muteExpiresAt: getFutureMutedTimestamp() }; + const left = { ...unread, left: true }; + + check([unread, inactive], isNotCounted); + check([unread, archived], isNotCounted); + check([unread, muted], isNotCounted); + check([unread, muted], isCounted, 'force-include'); + check([unread, left], isNotCounted); + }); + }); + + describe('countAllChatFoldersUnreadStats', () => { + function check( + chats: ReadonlyArray, + items: ReadonlyArray<{ + folder: FolderProps; + stats: StatsProps | null; + }>, + includeMuted: UnreadStatsIncludeMuted = 'force-exclude' + ) { + const folders: Array = []; + const expected = new Map(); + + for (const item of items) { + const folder = mockFolder(item.folder); + folders.push(folder); + if (item.stats != null) { + expected.set(folder.id, mockStats(item.stats)); + } + } + + const actual = countAllChatFoldersUnreadStats( + CurrentChatFolders.fromArray(folders), + chats.map(mockChat), + { includeMuted } + ); + + assert.deepEqual(actual, expected); + } + + it('should count each chat folder', () => { + const muted = { muteExpiresAt: getFutureMutedTimestamp() }; + + const chats: Array = [ + { type: 'group', unreadCount: 5 }, + { type: 'group', unreadCount: 2 }, + { type: 'group', unreadCount: 1, ...muted }, + { type: 'direct', unreadCount: 50 }, + { type: 'direct', unreadCount: 20 }, + { type: 'direct', unreadCount: 10, ...muted }, + ]; + + const empty = {}; + const allGroups = { includeAllGroupChats: true }; + const allDirect = { includeAllIndividualChats: true }; + const all = { ...allGroups, ...allDirect }; + + // no chats + check([], []); + check([], [{ folder: all, stats: null }]); + + // no folders + check(chats, []); + + // empty folder + check(chats, [{ folder: empty, stats: null }]); + + check(chats, [ + { folder: all, stats: { unreadCount: 77 } }, + { folder: allGroups, stats: { unreadCount: 7 } }, + { folder: allDirect, stats: { unreadCount: 70 } }, + ]); + + check( + chats, + [ + { folder: all, stats: { unreadCount: 88 } }, + { folder: allGroups, stats: { unreadCount: 8 } }, + { folder: allDirect, stats: { unreadCount: 80 } }, + ], + 'force-include' + ); + }); + }); +}); diff --git a/ts/test-node/util/countUnreadStats_test.std.ts b/ts/test-node/util/countUnreadStats_test.std.ts deleted file mode 100644 index e3159dd99a..0000000000 --- a/ts/test-node/util/countUnreadStats_test.std.ts +++ /dev/null @@ -1,199 +0,0 @@ -// Copyright 2022 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import { assert } from 'chai'; -import { v4 as generateUuid } from 'uuid'; -import { countConversationUnreadStats } from '../../util/countUnreadStats.std.js'; -import type { - UnreadStats, - ConversationPropsForUnreadStats, -} from '../../util/countUnreadStats.std.js'; - -function getFutureMutedTimestamp() { - return Date.now() + 12345; -} - -function getPastMutedTimestamp() { - return Date.now() - 1000; -} - -function mockChat( - props: Partial -): ConversationPropsForUnreadStats { - return { - id: generateUuid(), - type: 'direct', - activeAt: Date.now(), - isArchived: false, - markedUnread: false, - unreadCount: 0, - unreadMentionsCount: 0, - muteExpiresAt: undefined, - left: false, - ...props, - }; -} - -function mockStats(props: Partial): UnreadStats { - return { - unreadCount: 0, - unreadMentionsCount: 0, - readChatsMarkedUnreadCount: 0, - ...props, - }; -} - -describe('countUnreadStats', () => { - describe('countConversationUnreadStats', () => { - it('returns 0 if the conversation is archived', () => { - const isArchived = true; - - const archivedConversations = [ - mockChat({ isArchived, markedUnread: false, unreadCount: 0 }), - mockChat({ isArchived, markedUnread: false, unreadCount: 123 }), - mockChat({ isArchived, markedUnread: true, unreadCount: 0 }), - mockChat({ isArchived, markedUnread: true, unreadCount: undefined }), - mockChat({ isArchived, markedUnread: undefined, unreadCount: 0 }), - ]; - - for (const conversation of archivedConversations) { - assert.deepStrictEqual( - countConversationUnreadStats(conversation, { includeMuted: true }), - mockStats({ unreadCount: 0, readChatsMarkedUnreadCount: 0 }) - ); - assert.deepStrictEqual( - countConversationUnreadStats(conversation, { includeMuted: false }), - mockStats({ unreadCount: 0, readChatsMarkedUnreadCount: 0 }) - ); - } - }); - - it("returns 0 if the conversation is muted and the user doesn't want to include those in the result", () => { - const muteExpiresAt = getFutureMutedTimestamp(); - const mutedConversations = [ - mockChat({ muteExpiresAt, markedUnread: false, unreadCount: 0 }), - mockChat({ muteExpiresAt, markedUnread: false, unreadCount: 9 }), - mockChat({ muteExpiresAt, markedUnread: true, unreadCount: 0 }), - mockChat({ muteExpiresAt, markedUnread: true, unreadCount: undefined }), - ]; - for (const conversation of mutedConversations) { - assert.deepStrictEqual( - countConversationUnreadStats(conversation, { includeMuted: false }), - mockStats({ unreadCount: 0, readChatsMarkedUnreadCount: 0 }) - ); - } - }); - - it('returns the unread count if nonzero (and not archived)', () => { - const conversationsWithUnreadCount = [ - mockChat({ unreadCount: 9, markedUnread: false }), - mockChat({ unreadCount: 9, markedUnread: true }), - mockChat({ unreadCount: 9, muteExpiresAt: getPastMutedTimestamp() }), - ]; - - for (const conversation of conversationsWithUnreadCount) { - assert.deepStrictEqual( - countConversationUnreadStats(conversation, { includeMuted: false }), - mockStats({ unreadCount: 9 }) - ); - assert.deepStrictEqual( - countConversationUnreadStats(conversation, { includeMuted: true }), - mockStats({ unreadCount: 9 }) - ); - } - - const mutedWithUnreads = mockChat({ - unreadCount: 123, - muteExpiresAt: getFutureMutedTimestamp(), - }); - assert.deepStrictEqual( - countConversationUnreadStats(mutedWithUnreads, { includeMuted: true }), - mockStats({ unreadCount: 123 }) - ); - }); - - it('returns markedUnread:true if the conversation is marked unread', () => { - const conversationsMarkedUnread = [ - mockChat({ markedUnread: true }), - mockChat({ - markedUnread: true, - muteExpiresAt: getPastMutedTimestamp(), - }), - ]; - for (const conversation of conversationsMarkedUnread) { - assert.deepStrictEqual( - countConversationUnreadStats(conversation, { includeMuted: false }), - mockStats({ readChatsMarkedUnreadCount: 1 }) - ); - assert.deepStrictEqual( - countConversationUnreadStats(conversation, { includeMuted: true }), - mockStats({ readChatsMarkedUnreadCount: 1 }) - ); - } - - const mutedConversationsMarkedUnread = [ - mockChat({ - markedUnread: true, - muteExpiresAt: getFutureMutedTimestamp(), - }), - mockChat({ - markedUnread: true, - muteExpiresAt: getFutureMutedTimestamp(), - unreadCount: 0, - }), - ]; - for (const conversation of mutedConversationsMarkedUnread) { - assert.deepStrictEqual( - countConversationUnreadStats(conversation, { includeMuted: true }), - mockStats({ readChatsMarkedUnreadCount: 1 }) - ); - } - }); - - it('returns 0 if the conversation is read', () => { - const readConversations = [ - mockChat({ markedUnread: false, unreadCount: undefined }), - mockChat({ markedUnread: false, unreadCount: 0 }), - mockChat({ - markedUnread: false, - muteExpiresAt: getFutureMutedTimestamp(), - }), - mockChat({ - markedUnread: false, - muteExpiresAt: getPastMutedTimestamp(), - }), - ]; - for (const conversation of readConversations) { - assert.deepStrictEqual( - countConversationUnreadStats(conversation, { includeMuted: false }), - mockStats({ unreadCount: 0, readChatsMarkedUnreadCount: 0 }) - ); - assert.deepStrictEqual( - countConversationUnreadStats(conversation, { includeMuted: true }), - mockStats({ unreadCount: 0, readChatsMarkedUnreadCount: 0 }) - ); - } - }); - - it('returns 0 if the conversation has falsey activeAt', () => { - const readConversations = [ - mockChat({ activeAt: undefined, unreadCount: 2 }), - mockChat({ - activeAt: 0, - unreadCount: 2, - muteExpiresAt: getPastMutedTimestamp(), - }), - ]; - for (const conversation of readConversations) { - assert.deepStrictEqual( - countConversationUnreadStats(conversation, { includeMuted: false }), - mockStats({ unreadCount: 0, readChatsMarkedUnreadCount: 0 }) - ); - assert.deepStrictEqual( - countConversationUnreadStats(conversation, { includeMuted: true }), - mockStats({ unreadCount: 0, readChatsMarkedUnreadCount: 0 }) - ); - } - }); - }); -}); diff --git a/ts/util/countUnreadStats.std.ts b/ts/util/countUnreadStats.std.ts index d455c7dde1..6e79369fe2 100644 --- a/ts/util/countUnreadStats.std.ts +++ b/ts/util/countUnreadStats.std.ts @@ -36,7 +36,8 @@ type MutableUnreadStats = { */ export type UnreadStats = Readonly; -function createUnreadStats(): MutableUnreadStats { +/** @internal exported for testing */ +export function _createUnreadStats(): MutableUnreadStats { return { unreadCount: 0, unreadMentionsCount: 0, @@ -44,8 +45,14 @@ function createUnreadStats(): MutableUnreadStats { }; } +export type UnreadStatsIncludeMuted = + | 'setting-on' // badge-count-muted-conversations == true + | 'setting-off' // badge-count-muted-conversations == false + | 'force-include' + | 'force-exclude'; + export type UnreadStatsOptions = Readonly<{ - includeMuted: boolean; + includeMuted: UnreadStatsIncludeMuted; }>; export type ConversationPropsForUnreadStats = Readonly< @@ -65,7 +72,15 @@ export type ConversationPropsForUnreadStats = Readonly< export type AllChatFoldersUnreadStats = Map; -function _canCountConversation( +/** @internal exported for testing */ +export function _shouldExcludeMuted( + includeMuted: UnreadStatsIncludeMuted +): boolean { + return includeMuted === 'setting-off' || includeMuted === 'force-exclude'; +} + +/** @internal exported for testing */ +export function _canCountConversation( conversation: ConversationPropsForUnreadStats, options: UnreadStatsOptions ): boolean { @@ -75,7 +90,11 @@ function _canCountConversation( if (conversation.isArchived) { return false; } - if (!options.includeMuted && isConversationMuted(conversation)) { + + if ( + _shouldExcludeMuted(options.includeMuted) && + isConversationMuted(conversation) + ) { return false; } if (conversation.left) { @@ -84,8 +103,8 @@ function _canCountConversation( return true; } -/** @private */ -function _countConversation( +/** @internal exported for testing */ +export function _countConversation( unreadStats: MutableUnreadStats, conversation: ConversationPropsForUnreadStats ): void { @@ -96,7 +115,7 @@ function _countConversation( markedUnread = false, } = conversation; - const hasUnreadCount = unreadCount > 0; + const hasUnreadCount = unreadCount > 0 || unreadMentionsCount > 0; if (hasUnreadCount) { mutable.unreadCount += unreadCount; @@ -113,11 +132,13 @@ export function isConversationUnread( if (!_canCountConversation(conversation, options)) { return false; } - // Note: Don't need to look at unreadMentionsCount - const { unreadCount, markedUnread } = conversation; + const { unreadCount, unreadMentionsCount, markedUnread } = conversation; if (unreadCount != null && unreadCount !== 0) { return true; } + if (unreadMentionsCount != null && unreadMentionsCount !== 0) { + return true; + } if (markedUnread) { return true; } @@ -128,7 +149,7 @@ export function countConversationUnreadStats( conversation: ConversationPropsForUnreadStats, options: UnreadStatsOptions ): UnreadStats { - const unreadStats = createUnreadStats(); + const unreadStats = _createUnreadStats(); if (_canCountConversation(conversation, options)) { _countConversation(unreadStats, conversation); } @@ -139,7 +160,7 @@ export function countAllConversationsUnreadStats( conversations: ReadonlyArray, options: UnreadStatsOptions ): UnreadStats { - const unreadStats = createUnreadStats(); + const unreadStats = _createUnreadStats(); for (const conversation of conversations) { if (_canCountConversation(conversation, options)) { @@ -181,7 +202,7 @@ export function countAllChatFoldersUnreadStats( if (isConversationInChatFolder(chatFolder, conversation)) { let unreadStats = results.get(chatFolder.id); if (unreadStats == null) { - unreadStats = createUnreadStats(); + unreadStats = _createUnreadStats(); results.set(chatFolder.id, unreadStats); } diff --git a/ts/util/filterAndSortConversations.std.ts b/ts/util/filterAndSortConversations.std.ts index acff35e966..8f2c7aa71f 100644 --- a/ts/util/filterAndSortConversations.std.ts +++ b/ts/util/filterAndSortConversations.std.ts @@ -6,6 +6,7 @@ import type { ConversationType } from '../state/ducks/conversations.preload.js'; import { parseAndFormatPhoneNumber } from './libphonenumberInstance.std.js'; import { WEEK } from './durations/index.std.js'; import { fuseGetFnRemoveDiacritics, getCachedFuseIndex } from './fuse.std.js'; +import type { UnreadStatsIncludeMuted } from './countUnreadStats.std.js'; import { isConversationUnread } from './countUnreadStats.std.js'; import { getE164 } from './getE164.std.js'; import { removeDiacritics } from './removeDiacritics.std.js'; @@ -68,7 +69,7 @@ const COMMANDS = new Map(); function filterConversationsByUnread( conversations: ReadonlyArray, - includeMuted: boolean + includeMuted: UnreadStatsIncludeMuted ): Array { return conversations.filter(conversation => { return isConversationUnread(conversation, { includeMuted }); @@ -103,7 +104,10 @@ COMMANDS.set('groupIdEndsWith', (conversations, query) => { COMMANDS.set('unread', (conversations, query) => { const includeMuted = /^(?:m|muted)$/i.test(query) || false; - return filterConversationsByUnread(conversations, includeMuted); + return filterConversationsByUnread( + conversations, + includeMuted ? 'force-include' : 'force-exclude' + ); }); // See https://fusejs.io/examples.html#extended-search for @@ -166,7 +170,7 @@ export function filterAndSortConversations( conversationToInject?: ConversationType ): Array { let filteredConversations = filterByUnread - ? filterConversationsByUnread(conversations, true) + ? filterConversationsByUnread(conversations, 'force-include') : conversations; if (conversationToInject) {