Show "unplayed" dot on incoming audio messages

This commit is contained in:
Evan Hahn 2021-08-12 13:15:55 -05:00 committed by GitHub
parent 9fd191ae00
commit b0750e5f4e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 812 additions and 175 deletions

View file

@ -58,6 +58,7 @@ import {
StickerPackEvent, StickerPackEvent,
VerifiedEvent, VerifiedEvent,
ReadSyncEvent, ReadSyncEvent,
ViewSyncEvent,
ContactEvent, ContactEvent,
GroupEvent, GroupEvent,
EnvelopeEvent, EnvelopeEvent,
@ -79,7 +80,9 @@ import {
import { MessageRequests } from './messageModifiers/MessageRequests'; import { MessageRequests } from './messageModifiers/MessageRequests';
import { Reactions } from './messageModifiers/Reactions'; import { Reactions } from './messageModifiers/Reactions';
import { ReadSyncs } from './messageModifiers/ReadSyncs'; import { ReadSyncs } from './messageModifiers/ReadSyncs';
import { ViewSyncs } from './messageModifiers/ViewSyncs';
import { ViewOnceOpenSyncs } from './messageModifiers/ViewOnceOpenSyncs'; import { ViewOnceOpenSyncs } from './messageModifiers/ViewOnceOpenSyncs';
import { ReadStatus } from './messages/MessageReadStatus';
import { import {
SendStateByConversationId, SendStateByConversationId,
SendStatus, SendStatus,
@ -211,6 +214,10 @@ export async function startApp(): Promise<void> {
'readSync', 'readSync',
queuedEventListener(onReadSync) queuedEventListener(onReadSync)
); );
messageReceiver.addEventListener(
'viewSync',
queuedEventListener(onViewSync)
);
messageReceiver.addEventListener( messageReceiver.addEventListener(
'read', 'read',
queuedEventListener(onReadReceipt) queuedEventListener(onReadReceipt)
@ -3518,7 +3525,7 @@ export async function startApp(): Promise<void> {
conversationId: descriptor.id, conversationId: descriptor.id,
unidentifiedDeliveryReceived: data.unidentifiedDeliveryReceived, unidentifiedDeliveryReceived: data.unidentifiedDeliveryReceived,
type: 'incoming', type: 'incoming',
unread: true, readStatus: ReadStatus.Unread,
timestamp: data.timestamp, timestamp: data.timestamp,
} as Partial<MessageAttributesType>) as WhatIsThis); } as Partial<MessageAttributesType>) as WhatIsThis);
} }
@ -3851,6 +3858,38 @@ export async function startApp(): Promise<void> {
return ReadSyncs.getSingleton().onSync(receipt); 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) { async function onVerified(ev: VerifiedEvent) {
const e164 = ev.verified.destination; const e164 = ev.verified.destination;
const uuid = ev.verified.destinationUuid; const uuid = ev.verified.destinationUuid;

View file

@ -20,6 +20,7 @@ import {
VIDEO_MP4, VIDEO_MP4,
stringToMIMEType, stringToMIMEType,
} from '../../types/MIME'; } from '../../types/MIME';
import { ReadStatus } from '../../messages/MessageReadStatus';
import { MessageAudio } from './MessageAudio'; import { MessageAudio } from './MessageAudio';
import { computePeaks } from '../GlobalAudioContext'; import { computePeaks } from '../GlobalAudioContext';
import { setup as setupI18n } from '../../../js/modules/i18n'; import { setup as setupI18n } from '../../../js/modules/i18n';
@ -61,6 +62,7 @@ const MessageAudioContainer: React.FC<AudioAttachmentProps> = props => {
audio={audio} audio={audio}
computePeaks={computePeaks} computePeaks={computePeaks}
setActiveAudioID={(id, context) => setActive({ id, context })} setActiveAudioID={(id, context) => setActive({ id, context })}
onFirstPlayed={action('onFirstPlayed')}
activeAudioID={active.id} activeAudioID={active.id}
activeAudioContext={active.context} activeAudioContext={active.context}
/> />
@ -120,12 +122,17 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
isTapToViewExpired: overrideProps.isTapToViewExpired, isTapToViewExpired: overrideProps.isTapToViewExpired,
kickOffAttachmentDownload: action('kickOffAttachmentDownload'), kickOffAttachmentDownload: action('kickOffAttachmentDownload'),
markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'), markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'),
markViewed: action('markViewed'),
onHeightChange: action('onHeightChange'), onHeightChange: action('onHeightChange'),
openConversation: action('openConversation'), openConversation: action('openConversation'),
openLink: action('openLink'), openLink: action('openLink'),
previews: overrideProps.previews || [], previews: overrideProps.previews || [],
reactions: overrideProps.reactions, reactions: overrideProps.reactions,
reactToMessage: action('reactToMessage'), reactToMessage: action('reactToMessage'),
readStatus:
overrideProps.readStatus === undefined
? ReadStatus.Read
: overrideProps.readStatus,
renderEmojiPicker, renderEmojiPicker,
renderAudioAttachment, renderAudioAttachment,
replyToMessage: action('replyToMessage'), replyToMessage: action('replyToMessage'),
@ -866,22 +873,10 @@ story.add('Pending GIF', () => {
}); });
story.add('Audio', () => { story.add('Audio', () => {
const props = createProps({ const Wrapper = () => {
attachments: [ const [isPlayed, setIsPlayed] = React.useState(false);
{
contentType: AUDIO_MP3, const messageProps = createProps({
fileName: 'incompetech-com-Agnus-Dei-X.mp3',
url: '/fixtures/incompetech-com-Agnus-Dei-X.mp3',
},
],
status: 'sent',
});
return renderBothDirections(props);
});
story.add('Audio (played)', () => {
const props = createProps({
attachments: [ attachments: [
{ {
contentType: AUDIO_MP3, contentType: AUDIO_MP3,
@ -889,10 +884,37 @@ story.add('Audio (played)', () => {
url: '/fixtures/incompetech-com-Agnus-Dei-X.mp3', url: '/fixtures/incompetech-com-Agnus-Dei-X.mp3',
}, },
], ],
...(isPlayed
? {
status: 'viewed', status: 'viewed',
readStatus: ReadStatus.Viewed,
}
: {
status: 'read',
readStatus: ReadStatus.Read,
}),
}); });
return renderBothDirections(props); return (
<>
<button
type="button"
onClick={() => {
setIsPlayed(old => !old);
}}
style={{
display: 'block',
marginBottom: '2em',
}}
>
Toggle played
</button>
{renderBothDirections(messageProps)}
</>
);
};
return <Wrapper />;
}); });
story.add('Long Audio', () => { story.add('Long Audio', () => {

View file

@ -13,6 +13,7 @@ import {
ConversationTypeType, ConversationTypeType,
InteractionModeType, InteractionModeType,
} from '../../state/ducks/conversations'; } from '../../state/ducks/conversations';
import { ReadStatus } from '../../messages/MessageReadStatus';
import { Avatar } from '../Avatar'; import { Avatar } from '../Avatar';
import { Spinner } from '../Spinner'; import { Spinner } from '../Spinner';
import { MessageBody } from './MessageBody'; import { MessageBody } from './MessageBody';
@ -110,6 +111,7 @@ export type AudioAttachmentProps = {
kickOffAttachmentDownload(): void; kickOffAttachmentDownload(): void;
onCorrupted(): void; onCorrupted(): void;
onFirstPlayed(): void;
}; };
export type PropsData = { export type PropsData = {
@ -167,6 +169,8 @@ export type PropsData = {
isTapToViewExpired?: boolean; isTapToViewExpired?: boolean;
isTapToViewError?: boolean; isTapToViewError?: boolean;
readStatus: ReadStatus;
expirationLength?: number; expirationLength?: number;
expirationTimestamp?: number; expirationTimestamp?: number;
@ -225,6 +229,7 @@ export type PropsActions = {
attachment: AttachmentType; attachment: AttachmentType;
messageId: string; messageId: string;
}) => void; }) => void;
markViewed(messageId: string): void;
showVisualAttachment: (options: { showVisualAttachment: (options: {
attachment: AttachmentType; attachment: AttachmentType;
messageId: string; messageId: string;
@ -684,7 +689,9 @@ export class Message extends React.PureComponent<Props, State> {
isSticker, isSticker,
kickOffAttachmentDownload, kickOffAttachmentDownload,
markAttachmentAsCorrupted, markAttachmentAsCorrupted,
markViewed,
quote, quote,
readStatus,
reducedMotion, reducedMotion,
renderAudioAttachment, renderAudioAttachment,
renderingContext, renderingContext,
@ -791,8 +798,7 @@ export class Message extends React.PureComponent<Props, State> {
played = status === 'viewed'; played = status === 'viewed';
break; break;
case 'incoming': case 'incoming':
// Not implemented yet. See DESKTOP-1855. played = readStatus === ReadStatus.Viewed;
played = true;
break; break;
default: default:
window.log.error(missingCaseError(direction)); window.log.error(missingCaseError(direction));
@ -831,6 +837,9 @@ export class Message extends React.PureComponent<Props, State> {
messageId: id, messageId: id,
}); });
}, },
onFirstPlayed() {
markViewed(id);
},
}); });
} }
const { pending, fileName, fileSize, contentType } = firstAttachment; const { pending, fileName, fileSize, contentType } = firstAttachment;

View file

@ -37,6 +37,7 @@ export type Props = {
buttonRef: React.RefObject<HTMLButtonElement>; buttonRef: React.RefObject<HTMLButtonElement>;
kickOffAttachmentDownload(): void; kickOffAttachmentDownload(): void;
onCorrupted(): void; onCorrupted(): void;
onFirstPlayed(): void;
computePeaks(url: string, barCount: number): Promise<ComputePeaksResult>; computePeaks(url: string, barCount: number): Promise<ComputePeaksResult>;
activeAudioID: string | undefined; activeAudioID: string | undefined;
@ -163,6 +164,7 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
buttonRef, buttonRef,
kickOffAttachmentDownload, kickOffAttachmentDownload,
onCorrupted, onCorrupted,
onFirstPlayed,
audio, audio,
computePeaks, computePeaks,
@ -365,6 +367,12 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
} }
}; };
useEffect(() => {
if (!played && isPlaying) {
onFirstPlayed();
}
}, [played, isPlaying, onFirstPlayed]);
// Clicking waveform moves playback head position and starts playback. // Clicking waveform moves playback head position and starts playback.
const onWaveformClick = (event: React.MouseEvent) => { const onWaveformClick = (event: React.MouseEvent) => {
event.preventDefault(); event.preventDefault();

View file

@ -10,6 +10,7 @@ import { storiesOf } from '@storybook/react';
import { PropsData as MessageDataPropsType } from './Message'; import { PropsData as MessageDataPropsType } from './Message';
import { MessageDetail, Props } from './MessageDetail'; import { MessageDetail, Props } from './MessageDetail';
import { SendStatus } from '../../messages/MessageSendState'; import { SendStatus } from '../../messages/MessageSendState';
import { ReadStatus } from '../../messages/MessageReadStatus';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation'; import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
import { setup as setupI18n } from '../../../js/modules/i18n'; import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json'; import enMessages from '../../../_locales/en/messages.json';
@ -35,6 +36,7 @@ const defaultMessage: MessageDataPropsType = {
isBlocked: false, isBlocked: false,
isMessageRequestAccepted: true, isMessageRequestAccepted: true,
previews: [], previews: [],
readStatus: ReadStatus.Read,
status: 'sent', status: 'sent',
text: 'A message from Max', text: 'A message from Max',
timestamp: Date.now(), timestamp: Date.now(),
@ -71,6 +73,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
doubleCheckMissingQuoteReference: action('doubleCheckMissingQuoteReference'), doubleCheckMissingQuoteReference: action('doubleCheckMissingQuoteReference'),
kickOffAttachmentDownload: action('kickOffAttachmentDownload'), kickOffAttachmentDownload: action('kickOffAttachmentDownload'),
markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'), markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'),
markViewed: action('markViewed'),
openConversation: action('openConversation'), openConversation: action('openConversation'),
openLink: action('openLink'), openLink: action('openLink'),
reactToMessage: action('reactToMessage'), reactToMessage: action('reactToMessage'),

View file

@ -69,6 +69,7 @@ export type Props = {
| 'interactionMode' | 'interactionMode'
| 'kickOffAttachmentDownload' | 'kickOffAttachmentDownload'
| 'markAttachmentAsCorrupted' | 'markAttachmentAsCorrupted'
| 'markViewed'
| 'openConversation' | 'openConversation'
| 'openLink' | 'openLink'
| 'reactToMessage' | 'reactToMessage'
@ -269,6 +270,7 @@ export class MessageDetail extends React.Component<Props> {
interactionMode, interactionMode,
kickOffAttachmentDownload, kickOffAttachmentDownload,
markAttachmentAsCorrupted, markAttachmentAsCorrupted,
markViewed,
openConversation, openConversation,
openLink, openLink,
reactToMessage, reactToMessage,
@ -305,6 +307,7 @@ export class MessageDetail extends React.Component<Props> {
interactionMode={interactionMode} interactionMode={interactionMode}
kickOffAttachmentDownload={kickOffAttachmentDownload} kickOffAttachmentDownload={kickOffAttachmentDownload}
markAttachmentAsCorrupted={markAttachmentAsCorrupted} markAttachmentAsCorrupted={markAttachmentAsCorrupted}
markViewed={markViewed}
onHeightChange={noop} onHeightChange={noop}
openConversation={openConversation} openConversation={openConversation}
openLink={openLink} openLink={openLink}

View file

@ -19,6 +19,7 @@ import {
stringToMIMEType, stringToMIMEType,
} from '../../types/MIME'; } from '../../types/MIME';
import { Props, Quote } from './Quote'; import { Props, Quote } from './Quote';
import { ReadStatus } from '../../messages/MessageReadStatus';
import { setup as setupI18n } from '../../../js/modules/i18n'; import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json'; import enMessages from '../../../_locales/en/messages.json';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation'; import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
@ -56,11 +57,13 @@ const defaultMessageProps: MessagesProps = {
isMessageRequestAccepted: true, isMessageRequestAccepted: true,
kickOffAttachmentDownload: action('default--kickOffAttachmentDownload'), kickOffAttachmentDownload: action('default--kickOffAttachmentDownload'),
markAttachmentAsCorrupted: action('default--markAttachmentAsCorrupted'), markAttachmentAsCorrupted: action('default--markAttachmentAsCorrupted'),
markViewed: action('default--markViewed'),
onHeightChange: action('onHeightChange'), onHeightChange: action('onHeightChange'),
openConversation: action('default--openConversation'), openConversation: action('default--openConversation'),
openLink: action('default--openLink'), openLink: action('default--openLink'),
previews: [], previews: [],
reactToMessage: action('default--reactToMessage'), reactToMessage: action('default--reactToMessage'),
readStatus: ReadStatus.Read,
renderEmojiPicker: () => <div />, renderEmojiPicker: () => <div />,
renderAudioAttachment: () => <div>*AudioAttachment*</div>, renderAudioAttachment: () => <div>*AudioAttachment*</div>,
replyToMessage: action('default--replyToMessage'), replyToMessage: action('default--replyToMessage'),

View file

@ -20,6 +20,7 @@ import { LastSeenIndicator } from './LastSeenIndicator';
import { TimelineLoadingRow } from './TimelineLoadingRow'; import { TimelineLoadingRow } from './TimelineLoadingRow';
import { TypingBubble } from './TypingBubble'; import { TypingBubble } from './TypingBubble';
import { ContactSpoofingType } from '../../util/contactSpoofing'; import { ContactSpoofingType } from '../../util/contactSpoofing';
import { ReadStatus } from '../../messages/MessageReadStatus';
const i18n = setupI18n('en', enMessages); const i18n = setupI18n('en', enMessages);
@ -51,6 +52,7 @@ const items: Record<string, TimelineItemType> = {
isBlocked: false, isBlocked: false,
isMessageRequestAccepted: true, isMessageRequestAccepted: true,
previews: [], previews: [],
readStatus: ReadStatus.Read,
text: '🔥', text: '🔥',
timestamp: Date.now(), timestamp: Date.now(),
}, },
@ -70,6 +72,7 @@ const items: Record<string, TimelineItemType> = {
isBlocked: false, isBlocked: false,
isMessageRequestAccepted: true, isMessageRequestAccepted: true,
previews: [], previews: [],
readStatus: ReadStatus.Read,
text: 'Hello there from the new world! http://somewhere.com', text: 'Hello there from the new world! http://somewhere.com',
timestamp: Date.now(), timestamp: Date.now(),
}, },
@ -102,6 +105,7 @@ const items: Record<string, TimelineItemType> = {
isBlocked: false, isBlocked: false,
isMessageRequestAccepted: true, isMessageRequestAccepted: true,
previews: [], previews: [],
readStatus: ReadStatus.Read,
text: 'Hello there from the new world!', text: 'Hello there from the new world!',
timestamp: Date.now(), timestamp: Date.now(),
}, },
@ -200,6 +204,7 @@ const items: Record<string, TimelineItemType> = {
isBlocked: false, isBlocked: false,
isMessageRequestAccepted: true, isMessageRequestAccepted: true,
previews: [], previews: [],
readStatus: ReadStatus.Read,
status: 'sent', status: 'sent',
text: '🔥', text: '🔥',
timestamp: Date.now(), timestamp: Date.now(),
@ -220,6 +225,7 @@ const items: Record<string, TimelineItemType> = {
isBlocked: false, isBlocked: false,
isMessageRequestAccepted: true, isMessageRequestAccepted: true,
previews: [], previews: [],
readStatus: ReadStatus.Read,
status: 'read', status: 'read',
text: 'Hello there from the new world! http://somewhere.com', text: 'Hello there from the new world! http://somewhere.com',
timestamp: Date.now(), timestamp: Date.now(),
@ -240,6 +246,7 @@ const items: Record<string, TimelineItemType> = {
isBlocked: false, isBlocked: false,
isMessageRequestAccepted: true, isMessageRequestAccepted: true,
previews: [], previews: [],
readStatus: ReadStatus.Read,
status: 'sent', status: 'sent',
text: 'Hello there from the new world! 🔥', text: 'Hello there from the new world! 🔥',
timestamp: Date.now(), timestamp: Date.now(),
@ -260,6 +267,7 @@ const items: Record<string, TimelineItemType> = {
isBlocked: false, isBlocked: false,
isMessageRequestAccepted: true, isMessageRequestAccepted: true,
previews: [], previews: [],
readStatus: ReadStatus.Read,
status: 'sent', status: 'sent',
text: text:
'Hello there from the new world! And this is multiple lines of text. Lines and lines and lines.', '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<string, TimelineItemType> = {
isBlocked: false, isBlocked: false,
isMessageRequestAccepted: true, isMessageRequestAccepted: true,
previews: [], previews: [],
readStatus: ReadStatus.Read,
status: 'read', status: 'read',
text: text:
'Hello there from the new world! And this is multiple lines of text. Lines and lines and lines.', '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'), showContactModal: action('showContactModal'),
kickOffAttachmentDownload: action('kickOffAttachmentDownload'), kickOffAttachmentDownload: action('kickOffAttachmentDownload'),
markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'), markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'),
markViewed: action('markViewed'),
showVisualAttachment: action('showVisualAttachment'), showVisualAttachment: action('showVisualAttachment'),
downloadAttachment: action('downloadAttachment'), downloadAttachment: action('downloadAttachment'),
displayTapToViewMessage: action('displayTapToViewMessage'), displayTapToViewMessage: action('displayTapToViewMessage'),

View file

@ -1496,6 +1496,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
'loadNewerMessages', 'loadNewerMessages',
'loadNewestMessages', 'loadNewestMessages',
'markMessageRead', 'markMessageRead',
'markViewed',
'onBlock', 'onBlock',
'onBlockAndReportSpam', 'onBlockAndReportSpam',
'onDelete', 'onDelete',

View file

@ -57,6 +57,7 @@ const getDefaultProps = () => ({
kickOffAttachmentDownload: action('kickOffAttachmentDownload'), kickOffAttachmentDownload: action('kickOffAttachmentDownload'),
learnMoreAboutDeliveryIssue: action('learnMoreAboutDeliveryIssue'), learnMoreAboutDeliveryIssue: action('learnMoreAboutDeliveryIssue'),
markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'), markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'),
markViewed: action('markViewed'),
showMessageDetail: action('showMessageDetail'), showMessageDetail: action('showMessageDetail'),
openConversation: action('openConversation'), openConversation: action('openConversation'),
showContactDetail: action('showContactDetail'), showContactDetail: action('showContactDetail'),

View file

@ -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<void> {
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<void>(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;
}
}

View file

@ -6,6 +6,7 @@ import type { WebAPIType } from '../textsecure/WebAPI';
import { readSyncJobQueue } from './readSyncJobQueue'; import { readSyncJobQueue } from './readSyncJobQueue';
import { removeStorageKeyJobQueue } from './removeStorageKeyJobQueue'; import { removeStorageKeyJobQueue } from './removeStorageKeyJobQueue';
import { reportSpamJobQueue } from './reportSpamJobQueue'; import { reportSpamJobQueue } from './reportSpamJobQueue';
import { viewSyncJobQueue } from './viewSyncJobQueue';
/** /**
* Start all of the job queues. Should be called when the database is ready. * Start all of the job queues. Should be called when the database is ready.
@ -20,4 +21,5 @@ export function initializeAllJobQueues({
readSyncJobQueue.streamJobs(); readSyncJobQueue.streamJobs();
removeStorageKeyJobQueue.streamJobs(); removeStorageKeyJobQueue.streamJobs();
reportSpamJobQueue.streamJobs(); reportSpamJobQueue.streamJobs();
viewSyncJobQueue.streamJobs();
} }

View file

@ -5,25 +5,12 @@
import * as z from 'zod'; import * as z from 'zod';
import * as moment from 'moment'; import * as moment from 'moment';
import { chunk } from 'lodash'; import { exponentialBackoffMaxAttempts } from '../util/exponentialBackoff';
import { getSendOptions } from '../util/getSendOptions'; import { runReadOrViewSyncJob } from './helpers/runReadOrViewSyncJob';
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 { JobQueue } from './JobQueue'; import { JobQueue } from './JobQueue';
import { jobQueueDatabaseStore } from './JobQueueDatabaseStore'; import { jobQueueDatabaseStore } from './JobQueueDatabaseStore';
const CHUNK_SIZE = 100;
const MAX_RETRY_TIME = moment.duration(1, 'day').asMilliseconds(); const MAX_RETRY_TIME = moment.duration(1, 'day').asMilliseconds();
const readSyncJobDataSchema = z.object({ const readSyncJobDataSchema = z.object({
@ -48,71 +35,13 @@ export class ReadSyncJobQueue extends JobQueue<ReadSyncJobData> {
{ data, timestamp }: Readonly<{ data: ReadSyncJobData; timestamp: number }>, { data, timestamp }: Readonly<{ data: ReadSyncJobData; timestamp: number }>,
{ attempt }: Readonly<{ attempt: number }> { attempt }: Readonly<{ attempt: number }>
): Promise<void> { ): Promise<void> {
const { readSyncs } = data; await runReadOrViewSyncJob({
if (!readSyncs.length) { attempt,
log.info( isView: false,
"readSyncJobQueue: skipping this job because there's nothing to sync" maxRetryTime: MAX_RETRY_TIME,
); syncs: data.readSyncs,
return; timestamp,
}
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<void>(resolve => {
window.storage.onready(resolve);
}); });
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;
}
} }
} }

View file

@ -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<typeof viewSyncJobDataSchema>;
export class ViewSyncJobQueue extends JobQueue<ViewSyncJobData> {
protected parseData(data: unknown): ViewSyncJobData {
return viewSyncJobDataSchema.parse(data);
}
protected async run(
{ data, timestamp }: Readonly<{ data: ViewSyncJobData; timestamp: number }>,
{ attempt }: Readonly<{ attempt: number }>
): Promise<void> {
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),
});

View file

@ -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<ViewSyncAttributesType> {}
let singleton: ViewSyncs | undefined;
export class ViewSyncs extends Collection {
static getSingleton(): ViewSyncs {
if (!singleton) {
singleton = new ViewSyncs();
}
return singleton;
}
forMessage(message: MessageModel): Array<ViewSyncModel> {
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<void> {
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
);
}
}
}

View file

@ -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, number> = {
[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;

View file

@ -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<Pick<MessageAttributesType, 'readStatus'>>
): undefined | ReadStatus {
const shouldMigrate = message.readStatus == null;
if (!shouldMigrate) {
return;
}
const legacyUnread = (message as Record<string, unknown>).unread;
return legacyUnread ? ReadStatus.Unread : ReadStatus.Read;
}

5
ts/model-types.d.ts vendored
View file

@ -15,6 +15,7 @@ import { MessageModel } from './models/messages';
import { ConversationModel } from './models/conversations'; import { ConversationModel } from './models/conversations';
import { ProfileNameChangeType } from './util/getStringForProfileChange'; import { ProfileNameChangeType } from './util/getStringForProfileChange';
import { CapabilitiesType } from './textsecure/WebAPI'; import { CapabilitiesType } from './textsecure/WebAPI';
import { ReadStatus } from './messages/MessageReadStatus';
import { import {
SendState, SendState,
SendStateByConversationId, SendStateByConversationId,
@ -182,7 +183,6 @@ export type MessageAttributesType = {
source?: string; source?: string;
sourceUuid?: string; sourceUuid?: string;
unread?: boolean;
timestamp: number; timestamp: number;
// Backwards-compatibility with prerelease data schema // Backwards-compatibility with prerelease data schema
@ -191,6 +191,9 @@ export type MessageAttributesType = {
sendHQImages?: boolean; sendHQImages?: boolean;
// Should only be present for incoming messages
readStatus?: ReadStatus;
// Should only be present for outgoing messages // Should only be present for outgoing messages
sendStateByConversationId?: SendStateByConversationId; sendStateByConversationId?: SendStateByConversationId;
}; };

View file

@ -52,6 +52,7 @@ import { handleMessageSend } from '../util/handleMessageSend';
import { getConversationMembers } from '../util/getConversationMembers'; import { getConversationMembers } from '../util/getConversationMembers';
import { sendReadReceiptsFor } from '../util/sendReadReceiptsFor'; import { sendReadReceiptsFor } from '../util/sendReadReceiptsFor';
import { updateConversationsWithUuidLookup } from '../updateConversationsWithUuidLookup'; import { updateConversationsWithUuidLookup } from '../updateConversationsWithUuidLookup';
import { ReadStatus } from '../messages/MessageReadStatus';
import { SendStatus } from '../messages/MessageSendState'; import { SendStatus } from '../messages/MessageSendState';
import { import {
concat, concat,
@ -2454,7 +2455,7 @@ export class ConversationModel extends window.Backbone
sent_at: receivedAt, sent_at: receivedAt,
received_at: receivedAtCounter, received_at: receivedAtCounter,
received_at_ms: receivedAt, received_at_ms: receivedAt,
unread: 1, readStatus: ReadStatus.Unread,
// TODO: DESKTOP-722 // TODO: DESKTOP-722
// this type does not fully implement the interface it is expected to // this type does not fully implement the interface it is expected to
} as unknown) as typeof window.Whisper.MessageAttributesType; } as unknown) as typeof window.Whisper.MessageAttributesType;
@ -2494,7 +2495,7 @@ export class ConversationModel extends window.Backbone
sent_at: receivedAt, sent_at: receivedAt,
received_at: receivedAtCounter, received_at: receivedAtCounter,
received_at_ms: receivedAt, received_at_ms: receivedAt,
unread: 1, readStatus: ReadStatus.Unread,
// TODO: DESKTOP-722 // TODO: DESKTOP-722
// this type does not fully implement the interface it is expected to // this type does not fully implement the interface it is expected to
} as unknown) as typeof window.Whisper.MessageAttributesType; } 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: window.Signal.Util.incrementMessageCounter(),
received_at_ms: timestamp, received_at_ms: timestamp,
key_changed: keyChangedId, key_changed: keyChangedId,
unread: 1, readStatus: ReadStatus.Unread,
schemaVersion: Message.VERSION_NEEDED_FOR_DISPLAY, schemaVersion: Message.VERSION_NEEDED_FOR_DISPLAY,
// TODO: DESKTOP-722 // TODO: DESKTOP-722
// this type does not fully implement the interface it is expected to // this type does not fully implement the interface it is expected to
@ -2589,7 +2590,7 @@ export class ConversationModel extends window.Backbone
verifiedChanged: verifiedChangeId, verifiedChanged: verifiedChangeId,
verified, verified,
local: options.local, local: options.local,
unread: 1, readStatus: ReadStatus.Unread,
// TODO: DESKTOP-722 // TODO: DESKTOP-722
} as unknown) as typeof window.Whisper.MessageAttributesType; } as unknown) as typeof window.Whisper.MessageAttributesType;
@ -2647,7 +2648,7 @@ export class ConversationModel extends window.Backbone
sent_at: timestamp, sent_at: timestamp,
received_at: window.Signal.Util.incrementMessageCounter(), received_at: window.Signal.Util.incrementMessageCounter(),
received_at_ms: timestamp, received_at_ms: timestamp,
unread, readStatus: unread ? ReadStatus.Unread : ReadStatus.Read,
callHistoryDetails: detailsToSave, callHistoryDetails: detailsToSave,
// TODO: DESKTOP-722 // TODO: DESKTOP-722
} as unknown) as typeof window.Whisper.MessageAttributesType; } as unknown) as typeof window.Whisper.MessageAttributesType;
@ -2697,7 +2698,7 @@ export class ConversationModel extends window.Backbone
sent_at: now, sent_at: now,
received_at: window.Signal.Util.incrementMessageCounter(), received_at: window.Signal.Util.incrementMessageCounter(),
received_at_ms: now, received_at_ms: now,
unread: false, readStatus: ReadStatus.Read,
changedId: conversationId || this.id, changedId: conversationId || this.id,
profileChange, profileChange,
// TODO: DESKTOP-722 // TODO: DESKTOP-722
@ -2738,7 +2739,7 @@ export class ConversationModel extends window.Backbone
sent_at: now, sent_at: now,
received_at: window.Signal.Util.incrementMessageCounter(), received_at: window.Signal.Util.incrementMessageCounter(),
received_at_ms: now, received_at_ms: now,
unread: false, readStatus: ReadStatus.Read,
}; };
const id = await window.Signal.Data.saveMessage( const id = await window.Signal.Data.saveMessage(
@ -4163,7 +4164,7 @@ export class ConversationModel extends window.Backbone
const model = new window.Whisper.Message(({ const model = new window.Whisper.Message(({
// Even though this isn't reflected to the user, we want to place the last seen // 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. // indicator above it. We set it to 'unread' to trigger that placement.
unread: 1, readStatus: ReadStatus.Unread,
conversationId: this.id, conversationId: this.id,
// No type; 'incoming' messages are specially treated by conversation.markRead() // No type; 'incoming' messages are specially treated by conversation.markRead()
sent_at: timestamp, sent_at: timestamp,
@ -4266,7 +4267,7 @@ export class ConversationModel extends window.Backbone
type: 'message-history-unsynced', type: 'message-history-unsynced',
// Even though this isn't reflected to the user, we want to place the last seen // 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. // indicator above it. We set it to 'unread' to trigger that placement.
unread: 1, readStatus: ReadStatus.Unread,
conversationId: this.id, conversationId: this.id,
sent_at: timestamp, sent_at: timestamp,
received_at: window.Signal.Util.incrementMessageCounter(), received_at: window.Signal.Util.incrementMessageCounter(),

View file

@ -44,6 +44,7 @@ import * as Stickers from '../types/Stickers';
import { AttachmentType, isImage, isVideo } from '../types/Attachment'; import { AttachmentType, isImage, isVideo } from '../types/Attachment';
import { IMAGE_WEBP, stringToMIMEType } from '../types/MIME'; import { IMAGE_WEBP, stringToMIMEType } from '../types/MIME';
import { ourProfileKeyService } from '../services/ourProfileKey'; import { ourProfileKeyService } from '../services/ourProfileKey';
import { ReadStatus } from '../messages/MessageReadStatus';
import { import {
SendActionType, SendActionType,
SendStateByConversationId, SendStateByConversationId,
@ -53,9 +54,10 @@ import {
sendStateReducer, sendStateReducer,
someSendStatus, someSendStatus,
} from '../messages/MessageSendState'; } from '../messages/MessageSendState';
import { migrateLegacyReadStatus } from '../messages/migrateLegacyReadStatus';
import { migrateLegacySendAttributes } from '../messages/migrateLegacySendAttributes'; import { migrateLegacySendAttributes } from '../messages/migrateLegacySendAttributes';
import { getOwn } from '../util/getOwn'; import { getOwn } from '../util/getOwn';
import { markRead } from '../services/MessageUpdater'; import { markRead, markViewed } from '../services/MessageUpdater';
import { isMessageUnread } from '../util/isMessageUnread'; import { isMessageUnread } from '../util/isMessageUnread';
import { import {
isDirectConversation, isDirectConversation,
@ -104,6 +106,7 @@ import {
import { Deletes } from '../messageModifiers/Deletes'; import { Deletes } from '../messageModifiers/Deletes';
import { Reactions } from '../messageModifiers/Reactions'; import { Reactions } from '../messageModifiers/Reactions';
import { ReadSyncs } from '../messageModifiers/ReadSyncs'; import { ReadSyncs } from '../messageModifiers/ReadSyncs';
import { ViewSyncs } from '../messageModifiers/ViewSyncs';
import { ViewOnceOpenSyncs } from '../messageModifiers/ViewOnceOpenSyncs'; import { ViewOnceOpenSyncs } from '../messageModifiers/ViewOnceOpenSyncs';
import * as AttachmentDownloads from '../messageModifiers/AttachmentDownloads'; import * as AttachmentDownloads from '../messageModifiers/AttachmentDownloads';
import * as LinkPreview from '../types/LinkPreview'; import * as LinkPreview from '../types/LinkPreview';
@ -194,6 +197,11 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
); );
} }
const readStatus = migrateLegacyReadStatus(this.attributes);
if (readStatus !== undefined) {
this.set('readStatus', readStatus, { silent: true });
}
const sendStateByConversationId = migrateLegacySendAttributes( const sendStateByConversationId = migrateLegacySendAttributes(
this.attributes, this.attributes,
window.ConversationController.get.bind(window.ConversationController), window.ConversationController.get.bind(window.ConversationController),
@ -835,8 +843,8 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
return; return;
} }
if (isMessageUnread(this.attributes)) { if (this.get('readStatus') !== ReadStatus.Viewed) {
this.set(markRead(this.attributes)); this.set(markViewed(this.attributes));
} }
await this.eraseContents(); await this.eraseContents();
@ -3269,19 +3277,41 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
} }
if (type === 'incoming') { 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); const readSync = ReadSyncs.getSingleton().forMessage(message);
if (readSync) { const readSyncs = readSync ? [readSync] : [];
if (
message.get('expireTimer') && const viewSyncs = ViewSyncs.getSingleton().forMessage(message);
!message.get('expirationStartTimestamp')
) { if (message.get('expireTimer')) {
const existingExpirationStartTimestamp = message.get(
'expirationStartTimestamp'
);
const candidateTimestamps: Array<number> = [
Date.now(),
...(existingExpirationStartTimestamp
? [existingExpirationStartTimestamp]
: []),
...readSyncs.map(sync => sync.get('readAt')),
...viewSyncs.map(sync => sync.get('viewedAt')),
];
message.set( message.set(
'expirationStartTimestamp', 'expirationStartTimestamp',
Math.min(readSync.get('readAt'), Date.now()) Math.min(...candidateTimestamps)
); );
changed = true;
} }
message.unset('unread'); 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 // This is primarily to allow the conversation to mark all older
// messages as read, as is done when we receive a read sync for // messages as read, as is done when we receive a read sync for
// a message we already know about. // a message we already know about.
@ -3290,16 +3320,17 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
c.onReadMessage(message); c.onReadMessage(message);
} }
changed = true; changed = true;
} else if (isFirstRun) { }
if (isFirstRun && !viewSyncs.length && !readSyncs.length) {
conversation.set({ conversation.set({
unreadCount: (conversation.get('unreadCount') || 0) + 1, unreadCount: (conversation.get('unreadCount') || 0) + 1,
isArchived: false, isArchived: false,
}); });
} }
}
// Check for out-of-order view once open syncs // Check for out-of-order view once open syncs
if (type === 'incoming' && isTapToView(message.attributes)) { if (isTapToView(message.attributes)) {
const viewOnceOpenSync = ViewOnceOpenSyncs.getSingleton().forMessage( const viewOnceOpenSync = ViewOnceOpenSyncs.getSingleton().forMessage(
message message
); );
@ -3308,6 +3339,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
changed = true; changed = true;
} }
} }
}
// Does this message have any pending, previously-received associated reactions? // Does this message have any pending, previously-received associated reactions?
const reactions = Reactions.getSingleton().forMessage(message); const reactions = Reactions.getSingleton().forMessage(message);

View file

@ -1,16 +1,21 @@
// Copyright 2021 Signal Messenger, LLC // Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // 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( function markReadOrViewed(
messageAttrs: MessageAttributesType, messageAttrs: Readonly<MessageAttributesType>,
readAt?: number, readStatus: ReadStatus.Read | ReadStatus.Viewed,
{ skipSave = false } = {} timestamp: undefined | number,
skipSave: boolean
): MessageAttributesType { ): MessageAttributesType {
const nextMessageAttributes = { const oldReadStatus = messageAttrs.readStatus ?? ReadStatus.Read;
const newReadStatus = maxReadStatus(oldReadStatus, readStatus);
const nextMessageAttributes: MessageAttributesType = {
...messageAttrs, ...messageAttrs,
unread: false, readStatus: newReadStatus,
}; };
const { id: messageId, expireTimer, expirationStartTimestamp } = messageAttrs; const { id: messageId, expireTimer, expirationStartTimestamp } = messageAttrs;
@ -18,7 +23,7 @@ export function markRead(
if (expireTimer && !expirationStartTimestamp) { if (expireTimer && !expirationStartTimestamp) {
nextMessageAttributes.expirationStartTimestamp = Math.min( nextMessageAttributes.expirationStartTimestamp = Math.min(
Date.now(), Date.now(),
readAt || Date.now() timestamp || Date.now()
); );
} }
@ -30,3 +35,17 @@ export function markRead(
return nextMessageAttributes; return nextMessageAttributes;
} }
export const markRead = (
messageAttrs: Readonly<MessageAttributesType>,
readAt?: number,
{ skipSave = false } = {}
): MessageAttributesType =>
markReadOrViewed(messageAttrs, ReadStatus.Read, readAt, skipSave);
export const markViewed = (
messageAttrs: Readonly<MessageAttributesType>,
viewedAt?: number,
{ skipSave = false } = {}
): MessageAttributesType =>
markReadOrViewed(messageAttrs, ReadStatus.Viewed, viewedAt, skipSave);

View file

@ -30,6 +30,7 @@ import {
pick, pick,
} from 'lodash'; } from 'lodash';
import { ReadStatus } from '../messages/MessageReadStatus';
import { GroupV2MemberType } from '../model-types.d'; import { GroupV2MemberType } from '../model-types.d';
import { ReactionType } from '../types/Reactions'; import { ReactionType } from '../types/Reactions';
import { StoredJob } from '../jobs/types'; import { StoredJob } from '../jobs/types';
@ -2076,6 +2077,19 @@ function updateToSchemaVersion38(currentVersion: number, db: Database) {
console.log('updateToSchemaVersion38: success!'); 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 = [ const SCHEMA_VERSIONS = [
updateToSchemaVersion1, updateToSchemaVersion1,
updateToSchemaVersion2, updateToSchemaVersion2,
@ -2115,6 +2129,7 @@ const SCHEMA_VERSIONS = [
updateToSchemaVersion36, updateToSchemaVersion36,
updateToSchemaVersion37, updateToSchemaVersion37,
updateToSchemaVersion38, updateToSchemaVersion38,
updateToSchemaVersion39,
]; ];
function updateSchema(db: Database): void { function updateSchema(db: Database): void {
@ -3572,7 +3587,7 @@ function saveMessageSync(
sourceUuid, sourceUuid,
sourceDevice, sourceDevice,
type, type,
unread, readStatus,
expireTimer, expireTimer,
expirationStartTimestamp, expirationStartTimestamp,
} = data; } = data;
@ -3598,7 +3613,7 @@ function saveMessageSync(
sourceUuid: sourceUuid || null, sourceUuid: sourceUuid || null,
sourceDevice: sourceDevice || null, sourceDevice: sourceDevice || null,
type: type || null, type: type || null,
unread: unread ? 1 : 0, readStatus: readStatus ?? null,
}; };
if (id && !forceSave) { if (id && !forceSave) {
@ -3626,7 +3641,7 @@ function saveMessageSync(
sourceUuid = $sourceUuid, sourceUuid = $sourceUuid,
sourceDevice = $sourceDevice, sourceDevice = $sourceDevice,
type = $type, type = $type,
unread = $unread readStatus = $readStatus
WHERE id = $id; WHERE id = $id;
` `
).run(payload); ).run(payload);
@ -3663,7 +3678,7 @@ function saveMessageSync(
sourceUuid, sourceUuid,
sourceDevice, sourceDevice,
type, type,
unread readStatus
) values ( ) values (
$id, $id,
$json, $json,
@ -3685,7 +3700,7 @@ function saveMessageSync(
$sourceUuid, $sourceUuid,
$sourceDevice, $sourceDevice,
$type, $type,
$unread $readStatus
); );
` `
).run({ ).run({
@ -3812,7 +3827,7 @@ async function getUnreadCountForConversation(
.prepare<Query>( .prepare<Query>(
` `
SELECT COUNT(*) AS unreadCount FROM messages SELECT COUNT(*) AS unreadCount FROM messages
WHERE unread = 1 AND WHERE readStatus = ${ReadStatus.Unread} AND
conversationId = $conversationId AND conversationId = $conversationId AND
type = 'incoming'; type = 'incoming';
` `
@ -3862,14 +3877,13 @@ async function getUnreadByConversationAndMarkRead(
SELECT id, json FROM messages SELECT id, json FROM messages
INDEXED BY messages_unread INDEXED BY messages_unread
WHERE WHERE
unread = $unread AND readStatus = ${ReadStatus.Unread} AND
conversationId = $conversationId AND conversationId = $conversationId AND
received_at <= $newestUnreadId received_at <= $newestUnreadId
ORDER BY received_at DESC, sent_at DESC; ORDER BY received_at DESC, sent_at DESC;
` `
) )
.all({ .all({
unread: 1,
conversationId, conversationId,
newestUnreadId, newestUnreadId,
}); });
@ -3878,24 +3892,23 @@ async function getUnreadByConversationAndMarkRead(
` `
UPDATE messages UPDATE messages
SET SET
unread = 0, readStatus = ${ReadStatus.Read},
json = json_patch(json, $jsonPatch) json = json_patch(json, $jsonPatch)
WHERE WHERE
unread = $unread AND readStatus = ${ReadStatus.Unread} AND
conversationId = $conversationId AND conversationId = $conversationId AND
received_at <= $newestUnreadId; received_at <= $newestUnreadId;
` `
).run({ ).run({
conversationId, conversationId,
jsonPatch: JSON.stringify({ unread: 0 }), jsonPatch: JSON.stringify({ readStatus: ReadStatus.Read }),
newestUnreadId, newestUnreadId,
unread: 1,
}); });
return rows.map(row => { return rows.map(row => {
const json = jsonToObject(row.json); const json = jsonToObject(row.json);
return { return {
unread: false, readStatus: ReadStatus.Read,
...pick(json, [ ...pick(json, [
'expirationStartTimestamp', 'expirationStartTimestamp',
'id', 'id',
@ -4313,7 +4326,7 @@ function getOldestUnreadMessageForConversation(
` `
SELECT * FROM messages WHERE SELECT * FROM messages WHERE
conversationId = $conversationId AND conversationId = $conversationId AND
unread = 1 readStatus = ${ReadStatus.Unread}
ORDER BY received_at ASC, sent_at ASC ORDER BY received_at ASC, sent_at ASC
LIMIT 1; LIMIT 1;
` `
@ -4338,7 +4351,7 @@ function getTotalUnreadForConversation(conversationId: string): number {
FROM messages FROM messages
WHERE WHERE
conversationId = $conversationId AND conversationId = $conversationId AND
unread = 1; readStatus = ${ReadStatus.Unread};
` `
) )
.get({ .get({
@ -4469,8 +4482,9 @@ async function getMessagesUnexpectedlyMissingExpirationStartTimestamp(): Promise
( (
type IS 'outgoing' OR type IS 'outgoing' OR
(type IS 'incoming' AND ( (type IS 'incoming' AND (
unread = 0 OR readStatus = ${ReadStatus.Read} OR
unread IS NULL readStatus = ${ReadStatus.Viewed} OR
readStatus IS NULL
)) ))
); );
` `

View file

@ -45,6 +45,7 @@ import { ConversationColors } from '../../types/Colors';
import { CallMode } from '../../types/Calling'; import { CallMode } from '../../types/Calling';
import { SignalService as Proto } from '../../protobuf'; import { SignalService as Proto } from '../../protobuf';
import { AttachmentType, isVoiceMessage } from '../../types/Attachment'; import { AttachmentType, isVoiceMessage } from '../../types/Attachment';
import { ReadStatus } from '../../messages/MessageReadStatus';
import { CallingNotificationType } from '../../util/callingNotification'; import { CallingNotificationType } from '../../util/callingNotification';
import { memoizeByRoot } from '../../util/memoizeByRoot'; import { memoizeByRoot } from '../../util/memoizeByRoot';
@ -478,6 +479,7 @@ type ShallowPropsType = Pick<
| 'isTapToView' | 'isTapToView'
| 'isTapToViewError' | 'isTapToViewError'
| 'isTapToViewExpired' | 'isTapToViewExpired'
| 'readStatus'
| 'selectedReaction' | 'selectedReaction'
| 'status' | 'status'
| 'text' | 'text'
@ -544,6 +546,7 @@ const getShallowPropsForMessage = createSelectorCreator(memoizeByRoot, isEqual)(
isTapToViewError: isTapToViewError:
isMessageTapToView && isIncoming(message) && message.isTapToViewInvalid, isMessageTapToView && isIncoming(message) && message.isTapToViewInvalid,
isTapToViewExpired: isMessageTapToView && message.isErased, isTapToViewExpired: isMessageTapToView && message.isErased,
readStatus: message.readStatus ?? ReadStatus.Read,
selectedReaction, selectedReaction,
status: getMessagePropStatus(message, ourConversationId), status: getMessagePropStatus(message, ourConversationId),
text: createNonBreakingLastSeparator(message.body), text: createNonBreakingLastSeparator(message.body),

View file

@ -39,6 +39,7 @@ export type Props = {
computePeaks(url: string, barCount: number): Promise<ComputePeaksResult>; computePeaks(url: string, barCount: number): Promise<ComputePeaksResult>;
kickOffAttachmentDownload(): void; kickOffAttachmentDownload(): void;
onCorrupted(): void; onCorrupted(): void;
onFirstPlayed(): void;
}; };
const mapStateToProps = (state: StateType, props: Props) => { const mapStateToProps = (state: StateType, props: Props) => {

View file

@ -30,6 +30,7 @@ export type OwnProps = Pick<
| 'errors' | 'errors'
| 'kickOffAttachmentDownload' | 'kickOffAttachmentDownload'
| 'markAttachmentAsCorrupted' | 'markAttachmentAsCorrupted'
| 'markViewed'
| 'message' | 'message'
| 'openConversation' | 'openConversation'
| 'openLink' | 'openLink'
@ -71,6 +72,7 @@ const mapStateToProps = (
doubleCheckMissingQuoteReference, doubleCheckMissingQuoteReference,
kickOffAttachmentDownload, kickOffAttachmentDownload,
markAttachmentAsCorrupted, markAttachmentAsCorrupted,
markViewed,
openConversation, openConversation,
openLink, openLink,
reactToMessage, reactToMessage,
@ -115,6 +117,7 @@ const mapStateToProps = (
doubleCheckMissingQuoteReference, doubleCheckMissingQuoteReference,
kickOffAttachmentDownload, kickOffAttachmentDownload,
markAttachmentAsCorrupted, markAttachmentAsCorrupted,
markViewed,
openConversation, openConversation,
openLink, openLink,
reactToMessage, reactToMessage,

View file

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

View file

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

View file

@ -21,6 +21,7 @@ import {
} from '../../../state/selectors/search'; } from '../../../state/selectors/search';
import { makeLookup } from '../../../util/makeLookup'; import { makeLookup } from '../../../util/makeLookup';
import { getDefaultConversation } from '../../helpers/getDefaultConversation'; import { getDefaultConversation } from '../../helpers/getDefaultConversation';
import { ReadStatus } from '../../../messages/MessageReadStatus';
import { StateType, reducer as rootReducer } from '../../../state/reducer'; import { StateType, reducer as rootReducer } from '../../../state/reducer';
@ -54,7 +55,7 @@ describe('both/state/selectors/search', () => {
sourceUuid: 'sourceUuid', sourceUuid: 'sourceUuid',
timestamp: NOW, timestamp: NOW,
type: 'incoming' as const, type: 'incoming' as const,
unread: false, readStatus: ReadStatus.Read,
}; };
} }

View file

@ -2,20 +2,22 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai'; import { assert } from 'chai';
import { ReadStatus } from '../../messages/MessageReadStatus';
import { isMessageUnread } from '../../util/isMessageUnread'; import { isMessageUnread } from '../../util/isMessageUnread';
describe('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({}));
assert.isFalse(isMessageUnread({ unread: undefined })); assert.isFalse(isMessageUnread({ readStatus: undefined }));
}); });
it('returns false if the message is read', () => { it('returns false if the message is read or viewed', () => {
assert.isFalse(isMessageUnread({ unread: false })); assert.isFalse(isMessageUnread({ readStatus: ReadStatus.Read }));
assert.isFalse(isMessageUnread({ readStatus: ReadStatus.Viewed }));
}); });
it('returns true if the message is unread', () => { it('returns true if the message is unread', () => {
assert.isTrue(isMessageUnread({ unread: true })); assert.isTrue(isMessageUnread({ readStatus: ReadStatus.Unread }));
}); });
}); });

View file

@ -23,6 +23,7 @@ import {
reducer, reducer,
updateConversationLookups, updateConversationLookups,
} from '../../../state/ducks/conversations'; } from '../../../state/ducks/conversations';
import { ReadStatus } from '../../../messages/MessageReadStatus';
import { ContactSpoofingType } from '../../../util/contactSpoofing'; import { ContactSpoofingType } from '../../../util/contactSpoofing';
import { CallMode } from '../../../types/Calling'; import { CallMode } from '../../../types/Calling';
import * as groups from '../../../groups'; import * as groups from '../../../groups';
@ -317,7 +318,7 @@ describe('both/state/ducks/conversations', () => {
sourceUuid: 'sourceUuid', sourceUuid: 'sourceUuid',
timestamp: previousTime, timestamp: previousTime,
type: 'incoming' as const, type: 'incoming' as const,
unread: false, readStatus: ReadStatus.Read,
}; };
} }

View file

@ -91,6 +91,7 @@ import {
StickerPackEvent, StickerPackEvent,
VerifiedEvent, VerifiedEvent,
ReadSyncEvent, ReadSyncEvent,
ViewSyncEvent,
ContactEvent, ContactEvent,
ContactSyncEvent, ContactSyncEvent,
GroupEvent, GroupEvent,
@ -440,6 +441,11 @@ export default class MessageReceiver
handler: (ev: ReadSyncEvent) => void handler: (ev: ReadSyncEvent) => void
): void; ): void;
public addEventListener(
name: 'viewSync',
handler: (ev: ViewSyncEvent) => void
): void;
public addEventListener( public addEventListener(
name: 'contact', name: 'contact',
handler: (ev: ContactEvent) => void handler: (ev: ContactEvent) => void
@ -2206,6 +2212,9 @@ export default class MessageReceiver
if (syncMessage.keys) { if (syncMessage.keys) {
return this.handleKeys(envelope, syncMessage.keys); return this.handleKeys(envelope, syncMessage.keys);
} }
if (syncMessage.viewed && syncMessage.viewed.length) {
return this.handleViewed(envelope, syncMessage.viewed);
}
this.removeFromCache(envelope); this.removeFromCache(envelope);
window.log.warn( window.log.warn(
@ -2388,6 +2397,32 @@ export default class MessageReceiver
await Promise.all(results); await Promise.all(results);
} }
private async handleViewed(
envelope: ProcessedEnvelope,
viewed: ReadonlyArray<Proto.SyncMessage.IViewed>
): Promise<void> {
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( private async handleContacts(
envelope: ProcessedEnvelope, envelope: ProcessedEnvelope,
contacts: Proto.SyncMessage.IContacts contacts: Proto.SyncMessage.IContacts

View file

@ -1297,6 +1297,33 @@ export default class MessageSender {
}); });
} }
async syncView(
views: ReadonlyArray<{
senderUuid?: string;
senderE164?: string;
timestamp: number;
}>,
options?: SendOptionsType
): Promise<CallbackResultType> {
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( async syncViewOnceOpen(
sender: string | undefined, sender: string | undefined,
senderUuid: string, senderUuid: string,

View file

@ -399,3 +399,19 @@ export class ReadSyncEvent extends ConfirmableEvent {
super('readSync', confirm); 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);
}
}

View file

@ -36,7 +36,8 @@ export type SendTypesType =
| 'sentSync' | 'sentSync'
| 'typing' // excluded from send log | 'typing' // excluded from send log
| 'verificationSync' | 'verificationSync'
| 'viewOnceSync'; | 'viewOnceSync'
| 'viewSync';
export function shouldSaveProto(sendType: SendTypesType): boolean { export function shouldSaveProto(sendType: SendTypesType): boolean {
if (sendType === 'callingMessage') { if (sendType === 'callingMessage') {

View file

@ -1,8 +1,9 @@
// Copyright 2021 Signal Messenger, LLC // Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { ReadStatus } from '../messages/MessageReadStatus';
import type { MessageAttributesType } from '../model-types.d'; import type { MessageAttributesType } from '../model-types.d';
export const isMessageUnread = ( export const isMessageUnread = (
message: Readonly<Pick<MessageAttributesType, 'unread'>> message: Readonly<Pick<MessageAttributesType, 'readStatus'>>
): boolean => Boolean(message.unread); ): boolean => message.readStatus === ReadStatus.Unread;

View file

@ -63,6 +63,9 @@ import {
autoScale, autoScale,
handleImageAttachment, handleImageAttachment,
} from '../util/handleImageAttachment'; } from '../util/handleImageAttachment';
import { ReadStatus } from '../messages/MessageReadStatus';
import { markViewed } from '../services/MessageUpdater';
import { viewSyncJobQueue } from '../jobs/viewSyncJobQueue';
type AttachmentOptions = { type AttachmentOptions = {
messageId: string; messageId: string;
@ -861,6 +864,28 @@ Whisper.ConversationView = Whisper.View.extend({
} }
message.markAttachmentAsCorrupted(options.attachment); 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: { const showVisualAttachment = (options: {
attachment: AttachmentType; attachment: AttachmentType;
messageId: string; messageId: string;
@ -910,6 +935,7 @@ Whisper.ConversationView = Whisper.View.extend({
downloadNewVersion, downloadNewVersion,
kickOffAttachmentDownload, kickOffAttachmentDownload,
markAttachmentAsCorrupted, markAttachmentAsCorrupted,
markViewed: onMarkViewed,
openConversation, openConversation,
openLink, openLink,
reactToMessage, reactToMessage,