Update nav tab badges, fix several call tabs issues
This commit is contained in:
parent
ed6ffb695a
commit
9c7dc22a23
43 changed files with 1095 additions and 936 deletions
|
@ -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",
|
||||
|
|
19
app/main.ts
19
app/main.ts
|
@ -2132,9 +2132,22 @@ app.on('will-finish-launching', () => {
|
|||
});
|
||||
});
|
||||
|
||||
ipc.on('set-badge-count', (_event: Electron.Event, count: number) => {
|
||||
app.badgeCount = count;
|
||||
});
|
||||
ipc.on(
|
||||
'set-badge',
|
||||
(_event: Electron.Event, badge: number | 'marked-unread') => {
|
||||
if (badge === 'marked-unread') {
|
||||
if (process.platform === 'darwin') {
|
||||
// Will show a ● on macOS when undefined
|
||||
app.setBadgeCount(undefined);
|
||||
} else {
|
||||
// All other OS's need a number
|
||||
app.setBadgeCount(1);
|
||||
}
|
||||
} else {
|
||||
app.setBadgeCount(badge);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipc.on('remove-setup-menu-items', () => {
|
||||
setupMenu();
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,6 +25,7 @@
|
|||
align-items: start;
|
||||
flex-shrink: 0;
|
||||
padding-bottom: 6px;
|
||||
-webkit-app-region: drag;
|
||||
|
||||
.NavTabs__Toggle {
|
||||
width: $NavTabs__width;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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} />;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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" />
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -4432,6 +4432,7 @@ export class ConversationModel extends window.Backbone
|
|||
unreadMentionsCount,
|
||||
});
|
||||
window.Signal.Data.updateConversation(this.attributes);
|
||||
window.reduxActions.callHistory.updateCallHistoryUnreadCount();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -29,3 +29,10 @@ export const getCallHistorySelector = createSelector(
|
|||
};
|
||||
}
|
||||
);
|
||||
|
||||
export const getCallHistoryUnreadCount = createSelector(
|
||||
getCallHistory,
|
||||
callHistory => {
|
||||
return callHistory.unreadCount;
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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 };
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
|
|
@ -42,3 +42,8 @@ export const isOSUnsupported = createSelector(
|
|||
getUpdatesState,
|
||||
({ dialogType }) => dialogType === DialogType.UnsupportedOS
|
||||
);
|
||||
|
||||
export const getHasPendingUpdate = createSelector(
|
||||
getUpdatesState,
|
||||
({ didSnooze }) => didSnooze === true
|
||||
);
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}
|
||||
|
|
268
ts/test-both/util/countUnreadStats_test.ts
Normal file
268
ts/test-both/util/countUnreadStats_test.ts
Normal 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,
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
|
|
92
ts/util/countUnreadStats.ts
Normal file
92
ts/util/countUnreadStats.ts
Normal 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
|
||||
);
|
||||
}
|
|
@ -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 })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
2
ts/window.d.ts
vendored
|
@ -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: (
|
||||
|
|
|
@ -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: () => {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue