From d012779e87710725fb0df78c20198496722d4d64 Mon Sep 17 00:00:00 2001 From: trevor-signal <131492920+trevor-signal@users.noreply.github.com> Date: Tue, 23 May 2023 17:59:07 -0400 Subject: [PATCH] Show mentioned badges & enable scrolling to mentions in conversations --- _locales/en/messages.json | 4 + stylesheets/_modules.scss | 153 +++++++++++------- stylesheets/components/ScrollDownButton.scss | 112 ++++++------- ts/components/ConversationList.tsx | 1 + .../conversation/ScrollDownButton.stories.tsx | 36 +++-- .../conversation/ScrollDownButton.tsx | 76 +++++---- .../conversation/Timeline.stories.tsx | 1 + ts/components/conversation/Timeline.tsx | 32 ++-- .../BaseConversationListItem.tsx | 70 ++++++-- .../conversationList/ConversationListItem.tsx | 3 + ts/model-types.d.ts | 2 + ts/models/conversations.ts | 30 ++-- ts/models/messages.ts | 14 ++ ts/sql/Interface.ts | 14 ++ ts/sql/Server.ts | 73 +++++++++ ts/sql/migrations/83-mentions.ts | 38 +++++ ts/sql/migrations/index.ts | 3 +- ts/state/ducks/conversations.ts | 34 ++++ ts/state/smart/Timeline.tsx | 6 +- ts/test-electron/sql/timelineFetches_test.ts | 70 ++++++++ ts/test-node/sql_migrations_test.ts | 106 +++++++++++- 21 files changed, 694 insertions(+), 184 deletions(-) create mode 100644 ts/sql/migrations/83-mentions.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index ae3e7f6e633e..514cf8a740c6 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -863,6 +863,10 @@ "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" }, + "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": { "message": "1 Unread Message", "description": "(deleted 03/29/2023) Text for unread message separator, just one message" diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index f7dc7a5201c9..12391f4ce3e2 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -4460,8 +4460,7 @@ button.module-image__border-overlay:focus { } &--contact-or-conversation { - $unread-indicator-selector: '#{&}__unread-indicator'; - $avatar-container-unread-indicator-selector: '#{&}__avatar-container #{$unread-indicator-selector}'; + $unread-indicator: '#{&}__unread-indicator'; @include button-reset; @@ -4482,6 +4481,42 @@ button.module-image__border-overlay:focus { 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 { cursor: pointer; @@ -4492,10 +4527,16 @@ button.module-image__border-overlay:focus { &:hover:not(:disabled), &:focus:not(:disabled) { @include light-theme { - background-color: $color-black-alpha-06; + background-color: $color-gray-05; + #{$unread-indicator} { + border-color: $color-gray-05; + } } @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 { @include light-theme { - $background-color: $color-gray-15; - background-color: $background-color; - #{$avatar-container-unread-indicator-selector} { - border-color: $background-color; + background-color: $color-gray-15; + } + @include dark-theme { + background-color: $color-gray-65; + } + } + + &--is-selected &__avatar-container { + @include light-theme { + #{$unread-indicator} { + border-color: $color-gray-15; } } @include dark-theme { - $background-color: $color-gray-65; - background-color: $background-color; - #{$avatar-container-unread-indicator-selector} { - border-color: $background-color; + #{$unread-indicator} { + border-color: $color-gray-65; } } } @@ -4539,22 +4585,21 @@ button.module-image__border-overlay:focus { &__avatar-container { position: relative; - #{$unread-indicator-selector} { + #{$unread-indicator} { $border-width: 3px; $size: 21px + $border-width; @include rounded-corners; border: $border-width solid transparent; - display: none; height: $size; margin: 0; min-width: $size; position: absolute; - inset-inline-end: -(5px + $border-width); top: -(1px + $border-width); + display: none; .module-conversation-list--width-narrow & { - display: block; + display: flex; } @include light-theme { @@ -4563,39 +4608,21 @@ button.module-image__border-overlay:focus { @include dark-theme { border-color: $color-gray-80; } - } - } - // We want this to just be the unread indicator selector, not a child of the parent. - @at-root #{$unread-indicator-selector} { - $size: 18px; - flex-shrink: 0; - - @include font-caption-bold; - border-radius: 10px; - color: $color-white; - font-weight: 500; - height: $size; - line-height: $size; - margin-inline-start: 10px; - 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; + &--unread-messages { + inset-inline-end: -(5px + $border-width); + } + &--unread-mentions { + inset-inline-start: -(5px + $border-width); + } + &--is-selected { + @include light-theme { + border-color: $color-gray-15; + } + @include dark-theme { + border-color: $color-gray-65; + } + } } } @@ -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 { @@ -5012,11 +5048,6 @@ button.module-image__border-overlay:focus { ::-webkit-scrollbar-thumb { 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 { @@ -5028,11 +5059,6 @@ button.module-image__border-overlay:focus { ::-webkit-scrollbar-thumb { 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 { outline: none; } diff --git a/stylesheets/components/ScrollDownButton.scss b/stylesheets/components/ScrollDownButton.scss index 2fa05df19b17..9b282af418be 100644 --- a/stylesheets/components/ScrollDownButton.scss +++ b/stylesheets/components/ScrollDownButton.scss @@ -2,68 +2,72 @@ // SPDX-License-Identifier: AGPL-3.0-only .ScrollDownButton { - z-index: $z-index-scroll-down-button; - position: absolute; - inset-inline-end: 16px; - bottom: 12px; + position: relative; - &__button { - position: relative; + height: 36px; + width: 36px; - height: 36px; - width: 36px; + display: flex; + border-radius: 18px; + border: none; + outline: none; + align-items: center; + justify-content: center; - display: flex; - border-radius: 18px; - border: none; - outline: none; - align-items: center; - justify-content: center; + box-shadow: 0px 0px 2px $color-black-alpha-20, + 0px 2px 6px $color-black-alpha-12; - box-shadow: 0px 0px 2px $color-black-alpha-20, - 0px 2px 6px $color-black-alpha-12; + @include light-theme { + background-color: $color-white; + } + @include dark-theme { + background-color: $color-gray-75; + } + &__icon--unread-mentions { + height: 17px; + width: 17px; @include light-theme { - background-color: $color-white; + @include color-svg('../images/icons/v3/at/at.svg', $color-gray-75); } + @include dark-theme { - background-color: $color-gray-75; - } - - &__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; + @include color-svg('../images/icons/v3/at/at.svg', $color-gray-15); } } + + &__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; + } } diff --git a/ts/components/ConversationList.tsx b/ts/components/ConversationList.tsx index 927eeeedc8f6..64149800b50c 100644 --- a/ts/components/ConversationList.tsx +++ b/ts/components/ConversationList.tsx @@ -369,6 +369,7 @@ export function ConversationList({ 'typingContactId', 'unblurredAvatarPath', 'unreadCount', + 'unreadMentionsCount', 'uuid', ]); const { badges, title, unreadCount, lastMessage } = itemProps; diff --git a/ts/components/conversation/ScrollDownButton.stories.tsx b/ts/components/conversation/ScrollDownButton.stories.tsx index 93e5a984f8c5..b9bafb6e956c 100644 --- a/ts/components/conversation/ScrollDownButton.stories.tsx +++ b/ts/components/conversation/ScrollDownButton.stories.tsx @@ -7,15 +7,17 @@ import { action } from '@storybook/addon-actions'; import { setupI18n } from '../../util/setupI18n'; import enMessages from '../../../_locales/en/messages.json'; -import type { Props } from './ScrollDownButton'; -import { ScrollDownButton } from './ScrollDownButton'; +import type { ScrollDownButtonPropsType } from './ScrollDownButton'; +import { ScrollDownButton, ScrollDownButtonVariant } from './ScrollDownButton'; const i18n = setupI18n('en', enMessages); -const createProps = (overrideProps: Partial = {}): Props => ({ +const createProps = ( + overrideProps: Partial = {} +): ScrollDownButtonPropsType => ({ + variant: ScrollDownButtonVariant.UNREAD_MESSAGES, i18n, - scrollDown: action('scrollDown'), - conversationId: 'fake-conversation-id', + onClick: action('scrollDown'), ...overrideProps, }); @@ -23,7 +25,7 @@ export default { title: 'Components/Conversation/ScrollDownButton', component: ScrollDownButton, argTypes: { - unreadCount: { + count: { control: { type: 'radio' }, options: { None: undefined, @@ -36,10 +38,22 @@ export default { } as Meta; // eslint-disable-next-line react/function-component-definition -const Template: Story = args => ; +const Template: Story = args => ( + +); -export const Default = Template.bind({}); -Default.args = createProps({}); -Default.story = { - name: 'Default', +export const UnreadMessages = Template.bind({}); +UnreadMessages.args = createProps({ + variant: ScrollDownButtonVariant.UNREAD_MESSAGES, +}); +UnreadMessages.story = { + name: 'UnreadMessages', +}; + +export const UnreadMentions = Template.bind({}); +UnreadMentions.args = createProps({ + variant: ScrollDownButtonVariant.UNREAD_MENTIONS, +}); +UnreadMentions.story = { + name: 'UnreadMentions', }; diff --git a/ts/components/conversation/ScrollDownButton.tsx b/ts/components/conversation/ScrollDownButton.tsx index c4c581108e74..cadaf037d527 100644 --- a/ts/components/conversation/ScrollDownButton.tsx +++ b/ts/components/conversation/ScrollDownButton.tsx @@ -1,53 +1,69 @@ // Copyright 2019 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import classNames from 'classnames'; import React from 'react'; import type { LocalizerType } from '../../types/Util'; +import { getClassNamesFor } from '../../util/getClassNamesFor'; -export type Props = { - unreadCount?: number; - conversationId: string; - - scrollDown: (conversationId: string) => void; +export enum ScrollDownButtonVariant { + UNREAD_MESSAGES = 'unread-messages', + UNREAD_MENTIONS = 'unread-mentions', +} +export type ScrollDownButtonPropsType = { + variant: ScrollDownButtonVariant; + count?: number; + onClick: VoidFunction; i18n: LocalizerType; }; export function ScrollDownButton({ - conversationId, - unreadCount, + variant, + count, + onClick, i18n, - scrollDown, -}: Props): JSX.Element { - const altText = unreadCount - ? i18n('icu:messagesBelow') - : i18n('icu:scrollDown'); +}: ScrollDownButtonPropsType): JSX.Element { + const getClassName = getClassNamesFor('ScrollDownButton'); let badgeText: string | undefined; - if (unreadCount) { - if (unreadCount < 100) { - badgeText = unreadCount.toString(); + if (count) { + if (count < 100) { + badgeText = count.toString(); } else { 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 ( -
- -
+ ); } diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index a2f2b3a61971..ccfb5a006d25 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -277,6 +277,7 @@ const actions = () => ({ markMessageRead: action('markMessageRead'), toggleSelectMessage: action('toggleSelectMessage'), targetMessage: action('targetMessage'), + scrollToOldestUnreadMention: action('scrollToOldestUnreadMention'), clearTargetedMessage: action('clearTargetedMessage'), updateSharedGroups: action('updateSharedGroups'), diff --git a/ts/components/conversation/Timeline.tsx b/ts/components/conversation/Timeline.tsx index 44c0dbc3ff0c..f04c25e5f043 100644 --- a/ts/components/conversation/Timeline.tsx +++ b/ts/components/conversation/Timeline.tsx @@ -8,7 +8,7 @@ import React from 'react'; import Measure from 'react-measure'; import type { ReadonlyDeep } from 'type-fest'; -import { ScrollDownButton } from './ScrollDownButton'; +import { ScrollDownButton, ScrollDownButtonVariant } from './ScrollDownButton'; import type { LocalizerType, ThemeType } from '../../types/Util'; import type { ConversationType } from '../../state/ducks/conversations'; @@ -100,6 +100,7 @@ type PropsHousekeepingType = { isIncomingMessageRequest: boolean; isSomeoneTyping: boolean; unreadCount?: number; + unreadMentionsCount?: number; targetedMessageId?: string; invitedContactsForNewlyCreatedGroup: Array; @@ -168,6 +169,7 @@ export type PropsActionsType = { safeConversationId: string; }> ) => void; + scrollToOldestUnreadMention: (conversationId: string) => unknown; }; export type PropsType = PropsDataType & @@ -776,10 +778,12 @@ export class Timeline extends React.Component< renderTypingBubble, reviewGroupMemberNameCollision, reviewMessageRequestNameCollision, + scrollToOldestUnreadMention, shouldShowMiniPlayer, theme, totalUnseen, unreadCount, + unreadMentionsCount, } = this.props; const { hasRecentlyScrolled, @@ -815,7 +819,7 @@ export class Timeline extends React.Component< areAnyMessagesUnread && areAnyMessagesBelowCurrentPosition ); - const shouldShowScrollDownButton = Boolean( + const shouldShowScrollDownButtons = Boolean( areThereAnyMessages && (areUnreadBelowCurrentPosition || areSomeMessagesBelowCurrentPosition) ); @@ -1127,14 +1131,24 @@ export class Timeline extends React.Component< /> + {shouldShowScrollDownButtons ? ( +
+ {unreadMentionsCount ? ( + scrollToOldestUnreadMention(id)} + i18n={i18n} + /> + ) : null} - {shouldShowScrollDownButton ? ( - + +
) : null} )} diff --git a/ts/components/conversationList/BaseConversationListItem.tsx b/ts/components/conversationList/BaseConversationListItem.tsx index 3b2a3fea0d62..cfbdd4f1e369 100644 --- a/ts/components/conversationList/BaseConversationListItem.tsx +++ b/ts/components/conversationList/BaseConversationListItem.tsx @@ -52,6 +52,7 @@ type PropsType = { onClick?: () => void; shouldShowSpinner?: boolean; unreadCount?: number; + unreadMentionsCount?: number; avatarSize?: AvatarSize; testId?: string; } & Pick< @@ -107,6 +108,7 @@ export const BaseConversationListItem: FunctionComponent = title, unblurredAvatarPath, unreadCount, + unreadMentionsCount, uuid, } = props; @@ -166,6 +168,25 @@ export const BaseConversationListItem: FunctionComponent = ); } + const unreadIndicators = (() => { + if (!isUnread) { + return null; + } + return ( +
+ {unreadMentionsCount ? ( + + ) : null} + {unreadCount ? ( + + ) : null} +
+ ); + })(); + const contents = ( <>
@@ -189,7 +210,7 @@ export const BaseConversationListItem: FunctionComponent = ? { badge: props.badge, theme: props.theme } : { badge: undefined })} /> - + {unreadIndicators}
=
)} {messageStatusIcon} - + {unreadIndicators} ) : null} @@ -315,17 +336,46 @@ function Timestamp({ ); } -function UnreadIndicator({ - count = 0, - isUnread, -}: Readonly<{ count?: number; isUnread: boolean }>) { - if (!isUnread) { - return null; +enum UnreadIndicatorVariant { + UNREAD_MESSAGES = 'unread-messages', + UNREAD_MENTIONS = 'unread-mentions', +} + +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 = ( +
+ ); + break; + default: + throw new Error('Unexpected variant'); } return ( -
- {Boolean(count) && count} +
+ {content}
); } diff --git a/ts/components/conversationList/ConversationListItem.tsx b/ts/components/conversationList/ConversationListItem.tsx index 03061191a272..d0f97d229c3e 100644 --- a/ts/components/conversationList/ConversationListItem.tsx +++ b/ts/components/conversationList/ConversationListItem.tsx @@ -63,6 +63,7 @@ export type PropsData = Pick< | 'typingContactId' | 'unblurredAvatarPath' | 'unreadCount' + | 'unreadMentionsCount' | 'uuid' > & { badge?: BadgeType; @@ -106,6 +107,7 @@ export const ConversationListItem: FunctionComponent = React.memo( typingContactId, unblurredAvatarPath, unreadCount, + unreadMentionsCount, uuid, }) { const isMuted = Boolean(muteExpiresAt && Date.now() < muteExpiresAt); @@ -217,6 +219,7 @@ export const ConversationListItem: FunctionComponent = React.memo( theme={theme} title={title} unreadCount={unreadCount} + unreadMentionsCount={unreadMentionsCount} unblurredAvatarPath={unblurredAvatarPath} uuid={uuid} /> diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 1bef01542bcf..195349530a72 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -147,6 +147,7 @@ export type MessageAttributesType = { hasAttachments?: boolean | 0 | 1; hasFileAttachments?: boolean | 0 | 1; hasVisualMediaAttachments?: boolean | 0 | 1; + mentionsMe?: boolean | 0 | 1; isErased?: boolean; isTapToViewInvalid?: boolean; isViewOnce?: boolean; @@ -366,6 +367,7 @@ export type ConversationAttributesType = { storageVersion?: number; storageUnknownFields?: string; unreadCount?: number; + unreadMentionsCount?: number; version: number; // Private core info diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index edc46fc04d34..d4ee0849bc5c 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -2056,6 +2056,7 @@ export class ConversationModel extends window.Backbone ? window.i18n('icu:noteToSelf') : this.getTitle(), unreadCount: this.get('unreadCount') || 0, + unreadMentionsCount: this.get('unreadMentionsCount'), ...(isDirectConversation(this.attributes) ? { type: 'direct' as const, @@ -4913,17 +4914,28 @@ export class ConversationModel extends window.Backbone } async updateUnread(): Promise { - const unreadCount = await window.Signal.Data.getTotalUnreadForConversation( - this.id, - { - storyId: undefined, - includeStoryReplies: !isGroup(this.attributes), - } - ); + const options = { + 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'); - if (prevUnreadCount !== unreadCount) { - this.set({ unreadCount }); + const prevUnreadMentionsCount = this.get('unreadMentionsCount'); + if ( + prevUnreadCount !== unreadCount || + prevUnreadMentionsCount !== unreadMentionsCount + ) { + this.set({ + unreadCount, + unreadMentionsCount, + }); window.Signal.Data.updateConversation(this.attributes); } } diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 4b850113c21a..2df872b48c6b 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -2575,6 +2575,14 @@ export class MessageModel extends window.Backbone.Model { ); } + const ourPNI = window.textsecure.storage.user.getCheckedUuid( + UUIDKind.PNI + ); + const ourUuids: Set = new Set([ + ourACI.toString(), + ourPNI.toString(), + ]); + message.set({ id: messageId, attachments: dataMessage.attachments, @@ -2590,6 +2598,12 @@ export class MessageModel extends window.Backbone.Model { hasFileAttachments: dataMessage.hasFileAttachments, hasVisualMediaAttachments: dataMessage.hasVisualMediaAttachments, isViewOnce: Boolean(dataMessage.isViewOnce), + mentionsMe: (dataMessage.bodyRanges ?? []).some(bodyRange => { + if (!BodyRange.isMention(bodyRange)) { + return false; + } + return ourUuids.has(bodyRange.mentionUuid); + }), preview, requiredProtocolVersion: dataMessage.requiredProtocolVersion || diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 05b3a4c725db..b79d6432d6fe 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -514,6 +514,20 @@ export type DataInterface = { includeStoryReplies: boolean; } ) => Promise; + getTotalUnreadMentionsOfMeForConversation: ( + conversationId: string, + options: { + storyId?: string; + includeStoryReplies: boolean; + } + ) => Promise; + getOldestUnreadMentionOfMeForConversation( + conversationId: string, + options: { + storyId?: string; + includeStoryReplies: boolean; + } + ): Promise; getUnreadByConversationAndMarkRead: (options: { conversationId: string; includeStoryReplies: boolean; diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 4f5a2544ce3c..1f0d65414945 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -266,7 +266,9 @@ const dataInterface: ServerInterface = { getOlderMessagesByConversation, getAllStories, getNewerMessagesByConversation, + getOldestUnreadMentionOfMeForConversation, getTotalUnreadForConversation, + getTotalUnreadMentionsOfMeForConversation, getMessageMetricsForConversation, getConversationRangeCenteredOnMessage, getConversationMessageStats, @@ -1800,6 +1802,7 @@ function saveMessageSync( id, isErased, isViewOnce, + mentionsMe, received_at, schemaVersion, sent_at, @@ -1850,6 +1853,7 @@ function saveMessageSync( isChangeCreatedByUs: groupV2Change?.from === ourUuid ? 1 : 0, isErased: isErased ? 1 : 0, isViewOnce: isViewOnce ? 1 : 0, + mentionsMe: mentionsMe ? 1 : 0, received_at: received_at || null, schemaVersion: schemaVersion || 0, serverGuid: serverGuid || null, @@ -1881,6 +1885,7 @@ function saveMessageSync( isChangeCreatedByUs = $isChangeCreatedByUs, isErased = $isErased, isViewOnce = $isViewOnce, + mentionsMe = $mentionsMe, received_at = $received_at, schemaVersion = $schemaVersion, serverGuid = $serverGuid, @@ -1925,6 +1930,7 @@ function saveMessageSync( isChangeCreatedByUs, isErased, isViewOnce, + mentionsMe, received_at, schemaVersion, serverGuid, @@ -1950,6 +1956,7 @@ function saveMessageSync( $isChangeCreatedByUs, $isErased, $isViewOnce, + $mentionsMe, $received_at, $schemaVersion, $serverGuid, @@ -2885,6 +2892,38 @@ function getOldestUnseenMessageForConversation( return row; } +async function getOldestUnreadMentionOfMeForConversation( + conversationId: string, + options: { + storyId?: string; + includeStoryReplies: boolean; + } +): Promise { + 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( conversationId: string, options: { @@ -2918,6 +2957,40 @@ function getTotalUnreadForConversationSync( return row; } +async function getTotalUnreadMentionsOfMeForConversation( + conversationId: string, + options: { + storyId?: string; + includeStoryReplies: boolean; + } +): Promise { + 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( conversationId: string, { diff --git a/ts/sql/migrations/83-mentions.ts b/ts/sql/migrations/83-mentions.ts new file mode 100644 index 000000000000..084143b8572b --- /dev/null +++ b/ts/sql/migrations/83-mentions.ts @@ -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!'); +} diff --git a/ts/sql/migrations/index.ts b/ts/sql/migrations/index.ts index 2f333a4ddef0..34993d651815 100644 --- a/ts/sql/migrations/index.ts +++ b/ts/sql/migrations/index.ts @@ -58,6 +58,7 @@ import updateToSchemaVersion79 from './79-paging-lightbox'; import updateToSchemaVersion80 from './80-edited-messages'; import updateToSchemaVersion81 from './81-contact-removed-notification'; import updateToSchemaVersion82 from './82-edited-messages-read-index'; +import updateToSchemaVersion83 from './83-mentions'; function updateToSchemaVersion1( currentVersion: number, @@ -1982,10 +1983,10 @@ export const SCHEMA_VERSIONS = [ updateToSchemaVersion77, updateToSchemaVersion78, updateToSchemaVersion79, - updateToSchemaVersion80, updateToSchemaVersion81, updateToSchemaVersion82, + updateToSchemaVersion83, ]; export function updateSchema(db: Database, logger: LoggerType): void { diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index bf75092e33fb..d7b1ccc119c4 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -286,6 +286,7 @@ export type ConversationType = ReadonlyDeep< titleNoDefault?: string; searchableTitle?: string; unreadCount?: number; + unreadMentionsCount?: number; isSelected?: boolean; isFetchingUUID?: boolean; typingContactId?: string; @@ -1059,6 +1060,7 @@ export const actions = { saveAttachmentFromMessage, saveAvatarToDisk, scrollToMessage, + scrollToOldestUnreadMention, showSpoiler, targetMessage, setAccessControlAddFromInviteLinkSetting, @@ -1258,6 +1260,7 @@ function loadNewestMessages( payload: null, }; } + function loadOlderMessages( conversationId: string, oldestMessageId: string @@ -1304,6 +1307,7 @@ function markMessageRead( }); }; } + function removeMember( conversationId: string, memberConversationId: string @@ -3471,6 +3475,36 @@ function closeRecommendedGroupSizeModal(): CloseRecommendedGroupSizeModalActionT return { type: 'CLOSE_RECOMMENDED_GROUP_SIZE_MODAL' }; } +export function scrollToOldestUnreadMention( + conversationId: string +): ThunkAction { + 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( conversationId: string, messageId: string diff --git a/ts/state/smart/Timeline.tsx b/ts/state/smart/Timeline.tsx index ff6c2b110e60..8d5f74384560 100644 --- a/ts/state/smart/Timeline.tsx +++ b/ts/state/smart/Timeline.tsx @@ -236,7 +236,11 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => { return { id, - ...pick(conversation, ['unreadCount', 'isGroupV1AndDisabled']), + ...pick(conversation, [ + 'unreadCount', + 'unreadMentionsCount', + 'isGroupV1AndDisabled', + ]), isConversationSelected: state.conversations.selectedConversationId === id, isIncomingMessageRequest: Boolean( conversation.messageRequestsEnabled && diff --git a/ts/test-electron/sql/timelineFetches_test.ts b/ts/test-electron/sql/timelineFetches_test.ts index e785362a81e8..a0dc7cee23dd 100644 --- a/ts/test-electron/sql/timelineFetches_test.ts +++ b/ts/test-electron/sql/timelineFetches_test.ts @@ -17,6 +17,8 @@ const { getMessageMetricsForConversation, getNewerMessagesByConversation, getOlderMessagesByConversation, + getTotalUnreadMentionsOfMeForConversation, + getOldestUnreadMentionOfMeForConversation, } = dataInterface; function getUuid(): UUIDStringType { @@ -824,4 +826,72 @@ describe('sql/timelineFetches', () => { 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 = { + id: 'readMentionsMe', + readStatus: ReadStatus.Read, + mentionsMe: true, + }; + const unreadMentionsMe: Partial = { + id: 'unreadMentionsMe', + readStatus: ReadStatus.Unread, + mentionsMe: true, + }; + const unreadNoMention: Partial = { + id: 'unreadNoMention', + readStatus: ReadStatus.Unread, + }; + const unreadMentionsMeAgain: Partial = { + id: 'unreadMentionsMeAgain', + readStatus: ReadStatus.Unread, + mentionsMe: true, + }; + + const messages = [ + readMentionsMe, + unreadMentionsMe, + unreadNoMention, + unreadMentionsMeAgain, + ]; + + const formattedMessages = messages.map( + (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'); + }); + }); }); diff --git a/ts/test-node/sql_migrations_test.ts b/ts/test-node/sql_migrations_test.ts index 38d7ed4f2a68..6df3f3161def 100644 --- a/ts/test-node/sql_migrations_test.ts +++ b/ts/test-node/sql_migrations_test.ts @@ -9,9 +9,9 @@ import { v4 as generateGuid } from 'uuid'; import { SCHEMA_VERSIONS } from '../sql/migrations'; import { consoleLogger } from '../util/consoleLogger'; import { + _storyIdPredicate, getJobsInQueueSync, insertJobSync, - _storyIdPredicate, } from '../sql/Server'; import { ReadStatus } from '../messages/MessageReadStatus'; 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=?)' + ); + }); + }); });