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,
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;

View File

@ -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,33 +873,48 @@ story.add('Pending GIF', () => {
});
story.add('Audio', () => {
const props = createProps({
attachments: [
{
contentType: AUDIO_MP3,
fileName: 'incompetech-com-Agnus-Dei-X.mp3',
url: '/fixtures/incompetech-com-Agnus-Dei-X.mp3',
},
],
status: 'sent',
});
const Wrapper = () => {
const [isPlayed, setIsPlayed] = React.useState(false);
return renderBothDirections(props);
});
const messageProps = createProps({
attachments: [
{
contentType: AUDIO_MP3,
fileName: 'incompetech-com-Agnus-Dei-X.mp3',
url: '/fixtures/incompetech-com-Agnus-Dei-X.mp3',
},
],
...(isPlayed
? {
status: 'viewed',
readStatus: ReadStatus.Viewed,
}
: {
status: 'read',
readStatus: ReadStatus.Read,
}),
});
story.add('Audio (played)', () => {
const props = createProps({
attachments: [
{
contentType: AUDIO_MP3,
fileName: 'incompetech-com-Agnus-Dei-X.mp3',
url: '/fixtures/incompetech-com-Agnus-Dei-X.mp3',
},
],
status: 'viewed',
});
return (
<>
<button
type="button"
onClick={() => {
setIsPlayed(old => !old);
}}
style={{
display: 'block',
marginBottom: '2em',
}}
>
Toggle played
</button>
{renderBothDirections(messageProps)}
</>
);
};
return renderBothDirections(props);
return <Wrapper />;
});
story.add('Long Audio', () => {

View File

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

View File

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

View File

@ -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'),

View File

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

View File

@ -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'),

View File

@ -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'),

View File

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

View File

@ -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'),

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

View File

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

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 { 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;
};

View File

@ -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(),

View File

@ -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')
) {
message.set(
'expirationStartTimestamp',
Math.min(readSync.get('readAt'), Date.now())
);
}
const readSyncs = readSync ? [readSync] : [];
message.unset('unread');
const viewSyncs = ViewSyncs.getSingleton().forMessage(message);
if (message.get('expireTimer')) {
const existingExpirationStartTimestamp = message.get(
'expirationStartTimestamp'
);
const candidateTimestamps: Array<number> = [
Date.now(),
...(existingExpirationStartTimestamp
? [existingExpirationStartTimestamp]
: []),
...readSyncs.map(sync => sync.get('readAt')),
...viewSyncs.map(sync => sync.get('viewedAt')),
];
message.set(
'expirationStartTimestamp',
Math.min(...candidateTimestamps)
);
changed = true;
}
let newReadStatus: undefined | ReadStatus.Read | ReadStatus.Viewed;
if (viewSyncs.length) {
newReadStatus = ReadStatus.Viewed;
} else if (readSyncs.length) {
newReadStatus = ReadStatus.Read;
}
if (newReadStatus !== undefined) {
message.set('readStatus', newReadStatus);
// This is primarily to allow the conversation to mark all older
// messages as read, as is done when we receive a read sync for
// a message we already know about.
@ -3290,22 +3320,24 @@ export class MessageModel extends window.Backbone.Model<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)) {
const viewOnceOpenSync = ViewOnceOpenSyncs.getSingleton().forMessage(
message
);
if (viewOnceOpenSync) {
await message.markViewOnceMessageViewed({ fromSync: true });
changed = true;
// Check for out-of-order view once open syncs
if (isTapToView(message.attributes)) {
const viewOnceOpenSync = ViewOnceOpenSyncs.getSingleton().forMessage(
message
);
if (viewOnceOpenSync) {
await message.markViewOnceMessageViewed({ fromSync: true });
changed = true;
}
}
}

View File

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

View File

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

View File

@ -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),

View File

@ -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) => {

View File

@ -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,

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';
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,
};
}

View File

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

View File

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

View File

@ -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

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(
sender: string | undefined,
senderUuid: string,

View File

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

View File

@ -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') {

View File

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

View File

@ -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,