mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-24 04:09:49 +00:00
Fix badge counts for include muted setting
This commit is contained in:
@@ -369,7 +369,7 @@ export class ConversationController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const includeMuted =
|
const badgeCountMutedConversationsSetting =
|
||||||
itemStorage.get('badge-count-muted-conversations') || false;
|
itemStorage.get('badge-count-muted-conversations') || false;
|
||||||
|
|
||||||
const unreadStats = countAllConversationsUnreadStats(
|
const unreadStats = countAllConversationsUnreadStats(
|
||||||
@@ -390,7 +390,11 @@ export class ConversationController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
{ includeMuted }
|
{
|
||||||
|
includeMuted: badgeCountMutedConversationsSetting
|
||||||
|
? 'setting-on'
|
||||||
|
: 'setting-off',
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
drop(itemStorage.put('unreadCount', unreadStats.unreadCount));
|
drop(itemStorage.put('unreadCount', unreadStats.unreadCount));
|
||||||
|
|||||||
@@ -333,7 +333,7 @@ function shouldRemoveConversationFromUnreadList(
|
|||||||
conversation &&
|
conversation &&
|
||||||
(selectedConversationId == null ||
|
(selectedConversationId == null ||
|
||||||
selectedConversationId !== conversation.id) &&
|
selectedConversationId !== conversation.id) &&
|
||||||
!isConversationUnread(conversation, { includeMuted: true })
|
!isConversationUnread(conversation, { includeMuted: 'force-exclude' })
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -499,7 +499,7 @@ const doSearch = debounce(
|
|||||||
selectedConversation &&
|
selectedConversation &&
|
||||||
state.search.conversationIds.includes(selectedConversationId) &&
|
state.search.conversationIds.includes(selectedConversationId) &&
|
||||||
!isConversationUnread(selectedConversation, {
|
!isConversationUnread(selectedConversation, {
|
||||||
includeMuted: true,
|
includeMuted: 'force-include',
|
||||||
})
|
})
|
||||||
? selectedConversation
|
? selectedConversation
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|||||||
@@ -55,7 +55,10 @@ import {
|
|||||||
getUserConversationId,
|
getUserConversationId,
|
||||||
getUserNumber,
|
getUserNumber,
|
||||||
} from './user.std.js';
|
} 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 { createLogger } from '../../logging/log.std.js';
|
||||||
import { TimelineMessageLoadingState } from '../../util/timelineUtil.std.js';
|
import { TimelineMessageLoadingState } from '../../util/timelineUtil.std.js';
|
||||||
import { isSignalConversation } from '../../util/isSignalConversation.dom.js';
|
import { isSignalConversation } from '../../util/isSignalConversation.dom.js';
|
||||||
@@ -687,25 +690,32 @@ export const getAllGroupsWithInviteAccess = createSelector(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getAllConversationsUnreadStats = createSelector(
|
export const getAllConversationsUnreadStats: StateSelector<UnreadStats> =
|
||||||
getAllConversations,
|
createSelector(
|
||||||
(conversations): UnreadStats => {
|
getAllConversations,
|
||||||
return countAllConversationsUnreadStats(conversations, {
|
getBadgeCountMutedConversations,
|
||||||
includeMuted: false,
|
(conversations, badgeCountMutedConversations) => {
|
||||||
});
|
return countAllConversationsUnreadStats(conversations, {
|
||||||
}
|
includeMuted: badgeCountMutedConversations
|
||||||
);
|
? 'setting-on'
|
||||||
|
: 'setting-off',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export const getAllChatFoldersUnreadStats: StateSelector<AllChatFoldersUnreadStats> =
|
export const getAllChatFoldersUnreadStats: StateSelector<AllChatFoldersUnreadStats> =
|
||||||
createSelector(
|
createSelector(
|
||||||
getCurrentChatFolders,
|
getCurrentChatFolders,
|
||||||
getAllConversations,
|
getAllConversations,
|
||||||
(currentChatFolders, allConversations) => {
|
getBadgeCountMutedConversations,
|
||||||
|
(currentChatFolders, allConversations, badgeCountMutedConversations) => {
|
||||||
return countAllChatFoldersUnreadStats(
|
return countAllChatFoldersUnreadStats(
|
||||||
currentChatFolders,
|
currentChatFolders,
|
||||||
allConversations,
|
allConversations,
|
||||||
{
|
{
|
||||||
includeMuted: false,
|
includeMuted: badgeCountMutedConversations
|
||||||
|
? 'setting-on'
|
||||||
|
: 'setting-off',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
export const getTextFormattingEnabled = createSelector(
|
||||||
getItems,
|
getItems,
|
||||||
(state: ItemsStateType): boolean => Boolean(state.textFormatting ?? true)
|
(state: ItemsStateType): boolean => Boolean(state.textFormatting ?? true)
|
||||||
|
|||||||
376
ts/test-node/util/countUnreadStats_test.node.ts
Normal file
376
ts/test-node/util/countUnreadStats_test.node.ts
Normal file
@@ -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<ConversationPropsForUnreadStats>;
|
||||||
|
type StatsProps = Partial<UnreadStats>;
|
||||||
|
|
||||||
|
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<CurrentChatFolder>;
|
||||||
|
|
||||||
|
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<ChatProps>,
|
||||||
|
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<ChatProps>,
|
||||||
|
items: ReadonlyArray<{
|
||||||
|
folder: FolderProps;
|
||||||
|
stats: StatsProps | null;
|
||||||
|
}>,
|
||||||
|
includeMuted: UnreadStatsIncludeMuted = 'force-exclude'
|
||||||
|
) {
|
||||||
|
const folders: Array<CurrentChatFolder> = [];
|
||||||
|
const expected = new Map<ChatFolderId, UnreadStats>();
|
||||||
|
|
||||||
|
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<ChatProps> = [
|
||||||
|
{ 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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
|
||||||
): 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>): 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 })
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -36,7 +36,8 @@ type MutableUnreadStats = {
|
|||||||
*/
|
*/
|
||||||
export type UnreadStats = Readonly<MutableUnreadStats>;
|
export type UnreadStats = Readonly<MutableUnreadStats>;
|
||||||
|
|
||||||
function createUnreadStats(): MutableUnreadStats {
|
/** @internal exported for testing */
|
||||||
|
export function _createUnreadStats(): MutableUnreadStats {
|
||||||
return {
|
return {
|
||||||
unreadCount: 0,
|
unreadCount: 0,
|
||||||
unreadMentionsCount: 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<{
|
export type UnreadStatsOptions = Readonly<{
|
||||||
includeMuted: boolean;
|
includeMuted: UnreadStatsIncludeMuted;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type ConversationPropsForUnreadStats = Readonly<
|
export type ConversationPropsForUnreadStats = Readonly<
|
||||||
@@ -65,7 +72,15 @@ export type ConversationPropsForUnreadStats = Readonly<
|
|||||||
|
|
||||||
export type AllChatFoldersUnreadStats = Map<ChatFolderId, UnreadStats>;
|
export type AllChatFoldersUnreadStats = Map<ChatFolderId, UnreadStats>;
|
||||||
|
|
||||||
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,
|
conversation: ConversationPropsForUnreadStats,
|
||||||
options: UnreadStatsOptions
|
options: UnreadStatsOptions
|
||||||
): boolean {
|
): boolean {
|
||||||
@@ -75,7 +90,11 @@ function _canCountConversation(
|
|||||||
if (conversation.isArchived) {
|
if (conversation.isArchived) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!options.includeMuted && isConversationMuted(conversation)) {
|
|
||||||
|
if (
|
||||||
|
_shouldExcludeMuted(options.includeMuted) &&
|
||||||
|
isConversationMuted(conversation)
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (conversation.left) {
|
if (conversation.left) {
|
||||||
@@ -84,8 +103,8 @@ function _canCountConversation(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @private */
|
/** @internal exported for testing */
|
||||||
function _countConversation(
|
export function _countConversation(
|
||||||
unreadStats: MutableUnreadStats,
|
unreadStats: MutableUnreadStats,
|
||||||
conversation: ConversationPropsForUnreadStats
|
conversation: ConversationPropsForUnreadStats
|
||||||
): void {
|
): void {
|
||||||
@@ -96,7 +115,7 @@ function _countConversation(
|
|||||||
markedUnread = false,
|
markedUnread = false,
|
||||||
} = conversation;
|
} = conversation;
|
||||||
|
|
||||||
const hasUnreadCount = unreadCount > 0;
|
const hasUnreadCount = unreadCount > 0 || unreadMentionsCount > 0;
|
||||||
|
|
||||||
if (hasUnreadCount) {
|
if (hasUnreadCount) {
|
||||||
mutable.unreadCount += unreadCount;
|
mutable.unreadCount += unreadCount;
|
||||||
@@ -113,11 +132,13 @@ export function isConversationUnread(
|
|||||||
if (!_canCountConversation(conversation, options)) {
|
if (!_canCountConversation(conversation, options)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Note: Don't need to look at unreadMentionsCount
|
const { unreadCount, unreadMentionsCount, markedUnread } = conversation;
|
||||||
const { unreadCount, markedUnread } = conversation;
|
|
||||||
if (unreadCount != null && unreadCount !== 0) {
|
if (unreadCount != null && unreadCount !== 0) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (unreadMentionsCount != null && unreadMentionsCount !== 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (markedUnread) {
|
if (markedUnread) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -128,7 +149,7 @@ export function countConversationUnreadStats(
|
|||||||
conversation: ConversationPropsForUnreadStats,
|
conversation: ConversationPropsForUnreadStats,
|
||||||
options: UnreadStatsOptions
|
options: UnreadStatsOptions
|
||||||
): UnreadStats {
|
): UnreadStats {
|
||||||
const unreadStats = createUnreadStats();
|
const unreadStats = _createUnreadStats();
|
||||||
if (_canCountConversation(conversation, options)) {
|
if (_canCountConversation(conversation, options)) {
|
||||||
_countConversation(unreadStats, conversation);
|
_countConversation(unreadStats, conversation);
|
||||||
}
|
}
|
||||||
@@ -139,7 +160,7 @@ export function countAllConversationsUnreadStats(
|
|||||||
conversations: ReadonlyArray<ConversationPropsForUnreadStats>,
|
conversations: ReadonlyArray<ConversationPropsForUnreadStats>,
|
||||||
options: UnreadStatsOptions
|
options: UnreadStatsOptions
|
||||||
): UnreadStats {
|
): UnreadStats {
|
||||||
const unreadStats = createUnreadStats();
|
const unreadStats = _createUnreadStats();
|
||||||
|
|
||||||
for (const conversation of conversations) {
|
for (const conversation of conversations) {
|
||||||
if (_canCountConversation(conversation, options)) {
|
if (_canCountConversation(conversation, options)) {
|
||||||
@@ -181,7 +202,7 @@ export function countAllChatFoldersUnreadStats(
|
|||||||
if (isConversationInChatFolder(chatFolder, conversation)) {
|
if (isConversationInChatFolder(chatFolder, conversation)) {
|
||||||
let unreadStats = results.get(chatFolder.id);
|
let unreadStats = results.get(chatFolder.id);
|
||||||
if (unreadStats == null) {
|
if (unreadStats == null) {
|
||||||
unreadStats = createUnreadStats();
|
unreadStats = _createUnreadStats();
|
||||||
results.set(chatFolder.id, unreadStats);
|
results.set(chatFolder.id, unreadStats);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { ConversationType } from '../state/ducks/conversations.preload.js';
|
|||||||
import { parseAndFormatPhoneNumber } from './libphonenumberInstance.std.js';
|
import { parseAndFormatPhoneNumber } from './libphonenumberInstance.std.js';
|
||||||
import { WEEK } from './durations/index.std.js';
|
import { WEEK } from './durations/index.std.js';
|
||||||
import { fuseGetFnRemoveDiacritics, getCachedFuseIndex } from './fuse.std.js';
|
import { fuseGetFnRemoveDiacritics, getCachedFuseIndex } from './fuse.std.js';
|
||||||
|
import type { UnreadStatsIncludeMuted } from './countUnreadStats.std.js';
|
||||||
import { isConversationUnread } from './countUnreadStats.std.js';
|
import { isConversationUnread } from './countUnreadStats.std.js';
|
||||||
import { getE164 } from './getE164.std.js';
|
import { getE164 } from './getE164.std.js';
|
||||||
import { removeDiacritics } from './removeDiacritics.std.js';
|
import { removeDiacritics } from './removeDiacritics.std.js';
|
||||||
@@ -68,7 +69,7 @@ const COMMANDS = new Map<string, CommandRunnerType>();
|
|||||||
|
|
||||||
function filterConversationsByUnread(
|
function filterConversationsByUnread(
|
||||||
conversations: ReadonlyArray<ConversationType>,
|
conversations: ReadonlyArray<ConversationType>,
|
||||||
includeMuted: boolean
|
includeMuted: UnreadStatsIncludeMuted
|
||||||
): Array<ConversationType> {
|
): Array<ConversationType> {
|
||||||
return conversations.filter(conversation => {
|
return conversations.filter(conversation => {
|
||||||
return isConversationUnread(conversation, { includeMuted });
|
return isConversationUnread(conversation, { includeMuted });
|
||||||
@@ -103,7 +104,10 @@ COMMANDS.set('groupIdEndsWith', (conversations, query) => {
|
|||||||
|
|
||||||
COMMANDS.set('unread', (conversations, query) => {
|
COMMANDS.set('unread', (conversations, query) => {
|
||||||
const includeMuted = /^(?:m|muted)$/i.test(query) || false;
|
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
|
// See https://fusejs.io/examples.html#extended-search for
|
||||||
@@ -166,7 +170,7 @@ export function filterAndSortConversations(
|
|||||||
conversationToInject?: ConversationType
|
conversationToInject?: ConversationType
|
||||||
): Array<ConversationType> {
|
): Array<ConversationType> {
|
||||||
let filteredConversations = filterByUnread
|
let filteredConversations = filterByUnread
|
||||||
? filterConversationsByUnread(conversations, true)
|
? filterConversationsByUnread(conversations, 'force-include')
|
||||||
: conversations;
|
: conversations;
|
||||||
|
|
||||||
if (conversationToInject) {
|
if (conversationToInject) {
|
||||||
|
|||||||
Reference in New Issue
Block a user