From 1087e9261d7d450501d9dcf7c99e3192a1fde957 Mon Sep 17 00:00:00 2001 From: Payton Quinn Date: Wed, 20 Apr 2022 21:56:26 -0700 Subject: [PATCH 01/53] Fix dark mode for call settings --- stylesheets/_modules.scss | 2 ++ 1 file changed, 2 insertions(+) diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index a9003c1c6..3350067ac 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -7291,6 +7291,8 @@ button.module-image__border-overlay:focus { select { @include font-body-1; + background: $color-gray-75; + color: $color-gray-02; -webkit-appearance: none; border-radius: 4px; border: 1px solid $color-gray-45; From 6c68c00578145f48a8983e9853e0709a68628d51 Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Thu, 21 Apr 2022 16:20:41 -0400 Subject: [PATCH 02/53] Use useRefMerger in Input to avoid re-renders --- ts/components/Input.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ts/components/Input.tsx b/ts/components/Input.tsx index a4e335d4a..30c922d3b 100644 --- a/ts/components/Input.tsx +++ b/ts/components/Input.tsx @@ -14,7 +14,7 @@ import classNames from 'classnames'; import * as grapheme from '../util/grapheme'; import type { LocalizerType } from '../types/Util'; import { getClassNamesFor } from '../util/getClassNamesFor'; -import { refMerger } from '../util/refMerger'; +import { useRefMerger } from '../hooks/useRefMerger'; import { byteLength } from '../Bytes'; export type PropsType = { @@ -84,6 +84,7 @@ export const Input = forwardRef< const valueOnKeydownRef = useRef(value); const selectionStartOnKeydownRef = useRef(value.length); const [isLarge, setIsLarge] = useState(false); + const refMerger = useRefMerger(); const maybeSetLarge = useCallback(() => { if (!expandable) { From ed9f54d7d6fd18786a56f44602228b55eae8f929 Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Thu, 21 Apr 2022 13:42:20 -0700 Subject: [PATCH 03/53] Update libsignal-client to 0.16.0 --- package.json | 4 ++-- yarn.lock | 18 +++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 45bf5c4de..8cc4b00d0 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "@evanhahn/lottie-web-light": "5.8.1", "@popperjs/core": "2.9.2", "@react-spring/web": "9.4.1", - "@signalapp/libsignal-client": "0.15.0", + "@signalapp/libsignal-client": "0.16.0", "@sindresorhus/is": "0.8.0", "@types/fabric": "4.5.3", "abort-controller": "3.0.0", @@ -189,7 +189,7 @@ "@chanzuckerberg/axe-storybook-testing": "3.0.2", "@electron/fuses": "1.5.0", "@mixer/parallel-prettier": "2.0.1", - "@signalapp/mock-server": "1.5.0-rc.3", + "@signalapp/mock-server": "1.5.1", "@storybook/addon-actions": "5.1.11", "@storybook/addon-knobs": "5.1.11", "@storybook/addons": "5.1.11", diff --git a/yarn.lock b/yarn.lock index 1a75f1140..5f81c4346 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1364,20 +1364,20 @@ "@react-spring/shared" "~9.4.0" "@react-spring/types" "~9.4.0" -"@signalapp/libsignal-client@0.15.0": - version "0.15.0" - resolved "https://registry.yarnpkg.com/@signalapp/libsignal-client/-/libsignal-client-0.15.0.tgz#e35c949e390d76497acad7ac8b033a68899ae11e" - integrity sha512-DtSv1S/WFGQ+QT1YLuueP99A9Yg1viWJyKyyW+MJ/jM9U+7jNVNCswY+pJnd2VGc3R85x5Kvn/VXYtFrQx8FrA== +"@signalapp/libsignal-client@0.16.0", "@signalapp/libsignal-client@^0.16.0": + version "0.16.0" + resolved "https://registry.yarnpkg.com/@signalapp/libsignal-client/-/libsignal-client-0.16.0.tgz#7acba54b7ba05f513cdcf7f555efa1ccc6ce0145" + integrity sha512-/5EzlAcQoQReDomqV6VTtin5tvqvdUxoe8knSiz+L1kcLSlHA0So0zTR9WAdfQQ69t4q69vhaS4pu5yVI28YHA== dependencies: node-gyp-build "^4.2.3" uuid "^8.3.0" -"@signalapp/mock-server@1.5.0-rc.3": - version "1.5.0-rc.3" - resolved "https://registry.yarnpkg.com/@signalapp/mock-server/-/mock-server-1.5.0-rc.3.tgz#2bb0d80555e84740bf7c110386da6ec9200785d5" - integrity sha512-ShoMNL4XHtvBXDrzgQD2xXpqTDjzzVegQjlA15c9H285Z1qQJhQfnu3A13YDmAYqZ0LbXM8Re5AnWtZJYdGSCA== +"@signalapp/mock-server@1.5.1": + version "1.5.1" + resolved "https://registry.yarnpkg.com/@signalapp/mock-server/-/mock-server-1.5.1.tgz#e37a4505c037a3e85901cd00443d565cf9c2fe90" + integrity sha512-PqRrLhGPtKoTOeHj/L4tUlNkwXZ8MJMU3G7DaaVRAD+g+bpjpeb/ru73iH35K209wRdn4s7/hGUMaeRd6yKFxA== dependencies: - "@signalapp/libsignal-client" "0.15.0" + "@signalapp/libsignal-client" "^0.16.0" debug "^4.3.2" long "^4.0.0" micro "^9.3.4" From 3a1df01c9e3496e514315af46eed09179b602e06 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Fri, 22 Apr 2022 11:35:14 -0700 Subject: [PATCH 04/53] New 'unseenStatus' field for certain secondary message types --- ts/MessageSeenStatus.ts | 25 ++ ts/background.ts | 26 +- .../conversation/Timeline.stories.tsx | 10 +- ts/components/conversation/Timeline.tsx | 24 +- ts/model-types.d.ts | 5 +- ts/models/conversations.ts | 83 +++--- ts/models/messages.ts | 8 +- ts/sql/Interface.ts | 4 +- ts/sql/Server.ts | 77 +++++- ts/sql/migrations/56-add-unseen-to-message.ts | 88 ++++++ ts/sql/migrations/index.ts | 2 + ts/state/ducks/conversations.ts | 26 +- ts/state/selectors/conversations.ts | 18 +- ts/test-both/util/timelineUtil_test.ts | 2 +- ts/test-electron/sql/timelineFetches_test.ts | 18 +- .../state/ducks/conversations_test.ts | 18 +- ts/test-node/sql_migrations_test.ts | 254 ++++++++++++++++++ ts/util/getMessageIdForLogging.ts | 13 - ts/util/idForLogging.ts | 31 +++ ts/util/markConversationRead.ts | 10 +- ts/util/queueAttachmentDownloads.ts | 2 +- ts/util/timelineUtil.ts | 4 +- ts/views/conversation_view.ts | 5 +- 23 files changed, 610 insertions(+), 143 deletions(-) create mode 100644 ts/MessageSeenStatus.ts create mode 100644 ts/sql/migrations/56-add-unseen-to-message.ts delete mode 100644 ts/util/getMessageIdForLogging.ts create mode 100644 ts/util/idForLogging.ts diff --git a/ts/MessageSeenStatus.ts b/ts/MessageSeenStatus.ts new file mode 100644 index 000000000..40e43d088 --- /dev/null +++ b/ts/MessageSeenStatus.ts @@ -0,0 +1,25 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +/** + * `SeenStatus` represents either the idea that a message doesn't need to track its seen + * status, or the standard unseen/seen status pair. + * + * Unseen is a lot like unread - except that unseen messages only affect the placement + * of the last seen indicator and the count it shows. Unread messages will affect the + * left pane badging for conversations, as well as the overall badge count on the app. + */ +export enum SeenStatus { + NotApplicable = 0, + Unseen = 1, + Seen = 2, +} + +const STATUS_NUMBERS: Record = { + [SeenStatus.NotApplicable]: 0, + [SeenStatus.Unseen]: 1, + [SeenStatus.Seen]: 2, +}; + +export const maxSeenStatus = (a: SeenStatus, b: SeenStatus): SeenStatus => + STATUS_NUMBERS[a] > STATUS_NUMBERS[b] ? a : b; diff --git a/ts/background.ts b/ts/background.ts index 20a431c24..c287c22d1 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -143,6 +143,7 @@ import { ReactionSource } from './reactions/ReactionSource'; import { singleProtoJobQueue } from './jobs/singleProtoJobQueue'; import { getInitialState } from './state/getInitialState'; import { conversationJobQueue } from './jobs/conversationJobQueue'; +import { SeenStatus } from './MessageSeenStatus'; const MAX_ATTACHMENT_DOWNLOAD_AGE = 3600 * 72 * 1000; @@ -3052,22 +3053,24 @@ export async function startApp(): Promise { } return new window.Whisper.Message({ - source: window.textsecure.storage.user.getNumber(), - sourceUuid: window.textsecure.storage.user.getUuid()?.toString(), - sourceDevice: data.device, - sent_at: timestamp, - serverTimestamp: data.serverTimestamp, - received_at: data.receivedAtCounter, - received_at_ms: data.receivedAtDate, conversationId: descriptor.id, - timestamp, - type: 'outgoing', - sendStateByConversationId, - unidentifiedDeliveries, expirationStartTimestamp: Math.min( data.expirationStartTimestamp || timestamp, now ), + readStatus: ReadStatus.Read, + received_at_ms: data.receivedAtDate, + received_at: data.receivedAtCounter, + seenStatus: SeenStatus.NotApplicable, + sendStateByConversationId, + sent_at: timestamp, + serverTimestamp: data.serverTimestamp, + source: window.textsecure.storage.user.getNumber(), + sourceDevice: data.device, + sourceUuid: window.textsecure.storage.user.getUuid()?.toString(), + timestamp, + type: 'outgoing', + unidentifiedDeliveries, } as Partial as WhatIsThis); } @@ -3316,6 +3319,7 @@ export async function startApp(): Promise { unidentifiedDeliveryReceived: data.unidentifiedDeliveryReceived, type: data.message.isStory ? 'story' : 'incoming', readStatus: ReadStatus.Unread, + seenStatus: SeenStatus.Unseen, timestamp: data.timestamp, } as Partial as WhatIsThis); } diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index 4d000b9dc..8e600afc0 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -540,9 +540,9 @@ const useProps = (overrideProps: Partial = {}): PropsType => ({ items: overrideProps.items || Object.keys(items), scrollToIndex: overrideProps.scrollToIndex, scrollToIndexCounter: 0, - totalUnread: number('totalUnread', overrideProps.totalUnread || 0), - oldestUnreadIndex: - number('oldestUnreadIndex', overrideProps.oldestUnreadIndex || 0) || + totalUnseen: number('totalUnseen', overrideProps.totalUnseen || 0), + oldestUnseenIndex: + number('oldestUnseenIndex', overrideProps.oldestUnseenIndex || 0) || undefined, invitedContactsForNewlyCreatedGroup: overrideProps.invitedContactsForNewlyCreatedGroup || [], @@ -608,8 +608,8 @@ story.add('Empty (just hero)', () => { story.add('Last Seen', () => { const props = useProps({ - oldestUnreadIndex: 13, - totalUnread: 2, + oldestUnseenIndex: 13, + totalUnseen: 2, }); return ; diff --git a/ts/components/conversation/Timeline.tsx b/ts/components/conversation/Timeline.tsx index 9c0073315..fa49e0680 100644 --- a/ts/components/conversation/Timeline.tsx +++ b/ts/components/conversation/Timeline.tsx @@ -88,10 +88,10 @@ export type PropsDataType = { messageLoadingState?: TimelineMessageLoadingState; isNearBottom?: boolean; items: ReadonlyArray; - oldestUnreadIndex?: number; + oldestUnseenIndex?: number; scrollToIndex?: number; scrollToIndexCounter: number; - totalUnread: number; + totalUnseen: number; }; type PropsHousekeepingType = { @@ -342,7 +342,7 @@ export class Timeline extends React.Component< items, loadNewestMessages, messageLoadingState, - oldestUnreadIndex, + oldestUnseenIndex, selectMessage, } = this.props; const { newestBottomVisibleMessageId } = this.state; @@ -358,15 +358,15 @@ export class Timeline extends React.Component< if ( newestBottomVisibleMessageId && - isNumber(oldestUnreadIndex) && + isNumber(oldestUnseenIndex) && items.findIndex(item => item === newestBottomVisibleMessageId) < - oldestUnreadIndex + oldestUnseenIndex ) { if (setFocus) { - const messageId = items[oldestUnreadIndex]; + const messageId = items[oldestUnseenIndex]; selectMessage(messageId, id); } else { - this.scrollToItemIndex(oldestUnreadIndex); + this.scrollToItemIndex(oldestUnseenIndex); } } else if (haveNewest) { this.scrollToBottom(setFocus); @@ -790,7 +790,7 @@ export class Timeline extends React.Component< isSomeoneTyping, items, messageLoadingState, - oldestUnreadIndex, + oldestUnseenIndex, onBlock, onBlockAndReportSpam, onDelete, @@ -804,7 +804,7 @@ export class Timeline extends React.Component< reviewMessageRequestNameCollision, showContactModal, theme, - totalUnread, + totalUnseen, unblurAvatar, unreadCount, updateSharedGroups, @@ -898,17 +898,17 @@ export class Timeline extends React.Component< } let unreadIndicatorPlacement: undefined | UnreadIndicatorPlacement; - if (oldestUnreadIndex === itemIndex) { + if (oldestUnseenIndex === itemIndex) { unreadIndicatorPlacement = UnreadIndicatorPlacement.JustAbove; messageNodes.push( ); - } else if (oldestUnreadIndex === nextItemIndex) { + } else if (oldestUnseenIndex === nextItemIndex) { unreadIndicatorPlacement = UnreadIndicatorPlacement.JustBelow; } diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index e59ed4bd7..ac8ccb964 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -35,6 +35,7 @@ import { ReactionSource } from './reactions/ReactionSource'; import AccessRequiredEnum = Proto.AccessControl.AccessRequired; import MemberRoleEnum = Proto.Member.Role; +import { SeenStatus } from './MessageSeenStatus'; export type WhatIsThis = any; @@ -219,8 +220,10 @@ export type MessageAttributesType = { sendHQImages?: boolean; - // Should only be present for incoming messages + // Should only be present for incoming messages and errors readStatus?: ReadStatus; + // Used for all kinds of notifications, as well as incoming messages + seenStatus?: SeenStatus; // Should only be present for outgoing messages sendStateByConversationId?: SendStateByConversationId; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 0ab5b4384..3cd8ecb93 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -117,6 +117,8 @@ import { isMessageUnread } from '../util/isMessageUnread'; import type { SenderKeyTargetType } from '../util/sendToGroup'; import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue'; import { TimelineMessageLoadingState } from '../util/timelineUtil'; +import { SeenStatus } from '../MessageSeenStatus'; +import { getConversationIdForLogging } from '../util/idForLogging'; /* eslint-disable more/no-then */ window.Whisper = window.Whisper || {}; @@ -237,17 +239,7 @@ export class ConversationModel extends window.Backbone } idForLogging(): string { - if (isDirectConversation(this.attributes)) { - const uuid = this.get('uuid'); - const e164 = this.get('e164'); - return `${uuid || e164} (${this.id})`; - } - if (isGroupV2(this.attributes)) { - return `groupv2(${this.get('groupId')})`; - } - - const groupId = this.get('groupId'); - return `group(${groupId})`; + return getConversationIdForLogging(this.attributes); } // This is one of the few times that we want to collapse our uuid/e164 pair down into @@ -1508,8 +1500,8 @@ export class ConversationModel extends window.Backbone return; } - if (scrollToLatestUnread && metrics.oldestUnread) { - this.loadAndScroll(metrics.oldestUnread.id, { + if (scrollToLatestUnread && metrics.oldestUnseen) { + this.loadAndScroll(metrics.oldestUnseen.id, { disableScroll: !setFocus, }); return; @@ -2926,6 +2918,7 @@ export class ConversationModel extends window.Backbone received_at: receivedAtCounter, received_at_ms: receivedAt, readStatus: ReadStatus.Unread, + seenStatus: SeenStatus.Unseen, // TODO: DESKTOP-722 // this type does not fully implement the interface it is expected to } as unknown as MessageAttributesType; @@ -2968,6 +2961,7 @@ export class ConversationModel extends window.Backbone received_at: receivedAtCounter, received_at_ms: receivedAt, readStatus: ReadStatus.Unread, + seenStatus: SeenStatus.Unseen, // TODO: DESKTOP-722 // this type does not fully implement the interface it is expected to } as unknown as MessageAttributesType; @@ -3004,7 +2998,8 @@ export class ConversationModel extends window.Backbone received_at: window.Signal.Util.incrementMessageCounter(), received_at_ms: timestamp, key_changed: keyChangedId.toString(), - readStatus: ReadStatus.Unread, + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.Unseen, schemaVersion: Message.VERSION_NEEDED_FOR_DISPLAY, // TODO: DESKTOP-722 // this type does not fully implement the interface it is expected to @@ -3057,14 +3052,15 @@ export class ConversationModel extends window.Backbone const timestamp = Date.now(); const message = { conversationId: this.id, - type: 'verified-change', - sent_at: lastMessage, - received_at: window.Signal.Util.incrementMessageCounter(), - received_at_ms: timestamp, - verifiedChanged: verifiedChangeId, - verified, local: options.local, readStatus: ReadStatus.Unread, + received_at_ms: timestamp, + received_at: window.Signal.Util.incrementMessageCounter(), + seenStatus: SeenStatus.Unseen, + sent_at: lastMessage, + type: 'verified-change', + verified, + verifiedChanged: verifiedChangeId, // TODO: DESKTOP-722 } as unknown as MessageAttributesType; @@ -3128,6 +3124,7 @@ export class ConversationModel extends window.Backbone receivedAtCounter || window.Signal.Util.incrementMessageCounter(), received_at_ms: timestamp, readStatus: unread ? ReadStatus.Unread : ReadStatus.Read, + seenStatus: unread ? SeenStatus.Unseen : SeenStatus.NotApplicable, callHistoryDetails: detailsToSave, // TODO: DESKTOP-722 } as unknown as MessageAttributesType; @@ -3192,6 +3189,7 @@ export class ConversationModel extends window.Backbone received_at: window.Signal.Util.incrementMessageCounter(), received_at_ms: now, readStatus: ReadStatus.Read, + seenStatus: SeenStatus.NotApplicable, changedId: conversationId || this.id, profileChange, // TODO: DESKTOP-722 @@ -3228,14 +3226,15 @@ export class ConversationModel extends window.Backbone ): Promise { const now = Date.now(); const message: Partial = { - ...extra, - conversationId: this.id, type, sent_at: now, received_at: window.Signal.Util.incrementMessageCounter(), received_at_ms: now, readStatus: ReadStatus.Read, + seenStatus: SeenStatus.NotApplicable, + + ...extra, }; const id = await window.Signal.Data.saveMessage( @@ -3363,6 +3362,8 @@ export class ConversationModel extends window.Backbone await Promise.all( convos.map(convo => { return convo.addNotification('change-number-notification', { + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.Unseen, sourceUuid: sourceUuid.toString(), }); }) @@ -4037,6 +4038,8 @@ export class ConversationModel extends window.Backbone received_at_ms: now, expireTimer, recipients, + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.NotApplicable, sticker, bodyRanges: mentions, sendHQImages, @@ -4546,21 +4549,20 @@ export class ConversationModel extends window.Backbone window.Signal.Data.updateConversation(this.attributes); const model = new window.Whisper.Message({ - // Even though this isn't reflected to the user, we want to place the last seen - // indicator above it. We set it to 'unread' to trigger that placement. - readStatus: ReadStatus.Unread, conversationId: this.id, - // No type; 'incoming' messages are specially treated by conversation.markRead() - sent_at: sentAt, - received_at: receivedAt, - received_at_ms: receivedAtMS, - flags: Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, expirationTimerUpdate: { expireTimer, source, fromSync: options.fromSync, fromGroupUpdate: options.fromGroupUpdate, }, + flags: Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, + readStatus: ReadStatus.Unread, + received_at_ms: receivedAtMS, + received_at: receivedAt, + seenStatus: SeenStatus.Unseen, + sent_at: sentAt, + type: 'timer-notification', // TODO: DESKTOP-722 } as unknown as MessageAttributesType); @@ -4589,9 +4591,8 @@ export class ConversationModel extends window.Backbone const model = new window.Whisper.Message({ type: 'message-history-unsynced', - // Even though this isn't reflected to the user, we want to place the last seen - // indicator above it. We set it to 'unread' to trigger that placement. - readStatus: ReadStatus.Unread, + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.NotApplicable, conversationId: this.id, sent_at: timestamp, received_at: window.Signal.Util.incrementMessageCounter(), @@ -4633,12 +4634,14 @@ export class ConversationModel extends window.Backbone window.Signal.Data.updateConversation(this.attributes); const model = new window.Whisper.Message({ - group_update: { left: 'You' }, conversationId: this.id, - type: 'outgoing', - sent_at: now, - received_at: window.Signal.Util.incrementMessageCounter(), + group_update: { left: 'You' }, + readStatus: ReadStatus.Read, received_at_ms: now, + received_at: window.Signal.Util.incrementMessageCounter(), + seenStatus: SeenStatus.NotApplicable, + sent_at: now, + type: 'group', // TODO: DESKTOP-722 } as unknown as MessageAttributesType); @@ -4665,7 +4668,11 @@ export class ConversationModel extends window.Backbone async markRead( newestUnreadAt: number, - options: { readAt?: number; sendReadReceipts: boolean } = { + options: { + readAt?: number; + sendReadReceipts: boolean; + newestSentAt?: number; + } = { sendReadReceipts: true, } ): Promise { diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 649310a2d..81b3caaee 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -141,7 +141,7 @@ import { } from '../messages/helpers'; import type { ReplacementValuesType } from '../types/I18N'; import { viewOnceOpenJobQueue } from '../jobs/viewOnceOpenJobQueue'; -import { getMessageIdForLogging } from '../util/getMessageIdForLogging'; +import { getMessageIdForLogging } from '../util/idForLogging'; import { hasAttachmentDownloads } from '../util/hasAttachmentDownloads'; import { queueAttachmentDownloads } from '../util/queueAttachmentDownloads'; import { findStoryMessage } from '../util/findStoryMessage'; @@ -152,6 +152,7 @@ import { getMessageById } from '../messages/getMessageById'; import { shouldDownloadStory } from '../util/shouldDownloadStory'; import { shouldShowStoriesView } from '../state/selectors/stories'; import type { ContactWithHydratedAvatar } from '../textsecure/SendMessage'; +import { SeenStatus } from '../MessageSeenStatus'; /* eslint-disable camelcase */ /* eslint-disable more/no-then */ @@ -2760,7 +2761,10 @@ export class MessageModel extends window.Backbone.Model { newReadStatus = ReadStatus.Read; } - message.set('readStatus', newReadStatus); + message.set({ + readStatus: newReadStatus, + seenStatus: SeenStatus.Seen, + }); changed = true; this.pendingMarkRead = Math.min( diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 38f6cba9b..98e51487a 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -50,8 +50,8 @@ export type MessageMetricsType = { export type ConversationMetricsType = { oldest?: MessageMetricsType; newest?: MessageMetricsType; - oldestUnread?: MessageMetricsType; - totalUnread: number; + oldestUnseen?: MessageMetricsType; + totalUnseen: number; }; export type ConversationType = ConversationAttributesType; export type EmojiType = { diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 22ba8ff81..6e5b5ca07 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -110,6 +110,7 @@ import type { UnprocessedType, UnprocessedUpdateType, } from './Interface'; +import { SeenStatus } from '../MessageSeenStatus'; type ConversationRow = Readonly<{ json: string; @@ -1737,6 +1738,20 @@ function saveMessageSync( expireTimer, expirationStartTimestamp, } = data; + let { seenStatus } = data; + + if (readStatus === ReadStatus.Unread && seenStatus !== SeenStatus.Unseen) { + log.warn( + `saveMessage: Message ${id}/${type} is unread but had seenStatus=${seenStatus}. Forcing to UnseenStatus.Unseen.` + ); + + // eslint-disable-next-line no-param-reassign + data = { + ...data, + seenStatus: SeenStatus.Unseen, + }; + seenStatus = SeenStatus.Unseen; + } const payload = { id, @@ -1762,6 +1777,7 @@ function saveMessageSync( storyId: storyId || null, type: type || null, readStatus: readStatus ?? null, + seenStatus: seenStatus ?? SeenStatus.NotApplicable, }; if (id && !forceSave) { @@ -1791,7 +1807,8 @@ function saveMessageSync( sourceDevice = $sourceDevice, storyId = $storyId, type = $type, - readStatus = $readStatus + readStatus = $readStatus, + seenStatus = $seenStatus WHERE id = $id; ` ).run(payload); @@ -1834,7 +1851,8 @@ function saveMessageSync( sourceDevice, storyId, type, - readStatus + readStatus, + seenStatus ) values ( $id, $json, @@ -1858,7 +1876,8 @@ function saveMessageSync( $sourceDevice, $storyId, $type, - $readStatus + $readStatus, + $seenStatus ); ` ).run({ @@ -2110,16 +2129,21 @@ async function getUnreadByConversationAndMarkRead({ UPDATE messages SET readStatus = ${ReadStatus.Read}, + seenStatus = ${SeenStatus.Seen}, json = json_patch(json, $jsonPatch) WHERE - readStatus = ${ReadStatus.Unread} AND conversationId = $conversationId AND + seenStatus = ${SeenStatus.Unseen} AND + isStory = 0 AND (${_storyIdPredicate(storyId, isGroup)}) AND received_at <= $newestUnreadAt; ` ).run({ conversationId, - jsonPatch: JSON.stringify({ readStatus: ReadStatus.Read }), + jsonPatch: JSON.stringify({ + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.Seen, + }), newestUnreadAt, storyId: storyId || null, }); @@ -2644,7 +2668,7 @@ async function getLastConversationMessage({ return jsonToObject(row.json); } -function getOldestUnreadMessageForConversation( +function getOldestUnseenMessageForConversation( conversationId: string, storyId?: UUIDStringType, isGroup?: boolean @@ -2655,7 +2679,7 @@ function getOldestUnreadMessageForConversation( ` SELECT * FROM messages WHERE conversationId = $conversationId AND - readStatus = ${ReadStatus.Unread} AND + seenStatus = ${SeenStatus.Unseen} AND isStory IS 0 AND (${_storyIdPredicate(storyId, isGroup)}) ORDER BY received_at ASC, sent_at ASC @@ -2709,6 +2733,35 @@ function getTotalUnreadForConversationSync( return row['count(id)']; } +function getTotalUnseenForConversationSync( + conversationId: string, + storyId?: UUIDStringType, + isGroup?: boolean +): number { + const db = getInstance(); + const row = db + .prepare( + ` + SELECT count(id) + FROM messages + WHERE + conversationId = $conversationId AND + seenStatus = ${SeenStatus.Unseen} AND + isStory IS 0 AND + (${_storyIdPredicate(storyId, isGroup)}) + ` + ) + .get({ + conversationId, + storyId: storyId || null, + }); + + if (!row) { + throw new Error('getTotalUnseenForConversationSync: Unable to get count'); + } + + return row['count(id)']; +} async function getMessageMetricsForConversation( conversationId: string, @@ -2732,12 +2785,12 @@ function getMessageMetricsForConversationSync( storyId, isGroup ); - const oldestUnread = getOldestUnreadMessageForConversation( + const oldestUnseen = getOldestUnseenMessageForConversation( conversationId, storyId, isGroup ); - const totalUnread = getTotalUnreadForConversationSync( + const totalUnseen = getTotalUnseenForConversationSync( conversationId, storyId, isGroup @@ -2746,10 +2799,10 @@ function getMessageMetricsForConversationSync( return { oldest: oldest ? pick(oldest, ['received_at', 'sent_at', 'id']) : undefined, newest: newest ? pick(newest, ['received_at', 'sent_at', 'id']) : undefined, - oldestUnread: oldestUnread - ? pick(oldestUnread, ['received_at', 'sent_at', 'id']) + oldestUnseen: oldestUnseen + ? pick(oldestUnseen, ['received_at', 'sent_at', 'id']) : undefined, - totalUnread, + totalUnseen, }; } diff --git a/ts/sql/migrations/56-add-unseen-to-message.ts b/ts/sql/migrations/56-add-unseen-to-message.ts new file mode 100644 index 000000000..4a6927e38 --- /dev/null +++ b/ts/sql/migrations/56-add-unseen-to-message.ts @@ -0,0 +1,88 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { Database } from 'better-sqlite3'; +import { ReadStatus } from '../../messages/MessageReadStatus'; +import { SeenStatus } from '../../MessageSeenStatus'; + +import type { LoggerType } from '../../types/Logging'; + +export default function updateToSchemaVersion56( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 56) { + return; + } + + db.transaction(() => { + db.exec( + ` + --- Add column to messages table + + ALTER TABLE messages ADD COLUMN seenStatus NUMBER default 0; + + --- Add index to make searching on this field easy + + CREATE INDEX messages_unseen_no_story ON messages + (conversationId, seenStatus, isStory, received_at, sent_at) + WHERE + seenStatus IS NOT NULL; + + CREATE INDEX messages_unseen_with_story ON messages + (conversationId, seenStatus, isStory, storyId, received_at, sent_at) + WHERE + seenStatus IS NOT NULL; + + --- Update seenStatus to UnseenStatus.Unseen for certain messages + --- (NULL included because 'timer-notification' in 1:1 convos had type = NULL) + + UPDATE messages + SET + seenStatus = ${SeenStatus.Unseen} + WHERE + readStatus = ${ReadStatus.Unread} AND + ( + type IS NULL + OR + type IN ( + 'call-history', + 'change-number-notification', + 'chat-session-refreshed', + 'delivery-issue', + 'group', + 'incoming', + 'keychange', + 'timer-notification', + 'verified-change' + ) + ); + + --- Set readStatus to ReadStatus.Read for all other message types + + UPDATE messages + SET + readStatus = ${ReadStatus.Read} + WHERE + readStatus = ${ReadStatus.Unread} AND + type IS NOT NULL AND + type NOT IN ( + 'call-history', + 'change-number-notification', + 'chat-session-refreshed', + 'delivery-issue', + 'group', + 'incoming', + 'keychange', + 'timer-notification', + 'verified-change' + ); + ` + ); + + db.pragma('user_version = 56'); + })(); + + logger.info('updateToSchemaVersion56: success!'); +} diff --git a/ts/sql/migrations/index.ts b/ts/sql/migrations/index.ts index 862e3eee1..4f9196299 100644 --- a/ts/sql/migrations/index.ts +++ b/ts/sql/migrations/index.ts @@ -31,6 +31,7 @@ import updateToSchemaVersion52 from './52-optimize-stories'; import updateToSchemaVersion53 from './53-gv2-banned-members'; import updateToSchemaVersion54 from './54-unprocessed-received-at-counter'; import updateToSchemaVersion55 from './55-report-message-aci'; +import updateToSchemaVersion56 from './56-add-unseen-to-message'; function updateToSchemaVersion1( currentVersion: number, @@ -1925,6 +1926,7 @@ export const SCHEMA_VERSIONS = [ updateToSchemaVersion53, updateToSchemaVersion54, updateToSchemaVersion55, + updateToSchemaVersion56, ]; export function updateSchema(db: Database, logger: LoggerType): void { diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 8b66be748..139933775 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -234,8 +234,8 @@ type MessagePointerType = { type MessageMetricsType = { newest?: MessagePointerType; oldest?: MessagePointerType; - oldestUnread?: MessagePointerType; - totalUnread: number; + oldestUnseen?: MessagePointerType; + totalUnseen: number; }; export type MessageLookupType = { @@ -2673,7 +2673,7 @@ export function reducer( let metrics; if (messageIds.length === 0) { metrics = { - totalUnread: 0, + totalUnseen: 0, }; } else { metrics = { @@ -2791,7 +2791,7 @@ export function reducer( return state; } - let { newest, oldest, oldestUnread, totalUnread } = + let { newest, oldest, oldestUnseen, totalUnseen } = existingConversation.metrics; if (messages.length < 1) { @@ -2853,7 +2853,7 @@ export function reducer( const newMessageIds = difference(newIds, existingConversation.messageIds); const { isNearBottom } = existingConversation; - if ((!isNearBottom || !isActive) && !oldestUnread) { + if ((!isNearBottom || !isActive) && !oldestUnseen) { const oldestId = newMessageIds.find(messageId => { const message = lookup[messageId]; @@ -2861,7 +2861,7 @@ export function reducer( }); if (oldestId) { - oldestUnread = pick(lookup[oldestId], [ + oldestUnseen = pick(lookup[oldestId], [ 'id', 'received_at', 'sent_at', @@ -2869,14 +2869,14 @@ export function reducer( } } - // If this is a new incoming message, we'll increment our totalUnread count - if (isNewMessage && !isJustSent && oldestUnread) { + // If this is a new incoming message, we'll increment our totalUnseen count + if (isNewMessage && !isJustSent && oldestUnseen) { const newUnread: number = newMessageIds.reduce((sum, messageId) => { const message = lookup[messageId]; return sum + (message && isMessageUnread(message) ? 1 : 0); }, 0); - totalUnread = (totalUnread || 0) + newUnread; + totalUnseen = (totalUnseen || 0) + newUnread; } return { @@ -2896,8 +2896,8 @@ export function reducer( ...existingConversation.metrics, newest, oldest, - totalUnread, - oldestUnread, + totalUnseen, + oldestUnseen, }, }, }, @@ -2926,8 +2926,8 @@ export function reducer( ...existingConversation, metrics: { ...existingConversation.metrics, - oldestUnread: undefined, - totalUnread: 0, + oldestUnseen: undefined, + totalUnseen: 0, }, }, }, diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index d5177a783..a77c642e9 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -839,7 +839,7 @@ export function _conversationMessagesSelector( const lastId = messageIds.length === 0 ? undefined : messageIds[messageIds.length - 1]; - const { oldestUnread } = metrics; + const { oldestUnseen } = metrics; const haveNewest = !metrics.newest || !lastId || lastId === metrics.newest.id; const haveOldest = @@ -847,13 +847,13 @@ export function _conversationMessagesSelector( const items = messageIds; - const oldestUnreadIndex = oldestUnread - ? messageIds.findIndex(id => id === oldestUnread.id) + const oldestUnseenIndex = oldestUnseen + ? messageIds.findIndex(id => id === oldestUnseen.id) : undefined; const scrollToIndex = scrollToMessageId ? messageIds.findIndex(id => id === scrollToMessageId) : undefined; - const { totalUnread } = metrics; + const { totalUnseen } = metrics; return { haveNewest, @@ -861,14 +861,14 @@ export function _conversationMessagesSelector( isNearBottom, items, messageLoadingState, - oldestUnreadIndex: - isNumber(oldestUnreadIndex) && oldestUnreadIndex >= 0 - ? oldestUnreadIndex + oldestUnseenIndex: + isNumber(oldestUnseenIndex) && oldestUnseenIndex >= 0 + ? oldestUnseenIndex : undefined, scrollToIndex: isNumber(scrollToIndex) && scrollToIndex >= 0 ? scrollToIndex : undefined, scrollToIndexCounter: scrollToMessageCounter, - totalUnread, + totalUnseen, }; } @@ -901,7 +901,7 @@ export const getConversationMessagesSelector = createSelector( haveOldest: false, messageLoadingState: TimelineMessageLoadingState.DoingInitialLoad, scrollToIndexCounter: 0, - totalUnread: 0, + totalUnseen: 0, items: [], }; } diff --git a/ts/test-both/util/timelineUtil_test.ts b/ts/test-both/util/timelineUtil_test.ts index 43b3c2d74..4c1984abc 100644 --- a/ts/test-both/util/timelineUtil_test.ts +++ b/ts/test-both/util/timelineUtil_test.ts @@ -352,7 +352,7 @@ describe(' utilities', () => { const props = { ...defaultProps, items: fakeItems(10), - oldestUnreadIndex: 3, + oldestUnseenIndex: 3, }; assert.strictEqual( diff --git a/ts/test-electron/sql/timelineFetches_test.ts b/ts/test-electron/sql/timelineFetches_test.ts index 1319731ae..496dd6e7a 100644 --- a/ts/test-electron/sql/timelineFetches_test.ts +++ b/ts/test-electron/sql/timelineFetches_test.ts @@ -692,9 +692,9 @@ describe('sql/timelineFetches', () => { received_at: target - 8, timestamp: target - 8, }; - const oldestUnread: MessageAttributesType = { + const oldestUnseen: MessageAttributesType = { id: getUuid(), - body: 'oldestUnread', + body: 'oldestUnseen', type: 'incoming', conversationId, sent_at: target - 7, @@ -748,7 +748,7 @@ describe('sql/timelineFetches', () => { story, oldestInStory, oldest, - oldestUnread, + oldestUnseen, oldestStoryUnread, anotherUnread, newestInStory, @@ -769,11 +769,11 @@ describe('sql/timelineFetches', () => { ); assert.strictEqual(metricsInTimeline?.newest?.id, newest.id, 'newest'); assert.strictEqual( - metricsInTimeline?.oldestUnread?.id, - oldestUnread.id, - 'oldestUnread' + metricsInTimeline?.oldestUnseen?.id, + oldestUnseen.id, + 'oldestUnseen' ); - assert.strictEqual(metricsInTimeline?.totalUnread, 3, 'totalUnread'); + assert.strictEqual(metricsInTimeline?.totalUnseen, 3, 'totalUnseen'); const metricsInStory = await getMessageMetricsForConversation( conversationId, @@ -790,11 +790,11 @@ describe('sql/timelineFetches', () => { 'newestInStory' ); assert.strictEqual( - metricsInStory?.oldestUnread?.id, + metricsInStory?.oldestUnseen?.id, oldestStoryUnread.id, 'oldestStoryUnread' ); - assert.strictEqual(metricsInStory?.totalUnread, 1, 'totalUnread'); + assert.strictEqual(metricsInStory?.totalUnseen, 1, 'totalUnseen'); }); }); }); diff --git a/ts/test-electron/state/ducks/conversations_test.ts b/ts/test-electron/state/ducks/conversations_test.ts index 3345c2383..5f0e7a0e7 100644 --- a/ts/test-electron/state/ducks/conversations_test.ts +++ b/ts/test-electron/state/ducks/conversations_test.ts @@ -332,7 +332,7 @@ describe('both/state/ducks/conversations', () => { return { messageIds: [], metrics: { - totalUnread: 0, + totalUnseen: 0, }, scrollToMessageCounter: 0, }; @@ -1008,7 +1008,7 @@ describe('both/state/ducks/conversations', () => { ...getDefaultConversationMessage(), messageIds: [messageIdThree, messageIdTwo, messageId], metrics: { - totalUnread: 0, + totalUnseen: 0, }, }, }, @@ -1028,7 +1028,7 @@ describe('both/state/ducks/conversations', () => { ...getDefaultConversationMessage(), messageIds: [messageIdThree, messageIdTwo, messageId], metrics: { - totalUnread: 0, + totalUnseen: 0, newest: { id: messageId, received_at: time, @@ -1058,7 +1058,7 @@ describe('both/state/ducks/conversations', () => { ...getDefaultConversationMessage(), messageIds: [], metrics: { - totalUnread: 0, + totalUnseen: 0, newest: { id: messageId, received_at: time, @@ -1082,7 +1082,7 @@ describe('both/state/ducks/conversations', () => { messageIds: [], metrics: { newest: undefined, - totalUnread: 0, + totalUnseen: 0, }, }, }, @@ -1118,7 +1118,7 @@ describe('both/state/ducks/conversations', () => { ...getDefaultConversationMessage(), messageIds: [messageId, messageIdTwo, messageIdThree], metrics: { - totalUnread: 0, + totalUnseen: 0, }, }, }, @@ -1138,7 +1138,7 @@ describe('both/state/ducks/conversations', () => { ...getDefaultConversationMessage(), messageIds: [messageId, messageIdTwo, messageIdThree], metrics: { - totalUnread: 0, + totalUnseen: 0, oldest: { id: messageId, received_at: time, @@ -1168,7 +1168,7 @@ describe('both/state/ducks/conversations', () => { ...getDefaultConversationMessage(), messageIds: [], metrics: { - totalUnread: 0, + totalUnseen: 0, oldest: { id: messageId, received_at: time, @@ -1192,7 +1192,7 @@ describe('both/state/ducks/conversations', () => { messageIds: [], metrics: { oldest: undefined, - totalUnread: 0, + totalUnseen: 0, }, }, }, diff --git a/ts/test-node/sql_migrations_test.ts b/ts/test-node/sql_migrations_test.ts index 29ec49ae3..20169e9a0 100644 --- a/ts/test-node/sql_migrations_test.ts +++ b/ts/test-node/sql_migrations_test.ts @@ -13,6 +13,8 @@ import { insertJobSync, _storyIdPredicate, } from '../sql/Server'; +import { ReadStatus } from '../messages/MessageReadStatus'; +import { SeenStatus } from '../MessageSeenStatus'; const OUR_UUID = generateGuid(); @@ -1772,4 +1774,256 @@ describe('SQL migrations test', () => { ]); }); }); + + describe('updateToSchemaVersion56', () => { + it('updates unseenStatus for previously-unread messages', () => { + const MESSAGE_ID_1 = generateGuid(); + const MESSAGE_ID_2 = generateGuid(); + const MESSAGE_ID_3 = generateGuid(); + const MESSAGE_ID_4 = generateGuid(); + const MESSAGE_ID_5 = generateGuid(); + const MESSAGE_ID_6 = generateGuid(); + const MESSAGE_ID_7 = generateGuid(); + const MESSAGE_ID_8 = generateGuid(); + const MESSAGE_ID_9 = generateGuid(); + const MESSAGE_ID_10 = generateGuid(); + const MESSAGE_ID_11 = generateGuid(); + const CONVERSATION_ID = generateGuid(); + + updateToVersion(55); + + db.exec( + ` + INSERT INTO messages + (id, conversationId, type, readStatus) + VALUES + ('${MESSAGE_ID_1}', '${CONVERSATION_ID}', 'call-history', ${ReadStatus.Unread}), + ('${MESSAGE_ID_2}', '${CONVERSATION_ID}', 'change-number-notification', ${ReadStatus.Unread}), + ('${MESSAGE_ID_3}', '${CONVERSATION_ID}', 'chat-session-refreshed', ${ReadStatus.Unread}), + ('${MESSAGE_ID_4}', '${CONVERSATION_ID}', 'delivery-issue', ${ReadStatus.Unread}), + ('${MESSAGE_ID_5}', '${CONVERSATION_ID}', 'group', ${ReadStatus.Unread}), + ('${MESSAGE_ID_6}', '${CONVERSATION_ID}', 'incoming', ${ReadStatus.Unread}), + ('${MESSAGE_ID_7}', '${CONVERSATION_ID}', 'keychange', ${ReadStatus.Unread}), + ('${MESSAGE_ID_8}', '${CONVERSATION_ID}', 'timer-notification', ${ReadStatus.Unread}), + ('${MESSAGE_ID_9}', '${CONVERSATION_ID}', 'verified-change', ${ReadStatus.Unread}), + ('${MESSAGE_ID_10}', '${CONVERSATION_ID}', NULL, ${ReadStatus.Unread}), + ('${MESSAGE_ID_11}', '${CONVERSATION_ID}', 'other', ${ReadStatus.Unread}); + ` + ); + + assert.strictEqual( + db.prepare('SELECT COUNT(*) FROM messages;').pluck().get(), + 11, + 'starting total' + ); + assert.strictEqual( + db + .prepare( + `SELECT COUNT(*) FROM messages WHERE readStatus = ${ReadStatus.Unread};` + ) + .pluck() + .get(), + 11, + 'starting unread count' + ); + + updateToVersion(56); + + assert.strictEqual( + db.prepare('SELECT COUNT(*) FROM messages;').pluck().get(), + 11, + 'ending total' + ); + assert.strictEqual( + db + .prepare( + `SELECT COUNT(*) FROM messages WHERE readStatus = ${ReadStatus.Unread};` + ) + .pluck() + .get(), + 10, + 'ending unread count' + ); + assert.strictEqual( + db + .prepare( + `SELECT COUNT(*) FROM messages WHERE seenStatus = ${SeenStatus.Unseen};` + ) + .pluck() + .get(), + 10, + 'ending unseen count' + ); + + assert.strictEqual( + db + .prepare( + "SELECT readStatus FROM messages WHERE type = 'other' LIMIT 1;" + ) + .pluck() + .get(), + ReadStatus.Read, + "checking read status for lone 'other' message" + ); + }); + + it('creates usable index for getOldestUnseenMessageForConversation', () => { + updateToVersion(56); + + const first = db + .prepare( + ` + EXPLAIN QUERY PLAN + SELECT * FROM messages WHERE + conversationId = 'id-conversation-4' AND + seenStatus = ${SeenStatus.Unseen} AND + isStory IS 0 AND + NULL IS NULL + ORDER BY received_at ASC, sent_at ASC + LIMIT 1; + ` + ) + .all() + .map(({ detail }) => detail) + .join('\n'); + + assert.include(first, 'USING INDEX messages_unseen_no_story', 'first'); + assert.notInclude(first, 'TEMP B-TREE', 'first'); + assert.notInclude(first, 'SCAN', 'first'); + + const second = db + .prepare( + ` + EXPLAIN QUERY PLAN + SELECT * FROM messages WHERE + conversationId = 'id-conversation-4' AND + seenStatus = ${SeenStatus.Unseen} AND + isStory IS 0 AND + storyId IS 'id-story-4' + ORDER BY received_at ASC, sent_at ASC + LIMIT 1; + ` + ) + .all() + .map(({ detail }) => detail) + .join('\n'); + + assert.include( + second, + 'USING INDEX messages_unseen_with_story', + 'second' + ); + assert.notInclude(second, 'TEMP B-TREE', 'second'); + assert.notInclude(second, 'SCAN', 'second'); + }); + + it('creates usable index for getUnreadByConversationAndMarkRead', () => { + updateToVersion(56); + + const first = db + .prepare( + ` + EXPLAIN QUERY PLAN + UPDATE messages + SET + readStatus = ${ReadStatus.Read}, + seenStatus = ${SeenStatus.Seen}, + json = json_patch(json, '{ something: "one" }') + WHERE + conversationId = 'id-conversation-4' AND + seenStatus = ${SeenStatus.Unseen} AND + isStory = 0 AND + NULL IS NULL AND + received_at <= 2343233; + ` + ) + .all() + .map(({ detail }) => detail) + .join('\n'); + + assert.include(first, 'USING INDEX messages_unseen_no_story', 'first'); + assert.notInclude(first, 'TEMP B-TREE', 'first'); + assert.notInclude(first, 'SCAN', 'first'); + + const second = db + .prepare( + ` + EXPLAIN QUERY PLAN + UPDATE messages + SET + readStatus = ${ReadStatus.Read}, + seenStatus = ${SeenStatus.Seen}, + json = json_patch(json, '{ something: "one" }') + WHERE + conversationId = 'id-conversation-4' AND + seenStatus = ${SeenStatus.Unseen} AND + isStory = 0 AND + storyId IS 'id-story-4' AND + received_at <= 2343233; + ` + ) + .all() + .map(({ detail }) => detail) + .join('\n'); + + assert.include( + second, + 'USING INDEX messages_unseen_with_story', + 'second' + ); + assert.notInclude(second, 'TEMP B-TREE', 'second'); + assert.notInclude(second, 'SCAN', 'second'); + }); + + it('creates usable index for getTotalUnseenForConversationSync', () => { + updateToVersion(56); + + const first = db + .prepare( + ` + EXPLAIN QUERY PLAN + SELECT count(id) + FROM messages + WHERE + conversationId = 'id-conversation-4' AND + seenStatus = ${SeenStatus.Unseen} AND + isStory IS 0 AND + NULL IS NULL; + ` + ) + .all() + .map(({ detail }) => detail) + .join('\n'); + + // Weird, but we don't included received_at so it doesn't really matter + assert.include(first, 'USING INDEX messages_unseen_with_story', 'first'); + assert.notInclude(first, 'TEMP B-TREE', 'first'); + assert.notInclude(first, 'SCAN', 'first'); + + const second = db + .prepare( + ` + EXPLAIN QUERY PLAN + SELECT count(id) + FROM messages + WHERE + conversationId = 'id-conversation-4' AND + seenStatus = ${SeenStatus.Unseen} AND + isStory IS 0 AND + storyId IS 'id-story-4'; + ` + ) + .all() + .map(({ detail }) => detail) + .join('\n'); + + assert.include( + second, + 'USING INDEX messages_unseen_with_story', + 'second' + ); + assert.notInclude(second, 'TEMP B-TREE', 'second'); + assert.notInclude(second, 'SCAN', 'second'); + }); + }); }); diff --git a/ts/util/getMessageIdForLogging.ts b/ts/util/getMessageIdForLogging.ts deleted file mode 100644 index 2b1abc67a..000000000 --- a/ts/util/getMessageIdForLogging.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright 2020-2022 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import type { MessageAttributesType } from '../model-types.d'; -import { getSource, getSourceDevice, getSourceUuid } from '../messages/helpers'; - -export function getMessageIdForLogging(message: MessageAttributesType): string { - const account = getSourceUuid(message) || getSource(message); - const device = getSourceDevice(message); - const timestamp = message.sent_at; - - return `${account}.${device} ${timestamp}`; -} diff --git a/ts/util/idForLogging.ts b/ts/util/idForLogging.ts new file mode 100644 index 000000000..d03d7fbe2 --- /dev/null +++ b/ts/util/idForLogging.ts @@ -0,0 +1,31 @@ +// Copyright 2020-2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { + ConversationAttributesType, + MessageAttributesType, +} from '../model-types.d'; +import { getSource, getSourceDevice, getSourceUuid } from '../messages/helpers'; +import { isDirectConversation, isGroupV2 } from './whatTypeOfConversation'; + +export function getMessageIdForLogging(message: MessageAttributesType): string { + const account = getSourceUuid(message) || getSource(message); + const device = getSourceDevice(message); + const timestamp = message.sent_at; + + return `${account}.${device} ${timestamp}`; +} + +export function getConversationIdForLogging( + conversation: ConversationAttributesType +): string { + if (isDirectConversation(conversation)) { + const { uuid, e164, id } = conversation; + return `${uuid || e164} (${id})`; + } + if (isGroupV2(conversation)) { + return `groupv2(${conversation.groupId})`; + } + + return `group(${conversation.groupId})`; +} diff --git a/ts/util/markConversationRead.ts b/ts/util/markConversationRead.ts index b23c6135e..498f5c0c8 100644 --- a/ts/util/markConversationRead.ts +++ b/ts/util/markConversationRead.ts @@ -8,11 +8,16 @@ import { readSyncJobQueue } from '../jobs/readSyncJobQueue'; import { notificationService } from '../services/notifications'; import { isGroup } from './whatTypeOfConversation'; import * as log from '../logging/log'; +import { getConversationIdForLogging } from './idForLogging'; export async function markConversationRead( conversationAttrs: ConversationAttributesType, newestUnreadAt: number, - options: { readAt?: number; sendReadReceipts: boolean } = { + options: { + readAt?: number; + sendReadReceipts: boolean; + newestSentAt?: number; + } = { sendReadReceipts: true, } ): Promise { @@ -32,7 +37,8 @@ export async function markConversationRead( ]); log.info('markConversationRead', { - conversationId, + conversationId: getConversationIdForLogging(conversationAttrs), + newestSentAt: options.newestSentAt, newestUnreadAt, unreadMessages: unreadMessages.length, unreadReactions: unreadReactions.length, diff --git a/ts/util/queueAttachmentDownloads.ts b/ts/util/queueAttachmentDownloads.ts index 403d6df34..87fb5f955 100644 --- a/ts/util/queueAttachmentDownloads.ts +++ b/ts/util/queueAttachmentDownloads.ts @@ -13,7 +13,7 @@ import type { import * as AttachmentDownloads from '../messageModifiers/AttachmentDownloads'; import * as log from '../logging/log'; import { isLongMessage } from '../types/MIME'; -import { getMessageIdForLogging } from './getMessageIdForLogging'; +import { getMessageIdForLogging } from './idForLogging'; import { copyStickerToAttachments, savePackMetadata, diff --git a/ts/util/timelineUtil.ts b/ts/util/timelineUtil.ts index 2d85e8b0d..87bea03d2 100644 --- a/ts/util/timelineUtil.ts +++ b/ts/util/timelineUtil.ts @@ -134,7 +134,7 @@ type ScrollAnchorBeforeUpdateProps = Readonly< | 'isSomeoneTyping' | 'items' | 'messageLoadingState' - | 'oldestUnreadIndex' + | 'oldestUnseenIndex' | 'scrollToIndex' | 'scrollToIndexCounter' > @@ -169,7 +169,7 @@ export function getScrollAnchorBeforeUpdate( if (props.isIncomingMessageRequest) { return ScrollAnchor.ChangeNothing; } - if (isNumber(props.oldestUnreadIndex)) { + if (isNumber(props.oldestUnseenIndex)) { return ScrollAnchor.ScrollToUnreadIndicator; } return ScrollAnchor.ScrollToBottom; diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index 903f8cfe8..e63a34668 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -493,7 +493,10 @@ export class ConversationView extends window.Backbone.View { throw new Error(`markMessageRead: failed to load message ${messageId}`); } - await this.model.markRead(message.get('received_at')); + await this.model.markRead(message.get('received_at'), { + newestSentAt: message.get('sent_at'), + sendReadReceipts: true, + }); }; const createMessageRequestResponseHandler = From 4602cef6da199e57b2bbdad80990e5f2f9b986dc Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Fri, 22 Apr 2022 14:36:34 -0400 Subject: [PATCH 05/53] Full width/height stories --- stylesheets/components/StoryImage.scss | 1 - stylesheets/components/StoryViewer.scss | 20 ++-- ts/components/StoryViewer.stories.tsx | 35 ++++++ ts/components/StoryViewer.tsx | 135 +++++++++++++----------- ts/components/TextAttachment.tsx | 20 +--- ts/util/getStoryBackground.ts | 40 +++++++ 6 files changed, 163 insertions(+), 88 deletions(-) create mode 100644 ts/util/getStoryBackground.ts diff --git a/stylesheets/components/StoryImage.scss b/stylesheets/components/StoryImage.scss index 391d96302..a0a43b75d 100644 --- a/stylesheets/components/StoryImage.scss +++ b/stylesheets/components/StoryImage.scss @@ -3,7 +3,6 @@ .StoryImage { align-items: center; - border-radius: 8px; display: flex; height: 100%; justify-content: center; diff --git a/stylesheets/components/StoryViewer.scss b/stylesheets/components/StoryViewer.scss index 781e2165b..ae419de44 100644 --- a/stylesheets/components/StoryViewer.scss +++ b/stylesheets/components/StoryViewer.scss @@ -3,8 +3,7 @@ .StoryViewer { &__overlay { - background: $color-gray-95; - filter: blur(160px); + background-size: contain; height: 100vh; left: 0; position: absolute; @@ -15,6 +14,8 @@ &__content { align-items: center; + backdrop-filter: blur(90px); + background: $color-black-alpha-20; display: flex; flex-direction: column; height: 100vh; @@ -29,16 +30,19 @@ &__close-button { @include button-reset; @include modal-close-button; + right: 28px; top: var(--title-bar-drag-area-height); + z-index: $z-index-above-base; } &__more { @include button-reset; height: 24px; position: absolute; - right: 48px; + right: 80px; top: var(--title-bar-drag-area-height); width: 24px; + z-index: $z-index-above-base; @include color-svg('../images/icons/v2/more-horiz-24.svg', $color-white); @@ -51,14 +55,12 @@ &__container { flex-grow: 1; - margin-top: 36px; overflow: hidden; position: relative; z-index: $z-index-base; } &__story { - border-radius: 12px; max-height: 100%; outline: none; width: auto; @@ -67,7 +69,7 @@ &__meta { bottom: 0; left: 50%; - padding: 16px; + padding: 0 16px; position: absolute; transform: translateX(-50%); width: 284px; @@ -94,8 +96,10 @@ @include font-body-1-bold; color: $color-gray-05; padding: 4px 0; + margin-bottom: 24px; &__overlay { + @include button-reset; background: $color-black-alpha-60; height: 100%; left: 0; @@ -107,11 +111,9 @@ } &__actions { - align-items: center; display: flex; justify-content: center; - margin-bottom: 32px; - min-height: 52px; + min-height: 60px; } &__reply { diff --git a/ts/components/StoryViewer.stories.tsx b/ts/components/StoryViewer.stories.tsx index 0983ae048..ec30ba46d 100644 --- a/ts/components/StoryViewer.stories.tsx +++ b/ts/components/StoryViewer.stories.tsx @@ -122,3 +122,38 @@ story.add('So many stories', () => { /> ); }); + +story.add('Caption', () => ( + +)); + +story.add('Long Caption', () => ( + +)); diff --git a/ts/components/StoryViewer.tsx b/ts/components/StoryViewer.tsx index ee9805f0a..5dc369ad4 100644 --- a/ts/components/StoryViewer.tsx +++ b/ts/components/StoryViewer.tsx @@ -17,6 +17,7 @@ import { MessageTimestamp } from './conversation/MessageTimestamp'; import { StoryImage } from './StoryImage'; import { StoryViewsNRepliesModal } from './StoryViewsNRepliesModal'; import { getAvatarColor } from '../types/Colors'; +import { getStoryBackground } from '../util/getStoryBackground'; import { getStoryDuration } from '../util/getStoryDuration'; import { graphemeAwareSlice } from '../util/graphemeAwareSlice'; import { isDownloaded, isDownloading } from '../types/Attachment'; @@ -209,12 +210,12 @@ export const StoryViewer = ({ }, [currentStoryIndex, spring, storyDuration]); useEffect(() => { - if (hasReplyModal) { + if (hasReplyModal || hasExpandedCaption) { spring.pause(); } else { spring.resume(); } - }, [hasReplyModal, spring]); + }, [hasExpandedCaption, hasReplyModal, spring]); useEffect(() => { markStoryRead(messageId); @@ -273,21 +274,11 @@ export const StoryViewer = ({ return (
-
+
-
+
+ {isMe ? ( + <> + {viewCount && + (viewCount === 1 ? ( + {viewCount}]} + /> + ) : ( + {viewCount}]} + /> + ))} + {viewCount && replyCount && ' '} + {replyCount && + (replyCount === 1 ? ( + {replyCount}]} + /> + ) : ( + {replyCount}]} + /> + ))} + + ) : ( + canReply && ( + + ) + )} +
-
- {isMe ? ( - <> - {viewCount && - (viewCount === 1 ? ( - {viewCount}]} - /> - ) : ( - {viewCount}]} - /> - ))} - {viewCount && replyCount && ' '} - {replyCount && - (replyCount === 1 ? ( - {replyCount}]} - /> - ) : ( - {replyCount}]} - /> - ))} - - ) : ( - canReply && ( - - ) - )} -
+
{hasReplyModal && canReply && ( diff --git a/ts/util/getStoryBackground.ts b/ts/util/getStoryBackground.ts new file mode 100644 index 000000000..1e5601f5b --- /dev/null +++ b/ts/util/getStoryBackground.ts @@ -0,0 +1,40 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { AttachmentType, TextAttachmentType } from '../types/Attachment'; + +const COLOR_BLACK_ALPHA_90 = 'rgba(0, 0, 0, 0.9)'; +const COLOR_WHITE_INT = 4294704123; + +export function getHexFromNumber(color: number): string { + return `#${color.toString(16).slice(2)}`; +} + +export function getBackgroundColor({ + color, + gradient, +}: TextAttachmentType): string { + if (gradient) { + return `linear-gradient(${gradient.angle}deg, ${getHexFromNumber( + gradient.startColor || COLOR_WHITE_INT + )}, ${getHexFromNumber(gradient.endColor || COLOR_WHITE_INT)})`; + } + + return getHexFromNumber(color || COLOR_WHITE_INT); +} + +export function getStoryBackground(attachment?: AttachmentType): string { + if (!attachment) { + return COLOR_BLACK_ALPHA_90; + } + + if (attachment.textAttachment) { + return getBackgroundColor(attachment.textAttachment); + } + + if (attachment.url) { + return `url("${attachment.url}")`; + } + + return COLOR_BLACK_ALPHA_90; +} From 9973c661d02686346945aae7d683118ec51336bd Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Fri, 22 Apr 2022 14:45:45 -0400 Subject: [PATCH 06/53] Attach storyContext to group story replies --- ts/jobs/helpers/sendNormalMessage.ts | 1 + ts/jobs/helpers/sendReaction.ts | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/ts/jobs/helpers/sendNormalMessage.ts b/ts/jobs/helpers/sendNormalMessage.ts index 5d50d84ff..ed001f861 100644 --- a/ts/jobs/helpers/sendNormalMessage.ts +++ b/ts/jobs/helpers/sendNormalMessage.ts @@ -205,6 +205,7 @@ export async function sendNormalMessage( profileKey, quote, sticker, + storyContext, timestamp: messageTimestamp, mentions, }, diff --git a/ts/jobs/helpers/sendReaction.ts b/ts/jobs/helpers/sendReaction.ts index 6311c1441..bcc3155bf 100644 --- a/ts/jobs/helpers/sendReaction.ts +++ b/ts/jobs/helpers/sendReaction.ts @@ -22,7 +22,7 @@ import { getSendOptions } from '../../util/getSendOptions'; import { SignalService as Proto } from '../../protobuf'; import { handleMessageSend } from '../../util/handleMessageSend'; import { ourProfileKeyService } from '../../services/ourProfileKey'; -import { canReact } from '../../state/selectors/message'; +import { canReact, isStory } from '../../state/selectors/message'; import { findAndFormatContact } from '../../util/findAndFormatContact'; import { UUID } from '../../types/UUID'; import { handleMultipleSendErrors } from './handleMultipleSendErrors'; @@ -204,12 +204,6 @@ export async function sendReaction( return; } - let storyMessage: MessageModel | undefined; - const storyId = message.get('storyId'); - if (storyId) { - storyMessage = await getMessageById(storyId); - } - log.info('sending direct reaction message'); promise = window.textsecure.messaging.sendMessageToIdentifier({ identifier: recipientIdentifiersWithoutMe[0], @@ -226,10 +220,10 @@ export async function sendReaction( groupId: undefined, profileKey, options: sendOptions, - storyContext: storyMessage + storyContext: isStory(message.attributes) ? { - authorUuid: storyMessage.get('sourceUuid'), - timestamp: storyMessage.get('sent_at'), + authorUuid: message.get('sourceUuid'), + timestamp: message.get('sent_at'), } : undefined, }); @@ -261,6 +255,12 @@ export async function sendReaction( timestamp: pendingReaction.timestamp, expireTimer, profileKey, + storyContext: isStory(message.attributes) + ? { + authorUuid: message.get('sourceUuid'), + timestamp: message.get('sent_at'), + } + : undefined, }, messageId, sendOptions, From 7775f7d8066c8af28aaa509401266cd880a03037 Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Fri, 22 Apr 2022 12:02:23 -0700 Subject: [PATCH 07/53] Ignore PNI key upload errors for now --- ts/textsecure/AccountManager.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/ts/textsecure/AccountManager.ts b/ts/textsecure/AccountManager.ts index ec9cc90d2..a0b0fd267 100644 --- a/ts/textsecure/AccountManager.ts +++ b/ts/textsecure/AccountManager.ts @@ -310,8 +310,20 @@ export default class AccountManager extends EventTarget { kind ); - await this.server.registerKeys(keys, kind); - await this.confirmKeys(keys, kind); + try { + await this.server.registerKeys(keys, kind); + await this.confirmKeys(keys, kind); + } catch (error) { + if (kind === UUIDKind.PNI) { + log.error( + 'Failed to upload PNI prekeys. Moving on', + Errors.toLogFormat(error) + ); + return; + } + + throw error; + } }) ); } finally { From 72f979ea1d7f697313928782022b79b1bdb46ab1 Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Fri, 22 Apr 2022 23:16:13 -0400 Subject: [PATCH 08/53] Fix story reply box interactions --- _locales/en/messages.json | 4 + .../components/StoryViewsNRepliesModal.scss | 20 +- stylesheets/components/TextAttachment.scss | 1 - ts/components/StoryViewer.tsx | 92 ++++----- .../StoryViewsNRepliesModal.stories.tsx | 5 + ts/components/StoryViewsNRepliesModal.tsx | 184 ++++++++++-------- ts/util/lint/exceptions.json | 2 +- 7 files changed, 180 insertions(+), 128 deletions(-) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index f6c73bc2a..0c83b3fd7 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -7026,6 +7026,10 @@ "message": "Type a reply...", "description": "Placeholder text for the story reply modal" }, + "StoryViewsNRepliesModal__no-replies": { + "message": "No replies yet", + "description": "Placeholder text for when there are no replies" + }, "StoryViewsNRepliesModal__tab--views": { "message": "Views", "description": "Title for views tab" diff --git a/stylesheets/components/StoryViewsNRepliesModal.scss b/stylesheets/components/StoryViewsNRepliesModal.scss index a7989d385..b8e291c69 100644 --- a/stylesheets/components/StoryViewsNRepliesModal.scss +++ b/stylesheets/components/StoryViewsNRepliesModal.scss @@ -3,9 +3,27 @@ .StoryViewsNRepliesModal { min-width: 320px; + overflow: hidden; &--group { - min-height: 360px; + display: flex; + flex-direction: column; + min-height: 400px; + } + + &__replies { + flex: 1; + max-height: 75vh; + overflow-y: overlay; + + &--none { + align-items: center; + color: $color-gray-45; + display: flex; + flex: 1; + justify-content: center; + user-select: none; + } } &__overlay-container { diff --git a/stylesheets/components/TextAttachment.scss b/stylesheets/components/TextAttachment.scss index a4f48bc22..0fac48844 100644 --- a/stylesheets/components/TextAttachment.scss +++ b/stylesheets/components/TextAttachment.scss @@ -6,7 +6,6 @@ &__story { align-items: center; - border-radius: 12px; display: flex; flex-direction: column; height: 1280px; diff --git a/ts/components/StoryViewer.tsx b/ts/components/StoryViewer.tsx index 5dc369ad4..be95a03b3 100644 --- a/ts/components/StoryViewer.tsx +++ b/ts/components/StoryViewer.tsx @@ -60,6 +60,7 @@ export type PropsType = { replyState?: ReplyStateType; skinTone?: number; stories: Array; + views?: Array; }; const CAPTION_BUFFER = 20; @@ -88,6 +89,7 @@ export const StoryViewer = ({ replyState, skinTone, stories, + views, }: PropsType): JSX.Element => { const [currentStoryIndex, setCurrentStoryIndex] = useState(0); const [storyDuration, setStoryDuration] = useState(); @@ -268,7 +270,7 @@ export const StoryViewer = ({ const replies = replyState && replyState.messageId === messageId ? replyState.replies : []; - const viewCount = 0; + const viewCount = (views || []).length; const replyCount = replies.length; return ( @@ -388,49 +390,46 @@ export const StoryViewer = ({ ))}
- {isMe ? ( - <> - {viewCount && - (viewCount === 1 ? ( - {viewCount}]} - /> - ) : ( - {viewCount}]} - /> - ))} - {viewCount && replyCount && ' '} - {replyCount && - (replyCount === 1 ? ( - {replyCount}]} - /> - ) : ( - {replyCount}]} - /> - ))} - - ) : ( - canReply && ( - - ) + {canReply && ( + )}
@@ -454,13 +453,16 @@ export const StoryViewer = ({ authorTitle={title} getPreferredBadge={getPreferredBadge} i18n={i18n} + isGroupStory={isGroupStory} isMyStory={isMe} onClose={() => setHasReplyModal(false)} onReact={emoji => { onReactToStory(emoji, visibleStory); }} onReply={(message, mentions, replyTimestamp) => { - setHasReplyModal(false); + if (!isGroupStory) { + setHasReplyModal(false); + } onReplyToStory(message, mentions, replyTimestamp, visibleStory); }} onSetSkinTone={onSetSkinTone} diff --git a/ts/components/StoryViewsNRepliesModal.stories.tsx b/ts/components/StoryViewsNRepliesModal.stories.tsx index a7faa3fe3..72cf82721 100644 --- a/ts/components/StoryViewsNRepliesModal.stories.tsx +++ b/ts/components/StoryViewsNRepliesModal.stories.tsx @@ -112,12 +112,17 @@ story.add('Views only', () => ( /> )); +story.add('In a group (no replies)', () => ( + +)); + story.add('In a group', () => { const { views, replies } = getViewsAndReplies(); return ( diff --git a/ts/components/StoryViewsNRepliesModal.tsx b/ts/components/StoryViewsNRepliesModal.tsx index 6d1bfa16e..15e12120c 100644 --- a/ts/components/StoryViewsNRepliesModal.tsx +++ b/ts/components/StoryViewsNRepliesModal.tsx @@ -1,7 +1,7 @@ // Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import classNames from 'classnames'; import { usePopper } from 'react-popper'; import type { AttachmentType } from '../types/Attachment'; @@ -52,6 +52,7 @@ export type PropsType = { authorTitle: string; getPreferredBadge: PreferredBadgeSelectorType; i18n: LocalizerType; + isGroupStory?: boolean; isMyStory?: boolean; onClose: () => unknown; onReact: (emoji: string) => unknown; @@ -76,6 +77,7 @@ export const StoryViewsNRepliesModal = ({ authorTitle, getPreferredBadge, i18n, + isGroupStory, isMyStory, onClose, onReact, @@ -91,7 +93,8 @@ export const StoryViewsNRepliesModal = ({ storyPreviewAttachment, views, }: PropsType): JSX.Element => { - const inputApiRef = React.useRef(); + const inputApiRef = useRef(); + const [bottom, setBottom] = useState(null); const [messageBodyText, setMessageBodyText] = useState(''); const [showReactionPicker, setShowReactionPicker] = useState(false); @@ -122,13 +125,19 @@ export const StoryViewsNRepliesModal = ({ strategy: 'fixed', }); + useEffect(() => { + if (replies.length) { + bottom?.scrollIntoView({ behavior: 'smooth' }); + } + }, [bottom, replies.length]); + let composerElement: JSX.Element | undefined; if (!isMyStory) { composerElement = (
- {!replies.length && ( + {!isGroupStory && ( { + inputApiRef.current?.reset(); + onReply(...args); + }} onTextTooLong={onTextTooLong} placeholder={i18n('StoryViewsNRepliesModal__placeholder')} theme={ThemeType.dark} @@ -204,12 +216,48 @@ export const StoryViewsNRepliesModal = ({ ); } - const repliesElement = replies.length ? ( -
- {replies.map(reply => - reply.reactionEmoji ? ( -
-
+ let repliesElement: JSX.Element | undefined; + + if (replies.length) { + repliesElement = ( +
+ {replies.map(reply => + reply.reactionEmoji ? ( +
+
+ +
+
+ +
+ {i18n('StoryViewsNRepliesModal__reacted')} + +
+
+ +
+ ) : ( +
-
+
- {i18n('StoryViewsNRepliesModal__reacted')} + + +
- -
- ) : ( -
- -
-
- -
- - - - -
-
- ) - )} -
- ) : undefined; + ) + )} +
+
+ ); + } else if (isGroupStory) { + repliesElement = ( +
+ {i18n('StoryViewsNRepliesModal__no-replies')} +
+ ); + } const viewsElement = views.length ? (
@@ -358,28 +384,26 @@ export const StoryViewsNRepliesModal = ({ ) : undefined; - const hasOnlyViewsElement = - viewsElement && !repliesElement && !composerElement; - return ( - {tabsElement || ( - <> - {viewsElement} - {repliesElement} - {composerElement} - - )} +
+ {tabsElement || ( + <> + {viewsElement || repliesElement} + {composerElement} + + )} +
); }; diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 6bbb9cbc0..d071fdf1d 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -7702,7 +7702,7 @@ { "rule": "React-useRef", "path": "ts/components/StoryViewsNRepliesModal.tsx", - "line": " const inputApiRef = React.useRef();", + "line": " const inputApiRef = useRef();", "reasonCategory": "usageTrusted", "updated": "2022-02-15T17:57:06.507Z" }, From d8708e4e73bcc58d9f18ca4a535e4c9bc55bc489 Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Mon, 25 Apr 2022 13:25:50 -0400 Subject: [PATCH 09/53] Ensure that we resolve attachments before displaying them --- stylesheets/components/StoryImage.scss | 2 +- ts/components/StoryImage.tsx | 18 +- ts/components/StoryViewer.tsx | 5 + ts/components/TextAttachment.stories.tsx | 15 +- ts/components/TextAttachment.tsx | 40 +++-- ts/state/ducks/stories.ts | 77 +++++++- ts/test-electron/state/ducks/stories_test.ts | 178 +++++++++++++++++++ ts/types/Attachment.ts | 2 +- ts/util/getStoryDuration.ts | 13 +- 9 files changed, 319 insertions(+), 31 deletions(-) create mode 100644 ts/test-electron/state/ducks/stories_test.ts diff --git a/stylesheets/components/StoryImage.scss b/stylesheets/components/StoryImage.scss index a0a43b75d..c135a43ff 100644 --- a/stylesheets/components/StoryImage.scss +++ b/stylesheets/components/StoryImage.scss @@ -22,7 +22,7 @@ width: 100%; } - &__spinner-container { + &__overlay-container { align-items: center; display: flex; height: 100%; diff --git a/ts/components/StoryImage.tsx b/ts/components/StoryImage.tsx index ffc370f2b..2797cf233 100644 --- a/ts/components/StoryImage.tsx +++ b/ts/components/StoryImage.tsx @@ -40,7 +40,9 @@ export const StoryImage = ({ storyId, }: PropsType): JSX.Element | null => { const shouldDownloadAttachment = - !isDownloaded(attachment) && !isDownloading(attachment); + !isDownloaded(attachment) && + !isDownloading(attachment) && + !hasNotResolved(attachment); useEffect(() => { if (shouldDownloadAttachment) { @@ -61,7 +63,11 @@ export const StoryImage = ({ let storyElement: JSX.Element; if (attachment.textAttachment) { storyElement = ( - + ); } else if (isNotReadyToShow) { storyElement = ( @@ -98,10 +104,10 @@ export const StoryImage = ({ ); } - let spinner: JSX.Element | undefined; + let overlay: JSX.Element | undefined; if (isPending) { - spinner = ( -
+ overlay = ( +
@@ -117,7 +123,7 @@ export const StoryImage = ({ )} > {storyElement} - {spinner} + {overlay}
); }; diff --git a/ts/components/StoryViewer.tsx b/ts/components/StoryViewer.tsx index be95a03b3..54bc898de 100644 --- a/ts/components/StoryViewer.tsx +++ b/ts/components/StoryViewer.tsx @@ -198,6 +198,11 @@ export const StoryViewer = ({ // We need to be careful about this effect refreshing, it should only run // every time a story changes or its duration changes. useEffect(() => { + if (!storyDuration) { + spring.stop(); + return; + } + spring.start({ config: { duration: storyDuration, diff --git a/ts/components/TextAttachment.stories.tsx b/ts/components/TextAttachment.stories.tsx index 675d7f64f..650d94851 100644 --- a/ts/components/TextAttachment.stories.tsx +++ b/ts/components/TextAttachment.stories.tsx @@ -164,7 +164,20 @@ story.add('Link preview', () => ( preview: { url: 'https://www.signal.org/workworkwork', title: 'Signal >> Careers', - // TODO add image + }, + }} + /> +)); + +story.add('Link preview (thumbnail)', () => ( + > Careers', }, }} /> diff --git a/ts/components/TextAttachment.tsx b/ts/components/TextAttachment.tsx index 6a10a1dac..f30f23e7f 100644 --- a/ts/components/TextAttachment.tsx +++ b/ts/components/TextAttachment.tsx @@ -40,6 +40,7 @@ enum TextSize { export type PropsType = { i18n: LocalizerType; + isThumbnail?: boolean; textAttachment: TextAttachmentType; }; @@ -85,6 +86,7 @@ function getFont( export const TextAttachment = ({ i18n, + isThumbnail, textAttachment, }: PropsType): JSX.Element | null => { const linkPreview = useRef(null); @@ -149,25 +151,27 @@ export const TextAttachment = ({ )} {textAttachment.preview && ( <> - {linkPreviewOffsetTop && textAttachment.preview.url && ( - -
-
{i18n('TextAttachment__preview__link')}
-
-
- - )} +
+ + )}
{ +): ThunkAction< + void, + RootStateType, + unknown, + NoopActionType | ResolveAttachmentUrlActionType +> { return async dispatch => { const story = await getMessageById(storyId); @@ -226,6 +245,25 @@ function queueStoryDownload( } if (isDownloaded(attachment)) { + if (!attachment.path) { + return; + } + + // This function also resolves the attachment's URL in case we've already + // downloaded the attachment but haven't pointed its path to an absolute + // location on disk. + if (hasNotResolved(attachment)) { + dispatch({ + type: RESOLVE_ATTACHMENT_URL, + payload: { + messageId: storyId, + attachmentUrl: window.Signal.Migrations.getAbsoluteAttachmentPath( + attachment.path + ), + }, + }); + } + return; } @@ -500,5 +538,40 @@ export function reducer( }; } + if (action.type === RESOLVE_ATTACHMENT_URL) { + const { messageId, attachmentUrl } = action.payload; + + const storyIndex = state.stories.findIndex( + existingStory => existingStory.messageId === messageId + ); + + if (storyIndex < 0) { + return state; + } + + const story = state.stories[storyIndex]; + + if (!story.attachment) { + return state; + } + + const storyWithResolvedAttachment = { + ...story, + attachment: { + ...story.attachment, + url: attachmentUrl, + }, + }; + + return { + ...state, + stories: replaceIndex( + state.stories, + storyIndex, + storyWithResolvedAttachment + ), + }; + } + return state; } diff --git a/ts/test-electron/state/ducks/stories_test.ts b/ts/test-electron/state/ducks/stories_test.ts new file mode 100644 index 000000000..a769bf6de --- /dev/null +++ b/ts/test-electron/state/ducks/stories_test.ts @@ -0,0 +1,178 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as sinon from 'sinon'; +import path from 'path'; +import { assert } from 'chai'; +import { v4 as uuid } from 'uuid'; + +import type { StoriesStateType } from '../../../state/ducks/stories'; +import type { MessageAttributesType } from '../../../model-types.d'; +import { IMAGE_JPEG } from '../../../types/MIME'; +import { + actions, + getEmptyState, + reducer, + RESOLVE_ATTACHMENT_URL, +} from '../../../state/ducks/stories'; +import { noopAction } from '../../../state/ducks/noop'; +import { reducer as rootReducer } from '../../../state/reducer'; + +describe('both/state/ducks/stories', () => { + const getEmptyRootState = () => ({ + ...rootReducer(undefined, noopAction()), + stories: getEmptyState(), + }); + + function getStoryMessage(id: string): MessageAttributesType { + const now = Date.now(); + + return { + conversationId: uuid(), + id, + received_at: now, + sent_at: now, + timestamp: now, + type: 'story', + }; + } + + describe('queueStoryDownload', () => { + const { queueStoryDownload } = actions; + + it('no attachment, no dispatch', async function test() { + const storyId = uuid(); + const messageAttributes = getStoryMessage(storyId); + + window.MessageController.register(storyId, messageAttributes); + + const dispatch = sinon.spy(); + await queueStoryDownload(storyId)(dispatch, getEmptyRootState, null); + + sinon.assert.notCalled(dispatch); + }); + + it('downloading, no dispatch', async function test() { + const storyId = uuid(); + const messageAttributes = { + ...getStoryMessage(storyId), + attachments: [ + { + contentType: IMAGE_JPEG, + downloadJobId: uuid(), + pending: true, + size: 0, + }, + ], + }; + + window.MessageController.register(storyId, messageAttributes); + + const dispatch = sinon.spy(); + await queueStoryDownload(storyId)(dispatch, getEmptyRootState, null); + + sinon.assert.notCalled(dispatch); + }); + + it('downloaded, no dispatch', async function test() { + const storyId = uuid(); + const messageAttributes = { + ...getStoryMessage(storyId), + attachments: [ + { + contentType: IMAGE_JPEG, + path: 'image.jpg', + url: '/path/to/image.jpg', + size: 0, + }, + ], + }; + + window.MessageController.register(storyId, messageAttributes); + + const dispatch = sinon.spy(); + await queueStoryDownload(storyId)(dispatch, getEmptyRootState, null); + + sinon.assert.notCalled(dispatch); + }); + + it('downloaded, but unresolved, we should resolve the path', async function test() { + const storyId = uuid(); + const attachment = { + contentType: IMAGE_JPEG, + path: 'image.jpg', + size: 0, + }; + const messageAttributes = { + ...getStoryMessage(storyId), + attachments: [attachment], + }; + + window.MessageController.register(storyId, messageAttributes); + + const dispatch = sinon.spy(); + await queueStoryDownload(storyId)(dispatch, getEmptyRootState, null); + + const action = dispatch.getCall(0).args[0]; + + sinon.assert.calledWith(dispatch, { + type: RESOLVE_ATTACHMENT_URL, + payload: { + messageId: storyId, + attachmentUrl: action.payload.attachmentUrl, + }, + }); + assert.equal( + attachment.path, + path.basename(action.payload.attachmentUrl) + ); + + const stateWithStory: StoriesStateType = { + ...getEmptyRootState().stories, + stories: [ + { + ...messageAttributes, + messageId: storyId, + attachment, + }, + ], + }; + + const nextState = reducer(stateWithStory, action); + assert.isDefined(nextState.stories); + assert.equal( + nextState.stories[0].attachment?.url, + action.payload.attachmentUrl + ); + + const state = getEmptyRootState().stories; + + const sameState = reducer(state, action); + assert.isDefined(sameState.stories); + assert.equal(sameState, state); + }); + + it('not downloaded, queued for download', async function test() { + const storyId = uuid(); + const messageAttributes = { + ...getStoryMessage(storyId), + attachments: [ + { + contentType: IMAGE_JPEG, + size: 0, + }, + ], + }; + + window.MessageController.register(storyId, messageAttributes); + + const dispatch = sinon.spy(); + await queueStoryDownload(storyId)(dispatch, getEmptyRootState, null); + + sinon.assert.calledWith(dispatch, { + type: 'NOOP', + payload: null, + }); + }); + }); +}); diff --git a/ts/types/Attachment.ts b/ts/types/Attachment.ts index 8a5d001ee..f3bf88edc 100644 --- a/ts/types/Attachment.ts +++ b/ts/types/Attachment.ts @@ -730,7 +730,7 @@ export function isDownloaded(attachment?: AttachmentType): boolean { } export function hasNotResolved(attachment?: AttachmentType): boolean { - return Boolean(attachment && !attachment.url); + return Boolean(attachment && !attachment.url && !attachment.textAttachment); } export function isDownloading(attachment?: AttachmentType): boolean { diff --git a/ts/util/getStoryDuration.ts b/ts/util/getStoryDuration.ts index 43c9b6b80..7d6fda9b0 100644 --- a/ts/util/getStoryDuration.ts +++ b/ts/util/getStoryDuration.ts @@ -2,7 +2,12 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { AttachmentType } from '../types/Attachment'; -import { isGIF, isVideo } from '../types/Attachment'; +import { + hasNotResolved, + isDownloaded, + isGIF, + isVideo, +} from '../types/Attachment'; import { count } from './grapheme'; import { SECOND } from './durations'; @@ -12,7 +17,11 @@ const MIN_TEXT_DURATION = 3 * SECOND; export async function getStoryDuration( attachment: AttachmentType -): Promise { +): Promise { + if (!isDownloaded(attachment) || hasNotResolved(attachment)) { + return; + } + if (isGIF([attachment]) || isVideo([attachment])) { const videoEl = document.createElement('video'); if (!attachment.url) { From 5e0534310e9c6120aab41925f5d0d624dffaad00 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Mon, 25 Apr 2022 10:26:45 -0700 Subject: [PATCH 10/53] Message.tsx: Don't handle clicks outside of container --- ts/components/conversation/Message.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 88ca0cc04..b47159999 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -2668,6 +2668,10 @@ export class Message extends React.PureComponent { className={containerClassnames} style={containerStyles} onContextMenu={this.showContextMenu} + role="row" + onKeyDown={this.handleKeyDown} + onClick={this.handleClick} + tabIndex={-1} > {this.renderAuthor()} {this.renderContents()} @@ -2717,7 +2721,6 @@ export class Message extends React.PureComponent { // cannot be within another button role="button" onKeyDown={this.handleKeyDown} - onClick={this.handleClick} onFocus={this.handleFocus} ref={this.focusRef} > From fd610a630068c6f852be9775f55eb92aedca1689 Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Mon, 25 Apr 2022 13:54:13 -0700 Subject: [PATCH 11/53] Don't post extraneous verified change notification --- ts/models/conversations.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 3cd8ecb93..20d6b8231 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -2659,17 +2659,17 @@ export class ConversationModel extends window.Backbone } } - const didSomethingChange = keyChange || beginningVerified !== verified; + const didVerifiedChange = beginningVerified !== verified; const isExplicitUserAction = !options.viaStorageServiceSync; const shouldShowFromStorageSync = options.viaStorageServiceSync && verified !== UNVERIFIED; if ( - // The message came from an explicit verification in a client (not a contact sync - // or storage service sync) - (didSomethingChange && isExplicitUserAction) || - // The verification value received by the contact sync is different from what we + // The message came from an explicit verification in a client (not + // storage service sync) + (didVerifiedChange && isExplicitUserAction) || + // The verification value received by the storage sync is different from what we // have on record (and it's not a transition to UNVERIFIED) - (didSomethingChange && shouldShowFromStorageSync) || + (didVerifiedChange && shouldShowFromStorageSync) || // Our local verification status is VERIFIED and it hasn't changed, but the key did // change (Key1/VERIFIED -> Key2/VERIFIED), but we don't want to show DEFAULT -> // DEFAULT or UNVERIFIED -> UNVERIFIED From 40f16b98e2fe6303a5d98f7348b6f3565f13e9df Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Mon, 25 Apr 2022 13:56:59 -0700 Subject: [PATCH 12/53] Transcode link preview images --- ts/linkPreviews/linkPreviewFetch.ts | 17 ++++++++++ .../linkPreviews/linkPreviewFetch_test.ts | 34 +++++++++---------- 2 files changed, 33 insertions(+), 18 deletions(-) diff --git a/ts/linkPreviews/linkPreviewFetch.ts b/ts/linkPreviews/linkPreviewFetch.ts index 2c07ec255..98942f983 100644 --- a/ts/linkPreviews/linkPreviewFetch.ts +++ b/ts/linkPreviews/linkPreviewFetch.ts @@ -3,6 +3,7 @@ import type { RequestInit, Response } from 'node-fetch'; import type { AbortSignal as AbortSignalForNodeFetch } from 'abort-controller'; +import { blobToArrayBuffer } from 'blob-util'; import type { MIMEType } from '../types/MIME'; import { @@ -14,6 +15,7 @@ import { stringToMIMEType, } from '../types/MIME'; import type { LoggerType } from '../types/Logging'; +import { scaleImageToLevel } from '../util/scaleImageToLevel'; import * as log from '../logging/log'; const USER_AGENT = 'WhatsApp/2'; @@ -603,5 +605,20 @@ export async function fetchLinkPreviewImage( return null; } + // Scale link preview image + if (contentType !== IMAGE_GIF) { + const dataBlob = new Blob([data], { + type: contentType, + }); + const { blob: xcodedDataBlob } = await scaleImageToLevel( + dataBlob, + contentType, + false + ); + const xcodedDataArrayBuffer = await blobToArrayBuffer(xcodedDataBlob); + + data = new Uint8Array(xcodedDataArrayBuffer); + } + return { data, contentType }; } diff --git a/ts/test-electron/linkPreviews/linkPreviewFetch_test.ts b/ts/test-electron/linkPreviews/linkPreviewFetch_test.ts index 563e9418b..8681f9407 100644 --- a/ts/test-electron/linkPreviews/linkPreviewFetch_test.ts +++ b/ts/test-electron/linkPreviews/linkPreviewFetch_test.ts @@ -1151,15 +1151,14 @@ describe('link preview fetching', () => { ); assert.deepEqual( - await fetchLinkPreviewImage( - fakeFetch, - 'https://example.com/img', - new AbortController().signal - ), - { - data: fixture, - contentType: stringToMIMEType(contentType), - } + ( + await fetchLinkPreviewImage( + fakeFetch, + 'https://example.com/img', + new AbortController().signal + ) + )?.contentType, + stringToMIMEType(contentType) ); }); }); @@ -1238,15 +1237,14 @@ describe('link preview fetching', () => { ); assert.deepEqual( - await fetchLinkPreviewImage( - fakeFetch, - 'https://example.com/img', - new AbortController().signal - ), - { - data: fixture, - contentType: IMAGE_JPEG, - } + ( + await fetchLinkPreviewImage( + fakeFetch, + 'https://example.com/img', + new AbortController().signal + ) + )?.contentType, + IMAGE_JPEG ); sinon.assert.calledTwice(fakeFetch); From 5f3a62cbb653a59d270816a9f1f278bcc0425cf2 Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Mon, 25 Apr 2022 13:58:01 -0700 Subject: [PATCH 13/53] Update to electron@18.1.0 --- .github/workflows/benchmark.yml | 2 +- .github/workflows/ci.yml | 10 +++++----- .nvmrc | 2 +- app/main.ts | 1 - package.json | 6 +++--- yarn.lock | 20 ++++++++++---------- 6 files changed, 20 insertions(+), 21 deletions(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index ff255c684..09b3b7ed0 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -27,7 +27,7 @@ jobs: - name: Setup node.js uses: actions/setup-node@v2 with: - node-version: '16.13.0' + node-version: '16.13.2' - name: Install global dependencies run: npm install -g yarn@1.22.10 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8718926fc..286d2f0ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-node@v2 with: - node-version: '16.13.0' + node-version: '16.13.2' - run: npm install -g yarn@1.22.10 - name: Cache Desktop node_modules @@ -50,7 +50,7 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-node@v2 with: - node-version: '16.13.0' + node-version: '16.13.2' - run: npm install -g yarn@1.22.10 - name: Cache Desktop node_modules @@ -88,7 +88,7 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-node@v2 with: - node-version: '16.13.0' + node-version: '16.13.2' - run: sudo apt-get install xvfb - run: npm install -g yarn@1.22.10 @@ -129,7 +129,7 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-node@v2 with: - node-version: '16.13.0' + node-version: '16.13.2' - run: npm install -g yarn@1.22.10 - name: Cache Desktop node_modules @@ -175,7 +175,7 @@ jobs: - name: Setup node.js uses: actions/setup-node@v2 with: - node-version: '16.13.0' + node-version: '16.13.2' - name: Install global dependencies run: npm install -g yarn@1.22.10 diff --git a/.nvmrc b/.nvmrc index 58a4133d9..23d9c36a1 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -16.13.0 +16.13.2 diff --git a/app/main.ts b/app/main.ts index 7e0df7dc4..518a4c98f 100644 --- a/app/main.ts +++ b/app/main.ts @@ -489,7 +489,6 @@ async function createWindow() { __dirname, usePreloadBundle ? '../preload.bundle.js' : '../preload.js' ), - nativeWindowOpen: true, spellcheck: await getSpellCheckSetting(), backgroundThrottling: isThrottlingEnabled, enablePreferredSizeMode: true, diff --git a/package.json b/package.json index 8cc4b00d0..4e34a2c81 100644 --- a/package.json +++ b/package.json @@ -221,7 +221,7 @@ "@types/mkdirp": "0.5.2", "@types/mocha": "9.0.0", "@types/mustache": "4.1.2", - "@types/node": "16.11.26", + "@types/node": "16.11.29", "@types/node-fetch": "2.5.7", "@types/node-forge": "0.9.5", "@types/normalize-path": "3.0.0", @@ -264,7 +264,7 @@ "cross-env": "5.2.0", "css-loader": "3.2.0", "debug": "4.3.3", - "electron": "17.3.1", + "electron": "18.1.0", "electron-builder": "23.0.1", "electron-mocha": "11.0.2", "electron-notarize": "0.1.1", @@ -309,7 +309,7 @@ "sharp/color/color-string": "1.7.4" }, "engines": { - "node": "16.13.0" + "node": "16.13.2" }, "build": { "appId": "org.whispersystems.signal-desktop", diff --git a/yarn.lock b/yarn.lock index 5f81c4346..d5905398d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2259,15 +2259,15 @@ dependencies: "@types/node" "*" -"@types/node@*", "@types/node@^14.6.2": +"@types/node@*": version "14.14.37" resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.37.tgz#a3dd8da4eb84a996c36e331df98d82abd76b516e" integrity sha512-XYmBiy+ohOR4Lh5jE379fV2IU+6Jn4g5qASinhitfyO71b/sCo6MKsMLF5tc7Zf2CE8hViVQyYSobJNke8OvUw== -"@types/node@16.11.26": - version "16.11.26" - resolved "https://registry.yarnpkg.com/@types/node/-/node-16.11.26.tgz#63d204d136c9916fb4dcd1b50f9740fe86884e47" - integrity sha512-GZ7bu5A6+4DtG7q9GsoHXy3ALcgeIHP4NnL0Vv2wu0uUB/yQex26v0tf6/na1mm0+bS9Uw+0DFex7aaKr2qawQ== +"@types/node@16.11.29", "@types/node@^16.11.26": + version "16.11.29" + resolved "https://registry.yarnpkg.com/@types/node/-/node-16.11.29.tgz#2422b0bf46afb2568dc71df903afa36f56bab8ea" + integrity sha512-9dDdonLyPJQJ/kdOlDxAah+bTI+u2ccF3k62FErhquDuggoCX6piWez7j7o6yNE+rP2IRcZVQ6Tw4N0P38+rWA== "@types/node@>=13.7.0": version "17.0.17" @@ -6174,13 +6174,13 @@ electron-window@^0.8.0: dependencies: is-electron-renderer "^2.0.0" -electron@17.3.1: - version "17.3.1" - resolved "https://registry.yarnpkg.com/electron/-/electron-17.3.1.tgz#083b6bd034eb1ee7d75378316d6460348eb62605" - integrity sha512-C5E3uvXo1cmI+xYtbiMCW8AAGhBL0HbLA6cqD7FJmBoPtY88W/3A/km5z8oPGORyBNgSe7tSoHx4a6jWJIR+og== +electron@18.1.0: + version "18.1.0" + resolved "https://registry.yarnpkg.com/electron/-/electron-18.1.0.tgz#d92b76f301af1a8728adff8d6eeb42382e218fe8" + integrity sha512-P55wdHNTRMo7a/agC84ZEZDYEK/pTBcQdlp8lFbHcx3mO4Kr+Im/J5p2uQgiuXtown31HqNh2paL3V0p+E6rpQ== dependencies: "@electron/get" "^1.13.0" - "@types/node" "^14.6.2" + "@types/node" "^16.11.26" extract-zip "^1.0.3" elliptic@^6.0.0: From 2f44e33c9cbaf540ec2c7792b5eb90e1b1a5a14b Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Mon, 25 Apr 2022 17:03:24 -0400 Subject: [PATCH 14/53] Move link notification to the conversation hero --- stylesheets/_modules.scss | 97 -------------- stylesheets/components/ConversationHero.scss | 125 ++++++++++++++++++ stylesheets/components/SystemMessage.scss | 7 - stylesheets/manifest.scss | 1 + ts/background.ts | 16 --- .../conversation/ConversationHero.tsx | 3 + .../conversation/LinkNotification.stories.tsx | 16 --- .../conversation/LinkNotification.tsx | 13 -- .../conversation/Timeline.stories.tsx | 5 - .../conversation/TimelineItem.stories.tsx | 4 - ts/components/conversation/TimelineItem.tsx | 8 -- ts/model-types.d.ts | 1 - ts/models/conversations.ts | 34 ----- ts/models/messages.ts | 4 - ts/services/storageRecordOps.ts | 10 -- ts/sql/migrations/56-add-unseen-to-message.ts | 2 +- .../57-rm-message-history-unsynced.ts | 29 ++++ ts/sql/migrations/index.ts | 2 + ts/state/selectors/message.ts | 15 --- ts/state/smart/InstallScreen.tsx | 4 +- ts/test-both/util/timelineUtil_test.ts | 30 ----- .../sql/conversationSummary_test.ts | 48 ++----- ts/types/Message.ts | 11 +- .../message/initializeAttachmentMetadata.ts | 5 +- ts/util/index.ts | 2 - ts/util/postLinkExperience.ts | 32 ----- 26 files changed, 174 insertions(+), 350 deletions(-) create mode 100644 stylesheets/components/ConversationHero.scss delete mode 100644 ts/components/conversation/LinkNotification.stories.tsx delete mode 100644 ts/components/conversation/LinkNotification.tsx create mode 100644 ts/sql/migrations/57-rm-message-history-unsynced.ts delete mode 100644 ts/util/postLinkExperience.ts diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index a9003c1c6..31006e96c 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -2132,103 +2132,6 @@ button.ConversationDetails__action-button { color: $color-gray-45; } -// Module: Conversation Hero - -.module-conversation-hero { - padding: 32px 0 28px 0; - text-align: center; - - &__avatar { - margin-bottom: 12px; - } - - &__profile-name { - @include font-title-2; - margin-bottom: 2px; - - @include light-theme { - color: $color-gray-90; - } - - @include dark-theme { - color: $color-gray-05; - } - } - - &__with { - @include font-body-2; - margin: 0 auto; - margin-bottom: 16px; - max-width: 500px; - - @include light-theme { - color: $color-gray-60; - } - - @include dark-theme { - color: $color-gray-25; - } - } - - &__membership { - @include font-body-2; - - padding: 0 16px; - - @include light-theme { - color: $color-gray-60; - } - - @include dark-theme { - color: $color-gray-25; - } - - &__name { - @include font-body-2-bold; - } - } - - &__message-request-warning { - @include font-body-2; - - &__message { - display: flex; - margin-bottom: 12px; - align-items: center; - justify-content: center; - user-select: none; - - @include light-theme { - color: $color-gray-60; - } - @include dark-theme { - color: $color-gray-25; - } - - &::before { - content: ''; - display: block; - height: 14px; - margin-right: 8px; - width: 14px; - - @include light-theme { - @include color-svg( - '../images/icons/v2/info-outline-24.svg', - $color-gray-60 - ); - } - @include dark-theme { - @include color-svg( - '../images/icons/v2/info-solid-24.svg', - $color-gray-25 - ); - } - } - } - } -} - // Module: Message Request Actions .module-message-request-actions { diff --git a/stylesheets/components/ConversationHero.scss b/stylesheets/components/ConversationHero.scss new file mode 100644 index 000000000..9a038bc5f --- /dev/null +++ b/stylesheets/components/ConversationHero.scss @@ -0,0 +1,125 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.module-conversation-hero { + padding: 32px 0 28px 0; + text-align: center; + + &__avatar { + margin-bottom: 12px; + } + + &__profile-name { + @include font-title-2; + margin-bottom: 2px; + + @include light-theme { + color: $color-gray-90; + } + + @include dark-theme { + color: $color-gray-05; + } + } + + &__with { + @include font-body-2; + margin: 0 auto; + margin-bottom: 16px; + max-width: 500px; + + @include light-theme { + color: $color-gray-60; + } + + @include dark-theme { + color: $color-gray-25; + } + } + + &__membership { + @include font-body-2; + + padding: 0 16px; + + @include light-theme { + color: $color-gray-60; + } + + @include dark-theme { + color: $color-gray-25; + } + + &__name { + @include font-body-2-bold; + } + } + + &__message-request-warning { + @include font-body-2; + + &__message { + display: flex; + margin-bottom: 12px; + align-items: center; + justify-content: center; + user-select: none; + + @include light-theme { + color: $color-gray-60; + } + @include dark-theme { + color: $color-gray-25; + } + + &::before { + content: ''; + display: block; + height: 14px; + margin-right: 8px; + width: 14px; + + @include light-theme { + @include color-svg( + '../images/icons/v2/info-outline-24.svg', + $color-gray-60 + ); + } + @include dark-theme { + @include color-svg( + '../images/icons/v2/info-solid-24.svg', + $color-gray-25 + ); + } + } + } + } + + &__linkNotification { + @include font-body-2; + text-align: center; + user-select: none; + + &::before { + content: ''; + display: inline-block; + height: 16px; + margin-right: 8px; + vertical-align: middle; + width: 16px; + + @include light-theme { + @include color-svg( + '../images/icons/v2/info-outline-24.svg', + $color-gray-60 + ); + } + @include dark-theme { + @include color-svg( + '../images/icons/v2/info-solid-24.svg', + $color-gray-25 + ); + } + } + } +} diff --git a/stylesheets/components/SystemMessage.scss b/stylesheets/components/SystemMessage.scss index cedc9beb8..231a8a273 100644 --- a/stylesheets/components/SystemMessage.scss +++ b/stylesheets/components/SystemMessage.scss @@ -219,13 +219,6 @@ ); } - &--icon-unsynced::before { - @include system-message-icon( - '../images/icons/v2/info-outline-24.svg', - '../images/icons/v2/info-solid-24.svg' - ); - } - &--icon-verified::before { @include system-message-icon( '../images/icons/v2/check-24.svg', diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 9f04d2440..abc3e9854 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -57,6 +57,7 @@ @import './components/ContextMenu.scss'; @import './components/ConversationDetails.scss'; @import './components/ConversationHeader.scss'; +@import './components/ConversationHero.scss'; @import './components/ConversationView.scss'; @import './components/CustomColorEditor.scss'; @import './components/CustomizingPreferredReactionsModal.scss'; diff --git a/ts/background.ts b/ts/background.ts index c287c22d1..633edd1ac 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -2178,9 +2178,6 @@ export async function startApp(): Promise { ); } - log.info('firstRun: disabling post link experience'); - window.Signal.Util.postLinkExperience.stop(); - // Switch to inbox view even if contact sync is still running if ( window.reduxStore.getState().app.appView === AppViewType.Installer @@ -2647,13 +2644,6 @@ export async function startApp(): Promise { } ); } - - if (window.Signal.Util.postLinkExperience.isActive()) { - log.info( - 'onContactReceived: Adding the message history disclaimer on link' - ); - await conversation.addMessageHistoryDisclaimer(); - } } catch (error) { log.error('onContactReceived error:', Errors.toLogFormat(error)); } @@ -2722,12 +2712,6 @@ export async function startApp(): Promise { window.Signal.Data.updateConversation(conversation.attributes); - if (window.Signal.Util.postLinkExperience.isActive()) { - log.info( - 'onGroupReceived: Adding the message history disclaimer on link' - ); - await conversation.addMessageHistoryDisclaimer(); - } const { expireTimer } = details; const isValidExpireTimer = typeof expireTimer === 'number'; if (!isValidExpireTimer) { diff --git a/ts/components/conversation/ConversationHero.tsx b/ts/components/conversation/ConversationHero.tsx index 9459a35ea..afff7d68c 100644 --- a/ts/components/conversation/ConversationHero.tsx +++ b/ts/components/conversation/ConversationHero.tsx @@ -204,6 +204,9 @@ export const ConversationHero = ({ phoneNumber, sharedGroupNames, })} +
+ {i18n('messageHistoryUnsynced')} +
{isShowingMessageRequestWarning && ( ); diff --git a/ts/components/conversation/LinkNotification.tsx b/ts/components/conversation/LinkNotification.tsx deleted file mode 100644 index d5a667daf..000000000 --- a/ts/components/conversation/LinkNotification.tsx +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright 2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import * as React from 'react'; - -import { SystemMessage } from './SystemMessage'; -import type { LocalizerType } from '../../types/Util'; - -export const LinkNotification = ({ - i18n, -}: Readonly<{ i18n: LocalizerType }>): JSX.Element => ( - -); diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index 8e600afc0..985f5771b 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -337,11 +337,6 @@ const items: Record = { }, timestamp: Date.now(), }, - 'id-15': { - type: 'linkNotification', - data: null, - timestamp: Date.now(), - }, }; const actions = () => ({ diff --git a/ts/components/conversation/TimelineItem.stories.tsx b/ts/components/conversation/TimelineItem.stories.tsx index 2fe8961bd..f1eb2444a 100644 --- a/ts/components/conversation/TimelineItem.stories.tsx +++ b/ts/components/conversation/TimelineItem.stories.tsx @@ -417,10 +417,6 @@ storiesOf('Components/Conversation/TimelineItem', module) startedTime: Date.now(), }, }, - { - type: 'linkNotification', - data: null, - }, { type: 'profileChange', data: { diff --git a/ts/components/conversation/TimelineItem.tsx b/ts/components/conversation/TimelineItem.tsx index 35dd1b512..7fbcedca4 100644 --- a/ts/components/conversation/TimelineItem.tsx +++ b/ts/components/conversation/TimelineItem.tsx @@ -23,7 +23,6 @@ import type { PropsDataType as DeliveryIssueProps, } from './DeliveryIssueNotification'; import { DeliveryIssueNotification } from './DeliveryIssueNotification'; -import { LinkNotification } from './LinkNotification'; import type { PropsData as ChangeNumberNotificationProps } from './ChangeNumberNotification'; import { ChangeNumberNotification } from './ChangeNumberNotification'; import type { CallingNotificationType } from '../../util/callingNotification'; @@ -69,10 +68,6 @@ type DeliveryIssueType = { type: 'deliveryIssue'; data: DeliveryIssueProps; }; -type LinkNotificationType = { - type: 'linkNotification'; - data: null; -}; type MessageType = { type: 'message'; data: Omit; @@ -129,7 +124,6 @@ export type TimelineItemType = ( | GroupNotificationType | GroupV1MigrationType | GroupV2ChangeType - | LinkNotificationType | MessageType | ProfileChangeNotificationType | ResetSessionNotificationType @@ -261,8 +255,6 @@ export class TimelineItem extends React.PureComponent { i18n={i18n} /> ); - } else if (item.type === 'linkNotification') { - notification = ; } else if (item.type === 'timerNotification') { notification = ( diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index ac8ccb964..0ae7bd988 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -171,7 +171,6 @@ export type MessageAttributesType = { | 'group-v2-change' | 'incoming' | 'keychange' - | 'message-history-unsynced' | 'outgoing' | 'profile-change' | 'story' diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 20d6b8231..d11e9363e 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -219,8 +219,6 @@ export class ConversationModel extends window.Backbone private isFetchingUUID?: boolean; - private hasAddedHistoryDisclaimer?: boolean; - private lastIsTyping?: boolean; private muteTimer?: NodeJS.Timer; @@ -4578,38 +4576,6 @@ export class ConversationModel extends window.Backbone return message; } - async addMessageHistoryDisclaimer(): Promise { - const timestamp = Date.now(); - - if (this.hasAddedHistoryDisclaimer) { - log.warn( - `addMessageHistoryDisclaimer/${this.idForLogging()}: Refusing to add another this session` - ); - return; - } - this.hasAddedHistoryDisclaimer = true; - - const model = new window.Whisper.Message({ - type: 'message-history-unsynced', - readStatus: ReadStatus.Read, - seenStatus: SeenStatus.NotApplicable, - conversationId: this.id, - sent_at: timestamp, - received_at: window.Signal.Util.incrementMessageCounter(), - received_at_ms: timestamp, - // TODO: DESKTOP-722 - } as unknown as MessageAttributesType); - - const id = await window.Signal.Data.saveMessage(model.attributes, { - ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), - }); - - model.set({ id }); - - const message = window.MessageController.register(id, model); - this.addSingleMessage(message); - } - isSearchable(): boolean { return !this.get('left'); } diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 81b3caaee..ddd13c0ef 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -91,7 +91,6 @@ import { isGroupV2Change, isIncoming, isKeyChange, - isMessageHistoryUnsynced, isOutgoing, isStory, isProfileChange, @@ -295,7 +294,6 @@ export class MessageModel extends window.Backbone.Model { !isGroupV2Change(attributes) && !isGroupV1Migration(attributes) && !isKeyChange(attributes) && - !isMessageHistoryUnsynced(attributes) && !isProfileChange(attributes) && !isUniversalTimerNotification(attributes) && !isUnsupportedMessage(attributes) && @@ -1056,7 +1054,6 @@ export class MessageModel extends window.Backbone.Model { // Locally-generated notifications const isKeyChangeValue = isKeyChange(attributes); - const isMessageHistoryUnsyncedValue = isMessageHistoryUnsynced(attributes); const isProfileChangeValue = isProfileChange(attributes); const isUniversalTimerNotificationValue = isUniversalTimerNotification(attributes); @@ -1085,7 +1082,6 @@ export class MessageModel extends window.Backbone.Model { hasErrorsValue || // Locally-generated notifications isKeyChangeValue || - isMessageHistoryUnsyncedValue || isProfileChangeValue || isUniversalTimerNotificationValue; diff --git a/ts/services/storageRecordOps.ts b/ts/services/storageRecordOps.ts index 813fae98a..0b50b3fac 100644 --- a/ts/services/storageRecordOps.ts +++ b/ts/services/storageRecordOps.ts @@ -1111,16 +1111,6 @@ export async function mergeAccountRecord( remotelyPinnedConversations.forEach(conversation => { conversation.set({ isPinned: true, isArchived: false }); - - if ( - window.Signal.Util.postLinkExperience.isActive() && - isGroupV2(conversation.attributes) - ) { - log.info( - 'mergeAccountRecord: Adding the message history disclaimer on link' - ); - conversation.addMessageHistoryDisclaimer(); - } updatedConversations.push(conversation); }); diff --git a/ts/sql/migrations/56-add-unseen-to-message.ts b/ts/sql/migrations/56-add-unseen-to-message.ts index 4a6927e38..1b7f7704a 100644 --- a/ts/sql/migrations/56-add-unseen-to-message.ts +++ b/ts/sql/migrations/56-add-unseen-to-message.ts @@ -1,4 +1,4 @@ -// Copyright 2021 Signal Messenger, LLC +// Copyright 2021-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { Database } from 'better-sqlite3'; diff --git a/ts/sql/migrations/57-rm-message-history-unsynced.ts b/ts/sql/migrations/57-rm-message-history-unsynced.ts new file mode 100644 index 000000000..a49c24708 --- /dev/null +++ b/ts/sql/migrations/57-rm-message-history-unsynced.ts @@ -0,0 +1,29 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { Database } from 'better-sqlite3'; + +import type { LoggerType } from '../../types/Logging'; + +export default function updateToSchemaVersion57( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 57) { + return; + } + + db.transaction(() => { + db.exec( + ` + DELETE FROM messages + WHERE type IS 'message-history-unsynced'; + ` + ); + + db.pragma('user_version = 57'); + })(); + + logger.info('updateToSchemaVersion57: success!'); +} diff --git a/ts/sql/migrations/index.ts b/ts/sql/migrations/index.ts index 4f9196299..2e3ab1bb4 100644 --- a/ts/sql/migrations/index.ts +++ b/ts/sql/migrations/index.ts @@ -32,6 +32,7 @@ import updateToSchemaVersion53 from './53-gv2-banned-members'; import updateToSchemaVersion54 from './54-unprocessed-received-at-counter'; import updateToSchemaVersion55 from './55-report-message-aci'; import updateToSchemaVersion56 from './56-add-unseen-to-message'; +import updateToSchemaVersion57 from './57-rm-message-history-unsynced'; function updateToSchemaVersion1( currentVersion: number, @@ -1927,6 +1928,7 @@ export const SCHEMA_VERSIONS = [ updateToSchemaVersion54, updateToSchemaVersion55, updateToSchemaVersion56, + updateToSchemaVersion57, ]; export function updateSchema(db: Database, logger: LoggerType): void { diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index 85a542275..ab08f0d35 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -785,13 +785,6 @@ export function getPropsForBubble( timestamp, }; } - if (isMessageHistoryUnsynced(message)) { - return { - type: 'linkNotification', - data: null, - timestamp, - }; - } if (isExpirationTimerUpdate(message)) { return { type: 'timerNotification', @@ -984,14 +977,6 @@ function getPropsForGroupV1Migration( }; } -// Message History Unsynced - -export function isMessageHistoryUnsynced( - message: MessageWithUIFieldsType -): boolean { - return message.type === 'message-history-unsynced'; -} - // Note: props are null! // Expiration Timer Update diff --git a/ts/state/smart/InstallScreen.tsx b/ts/state/smart/InstallScreen.tsx index 1a0fbb9c2..372288aee 100644 --- a/ts/state/smart/InstallScreen.tsx +++ b/ts/state/smart/InstallScreen.tsx @@ -1,4 +1,4 @@ -// Copyright 2021 Signal Messenger, LLC +// Copyright 2021-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { ComponentProps, ReactElement } from 'react'; @@ -193,8 +193,6 @@ export function SmartInstallScreen(): ReactElement { throw new Error('Cannot confirm number; the component was unmounted'); } - window.Signal.Util.postLinkExperience.start(); - return result; }; diff --git a/ts/test-both/util/timelineUtil_test.ts b/ts/test-both/util/timelineUtil_test.ts index 4c1984abc..5dfdfbb9e 100644 --- a/ts/test-both/util/timelineUtil_test.ts +++ b/ts/test-both/util/timelineUtil_test.ts @@ -40,24 +40,6 @@ describe(' utilities', () => { assert.isFalse(areMessagesInSameGroup(undefined, false, defaultNewer)); }); - it('returns false if either item is not a message', () => { - const linkNotification = { - type: 'linkNotification' as const, - data: null, - timestamp: Date.now(), - }; - - assert.isFalse( - areMessagesInSameGroup(defaultNewer, false, linkNotification) - ); - assert.isFalse( - areMessagesInSameGroup(linkNotification, false, defaultNewer) - ); - assert.isFalse( - areMessagesInSameGroup(linkNotification, false, linkNotification) - ); - }); - it("returns false if authors don't match", () => { const older = { ...defaultOlder, @@ -155,18 +137,6 @@ describe(' utilities', () => { ); }); - it('returns false if newer item is not a message', () => { - const linkNotification = { - type: 'linkNotification' as const, - data: null, - timestamp: Date.now(), - }; - - assert.isFalse( - shouldCurrentMessageHideMetadata(true, defaultCurrent, linkNotification) - ); - }); - it('returns false if newer is deletedForEveryone', () => { const newer = { ...defaultNewer, diff --git a/ts/test-electron/sql/conversationSummary_test.ts b/ts/test-electron/sql/conversationSummary_test.ts index 4c4800dbe..784ed7660 100644 --- a/ts/test-electron/sql/conversationSummary_test.ts +++ b/ts/test-electron/sql/conversationSummary_test.ts @@ -1,4 +1,4 @@ -// Copyright 2021 Signal Messenger, LLC +// Copyright 2021-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { assert } from 'chai'; @@ -166,15 +166,6 @@ describe('sql/conversationSummary', () => { timestamp: now + 3, }; const message4: MessageAttributesType = { - id: getUuid(), - body: 'message 4', - type: 'message-history-unsynced', - conversationId, - sent_at: now + 4, - received_at: now + 4, - timestamp: now + 4, - }; - const message5: MessageAttributesType = { id: getUuid(), body: 'message 5', type: 'profile-change', @@ -183,7 +174,7 @@ describe('sql/conversationSummary', () => { received_at: now + 5, timestamp: now + 5, }; - const message6: MessageAttributesType = { + const message5: MessageAttributesType = { id: getUuid(), body: 'message 6', type: 'story', @@ -192,7 +183,7 @@ describe('sql/conversationSummary', () => { received_at: now + 6, timestamp: now + 6, }; - const message7: MessageAttributesType = { + const message6: MessageAttributesType = { id: getUuid(), body: 'message 7', type: 'universal-timer-notification', @@ -201,7 +192,7 @@ describe('sql/conversationSummary', () => { received_at: now + 7, timestamp: now + 7, }; - const message8: MessageAttributesType = { + const message7: MessageAttributesType = { id: getUuid(), body: 'message 8', type: 'verified-change', @@ -212,23 +203,14 @@ describe('sql/conversationSummary', () => { }; await saveMessages( - [ - message1, - message2, - message3, - message4, - message5, - message6, - message7, - message8, - ], + [message1, message2, message3, message4, message5, message6, message7], { forceSave: true, ourUuid, } ); - assert.lengthOf(await _getAllMessages(), 8); + assert.lengthOf(await _getAllMessages(), 7); const messages = await getConversationMessageStats({ conversationId, @@ -282,15 +264,6 @@ describe('sql/conversationSummary', () => { timestamp: now + 4, }; const message5: MessageAttributesType = { - id: getUuid(), - body: 'message 5', - type: 'message-history-unsynced', - conversationId, - sent_at: now + 5, - received_at: now + 5, - timestamp: now + 5, - }; - const message6: MessageAttributesType = { id: getUuid(), body: 'message 6', type: 'profile-change', @@ -299,7 +272,7 @@ describe('sql/conversationSummary', () => { received_at: now + 6, timestamp: now + 6, }; - const message7: MessageAttributesType = { + const message6: MessageAttributesType = { id: getUuid(), body: 'message 7', type: 'story', @@ -308,7 +281,7 @@ describe('sql/conversationSummary', () => { received_at: now + 7, timestamp: now + 7, }; - const message8: MessageAttributesType = { + const message7: MessageAttributesType = { id: getUuid(), body: 'message 8', type: 'universal-timer-notification', @@ -317,7 +290,7 @@ describe('sql/conversationSummary', () => { received_at: now + 8, timestamp: now + 8, }; - const message9: MessageAttributesType = { + const message8: MessageAttributesType = { id: getUuid(), body: 'message 9', type: 'verified-change', @@ -337,7 +310,6 @@ describe('sql/conversationSummary', () => { message6, message7, message8, - message9, ], { forceSave: true, @@ -345,7 +317,7 @@ describe('sql/conversationSummary', () => { } ); - assert.lengthOf(await _getAllMessages(), 9); + assert.lengthOf(await _getAllMessages(), 8); const messages = await getConversationMessageStats({ conversationId, diff --git a/ts/types/Message.ts b/ts/types/Message.ts index f92a37fc0..2a2f48a6f 100644 --- a/ts/types/Message.ts +++ b/ts/types/Message.ts @@ -1,4 +1,4 @@ -// Copyright 2018-2021 Signal Messenger, LLC +// Copyright 2018-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only /* eslint-disable camelcase */ @@ -10,7 +10,6 @@ import type { IndexableBoolean, IndexablePresence } from './IndexedDB'; export type Message = ( | UserMessage | VerifiedChangeMessage - | MessageHistoryUnsyncedMessage | ProfileChangeNotificationMessage ) & { deletedForEveryone?: boolean }; export type UserMessage = IncomingMessage | OutgoingMessage; @@ -68,14 +67,6 @@ export type VerifiedChangeMessage = Readonly< ExpirationTimerUpdate >; -export type MessageHistoryUnsyncedMessage = Readonly< - { - type: 'message-history-unsynced'; - } & SharedMessageProperties & - MessageSchemaVersion5 & - ExpirationTimerUpdate ->; - export type ProfileChangeNotificationMessage = Readonly< { type: 'profile-change'; diff --git a/ts/types/message/initializeAttachmentMetadata.ts b/ts/types/message/initializeAttachmentMetadata.ts index 97b31e9d8..ac15b9d04 100644 --- a/ts/types/message/initializeAttachmentMetadata.ts +++ b/ts/types/message/initializeAttachmentMetadata.ts @@ -1,4 +1,4 @@ -// Copyright 2018-2020 Signal Messenger, LLC +// Copyright 2018-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as Attachment from '../Attachment'; @@ -19,9 +19,6 @@ export const initializeAttachmentMetadata = async ( if (message.type === 'verified-change') { return message; } - if (message.type === 'message-history-unsynced') { - return message; - } if (message.type === 'profile-change') { return message; } diff --git a/ts/util/index.ts b/ts/util/index.ts index 213404268..a89c73f38 100644 --- a/ts/util/index.ts +++ b/ts/util/index.ts @@ -38,7 +38,6 @@ import { } from './sessionTranslation'; import * as zkgroup from './zkgroup'; import { StartupQueue } from './StartupQueue'; -import { postLinkExperience } from './postLinkExperience'; import { sendToGroup, sendContentMessageToGroup } from './sendToGroup'; import { RetryPlaceholders } from './retryPlaceholders'; import * as expirationTimer from './expirationTimer'; @@ -70,7 +69,6 @@ export { MessageController, missingCaseError, parseRemoteClientExpiration, - postLinkExperience, queueUpdateMessage, RetryPlaceholders, saveNewMessageBatcher, diff --git a/ts/util/postLinkExperience.ts b/ts/util/postLinkExperience.ts deleted file mode 100644 index 966bed7e0..000000000 --- a/ts/util/postLinkExperience.ts +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright 2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import { MINUTE } from './durations'; - -class PostLinkExperience { - private hasNotFinishedSync: boolean; - - constructor() { - this.hasNotFinishedSync = false; - } - - start() { - this.hasNotFinishedSync = true; - - // timeout "post link" after 10 minutes in case the syncs don't complete - // in time or are never called. - setTimeout(() => { - this.stop(); - }, 10 * MINUTE); - } - - stop() { - this.hasNotFinishedSync = false; - } - - isActive(): boolean { - return this.hasNotFinishedSync === true; - } -} - -export const postLinkExperience = new PostLinkExperience(); From 4c1b27c150039482625dffe354fda3c4c0d11a0d Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Mon, 25 Apr 2022 14:03:47 -0700 Subject: [PATCH 15/53] sendProfileKey: Be resilient to more kinds of errors --- ts/jobs/helpers/sendProfileKey.ts | 51 +++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/ts/jobs/helpers/sendProfileKey.ts b/ts/jobs/helpers/sendProfileKey.ts index 3e7cb1b07..d48a09fe1 100644 --- a/ts/jobs/helpers/sendProfileKey.ts +++ b/ts/jobs/helpers/sendProfileKey.ts @@ -7,6 +7,7 @@ import { handleMessageSend } from '../../util/handleMessageSend'; import { getSendOptions } from '../../util/getSendOptions'; import { isDirectConversation, + isGroup, isGroupV2, } from '../../util/whatTypeOfConversation'; import { SignalService as Proto } from '../../protobuf'; @@ -22,10 +23,39 @@ import type { ProfileKeyJobData, } from '../conversationJobQueue'; import type { CallbackResultType } from '../../textsecure/Types.d'; -import { getUntrustedConversationIds } from './getUntrustedConversationIds'; -import { areAllErrorsUnregistered } from './areAllErrorsUnregistered'; import { isConversationAccepted } from '../../util/isConversationAccepted'; import { isConversationUnregistered } from '../../util/isConversationUnregistered'; +import type { ConversationAttributesType } from '../../model-types.d'; +import { + OutgoingIdentityKeyError, + SendMessageChallengeError, + SendMessageProtoError, + UnregisteredUserError, +} from '../../textsecure/Errors'; + +export function canAllErrorsBeIgnored( + conversation: ConversationAttributesType, + error: unknown +): boolean { + if ( + error instanceof OutgoingIdentityKeyError || + error instanceof SendMessageChallengeError || + error instanceof UnregisteredUserError + ) { + return true; + } + + return Boolean( + isGroup(conversation) && + error instanceof SendMessageProtoError && + error.errors?.every( + item => + item instanceof OutgoingIdentityKeyError || + item instanceof SendMessageChallengeError || + item instanceof UnregisteredUserError + ) + ); +} // Note: because we don't have a recipient map, we will resend this message to folks that // got it on the first go-round, if some sends fail. This is okay, because a recipient @@ -71,18 +101,7 @@ export async function sendProfileKey( // Note: flags and the profileKey itself are all that matter in the proto. - const untrustedConversationIds = getUntrustedConversationIds( - conversation.getRecipients() - ); - if (untrustedConversationIds.length) { - window.reduxActions.conversations.conversationStoppedByMissingVerification({ - conversationId: conversation.id, - untrustedConversationIds, - }); - throw new Error( - `Profile key send blocked because ${untrustedConversationIds.length} conversation(s) were untrusted. Failing this attempt.` - ); - } + // Note: we don't check for untrusted conversations here; we attempt to send anyway if (isDirectConversation(conversation.attributes)) { if (!isConversationAccepted(conversation.attributes)) { @@ -149,9 +168,9 @@ export async function sendProfileKey( sendType, }); } catch (error: unknown) { - if (areAllErrorsUnregistered(conversation.attributes, error)) { + if (canAllErrorsBeIgnored(conversation.attributes, error)) { log.info( - 'Group send failures were all UnregisteredUserError, returning succcessfully.' + 'Group send failures were all OutgoingIdentityKeyError, SendMessageChallengeError, or UnregisteredUserError. Returning succcessfully.' ); return; } From decf65078c2d8a2f829ad9b55e6478052bc3a18e Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Mon, 25 Apr 2022 14:12:22 -0700 Subject: [PATCH 16/53] Fix screen reader interaction with message audio --- ts/components/conversation/MessageAudio.tsx | 79 ++++++++++++++++++--- 1 file changed, 71 insertions(+), 8 deletions(-) diff --git a/ts/components/conversation/MessageAudio.tsx b/ts/components/conversation/MessageAudio.tsx index ac861921f..5dc0bb15b 100644 --- a/ts/components/conversation/MessageAudio.tsx +++ b/ts/components/conversation/MessageAudio.tsx @@ -1,7 +1,13 @@ // Copyright 2021-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useRef, useEffect, useState } from 'react'; +import React, { + useRef, + useEffect, + useState, + useReducer, + useCallback, +} from 'react'; import classNames from 'classnames'; import { noop } from 'lodash'; @@ -9,6 +15,7 @@ import { assert } from '../../util/assert'; import type { LocalizerType } from '../../types/Util'; import type { AttachmentType } from '../../types/Attachment'; import { isDownloaded } from '../../types/Attachment'; +import { missingCaseError } from '../../util/missingCaseError'; import type { DirectionType, MessageStatusType } from './Message'; import type { ComputePeaksResult } from '../GlobalAudioContext'; @@ -133,6 +140,37 @@ const Button: React.FC = props => { ); }; +type StateType = Readonly<{ + isPlaying: boolean; + currentTime: number; + lastAriaTime: number; +}>; + +type ActionType = Readonly< + | { + type: 'SET_IS_PLAYING'; + value: boolean; + } + | { + type: 'SET_CURRENT_TIME'; + value: number; + } +>; + +function reducer(state: StateType, action: ActionType): StateType { + if (action.type === 'SET_IS_PLAYING') { + return { + ...state, + isPlaying: action.value, + lastAriaTime: state.currentTime, + }; + } + if (action.type === 'SET_CURRENT_TIME') { + return { ...state, currentTime: action.value }; + } + throw missingCaseError(action); +} + /** * Display message audio attachment along with its waveform, duration, and * toggle Play/Pause button. @@ -184,11 +222,27 @@ export const MessageAudio: React.FC = (props: Props) => { activeAudioID === id && activeAudioContext === renderingContext; const waveformRef = useRef(null); - const [isPlaying, setIsPlaying] = useState( - isActive && !(audio.paused || audio.ended) + const [{ isPlaying, currentTime, lastAriaTime }, dispatch] = useReducer( + reducer, + { + isPlaying: isActive && !(audio.paused || audio.ended), + currentTime: isActive ? audio.currentTime : 0, + lastAriaTime: isActive ? audio.currentTime : 0, + } ); - const [currentTime, setCurrentTime] = useState( - isActive ? audio.currentTime : 0 + + const setIsPlaying = useCallback( + (value: boolean) => { + dispatch({ type: 'SET_IS_PLAYING', value }); + }, + [dispatch] + ); + + const setCurrentTime = useCallback( + (value: number) => { + dispatch({ type: 'SET_CURRENT_TIME', value }); + }, + [dispatch] ); // NOTE: Avoid division by zero @@ -326,7 +380,15 @@ export const MessageAudio: React.FC = (props: Props) => { audio.removeEventListener('loadedmetadata', onLoadedMetadata); audio.removeEventListener('durationchange', onDurationChange); }; - }, [id, audio, isActive, currentTime, duration]); + }, [ + id, + audio, + isActive, + currentTime, + duration, + setCurrentTime, + setIsPlaying, + ]); // This effect detects `isPlaying` changes and starts/pauses playback when // needed (+keeps waveform position and audio position in sync). @@ -457,10 +519,10 @@ export const MessageAudio: React.FC = (props: Props) => { role="slider" aria-label={i18n('MessageAudio--slider')} aria-orientation="horizontal" - aria-valuenow={currentTime} + aria-valuenow={lastAriaTime} aria-valuemin={0} aria-valuemax={duration} - aria-valuetext={timeToText(currentTime)} + aria-valuetext={timeToText(lastAriaTime)} > {peaks.map((peak, i) => { let height = Math.max(BAR_MIN_HEIGHT, BAR_MAX_HEIGHT * peak); @@ -531,6 +593,7 @@ export const MessageAudio: React.FC = (props: Props) => { const metadata = (
); diff --git a/ts/state/smart/StoryViewer.tsx b/ts/state/smart/StoryViewer.tsx index e5d63105f..ffcc22023 100644 --- a/ts/state/smart/StoryViewer.tsx +++ b/ts/state/smart/StoryViewer.tsx @@ -13,13 +13,14 @@ import { getEmojiSkinTone, getPreferredReactionEmoji, } from '../selectors/items'; -import { getStoriesSelector, getStoryReplies } from '../selectors/stories'; import { getIntl } from '../selectors/user'; import { getPreferredBadgeSelector } from '../selectors/badges'; +import { getStoriesSelector, getStoryReplies } from '../selectors/stories'; import { renderEmojiPicker } from './renderEmojiPicker'; import { showToast } from '../../util/showToast'; import { useActions as useEmojisActions } from '../ducks/emojis'; import { useActions as useItemsActions } from '../ducks/items'; +import { useConversationsActions } from '../ducks/conversations'; import { useRecentEmojis } from '../selectors/emojis'; import { useStoriesActions } from '../ducks/stories'; @@ -39,6 +40,8 @@ export function SmartStoryViewer({ const storiesActions = useStoriesActions(); const { onSetSkinTone } = useItemsActions(); const { onUseEmoji } = useEmojisActions(); + const { openConversationInternal, toggleHideStories } = + useConversationsActions(); const i18n = useSelector(getIntl); const getPreferredBadge = useSelector(getPreferredBadgeSelector); @@ -66,6 +69,11 @@ export function SmartStoryViewer({ group={group} i18n={i18n} onClose={onClose} + onHideStory={toggleHideStories} + onGoToConversation={senderId => { + openConversationInternal({ conversationId: senderId }); + storiesActions.toggleStoriesView(); + }} onNextUserStories={onNextUserStories} onPrevUserStories={onPrevUserStories} onReactToStory={async (emoji, story) => { From 4090e968b6f9848a2a24992362747c17fa6af046 Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Fri, 29 Apr 2022 14:48:26 -0400 Subject: [PATCH 39/53] Log better errors when unable to show attachments --- ts/test-both/types/errors_test.ts | 2 +- ts/types/errors.ts | 6 ++++++ ts/views/conversation_view.ts | 15 +++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/ts/test-both/types/errors_test.ts b/ts/test-both/types/errors_test.ts index 12701ebeb..e5784619d 100644 --- a/ts/test-both/types/errors_test.ts +++ b/ts/test-both/types/errors_test.ts @@ -29,7 +29,7 @@ describe('Errors', () => { assert.isUndefined(error.stack); const formattedError = Errors.toLogFormat(error); - assert.strictEqual(formattedError, 'Error: boom'); + assert.strictEqual(formattedError, 'boom'); }); [0, false, null, undefined].forEach(value => { diff --git a/ts/types/errors.ts b/ts/types/errors.ts index c41afe842..582602674 100644 --- a/ts/types/errors.ts +++ b/ts/types/errors.ts @@ -3,11 +3,17 @@ /* eslint-disable max-classes-per-file */ +import { get, has } from 'lodash'; + export function toLogFormat(error: unknown): string { if (error instanceof Error && error.stack) { return error.stack; } + if (has(error, 'message')) { + return get(error, 'message'); + } + return String(error); } diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index e63a34668..a5a9e5ab6 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -2138,6 +2138,21 @@ export class ConversationView extends window.Backbone.View { getAbsoluteAttachmentPath(item.thumbnail?.path ?? ''), })); + if (!media.length) { + log.error( + 'showLightbox: unable to load attachment', + attachments.map(x => ({ + contentType: x.contentType, + error: x.error, + flags: x.flags, + path: x.path, + size: x.size, + })) + ); + showToast(ToastUnableToLoadAttachment); + return; + } + const selectedMedia = media.find(item => attachment.path === item.path) || media[0]; From 925b89b3a935e1063d256e60dd2bf7924ed44cd9 Mon Sep 17 00:00:00 2001 From: Jim Gustafson Date: Fri, 29 Apr 2022 12:58:25 -0700 Subject: [PATCH 40/53] Update to RingRTC v2.20.4 --- package.json | 2 +- yarn.lock | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index c0799374d..10e0ba59f 100644 --- a/package.json +++ b/package.json @@ -164,7 +164,7 @@ "redux-ts-utils": "3.2.2", "reselect": "4.1.2", "rimraf": "2.6.2", - "ringrtc": "https://github.com/signalapp/signal-ringrtc-node.git#e66b5511e6a4d5b2871383ac45cf4c39582352b2", + "ringrtc": "https://github.com/signalapp/signal-ringrtc-node.git#ccb71f118a8f8962addc53333730c77b287d7e43", "rotating-file-stream": "2.1.5", "sanitize.css": "11.0.0", "semver": "5.4.1", diff --git a/yarn.lock b/yarn.lock index 8a8d8098f..a4c4fafac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13356,9 +13356,9 @@ rimraf@^3.0.0, rimraf@^3.0.2: dependencies: glob "^7.1.3" -"ringrtc@https://github.com/signalapp/signal-ringrtc-node.git#e66b5511e6a4d5b2871383ac45cf4c39582352b2": - version "2.20.2" - resolved "https://github.com/signalapp/signal-ringrtc-node.git#e66b5511e6a4d5b2871383ac45cf4c39582352b2" +"ringrtc@https://github.com/signalapp/signal-ringrtc-node.git#ccb71f118a8f8962addc53333730c77b287d7e43": + version "2.20.4" + resolved "https://github.com/signalapp/signal-ringrtc-node.git#ccb71f118a8f8962addc53333730c77b287d7e43" ripemd160@^2.0.0, ripemd160@^2.0.1: version "2.0.1" From e078a2ae540a61cc75e84275bd93739db60347ef Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Fri, 29 Apr 2022 16:42:47 -0700 Subject: [PATCH 41/53] Ensure that seenStatus is always updated along with readStatus --- ts/models/conversations.ts | 1 + ts/models/messages.ts | 11 ++++++++++- ts/services/MessageUpdater.ts | 2 ++ ts/sql/Interface.ts | 12 +++++++++++- ts/sql/Server.ts | 19 ++++++++++++++++--- ts/test-electron/sql/markRead_test.ts | 2 +- ts/util/markConversationRead.ts | 16 ++++++++++++---- 7 files changed, 53 insertions(+), 10 deletions(-) diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 17d07ab3a..1b95db1d8 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -3383,6 +3383,7 @@ export class ConversationModel extends window.Backbone return this.queueJob('onReadMessage', () => // eslint-disable-next-line @typescript-eslint/no-non-null-assertion this.markRead(message.get('received_at')!, { + newestSentAt: message.get('sent_at'), sendReadReceipts: false, readAt, }) diff --git a/ts/models/messages.ts b/ts/models/messages.ts index ccac8926e..54b26bbba 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -210,7 +210,16 @@ export class MessageModel extends window.Backbone.Model { const readStatus = migrateLegacyReadStatus(this.attributes); if (readStatus !== undefined) { - this.set('readStatus', readStatus, { silent: true }); + this.set( + { + readStatus, + seenStatus: + readStatus === ReadStatus.Unread + ? SeenStatus.Unseen + : SeenStatus.Seen, + }, + { silent: true } + ); } const sendStateByConversationId = migrateLegacySendAttributes( diff --git a/ts/services/MessageUpdater.ts b/ts/services/MessageUpdater.ts index 68aa8121e..e2d984b24 100644 --- a/ts/services/MessageUpdater.ts +++ b/ts/services/MessageUpdater.ts @@ -4,6 +4,7 @@ import type { MessageAttributesType } from '../model-types.d'; import { ReadStatus, maxReadStatus } from '../messages/MessageReadStatus'; import { notificationService } from './notifications'; +import { SeenStatus } from '../MessageSeenStatus'; function markReadOrViewed( messageAttrs: Readonly, @@ -17,6 +18,7 @@ function markReadOrViewed( const nextMessageAttributes: MessageAttributesType = { ...messageAttrs, readStatus: newReadStatus, + seenStatus: SeenStatus.Seen, }; const { id: messageId, expireTimer, expirationStartTimestamp } = messageAttrs; diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index dd655ca57..c5311bde1 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -21,6 +21,7 @@ import type { UUIDStringType } from '../types/UUID'; import type { BadgeType } from '../badges/types'; import type { RemoveAllConfiguration } from '../types/RemoveAllConfiguration'; import type { LoggerType } from '../types/Logging'; +import type { ReadStatus } from '../messages/MessageReadStatus'; export type AttachmentDownloadJobTypeType = | 'long-message' @@ -397,7 +398,16 @@ export type DataInterface = { storyId?: UUIDStringType; }) => Promise< Array< - Pick + { originalReadStatus: ReadStatus | undefined } & Pick< + MessageType, + | 'id' + | 'readStatus' + | 'seenStatus' + | 'sent_at' + | 'source' + | 'sourceUuid' + | 'type' + > > >; getUnreadReactionsAndMarkRead: (options: { diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 018921553..6d8a7e843 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -2075,7 +2075,18 @@ async function getUnreadByConversationAndMarkRead({ storyId?: UUIDStringType; readAt?: number; }): Promise< - Array> + Array< + { originalReadStatus: ReadStatus | undefined } & Pick< + MessageType, + | 'id' + | 'source' + | 'sourceUuid' + | 'sent_at' + | 'type' + | 'readStatus' + | 'seenStatus' + > + > > { const db = getInstance(); return db.transaction(() => { @@ -2109,10 +2120,10 @@ async function getUnreadByConversationAndMarkRead({ .prepare( ` SELECT id, json FROM messages - INDEXED BY messages_unread WHERE - readStatus = ${ReadStatus.Unread} AND conversationId = $conversationId AND + seenStatus = ${SeenStatus.Unseen} AND + isStory = 0 AND (${_storyIdPredicate(storyId, isGroup)}) AND received_at <= $newestUnreadAt ORDER BY received_at DESC, sent_at DESC; @@ -2151,7 +2162,9 @@ async function getUnreadByConversationAndMarkRead({ return rows.map(row => { const json = jsonToObject(row.json); return { + originalReadStatus: json.readStatus, readStatus: ReadStatus.Read, + seenStatus: SeenStatus.Seen, ...pick(json, [ 'expirationStartTimestamp', 'id', diff --git a/ts/test-electron/sql/markRead_test.ts b/ts/test-electron/sql/markRead_test.ts index a96b02c54..f00769acb 100644 --- a/ts/test-electron/sql/markRead_test.ts +++ b/ts/test-electron/sql/markRead_test.ts @@ -166,7 +166,7 @@ describe('sql/markRead', () => { readAt, }); - assert.lengthOf(markedRead2, 3, 'three messages marked read'); + assert.lengthOf(markedRead2, 2, 'two messages marked read'); assert.strictEqual(markedRead2[0].id, message7.id, 'should be message7'); assert.strictEqual( diff --git a/ts/util/markConversationRead.ts b/ts/util/markConversationRead.ts index 498f5c0c8..ab3c602be 100644 --- a/ts/util/markConversationRead.ts +++ b/ts/util/markConversationRead.ts @@ -1,6 +1,8 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import { omit } from 'lodash'; + import type { ConversationAttributesType } from '../model-types.d'; import { hasErrors } from '../state/selectors/message'; import { readReceiptsJobQueue } from '../jobs/readReceiptsJobQueue'; @@ -9,6 +11,7 @@ import { notificationService } from '../services/notifications'; import { isGroup } from './whatTypeOfConversation'; import * as log from '../logging/log'; import { getConversationIdForLogging } from './idForLogging'; +import { ReadStatus } from '../messages/MessageReadStatus'; export async function markConversationRead( conversationAttrs: ConversationAttributesType, @@ -76,11 +79,12 @@ export async function markConversationRead( const message = window.MessageController.getById(messageSyncData.id); // we update the in-memory MessageModel with the fresh database call data if (message) { - message.set(messageSyncData); + message.set(omit(messageSyncData, 'originalReadStatus')); } return { messageId: messageSyncData.id, + originalReadStatus: messageSyncData.originalReadStatus, senderE164: messageSyncData.source, senderUuid: messageSyncData.sourceUuid, senderId: window.ConversationController.ensureContactIds({ @@ -92,14 +96,18 @@ export async function markConversationRead( }; }); - // Some messages we're marking read are local notifications with no sender - // If a message has errors, we don't want to send anything out about it. + // Some messages we're marking read are local notifications with no sender or were just + // unseen and not unread. + // Also, if a message has errors, we don't want to send anything out about it: // read syncs - let's wait for a client that really understands the message // to mark it read. we'll mark our local error read locally, though. // read receipts - here we can run into infinite loops, where each time the // conversation is viewed, another error message shows up for the contact const unreadMessagesSyncData = allReadMessagesSync.filter( - item => Boolean(item.senderId) && !item.hasErrors + item => + Boolean(item.senderId) && + item.originalReadStatus === ReadStatus.Unread && + !item.hasErrors ); const readSyncs: Array<{ From a77861e5c4536a9a1ab478df79ab363b9e68636d Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Mon, 2 May 2022 12:24:41 -0400 Subject: [PATCH 42/53] Fix video playback in StoryViewer for multiple videos --- ts/components/StoryImage.tsx | 20 +++++++++++++++++++- ts/components/StoryViewer.tsx | 1 + ts/util/lint/exceptions.json | 7 +++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/ts/components/StoryImage.tsx b/ts/components/StoryImage.tsx index 2797cf233..bbb20b6dd 100644 --- a/ts/components/StoryImage.tsx +++ b/ts/components/StoryImage.tsx @@ -1,7 +1,7 @@ // Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useEffect } from 'react'; +import React, { useEffect, useRef } from 'react'; import classNames from 'classnames'; import { Blurhash } from 'react-blurhash'; @@ -23,6 +23,7 @@ import { isVideoTypeSupported } from '../util/GoogleChrome'; export type PropsType = { readonly attachment?: AttachmentType; readonly i18n: LocalizerType; + readonly isPaused?: boolean; readonly isThumbnail?: boolean; readonly label: string; readonly moduleClassName?: string; @@ -33,6 +34,7 @@ export type PropsType = { export const StoryImage = ({ attachment, i18n, + isPaused, isThumbnail, label, moduleClassName, @@ -44,12 +46,26 @@ export const StoryImage = ({ !isDownloading(attachment) && !hasNotResolved(attachment); + const videoRef = useRef(null); + useEffect(() => { if (shouldDownloadAttachment) { queueStoryDownload(storyId); } }, [queueStoryDownload, shouldDownloadAttachment, storyId]); + useEffect(() => { + if (!videoRef.current) { + return; + } + + if (isPaused) { + videoRef.current.pause(); + } else { + videoRef.current.play(); + } + }, [isPaused]); + if (!attachment) { return null; } @@ -85,7 +101,9 @@ export const StoryImage = ({ autoPlay className={getClassName('__image')} controls={false} + key={attachment.url} loop={shouldLoop} + ref={videoRef} > diff --git a/ts/components/StoryViewer.tsx b/ts/components/StoryViewer.tsx index e201c6a7f..88250cf5b 100644 --- a/ts/components/StoryViewer.tsx +++ b/ts/components/StoryViewer.tsx @@ -318,6 +318,7 @@ export const StoryViewer = ({ (null);", + "reasonCategory": "usageTrusted", + "updated": "2022-04-29T23:54:21.656Z" + }, { "rule": "React-useRef", "path": "ts/components/StoryViewsNRepliesModal.tsx", From 87a5ddc437d7879087fc042dde53f0f18ae086a2 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Mon, 2 May 2022 16:42:07 -0700 Subject: [PATCH 43/53] MediaGallery: Localize Media and Documents tab headers --- ts/components/conversation/media-gallery/MediaGallery.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ts/components/conversation/media-gallery/MediaGallery.tsx b/ts/components/conversation/media-gallery/MediaGallery.tsx index ca50d7818..1f287ee10 100644 --- a/ts/components/conversation/media-gallery/MediaGallery.tsx +++ b/ts/components/conversation/media-gallery/MediaGallery.tsx @@ -89,19 +89,20 @@ export class MediaGallery extends React.Component { } public override render(): JSX.Element { + const { i18n } = this.props; const { selectedTab } = this.state; return (
Date: Tue, 3 May 2022 12:02:43 -0400 Subject: [PATCH 44/53] Fixes going to oldest unread story when viewing --- ts/components/StoryViewer.stories.tsx | 19 --------------- ts/components/StoryViewer.tsx | 33 +++++++++++++++++++-------- ts/state/smart/StoryViewer.tsx | 3 --- ts/util/lint/exceptions.json | 7 ++++++ 4 files changed, 30 insertions(+), 32 deletions(-) diff --git a/ts/components/StoryViewer.stories.tsx b/ts/components/StoryViewer.stories.tsx index 1f98d2df2..4811fd02a 100644 --- a/ts/components/StoryViewer.stories.tsx +++ b/ts/components/StoryViewer.stories.tsx @@ -39,7 +39,6 @@ function getDefaultProps(): PropsType { preferredReactionEmoji: ['❤️', '👍', '👎', '😂', '😮', '😢'], queueStoryDownload: action('queueStoryDownload'), renderEmojiPicker: () =>
, - selectedStoryIndex: 0, stories: [ { attachment: fakeAttachment({ @@ -109,24 +108,6 @@ story.add('Multi story', () => { ); }); -story.add('So many stories (start on story 4)', () => { - const sender = getDefaultConversation(); - return ( - - ); -}); - story.add('Caption', () => ( ; renderEmojiPicker: (props: RenderEmojiPickerProps) => JSX.Element; replyState?: ReplyStateType; - selectedStoryIndex: number; skinTone?: number; stories: Array; views?: Array; @@ -94,13 +99,11 @@ export const StoryViewer = ({ recentEmojis, renderEmojiPicker, replyState, - selectedStoryIndex, skinTone, stories, views, }: PropsType): JSX.Element => { - const [currentStoryIndex, setCurrentStoryIndex] = - useState(selectedStoryIndex); + const [currentStoryIndex, setCurrentStoryIndex] = useState(0); const [storyDuration, setStoryDuration] = useState(); const [isShowingContextMenu, setIsShowingContextMenu] = useState(false); const [hasConfirmHideStory, setHasConfirmHideStory] = useState(false); @@ -155,12 +158,22 @@ export const StoryViewer = ({ setHasExpandedCaption(false); }, [messageId]); - // In case we want to change the story we're viewing from 0 -> N + // These exist to change currentStoryIndex to the oldest unread story a user + // has, or set to 0 whenever conversationId changes. + // We use a ref so that we only depend on conversationId changing, since + // the stories Array will change once we mark as story as viewed. + const storiesRef = useRef(stories); + useEffect(() => { - if (selectedStoryIndex) { - setCurrentStoryIndex(selectedStoryIndex); - } - }, [selectedStoryIndex]); + const unreadStoryIndex = storiesRef.current.findIndex( + story => story.isUnread + ); + setCurrentStoryIndex(unreadStoryIndex < 0 ? 0 : unreadStoryIndex); + }, [conversationId]); + + useEffect(() => { + storiesRef.current = stories; + }, [stories]); // Either we show the next story in the current user's stories or we ask // for the next user's stories. diff --git a/ts/state/smart/StoryViewer.tsx b/ts/state/smart/StoryViewer.tsx index ffcc22023..e1188b00b 100644 --- a/ts/state/smart/StoryViewer.tsx +++ b/ts/state/smart/StoryViewer.tsx @@ -55,8 +55,6 @@ export function SmartStoryViewer({ >(getStoriesSelector); const { group, stories } = getStoriesByConversationId(conversationId); - const unreadStoryIndex = stories.findIndex(story => story.isUnread); - const selectedStoryIndex = unreadStoryIndex > 0 ? unreadStoryIndex : 0; const recentEmojis = useRecentEmojis(); const skinTone = useSelector(getEmojiSkinTone); @@ -96,7 +94,6 @@ export function SmartStoryViewer({ recentEmojis={recentEmojis} renderEmojiPicker={renderEmojiPicker} replyState={replyState} - selectedStoryIndex={selectedStoryIndex} stories={stories} skinTone={skinTone} {...storiesActions} diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index c2305a25e..5baec742c 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -7706,6 +7706,13 @@ "reasonCategory": "usageTrusted", "updated": "2022-04-29T23:54:21.656Z" }, + { + "rule": "React-useRef", + "path": "ts/components/StoryViewer.tsx", + "line": " const storiesRef = useRef(stories);", + "reasonCategory": "usageTrusted", + "updated": "2022-04-30T00:44:47.213Z" + }, { "rule": "React-useRef", "path": "ts/components/StoryViewsNRepliesModal.tsx", From dad4fffb4eef5c351a9d6b4b122082ee9c026b89 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Tue, 3 May 2022 10:07:16 -0700 Subject: [PATCH 45/53] Notarization: Update electron-notarize and start using notarytool --- package.json | 2 +- ts/scripts/notarize.ts | 10 ++++++++++ yarn.lock | 10 +++++----- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 10e0ba59f..1d25419e0 100644 --- a/package.json +++ b/package.json @@ -267,7 +267,7 @@ "electron": "18.1.0", "electron-builder": "23.0.8", "electron-mocha": "11.0.2", - "electron-notarize": "0.1.1", + "electron-notarize": "1.2.1", "esbuild": "0.14.28", "eslint": "7.7.0", "eslint-config-airbnb-typescript-prettier": "4.2.0", diff --git a/ts/scripts/notarize.ts b/ts/scripts/notarize.ts index d441a4533..d9eadf241 100644 --- a/ts/scripts/notarize.ts +++ b/ts/scripts/notarize.ts @@ -47,15 +47,25 @@ export async function afterSign({ return; } + const teamId = process.env.APPLE_TEAM_ID; + if (!teamId) { + console.warn( + 'teamId must be provided in environment variable APPLE_TEAM_ID' + ); + return; + } + console.log('Notarizing with...'); console.log(` primaryBundleId: ${appBundleId}`); console.log(` username: ${appleId}`); console.log(` file: ${appPath}`); await notarize({ + tool: 'notarytool', appBundleId, appPath, appleId, appleIdPassword, + teamId, }); } diff --git a/yarn.lock b/yarn.lock index a4c4fafac..3300af745 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6130,13 +6130,13 @@ electron-mocha@11.0.2: which "^2.0.2" yargs "^16.2.0" -electron-notarize@0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/electron-notarize/-/electron-notarize-0.1.1.tgz#c3563d70c5e7b3315f44e8495b30050a8c408b91" - integrity sha512-TpKfJcz4LXl5jiGvZTs5fbEx+wUFXV5u8voeG5WCHWfY/cdgdD8lDZIZRqLVOtR3VO+drgJ9aiSHIO9TYn/fKg== +electron-notarize@1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/electron-notarize/-/electron-notarize-1.2.1.tgz#347c18eca8e29dddadadee511b870c13d4008baf" + integrity sha512-u/ECWhIrhkSQpZM4cJzVZ5TsmkaqrRo5LDC/KMbGF0sPkm53Ng59+M0zp8QVaql0obfJy9vlVT+4iOkAi2UDlA== dependencies: debug "^4.1.1" - fs-extra "^8.0.1" + fs-extra "^9.0.1" electron-osx-sign@^0.6.0: version "0.6.0" From 364d690cf3f5f3feaaae5b12cac72d901b0a9631 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Tue, 3 May 2022 12:06:47 -0700 Subject: [PATCH 46/53] On database error: Escape copies error and quits, additional logging --- app/main.ts | 46 ++++++++++++++++++++++++++-------------------- ts/sql/Server.ts | 1 + 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/app/main.ts b/app/main.ts index 518a4c98f..2bed7f977 100644 --- a/app/main.ts +++ b/app/main.ts @@ -1287,6 +1287,21 @@ function showPermissionsPopupWindow(forCalling: boolean, forCamera: boolean) { }); } +const runSQLCorruptionHandler = async () => { + // This is a glorified event handler. Normally, this promise never resolves, + // but if there is a corruption error triggered by any query that we run + // against the database - the promise will resolve and we will call + // `onDatabaseError`. + const error = await sql.whenCorrupted(); + + getLogger().error( + 'Detected sql corruption in main process. ' + + `Restarting the application immediately. Error: ${error.message}` + ); + + await onDatabaseError(error.stack || error.message); +}; + async function initializeSQL( userDataPath: string ): Promise<{ ok: true; error: undefined } | { ok: false; error: Error }> { @@ -1331,6 +1346,9 @@ async function initializeSQL( sqlInitTimeEnd = Date.now(); } + // Only if we've initialized things successfully do we set up the corruption handler + runSQLCorruptionHandler(); + return { ok: true, error: undefined }; } @@ -1346,44 +1364,32 @@ const onDatabaseError = async (error: string) => { const buttonIndex = dialog.showMessageBoxSync({ buttons: [ - getLocale().i18n('copyErrorAndQuit'), getLocale().i18n('deleteAndRestart'), + getLocale().i18n('copyErrorAndQuit'), ], - defaultId: 0, + defaultId: 1, + cancelId: 1, detail: redactAll(error), message: getLocale().i18n('databaseError'), noLink: true, type: 'error', }); - if (buttonIndex === 0) { + if (buttonIndex === 1) { clipboard.writeText(`Database startup error:\n\n${redactAll(error)}`); } else { await sql.removeDB(); userConfig.remove(); + getLogger().error( + 'onDatabaseError: Requesting immediate restart after quit' + ); app.relaunch(); } + getLogger().error('onDatabaseError: Quitting application'); app.exit(1); }; -const runSQLCorruptionHandler = async () => { - // This is a glorified event handler. Normally, this promise never resolves, - // but if there is a corruption error triggered by any query that we run - // against the database - the promise will resolve and we will call - // `onDatabaseError`. - const error = await sql.whenCorrupted(); - - getLogger().error( - 'Detected sql corruption in main process. ' + - `Restarting the application immediately. Error: ${error.message}` - ); - - await onDatabaseError(error.stack || error.message); -}; - -runSQLCorruptionHandler(); - let sqlInitPromise: | Promise<{ ok: true; error: undefined } | { ok: false; error: Error }> | undefined; diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 6d8a7e843..e7c6febbf 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -595,6 +595,7 @@ async function removeDB(): Promise { ); } + logger.warn('removeDB: Removing all database files'); rimraf.sync(databaseFilePath); rimraf.sync(`${databaseFilePath}-shm`); rimraf.sync(`${databaseFilePath}-wal`); From a9c788b689fb0f5d1affea162c924f5c463583fc Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Tue, 3 May 2022 15:08:36 -0400 Subject: [PATCH 47/53] Adds spacing to the story list --- stylesheets/components/Stories.scss | 1 + stylesheets/components/StoryListItem.scss | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/stylesheets/components/Stories.scss b/stylesheets/components/Stories.scss index fa0aae3d4..e250b6f06 100644 --- a/stylesheets/components/Stories.scss +++ b/stylesheets/components/Stories.scss @@ -97,6 +97,7 @@ @include scrollbar; flex: 1; overflow-y: overlay; + padding: 0 10px; &--empty { @include font-body-1; diff --git a/stylesheets/components/StoryListItem.scss b/stylesheets/components/StoryListItem.scss index 8ab16e23c..049d3892c 100644 --- a/stylesheets/components/StoryListItem.scss +++ b/stylesheets/components/StoryListItem.scss @@ -6,8 +6,8 @@ align-items: center; border-radius: 10px; display: flex; - padding: 0 20px; height: 96px; + padding: 0 10px; width: 100%; @include keyboard-mode { From d4e0f6a38d2d72ad679154491732c25485838553 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Tue, 3 May 2022 13:24:31 -0700 Subject: [PATCH 48/53] Update conversation.unreadCount in just one place, from database --- ts/models/conversations.ts | 8 +++++++- ts/models/messages.ts | 3 ++- ts/util/sendToGroup.ts | 7 +++---- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 1b95db1d8..6afcc6474 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -4644,10 +4644,16 @@ export class ConversationModel extends window.Backbone } ): Promise { await markConversationRead(this.attributes, newestUnreadAt, options); + await this.updateUnread(); + } + async updateUnread(): Promise { const unreadCount = await window.Signal.Data.getTotalUnreadForConversation( this.id, - { storyId: undefined, isGroup: isGroup(this.attributes) } + { + storyId: undefined, + isGroup: isGroup(this.attributes), + } ); const prevUnreadCount = this.get('unreadCount'); diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 54b26bbba..0b79be443 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -2647,6 +2647,8 @@ export class MessageModel extends window.Backbone.Model { window.Whisper.events.trigger('incrementProgress'); confirm(); + + conversation.queueJob('updateUnread', () => conversation.updateUnread()); } // This function is called twice - once from handleDataMessage, and then again from @@ -2776,7 +2778,6 @@ export class MessageModel extends window.Backbone.Model { ); } else if (isFirstRun && !isGroupStoryReply) { conversation.set({ - unreadCount: (conversation.get('unreadCount') || 0) + 1, isArchived: false, }); } diff --git a/ts/util/sendToGroup.ts b/ts/util/sendToGroup.ts index 5ac6b7ba5..c164858da 100644 --- a/ts/util/sendToGroup.ts +++ b/ts/util/sendToGroup.ts @@ -22,7 +22,7 @@ import { import { Address } from '../types/Address'; import { QualifiedAddress } from '../types/QualifiedAddress'; import { UUID } from '../types/UUID'; -import { isEnabled } from '../RemoteConfig'; +import { getValue, isEnabled } from '../RemoteConfig'; import { isRecord } from './isRecord'; import { isOlderThan } from './timestamp'; @@ -55,7 +55,6 @@ import { multiRecipient410ResponseSchema, } from '../textsecure/WebAPI'; import { SignalService as Proto } from '../protobuf'; -import * as RemoteConfig from '../RemoteConfig'; import { strictAssert } from './assert'; import * as log from '../logging/log'; @@ -169,8 +168,8 @@ export async function sendContentMessageToGroup({ if ( isEnabled('desktop.sendSenderKey3') && + isEnabled('desktop.senderKey.send') && ourConversation?.get('capabilities')?.senderKey && - RemoteConfig.isEnabled('desktop.senderKey.send') && sendTarget.isValid() ) { try { @@ -681,7 +680,7 @@ const MAX_SENDER_KEY_EXPIRE_DURATION = 90 * DAY; function getSenderKeyExpireDuration(): number { try { const parsed = parseIntOrThrow( - window.Signal.RemoteConfig.getValue('desktop.senderKeyMaxAge'), + getValue('desktop.senderKeyMaxAge'), 'getSenderKeyExpireDuration' ); From 7d8464757b77dcbb9f73d128a345050d73fe6b5f Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Tue, 3 May 2022 19:50:44 -0400 Subject: [PATCH 49/53] Story viewing improvements --- stylesheets/_variables.scss | 3 ++ stylesheets/components/StoryViewer.scss | 70 +++++++++++++++++++++++- ts/components/StoryViewer.stories.tsx | 6 +++ ts/components/StoryViewer.tsx | 72 +++++++++++++++++++++++++ 4 files changed, 149 insertions(+), 2 deletions(-) diff --git a/stylesheets/_variables.scss b/stylesheets/_variables.scss index ed4fe971b..df7efbf53 100644 --- a/stylesheets/_variables.scss +++ b/stylesheets/_variables.scss @@ -40,6 +40,7 @@ $color-black-alpha-05: rgba($color-black, 0.05); $color-black-alpha-06: rgba($color-black, 0.06); $color-black-alpha-08: rgba($color-black, 0.08); $color-black-alpha-12: rgba($color-black, 0.12); +$color-black-alpha-16: rgba($color-black, 0.16); $color-black-alpha-20: rgba($color-black, 0.2); $color-black-alpha-30: rgba($color-black, 0.3); $color-black-alpha-40: rgba($color-black, 0.4); @@ -49,6 +50,8 @@ $color-black-alpha-70: rgba($color-black, 0.7); $color-black-alpha-80: rgba($color-black, 0.8); $color-black-alpha-90: rgba($color-black, 0.9); +$color-transparent: rgba(0, 0, 0, 0); + $color-ultramarine-dark: #1851b4; $color-ultramarine-icon: #3a76f0; $color-ultramarine-light: #6191f3; diff --git a/stylesheets/components/StoryViewer.scss b/stylesheets/components/StoryViewer.scss index ae419de44..7f0197c13 100644 --- a/stylesheets/components/StoryViewer.scss +++ b/stylesheets/components/StoryViewer.scss @@ -72,7 +72,8 @@ padding: 0 16px; position: absolute; transform: translateX(-50%); - width: 284px; + min-width: 284px; + width: 56.25vh; z-index: $z-index-above-base; &--group-avatar { @@ -93,7 +94,7 @@ } &__caption { - @include font-body-1-bold; + @include font-body-1; color: $color-gray-05; padding: 4px 0; margin-bottom: 24px; @@ -146,4 +147,69 @@ height: 100%; } } + + &__arrow { + align-items: center; + display: flex; + height: 100vh; + position: absolute; + width: 25%; + + button { + @include button-reset; + height: 24px; + opacity: 0; + width: 24px; + transition: opacity 200ms ease-in-out; + } + + &--left { + justify-content: flex-start; + left: 0; + + button { + margin-left: 24px; + @include color-svg( + '../images/icons/v2/chevron-left-24.svg', + $color-white + ); + } + } + + &--right { + justify-content: flex-end; + right: 0; + + button { + margin-right: 24px; + @include color-svg( + '../images/icons/v2/chevron-right-24.svg', + $color-white + ); + } + } + + &--visible button { + opacity: 1; + visibility: visible; + } + } + + &__protection { + position: absolute; + width: 100%; + z-index: $z-index-above-base; + + &--top { + background: linear-gradient($color-black-alpha-16, $color-transparent); + top: 0; + height: 80px; + } + + &--bottom { + background: linear-gradient($color-transparent, $color-black-alpha-40); + bottom: 0; + height: 180px; + } + } } diff --git a/ts/components/StoryViewer.stories.tsx b/ts/components/StoryViewer.stories.tsx index 4811fd02a..f3b9ec515 100644 --- a/ts/components/StoryViewer.stories.tsx +++ b/ts/components/StoryViewer.stories.tsx @@ -42,6 +42,7 @@ function getDefaultProps(): PropsType { stories: [ { attachment: fakeAttachment({ + path: 'snow.jpg', url: '/fixtures/snow.jpg', }), messageId: '123', @@ -60,6 +61,7 @@ story.add('Wide story', () => ( stories={[ { attachment: fakeAttachment({ + path: 'file.jpg', url: '/fixtures/nathan-anderson-316188-unsplash.jpg', }), messageId: '123', @@ -89,6 +91,7 @@ story.add('Multi story', () => { stories={[ { attachment: fakeAttachment({ + path: 'snow.jpg', url: '/fixtures/snow.jpg', }), messageId: '123', @@ -97,6 +100,7 @@ story.add('Multi story', () => { }, { attachment: fakeAttachment({ + path: 'file.jpg', url: '/fixtures/nathan-anderson-316188-unsplash.jpg', }), messageId: '456', @@ -115,6 +119,7 @@ story.add('Caption', () => ( { attachment: fakeAttachment({ caption: 'This place looks lovely', + path: 'file.jpg', url: '/fixtures/nathan-anderson-316188-unsplash.jpg', }), messageId: '123', @@ -133,6 +138,7 @@ story.add('Long Caption', () => ( attachment: fakeAttachment({ caption: 'Snowycle, snowycle, snowycle\nI want to ride my snowycle, snowycle, snowycle\nI want to ride my snowycle\nI want to ride my snow\nI want to ride my snowycle\nI want to ride it where I like\nSnowycle, snowycle, snowycle\nI want to ride my snowycle, snowycle, snowycle\nI want to ride my snowycle\nI want to ride my snow\nI want to ride my snowycle\nI want to ride it where I like\nSnowycle, snowycle, snowycle\nI want to ride my snowycle, snowycle, snowycle\nI want to ride my snowycle\nI want to ride my snow\nI want to ride my snowycle\nI want to ride it where I like', + path: 'file.jpg', url: '/fixtures/snow.jpg', }), messageId: '123', diff --git a/ts/components/StoryViewer.tsx b/ts/components/StoryViewer.tsx index a1ed70fea..3f3f42c98 100644 --- a/ts/components/StoryViewer.tsx +++ b/ts/components/StoryViewer.tsx @@ -9,6 +9,7 @@ import React, { useRef, useState, } from 'react'; +import classNames from 'classnames'; import { useSpring, animated, to } from '@react-spring/web'; import type { BodyRangeType, LocalizerType } from '../types/Util'; import type { ConversationType } from '../state/ducks/conversations'; @@ -76,6 +77,13 @@ export type PropsType = { const CAPTION_BUFFER = 20; const CAPTION_INITIAL_LENGTH = 200; const CAPTION_MAX_LENGTH = 700; +const MOUSE_IDLE_TIME = 3000; + +enum Arrow { + None, + Left, + Right, +} export const StoryViewer = ({ conversationId, @@ -313,6 +321,38 @@ export const StoryViewer = ({ loadStoryReplies(conversationId, messageId); }, [conversationId, isGroupStory, loadStoryReplies, messageId]); + const [arrowToShow, setArrowToShow] = useState(Arrow.None); + + useEffect(() => { + if (arrowToShow === Arrow.None) { + return; + } + + let lastMouseMove: number | undefined; + + function updateLastMouseMove() { + lastMouseMove = Date.now(); + } + + function checkMouseIdle() { + requestAnimationFrame(() => { + if (lastMouseMove && Date.now() - lastMouseMove > MOUSE_IDLE_TIME) { + setArrowToShow(Arrow.None); + } else { + checkMouseIdle(); + } + }); + } + checkMouseIdle(); + + document.addEventListener('mousemove', updateLastMouseMove); + + return () => { + lastMouseMove = undefined; + document.removeEventListener('mousemove', updateLastMouseMove); + }; + }, [arrowToShow]); + const replies = replyState && replyState.messageId === messageId ? replyState.replies : []; @@ -327,6 +367,22 @@ export const StoryViewer = ({ style={{ background: getStoryBackground(attachment) }} />
+
setArrowToShow(Arrow.Left)} + > +
+
+
setArrowToShow(Arrow.Right)} + > +
+
); }; diff --git a/ts/components/StoryViewer.tsx b/ts/components/StoryViewer.tsx index 3f3f42c98..72027223d 100644 --- a/ts/components/StoryViewer.tsx +++ b/ts/components/StoryViewer.tsx @@ -18,6 +18,7 @@ import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; import type { RenderEmojiPickerProps } from './conversation/ReactionPicker'; import type { ReplyStateType } from '../types/Stories'; import type { StoryViewType } from './StoryListItem'; +import { AnimatedEmojiGalore } from './AnimatedEmojiGalore'; import { Avatar, AvatarSize } from './Avatar'; import { ConfirmationDialog } from './ConfirmationDialog'; import { ContextMenuPopper } from './ContextMenu'; @@ -117,6 +118,7 @@ export const StoryViewer = ({ const [hasConfirmHideStory, setHasConfirmHideStory] = useState(false); const [referenceElement, setReferenceElement] = useState(null); + const [reactionEmoji, setReactionEmoji] = useState(); const visibleStory = stories[currentStoryIndex]; @@ -263,7 +265,8 @@ export const StoryViewer = ({ hasConfirmHideStory || hasExpandedCaption || hasReplyModal || - isShowingContextMenu; + isShowingContextMenu || + Boolean(reactionEmoji); useEffect(() => { if (shouldPauseViewing) { @@ -392,7 +395,18 @@ export const StoryViewer = ({ moduleClassName="StoryViewer__story" queueStoryDownload={queueStoryDownload} storyId={messageId} - /> + > + {reactionEmoji && ( +
+ { + setReactionEmoji(undefined); + }} + /> +
+ )} + {hasExpandedCaption && (
+ type="button" + />
)} -
- {caption && ( -
- {caption.text} - {caption.hasReadMore && !hasExpandedCaption && ( - - )} -
- )} - - {group && ( - - )} -
- {group - ? i18n('Stories__from-to-group', { - name: title, - group: group.title, - }) - : title} -
- -
- {stories.map((story, index) => ( -
- {currentStoryIndex === index ? ( - `${width}%`), - }} - /> - ) : ( -
- )} -
- ))} -
-
- {canReply && ( +
+
+ {caption && ( +
+ {caption.text} + {caption.hasReadMore && !hasExpandedCaption && ( )}
+ )} + + {group && ( + + )} +
+ {group + ? i18n('Stories__from-to-group', { + name: title, + group: group.title, + }) + : title} +
+ +
+ {stories.map((story, index) => ( +
+ {currentStoryIndex === index ? ( + `${width}%`), + }} + /> + ) : ( +
+ )} +
+ ))} +
+
+ {canReply && ( + + )}
-
setArrowToShow(Arrow.Right)} - > -
+ type="button" + />