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",
"description": "Show in the nav tabs when the nav tabs are visible, hides the nav tabs"
},
"icu:NavTabs__ItemIconLabel--HasError": {
"messageformat": "An error occurred",
"description": "Nav Tabs > Tab has error > Accessibility Text"
},
"icu:NavTabs__ItemIconLabel--UnreadCount": {
"messageformat": "{count, number} unread",
"description": "Nav Tabs > Unread badge > Accessibility Text"
@ -317,7 +321,11 @@
},
"icu:NavTabs__ItemLabel--Settings": {
"messageformat": "Settings",
"description": "Nav Tabs > Settings Button > Accessibility Text"
"description": "Nav Tabs > Settings Button > Label & Accessibility Text"
},
"icu:NavTabs__ItemLabel--Update": {
"messageformat": "Update Signal",
"description": "Nav Tabs > Settings Button > Label & Accessibility Text"
},
"icu:NavTabs__ItemLabel--Profile": {
"messageformat": "Profile",

View file

@ -2132,9 +2132,22 @@ app.on('will-finish-launching', () => {
});
});
ipc.on('set-badge-count', (_event: Electron.Event, count: number) => {
app.badgeCount = count;
});
ipc.on(
'set-badge',
(_event: Electron.Event, badge: number | 'marked-unread') => {
if (badge === 'marked-unread') {
if (process.platform === 'darwin') {
// Will show a ● on macOS when undefined
app.setBadgeCount(undefined);
} else {
// All other OS's need a number
app.setBadgeCount(1);
}
} else {
app.setBadgeCount(badge);
}
}
);
ipc.on('remove-setup-menu-items', () => {
setupMenu();

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 */
.module-calling-device-selection {

View file

@ -46,6 +46,30 @@
flex-direction: column;
}
.CallsTab__EmptyStateIcon {
width: 56px;
height: 56px;
opacity: 0.7;
@include light-theme {
@include color-svg('../images/icons/v3/phone/phone.svg', $color-gray-60);
}
@include dark-theme {
@include color-svg('../images/icons/v3/phone/phone.svg', $color-gray-25);
}
}
.CallsTab__EmptyStateLabel {
margin-block: 12px 0;
margin-inline: 0;
opacity: 0.7;
@include light-theme {
color: $color-gray-60;
}
@include dark-theme {
color: $color-gray-25;
}
}
.CallsTab__ConversationCallDetails {
display: block;
overflow: auto;
@ -208,10 +232,6 @@
font-weight: bold;
}
.CallsList__ItemCallInfo {
@include font-body-1;
}
// Override .ListTile__subtitle so ellipsis is correct color
.CallsList__Item--missed .ListTile__subtitle {
color: $color-accent-red;
@ -219,7 +239,12 @@
// Override .ListTile
.ListTile.CallsList__ItemTile {
padding-block: 12px;
padding-block: 10px;
// Override .ListTile__subtitle with correct font size
.ListTile__subtitle {
@include font-body-2;
}
}
.CallsList__Item--selected .CallsList__ItemTile {

View file

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

View file

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

View file

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

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

View file

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

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;
}>;
const CALL_LIST_ITEM_ROW_HEIGHT = 62;
function rowHeight() {
return ListTile.heightFull;
return CALL_LIST_ITEM_ROW_HEIGHT;
}
export function CallsList({
@ -275,6 +277,7 @@ export function CallsList({
return (
<div key={key} style={style}>
<ListTile
moduleClassName="CallsList__ItemTile"
leading={<div className="CallsList__LoadingAvatar" />}
title={
<span className="CallsList__LoadingText CallsList__LoadingText--title" />

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,32 +1,91 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Key, ReactNode } from 'react';
import React, { useEffect, useState } from 'react';
import type { Key } from 'react';
import React from 'react';
import { Tabs, TabList, Tab, TabPanels, TabPanel } from 'react-aria-components';
import classNames from 'classnames';
import { usePopper } from 'react-popper';
import { createPortal } from 'react-dom';
import { Avatar, AvatarSize } from './Avatar';
import type { LocalizerType, ThemeType } from '../types/Util';
import type { ConversationType } from '../state/ducks/conversations';
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 { Tooltip, TooltipPlacement } from './Tooltip';
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<{
i18n: LocalizerType;
badge?: ReactNode;
iconClassName: string;
id: NavTab;
hasError?: boolean;
label: string;
unreadStats: UnreadStats | null;
}>;
function NavTabsItem({ i18n, badge, iconClassName, id, label }: NavTabProps) {
function NavTabsItem({
i18n,
iconClassName,
id,
label,
unreadStats,
hasError,
}: NavTabProps) {
const isRTL = i18n.getLocaleDirection() === 'rtl';
return (
<Tab id={id} data-testid={`NavTabsItem--${id}`} className="NavTabs__Item">
@ -43,7 +102,11 @@ function NavTabsItem({ i18n, badge, iconClassName, id, label }: NavTabProps) {
role="presentation"
className={`NavTabs__ItemIcon ${iconClassName}`}
/>
{badge && <span className="NavTabs__ItemBadge">{badge}</span>}
<NavTabsItemBadges
i18n={i18n}
unreadStats={unreadStats}
hasError={hasError}
/>
</span>
</span>
</Tooltip>
@ -52,19 +115,28 @@ function NavTabsItem({ i18n, badge, iconClassName, id, label }: NavTabProps) {
}
export type NavTabPanelProps = Readonly<{
appUnreadStats: UnreadStats;
collapsed: boolean;
hasFailedStorySends: boolean;
hasPendingUpdate: boolean;
onToggleCollapse(collapsed: boolean): void;
}>;
export type NavTabsToggleProps = Readonly<{
appUnreadStats: UnreadStats | null;
i18n: LocalizerType;
hasFailedStorySends: boolean;
hasPendingUpdate: boolean;
navTabsCollapsed: boolean;
onToggleNavTabsCollapse(navTabsCollapsed: boolean): void;
}>;
export function NavTabsToggle({
i18n,
hasFailedStorySends,
hasPendingUpdate,
navTabsCollapsed,
appUnreadStats,
onToggleNavTabsCollapse,
}: NavTabsToggleProps): JSX.Element {
function handleToggle() {
@ -87,11 +159,19 @@ export function NavTabsToggle({
delay={600}
>
<span className="NavTabs__ItemButton">
<span className="NavTabs__ItemContent">
<span
role="presentation"
className="NavTabs__ItemIcon NavTabs__ItemIcon--Menu"
/>
<span className="NavTabs__ItemLabel">{label}</span>
<NavTabsItemBadges
i18n={i18n}
unreadStats={appUnreadStats}
hasError={hasFailedStorySends}
hasPendingUpdate={hasPendingUpdate}
/>
</span>
</span>
</Tooltip>
</button>
@ -116,6 +196,7 @@ export type NavTabsProps = Readonly<{
selectedNavTab: NavTab;
storiesEnabled: boolean;
theme: ThemeType;
unreadCallsCount: number;
unreadConversationsStats: UnreadStats;
unreadStoriesCount: number;
}>;
@ -138,6 +219,7 @@ export function NavTabs({
selectedNavTab,
storiesEnabled,
theme,
unreadCallsCount,
unreadConversationsStats,
unreadStoriesCount,
}: NavTabsProps): JSX.Element {
@ -147,63 +229,6 @@ export function NavTabs({
const isRTL = i18n.getLocaleDirection() === 'rtl';
const [targetElement, setTargetElement] = useState<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 (
<Tabs orientation="vertical" className="NavTabs__Container">
<nav
@ -215,6 +240,10 @@ export function NavTabs({
i18n={i18n}
navTabsCollapsed={navTabsCollapsed}
onToggleNavTabsCollapse={onToggleNavTabsCollapse}
// These are all shown elsewhere when nav tabs are shown
hasFailedStorySends={false}
hasPendingUpdate={false}
appUnreadStats={null}
/>
<TabList
className="NavTabs__TabList"
@ -226,31 +255,18 @@ export function NavTabs({
id={NavTab.Chats}
label="Chats"
iconClassName="NavTabs__ItemIcon--Chats"
badge={
// 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
}
unreadStats={unreadConversationsStats}
/>
<NavTabsItem
i18n={i18n}
id={NavTab.Calls}
label="Calls"
iconClassName="NavTabs__ItemIcon--Calls"
unreadStats={{
unreadCount: unreadCallsCount,
unreadMentionsCount: 0,
markedUnread: false,
}}
/>
{storiesEnabled && (
<NavTabsItem
@ -258,22 +274,53 @@ export function NavTabs({
id={NavTab.Stories}
label="Stories"
iconClassName="NavTabs__ItemIcon--Stories"
badge={
// eslint-disable-next-line no-nested-ternary
hasFailedStorySends
? '!'
: unreadStoriesCount > 0
? unreadStoriesCount
: null
}
hasError={hasFailedStorySends}
unreadStats={{
unreadCount: unreadStoriesCount,
unreadMentionsCount: 0,
markedUnread: false,
}}
/>
)}
</TabList>
<div className="NavTabs__Misc">
<ContextMenu
i18n={i18n}
menuOptions={[
{
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
>
{({ openMenu, onKeyDown, ref }) => {
return (
<button
type="button"
className="NavTabs__Item"
onClick={onShowSettings}
onKeyDown={event => {
if (hasPendingUpdate) {
onKeyDown(event);
}
}}
onClick={event => {
if (hasPendingUpdate) {
openMenu(event);
} else {
onShowSettings();
}
}}
>
<Tooltip
content={i18n('icu:NavTabs__ItemLabel--Settings')}
@ -281,7 +328,8 @@ export function NavTabs({
direction={TooltipPlacement.Right}
delay={600}
>
<span className="NavTabs__ItemButton">
<span className="NavTabs__ItemButton" ref={ref}>
<span className="NavTabs__ItemContent">
<span
role="presentation"
className="NavTabs__ItemIcon NavTabs__ItemIcon--Settings"
@ -289,16 +337,26 @@ export function NavTabs({
<span className="NavTabs__ItemLabel">
{i18n('icu:NavTabs__ItemLabel--Settings')}
</span>
<NavTabsItemBadges
i18n={i18n}
unreadStats={null}
hasPendingUpdate={hasPendingUpdate}
/>
</span>
</span>
</Tooltip>
</button>
);
}}
</ContextMenu>
<button
type="button"
className="NavTabs__Item NavTabs__Item--Profile"
data-supertab
onClick={() => {
setShowAvatarPopup(true);
onToggleProfileEditor();
}}
aria-label={i18n('icu:NavTabs__ItemLabel--Profile')}
>
@ -308,7 +366,7 @@ export function NavTabs({
direction={isRTL ? TooltipPlacement.Left : TooltipPlacement.Right}
delay={600}
>
<span className="NavTabs__ItemButton" ref={setTargetElement}>
<span className="NavTabs__ItemButton">
<span className="NavTabs__ItemContent">
<Avatar
acceptedMessageRequest
@ -328,49 +386,10 @@ export function NavTabs({
sharedGroupNames={[]}
size={AvatarSize.TWENTY_EIGHT}
/>
{hasPendingUpdate && <div className="NavTabs__AvatarBadge" />}
</span>
</span>
</Tooltip>
</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>
</nav>
<TabPanels>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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 { canEditMessage } from '../../util/canEditMessage';
import { isOutgoing } from '../../messages/helpers';
import {
countAllConversationsUnreadStats,
type UnreadStats,
} from '../../util/countUnreadStats';
export type ConversationWithStoriesType = ConversationType & {
hasStories?: HasStories;
@ -532,38 +536,13 @@ export const getAllGroupsWithInviteAccess = createSelector(
})
);
export type UnreadStats = Readonly<{
unreadCount: number;
unreadMentionsCount: number;
markedUnread: boolean;
}>;
export const getAllConversationsUnreadStats = createSelector(
getLeftPaneLists,
(leftPaneLists: LeftPaneLists): UnreadStats => {
let unreadCount = 0;
let unreadMentionsCount = 0;
let markedUnread = false;
function count(conversations: ReadonlyArray<ConversationType>) {
conversations.forEach(conversation => {
if (conversation.unreadCount != null) {
unreadCount += conversation.unreadCount;
}
if (conversation.unreadMentionsCount != null) {
unreadMentionsCount += conversation.unreadMentionsCount;
}
if (conversation.markedUnread) {
markedUnread = true;
}
getAllConversations,
(conversations): UnreadStats => {
return countAllConversationsUnreadStats(conversations, {
includeMuted: false,
});
}
count(leftPaneLists.pinnedConversations);
count(leftPaneLists.conversations);
return { unreadCount, unreadMentionsCount, markedUnread };
}
);
/**

View file

@ -4,6 +4,9 @@
import { createSelector } from 'reselect';
import type { StateType } from '../reducer';
import type { NavStateType } from '../ducks/nav';
import { getAllConversationsUnreadStats } from './conversations';
import { getStoriesNotificationCount } from './stories';
import type { UnreadStats } from '../../util/countUnreadStats';
function getNav(state: StateType): NavStateType {
return state.nav;
@ -12,3 +15,17 @@ function getNav(state: StateType): NavStateType {
export const getSelectedNavTab = createSelector(getNav, nav => {
return nav.selectedNavTab;
});
export const getAppUnreadStats = createSelector(
getAllConversationsUnreadStats,
getStoriesNotificationCount,
(conversationsUnreadStats, storiesNotificationCount): UnreadStats => {
return {
// Note: Conversation unread stats includes the call history unread count.
unreadCount:
conversationsUnreadStats.unreadCount + storiesNotificationCount,
unreadMentionsCount: conversationsUnreadStats.unreadMentionsCount,
markedUnread: conversationsUnreadStats.markedUnread,
};
}
);

View file

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

View file

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

View file

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

View file

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

View file

@ -21,6 +21,7 @@ import {
} from '../selectors/items';
import {
getAddStoryData,
getHasAnyFailedStorySends,
getSelectedStoryData,
getStories,
} from '../selectors/stories';
@ -30,6 +31,8 @@ import { useStoriesActions } from '../ducks/stories';
import { useToastActions } from '../ducks/toast';
import { useAudioPlayerActions } from '../ducks/audioPlayer';
import { useItemsActions } from '../ducks/items';
import { getHasPendingUpdate } from '../selectors/updates';
import { getAppUnreadStats } from '../selectors/nav';
function renderStoryCreator(): JSX.Element {
return <SmartStoryCreator />;
@ -66,6 +69,9 @@ export function SmartStoriesTab(): JSX.Element | null {
);
const hasViewReceiptSetting = useSelector(getHasStoryViewReceiptSetting);
const hasPendingUpdate = useSelector(getHasPendingUpdate);
const hasFailedStorySends = useSelector(getHasAnyFailedStorySends);
const appUnreadStats = useSelector(getAppUnreadStats);
const remoteConfig = useSelector(getRemoteConfig);
const maxAttachmentSizeInKb = getMaximumAttachmentSizeInKb(
@ -92,8 +98,11 @@ export function SmartStoriesTab(): JSX.Element | null {
return (
<StoriesTab
appUnreadStats={appUnreadStats}
addStoryData={addStoryData}
getPreferredBadge={getPreferredBadge}
hasFailedStorySends={hasFailedStorySends}
hasPendingUpdate={hasPendingUpdate}
hiddenStories={hiddenStories}
i18n={i18n}
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');
await window.getByRole('button', { name: 'Profile' }).click();
debug('opening profile editor');
await window
.locator('.module-avatar-popup .module-avatar-popup__profile')
.click();
debug('opening username editor');
const profileEditor = window.locator('.ProfileEditor');
await profileEditor.locator('.ProfileEditor__row >> "Username"').click();

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

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;
setAutoHideMenuBar: (value: boolean) => void;
setAutoLaunch: (value: boolean) => Promise<void>;
setBadgeCount: (count: number) => void;
setBadge: (badge: number | 'marked-unread') => void;
setMenuBarVisibility: (value: boolean) => void;
showDebugLog: () => void;
showPermissionsPopup: (

View file

@ -103,7 +103,7 @@ const IPC: IPCType = {
},
setAutoHideMenuBar: autoHide => ipc.send('set-auto-hide-menu-bar', autoHide),
setAutoLaunch: value => ipc.invoke('set-auto-launch', value),
setBadgeCount: count => ipc.send('set-badge-count', count),
setBadge: badge => ipc.send('set-badge', badge),
setMenuBarVisibility: visibility =>
ipc.send('set-menu-bar-visibility', visibility),
showDebugLog: () => {