Show mentioned badges & enable scrolling to mentions in conversations
This commit is contained in:
parent
caaeda8abe
commit
d012779e87
21 changed files with 694 additions and 184 deletions
|
@ -863,6 +863,10 @@
|
||||||
"messageformat": "New messages below",
|
"messageformat": "New messages below",
|
||||||
"description": "Alt text for button to take user down to bottom of conversation with more than one message out of screen"
|
"description": "Alt text for button to take user down to bottom of conversation with more than one message out of screen"
|
||||||
},
|
},
|
||||||
|
"icu:mentionsBelow": {
|
||||||
|
"messageformat": "New mentions below",
|
||||||
|
"description": "Alt text for button to take user down to next mention of them further down the message list (currently out of screen)"
|
||||||
|
},
|
||||||
"unreadMessage": {
|
"unreadMessage": {
|
||||||
"message": "1 Unread Message",
|
"message": "1 Unread Message",
|
||||||
"description": "(deleted 03/29/2023) Text for unread message separator, just one message"
|
"description": "(deleted 03/29/2023) Text for unread message separator, just one message"
|
||||||
|
|
|
@ -4460,8 +4460,7 @@ button.module-image__border-overlay:focus {
|
||||||
}
|
}
|
||||||
|
|
||||||
&--contact-or-conversation {
|
&--contact-or-conversation {
|
||||||
$unread-indicator-selector: '#{&}__unread-indicator';
|
$unread-indicator: '#{&}__unread-indicator';
|
||||||
$avatar-container-unread-indicator-selector: '#{&}__avatar-container #{$unread-indicator-selector}';
|
|
||||||
|
|
||||||
@include button-reset;
|
@include button-reset;
|
||||||
|
|
||||||
|
@ -4482,6 +4481,42 @@ button.module-image__border-overlay:focus {
|
||||||
padding-inline: 14px 0;
|
padding-inline: 14px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#{$unread-indicator} {
|
||||||
|
$size: 18px;
|
||||||
|
height: $size;
|
||||||
|
min-width: $size;
|
||||||
|
border-radius: 10px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.module-conversation-list--width-narrow & {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include light-theme {
|
||||||
|
background-color: $color-ultramarine;
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
background-color: $color-ultramarine-dawn;
|
||||||
|
}
|
||||||
|
&--unread-messages {
|
||||||
|
@include font-caption-bold;
|
||||||
|
text-align: center;
|
||||||
|
word-break: normal;
|
||||||
|
padding-inline: 4px;
|
||||||
|
line-height: 100%;
|
||||||
|
color: $color-white;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
&--unread-mentions__icon {
|
||||||
|
@include color-svg('../images/icons/v3/at/at.svg', $color-white);
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&--is-button {
|
&--is-button {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
@ -4492,10 +4527,16 @@ button.module-image__border-overlay:focus {
|
||||||
&:hover:not(:disabled),
|
&:hover:not(:disabled),
|
||||||
&:focus:not(:disabled) {
|
&:focus:not(:disabled) {
|
||||||
@include light-theme {
|
@include light-theme {
|
||||||
background-color: $color-black-alpha-06;
|
background-color: $color-gray-05;
|
||||||
|
#{$unread-indicator} {
|
||||||
|
border-color: $color-gray-05;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@include dark-theme {
|
@include dark-theme {
|
||||||
background-color: $color-white-alpha-06;
|
background-color: $color-gray-75;
|
||||||
|
#{$unread-indicator} {
|
||||||
|
border-color: $color-gray-75;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4521,17 +4562,22 @@ button.module-image__border-overlay:focus {
|
||||||
|
|
||||||
&--is-selected {
|
&--is-selected {
|
||||||
@include light-theme {
|
@include light-theme {
|
||||||
$background-color: $color-gray-15;
|
background-color: $color-gray-15;
|
||||||
background-color: $background-color;
|
}
|
||||||
#{$avatar-container-unread-indicator-selector} {
|
@include dark-theme {
|
||||||
border-color: $background-color;
|
background-color: $color-gray-65;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--is-selected &__avatar-container {
|
||||||
|
@include light-theme {
|
||||||
|
#{$unread-indicator} {
|
||||||
|
border-color: $color-gray-15;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@include dark-theme {
|
@include dark-theme {
|
||||||
$background-color: $color-gray-65;
|
#{$unread-indicator} {
|
||||||
background-color: $background-color;
|
border-color: $color-gray-65;
|
||||||
#{$avatar-container-unread-indicator-selector} {
|
|
||||||
border-color: $background-color;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4539,22 +4585,21 @@ button.module-image__border-overlay:focus {
|
||||||
&__avatar-container {
|
&__avatar-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
#{$unread-indicator-selector} {
|
#{$unread-indicator} {
|
||||||
$border-width: 3px;
|
$border-width: 3px;
|
||||||
$size: 21px + $border-width;
|
$size: 21px + $border-width;
|
||||||
|
|
||||||
@include rounded-corners;
|
@include rounded-corners;
|
||||||
border: $border-width solid transparent;
|
border: $border-width solid transparent;
|
||||||
display: none;
|
|
||||||
height: $size;
|
height: $size;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-width: $size;
|
min-width: $size;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset-inline-end: -(5px + $border-width);
|
|
||||||
top: -(1px + $border-width);
|
top: -(1px + $border-width);
|
||||||
|
display: none;
|
||||||
|
|
||||||
.module-conversation-list--width-narrow & {
|
.module-conversation-list--width-narrow & {
|
||||||
display: block;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
@include light-theme {
|
@include light-theme {
|
||||||
|
@ -4563,39 +4608,21 @@ button.module-image__border-overlay:focus {
|
||||||
@include dark-theme {
|
@include dark-theme {
|
||||||
border-color: $color-gray-80;
|
border-color: $color-gray-80;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// We want this to just be the unread indicator selector, not a child of the parent.
|
&--unread-messages {
|
||||||
@at-root #{$unread-indicator-selector} {
|
inset-inline-end: -(5px + $border-width);
|
||||||
$size: 18px;
|
}
|
||||||
flex-shrink: 0;
|
&--unread-mentions {
|
||||||
|
inset-inline-start: -(5px + $border-width);
|
||||||
@include font-caption-bold;
|
}
|
||||||
border-radius: 10px;
|
&--is-selected {
|
||||||
color: $color-white;
|
@include light-theme {
|
||||||
font-weight: 500;
|
border-color: $color-gray-15;
|
||||||
height: $size;
|
}
|
||||||
line-height: $size;
|
@include dark-theme {
|
||||||
margin-inline-start: 10px;
|
border-color: $color-gray-65;
|
||||||
margin-top: 1px;
|
}
|
||||||
min-width: $size;
|
}
|
||||||
padding-inline: 4px;
|
|
||||||
text-align: center;
|
|
||||||
word-break: normal;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
.module-conversation-list--width-narrow & {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@include light-theme {
|
|
||||||
background-color: $color-ultramarine;
|
|
||||||
}
|
|
||||||
@include dark-theme {
|
|
||||||
background-color: $color-ultramarine-dawn;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4860,6 +4887,15 @@ button.module-image__border-overlay:focus {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__unread-indicators {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-inline-start: 10px;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__checkbox {
|
&__checkbox {
|
||||||
|
@ -5012,11 +5048,6 @@ button.module-image__border-overlay:focus {
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
border: 2px solid $color-gray-02;
|
border: 2px solid $color-gray-02;
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-conversation-list__item--contact-or-conversation:hover
|
|
||||||
.module-conversation-list__item--contact-or-conversation__unread-indicator {
|
|
||||||
border-color: mix($color-black, $background-color, 6%);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@include dark-theme {
|
@include dark-theme {
|
||||||
|
@ -5028,11 +5059,6 @@ button.module-image__border-overlay:focus {
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
border: 2px solid $color-gray-80;
|
border: 2px solid $color-gray-80;
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-conversation-list__item--contact-or-conversation:hover
|
|
||||||
.module-conversation-list__item--contact-or-conversation__unread-indicator {
|
|
||||||
border-color: mix($color-white, $background-color, 6%);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -5351,6 +5377,17 @@ button.module-image__border-overlay:focus {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.module-timeline__scrolldown-buttons {
|
||||||
|
z-index: $z-index-scroll-down-button;
|
||||||
|
position: absolute;
|
||||||
|
inset-inline-end: 16px;
|
||||||
|
bottom: 12px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.ReactVirtualized__List {
|
.ReactVirtualized__List {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,68 +2,72 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
.ScrollDownButton {
|
.ScrollDownButton {
|
||||||
z-index: $z-index-scroll-down-button;
|
position: relative;
|
||||||
position: absolute;
|
|
||||||
inset-inline-end: 16px;
|
|
||||||
bottom: 12px;
|
|
||||||
|
|
||||||
&__button {
|
height: 36px;
|
||||||
position: relative;
|
width: 36px;
|
||||||
|
|
||||||
height: 36px;
|
display: flex;
|
||||||
width: 36px;
|
border-radius: 18px;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
display: flex;
|
box-shadow: 0px 0px 2px $color-black-alpha-20,
|
||||||
border-radius: 18px;
|
0px 2px 6px $color-black-alpha-12;
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
box-shadow: 0px 0px 2px $color-black-alpha-20,
|
@include light-theme {
|
||||||
0px 2px 6px $color-black-alpha-12;
|
background-color: $color-white;
|
||||||
|
}
|
||||||
|
@include dark-theme {
|
||||||
|
background-color: $color-gray-75;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon--unread-mentions {
|
||||||
|
height: 17px;
|
||||||
|
width: 17px;
|
||||||
@include light-theme {
|
@include light-theme {
|
||||||
background-color: $color-white;
|
@include color-svg('../images/icons/v3/at/at.svg', $color-gray-75);
|
||||||
}
|
}
|
||||||
|
|
||||||
@include dark-theme {
|
@include dark-theme {
|
||||||
background-color: $color-gray-75;
|
@include color-svg('../images/icons/v3/at/at.svg', $color-gray-15);
|
||||||
}
|
|
||||||
|
|
||||||
&__icon {
|
|
||||||
@include light-theme {
|
|
||||||
@include color-svg(
|
|
||||||
'../images/icons/v3/chevron/chevron-down.svg',
|
|
||||||
$color-gray-75
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@include dark-theme {
|
|
||||||
@include color-svg(
|
|
||||||
'../images/icons/v3/chevron/chevron-down.svg',
|
|
||||||
$color-gray-15
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
height: 20px;
|
|
||||||
width: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__badge {
|
|
||||||
position: absolute;
|
|
||||||
top: -8px;
|
|
||||||
height: 16px;
|
|
||||||
min-width: 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding-block: 1px;
|
|
||||||
padding-inline: 4px;
|
|
||||||
background-color: $color-ultramarine;
|
|
||||||
color: $color-white;
|
|
||||||
|
|
||||||
font-size: 10px;
|
|
||||||
line-height: 14px;
|
|
||||||
|
|
||||||
box-shadow: 0px 1px 4px $color-black-alpha-24;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__icon--unread-messages {
|
||||||
|
height: 20px;
|
||||||
|
width: 20px;
|
||||||
|
@include light-theme {
|
||||||
|
@include color-svg(
|
||||||
|
'../images/icons/v3/chevron/chevron-down.svg',
|
||||||
|
$color-gray-75
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@include dark-theme {
|
||||||
|
@include color-svg(
|
||||||
|
'../images/icons/v3/chevron/chevron-down.svg',
|
||||||
|
$color-gray-15
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
height: 16px;
|
||||||
|
min-width: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding-block: 1px;
|
||||||
|
padding-inline: 4px;
|
||||||
|
background-color: $color-ultramarine;
|
||||||
|
color: $color-white;
|
||||||
|
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 14px;
|
||||||
|
|
||||||
|
box-shadow: 0px 1px 4px $color-black-alpha-24;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -369,6 +369,7 @@ export function ConversationList({
|
||||||
'typingContactId',
|
'typingContactId',
|
||||||
'unblurredAvatarPath',
|
'unblurredAvatarPath',
|
||||||
'unreadCount',
|
'unreadCount',
|
||||||
|
'unreadMentionsCount',
|
||||||
'uuid',
|
'uuid',
|
||||||
]);
|
]);
|
||||||
const { badges, title, unreadCount, lastMessage } = itemProps;
|
const { badges, title, unreadCount, lastMessage } = itemProps;
|
||||||
|
|
|
@ -7,15 +7,17 @@ import { action } from '@storybook/addon-actions';
|
||||||
|
|
||||||
import { setupI18n } from '../../util/setupI18n';
|
import { setupI18n } from '../../util/setupI18n';
|
||||||
import enMessages from '../../../_locales/en/messages.json';
|
import enMessages from '../../../_locales/en/messages.json';
|
||||||
import type { Props } from './ScrollDownButton';
|
import type { ScrollDownButtonPropsType } from './ScrollDownButton';
|
||||||
import { ScrollDownButton } from './ScrollDownButton';
|
import { ScrollDownButton, ScrollDownButtonVariant } from './ScrollDownButton';
|
||||||
|
|
||||||
const i18n = setupI18n('en', enMessages);
|
const i18n = setupI18n('en', enMessages);
|
||||||
|
|
||||||
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
const createProps = (
|
||||||
|
overrideProps: Partial<ScrollDownButtonPropsType> = {}
|
||||||
|
): ScrollDownButtonPropsType => ({
|
||||||
|
variant: ScrollDownButtonVariant.UNREAD_MESSAGES,
|
||||||
i18n,
|
i18n,
|
||||||
scrollDown: action('scrollDown'),
|
onClick: action('scrollDown'),
|
||||||
conversationId: 'fake-conversation-id',
|
|
||||||
...overrideProps,
|
...overrideProps,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -23,7 +25,7 @@ export default {
|
||||||
title: 'Components/Conversation/ScrollDownButton',
|
title: 'Components/Conversation/ScrollDownButton',
|
||||||
component: ScrollDownButton,
|
component: ScrollDownButton,
|
||||||
argTypes: {
|
argTypes: {
|
||||||
unreadCount: {
|
count: {
|
||||||
control: { type: 'radio' },
|
control: { type: 'radio' },
|
||||||
options: {
|
options: {
|
||||||
None: undefined,
|
None: undefined,
|
||||||
|
@ -36,10 +38,22 @@ export default {
|
||||||
} as Meta;
|
} as Meta;
|
||||||
|
|
||||||
// eslint-disable-next-line react/function-component-definition
|
// eslint-disable-next-line react/function-component-definition
|
||||||
const Template: Story<Props> = args => <ScrollDownButton {...args} />;
|
const Template: Story<ScrollDownButtonPropsType> = args => (
|
||||||
|
<ScrollDownButton {...args} />
|
||||||
|
);
|
||||||
|
|
||||||
export const Default = Template.bind({});
|
export const UnreadMessages = Template.bind({});
|
||||||
Default.args = createProps({});
|
UnreadMessages.args = createProps({
|
||||||
Default.story = {
|
variant: ScrollDownButtonVariant.UNREAD_MESSAGES,
|
||||||
name: 'Default',
|
});
|
||||||
|
UnreadMessages.story = {
|
||||||
|
name: 'UnreadMessages',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UnreadMentions = Template.bind({});
|
||||||
|
UnreadMentions.args = createProps({
|
||||||
|
variant: ScrollDownButtonVariant.UNREAD_MENTIONS,
|
||||||
|
});
|
||||||
|
UnreadMentions.story = {
|
||||||
|
name: 'UnreadMentions',
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,53 +1,69 @@
|
||||||
// Copyright 2019 Signal Messenger, LLC
|
// Copyright 2019 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import type { LocalizerType } from '../../types/Util';
|
import type { LocalizerType } from '../../types/Util';
|
||||||
|
import { getClassNamesFor } from '../../util/getClassNamesFor';
|
||||||
|
|
||||||
export type Props = {
|
export enum ScrollDownButtonVariant {
|
||||||
unreadCount?: number;
|
UNREAD_MESSAGES = 'unread-messages',
|
||||||
conversationId: string;
|
UNREAD_MENTIONS = 'unread-mentions',
|
||||||
|
}
|
||||||
scrollDown: (conversationId: string) => void;
|
|
||||||
|
|
||||||
|
export type ScrollDownButtonPropsType = {
|
||||||
|
variant: ScrollDownButtonVariant;
|
||||||
|
count?: number;
|
||||||
|
onClick: VoidFunction;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ScrollDownButton({
|
export function ScrollDownButton({
|
||||||
conversationId,
|
variant,
|
||||||
unreadCount,
|
count,
|
||||||
|
onClick,
|
||||||
i18n,
|
i18n,
|
||||||
scrollDown,
|
}: ScrollDownButtonPropsType): JSX.Element {
|
||||||
}: Props): JSX.Element {
|
const getClassName = getClassNamesFor('ScrollDownButton');
|
||||||
const altText = unreadCount
|
|
||||||
? i18n('icu:messagesBelow')
|
|
||||||
: i18n('icu:scrollDown');
|
|
||||||
|
|
||||||
let badgeText: string | undefined;
|
let badgeText: string | undefined;
|
||||||
if (unreadCount) {
|
if (count) {
|
||||||
if (unreadCount < 100) {
|
if (count < 100) {
|
||||||
badgeText = unreadCount.toString();
|
badgeText = count.toString();
|
||||||
} else {
|
} else {
|
||||||
badgeText = '99+';
|
badgeText = '99+';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let altText: string;
|
||||||
|
switch (variant) {
|
||||||
|
case ScrollDownButtonVariant.UNREAD_MESSAGES:
|
||||||
|
altText = count ? i18n('icu:messagesBelow') : i18n('icu:scrollDown');
|
||||||
|
break;
|
||||||
|
case ScrollDownButtonVariant.UNREAD_MENTIONS:
|
||||||
|
altText = i18n('icu:mentionsBelow');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unexpected variant: ${variant}`);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ScrollDownButton">
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
className={classNames(getClassName(''), getClassName(`__${variant}`))}
|
||||||
className="ScrollDownButton__button"
|
onClick={onClick}
|
||||||
onClick={() => {
|
title={altText}
|
||||||
scrollDown(conversationId);
|
>
|
||||||
}}
|
{badgeText ? (
|
||||||
title={altText}
|
<div className={getClassName('__badge')}>{badgeText}</div>
|
||||||
>
|
) : null}
|
||||||
{badgeText ? (
|
<div
|
||||||
<div className="ScrollDownButton__button__badge">{badgeText}</div>
|
className={classNames(
|
||||||
) : null}
|
getClassName('__icon'),
|
||||||
<div className="ScrollDownButton__button__icon" />
|
getClassName(`__icon--${variant}`)
|
||||||
</button>
|
)}
|
||||||
</div>
|
/>
|
||||||
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -277,6 +277,7 @@ const actions = () => ({
|
||||||
markMessageRead: action('markMessageRead'),
|
markMessageRead: action('markMessageRead'),
|
||||||
toggleSelectMessage: action('toggleSelectMessage'),
|
toggleSelectMessage: action('toggleSelectMessage'),
|
||||||
targetMessage: action('targetMessage'),
|
targetMessage: action('targetMessage'),
|
||||||
|
scrollToOldestUnreadMention: action('scrollToOldestUnreadMention'),
|
||||||
clearTargetedMessage: action('clearTargetedMessage'),
|
clearTargetedMessage: action('clearTargetedMessage'),
|
||||||
updateSharedGroups: action('updateSharedGroups'),
|
updateSharedGroups: action('updateSharedGroups'),
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,7 @@ import React from 'react';
|
||||||
import Measure from 'react-measure';
|
import Measure from 'react-measure';
|
||||||
|
|
||||||
import type { ReadonlyDeep } from 'type-fest';
|
import type { ReadonlyDeep } from 'type-fest';
|
||||||
import { ScrollDownButton } from './ScrollDownButton';
|
import { ScrollDownButton, ScrollDownButtonVariant } from './ScrollDownButton';
|
||||||
|
|
||||||
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';
|
||||||
|
@ -100,6 +100,7 @@ type PropsHousekeepingType = {
|
||||||
isIncomingMessageRequest: boolean;
|
isIncomingMessageRequest: boolean;
|
||||||
isSomeoneTyping: boolean;
|
isSomeoneTyping: boolean;
|
||||||
unreadCount?: number;
|
unreadCount?: number;
|
||||||
|
unreadMentionsCount?: number;
|
||||||
|
|
||||||
targetedMessageId?: string;
|
targetedMessageId?: string;
|
||||||
invitedContactsForNewlyCreatedGroup: Array<ConversationType>;
|
invitedContactsForNewlyCreatedGroup: Array<ConversationType>;
|
||||||
|
@ -168,6 +169,7 @@ export type PropsActionsType = {
|
||||||
safeConversationId: string;
|
safeConversationId: string;
|
||||||
}>
|
}>
|
||||||
) => void;
|
) => void;
|
||||||
|
scrollToOldestUnreadMention: (conversationId: string) => unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PropsType = PropsDataType &
|
export type PropsType = PropsDataType &
|
||||||
|
@ -776,10 +778,12 @@ export class Timeline extends React.Component<
|
||||||
renderTypingBubble,
|
renderTypingBubble,
|
||||||
reviewGroupMemberNameCollision,
|
reviewGroupMemberNameCollision,
|
||||||
reviewMessageRequestNameCollision,
|
reviewMessageRequestNameCollision,
|
||||||
|
scrollToOldestUnreadMention,
|
||||||
shouldShowMiniPlayer,
|
shouldShowMiniPlayer,
|
||||||
theme,
|
theme,
|
||||||
totalUnseen,
|
totalUnseen,
|
||||||
unreadCount,
|
unreadCount,
|
||||||
|
unreadMentionsCount,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const {
|
const {
|
||||||
hasRecentlyScrolled,
|
hasRecentlyScrolled,
|
||||||
|
@ -815,7 +819,7 @@ export class Timeline extends React.Component<
|
||||||
areAnyMessagesUnread &&
|
areAnyMessagesUnread &&
|
||||||
areAnyMessagesBelowCurrentPosition
|
areAnyMessagesBelowCurrentPosition
|
||||||
);
|
);
|
||||||
const shouldShowScrollDownButton = Boolean(
|
const shouldShowScrollDownButtons = Boolean(
|
||||||
areThereAnyMessages &&
|
areThereAnyMessages &&
|
||||||
(areUnreadBelowCurrentPosition || areSomeMessagesBelowCurrentPosition)
|
(areUnreadBelowCurrentPosition || areSomeMessagesBelowCurrentPosition)
|
||||||
);
|
);
|
||||||
|
@ -1127,14 +1131,24 @@ export class Timeline extends React.Component<
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
{shouldShowScrollDownButtons ? (
|
||||||
|
<div className="module-timeline__scrolldown-buttons">
|
||||||
|
{unreadMentionsCount ? (
|
||||||
|
<ScrollDownButton
|
||||||
|
variant={ScrollDownButtonVariant.UNREAD_MENTIONS}
|
||||||
|
count={unreadMentionsCount}
|
||||||
|
onClick={() => scrollToOldestUnreadMention(id)}
|
||||||
|
i18n={i18n}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{shouldShowScrollDownButton ? (
|
<ScrollDownButton
|
||||||
<ScrollDownButton
|
variant={ScrollDownButtonVariant.UNREAD_MESSAGES}
|
||||||
conversationId={id}
|
count={areUnreadBelowCurrentPosition ? unreadCount : 0}
|
||||||
unreadCount={areUnreadBelowCurrentPosition ? unreadCount : 0}
|
onClick={this.onClickScrollDownButton}
|
||||||
scrollDown={this.onClickScrollDownButton}
|
i18n={i18n}
|
||||||
i18n={i18n}
|
/>
|
||||||
/>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -52,6 +52,7 @@ type PropsType = {
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
shouldShowSpinner?: boolean;
|
shouldShowSpinner?: boolean;
|
||||||
unreadCount?: number;
|
unreadCount?: number;
|
||||||
|
unreadMentionsCount?: number;
|
||||||
avatarSize?: AvatarSize;
|
avatarSize?: AvatarSize;
|
||||||
testId?: string;
|
testId?: string;
|
||||||
} & Pick<
|
} & Pick<
|
||||||
|
@ -107,6 +108,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
|
||||||
title,
|
title,
|
||||||
unblurredAvatarPath,
|
unblurredAvatarPath,
|
||||||
unreadCount,
|
unreadCount,
|
||||||
|
unreadMentionsCount,
|
||||||
uuid,
|
uuid,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
@ -166,6 +168,25 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const unreadIndicators = (() => {
|
||||||
|
if (!isUnread) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={`${CONTENT_CLASS_NAME}__unread-indicators`}>
|
||||||
|
{unreadMentionsCount ? (
|
||||||
|
<UnreadIndicator variant={UnreadIndicatorVariant.UNREAD_MENTIONS} />
|
||||||
|
) : null}
|
||||||
|
{unreadCount ? (
|
||||||
|
<UnreadIndicator
|
||||||
|
variant={UnreadIndicatorVariant.UNREAD_MESSAGES}
|
||||||
|
count={unreadCount}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
|
||||||
const contents = (
|
const contents = (
|
||||||
<>
|
<>
|
||||||
<div className={AVATAR_CONTAINER_CLASS_NAME}>
|
<div className={AVATAR_CONTAINER_CLASS_NAME}>
|
||||||
|
@ -189,7 +210,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
|
||||||
? { badge: props.badge, theme: props.theme }
|
? { badge: props.badge, theme: props.theme }
|
||||||
: { badge: undefined })}
|
: { badge: undefined })}
|
||||||
/>
|
/>
|
||||||
<UnreadIndicator count={unreadCount} isUnread={isUnread} />
|
{unreadIndicators}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
|
@ -216,7 +237,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{messageStatusIcon}
|
{messageStatusIcon}
|
||||||
<UnreadIndicator count={unreadCount} isUnread={isUnread} />
|
{unreadIndicators}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
@ -315,17 +336,46 @@ function Timestamp({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function UnreadIndicator({
|
enum UnreadIndicatorVariant {
|
||||||
count = 0,
|
UNREAD_MESSAGES = 'unread-messages',
|
||||||
isUnread,
|
UNREAD_MENTIONS = 'unread-mentions',
|
||||||
}: Readonly<{ count?: number; isUnread: boolean }>) {
|
}
|
||||||
if (!isUnread) {
|
|
||||||
return null;
|
type UnreadIndicatorPropsType =
|
||||||
|
| {
|
||||||
|
variant: UnreadIndicatorVariant.UNREAD_MESSAGES;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
| { variant: UnreadIndicatorVariant.UNREAD_MENTIONS };
|
||||||
|
|
||||||
|
function UnreadIndicator(props: UnreadIndicatorPropsType) {
|
||||||
|
let content: React.ReactNode;
|
||||||
|
|
||||||
|
switch (props.variant) {
|
||||||
|
case UnreadIndicatorVariant.UNREAD_MESSAGES:
|
||||||
|
content = props.count > 0 && props.count;
|
||||||
|
break;
|
||||||
|
case UnreadIndicatorVariant.UNREAD_MENTIONS:
|
||||||
|
content = (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
`${BASE_CLASS_NAME}__unread-indicator--${props.variant}__icon`
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error('Unexpected variant');
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames(`${BASE_CLASS_NAME}__unread-indicator`)}>
|
<div
|
||||||
{Boolean(count) && count}
|
className={classNames(
|
||||||
|
`${BASE_CLASS_NAME}__unread-indicator`,
|
||||||
|
`${BASE_CLASS_NAME}__unread-indicator--${props.variant}`
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -63,6 +63,7 @@ export type PropsData = Pick<
|
||||||
| 'typingContactId'
|
| 'typingContactId'
|
||||||
| 'unblurredAvatarPath'
|
| 'unblurredAvatarPath'
|
||||||
| 'unreadCount'
|
| 'unreadCount'
|
||||||
|
| 'unreadMentionsCount'
|
||||||
| 'uuid'
|
| 'uuid'
|
||||||
> & {
|
> & {
|
||||||
badge?: BadgeType;
|
badge?: BadgeType;
|
||||||
|
@ -106,6 +107,7 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
|
||||||
typingContactId,
|
typingContactId,
|
||||||
unblurredAvatarPath,
|
unblurredAvatarPath,
|
||||||
unreadCount,
|
unreadCount,
|
||||||
|
unreadMentionsCount,
|
||||||
uuid,
|
uuid,
|
||||||
}) {
|
}) {
|
||||||
const isMuted = Boolean(muteExpiresAt && Date.now() < muteExpiresAt);
|
const isMuted = Boolean(muteExpiresAt && Date.now() < muteExpiresAt);
|
||||||
|
@ -217,6 +219,7 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
|
||||||
theme={theme}
|
theme={theme}
|
||||||
title={title}
|
title={title}
|
||||||
unreadCount={unreadCount}
|
unreadCount={unreadCount}
|
||||||
|
unreadMentionsCount={unreadMentionsCount}
|
||||||
unblurredAvatarPath={unblurredAvatarPath}
|
unblurredAvatarPath={unblurredAvatarPath}
|
||||||
uuid={uuid}
|
uuid={uuid}
|
||||||
/>
|
/>
|
||||||
|
|
2
ts/model-types.d.ts
vendored
2
ts/model-types.d.ts
vendored
|
@ -147,6 +147,7 @@ export type MessageAttributesType = {
|
||||||
hasAttachments?: boolean | 0 | 1;
|
hasAttachments?: boolean | 0 | 1;
|
||||||
hasFileAttachments?: boolean | 0 | 1;
|
hasFileAttachments?: boolean | 0 | 1;
|
||||||
hasVisualMediaAttachments?: boolean | 0 | 1;
|
hasVisualMediaAttachments?: boolean | 0 | 1;
|
||||||
|
mentionsMe?: boolean | 0 | 1;
|
||||||
isErased?: boolean;
|
isErased?: boolean;
|
||||||
isTapToViewInvalid?: boolean;
|
isTapToViewInvalid?: boolean;
|
||||||
isViewOnce?: boolean;
|
isViewOnce?: boolean;
|
||||||
|
@ -366,6 +367,7 @@ export type ConversationAttributesType = {
|
||||||
storageVersion?: number;
|
storageVersion?: number;
|
||||||
storageUnknownFields?: string;
|
storageUnknownFields?: string;
|
||||||
unreadCount?: number;
|
unreadCount?: number;
|
||||||
|
unreadMentionsCount?: number;
|
||||||
version: number;
|
version: number;
|
||||||
|
|
||||||
// Private core info
|
// Private core info
|
||||||
|
|
|
@ -2056,6 +2056,7 @@ export class ConversationModel extends window.Backbone
|
||||||
? window.i18n('icu:noteToSelf')
|
? window.i18n('icu:noteToSelf')
|
||||||
: this.getTitle(),
|
: this.getTitle(),
|
||||||
unreadCount: this.get('unreadCount') || 0,
|
unreadCount: this.get('unreadCount') || 0,
|
||||||
|
unreadMentionsCount: this.get('unreadMentionsCount'),
|
||||||
...(isDirectConversation(this.attributes)
|
...(isDirectConversation(this.attributes)
|
||||||
? {
|
? {
|
||||||
type: 'direct' as const,
|
type: 'direct' as const,
|
||||||
|
@ -4913,17 +4914,28 @@ export class ConversationModel extends window.Backbone
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateUnread(): Promise<void> {
|
async updateUnread(): Promise<void> {
|
||||||
const unreadCount = await window.Signal.Data.getTotalUnreadForConversation(
|
const options = {
|
||||||
this.id,
|
storyId: undefined,
|
||||||
{
|
includeStoryReplies: !isGroup(this.attributes),
|
||||||
storyId: undefined,
|
};
|
||||||
includeStoryReplies: !isGroup(this.attributes),
|
const [unreadCount, unreadMentionsCount] = await Promise.all([
|
||||||
}
|
window.Signal.Data.getTotalUnreadForConversation(this.id, options),
|
||||||
);
|
window.Signal.Data.getTotalUnreadMentionsOfMeForConversation(
|
||||||
|
this.id,
|
||||||
|
options
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
const prevUnreadCount = this.get('unreadCount');
|
const prevUnreadCount = this.get('unreadCount');
|
||||||
if (prevUnreadCount !== unreadCount) {
|
const prevUnreadMentionsCount = this.get('unreadMentionsCount');
|
||||||
this.set({ unreadCount });
|
if (
|
||||||
|
prevUnreadCount !== unreadCount ||
|
||||||
|
prevUnreadMentionsCount !== unreadMentionsCount
|
||||||
|
) {
|
||||||
|
this.set({
|
||||||
|
unreadCount,
|
||||||
|
unreadMentionsCount,
|
||||||
|
});
|
||||||
window.Signal.Data.updateConversation(this.attributes);
|
window.Signal.Data.updateConversation(this.attributes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2575,6 +2575,14 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ourPNI = window.textsecure.storage.user.getCheckedUuid(
|
||||||
|
UUIDKind.PNI
|
||||||
|
);
|
||||||
|
const ourUuids: Set<string> = new Set([
|
||||||
|
ourACI.toString(),
|
||||||
|
ourPNI.toString(),
|
||||||
|
]);
|
||||||
|
|
||||||
message.set({
|
message.set({
|
||||||
id: messageId,
|
id: messageId,
|
||||||
attachments: dataMessage.attachments,
|
attachments: dataMessage.attachments,
|
||||||
|
@ -2590,6 +2598,12 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
hasFileAttachments: dataMessage.hasFileAttachments,
|
hasFileAttachments: dataMessage.hasFileAttachments,
|
||||||
hasVisualMediaAttachments: dataMessage.hasVisualMediaAttachments,
|
hasVisualMediaAttachments: dataMessage.hasVisualMediaAttachments,
|
||||||
isViewOnce: Boolean(dataMessage.isViewOnce),
|
isViewOnce: Boolean(dataMessage.isViewOnce),
|
||||||
|
mentionsMe: (dataMessage.bodyRanges ?? []).some(bodyRange => {
|
||||||
|
if (!BodyRange.isMention(bodyRange)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return ourUuids.has(bodyRange.mentionUuid);
|
||||||
|
}),
|
||||||
preview,
|
preview,
|
||||||
requiredProtocolVersion:
|
requiredProtocolVersion:
|
||||||
dataMessage.requiredProtocolVersion ||
|
dataMessage.requiredProtocolVersion ||
|
||||||
|
|
|
@ -514,6 +514,20 @@ export type DataInterface = {
|
||||||
includeStoryReplies: boolean;
|
includeStoryReplies: boolean;
|
||||||
}
|
}
|
||||||
) => Promise<number>;
|
) => Promise<number>;
|
||||||
|
getTotalUnreadMentionsOfMeForConversation: (
|
||||||
|
conversationId: string,
|
||||||
|
options: {
|
||||||
|
storyId?: string;
|
||||||
|
includeStoryReplies: boolean;
|
||||||
|
}
|
||||||
|
) => Promise<number>;
|
||||||
|
getOldestUnreadMentionOfMeForConversation(
|
||||||
|
conversationId: string,
|
||||||
|
options: {
|
||||||
|
storyId?: string;
|
||||||
|
includeStoryReplies: boolean;
|
||||||
|
}
|
||||||
|
): Promise<MessageMetricsType | undefined>;
|
||||||
getUnreadByConversationAndMarkRead: (options: {
|
getUnreadByConversationAndMarkRead: (options: {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
includeStoryReplies: boolean;
|
includeStoryReplies: boolean;
|
||||||
|
|
|
@ -266,7 +266,9 @@ const dataInterface: ServerInterface = {
|
||||||
getOlderMessagesByConversation,
|
getOlderMessagesByConversation,
|
||||||
getAllStories,
|
getAllStories,
|
||||||
getNewerMessagesByConversation,
|
getNewerMessagesByConversation,
|
||||||
|
getOldestUnreadMentionOfMeForConversation,
|
||||||
getTotalUnreadForConversation,
|
getTotalUnreadForConversation,
|
||||||
|
getTotalUnreadMentionsOfMeForConversation,
|
||||||
getMessageMetricsForConversation,
|
getMessageMetricsForConversation,
|
||||||
getConversationRangeCenteredOnMessage,
|
getConversationRangeCenteredOnMessage,
|
||||||
getConversationMessageStats,
|
getConversationMessageStats,
|
||||||
|
@ -1800,6 +1802,7 @@ function saveMessageSync(
|
||||||
id,
|
id,
|
||||||
isErased,
|
isErased,
|
||||||
isViewOnce,
|
isViewOnce,
|
||||||
|
mentionsMe,
|
||||||
received_at,
|
received_at,
|
||||||
schemaVersion,
|
schemaVersion,
|
||||||
sent_at,
|
sent_at,
|
||||||
|
@ -1850,6 +1853,7 @@ function saveMessageSync(
|
||||||
isChangeCreatedByUs: groupV2Change?.from === ourUuid ? 1 : 0,
|
isChangeCreatedByUs: groupV2Change?.from === ourUuid ? 1 : 0,
|
||||||
isErased: isErased ? 1 : 0,
|
isErased: isErased ? 1 : 0,
|
||||||
isViewOnce: isViewOnce ? 1 : 0,
|
isViewOnce: isViewOnce ? 1 : 0,
|
||||||
|
mentionsMe: mentionsMe ? 1 : 0,
|
||||||
received_at: received_at || null,
|
received_at: received_at || null,
|
||||||
schemaVersion: schemaVersion || 0,
|
schemaVersion: schemaVersion || 0,
|
||||||
serverGuid: serverGuid || null,
|
serverGuid: serverGuid || null,
|
||||||
|
@ -1881,6 +1885,7 @@ function saveMessageSync(
|
||||||
isChangeCreatedByUs = $isChangeCreatedByUs,
|
isChangeCreatedByUs = $isChangeCreatedByUs,
|
||||||
isErased = $isErased,
|
isErased = $isErased,
|
||||||
isViewOnce = $isViewOnce,
|
isViewOnce = $isViewOnce,
|
||||||
|
mentionsMe = $mentionsMe,
|
||||||
received_at = $received_at,
|
received_at = $received_at,
|
||||||
schemaVersion = $schemaVersion,
|
schemaVersion = $schemaVersion,
|
||||||
serverGuid = $serverGuid,
|
serverGuid = $serverGuid,
|
||||||
|
@ -1925,6 +1930,7 @@ function saveMessageSync(
|
||||||
isChangeCreatedByUs,
|
isChangeCreatedByUs,
|
||||||
isErased,
|
isErased,
|
||||||
isViewOnce,
|
isViewOnce,
|
||||||
|
mentionsMe,
|
||||||
received_at,
|
received_at,
|
||||||
schemaVersion,
|
schemaVersion,
|
||||||
serverGuid,
|
serverGuid,
|
||||||
|
@ -1950,6 +1956,7 @@ function saveMessageSync(
|
||||||
$isChangeCreatedByUs,
|
$isChangeCreatedByUs,
|
||||||
$isErased,
|
$isErased,
|
||||||
$isViewOnce,
|
$isViewOnce,
|
||||||
|
$mentionsMe,
|
||||||
$received_at,
|
$received_at,
|
||||||
$schemaVersion,
|
$schemaVersion,
|
||||||
$serverGuid,
|
$serverGuid,
|
||||||
|
@ -2885,6 +2892,38 @@ function getOldestUnseenMessageForConversation(
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getOldestUnreadMentionOfMeForConversation(
|
||||||
|
conversationId: string,
|
||||||
|
options: {
|
||||||
|
storyId?: string;
|
||||||
|
includeStoryReplies: boolean;
|
||||||
|
}
|
||||||
|
): Promise<MessageMetricsType | undefined> {
|
||||||
|
return getOldestUnreadMentionOfMeForConversationSync(conversationId, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOldestUnreadMentionOfMeForConversationSync(
|
||||||
|
conversationId: string,
|
||||||
|
options: {
|
||||||
|
storyId?: string;
|
||||||
|
includeStoryReplies: boolean;
|
||||||
|
}
|
||||||
|
): MessageMetricsType | undefined {
|
||||||
|
const db = getInstance();
|
||||||
|
const [query, params] = sql`
|
||||||
|
SELECT received_at, sent_at, id FROM messages WHERE
|
||||||
|
conversationId = ${conversationId} AND
|
||||||
|
readStatus = ${ReadStatus.Unread} AND
|
||||||
|
mentionsMe IS 1 AND
|
||||||
|
isStory IS 0 AND
|
||||||
|
(${_storyIdPredicate(options.storyId, options.includeStoryReplies)})
|
||||||
|
ORDER BY received_at ASC, sent_at ASC
|
||||||
|
LIMIT 1;
|
||||||
|
`;
|
||||||
|
|
||||||
|
return db.prepare(query).get(params);
|
||||||
|
}
|
||||||
|
|
||||||
async function getTotalUnreadForConversation(
|
async function getTotalUnreadForConversation(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
options: {
|
options: {
|
||||||
|
@ -2918,6 +2957,40 @@ function getTotalUnreadForConversationSync(
|
||||||
|
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
async function getTotalUnreadMentionsOfMeForConversation(
|
||||||
|
conversationId: string,
|
||||||
|
options: {
|
||||||
|
storyId?: string;
|
||||||
|
includeStoryReplies: boolean;
|
||||||
|
}
|
||||||
|
): Promise<number> {
|
||||||
|
return getTotalUnreadMentionsOfMeForConversationSync(conversationId, options);
|
||||||
|
}
|
||||||
|
function getTotalUnreadMentionsOfMeForConversationSync(
|
||||||
|
conversationId: string,
|
||||||
|
{
|
||||||
|
storyId,
|
||||||
|
includeStoryReplies,
|
||||||
|
}: {
|
||||||
|
storyId?: string;
|
||||||
|
includeStoryReplies: boolean;
|
||||||
|
}
|
||||||
|
): number {
|
||||||
|
const db = getInstance();
|
||||||
|
const [query, params] = sql`
|
||||||
|
SELECT count(1)
|
||||||
|
FROM messages
|
||||||
|
WHERE
|
||||||
|
conversationId = ${conversationId} AND
|
||||||
|
readStatus = ${ReadStatus.Unread} AND
|
||||||
|
mentionsMe IS 1 AND
|
||||||
|
isStory IS 0 AND
|
||||||
|
(${_storyIdPredicate(storyId, includeStoryReplies)})
|
||||||
|
`;
|
||||||
|
const row = db.prepare(query).pluck().get(params);
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
function getTotalUnseenForConversationSync(
|
function getTotalUnseenForConversationSync(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
{
|
{
|
||||||
|
|
38
ts/sql/migrations/83-mentions.ts
Normal file
38
ts/sql/migrations/83-mentions.ts
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import type { Database } from '@signalapp/better-sqlite3';
|
||||||
|
import type { LoggerType } from '../../types/Logging';
|
||||||
|
|
||||||
|
export default function updateToSchemaVersion83(
|
||||||
|
currentVersion: number,
|
||||||
|
db: Database,
|
||||||
|
logger: LoggerType
|
||||||
|
): void {
|
||||||
|
if (currentVersion >= 83) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.transaction(() => {
|
||||||
|
db.exec(
|
||||||
|
`
|
||||||
|
ALTER TABLE messages
|
||||||
|
ADD COLUMN mentionsMe INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- one which includes story data...
|
||||||
|
CREATE INDEX messages_unread_mentions ON messages
|
||||||
|
(conversationId, readStatus, mentionsMe, isStory, storyId, received_at, sent_at)
|
||||||
|
WHERE readStatus IS NOT NULL;
|
||||||
|
|
||||||
|
-- ...and one which doesn't, so storyPredicate works as expected
|
||||||
|
CREATE INDEX messages_unread_mentions_no_story_id ON messages
|
||||||
|
(conversationId, readStatus, mentionsMe, isStory, received_at, sent_at)
|
||||||
|
WHERE isStory IS 0 AND readStatus IS NOT NULL;
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
db.pragma('user_version = 83');
|
||||||
|
})();
|
||||||
|
|
||||||
|
logger.info('updateToSchemaVersion83: success!');
|
||||||
|
}
|
|
@ -58,6 +58,7 @@ import updateToSchemaVersion79 from './79-paging-lightbox';
|
||||||
import updateToSchemaVersion80 from './80-edited-messages';
|
import updateToSchemaVersion80 from './80-edited-messages';
|
||||||
import updateToSchemaVersion81 from './81-contact-removed-notification';
|
import updateToSchemaVersion81 from './81-contact-removed-notification';
|
||||||
import updateToSchemaVersion82 from './82-edited-messages-read-index';
|
import updateToSchemaVersion82 from './82-edited-messages-read-index';
|
||||||
|
import updateToSchemaVersion83 from './83-mentions';
|
||||||
|
|
||||||
function updateToSchemaVersion1(
|
function updateToSchemaVersion1(
|
||||||
currentVersion: number,
|
currentVersion: number,
|
||||||
|
@ -1982,10 +1983,10 @@ export const SCHEMA_VERSIONS = [
|
||||||
updateToSchemaVersion77,
|
updateToSchemaVersion77,
|
||||||
updateToSchemaVersion78,
|
updateToSchemaVersion78,
|
||||||
updateToSchemaVersion79,
|
updateToSchemaVersion79,
|
||||||
|
|
||||||
updateToSchemaVersion80,
|
updateToSchemaVersion80,
|
||||||
updateToSchemaVersion81,
|
updateToSchemaVersion81,
|
||||||
updateToSchemaVersion82,
|
updateToSchemaVersion82,
|
||||||
|
updateToSchemaVersion83,
|
||||||
];
|
];
|
||||||
|
|
||||||
export function updateSchema(db: Database, logger: LoggerType): void {
|
export function updateSchema(db: Database, logger: LoggerType): void {
|
||||||
|
|
|
@ -286,6 +286,7 @@ export type ConversationType = ReadonlyDeep<
|
||||||
titleNoDefault?: string;
|
titleNoDefault?: string;
|
||||||
searchableTitle?: string;
|
searchableTitle?: string;
|
||||||
unreadCount?: number;
|
unreadCount?: number;
|
||||||
|
unreadMentionsCount?: number;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
isFetchingUUID?: boolean;
|
isFetchingUUID?: boolean;
|
||||||
typingContactId?: string;
|
typingContactId?: string;
|
||||||
|
@ -1059,6 +1060,7 @@ export const actions = {
|
||||||
saveAttachmentFromMessage,
|
saveAttachmentFromMessage,
|
||||||
saveAvatarToDisk,
|
saveAvatarToDisk,
|
||||||
scrollToMessage,
|
scrollToMessage,
|
||||||
|
scrollToOldestUnreadMention,
|
||||||
showSpoiler,
|
showSpoiler,
|
||||||
targetMessage,
|
targetMessage,
|
||||||
setAccessControlAddFromInviteLinkSetting,
|
setAccessControlAddFromInviteLinkSetting,
|
||||||
|
@ -1258,6 +1260,7 @@ function loadNewestMessages(
|
||||||
payload: null,
|
payload: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadOlderMessages(
|
function loadOlderMessages(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
oldestMessageId: string
|
oldestMessageId: string
|
||||||
|
@ -1304,6 +1307,7 @@ function markMessageRead(
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeMember(
|
function removeMember(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
memberConversationId: string
|
memberConversationId: string
|
||||||
|
@ -3471,6 +3475,36 @@ function closeRecommendedGroupSizeModal(): CloseRecommendedGroupSizeModalActionT
|
||||||
return { type: 'CLOSE_RECOMMENDED_GROUP_SIZE_MODAL' };
|
return { type: 'CLOSE_RECOMMENDED_GROUP_SIZE_MODAL' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function scrollToOldestUnreadMention(
|
||||||
|
conversationId: string
|
||||||
|
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
|
||||||
|
return async (dispatch, getState) => {
|
||||||
|
const conversation = getOwn(
|
||||||
|
getState().conversations.conversationLookup,
|
||||||
|
conversationId
|
||||||
|
);
|
||||||
|
if (!conversation) {
|
||||||
|
log.warn(`No conversation found: [${conversationId}]`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldestUnreadMention =
|
||||||
|
await window.Signal.Data.getOldestUnreadMentionOfMeForConversation(
|
||||||
|
conversationId,
|
||||||
|
{
|
||||||
|
includeStoryReplies: !isGroup(conversation),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!oldestUnreadMention) {
|
||||||
|
log.warn(`No unread mention found for conversation: [${conversationId}]`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(scrollToMessage(conversationId, oldestUnreadMention.id));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function scrollToMessage(
|
export function scrollToMessage(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
messageId: string
|
messageId: string
|
||||||
|
|
|
@ -236,7 +236,11 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
...pick(conversation, ['unreadCount', 'isGroupV1AndDisabled']),
|
...pick(conversation, [
|
||||||
|
'unreadCount',
|
||||||
|
'unreadMentionsCount',
|
||||||
|
'isGroupV1AndDisabled',
|
||||||
|
]),
|
||||||
isConversationSelected: state.conversations.selectedConversationId === id,
|
isConversationSelected: state.conversations.selectedConversationId === id,
|
||||||
isIncomingMessageRequest: Boolean(
|
isIncomingMessageRequest: Boolean(
|
||||||
conversation.messageRequestsEnabled &&
|
conversation.messageRequestsEnabled &&
|
||||||
|
|
|
@ -17,6 +17,8 @@ const {
|
||||||
getMessageMetricsForConversation,
|
getMessageMetricsForConversation,
|
||||||
getNewerMessagesByConversation,
|
getNewerMessagesByConversation,
|
||||||
getOlderMessagesByConversation,
|
getOlderMessagesByConversation,
|
||||||
|
getTotalUnreadMentionsOfMeForConversation,
|
||||||
|
getOldestUnreadMentionOfMeForConversation,
|
||||||
} = dataInterface;
|
} = dataInterface;
|
||||||
|
|
||||||
function getUuid(): UUIDStringType {
|
function getUuid(): UUIDStringType {
|
||||||
|
@ -824,4 +826,72 @@ describe('sql/timelineFetches', () => {
|
||||||
assert.strictEqual(metricsInStory?.totalUnseen, 1, 'totalUnseen');
|
assert.strictEqual(metricsInStory?.totalUnseen, 1, 'totalUnseen');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('mentionsCount & oldestUnreadMention', () => {
|
||||||
|
it('returns unread mentions count and oldest unread mention', async () => {
|
||||||
|
assert.lengthOf(await _getAllMessages(), 0);
|
||||||
|
|
||||||
|
const target = Date.now();
|
||||||
|
const conversationId = getUuid();
|
||||||
|
const ourUuid = getUuid();
|
||||||
|
|
||||||
|
const readMentionsMe: Partial<MessageAttributesType> = {
|
||||||
|
id: 'readMentionsMe',
|
||||||
|
readStatus: ReadStatus.Read,
|
||||||
|
mentionsMe: true,
|
||||||
|
};
|
||||||
|
const unreadMentionsMe: Partial<MessageAttributesType> = {
|
||||||
|
id: 'unreadMentionsMe',
|
||||||
|
readStatus: ReadStatus.Unread,
|
||||||
|
mentionsMe: true,
|
||||||
|
};
|
||||||
|
const unreadNoMention: Partial<MessageAttributesType> = {
|
||||||
|
id: 'unreadNoMention',
|
||||||
|
readStatus: ReadStatus.Unread,
|
||||||
|
};
|
||||||
|
const unreadMentionsMeAgain: Partial<MessageAttributesType> = {
|
||||||
|
id: 'unreadMentionsMeAgain',
|
||||||
|
readStatus: ReadStatus.Unread,
|
||||||
|
mentionsMe: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const messages = [
|
||||||
|
readMentionsMe,
|
||||||
|
unreadMentionsMe,
|
||||||
|
unreadNoMention,
|
||||||
|
unreadMentionsMeAgain,
|
||||||
|
];
|
||||||
|
|
||||||
|
const formattedMessages = messages.map<MessageAttributesType>(
|
||||||
|
(message, idx) => {
|
||||||
|
return {
|
||||||
|
id: getUuid(),
|
||||||
|
body: 'body',
|
||||||
|
type: 'incoming',
|
||||||
|
sent_at: target - messages.length + idx,
|
||||||
|
received_at: target - messages.length + idx,
|
||||||
|
timestamp: target - messages.length + idx,
|
||||||
|
conversationId,
|
||||||
|
...message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await saveMessages(formattedMessages, { forceSave: true, ourUuid });
|
||||||
|
|
||||||
|
assert.lengthOf(await _getAllMessages(), 4);
|
||||||
|
|
||||||
|
const unreadMentions = await getTotalUnreadMentionsOfMeForConversation(
|
||||||
|
conversationId,
|
||||||
|
{ includeStoryReplies: false }
|
||||||
|
);
|
||||||
|
const oldestUnreadMention =
|
||||||
|
await getOldestUnreadMentionOfMeForConversation(conversationId, {
|
||||||
|
includeStoryReplies: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(unreadMentions, 2);
|
||||||
|
assert.strictEqual(oldestUnreadMention?.id, 'unreadMentionsMe');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -9,9 +9,9 @@ import { v4 as generateGuid } from 'uuid';
|
||||||
import { SCHEMA_VERSIONS } from '../sql/migrations';
|
import { SCHEMA_VERSIONS } from '../sql/migrations';
|
||||||
import { consoleLogger } from '../util/consoleLogger';
|
import { consoleLogger } from '../util/consoleLogger';
|
||||||
import {
|
import {
|
||||||
|
_storyIdPredicate,
|
||||||
getJobsInQueueSync,
|
getJobsInQueueSync,
|
||||||
insertJobSync,
|
insertJobSync,
|
||||||
_storyIdPredicate,
|
|
||||||
} from '../sql/Server';
|
} from '../sql/Server';
|
||||||
import { ReadStatus } from '../messages/MessageReadStatus';
|
import { ReadStatus } from '../messages/MessageReadStatus';
|
||||||
import { SeenStatus } from '../MessageSeenStatus';
|
import { SeenStatus } from '../MessageSeenStatus';
|
||||||
|
@ -3082,4 +3082,108 @@ describe('SQL migrations test', () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('updateToSchemaVersion83', () => {
|
||||||
|
beforeEach(() => updateToVersion(83));
|
||||||
|
|
||||||
|
it('ensures that index is used for getTotalUnreadMentionsOfMeForConversation, no storyId', () => {
|
||||||
|
const { detail } = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
EXPLAIN QUERY PLAN
|
||||||
|
SELECT count(1)
|
||||||
|
FROM messages
|
||||||
|
WHERE
|
||||||
|
conversationId = 'conversationId' AND
|
||||||
|
readStatus = ${ReadStatus.Unread} AND
|
||||||
|
mentionsMe IS 1 AND
|
||||||
|
isStory IS 0 AND
|
||||||
|
NULL IS NULL
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
assert.notInclude(detail, 'B-TREE');
|
||||||
|
assert.notInclude(detail, 'SCAN');
|
||||||
|
assert.include(
|
||||||
|
detail,
|
||||||
|
'SEARCH messages USING INDEX messages_unread_mentions_no_story_id (conversationId=? AND readStatus=? AND mentionsMe=? AND isStory=?)'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ensures that index is used for getTotalUnreadMentionsOfMeForConversation, with storyId', () => {
|
||||||
|
const { detail } = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
EXPLAIN QUERY PLAN
|
||||||
|
SELECT count(1)
|
||||||
|
FROM messages
|
||||||
|
WHERE
|
||||||
|
conversationId = 'conversationId' AND
|
||||||
|
readStatus = ${ReadStatus.Unread} AND
|
||||||
|
mentionsMe IS 1 AND
|
||||||
|
isStory IS 0 AND
|
||||||
|
storyId IS 'storyId'
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
assert.notInclude(detail, 'B-TREE');
|
||||||
|
assert.notInclude(detail, 'SCAN');
|
||||||
|
assert.include(
|
||||||
|
detail,
|
||||||
|
'SEARCH messages USING INDEX messages_unread_mentions (conversationId=? AND readStatus=? AND mentionsMe=? AND isStory=? AND storyId=?)'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ensures that index is used for getOldestUnreadMentionOfMeForConversation, no storyId', () => {
|
||||||
|
const { detail } = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
EXPLAIN QUERY PLAN
|
||||||
|
SELECT received_at, sent_at, id FROM messages WHERE
|
||||||
|
conversationId = 'conversationId' AND
|
||||||
|
readStatus = ${ReadStatus.Unread} AND
|
||||||
|
mentionsMe IS 1 AND
|
||||||
|
isStory IS 0 AND
|
||||||
|
NULL is NULL
|
||||||
|
ORDER BY received_at ASC, sent_at ASC
|
||||||
|
LIMIT 1;
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
assert.notInclude(detail, 'B-TREE');
|
||||||
|
assert.notInclude(detail, 'SCAN');
|
||||||
|
assert.include(
|
||||||
|
detail,
|
||||||
|
'SEARCH messages USING INDEX messages_unread_mentions_no_story_id (conversationId=? AND readStatus=? AND mentionsMe=? AND isStory=?)'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ensures that index is used for getOldestUnreadMentionOfMeForConversation, with storyId', () => {
|
||||||
|
const { detail } = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
EXPLAIN QUERY PLAN
|
||||||
|
SELECT received_at, sent_at, id FROM messages WHERE
|
||||||
|
conversationId = 'conversationId' AND
|
||||||
|
readStatus = ${ReadStatus.Unread} AND
|
||||||
|
mentionsMe IS 1 AND
|
||||||
|
isStory IS 0 AND
|
||||||
|
storyId IS 'storyId'
|
||||||
|
ORDER BY received_at ASC, sent_at ASC
|
||||||
|
LIMIT 1;
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
assert.notInclude(detail, 'B-TREE');
|
||||||
|
assert.notInclude(detail, 'SCAN');
|
||||||
|
assert.include(
|
||||||
|
detail,
|
||||||
|
'SEARCH messages USING INDEX messages_unread_mentions (conversationId=? AND readStatus=? AND mentionsMe=? AND isStory=? AND storyId=?)'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue