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",
|
"messageformat": "Hide Tabs",
|
||||||
"description": "Show in the nav tabs when the nav tabs are visible, hides the nav tabs"
|
"description": "Show in the nav tabs when the nav tabs are visible, hides the nav tabs"
|
||||||
},
|
},
|
||||||
|
"icu:NavTabs__ItemIconLabel--HasError": {
|
||||||
|
"messageformat": "An error occurred",
|
||||||
|
"description": "Nav Tabs > Tab has error > Accessibility Text"
|
||||||
|
},
|
||||||
"icu:NavTabs__ItemIconLabel--UnreadCount": {
|
"icu:NavTabs__ItemIconLabel--UnreadCount": {
|
||||||
"messageformat": "{count, number} unread",
|
"messageformat": "{count, number} unread",
|
||||||
"description": "Nav Tabs > Unread badge > Accessibility Text"
|
"description": "Nav Tabs > Unread badge > Accessibility Text"
|
||||||
|
@ -317,7 +321,11 @@
|
||||||
},
|
},
|
||||||
"icu:NavTabs__ItemLabel--Settings": {
|
"icu:NavTabs__ItemLabel--Settings": {
|
||||||
"messageformat": "Settings",
|
"messageformat": "Settings",
|
||||||
"description": "Nav Tabs > Settings Button > Accessibility Text"
|
"description": "Nav Tabs > Settings Button > Label & Accessibility Text"
|
||||||
|
},
|
||||||
|
"icu:NavTabs__ItemLabel--Update": {
|
||||||
|
"messageformat": "Update Signal",
|
||||||
|
"description": "Nav Tabs > Settings Button > Label & Accessibility Text"
|
||||||
},
|
},
|
||||||
"icu:NavTabs__ItemLabel--Profile": {
|
"icu:NavTabs__ItemLabel--Profile": {
|
||||||
"messageformat": "Profile",
|
"messageformat": "Profile",
|
||||||
|
|
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) => {
|
ipc.on(
|
||||||
app.badgeCount = count;
|
'set-badge',
|
||||||
});
|
(_event: Electron.Event, badge: number | 'marked-unread') => {
|
||||||
|
if (badge === 'marked-unread') {
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
// Will show a ● on macOS when undefined
|
||||||
|
app.setBadgeCount(undefined);
|
||||||
|
} else {
|
||||||
|
// All other OS's need a number
|
||||||
|
app.setBadgeCount(1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
app.setBadgeCount(badge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
ipc.on('remove-setup-menu-items', () => {
|
ipc.on('remove-setup-menu-items', () => {
|
||||||
setupMenu();
|
setupMenu();
|
||||||
|
|
|
@ -6364,212 +6364,6 @@ button.module-image__border-overlay:focus {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Module: Avatar Popup
|
|
||||||
|
|
||||||
.module-avatar-popup {
|
|
||||||
min-width: 240px;
|
|
||||||
max-width: 320px;
|
|
||||||
|
|
||||||
border-radius: 4px;
|
|
||||||
padding-bottom: 4px;
|
|
||||||
|
|
||||||
@include popper-shadow;
|
|
||||||
|
|
||||||
@include light-theme {
|
|
||||||
color: $color-gray-90;
|
|
||||||
background-color: $color-white;
|
|
||||||
}
|
|
||||||
@include dark-theme {
|
|
||||||
color: $color-gray-05;
|
|
||||||
background-color: $color-gray-75;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.module-avatar-popup__profile {
|
|
||||||
@include button-reset();
|
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
@include light-theme {
|
|
||||||
&:hover {
|
|
||||||
background-color: $color-gray-05;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@include dark-theme {
|
|
||||||
&:hover {
|
|
||||||
background-color: $color-gray-60;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@include keyboard-mode {
|
|
||||||
&:hover {
|
|
||||||
background-color: inherit;
|
|
||||||
}
|
|
||||||
&:focus {
|
|
||||||
background-color: $color-gray-05;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@include dark-keyboard-mode {
|
|
||||||
&:hover {
|
|
||||||
background-color: inherit;
|
|
||||||
}
|
|
||||||
&:focus {
|
|
||||||
background-color: $color-gray-60;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.module-avatar-popup__profile {
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.module-avatar-popup__profile__text {
|
|
||||||
margin-inline-start: 10px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.module-avatar-popup__profile__name {
|
|
||||||
@include font-body-1-bold;
|
|
||||||
}
|
|
||||||
.module-avatar-popup__profile__number {
|
|
||||||
@include font-subtitle;
|
|
||||||
|
|
||||||
@include light-theme {
|
|
||||||
color: $color-gray-60;
|
|
||||||
}
|
|
||||||
@include dark-theme {
|
|
||||||
color: $color-gray-25;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.module-avatar-popup__profile__name,
|
|
||||||
.module-avatar-popup__profile__number {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.module-avatar-popup__divider {
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
|
|
||||||
height: 1px;
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
|
|
||||||
@include light-theme {
|
|
||||||
background-color: $color-gray-15;
|
|
||||||
}
|
|
||||||
@include dark-theme {
|
|
||||||
background-color: $color-gray-60;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.module-avatar-popup__item {
|
|
||||||
@include font-body-2;
|
|
||||||
@include button-reset;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
width: 100%;
|
|
||||||
height: 32px;
|
|
||||||
padding: 6px;
|
|
||||||
|
|
||||||
@include light-theme {
|
|
||||||
&:hover {
|
|
||||||
background-color: $color-gray-05;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@include keyboard-mode {
|
|
||||||
&:hover {
|
|
||||||
background-color: inherit;
|
|
||||||
}
|
|
||||||
&:focus {
|
|
||||||
background-color: $color-gray-05;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@include dark-theme {
|
|
||||||
&:hover {
|
|
||||||
background-color: $color-gray-60;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@include dark-keyboard-mode {
|
|
||||||
&:hover {
|
|
||||||
background-color: inherit;
|
|
||||||
}
|
|
||||||
&:focus {
|
|
||||||
background-color: $color-gray-60;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.module-avatar-popup__item__icon {
|
|
||||||
margin-inline-start: 6px;
|
|
||||||
|
|
||||||
height: 16px;
|
|
||||||
width: 16px;
|
|
||||||
|
|
||||||
&--update {
|
|
||||||
@include light-theme {
|
|
||||||
@include color-svg(
|
|
||||||
'../images/icons/v3/refresh/refresh.svg',
|
|
||||||
$color-gray-75
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@include dark-theme {
|
|
||||||
@include color-svg(
|
|
||||||
'../images/icons/v3/refresh/refresh.svg',
|
|
||||||
$color-gray-15
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.module-avatar-popup__item__icon-settings {
|
|
||||||
@include light-theme {
|
|
||||||
@include color-svg(
|
|
||||||
'../images/icons/v3/settings/settings-compact.svg',
|
|
||||||
$color-gray-75
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@include dark-theme {
|
|
||||||
@include color-svg(
|
|
||||||
'../images/icons/v3/settings/settings-compact.svg',
|
|
||||||
$color-gray-15
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.module-avatar-popup__item__icon-archive {
|
|
||||||
@include light-theme {
|
|
||||||
@include color-svg(
|
|
||||||
'../images/icons/v3/archive/archive-compact.svg',
|
|
||||||
$color-gray-75
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@include dark-theme {
|
|
||||||
@include color-svg(
|
|
||||||
'../images/icons/v3/archive/archive-compact.svg',
|
|
||||||
$color-gray-15
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.module-avatar-popup__item__text {
|
|
||||||
flex-grow: 1;
|
|
||||||
margin-inline-start: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.module-avatar-popup__item--badge {
|
|
||||||
background: $color-ultramarine;
|
|
||||||
border-radius: 100%;
|
|
||||||
height: 8px;
|
|
||||||
margin-inline-end: 10px;
|
|
||||||
width: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Calling: Device Selection */
|
/* Calling: Device Selection */
|
||||||
|
|
||||||
.module-calling-device-selection {
|
.module-calling-device-selection {
|
||||||
|
|
|
@ -46,6 +46,30 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.CallsTab__EmptyStateIcon {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
opacity: 0.7;
|
||||||
|
@include light-theme {
|
||||||
|
@include color-svg('../images/icons/v3/phone/phone.svg', $color-gray-60);
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
@include color-svg('../images/icons/v3/phone/phone.svg', $color-gray-25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.CallsTab__EmptyStateLabel {
|
||||||
|
margin-block: 12px 0;
|
||||||
|
margin-inline: 0;
|
||||||
|
opacity: 0.7;
|
||||||
|
@include light-theme {
|
||||||
|
color: $color-gray-60;
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
color: $color-gray-25;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.CallsTab__ConversationCallDetails {
|
.CallsTab__ConversationCallDetails {
|
||||||
display: block;
|
display: block;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
@ -208,10 +232,6 @@
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.CallsList__ItemCallInfo {
|
|
||||||
@include font-body-1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Override .ListTile__subtitle so ellipsis is correct color
|
// Override .ListTile__subtitle so ellipsis is correct color
|
||||||
.CallsList__Item--missed .ListTile__subtitle {
|
.CallsList__Item--missed .ListTile__subtitle {
|
||||||
color: $color-accent-red;
|
color: $color-accent-red;
|
||||||
|
@ -219,7 +239,12 @@
|
||||||
|
|
||||||
// Override .ListTile
|
// Override .ListTile
|
||||||
.ListTile.CallsList__ItemTile {
|
.ListTile.CallsList__ItemTile {
|
||||||
padding-block: 12px;
|
padding-block: 10px;
|
||||||
|
|
||||||
|
// Override .ListTile__subtitle with correct font size
|
||||||
|
.ListTile__subtitle {
|
||||||
|
@include font-body-2;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.CallsList__Item--selected .CallsList__ItemTile {
|
.CallsList__Item--selected .CallsList__ItemTile {
|
||||||
|
|
|
@ -5,9 +5,14 @@
|
||||||
&__distribution {
|
&__distribution {
|
||||||
&__title {
|
&__title {
|
||||||
@include font-body-1-bold;
|
@include font-body-1-bold;
|
||||||
color: $color-gray-05;
|
|
||||||
margin-block: 24px 8px;
|
margin-block: 24px 8px;
|
||||||
margin-inline: 10px;
|
margin-inline: 10px;
|
||||||
|
@include light-theme() {
|
||||||
|
color: $color-gray-90;
|
||||||
|
}
|
||||||
|
@include dark-theme() {
|
||||||
|
color: $color-gray-05;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -23,21 +28,36 @@
|
||||||
padding-inline-end: 10px;
|
padding-inline-end: 10px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: $color-gray-65;
|
@include light-theme {
|
||||||
|
background: $color-gray-15;
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
background: $color-gray-65;
|
||||||
|
}
|
||||||
|
|
||||||
& .MyStories__story__download,
|
& .MyStories__story__download,
|
||||||
.MyStories__story__more__button {
|
.MyStories__story__more__button {
|
||||||
background: $color-gray-60;
|
@include light-theme() {
|
||||||
|
background: $color-gray-20;
|
||||||
|
}
|
||||||
|
@include dark-theme() {
|
||||||
|
background: $color-gray-60;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__details {
|
&__details {
|
||||||
@include font-body-1-bold;
|
@include font-body-1-bold;
|
||||||
color: $color-gray-05;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin-inline-start: 12px;
|
margin-inline-start: 12px;
|
||||||
|
@include light-theme() {
|
||||||
|
color: $color-gray-90;
|
||||||
|
}
|
||||||
|
@include dark-theme() {
|
||||||
|
color: $color-gray-05;
|
||||||
|
}
|
||||||
|
|
||||||
&__failed {
|
&__failed {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
@ -58,80 +78,142 @@
|
||||||
&__button {
|
&__button {
|
||||||
@include button-reset;
|
@include button-reset;
|
||||||
@include font-subtitle;
|
@include font-subtitle;
|
||||||
color: $color-gray-25;
|
@include light-theme {
|
||||||
|
color: $color-gray-90;
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
color: $color-gray-25;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__timestamp {
|
&__timestamp {
|
||||||
color: $color-gray-25;
|
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
|
@include light-theme {
|
||||||
|
color: $color-gray-90;
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
color: $color-gray-25;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__download {
|
&__download {
|
||||||
@include button-reset;
|
@include button-reset;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: $color-gray-65;
|
|
||||||
border-radius: 100%;
|
border-radius: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 28px;
|
width: 28px;
|
||||||
|
@include light-theme {
|
||||||
|
background: $color-gray-20;
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
background: $color-gray-65;
|
||||||
|
}
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
@include color-svg(
|
|
||||||
'../images/icons/v3/save/save-compact.svg',
|
|
||||||
$color-gray-15
|
|
||||||
);
|
|
||||||
content: '';
|
content: '';
|
||||||
height: 18px;
|
height: 18px;
|
||||||
width: 18px;
|
width: 18px;
|
||||||
|
@include light-theme {
|
||||||
|
@include color-svg(
|
||||||
|
'../images/icons/v3/save/save-compact.svg',
|
||||||
|
$color-black
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
@include color-svg(
|
||||||
|
'../images/icons/v3/save/save-compact.svg',
|
||||||
|
$color-gray-15
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: $color-gray-75 !important;
|
@include light-theme() {
|
||||||
|
background: $color-white !important;
|
||||||
|
}
|
||||||
|
@include dark-theme() {
|
||||||
|
background: $color-gray-75 !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__more__button {
|
&__more__button {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: $color-gray-65;
|
|
||||||
border-radius: 100%;
|
border-radius: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-inline-start: 16px;
|
margin-inline-start: 16px;
|
||||||
width: 28px;
|
width: 28px;
|
||||||
|
@include light-theme {
|
||||||
|
background: $color-gray-15;
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
background: $color-gray-65;
|
||||||
|
}
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
@include color-svg(
|
|
||||||
'../images/icons/v3/more/more-compact.svg',
|
|
||||||
$color-gray-15
|
|
||||||
);
|
|
||||||
content: '';
|
content: '';
|
||||||
height: 18px;
|
height: 18px;
|
||||||
width: 18px;
|
width: 18px;
|
||||||
|
@include light-theme {
|
||||||
|
@include color-svg(
|
||||||
|
'../images/icons/v3/more/more-compact.svg',
|
||||||
|
$color-black
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
@include color-svg(
|
||||||
|
'../images/icons/v3/more/more-compact.svg',
|
||||||
|
$color-gray-15
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: $color-gray-75 !important;
|
@include light-theme() {
|
||||||
|
background: $color-white !important;
|
||||||
|
}
|
||||||
|
@include dark-theme() {
|
||||||
|
background: $color-gray-75 !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__icon {
|
&__icon {
|
||||||
&--forward {
|
&--forward {
|
||||||
@include color-svg(
|
@include light-theme() {
|
||||||
'../images/icons/v3/forward/forward-compact.svg',
|
@include color-svg(
|
||||||
$color-white
|
'../images/icons/v3/forward/forward-compact.svg',
|
||||||
);
|
$color-black
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@include dark-theme() {
|
||||||
|
@include color-svg(
|
||||||
|
'../images/icons/v3/forward/forward-compact.svg',
|
||||||
|
$color-white
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&--delete {
|
&--delete {
|
||||||
@include color-svg(
|
@include light-theme() {
|
||||||
'../images/icons/v3/trash/trash-compact.svg',
|
@include color-svg(
|
||||||
$color-white
|
'../images/icons/v3/trash/trash-compact.svg',
|
||||||
);
|
$color-black
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@include dark-theme() {
|
||||||
|
@include color-svg(
|
||||||
|
'../images/icons/v3/trash/trash-compact.svg',
|
||||||
|
$color-white
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -157,19 +239,26 @@
|
||||||
@include light-theme {
|
@include light-theme {
|
||||||
border-color: $color-gray-04;
|
border-color: $color-gray-04;
|
||||||
}
|
}
|
||||||
|
|
||||||
@include dark-theme {
|
@include dark-theme {
|
||||||
border-color: $color-gray-80;
|
border-color: $color-gray-80;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
content: '';
|
content: '';
|
||||||
@include color-svg(
|
|
||||||
'../images/icons/v3/plus/plus-compact-bold.svg',
|
|
||||||
$color-white
|
|
||||||
);
|
|
||||||
height: 12px;
|
height: 12px;
|
||||||
width: 12px;
|
width: 12px;
|
||||||
|
@include light-theme {
|
||||||
|
@include color-svg(
|
||||||
|
'../images/icons/v3/plus/plus-compact-bold.svg',
|
||||||
|
$color-black
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
@include color-svg(
|
||||||
|
'../images/icons/v3/plus/plus-compact-bold.svg',
|
||||||
|
$color-white
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,6 +25,7 @@
|
||||||
align-items: start;
|
align-items: start;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding-bottom: 6px;
|
padding-bottom: 6px;
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
|
||||||
.NavTabs__Toggle {
|
.NavTabs__Toggle {
|
||||||
width: $NavTabs__width;
|
width: $NavTabs__width;
|
||||||
|
|
|
@ -110,7 +110,7 @@ $NavTabs__ProfileAvatar__size: 28px;
|
||||||
@include sr-only;
|
@include sr-only;
|
||||||
}
|
}
|
||||||
|
|
||||||
.NavTabs__ItemBadge {
|
.NavTabs__ItemUnreadBadge {
|
||||||
@include rounded-corners;
|
@include rounded-corners;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background-color: $color-accent-red;
|
background-color: $color-accent-red;
|
||||||
|
@ -131,6 +131,17 @@ $NavTabs__ProfileAvatar__size: 28px;
|
||||||
word-break: keep-all;
|
word-break: keep-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.NavTabs__ItemUpdateBadge {
|
||||||
|
background: $color-ultramarine;
|
||||||
|
border-radius: 100%;
|
||||||
|
border: 1px solid $color-white;
|
||||||
|
height: 8px;
|
||||||
|
width: 8px;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
inset-inline-end: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.NavTabs__ItemIcon {
|
.NavTabs__ItemIcon {
|
||||||
display: block;
|
display: block;
|
||||||
width: $NavTabs__ItemIcon__size;
|
width: $NavTabs__ItemIcon__size;
|
||||||
|
@ -205,13 +216,26 @@ $NavTabs__ProfileAvatar__size: 28px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.NavTabs__AvatarBadge {
|
.NavTabs__ContextMenuIcon--Settings {
|
||||||
background: $color-ultramarine;
|
@include dark-theme {
|
||||||
border-radius: 100%;
|
@include color-svg(
|
||||||
border: 1px solid $color-white;
|
'../images/icons/v3/settings/settings.svg',
|
||||||
height: 8px;
|
$color-white
|
||||||
width: 8px;
|
);
|
||||||
position: absolute;
|
}
|
||||||
top: 0;
|
@include light-theme {
|
||||||
inset-inline-end: 0;
|
@include color-svg(
|
||||||
|
'../images/icons/v3/settings/settings.svg',
|
||||||
|
$color-black
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.NavTabs__ContextMenuIcon--Update {
|
||||||
|
@include dark-theme {
|
||||||
|
@include color-svg('../images/icons/v3/refresh/refresh.svg', $color-white);
|
||||||
|
}
|
||||||
|
@include light-theme {
|
||||||
|
@include color-svg('../images/icons/v3/refresh/refresh.svg', $color-black);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
&__placeholder {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: $color-gray-45;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
opacity: 0.7;
|
||||||
|
@include light-theme {
|
||||||
|
color: $color-gray-60;
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
color: $color-gray-25;
|
||||||
|
}
|
||||||
|
|
||||||
&__stories {
|
&__stories {
|
||||||
height: 56px;
|
height: 56px;
|
||||||
margin-bottom: 22px;
|
margin-bottom: 22px;
|
||||||
width: 56px;
|
width: 56px;
|
||||||
|
@include light-theme {
|
||||||
@include color-svg(
|
@include color-svg('../images/icons/v3/stories/stories-display.svg', $color-gray-60);
|
||||||
'../images/icons/v3/stories/stories-display.svg',
|
}
|
||||||
$color-gray-45
|
@include dark-theme {
|
||||||
);
|
@include color-svg('../images/icons/v3/stories/stories-display.svg', $color-gray-25);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -22,7 +22,6 @@ import { maybeDeriveGroupV2Id } from './groups';
|
||||||
import { assertDev, strictAssert } from './util/assert';
|
import { assertDev, strictAssert } from './util/assert';
|
||||||
import { drop } from './util/drop';
|
import { drop } from './util/drop';
|
||||||
import { isGroupV1, isGroupV2 } from './util/whatTypeOfConversation';
|
import { isGroupV1, isGroupV2 } from './util/whatTypeOfConversation';
|
||||||
import { getConversationUnreadCountForAppBadge } from './util/getConversationUnreadCountForAppBadge';
|
|
||||||
import type { ServiceIdString } from './types/ServiceId';
|
import type { ServiceIdString } from './types/ServiceId';
|
||||||
import {
|
import {
|
||||||
isServiceIdString,
|
isServiceIdString,
|
||||||
|
@ -36,6 +35,7 @@ import { getServiceIdsForE164s } from './util/getServiceIdsForE164s';
|
||||||
import { SIGNAL_ACI, SIGNAL_AVATAR_PATH } from './types/SignalConversation';
|
import { SIGNAL_ACI, SIGNAL_AVATAR_PATH } from './types/SignalConversation';
|
||||||
import { getTitleNoDefault } from './util/getTitle';
|
import { getTitleNoDefault } from './util/getTitle';
|
||||||
import * as StorageService from './services/storage';
|
import * as StorageService from './services/storage';
|
||||||
|
import { countAllConversationsUnreadStats } from './util/countUnreadStats';
|
||||||
|
|
||||||
type ConvoMatchType =
|
type ConvoMatchType =
|
||||||
| {
|
| {
|
||||||
|
@ -185,28 +185,31 @@ export class ConversationController {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const canCountMutedConversations =
|
const includeMuted =
|
||||||
window.storage.get('badge-count-muted-conversations') || false;
|
window.storage.get('badge-count-muted-conversations') || false;
|
||||||
|
|
||||||
const newUnreadCount = this._conversations.reduce(
|
const unreadStats = countAllConversationsUnreadStats(
|
||||||
(result: number, conversation: ConversationModel) =>
|
this._conversations.map(conversation => conversation.format()),
|
||||||
result +
|
{ includeMuted }
|
||||||
getConversationUnreadCountForAppBadge(
|
|
||||||
conversation.attributes,
|
|
||||||
canCountMutedConversations
|
|
||||||
),
|
|
||||||
0
|
|
||||||
);
|
);
|
||||||
drop(window.storage.put('unreadCount', newUnreadCount));
|
|
||||||
|
|
||||||
if (newUnreadCount > 0) {
|
drop(window.storage.put('unreadCount', unreadStats.unreadCount));
|
||||||
window.IPC.setBadgeCount(newUnreadCount);
|
|
||||||
window.document.title = `${window.getTitle()} (${newUnreadCount})`;
|
if (unreadStats.unreadCount > 0) {
|
||||||
|
window.IPC.setBadge(unreadStats.unreadCount);
|
||||||
|
window.IPC.updateTrayIcon(unreadStats.unreadCount);
|
||||||
|
window.document.title = `${window.getTitle()} (${
|
||||||
|
unreadStats.unreadCount
|
||||||
|
})`;
|
||||||
|
} else if (unreadStats.markedUnread) {
|
||||||
|
window.IPC.setBadge('marked-unread');
|
||||||
|
window.IPC.updateTrayIcon(1);
|
||||||
|
window.document.title = `${window.getTitle()} (1)`;
|
||||||
} else {
|
} else {
|
||||||
window.IPC.setBadgeCount(0);
|
window.IPC.setBadge(0);
|
||||||
|
window.IPC.updateTrayIcon(0);
|
||||||
window.document.title = window.getTitle();
|
window.document.title = window.getTitle();
|
||||||
}
|
}
|
||||||
window.IPC.updateTrayIcon(newUnreadCount);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onEmpty(): void {
|
onEmpty(): void {
|
||||||
|
|
|
@ -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;
|
) => void;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
const CALL_LIST_ITEM_ROW_HEIGHT = 62;
|
||||||
|
|
||||||
function rowHeight() {
|
function rowHeight() {
|
||||||
return ListTile.heightFull;
|
return CALL_LIST_ITEM_ROW_HEIGHT;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CallsList({
|
export function CallsList({
|
||||||
|
@ -275,6 +277,7 @@ export function CallsList({
|
||||||
return (
|
return (
|
||||||
<div key={key} style={style}>
|
<div key={key} style={style}>
|
||||||
<ListTile
|
<ListTile
|
||||||
|
moduleClassName="CallsList__ItemTile"
|
||||||
leading={<div className="CallsList__LoadingAvatar" />}
|
leading={<div className="CallsList__LoadingAvatar" />}
|
||||||
title={
|
title={
|
||||||
<span className="CallsList__LoadingText CallsList__LoadingText--title" />
|
<span className="CallsList__LoadingText CallsList__LoadingText--title" />
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
// Copyright 2023 Signal Messenger, LLC
|
// Copyright 2023 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import React, { useCallback, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import type { LocalizerType } from '../types/I18N';
|
import type { LocalizerType } from '../types/I18N';
|
||||||
import { NavSidebar, NavSidebarActionButton } from './NavSidebar';
|
import { NavSidebar, NavSidebarActionButton } from './NavSidebar';
|
||||||
import { CallsList } from './CallsList';
|
import { CallsList } from './CallsList';
|
||||||
|
@ -16,6 +16,7 @@ import { useEscapeHandling } from '../hooks/useEscapeHandling';
|
||||||
import type { ActiveCallStateType } from '../state/ducks/calling';
|
import type { ActiveCallStateType } from '../state/ducks/calling';
|
||||||
import { ContextMenu } from './ContextMenu';
|
import { ContextMenu } from './ContextMenu';
|
||||||
import { ConfirmationDialog } from './ConfirmationDialog';
|
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||||
|
import type { UnreadStats } from '../util/countUnreadStats';
|
||||||
|
|
||||||
enum CallsTabSidebarView {
|
enum CallsTabSidebarView {
|
||||||
CallsListView,
|
CallsListView,
|
||||||
|
@ -25,6 +26,7 @@ enum CallsTabSidebarView {
|
||||||
type CallsTabProps = Readonly<{
|
type CallsTabProps = Readonly<{
|
||||||
activeCall: ActiveCallStateType | undefined;
|
activeCall: ActiveCallStateType | undefined;
|
||||||
allConversations: ReadonlyArray<ConversationType>;
|
allConversations: ReadonlyArray<ConversationType>;
|
||||||
|
appUnreadStats: UnreadStats;
|
||||||
getCallHistoryGroupsCount: (
|
getCallHistoryGroupsCount: (
|
||||||
options: CallHistoryFilterOptions
|
options: CallHistoryFilterOptions
|
||||||
) => Promise<number>;
|
) => Promise<number>;
|
||||||
|
@ -33,9 +35,12 @@ type CallsTabProps = Readonly<{
|
||||||
pagination: CallHistoryPagination
|
pagination: CallHistoryPagination
|
||||||
) => Promise<Array<CallHistoryGroup>>;
|
) => Promise<Array<CallHistoryGroup>>;
|
||||||
getConversation: (id: string) => ConversationType | void;
|
getConversation: (id: string) => ConversationType | void;
|
||||||
|
hasFailedStorySends: boolean;
|
||||||
|
hasPendingUpdate: boolean;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
navTabsCollapsed: boolean;
|
navTabsCollapsed: boolean;
|
||||||
onClearCallHistory: () => void;
|
onClearCallHistory: () => void;
|
||||||
|
onMarkCallHistoryRead: (conversationId: string, callId: string) => void;
|
||||||
onToggleNavTabsCollapse: (navTabsCollapsed: boolean) => void;
|
onToggleNavTabsCollapse: (navTabsCollapsed: boolean) => void;
|
||||||
onOutgoingAudioCallInConversation: (conversationId: string) => void;
|
onOutgoingAudioCallInConversation: (conversationId: string) => void;
|
||||||
onOutgoingVideoCallInConversation: (conversationId: string) => void;
|
onOutgoingVideoCallInConversation: (conversationId: string) => void;
|
||||||
|
@ -51,12 +56,16 @@ type CallsTabProps = Readonly<{
|
||||||
export function CallsTab({
|
export function CallsTab({
|
||||||
activeCall,
|
activeCall,
|
||||||
allConversations,
|
allConversations,
|
||||||
|
appUnreadStats,
|
||||||
getCallHistoryGroupsCount,
|
getCallHistoryGroupsCount,
|
||||||
getCallHistoryGroups,
|
getCallHistoryGroups,
|
||||||
getConversation,
|
getConversation,
|
||||||
|
hasFailedStorySends,
|
||||||
|
hasPendingUpdate,
|
||||||
i18n,
|
i18n,
|
||||||
navTabsCollapsed,
|
navTabsCollapsed,
|
||||||
onClearCallHistory,
|
onClearCallHistory,
|
||||||
|
onMarkCallHistoryRead,
|
||||||
onToggleNavTabsCollapse,
|
onToggleNavTabsCollapse,
|
||||||
onOutgoingAudioCallInConversation,
|
onOutgoingAudioCallInConversation,
|
||||||
onOutgoingVideoCallInConversation,
|
onOutgoingVideoCallInConversation,
|
||||||
|
@ -131,6 +140,14 @@ export function CallsTab({
|
||||||
[updateSidebarView, onOutgoingVideoCallInConversation]
|
[updateSidebarView, onOutgoingVideoCallInConversation]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selected?.callHistoryGroup != null) {
|
||||||
|
selected.callHistoryGroup.children.forEach(child => {
|
||||||
|
onMarkCallHistoryRead(selected.conversationId, child.callId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [selected, onMarkCallHistoryRead]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="CallsTab">
|
<div className="CallsTab">
|
||||||
|
@ -141,6 +158,9 @@ export function CallsTab({
|
||||||
? i18n('icu:CallsTab__HeaderTitle--CallsList')
|
? i18n('icu:CallsTab__HeaderTitle--CallsList')
|
||||||
: i18n('icu:CallsTab__HeaderTitle--NewCall')
|
: i18n('icu:CallsTab__HeaderTitle--NewCall')
|
||||||
}
|
}
|
||||||
|
appUnreadStats={appUnreadStats}
|
||||||
|
hasFailedStorySends={hasFailedStorySends}
|
||||||
|
hasPendingUpdate={hasPendingUpdate}
|
||||||
navTabsCollapsed={navTabsCollapsed}
|
navTabsCollapsed={navTabsCollapsed}
|
||||||
onBack={
|
onBack={
|
||||||
sidebarView === CallsTabSidebarView.NewCallView
|
sidebarView === CallsTabSidebarView.NewCallView
|
||||||
|
@ -232,7 +252,10 @@ export function CallsTab({
|
||||||
</NavSidebar>
|
</NavSidebar>
|
||||||
{selected == null ? (
|
{selected == null ? (
|
||||||
<div className="CallsTab__EmptyState">
|
<div className="CallsTab__EmptyState">
|
||||||
{i18n('icu:CallsTab__EmptyStateText')}
|
<div className="CallsTab__EmptyStateIcon" />
|
||||||
|
<p className="CallsTab__EmptyStateLabel">
|
||||||
|
{i18n('icu:CallsTab__EmptyStateText')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
|
|
|
@ -6,9 +6,13 @@ import { Environment, getEnvironment } from '../environment';
|
||||||
import type { LocalizerType } from '../types/I18N';
|
import type { LocalizerType } from '../types/I18N';
|
||||||
import type { NavTabPanelProps } from './NavTabs';
|
import type { NavTabPanelProps } from './NavTabs';
|
||||||
import { WhatsNewLink } from './WhatsNewLink';
|
import { WhatsNewLink } from './WhatsNewLink';
|
||||||
|
import type { UnreadStats } from '../util/countUnreadStats';
|
||||||
|
|
||||||
type ChatsTabProps = Readonly<{
|
type ChatsTabProps = Readonly<{
|
||||||
|
appUnreadStats: UnreadStats;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
|
hasPendingUpdate: boolean;
|
||||||
|
hasFailedStorySends: boolean;
|
||||||
navTabsCollapsed: boolean;
|
navTabsCollapsed: boolean;
|
||||||
onToggleNavTabsCollapse: (navTabsCollapsed: boolean) => void;
|
onToggleNavTabsCollapse: (navTabsCollapsed: boolean) => void;
|
||||||
prevConversationId: string | undefined;
|
prevConversationId: string | undefined;
|
||||||
|
@ -20,7 +24,10 @@ type ChatsTabProps = Readonly<{
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export function ChatsTab({
|
export function ChatsTab({
|
||||||
|
appUnreadStats,
|
||||||
i18n,
|
i18n,
|
||||||
|
hasPendingUpdate,
|
||||||
|
hasFailedStorySends,
|
||||||
navTabsCollapsed,
|
navTabsCollapsed,
|
||||||
onToggleNavTabsCollapse,
|
onToggleNavTabsCollapse,
|
||||||
prevConversationId,
|
prevConversationId,
|
||||||
|
@ -34,7 +41,10 @@ export function ChatsTab({
|
||||||
<>
|
<>
|
||||||
<div id="LeftPane">
|
<div id="LeftPane">
|
||||||
{renderLeftPane({
|
{renderLeftPane({
|
||||||
|
appUnreadStats,
|
||||||
collapsed: navTabsCollapsed,
|
collapsed: navTabsCollapsed,
|
||||||
|
hasPendingUpdate,
|
||||||
|
hasFailedStorySends,
|
||||||
onToggleCollapse: onToggleNavTabsCollapse,
|
onToggleCollapse: onToggleNavTabsCollapse,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -133,6 +133,11 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
appUnreadStats: {
|
||||||
|
unreadCount: 0,
|
||||||
|
unreadMentionsCount: 0,
|
||||||
|
markedUnread: false,
|
||||||
|
},
|
||||||
clearConversationSearch: action('clearConversationSearch'),
|
clearConversationSearch: action('clearConversationSearch'),
|
||||||
clearGroupCreationError: action('clearGroupCreationError'),
|
clearGroupCreationError: action('clearGroupCreationError'),
|
||||||
clearSearch: action('clearSearch'),
|
clearSearch: action('clearSearch'),
|
||||||
|
@ -143,6 +148,8 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
|
||||||
composeSaveAvatarToDisk: action('composeSaveAvatarToDisk'),
|
composeSaveAvatarToDisk: action('composeSaveAvatarToDisk'),
|
||||||
createGroup: action('createGroup'),
|
createGroup: action('createGroup'),
|
||||||
getPreferredBadge: () => undefined,
|
getPreferredBadge: () => undefined,
|
||||||
|
hasFailedStorySends: false,
|
||||||
|
hasPendingUpdate: false,
|
||||||
i18n,
|
i18n,
|
||||||
isMacOS: boolean('isMacOS', false),
|
isMacOS: boolean('isMacOS', false),
|
||||||
preferredWidthFromStorage: 320,
|
preferredWidthFromStorage: 320,
|
||||||
|
|
|
@ -48,6 +48,7 @@ import {
|
||||||
NavSidebarSearchHeader,
|
NavSidebarSearchHeader,
|
||||||
} from './NavSidebar';
|
} from './NavSidebar';
|
||||||
import { ContextMenu } from './ContextMenu';
|
import { ContextMenu } from './ContextMenu';
|
||||||
|
import type { UnreadStats } from '../util/countUnreadStats';
|
||||||
|
|
||||||
export enum LeftPaneMode {
|
export enum LeftPaneMode {
|
||||||
Inbox,
|
Inbox,
|
||||||
|
@ -59,8 +60,11 @@ export enum LeftPaneMode {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PropsType = {
|
export type PropsType = {
|
||||||
|
appUnreadStats: UnreadStats;
|
||||||
hasExpiredDialog: boolean;
|
hasExpiredDialog: boolean;
|
||||||
|
hasFailedStorySends: boolean;
|
||||||
hasNetworkDialog: boolean;
|
hasNetworkDialog: boolean;
|
||||||
|
hasPendingUpdate: boolean;
|
||||||
hasRelinkDialog: boolean;
|
hasRelinkDialog: boolean;
|
||||||
hasUpdateDialog: boolean;
|
hasUpdateDialog: boolean;
|
||||||
isUpdateDownloaded: boolean;
|
isUpdateDownloaded: boolean;
|
||||||
|
@ -154,6 +158,7 @@ export type PropsType = {
|
||||||
} & LookupConversationWithoutServiceIdActionsType;
|
} & LookupConversationWithoutServiceIdActionsType;
|
||||||
|
|
||||||
export function LeftPane({
|
export function LeftPane({
|
||||||
|
appUnreadStats,
|
||||||
blockConversation,
|
blockConversation,
|
||||||
challengeStatus,
|
challengeStatus,
|
||||||
clearConversationSearch,
|
clearConversationSearch,
|
||||||
|
@ -168,7 +173,9 @@ export function LeftPane({
|
||||||
createGroup,
|
createGroup,
|
||||||
getPreferredBadge,
|
getPreferredBadge,
|
||||||
hasExpiredDialog,
|
hasExpiredDialog,
|
||||||
|
hasFailedStorySends,
|
||||||
hasNetworkDialog,
|
hasNetworkDialog,
|
||||||
|
hasPendingUpdate,
|
||||||
hasRelinkDialog,
|
hasRelinkDialog,
|
||||||
hasUpdateDialog,
|
hasUpdateDialog,
|
||||||
i18n,
|
i18n,
|
||||||
|
@ -549,6 +556,9 @@ export function LeftPane({
|
||||||
modeSpecificProps.mode === LeftPaneMode.SetGroupMetadata
|
modeSpecificProps.mode === LeftPaneMode.SetGroupMetadata
|
||||||
}
|
}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
|
appUnreadStats={appUnreadStats}
|
||||||
|
hasFailedStorySends={hasFailedStorySends}
|
||||||
|
hasPendingUpdate={hasPendingUpdate}
|
||||||
navTabsCollapsed={navTabsCollapsed}
|
navTabsCollapsed={navTabsCollapsed}
|
||||||
onToggleNavTabsCollapse={toggleNavTabsCollapse}
|
onToggleNavTabsCollapse={toggleNavTabsCollapse}
|
||||||
preferredLeftPaneWidth={preferredWidthFromStorage}
|
preferredLeftPaneWidth={preferredWidthFromStorage}
|
||||||
|
|
|
@ -9,6 +9,7 @@ import {
|
||||||
StoryViewModeType,
|
StoryViewModeType,
|
||||||
} from '../types/Stories';
|
} from '../types/Stories';
|
||||||
import type { LocalizerType } from '../types/Util';
|
import type { LocalizerType } from '../types/Util';
|
||||||
|
import { ThemeType } from '../types/Util';
|
||||||
import type { ViewStoryActionCreatorType } from '../state/ducks/stories';
|
import type { ViewStoryActionCreatorType } from '../state/ducks/stories';
|
||||||
import { ConfirmationDialog } from './ConfirmationDialog';
|
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||||
import { ContextMenu } from './ContextMenu';
|
import { ContextMenu } from './ContextMenu';
|
||||||
|
@ -19,9 +20,13 @@ import { Theme } from '../util/theme';
|
||||||
import { resolveStorySendStatus } from '../util/resolveStorySendStatus';
|
import { resolveStorySendStatus } from '../util/resolveStorySendStatus';
|
||||||
import { useRetryStorySend } from '../hooks/useRetryStorySend';
|
import { useRetryStorySend } from '../hooks/useRetryStorySend';
|
||||||
import { NavSidebar } from './NavSidebar';
|
import { NavSidebar } from './NavSidebar';
|
||||||
|
import type { UnreadStats } from '../util/countUnreadStats';
|
||||||
|
|
||||||
export type PropsType = {
|
export type PropsType = {
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
|
appUnreadStats: UnreadStats;
|
||||||
|
hasFailedStorySends: boolean;
|
||||||
|
hasPendingUpdate: boolean;
|
||||||
navTabsCollapsed: boolean;
|
navTabsCollapsed: boolean;
|
||||||
myStories: Array<MyStoryType>;
|
myStories: Array<MyStoryType>;
|
||||||
onBack: () => unknown;
|
onBack: () => unknown;
|
||||||
|
@ -36,10 +41,14 @@ export type PropsType = {
|
||||||
hasViewReceiptSetting: boolean;
|
hasViewReceiptSetting: boolean;
|
||||||
preferredLeftPaneWidth: number;
|
preferredLeftPaneWidth: number;
|
||||||
savePreferredLeftPaneWidth: (preferredLeftPaneWidth: number) => void;
|
savePreferredLeftPaneWidth: (preferredLeftPaneWidth: number) => void;
|
||||||
|
theme: ThemeType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function MyStories({
|
export function MyStories({
|
||||||
i18n,
|
i18n,
|
||||||
|
appUnreadStats,
|
||||||
|
hasFailedStorySends,
|
||||||
|
hasPendingUpdate,
|
||||||
navTabsCollapsed,
|
navTabsCollapsed,
|
||||||
myStories,
|
myStories,
|
||||||
onBack,
|
onBack,
|
||||||
|
@ -54,6 +63,7 @@ export function MyStories({
|
||||||
onToggleNavTabsCollapse,
|
onToggleNavTabsCollapse,
|
||||||
preferredLeftPaneWidth,
|
preferredLeftPaneWidth,
|
||||||
savePreferredLeftPaneWidth,
|
savePreferredLeftPaneWidth,
|
||||||
|
theme,
|
||||||
}: PropsType): JSX.Element {
|
}: PropsType): JSX.Element {
|
||||||
const [confirmDeleteStory, setConfirmDeleteStory] = useState<
|
const [confirmDeleteStory, setConfirmDeleteStory] = useState<
|
||||||
StoryViewType | undefined
|
StoryViewType | undefined
|
||||||
|
@ -80,6 +90,9 @@ export function MyStories({
|
||||||
<NavSidebar
|
<NavSidebar
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
title={i18n('icu:MyStories__title')}
|
title={i18n('icu:MyStories__title')}
|
||||||
|
appUnreadStats={appUnreadStats}
|
||||||
|
hasFailedStorySends={hasFailedStorySends}
|
||||||
|
hasPendingUpdate={hasPendingUpdate}
|
||||||
navTabsCollapsed={navTabsCollapsed}
|
navTabsCollapsed={navTabsCollapsed}
|
||||||
onBack={onBack}
|
onBack={onBack}
|
||||||
onToggleNavTabsCollapse={onToggleNavTabsCollapse}
|
onToggleNavTabsCollapse={onToggleNavTabsCollapse}
|
||||||
|
@ -109,6 +122,7 @@ export function MyStories({
|
||||||
retryMessageSend={retryMessageSend}
|
retryMessageSend={retryMessageSend}
|
||||||
setConfirmDeleteStory={setConfirmDeleteStory}
|
setConfirmDeleteStory={setConfirmDeleteStory}
|
||||||
story={story}
|
story={story}
|
||||||
|
theme={theme}
|
||||||
viewStory={viewStory}
|
viewStory={viewStory}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
@ -135,6 +149,7 @@ type StorySentPropsType = Pick<
|
||||||
| 'retryMessageSend'
|
| 'retryMessageSend'
|
||||||
| 'viewStory'
|
| 'viewStory'
|
||||||
| 'onMediaPlaybackStart'
|
| 'onMediaPlaybackStart'
|
||||||
|
| 'theme'
|
||||||
> & {
|
> & {
|
||||||
setConfirmDeleteStory: (_: StoryViewType | undefined) => unknown;
|
setConfirmDeleteStory: (_: StoryViewType | undefined) => unknown;
|
||||||
story: StoryViewType;
|
story: StoryViewType;
|
||||||
|
@ -150,6 +165,7 @@ function StorySent({
|
||||||
retryMessageSend,
|
retryMessageSend,
|
||||||
setConfirmDeleteStory,
|
setConfirmDeleteStory,
|
||||||
story,
|
story,
|
||||||
|
theme,
|
||||||
viewStory,
|
viewStory,
|
||||||
}: StorySentPropsType): JSX.Element {
|
}: StorySentPropsType): JSX.Element {
|
||||||
const sendStatus = resolveStorySendStatus(story.sendState ?? []);
|
const sendStatus = resolveStorySendStatus(story.sendState ?? []);
|
||||||
|
@ -278,7 +294,7 @@ function StorySent({
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
moduleClassName="MyStories__story__more"
|
moduleClassName="MyStories__story__more"
|
||||||
theme={Theme.Dark}
|
theme={theme === ThemeType.dark ? Theme.Dark : Theme.Light}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -14,6 +14,7 @@ import {
|
||||||
getWidthFromPreferredWidth,
|
getWidthFromPreferredWidth,
|
||||||
} from '../util/leftPaneWidth';
|
} from '../util/leftPaneWidth';
|
||||||
import { WidthBreakpoint, getNavSidebarWidthBreakpoint } from './_util';
|
import { WidthBreakpoint, getNavSidebarWidthBreakpoint } from './_util';
|
||||||
|
import type { UnreadStats } from '../util/countUnreadStats';
|
||||||
|
|
||||||
export function NavSidebarActionButton({
|
export function NavSidebarActionButton({
|
||||||
icon,
|
icon,
|
||||||
|
@ -43,6 +44,8 @@ export type NavSidebarProps = Readonly<{
|
||||||
actions?: ReactNode;
|
actions?: ReactNode;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
|
hasFailedStorySends: boolean;
|
||||||
|
hasPendingUpdate: boolean;
|
||||||
hideHeader?: boolean;
|
hideHeader?: boolean;
|
||||||
navTabsCollapsed: boolean;
|
navTabsCollapsed: boolean;
|
||||||
onBack?: (() => void) | null;
|
onBack?: (() => void) | null;
|
||||||
|
@ -51,6 +54,7 @@ export type NavSidebarProps = Readonly<{
|
||||||
requiresFullWidth: boolean;
|
requiresFullWidth: boolean;
|
||||||
savePreferredLeftPaneWidth: (width: number) => void;
|
savePreferredLeftPaneWidth: (width: number) => void;
|
||||||
title: string;
|
title: string;
|
||||||
|
appUnreadStats: UnreadStats;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
enum DragState {
|
enum DragState {
|
||||||
|
@ -64,6 +68,8 @@ export function NavSidebar({
|
||||||
children,
|
children,
|
||||||
hideHeader,
|
hideHeader,
|
||||||
i18n,
|
i18n,
|
||||||
|
hasFailedStorySends,
|
||||||
|
hasPendingUpdate,
|
||||||
navTabsCollapsed,
|
navTabsCollapsed,
|
||||||
onBack,
|
onBack,
|
||||||
onToggleNavTabsCollapse,
|
onToggleNavTabsCollapse,
|
||||||
|
@ -71,6 +77,7 @@ export function NavSidebar({
|
||||||
requiresFullWidth,
|
requiresFullWidth,
|
||||||
savePreferredLeftPaneWidth,
|
savePreferredLeftPaneWidth,
|
||||||
title,
|
title,
|
||||||
|
appUnreadStats,
|
||||||
}: NavSidebarProps): JSX.Element {
|
}: NavSidebarProps): JSX.Element {
|
||||||
const [dragState, setDragState] = useState(DragState.INITIAL);
|
const [dragState, setDragState] = useState(DragState.INITIAL);
|
||||||
|
|
||||||
|
@ -155,6 +162,9 @@ export function NavSidebar({
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
navTabsCollapsed={navTabsCollapsed}
|
navTabsCollapsed={navTabsCollapsed}
|
||||||
onToggleNavTabsCollapse={onToggleNavTabsCollapse}
|
onToggleNavTabsCollapse={onToggleNavTabsCollapse}
|
||||||
|
hasFailedStorySends={hasFailedStorySends}
|
||||||
|
hasPendingUpdate={hasPendingUpdate}
|
||||||
|
appUnreadStats={appUnreadStats}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
|
|
|
@ -1,32 +1,91 @@
|
||||||
// Copyright 2023 Signal Messenger, LLC
|
// Copyright 2023 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import type { Key, ReactNode } from 'react';
|
import type { Key } from 'react';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React from 'react';
|
||||||
import { Tabs, TabList, Tab, TabPanels, TabPanel } from 'react-aria-components';
|
import { Tabs, TabList, Tab, TabPanels, TabPanel } from 'react-aria-components';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { usePopper } from 'react-popper';
|
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
import { Avatar, AvatarSize } from './Avatar';
|
import { Avatar, AvatarSize } from './Avatar';
|
||||||
import type { LocalizerType, ThemeType } from '../types/Util';
|
import type { LocalizerType, ThemeType } from '../types/Util';
|
||||||
import type { ConversationType } from '../state/ducks/conversations';
|
import type { ConversationType } from '../state/ducks/conversations';
|
||||||
import type { BadgeType } from '../badges/types';
|
import type { BadgeType } from '../badges/types';
|
||||||
import { AvatarPopup } from './AvatarPopup';
|
|
||||||
import { handleOutsideClick } from '../util/handleOutsideClick';
|
|
||||||
import type { UnreadStats } from '../state/selectors/conversations';
|
|
||||||
import { NavTab } from '../state/ducks/nav';
|
import { NavTab } from '../state/ducks/nav';
|
||||||
import { Tooltip, TooltipPlacement } from './Tooltip';
|
import { Tooltip, TooltipPlacement } from './Tooltip';
|
||||||
import { Theme } from '../util/theme';
|
import { Theme } from '../util/theme';
|
||||||
|
import type { UnreadStats } from '../util/countUnreadStats';
|
||||||
|
import { ContextMenu } from './ContextMenu';
|
||||||
|
|
||||||
|
type NavTabsItemBadgesProps = Readonly<{
|
||||||
|
i18n: LocalizerType;
|
||||||
|
hasError?: boolean;
|
||||||
|
hasPendingUpdate?: boolean;
|
||||||
|
unreadStats: UnreadStats | null;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
function NavTabsItemBadges({
|
||||||
|
i18n,
|
||||||
|
hasError,
|
||||||
|
hasPendingUpdate,
|
||||||
|
unreadStats,
|
||||||
|
}: NavTabsItemBadgesProps) {
|
||||||
|
if (hasError) {
|
||||||
|
return (
|
||||||
|
<span className="NavTabs__ItemUnreadBadge">
|
||||||
|
<span className="NavTabs__ItemIconLabel">
|
||||||
|
{i18n('icu:NavTabs__ItemIconLabel--HasError')}
|
||||||
|
</span>
|
||||||
|
<span aria-hidden>!</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPendingUpdate) {
|
||||||
|
return <div className="NavTabs__ItemUpdateBadge" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unreadStats != null) {
|
||||||
|
if (unreadStats.unreadCount > 0) {
|
||||||
|
return (
|
||||||
|
<span className="NavTabs__ItemUnreadBadge">
|
||||||
|
<span className="NavTabs__ItemIconLabel">
|
||||||
|
{i18n('icu:NavTabs__ItemIconLabel--UnreadCount', {
|
||||||
|
count: unreadStats.unreadCount,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span aria-hidden>{unreadStats.unreadCount}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unreadStats.markedUnread) {
|
||||||
|
return (
|
||||||
|
<span className="NavTabs__ItemUnreadBadge">
|
||||||
|
{i18n('icu:NavTabs__ItemIconLabel--MarkedUnread')}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
type NavTabProps = Readonly<{
|
type NavTabProps = Readonly<{
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
badge?: ReactNode;
|
|
||||||
iconClassName: string;
|
iconClassName: string;
|
||||||
id: NavTab;
|
id: NavTab;
|
||||||
|
hasError?: boolean;
|
||||||
label: string;
|
label: string;
|
||||||
|
unreadStats: UnreadStats | null;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
function NavTabsItem({ i18n, badge, iconClassName, id, label }: NavTabProps) {
|
function NavTabsItem({
|
||||||
|
i18n,
|
||||||
|
iconClassName,
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
unreadStats,
|
||||||
|
hasError,
|
||||||
|
}: NavTabProps) {
|
||||||
const isRTL = i18n.getLocaleDirection() === 'rtl';
|
const isRTL = i18n.getLocaleDirection() === 'rtl';
|
||||||
return (
|
return (
|
||||||
<Tab id={id} data-testid={`NavTabsItem--${id}`} className="NavTabs__Item">
|
<Tab id={id} data-testid={`NavTabsItem--${id}`} className="NavTabs__Item">
|
||||||
|
@ -43,7 +102,11 @@ function NavTabsItem({ i18n, badge, iconClassName, id, label }: NavTabProps) {
|
||||||
role="presentation"
|
role="presentation"
|
||||||
className={`NavTabs__ItemIcon ${iconClassName}`}
|
className={`NavTabs__ItemIcon ${iconClassName}`}
|
||||||
/>
|
/>
|
||||||
{badge && <span className="NavTabs__ItemBadge">{badge}</span>}
|
<NavTabsItemBadges
|
||||||
|
i18n={i18n}
|
||||||
|
unreadStats={unreadStats}
|
||||||
|
hasError={hasError}
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
@ -52,19 +115,28 @@ function NavTabsItem({ i18n, badge, iconClassName, id, label }: NavTabProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type NavTabPanelProps = Readonly<{
|
export type NavTabPanelProps = Readonly<{
|
||||||
|
appUnreadStats: UnreadStats;
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
|
hasFailedStorySends: boolean;
|
||||||
|
hasPendingUpdate: boolean;
|
||||||
onToggleCollapse(collapsed: boolean): void;
|
onToggleCollapse(collapsed: boolean): void;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type NavTabsToggleProps = Readonly<{
|
export type NavTabsToggleProps = Readonly<{
|
||||||
|
appUnreadStats: UnreadStats | null;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
|
hasFailedStorySends: boolean;
|
||||||
|
hasPendingUpdate: boolean;
|
||||||
navTabsCollapsed: boolean;
|
navTabsCollapsed: boolean;
|
||||||
onToggleNavTabsCollapse(navTabsCollapsed: boolean): void;
|
onToggleNavTabsCollapse(navTabsCollapsed: boolean): void;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export function NavTabsToggle({
|
export function NavTabsToggle({
|
||||||
i18n,
|
i18n,
|
||||||
|
hasFailedStorySends,
|
||||||
|
hasPendingUpdate,
|
||||||
navTabsCollapsed,
|
navTabsCollapsed,
|
||||||
|
appUnreadStats,
|
||||||
onToggleNavTabsCollapse,
|
onToggleNavTabsCollapse,
|
||||||
}: NavTabsToggleProps): JSX.Element {
|
}: NavTabsToggleProps): JSX.Element {
|
||||||
function handleToggle() {
|
function handleToggle() {
|
||||||
|
@ -87,11 +159,19 @@ export function NavTabsToggle({
|
||||||
delay={600}
|
delay={600}
|
||||||
>
|
>
|
||||||
<span className="NavTabs__ItemButton">
|
<span className="NavTabs__ItemButton">
|
||||||
<span
|
<span className="NavTabs__ItemContent">
|
||||||
role="presentation"
|
<span
|
||||||
className="NavTabs__ItemIcon NavTabs__ItemIcon--Menu"
|
role="presentation"
|
||||||
/>
|
className="NavTabs__ItemIcon NavTabs__ItemIcon--Menu"
|
||||||
<span className="NavTabs__ItemLabel">{label}</span>
|
/>
|
||||||
|
<span className="NavTabs__ItemLabel">{label}</span>
|
||||||
|
<NavTabsItemBadges
|
||||||
|
i18n={i18n}
|
||||||
|
unreadStats={appUnreadStats}
|
||||||
|
hasError={hasFailedStorySends}
|
||||||
|
hasPendingUpdate={hasPendingUpdate}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</button>
|
</button>
|
||||||
|
@ -116,6 +196,7 @@ export type NavTabsProps = Readonly<{
|
||||||
selectedNavTab: NavTab;
|
selectedNavTab: NavTab;
|
||||||
storiesEnabled: boolean;
|
storiesEnabled: boolean;
|
||||||
theme: ThemeType;
|
theme: ThemeType;
|
||||||
|
unreadCallsCount: number;
|
||||||
unreadConversationsStats: UnreadStats;
|
unreadConversationsStats: UnreadStats;
|
||||||
unreadStoriesCount: number;
|
unreadStoriesCount: number;
|
||||||
}>;
|
}>;
|
||||||
|
@ -138,6 +219,7 @@ export function NavTabs({
|
||||||
selectedNavTab,
|
selectedNavTab,
|
||||||
storiesEnabled,
|
storiesEnabled,
|
||||||
theme,
|
theme,
|
||||||
|
unreadCallsCount,
|
||||||
unreadConversationsStats,
|
unreadConversationsStats,
|
||||||
unreadStoriesCount,
|
unreadStoriesCount,
|
||||||
}: NavTabsProps): JSX.Element {
|
}: NavTabsProps): JSX.Element {
|
||||||
|
@ -147,63 +229,6 @@ export function NavTabs({
|
||||||
|
|
||||||
const isRTL = i18n.getLocaleDirection() === 'rtl';
|
const isRTL = i18n.getLocaleDirection() === 'rtl';
|
||||||
|
|
||||||
const [targetElement, setTargetElement] = useState<HTMLElement | null>(null);
|
|
||||||
const [popperElement, setPopperElement] = useState<HTMLElement | null>(null);
|
|
||||||
const [portalElement, setPortalElement] = useState<HTMLElement | null>(null);
|
|
||||||
|
|
||||||
const [showAvatarPopup, setShowAvatarPopup] = useState(false);
|
|
||||||
|
|
||||||
const popper = usePopper(targetElement, popperElement, {
|
|
||||||
placement: 'bottom-start',
|
|
||||||
strategy: 'fixed',
|
|
||||||
modifiers: [
|
|
||||||
{
|
|
||||||
name: 'offset',
|
|
||||||
options: {
|
|
||||||
offset: [null, 4],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
document.body.appendChild(div);
|
|
||||||
setPortalElement(div);
|
|
||||||
return () => {
|
|
||||||
div.remove();
|
|
||||||
setPortalElement(null);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return handleOutsideClick(
|
|
||||||
() => {
|
|
||||||
if (!showAvatarPopup) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
setShowAvatarPopup(false);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
containerElements: [portalElement, targetElement],
|
|
||||||
name: 'MainHeader.showAvatarPopup',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}, [portalElement, targetElement, showAvatarPopup]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function handleGlobalKeyDown(event: KeyboardEvent) {
|
|
||||||
if (showAvatarPopup && event.key === 'Escape') {
|
|
||||||
setShowAvatarPopup(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener('keydown', handleGlobalKeyDown, true);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('keydown', handleGlobalKeyDown, true);
|
|
||||||
};
|
|
||||||
}, [showAvatarPopup]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs orientation="vertical" className="NavTabs__Container">
|
<Tabs orientation="vertical" className="NavTabs__Container">
|
||||||
<nav
|
<nav
|
||||||
|
@ -215,6 +240,10 @@ export function NavTabs({
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
navTabsCollapsed={navTabsCollapsed}
|
navTabsCollapsed={navTabsCollapsed}
|
||||||
onToggleNavTabsCollapse={onToggleNavTabsCollapse}
|
onToggleNavTabsCollapse={onToggleNavTabsCollapse}
|
||||||
|
// These are all shown elsewhere when nav tabs are shown
|
||||||
|
hasFailedStorySends={false}
|
||||||
|
hasPendingUpdate={false}
|
||||||
|
appUnreadStats={null}
|
||||||
/>
|
/>
|
||||||
<TabList
|
<TabList
|
||||||
className="NavTabs__TabList"
|
className="NavTabs__TabList"
|
||||||
|
@ -226,31 +255,18 @@ export function NavTabs({
|
||||||
id={NavTab.Chats}
|
id={NavTab.Chats}
|
||||||
label="Chats"
|
label="Chats"
|
||||||
iconClassName="NavTabs__ItemIcon--Chats"
|
iconClassName="NavTabs__ItemIcon--Chats"
|
||||||
badge={
|
unreadStats={unreadConversationsStats}
|
||||||
// eslint-disable-next-line no-nested-ternary
|
|
||||||
unreadConversationsStats.unreadCount > 0 ? (
|
|
||||||
<>
|
|
||||||
<span className="NavTabs__ItemIconLabel">
|
|
||||||
{i18n('icu:NavTabs__ItemIconLabel--UnreadCount', {
|
|
||||||
count: unreadConversationsStats.unreadCount,
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
<span aria-hidden>
|
|
||||||
{unreadConversationsStats.unreadCount}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
) : unreadConversationsStats.markedUnread ? (
|
|
||||||
<span className="NavTabs__ItemIconLabel">
|
|
||||||
{i18n('icu:NavTabs__ItemIconLabel--MarkedUnread')}
|
|
||||||
</span>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<NavTabsItem
|
<NavTabsItem
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
id={NavTab.Calls}
|
id={NavTab.Calls}
|
||||||
label="Calls"
|
label="Calls"
|
||||||
iconClassName="NavTabs__ItemIcon--Calls"
|
iconClassName="NavTabs__ItemIcon--Calls"
|
||||||
|
unreadStats={{
|
||||||
|
unreadCount: unreadCallsCount,
|
||||||
|
unreadMentionsCount: 0,
|
||||||
|
markedUnread: false,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{storiesEnabled && (
|
{storiesEnabled && (
|
||||||
<NavTabsItem
|
<NavTabsItem
|
||||||
|
@ -258,47 +274,89 @@ export function NavTabs({
|
||||||
id={NavTab.Stories}
|
id={NavTab.Stories}
|
||||||
label="Stories"
|
label="Stories"
|
||||||
iconClassName="NavTabs__ItemIcon--Stories"
|
iconClassName="NavTabs__ItemIcon--Stories"
|
||||||
badge={
|
hasError={hasFailedStorySends}
|
||||||
// eslint-disable-next-line no-nested-ternary
|
unreadStats={{
|
||||||
hasFailedStorySends
|
unreadCount: unreadStoriesCount,
|
||||||
? '!'
|
unreadMentionsCount: 0,
|
||||||
: unreadStoriesCount > 0
|
markedUnread: false,
|
||||||
? unreadStoriesCount
|
}}
|
||||||
: null
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TabList>
|
</TabList>
|
||||||
<div className="NavTabs__Misc">
|
<div className="NavTabs__Misc">
|
||||||
<button
|
<ContextMenu
|
||||||
type="button"
|
i18n={i18n}
|
||||||
className="NavTabs__Item"
|
menuOptions={[
|
||||||
onClick={onShowSettings}
|
{
|
||||||
|
icon: 'NavTabs__ContextMenuIcon--Settings',
|
||||||
|
label: i18n('icu:NavTabs__ItemLabel--Settings'),
|
||||||
|
onClick: onShowSettings,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'NavTabs__ContextMenuIcon--Update',
|
||||||
|
label: i18n('icu:NavTabs__ItemLabel--Update'),
|
||||||
|
onClick: onStartUpdate,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
popperOptions={{
|
||||||
|
placement: 'top-start',
|
||||||
|
strategy: 'absolute',
|
||||||
|
}}
|
||||||
|
portalToRoot
|
||||||
>
|
>
|
||||||
<Tooltip
|
{({ openMenu, onKeyDown, ref }) => {
|
||||||
content={i18n('icu:NavTabs__ItemLabel--Settings')}
|
return (
|
||||||
theme={Theme.Dark}
|
<button
|
||||||
direction={TooltipPlacement.Right}
|
type="button"
|
||||||
delay={600}
|
className="NavTabs__Item"
|
||||||
>
|
onKeyDown={event => {
|
||||||
<span className="NavTabs__ItemButton">
|
if (hasPendingUpdate) {
|
||||||
<span
|
onKeyDown(event);
|
||||||
role="presentation"
|
}
|
||||||
className="NavTabs__ItemIcon NavTabs__ItemIcon--Settings"
|
}}
|
||||||
/>
|
onClick={event => {
|
||||||
<span className="NavTabs__ItemLabel">
|
if (hasPendingUpdate) {
|
||||||
{i18n('icu:NavTabs__ItemLabel--Settings')}
|
openMenu(event);
|
||||||
</span>
|
} else {
|
||||||
</span>
|
onShowSettings();
|
||||||
</Tooltip>
|
}
|
||||||
</button>
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip
|
||||||
|
content={i18n('icu:NavTabs__ItemLabel--Settings')}
|
||||||
|
theme={Theme.Dark}
|
||||||
|
direction={TooltipPlacement.Right}
|
||||||
|
delay={600}
|
||||||
|
>
|
||||||
|
<span className="NavTabs__ItemButton" ref={ref}>
|
||||||
|
<span className="NavTabs__ItemContent">
|
||||||
|
<span
|
||||||
|
role="presentation"
|
||||||
|
className="NavTabs__ItemIcon NavTabs__ItemIcon--Settings"
|
||||||
|
/>
|
||||||
|
<span className="NavTabs__ItemLabel">
|
||||||
|
{i18n('icu:NavTabs__ItemLabel--Settings')}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<NavTabsItemBadges
|
||||||
|
i18n={i18n}
|
||||||
|
unreadStats={null}
|
||||||
|
hasPendingUpdate={hasPendingUpdate}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</ContextMenu>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="NavTabs__Item NavTabs__Item--Profile"
|
className="NavTabs__Item NavTabs__Item--Profile"
|
||||||
data-supertab
|
data-supertab
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowAvatarPopup(true);
|
onToggleProfileEditor();
|
||||||
}}
|
}}
|
||||||
aria-label={i18n('icu:NavTabs__ItemLabel--Profile')}
|
aria-label={i18n('icu:NavTabs__ItemLabel--Profile')}
|
||||||
>
|
>
|
||||||
|
@ -308,7 +366,7 @@ export function NavTabs({
|
||||||
direction={isRTL ? TooltipPlacement.Left : TooltipPlacement.Right}
|
direction={isRTL ? TooltipPlacement.Left : TooltipPlacement.Right}
|
||||||
delay={600}
|
delay={600}
|
||||||
>
|
>
|
||||||
<span className="NavTabs__ItemButton" ref={setTargetElement}>
|
<span className="NavTabs__ItemButton">
|
||||||
<span className="NavTabs__ItemContent">
|
<span className="NavTabs__ItemContent">
|
||||||
<Avatar
|
<Avatar
|
||||||
acceptedMessageRequest
|
acceptedMessageRequest
|
||||||
|
@ -328,49 +386,10 @@ export function NavTabs({
|
||||||
sharedGroupNames={[]}
|
sharedGroupNames={[]}
|
||||||
size={AvatarSize.TWENTY_EIGHT}
|
size={AvatarSize.TWENTY_EIGHT}
|
||||||
/>
|
/>
|
||||||
{hasPendingUpdate && <div className="NavTabs__AvatarBadge" />}
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</button>
|
</button>
|
||||||
{showAvatarPopup &&
|
|
||||||
portalElement != null &&
|
|
||||||
createPortal(
|
|
||||||
<div
|
|
||||||
id="MainHeader__AvatarPopup"
|
|
||||||
ref={setPopperElement}
|
|
||||||
style={{ ...popper.styles.popper, zIndex: 10 }}
|
|
||||||
{...popper.attributes.popper}
|
|
||||||
>
|
|
||||||
<AvatarPopup
|
|
||||||
acceptedMessageRequest
|
|
||||||
badge={badge}
|
|
||||||
i18n={i18n}
|
|
||||||
isMe
|
|
||||||
color={me.color}
|
|
||||||
conversationType="direct"
|
|
||||||
name={me.name}
|
|
||||||
phoneNumber={me.phoneNumber}
|
|
||||||
profileName={me.profileName}
|
|
||||||
theme={theme}
|
|
||||||
title={me.title}
|
|
||||||
avatarPath={me.avatarPath}
|
|
||||||
hasPendingUpdate={hasPendingUpdate}
|
|
||||||
// See the comment above about `sharedGroupNames`.
|
|
||||||
sharedGroupNames={[]}
|
|
||||||
onEditProfile={() => {
|
|
||||||
onToggleProfileEditor();
|
|
||||||
setShowAvatarPopup(false);
|
|
||||||
}}
|
|
||||||
onStartUpdate={() => {
|
|
||||||
onStartUpdate();
|
|
||||||
setShowAvatarPopup(false);
|
|
||||||
}}
|
|
||||||
style={{}}
|
|
||||||
/>
|
|
||||||
</div>,
|
|
||||||
portalElement
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<TabPanels>
|
<TabPanels>
|
||||||
|
|
|
@ -79,6 +79,7 @@ export type PropsDataType = {
|
||||||
hasCompletedUsernameLinkOnboarding: boolean;
|
hasCompletedUsernameLinkOnboarding: boolean;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
isUsernameFlagEnabled: boolean;
|
isUsernameFlagEnabled: boolean;
|
||||||
|
phoneNumber?: string;
|
||||||
userAvatarData: ReadonlyArray<AvatarDataType>;
|
userAvatarData: ReadonlyArray<AvatarDataType>;
|
||||||
username?: string;
|
username?: string;
|
||||||
usernameEditState: UsernameEditState;
|
usernameEditState: UsernameEditState;
|
||||||
|
@ -154,6 +155,7 @@ export function ProfileEditor({
|
||||||
onProfileChanged,
|
onProfileChanged,
|
||||||
onSetSkinTone,
|
onSetSkinTone,
|
||||||
openUsernameReservationModal,
|
openUsernameReservationModal,
|
||||||
|
phoneNumber,
|
||||||
profileAvatarPath,
|
profileAvatarPath,
|
||||||
recentEmojis,
|
recentEmojis,
|
||||||
renderEditUsernameModalBody,
|
renderEditUsernameModalBody,
|
||||||
|
@ -678,6 +680,10 @@ export function ProfileEditor({
|
||||||
width: 80,
|
width: 80,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<h1 className="ProfileEditor__Title">{getFullNameText()}</h1>
|
||||||
|
{phoneNumber != null && (
|
||||||
|
<p className="ProfileEditor__PhoneNumber">{phoneNumber}</p>
|
||||||
|
)}
|
||||||
<hr className="ProfileEditor__divider" />
|
<hr className="ProfileEditor__divider" />
|
||||||
<PanelRow
|
<PanelRow
|
||||||
className="ProfileEditor__row"
|
className="ProfileEditor__row"
|
||||||
|
|
|
@ -24,11 +24,15 @@ import { StoriesPane } from './StoriesPane';
|
||||||
import { NavSidebar, NavSidebarActionButton } from './NavSidebar';
|
import { NavSidebar, NavSidebarActionButton } from './NavSidebar';
|
||||||
import { StoriesAddStoryButton } from './StoriesAddStoryButton';
|
import { StoriesAddStoryButton } from './StoriesAddStoryButton';
|
||||||
import { ContextMenu } from './ContextMenu';
|
import { ContextMenu } from './ContextMenu';
|
||||||
|
import type { UnreadStats } from '../util/countUnreadStats';
|
||||||
|
|
||||||
export type PropsType = {
|
export type PropsType = {
|
||||||
addStoryData: AddStoryData;
|
addStoryData: AddStoryData;
|
||||||
|
appUnreadStats: UnreadStats;
|
||||||
deleteStoryForEveryone: (story: StoryViewType) => unknown;
|
deleteStoryForEveryone: (story: StoryViewType) => unknown;
|
||||||
getPreferredBadge: PreferredBadgeSelectorType;
|
getPreferredBadge: PreferredBadgeSelectorType;
|
||||||
|
hasFailedStorySends: boolean;
|
||||||
|
hasPendingUpdate: boolean;
|
||||||
hasViewReceiptSetting: boolean;
|
hasViewReceiptSetting: boolean;
|
||||||
hiddenStories: Array<ConversationStoryType>;
|
hiddenStories: Array<ConversationStoryType>;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
|
@ -61,8 +65,11 @@ export type PropsType = {
|
||||||
|
|
||||||
export function StoriesTab({
|
export function StoriesTab({
|
||||||
addStoryData,
|
addStoryData,
|
||||||
|
appUnreadStats,
|
||||||
deleteStoryForEveryone,
|
deleteStoryForEveryone,
|
||||||
getPreferredBadge,
|
getPreferredBadge,
|
||||||
|
hasFailedStorySends,
|
||||||
|
hasPendingUpdate,
|
||||||
hasViewReceiptSetting,
|
hasViewReceiptSetting,
|
||||||
hiddenStories,
|
hiddenStories,
|
||||||
i18n,
|
i18n,
|
||||||
|
@ -104,6 +111,9 @@ export function StoriesTab({
|
||||||
{addStoryData && renderStoryCreator()}
|
{addStoryData && renderStoryCreator()}
|
||||||
{isMyStories && myStories.length ? (
|
{isMyStories && myStories.length ? (
|
||||||
<MyStories
|
<MyStories
|
||||||
|
appUnreadStats={appUnreadStats}
|
||||||
|
hasFailedStorySends={hasFailedStorySends}
|
||||||
|
hasPendingUpdate={hasPendingUpdate}
|
||||||
hasViewReceiptSetting={hasViewReceiptSetting}
|
hasViewReceiptSetting={hasViewReceiptSetting}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
myStories={myStories}
|
myStories={myStories}
|
||||||
|
@ -118,17 +128,21 @@ export function StoriesTab({
|
||||||
queueStoryDownload={queueStoryDownload}
|
queueStoryDownload={queueStoryDownload}
|
||||||
retryMessageSend={retryMessageSend}
|
retryMessageSend={retryMessageSend}
|
||||||
savePreferredLeftPaneWidth={savePreferredLeftPaneWidth}
|
savePreferredLeftPaneWidth={savePreferredLeftPaneWidth}
|
||||||
|
theme={theme}
|
||||||
viewStory={viewStory}
|
viewStory={viewStory}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<NavSidebar
|
<NavSidebar
|
||||||
title="Stories"
|
title="Stories"
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
|
hasFailedStorySends={hasFailedStorySends}
|
||||||
|
hasPendingUpdate={hasPendingUpdate}
|
||||||
navTabsCollapsed={navTabsCollapsed}
|
navTabsCollapsed={navTabsCollapsed}
|
||||||
onToggleNavTabsCollapse={onToggleNavTabsCollapse}
|
onToggleNavTabsCollapse={onToggleNavTabsCollapse}
|
||||||
preferredLeftPaneWidth={preferredLeftPaneWidth}
|
preferredLeftPaneWidth={preferredLeftPaneWidth}
|
||||||
requiresFullWidth
|
requiresFullWidth
|
||||||
savePreferredLeftPaneWidth={savePreferredLeftPaneWidth}
|
savePreferredLeftPaneWidth={savePreferredLeftPaneWidth}
|
||||||
|
appUnreadStats={appUnreadStats}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<StoriesAddStoryButton
|
<StoriesAddStoryButton
|
||||||
|
|
|
@ -4432,6 +4432,7 @@ export class ConversationModel extends window.Backbone
|
||||||
unreadMentionsCount,
|
unreadMentionsCount,
|
||||||
});
|
});
|
||||||
window.Signal.Data.updateConversation(this.attributes);
|
window.Signal.Data.updateConversation(this.attributes);
|
||||||
|
window.reduxActions.callHistory.updateCallHistoryUnreadCount();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -636,6 +636,8 @@ export type DataInterface = {
|
||||||
}): Promise<MessageType | undefined>;
|
}): Promise<MessageType | undefined>;
|
||||||
getAllCallHistory: () => Promise<ReadonlyArray<CallHistoryDetails>>;
|
getAllCallHistory: () => Promise<ReadonlyArray<CallHistoryDetails>>;
|
||||||
clearCallHistory: (beforeTimestamp: number) => Promise<Array<string>>;
|
clearCallHistory: (beforeTimestamp: number) => Promise<Array<string>>;
|
||||||
|
getCallHistoryUnreadCount(): Promise<number>;
|
||||||
|
markCallHistoryRead(callId: string): Promise<void>;
|
||||||
getCallHistoryMessageByCallId(options: {
|
getCallHistoryMessageByCallId(options: {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
callId: string;
|
callId: string;
|
||||||
|
|
|
@ -307,6 +307,8 @@ const dataInterface: ServerInterface = {
|
||||||
getLastConversationMessage,
|
getLastConversationMessage,
|
||||||
getAllCallHistory,
|
getAllCallHistory,
|
||||||
clearCallHistory,
|
clearCallHistory,
|
||||||
|
getCallHistoryUnreadCount,
|
||||||
|
markCallHistoryRead,
|
||||||
getCallHistoryMessageByCallId,
|
getCallHistoryMessageByCallId,
|
||||||
getCallHistory,
|
getCallHistory,
|
||||||
getCallHistoryGroupsCount,
|
getCallHistoryGroupsCount,
|
||||||
|
@ -3346,11 +3348,38 @@ async function getCallHistory(
|
||||||
return callHistoryDetailsSchema.parse(row);
|
return callHistoryDetailsSchema.parse(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
const MISSED = sqlConstant(DirectCallStatus.Missed);
|
const READ_STATUS_UNREAD = sqlConstant(ReadStatus.Unread);
|
||||||
const DELETED = sqlConstant(DirectCallStatus.Deleted);
|
const READ_STATUS_READ = sqlConstant(ReadStatus.Read);
|
||||||
const INCOMING = sqlConstant(CallDirection.Incoming);
|
const CALL_STATUS_MISSED = sqlConstant(DirectCallStatus.Missed);
|
||||||
|
const CALL_STATUS_DELETED = sqlConstant(DirectCallStatus.Deleted);
|
||||||
|
const CALL_STATUS_INCOMING = sqlConstant(CallDirection.Incoming);
|
||||||
const FOUR_HOURS_IN_MS = sqlConstant(4 * 60 * 60 * 1000);
|
const FOUR_HOURS_IN_MS = sqlConstant(4 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
async function getCallHistoryUnreadCount(): Promise<number> {
|
||||||
|
const db = getInstance();
|
||||||
|
const [query, params] = sql`
|
||||||
|
SELECT count(*) FROM messages
|
||||||
|
LEFT JOIN callsHistory ON callsHistory.callId = messages.callId
|
||||||
|
WHERE messages.type IS 'call-history'
|
||||||
|
AND messages.readStatus IS ${READ_STATUS_UNREAD}
|
||||||
|
AND callsHistory.status IS ${CALL_STATUS_MISSED}
|
||||||
|
AND callsHistory.direction IS ${CALL_STATUS_INCOMING}
|
||||||
|
`;
|
||||||
|
const row = db.prepare(query).pluck().get(params);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markCallHistoryRead(callId: string): Promise<void> {
|
||||||
|
const db = getInstance();
|
||||||
|
const [query, params] = sql`
|
||||||
|
UPDATE messages
|
||||||
|
SET readStatus = ${READ_STATUS_READ}
|
||||||
|
WHERE type IS 'call-history'
|
||||||
|
AND callId IS ${callId}
|
||||||
|
`;
|
||||||
|
db.prepare(query).run(params);
|
||||||
|
}
|
||||||
|
|
||||||
function getCallHistoryGroupDataSync(
|
function getCallHistoryGroupDataSync(
|
||||||
db: Database,
|
db: Database,
|
||||||
isCount: boolean,
|
isCount: boolean,
|
||||||
|
@ -3406,10 +3435,10 @@ function getCallHistoryGroupDataSync(
|
||||||
|
|
||||||
const filterClause =
|
const filterClause =
|
||||||
status === CallHistoryFilterStatus.All
|
status === CallHistoryFilterStatus.All
|
||||||
? sqlFragment`status IS NOT ${DELETED}`
|
? sqlFragment`status IS NOT ${CALL_STATUS_DELETED}`
|
||||||
: sqlFragment`
|
: sqlFragment`
|
||||||
direction IS ${INCOMING} AND
|
direction IS ${CALL_STATUS_INCOMING} AND
|
||||||
status IS ${MISSED} AND status IS NOT ${DELETED}
|
status IS ${CALL_STATUS_MISSED} AND status IS NOT ${CALL_STATUS_DELETED}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const offsetLimit =
|
const offsetLimit =
|
||||||
|
@ -3445,8 +3474,8 @@ function getCallHistoryGroupDataSync(
|
||||||
-- Tracking Android & Desktop separately to make the queries easier to compare
|
-- Tracking Android & Desktop separately to make the queries easier to compare
|
||||||
-- Android Constraints:
|
-- Android Constraints:
|
||||||
AND (
|
AND (
|
||||||
(callsHistory.status IS c.status AND callsHistory.status IS ${MISSED}) OR
|
(callsHistory.status IS c.status AND callsHistory.status IS ${CALL_STATUS_MISSED}) OR
|
||||||
(callsHistory.status IS NOT ${MISSED} AND c.status IS NOT ${MISSED})
|
(callsHistory.status IS NOT ${CALL_STATUS_MISSED} AND c.status IS NOT ${CALL_STATUS_MISSED})
|
||||||
)
|
)
|
||||||
-- Desktop Constraints:
|
-- Desktop Constraints:
|
||||||
AND callsHistory.status IS c.status
|
AND callsHistory.status IS c.status
|
||||||
|
@ -3474,8 +3503,8 @@ function getCallHistoryGroupDataSync(
|
||||||
-- Tracking Android & Desktop separately to make the queries easier to compare
|
-- Tracking Android & Desktop separately to make the queries easier to compare
|
||||||
-- Android Constraints:
|
-- Android Constraints:
|
||||||
AND (
|
AND (
|
||||||
(callsHistory.status IS c.status AND callsHistory.status IS ${MISSED}) OR
|
(callsHistory.status IS c.status AND callsHistory.status IS ${CALL_STATUS_MISSED}) OR
|
||||||
(callsHistory.status IS NOT ${MISSED} AND c.status IS NOT ${MISSED})
|
(callsHistory.status IS NOT ${CALL_STATUS_MISSED} AND c.status IS NOT ${CALL_STATUS_MISSED})
|
||||||
)
|
)
|
||||||
-- Desktop Constraints:
|
-- Desktop Constraints:
|
||||||
AND callsHistory.status IS c.status
|
AND callsHistory.status IS c.status
|
||||||
|
|
|
@ -17,11 +17,13 @@ import * as Errors from '../../types/errors';
|
||||||
export type CallHistoryState = ReadonlyDeep<{
|
export type CallHistoryState = ReadonlyDeep<{
|
||||||
// This informs the app that underlying call history data has changed.
|
// This informs the app that underlying call history data has changed.
|
||||||
edition: number;
|
edition: number;
|
||||||
|
unreadCount: number;
|
||||||
callHistoryByCallId: Record<string, CallHistoryDetails>;
|
callHistoryByCallId: Record<string, CallHistoryDetails>;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
const CALL_HISTORY_CACHE = 'callHistory/CACHE';
|
const CALL_HISTORY_CACHE = 'callHistory/CACHE';
|
||||||
const CALL_HISTORY_RESET = 'callHistory/RESET';
|
const CALL_HISTORY_RESET = 'callHistory/RESET';
|
||||||
|
const CALL_HISTORY_UPDATE_UNREAD = 'callHistory/UPDATE_UNREAD';
|
||||||
|
|
||||||
export type CallHistoryCache = ReadonlyDeep<{
|
export type CallHistoryCache = ReadonlyDeep<{
|
||||||
type: typeof CALL_HISTORY_CACHE;
|
type: typeof CALL_HISTORY_CACHE;
|
||||||
|
@ -32,17 +34,58 @@ export type CallHistoryReset = ReadonlyDeep<{
|
||||||
type: typeof CALL_HISTORY_RESET;
|
type: typeof CALL_HISTORY_RESET;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
export type CallHistoryUpdateUnread = ReadonlyDeep<{
|
||||||
|
type: typeof CALL_HISTORY_UPDATE_UNREAD;
|
||||||
|
payload: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
export type CallHistoryAction = ReadonlyDeep<
|
export type CallHistoryAction = ReadonlyDeep<
|
||||||
CallHistoryCache | CallHistoryReset
|
CallHistoryCache | CallHistoryReset | CallHistoryUpdateUnread
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export function getEmptyState(): CallHistoryState {
|
export function getEmptyState(): CallHistoryState {
|
||||||
return {
|
return {
|
||||||
edition: 0,
|
edition: 0,
|
||||||
|
unreadCount: 0,
|
||||||
callHistoryByCallId: {},
|
callHistoryByCallId: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateCallHistoryUnreadCount(): ThunkAction<
|
||||||
|
void,
|
||||||
|
RootStateType,
|
||||||
|
unknown,
|
||||||
|
CallHistoryUpdateUnread
|
||||||
|
> {
|
||||||
|
return async dispatch => {
|
||||||
|
try {
|
||||||
|
const unreadCount = await window.Signal.Data.getCallHistoryUnreadCount();
|
||||||
|
dispatch({ type: CALL_HISTORY_UPDATE_UNREAD, payload: unreadCount });
|
||||||
|
} catch (error) {
|
||||||
|
log.error(
|
||||||
|
'Error updating call history unread count',
|
||||||
|
Errors.toLogFormat(error)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function markCallHistoryRead(
|
||||||
|
conversationId: string,
|
||||||
|
callId: string
|
||||||
|
): ThunkAction<void, RootStateType, unknown, CallHistoryUpdateUnread> {
|
||||||
|
return async dispatch => {
|
||||||
|
try {
|
||||||
|
await window.Signal.Data.markCallHistoryRead(callId);
|
||||||
|
await window.ConversationController.get(conversationId)?.updateUnread();
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Error marking call history read', Errors.toLogFormat(error));
|
||||||
|
} finally {
|
||||||
|
dispatch(updateCallHistoryUnreadCount());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function cacheCallHistory(callHistory: CallHistoryDetails): CallHistoryCache {
|
function cacheCallHistory(callHistory: CallHistoryDetails): CallHistoryCache {
|
||||||
return {
|
return {
|
||||||
type: CALL_HISTORY_CACHE,
|
type: CALL_HISTORY_CACHE,
|
||||||
|
@ -65,6 +108,7 @@ function clearAllCallHistory(): ThunkAction<
|
||||||
} finally {
|
} finally {
|
||||||
// Just force a reset, even if the clear failed.
|
// Just force a reset, even if the clear failed.
|
||||||
dispatch({ type: CALL_HISTORY_RESET });
|
dispatch({ type: CALL_HISTORY_RESET });
|
||||||
|
dispatch(updateCallHistoryUnreadCount());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -72,6 +116,8 @@ function clearAllCallHistory(): ThunkAction<
|
||||||
export const actions = {
|
export const actions = {
|
||||||
cacheCallHistory,
|
cacheCallHistory,
|
||||||
clearAllCallHistory,
|
clearAllCallHistory,
|
||||||
|
updateCallHistoryUnreadCount,
|
||||||
|
markCallHistoryRead,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useCallHistoryActions = (): BoundActionCreatorsMapObject<
|
export const useCallHistoryActions = (): BoundActionCreatorsMapObject<
|
||||||
|
@ -93,6 +139,11 @@ export function reducer(
|
||||||
[action.payload.callId]: action.payload,
|
[action.payload.callId]: action.payload,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
case CALL_HISTORY_UPDATE_UNREAD:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
unreadCount: action.payload,
|
||||||
|
};
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 { getHasStoriesSelector } from './stories2';
|
||||||
import { canEditMessage } from '../../util/canEditMessage';
|
import { canEditMessage } from '../../util/canEditMessage';
|
||||||
import { isOutgoing } from '../../messages/helpers';
|
import { isOutgoing } from '../../messages/helpers';
|
||||||
|
import {
|
||||||
|
countAllConversationsUnreadStats,
|
||||||
|
type UnreadStats,
|
||||||
|
} from '../../util/countUnreadStats';
|
||||||
|
|
||||||
export type ConversationWithStoriesType = ConversationType & {
|
export type ConversationWithStoriesType = ConversationType & {
|
||||||
hasStories?: HasStories;
|
hasStories?: HasStories;
|
||||||
|
@ -532,37 +536,12 @@ export const getAllGroupsWithInviteAccess = createSelector(
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
export type UnreadStats = Readonly<{
|
|
||||||
unreadCount: number;
|
|
||||||
unreadMentionsCount: number;
|
|
||||||
markedUnread: boolean;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export const getAllConversationsUnreadStats = createSelector(
|
export const getAllConversationsUnreadStats = createSelector(
|
||||||
getLeftPaneLists,
|
getAllConversations,
|
||||||
(leftPaneLists: LeftPaneLists): UnreadStats => {
|
(conversations): UnreadStats => {
|
||||||
let unreadCount = 0;
|
return countAllConversationsUnreadStats(conversations, {
|
||||||
let unreadMentionsCount = 0;
|
includeMuted: false,
|
||||||
let markedUnread = false;
|
});
|
||||||
|
|
||||||
function count(conversations: ReadonlyArray<ConversationType>) {
|
|
||||||
conversations.forEach(conversation => {
|
|
||||||
if (conversation.unreadCount != null) {
|
|
||||||
unreadCount += conversation.unreadCount;
|
|
||||||
}
|
|
||||||
if (conversation.unreadMentionsCount != null) {
|
|
||||||
unreadMentionsCount += conversation.unreadMentionsCount;
|
|
||||||
}
|
|
||||||
if (conversation.markedUnread) {
|
|
||||||
markedUnread = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
count(leftPaneLists.pinnedConversations);
|
|
||||||
count(leftPaneLists.conversations);
|
|
||||||
|
|
||||||
return { unreadCount, unreadMentionsCount, markedUnread };
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,9 @@
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
import type { StateType } from '../reducer';
|
import type { StateType } from '../reducer';
|
||||||
import type { NavStateType } from '../ducks/nav';
|
import type { NavStateType } from '../ducks/nav';
|
||||||
|
import { getAllConversationsUnreadStats } from './conversations';
|
||||||
|
import { getStoriesNotificationCount } from './stories';
|
||||||
|
import type { UnreadStats } from '../../util/countUnreadStats';
|
||||||
|
|
||||||
function getNav(state: StateType): NavStateType {
|
function getNav(state: StateType): NavStateType {
|
||||||
return state.nav;
|
return state.nav;
|
||||||
|
@ -12,3 +15,17 @@ function getNav(state: StateType): NavStateType {
|
||||||
export const getSelectedNavTab = createSelector(getNav, nav => {
|
export const getSelectedNavTab = createSelector(getNav, nav => {
|
||||||
return nav.selectedNavTab;
|
return nav.selectedNavTab;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const getAppUnreadStats = createSelector(
|
||||||
|
getAllConversationsUnreadStats,
|
||||||
|
getStoriesNotificationCount,
|
||||||
|
(conversationsUnreadStats, storiesNotificationCount): UnreadStats => {
|
||||||
|
return {
|
||||||
|
// Note: Conversation unread stats includes the call history unread count.
|
||||||
|
unreadCount:
|
||||||
|
conversationsUnreadStats.unreadCount + storiesNotificationCount,
|
||||||
|
unreadMentionsCount: conversationsUnreadStats.unreadMentionsCount,
|
||||||
|
markedUnread: conversationsUnreadStats.markedUnread,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
|
@ -42,3 +42,8 @@ export const isOSUnsupported = createSelector(
|
||||||
getUpdatesState,
|
getUpdatesState,
|
||||||
({ dialogType }) => dialogType === DialogType.UnsupportedOS
|
({ dialogType }) => dialogType === DialogType.UnsupportedOS
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const getHasPendingUpdate = createSelector(
|
||||||
|
getUpdatesState,
|
||||||
|
({ didSnooze }) => didSnooze === true
|
||||||
|
);
|
||||||
|
|
|
@ -27,6 +27,9 @@ import { useCallingActions } from '../ducks/calling';
|
||||||
import { getActiveCallState } from '../selectors/calling';
|
import { getActiveCallState } from '../selectors/calling';
|
||||||
import { useCallHistoryActions } from '../ducks/callHistory';
|
import { useCallHistoryActions } from '../ducks/callHistory';
|
||||||
import { getCallHistoryEdition } from '../selectors/callHistory';
|
import { getCallHistoryEdition } from '../selectors/callHistory';
|
||||||
|
import { getHasPendingUpdate } from '../selectors/updates';
|
||||||
|
import { getHasAnyFailedStorySends } from '../selectors/stories';
|
||||||
|
import { getAppUnreadStats } from '../selectors/nav';
|
||||||
|
|
||||||
function getCallHistoryFilter(
|
function getCallHistoryFilter(
|
||||||
allConversations: Array<ConversationType>,
|
allConversations: Array<ConversationType>,
|
||||||
|
@ -91,11 +94,16 @@ export function SmartCallsTab(): JSX.Element {
|
||||||
const activeCall = useSelector(getActiveCallState);
|
const activeCall = useSelector(getActiveCallState);
|
||||||
const callHistoryEdition = useSelector(getCallHistoryEdition);
|
const callHistoryEdition = useSelector(getCallHistoryEdition);
|
||||||
|
|
||||||
|
const hasPendingUpdate = useSelector(getHasPendingUpdate);
|
||||||
|
const hasFailedStorySends = useSelector(getHasAnyFailedStorySends);
|
||||||
|
const appUnreadStats = useSelector(getAppUnreadStats);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
onOutgoingAudioCallInConversation,
|
onOutgoingAudioCallInConversation,
|
||||||
onOutgoingVideoCallInConversation,
|
onOutgoingVideoCallInConversation,
|
||||||
} = useCallingActions();
|
} = useCallingActions();
|
||||||
const { clearAllCallHistory: clearCallHistory } = useCallHistoryActions();
|
const { clearAllCallHistory: clearCallHistory, markCallHistoryRead } =
|
||||||
|
useCallHistoryActions();
|
||||||
|
|
||||||
const getCallHistoryGroupsCount = useCallback(
|
const getCallHistoryGroupsCount = useCallback(
|
||||||
async (options: CallHistoryFilterOptions) => {
|
async (options: CallHistoryFilterOptions) => {
|
||||||
|
@ -145,12 +153,16 @@ export function SmartCallsTab(): JSX.Element {
|
||||||
<CallsTab
|
<CallsTab
|
||||||
activeCall={activeCall}
|
activeCall={activeCall}
|
||||||
allConversations={allConversations}
|
allConversations={allConversations}
|
||||||
|
appUnreadStats={appUnreadStats}
|
||||||
getConversation={getConversation}
|
getConversation={getConversation}
|
||||||
getCallHistoryGroupsCount={getCallHistoryGroupsCount}
|
getCallHistoryGroupsCount={getCallHistoryGroupsCount}
|
||||||
getCallHistoryGroups={getCallHistoryGroups}
|
getCallHistoryGroups={getCallHistoryGroups}
|
||||||
|
hasFailedStorySends={hasFailedStorySends}
|
||||||
|
hasPendingUpdate={hasPendingUpdate}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
navTabsCollapsed={navTabsCollapsed}
|
navTabsCollapsed={navTabsCollapsed}
|
||||||
onClearCallHistory={clearCallHistory}
|
onClearCallHistory={clearCallHistory}
|
||||||
|
onMarkCallHistoryRead={markCallHistoryRead}
|
||||||
onToggleNavTabsCollapse={toggleNavTabsCollapse}
|
onToggleNavTabsCollapse={toggleNavTabsCollapse}
|
||||||
onOutgoingAudioCallInConversation={onOutgoingAudioCallInConversation}
|
onOutgoingAudioCallInConversation={onOutgoingAudioCallInConversation}
|
||||||
onOutgoingVideoCallInConversation={onOutgoingVideoCallInConversation}
|
onOutgoingVideoCallInConversation={onOutgoingVideoCallInConversation}
|
||||||
|
|
|
@ -20,6 +20,9 @@ import { showToast } from '../../util/showToast';
|
||||||
import { ToastStickerPackInstallFailed } from '../../components/ToastStickerPackInstallFailed';
|
import { ToastStickerPackInstallFailed } from '../../components/ToastStickerPackInstallFailed';
|
||||||
import { getNavTabsCollapsed } from '../selectors/items';
|
import { getNavTabsCollapsed } from '../selectors/items';
|
||||||
import { useItemsActions } from '../ducks/items';
|
import { useItemsActions } from '../ducks/items';
|
||||||
|
import { getHasAnyFailedStorySends } from '../selectors/stories';
|
||||||
|
import { getHasPendingUpdate } from '../selectors/updates';
|
||||||
|
import { getAppUnreadStats } from '../selectors/nav';
|
||||||
|
|
||||||
function renderConversationView() {
|
function renderConversationView() {
|
||||||
return <SmartConversationView />;
|
return <SmartConversationView />;
|
||||||
|
@ -36,6 +39,10 @@ function renderMiniPlayer(options: { shouldFlow: boolean }) {
|
||||||
export function SmartChatsTab(): JSX.Element {
|
export function SmartChatsTab(): JSX.Element {
|
||||||
const i18n = useSelector(getIntl);
|
const i18n = useSelector(getIntl);
|
||||||
const navTabsCollapsed = useSelector(getNavTabsCollapsed);
|
const navTabsCollapsed = useSelector(getNavTabsCollapsed);
|
||||||
|
const hasFailedStorySends = useSelector(getHasAnyFailedStorySends);
|
||||||
|
const hasPendingUpdate = useSelector(getHasPendingUpdate);
|
||||||
|
const appUnreadStats = useSelector(getAppUnreadStats);
|
||||||
|
|
||||||
const { selectedConversationId, targetedMessage, targetedMessageSource } =
|
const { selectedConversationId, targetedMessage, targetedMessageSource } =
|
||||||
useSelector<StateType, ConversationsStateType>(
|
useSelector<StateType, ConversationsStateType>(
|
||||||
state => state.conversations
|
state => state.conversations
|
||||||
|
@ -137,7 +144,10 @@ export function SmartChatsTab(): JSX.Element {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChatsTab
|
<ChatsTab
|
||||||
|
appUnreadStats={appUnreadStats}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
|
hasFailedStorySends={hasFailedStorySends}
|
||||||
|
hasPendingUpdate={hasPendingUpdate}
|
||||||
navTabsCollapsed={navTabsCollapsed}
|
navTabsCollapsed={navTabsCollapsed}
|
||||||
onToggleNavTabsCollapse={toggleNavTabsCollapse}
|
onToggleNavTabsCollapse={toggleNavTabsCollapse}
|
||||||
prevConversationId={prevConversationId}
|
prevConversationId={prevConversationId}
|
||||||
|
|
|
@ -11,7 +11,6 @@ import {
|
||||||
getMe,
|
getMe,
|
||||||
} from '../selectors/conversations';
|
} from '../selectors/conversations';
|
||||||
import { getPreferredBadgeSelector } from '../selectors/badges';
|
import { getPreferredBadgeSelector } from '../selectors/badges';
|
||||||
import type { StateType } from '../reducer';
|
|
||||||
import {
|
import {
|
||||||
getHasAnyFailedStorySends,
|
getHasAnyFailedStorySends,
|
||||||
getStoriesNotificationCount,
|
getStoriesNotificationCount,
|
||||||
|
@ -23,6 +22,8 @@ import { getStoriesEnabled } from '../selectors/items';
|
||||||
import { getSelectedNavTab } from '../selectors/nav';
|
import { getSelectedNavTab } from '../selectors/nav';
|
||||||
import type { NavTab } from '../ducks/nav';
|
import type { NavTab } from '../ducks/nav';
|
||||||
import { useNavActions } from '../ducks/nav';
|
import { useNavActions } from '../ducks/nav';
|
||||||
|
import { getHasPendingUpdate } from '../selectors/updates';
|
||||||
|
import { getCallHistoryUnreadCount } from '../selectors/callHistory';
|
||||||
|
|
||||||
export type SmartNavTabsProps = Readonly<{
|
export type SmartNavTabsProps = Readonly<{
|
||||||
navTabsCollapsed: boolean;
|
navTabsCollapsed: boolean;
|
||||||
|
@ -48,11 +49,9 @@ export function SmartNavTabs({
|
||||||
const storiesEnabled = useSelector(getStoriesEnabled);
|
const storiesEnabled = useSelector(getStoriesEnabled);
|
||||||
const unreadConversationsStats = useSelector(getAllConversationsUnreadStats);
|
const unreadConversationsStats = useSelector(getAllConversationsUnreadStats);
|
||||||
const unreadStoriesCount = useSelector(getStoriesNotificationCount);
|
const unreadStoriesCount = useSelector(getStoriesNotificationCount);
|
||||||
|
const unreadCallsCount = useSelector(getCallHistoryUnreadCount);
|
||||||
const hasFailedStorySends = useSelector(getHasAnyFailedStorySends);
|
const hasFailedStorySends = useSelector(getHasAnyFailedStorySends);
|
||||||
|
const hasPendingUpdate = useSelector(getHasPendingUpdate);
|
||||||
const hasPendingUpdate = useSelector((state: StateType) => {
|
|
||||||
return state.updates.didSnooze;
|
|
||||||
});
|
|
||||||
|
|
||||||
const { toggleProfileEditor } = useGlobalModalActions();
|
const { toggleProfileEditor } = useGlobalModalActions();
|
||||||
const { startUpdate } = useUpdatesActions();
|
const { startUpdate } = useUpdatesActions();
|
||||||
|
@ -87,6 +86,7 @@ export function SmartNavTabs({
|
||||||
selectedNavTab={selectedNavTab}
|
selectedNavTab={selectedNavTab}
|
||||||
storiesEnabled={storiesEnabled}
|
storiesEnabled={storiesEnabled}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
|
unreadCallsCount={unreadCallsCount}
|
||||||
unreadConversationsStats={unreadConversationsStats}
|
unreadConversationsStats={unreadConversationsStats}
|
||||||
unreadStoriesCount={unreadStoriesCount}
|
unreadStoriesCount={unreadStoriesCount}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -45,6 +45,7 @@ function mapStateToProps(
|
||||||
firstName,
|
firstName,
|
||||||
familyName,
|
familyName,
|
||||||
id: conversationId,
|
id: conversationId,
|
||||||
|
phoneNumber,
|
||||||
username,
|
username,
|
||||||
} = getMe(state);
|
} = getMe(state);
|
||||||
const recentEmojis = selectRecentEmojis(state);
|
const recentEmojis = selectRecentEmojis(state);
|
||||||
|
@ -74,6 +75,7 @@ function mapStateToProps(
|
||||||
isUsernameFlagEnabled,
|
isUsernameFlagEnabled,
|
||||||
recentEmojis,
|
recentEmojis,
|
||||||
skinTone,
|
skinTone,
|
||||||
|
phoneNumber,
|
||||||
userAvatarData,
|
userAvatarData,
|
||||||
username,
|
username,
|
||||||
usernameEditState,
|
usernameEditState,
|
||||||
|
|
|
@ -21,6 +21,7 @@ import {
|
||||||
} from '../selectors/items';
|
} from '../selectors/items';
|
||||||
import {
|
import {
|
||||||
getAddStoryData,
|
getAddStoryData,
|
||||||
|
getHasAnyFailedStorySends,
|
||||||
getSelectedStoryData,
|
getSelectedStoryData,
|
||||||
getStories,
|
getStories,
|
||||||
} from '../selectors/stories';
|
} from '../selectors/stories';
|
||||||
|
@ -30,6 +31,8 @@ import { useStoriesActions } from '../ducks/stories';
|
||||||
import { useToastActions } from '../ducks/toast';
|
import { useToastActions } from '../ducks/toast';
|
||||||
import { useAudioPlayerActions } from '../ducks/audioPlayer';
|
import { useAudioPlayerActions } from '../ducks/audioPlayer';
|
||||||
import { useItemsActions } from '../ducks/items';
|
import { useItemsActions } from '../ducks/items';
|
||||||
|
import { getHasPendingUpdate } from '../selectors/updates';
|
||||||
|
import { getAppUnreadStats } from '../selectors/nav';
|
||||||
|
|
||||||
function renderStoryCreator(): JSX.Element {
|
function renderStoryCreator(): JSX.Element {
|
||||||
return <SmartStoryCreator />;
|
return <SmartStoryCreator />;
|
||||||
|
@ -66,6 +69,9 @@ export function SmartStoriesTab(): JSX.Element | null {
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasViewReceiptSetting = useSelector(getHasStoryViewReceiptSetting);
|
const hasViewReceiptSetting = useSelector(getHasStoryViewReceiptSetting);
|
||||||
|
const hasPendingUpdate = useSelector(getHasPendingUpdate);
|
||||||
|
const hasFailedStorySends = useSelector(getHasAnyFailedStorySends);
|
||||||
|
const appUnreadStats = useSelector(getAppUnreadStats);
|
||||||
|
|
||||||
const remoteConfig = useSelector(getRemoteConfig);
|
const remoteConfig = useSelector(getRemoteConfig);
|
||||||
const maxAttachmentSizeInKb = getMaximumAttachmentSizeInKb(
|
const maxAttachmentSizeInKb = getMaximumAttachmentSizeInKb(
|
||||||
|
@ -92,8 +98,11 @@ export function SmartStoriesTab(): JSX.Element | null {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StoriesTab
|
<StoriesTab
|
||||||
|
appUnreadStats={appUnreadStats}
|
||||||
addStoryData={addStoryData}
|
addStoryData={addStoryData}
|
||||||
getPreferredBadge={getPreferredBadge}
|
getPreferredBadge={getPreferredBadge}
|
||||||
|
hasFailedStorySends={hasFailedStorySends}
|
||||||
|
hasPendingUpdate={hasPendingUpdate}
|
||||||
hiddenStories={hiddenStories}
|
hiddenStories={hiddenStories}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
maxAttachmentSizeInKb={maxAttachmentSizeInKb}
|
maxAttachmentSizeInKb={maxAttachmentSizeInKb}
|
||||||
|
|
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');
|
debug('opening avatar context menu');
|
||||||
await window.getByRole('button', { name: 'Profile' }).click();
|
await window.getByRole('button', { name: 'Profile' }).click();
|
||||||
|
|
||||||
debug('opening profile editor');
|
|
||||||
await window
|
|
||||||
.locator('.module-avatar-popup .module-avatar-popup__profile')
|
|
||||||
.click();
|
|
||||||
|
|
||||||
debug('opening username editor');
|
debug('opening username editor');
|
||||||
const profileEditor = window.locator('.ProfileEditor');
|
const profileEditor = window.locator('.ProfileEditor');
|
||||||
await profileEditor.locator('.ProfileEditor__row >> "Username"').click();
|
await profileEditor.locator('.ProfileEditor__row >> "Username"').click();
|
||||||
|
|
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 { parseAndFormatPhoneNumber } from './libphonenumberInstance';
|
||||||
import { WEEK } from './durations';
|
import { WEEK } from './durations';
|
||||||
import { fuseGetFnRemoveDiacritics, getCachedFuseIndex } from './fuse';
|
import { fuseGetFnRemoveDiacritics, getCachedFuseIndex } from './fuse';
|
||||||
import { getConversationUnreadCountForAppBadge } from './getConversationUnreadCountForAppBadge';
|
import { countConversationUnreadStats, hasUnread } from './countUnreadStats';
|
||||||
|
|
||||||
// Fuse.js scores have order of 0.01
|
// Fuse.js scores have order of 0.01
|
||||||
const ACTIVE_AT_SCORE_FACTOR = (1 / WEEK) * 0.01;
|
const ACTIVE_AT_SCORE_FACTOR = (1 / WEEK) * 0.01;
|
||||||
|
@ -73,18 +73,11 @@ COMMANDS.set('groupIdEndsWith', (conversations, query) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
COMMANDS.set('unread', conversations => {
|
COMMANDS.set('unread', conversations => {
|
||||||
const canCountMutedConversations =
|
const includeMuted =
|
||||||
window.storage.get('badge-count-muted-conversations') || false;
|
window.storage.get('badge-count-muted-conversations') || false;
|
||||||
|
return conversations.filter(conversation => {
|
||||||
return conversations.filter(convo => {
|
return hasUnread(
|
||||||
return getConversationUnreadCountForAppBadge(
|
countConversationUnreadStats(conversation, { includeMuted })
|
||||||
{
|
|
||||||
...convo,
|
|
||||||
|
|
||||||
// Difference between redux type and conversation attributes
|
|
||||||
active_at: convo.activeAt,
|
|
||||||
},
|
|
||||||
canCountMutedConversations
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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;
|
restart: () => void;
|
||||||
setAutoHideMenuBar: (value: boolean) => void;
|
setAutoHideMenuBar: (value: boolean) => void;
|
||||||
setAutoLaunch: (value: boolean) => Promise<void>;
|
setAutoLaunch: (value: boolean) => Promise<void>;
|
||||||
setBadgeCount: (count: number) => void;
|
setBadge: (badge: number | 'marked-unread') => void;
|
||||||
setMenuBarVisibility: (value: boolean) => void;
|
setMenuBarVisibility: (value: boolean) => void;
|
||||||
showDebugLog: () => void;
|
showDebugLog: () => void;
|
||||||
showPermissionsPopup: (
|
showPermissionsPopup: (
|
||||||
|
|
|
@ -103,7 +103,7 @@ const IPC: IPCType = {
|
||||||
},
|
},
|
||||||
setAutoHideMenuBar: autoHide => ipc.send('set-auto-hide-menu-bar', autoHide),
|
setAutoHideMenuBar: autoHide => ipc.send('set-auto-hide-menu-bar', autoHide),
|
||||||
setAutoLaunch: value => ipc.invoke('set-auto-launch', value),
|
setAutoLaunch: value => ipc.invoke('set-auto-launch', value),
|
||||||
setBadgeCount: count => ipc.send('set-badge-count', count),
|
setBadge: badge => ipc.send('set-badge', badge),
|
||||||
setMenuBarVisibility: visibility =>
|
setMenuBarVisibility: visibility =>
|
||||||
ipc.send('set-menu-bar-visibility', visibility),
|
ipc.send('set-menu-bar-visibility', visibility),
|
||||||
showDebugLog: () => {
|
showDebugLog: () => {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue