From 9c7dc22a2343bffe146578773c1442c96bb86a7d Mon Sep 17 00:00:00 2001 From: Jamie Kyle <113370520+jamiebuilds-signal@users.noreply.github.com> Date: Mon, 14 Aug 2023 16:28:47 -0700 Subject: [PATCH] Update nav tab badges, fix several call tabs issues --- _locales/en/messages.json | 10 +- app/main.ts | 19 +- stylesheets/_modules.scss | 206 ----------- stylesheets/components/CallsTab.scss | 35 +- stylesheets/components/MyStories.scss | 151 ++++++-- stylesheets/components/NavSidebar.scss | 1 + stylesheets/components/NavTabs.scss | 44 ++- stylesheets/components/ProfileEditor.scss | 32 ++ stylesheets/components/Stories.scss | 19 +- ts/ConversationController.ts | 35 +- ts/components/AvatarPopup.stories.tsx | 111 ------ ts/components/AvatarPopup.tsx | 93 ----- ts/components/CallsList.tsx | 5 +- ts/components/CallsTab.tsx | 27 +- ts/components/ChatsTab.tsx | 10 + ts/components/LeftPane.stories.tsx | 7 + ts/components/LeftPane.tsx | 10 + ts/components/MyStories.tsx | 18 +- ts/components/NavSidebar.tsx | 10 + ts/components/NavTabs.tsx | 341 +++++++++--------- ts/components/ProfileEditor.tsx | 6 + ts/components/StoriesTab.tsx | 14 + ts/models/conversations.ts | 1 + ts/sql/Interface.ts | 2 + ts/sql/Server.ts | 49 ++- ts/state/ducks/callHistory.ts | 53 ++- ts/state/selectors/callHistory.ts | 7 + ts/state/selectors/conversations.ts | 39 +- ts/state/selectors/nav.ts | 17 + ts/state/selectors/updates.ts | 5 + ts/state/smart/CallsTab.tsx | 14 +- ts/state/smart/ChatsTab.tsx | 10 + ts/state/smart/NavTabs.tsx | 10 +- ts/state/smart/ProfileEditorModal.tsx | 2 + ts/state/smart/StoriesTab.tsx | 9 + ts/test-both/util/countUnreadStats_test.ts | 268 ++++++++++++++ ...ConversationUnreadCountForAppBadge_test.ts | 180 --------- ts/test-mock/pnp/username_test.ts | 5 - ts/util/countUnreadStats.ts | 92 +++++ ts/util/filterAndSortConversations.ts | 17 +- .../getConversationUnreadCountForAppBadge.ts | 43 --- ts/window.d.ts | 2 +- ts/windows/main/phase1-ipc.ts | 2 +- 43 files changed, 1095 insertions(+), 936 deletions(-) delete mode 100644 ts/components/AvatarPopup.stories.tsx delete mode 100644 ts/components/AvatarPopup.tsx create mode 100644 ts/test-both/util/countUnreadStats_test.ts delete mode 100644 ts/test-both/util/getConversationUnreadCountForAppBadge_test.ts create mode 100644 ts/util/countUnreadStats.ts delete mode 100644 ts/util/getConversationUnreadCountForAppBadge.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index e2bb9a58c66b..74f242a731df 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -307,6 +307,10 @@ "messageformat": "Hide Tabs", "description": "Show in the nav tabs when the nav tabs are visible, hides the nav tabs" }, + "icu:NavTabs__ItemIconLabel--HasError": { + "messageformat": "An error occurred", + "description": "Nav Tabs > Tab has error > Accessibility Text" + }, "icu:NavTabs__ItemIconLabel--UnreadCount": { "messageformat": "{count, number} unread", "description": "Nav Tabs > Unread badge > Accessibility Text" @@ -317,7 +321,11 @@ }, "icu:NavTabs__ItemLabel--Settings": { "messageformat": "Settings", - "description": "Nav Tabs > Settings Button > Accessibility Text" + "description": "Nav Tabs > Settings Button > Label & Accessibility Text" + }, + "icu:NavTabs__ItemLabel--Update": { + "messageformat": "Update Signal", + "description": "Nav Tabs > Settings Button > Label & Accessibility Text" }, "icu:NavTabs__ItemLabel--Profile": { "messageformat": "Profile", diff --git a/app/main.ts b/app/main.ts index c0210c5525f7..f28ac147567b 100644 --- a/app/main.ts +++ b/app/main.ts @@ -2132,9 +2132,22 @@ app.on('will-finish-launching', () => { }); }); -ipc.on('set-badge-count', (_event: Electron.Event, count: number) => { - app.badgeCount = count; -}); +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('remove-setup-menu-items', () => { setupMenu(); diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index a826e710389a..8088e279233c 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -6364,212 +6364,6 @@ button.module-image__border-overlay:focus { } } -// Module: Avatar Popup - -.module-avatar-popup { - min-width: 240px; - max-width: 320px; - - border-radius: 4px; - padding-bottom: 4px; - - @include popper-shadow; - - @include light-theme { - color: $color-gray-90; - background-color: $color-white; - } - @include dark-theme { - color: $color-gray-05; - background-color: $color-gray-75; - } -} - -.module-avatar-popup__profile { - @include button-reset(); - align-items: center; - display: flex; - flex-direction: row; - width: 100%; - - @include light-theme { - &:hover { - background-color: $color-gray-05; - } - } - @include dark-theme { - &:hover { - background-color: $color-gray-60; - } - } - @include keyboard-mode { - &:hover { - background-color: inherit; - } - &:focus { - background-color: $color-gray-05; - } - } - @include dark-keyboard-mode { - &:hover { - background-color: inherit; - } - &:focus { - background-color: $color-gray-60; - } - } -} - -.module-avatar-popup__profile { - padding: 12px; -} - -.module-avatar-popup__profile__text { - margin-inline-start: 10px; - overflow: hidden; -} - -.module-avatar-popup__profile__name { - @include font-body-1-bold; -} -.module-avatar-popup__profile__number { - @include font-subtitle; - - @include light-theme { - color: $color-gray-60; - } - @include dark-theme { - color: $color-gray-25; - } -} - -.module-avatar-popup__profile__name, -.module-avatar-popup__profile__number { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.module-avatar-popup__divider { - border: none; - padding: 0; - margin: 0; - - height: 1px; - width: 100%; - margin-bottom: 6px; - - @include light-theme { - background-color: $color-gray-15; - } - @include dark-theme { - background-color: $color-gray-60; - } -} - -.module-avatar-popup__item { - @include font-body-2; - @include button-reset; - - display: flex; - flex-direction: row; - align-items: center; - - width: 100%; - height: 32px; - padding: 6px; - - @include light-theme { - &:hover { - background-color: $color-gray-05; - } - } - @include keyboard-mode { - &:hover { - background-color: inherit; - } - &:focus { - background-color: $color-gray-05; - } - } - @include dark-theme { - &:hover { - background-color: $color-gray-60; - } - } - @include dark-keyboard-mode { - &:hover { - background-color: inherit; - } - &:focus { - background-color: $color-gray-60; - } - } -} - -.module-avatar-popup__item__icon { - margin-inline-start: 6px; - - height: 16px; - width: 16px; - - &--update { - @include light-theme { - @include color-svg( - '../images/icons/v3/refresh/refresh.svg', - $color-gray-75 - ); - } - @include dark-theme { - @include color-svg( - '../images/icons/v3/refresh/refresh.svg', - $color-gray-15 - ); - } - } -} -.module-avatar-popup__item__icon-settings { - @include light-theme { - @include color-svg( - '../images/icons/v3/settings/settings-compact.svg', - $color-gray-75 - ); - } - @include dark-theme { - @include color-svg( - '../images/icons/v3/settings/settings-compact.svg', - $color-gray-15 - ); - } -} -.module-avatar-popup__item__icon-archive { - @include light-theme { - @include color-svg( - '../images/icons/v3/archive/archive-compact.svg', - $color-gray-75 - ); - } - @include dark-theme { - @include color-svg( - '../images/icons/v3/archive/archive-compact.svg', - $color-gray-15 - ); - } -} - -.module-avatar-popup__item__text { - flex-grow: 1; - margin-inline-start: 8px; -} - -.module-avatar-popup__item--badge { - background: $color-ultramarine; - border-radius: 100%; - height: 8px; - margin-inline-end: 10px; - width: 8px; -} - /* Calling: Device Selection */ .module-calling-device-selection { diff --git a/stylesheets/components/CallsTab.scss b/stylesheets/components/CallsTab.scss index a302b2868d23..5485ac6c81f2 100644 --- a/stylesheets/components/CallsTab.scss +++ b/stylesheets/components/CallsTab.scss @@ -46,6 +46,30 @@ flex-direction: column; } +.CallsTab__EmptyStateIcon { + width: 56px; + height: 56px; + opacity: 0.7; + @include light-theme { + @include color-svg('../images/icons/v3/phone/phone.svg', $color-gray-60); + } + @include dark-theme { + @include color-svg('../images/icons/v3/phone/phone.svg', $color-gray-25); + } +} + +.CallsTab__EmptyStateLabel { + margin-block: 12px 0; + margin-inline: 0; + opacity: 0.7; + @include light-theme { + color: $color-gray-60; + } + @include dark-theme { + color: $color-gray-25; + } +} + .CallsTab__ConversationCallDetails { display: block; overflow: auto; @@ -208,10 +232,6 @@ font-weight: bold; } -.CallsList__ItemCallInfo { - @include font-body-1; -} - // Override .ListTile__subtitle so ellipsis is correct color .CallsList__Item--missed .ListTile__subtitle { color: $color-accent-red; @@ -219,7 +239,12 @@ // Override .ListTile .ListTile.CallsList__ItemTile { - padding-block: 12px; + padding-block: 10px; + + // Override .ListTile__subtitle with correct font size + .ListTile__subtitle { + @include font-body-2; + } } .CallsList__Item--selected .CallsList__ItemTile { diff --git a/stylesheets/components/MyStories.scss b/stylesheets/components/MyStories.scss index a389fe24e0ed..a57b2cf205c0 100644 --- a/stylesheets/components/MyStories.scss +++ b/stylesheets/components/MyStories.scss @@ -5,9 +5,14 @@ &__distribution { &__title { @include font-body-1-bold; - color: $color-gray-05; margin-block: 24px 8px; margin-inline: 10px; + @include light-theme() { + color: $color-gray-90; + } + @include dark-theme() { + color: $color-gray-05; + } } } @@ -23,21 +28,36 @@ padding-inline-end: 10px; &:hover { - background: $color-gray-65; + @include light-theme { + background: $color-gray-15; + } + @include dark-theme { + background: $color-gray-65; + } & .MyStories__story__download, .MyStories__story__more__button { - background: $color-gray-60; + @include light-theme() { + background: $color-gray-20; + } + @include dark-theme() { + background: $color-gray-60; + } } } &__details { @include font-body-1-bold; - color: $color-gray-05; display: flex; flex-direction: column; flex: 1; margin-inline-start: 12px; + @include light-theme() { + color: $color-gray-90; + } + @include dark-theme() { + color: $color-gray-05; + } &__failed { align-items: center; @@ -58,80 +78,142 @@ &__button { @include button-reset; @include font-subtitle; - color: $color-gray-25; + @include light-theme { + color: $color-gray-90; + } + @include dark-theme { + color: $color-gray-25; + } } } } &__timestamp { - color: $color-gray-25; font-weight: normal; + @include light-theme { + color: $color-gray-90; + } + @include dark-theme { + color: $color-gray-25; + } } &__download { @include button-reset; align-items: center; - background: $color-gray-65; border-radius: 100%; display: flex; height: 28px; justify-content: center; width: 28px; + @include light-theme { + background: $color-gray-20; + } + @include dark-theme { + background: $color-gray-65; + } &::after { - @include color-svg( - '../images/icons/v3/save/save-compact.svg', - $color-gray-15 - ); content: ''; height: 18px; width: 18px; + @include light-theme { + @include color-svg( + '../images/icons/v3/save/save-compact.svg', + $color-black + ); + } + @include dark-theme { + @include color-svg( + '../images/icons/v3/save/save-compact.svg', + $color-gray-15 + ); + } } &:hover { - background: $color-gray-75 !important; + @include light-theme() { + background: $color-white !important; + } + @include dark-theme() { + background: $color-gray-75 !important; + } } } &__more__button { align-items: center; - background: $color-gray-65; border-radius: 100%; display: flex; height: 28px; justify-content: center; margin-inline-start: 16px; width: 28px; + @include light-theme { + background: $color-gray-15; + } + @include dark-theme { + background: $color-gray-65; + } &::after { - @include color-svg( - '../images/icons/v3/more/more-compact.svg', - $color-gray-15 - ); content: ''; height: 18px; width: 18px; + @include light-theme { + @include color-svg( + '../images/icons/v3/more/more-compact.svg', + $color-black + ); + } + @include dark-theme { + @include color-svg( + '../images/icons/v3/more/more-compact.svg', + $color-gray-15 + ); + } } &:hover { - background: $color-gray-75 !important; + @include light-theme() { + background: $color-white !important; + } + @include dark-theme() { + background: $color-gray-75 !important; + } } } } &__icon { &--forward { - @include color-svg( - '../images/icons/v3/forward/forward-compact.svg', - $color-white - ); + @include light-theme() { + @include color-svg( + '../images/icons/v3/forward/forward-compact.svg', + $color-black + ); + } + @include dark-theme() { + @include color-svg( + '../images/icons/v3/forward/forward-compact.svg', + $color-white + ); + } } &--delete { - @include color-svg( - '../images/icons/v3/trash/trash-compact.svg', - $color-white - ); + @include light-theme() { + @include color-svg( + '../images/icons/v3/trash/trash-compact.svg', + $color-black + ); + } + @include dark-theme() { + @include color-svg( + '../images/icons/v3/trash/trash-compact.svg', + $color-white + ); + } } } @@ -157,19 +239,26 @@ @include light-theme { border-color: $color-gray-04; } - @include dark-theme { border-color: $color-gray-80; } &::after { content: ''; - @include color-svg( - '../images/icons/v3/plus/plus-compact-bold.svg', - $color-white - ); height: 12px; width: 12px; + @include light-theme { + @include color-svg( + '../images/icons/v3/plus/plus-compact-bold.svg', + $color-black + ); + } + @include dark-theme { + @include color-svg( + '../images/icons/v3/plus/plus-compact-bold.svg', + $color-white + ); + } } } } diff --git a/stylesheets/components/NavSidebar.scss b/stylesheets/components/NavSidebar.scss index c70139dc235a..0db20f7c891c 100644 --- a/stylesheets/components/NavSidebar.scss +++ b/stylesheets/components/NavSidebar.scss @@ -25,6 +25,7 @@ align-items: start; flex-shrink: 0; padding-bottom: 6px; + -webkit-app-region: drag; .NavTabs__Toggle { width: $NavTabs__width; diff --git a/stylesheets/components/NavTabs.scss b/stylesheets/components/NavTabs.scss index c64efd7428bf..c2208269e160 100644 --- a/stylesheets/components/NavTabs.scss +++ b/stylesheets/components/NavTabs.scss @@ -110,7 +110,7 @@ $NavTabs__ProfileAvatar__size: 28px; @include sr-only; } -.NavTabs__ItemBadge { +.NavTabs__ItemUnreadBadge { @include rounded-corners; align-items: center; background-color: $color-accent-red; @@ -131,6 +131,17 @@ $NavTabs__ProfileAvatar__size: 28px; word-break: keep-all; } +.NavTabs__ItemUpdateBadge { + background: $color-ultramarine; + border-radius: 100%; + border: 1px solid $color-white; + height: 8px; + width: 8px; + position: absolute; + top: 0; + inset-inline-end: 0; +} + .NavTabs__ItemIcon { display: block; width: $NavTabs__ItemIcon__size; @@ -205,13 +216,26 @@ $NavTabs__ProfileAvatar__size: 28px; min-width: 0; } -.NavTabs__AvatarBadge { - background: $color-ultramarine; - border-radius: 100%; - border: 1px solid $color-white; - height: 8px; - width: 8px; - position: absolute; - top: 0; - inset-inline-end: 0; +.NavTabs__ContextMenuIcon--Settings { + @include dark-theme { + @include color-svg( + '../images/icons/v3/settings/settings.svg', + $color-white + ); + } + @include light-theme { + @include color-svg( + '../images/icons/v3/settings/settings.svg', + $color-black + ); + } +} + +.NavTabs__ContextMenuIcon--Update { + @include dark-theme { + @include color-svg('../images/icons/v3/refresh/refresh.svg', $color-white); + } + @include light-theme { + @include color-svg('../images/icons/v3/refresh/refresh.svg', $color-black); + } } diff --git a/stylesheets/components/ProfileEditor.scss b/stylesheets/components/ProfileEditor.scss index 87e980c79b80..af4e0634d26a 100644 --- a/stylesheets/components/ProfileEditor.scss +++ b/stylesheets/components/ProfileEditor.scss @@ -235,3 +235,35 @@ } } } + +.ProfileEditor__Title { + @include font-title-1; + text-align: center; + overflow-x: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-block: 0 4px; + margin-inline: 0; + @include light-theme() { + color: $color-gray-90; + } + @include dark-theme() { + color: $color-gray-05; + } +} + +.ProfileEditor__PhoneNumber { + @include font-body-2; + text-align: center; + overflow-x: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-block: 0 14px; + margin-inline: 0; + @include light-theme() { + color: $color-black; + } + @include dark-theme() { + color: $color-white; + } +} diff --git a/stylesheets/components/Stories.scss b/stylesheets/components/Stories.scss index a72335d335bf..84c42e7baf31 100644 --- a/stylesheets/components/Stories.scss +++ b/stylesheets/components/Stories.scss @@ -104,21 +104,28 @@ &__placeholder { align-items: center; - color: $color-gray-45; display: flex; flex-direction: column; flex: 1; justify-content: center; + opacity: 0.7; + @include light-theme { + color: $color-gray-60; + } + @include dark-theme { + color: $color-gray-25; + } &__stories { height: 56px; margin-bottom: 22px; width: 56px; - - @include color-svg( - '../images/icons/v3/stories/stories-display.svg', - $color-gray-45 - ); + @include light-theme { + @include color-svg('../images/icons/v3/stories/stories-display.svg', $color-gray-60); + } + @include dark-theme { + @include color-svg('../images/icons/v3/stories/stories-display.svg', $color-gray-25); + } } } diff --git a/ts/ConversationController.ts b/ts/ConversationController.ts index 314ce8a36e42..666968e7607d 100644 --- a/ts/ConversationController.ts +++ b/ts/ConversationController.ts @@ -22,7 +22,6 @@ import { maybeDeriveGroupV2Id } from './groups'; import { assertDev, strictAssert } from './util/assert'; import { drop } from './util/drop'; import { isGroupV1, isGroupV2 } from './util/whatTypeOfConversation'; -import { getConversationUnreadCountForAppBadge } from './util/getConversationUnreadCountForAppBadge'; import type { ServiceIdString } from './types/ServiceId'; import { isServiceIdString, @@ -36,6 +35,7 @@ import { getServiceIdsForE164s } from './util/getServiceIdsForE164s'; import { SIGNAL_ACI, SIGNAL_AVATAR_PATH } from './types/SignalConversation'; import { getTitleNoDefault } from './util/getTitle'; import * as StorageService from './services/storage'; +import { countAllConversationsUnreadStats } from './util/countUnreadStats'; type ConvoMatchType = | { @@ -185,28 +185,31 @@ export class ConversationController { return; } - const canCountMutedConversations = + const includeMuted = window.storage.get('badge-count-muted-conversations') || false; - const newUnreadCount = this._conversations.reduce( - (result: number, conversation: ConversationModel) => - result + - getConversationUnreadCountForAppBadge( - conversation.attributes, - canCountMutedConversations - ), - 0 + const unreadStats = countAllConversationsUnreadStats( + this._conversations.map(conversation => conversation.format()), + { includeMuted } ); - drop(window.storage.put('unreadCount', newUnreadCount)); - if (newUnreadCount > 0) { - window.IPC.setBadgeCount(newUnreadCount); - window.document.title = `${window.getTitle()} (${newUnreadCount})`; + drop(window.storage.put('unreadCount', unreadStats.unreadCount)); + + if (unreadStats.unreadCount > 0) { + window.IPC.setBadge(unreadStats.unreadCount); + window.IPC.updateTrayIcon(unreadStats.unreadCount); + window.document.title = `${window.getTitle()} (${ + unreadStats.unreadCount + })`; + } else if (unreadStats.markedUnread) { + window.IPC.setBadge('marked-unread'); + window.IPC.updateTrayIcon(1); + window.document.title = `${window.getTitle()} (1)`; } else { - window.IPC.setBadgeCount(0); + window.IPC.setBadge(0); + window.IPC.updateTrayIcon(0); window.document.title = window.getTitle(); } - window.IPC.updateTrayIcon(newUnreadCount); } onEmpty(): void { diff --git a/ts/components/AvatarPopup.stories.tsx b/ts/components/AvatarPopup.stories.tsx deleted file mode 100644 index ae6061511169..000000000000 --- a/ts/components/AvatarPopup.stories.tsx +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright 2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import * as React from 'react'; - -import { action } from '@storybook/addon-actions'; -import { boolean, select, text } from '@storybook/addon-knobs'; - -import type { Props } from './AvatarPopup'; -import { AvatarPopup } from './AvatarPopup'; -import type { AvatarColorType } from '../types/Colors'; -import { AvatarColors } from '../types/Colors'; -import { setupI18n } from '../util/setupI18n'; -import enMessages from '../../_locales/en/messages.json'; -import { StorybookThemeContext } from '../../.storybook/StorybookThemeContext'; -import { getFakeBadge } from '../test-both/helpers/getFakeBadge'; - -const i18n = setupI18n('en', enMessages); - -const colorMap: Record = AvatarColors.reduce( - (m, color) => ({ - ...m, - [color]: color, - }), - {} -); - -const conversationTypeMap: Record = { - direct: 'direct', - group: 'group', -}; - -const useProps = (overrideProps: Partial = {}): Props => ({ - acceptedMessageRequest: true, - avatarPath: text('avatarPath', overrideProps.avatarPath || ''), - badge: overrideProps.badge, - color: select('color', colorMap, overrideProps.color || AvatarColors[0]), - conversationType: select( - 'conversationType', - conversationTypeMap, - overrideProps.conversationType || 'direct' - ), - hasPendingUpdate: Boolean(overrideProps.hasPendingUpdate), - i18n, - isMe: true, - noteToSelf: boolean('noteToSelf', overrideProps.noteToSelf || false), - onEditProfile: action('onEditProfile'), - onStartUpdate: action('startUpdate'), - phoneNumber: text('phoneNumber', overrideProps.phoneNumber || ''), - profileName: text('profileName', overrideProps.profileName || ''), - sharedGroupNames: [], - style: {}, - theme: React.useContext(StorybookThemeContext), - title: text('title', overrideProps.title || ''), -}); - -export default { - title: 'Components/Avatar Popup', -}; - -export function AvatarOnly(): JSX.Element { - const props = useProps(); - - return ; -} - -export function HasBadge(): JSX.Element { - const props = useProps({ - badge: getFakeBadge(), - title: 'Janet Yellen', - }); - - return ; -} - -HasBadge.story = { - name: 'Has badge', -}; - -export function Title(): JSX.Element { - const props = useProps({ - title: 'My Great Title', - }); - - return ; -} - -export function ProfileName(): JSX.Element { - const props = useProps({ - profileName: 'Sam Neill', - }); - - return ; -} - -export function PhoneNumber(): JSX.Element { - const props = useProps({ - profileName: 'Sam Neill', - phoneNumber: '(555) 867-5309', - }); - - return ; -} - -export function UpdateAvailable(): JSX.Element { - const props = useProps({ - hasPendingUpdate: true, - }); - - return ; -} diff --git a/ts/components/AvatarPopup.tsx b/ts/components/AvatarPopup.tsx deleted file mode 100644 index c4eedc6de015..000000000000 --- a/ts/components/AvatarPopup.tsx +++ /dev/null @@ -1,93 +0,0 @@ -// Copyright 2019 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import * as React from 'react'; -import classNames from 'classnames'; - -import type { Props as AvatarProps } from './Avatar'; -import { Avatar, AvatarSize } from './Avatar'; -import { useRestoreFocus } from '../hooks/useRestoreFocus'; - -import type { LocalizerType, ThemeType } from '../types/Util'; -import { UserText } from './UserText'; - -export type Props = { - readonly i18n: LocalizerType; - readonly theme: ThemeType; - - hasPendingUpdate: boolean; - - onEditProfile: () => unknown; - onStartUpdate: () => unknown; - - // Matches Popper's RefHandler type - innerRef?: React.Ref; - style: React.CSSProperties; - name?: string; -} & Omit; - -export function AvatarPopup(props: Props): JSX.Element { - const { - hasPendingUpdate, - i18n, - name, - onEditProfile, - onStartUpdate, - phoneNumber, - profileName, - style, - title, - } = props; - - const shouldShowNumber = Boolean(name || profileName); - - // Note: mechanisms to dismiss this view are all in its host, MainHeader - - // Focus first button after initial render, restore focus on teardown - const [focusRef] = useRestoreFocus(); - - return ( -
- - - {hasPendingUpdate && ( - <> -
- - - )} -
- ); -} diff --git a/ts/components/CallsList.tsx b/ts/components/CallsList.tsx index 7f764097beb3..1a6d35210c69 100644 --- a/ts/components/CallsList.tsx +++ b/ts/components/CallsList.tsx @@ -122,8 +122,10 @@ type CallsListProps = Readonly<{ ) => void; }>; +const CALL_LIST_ITEM_ROW_HEIGHT = 62; + function rowHeight() { - return ListTile.heightFull; + return CALL_LIST_ITEM_ROW_HEIGHT; } export function CallsList({ @@ -275,6 +277,7 @@ export function CallsList({ return (
} title={ diff --git a/ts/components/CallsTab.tsx b/ts/components/CallsTab.tsx index 3621e0388bd3..e1a764ec648a 100644 --- a/ts/components/CallsTab.tsx +++ b/ts/components/CallsTab.tsx @@ -1,7 +1,7 @@ // Copyright 2023 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import type { LocalizerType } from '../types/I18N'; import { NavSidebar, NavSidebarActionButton } from './NavSidebar'; import { CallsList } from './CallsList'; @@ -16,6 +16,7 @@ import { useEscapeHandling } from '../hooks/useEscapeHandling'; import type { ActiveCallStateType } from '../state/ducks/calling'; import { ContextMenu } from './ContextMenu'; import { ConfirmationDialog } from './ConfirmationDialog'; +import type { UnreadStats } from '../util/countUnreadStats'; enum CallsTabSidebarView { CallsListView, @@ -25,6 +26,7 @@ enum CallsTabSidebarView { type CallsTabProps = Readonly<{ activeCall: ActiveCallStateType | undefined; allConversations: ReadonlyArray; + appUnreadStats: UnreadStats; getCallHistoryGroupsCount: ( options: CallHistoryFilterOptions ) => Promise; @@ -33,9 +35,12 @@ type CallsTabProps = Readonly<{ pagination: CallHistoryPagination ) => Promise>; getConversation: (id: string) => ConversationType | void; + hasFailedStorySends: boolean; + hasPendingUpdate: boolean; i18n: LocalizerType; navTabsCollapsed: boolean; onClearCallHistory: () => void; + onMarkCallHistoryRead: (conversationId: string, callId: string) => void; onToggleNavTabsCollapse: (navTabsCollapsed: boolean) => void; onOutgoingAudioCallInConversation: (conversationId: string) => void; onOutgoingVideoCallInConversation: (conversationId: string) => void; @@ -51,12 +56,16 @@ type CallsTabProps = Readonly<{ export function CallsTab({ activeCall, allConversations, + appUnreadStats, getCallHistoryGroupsCount, getCallHistoryGroups, getConversation, + hasFailedStorySends, + hasPendingUpdate, i18n, navTabsCollapsed, onClearCallHistory, + onMarkCallHistoryRead, onToggleNavTabsCollapse, onOutgoingAudioCallInConversation, onOutgoingVideoCallInConversation, @@ -131,6 +140,14 @@ export function CallsTab({ [updateSidebarView, onOutgoingVideoCallInConversation] ); + useEffect(() => { + if (selected?.callHistoryGroup != null) { + selected.callHistoryGroup.children.forEach(child => { + onMarkCallHistoryRead(selected.conversationId, child.callId); + }); + } + }, [selected, onMarkCallHistoryRead]); + return ( <>
@@ -141,6 +158,9 @@ export function CallsTab({ ? i18n('icu:CallsTab__HeaderTitle--CallsList') : i18n('icu:CallsTab__HeaderTitle--NewCall') } + appUnreadStats={appUnreadStats} + hasFailedStorySends={hasFailedStorySends} + hasPendingUpdate={hasPendingUpdate} navTabsCollapsed={navTabsCollapsed} onBack={ sidebarView === CallsTabSidebarView.NewCallView @@ -232,7 +252,10 @@ export function CallsTab({ {selected == null ? (
- {i18n('icu:CallsTab__EmptyStateText')} +
+

+ {i18n('icu:CallsTab__EmptyStateText')} +

) : (
void; prevConversationId: string | undefined; @@ -20,7 +24,10 @@ type ChatsTabProps = Readonly<{ }>; export function ChatsTab({ + appUnreadStats, i18n, + hasPendingUpdate, + hasFailedStorySends, navTabsCollapsed, onToggleNavTabsCollapse, prevConversationId, @@ -34,7 +41,10 @@ export function ChatsTab({ <>
{renderLeftPane({ + appUnreadStats, collapsed: navTabsCollapsed, + hasPendingUpdate, + hasFailedStorySends, onToggleCollapse: onToggleNavTabsCollapse, })}
diff --git a/ts/components/LeftPane.stories.tsx b/ts/components/LeftPane.stories.tsx index 4654be27187a..4b8276fdc2fd 100644 --- a/ts/components/LeftPane.stories.tsx +++ b/ts/components/LeftPane.stories.tsx @@ -133,6 +133,11 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => { ); return { + appUnreadStats: { + unreadCount: 0, + unreadMentionsCount: 0, + markedUnread: false, + }, clearConversationSearch: action('clearConversationSearch'), clearGroupCreationError: action('clearGroupCreationError'), clearSearch: action('clearSearch'), @@ -143,6 +148,8 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => { composeSaveAvatarToDisk: action('composeSaveAvatarToDisk'), createGroup: action('createGroup'), getPreferredBadge: () => undefined, + hasFailedStorySends: false, + hasPendingUpdate: false, i18n, isMacOS: boolean('isMacOS', false), preferredWidthFromStorage: 320, diff --git a/ts/components/LeftPane.tsx b/ts/components/LeftPane.tsx index 05fa2b4954c2..4412dcbfd2a9 100644 --- a/ts/components/LeftPane.tsx +++ b/ts/components/LeftPane.tsx @@ -48,6 +48,7 @@ import { NavSidebarSearchHeader, } from './NavSidebar'; import { ContextMenu } from './ContextMenu'; +import type { UnreadStats } from '../util/countUnreadStats'; export enum LeftPaneMode { Inbox, @@ -59,8 +60,11 @@ export enum LeftPaneMode { } export type PropsType = { + appUnreadStats: UnreadStats; hasExpiredDialog: boolean; + hasFailedStorySends: boolean; hasNetworkDialog: boolean; + hasPendingUpdate: boolean; hasRelinkDialog: boolean; hasUpdateDialog: boolean; isUpdateDownloaded: boolean; @@ -154,6 +158,7 @@ export type PropsType = { } & LookupConversationWithoutServiceIdActionsType; export function LeftPane({ + appUnreadStats, blockConversation, challengeStatus, clearConversationSearch, @@ -168,7 +173,9 @@ export function LeftPane({ createGroup, getPreferredBadge, hasExpiredDialog, + hasFailedStorySends, hasNetworkDialog, + hasPendingUpdate, hasRelinkDialog, hasUpdateDialog, i18n, @@ -549,6 +556,9 @@ export function LeftPane({ modeSpecificProps.mode === LeftPaneMode.SetGroupMetadata } i18n={i18n} + appUnreadStats={appUnreadStats} + hasFailedStorySends={hasFailedStorySends} + hasPendingUpdate={hasPendingUpdate} navTabsCollapsed={navTabsCollapsed} onToggleNavTabsCollapse={toggleNavTabsCollapse} preferredLeftPaneWidth={preferredWidthFromStorage} diff --git a/ts/components/MyStories.tsx b/ts/components/MyStories.tsx index 75e866944ebb..4ebc44d5b22a 100644 --- a/ts/components/MyStories.tsx +++ b/ts/components/MyStories.tsx @@ -9,6 +9,7 @@ import { StoryViewModeType, } from '../types/Stories'; import type { LocalizerType } from '../types/Util'; +import { ThemeType } from '../types/Util'; import type { ViewStoryActionCreatorType } from '../state/ducks/stories'; import { ConfirmationDialog } from './ConfirmationDialog'; import { ContextMenu } from './ContextMenu'; @@ -19,9 +20,13 @@ import { Theme } from '../util/theme'; import { resolveStorySendStatus } from '../util/resolveStorySendStatus'; import { useRetryStorySend } from '../hooks/useRetryStorySend'; import { NavSidebar } from './NavSidebar'; +import type { UnreadStats } from '../util/countUnreadStats'; export type PropsType = { i18n: LocalizerType; + appUnreadStats: UnreadStats; + hasFailedStorySends: boolean; + hasPendingUpdate: boolean; navTabsCollapsed: boolean; myStories: Array; onBack: () => unknown; @@ -36,10 +41,14 @@ export type PropsType = { hasViewReceiptSetting: boolean; preferredLeftPaneWidth: number; savePreferredLeftPaneWidth: (preferredLeftPaneWidth: number) => void; + theme: ThemeType; }; export function MyStories({ i18n, + appUnreadStats, + hasFailedStorySends, + hasPendingUpdate, navTabsCollapsed, myStories, onBack, @@ -54,6 +63,7 @@ export function MyStories({ onToggleNavTabsCollapse, preferredLeftPaneWidth, savePreferredLeftPaneWidth, + theme, }: PropsType): JSX.Element { const [confirmDeleteStory, setConfirmDeleteStory] = useState< StoryViewType | undefined @@ -80,6 +90,9 @@ export function MyStories({ ))} @@ -135,6 +149,7 @@ type StorySentPropsType = Pick< | 'retryMessageSend' | 'viewStory' | 'onMediaPlaybackStart' + | 'theme' > & { setConfirmDeleteStory: (_: StoryViewType | undefined) => unknown; story: StoryViewType; @@ -150,6 +165,7 @@ function StorySent({ retryMessageSend, setConfirmDeleteStory, story, + theme, viewStory, }: StorySentPropsType): JSX.Element { const sendStatus = resolveStorySendStatus(story.sendState ?? []); @@ -278,7 +294,7 @@ function StorySent({ }, ]} moduleClassName="MyStories__story__more" - theme={Theme.Dark} + theme={theme === ThemeType.dark ? Theme.Dark : Theme.Light} />
); diff --git a/ts/components/NavSidebar.tsx b/ts/components/NavSidebar.tsx index b5d7b9b9f3d2..3ec1d4002429 100644 --- a/ts/components/NavSidebar.tsx +++ b/ts/components/NavSidebar.tsx @@ -14,6 +14,7 @@ import { getWidthFromPreferredWidth, } from '../util/leftPaneWidth'; import { WidthBreakpoint, getNavSidebarWidthBreakpoint } from './_util'; +import type { UnreadStats } from '../util/countUnreadStats'; export function NavSidebarActionButton({ icon, @@ -43,6 +44,8 @@ export type NavSidebarProps = Readonly<{ actions?: ReactNode; children: ReactNode; i18n: LocalizerType; + hasFailedStorySends: boolean; + hasPendingUpdate: boolean; hideHeader?: boolean; navTabsCollapsed: boolean; onBack?: (() => void) | null; @@ -51,6 +54,7 @@ export type NavSidebarProps = Readonly<{ requiresFullWidth: boolean; savePreferredLeftPaneWidth: (width: number) => void; title: string; + appUnreadStats: UnreadStats; }>; enum DragState { @@ -64,6 +68,8 @@ export function NavSidebar({ children, hideHeader, i18n, + hasFailedStorySends, + hasPendingUpdate, navTabsCollapsed, onBack, onToggleNavTabsCollapse, @@ -71,6 +77,7 @@ export function NavSidebar({ requiresFullWidth, savePreferredLeftPaneWidth, title, + appUnreadStats, }: NavSidebarProps): JSX.Element { const [dragState, setDragState] = useState(DragState.INITIAL); @@ -155,6 +162,9 @@ export function NavSidebar({ i18n={i18n} navTabsCollapsed={navTabsCollapsed} onToggleNavTabsCollapse={onToggleNavTabsCollapse} + hasFailedStorySends={hasFailedStorySends} + hasPendingUpdate={hasPendingUpdate} + appUnreadStats={appUnreadStats} /> )}
; + +function NavTabsItemBadges({ + i18n, + hasError, + hasPendingUpdate, + unreadStats, +}: NavTabsItemBadgesProps) { + if (hasError) { + return ( + + + {i18n('icu:NavTabs__ItemIconLabel--HasError')} + + ! + + ); + } + + if (hasPendingUpdate) { + return
; + } + + if (unreadStats != null) { + if (unreadStats.unreadCount > 0) { + return ( + + + {i18n('icu:NavTabs__ItemIconLabel--UnreadCount', { + count: unreadStats.unreadCount, + })} + + {unreadStats.unreadCount} + + ); + } + + if (unreadStats.markedUnread) { + return ( + + {i18n('icu:NavTabs__ItemIconLabel--MarkedUnread')} + + ); + } + } + + return null; +} type NavTabProps = Readonly<{ i18n: LocalizerType; - badge?: ReactNode; iconClassName: string; id: NavTab; + hasError?: boolean; label: string; + unreadStats: UnreadStats | null; }>; -function NavTabsItem({ i18n, badge, iconClassName, id, label }: NavTabProps) { +function NavTabsItem({ + i18n, + iconClassName, + id, + label, + unreadStats, + hasError, +}: NavTabProps) { const isRTL = i18n.getLocaleDirection() === 'rtl'; return ( @@ -43,7 +102,11 @@ function NavTabsItem({ i18n, badge, iconClassName, id, label }: NavTabProps) { role="presentation" className={`NavTabs__ItemIcon ${iconClassName}`} /> - {badge && {badge}} + @@ -52,19 +115,28 @@ function NavTabsItem({ i18n, badge, iconClassName, id, label }: NavTabProps) { } export type NavTabPanelProps = Readonly<{ + appUnreadStats: UnreadStats; collapsed: boolean; + hasFailedStorySends: boolean; + hasPendingUpdate: boolean; onToggleCollapse(collapsed: boolean): void; }>; export type NavTabsToggleProps = Readonly<{ + appUnreadStats: UnreadStats | null; i18n: LocalizerType; + hasFailedStorySends: boolean; + hasPendingUpdate: boolean; navTabsCollapsed: boolean; onToggleNavTabsCollapse(navTabsCollapsed: boolean): void; }>; export function NavTabsToggle({ i18n, + hasFailedStorySends, + hasPendingUpdate, navTabsCollapsed, + appUnreadStats, onToggleNavTabsCollapse, }: NavTabsToggleProps): JSX.Element { function handleToggle() { @@ -87,11 +159,19 @@ export function NavTabsToggle({ delay={600} > - - {label} + + + {label} + + @@ -116,6 +196,7 @@ export type NavTabsProps = Readonly<{ selectedNavTab: NavTab; storiesEnabled: boolean; theme: ThemeType; + unreadCallsCount: number; unreadConversationsStats: UnreadStats; unreadStoriesCount: number; }>; @@ -138,6 +219,7 @@ export function NavTabs({ selectedNavTab, storiesEnabled, theme, + unreadCallsCount, unreadConversationsStats, unreadStoriesCount, }: NavTabsProps): JSX.Element { @@ -147,63 +229,6 @@ export function NavTabs({ const isRTL = i18n.getLocaleDirection() === 'rtl'; - const [targetElement, setTargetElement] = useState(null); - const [popperElement, setPopperElement] = useState(null); - const [portalElement, setPortalElement] = useState(null); - - const [showAvatarPopup, setShowAvatarPopup] = useState(false); - - const popper = usePopper(targetElement, popperElement, { - placement: 'bottom-start', - strategy: 'fixed', - modifiers: [ - { - name: 'offset', - options: { - offset: [null, 4], - }, - }, - ], - }); - - useEffect(() => { - const div = document.createElement('div'); - document.body.appendChild(div); - setPortalElement(div); - return () => { - div.remove(); - setPortalElement(null); - }; - }, []); - - useEffect(() => { - return handleOutsideClick( - () => { - if (!showAvatarPopup) { - return false; - } - setShowAvatarPopup(false); - return true; - }, - { - containerElements: [portalElement, targetElement], - name: 'MainHeader.showAvatarPopup', - } - ); - }, [portalElement, targetElement, showAvatarPopup]); - - useEffect(() => { - function handleGlobalKeyDown(event: KeyboardEvent) { - if (showAvatarPopup && event.key === 'Escape') { - setShowAvatarPopup(false); - } - } - document.addEventListener('keydown', handleGlobalKeyDown, true); - return () => { - document.removeEventListener('keydown', handleGlobalKeyDown, true); - }; - }, [showAvatarPopup]); - return ( diff --git a/ts/components/ProfileEditor.tsx b/ts/components/ProfileEditor.tsx index 72e4a8f461c4..eba00c326a35 100644 --- a/ts/components/ProfileEditor.tsx +++ b/ts/components/ProfileEditor.tsx @@ -79,6 +79,7 @@ export type PropsDataType = { hasCompletedUsernameLinkOnboarding: boolean; i18n: LocalizerType; isUsernameFlagEnabled: boolean; + phoneNumber?: string; userAvatarData: ReadonlyArray; username?: string; usernameEditState: UsernameEditState; @@ -154,6 +155,7 @@ export function ProfileEditor({ onProfileChanged, onSetSkinTone, openUsernameReservationModal, + phoneNumber, profileAvatarPath, recentEmojis, renderEditUsernameModalBody, @@ -678,6 +680,10 @@ export function ProfileEditor({ width: 80, }} /> +

{getFullNameText()}

+ {phoneNumber != null && ( +

{phoneNumber}

+ )}
unknown; getPreferredBadge: PreferredBadgeSelectorType; + hasFailedStorySends: boolean; + hasPendingUpdate: boolean; hasViewReceiptSetting: boolean; hiddenStories: Array; i18n: LocalizerType; @@ -61,8 +65,11 @@ export type PropsType = { export function StoriesTab({ addStoryData, + appUnreadStats, deleteStoryForEveryone, getPreferredBadge, + hasFailedStorySends, + hasPendingUpdate, hasViewReceiptSetting, hiddenStories, i18n, @@ -104,6 +111,9 @@ export function StoriesTab({ {addStoryData && renderStoryCreator()} {isMyStories && myStories.length ? ( ) : ( ; getAllCallHistory: () => Promise>; clearCallHistory: (beforeTimestamp: number) => Promise>; + getCallHistoryUnreadCount(): Promise; + markCallHistoryRead(callId: string): Promise; getCallHistoryMessageByCallId(options: { conversationId: string; callId: string; diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 778d2dda14c4..a420311820c2 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -307,6 +307,8 @@ const dataInterface: ServerInterface = { getLastConversationMessage, getAllCallHistory, clearCallHistory, + getCallHistoryUnreadCount, + markCallHistoryRead, getCallHistoryMessageByCallId, getCallHistory, getCallHistoryGroupsCount, @@ -3346,11 +3348,38 @@ async function getCallHistory( return callHistoryDetailsSchema.parse(row); } -const MISSED = sqlConstant(DirectCallStatus.Missed); -const DELETED = sqlConstant(DirectCallStatus.Deleted); -const INCOMING = sqlConstant(CallDirection.Incoming); +const READ_STATUS_UNREAD = sqlConstant(ReadStatus.Unread); +const READ_STATUS_READ = sqlConstant(ReadStatus.Read); +const CALL_STATUS_MISSED = sqlConstant(DirectCallStatus.Missed); +const CALL_STATUS_DELETED = sqlConstant(DirectCallStatus.Deleted); +const CALL_STATUS_INCOMING = sqlConstant(CallDirection.Incoming); const FOUR_HOURS_IN_MS = sqlConstant(4 * 60 * 60 * 1000); +async function getCallHistoryUnreadCount(): Promise { + const db = getInstance(); + const [query, params] = sql` + SELECT count(*) FROM messages + LEFT JOIN callsHistory ON callsHistory.callId = messages.callId + WHERE messages.type IS 'call-history' + AND messages.readStatus IS ${READ_STATUS_UNREAD} + AND callsHistory.status IS ${CALL_STATUS_MISSED} + AND callsHistory.direction IS ${CALL_STATUS_INCOMING} + `; + const row = db.prepare(query).pluck().get(params); + return row; +} + +async function markCallHistoryRead(callId: string): Promise { + const db = getInstance(); + const [query, params] = sql` + UPDATE messages + SET readStatus = ${READ_STATUS_READ} + WHERE type IS 'call-history' + AND callId IS ${callId} + `; + db.prepare(query).run(params); +} + function getCallHistoryGroupDataSync( db: Database, isCount: boolean, @@ -3406,10 +3435,10 @@ function getCallHistoryGroupDataSync( const filterClause = status === CallHistoryFilterStatus.All - ? sqlFragment`status IS NOT ${DELETED}` + ? sqlFragment`status IS NOT ${CALL_STATUS_DELETED}` : sqlFragment` - direction IS ${INCOMING} AND - status IS ${MISSED} AND status IS NOT ${DELETED} + direction IS ${CALL_STATUS_INCOMING} AND + status IS ${CALL_STATUS_MISSED} AND status IS NOT ${CALL_STATUS_DELETED} `; const offsetLimit = @@ -3445,8 +3474,8 @@ function getCallHistoryGroupDataSync( -- Tracking Android & Desktop separately to make the queries easier to compare -- Android Constraints: AND ( - (callsHistory.status IS c.status AND callsHistory.status IS ${MISSED}) OR - (callsHistory.status IS NOT ${MISSED} AND c.status IS NOT ${MISSED}) + (callsHistory.status IS c.status AND callsHistory.status IS ${CALL_STATUS_MISSED}) OR + (callsHistory.status IS NOT ${CALL_STATUS_MISSED} AND c.status IS NOT ${CALL_STATUS_MISSED}) ) -- Desktop Constraints: AND callsHistory.status IS c.status @@ -3474,8 +3503,8 @@ function getCallHistoryGroupDataSync( -- Tracking Android & Desktop separately to make the queries easier to compare -- Android Constraints: AND ( - (callsHistory.status IS c.status AND callsHistory.status IS ${MISSED}) OR - (callsHistory.status IS NOT ${MISSED} AND c.status IS NOT ${MISSED}) + (callsHistory.status IS c.status AND callsHistory.status IS ${CALL_STATUS_MISSED}) OR + (callsHistory.status IS NOT ${CALL_STATUS_MISSED} AND c.status IS NOT ${CALL_STATUS_MISSED}) ) -- Desktop Constraints: AND callsHistory.status IS c.status diff --git a/ts/state/ducks/callHistory.ts b/ts/state/ducks/callHistory.ts index cf904a737ae7..c573ed212124 100644 --- a/ts/state/ducks/callHistory.ts +++ b/ts/state/ducks/callHistory.ts @@ -17,11 +17,13 @@ import * as Errors from '../../types/errors'; export type CallHistoryState = ReadonlyDeep<{ // This informs the app that underlying call history data has changed. edition: number; + unreadCount: number; callHistoryByCallId: Record; }>; const CALL_HISTORY_CACHE = 'callHistory/CACHE'; const CALL_HISTORY_RESET = 'callHistory/RESET'; +const CALL_HISTORY_UPDATE_UNREAD = 'callHistory/UPDATE_UNREAD'; export type CallHistoryCache = ReadonlyDeep<{ type: typeof CALL_HISTORY_CACHE; @@ -32,17 +34,58 @@ export type CallHistoryReset = ReadonlyDeep<{ type: typeof CALL_HISTORY_RESET; }>; +export type CallHistoryUpdateUnread = ReadonlyDeep<{ + type: typeof CALL_HISTORY_UPDATE_UNREAD; + payload: number; +}>; + export type CallHistoryAction = ReadonlyDeep< - CallHistoryCache | CallHistoryReset + CallHistoryCache | CallHistoryReset | CallHistoryUpdateUnread >; export function getEmptyState(): CallHistoryState { return { edition: 0, + unreadCount: 0, callHistoryByCallId: {}, }; } +function updateCallHistoryUnreadCount(): ThunkAction< + void, + RootStateType, + unknown, + CallHistoryUpdateUnread +> { + return async dispatch => { + try { + const unreadCount = await window.Signal.Data.getCallHistoryUnreadCount(); + dispatch({ type: CALL_HISTORY_UPDATE_UNREAD, payload: unreadCount }); + } catch (error) { + log.error( + 'Error updating call history unread count', + Errors.toLogFormat(error) + ); + } + }; +} + +function markCallHistoryRead( + conversationId: string, + callId: string +): ThunkAction { + return async dispatch => { + try { + await window.Signal.Data.markCallHistoryRead(callId); + await window.ConversationController.get(conversationId)?.updateUnread(); + } catch (error) { + log.error('Error marking call history read', Errors.toLogFormat(error)); + } finally { + dispatch(updateCallHistoryUnreadCount()); + } + }; +} + function cacheCallHistory(callHistory: CallHistoryDetails): CallHistoryCache { return { type: CALL_HISTORY_CACHE, @@ -65,6 +108,7 @@ function clearAllCallHistory(): ThunkAction< } finally { // Just force a reset, even if the clear failed. dispatch({ type: CALL_HISTORY_RESET }); + dispatch(updateCallHistoryUnreadCount()); } }; } @@ -72,6 +116,8 @@ function clearAllCallHistory(): ThunkAction< export const actions = { cacheCallHistory, clearAllCallHistory, + updateCallHistoryUnreadCount, + markCallHistoryRead, }; export const useCallHistoryActions = (): BoundActionCreatorsMapObject< @@ -93,6 +139,11 @@ export function reducer( [action.payload.callId]: action.payload, }, }; + case CALL_HISTORY_UPDATE_UNREAD: + return { + ...state, + unreadCount: action.payload, + }; default: return state; } diff --git a/ts/state/selectors/callHistory.ts b/ts/state/selectors/callHistory.ts index 02b95ca50390..fdff49c8b2bf 100644 --- a/ts/state/selectors/callHistory.ts +++ b/ts/state/selectors/callHistory.ts @@ -29,3 +29,10 @@ export const getCallHistorySelector = createSelector( }; } ); + +export const getCallHistoryUnreadCount = createSelector( + getCallHistory, + callHistory => { + return callHistory.unreadCount; + } +); diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 71c7721afba6..67a6e5061c3d 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -66,6 +66,10 @@ import type { HasStories } from '../../types/Stories'; import { getHasStoriesSelector } from './stories2'; import { canEditMessage } from '../../util/canEditMessage'; import { isOutgoing } from '../../messages/helpers'; +import { + countAllConversationsUnreadStats, + type UnreadStats, +} from '../../util/countUnreadStats'; export type ConversationWithStoriesType = ConversationType & { hasStories?: HasStories; @@ -532,37 +536,12 @@ export const getAllGroupsWithInviteAccess = createSelector( }) ); -export type UnreadStats = Readonly<{ - unreadCount: number; - unreadMentionsCount: number; - markedUnread: boolean; -}>; - export const getAllConversationsUnreadStats = createSelector( - getLeftPaneLists, - (leftPaneLists: LeftPaneLists): UnreadStats => { - let unreadCount = 0; - let unreadMentionsCount = 0; - let markedUnread = false; - - function count(conversations: ReadonlyArray) { - conversations.forEach(conversation => { - if (conversation.unreadCount != null) { - unreadCount += conversation.unreadCount; - } - if (conversation.unreadMentionsCount != null) { - unreadMentionsCount += conversation.unreadMentionsCount; - } - if (conversation.markedUnread) { - markedUnread = true; - } - }); - } - - count(leftPaneLists.pinnedConversations); - count(leftPaneLists.conversations); - - return { unreadCount, unreadMentionsCount, markedUnread }; + getAllConversations, + (conversations): UnreadStats => { + return countAllConversationsUnreadStats(conversations, { + includeMuted: false, + }); } ); diff --git a/ts/state/selectors/nav.ts b/ts/state/selectors/nav.ts index 6109f67edb8d..8a66bd8fded5 100644 --- a/ts/state/selectors/nav.ts +++ b/ts/state/selectors/nav.ts @@ -4,6 +4,9 @@ import { createSelector } from 'reselect'; import type { StateType } from '../reducer'; import type { NavStateType } from '../ducks/nav'; +import { getAllConversationsUnreadStats } from './conversations'; +import { getStoriesNotificationCount } from './stories'; +import type { UnreadStats } from '../../util/countUnreadStats'; function getNav(state: StateType): NavStateType { return state.nav; @@ -12,3 +15,17 @@ function getNav(state: StateType): NavStateType { export const getSelectedNavTab = createSelector(getNav, nav => { return nav.selectedNavTab; }); + +export const getAppUnreadStats = createSelector( + getAllConversationsUnreadStats, + getStoriesNotificationCount, + (conversationsUnreadStats, storiesNotificationCount): UnreadStats => { + return { + // Note: Conversation unread stats includes the call history unread count. + unreadCount: + conversationsUnreadStats.unreadCount + storiesNotificationCount, + unreadMentionsCount: conversationsUnreadStats.unreadMentionsCount, + markedUnread: conversationsUnreadStats.markedUnread, + }; + } +); diff --git a/ts/state/selectors/updates.ts b/ts/state/selectors/updates.ts index b6aa506b6b7e..ab481490e74c 100644 --- a/ts/state/selectors/updates.ts +++ b/ts/state/selectors/updates.ts @@ -42,3 +42,8 @@ export const isOSUnsupported = createSelector( getUpdatesState, ({ dialogType }) => dialogType === DialogType.UnsupportedOS ); + +export const getHasPendingUpdate = createSelector( + getUpdatesState, + ({ didSnooze }) => didSnooze === true +); diff --git a/ts/state/smart/CallsTab.tsx b/ts/state/smart/CallsTab.tsx index 73a571e92076..8995367087f2 100644 --- a/ts/state/smart/CallsTab.tsx +++ b/ts/state/smart/CallsTab.tsx @@ -27,6 +27,9 @@ import { useCallingActions } from '../ducks/calling'; import { getActiveCallState } from '../selectors/calling'; import { useCallHistoryActions } from '../ducks/callHistory'; import { getCallHistoryEdition } from '../selectors/callHistory'; +import { getHasPendingUpdate } from '../selectors/updates'; +import { getHasAnyFailedStorySends } from '../selectors/stories'; +import { getAppUnreadStats } from '../selectors/nav'; function getCallHistoryFilter( allConversations: Array, @@ -91,11 +94,16 @@ export function SmartCallsTab(): JSX.Element { const activeCall = useSelector(getActiveCallState); const callHistoryEdition = useSelector(getCallHistoryEdition); + const hasPendingUpdate = useSelector(getHasPendingUpdate); + const hasFailedStorySends = useSelector(getHasAnyFailedStorySends); + const appUnreadStats = useSelector(getAppUnreadStats); + const { onOutgoingAudioCallInConversation, onOutgoingVideoCallInConversation, } = useCallingActions(); - const { clearAllCallHistory: clearCallHistory } = useCallHistoryActions(); + const { clearAllCallHistory: clearCallHistory, markCallHistoryRead } = + useCallHistoryActions(); const getCallHistoryGroupsCount = useCallback( async (options: CallHistoryFilterOptions) => { @@ -145,12 +153,16 @@ export function SmartCallsTab(): JSX.Element { ; @@ -36,6 +39,10 @@ function renderMiniPlayer(options: { shouldFlow: boolean }) { export function SmartChatsTab(): JSX.Element { const i18n = useSelector(getIntl); const navTabsCollapsed = useSelector(getNavTabsCollapsed); + const hasFailedStorySends = useSelector(getHasAnyFailedStorySends); + const hasPendingUpdate = useSelector(getHasPendingUpdate); + const appUnreadStats = useSelector(getAppUnreadStats); + const { selectedConversationId, targetedMessage, targetedMessageSource } = useSelector( state => state.conversations @@ -137,7 +144,10 @@ export function SmartChatsTab(): JSX.Element { return ( { - return state.updates.didSnooze; - }); + const hasPendingUpdate = useSelector(getHasPendingUpdate); const { toggleProfileEditor } = useGlobalModalActions(); const { startUpdate } = useUpdatesActions(); @@ -87,6 +86,7 @@ export function SmartNavTabs({ selectedNavTab={selectedNavTab} storiesEnabled={storiesEnabled} theme={theme} + unreadCallsCount={unreadCallsCount} unreadConversationsStats={unreadConversationsStats} unreadStoriesCount={unreadStoriesCount} /> diff --git a/ts/state/smart/ProfileEditorModal.tsx b/ts/state/smart/ProfileEditorModal.tsx index ced8485db794..1516af07423b 100644 --- a/ts/state/smart/ProfileEditorModal.tsx +++ b/ts/state/smart/ProfileEditorModal.tsx @@ -45,6 +45,7 @@ function mapStateToProps( firstName, familyName, id: conversationId, + phoneNumber, username, } = getMe(state); const recentEmojis = selectRecentEmojis(state); @@ -74,6 +75,7 @@ function mapStateToProps( isUsernameFlagEnabled, recentEmojis, skinTone, + phoneNumber, userAvatarData, username, usernameEditState, diff --git a/ts/state/smart/StoriesTab.tsx b/ts/state/smart/StoriesTab.tsx index b2ce54a08b6f..43359d3764ce 100644 --- a/ts/state/smart/StoriesTab.tsx +++ b/ts/state/smart/StoriesTab.tsx @@ -21,6 +21,7 @@ import { } from '../selectors/items'; import { getAddStoryData, + getHasAnyFailedStorySends, getSelectedStoryData, getStories, } from '../selectors/stories'; @@ -30,6 +31,8 @@ import { useStoriesActions } from '../ducks/stories'; import { useToastActions } from '../ducks/toast'; import { useAudioPlayerActions } from '../ducks/audioPlayer'; import { useItemsActions } from '../ducks/items'; +import { getHasPendingUpdate } from '../selectors/updates'; +import { getAppUnreadStats } from '../selectors/nav'; function renderStoryCreator(): JSX.Element { return ; @@ -66,6 +69,9 @@ export function SmartStoriesTab(): JSX.Element | null { ); const hasViewReceiptSetting = useSelector(getHasStoryViewReceiptSetting); + const hasPendingUpdate = useSelector(getHasPendingUpdate); + const hasFailedStorySends = useSelector(getHasAnyFailedStorySends); + const appUnreadStats = useSelector(getAppUnreadStats); const remoteConfig = useSelector(getRemoteConfig); const maxAttachmentSizeInKb = getMaximumAttachmentSizeInKb( @@ -92,8 +98,11 @@ export function SmartStoriesTab(): JSX.Element | null { return ( { + const mutedTimestamp = (): number => Date.now() + 12345; + const oldMutedTimestamp = (): number => Date.now() - 1000; + + it('returns 0 if the conversation is archived', () => { + const archivedConversations = [ + { + activeAt: Date.now(), + isArchived: true, + markedUnread: false, + unreadCount: 0, + }, + { + activeAt: Date.now(), + isArchived: true, + markedUnread: false, + unreadCount: 123, + }, + { + activeAt: Date.now(), + isArchived: true, + markedUnread: true, + unreadCount: 0, + }, + { activeAt: Date.now(), isArchived: true, markedUnread: true }, + ]; + for (const conversation of archivedConversations) { + assert.deepStrictEqual( + countConversationUnreadStats(conversation, { includeMuted: true }), + { + unreadCount: 0, + unreadMentionsCount: 0, + markedUnread: false, + } + ); + assert.deepStrictEqual( + countConversationUnreadStats(conversation, { includeMuted: false }), + { + unreadCount: 0, + unreadMentionsCount: 0, + markedUnread: false, + } + ); + } + }); + + it("returns 0 if the conversation is muted and the user doesn't want to include those in the result", () => { + const mutedConversations = [ + { + activeAt: Date.now(), + muteExpiresAt: mutedTimestamp(), + markedUnread: false, + unreadCount: 0, + }, + { + activeAt: Date.now(), + muteExpiresAt: mutedTimestamp(), + markedUnread: false, + unreadCount: 9, + }, + { + activeAt: Date.now(), + muteExpiresAt: mutedTimestamp(), + markedUnread: true, + unreadCount: 0, + }, + { + activeAt: Date.now(), + muteExpiresAt: mutedTimestamp(), + markedUnread: true, + }, + ]; + for (const conversation of mutedConversations) { + assert.deepStrictEqual( + countConversationUnreadStats(conversation, { includeMuted: false }), + { + unreadCount: 0, + unreadMentionsCount: 0, + markedUnread: false, + } + ); + } + }); + + it('returns the unread count if nonzero (and not archived)', () => { + const conversationsWithUnreadCount = [ + { activeAt: Date.now(), unreadCount: 9, markedUnread: false }, + { activeAt: Date.now(), unreadCount: 9, markedUnread: true }, + { + activeAt: Date.now(), + unreadCount: 9, + markedUnread: false, + muteExpiresAt: oldMutedTimestamp(), + }, + { + activeAt: Date.now(), + unreadCount: 9, + markedUnread: false, + isArchived: false, + }, + ]; + for (const conversation of conversationsWithUnreadCount) { + assert.deepStrictEqual( + countConversationUnreadStats(conversation, { includeMuted: false }), + { + unreadCount: 9, + unreadMentionsCount: 0, + markedUnread: conversation.markedUnread, + } + ); + assert.deepStrictEqual( + countConversationUnreadStats(conversation, { includeMuted: true }), + { + unreadCount: 9, + unreadMentionsCount: 0, + markedUnread: conversation.markedUnread, + } + ); + } + + const mutedWithUnreads = { + activeAt: Date.now(), + unreadCount: 123, + markedUnread: false, + muteExpiresAt: mutedTimestamp(), + }; + assert.deepStrictEqual( + countConversationUnreadStats(mutedWithUnreads, { includeMuted: true }), + { + unreadCount: 123, + unreadMentionsCount: 0, + markedUnread: false, + } + ); + }); + + it('returns markedUnread:true if the conversation is marked unread', () => { + const conversationsMarkedUnread = [ + { activeAt: Date.now(), markedUnread: true }, + { activeAt: Date.now(), markedUnread: true, unreadCount: 0 }, + { + activeAt: Date.now(), + markedUnread: true, + muteExpiresAt: oldMutedTimestamp(), + }, + { + activeAt: Date.now(), + markedUnread: true, + muteExpiresAt: oldMutedTimestamp(), + isArchived: false, + }, + ]; + for (const conversation of conversationsMarkedUnread) { + assert.deepStrictEqual( + countConversationUnreadStats(conversation, { includeMuted: false }), + { + unreadCount: 0, + unreadMentionsCount: 0, + markedUnread: true, + } + ); + assert.deepStrictEqual( + countConversationUnreadStats(conversation, { includeMuted: true }), + { + unreadCount: 0, + unreadMentionsCount: 0, + markedUnread: true, + } + ); + } + + const mutedConversationsMarkedUnread = [ + { + activeAt: Date.now(), + markedUnread: true, + muteExpiresAt: mutedTimestamp(), + }, + { + activeAt: Date.now(), + markedUnread: true, + muteExpiresAt: mutedTimestamp(), + unreadCount: 0, + }, + ]; + for (const conversation of mutedConversationsMarkedUnread) { + assert.deepStrictEqual( + countConversationUnreadStats(conversation, { includeMuted: true }), + { + unreadCount: 0, + unreadMentionsCount: 0, + markedUnread: true, + } + ); + } + }); + + it('returns 0 if the conversation is read', () => { + const readConversations = [ + { activeAt: Date.now(), markedUnread: false }, + { activeAt: Date.now(), markedUnread: false, unreadCount: 0 }, + { + activeAt: Date.now(), + markedUnread: false, + mutedTimestamp: mutedTimestamp(), + }, + { + activeAt: Date.now(), + markedUnread: false, + mutedTimestamp: oldMutedTimestamp(), + }, + ]; + for (const conversation of readConversations) { + assert.deepStrictEqual( + countConversationUnreadStats(conversation, { includeMuted: false }), + { + unreadCount: 0, + unreadMentionsCount: 0, + markedUnread: false, + } + ); + assert.deepStrictEqual( + countConversationUnreadStats(conversation, { includeMuted: true }), + { + unreadCount: 0, + unreadMentionsCount: 0, + markedUnread: false, + } + ); + } + }); + + it('returns 0 if the conversation has falsey activeAt', () => { + const readConversations = [ + { activeAt: undefined, markedUnread: false, unreadCount: 2 }, + { + activeAt: 0, + unreadCount: 2, + markedUnread: false, + mutedTimestamp: oldMutedTimestamp(), + }, + ]; + for (const conversation of readConversations) { + assert.deepStrictEqual( + countConversationUnreadStats(conversation, { includeMuted: false }), + { + unreadCount: 0, + unreadMentionsCount: 0, + markedUnread: false, + } + ); + assert.deepStrictEqual( + countConversationUnreadStats(conversation, { includeMuted: true }), + { + unreadCount: 0, + unreadMentionsCount: 0, + markedUnread: false, + } + ); + } + }); +}); diff --git a/ts/test-both/util/getConversationUnreadCountForAppBadge_test.ts b/ts/test-both/util/getConversationUnreadCountForAppBadge_test.ts deleted file mode 100644 index 7bbbfd3c5449..000000000000 --- a/ts/test-both/util/getConversationUnreadCountForAppBadge_test.ts +++ /dev/null @@ -1,180 +0,0 @@ -// Copyright 2022 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import { assert } from 'chai'; - -import { getConversationUnreadCountForAppBadge } from '../../util/getConversationUnreadCountForAppBadge'; - -describe('getConversationUnreadCountForAppBadge', () => { - const getCount = getConversationUnreadCountForAppBadge; - - const mutedTimestamp = (): number => Date.now() + 12345; - const oldMutedTimestamp = (): number => Date.now() - 1000; - - it('returns 0 if the conversation is archived', () => { - const archivedConversations = [ - { - active_at: Date.now(), - isArchived: true, - markedUnread: false, - unreadCount: 0, - }, - { - active_at: Date.now(), - isArchived: true, - markedUnread: false, - unreadCount: 123, - }, - { - active_at: Date.now(), - isArchived: true, - markedUnread: true, - unreadCount: 0, - }, - { active_at: Date.now(), isArchived: true, markedUnread: true }, - ]; - for (const conversation of archivedConversations) { - assert.strictEqual(getCount(conversation, true), 0); - assert.strictEqual(getCount(conversation, false), 0); - } - }); - - it("returns 0 if the conversation is muted and the user doesn't want to include those in the result", () => { - const mutedConversations = [ - { - active_at: Date.now(), - muteExpiresAt: mutedTimestamp(), - markedUnread: false, - unreadCount: 0, - }, - { - active_at: Date.now(), - muteExpiresAt: mutedTimestamp(), - markedUnread: false, - unreadCount: 9, - }, - { - active_at: Date.now(), - muteExpiresAt: mutedTimestamp(), - markedUnread: true, - unreadCount: 0, - }, - { - active_at: Date.now(), - muteExpiresAt: mutedTimestamp(), - markedUnread: true, - }, - ]; - for (const conversation of mutedConversations) { - assert.strictEqual(getCount(conversation, false), 0); - } - }); - - it('returns the unread count if nonzero (and not archived)', () => { - const conversationsWithUnreadCount = [ - { active_at: Date.now(), unreadCount: 9, markedUnread: false }, - { active_at: Date.now(), unreadCount: 9, markedUnread: true }, - { - active_at: Date.now(), - unreadCount: 9, - markedUnread: false, - muteExpiresAt: oldMutedTimestamp(), - }, - { - active_at: Date.now(), - unreadCount: 9, - markedUnread: false, - isArchived: false, - }, - ]; - for (const conversation of conversationsWithUnreadCount) { - assert.strictEqual(getCount(conversation, false), 9); - assert.strictEqual(getCount(conversation, true), 9); - } - - const mutedWithUnreads = { - active_at: Date.now(), - unreadCount: 123, - markedUnread: false, - muteExpiresAt: mutedTimestamp(), - }; - assert.strictEqual(getCount(mutedWithUnreads, true), 123); - }); - - it('returns 1 if the conversation is marked unread', () => { - const conversationsMarkedUnread = [ - { active_at: Date.now(), markedUnread: true }, - { active_at: Date.now(), markedUnread: true, unreadCount: 0 }, - { - active_at: Date.now(), - markedUnread: true, - muteExpiresAt: oldMutedTimestamp(), - }, - { - active_at: Date.now(), - markedUnread: true, - muteExpiresAt: oldMutedTimestamp(), - isArchived: false, - }, - ]; - for (const conversation of conversationsMarkedUnread) { - assert.strictEqual(getCount(conversation, false), 1); - assert.strictEqual(getCount(conversation, true), 1); - } - - const mutedConversationsMarkedUnread = [ - { - active_at: Date.now(), - markedUnread: true, - muteExpiresAt: mutedTimestamp(), - }, - { - active_at: Date.now(), - markedUnread: true, - muteExpiresAt: mutedTimestamp(), - unreadCount: 0, - }, - ]; - for (const conversation of mutedConversationsMarkedUnread) { - assert.strictEqual(getCount(conversation, true), 1); - } - }); - - it('returns 0 if the conversation is read', () => { - const readConversations = [ - { active_at: Date.now(), markedUnread: false }, - { active_at: Date.now(), markedUnread: false, unreadCount: 0 }, - { - active_at: Date.now(), - markedUnread: false, - mutedTimestamp: mutedTimestamp(), - }, - { - active_at: Date.now(), - markedUnread: false, - mutedTimestamp: oldMutedTimestamp(), - }, - ]; - for (const conversation of readConversations) { - assert.strictEqual(getCount(conversation, false), 0); - assert.strictEqual(getCount(conversation, true), 0); - } - }); - - it('returns 0 if the conversation has falsey active_at', () => { - const readConversations = [ - { active_at: undefined, markedUnread: false, unreadCount: 2 }, - { active_at: null, markedUnread: true, unreadCount: 0 }, - { - active_at: 0, - unreadCount: 2, - markedUnread: false, - mutedTimestamp: oldMutedTimestamp(), - }, - ]; - for (const conversation of readConversations) { - assert.strictEqual(getCount(conversation, false), 0); - assert.strictEqual(getCount(conversation, true), 0); - } - }); -}); diff --git a/ts/test-mock/pnp/username_test.ts b/ts/test-mock/pnp/username_test.ts index e08ded4bcf95..785ff5fb5abb 100644 --- a/ts/test-mock/pnp/username_test.ts +++ b/ts/test-mock/pnp/username_test.ts @@ -158,11 +158,6 @@ describe('pnp/username', function needsName() { debug('opening avatar context menu'); await window.getByRole('button', { name: 'Profile' }).click(); - debug('opening profile editor'); - await window - .locator('.module-avatar-popup .module-avatar-popup__profile') - .click(); - debug('opening username editor'); const profileEditor = window.locator('.ProfileEditor'); await profileEditor.locator('.ProfileEditor__row >> "Username"').click(); diff --git a/ts/util/countUnreadStats.ts b/ts/util/countUnreadStats.ts new file mode 100644 index 000000000000..8b848fd77807 --- /dev/null +++ b/ts/util/countUnreadStats.ts @@ -0,0 +1,92 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { ConversationType } from '../state/ducks/conversations'; +import { isConversationMuted } from './isConversationMuted'; + +/** + * This can be used to describe unread counts of chats, stories, and calls, + * individually or all of them together. + */ +export type UnreadStats = Readonly<{ + unreadCount: number; + unreadMentionsCount: number; + markedUnread: boolean; +}>; + +function getEmptyUnreadStats(): UnreadStats { + return { + unreadCount: 0, + unreadMentionsCount: 0, + markedUnread: false, + }; +} + +export type UnreadStatsOptions = Readonly<{ + includeMuted: boolean; +}>; + +type ConversationPropsForUnreadStats = Readonly< + Pick< + ConversationType, + | 'activeAt' + | 'isArchived' + | 'markedUnread' + | 'muteExpiresAt' + | 'unreadCount' + | 'unreadMentionsCount' + > +>; + +function canCountConversation( + conversation: ConversationPropsForUnreadStats, + options: UnreadStatsOptions +): boolean { + if (conversation.activeAt == null || conversation.activeAt === 0) { + return false; + } + if (conversation.isArchived) { + return false; + } + if (!options.includeMuted && isConversationMuted(conversation)) { + return false; + } + return true; +} + +export function countConversationUnreadStats( + conversation: ConversationPropsForUnreadStats, + options: UnreadStatsOptions +): UnreadStats { + if (canCountConversation(conversation, options)) { + return { + unreadCount: conversation.unreadCount ?? 0, + unreadMentionsCount: conversation.unreadMentionsCount ?? 0, + markedUnread: conversation.markedUnread ?? false, + }; + } + return getEmptyUnreadStats(); +} + +export function countAllConversationsUnreadStats( + conversations: ReadonlyArray, + options: UnreadStatsOptions +): UnreadStats { + return conversations.reduce((total, conversation) => { + const stats = countConversationUnreadStats(conversation, options); + return { + unreadCount: total.unreadCount + stats.unreadCount, + unreadMentionsCount: + total.unreadMentionsCount + stats.unreadMentionsCount, + markedUnread: total.markedUnread || stats.markedUnread, + }; + }, getEmptyUnreadStats()); +} + +export function hasUnread(unreadStats: UnreadStats): boolean { + return ( + unreadStats.unreadCount > 0 || + unreadStats.unreadMentionsCount > 0 || + unreadStats.markedUnread + ); +} diff --git a/ts/util/filterAndSortConversations.ts b/ts/util/filterAndSortConversations.ts index ed50f08c56c1..df44c7eee960 100644 --- a/ts/util/filterAndSortConversations.ts +++ b/ts/util/filterAndSortConversations.ts @@ -7,7 +7,7 @@ import type { ConversationType } from '../state/ducks/conversations'; import { parseAndFormatPhoneNumber } from './libphonenumberInstance'; import { WEEK } from './durations'; import { fuseGetFnRemoveDiacritics, getCachedFuseIndex } from './fuse'; -import { getConversationUnreadCountForAppBadge } from './getConversationUnreadCountForAppBadge'; +import { countConversationUnreadStats, hasUnread } from './countUnreadStats'; // Fuse.js scores have order of 0.01 const ACTIVE_AT_SCORE_FACTOR = (1 / WEEK) * 0.01; @@ -73,18 +73,11 @@ COMMANDS.set('groupIdEndsWith', (conversations, query) => { }); COMMANDS.set('unread', conversations => { - const canCountMutedConversations = + const includeMuted = window.storage.get('badge-count-muted-conversations') || false; - - return conversations.filter(convo => { - return getConversationUnreadCountForAppBadge( - { - ...convo, - - // Difference between redux type and conversation attributes - active_at: convo.activeAt, - }, - canCountMutedConversations + return conversations.filter(conversation => { + return hasUnread( + countConversationUnreadStats(conversation, { includeMuted }) ); }); }); diff --git a/ts/util/getConversationUnreadCountForAppBadge.ts b/ts/util/getConversationUnreadCountForAppBadge.ts deleted file mode 100644 index 2c911f0c2251..000000000000 --- a/ts/util/getConversationUnreadCountForAppBadge.ts +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright 2022 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import type { ConversationAttributesType } from '../model-types.d'; -import { isConversationMuted } from './isConversationMuted'; - -export function getConversationUnreadCountForAppBadge( - conversation: Readonly< - Pick< - ConversationAttributesType, - | 'active_at' - | 'isArchived' - | 'markedUnread' - | 'muteExpiresAt' - | 'unreadCount' - > - >, - canCountMutedConversations: boolean -): number { - const { isArchived, markedUnread, unreadCount } = conversation; - - if (!conversation.active_at) { - return 0; - } - - if (isArchived) { - return 0; - } - - if (!canCountMutedConversations && isConversationMuted(conversation)) { - return 0; - } - - if (unreadCount) { - return unreadCount; - } - - if (markedUnread) { - return 1; - } - - return 0; -} diff --git a/ts/window.d.ts b/ts/window.d.ts index 68d92bf67944..1134a73425f0 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -78,7 +78,7 @@ export type IPCType = { restart: () => void; setAutoHideMenuBar: (value: boolean) => void; setAutoLaunch: (value: boolean) => Promise; - setBadgeCount: (count: number) => void; + setBadge: (badge: number | 'marked-unread') => void; setMenuBarVisibility: (value: boolean) => void; showDebugLog: () => void; showPermissionsPopup: ( diff --git a/ts/windows/main/phase1-ipc.ts b/ts/windows/main/phase1-ipc.ts index 06b17ccf0ab8..5abad790c8ab 100644 --- a/ts/windows/main/phase1-ipc.ts +++ b/ts/windows/main/phase1-ipc.ts @@ -103,7 +103,7 @@ const IPC: IPCType = { }, setAutoHideMenuBar: autoHide => ipc.send('set-auto-hide-menu-bar', autoHide), setAutoLaunch: value => ipc.invoke('set-auto-launch', value), - setBadgeCount: count => ipc.send('set-badge-count', count), + setBadge: badge => ipc.send('set-badge', badge), setMenuBarVisibility: visibility => ipc.send('set-menu-bar-visibility', visibility), showDebugLog: () => {