Show "unplayed" dot on incoming audio messages
This commit is contained in:
parent
9fd191ae00
commit
b0750e5f4e
36 changed files with 812 additions and 175 deletions
|
@ -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<void> {
|
|||
'readSync',
|
||||
queuedEventListener(onReadSync)
|
||||
);
|
||||
messageReceiver.addEventListener(
|
||||
'viewSync',
|
||||
queuedEventListener(onViewSync)
|
||||
);
|
||||
messageReceiver.addEventListener(
|
||||
'read',
|
||||
queuedEventListener(onReadReceipt)
|
||||
|
@ -3518,7 +3525,7 @@ export async function startApp(): Promise<void> {
|
|||
conversationId: descriptor.id,
|
||||
unidentifiedDeliveryReceived: data.unidentifiedDeliveryReceived,
|
||||
type: 'incoming',
|
||||
unread: true,
|
||||
readStatus: ReadStatus.Unread,
|
||||
timestamp: data.timestamp,
|
||||
} as Partial<MessageAttributesType>) as WhatIsThis);
|
||||
}
|
||||
|
@ -3851,6 +3858,38 @@ export async function startApp(): Promise<void> {
|
|||
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;
|
||||
|
|
|
@ -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<AudioAttachmentProps> = 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> = {}): 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,22 +873,10 @@ 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',
|
||||
});
|
||||
|
||||
return renderBothDirections(props);
|
||||
});
|
||||
|
||||
story.add('Audio (played)', () => {
|
||||
const props = createProps({
|
||||
const Wrapper = () => {
|
||||
const [isPlayed, setIsPlayed] = React.useState(false);
|
||||
|
||||
const messageProps = createProps({
|
||||
attachments: [
|
||||
{
|
||||
contentType: AUDIO_MP3,
|
||||
|
@ -889,10 +884,37 @@ story.add('Audio (played)', () => {
|
|||
url: '/fixtures/incompetech-com-Agnus-Dei-X.mp3',
|
||||
},
|
||||
],
|
||||
...(isPlayed
|
||||
? {
|
||||
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', () => {
|
||||
|
|
|
@ -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<Props, State> {
|
|||
isSticker,
|
||||
kickOffAttachmentDownload,
|
||||
markAttachmentAsCorrupted,
|
||||
markViewed,
|
||||
quote,
|
||||
readStatus,
|
||||
reducedMotion,
|
||||
renderAudioAttachment,
|
||||
renderingContext,
|
||||
|
@ -791,8 +798,7 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
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<Props, State> {
|
|||
messageId: id,
|
||||
});
|
||||
},
|
||||
onFirstPlayed() {
|
||||
markViewed(id);
|
||||
},
|
||||
});
|
||||
}
|
||||
const { pending, fileName, fileSize, contentType } = firstAttachment;
|
||||
|
|
|
@ -37,6 +37,7 @@ export type Props = {
|
|||
buttonRef: React.RefObject<HTMLButtonElement>;
|
||||
kickOffAttachmentDownload(): void;
|
||||
onCorrupted(): void;
|
||||
onFirstPlayed(): void;
|
||||
|
||||
computePeaks(url: string, barCount: number): Promise<ComputePeaksResult>;
|
||||
activeAudioID: string | undefined;
|
||||
|
@ -163,6 +164,7 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
|
|||
buttonRef,
|
||||
kickOffAttachmentDownload,
|
||||
onCorrupted,
|
||||
onFirstPlayed,
|
||||
|
||||
audio,
|
||||
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.
|
||||
const onWaveformClick = (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
|
|
|
@ -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> = {}): Props => ({
|
|||
doubleCheckMissingQuoteReference: action('doubleCheckMissingQuoteReference'),
|
||||
kickOffAttachmentDownload: action('kickOffAttachmentDownload'),
|
||||
markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'),
|
||||
markViewed: action('markViewed'),
|
||||
openConversation: action('openConversation'),
|
||||
openLink: action('openLink'),
|
||||
reactToMessage: action('reactToMessage'),
|
||||
|
|
|
@ -69,6 +69,7 @@ export type Props = {
|
|||
| 'interactionMode'
|
||||
| 'kickOffAttachmentDownload'
|
||||
| 'markAttachmentAsCorrupted'
|
||||
| 'markViewed'
|
||||
| 'openConversation'
|
||||
| 'openLink'
|
||||
| 'reactToMessage'
|
||||
|
@ -269,6 +270,7 @@ export class MessageDetail extends React.Component<Props> {
|
|||
interactionMode,
|
||||
kickOffAttachmentDownload,
|
||||
markAttachmentAsCorrupted,
|
||||
markViewed,
|
||||
openConversation,
|
||||
openLink,
|
||||
reactToMessage,
|
||||
|
@ -305,6 +307,7 @@ export class MessageDetail extends React.Component<Props> {
|
|||
interactionMode={interactionMode}
|
||||
kickOffAttachmentDownload={kickOffAttachmentDownload}
|
||||
markAttachmentAsCorrupted={markAttachmentAsCorrupted}
|
||||
markViewed={markViewed}
|
||||
onHeightChange={noop}
|
||||
openConversation={openConversation}
|
||||
openLink={openLink}
|
||||
|
|
|
@ -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: () => <div />,
|
||||
renderAudioAttachment: () => <div>*AudioAttachment*</div>,
|
||||
replyToMessage: action('default--replyToMessage'),
|
||||
|
|
|
@ -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<string, TimelineItemType> = {
|
|||
isBlocked: false,
|
||||
isMessageRequestAccepted: true,
|
||||
previews: [],
|
||||
readStatus: ReadStatus.Read,
|
||||
text: '🔥',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
|
@ -70,6 +72,7 @@ const items: Record<string, TimelineItemType> = {
|
|||
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<string, TimelineItemType> = {
|
|||
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<string, TimelineItemType> = {
|
|||
isBlocked: false,
|
||||
isMessageRequestAccepted: true,
|
||||
previews: [],
|
||||
readStatus: ReadStatus.Read,
|
||||
status: 'sent',
|
||||
text: '🔥',
|
||||
timestamp: Date.now(),
|
||||
|
@ -220,6 +225,7 @@ const items: Record<string, TimelineItemType> = {
|
|||
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<string, TimelineItemType> = {
|
|||
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<string, TimelineItemType> = {
|
|||
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<string, TimelineItemType> = {
|
|||
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'),
|
||||
|
|
|
@ -1496,6 +1496,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
|||
'loadNewerMessages',
|
||||
'loadNewestMessages',
|
||||
'markMessageRead',
|
||||
'markViewed',
|
||||
'onBlock',
|
||||
'onBlockAndReportSpam',
|
||||
'onDelete',
|
||||
|
|
|
@ -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'),
|
||||
|
|
118
ts/jobs/helpers/runReadOrViewSyncJob.ts
Normal file
118
ts/jobs/helpers/runReadOrViewSyncJob.ts
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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<ReadSyncJobData> {
|
|||
{ data, timestamp }: Readonly<{ data: ReadSyncJobData; timestamp: number }>,
|
||||
{ attempt }: Readonly<{ attempt: number }>
|
||||
): Promise<void> {
|
||||
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<void>(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
54
ts/jobs/viewSyncJobQueue.ts
Normal file
54
ts/jobs/viewSyncJobQueue.ts
Normal 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),
|
||||
});
|
101
ts/messageModifiers/ViewSyncs.ts
Normal file
101
ts/messageModifiers/ViewSyncs.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
26
ts/messages/MessageReadStatus.ts
Normal file
26
ts/messages/MessageReadStatus.ts
Normal 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;
|
17
ts/messages/migrateLegacyReadStatus.ts
Normal file
17
ts/messages/migrateLegacyReadStatus.ts
Normal 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
5
ts/model-types.d.ts
vendored
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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<MessageAttributesType> {
|
|||
);
|
||||
}
|
||||
|
||||
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<MessageAttributesType> {
|
|||
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<MessageAttributesType> {
|
|||
}
|
||||
|
||||
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')
|
||||
) {
|
||||
const readSyncs = readSync ? [readSync] : [];
|
||||
|
||||
const viewSyncs = ViewSyncs.getSingleton().forMessage(message);
|
||||
|
||||
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(
|
||||
'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
|
||||
// messages as read, as is done when we receive a read sync for
|
||||
// a message we already know about.
|
||||
|
@ -3290,16 +3320,17 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
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)) {
|
||||
if (isTapToView(message.attributes)) {
|
||||
const viewOnceOpenSync = ViewOnceOpenSyncs.getSingleton().forMessage(
|
||||
message
|
||||
);
|
||||
|
@ -3308,6 +3339,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Does this message have any pending, previously-received associated reactions?
|
||||
const reactions = Reactions.getSingleton().forMessage(message);
|
||||
|
|
|
@ -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<MessageAttributesType>,
|
||||
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<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);
|
||||
|
|
|
@ -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<Query>(
|
||||
`
|
||||
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
|
||||
))
|
||||
);
|
||||
`
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -39,6 +39,7 @@ export type Props = {
|
|||
computePeaks(url: string, barCount: number): Promise<ComputePeaksResult>;
|
||||
kickOffAttachmentDownload(): void;
|
||||
onCorrupted(): void;
|
||||
onFirstPlayed(): void;
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: StateType, props: Props) => {
|
||||
|
|
|
@ -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,
|
||||
|
|
50
ts/test-both/messages/MessageReadStatus_test.ts
Normal file
50
ts/test-both/messages/MessageReadStatus_test.ts
Normal 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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
55
ts/test-both/messages/migrateLegacyReadStatus_test.ts
Normal file
55
ts/test-both/messages/migrateLegacyReadStatus_test.ts
Normal 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
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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 }));
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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<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(
|
||||
envelope: ProcessedEnvelope,
|
||||
contacts: Proto.SyncMessage.IContacts
|
||||
|
|
|
@ -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(
|
||||
sender: string | undefined,
|
||||
senderUuid: string,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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') {
|
||||
|
|
|
@ -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<Pick<MessageAttributesType, 'unread'>>
|
||||
): boolean => Boolean(message.unread);
|
||||
message: Readonly<Pick<MessageAttributesType, 'readStatus'>>
|
||||
): boolean => message.readStatus === ReadStatus.Unread;
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in a new issue