diff --git a/ts/background.ts b/ts/background.ts index ac4803a68..1ffa52bde 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -58,6 +58,7 @@ import { StickerPackEvent, VerifiedEvent, ReadSyncEvent, + ViewSyncEvent, ContactEvent, GroupEvent, EnvelopeEvent, @@ -79,7 +80,9 @@ import { import { MessageRequests } from './messageModifiers/MessageRequests'; import { Reactions } from './messageModifiers/Reactions'; import { ReadSyncs } from './messageModifiers/ReadSyncs'; +import { ViewSyncs } from './messageModifiers/ViewSyncs'; import { ViewOnceOpenSyncs } from './messageModifiers/ViewOnceOpenSyncs'; +import { ReadStatus } from './messages/MessageReadStatus'; import { SendStateByConversationId, SendStatus, @@ -211,6 +214,10 @@ export async function startApp(): Promise { 'readSync', queuedEventListener(onReadSync) ); + messageReceiver.addEventListener( + 'viewSync', + queuedEventListener(onViewSync) + ); messageReceiver.addEventListener( 'read', queuedEventListener(onReadReceipt) @@ -3518,7 +3525,7 @@ export async function startApp(): Promise { conversationId: descriptor.id, unidentifiedDeliveryReceived: data.unidentifiedDeliveryReceived, type: 'incoming', - unread: true, + readStatus: ReadStatus.Unread, timestamp: data.timestamp, } as Partial) as WhatIsThis); } @@ -3851,6 +3858,38 @@ export async function startApp(): Promise { return ReadSyncs.getSingleton().onSync(receipt); } + function onViewSync(ev: ViewSyncEvent) { + const { envelopeTimestamp, senderE164, senderUuid, timestamp } = ev.view; + const senderId = window.ConversationController.ensureContactIds({ + e164: senderE164, + uuid: senderUuid, + }); + + window.log.info( + 'view sync', + senderE164, + senderUuid, + envelopeTimestamp, + senderId, + 'for message', + timestamp + ); + + const receipt = ViewSyncs.getSingleton().add({ + senderId, + senderE164, + senderUuid, + timestamp, + viewedAt: envelopeTimestamp, + }); + + receipt.on('remove', ev.confirm); + + // Note: Here we wait, because we want viewed states to be in the database + // before we move on. + return ViewSyncs.getSingleton().onSync(receipt); + } + async function onVerified(ev: VerifiedEvent) { const e164 = ev.verified.destination; const uuid = ev.verified.destinationUuid; diff --git a/ts/components/conversation/Message.stories.tsx b/ts/components/conversation/Message.stories.tsx index c48cbf9d3..459bace59 100644 --- a/ts/components/conversation/Message.stories.tsx +++ b/ts/components/conversation/Message.stories.tsx @@ -20,6 +20,7 @@ import { VIDEO_MP4, stringToMIMEType, } from '../../types/MIME'; +import { ReadStatus } from '../../messages/MessageReadStatus'; import { MessageAudio } from './MessageAudio'; import { computePeaks } from '../GlobalAudioContext'; import { setup as setupI18n } from '../../../js/modules/i18n'; @@ -61,6 +62,7 @@ const MessageAudioContainer: React.FC = props => { audio={audio} computePeaks={computePeaks} setActiveAudioID={(id, context) => setActive({ id, context })} + onFirstPlayed={action('onFirstPlayed')} activeAudioID={active.id} activeAudioContext={active.context} /> @@ -120,12 +122,17 @@ const createProps = (overrideProps: Partial = {}): Props => ({ isTapToViewExpired: overrideProps.isTapToViewExpired, kickOffAttachmentDownload: action('kickOffAttachmentDownload'), markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'), + markViewed: action('markViewed'), onHeightChange: action('onHeightChange'), openConversation: action('openConversation'), openLink: action('openLink'), previews: overrideProps.previews || [], reactions: overrideProps.reactions, reactToMessage: action('reactToMessage'), + readStatus: + overrideProps.readStatus === undefined + ? ReadStatus.Read + : overrideProps.readStatus, renderEmojiPicker, renderAudioAttachment, replyToMessage: action('replyToMessage'), @@ -866,33 +873,48 @@ story.add('Pending GIF', () => { }); story.add('Audio', () => { - const props = createProps({ - attachments: [ - { - contentType: AUDIO_MP3, - fileName: 'incompetech-com-Agnus-Dei-X.mp3', - url: '/fixtures/incompetech-com-Agnus-Dei-X.mp3', - }, - ], - status: 'sent', - }); + const Wrapper = () => { + const [isPlayed, setIsPlayed] = React.useState(false); - return renderBothDirections(props); -}); + const messageProps = createProps({ + attachments: [ + { + contentType: AUDIO_MP3, + fileName: 'incompetech-com-Agnus-Dei-X.mp3', + url: '/fixtures/incompetech-com-Agnus-Dei-X.mp3', + }, + ], + ...(isPlayed + ? { + status: 'viewed', + readStatus: ReadStatus.Viewed, + } + : { + status: 'read', + readStatus: ReadStatus.Read, + }), + }); -story.add('Audio (played)', () => { - const props = createProps({ - attachments: [ - { - contentType: AUDIO_MP3, - fileName: 'incompetech-com-Agnus-Dei-X.mp3', - url: '/fixtures/incompetech-com-Agnus-Dei-X.mp3', - }, - ], - status: 'viewed', - }); + return ( + <> + + {renderBothDirections(messageProps)} + + ); + }; - return renderBothDirections(props); + return ; }); story.add('Long Audio', () => { diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 02316d54d..b153bf13b 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -13,6 +13,7 @@ import { ConversationTypeType, InteractionModeType, } from '../../state/ducks/conversations'; +import { ReadStatus } from '../../messages/MessageReadStatus'; import { Avatar } from '../Avatar'; import { Spinner } from '../Spinner'; import { MessageBody } from './MessageBody'; @@ -110,6 +111,7 @@ export type AudioAttachmentProps = { kickOffAttachmentDownload(): void; onCorrupted(): void; + onFirstPlayed(): void; }; export type PropsData = { @@ -167,6 +169,8 @@ export type PropsData = { isTapToViewExpired?: boolean; isTapToViewError?: boolean; + readStatus: ReadStatus; + expirationLength?: number; expirationTimestamp?: number; @@ -225,6 +229,7 @@ export type PropsActions = { attachment: AttachmentType; messageId: string; }) => void; + markViewed(messageId: string): void; showVisualAttachment: (options: { attachment: AttachmentType; messageId: string; @@ -684,7 +689,9 @@ export class Message extends React.PureComponent { isSticker, kickOffAttachmentDownload, markAttachmentAsCorrupted, + markViewed, quote, + readStatus, reducedMotion, renderAudioAttachment, renderingContext, @@ -791,8 +798,7 @@ export class Message extends React.PureComponent { played = status === 'viewed'; break; case 'incoming': - // Not implemented yet. See DESKTOP-1855. - played = true; + played = readStatus === ReadStatus.Viewed; break; default: window.log.error(missingCaseError(direction)); @@ -831,6 +837,9 @@ export class Message extends React.PureComponent { messageId: id, }); }, + onFirstPlayed() { + markViewed(id); + }, }); } const { pending, fileName, fileSize, contentType } = firstAttachment; diff --git a/ts/components/conversation/MessageAudio.tsx b/ts/components/conversation/MessageAudio.tsx index df8ee9144..cffd20edb 100644 --- a/ts/components/conversation/MessageAudio.tsx +++ b/ts/components/conversation/MessageAudio.tsx @@ -37,6 +37,7 @@ export type Props = { buttonRef: React.RefObject; kickOffAttachmentDownload(): void; onCorrupted(): void; + onFirstPlayed(): void; computePeaks(url: string, barCount: number): Promise; activeAudioID: string | undefined; @@ -163,6 +164,7 @@ export const MessageAudio: React.FC = (props: Props) => { buttonRef, kickOffAttachmentDownload, onCorrupted, + onFirstPlayed, audio, computePeaks, @@ -365,6 +367,12 @@ export const MessageAudio: React.FC = (props: Props) => { } }; + useEffect(() => { + if (!played && isPlaying) { + onFirstPlayed(); + } + }, [played, isPlaying, onFirstPlayed]); + // Clicking waveform moves playback head position and starts playback. const onWaveformClick = (event: React.MouseEvent) => { event.preventDefault(); diff --git a/ts/components/conversation/MessageDetail.stories.tsx b/ts/components/conversation/MessageDetail.stories.tsx index e05623c04..ccbb3a89d 100644 --- a/ts/components/conversation/MessageDetail.stories.tsx +++ b/ts/components/conversation/MessageDetail.stories.tsx @@ -10,6 +10,7 @@ import { storiesOf } from '@storybook/react'; import { PropsData as MessageDataPropsType } from './Message'; import { MessageDetail, Props } from './MessageDetail'; import { SendStatus } from '../../messages/MessageSendState'; +import { ReadStatus } from '../../messages/MessageReadStatus'; import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation'; import { setup as setupI18n } from '../../../js/modules/i18n'; import enMessages from '../../../_locales/en/messages.json'; @@ -35,6 +36,7 @@ const defaultMessage: MessageDataPropsType = { isBlocked: false, isMessageRequestAccepted: true, previews: [], + readStatus: ReadStatus.Read, status: 'sent', text: 'A message from Max', timestamp: Date.now(), @@ -71,6 +73,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ doubleCheckMissingQuoteReference: action('doubleCheckMissingQuoteReference'), kickOffAttachmentDownload: action('kickOffAttachmentDownload'), markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'), + markViewed: action('markViewed'), openConversation: action('openConversation'), openLink: action('openLink'), reactToMessage: action('reactToMessage'), diff --git a/ts/components/conversation/MessageDetail.tsx b/ts/components/conversation/MessageDetail.tsx index 9f5451a5a..2ec2d92f6 100644 --- a/ts/components/conversation/MessageDetail.tsx +++ b/ts/components/conversation/MessageDetail.tsx @@ -69,6 +69,7 @@ export type Props = { | 'interactionMode' | 'kickOffAttachmentDownload' | 'markAttachmentAsCorrupted' + | 'markViewed' | 'openConversation' | 'openLink' | 'reactToMessage' @@ -269,6 +270,7 @@ export class MessageDetail extends React.Component { interactionMode, kickOffAttachmentDownload, markAttachmentAsCorrupted, + markViewed, openConversation, openLink, reactToMessage, @@ -305,6 +307,7 @@ export class MessageDetail extends React.Component { interactionMode={interactionMode} kickOffAttachmentDownload={kickOffAttachmentDownload} markAttachmentAsCorrupted={markAttachmentAsCorrupted} + markViewed={markViewed} onHeightChange={noop} openConversation={openConversation} openLink={openLink} diff --git a/ts/components/conversation/Quote.stories.tsx b/ts/components/conversation/Quote.stories.tsx index 8ff5b49de..e3de0741d 100644 --- a/ts/components/conversation/Quote.stories.tsx +++ b/ts/components/conversation/Quote.stories.tsx @@ -19,6 +19,7 @@ import { stringToMIMEType, } from '../../types/MIME'; import { Props, Quote } from './Quote'; +import { ReadStatus } from '../../messages/MessageReadStatus'; import { setup as setupI18n } from '../../../js/modules/i18n'; import enMessages from '../../../_locales/en/messages.json'; import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation'; @@ -56,11 +57,13 @@ const defaultMessageProps: MessagesProps = { isMessageRequestAccepted: true, kickOffAttachmentDownload: action('default--kickOffAttachmentDownload'), markAttachmentAsCorrupted: action('default--markAttachmentAsCorrupted'), + markViewed: action('default--markViewed'), onHeightChange: action('onHeightChange'), openConversation: action('default--openConversation'), openLink: action('default--openLink'), previews: [], reactToMessage: action('default--reactToMessage'), + readStatus: ReadStatus.Read, renderEmojiPicker: () =>
, renderAudioAttachment: () =>
*AudioAttachment*
, replyToMessage: action('default--replyToMessage'), diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index 62c4a0893..59883abf9 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -20,6 +20,7 @@ import { LastSeenIndicator } from './LastSeenIndicator'; import { TimelineLoadingRow } from './TimelineLoadingRow'; import { TypingBubble } from './TypingBubble'; import { ContactSpoofingType } from '../../util/contactSpoofing'; +import { ReadStatus } from '../../messages/MessageReadStatus'; const i18n = setupI18n('en', enMessages); @@ -51,6 +52,7 @@ const items: Record = { isBlocked: false, isMessageRequestAccepted: true, previews: [], + readStatus: ReadStatus.Read, text: '🔥', timestamp: Date.now(), }, @@ -70,6 +72,7 @@ const items: Record = { isBlocked: false, isMessageRequestAccepted: true, previews: [], + readStatus: ReadStatus.Read, text: 'Hello there from the new world! http://somewhere.com', timestamp: Date.now(), }, @@ -102,6 +105,7 @@ const items: Record = { isBlocked: false, isMessageRequestAccepted: true, previews: [], + readStatus: ReadStatus.Read, text: 'Hello there from the new world!', timestamp: Date.now(), }, @@ -200,6 +204,7 @@ const items: Record = { isBlocked: false, isMessageRequestAccepted: true, previews: [], + readStatus: ReadStatus.Read, status: 'sent', text: '🔥', timestamp: Date.now(), @@ -220,6 +225,7 @@ const items: Record = { isBlocked: false, isMessageRequestAccepted: true, previews: [], + readStatus: ReadStatus.Read, status: 'read', text: 'Hello there from the new world! http://somewhere.com', timestamp: Date.now(), @@ -240,6 +246,7 @@ const items: Record = { isBlocked: false, isMessageRequestAccepted: true, previews: [], + readStatus: ReadStatus.Read, status: 'sent', text: 'Hello there from the new world! 🔥', timestamp: Date.now(), @@ -260,6 +267,7 @@ const items: Record = { isBlocked: false, isMessageRequestAccepted: true, previews: [], + readStatus: ReadStatus.Read, status: 'sent', text: 'Hello there from the new world! And this is multiple lines of text. Lines and lines and lines.', @@ -281,6 +289,7 @@ const items: Record = { isBlocked: false, isMessageRequestAccepted: true, previews: [], + readStatus: ReadStatus.Read, status: 'read', text: 'Hello there from the new world! And this is multiple lines of text. Lines and lines and lines.', @@ -325,6 +334,7 @@ const actions = () => ({ showContactModal: action('showContactModal'), kickOffAttachmentDownload: action('kickOffAttachmentDownload'), markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'), + markViewed: action('markViewed'), showVisualAttachment: action('showVisualAttachment'), downloadAttachment: action('downloadAttachment'), displayTapToViewMessage: action('displayTapToViewMessage'), diff --git a/ts/components/conversation/Timeline.tsx b/ts/components/conversation/Timeline.tsx index 1ecf0a7d8..7cf0fd5a8 100644 --- a/ts/components/conversation/Timeline.tsx +++ b/ts/components/conversation/Timeline.tsx @@ -1496,6 +1496,7 @@ export class Timeline extends React.PureComponent { 'loadNewerMessages', 'loadNewestMessages', 'markMessageRead', + 'markViewed', 'onBlock', 'onBlockAndReportSpam', 'onDelete', diff --git a/ts/components/conversation/TimelineItem.stories.tsx b/ts/components/conversation/TimelineItem.stories.tsx index d066ad832..28a3bbf2a 100644 --- a/ts/components/conversation/TimelineItem.stories.tsx +++ b/ts/components/conversation/TimelineItem.stories.tsx @@ -57,6 +57,7 @@ const getDefaultProps = () => ({ kickOffAttachmentDownload: action('kickOffAttachmentDownload'), learnMoreAboutDeliveryIssue: action('learnMoreAboutDeliveryIssue'), markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'), + markViewed: action('markViewed'), showMessageDetail: action('showMessageDetail'), openConversation: action('openConversation'), showContactDetail: action('showContactDetail'), diff --git a/ts/jobs/helpers/runReadOrViewSyncJob.ts b/ts/jobs/helpers/runReadOrViewSyncJob.ts new file mode 100644 index 000000000..26a389995 --- /dev/null +++ b/ts/jobs/helpers/runReadOrViewSyncJob.ts @@ -0,0 +1,118 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { chunk } from 'lodash'; +import * as log from '../../logging/log'; +import { waitForOnline } from '../../util/waitForOnline'; +import { getSendOptions } from '../../util/getSendOptions'; +import { handleMessageSend, SendTypesType } from '../../util/handleMessageSend'; +import { isNotNil } from '../../util/isNotNil'; +import { sleep } from '../../util/sleep'; +import { exponentialBackoffSleepTime } from '../../util/exponentialBackoff'; +import { isDone as isDeviceLinked } from '../../util/registration'; +import { parseIntWithFallback } from '../../util/parseIntWithFallback'; + +const CHUNK_SIZE = 100; + +export async function runReadOrViewSyncJob({ + attempt, + isView, + maxRetryTime, + syncs, + timestamp, +}: Readonly<{ + attempt: number; + isView: boolean; + maxRetryTime: number; + syncs: ReadonlyArray<{ + messageId?: string; + senderE164?: string; + senderUuid?: string; + timestamp: number; + }>; + timestamp: number; +}>): Promise { + let sendType: SendTypesType; + let nameForLogging: string; + let doSync: + | typeof window.textsecure.messaging.syncReadMessages + | typeof window.textsecure.messaging.syncView; + if (isView) { + sendType = 'viewSync'; + nameForLogging = 'viewSyncJobQueue'; + doSync = window.textsecure.messaging.syncView.bind( + window.textsecure.messaging + ); + } else { + sendType = 'readSync'; + nameForLogging = 'readSyncJobQueue'; + doSync = window.textsecure.messaging.syncReadMessages.bind( + window.textsecure.messaging + ); + } + + const logInfo = (message: string): void => { + log.info(`${nameForLogging}: ${message}`); + }; + + if (!syncs.length) { + logInfo("skipping this job because there's nothing to sync"); + return; + } + + const maxJobAge = timestamp + maxRetryTime; + const timeRemaining = maxJobAge - Date.now(); + + if (timeRemaining <= 0) { + logInfo("giving up because it's been too long"); + return; + } + + try { + await waitForOnline(window.navigator, window, { timeout: timeRemaining }); + } catch (err) { + logInfo("didn't come online in time, giving up"); + return; + } + + await new Promise(resolve => { + window.storage.onready(resolve); + }); + + if (!isDeviceLinked()) { + logInfo("skipping this job because we're unlinked"); + return; + } + + await sleep(exponentialBackoffSleepTime(attempt)); + + const ourConversation = window.ConversationController.getOurConversationOrThrow(); + const sendOptions = await getSendOptions(ourConversation.attributes, { + syncMessage: true, + }); + + try { + await Promise.all( + chunk(syncs, CHUNK_SIZE).map(batch => { + const messageIds = batch.map(item => item.messageId).filter(isNotNil); + + return handleMessageSend(doSync(batch, sendOptions), { + messageIds, + sendType, + }); + }) + ); + } catch (err: unknown) { + if (!(err instanceof Error)) { + throw err; + } + + const code = parseIntWithFallback(err.code, -1); + if (code === 508) { + logInfo('server responded with 508. Giving up on this job'); + return; + } + + throw err; + } +} diff --git a/ts/jobs/initializeAllJobQueues.ts b/ts/jobs/initializeAllJobQueues.ts index 0068202ff..532e8c4bb 100644 --- a/ts/jobs/initializeAllJobQueues.ts +++ b/ts/jobs/initializeAllJobQueues.ts @@ -6,6 +6,7 @@ import type { WebAPIType } from '../textsecure/WebAPI'; import { readSyncJobQueue } from './readSyncJobQueue'; import { removeStorageKeyJobQueue } from './removeStorageKeyJobQueue'; import { reportSpamJobQueue } from './reportSpamJobQueue'; +import { viewSyncJobQueue } from './viewSyncJobQueue'; /** * Start all of the job queues. Should be called when the database is ready. @@ -20,4 +21,5 @@ export function initializeAllJobQueues({ readSyncJobQueue.streamJobs(); removeStorageKeyJobQueue.streamJobs(); reportSpamJobQueue.streamJobs(); + viewSyncJobQueue.streamJobs(); } diff --git a/ts/jobs/readSyncJobQueue.ts b/ts/jobs/readSyncJobQueue.ts index 3078dd59b..95d09b0d3 100644 --- a/ts/jobs/readSyncJobQueue.ts +++ b/ts/jobs/readSyncJobQueue.ts @@ -5,25 +5,12 @@ import * as z from 'zod'; import * as moment from 'moment'; -import { chunk } from 'lodash'; -import { getSendOptions } from '../util/getSendOptions'; -import { handleMessageSend } from '../util/handleMessageSend'; -import { isNotNil } from '../util/isNotNil'; -import { sleep } from '../util/sleep'; -import { - exponentialBackoffSleepTime, - exponentialBackoffMaxAttempts, -} from '../util/exponentialBackoff'; -import * as log from '../logging/log'; -import { isDone as isDeviceLinked } from '../util/registration'; -import { waitForOnline } from '../util/waitForOnline'; -import { parseIntWithFallback } from '../util/parseIntWithFallback'; +import { exponentialBackoffMaxAttempts } from '../util/exponentialBackoff'; +import { runReadOrViewSyncJob } from './helpers/runReadOrViewSyncJob'; import { JobQueue } from './JobQueue'; import { jobQueueDatabaseStore } from './JobQueueDatabaseStore'; -const CHUNK_SIZE = 100; - const MAX_RETRY_TIME = moment.duration(1, 'day').asMilliseconds(); const readSyncJobDataSchema = z.object({ @@ -48,71 +35,13 @@ export class ReadSyncJobQueue extends JobQueue { { data, timestamp }: Readonly<{ data: ReadSyncJobData; timestamp: number }>, { attempt }: Readonly<{ attempt: number }> ): Promise { - const { readSyncs } = data; - if (!readSyncs.length) { - log.info( - "readSyncJobQueue: skipping this job because there's nothing to sync" - ); - return; - } - - const maxJobAge = timestamp + MAX_RETRY_TIME; - const timeRemaining = maxJobAge - Date.now(); - - if (timeRemaining <= 0) { - log.info("readSyncJobQueue: giving up because it's been too long"); - return; - } - - try { - await waitForOnline(window.navigator, window, { timeout: timeRemaining }); - } catch (err) { - log.info("readSyncJobQueue: didn't come online in time, giving up"); - return; - } - - await new Promise(resolve => { - window.storage.onready(resolve); + await runReadOrViewSyncJob({ + attempt, + isView: false, + maxRetryTime: MAX_RETRY_TIME, + syncs: data.readSyncs, + timestamp, }); - - if (!isDeviceLinked()) { - log.info("readSyncJobQueue: skipping this job because we're unlinked"); - return; - } - - await sleep(exponentialBackoffSleepTime(attempt)); - - const ourConversation = window.ConversationController.getOurConversationOrThrow(); - const sendOptions = await getSendOptions(ourConversation.attributes, { - syncMessage: true, - }); - - try { - await Promise.all( - chunk(readSyncs, CHUNK_SIZE).map(batch => { - const messageIds = batch.map(item => item.messageId).filter(isNotNil); - - return handleMessageSend( - window.textsecure.messaging.syncReadMessages(batch, sendOptions), - { messageIds, sendType: 'readSync' } - ); - }) - ); - } catch (err: unknown) { - if (!(err instanceof Error)) { - throw err; - } - - const code = parseIntWithFallback(err.code, -1); - if (code === 508) { - log.info( - 'readSyncJobQueue: server responded with 508. Giving up on this job' - ); - return; - } - - throw err; - } } } diff --git a/ts/jobs/viewSyncJobQueue.ts b/ts/jobs/viewSyncJobQueue.ts new file mode 100644 index 000000000..b2985bd55 --- /dev/null +++ b/ts/jobs/viewSyncJobQueue.ts @@ -0,0 +1,54 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +/* eslint-disable class-methods-use-this */ + +import * as z from 'zod'; +import * as moment from 'moment'; +import { exponentialBackoffMaxAttempts } from '../util/exponentialBackoff'; +import { runReadOrViewSyncJob } from './helpers/runReadOrViewSyncJob'; + +import { JobQueue } from './JobQueue'; +import { jobQueueDatabaseStore } from './JobQueueDatabaseStore'; + +const MAX_RETRY_TIME = moment.duration(1, 'day').asMilliseconds(); + +const viewSyncJobDataSchema = z.object({ + viewSyncs: z.array( + z.object({ + messageId: z.string().optional(), + senderE164: z.string().optional(), + senderUuid: z.string().optional(), + timestamp: z.number(), + }) + ), +}); + +export type ViewSyncJobData = z.infer; + +export class ViewSyncJobQueue extends JobQueue { + protected parseData(data: unknown): ViewSyncJobData { + return viewSyncJobDataSchema.parse(data); + } + + protected async run( + { data, timestamp }: Readonly<{ data: ViewSyncJobData; timestamp: number }>, + { attempt }: Readonly<{ attempt: number }> + ): Promise { + await runReadOrViewSyncJob({ + attempt, + isView: true, + maxRetryTime: MAX_RETRY_TIME, + syncs: data.viewSyncs, + timestamp, + }); + } +} + +export const viewSyncJobQueue = new ViewSyncJobQueue({ + store: jobQueueDatabaseStore, + + queueType: 'view sync', + + maxAttempts: exponentialBackoffMaxAttempts(MAX_RETRY_TIME), +}); diff --git a/ts/messageModifiers/ViewSyncs.ts b/ts/messageModifiers/ViewSyncs.ts new file mode 100644 index 000000000..4f31ee838 --- /dev/null +++ b/ts/messageModifiers/ViewSyncs.ts @@ -0,0 +1,101 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +/* eslint-disable max-classes-per-file */ + +import { Collection, Model } from 'backbone'; + +import { MessageModel } from '../models/messages'; +import { ReadStatus } from '../messages/MessageReadStatus'; +import { markViewed } from '../services/MessageUpdater'; +import { isIncoming } from '../state/selectors/message'; + +type ViewSyncAttributesType = { + senderId: string; + senderE164: string; + senderUuid: string; + timestamp: number; + viewedAt: number; +}; + +class ViewSyncModel extends Model {} + +let singleton: ViewSyncs | undefined; + +export class ViewSyncs extends Collection { + static getSingleton(): ViewSyncs { + if (!singleton) { + singleton = new ViewSyncs(); + } + + return singleton; + } + + forMessage(message: MessageModel): Array { + const senderId = window.ConversationController.ensureContactIds({ + e164: message.get('source'), + uuid: message.get('sourceUuid'), + }); + const syncs = this.filter(item => { + return ( + item.get('senderId') === senderId && + item.get('timestamp') === message.get('sent_at') + ); + }); + if (syncs.length) { + window.log.info( + `Found ${syncs.length} early view sync(s) for message ${message.get( + 'sent_at' + )}` + ); + this.remove(syncs); + } + return syncs; + } + + async onSync(sync: ViewSyncModel): Promise { + try { + const messages = await window.Signal.Data.getMessagesBySentAt( + sync.get('timestamp'), + { + MessageCollection: window.Whisper.MessageCollection, + } + ); + + const found = messages.find(item => { + const senderId = window.ConversationController.ensureContactIds({ + e164: item.get('source'), + uuid: item.get('sourceUuid'), + }); + + return isIncoming(item.attributes) && senderId === sync.get('senderId'); + }); + + if (!found) { + window.log.info( + 'Nothing found for view sync', + sync.get('senderId'), + sync.get('senderE164'), + sync.get('senderUuid'), + sync.get('timestamp') + ); + return; + } + + window.Whisper.Notifications.removeBy({ messageId: found.id }); + + const message = window.MessageController.register(found.id, found); + + if (message.get('readStatus') !== ReadStatus.Viewed) { + message.set(markViewed(message.attributes, sync.get('viewedAt'))); + } + + this.remove(sync); + } catch (error) { + window.log.error( + 'ViewSyncs.onSync error:', + error && error.stack ? error.stack : error + ); + } + } +} diff --git a/ts/messages/MessageReadStatus.ts b/ts/messages/MessageReadStatus.ts new file mode 100644 index 000000000..f00e3634b --- /dev/null +++ b/ts/messages/MessageReadStatus.ts @@ -0,0 +1,26 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +/** + * `ReadStatus` represents your local read/viewed status of a single incoming message. + * Messages go from Unread to Read to Viewed; they never go "backwards". + * + * Note that a conversation can be marked unread, which is not at the message level. + * + * Be careful when changing these values, as they are persisted. Notably, we previously + * had a field called "unread", which is why Unread corresponds to 1 and Read to 0. + */ +export enum ReadStatus { + Unread = 1, + Read = 0, + Viewed = 2, +} + +const STATUS_NUMBERS: Record = { + [ReadStatus.Unread]: 0, + [ReadStatus.Read]: 1, + [ReadStatus.Viewed]: 2, +}; + +export const maxReadStatus = (a: ReadStatus, b: ReadStatus): ReadStatus => + STATUS_NUMBERS[a] > STATUS_NUMBERS[b] ? a : b; diff --git a/ts/messages/migrateLegacyReadStatus.ts b/ts/messages/migrateLegacyReadStatus.ts new file mode 100644 index 000000000..b09e6fd0f --- /dev/null +++ b/ts/messages/migrateLegacyReadStatus.ts @@ -0,0 +1,17 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { MessageAttributesType } from '../model-types.d'; +import { ReadStatus } from './MessageReadStatus'; + +export function migrateLegacyReadStatus( + message: Readonly> +): undefined | ReadStatus { + const shouldMigrate = message.readStatus == null; + if (!shouldMigrate) { + return; + } + + const legacyUnread = (message as Record).unread; + return legacyUnread ? ReadStatus.Unread : ReadStatus.Read; +} diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 3a1368e0a..f06b0f0b8 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -15,6 +15,7 @@ import { MessageModel } from './models/messages'; import { ConversationModel } from './models/conversations'; import { ProfileNameChangeType } from './util/getStringForProfileChange'; import { CapabilitiesType } from './textsecure/WebAPI'; +import { ReadStatus } from './messages/MessageReadStatus'; import { SendState, SendStateByConversationId, @@ -182,7 +183,6 @@ export type MessageAttributesType = { source?: string; sourceUuid?: string; - unread?: boolean; timestamp: number; // Backwards-compatibility with prerelease data schema @@ -191,6 +191,9 @@ export type MessageAttributesType = { sendHQImages?: boolean; + // Should only be present for incoming messages + readStatus?: ReadStatus; + // Should only be present for outgoing messages sendStateByConversationId?: SendStateByConversationId; }; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index c413c790e..1c8de7d88 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -52,6 +52,7 @@ import { handleMessageSend } from '../util/handleMessageSend'; import { getConversationMembers } from '../util/getConversationMembers'; import { sendReadReceiptsFor } from '../util/sendReadReceiptsFor'; import { updateConversationsWithUuidLookup } from '../updateConversationsWithUuidLookup'; +import { ReadStatus } from '../messages/MessageReadStatus'; import { SendStatus } from '../messages/MessageSendState'; import { concat, @@ -2454,7 +2455,7 @@ export class ConversationModel extends window.Backbone sent_at: receivedAt, received_at: receivedAtCounter, received_at_ms: receivedAt, - unread: 1, + readStatus: ReadStatus.Unread, // TODO: DESKTOP-722 // this type does not fully implement the interface it is expected to } as unknown) as typeof window.Whisper.MessageAttributesType; @@ -2494,7 +2495,7 @@ export class ConversationModel extends window.Backbone sent_at: receivedAt, received_at: receivedAtCounter, received_at_ms: receivedAt, - unread: 1, + readStatus: ReadStatus.Unread, // TODO: DESKTOP-722 // this type does not fully implement the interface it is expected to } as unknown) as typeof window.Whisper.MessageAttributesType; @@ -2529,7 +2530,7 @@ export class ConversationModel extends window.Backbone received_at: window.Signal.Util.incrementMessageCounter(), received_at_ms: timestamp, key_changed: keyChangedId, - unread: 1, + readStatus: ReadStatus.Unread, schemaVersion: Message.VERSION_NEEDED_FOR_DISPLAY, // TODO: DESKTOP-722 // this type does not fully implement the interface it is expected to @@ -2589,7 +2590,7 @@ export class ConversationModel extends window.Backbone verifiedChanged: verifiedChangeId, verified, local: options.local, - unread: 1, + readStatus: ReadStatus.Unread, // TODO: DESKTOP-722 } as unknown) as typeof window.Whisper.MessageAttributesType; @@ -2647,7 +2648,7 @@ export class ConversationModel extends window.Backbone sent_at: timestamp, received_at: window.Signal.Util.incrementMessageCounter(), received_at_ms: timestamp, - unread, + readStatus: unread ? ReadStatus.Unread : ReadStatus.Read, callHistoryDetails: detailsToSave, // TODO: DESKTOP-722 } as unknown) as typeof window.Whisper.MessageAttributesType; @@ -2697,7 +2698,7 @@ export class ConversationModel extends window.Backbone sent_at: now, received_at: window.Signal.Util.incrementMessageCounter(), received_at_ms: now, - unread: false, + readStatus: ReadStatus.Read, changedId: conversationId || this.id, profileChange, // TODO: DESKTOP-722 @@ -2738,7 +2739,7 @@ export class ConversationModel extends window.Backbone sent_at: now, received_at: window.Signal.Util.incrementMessageCounter(), received_at_ms: now, - unread: false, + readStatus: ReadStatus.Read, }; const id = await window.Signal.Data.saveMessage( @@ -4163,7 +4164,7 @@ export class ConversationModel extends window.Backbone 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. - unread: 1, + readStatus: ReadStatus.Unread, conversationId: this.id, // No type; 'incoming' messages are specially treated by conversation.markRead() sent_at: timestamp, @@ -4266,7 +4267,7 @@ export class ConversationModel extends window.Backbone 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. - unread: 1, + readStatus: ReadStatus.Unread, conversationId: this.id, sent_at: timestamp, received_at: window.Signal.Util.incrementMessageCounter(), diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 3e4eb47d0..861270e90 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -44,6 +44,7 @@ import * as Stickers from '../types/Stickers'; import { AttachmentType, isImage, isVideo } from '../types/Attachment'; import { IMAGE_WEBP, stringToMIMEType } from '../types/MIME'; import { ourProfileKeyService } from '../services/ourProfileKey'; +import { ReadStatus } from '../messages/MessageReadStatus'; import { SendActionType, SendStateByConversationId, @@ -53,9 +54,10 @@ import { sendStateReducer, someSendStatus, } from '../messages/MessageSendState'; +import { migrateLegacyReadStatus } from '../messages/migrateLegacyReadStatus'; import { migrateLegacySendAttributes } from '../messages/migrateLegacySendAttributes'; import { getOwn } from '../util/getOwn'; -import { markRead } from '../services/MessageUpdater'; +import { markRead, markViewed } from '../services/MessageUpdater'; import { isMessageUnread } from '../util/isMessageUnread'; import { isDirectConversation, @@ -104,6 +106,7 @@ import { import { Deletes } from '../messageModifiers/Deletes'; import { Reactions } from '../messageModifiers/Reactions'; import { ReadSyncs } from '../messageModifiers/ReadSyncs'; +import { ViewSyncs } from '../messageModifiers/ViewSyncs'; import { ViewOnceOpenSyncs } from '../messageModifiers/ViewOnceOpenSyncs'; import * as AttachmentDownloads from '../messageModifiers/AttachmentDownloads'; import * as LinkPreview from '../types/LinkPreview'; @@ -194,6 +197,11 @@ export class MessageModel extends window.Backbone.Model { ); } + const readStatus = migrateLegacyReadStatus(this.attributes); + if (readStatus !== undefined) { + this.set('readStatus', readStatus, { silent: true }); + } + const sendStateByConversationId = migrateLegacySendAttributes( this.attributes, window.ConversationController.get.bind(window.ConversationController), @@ -835,8 +843,8 @@ export class MessageModel extends window.Backbone.Model { return; } - if (isMessageUnread(this.attributes)) { - this.set(markRead(this.attributes)); + if (this.get('readStatus') !== ReadStatus.Viewed) { + this.set(markViewed(this.attributes)); } await this.eraseContents(); @@ -3269,19 +3277,41 @@ export class MessageModel extends window.Backbone.Model { } if (type === 'incoming') { + // In a followup (see DESKTOP-2100), we want to make `ReadSyncs#forMessage` return + // an array, not an object. This array wrapping makes that future a bit easier. const readSync = ReadSyncs.getSingleton().forMessage(message); - if (readSync) { - if ( - message.get('expireTimer') && - !message.get('expirationStartTimestamp') - ) { - message.set( - 'expirationStartTimestamp', - Math.min(readSync.get('readAt'), Date.now()) - ); - } + const readSyncs = readSync ? [readSync] : []; - message.unset('unread'); + const viewSyncs = ViewSyncs.getSingleton().forMessage(message); + + if (message.get('expireTimer')) { + const existingExpirationStartTimestamp = message.get( + 'expirationStartTimestamp' + ); + const candidateTimestamps: Array = [ + Date.now(), + ...(existingExpirationStartTimestamp + ? [existingExpirationStartTimestamp] + : []), + ...readSyncs.map(sync => sync.get('readAt')), + ...viewSyncs.map(sync => sync.get('viewedAt')), + ]; + message.set( + 'expirationStartTimestamp', + Math.min(...candidateTimestamps) + ); + changed = true; + } + + let newReadStatus: undefined | ReadStatus.Read | ReadStatus.Viewed; + if (viewSyncs.length) { + newReadStatus = ReadStatus.Viewed; + } else if (readSyncs.length) { + newReadStatus = ReadStatus.Read; + } + + if (newReadStatus !== undefined) { + message.set('readStatus', newReadStatus); // This is primarily to allow the conversation to mark all older // messages as read, as is done when we receive a read sync for // a message we already know about. @@ -3290,22 +3320,24 @@ export class MessageModel extends window.Backbone.Model { c.onReadMessage(message); } changed = true; - } else if (isFirstRun) { + } + + if (isFirstRun && !viewSyncs.length && !readSyncs.length) { conversation.set({ unreadCount: (conversation.get('unreadCount') || 0) + 1, isArchived: false, }); } - } - // Check for out-of-order view once open syncs - if (type === 'incoming' && isTapToView(message.attributes)) { - const viewOnceOpenSync = ViewOnceOpenSyncs.getSingleton().forMessage( - message - ); - if (viewOnceOpenSync) { - await message.markViewOnceMessageViewed({ fromSync: true }); - changed = true; + // Check for out-of-order view once open syncs + if (isTapToView(message.attributes)) { + const viewOnceOpenSync = ViewOnceOpenSyncs.getSingleton().forMessage( + message + ); + if (viewOnceOpenSync) { + await message.markViewOnceMessageViewed({ fromSync: true }); + changed = true; + } } } diff --git a/ts/services/MessageUpdater.ts b/ts/services/MessageUpdater.ts index bfedd8b45..b83670e54 100644 --- a/ts/services/MessageUpdater.ts +++ b/ts/services/MessageUpdater.ts @@ -1,16 +1,21 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { MessageAttributesType } from '../model-types.d'; +import type { MessageAttributesType } from '../model-types.d'; +import { ReadStatus, maxReadStatus } from '../messages/MessageReadStatus'; -export function markRead( - messageAttrs: MessageAttributesType, - readAt?: number, - { skipSave = false } = {} +function markReadOrViewed( + messageAttrs: Readonly, + readStatus: ReadStatus.Read | ReadStatus.Viewed, + timestamp: undefined | number, + skipSave: boolean ): MessageAttributesType { - const nextMessageAttributes = { + const oldReadStatus = messageAttrs.readStatus ?? ReadStatus.Read; + const newReadStatus = maxReadStatus(oldReadStatus, readStatus); + + const nextMessageAttributes: MessageAttributesType = { ...messageAttrs, - unread: false, + readStatus: newReadStatus, }; const { id: messageId, expireTimer, expirationStartTimestamp } = messageAttrs; @@ -18,7 +23,7 @@ export function markRead( if (expireTimer && !expirationStartTimestamp) { nextMessageAttributes.expirationStartTimestamp = Math.min( Date.now(), - readAt || Date.now() + timestamp || Date.now() ); } @@ -30,3 +35,17 @@ export function markRead( return nextMessageAttributes; } + +export const markRead = ( + messageAttrs: Readonly, + readAt?: number, + { skipSave = false } = {} +): MessageAttributesType => + markReadOrViewed(messageAttrs, ReadStatus.Read, readAt, skipSave); + +export const markViewed = ( + messageAttrs: Readonly, + viewedAt?: number, + { skipSave = false } = {} +): MessageAttributesType => + markReadOrViewed(messageAttrs, ReadStatus.Viewed, viewedAt, skipSave); diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 6f2f38c85..8fccdbc94 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -30,6 +30,7 @@ import { pick, } from 'lodash'; +import { ReadStatus } from '../messages/MessageReadStatus'; import { GroupV2MemberType } from '../model-types.d'; import { ReactionType } from '../types/Reactions'; import { StoredJob } from '../jobs/types'; @@ -2076,6 +2077,19 @@ function updateToSchemaVersion38(currentVersion: number, db: Database) { console.log('updateToSchemaVersion38: success!'); } +function updateToSchemaVersion39(currentVersion: number, db: Database) { + if (currentVersion >= 39) { + return; + } + + db.transaction(() => { + db.exec('ALTER TABLE messages RENAME COLUMN unread TO readStatus;'); + + db.pragma('user_version = 39'); + })(); + console.log('updateToSchemaVersion39: success!'); +} + const SCHEMA_VERSIONS = [ updateToSchemaVersion1, updateToSchemaVersion2, @@ -2115,6 +2129,7 @@ const SCHEMA_VERSIONS = [ updateToSchemaVersion36, updateToSchemaVersion37, updateToSchemaVersion38, + updateToSchemaVersion39, ]; function updateSchema(db: Database): void { @@ -3572,7 +3587,7 @@ function saveMessageSync( sourceUuid, sourceDevice, type, - unread, + readStatus, expireTimer, expirationStartTimestamp, } = data; @@ -3598,7 +3613,7 @@ function saveMessageSync( sourceUuid: sourceUuid || null, sourceDevice: sourceDevice || null, type: type || null, - unread: unread ? 1 : 0, + readStatus: readStatus ?? null, }; if (id && !forceSave) { @@ -3626,7 +3641,7 @@ function saveMessageSync( sourceUuid = $sourceUuid, sourceDevice = $sourceDevice, type = $type, - unread = $unread + readStatus = $readStatus WHERE id = $id; ` ).run(payload); @@ -3663,7 +3678,7 @@ function saveMessageSync( sourceUuid, sourceDevice, type, - unread + readStatus ) values ( $id, $json, @@ -3685,7 +3700,7 @@ function saveMessageSync( $sourceUuid, $sourceDevice, $type, - $unread + $readStatus ); ` ).run({ @@ -3812,7 +3827,7 @@ async function getUnreadCountForConversation( .prepare( ` SELECT COUNT(*) AS unreadCount FROM messages - WHERE unread = 1 AND + WHERE readStatus = ${ReadStatus.Unread} AND conversationId = $conversationId AND type = 'incoming'; ` @@ -3862,14 +3877,13 @@ async function getUnreadByConversationAndMarkRead( SELECT id, json FROM messages INDEXED BY messages_unread WHERE - unread = $unread AND + readStatus = ${ReadStatus.Unread} AND conversationId = $conversationId AND received_at <= $newestUnreadId ORDER BY received_at DESC, sent_at DESC; ` ) .all({ - unread: 1, conversationId, newestUnreadId, }); @@ -3878,24 +3892,23 @@ async function getUnreadByConversationAndMarkRead( ` UPDATE messages SET - unread = 0, + readStatus = ${ReadStatus.Read}, json = json_patch(json, $jsonPatch) WHERE - unread = $unread AND + readStatus = ${ReadStatus.Unread} AND conversationId = $conversationId AND received_at <= $newestUnreadId; ` ).run({ conversationId, - jsonPatch: JSON.stringify({ unread: 0 }), + jsonPatch: JSON.stringify({ readStatus: ReadStatus.Read }), newestUnreadId, - unread: 1, }); return rows.map(row => { const json = jsonToObject(row.json); return { - unread: false, + readStatus: ReadStatus.Read, ...pick(json, [ 'expirationStartTimestamp', 'id', @@ -4313,7 +4326,7 @@ function getOldestUnreadMessageForConversation( ` SELECT * FROM messages WHERE conversationId = $conversationId AND - unread = 1 + readStatus = ${ReadStatus.Unread} ORDER BY received_at ASC, sent_at ASC LIMIT 1; ` @@ -4338,7 +4351,7 @@ function getTotalUnreadForConversation(conversationId: string): number { FROM messages WHERE conversationId = $conversationId AND - unread = 1; + readStatus = ${ReadStatus.Unread}; ` ) .get({ @@ -4469,8 +4482,9 @@ async function getMessagesUnexpectedlyMissingExpirationStartTimestamp(): Promise ( type IS 'outgoing' OR (type IS 'incoming' AND ( - unread = 0 OR - unread IS NULL + readStatus = ${ReadStatus.Read} OR + readStatus = ${ReadStatus.Viewed} OR + readStatus IS NULL )) ); ` diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index c95bedf52..18ece6691 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -45,6 +45,7 @@ import { ConversationColors } from '../../types/Colors'; import { CallMode } from '../../types/Calling'; import { SignalService as Proto } from '../../protobuf'; import { AttachmentType, isVoiceMessage } from '../../types/Attachment'; +import { ReadStatus } from '../../messages/MessageReadStatus'; import { CallingNotificationType } from '../../util/callingNotification'; import { memoizeByRoot } from '../../util/memoizeByRoot'; @@ -478,6 +479,7 @@ type ShallowPropsType = Pick< | 'isTapToView' | 'isTapToViewError' | 'isTapToViewExpired' + | 'readStatus' | 'selectedReaction' | 'status' | 'text' @@ -544,6 +546,7 @@ const getShallowPropsForMessage = createSelectorCreator(memoizeByRoot, isEqual)( isTapToViewError: isMessageTapToView && isIncoming(message) && message.isTapToViewInvalid, isTapToViewExpired: isMessageTapToView && message.isErased, + readStatus: message.readStatus ?? ReadStatus.Read, selectedReaction, status: getMessagePropStatus(message, ourConversationId), text: createNonBreakingLastSeparator(message.body), diff --git a/ts/state/smart/MessageAudio.tsx b/ts/state/smart/MessageAudio.tsx index 26d5b8984..23e9ae9a4 100644 --- a/ts/state/smart/MessageAudio.tsx +++ b/ts/state/smart/MessageAudio.tsx @@ -39,6 +39,7 @@ export type Props = { computePeaks(url: string, barCount: number): Promise; kickOffAttachmentDownload(): void; onCorrupted(): void; + onFirstPlayed(): void; }; const mapStateToProps = (state: StateType, props: Props) => { diff --git a/ts/state/smart/MessageDetail.tsx b/ts/state/smart/MessageDetail.tsx index 71cbebc37..6d312dee0 100644 --- a/ts/state/smart/MessageDetail.tsx +++ b/ts/state/smart/MessageDetail.tsx @@ -30,6 +30,7 @@ export type OwnProps = Pick< | 'errors' | 'kickOffAttachmentDownload' | 'markAttachmentAsCorrupted' + | 'markViewed' | 'message' | 'openConversation' | 'openLink' @@ -71,6 +72,7 @@ const mapStateToProps = ( doubleCheckMissingQuoteReference, kickOffAttachmentDownload, markAttachmentAsCorrupted, + markViewed, openConversation, openLink, reactToMessage, @@ -115,6 +117,7 @@ const mapStateToProps = ( doubleCheckMissingQuoteReference, kickOffAttachmentDownload, markAttachmentAsCorrupted, + markViewed, openConversation, openLink, reactToMessage, diff --git a/ts/test-both/messages/MessageReadStatus_test.ts b/ts/test-both/messages/MessageReadStatus_test.ts new file mode 100644 index 000000000..c33967ab9 --- /dev/null +++ b/ts/test-both/messages/MessageReadStatus_test.ts @@ -0,0 +1,50 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import { ReadStatus, maxReadStatus } from '../../messages/MessageReadStatus'; + +describe('message read status utilities', () => { + describe('maxReadStatus', () => { + it('returns the status if passed the same status twice', () => { + assert.strictEqual( + maxReadStatus(ReadStatus.Unread, ReadStatus.Unread), + ReadStatus.Unread + ); + }); + + it('sorts Unread < Read', () => { + assert.strictEqual( + maxReadStatus(ReadStatus.Unread, ReadStatus.Read), + ReadStatus.Read + ); + assert.strictEqual( + maxReadStatus(ReadStatus.Read, ReadStatus.Unread), + ReadStatus.Read + ); + }); + + it('sorts Read < Viewed', () => { + assert.strictEqual( + maxReadStatus(ReadStatus.Read, ReadStatus.Viewed), + ReadStatus.Viewed + ); + assert.strictEqual( + maxReadStatus(ReadStatus.Viewed, ReadStatus.Read), + ReadStatus.Viewed + ); + }); + + it('sorts Unread < Viewed', () => { + assert.strictEqual( + maxReadStatus(ReadStatus.Unread, ReadStatus.Viewed), + ReadStatus.Viewed + ); + assert.strictEqual( + maxReadStatus(ReadStatus.Viewed, ReadStatus.Unread), + ReadStatus.Viewed + ); + }); + }); +}); diff --git a/ts/test-both/messages/migrateLegacyReadStatus_test.ts b/ts/test-both/messages/migrateLegacyReadStatus_test.ts new file mode 100644 index 000000000..fac57fe77 --- /dev/null +++ b/ts/test-both/messages/migrateLegacyReadStatus_test.ts @@ -0,0 +1,55 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +// We want to cast to `any` because we're passing an unexpected field. +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { assert } from 'chai'; +import { ReadStatus } from '../../messages/MessageReadStatus'; + +import { migrateLegacyReadStatus } from '../../messages/migrateLegacyReadStatus'; + +describe('migrateLegacyReadStatus', () => { + it("doesn't migrate messages that already have the modern read state", () => { + assert.isUndefined( + migrateLegacyReadStatus({ readStatus: ReadStatus.Read }) + ); + assert.isUndefined( + migrateLegacyReadStatus({ readStatus: ReadStatus.Unread }) + ); + }); + + it('converts legacy read values to "read"', () => { + assert.strictEqual(migrateLegacyReadStatus({}), ReadStatus.Read); + assert.strictEqual( + migrateLegacyReadStatus({ unread: 0 } as any), + ReadStatus.Read + ); + assert.strictEqual( + migrateLegacyReadStatus({ unread: false } as any), + ReadStatus.Read + ); + }); + + it('converts legacy unread values to "unread"', () => { + assert.strictEqual( + migrateLegacyReadStatus({ unread: 1 } as any), + ReadStatus.Unread + ); + assert.strictEqual( + migrateLegacyReadStatus({ unread: true } as any), + ReadStatus.Unread + ); + }); + + it('converts unexpected truthy values to "unread"', () => { + assert.strictEqual( + migrateLegacyReadStatus({ unread: 99 } as any), + ReadStatus.Unread + ); + assert.strictEqual( + migrateLegacyReadStatus({ unread: 'wow!' } as any), + ReadStatus.Unread + ); + }); +}); diff --git a/ts/test-both/state/selectors/search_test.ts b/ts/test-both/state/selectors/search_test.ts index 958754c02..54335406a 100644 --- a/ts/test-both/state/selectors/search_test.ts +++ b/ts/test-both/state/selectors/search_test.ts @@ -21,6 +21,7 @@ import { } from '../../../state/selectors/search'; import { makeLookup } from '../../../util/makeLookup'; import { getDefaultConversation } from '../../helpers/getDefaultConversation'; +import { ReadStatus } from '../../../messages/MessageReadStatus'; import { StateType, reducer as rootReducer } from '../../../state/reducer'; @@ -54,7 +55,7 @@ describe('both/state/selectors/search', () => { sourceUuid: 'sourceUuid', timestamp: NOW, type: 'incoming' as const, - unread: false, + readStatus: ReadStatus.Read, }; } diff --git a/ts/test-both/util/isMessageUnread_test.ts b/ts/test-both/util/isMessageUnread_test.ts index e5da7a195..4e2670fea 100644 --- a/ts/test-both/util/isMessageUnread_test.ts +++ b/ts/test-both/util/isMessageUnread_test.ts @@ -2,20 +2,22 @@ // SPDX-License-Identifier: AGPL-3.0-only import { assert } from 'chai'; +import { ReadStatus } from '../../messages/MessageReadStatus'; import { isMessageUnread } from '../../util/isMessageUnread'; describe('isMessageUnread', () => { - it("returns false if the message's `unread` field is undefined", () => { + it("returns false if the message's `readStatus` field is undefined", () => { assert.isFalse(isMessageUnread({})); - assert.isFalse(isMessageUnread({ unread: undefined })); + assert.isFalse(isMessageUnread({ readStatus: undefined })); }); - it('returns false if the message is read', () => { - assert.isFalse(isMessageUnread({ unread: false })); + it('returns false if the message is read or viewed', () => { + assert.isFalse(isMessageUnread({ readStatus: ReadStatus.Read })); + assert.isFalse(isMessageUnread({ readStatus: ReadStatus.Viewed })); }); it('returns true if the message is unread', () => { - assert.isTrue(isMessageUnread({ unread: true })); + assert.isTrue(isMessageUnread({ readStatus: ReadStatus.Unread })); }); }); diff --git a/ts/test-electron/state/ducks/conversations_test.ts b/ts/test-electron/state/ducks/conversations_test.ts index 388e3b406..b344be323 100644 --- a/ts/test-electron/state/ducks/conversations_test.ts +++ b/ts/test-electron/state/ducks/conversations_test.ts @@ -23,6 +23,7 @@ import { reducer, updateConversationLookups, } from '../../../state/ducks/conversations'; +import { ReadStatus } from '../../../messages/MessageReadStatus'; import { ContactSpoofingType } from '../../../util/contactSpoofing'; import { CallMode } from '../../../types/Calling'; import * as groups from '../../../groups'; @@ -317,7 +318,7 @@ describe('both/state/ducks/conversations', () => { sourceUuid: 'sourceUuid', timestamp: previousTime, type: 'incoming' as const, - unread: false, + readStatus: ReadStatus.Read, }; } diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index a7845b7e9..4f9533132 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -91,6 +91,7 @@ import { StickerPackEvent, VerifiedEvent, ReadSyncEvent, + ViewSyncEvent, ContactEvent, ContactSyncEvent, GroupEvent, @@ -440,6 +441,11 @@ export default class MessageReceiver handler: (ev: ReadSyncEvent) => void ): void; + public addEventListener( + name: 'viewSync', + handler: (ev: ViewSyncEvent) => void + ): void; + public addEventListener( name: 'contact', handler: (ev: ContactEvent) => void @@ -2206,6 +2212,9 @@ export default class MessageReceiver if (syncMessage.keys) { return this.handleKeys(envelope, syncMessage.keys); } + if (syncMessage.viewed && syncMessage.viewed.length) { + return this.handleViewed(envelope, syncMessage.viewed); + } this.removeFromCache(envelope); window.log.warn( @@ -2388,6 +2397,32 @@ export default class MessageReceiver await Promise.all(results); } + private async handleViewed( + envelope: ProcessedEnvelope, + viewed: ReadonlyArray + ): Promise { + window.log.info( + 'MessageReceiver.handleViewed', + this.getEnvelopeId(envelope) + ); + await Promise.all( + viewed.map(async ({ timestamp, senderE164, senderUuid }) => { + const ev = new ViewSyncEvent( + { + envelopeTimestamp: envelope.timestamp, + timestamp: normalizeNumber(dropNull(timestamp)), + senderE164: dropNull(senderE164), + senderUuid: senderUuid + ? normalizeUuid(senderUuid, 'handleViewed.senderUuid') + : undefined, + }, + this.removeFromCache.bind(this, envelope) + ); + await this.dispatchAndWait(ev); + }) + ); + } + private async handleContacts( envelope: ProcessedEnvelope, contacts: Proto.SyncMessage.IContacts diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index 6ee1e5164..57f67a0d5 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -1297,6 +1297,33 @@ export default class MessageSender { }); } + async syncView( + views: ReadonlyArray<{ + senderUuid?: string; + senderE164?: string; + timestamp: number; + }>, + options?: SendOptionsType + ): Promise { + const myNumber = window.textsecure.storage.user.getNumber(); + const myUuid = window.textsecure.storage.user.getUuid(); + + const syncMessage = this.createSyncMessage(); + syncMessage.viewed = views.map(view => new Proto.SyncMessage.Viewed(view)); + const contentMessage = new Proto.Content(); + contentMessage.syncMessage = syncMessage; + + const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; + + return this.sendIndividualProto({ + identifier: myUuid || myNumber, + proto: contentMessage, + timestamp: Date.now(), + contentHint: ContentHint.RESENDABLE, + options, + }); + } + async syncViewOnceOpen( sender: string | undefined, senderUuid: string, diff --git a/ts/textsecure/messageReceiverEvents.ts b/ts/textsecure/messageReceiverEvents.ts index ef4db11f1..69e481943 100644 --- a/ts/textsecure/messageReceiverEvents.ts +++ b/ts/textsecure/messageReceiverEvents.ts @@ -399,3 +399,19 @@ export class ReadSyncEvent extends ConfirmableEvent { super('readSync', confirm); } } + +export type ViewSyncEventData = Readonly<{ + timestamp?: number; + envelopeTimestamp: number; + senderE164?: string; + senderUuid?: string; +}>; + +export class ViewSyncEvent extends ConfirmableEvent { + constructor( + public readonly view: ViewSyncEventData, + confirm: ConfirmCallback + ) { + super('viewSync', confirm); + } +} diff --git a/ts/util/handleMessageSend.ts b/ts/util/handleMessageSend.ts index ec2abb81a..3bd8a2a19 100644 --- a/ts/util/handleMessageSend.ts +++ b/ts/util/handleMessageSend.ts @@ -36,7 +36,8 @@ export type SendTypesType = | 'sentSync' | 'typing' // excluded from send log | 'verificationSync' - | 'viewOnceSync'; + | 'viewOnceSync' + | 'viewSync'; export function shouldSaveProto(sendType: SendTypesType): boolean { if (sendType === 'callingMessage') { diff --git a/ts/util/isMessageUnread.ts b/ts/util/isMessageUnread.ts index 3c58a9b2c..641328e5b 100644 --- a/ts/util/isMessageUnread.ts +++ b/ts/util/isMessageUnread.ts @@ -1,8 +1,9 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import { ReadStatus } from '../messages/MessageReadStatus'; import type { MessageAttributesType } from '../model-types.d'; export const isMessageUnread = ( - message: Readonly> -): boolean => Boolean(message.unread); + message: Readonly> +): boolean => message.readStatus === ReadStatus.Unread; diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index 01827fea4..64d2540c5 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -63,6 +63,9 @@ import { autoScale, handleImageAttachment, } from '../util/handleImageAttachment'; +import { ReadStatus } from '../messages/MessageReadStatus'; +import { markViewed } from '../services/MessageUpdater'; +import { viewSyncJobQueue } from '../jobs/viewSyncJobQueue'; type AttachmentOptions = { messageId: string; @@ -861,6 +864,28 @@ Whisper.ConversationView = Whisper.View.extend({ } message.markAttachmentAsCorrupted(options.attachment); }; + const onMarkViewed = (messageId: string): void => { + const message = window.MessageController.getById(messageId); + if (!message) { + throw new Error(`onMarkViewed: Message ${messageId} missing!`); + } + + if (message.get('readStatus') === ReadStatus.Viewed) { + return; + } + + message.set(markViewed(message.attributes, Date.now())); + viewSyncJobQueue.add({ + viewSyncs: [ + { + messageId, + senderE164: message.get('source'), + senderUuid: message.get('sourceUuid'), + timestamp: message.get('sent_at'), + }, + ], + }); + }; const showVisualAttachment = (options: { attachment: AttachmentType; messageId: string; @@ -910,6 +935,7 @@ Whisper.ConversationView = Whisper.View.extend({ downloadNewVersion, kickOffAttachmentDownload, markAttachmentAsCorrupted, + markViewed: onMarkViewed, openConversation, openLink, reactToMessage,