Update nav tab badges, fix several call tabs issues

This commit is contained in:
Jamie Kyle 2023-08-14 16:28:47 -07:00 committed by Jamie Kyle
parent ed6ffb695a
commit 9c7dc22a23
43 changed files with 1095 additions and 936 deletions

View file

@ -307,6 +307,10 @@
"messageformat": "Hide Tabs", "messageformat": "Hide Tabs",
"description": "Show in the nav tabs when the nav tabs are visible, hides the nav 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": { "icu:NavTabs__ItemIconLabel--UnreadCount": {
"messageformat": "{count, number} unread", "messageformat": "{count, number} unread",
"description": "Nav Tabs > Unread badge > Accessibility Text" "description": "Nav Tabs > Unread badge > Accessibility Text"
@ -317,7 +321,11 @@
}, },
"icu:NavTabs__ItemLabel--Settings": { "icu:NavTabs__ItemLabel--Settings": {
"messageformat": "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": { "icu:NavTabs__ItemLabel--Profile": {
"messageformat": "Profile", "messageformat": "Profile",

View file

@ -2132,9 +2132,22 @@ app.on('will-finish-launching', () => {
}); });
}); });
ipc.on('set-badge-count', (_event: Electron.Event, count: number) => { ipc.on(
app.badgeCount = count; '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', () => { ipc.on('remove-setup-menu-items', () => {
setupMenu(); setupMenu();

View file

@ -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 */ /* Calling: Device Selection */
.module-calling-device-selection { .module-calling-device-selection {

View file

@ -46,6 +46,30 @@
flex-direction: column; 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 { .CallsTab__ConversationCallDetails {
display: block; display: block;
overflow: auto; overflow: auto;
@ -208,10 +232,6 @@
font-weight: bold; font-weight: bold;
} }
.CallsList__ItemCallInfo {
@include font-body-1;
}
// Override .ListTile__subtitle so ellipsis is correct color // Override .ListTile__subtitle so ellipsis is correct color
.CallsList__Item--missed .ListTile__subtitle { .CallsList__Item--missed .ListTile__subtitle {
color: $color-accent-red; color: $color-accent-red;
@ -219,7 +239,12 @@
// Override .ListTile // Override .ListTile
.ListTile.CallsList__ItemTile { .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 { .CallsList__Item--selected .CallsList__ItemTile {

View file

@ -5,9 +5,14 @@
&__distribution { &__distribution {
&__title { &__title {
@include font-body-1-bold; @include font-body-1-bold;
color: $color-gray-05;
margin-block: 24px 8px; margin-block: 24px 8px;
margin-inline: 10px; 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; padding-inline-end: 10px;
&:hover { &:hover {
background: $color-gray-65; @include light-theme {
background: $color-gray-15;
}
@include dark-theme {
background: $color-gray-65;
}
& .MyStories__story__download, & .MyStories__story__download,
.MyStories__story__more__button { .MyStories__story__more__button {
background: $color-gray-60; @include light-theme() {
background: $color-gray-20;
}
@include dark-theme() {
background: $color-gray-60;
}
} }
} }
&__details { &__details {
@include font-body-1-bold; @include font-body-1-bold;
color: $color-gray-05;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1; flex: 1;
margin-inline-start: 12px; margin-inline-start: 12px;
@include light-theme() {
color: $color-gray-90;
}
@include dark-theme() {
color: $color-gray-05;
}
&__failed { &__failed {
align-items: center; align-items: center;
@ -58,80 +78,142 @@
&__button { &__button {
@include button-reset; @include button-reset;
@include font-subtitle; @include font-subtitle;
color: $color-gray-25; @include light-theme {
color: $color-gray-90;
}
@include dark-theme {
color: $color-gray-25;
}
} }
} }
} }
&__timestamp { &__timestamp {
color: $color-gray-25;
font-weight: normal; font-weight: normal;
@include light-theme {
color: $color-gray-90;
}
@include dark-theme {
color: $color-gray-25;
}
} }
&__download { &__download {
@include button-reset; @include button-reset;
align-items: center; align-items: center;
background: $color-gray-65;
border-radius: 100%; border-radius: 100%;
display: flex; display: flex;
height: 28px; height: 28px;
justify-content: center; justify-content: center;
width: 28px; width: 28px;
@include light-theme {
background: $color-gray-20;
}
@include dark-theme {
background: $color-gray-65;
}
&::after { &::after {
@include color-svg(
'../images/icons/v3/save/save-compact.svg',
$color-gray-15
);
content: ''; content: '';
height: 18px; height: 18px;
width: 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 { &:hover {
background: $color-gray-75 !important; @include light-theme() {
background: $color-white !important;
}
@include dark-theme() {
background: $color-gray-75 !important;
}
} }
} }
&__more__button { &__more__button {
align-items: center; align-items: center;
background: $color-gray-65;
border-radius: 100%; border-radius: 100%;
display: flex; display: flex;
height: 28px; height: 28px;
justify-content: center; justify-content: center;
margin-inline-start: 16px; margin-inline-start: 16px;
width: 28px; width: 28px;
@include light-theme {
background: $color-gray-15;
}
@include dark-theme {
background: $color-gray-65;
}
&::after { &::after {
@include color-svg(
'../images/icons/v3/more/more-compact.svg',
$color-gray-15
);
content: ''; content: '';
height: 18px; height: 18px;
width: 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 { &:hover {
background: $color-gray-75 !important; @include light-theme() {
background: $color-white !important;
}
@include dark-theme() {
background: $color-gray-75 !important;
}
} }
} }
} }
&__icon { &__icon {
&--forward { &--forward {
@include color-svg( @include light-theme() {
'../images/icons/v3/forward/forward-compact.svg', @include color-svg(
$color-white '../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 { &--delete {
@include color-svg( @include light-theme() {
'../images/icons/v3/trash/trash-compact.svg', @include color-svg(
$color-white '../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 { @include light-theme {
border-color: $color-gray-04; border-color: $color-gray-04;
} }
@include dark-theme { @include dark-theme {
border-color: $color-gray-80; border-color: $color-gray-80;
} }
&::after { &::after {
content: ''; content: '';
@include color-svg(
'../images/icons/v3/plus/plus-compact-bold.svg',
$color-white
);
height: 12px; height: 12px;
width: 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
);
}
} }
} }
} }

View file

@ -25,6 +25,7 @@
align-items: start; align-items: start;
flex-shrink: 0; flex-shrink: 0;
padding-bottom: 6px; padding-bottom: 6px;
-webkit-app-region: drag;
.NavTabs__Toggle { .NavTabs__Toggle {
width: $NavTabs__width; width: $NavTabs__width;

View file

@ -110,7 +110,7 @@ $NavTabs__ProfileAvatar__size: 28px;
@include sr-only; @include sr-only;
} }
.NavTabs__ItemBadge { .NavTabs__ItemUnreadBadge {
@include rounded-corners; @include rounded-corners;
align-items: center; align-items: center;
background-color: $color-accent-red; background-color: $color-accent-red;
@ -131,6 +131,17 @@ $NavTabs__ProfileAvatar__size: 28px;
word-break: keep-all; 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 { .NavTabs__ItemIcon {
display: block; display: block;
width: $NavTabs__ItemIcon__size; width: $NavTabs__ItemIcon__size;
@ -205,13 +216,26 @@ $NavTabs__ProfileAvatar__size: 28px;
min-width: 0; min-width: 0;
} }
.NavTabs__AvatarBadge { .NavTabs__ContextMenuIcon--Settings {
background: $color-ultramarine; @include dark-theme {
border-radius: 100%; @include color-svg(
border: 1px solid $color-white; '../images/icons/v3/settings/settings.svg',
height: 8px; $color-white
width: 8px; );
position: absolute; }
top: 0; @include light-theme {
inset-inline-end: 0; @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);
}
} }

View file

@ -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;
}
}

View file

@ -104,21 +104,28 @@
&__placeholder { &__placeholder {
align-items: center; align-items: center;
color: $color-gray-45;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1; flex: 1;
justify-content: center; justify-content: center;
opacity: 0.7;
@include light-theme {
color: $color-gray-60;
}
@include dark-theme {
color: $color-gray-25;
}
&__stories { &__stories {
height: 56px; height: 56px;
margin-bottom: 22px; margin-bottom: 22px;
width: 56px; width: 56px;
@include light-theme {
@include color-svg( @include color-svg('../images/icons/v3/stories/stories-display.svg', $color-gray-60);
'../images/icons/v3/stories/stories-display.svg', }
$color-gray-45 @include dark-theme {
); @include color-svg('../images/icons/v3/stories/stories-display.svg', $color-gray-25);
}
} }
} }

View file

@ -22,7 +22,6 @@ import { maybeDeriveGroupV2Id } from './groups';
import { assertDev, strictAssert } from './util/assert'; import { assertDev, strictAssert } from './util/assert';
import { drop } from './util/drop'; import { drop } from './util/drop';
import { isGroupV1, isGroupV2 } from './util/whatTypeOfConversation'; import { isGroupV1, isGroupV2 } from './util/whatTypeOfConversation';
import { getConversationUnreadCountForAppBadge } from './util/getConversationUnreadCountForAppBadge';
import type { ServiceIdString } from './types/ServiceId'; import type { ServiceIdString } from './types/ServiceId';
import { import {
isServiceIdString, isServiceIdString,
@ -36,6 +35,7 @@ import { getServiceIdsForE164s } from './util/getServiceIdsForE164s';
import { SIGNAL_ACI, SIGNAL_AVATAR_PATH } from './types/SignalConversation'; import { SIGNAL_ACI, SIGNAL_AVATAR_PATH } from './types/SignalConversation';
import { getTitleNoDefault } from './util/getTitle'; import { getTitleNoDefault } from './util/getTitle';
import * as StorageService from './services/storage'; import * as StorageService from './services/storage';
import { countAllConversationsUnreadStats } from './util/countUnreadStats';
type ConvoMatchType = type ConvoMatchType =
| { | {
@ -185,28 +185,31 @@ export class ConversationController {
return; return;
} }
const canCountMutedConversations = const includeMuted =
window.storage.get('badge-count-muted-conversations') || false; window.storage.get('badge-count-muted-conversations') || false;
const newUnreadCount = this._conversations.reduce( const unreadStats = countAllConversationsUnreadStats(
(result: number, conversation: ConversationModel) => this._conversations.map(conversation => conversation.format()),
result + { includeMuted }
getConversationUnreadCountForAppBadge(
conversation.attributes,
canCountMutedConversations
),
0
); );
drop(window.storage.put('unreadCount', newUnreadCount));
if (newUnreadCount > 0) { drop(window.storage.put('unreadCount', unreadStats.unreadCount));
window.IPC.setBadgeCount(newUnreadCount);
window.document.title = `${window.getTitle()} (${newUnreadCount})`; 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 { } else {
window.IPC.setBadgeCount(0); window.IPC.setBadge(0);
window.IPC.updateTrayIcon(0);
window.document.title = window.getTitle(); window.document.title = window.getTitle();
} }
window.IPC.updateTrayIcon(newUnreadCount);
} }
onEmpty(): void { onEmpty(): void {

View file

@ -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<string, AvatarColorType> = AvatarColors.reduce(
(m, color) => ({
...m,
[color]: color,
}),
{}
);
const conversationTypeMap: Record<string, Props['conversationType']> = {
direct: 'direct',
group: 'group',
};
const useProps = (overrideProps: Partial<Props> = {}): 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 <AvatarPopup {...props} />;
}
export function HasBadge(): JSX.Element {
const props = useProps({
badge: getFakeBadge(),
title: 'Janet Yellen',
});
return <AvatarPopup {...props} />;
}
HasBadge.story = {
name: 'Has badge',
};
export function Title(): JSX.Element {
const props = useProps({
title: 'My Great Title',
});
return <AvatarPopup {...props} />;
}
export function ProfileName(): JSX.Element {
const props = useProps({
profileName: 'Sam Neill',
});
return <AvatarPopup {...props} />;
}
export function PhoneNumber(): JSX.Element {
const props = useProps({
profileName: 'Sam Neill',
phoneNumber: '(555) 867-5309',
});
return <AvatarPopup {...props} />;
}
export function UpdateAvailable(): JSX.Element {
const props = useProps({
hasPendingUpdate: true,
});
return <AvatarPopup {...props} />;
}

View file

@ -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<HTMLDivElement>;
style: React.CSSProperties;
name?: string;
} & Omit<AvatarProps, 'onClick' | 'size'>;
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 (
<div style={style} className="module-avatar-popup">
<button
className="module-avatar-popup__profile"
onClick={onEditProfile}
ref={focusRef}
type="button"
>
<Avatar {...props} size={AvatarSize.FORTY_EIGHT} />
<div className="module-avatar-popup__profile__text">
<div className="module-avatar-popup__profile__name">
<UserText text={profileName || title} />
</div>
{shouldShowNumber ? (
<div className="module-avatar-popup__profile__number">
{phoneNumber}
</div>
) : null}
</div>
</button>
{hasPendingUpdate && (
<>
<hr className="module-avatar-popup__divider" />
<button
type="button"
className="module-avatar-popup__item"
onClick={onStartUpdate}
>
<div
className={classNames(
'module-avatar-popup__item__icon',
'module-avatar-popup__item__icon--update'
)}
/>
<div className="module-avatar-popup__item__text">
{i18n('icu:avatarMenuUpdateAvailable')}
</div>
<div className="module-avatar-popup__item--badge" />
</button>
</>
)}
</div>
);
}

View file

@ -122,8 +122,10 @@ type CallsListProps = Readonly<{
) => void; ) => void;
}>; }>;
const CALL_LIST_ITEM_ROW_HEIGHT = 62;
function rowHeight() { function rowHeight() {
return ListTile.heightFull; return CALL_LIST_ITEM_ROW_HEIGHT;
} }
export function CallsList({ export function CallsList({
@ -275,6 +277,7 @@ export function CallsList({
return ( return (
<div key={key} style={style}> <div key={key} style={style}>
<ListTile <ListTile
moduleClassName="CallsList__ItemTile"
leading={<div className="CallsList__LoadingAvatar" />} leading={<div className="CallsList__LoadingAvatar" />}
title={ title={
<span className="CallsList__LoadingText CallsList__LoadingText--title" /> <span className="CallsList__LoadingText CallsList__LoadingText--title" />

View file

@ -1,7 +1,7 @@
// Copyright 2023 Signal Messenger, LLC // Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // 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 type { LocalizerType } from '../types/I18N';
import { NavSidebar, NavSidebarActionButton } from './NavSidebar'; import { NavSidebar, NavSidebarActionButton } from './NavSidebar';
import { CallsList } from './CallsList'; import { CallsList } from './CallsList';
@ -16,6 +16,7 @@ import { useEscapeHandling } from '../hooks/useEscapeHandling';
import type { ActiveCallStateType } from '../state/ducks/calling'; import type { ActiveCallStateType } from '../state/ducks/calling';
import { ContextMenu } from './ContextMenu'; import { ContextMenu } from './ContextMenu';
import { ConfirmationDialog } from './ConfirmationDialog'; import { ConfirmationDialog } from './ConfirmationDialog';
import type { UnreadStats } from '../util/countUnreadStats';
enum CallsTabSidebarView { enum CallsTabSidebarView {
CallsListView, CallsListView,
@ -25,6 +26,7 @@ enum CallsTabSidebarView {
type CallsTabProps = Readonly<{ type CallsTabProps = Readonly<{
activeCall: ActiveCallStateType | undefined; activeCall: ActiveCallStateType | undefined;
allConversations: ReadonlyArray<ConversationType>; allConversations: ReadonlyArray<ConversationType>;
appUnreadStats: UnreadStats;
getCallHistoryGroupsCount: ( getCallHistoryGroupsCount: (
options: CallHistoryFilterOptions options: CallHistoryFilterOptions
) => Promise<number>; ) => Promise<number>;
@ -33,9 +35,12 @@ type CallsTabProps = Readonly<{
pagination: CallHistoryPagination pagination: CallHistoryPagination
) => Promise<Array<CallHistoryGroup>>; ) => Promise<Array<CallHistoryGroup>>;
getConversation: (id: string) => ConversationType | void; getConversation: (id: string) => ConversationType | void;
hasFailedStorySends: boolean;
hasPendingUpdate: boolean;
i18n: LocalizerType; i18n: LocalizerType;
navTabsCollapsed: boolean; navTabsCollapsed: boolean;
onClearCallHistory: () => void; onClearCallHistory: () => void;
onMarkCallHistoryRead: (conversationId: string, callId: string) => void;
onToggleNavTabsCollapse: (navTabsCollapsed: boolean) => void; onToggleNavTabsCollapse: (navTabsCollapsed: boolean) => void;
onOutgoingAudioCallInConversation: (conversationId: string) => void; onOutgoingAudioCallInConversation: (conversationId: string) => void;
onOutgoingVideoCallInConversation: (conversationId: string) => void; onOutgoingVideoCallInConversation: (conversationId: string) => void;
@ -51,12 +56,16 @@ type CallsTabProps = Readonly<{
export function CallsTab({ export function CallsTab({
activeCall, activeCall,
allConversations, allConversations,
appUnreadStats,
getCallHistoryGroupsCount, getCallHistoryGroupsCount,
getCallHistoryGroups, getCallHistoryGroups,
getConversation, getConversation,
hasFailedStorySends,
hasPendingUpdate,
i18n, i18n,
navTabsCollapsed, navTabsCollapsed,
onClearCallHistory, onClearCallHistory,
onMarkCallHistoryRead,
onToggleNavTabsCollapse, onToggleNavTabsCollapse,
onOutgoingAudioCallInConversation, onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation, onOutgoingVideoCallInConversation,
@ -131,6 +140,14 @@ export function CallsTab({
[updateSidebarView, onOutgoingVideoCallInConversation] [updateSidebarView, onOutgoingVideoCallInConversation]
); );
useEffect(() => {
if (selected?.callHistoryGroup != null) {
selected.callHistoryGroup.children.forEach(child => {
onMarkCallHistoryRead(selected.conversationId, child.callId);
});
}
}, [selected, onMarkCallHistoryRead]);
return ( return (
<> <>
<div className="CallsTab"> <div className="CallsTab">
@ -141,6 +158,9 @@ export function CallsTab({
? i18n('icu:CallsTab__HeaderTitle--CallsList') ? i18n('icu:CallsTab__HeaderTitle--CallsList')
: i18n('icu:CallsTab__HeaderTitle--NewCall') : i18n('icu:CallsTab__HeaderTitle--NewCall')
} }
appUnreadStats={appUnreadStats}
hasFailedStorySends={hasFailedStorySends}
hasPendingUpdate={hasPendingUpdate}
navTabsCollapsed={navTabsCollapsed} navTabsCollapsed={navTabsCollapsed}
onBack={ onBack={
sidebarView === CallsTabSidebarView.NewCallView sidebarView === CallsTabSidebarView.NewCallView
@ -232,7 +252,10 @@ export function CallsTab({
</NavSidebar> </NavSidebar>
{selected == null ? ( {selected == null ? (
<div className="CallsTab__EmptyState"> <div className="CallsTab__EmptyState">
{i18n('icu:CallsTab__EmptyStateText')} <div className="CallsTab__EmptyStateIcon" />
<p className="CallsTab__EmptyStateLabel">
{i18n('icu:CallsTab__EmptyStateText')}
</p>
</div> </div>
) : ( ) : (
<div <div

View file

@ -6,9 +6,13 @@ import { Environment, getEnvironment } from '../environment';
import type { LocalizerType } from '../types/I18N'; import type { LocalizerType } from '../types/I18N';
import type { NavTabPanelProps } from './NavTabs'; import type { NavTabPanelProps } from './NavTabs';
import { WhatsNewLink } from './WhatsNewLink'; import { WhatsNewLink } from './WhatsNewLink';
import type { UnreadStats } from '../util/countUnreadStats';
type ChatsTabProps = Readonly<{ type ChatsTabProps = Readonly<{
appUnreadStats: UnreadStats;
i18n: LocalizerType; i18n: LocalizerType;
hasPendingUpdate: boolean;
hasFailedStorySends: boolean;
navTabsCollapsed: boolean; navTabsCollapsed: boolean;
onToggleNavTabsCollapse: (navTabsCollapsed: boolean) => void; onToggleNavTabsCollapse: (navTabsCollapsed: boolean) => void;
prevConversationId: string | undefined; prevConversationId: string | undefined;
@ -20,7 +24,10 @@ type ChatsTabProps = Readonly<{
}>; }>;
export function ChatsTab({ export function ChatsTab({
appUnreadStats,
i18n, i18n,
hasPendingUpdate,
hasFailedStorySends,
navTabsCollapsed, navTabsCollapsed,
onToggleNavTabsCollapse, onToggleNavTabsCollapse,
prevConversationId, prevConversationId,
@ -34,7 +41,10 @@ export function ChatsTab({
<> <>
<div id="LeftPane"> <div id="LeftPane">
{renderLeftPane({ {renderLeftPane({
appUnreadStats,
collapsed: navTabsCollapsed, collapsed: navTabsCollapsed,
hasPendingUpdate,
hasFailedStorySends,
onToggleCollapse: onToggleNavTabsCollapse, onToggleCollapse: onToggleNavTabsCollapse,
})} })}
</div> </div>

View file

@ -133,6 +133,11 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
); );
return { return {
appUnreadStats: {
unreadCount: 0,
unreadMentionsCount: 0,
markedUnread: false,
},
clearConversationSearch: action('clearConversationSearch'), clearConversationSearch: action('clearConversationSearch'),
clearGroupCreationError: action('clearGroupCreationError'), clearGroupCreationError: action('clearGroupCreationError'),
clearSearch: action('clearSearch'), clearSearch: action('clearSearch'),
@ -143,6 +148,8 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
composeSaveAvatarToDisk: action('composeSaveAvatarToDisk'), composeSaveAvatarToDisk: action('composeSaveAvatarToDisk'),
createGroup: action('createGroup'), createGroup: action('createGroup'),
getPreferredBadge: () => undefined, getPreferredBadge: () => undefined,
hasFailedStorySends: false,
hasPendingUpdate: false,
i18n, i18n,
isMacOS: boolean('isMacOS', false), isMacOS: boolean('isMacOS', false),
preferredWidthFromStorage: 320, preferredWidthFromStorage: 320,

View file

@ -48,6 +48,7 @@ import {
NavSidebarSearchHeader, NavSidebarSearchHeader,
} from './NavSidebar'; } from './NavSidebar';
import { ContextMenu } from './ContextMenu'; import { ContextMenu } from './ContextMenu';
import type { UnreadStats } from '../util/countUnreadStats';
export enum LeftPaneMode { export enum LeftPaneMode {
Inbox, Inbox,
@ -59,8 +60,11 @@ export enum LeftPaneMode {
} }
export type PropsType = { export type PropsType = {
appUnreadStats: UnreadStats;
hasExpiredDialog: boolean; hasExpiredDialog: boolean;
hasFailedStorySends: boolean;
hasNetworkDialog: boolean; hasNetworkDialog: boolean;
hasPendingUpdate: boolean;
hasRelinkDialog: boolean; hasRelinkDialog: boolean;
hasUpdateDialog: boolean; hasUpdateDialog: boolean;
isUpdateDownloaded: boolean; isUpdateDownloaded: boolean;
@ -154,6 +158,7 @@ export type PropsType = {
} & LookupConversationWithoutServiceIdActionsType; } & LookupConversationWithoutServiceIdActionsType;
export function LeftPane({ export function LeftPane({
appUnreadStats,
blockConversation, blockConversation,
challengeStatus, challengeStatus,
clearConversationSearch, clearConversationSearch,
@ -168,7 +173,9 @@ export function LeftPane({
createGroup, createGroup,
getPreferredBadge, getPreferredBadge,
hasExpiredDialog, hasExpiredDialog,
hasFailedStorySends,
hasNetworkDialog, hasNetworkDialog,
hasPendingUpdate,
hasRelinkDialog, hasRelinkDialog,
hasUpdateDialog, hasUpdateDialog,
i18n, i18n,
@ -549,6 +556,9 @@ export function LeftPane({
modeSpecificProps.mode === LeftPaneMode.SetGroupMetadata modeSpecificProps.mode === LeftPaneMode.SetGroupMetadata
} }
i18n={i18n} i18n={i18n}
appUnreadStats={appUnreadStats}
hasFailedStorySends={hasFailedStorySends}
hasPendingUpdate={hasPendingUpdate}
navTabsCollapsed={navTabsCollapsed} navTabsCollapsed={navTabsCollapsed}
onToggleNavTabsCollapse={toggleNavTabsCollapse} onToggleNavTabsCollapse={toggleNavTabsCollapse}
preferredLeftPaneWidth={preferredWidthFromStorage} preferredLeftPaneWidth={preferredWidthFromStorage}

View file

@ -9,6 +9,7 @@ import {
StoryViewModeType, StoryViewModeType,
} from '../types/Stories'; } from '../types/Stories';
import type { LocalizerType } from '../types/Util'; import type { LocalizerType } from '../types/Util';
import { ThemeType } from '../types/Util';
import type { ViewStoryActionCreatorType } from '../state/ducks/stories'; import type { ViewStoryActionCreatorType } from '../state/ducks/stories';
import { ConfirmationDialog } from './ConfirmationDialog'; import { ConfirmationDialog } from './ConfirmationDialog';
import { ContextMenu } from './ContextMenu'; import { ContextMenu } from './ContextMenu';
@ -19,9 +20,13 @@ import { Theme } from '../util/theme';
import { resolveStorySendStatus } from '../util/resolveStorySendStatus'; import { resolveStorySendStatus } from '../util/resolveStorySendStatus';
import { useRetryStorySend } from '../hooks/useRetryStorySend'; import { useRetryStorySend } from '../hooks/useRetryStorySend';
import { NavSidebar } from './NavSidebar'; import { NavSidebar } from './NavSidebar';
import type { UnreadStats } from '../util/countUnreadStats';
export type PropsType = { export type PropsType = {
i18n: LocalizerType; i18n: LocalizerType;
appUnreadStats: UnreadStats;
hasFailedStorySends: boolean;
hasPendingUpdate: boolean;
navTabsCollapsed: boolean; navTabsCollapsed: boolean;
myStories: Array<MyStoryType>; myStories: Array<MyStoryType>;
onBack: () => unknown; onBack: () => unknown;
@ -36,10 +41,14 @@ export type PropsType = {
hasViewReceiptSetting: boolean; hasViewReceiptSetting: boolean;
preferredLeftPaneWidth: number; preferredLeftPaneWidth: number;
savePreferredLeftPaneWidth: (preferredLeftPaneWidth: number) => void; savePreferredLeftPaneWidth: (preferredLeftPaneWidth: number) => void;
theme: ThemeType;
}; };
export function MyStories({ export function MyStories({
i18n, i18n,
appUnreadStats,
hasFailedStorySends,
hasPendingUpdate,
navTabsCollapsed, navTabsCollapsed,
myStories, myStories,
onBack, onBack,
@ -54,6 +63,7 @@ export function MyStories({
onToggleNavTabsCollapse, onToggleNavTabsCollapse,
preferredLeftPaneWidth, preferredLeftPaneWidth,
savePreferredLeftPaneWidth, savePreferredLeftPaneWidth,
theme,
}: PropsType): JSX.Element { }: PropsType): JSX.Element {
const [confirmDeleteStory, setConfirmDeleteStory] = useState< const [confirmDeleteStory, setConfirmDeleteStory] = useState<
StoryViewType | undefined StoryViewType | undefined
@ -80,6 +90,9 @@ export function MyStories({
<NavSidebar <NavSidebar
i18n={i18n} i18n={i18n}
title={i18n('icu:MyStories__title')} title={i18n('icu:MyStories__title')}
appUnreadStats={appUnreadStats}
hasFailedStorySends={hasFailedStorySends}
hasPendingUpdate={hasPendingUpdate}
navTabsCollapsed={navTabsCollapsed} navTabsCollapsed={navTabsCollapsed}
onBack={onBack} onBack={onBack}
onToggleNavTabsCollapse={onToggleNavTabsCollapse} onToggleNavTabsCollapse={onToggleNavTabsCollapse}
@ -109,6 +122,7 @@ export function MyStories({
retryMessageSend={retryMessageSend} retryMessageSend={retryMessageSend}
setConfirmDeleteStory={setConfirmDeleteStory} setConfirmDeleteStory={setConfirmDeleteStory}
story={story} story={story}
theme={theme}
viewStory={viewStory} viewStory={viewStory}
/> />
))} ))}
@ -135,6 +149,7 @@ type StorySentPropsType = Pick<
| 'retryMessageSend' | 'retryMessageSend'
| 'viewStory' | 'viewStory'
| 'onMediaPlaybackStart' | 'onMediaPlaybackStart'
| 'theme'
> & { > & {
setConfirmDeleteStory: (_: StoryViewType | undefined) => unknown; setConfirmDeleteStory: (_: StoryViewType | undefined) => unknown;
story: StoryViewType; story: StoryViewType;
@ -150,6 +165,7 @@ function StorySent({
retryMessageSend, retryMessageSend,
setConfirmDeleteStory, setConfirmDeleteStory,
story, story,
theme,
viewStory, viewStory,
}: StorySentPropsType): JSX.Element { }: StorySentPropsType): JSX.Element {
const sendStatus = resolveStorySendStatus(story.sendState ?? []); const sendStatus = resolveStorySendStatus(story.sendState ?? []);
@ -278,7 +294,7 @@ function StorySent({
}, },
]} ]}
moduleClassName="MyStories__story__more" moduleClassName="MyStories__story__more"
theme={Theme.Dark} theme={theme === ThemeType.dark ? Theme.Dark : Theme.Light}
/> />
</div> </div>
); );

View file

@ -14,6 +14,7 @@ import {
getWidthFromPreferredWidth, getWidthFromPreferredWidth,
} from '../util/leftPaneWidth'; } from '../util/leftPaneWidth';
import { WidthBreakpoint, getNavSidebarWidthBreakpoint } from './_util'; import { WidthBreakpoint, getNavSidebarWidthBreakpoint } from './_util';
import type { UnreadStats } from '../util/countUnreadStats';
export function NavSidebarActionButton({ export function NavSidebarActionButton({
icon, icon,
@ -43,6 +44,8 @@ export type NavSidebarProps = Readonly<{
actions?: ReactNode; actions?: ReactNode;
children: ReactNode; children: ReactNode;
i18n: LocalizerType; i18n: LocalizerType;
hasFailedStorySends: boolean;
hasPendingUpdate: boolean;
hideHeader?: boolean; hideHeader?: boolean;
navTabsCollapsed: boolean; navTabsCollapsed: boolean;
onBack?: (() => void) | null; onBack?: (() => void) | null;
@ -51,6 +54,7 @@ export type NavSidebarProps = Readonly<{
requiresFullWidth: boolean; requiresFullWidth: boolean;
savePreferredLeftPaneWidth: (width: number) => void; savePreferredLeftPaneWidth: (width: number) => void;
title: string; title: string;
appUnreadStats: UnreadStats;
}>; }>;
enum DragState { enum DragState {
@ -64,6 +68,8 @@ export function NavSidebar({
children, children,
hideHeader, hideHeader,
i18n, i18n,
hasFailedStorySends,
hasPendingUpdate,
navTabsCollapsed, navTabsCollapsed,
onBack, onBack,
onToggleNavTabsCollapse, onToggleNavTabsCollapse,
@ -71,6 +77,7 @@ export function NavSidebar({
requiresFullWidth, requiresFullWidth,
savePreferredLeftPaneWidth, savePreferredLeftPaneWidth,
title, title,
appUnreadStats,
}: NavSidebarProps): JSX.Element { }: NavSidebarProps): JSX.Element {
const [dragState, setDragState] = useState(DragState.INITIAL); const [dragState, setDragState] = useState(DragState.INITIAL);
@ -155,6 +162,9 @@ export function NavSidebar({
i18n={i18n} i18n={i18n}
navTabsCollapsed={navTabsCollapsed} navTabsCollapsed={navTabsCollapsed}
onToggleNavTabsCollapse={onToggleNavTabsCollapse} onToggleNavTabsCollapse={onToggleNavTabsCollapse}
hasFailedStorySends={hasFailedStorySends}
hasPendingUpdate={hasPendingUpdate}
appUnreadStats={appUnreadStats}
/> />
)} )}
<div <div

View file

@ -1,32 +1,91 @@
// Copyright 2023 Signal Messenger, LLC // Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { Key, ReactNode } from 'react'; import type { Key } from 'react';
import React, { useEffect, useState } from 'react'; import React from 'react';
import { Tabs, TabList, Tab, TabPanels, TabPanel } from 'react-aria-components'; import { Tabs, TabList, Tab, TabPanels, TabPanel } from 'react-aria-components';
import classNames from 'classnames'; import classNames from 'classnames';
import { usePopper } from 'react-popper';
import { createPortal } from 'react-dom';
import { Avatar, AvatarSize } from './Avatar'; import { Avatar, AvatarSize } from './Avatar';
import type { LocalizerType, ThemeType } from '../types/Util'; import type { LocalizerType, ThemeType } from '../types/Util';
import type { ConversationType } from '../state/ducks/conversations'; import type { ConversationType } from '../state/ducks/conversations';
import type { BadgeType } from '../badges/types'; import type { BadgeType } from '../badges/types';
import { AvatarPopup } from './AvatarPopup';
import { handleOutsideClick } from '../util/handleOutsideClick';
import type { UnreadStats } from '../state/selectors/conversations';
import { NavTab } from '../state/ducks/nav'; import { NavTab } from '../state/ducks/nav';
import { Tooltip, TooltipPlacement } from './Tooltip'; import { Tooltip, TooltipPlacement } from './Tooltip';
import { Theme } from '../util/theme'; import { Theme } from '../util/theme';
import type { UnreadStats } from '../util/countUnreadStats';
import { ContextMenu } from './ContextMenu';
type NavTabsItemBadgesProps = Readonly<{
i18n: LocalizerType;
hasError?: boolean;
hasPendingUpdate?: boolean;
unreadStats: UnreadStats | null;
}>;
function NavTabsItemBadges({
i18n,
hasError,
hasPendingUpdate,
unreadStats,
}: NavTabsItemBadgesProps) {
if (hasError) {
return (
<span className="NavTabs__ItemUnreadBadge">
<span className="NavTabs__ItemIconLabel">
{i18n('icu:NavTabs__ItemIconLabel--HasError')}
</span>
<span aria-hidden>!</span>
</span>
);
}
if (hasPendingUpdate) {
return <div className="NavTabs__ItemUpdateBadge" />;
}
if (unreadStats != null) {
if (unreadStats.unreadCount > 0) {
return (
<span className="NavTabs__ItemUnreadBadge">
<span className="NavTabs__ItemIconLabel">
{i18n('icu:NavTabs__ItemIconLabel--UnreadCount', {
count: unreadStats.unreadCount,
})}
</span>
<span aria-hidden>{unreadStats.unreadCount}</span>
</span>
);
}
if (unreadStats.markedUnread) {
return (
<span className="NavTabs__ItemUnreadBadge">
{i18n('icu:NavTabs__ItemIconLabel--MarkedUnread')}
</span>
);
}
}
return null;
}
type NavTabProps = Readonly<{ type NavTabProps = Readonly<{
i18n: LocalizerType; i18n: LocalizerType;
badge?: ReactNode;
iconClassName: string; iconClassName: string;
id: NavTab; id: NavTab;
hasError?: boolean;
label: string; 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'; const isRTL = i18n.getLocaleDirection() === 'rtl';
return ( return (
<Tab id={id} data-testid={`NavTabsItem--${id}`} className="NavTabs__Item"> <Tab id={id} data-testid={`NavTabsItem--${id}`} className="NavTabs__Item">
@ -43,7 +102,11 @@ function NavTabsItem({ i18n, badge, iconClassName, id, label }: NavTabProps) {
role="presentation" role="presentation"
className={`NavTabs__ItemIcon ${iconClassName}`} className={`NavTabs__ItemIcon ${iconClassName}`}
/> />
{badge && <span className="NavTabs__ItemBadge">{badge}</span>} <NavTabsItemBadges
i18n={i18n}
unreadStats={unreadStats}
hasError={hasError}
/>
</span> </span>
</span> </span>
</Tooltip> </Tooltip>
@ -52,19 +115,28 @@ function NavTabsItem({ i18n, badge, iconClassName, id, label }: NavTabProps) {
} }
export type NavTabPanelProps = Readonly<{ export type NavTabPanelProps = Readonly<{
appUnreadStats: UnreadStats;
collapsed: boolean; collapsed: boolean;
hasFailedStorySends: boolean;
hasPendingUpdate: boolean;
onToggleCollapse(collapsed: boolean): void; onToggleCollapse(collapsed: boolean): void;
}>; }>;
export type NavTabsToggleProps = Readonly<{ export type NavTabsToggleProps = Readonly<{
appUnreadStats: UnreadStats | null;
i18n: LocalizerType; i18n: LocalizerType;
hasFailedStorySends: boolean;
hasPendingUpdate: boolean;
navTabsCollapsed: boolean; navTabsCollapsed: boolean;
onToggleNavTabsCollapse(navTabsCollapsed: boolean): void; onToggleNavTabsCollapse(navTabsCollapsed: boolean): void;
}>; }>;
export function NavTabsToggle({ export function NavTabsToggle({
i18n, i18n,
hasFailedStorySends,
hasPendingUpdate,
navTabsCollapsed, navTabsCollapsed,
appUnreadStats,
onToggleNavTabsCollapse, onToggleNavTabsCollapse,
}: NavTabsToggleProps): JSX.Element { }: NavTabsToggleProps): JSX.Element {
function handleToggle() { function handleToggle() {
@ -87,11 +159,19 @@ export function NavTabsToggle({
delay={600} delay={600}
> >
<span className="NavTabs__ItemButton"> <span className="NavTabs__ItemButton">
<span <span className="NavTabs__ItemContent">
role="presentation" <span
className="NavTabs__ItemIcon NavTabs__ItemIcon--Menu" role="presentation"
/> className="NavTabs__ItemIcon NavTabs__ItemIcon--Menu"
<span className="NavTabs__ItemLabel">{label}</span> />
<span className="NavTabs__ItemLabel">{label}</span>
<NavTabsItemBadges
i18n={i18n}
unreadStats={appUnreadStats}
hasError={hasFailedStorySends}
hasPendingUpdate={hasPendingUpdate}
/>
</span>
</span> </span>
</Tooltip> </Tooltip>
</button> </button>
@ -116,6 +196,7 @@ export type NavTabsProps = Readonly<{
selectedNavTab: NavTab; selectedNavTab: NavTab;
storiesEnabled: boolean; storiesEnabled: boolean;
theme: ThemeType; theme: ThemeType;
unreadCallsCount: number;
unreadConversationsStats: UnreadStats; unreadConversationsStats: UnreadStats;
unreadStoriesCount: number; unreadStoriesCount: number;
}>; }>;
@ -138,6 +219,7 @@ export function NavTabs({
selectedNavTab, selectedNavTab,
storiesEnabled, storiesEnabled,
theme, theme,
unreadCallsCount,
unreadConversationsStats, unreadConversationsStats,
unreadStoriesCount, unreadStoriesCount,
}: NavTabsProps): JSX.Element { }: NavTabsProps): JSX.Element {
@ -147,63 +229,6 @@ export function NavTabs({
const isRTL = i18n.getLocaleDirection() === 'rtl'; const isRTL = i18n.getLocaleDirection() === 'rtl';
const [targetElement, setTargetElement] = useState<HTMLElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLElement | null>(null);
const [portalElement, setPortalElement] = useState<HTMLElement | null>(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 ( return (
<Tabs orientation="vertical" className="NavTabs__Container"> <Tabs orientation="vertical" className="NavTabs__Container">
<nav <nav
@ -215,6 +240,10 @@ export function NavTabs({
i18n={i18n} i18n={i18n}
navTabsCollapsed={navTabsCollapsed} navTabsCollapsed={navTabsCollapsed}
onToggleNavTabsCollapse={onToggleNavTabsCollapse} onToggleNavTabsCollapse={onToggleNavTabsCollapse}
// These are all shown elsewhere when nav tabs are shown
hasFailedStorySends={false}
hasPendingUpdate={false}
appUnreadStats={null}
/> />
<TabList <TabList
className="NavTabs__TabList" className="NavTabs__TabList"
@ -226,31 +255,18 @@ export function NavTabs({
id={NavTab.Chats} id={NavTab.Chats}
label="Chats" label="Chats"
iconClassName="NavTabs__ItemIcon--Chats" iconClassName="NavTabs__ItemIcon--Chats"
badge={ unreadStats={unreadConversationsStats}
// eslint-disable-next-line no-nested-ternary
unreadConversationsStats.unreadCount > 0 ? (
<>
<span className="NavTabs__ItemIconLabel">
{i18n('icu:NavTabs__ItemIconLabel--UnreadCount', {
count: unreadConversationsStats.unreadCount,
})}
</span>
<span aria-hidden>
{unreadConversationsStats.unreadCount}
</span>
</>
) : unreadConversationsStats.markedUnread ? (
<span className="NavTabs__ItemIconLabel">
{i18n('icu:NavTabs__ItemIconLabel--MarkedUnread')}
</span>
) : null
}
/> />
<NavTabsItem <NavTabsItem
i18n={i18n} i18n={i18n}
id={NavTab.Calls} id={NavTab.Calls}
label="Calls" label="Calls"
iconClassName="NavTabs__ItemIcon--Calls" iconClassName="NavTabs__ItemIcon--Calls"
unreadStats={{
unreadCount: unreadCallsCount,
unreadMentionsCount: 0,
markedUnread: false,
}}
/> />
{storiesEnabled && ( {storiesEnabled && (
<NavTabsItem <NavTabsItem
@ -258,47 +274,89 @@ export function NavTabs({
id={NavTab.Stories} id={NavTab.Stories}
label="Stories" label="Stories"
iconClassName="NavTabs__ItemIcon--Stories" iconClassName="NavTabs__ItemIcon--Stories"
badge={ hasError={hasFailedStorySends}
// eslint-disable-next-line no-nested-ternary unreadStats={{
hasFailedStorySends unreadCount: unreadStoriesCount,
? '!' unreadMentionsCount: 0,
: unreadStoriesCount > 0 markedUnread: false,
? unreadStoriesCount }}
: null
}
/> />
)} )}
</TabList> </TabList>
<div className="NavTabs__Misc"> <div className="NavTabs__Misc">
<button <ContextMenu
type="button" i18n={i18n}
className="NavTabs__Item" menuOptions={[
onClick={onShowSettings} {
icon: 'NavTabs__ContextMenuIcon--Settings',
label: i18n('icu:NavTabs__ItemLabel--Settings'),
onClick: onShowSettings,
},
{
icon: 'NavTabs__ContextMenuIcon--Update',
label: i18n('icu:NavTabs__ItemLabel--Update'),
onClick: onStartUpdate,
},
]}
popperOptions={{
placement: 'top-start',
strategy: 'absolute',
}}
portalToRoot
> >
<Tooltip {({ openMenu, onKeyDown, ref }) => {
content={i18n('icu:NavTabs__ItemLabel--Settings')} return (
theme={Theme.Dark} <button
direction={TooltipPlacement.Right} type="button"
delay={600} className="NavTabs__Item"
> onKeyDown={event => {
<span className="NavTabs__ItemButton"> if (hasPendingUpdate) {
<span onKeyDown(event);
role="presentation" }
className="NavTabs__ItemIcon NavTabs__ItemIcon--Settings" }}
/> onClick={event => {
<span className="NavTabs__ItemLabel"> if (hasPendingUpdate) {
{i18n('icu:NavTabs__ItemLabel--Settings')} openMenu(event);
</span> } else {
</span> onShowSettings();
</Tooltip> }
</button> }}
>
<Tooltip
content={i18n('icu:NavTabs__ItemLabel--Settings')}
theme={Theme.Dark}
direction={TooltipPlacement.Right}
delay={600}
>
<span className="NavTabs__ItemButton" ref={ref}>
<span className="NavTabs__ItemContent">
<span
role="presentation"
className="NavTabs__ItemIcon NavTabs__ItemIcon--Settings"
/>
<span className="NavTabs__ItemLabel">
{i18n('icu:NavTabs__ItemLabel--Settings')}
</span>
<NavTabsItemBadges
i18n={i18n}
unreadStats={null}
hasPendingUpdate={hasPendingUpdate}
/>
</span>
</span>
</Tooltip>
</button>
);
}}
</ContextMenu>
<button <button
type="button" type="button"
className="NavTabs__Item NavTabs__Item--Profile" className="NavTabs__Item NavTabs__Item--Profile"
data-supertab data-supertab
onClick={() => { onClick={() => {
setShowAvatarPopup(true); onToggleProfileEditor();
}} }}
aria-label={i18n('icu:NavTabs__ItemLabel--Profile')} aria-label={i18n('icu:NavTabs__ItemLabel--Profile')}
> >
@ -308,7 +366,7 @@ export function NavTabs({
direction={isRTL ? TooltipPlacement.Left : TooltipPlacement.Right} direction={isRTL ? TooltipPlacement.Left : TooltipPlacement.Right}
delay={600} delay={600}
> >
<span className="NavTabs__ItemButton" ref={setTargetElement}> <span className="NavTabs__ItemButton">
<span className="NavTabs__ItemContent"> <span className="NavTabs__ItemContent">
<Avatar <Avatar
acceptedMessageRequest acceptedMessageRequest
@ -328,49 +386,10 @@ export function NavTabs({
sharedGroupNames={[]} sharedGroupNames={[]}
size={AvatarSize.TWENTY_EIGHT} size={AvatarSize.TWENTY_EIGHT}
/> />
{hasPendingUpdate && <div className="NavTabs__AvatarBadge" />}
</span> </span>
</span> </span>
</Tooltip> </Tooltip>
</button> </button>
{showAvatarPopup &&
portalElement != null &&
createPortal(
<div
id="MainHeader__AvatarPopup"
ref={setPopperElement}
style={{ ...popper.styles.popper, zIndex: 10 }}
{...popper.attributes.popper}
>
<AvatarPopup
acceptedMessageRequest
badge={badge}
i18n={i18n}
isMe
color={me.color}
conversationType="direct"
name={me.name}
phoneNumber={me.phoneNumber}
profileName={me.profileName}
theme={theme}
title={me.title}
avatarPath={me.avatarPath}
hasPendingUpdate={hasPendingUpdate}
// See the comment above about `sharedGroupNames`.
sharedGroupNames={[]}
onEditProfile={() => {
onToggleProfileEditor();
setShowAvatarPopup(false);
}}
onStartUpdate={() => {
onStartUpdate();
setShowAvatarPopup(false);
}}
style={{}}
/>
</div>,
portalElement
)}
</div> </div>
</nav> </nav>
<TabPanels> <TabPanels>

View file

@ -79,6 +79,7 @@ export type PropsDataType = {
hasCompletedUsernameLinkOnboarding: boolean; hasCompletedUsernameLinkOnboarding: boolean;
i18n: LocalizerType; i18n: LocalizerType;
isUsernameFlagEnabled: boolean; isUsernameFlagEnabled: boolean;
phoneNumber?: string;
userAvatarData: ReadonlyArray<AvatarDataType>; userAvatarData: ReadonlyArray<AvatarDataType>;
username?: string; username?: string;
usernameEditState: UsernameEditState; usernameEditState: UsernameEditState;
@ -154,6 +155,7 @@ export function ProfileEditor({
onProfileChanged, onProfileChanged,
onSetSkinTone, onSetSkinTone,
openUsernameReservationModal, openUsernameReservationModal,
phoneNumber,
profileAvatarPath, profileAvatarPath,
recentEmojis, recentEmojis,
renderEditUsernameModalBody, renderEditUsernameModalBody,
@ -678,6 +680,10 @@ export function ProfileEditor({
width: 80, width: 80,
}} }}
/> />
<h1 className="ProfileEditor__Title">{getFullNameText()}</h1>
{phoneNumber != null && (
<p className="ProfileEditor__PhoneNumber">{phoneNumber}</p>
)}
<hr className="ProfileEditor__divider" /> <hr className="ProfileEditor__divider" />
<PanelRow <PanelRow
className="ProfileEditor__row" className="ProfileEditor__row"

View file

@ -24,11 +24,15 @@ import { StoriesPane } from './StoriesPane';
import { NavSidebar, NavSidebarActionButton } from './NavSidebar'; import { NavSidebar, NavSidebarActionButton } from './NavSidebar';
import { StoriesAddStoryButton } from './StoriesAddStoryButton'; import { StoriesAddStoryButton } from './StoriesAddStoryButton';
import { ContextMenu } from './ContextMenu'; import { ContextMenu } from './ContextMenu';
import type { UnreadStats } from '../util/countUnreadStats';
export type PropsType = { export type PropsType = {
addStoryData: AddStoryData; addStoryData: AddStoryData;
appUnreadStats: UnreadStats;
deleteStoryForEveryone: (story: StoryViewType) => unknown; deleteStoryForEveryone: (story: StoryViewType) => unknown;
getPreferredBadge: PreferredBadgeSelectorType; getPreferredBadge: PreferredBadgeSelectorType;
hasFailedStorySends: boolean;
hasPendingUpdate: boolean;
hasViewReceiptSetting: boolean; hasViewReceiptSetting: boolean;
hiddenStories: Array<ConversationStoryType>; hiddenStories: Array<ConversationStoryType>;
i18n: LocalizerType; i18n: LocalizerType;
@ -61,8 +65,11 @@ export type PropsType = {
export function StoriesTab({ export function StoriesTab({
addStoryData, addStoryData,
appUnreadStats,
deleteStoryForEveryone, deleteStoryForEveryone,
getPreferredBadge, getPreferredBadge,
hasFailedStorySends,
hasPendingUpdate,
hasViewReceiptSetting, hasViewReceiptSetting,
hiddenStories, hiddenStories,
i18n, i18n,
@ -104,6 +111,9 @@ export function StoriesTab({
{addStoryData && renderStoryCreator()} {addStoryData && renderStoryCreator()}
{isMyStories && myStories.length ? ( {isMyStories && myStories.length ? (
<MyStories <MyStories
appUnreadStats={appUnreadStats}
hasFailedStorySends={hasFailedStorySends}
hasPendingUpdate={hasPendingUpdate}
hasViewReceiptSetting={hasViewReceiptSetting} hasViewReceiptSetting={hasViewReceiptSetting}
i18n={i18n} i18n={i18n}
myStories={myStories} myStories={myStories}
@ -118,17 +128,21 @@ export function StoriesTab({
queueStoryDownload={queueStoryDownload} queueStoryDownload={queueStoryDownload}
retryMessageSend={retryMessageSend} retryMessageSend={retryMessageSend}
savePreferredLeftPaneWidth={savePreferredLeftPaneWidth} savePreferredLeftPaneWidth={savePreferredLeftPaneWidth}
theme={theme}
viewStory={viewStory} viewStory={viewStory}
/> />
) : ( ) : (
<NavSidebar <NavSidebar
title="Stories" title="Stories"
i18n={i18n} i18n={i18n}
hasFailedStorySends={hasFailedStorySends}
hasPendingUpdate={hasPendingUpdate}
navTabsCollapsed={navTabsCollapsed} navTabsCollapsed={navTabsCollapsed}
onToggleNavTabsCollapse={onToggleNavTabsCollapse} onToggleNavTabsCollapse={onToggleNavTabsCollapse}
preferredLeftPaneWidth={preferredLeftPaneWidth} preferredLeftPaneWidth={preferredLeftPaneWidth}
requiresFullWidth requiresFullWidth
savePreferredLeftPaneWidth={savePreferredLeftPaneWidth} savePreferredLeftPaneWidth={savePreferredLeftPaneWidth}
appUnreadStats={appUnreadStats}
actions={ actions={
<> <>
<StoriesAddStoryButton <StoriesAddStoryButton

View file

@ -4432,6 +4432,7 @@ export class ConversationModel extends window.Backbone
unreadMentionsCount, unreadMentionsCount,
}); });
window.Signal.Data.updateConversation(this.attributes); window.Signal.Data.updateConversation(this.attributes);
window.reduxActions.callHistory.updateCallHistoryUnreadCount();
} }
} }

View file

@ -636,6 +636,8 @@ export type DataInterface = {
}): Promise<MessageType | undefined>; }): Promise<MessageType | undefined>;
getAllCallHistory: () => Promise<ReadonlyArray<CallHistoryDetails>>; getAllCallHistory: () => Promise<ReadonlyArray<CallHistoryDetails>>;
clearCallHistory: (beforeTimestamp: number) => Promise<Array<string>>; clearCallHistory: (beforeTimestamp: number) => Promise<Array<string>>;
getCallHistoryUnreadCount(): Promise<number>;
markCallHistoryRead(callId: string): Promise<void>;
getCallHistoryMessageByCallId(options: { getCallHistoryMessageByCallId(options: {
conversationId: string; conversationId: string;
callId: string; callId: string;

View file

@ -307,6 +307,8 @@ const dataInterface: ServerInterface = {
getLastConversationMessage, getLastConversationMessage,
getAllCallHistory, getAllCallHistory,
clearCallHistory, clearCallHistory,
getCallHistoryUnreadCount,
markCallHistoryRead,
getCallHistoryMessageByCallId, getCallHistoryMessageByCallId,
getCallHistory, getCallHistory,
getCallHistoryGroupsCount, getCallHistoryGroupsCount,
@ -3346,11 +3348,38 @@ async function getCallHistory(
return callHistoryDetailsSchema.parse(row); return callHistoryDetailsSchema.parse(row);
} }
const MISSED = sqlConstant(DirectCallStatus.Missed); const READ_STATUS_UNREAD = sqlConstant(ReadStatus.Unread);
const DELETED = sqlConstant(DirectCallStatus.Deleted); const READ_STATUS_READ = sqlConstant(ReadStatus.Read);
const INCOMING = sqlConstant(CallDirection.Incoming); 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); const FOUR_HOURS_IN_MS = sqlConstant(4 * 60 * 60 * 1000);
async function getCallHistoryUnreadCount(): Promise<number> {
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<void> {
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( function getCallHistoryGroupDataSync(
db: Database, db: Database,
isCount: boolean, isCount: boolean,
@ -3406,10 +3435,10 @@ function getCallHistoryGroupDataSync(
const filterClause = const filterClause =
status === CallHistoryFilterStatus.All status === CallHistoryFilterStatus.All
? sqlFragment`status IS NOT ${DELETED}` ? sqlFragment`status IS NOT ${CALL_STATUS_DELETED}`
: sqlFragment` : sqlFragment`
direction IS ${INCOMING} AND direction IS ${CALL_STATUS_INCOMING} AND
status IS ${MISSED} AND status IS NOT ${DELETED} status IS ${CALL_STATUS_MISSED} AND status IS NOT ${CALL_STATUS_DELETED}
`; `;
const offsetLimit = const offsetLimit =
@ -3445,8 +3474,8 @@ function getCallHistoryGroupDataSync(
-- Tracking Android & Desktop separately to make the queries easier to compare -- Tracking Android & Desktop separately to make the queries easier to compare
-- Android Constraints: -- Android Constraints:
AND ( AND (
(callsHistory.status IS c.status AND callsHistory.status IS ${MISSED}) OR (callsHistory.status IS c.status AND callsHistory.status IS ${CALL_STATUS_MISSED}) OR
(callsHistory.status IS NOT ${MISSED} AND c.status IS NOT ${MISSED}) (callsHistory.status IS NOT ${CALL_STATUS_MISSED} AND c.status IS NOT ${CALL_STATUS_MISSED})
) )
-- Desktop Constraints: -- Desktop Constraints:
AND callsHistory.status IS c.status AND callsHistory.status IS c.status
@ -3474,8 +3503,8 @@ function getCallHistoryGroupDataSync(
-- Tracking Android & Desktop separately to make the queries easier to compare -- Tracking Android & Desktop separately to make the queries easier to compare
-- Android Constraints: -- Android Constraints:
AND ( AND (
(callsHistory.status IS c.status AND callsHistory.status IS ${MISSED}) OR (callsHistory.status IS c.status AND callsHistory.status IS ${CALL_STATUS_MISSED}) OR
(callsHistory.status IS NOT ${MISSED} AND c.status IS NOT ${MISSED}) (callsHistory.status IS NOT ${CALL_STATUS_MISSED} AND c.status IS NOT ${CALL_STATUS_MISSED})
) )
-- Desktop Constraints: -- Desktop Constraints:
AND callsHistory.status IS c.status AND callsHistory.status IS c.status

View file

@ -17,11 +17,13 @@ import * as Errors from '../../types/errors';
export type CallHistoryState = ReadonlyDeep<{ export type CallHistoryState = ReadonlyDeep<{
// This informs the app that underlying call history data has changed. // This informs the app that underlying call history data has changed.
edition: number; edition: number;
unreadCount: number;
callHistoryByCallId: Record<string, CallHistoryDetails>; callHistoryByCallId: Record<string, CallHistoryDetails>;
}>; }>;
const CALL_HISTORY_CACHE = 'callHistory/CACHE'; const CALL_HISTORY_CACHE = 'callHistory/CACHE';
const CALL_HISTORY_RESET = 'callHistory/RESET'; const CALL_HISTORY_RESET = 'callHistory/RESET';
const CALL_HISTORY_UPDATE_UNREAD = 'callHistory/UPDATE_UNREAD';
export type CallHistoryCache = ReadonlyDeep<{ export type CallHistoryCache = ReadonlyDeep<{
type: typeof CALL_HISTORY_CACHE; type: typeof CALL_HISTORY_CACHE;
@ -32,17 +34,58 @@ export type CallHistoryReset = ReadonlyDeep<{
type: typeof CALL_HISTORY_RESET; type: typeof CALL_HISTORY_RESET;
}>; }>;
export type CallHistoryUpdateUnread = ReadonlyDeep<{
type: typeof CALL_HISTORY_UPDATE_UNREAD;
payload: number;
}>;
export type CallHistoryAction = ReadonlyDeep< export type CallHistoryAction = ReadonlyDeep<
CallHistoryCache | CallHistoryReset CallHistoryCache | CallHistoryReset | CallHistoryUpdateUnread
>; >;
export function getEmptyState(): CallHistoryState { export function getEmptyState(): CallHistoryState {
return { return {
edition: 0, edition: 0,
unreadCount: 0,
callHistoryByCallId: {}, 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<void, RootStateType, unknown, CallHistoryUpdateUnread> {
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 { function cacheCallHistory(callHistory: CallHistoryDetails): CallHistoryCache {
return { return {
type: CALL_HISTORY_CACHE, type: CALL_HISTORY_CACHE,
@ -65,6 +108,7 @@ function clearAllCallHistory(): ThunkAction<
} finally { } finally {
// Just force a reset, even if the clear failed. // Just force a reset, even if the clear failed.
dispatch({ type: CALL_HISTORY_RESET }); dispatch({ type: CALL_HISTORY_RESET });
dispatch(updateCallHistoryUnreadCount());
} }
}; };
} }
@ -72,6 +116,8 @@ function clearAllCallHistory(): ThunkAction<
export const actions = { export const actions = {
cacheCallHistory, cacheCallHistory,
clearAllCallHistory, clearAllCallHistory,
updateCallHistoryUnreadCount,
markCallHistoryRead,
}; };
export const useCallHistoryActions = (): BoundActionCreatorsMapObject< export const useCallHistoryActions = (): BoundActionCreatorsMapObject<
@ -93,6 +139,11 @@ export function reducer(
[action.payload.callId]: action.payload, [action.payload.callId]: action.payload,
}, },
}; };
case CALL_HISTORY_UPDATE_UNREAD:
return {
...state,
unreadCount: action.payload,
};
default: default:
return state; return state;
} }

View file

@ -29,3 +29,10 @@ export const getCallHistorySelector = createSelector(
}; };
} }
); );
export const getCallHistoryUnreadCount = createSelector(
getCallHistory,
callHistory => {
return callHistory.unreadCount;
}
);

View file

@ -66,6 +66,10 @@ import type { HasStories } from '../../types/Stories';
import { getHasStoriesSelector } from './stories2'; import { getHasStoriesSelector } from './stories2';
import { canEditMessage } from '../../util/canEditMessage'; import { canEditMessage } from '../../util/canEditMessage';
import { isOutgoing } from '../../messages/helpers'; import { isOutgoing } from '../../messages/helpers';
import {
countAllConversationsUnreadStats,
type UnreadStats,
} from '../../util/countUnreadStats';
export type ConversationWithStoriesType = ConversationType & { export type ConversationWithStoriesType = ConversationType & {
hasStories?: HasStories; hasStories?: HasStories;
@ -532,37 +536,12 @@ export const getAllGroupsWithInviteAccess = createSelector(
}) })
); );
export type UnreadStats = Readonly<{
unreadCount: number;
unreadMentionsCount: number;
markedUnread: boolean;
}>;
export const getAllConversationsUnreadStats = createSelector( export const getAllConversationsUnreadStats = createSelector(
getLeftPaneLists, getAllConversations,
(leftPaneLists: LeftPaneLists): UnreadStats => { (conversations): UnreadStats => {
let unreadCount = 0; return countAllConversationsUnreadStats(conversations, {
let unreadMentionsCount = 0; includeMuted: false,
let markedUnread = false; });
function count(conversations: ReadonlyArray<ConversationType>) {
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 };
} }
); );

View file

@ -4,6 +4,9 @@
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import type { StateType } from '../reducer'; import type { StateType } from '../reducer';
import type { NavStateType } from '../ducks/nav'; 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 { function getNav(state: StateType): NavStateType {
return state.nav; return state.nav;
@ -12,3 +15,17 @@ function getNav(state: StateType): NavStateType {
export const getSelectedNavTab = createSelector(getNav, nav => { export const getSelectedNavTab = createSelector(getNav, nav => {
return nav.selectedNavTab; 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,
};
}
);

View file

@ -42,3 +42,8 @@ export const isOSUnsupported = createSelector(
getUpdatesState, getUpdatesState,
({ dialogType }) => dialogType === DialogType.UnsupportedOS ({ dialogType }) => dialogType === DialogType.UnsupportedOS
); );
export const getHasPendingUpdate = createSelector(
getUpdatesState,
({ didSnooze }) => didSnooze === true
);

View file

@ -27,6 +27,9 @@ import { useCallingActions } from '../ducks/calling';
import { getActiveCallState } from '../selectors/calling'; import { getActiveCallState } from '../selectors/calling';
import { useCallHistoryActions } from '../ducks/callHistory'; import { useCallHistoryActions } from '../ducks/callHistory';
import { getCallHistoryEdition } from '../selectors/callHistory'; import { getCallHistoryEdition } from '../selectors/callHistory';
import { getHasPendingUpdate } from '../selectors/updates';
import { getHasAnyFailedStorySends } from '../selectors/stories';
import { getAppUnreadStats } from '../selectors/nav';
function getCallHistoryFilter( function getCallHistoryFilter(
allConversations: Array<ConversationType>, allConversations: Array<ConversationType>,
@ -91,11 +94,16 @@ export function SmartCallsTab(): JSX.Element {
const activeCall = useSelector(getActiveCallState); const activeCall = useSelector(getActiveCallState);
const callHistoryEdition = useSelector(getCallHistoryEdition); const callHistoryEdition = useSelector(getCallHistoryEdition);
const hasPendingUpdate = useSelector(getHasPendingUpdate);
const hasFailedStorySends = useSelector(getHasAnyFailedStorySends);
const appUnreadStats = useSelector(getAppUnreadStats);
const { const {
onOutgoingAudioCallInConversation, onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation, onOutgoingVideoCallInConversation,
} = useCallingActions(); } = useCallingActions();
const { clearAllCallHistory: clearCallHistory } = useCallHistoryActions(); const { clearAllCallHistory: clearCallHistory, markCallHistoryRead } =
useCallHistoryActions();
const getCallHistoryGroupsCount = useCallback( const getCallHistoryGroupsCount = useCallback(
async (options: CallHistoryFilterOptions) => { async (options: CallHistoryFilterOptions) => {
@ -145,12 +153,16 @@ export function SmartCallsTab(): JSX.Element {
<CallsTab <CallsTab
activeCall={activeCall} activeCall={activeCall}
allConversations={allConversations} allConversations={allConversations}
appUnreadStats={appUnreadStats}
getConversation={getConversation} getConversation={getConversation}
getCallHistoryGroupsCount={getCallHistoryGroupsCount} getCallHistoryGroupsCount={getCallHistoryGroupsCount}
getCallHistoryGroups={getCallHistoryGroups} getCallHistoryGroups={getCallHistoryGroups}
hasFailedStorySends={hasFailedStorySends}
hasPendingUpdate={hasPendingUpdate}
i18n={i18n} i18n={i18n}
navTabsCollapsed={navTabsCollapsed} navTabsCollapsed={navTabsCollapsed}
onClearCallHistory={clearCallHistory} onClearCallHistory={clearCallHistory}
onMarkCallHistoryRead={markCallHistoryRead}
onToggleNavTabsCollapse={toggleNavTabsCollapse} onToggleNavTabsCollapse={toggleNavTabsCollapse}
onOutgoingAudioCallInConversation={onOutgoingAudioCallInConversation} onOutgoingAudioCallInConversation={onOutgoingAudioCallInConversation}
onOutgoingVideoCallInConversation={onOutgoingVideoCallInConversation} onOutgoingVideoCallInConversation={onOutgoingVideoCallInConversation}

View file

@ -20,6 +20,9 @@ import { showToast } from '../../util/showToast';
import { ToastStickerPackInstallFailed } from '../../components/ToastStickerPackInstallFailed'; import { ToastStickerPackInstallFailed } from '../../components/ToastStickerPackInstallFailed';
import { getNavTabsCollapsed } from '../selectors/items'; import { getNavTabsCollapsed } from '../selectors/items';
import { useItemsActions } from '../ducks/items'; import { useItemsActions } from '../ducks/items';
import { getHasAnyFailedStorySends } from '../selectors/stories';
import { getHasPendingUpdate } from '../selectors/updates';
import { getAppUnreadStats } from '../selectors/nav';
function renderConversationView() { function renderConversationView() {
return <SmartConversationView />; return <SmartConversationView />;
@ -36,6 +39,10 @@ function renderMiniPlayer(options: { shouldFlow: boolean }) {
export function SmartChatsTab(): JSX.Element { export function SmartChatsTab(): JSX.Element {
const i18n = useSelector(getIntl); const i18n = useSelector(getIntl);
const navTabsCollapsed = useSelector(getNavTabsCollapsed); const navTabsCollapsed = useSelector(getNavTabsCollapsed);
const hasFailedStorySends = useSelector(getHasAnyFailedStorySends);
const hasPendingUpdate = useSelector(getHasPendingUpdate);
const appUnreadStats = useSelector(getAppUnreadStats);
const { selectedConversationId, targetedMessage, targetedMessageSource } = const { selectedConversationId, targetedMessage, targetedMessageSource } =
useSelector<StateType, ConversationsStateType>( useSelector<StateType, ConversationsStateType>(
state => state.conversations state => state.conversations
@ -137,7 +144,10 @@ export function SmartChatsTab(): JSX.Element {
return ( return (
<ChatsTab <ChatsTab
appUnreadStats={appUnreadStats}
i18n={i18n} i18n={i18n}
hasFailedStorySends={hasFailedStorySends}
hasPendingUpdate={hasPendingUpdate}
navTabsCollapsed={navTabsCollapsed} navTabsCollapsed={navTabsCollapsed}
onToggleNavTabsCollapse={toggleNavTabsCollapse} onToggleNavTabsCollapse={toggleNavTabsCollapse}
prevConversationId={prevConversationId} prevConversationId={prevConversationId}

View file

@ -11,7 +11,6 @@ import {
getMe, getMe,
} from '../selectors/conversations'; } from '../selectors/conversations';
import { getPreferredBadgeSelector } from '../selectors/badges'; import { getPreferredBadgeSelector } from '../selectors/badges';
import type { StateType } from '../reducer';
import { import {
getHasAnyFailedStorySends, getHasAnyFailedStorySends,
getStoriesNotificationCount, getStoriesNotificationCount,
@ -23,6 +22,8 @@ import { getStoriesEnabled } from '../selectors/items';
import { getSelectedNavTab } from '../selectors/nav'; import { getSelectedNavTab } from '../selectors/nav';
import type { NavTab } from '../ducks/nav'; import type { NavTab } from '../ducks/nav';
import { useNavActions } from '../ducks/nav'; import { useNavActions } from '../ducks/nav';
import { getHasPendingUpdate } from '../selectors/updates';
import { getCallHistoryUnreadCount } from '../selectors/callHistory';
export type SmartNavTabsProps = Readonly<{ export type SmartNavTabsProps = Readonly<{
navTabsCollapsed: boolean; navTabsCollapsed: boolean;
@ -48,11 +49,9 @@ export function SmartNavTabs({
const storiesEnabled = useSelector(getStoriesEnabled); const storiesEnabled = useSelector(getStoriesEnabled);
const unreadConversationsStats = useSelector(getAllConversationsUnreadStats); const unreadConversationsStats = useSelector(getAllConversationsUnreadStats);
const unreadStoriesCount = useSelector(getStoriesNotificationCount); const unreadStoriesCount = useSelector(getStoriesNotificationCount);
const unreadCallsCount = useSelector(getCallHistoryUnreadCount);
const hasFailedStorySends = useSelector(getHasAnyFailedStorySends); const hasFailedStorySends = useSelector(getHasAnyFailedStorySends);
const hasPendingUpdate = useSelector(getHasPendingUpdate);
const hasPendingUpdate = useSelector((state: StateType) => {
return state.updates.didSnooze;
});
const { toggleProfileEditor } = useGlobalModalActions(); const { toggleProfileEditor } = useGlobalModalActions();
const { startUpdate } = useUpdatesActions(); const { startUpdate } = useUpdatesActions();
@ -87,6 +86,7 @@ export function SmartNavTabs({
selectedNavTab={selectedNavTab} selectedNavTab={selectedNavTab}
storiesEnabled={storiesEnabled} storiesEnabled={storiesEnabled}
theme={theme} theme={theme}
unreadCallsCount={unreadCallsCount}
unreadConversationsStats={unreadConversationsStats} unreadConversationsStats={unreadConversationsStats}
unreadStoriesCount={unreadStoriesCount} unreadStoriesCount={unreadStoriesCount}
/> />

View file

@ -45,6 +45,7 @@ function mapStateToProps(
firstName, firstName,
familyName, familyName,
id: conversationId, id: conversationId,
phoneNumber,
username, username,
} = getMe(state); } = getMe(state);
const recentEmojis = selectRecentEmojis(state); const recentEmojis = selectRecentEmojis(state);
@ -74,6 +75,7 @@ function mapStateToProps(
isUsernameFlagEnabled, isUsernameFlagEnabled,
recentEmojis, recentEmojis,
skinTone, skinTone,
phoneNumber,
userAvatarData, userAvatarData,
username, username,
usernameEditState, usernameEditState,

View file

@ -21,6 +21,7 @@ import {
} from '../selectors/items'; } from '../selectors/items';
import { import {
getAddStoryData, getAddStoryData,
getHasAnyFailedStorySends,
getSelectedStoryData, getSelectedStoryData,
getStories, getStories,
} from '../selectors/stories'; } from '../selectors/stories';
@ -30,6 +31,8 @@ import { useStoriesActions } from '../ducks/stories';
import { useToastActions } from '../ducks/toast'; import { useToastActions } from '../ducks/toast';
import { useAudioPlayerActions } from '../ducks/audioPlayer'; import { useAudioPlayerActions } from '../ducks/audioPlayer';
import { useItemsActions } from '../ducks/items'; import { useItemsActions } from '../ducks/items';
import { getHasPendingUpdate } from '../selectors/updates';
import { getAppUnreadStats } from '../selectors/nav';
function renderStoryCreator(): JSX.Element { function renderStoryCreator(): JSX.Element {
return <SmartStoryCreator />; return <SmartStoryCreator />;
@ -66,6 +69,9 @@ export function SmartStoriesTab(): JSX.Element | null {
); );
const hasViewReceiptSetting = useSelector(getHasStoryViewReceiptSetting); const hasViewReceiptSetting = useSelector(getHasStoryViewReceiptSetting);
const hasPendingUpdate = useSelector(getHasPendingUpdate);
const hasFailedStorySends = useSelector(getHasAnyFailedStorySends);
const appUnreadStats = useSelector(getAppUnreadStats);
const remoteConfig = useSelector(getRemoteConfig); const remoteConfig = useSelector(getRemoteConfig);
const maxAttachmentSizeInKb = getMaximumAttachmentSizeInKb( const maxAttachmentSizeInKb = getMaximumAttachmentSizeInKb(
@ -92,8 +98,11 @@ export function SmartStoriesTab(): JSX.Element | null {
return ( return (
<StoriesTab <StoriesTab
appUnreadStats={appUnreadStats}
addStoryData={addStoryData} addStoryData={addStoryData}
getPreferredBadge={getPreferredBadge} getPreferredBadge={getPreferredBadge}
hasFailedStorySends={hasFailedStorySends}
hasPendingUpdate={hasPendingUpdate}
hiddenStories={hiddenStories} hiddenStories={hiddenStories}
i18n={i18n} i18n={i18n}
maxAttachmentSizeInKb={maxAttachmentSizeInKb} maxAttachmentSizeInKb={maxAttachmentSizeInKb}

View file

@ -0,0 +1,268 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { countConversationUnreadStats } from '../../util/countUnreadStats';
describe('countConversationUnreadStats', () => {
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,
}
);
}
});
});

View file

@ -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);
}
});
});

View file

@ -158,11 +158,6 @@ describe('pnp/username', function needsName() {
debug('opening avatar context menu'); debug('opening avatar context menu');
await window.getByRole('button', { name: 'Profile' }).click(); 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'); debug('opening username editor');
const profileEditor = window.locator('.ProfileEditor'); const profileEditor = window.locator('.ProfileEditor');
await profileEditor.locator('.ProfileEditor__row >> "Username"').click(); await profileEditor.locator('.ProfileEditor__row >> "Username"').click();

View file

@ -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<ConversationPropsForUnreadStats>,
options: UnreadStatsOptions
): UnreadStats {
return conversations.reduce<UnreadStats>((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
);
}

View file

@ -7,7 +7,7 @@ import type { ConversationType } from '../state/ducks/conversations';
import { parseAndFormatPhoneNumber } from './libphonenumberInstance'; import { parseAndFormatPhoneNumber } from './libphonenumberInstance';
import { WEEK } from './durations'; import { WEEK } from './durations';
import { fuseGetFnRemoveDiacritics, getCachedFuseIndex } from './fuse'; import { fuseGetFnRemoveDiacritics, getCachedFuseIndex } from './fuse';
import { getConversationUnreadCountForAppBadge } from './getConversationUnreadCountForAppBadge'; import { countConversationUnreadStats, hasUnread } from './countUnreadStats';
// Fuse.js scores have order of 0.01 // Fuse.js scores have order of 0.01
const ACTIVE_AT_SCORE_FACTOR = (1 / WEEK) * 0.01; const ACTIVE_AT_SCORE_FACTOR = (1 / WEEK) * 0.01;
@ -73,18 +73,11 @@ COMMANDS.set('groupIdEndsWith', (conversations, query) => {
}); });
COMMANDS.set('unread', conversations => { COMMANDS.set('unread', conversations => {
const canCountMutedConversations = const includeMuted =
window.storage.get('badge-count-muted-conversations') || false; window.storage.get('badge-count-muted-conversations') || false;
return conversations.filter(conversation => {
return conversations.filter(convo => { return hasUnread(
return getConversationUnreadCountForAppBadge( countConversationUnreadStats(conversation, { includeMuted })
{
...convo,
// Difference between redux type and conversation attributes
active_at: convo.activeAt,
},
canCountMutedConversations
); );
}); });
}); });

View file

@ -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;
}

2
ts/window.d.ts vendored
View file

@ -78,7 +78,7 @@ export type IPCType = {
restart: () => void; restart: () => void;
setAutoHideMenuBar: (value: boolean) => void; setAutoHideMenuBar: (value: boolean) => void;
setAutoLaunch: (value: boolean) => Promise<void>; setAutoLaunch: (value: boolean) => Promise<void>;
setBadgeCount: (count: number) => void; setBadge: (badge: number | 'marked-unread') => void;
setMenuBarVisibility: (value: boolean) => void; setMenuBarVisibility: (value: boolean) => void;
showDebugLog: () => void; showDebugLog: () => void;
showPermissionsPopup: ( showPermissionsPopup: (

View file

@ -103,7 +103,7 @@ const IPC: IPCType = {
}, },
setAutoHideMenuBar: autoHide => ipc.send('set-auto-hide-menu-bar', autoHide), setAutoHideMenuBar: autoHide => ipc.send('set-auto-hide-menu-bar', autoHide),
setAutoLaunch: value => ipc.invoke('set-auto-launch', value), 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 => setMenuBarVisibility: visibility =>
ipc.send('set-menu-bar-visibility', visibility), ipc.send('set-menu-bar-visibility', visibility),
showDebugLog: () => { showDebugLog: () => {