Show mentioned badges & enable scrolling to mentions in conversations

This commit is contained in:
trevor-signal 2023-05-23 17:59:07 -04:00 committed by GitHub
parent caaeda8abe
commit d012779e87
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 694 additions and 184 deletions

View file

@ -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"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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'),

View file

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

View file

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

View file

@ -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
View file

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

View file

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

View file

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

View file

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

View file

@ -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,
{ {

View 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!');
}

View file

@ -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 {

View file

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

View file

@ -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 &&

View file

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

View file

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