From 616e0260a2424aa7de123715cf7faf6a7daa173f Mon Sep 17 00:00:00 2001 From: trevor-signal <131492920+trevor-signal@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:14:58 -0400 Subject: [PATCH] Support new unread badge count type --- _locales/en/messages.json | 16 ++++ app/main.main.ts | 19 +--- protos/Backups.proto | 7 ++ protos/SignalStorage.proto | 7 ++ ts/ConversationController.preload.ts | 31 +++---- ts/components/CallsTab.dom.tsx | 7 +- ts/components/ChatsTab.dom.stories.tsx | 6 +- ts/components/ChatsTab.dom.tsx | 7 +- ts/components/LeftPane.dom.stories.tsx | 7 +- ts/components/LeftPane.dom.tsx | 7 +- ts/components/MyStories.dom.tsx | 7 +- ts/components/NavSidebar.dom.tsx | 7 +- ts/components/NavTabs.dom.stories.tsx | 2 + ts/components/NavTabs.dom.tsx | 77 +++++++-------- ts/components/Preferences.dom.stories.tsx | 20 +--- ts/components/Preferences.dom.tsx | 39 +++++++- ts/components/StoriesTab.dom.tsx | 9 +- .../leftPane/LeftPaneChatFolders.dom.tsx | 33 ++++--- ts/services/backups/export.preload.ts | 19 ++++ ts/services/backups/import.preload.ts | 20 ++++ ts/services/storageRecordOps.preload.ts | 49 +++++++++- ts/state/selectors/conversations.dom.ts | 42 ++++----- ts/state/selectors/items.dom.ts | 11 +++ ts/state/smart/CallsTab.preload.tsx | 6 +- ts/state/smart/ChatsTab.preload.tsx | 6 +- ts/state/smart/LeftPane.preload.tsx | 4 +- .../smart/LeftPaneChatFolders.preload.tsx | 3 + ts/state/smart/NavTabs.preload.tsx | 7 +- ts/state/smart/Preferences.preload.tsx | 81 ++++++++-------- ts/state/smart/StoriesTab.preload.tsx | 6 +- ts/test-helpers/generateBackup.node.ts | 1 + .../util/countUnreadStats_test.node.ts | 93 +++++++++++++++---- ts/types/StorageKeys.std.ts | 18 ++++ ts/util/countUnreadStats.std.ts | 28 ++++++ ts/window.d.ts | 2 +- ts/windows/main/phase1-ipc.preload.ts | 2 +- 36 files changed, 455 insertions(+), 251 deletions(-) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index ca282887e0..8c9d8f495a 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -8248,6 +8248,22 @@ "messageformat": "Reset", "description": "Preferences > Notifications > Reset Section > Confirmation Modal > Label of the button that confirms restoring the default notification settings" }, + "icu:Preferences__Notifications__AppBadgeSection__BadgeCount__Label": { + "messageformat": "Badge count", + "description": "Preferences > Notifications > App Badge Section > Label for the badge count setting select box" + }, + "icu:Preferences__Notifications__AppBadgeSection__BadgeCount__Description": { + "messageformat": "Select if the badge count displays the number of unread messages or the number of unread chats", + "description": "Preferences > Notifications > App Badge Section > Description for the badge count setting select box" + }, + "icu:Preferences__Notifications__AppBadgeSection__BadgeCount__UnreadMessages": { + "messageformat": "Unread messages", + "description": "Preferences > Notifications > App Badge Section > Option for showing the number of unread messages in the badge count" + }, + "icu:Preferences__Notifications__AppBadgeSection__BadgeCount__UnreadChats": { + "messageformat": "Unread chats", + "description": "Preferences > Notifications > App Badge Section > Option for showing the number of unread chats in the badge count" + }, "icu:PlaintextExport--PreferencesRow--Header": { "messageformat": "Export chat history", "description": "Shown in the Preferences/Chats page, header for a row" diff --git a/app/main.main.ts b/app/main.main.ts index 44a8f7ca86..797ef98e85 100644 --- a/app/main.main.ts +++ b/app/main.main.ts @@ -2688,22 +2688,9 @@ if (!app.isDefaultProtocolClient('signalcaptcha')) { ); } -ipc.on( - 'set-badge', - (_event: Electron.Event, badge: number | 'marked-unread') => { - if (badge === 'marked-unread') { - if (process.platform === 'darwin') { - // Will show a ● on macOS when undefined - app.setBadgeCount(undefined); - } else { - // All other OS's need a number - app.setBadgeCount(1); - } - } else { - app.setBadgeCount(badge); - } - } -); +ipc.on('set-badge-count', (_event: Electron.Event, badgeCount: number) => { + app.setBadgeCount(badgeCount); +}); ipc.on('remove-setup-menu-items', () => { setupMenu(); diff --git a/protos/Backups.proto b/protos/Backups.proto index 267aa51261..8e7c2a25e3 100644 --- a/protos/Backups.proto +++ b/protos/Backups.proto @@ -105,6 +105,12 @@ message AccountData { } message AccountSettings { + enum UnreadBadgeType { + UNKNOWN_BADGE_TYPE = 0; // Interpret as "Unread messages" + UNREAD_MESSAGES = 1; + UNREAD_CHATS = 2; + } + bool readReceipts = 1; bool sealedSenderIndicators = 2; bool typingIndicators = 3; @@ -138,6 +144,7 @@ message AccountData { bool allowSealedSenderFromAnyone = 30; bool allowAutomaticKeyVerification = 31; bool hasSeenAdminDeleteEducationDialog = 32; + UnreadBadgeType unreadBadgeType = 33; optional bool includeMutedChatsInBadge = 34; // If unset, consider this disabled optional bool reactionNotifications = 35; // If unset, consider this enabled optional bool notifyForCallsIfMuted = 36; // If unset, consider this disabled diff --git a/protos/SignalStorage.proto b/protos/SignalStorage.proto index 20cc866161..067a7d487a 100644 --- a/protos/SignalStorage.proto +++ b/protos/SignalStorage.proto @@ -189,6 +189,12 @@ message Payments { } message AccountRecord { + enum UnreadBadgeType { + UNKNOWN_BADGE_TYPE = 0; // Interpret as "Unread messages" + UNREAD_MESSAGES = 1; + UNREAD_CHATS = 2; + } + enum PhoneNumberSharingMode { UNKNOWN = 0; EVERYBODY = 1; @@ -312,6 +318,7 @@ message AccountRecord { optional bool releaseNotesChatBlocked = 50; optional bool releaseNotesChatMarkedUnread = 51; optional uint64 releaseNotesChatBlockedAt = 52; // only set if known (>0) + UnreadBadgeType unreadBadgeType = 53; OptionalBool includeMutedChatsInBadge = 54; // If unset, consider this off OptionalBool reactionNotifications = 55; // If unset, consider this on OptionalBool notifyForCallsIfMuted = 56; // If unset, consider this off diff --git a/ts/ConversationController.preload.ts b/ts/ConversationController.preload.ts index 7e4dd56da5..8c4f77fde7 100644 --- a/ts/ConversationController.preload.ts +++ b/ts/ConversationController.preload.ts @@ -41,7 +41,11 @@ import { getTitleNoDefault } from './util/getTitle.preload.ts'; import * as StorageService from './services/storage.preload.ts'; import { cdsLookup } from './textsecure/WebAPI.preload.ts'; import type { ConversationPropsForUnreadStats } from './util/countUnreadStats.std.ts'; -import { countAllConversationsUnreadStats } from './util/countUnreadStats.std.ts'; +import { + countAllConversationsUnreadStats, + getUnreadCountForBadge, +} from './util/countUnreadStats.std.ts'; +import { STORAGE_KEY_DEFAULTS } from './types/StorageKeys.std.ts'; import { isTestOrMockEnvironment } from './environment.std.ts'; import { isConversationAccepted } from './util/isConversationAccepted.preload.ts'; import { areWePending } from './util/groupMembershipUtils.preload.ts'; @@ -390,6 +394,9 @@ export class ConversationController { const badgeCountMutedConversationsSetting = itemStorage.get('badge-count-muted-conversations') || false; + const unreadCountBadgeType = + itemStorage.get('unreadCountBadgeType') ?? + STORAGE_KEY_DEFAULTS.unreadCountBadgeType; const { activeProfile } = window.reduxStore.getState().notificationProfiles; const unreadStats = countAllConversationsUnreadStats( @@ -420,22 +427,12 @@ export class ConversationController { drop(itemStorage.put('unreadCount', unreadStats.unreadCount)); - if (unreadStats.unreadCount > 0) { - const total = - unreadStats.unreadCount + unreadStats.readChatsMarkedUnreadCount; - window.IPC.setBadge(total); - window.IPC.updateTrayIcon(total); - window.document.title = `${window.getTitle()} (${total})`; - } else if (unreadStats.readChatsMarkedUnreadCount > 0) { - const total = unreadStats.readChatsMarkedUnreadCount; - window.IPC.setBadge(total); - window.IPC.updateTrayIcon(total); - window.document.title = `${window.getTitle()} (${total})`; - } else { - window.IPC.setBadge(0); - window.IPC.updateTrayIcon(0); - window.document.title = window.getTitle(); - } + const total = getUnreadCountForBadge(unreadStats, unreadCountBadgeType); + + window.IPC.setBadgeCount(total); + window.IPC.updateTrayIcon(total); + window.document.title = + total > 0 ? `${window.getTitle()} (${total})` : window.getTitle(); } onEmpty(): void { diff --git a/ts/components/CallsTab.dom.tsx b/ts/components/CallsTab.dom.tsx index 1ebda19039..ef86511eca 100644 --- a/ts/components/CallsTab.dom.tsx +++ b/ts/components/CallsTab.dom.tsx @@ -17,7 +17,6 @@ import type { ActiveCallStateType, PeekNotConnectedGroupCallType, } from '../state/ducks/calling.preload.ts'; -import type { UnreadStats } from '../util/countUnreadStats.std.ts'; import type { getCallIdFromEra } from '../util/callDisposition.preload.ts'; import type { CallLinkType } from '../types/CallLink.std.ts'; import type { CallStateType } from '../state/selectors/calling.std.ts'; @@ -35,7 +34,7 @@ enum CallsTabSidebarView { type CallsTabProps = Readonly<{ activeCall: ActiveCallStateType | undefined; allConversations: ReadonlyArray; - otherTabsUnreadStats: UnreadStats; + otherTabsUnreadCount: number; getCallHistoryGroupsCount: ( options: CallHistoryFilterOptions ) => Promise; @@ -95,7 +94,7 @@ export type CallsTabSelectedView = export function CallsTab({ activeCall, allConversations, - otherTabsUnreadStats, + otherTabsUnreadCount, getCallHistoryGroupsCount, getCallHistoryGroups, getCallIdFromEra, @@ -205,7 +204,7 @@ export function CallsTab({ ? i18n('icu:CallsTab__HeaderTitle--CallsList') : i18n('icu:CallsTab__HeaderTitle--NewCall') } - otherTabsUnreadStats={otherTabsUnreadStats} + otherTabsUnreadCount={otherTabsUnreadCount} hasFailedStorySends={hasFailedStorySends} hasPendingUpdate={hasPendingUpdate} navTabsCollapsed={navTabsCollapsed} diff --git a/ts/components/ChatsTab.dom.stories.tsx b/ts/components/ChatsTab.dom.stories.tsx index 50ff28bbdf..9b2036f9a7 100644 --- a/ts/components/ChatsTab.dom.stories.tsx +++ b/ts/components/ChatsTab.dom.stories.tsx @@ -18,11 +18,7 @@ export default { }, args: { i18n, - otherTabsUnreadStats: { - unreadCount: 0, - unreadMentionsCount: 0, - readChatsMarkedUnreadCount: 0, - }, + otherTabsUnreadCount: 0, isStaging: false, hasPendingUpdate: false, hasFailedStorySends: false, diff --git a/ts/components/ChatsTab.dom.tsx b/ts/components/ChatsTab.dom.tsx index 04a791726d..ef401c3167 100644 --- a/ts/components/ChatsTab.dom.tsx +++ b/ts/components/ChatsTab.dom.tsx @@ -6,12 +6,11 @@ import type { JSX } from 'react'; import type { LocalizerType } from '../types/I18N.std.ts'; import type { NavTabPanelProps } from './NavTabs.dom.tsx'; import { WhatsNewLink } from './WhatsNewLink.dom.tsx'; -import type { UnreadStats } from '../util/countUnreadStats.std.ts'; import type { SmartConversationViewProps } from '../state/smart/ConversationView.preload.tsx'; import { tw } from '../axo/tw.dom.tsx'; export type ChatsTabProps = Readonly<{ - otherTabsUnreadStats: UnreadStats; + otherTabsUnreadCount: number; i18n: LocalizerType; isStaging: boolean; hasPendingUpdate: boolean; @@ -26,7 +25,7 @@ export type ChatsTabProps = Readonly<{ }>; export function ChatsTab({ - otherTabsUnreadStats, + otherTabsUnreadCount, i18n, isStaging, hasPendingUpdate, @@ -43,7 +42,7 @@ export function ChatsTab({ <>
{renderLeftPane({ - otherTabsUnreadStats, + otherTabsUnreadCount, collapsed: navTabsCollapsed, hasPendingUpdate, hasFailedStorySends, diff --git a/ts/components/LeftPane.dom.stories.tsx b/ts/components/LeftPane.dom.stories.tsx index ef938ade0c..8647f6be78 100644 --- a/ts/components/LeftPane.dom.stories.tsx +++ b/ts/components/LeftPane.dom.stories.tsx @@ -146,11 +146,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => { const isUpdateDownloaded = false; return { - otherTabsUnreadStats: { - unreadCount: 0, - unreadMentionsCount: 0, - readChatsMarkedUnreadCount: 0, - }, + otherTabsUnreadCount: 0, backupMediaDownloadProgress: { isBackupMediaEnabled: true, downloadBannerDismissed: false, @@ -341,6 +337,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => { currentChatFolders={CurrentChatFolders.createEmpty()} allChatFoldersUnreadStats={new Map()} allChatFoldersMutedStats={new Map()} + unreadCountBadgeType="unread-messages" selectedChatFolder={null} onSelectedChatFolderIdChange={action('onSelectedChatFolderIdChange')} onChatFolderMarkRead={action('onChatFolderMarkRead')} diff --git a/ts/components/LeftPane.dom.tsx b/ts/components/LeftPane.dom.tsx index 63f3ce7eea..050b5f57b2 100644 --- a/ts/components/LeftPane.dom.tsx +++ b/ts/components/LeftPane.dom.tsx @@ -63,7 +63,6 @@ import { NavSidebarActionButton, NavSidebarSearchHeader, } from './NavSidebar.dom.tsx'; -import type { UnreadStats } from '../util/countUnreadStats.std.ts'; import { BackupMediaDownloadProgress } from './BackupMediaDownloadProgress.dom.tsx'; import type { ServerAlertsType, @@ -89,7 +88,7 @@ export type PropsType = { isPaused: boolean; downloadBannerDismissed: boolean; }; - otherTabsUnreadStats: UnreadStats; + otherTabsUnreadCount: number; hasAnyCurrentCustomChatFolders: boolean; hasClockSkewDialog: boolean; hasExpiredDialog: boolean; @@ -225,7 +224,7 @@ export type PropsType = { export function LeftPane({ backupMediaDownloadProgress, - otherTabsUnreadStats, + otherTabsUnreadCount, blockConversation, cancelBackupMediaDownload, challengeStatus, @@ -811,7 +810,7 @@ export function LeftPane({ title={i18n('icu:LeftPane--chats')} hideHeader={hideHeader} i18n={i18n} - otherTabsUnreadStats={otherTabsUnreadStats} + otherTabsUnreadCount={otherTabsUnreadCount} hasFailedStorySends={hasFailedStorySends} hasPendingUpdate={hasPendingUpdate} navTabsCollapsed={navTabsCollapsed} diff --git a/ts/components/MyStories.dom.tsx b/ts/components/MyStories.dom.tsx index 9abe942a87..30cd3ca73e 100644 --- a/ts/components/MyStories.dom.tsx +++ b/ts/components/MyStories.dom.tsx @@ -20,13 +20,12 @@ import { resolveStorySendStatus } from '../util/resolveStorySendStatus.std.ts'; import { useRetryStorySend } from '../hooks/useRetryStorySend.dom.tsx'; import { NavSidebar } from './NavSidebar.dom.tsx'; import type { WidthBreakpoint } from './_util.std.ts'; -import type { UnreadStats } from '../util/countUnreadStats.std.ts'; import { AxoConfirmDialog } from '../axo/AxoConfirmDialog.dom.tsx'; import { strictAssert } from '../util/assert.std.ts'; export type PropsType = { i18n: LocalizerType; - otherTabsUnreadStats: UnreadStats; + otherTabsUnreadCount: number; hasFailedStorySends: boolean; hasPendingUpdate: boolean; navTabsCollapsed: boolean; @@ -51,7 +50,7 @@ export type PropsType = { export function MyStories({ i18n, - otherTabsUnreadStats, + otherTabsUnreadCount, hasFailedStorySends, hasPendingUpdate, navTabsCollapsed, @@ -101,7 +100,7 @@ export function MyStories({ void; title: string; - otherTabsUnreadStats: UnreadStats; + otherTabsUnreadCount: number; renderToastManager: (_: SmartToastManagerPropsType) => JSX.Element; }>; @@ -91,7 +90,7 @@ export function NavSidebar({ requiresFullWidth, savePreferredLeftPaneWidth, title, - otherTabsUnreadStats, + otherTabsUnreadCount, renderToastManager, }: NavSidebarProps): JSX.Element { const isRTL = i18n.getLocaleDirection() === 'rtl'; @@ -190,7 +189,7 @@ export function NavSidebar({ onToggleNavTabsCollapse={onToggleNavTabsCollapse} hasFailedStorySends={hasFailedStorySends} hasPendingUpdate={hasPendingUpdate} - otherTabsUnreadStats={otherTabsUnreadStats} + otherTabsUnreadCount={otherTabsUnreadCount} /> )}
; function NavTabsItemBadges({ i18n, hasError, hasPendingUpdate, - unreadStats, + unreadCount, }: NavTabsItemBadgesProps) { if (hasError) { return ( @@ -39,31 +41,17 @@ function NavTabsItemBadges({ return
; } - if (unreadStats != null) { - if (unreadStats.unreadCount > 0) { - const total = - unreadStats.unreadCount + unreadStats.readChatsMarkedUnreadCount; - return ( - - - {i18n('icu:NavTabs__ItemIconLabel--UnreadCount', { - count: total, - })} - - {total} + if (unreadCount > 0) { + return ( + + + {i18n('icu:NavTabs__ItemIconLabel--UnreadCount', { + count: unreadCount, + })} - ); - } - - if (unreadStats.readChatsMarkedUnreadCount > 0) { - return ( - - - {i18n('icu:NavTabs__ItemIconLabel--MarkedUnread')} - - - ); - } + {unreadCount} + + ); } return null; @@ -76,7 +64,7 @@ type NavTabProps = Readonly<{ id: NavTab; label: string; navTabClassName: string; - unreadStats: UnreadStats | null; + unreadCount: number; hasPendingUpdate?: boolean; }>; @@ -87,7 +75,7 @@ function NavTabsItem({ id, label, navTabClassName, - unreadStats, + unreadCount, hasPendingUpdate, }: NavTabProps) { const isRTL = i18n.getLocaleDirection() === 'rtl'; @@ -112,7 +100,7 @@ function NavTabsItem({ /> @@ -124,7 +112,7 @@ function NavTabsItem({ } export type NavTabPanelProps = Readonly<{ - otherTabsUnreadStats: UnreadStats; + otherTabsUnreadCount: number; collapsed: boolean; hasFailedStorySends: boolean; hasPendingUpdate: boolean; @@ -132,7 +120,7 @@ export type NavTabPanelProps = Readonly<{ }>; export type NavTabsToggleProps = Readonly<{ - otherTabsUnreadStats: UnreadStats | null; + otherTabsUnreadCount: number; i18n: LocalizerType; hasFailedStorySends: boolean; hasPendingUpdate: boolean; @@ -145,7 +133,7 @@ export function NavTabsToggle({ hasFailedStorySends, hasPendingUpdate, navTabsCollapsed, - otherTabsUnreadStats, + otherTabsUnreadCount, onToggleNavTabsCollapse, }: NavTabsToggleProps): JSX.Element { function handleToggle() { @@ -178,7 +166,7 @@ export function NavTabsToggle({ {label} @@ -204,6 +192,7 @@ export type NavTabsProps = Readonly<{ storiesEnabled: boolean; unreadCallsCount: number; unreadConversationsStats: UnreadStats; + unreadCountBadgeType: UnreadCountBadgeType; unreadStoriesCount: number; }>; @@ -222,6 +211,7 @@ export function NavTabs({ storiesEnabled, unreadCallsCount, unreadConversationsStats, + unreadCountBadgeType, unreadStoriesCount, }: NavTabsProps): JSX.Element { function handleSelectionChange(key: Key) { @@ -266,7 +256,7 @@ export function NavTabs({ // These are all shown elsewhere when nav tabs are shown hasFailedStorySends={false} hasPendingUpdate={false} - otherTabsUnreadStats={null} + otherTabsUnreadCount={0} /> {storiesEnabled && ( )} diff --git a/ts/components/Preferences.dom.stories.tsx b/ts/components/Preferences.dom.stories.tsx index 9255738175..24c40a54a8 100644 --- a/ts/components/Preferences.dom.stories.tsx +++ b/ts/components/Preferences.dom.stories.tsx @@ -517,11 +517,7 @@ export default { notificationContent: 'name', notifyWhileMuted: { calls: false, mentions: true, replies: true }, osName: 'windows', - otherTabsUnreadStats: { - unreadCount: 0, - unreadMentionsCount: 0, - readChatsMarkedUnreadCount: 0, - }, + otherTabsUnreadCount: 0, settingsLocation: { page: SettingsPage.Profile, state: ProfileEditorPage.None, @@ -539,6 +535,7 @@ export default { themeSetting: 'system', theme: ThemeType.light, universalExpireTimer: DurationInSeconds.HOUR, + unreadCountBadgeType: 'unread-messages', weArePrimaryDevice: false, whoCanFindMe: PhoneNumberDiscoverability.Discoverable, whoCanSeeMe: PhoneNumberSharingMode.Everybody, @@ -645,6 +642,7 @@ export default { onToggleNavTabsCollapse: action('onToggleNavTabsCollapse'), onTypingIndicatorsChange: action('onTypingIndicatorsChange'), onUniversalExpireTimerChange: action('onUniversalExpireTimerChange'), + onUnreadCountBadgeTypeChange: action('onUnreadCountBadgeTypeChange'), onWhoCanFindMeChange: action('onWhoCanFindMeChange'), onWhoCanSeeMeChange: action('onWhoCanSeeMeChange'), onZoomFactorChange: action('onZoomFactorChange'), @@ -1407,20 +1405,12 @@ export const NavTabsCollapsedWithBadges = Template.bind({}); NavTabsCollapsedWithBadges.args = { navTabsCollapsed: true, hasFailedStorySends: false, - otherTabsUnreadStats: { - unreadCount: 1, - unreadMentionsCount: 2, - readChatsMarkedUnreadCount: 0, - }, + otherTabsUnreadCount: 1, }; export const NavTabsCollapsedWithExclamation = Template.bind({}); NavTabsCollapsedWithExclamation.args = { navTabsCollapsed: true, hasFailedStorySends: true, - otherTabsUnreadStats: { - unreadCount: 1, - unreadMentionsCount: 2, - readChatsMarkedUnreadCount: 0, - }, + otherTabsUnreadCount: 1, }; diff --git a/ts/components/Preferences.dom.tsx b/ts/components/Preferences.dom.tsx index c0bbf33594..ddff01076b 100644 --- a/ts/components/Preferences.dom.tsx +++ b/ts/components/Preferences.dom.tsx @@ -49,6 +49,7 @@ import type { MediaDeviceSettings } from '../types/Calling.std.ts'; import type { ValidationResultType as BackupValidationResultType } from '../services/backups/index.preload.ts'; import type { AutoDownloadAttachmentType, + UnreadCountBadgeType, NotificationSettingType, SentMediaQualitySettingType, ZoomFactorType, @@ -78,7 +79,6 @@ import type { BackupsSubscriptionType, BackupStatusType, } from '../types/backups.node.ts'; -import type { UnreadStats } from '../util/countUnreadStats.std.ts'; import type { BadgeType } from '../badges/types.std.ts'; import type { MessageCountBySchemaVersionType } from '../sql/Interface.std.ts'; import type { MessageAttributesType } from '../model-types.d.ts'; @@ -187,6 +187,7 @@ export type PropsDataType = { sentMediaQualitySetting: SentMediaQualitySettingType; themeSetting: ThemeSettingType | undefined; universalExpireTimer: DurationInSeconds; + unreadCountBadgeType: UnreadCountBadgeType; whoCanFindMe: PhoneNumberDiscoverability; whoCanSeeMe: PhoneNumberSharingMode; zoomFactor: ZoomFactorType | undefined; @@ -203,7 +204,7 @@ export type PropsDataType = { initialSpellCheckSetting: boolean; me: ConversationType; navTabsCollapsed: boolean; - otherTabsUnreadStats: UnreadStats; + otherTabsUnreadCount: number; preferredWidthFromStorage: number; shouldShowUpdateDialog: boolean; theme: ThemeType; @@ -378,6 +379,7 @@ type PropsFunctionType = { onToggleNavTabsCollapse: (navTabsCollapsed: boolean) => void; onTypingIndicatorsChange: CheckboxChangeHandlerType; onUniversalExpireTimerChange: SelectChangeHandlerType; + onUnreadCountBadgeTypeChange: SelectChangeHandlerType; onWhoCanFindMeChange: SelectChangeHandlerType; onWhoCanSeeMeChange: SelectChangeHandlerType; onZoomFactorChange: SelectChangeHandlerType; @@ -576,10 +578,11 @@ export function Preferences({ onToggleNavTabsCollapse, onTypingIndicatorsChange, onUniversalExpireTimerChange, + onUnreadCountBadgeTypeChange, onWhoCanFindMeChange, onWhoCanSeeMeChange, onZoomFactorChange, - otherTabsUnreadStats, + otherTabsUnreadCount, settingsLocation, phoneNumber = '', pickLocalBackupFolder, @@ -619,6 +622,7 @@ export function Preferences({ theme, themeSetting, universalExpireTimer, + unreadCountBadgeType, validateBackup, whoCanFindMe, whoCanSeeMe, @@ -1594,6 +1598,33 @@ export function Preferences({ + { + onUnreadCountBadgeTypeChange(value as UnreadCountBadgeType); + }} + placeholder="" + options={[ + { + label: i18n( + 'icu:Preferences__Notifications__AppBadgeSection__BadgeCount__UnreadMessages' + ), + value: 'unread-messages', + }, + { + label: i18n( + 'icu:Preferences__Notifications__AppBadgeSection__BadgeCount__UnreadChats' + ), + value: 'unread-chats', + }, + ]} + /> unknown; getPreferredBadge: PreferredBadgeSelectorType; hasFailedStorySends: boolean; @@ -70,7 +69,7 @@ export type PropsType = { export function StoriesTab({ addStoryData, - otherTabsUnreadStats, + otherTabsUnreadCount, deleteStoryForEveryone, getPreferredBadge, hasFailedStorySends, @@ -117,7 +116,7 @@ export function StoriesTab({ {addStoryData && renderStoryCreator()} {isMyStories && myStories.length ? ( diff --git a/ts/components/leftPane/LeftPaneChatFolders.dom.tsx b/ts/components/leftPane/LeftPaneChatFolders.dom.tsx index c449c7ff00..74fb5b9b61 100644 --- a/ts/components/leftPane/LeftPaneChatFolders.dom.tsx +++ b/ts/components/leftPane/LeftPaneChatFolders.dom.tsx @@ -21,6 +21,7 @@ import type { AllChatFoldersUnreadStats, UnreadStats, } from '../../util/countUnreadStats.std.ts'; +import { getUnreadCountForBadge } from '../../util/countUnreadStats.std.ts'; import { WidthBreakpoint } from '../_util.std.ts'; import { AxoSelect } from '../../axo/AxoSelect.dom.tsx'; import { AxoContextMenu } from '../../axo/AxoContextMenu.dom.tsx'; @@ -33,12 +34,14 @@ import type { import type { AxoSymbol } from '../../axo/AxoSymbol.dom.tsx'; import { UserText } from '../UserText.dom.tsx'; import { CurrentChatFolders } from '../../types/CurrentChatFolders.std.ts'; +import type { UnreadCountBadgeType } from '../../types/StorageKeys.std.ts'; export type LeftPaneChatFoldersProps = Readonly<{ i18n: LocalizerType; navSidebarWidthBreakpoint: WidthBreakpoint | null; currentChatFolders: CurrentChatFolders; allChatFoldersUnreadStats: AllChatFoldersUnreadStats; + unreadCountBadgeType: UnreadCountBadgeType; allChatFoldersMutedStats: AllChatFoldersMutedStats; selectedChatFolder: ChatFolder | null; onSelectedChatFolderIdChange: (newValue: ChatFolderId) => void; @@ -50,17 +53,15 @@ export type LeftPaneChatFoldersProps = Readonly<{ onChatFolderOpenSettings: (chatFolderId: ChatFolderId) => void; }>; -function getBadgeValue(unreadStats: UnreadStats | null): number | null { +function getBadgeValue( + unreadStats: UnreadStats | null, + unreadCountBadgeType: UnreadCountBadgeType +): number | null { if (unreadStats == null) { return null; } - if (unreadStats.unreadCount > 0) { - return unreadStats.unreadCount + unreadStats.readChatsMarkedUnreadCount; - } - if (unreadStats.readChatsMarkedUnreadCount > 0) { - return unreadStats.readChatsMarkedUnreadCount; - } - return null; + const total = getUnreadCountForBadge(unreadStats, unreadCountBadgeType); + return total > 0 ? total : null; } function getChatFolderLabel( @@ -143,6 +144,7 @@ export function LeftPaneChatFolders( i18n={i18n} chatFolder={chatFolder} unreadStats={unreadStats} + unreadCountBadgeType={props.unreadCountBadgeType} /> ); })} @@ -177,6 +179,7 @@ export function LeftPaneChatFolders( i18n={i18n} chatFolder={chatFolder} unreadStats={unreadStats} + unreadCountBadgeType={props.unreadCountBadgeType} mutedStats={mutedStats} onChatFolderMarkRead={props.onChatFolderMarkRead} onChatFolderUpdateMute={props.onChatFolderUpdateMute} @@ -195,12 +198,13 @@ function ChatFolderSelectItem(props: { i18n: LocalizerType; chatFolder: ChatFolder; unreadStats: UnreadStats | null; + unreadCountBadgeType: UnreadCountBadgeType; }): JSX.Element { - const { i18n, unreadStats } = props; + const { i18n, unreadStats, unreadCountBadgeType } = props; const badgeValue = useMemo(() => { - return getBadgeValue(unreadStats); - }, [unreadStats]); + return getBadgeValue(unreadStats, unreadCountBadgeType); + }, [unreadStats, unreadCountBadgeType]); return ( void; onChatFolderUpdateMute: ( @@ -238,11 +243,11 @@ function ChatFolderSegmentedControlItem(props: { ) => void; onChatFolderOpenSettings: (chatFolderId: ChatFolderId) => void; }): JSX.Element { - const { i18n, unreadStats } = props; + const { i18n, unreadStats, unreadCountBadgeType } = props; const badgeValue = useMemo(() => { - return getBadgeValue(unreadStats); - }, [unreadStats]); + return getBadgeValue(unreadStats, unreadCountBadgeType); + }, [unreadStats, unreadCountBadgeType]); return ( { + (currentChatFolders, allConversations, activeProfile) => { return countAllChatFoldersUnreadStats( currentChatFolders, allConversations, { activeProfile, - includeMuted: badgeCountMutedConversations - ? 'setting-on' - : 'setting-off', + includeMuted: 'force-include', } ); } @@ -1510,42 +1504,38 @@ const getStoriesNotificationCount = createSelector( } ); -export const getOtherTabsUnreadStats = createSelector( +export const getOtherTabsUnreadCount = createSelector( getSelectedNavTab, getAllConversationsUnreadStats, + getUnreadCountBadgeType, getCallHistoryUnreadCount, getStoriesNotificationCount, ( selectedNavTab, conversationsUnreadStats, + unreadCountBadgeType, callHistoryUnreadCount, storiesNotificationCount - ): UnreadStats => { - let unreadCount = 0; - let unreadMentionsCount = 0; - let readChatsMarkedUnreadCount = 0; + ): number => { + let count = 0; if (selectedNavTab !== NavTab.Chats) { - unreadCount += conversationsUnreadStats.unreadCount; - unreadMentionsCount += conversationsUnreadStats.unreadMentionsCount; - readChatsMarkedUnreadCount += - conversationsUnreadStats.readChatsMarkedUnreadCount; + count += getUnreadCountForBadge( + conversationsUnreadStats, + unreadCountBadgeType + ); } // Note: Conversation unread stats includes the call history unread count. if (selectedNavTab !== NavTab.Calls) { - unreadCount += callHistoryUnreadCount; + count += callHistoryUnreadCount; } if (selectedNavTab !== NavTab.Stories) { - unreadCount += storiesNotificationCount; + count += storiesNotificationCount; } - return { - unreadCount, - unreadMentionsCount, - readChatsMarkedUnreadCount, - }; + return count; } ); diff --git a/ts/state/selectors/items.dom.ts b/ts/state/selectors/items.dom.ts index 237bf9095c..a8d34f6718 100644 --- a/ts/state/selectors/items.dom.ts +++ b/ts/state/selectors/items.dom.ts @@ -12,6 +12,8 @@ import type { CustomColorType, } from '../../types/Colors.std.ts'; import type { AciString } from '../../types/ServiceId.std.ts'; +import type { UnreadCountBadgeType } from '../../types/StorageKeys.std.ts'; +import { STORAGE_KEY_DEFAULTS } from '../../types/StorageKeys.std.ts'; import { DEFAULT_CONVERSATION_COLOR } from '../../types/Colors.std.ts'; import { getPreferredReactionEmoji as getPreferredReactionEmojiFromStoredValue } from '../../reactions/preferredReactionEmoji.std.ts'; import type { NotifyWhileMuted } from '../../util/notifyWhileMuted.std.ts'; @@ -229,6 +231,15 @@ export const getGlobalNotifyWhileMuted = createSelector( }) ); +export const getUnreadCountBadgeType = createSelector( + getItems, + (state: ItemsStateType): UnreadCountBadgeType => { + return ( + state.unreadCountBadgeType ?? STORAGE_KEY_DEFAULTS.unreadCountBadgeType + ); + } +); + export const getTextFormattingEnabled = createSelector( getItems, (state: ItemsStateType): boolean => state.textFormatting ?? true diff --git a/ts/state/smart/CallsTab.preload.tsx b/ts/state/smart/CallsTab.preload.tsx index 27a3f5e962..1901550cf6 100644 --- a/ts/state/smart/CallsTab.preload.tsx +++ b/ts/state/smart/CallsTab.preload.tsx @@ -13,7 +13,7 @@ import { CallsTab } from '../../components/CallsTab.dom.tsx'; import { getAllConversations, getConversationSelector, - getOtherTabsUnreadStats, + getOtherTabsUnreadCount, } from '../selectors/conversations.dom.ts'; import { filterAndSortConversations } from '../../util/filterAndSortConversations.std.ts'; import type { @@ -152,7 +152,7 @@ export const SmartCallsTab = memo(function SmartCallsTab() { const hasPendingUpdate = useSelector(getHasPendingUpdate); const hasFailedStorySends = useSelector(getHasAnyFailedStorySends); - const otherTabsUnreadStats = useSelector(getOtherTabsUnreadStats); + const otherTabsUnreadCount = useSelector(getOtherTabsUnreadCount); const { createCallLink, @@ -223,7 +223,7 @@ export const SmartCallsTab = memo(function SmartCallsTab() { { export const SmartLeftPane = memo(function SmartLeftPane({ hasFailedStorySends, hasPendingUpdate, - otherTabsUnreadStats, + otherTabsUnreadCount, }: NavTabPanelProps) { const challengeStatus = useSelector(getChallengeStatus); const composerStep = useSelector(getComposerStep); @@ -488,7 +488,7 @@ export const SmartLeftPane = memo(function SmartLeftPane({ onOutgoingAudioCallInConversation={onOutgoingAudioCallInConversation} onOutgoingVideoCallInConversation={onOutgoingVideoCallInConversation} openUsernameReservationModal={openUsernameReservationModal} - otherTabsUnreadStats={otherTabsUnreadStats} + otherTabsUnreadCount={otherTabsUnreadCount} pauseBackupMediaDownload={pauseBackupMediaDownload} preferredWidthFromStorage={preferredWidthFromStorage} preloadConversation={maybePreloadConversation} diff --git a/ts/state/smart/LeftPaneChatFolders.preload.tsx b/ts/state/smart/LeftPaneChatFolders.preload.tsx index 1083e8c8a4..a70a70eefa 100644 --- a/ts/state/smart/LeftPaneChatFolders.preload.tsx +++ b/ts/state/smart/LeftPaneChatFolders.preload.tsx @@ -12,6 +12,7 @@ import { getAllChatFoldersMutedStats, getAllChatFoldersUnreadStats, } from '../selectors/conversations.dom.ts'; +import { getUnreadCountBadgeType } from '../selectors/items.dom.ts'; import { useChatFolderActions } from '../ducks/chatFolders.preload.ts'; import { NavSidebarWidthBreakpointContext } from '../../components/NavSidebar.dom.tsx'; import { useNavActions } from '../ducks/nav.std.ts'; @@ -26,6 +27,7 @@ export const SmartLeftPaneChatFolders = memo( const currentChatFolders = useSelector(getCurrentChatFolders); const allChatFoldersUnreadStats = useSelector(getAllChatFoldersUnreadStats); const allChatFoldersMutedStats = useSelector(getAllChatFoldersMutedStats); + const unreadCountBadgeType = useSelector(getUnreadCountBadgeType); const selectedChatFolder = useSelector(getSelectedChatFolder); const navSidebarWidthBreakpoint = useContext( NavSidebarWidthBreakpointContext @@ -59,6 +61,7 @@ export const SmartLeftPaneChatFolders = memo( currentChatFolders={currentChatFolders} allChatFoldersUnreadStats={allChatFoldersUnreadStats} allChatFoldersMutedStats={allChatFoldersMutedStats} + unreadCountBadgeType={unreadCountBadgeType} selectedChatFolder={selectedChatFolder} onSelectedChatFolderIdChange={updateSelectedChatFolderId} onChatFolderMarkRead={markChatFolderRead} diff --git a/ts/state/smart/NavTabs.preload.tsx b/ts/state/smart/NavTabs.preload.tsx index 454b3f7593..018bfdb1fb 100644 --- a/ts/state/smart/NavTabs.preload.tsx +++ b/ts/state/smart/NavTabs.preload.tsx @@ -11,7 +11,10 @@ import { getHasAnyFailedStorySends, getStoriesNotificationCount, } from '../selectors/stories.preload.ts'; -import { getStoriesEnabled } from '../selectors/items.dom.ts'; +import { + getStoriesEnabled, + getUnreadCountBadgeType, +} from '../selectors/items.dom.ts'; import { getSelectedNavTab } from '../selectors/nav.std.ts'; import { useNavActions } from '../ducks/nav.std.ts'; import { getHasPendingUpdate } from '../selectors/updates.std.ts'; @@ -38,6 +41,7 @@ export const SmartNavTabs = memo(function SmartNavTabs({ }: SmartNavTabsProps): JSX.Element { const i18n = useSelector(getIntl); const selectedNavTab = useSelector(getSelectedNavTab); + const unreadCountBadgeType = useSelector(getUnreadCountBadgeType); const storiesEnabled = useSelector(getStoriesEnabled); const unreadConversationsStats = useSelector(getAllConversationsUnreadStats); const unreadStoriesCount = useSelector(getStoriesNotificationCount); @@ -60,6 +64,7 @@ export const SmartNavTabs = memo(function SmartNavTabs({ return ( ; - function renderUpdateDialog( props: Readonly<{ containerWidthBreakpoint: WidthBreakpoint }> ): JSX.Element { @@ -267,7 +254,7 @@ export function SmartPreferences(): JSX.Element | null { const hasFailedStorySends = useSelector(getHasAnyFailedStorySends); const me = useSelector(getMe); const navTabsCollapsed = useSelector(getNavTabsCollapsed); - const otherTabsUnreadStats = useSelector(getOtherTabsUnreadStats); + const otherTabsUnreadCount = useSelector(getOtherTabsUnreadCount); const preferredWidthFromStorage = useSelector(getPreferredLeftPaneWidth); const getPreferredBadge = useSelector(getPreferredBadgeSelector); const theme = useSelector(getTheme); @@ -737,7 +724,7 @@ export function SmartPreferences(): JSX.Element | null { const [hasAudioNotifications, onAudioNotificationsChange] = createItemsAccess( 'audio-notification', - NOTIFICATION_SETTING_DEFAULTS['audio-notification'] + STORAGE_KEY_DEFAULTS['audio-notification'] ); const [hasAutoConvertEmoji, onAutoConvertEmojiChange] = createItemsAccess( 'autoConvertEmoji', @@ -755,7 +742,7 @@ export function SmartPreferences(): JSX.Element | null { ); const [hasCallNotifications, onCallNotificationsChange] = createItemsAccess( 'call-system-notification', - NOTIFICATION_SETTING_DEFAULTS['call-system-notification'] + STORAGE_KEY_DEFAULTS['call-system-notification'] ); const [hasIncomingCallNotifications, onIncomingCallNotificationsChange] = createItemsAccess('incoming-call-notification', true); @@ -764,7 +751,7 @@ export function SmartPreferences(): JSX.Element | null { const [hasCountMutedConversations, onCountMutedConversationsChange] = createItemsAccess( 'badge-count-muted-conversations', - NOTIFICATION_SETTING_DEFAULTS['badge-count-muted-conversations'], + STORAGE_KEY_DEFAULTS['badge-count-muted-conversations'], () => { window.Whisper.events.emit('updateUnreadCount'); const account = @@ -772,6 +759,17 @@ export function SmartPreferences(): JSX.Element | null { account.captureChange('badge-count-muted-conversations'); } ); + const [unreadCountBadgeType, onUnreadCountBadgeTypeChange] = + createItemsAccess( + 'unreadCountBadgeType', + STORAGE_KEY_DEFAULTS.unreadCountBadgeType, + () => { + const account = + window.ConversationController.getOurConversationOrThrow(); + account.captureChange('unreadCountBadgeType'); + window.Whisper.events.emit('updateUnreadCount'); + } + ); const [hasHideMenuBar, onHideMenuBarChange] = createItemsAccess( 'hide-menu-bar', false, @@ -782,17 +780,17 @@ export function SmartPreferences(): JSX.Element | null { ); const [hasMessageAudio, onMessageAudioChange] = createItemsAccess( 'audioMessage', - NOTIFICATION_SETTING_DEFAULTS.audioMessage + STORAGE_KEY_DEFAULTS.audioMessage ); const [hasNotificationAttention, onNotificationAttentionChange] = createItemsAccess( 'notification-draw-attention', - NOTIFICATION_SETTING_DEFAULTS['notification-draw-attention'] + STORAGE_KEY_DEFAULTS['notification-draw-attention'] ); const [hasReactionNotifications, onReactionNotificationsChange] = createItemsAccess( 'reaction-notification', - NOTIFICATION_SETTING_DEFAULTS['reaction-notification'], + STORAGE_KEY_DEFAULTS['reaction-notification'], () => { const account = window.ConversationController.getOurConversationOrThrow(); @@ -802,13 +800,13 @@ export function SmartPreferences(): JSX.Element | null { const [notificationContent, onNotificationContentChange] = createItemsAccess( 'notification-setting', - NOTIFICATION_SETTING_DEFAULTS['notification-setting'] + STORAGE_KEY_DEFAULTS['notification-setting'] ); const hasNotifications = notificationContent !== 'off'; const onNotificationsChange = (value: boolean) => { putItem( 'notification-setting', - value ? DEFAULT_NOTIFICATION_SETTING : 'off' + value ? STORAGE_KEY_DEFAULTS['notification-setting'] : 'off' ); }; @@ -825,32 +823,27 @@ export function SmartPreferences(): JSX.Element | null { const onResetNotificationSettings = () => { // Reset global settings - onNotificationContentChange( - NOTIFICATION_SETTING_DEFAULTS['notification-setting'] - ); - onCallNotificationsChange( - NOTIFICATION_SETTING_DEFAULTS['call-system-notification'] - ); + onNotificationContentChange(STORAGE_KEY_DEFAULTS['notification-setting']); + onCallNotificationsChange(STORAGE_KEY_DEFAULTS['call-system-notification']); onReactionNotificationsChange( - NOTIFICATION_SETTING_DEFAULTS['reaction-notification'] + STORAGE_KEY_DEFAULTS['reaction-notification'] ); onNotificationAttentionChange( - NOTIFICATION_SETTING_DEFAULTS['notification-draw-attention'] + STORAGE_KEY_DEFAULTS['notification-draw-attention'] ); - onAudioNotificationsChange( - NOTIFICATION_SETTING_DEFAULTS['audio-notification'] - ); - onMessageAudioChange(NOTIFICATION_SETTING_DEFAULTS.audioMessage); + onAudioNotificationsChange(STORAGE_KEY_DEFAULTS['audio-notification']); + onMessageAudioChange(STORAGE_KEY_DEFAULTS.audioMessage); + onUnreadCountBadgeTypeChange(STORAGE_KEY_DEFAULTS.unreadCountBadgeType); onCountMutedConversationsChange( - NOTIFICATION_SETTING_DEFAULTS['badge-count-muted-conversations'] + STORAGE_KEY_DEFAULTS['badge-count-muted-conversations'] ); const account = window.ConversationController.getOurConversationOrThrow(); for (const itemKey of Object.values(NOTIFY_WHILE_MUTED_FIELDS)) { - if (itemStorage.get(itemKey) === NOTIFICATION_SETTING_DEFAULTS[itemKey]) { + if (itemStorage.get(itemKey) === STORAGE_KEY_DEFAULTS[itemKey]) { continue; } - drop(itemStorage.put(itemKey, NOTIFICATION_SETTING_DEFAULTS[itemKey])); + drop(itemStorage.put(itemKey, STORAGE_KEY_DEFAULTS[itemKey])); account.captureChange(itemKey); } @@ -1164,12 +1157,13 @@ export function SmartPreferences(): JSX.Element | null { onToggleNavTabsCollapse={toggleNavTabsCollapse} onTypingIndicatorsChange={onTypingIndicatorsChange} onUniversalExpireTimerChange={onUniversalExpireTimerChange} + onUnreadCountBadgeTypeChange={onUnreadCountBadgeTypeChange} onWhoCanFindMeChange={onWhoCanFindMeChange} onWhoCanSeeMeChange={onWhoCanSeeMeChange} onZoomFactorChange={onZoomFactorChange} openFileInFolder={openFileInFolder} osName={osName} - otherTabsUnreadStats={otherTabsUnreadStats} + otherTabsUnreadCount={otherTabsUnreadCount} settingsLocation={settingsLocation} pickLocalBackupFolder={pickLocalBackupFolder} preferredSystemLocales={preferredSystemLocales} @@ -1212,6 +1206,7 @@ export function SmartPreferences(): JSX.Element | null { theme={theme} themeSetting={themeSetting} universalExpireTimer={universalExpireTimer} + unreadCountBadgeType={unreadCountBadgeType} validateBackup={validateBackup} whoCanFindMe={whoCanFindMe} whoCanSeeMe={whoCanSeeMe} diff --git a/ts/state/smart/StoriesTab.preload.tsx b/ts/state/smart/StoriesTab.preload.tsx index 67691eba5d..d20d01af86 100644 --- a/ts/state/smart/StoriesTab.preload.tsx +++ b/ts/state/smart/StoriesTab.preload.tsx @@ -10,7 +10,7 @@ import { getMaximumOutgoingVideoSize } from '../../types/AttachmentSize.std.ts'; import { getValue, type ConfigKeyType } from '../../RemoteConfig.dom.ts'; import { getMe, - getOtherTabsUnreadStats, + getOtherTabsUnreadCount, } from '../selectors/conversations.dom.ts'; import { getIntl, getTheme } from '../selectors/user.std.ts'; import { getPreferredBadgeSelector } from '../selectors/badges.preload.ts'; @@ -65,7 +65,7 @@ export const SmartStoriesTab = memo(function SmartStoriesTab() { const hasViewReceiptSetting = useSelector(getHasStoryViewReceiptSetting); const hasPendingUpdate = useSelector(getHasPendingUpdate); const hasFailedStorySends = useSelector(getHasAnyFailedStorySends); - const otherTabsUnreadStats = useSelector(getOtherTabsUnreadStats); + const otherTabsUnreadCount = useSelector(getOtherTabsUnreadCount); const remoteConfig = useSelector(getRemoteConfig); const maxAttachmentVideoSize = getMaximumOutgoingVideoSize( @@ -108,7 +108,7 @@ export const SmartStoriesTab = memo(function SmartStoriesTab() { return ( { it('should count unreadCount', () => { check({ unreadCount: undefined }, { unreadCount: 0 }); check({ unreadCount: 0 }, { unreadCount: 0 }); - check({ unreadCount: 1 }, { unreadCount: 1 }); - check({ unreadCount: 42 }, { unreadCount: 42 }); + check({ unreadCount: 1 }, { unreadCount: 1, unreadChatsCount: 1 }); + check({ unreadCount: 42 }, { unreadCount: 42, unreadChatsCount: 1 }); }); it('should count unreadMentionsCount', () => { check({ unreadMentionsCount: undefined }, { unreadMentionsCount: 0 }); check({ unreadMentionsCount: 0 }, { unreadMentionsCount: 0 }); - check({ unreadMentionsCount: 1 }, { unreadMentionsCount: 1 }); - check({ unreadMentionsCount: 42 }, { unreadMentionsCount: 42 }); + check( + { unreadMentionsCount: 1 }, + { unreadMentionsCount: 1, unreadChatsCount: 1 } + ); + check( + { unreadMentionsCount: 42 }, + { unreadMentionsCount: 42, unreadChatsCount: 1 } + ); }); it('should count readChatsMarkedUnreadCount', () => { const read = { readChatsMarkedUnreadCount: 1 }; - const unread = { unreadCount: 42, readChatsMarkedUnreadCount: 0 }; + const unread = { + unreadCount: 42, + unreadChatsCount: 1, + readChatsMarkedUnreadCount: 0, + }; const mentions = { unreadMentionsCount: 42, + unreadChatsCount: 1, readChatsMarkedUnreadCount: 0, }; @@ -235,25 +249,29 @@ describe('countUnreadStats', () => { check([read], { unreadCount: 0 }); check([read, read], { unreadCount: 0 }); - check([read, unread], { unreadCount: 10 }); + check([read, unread], { unreadCount: 10, unreadChatsCount: 1 }); - check([unread], { unreadCount: 10 }); - check([unread, unread], { unreadCount: 20 }); + check([unread], { unreadCount: 10, unreadChatsCount: 1 }); + check([unread, unread], { unreadCount: 20, unreadChatsCount: 2 }); - check([mentions], { unreadMentionsCount: 10 }); - check([mentions, mentions], { unreadMentionsCount: 20 }); + check([mentions], { unreadMentionsCount: 10, unreadChatsCount: 1 }); + check([mentions, mentions], { + unreadMentionsCount: 20, + unreadChatsCount: 2, + }); check([markedUnread], { readChatsMarkedUnreadCount: 1 }); check([markedUnread, markedUnread], { readChatsMarkedUnreadCount: 2 }); check([unreadAndMarkedUnread], { unreadCount: 10, + unreadChatsCount: 1, readChatsMarkedUnreadCount: 0, }); }); it('should check if each conversation can be counted', () => { - const isCounted = { unreadCount: 20 }; - const isNotCounted = { unreadCount: 10 }; + const isCounted = { unreadCount: 20, unreadChatsCount: 2 }; + const isNotCounted = { unreadCount: 10, unreadChatsCount: 1 }; const unread = { unreadCount: 10 }; const inactive = { ...unread, activeAt: 0 }; @@ -269,6 +287,42 @@ describe('countUnreadStats', () => { }); }); + describe('getUnreadCountForBadge', () => { + function check( + stats: StatsProps, + expected: Record + ) { + const unreadStats = mockStats(stats); + assert.equal( + getUnreadCountForBadge(unreadStats, 'unread-messages'), + expected['unread-messages'] + ); + assert.equal( + getUnreadCountForBadge(unreadStats, 'unread-chats'), + expected['unread-chats'] + ); + } + + it('should count unread messages or unread chats', () => { + check({}, { 'unread-messages': 0, 'unread-chats': 0 }); + check( + { unreadCount: 20, unreadChatsCount: 2 }, + { 'unread-messages': 20, 'unread-chats': 2 } + ); + }); + + it('should always include chats that are only marked unread', () => { + check( + { readChatsMarkedUnreadCount: 3 }, + { 'unread-messages': 3, 'unread-chats': 3 } + ); + check( + { unreadCount: 20, unreadChatsCount: 2, readChatsMarkedUnreadCount: 3 }, + { 'unread-messages': 23, 'unread-chats': 5 } + ); + }); + }); + describe('countAllChatFoldersUnreadStats', () => { function check( chats: ReadonlyArray, @@ -326,17 +380,20 @@ describe('countUnreadStats', () => { check(chats, [{ folder: empty, stats: null }]); check(chats, [ - { folder: all, stats: { unreadCount: 77 } }, - { folder: allGroups, stats: { unreadCount: 7 } }, - { folder: allDirect, stats: { unreadCount: 70 } }, + { folder: all, stats: { unreadCount: 77, unreadChatsCount: 4 } }, + { folder: allGroups, stats: { unreadCount: 7, unreadChatsCount: 2 } }, + { folder: allDirect, stats: { unreadCount: 70, unreadChatsCount: 2 } }, ]); check( chats, [ - { folder: all, stats: { unreadCount: 88 } }, - { folder: allGroups, stats: { unreadCount: 8 } }, - { folder: allDirect, stats: { unreadCount: 80 } }, + { folder: all, stats: { unreadCount: 88, unreadChatsCount: 6 } }, + { folder: allGroups, stats: { unreadCount: 8, unreadChatsCount: 3 } }, + { + folder: allDirect, + stats: { unreadCount: 80, unreadChatsCount: 3 }, + }, ], 'force-include' ); diff --git a/ts/types/StorageKeys.std.ts b/ts/types/StorageKeys.std.ts index 62463d6ac7..4f43af0594 100644 --- a/ts/types/StorageKeys.std.ts +++ b/ts/types/StorageKeys.std.ts @@ -51,6 +51,8 @@ export type SentMediaQualitySettingType = 'standard' | 'high'; export type NotificationSettingType = 'message' | 'name' | 'count' | 'off'; +export type UnreadCountBadgeType = 'unread-messages' | 'unread-chats'; + export type IdentityKeyMap = Record< ServiceIdString, { @@ -194,6 +196,7 @@ export type StorageAccessType = { preferredReactionEmoji: ReadonlyArray; emojiSkinToneDefault: Emoji.SkinTone; unreadCount: number; + unreadCountBadgeType: UnreadCountBadgeType; 'challenge:conversations': ReadonlyArray; deviceNameEncrypted: boolean; @@ -399,6 +402,7 @@ export const STORAGE_KEYS_TO_PRESERVE_AFTER_UNLINK = [ 'showStickersIntroduction', 'emojiSkinToneDefault', 'textFormatting', + 'unreadCountBadgeType', 'zoomFactor', // Bookkeeping keys @@ -647,3 +651,17 @@ export type AssertStorageUnlinkKeysAreExhaustive = AssertTrue< keyof StorageAccessType > >; + +export const STORAGE_KEY_DEFAULTS = { + 'audio-notification': false, + 'badge-count-muted-conversations': false, + 'call-system-notification': true, + 'notification-draw-attention': false, + 'notification-setting': 'message', + 'reaction-notification': true, + audioMessage: false, + notifyForCallsIfMuted: undefined, + notifyForMentionsIfMuted: undefined, + notifyForRepliesIfMuted: undefined, + unreadCountBadgeType: 'unread-messages', +} as const satisfies Partial; diff --git a/ts/util/countUnreadStats.std.ts b/ts/util/countUnreadStats.std.ts index a3def11298..71b17a711a 100644 --- a/ts/util/countUnreadStats.std.ts +++ b/ts/util/countUnreadStats.std.ts @@ -6,6 +6,7 @@ import { CurrentChatFolders } from '../types/CurrentChatFolders.std.ts'; import { isConversationMuted } from './isConversationMuted.std.ts'; import type { ConversationType } from '../state/ducks/conversations.preload.ts'; +import type { UnreadCountBadgeType } from '../types/StorageKeys.std.ts'; import type { ChatFolderId } from '../types/ChatFolder.std.ts'; import type { NotificationProfileType } from '../types/NotificationProfile.std.ts'; @@ -19,6 +20,15 @@ type MutableUnreadStats = { */ unreadCount: number; + /** + * Number of countable conversations in the set that have at least one + * unread message. + * + * Note: Chats that are only marked unread are counted in + * `readChatsMarkedUnreadCount` instead. + */ + unreadChatsCount: number; + /** * Total of `conversation.unreadMentionsCount` * in all countable conversations in the set. @@ -42,6 +52,7 @@ export type UnreadStats = Readonly; export function _createUnreadStats(): MutableUnreadStats { return { unreadCount: 0, + unreadChatsCount: 0, unreadMentionsCount: 0, readChatsMarkedUnreadCount: 0, }; @@ -124,12 +135,29 @@ export function _countConversation( if (hasUnreadCount) { mutable.unreadCount += unreadCount; + mutable.unreadChatsCount += 1; mutable.unreadMentionsCount += unreadMentionsCount; } else if (markedUnread) { mutable.readChatsMarkedUnreadCount += 1; } } +/** + * The number to show anywhere we are aggregating unreads across chats. Chats that are + * marked-unread count as 1, for either badge count type. + */ +export function getUnreadCountForBadge( + unreadStats: UnreadStats, + unreadCountBadgeType: UnreadCountBadgeType +): number { + const unreadCount = + unreadCountBadgeType === 'unread-chats' + ? unreadStats.unreadChatsCount + : unreadStats.unreadCount; + + return unreadCount + unreadStats.readChatsMarkedUnreadCount; +} + export function isConversationUnread( conversation: ConversationPropsForUnreadStats, options: UnreadStatsOptions diff --git a/ts/window.d.ts b/ts/window.d.ts index a219c1c393..3a5c6f135e 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -51,7 +51,7 @@ export type IPCType = { removeSetupMenuItems: () => unknown; setAutoHideMenuBar: (value: boolean) => void; setAutoLaunch: (value: boolean) => Promise; - setBadge: (badge: number | 'marked-unread') => void; + setBadgeCount: (badgeCount: number) => void; setMediaPermissions: (value: boolean) => Promise; setMediaCameraPermissions: (value: boolean) => Promise; setMenuBarVisibility: (value: boolean) => void; diff --git a/ts/windows/main/phase1-ipc.preload.ts b/ts/windows/main/phase1-ipc.preload.ts index e146d16be8..29eca8a9e1 100644 --- a/ts/windows/main/phase1-ipc.preload.ts +++ b/ts/windows/main/phase1-ipc.preload.ts @@ -134,7 +134,7 @@ const IPC: IPCType = { removeSetupMenuItems: () => ipc.send('remove-setup-menu-items'), setAutoHideMenuBar: autoHide => ipc.send('set-auto-hide-menu-bar', autoHide), setAutoLaunch: value => ipc.invoke('set-auto-launch', value), - setBadge: badge => ipc.send('set-badge', badge), + setBadgeCount: badgeCount => ipc.send('set-badge-count', badgeCount), setMenuBarVisibility: visibility => ipc.send('set-menu-bar-visibility', visibility), showDebugLog: (options?: { mode?: 'submit' | 'close' }) => {