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,
|
StickerPackEvent,
|
||||||
VerifiedEvent,
|
VerifiedEvent,
|
||||||
ReadSyncEvent,
|
ReadSyncEvent,
|
||||||
|
ViewSyncEvent,
|
||||||
ContactEvent,
|
ContactEvent,
|
||||||
GroupEvent,
|
GroupEvent,
|
||||||
EnvelopeEvent,
|
EnvelopeEvent,
|
||||||
|
@ -79,7 +80,9 @@ import {
|
||||||
import { MessageRequests } from './messageModifiers/MessageRequests';
|
import { MessageRequests } from './messageModifiers/MessageRequests';
|
||||||
import { Reactions } from './messageModifiers/Reactions';
|
import { Reactions } from './messageModifiers/Reactions';
|
||||||
import { ReadSyncs } from './messageModifiers/ReadSyncs';
|
import { ReadSyncs } from './messageModifiers/ReadSyncs';
|
||||||
|
import { ViewSyncs } from './messageModifiers/ViewSyncs';
|
||||||
import { ViewOnceOpenSyncs } from './messageModifiers/ViewOnceOpenSyncs';
|
import { ViewOnceOpenSyncs } from './messageModifiers/ViewOnceOpenSyncs';
|
||||||
|
import { ReadStatus } from './messages/MessageReadStatus';
|
||||||
import {
|
import {
|
||||||
SendStateByConversationId,
|
SendStateByConversationId,
|
||||||
SendStatus,
|
SendStatus,
|
||||||
|
@ -211,6 +214,10 @@ export async function startApp(): Promise<void> {
|
||||||
'readSync',
|
'readSync',
|
||||||
queuedEventListener(onReadSync)
|
queuedEventListener(onReadSync)
|
||||||
);
|
);
|
||||||
|
messageReceiver.addEventListener(
|
||||||
|
'viewSync',
|
||||||
|
queuedEventListener(onViewSync)
|
||||||
|
);
|
||||||
messageReceiver.addEventListener(
|
messageReceiver.addEventListener(
|
||||||
'read',
|
'read',
|
||||||
queuedEventListener(onReadReceipt)
|
queuedEventListener(onReadReceipt)
|
||||||
|
@ -3518,7 +3525,7 @@ export async function startApp(): Promise<void> {
|
||||||
conversationId: descriptor.id,
|
conversationId: descriptor.id,
|
||||||
unidentifiedDeliveryReceived: data.unidentifiedDeliveryReceived,
|
unidentifiedDeliveryReceived: data.unidentifiedDeliveryReceived,
|
||||||
type: 'incoming',
|
type: 'incoming',
|
||||||
unread: true,
|
readStatus: ReadStatus.Unread,
|
||||||
timestamp: data.timestamp,
|
timestamp: data.timestamp,
|
||||||
} as Partial<MessageAttributesType>) as WhatIsThis);
|
} as Partial<MessageAttributesType>) as WhatIsThis);
|
||||||
}
|
}
|
||||||
|
@ -3851,6 +3858,38 @@ export async function startApp(): Promise<void> {
|
||||||
return ReadSyncs.getSingleton().onSync(receipt);
|
return ReadSyncs.getSingleton().onSync(receipt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onViewSync(ev: ViewSyncEvent) {
|
||||||
|
const { envelopeTimestamp, senderE164, senderUuid, timestamp } = ev.view;
|
||||||
|
const senderId = window.ConversationController.ensureContactIds({
|
||||||
|
e164: senderE164,
|
||||||
|
uuid: senderUuid,
|
||||||
|
});
|
||||||
|
|
||||||
|
window.log.info(
|
||||||
|
'view sync',
|
||||||
|
senderE164,
|
||||||
|
senderUuid,
|
||||||
|
envelopeTimestamp,
|
||||||
|
senderId,
|
||||||
|
'for message',
|
||||||
|
timestamp
|
||||||
|
);
|
||||||
|
|
||||||
|
const receipt = ViewSyncs.getSingleton().add({
|
||||||
|
senderId,
|
||||||
|
senderE164,
|
||||||
|
senderUuid,
|
||||||
|
timestamp,
|
||||||
|
viewedAt: envelopeTimestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
receipt.on('remove', ev.confirm);
|
||||||
|
|
||||||
|
// Note: Here we wait, because we want viewed states to be in the database
|
||||||
|
// before we move on.
|
||||||
|
return ViewSyncs.getSingleton().onSync(receipt);
|
||||||
|
}
|
||||||
|
|
||||||
async function onVerified(ev: VerifiedEvent) {
|
async function onVerified(ev: VerifiedEvent) {
|
||||||
const e164 = ev.verified.destination;
|
const e164 = ev.verified.destination;
|
||||||
const uuid = ev.verified.destinationUuid;
|
const uuid = ev.verified.destinationUuid;
|
||||||
|
|
|
@ -20,6 +20,7 @@ import {
|
||||||
VIDEO_MP4,
|
VIDEO_MP4,
|
||||||
stringToMIMEType,
|
stringToMIMEType,
|
||||||
} from '../../types/MIME';
|
} from '../../types/MIME';
|
||||||
|
import { ReadStatus } from '../../messages/MessageReadStatus';
|
||||||
import { MessageAudio } from './MessageAudio';
|
import { MessageAudio } from './MessageAudio';
|
||||||
import { computePeaks } from '../GlobalAudioContext';
|
import { computePeaks } from '../GlobalAudioContext';
|
||||||
import { setup as setupI18n } from '../../../js/modules/i18n';
|
import { setup as setupI18n } from '../../../js/modules/i18n';
|
||||||
|
@ -61,6 +62,7 @@ const MessageAudioContainer: React.FC<AudioAttachmentProps> = props => {
|
||||||
audio={audio}
|
audio={audio}
|
||||||
computePeaks={computePeaks}
|
computePeaks={computePeaks}
|
||||||
setActiveAudioID={(id, context) => setActive({ id, context })}
|
setActiveAudioID={(id, context) => setActive({ id, context })}
|
||||||
|
onFirstPlayed={action('onFirstPlayed')}
|
||||||
activeAudioID={active.id}
|
activeAudioID={active.id}
|
||||||
activeAudioContext={active.context}
|
activeAudioContext={active.context}
|
||||||
/>
|
/>
|
||||||
|
@ -120,12 +122,17 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
isTapToViewExpired: overrideProps.isTapToViewExpired,
|
isTapToViewExpired: overrideProps.isTapToViewExpired,
|
||||||
kickOffAttachmentDownload: action('kickOffAttachmentDownload'),
|
kickOffAttachmentDownload: action('kickOffAttachmentDownload'),
|
||||||
markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'),
|
markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'),
|
||||||
|
markViewed: action('markViewed'),
|
||||||
onHeightChange: action('onHeightChange'),
|
onHeightChange: action('onHeightChange'),
|
||||||
openConversation: action('openConversation'),
|
openConversation: action('openConversation'),
|
||||||
openLink: action('openLink'),
|
openLink: action('openLink'),
|
||||||
previews: overrideProps.previews || [],
|
previews: overrideProps.previews || [],
|
||||||
reactions: overrideProps.reactions,
|
reactions: overrideProps.reactions,
|
||||||
reactToMessage: action('reactToMessage'),
|
reactToMessage: action('reactToMessage'),
|
||||||
|
readStatus:
|
||||||
|
overrideProps.readStatus === undefined
|
||||||
|
? ReadStatus.Read
|
||||||
|
: overrideProps.readStatus,
|
||||||
renderEmojiPicker,
|
renderEmojiPicker,
|
||||||
renderAudioAttachment,
|
renderAudioAttachment,
|
||||||
replyToMessage: action('replyToMessage'),
|
replyToMessage: action('replyToMessage'),
|
||||||
|
@ -866,22 +873,10 @@ story.add('Pending GIF', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
story.add('Audio', () => {
|
story.add('Audio', () => {
|
||||||
const props = createProps({
|
const Wrapper = () => {
|
||||||
attachments: [
|
const [isPlayed, setIsPlayed] = React.useState(false);
|
||||||
{
|
|
||||||
contentType: AUDIO_MP3,
|
const messageProps = createProps({
|
||||||
fileName: 'incompetech-com-Agnus-Dei-X.mp3',
|
|
||||||
url: '/fixtures/incompetech-com-Agnus-Dei-X.mp3',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
status: 'sent',
|
|
||||||
});
|
|
||||||
|
|
||||||
return renderBothDirections(props);
|
|
||||||
});
|
|
||||||
|
|
||||||
story.add('Audio (played)', () => {
|
|
||||||
const props = createProps({
|
|
||||||
attachments: [
|
attachments: [
|
||||||
{
|
{
|
||||||
contentType: AUDIO_MP3,
|
contentType: AUDIO_MP3,
|
||||||
|
@ -889,10 +884,37 @@ story.add('Audio (played)', () => {
|
||||||
url: '/fixtures/incompetech-com-Agnus-Dei-X.mp3',
|
url: '/fixtures/incompetech-com-Agnus-Dei-X.mp3',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
...(isPlayed
|
||||||
|
? {
|
||||||
status: 'viewed',
|
status: 'viewed',
|
||||||
|
readStatus: ReadStatus.Viewed,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
status: 'read',
|
||||||
|
readStatus: ReadStatus.Read,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return renderBothDirections(props);
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setIsPlayed(old => !old);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: '2em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Toggle played
|
||||||
|
</button>
|
||||||
|
{renderBothDirections(messageProps)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Wrapper />;
|
||||||
});
|
});
|
||||||
|
|
||||||
story.add('Long Audio', () => {
|
story.add('Long Audio', () => {
|
||||||
|
|
|
@ -13,6 +13,7 @@ import {
|
||||||
ConversationTypeType,
|
ConversationTypeType,
|
||||||
InteractionModeType,
|
InteractionModeType,
|
||||||
} from '../../state/ducks/conversations';
|
} from '../../state/ducks/conversations';
|
||||||
|
import { ReadStatus } from '../../messages/MessageReadStatus';
|
||||||
import { Avatar } from '../Avatar';
|
import { Avatar } from '../Avatar';
|
||||||
import { Spinner } from '../Spinner';
|
import { Spinner } from '../Spinner';
|
||||||
import { MessageBody } from './MessageBody';
|
import { MessageBody } from './MessageBody';
|
||||||
|
@ -110,6 +111,7 @@ export type AudioAttachmentProps = {
|
||||||
|
|
||||||
kickOffAttachmentDownload(): void;
|
kickOffAttachmentDownload(): void;
|
||||||
onCorrupted(): void;
|
onCorrupted(): void;
|
||||||
|
onFirstPlayed(): void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PropsData = {
|
export type PropsData = {
|
||||||
|
@ -167,6 +169,8 @@ export type PropsData = {
|
||||||
isTapToViewExpired?: boolean;
|
isTapToViewExpired?: boolean;
|
||||||
isTapToViewError?: boolean;
|
isTapToViewError?: boolean;
|
||||||
|
|
||||||
|
readStatus: ReadStatus;
|
||||||
|
|
||||||
expirationLength?: number;
|
expirationLength?: number;
|
||||||
expirationTimestamp?: number;
|
expirationTimestamp?: number;
|
||||||
|
|
||||||
|
@ -225,6 +229,7 @@ export type PropsActions = {
|
||||||
attachment: AttachmentType;
|
attachment: AttachmentType;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
}) => void;
|
}) => void;
|
||||||
|
markViewed(messageId: string): void;
|
||||||
showVisualAttachment: (options: {
|
showVisualAttachment: (options: {
|
||||||
attachment: AttachmentType;
|
attachment: AttachmentType;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
|
@ -684,7 +689,9 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
isSticker,
|
isSticker,
|
||||||
kickOffAttachmentDownload,
|
kickOffAttachmentDownload,
|
||||||
markAttachmentAsCorrupted,
|
markAttachmentAsCorrupted,
|
||||||
|
markViewed,
|
||||||
quote,
|
quote,
|
||||||
|
readStatus,
|
||||||
reducedMotion,
|
reducedMotion,
|
||||||
renderAudioAttachment,
|
renderAudioAttachment,
|
||||||
renderingContext,
|
renderingContext,
|
||||||
|
@ -791,8 +798,7 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
played = status === 'viewed';
|
played = status === 'viewed';
|
||||||
break;
|
break;
|
||||||
case 'incoming':
|
case 'incoming':
|
||||||
// Not implemented yet. See DESKTOP-1855.
|
played = readStatus === ReadStatus.Viewed;
|
||||||
played = true;
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
window.log.error(missingCaseError(direction));
|
window.log.error(missingCaseError(direction));
|
||||||
|
@ -831,6 +837,9 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
messageId: id,
|
messageId: id,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
onFirstPlayed() {
|
||||||
|
markViewed(id);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const { pending, fileName, fileSize, contentType } = firstAttachment;
|
const { pending, fileName, fileSize, contentType } = firstAttachment;
|
||||||
|
|
|
@ -37,6 +37,7 @@ export type Props = {
|
||||||
buttonRef: React.RefObject<HTMLButtonElement>;
|
buttonRef: React.RefObject<HTMLButtonElement>;
|
||||||
kickOffAttachmentDownload(): void;
|
kickOffAttachmentDownload(): void;
|
||||||
onCorrupted(): void;
|
onCorrupted(): void;
|
||||||
|
onFirstPlayed(): void;
|
||||||
|
|
||||||
computePeaks(url: string, barCount: number): Promise<ComputePeaksResult>;
|
computePeaks(url: string, barCount: number): Promise<ComputePeaksResult>;
|
||||||
activeAudioID: string | undefined;
|
activeAudioID: string | undefined;
|
||||||
|
@ -163,6 +164,7 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
|
||||||
buttonRef,
|
buttonRef,
|
||||||
kickOffAttachmentDownload,
|
kickOffAttachmentDownload,
|
||||||
onCorrupted,
|
onCorrupted,
|
||||||
|
onFirstPlayed,
|
||||||
|
|
||||||
audio,
|
audio,
|
||||||
computePeaks,
|
computePeaks,
|
||||||
|
@ -365,6 +367,12 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!played && isPlaying) {
|
||||||
|
onFirstPlayed();
|
||||||
|
}
|
||||||
|
}, [played, isPlaying, onFirstPlayed]);
|
||||||
|
|
||||||
// Clicking waveform moves playback head position and starts playback.
|
// Clicking waveform moves playback head position and starts playback.
|
||||||
const onWaveformClick = (event: React.MouseEvent) => {
|
const onWaveformClick = (event: React.MouseEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
|
@ -10,6 +10,7 @@ import { storiesOf } from '@storybook/react';
|
||||||
import { PropsData as MessageDataPropsType } from './Message';
|
import { PropsData as MessageDataPropsType } from './Message';
|
||||||
import { MessageDetail, Props } from './MessageDetail';
|
import { MessageDetail, Props } from './MessageDetail';
|
||||||
import { SendStatus } from '../../messages/MessageSendState';
|
import { SendStatus } from '../../messages/MessageSendState';
|
||||||
|
import { ReadStatus } from '../../messages/MessageReadStatus';
|
||||||
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
|
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
|
||||||
import { setup as setupI18n } from '../../../js/modules/i18n';
|
import { setup as setupI18n } from '../../../js/modules/i18n';
|
||||||
import enMessages from '../../../_locales/en/messages.json';
|
import enMessages from '../../../_locales/en/messages.json';
|
||||||
|
@ -35,6 +36,7 @@ const defaultMessage: MessageDataPropsType = {
|
||||||
isBlocked: false,
|
isBlocked: false,
|
||||||
isMessageRequestAccepted: true,
|
isMessageRequestAccepted: true,
|
||||||
previews: [],
|
previews: [],
|
||||||
|
readStatus: ReadStatus.Read,
|
||||||
status: 'sent',
|
status: 'sent',
|
||||||
text: 'A message from Max',
|
text: 'A message from Max',
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
|
@ -71,6 +73,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
doubleCheckMissingQuoteReference: action('doubleCheckMissingQuoteReference'),
|
doubleCheckMissingQuoteReference: action('doubleCheckMissingQuoteReference'),
|
||||||
kickOffAttachmentDownload: action('kickOffAttachmentDownload'),
|
kickOffAttachmentDownload: action('kickOffAttachmentDownload'),
|
||||||
markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'),
|
markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'),
|
||||||
|
markViewed: action('markViewed'),
|
||||||
openConversation: action('openConversation'),
|
openConversation: action('openConversation'),
|
||||||
openLink: action('openLink'),
|
openLink: action('openLink'),
|
||||||
reactToMessage: action('reactToMessage'),
|
reactToMessage: action('reactToMessage'),
|
||||||
|
|
|
@ -69,6 +69,7 @@ export type Props = {
|
||||||
| 'interactionMode'
|
| 'interactionMode'
|
||||||
| 'kickOffAttachmentDownload'
|
| 'kickOffAttachmentDownload'
|
||||||
| 'markAttachmentAsCorrupted'
|
| 'markAttachmentAsCorrupted'
|
||||||
|
| 'markViewed'
|
||||||
| 'openConversation'
|
| 'openConversation'
|
||||||
| 'openLink'
|
| 'openLink'
|
||||||
| 'reactToMessage'
|
| 'reactToMessage'
|
||||||
|
@ -269,6 +270,7 @@ export class MessageDetail extends React.Component<Props> {
|
||||||
interactionMode,
|
interactionMode,
|
||||||
kickOffAttachmentDownload,
|
kickOffAttachmentDownload,
|
||||||
markAttachmentAsCorrupted,
|
markAttachmentAsCorrupted,
|
||||||
|
markViewed,
|
||||||
openConversation,
|
openConversation,
|
||||||
openLink,
|
openLink,
|
||||||
reactToMessage,
|
reactToMessage,
|
||||||
|
@ -305,6 +307,7 @@ export class MessageDetail extends React.Component<Props> {
|
||||||
interactionMode={interactionMode}
|
interactionMode={interactionMode}
|
||||||
kickOffAttachmentDownload={kickOffAttachmentDownload}
|
kickOffAttachmentDownload={kickOffAttachmentDownload}
|
||||||
markAttachmentAsCorrupted={markAttachmentAsCorrupted}
|
markAttachmentAsCorrupted={markAttachmentAsCorrupted}
|
||||||
|
markViewed={markViewed}
|
||||||
onHeightChange={noop}
|
onHeightChange={noop}
|
||||||
openConversation={openConversation}
|
openConversation={openConversation}
|
||||||
openLink={openLink}
|
openLink={openLink}
|
||||||
|
|
|
@ -19,6 +19,7 @@ import {
|
||||||
stringToMIMEType,
|
stringToMIMEType,
|
||||||
} from '../../types/MIME';
|
} from '../../types/MIME';
|
||||||
import { Props, Quote } from './Quote';
|
import { Props, Quote } from './Quote';
|
||||||
|
import { ReadStatus } from '../../messages/MessageReadStatus';
|
||||||
import { setup as setupI18n } from '../../../js/modules/i18n';
|
import { setup as setupI18n } from '../../../js/modules/i18n';
|
||||||
import enMessages from '../../../_locales/en/messages.json';
|
import enMessages from '../../../_locales/en/messages.json';
|
||||||
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
|
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
|
||||||
|
@ -56,11 +57,13 @@ const defaultMessageProps: MessagesProps = {
|
||||||
isMessageRequestAccepted: true,
|
isMessageRequestAccepted: true,
|
||||||
kickOffAttachmentDownload: action('default--kickOffAttachmentDownload'),
|
kickOffAttachmentDownload: action('default--kickOffAttachmentDownload'),
|
||||||
markAttachmentAsCorrupted: action('default--markAttachmentAsCorrupted'),
|
markAttachmentAsCorrupted: action('default--markAttachmentAsCorrupted'),
|
||||||
|
markViewed: action('default--markViewed'),
|
||||||
onHeightChange: action('onHeightChange'),
|
onHeightChange: action('onHeightChange'),
|
||||||
openConversation: action('default--openConversation'),
|
openConversation: action('default--openConversation'),
|
||||||
openLink: action('default--openLink'),
|
openLink: action('default--openLink'),
|
||||||
previews: [],
|
previews: [],
|
||||||
reactToMessage: action('default--reactToMessage'),
|
reactToMessage: action('default--reactToMessage'),
|
||||||
|
readStatus: ReadStatus.Read,
|
||||||
renderEmojiPicker: () => <div />,
|
renderEmojiPicker: () => <div />,
|
||||||
renderAudioAttachment: () => <div>*AudioAttachment*</div>,
|
renderAudioAttachment: () => <div>*AudioAttachment*</div>,
|
||||||
replyToMessage: action('default--replyToMessage'),
|
replyToMessage: action('default--replyToMessage'),
|
||||||
|
|
|
@ -20,6 +20,7 @@ import { LastSeenIndicator } from './LastSeenIndicator';
|
||||||
import { TimelineLoadingRow } from './TimelineLoadingRow';
|
import { TimelineLoadingRow } from './TimelineLoadingRow';
|
||||||
import { TypingBubble } from './TypingBubble';
|
import { TypingBubble } from './TypingBubble';
|
||||||
import { ContactSpoofingType } from '../../util/contactSpoofing';
|
import { ContactSpoofingType } from '../../util/contactSpoofing';
|
||||||
|
import { ReadStatus } from '../../messages/MessageReadStatus';
|
||||||
|
|
||||||
const i18n = setupI18n('en', enMessages);
|
const i18n = setupI18n('en', enMessages);
|
||||||
|
|
||||||
|
@ -51,6 +52,7 @@ const items: Record<string, TimelineItemType> = {
|
||||||
isBlocked: false,
|
isBlocked: false,
|
||||||
isMessageRequestAccepted: true,
|
isMessageRequestAccepted: true,
|
||||||
previews: [],
|
previews: [],
|
||||||
|
readStatus: ReadStatus.Read,
|
||||||
text: '🔥',
|
text: '🔥',
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
},
|
},
|
||||||
|
@ -70,6 +72,7 @@ const items: Record<string, TimelineItemType> = {
|
||||||
isBlocked: false,
|
isBlocked: false,
|
||||||
isMessageRequestAccepted: true,
|
isMessageRequestAccepted: true,
|
||||||
previews: [],
|
previews: [],
|
||||||
|
readStatus: ReadStatus.Read,
|
||||||
text: 'Hello there from the new world! http://somewhere.com',
|
text: 'Hello there from the new world! http://somewhere.com',
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
},
|
},
|
||||||
|
@ -102,6 +105,7 @@ const items: Record<string, TimelineItemType> = {
|
||||||
isBlocked: false,
|
isBlocked: false,
|
||||||
isMessageRequestAccepted: true,
|
isMessageRequestAccepted: true,
|
||||||
previews: [],
|
previews: [],
|
||||||
|
readStatus: ReadStatus.Read,
|
||||||
text: 'Hello there from the new world!',
|
text: 'Hello there from the new world!',
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
},
|
},
|
||||||
|
@ -200,6 +204,7 @@ const items: Record<string, TimelineItemType> = {
|
||||||
isBlocked: false,
|
isBlocked: false,
|
||||||
isMessageRequestAccepted: true,
|
isMessageRequestAccepted: true,
|
||||||
previews: [],
|
previews: [],
|
||||||
|
readStatus: ReadStatus.Read,
|
||||||
status: 'sent',
|
status: 'sent',
|
||||||
text: '🔥',
|
text: '🔥',
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
|
@ -220,6 +225,7 @@ const items: Record<string, TimelineItemType> = {
|
||||||
isBlocked: false,
|
isBlocked: false,
|
||||||
isMessageRequestAccepted: true,
|
isMessageRequestAccepted: true,
|
||||||
previews: [],
|
previews: [],
|
||||||
|
readStatus: ReadStatus.Read,
|
||||||
status: 'read',
|
status: 'read',
|
||||||
text: 'Hello there from the new world! http://somewhere.com',
|
text: 'Hello there from the new world! http://somewhere.com',
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
|
@ -240,6 +246,7 @@ const items: Record<string, TimelineItemType> = {
|
||||||
isBlocked: false,
|
isBlocked: false,
|
||||||
isMessageRequestAccepted: true,
|
isMessageRequestAccepted: true,
|
||||||
previews: [],
|
previews: [],
|
||||||
|
readStatus: ReadStatus.Read,
|
||||||
status: 'sent',
|
status: 'sent',
|
||||||
text: 'Hello there from the new world! 🔥',
|
text: 'Hello there from the new world! 🔥',
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
|
@ -260,6 +267,7 @@ const items: Record<string, TimelineItemType> = {
|
||||||
isBlocked: false,
|
isBlocked: false,
|
||||||
isMessageRequestAccepted: true,
|
isMessageRequestAccepted: true,
|
||||||
previews: [],
|
previews: [],
|
||||||
|
readStatus: ReadStatus.Read,
|
||||||
status: 'sent',
|
status: 'sent',
|
||||||
text:
|
text:
|
||||||
'Hello there from the new world! And this is multiple lines of text. Lines and lines and lines.',
|
'Hello there from the new world! And this is multiple lines of text. Lines and lines and lines.',
|
||||||
|
@ -281,6 +289,7 @@ const items: Record<string, TimelineItemType> = {
|
||||||
isBlocked: false,
|
isBlocked: false,
|
||||||
isMessageRequestAccepted: true,
|
isMessageRequestAccepted: true,
|
||||||
previews: [],
|
previews: [],
|
||||||
|
readStatus: ReadStatus.Read,
|
||||||
status: 'read',
|
status: 'read',
|
||||||
text:
|
text:
|
||||||
'Hello there from the new world! And this is multiple lines of text. Lines and lines and lines.',
|
'Hello there from the new world! And this is multiple lines of text. Lines and lines and lines.',
|
||||||
|
@ -325,6 +334,7 @@ const actions = () => ({
|
||||||
showContactModal: action('showContactModal'),
|
showContactModal: action('showContactModal'),
|
||||||
kickOffAttachmentDownload: action('kickOffAttachmentDownload'),
|
kickOffAttachmentDownload: action('kickOffAttachmentDownload'),
|
||||||
markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'),
|
markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'),
|
||||||
|
markViewed: action('markViewed'),
|
||||||
showVisualAttachment: action('showVisualAttachment'),
|
showVisualAttachment: action('showVisualAttachment'),
|
||||||
downloadAttachment: action('downloadAttachment'),
|
downloadAttachment: action('downloadAttachment'),
|
||||||
displayTapToViewMessage: action('displayTapToViewMessage'),
|
displayTapToViewMessage: action('displayTapToViewMessage'),
|
||||||
|
|
|
@ -1496,6 +1496,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
|
||||||
'loadNewerMessages',
|
'loadNewerMessages',
|
||||||
'loadNewestMessages',
|
'loadNewestMessages',
|
||||||
'markMessageRead',
|
'markMessageRead',
|
||||||
|
'markViewed',
|
||||||
'onBlock',
|
'onBlock',
|
||||||
'onBlockAndReportSpam',
|
'onBlockAndReportSpam',
|
||||||
'onDelete',
|
'onDelete',
|
||||||
|
|
|
@ -57,6 +57,7 @@ const getDefaultProps = () => ({
|
||||||
kickOffAttachmentDownload: action('kickOffAttachmentDownload'),
|
kickOffAttachmentDownload: action('kickOffAttachmentDownload'),
|
||||||
learnMoreAboutDeliveryIssue: action('learnMoreAboutDeliveryIssue'),
|
learnMoreAboutDeliveryIssue: action('learnMoreAboutDeliveryIssue'),
|
||||||
markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'),
|
markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'),
|
||||||
|
markViewed: action('markViewed'),
|
||||||
showMessageDetail: action('showMessageDetail'),
|
showMessageDetail: action('showMessageDetail'),
|
||||||
openConversation: action('openConversation'),
|
openConversation: action('openConversation'),
|
||||||
showContactDetail: action('showContactDetail'),
|
showContactDetail: action('showContactDetail'),
|
||||||
|
|
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 { readSyncJobQueue } from './readSyncJobQueue';
|
||||||
import { removeStorageKeyJobQueue } from './removeStorageKeyJobQueue';
|
import { removeStorageKeyJobQueue } from './removeStorageKeyJobQueue';
|
||||||
import { reportSpamJobQueue } from './reportSpamJobQueue';
|
import { reportSpamJobQueue } from './reportSpamJobQueue';
|
||||||
|
import { viewSyncJobQueue } from './viewSyncJobQueue';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start all of the job queues. Should be called when the database is ready.
|
* Start all of the job queues. Should be called when the database is ready.
|
||||||
|
@ -20,4 +21,5 @@ export function initializeAllJobQueues({
|
||||||
readSyncJobQueue.streamJobs();
|
readSyncJobQueue.streamJobs();
|
||||||
removeStorageKeyJobQueue.streamJobs();
|
removeStorageKeyJobQueue.streamJobs();
|
||||||
reportSpamJobQueue.streamJobs();
|
reportSpamJobQueue.streamJobs();
|
||||||
|
viewSyncJobQueue.streamJobs();
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,25 +5,12 @@
|
||||||
|
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
import * as moment from 'moment';
|
import * as moment from 'moment';
|
||||||
import { chunk } from 'lodash';
|
import { exponentialBackoffMaxAttempts } from '../util/exponentialBackoff';
|
||||||
import { getSendOptions } from '../util/getSendOptions';
|
import { runReadOrViewSyncJob } from './helpers/runReadOrViewSyncJob';
|
||||||
import { handleMessageSend } from '../util/handleMessageSend';
|
|
||||||
import { isNotNil } from '../util/isNotNil';
|
|
||||||
import { sleep } from '../util/sleep';
|
|
||||||
import {
|
|
||||||
exponentialBackoffSleepTime,
|
|
||||||
exponentialBackoffMaxAttempts,
|
|
||||||
} from '../util/exponentialBackoff';
|
|
||||||
import * as log from '../logging/log';
|
|
||||||
import { isDone as isDeviceLinked } from '../util/registration';
|
|
||||||
import { waitForOnline } from '../util/waitForOnline';
|
|
||||||
import { parseIntWithFallback } from '../util/parseIntWithFallback';
|
|
||||||
|
|
||||||
import { JobQueue } from './JobQueue';
|
import { JobQueue } from './JobQueue';
|
||||||
import { jobQueueDatabaseStore } from './JobQueueDatabaseStore';
|
import { jobQueueDatabaseStore } from './JobQueueDatabaseStore';
|
||||||
|
|
||||||
const CHUNK_SIZE = 100;
|
|
||||||
|
|
||||||
const MAX_RETRY_TIME = moment.duration(1, 'day').asMilliseconds();
|
const MAX_RETRY_TIME = moment.duration(1, 'day').asMilliseconds();
|
||||||
|
|
||||||
const readSyncJobDataSchema = z.object({
|
const readSyncJobDataSchema = z.object({
|
||||||
|
@ -48,71 +35,13 @@ export class ReadSyncJobQueue extends JobQueue<ReadSyncJobData> {
|
||||||
{ data, timestamp }: Readonly<{ data: ReadSyncJobData; timestamp: number }>,
|
{ data, timestamp }: Readonly<{ data: ReadSyncJobData; timestamp: number }>,
|
||||||
{ attempt }: Readonly<{ attempt: number }>
|
{ attempt }: Readonly<{ attempt: number }>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { readSyncs } = data;
|
await runReadOrViewSyncJob({
|
||||||
if (!readSyncs.length) {
|
attempt,
|
||||||
log.info(
|
isView: false,
|
||||||
"readSyncJobQueue: skipping this job because there's nothing to sync"
|
maxRetryTime: MAX_RETRY_TIME,
|
||||||
);
|
syncs: data.readSyncs,
|
||||||
return;
|
timestamp,
|
||||||
}
|
|
||||||
|
|
||||||
const maxJobAge = timestamp + MAX_RETRY_TIME;
|
|
||||||
const timeRemaining = maxJobAge - Date.now();
|
|
||||||
|
|
||||||
if (timeRemaining <= 0) {
|
|
||||||
log.info("readSyncJobQueue: giving up because it's been too long");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await waitForOnline(window.navigator, window, { timeout: timeRemaining });
|
|
||||||
} catch (err) {
|
|
||||||
log.info("readSyncJobQueue: didn't come online in time, giving up");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise<void>(resolve => {
|
|
||||||
window.storage.onready(resolve);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!isDeviceLinked()) {
|
|
||||||
log.info("readSyncJobQueue: skipping this job because we're unlinked");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await sleep(exponentialBackoffSleepTime(attempt));
|
|
||||||
|
|
||||||
const ourConversation = window.ConversationController.getOurConversationOrThrow();
|
|
||||||
const sendOptions = await getSendOptions(ourConversation.attributes, {
|
|
||||||
syncMessage: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await Promise.all(
|
|
||||||
chunk(readSyncs, CHUNK_SIZE).map(batch => {
|
|
||||||
const messageIds = batch.map(item => item.messageId).filter(isNotNil);
|
|
||||||
|
|
||||||
return handleMessageSend(
|
|
||||||
window.textsecure.messaging.syncReadMessages(batch, sendOptions),
|
|
||||||
{ messageIds, sendType: 'readSync' }
|
|
||||||
);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
if (!(err instanceof Error)) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
const code = parseIntWithFallback(err.code, -1);
|
|
||||||
if (code === 508) {
|
|
||||||
log.info(
|
|
||||||
'readSyncJobQueue: server responded with 508. Giving up on this job'
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
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 { ConversationModel } from './models/conversations';
|
||||||
import { ProfileNameChangeType } from './util/getStringForProfileChange';
|
import { ProfileNameChangeType } from './util/getStringForProfileChange';
|
||||||
import { CapabilitiesType } from './textsecure/WebAPI';
|
import { CapabilitiesType } from './textsecure/WebAPI';
|
||||||
|
import { ReadStatus } from './messages/MessageReadStatus';
|
||||||
import {
|
import {
|
||||||
SendState,
|
SendState,
|
||||||
SendStateByConversationId,
|
SendStateByConversationId,
|
||||||
|
@ -182,7 +183,6 @@ export type MessageAttributesType = {
|
||||||
source?: string;
|
source?: string;
|
||||||
sourceUuid?: string;
|
sourceUuid?: string;
|
||||||
|
|
||||||
unread?: boolean;
|
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
|
|
||||||
// Backwards-compatibility with prerelease data schema
|
// Backwards-compatibility with prerelease data schema
|
||||||
|
@ -191,6 +191,9 @@ export type MessageAttributesType = {
|
||||||
|
|
||||||
sendHQImages?: boolean;
|
sendHQImages?: boolean;
|
||||||
|
|
||||||
|
// Should only be present for incoming messages
|
||||||
|
readStatus?: ReadStatus;
|
||||||
|
|
||||||
// Should only be present for outgoing messages
|
// Should only be present for outgoing messages
|
||||||
sendStateByConversationId?: SendStateByConversationId;
|
sendStateByConversationId?: SendStateByConversationId;
|
||||||
};
|
};
|
||||||
|
|
|
@ -52,6 +52,7 @@ import { handleMessageSend } from '../util/handleMessageSend';
|
||||||
import { getConversationMembers } from '../util/getConversationMembers';
|
import { getConversationMembers } from '../util/getConversationMembers';
|
||||||
import { sendReadReceiptsFor } from '../util/sendReadReceiptsFor';
|
import { sendReadReceiptsFor } from '../util/sendReadReceiptsFor';
|
||||||
import { updateConversationsWithUuidLookup } from '../updateConversationsWithUuidLookup';
|
import { updateConversationsWithUuidLookup } from '../updateConversationsWithUuidLookup';
|
||||||
|
import { ReadStatus } from '../messages/MessageReadStatus';
|
||||||
import { SendStatus } from '../messages/MessageSendState';
|
import { SendStatus } from '../messages/MessageSendState';
|
||||||
import {
|
import {
|
||||||
concat,
|
concat,
|
||||||
|
@ -2454,7 +2455,7 @@ export class ConversationModel extends window.Backbone
|
||||||
sent_at: receivedAt,
|
sent_at: receivedAt,
|
||||||
received_at: receivedAtCounter,
|
received_at: receivedAtCounter,
|
||||||
received_at_ms: receivedAt,
|
received_at_ms: receivedAt,
|
||||||
unread: 1,
|
readStatus: ReadStatus.Unread,
|
||||||
// TODO: DESKTOP-722
|
// TODO: DESKTOP-722
|
||||||
// this type does not fully implement the interface it is expected to
|
// this type does not fully implement the interface it is expected to
|
||||||
} as unknown) as typeof window.Whisper.MessageAttributesType;
|
} as unknown) as typeof window.Whisper.MessageAttributesType;
|
||||||
|
@ -2494,7 +2495,7 @@ export class ConversationModel extends window.Backbone
|
||||||
sent_at: receivedAt,
|
sent_at: receivedAt,
|
||||||
received_at: receivedAtCounter,
|
received_at: receivedAtCounter,
|
||||||
received_at_ms: receivedAt,
|
received_at_ms: receivedAt,
|
||||||
unread: 1,
|
readStatus: ReadStatus.Unread,
|
||||||
// TODO: DESKTOP-722
|
// TODO: DESKTOP-722
|
||||||
// this type does not fully implement the interface it is expected to
|
// this type does not fully implement the interface it is expected to
|
||||||
} as unknown) as typeof window.Whisper.MessageAttributesType;
|
} as unknown) as typeof window.Whisper.MessageAttributesType;
|
||||||
|
@ -2529,7 +2530,7 @@ export class ConversationModel extends window.Backbone
|
||||||
received_at: window.Signal.Util.incrementMessageCounter(),
|
received_at: window.Signal.Util.incrementMessageCounter(),
|
||||||
received_at_ms: timestamp,
|
received_at_ms: timestamp,
|
||||||
key_changed: keyChangedId,
|
key_changed: keyChangedId,
|
||||||
unread: 1,
|
readStatus: ReadStatus.Unread,
|
||||||
schemaVersion: Message.VERSION_NEEDED_FOR_DISPLAY,
|
schemaVersion: Message.VERSION_NEEDED_FOR_DISPLAY,
|
||||||
// TODO: DESKTOP-722
|
// TODO: DESKTOP-722
|
||||||
// this type does not fully implement the interface it is expected to
|
// this type does not fully implement the interface it is expected to
|
||||||
|
@ -2589,7 +2590,7 @@ export class ConversationModel extends window.Backbone
|
||||||
verifiedChanged: verifiedChangeId,
|
verifiedChanged: verifiedChangeId,
|
||||||
verified,
|
verified,
|
||||||
local: options.local,
|
local: options.local,
|
||||||
unread: 1,
|
readStatus: ReadStatus.Unread,
|
||||||
// TODO: DESKTOP-722
|
// TODO: DESKTOP-722
|
||||||
} as unknown) as typeof window.Whisper.MessageAttributesType;
|
} as unknown) as typeof window.Whisper.MessageAttributesType;
|
||||||
|
|
||||||
|
@ -2647,7 +2648,7 @@ export class ConversationModel extends window.Backbone
|
||||||
sent_at: timestamp,
|
sent_at: timestamp,
|
||||||
received_at: window.Signal.Util.incrementMessageCounter(),
|
received_at: window.Signal.Util.incrementMessageCounter(),
|
||||||
received_at_ms: timestamp,
|
received_at_ms: timestamp,
|
||||||
unread,
|
readStatus: unread ? ReadStatus.Unread : ReadStatus.Read,
|
||||||
callHistoryDetails: detailsToSave,
|
callHistoryDetails: detailsToSave,
|
||||||
// TODO: DESKTOP-722
|
// TODO: DESKTOP-722
|
||||||
} as unknown) as typeof window.Whisper.MessageAttributesType;
|
} as unknown) as typeof window.Whisper.MessageAttributesType;
|
||||||
|
@ -2697,7 +2698,7 @@ export class ConversationModel extends window.Backbone
|
||||||
sent_at: now,
|
sent_at: now,
|
||||||
received_at: window.Signal.Util.incrementMessageCounter(),
|
received_at: window.Signal.Util.incrementMessageCounter(),
|
||||||
received_at_ms: now,
|
received_at_ms: now,
|
||||||
unread: false,
|
readStatus: ReadStatus.Read,
|
||||||
changedId: conversationId || this.id,
|
changedId: conversationId || this.id,
|
||||||
profileChange,
|
profileChange,
|
||||||
// TODO: DESKTOP-722
|
// TODO: DESKTOP-722
|
||||||
|
@ -2738,7 +2739,7 @@ export class ConversationModel extends window.Backbone
|
||||||
sent_at: now,
|
sent_at: now,
|
||||||
received_at: window.Signal.Util.incrementMessageCounter(),
|
received_at: window.Signal.Util.incrementMessageCounter(),
|
||||||
received_at_ms: now,
|
received_at_ms: now,
|
||||||
unread: false,
|
readStatus: ReadStatus.Read,
|
||||||
};
|
};
|
||||||
|
|
||||||
const id = await window.Signal.Data.saveMessage(
|
const id = await window.Signal.Data.saveMessage(
|
||||||
|
@ -4163,7 +4164,7 @@ export class ConversationModel extends window.Backbone
|
||||||
const model = new window.Whisper.Message(({
|
const model = new window.Whisper.Message(({
|
||||||
// Even though this isn't reflected to the user, we want to place the last seen
|
// Even though this isn't reflected to the user, we want to place the last seen
|
||||||
// indicator above it. We set it to 'unread' to trigger that placement.
|
// indicator above it. We set it to 'unread' to trigger that placement.
|
||||||
unread: 1,
|
readStatus: ReadStatus.Unread,
|
||||||
conversationId: this.id,
|
conversationId: this.id,
|
||||||
// No type; 'incoming' messages are specially treated by conversation.markRead()
|
// No type; 'incoming' messages are specially treated by conversation.markRead()
|
||||||
sent_at: timestamp,
|
sent_at: timestamp,
|
||||||
|
@ -4266,7 +4267,7 @@ export class ConversationModel extends window.Backbone
|
||||||
type: 'message-history-unsynced',
|
type: 'message-history-unsynced',
|
||||||
// Even though this isn't reflected to the user, we want to place the last seen
|
// Even though this isn't reflected to the user, we want to place the last seen
|
||||||
// indicator above it. We set it to 'unread' to trigger that placement.
|
// indicator above it. We set it to 'unread' to trigger that placement.
|
||||||
unread: 1,
|
readStatus: ReadStatus.Unread,
|
||||||
conversationId: this.id,
|
conversationId: this.id,
|
||||||
sent_at: timestamp,
|
sent_at: timestamp,
|
||||||
received_at: window.Signal.Util.incrementMessageCounter(),
|
received_at: window.Signal.Util.incrementMessageCounter(),
|
||||||
|
|
|
@ -44,6 +44,7 @@ import * as Stickers from '../types/Stickers';
|
||||||
import { AttachmentType, isImage, isVideo } from '../types/Attachment';
|
import { AttachmentType, isImage, isVideo } from '../types/Attachment';
|
||||||
import { IMAGE_WEBP, stringToMIMEType } from '../types/MIME';
|
import { IMAGE_WEBP, stringToMIMEType } from '../types/MIME';
|
||||||
import { ourProfileKeyService } from '../services/ourProfileKey';
|
import { ourProfileKeyService } from '../services/ourProfileKey';
|
||||||
|
import { ReadStatus } from '../messages/MessageReadStatus';
|
||||||
import {
|
import {
|
||||||
SendActionType,
|
SendActionType,
|
||||||
SendStateByConversationId,
|
SendStateByConversationId,
|
||||||
|
@ -53,9 +54,10 @@ import {
|
||||||
sendStateReducer,
|
sendStateReducer,
|
||||||
someSendStatus,
|
someSendStatus,
|
||||||
} from '../messages/MessageSendState';
|
} from '../messages/MessageSendState';
|
||||||
|
import { migrateLegacyReadStatus } from '../messages/migrateLegacyReadStatus';
|
||||||
import { migrateLegacySendAttributes } from '../messages/migrateLegacySendAttributes';
|
import { migrateLegacySendAttributes } from '../messages/migrateLegacySendAttributes';
|
||||||
import { getOwn } from '../util/getOwn';
|
import { getOwn } from '../util/getOwn';
|
||||||
import { markRead } from '../services/MessageUpdater';
|
import { markRead, markViewed } from '../services/MessageUpdater';
|
||||||
import { isMessageUnread } from '../util/isMessageUnread';
|
import { isMessageUnread } from '../util/isMessageUnread';
|
||||||
import {
|
import {
|
||||||
isDirectConversation,
|
isDirectConversation,
|
||||||
|
@ -104,6 +106,7 @@ import {
|
||||||
import { Deletes } from '../messageModifiers/Deletes';
|
import { Deletes } from '../messageModifiers/Deletes';
|
||||||
import { Reactions } from '../messageModifiers/Reactions';
|
import { Reactions } from '../messageModifiers/Reactions';
|
||||||
import { ReadSyncs } from '../messageModifiers/ReadSyncs';
|
import { ReadSyncs } from '../messageModifiers/ReadSyncs';
|
||||||
|
import { ViewSyncs } from '../messageModifiers/ViewSyncs';
|
||||||
import { ViewOnceOpenSyncs } from '../messageModifiers/ViewOnceOpenSyncs';
|
import { ViewOnceOpenSyncs } from '../messageModifiers/ViewOnceOpenSyncs';
|
||||||
import * as AttachmentDownloads from '../messageModifiers/AttachmentDownloads';
|
import * as AttachmentDownloads from '../messageModifiers/AttachmentDownloads';
|
||||||
import * as LinkPreview from '../types/LinkPreview';
|
import * as LinkPreview from '../types/LinkPreview';
|
||||||
|
@ -194,6 +197,11 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const readStatus = migrateLegacyReadStatus(this.attributes);
|
||||||
|
if (readStatus !== undefined) {
|
||||||
|
this.set('readStatus', readStatus, { silent: true });
|
||||||
|
}
|
||||||
|
|
||||||
const sendStateByConversationId = migrateLegacySendAttributes(
|
const sendStateByConversationId = migrateLegacySendAttributes(
|
||||||
this.attributes,
|
this.attributes,
|
||||||
window.ConversationController.get.bind(window.ConversationController),
|
window.ConversationController.get.bind(window.ConversationController),
|
||||||
|
@ -835,8 +843,8 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isMessageUnread(this.attributes)) {
|
if (this.get('readStatus') !== ReadStatus.Viewed) {
|
||||||
this.set(markRead(this.attributes));
|
this.set(markViewed(this.attributes));
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.eraseContents();
|
await this.eraseContents();
|
||||||
|
@ -3269,19 +3277,41 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'incoming') {
|
if (type === 'incoming') {
|
||||||
|
// In a followup (see DESKTOP-2100), we want to make `ReadSyncs#forMessage` return
|
||||||
|
// an array, not an object. This array wrapping makes that future a bit easier.
|
||||||
const readSync = ReadSyncs.getSingleton().forMessage(message);
|
const readSync = ReadSyncs.getSingleton().forMessage(message);
|
||||||
if (readSync) {
|
const readSyncs = readSync ? [readSync] : [];
|
||||||
if (
|
|
||||||
message.get('expireTimer') &&
|
const viewSyncs = ViewSyncs.getSingleton().forMessage(message);
|
||||||
!message.get('expirationStartTimestamp')
|
|
||||||
) {
|
if (message.get('expireTimer')) {
|
||||||
|
const existingExpirationStartTimestamp = message.get(
|
||||||
|
'expirationStartTimestamp'
|
||||||
|
);
|
||||||
|
const candidateTimestamps: Array<number> = [
|
||||||
|
Date.now(),
|
||||||
|
...(existingExpirationStartTimestamp
|
||||||
|
? [existingExpirationStartTimestamp]
|
||||||
|
: []),
|
||||||
|
...readSyncs.map(sync => sync.get('readAt')),
|
||||||
|
...viewSyncs.map(sync => sync.get('viewedAt')),
|
||||||
|
];
|
||||||
message.set(
|
message.set(
|
||||||
'expirationStartTimestamp',
|
'expirationStartTimestamp',
|
||||||
Math.min(readSync.get('readAt'), Date.now())
|
Math.min(...candidateTimestamps)
|
||||||
);
|
);
|
||||||
|
changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
message.unset('unread');
|
let newReadStatus: undefined | ReadStatus.Read | ReadStatus.Viewed;
|
||||||
|
if (viewSyncs.length) {
|
||||||
|
newReadStatus = ReadStatus.Viewed;
|
||||||
|
} else if (readSyncs.length) {
|
||||||
|
newReadStatus = ReadStatus.Read;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newReadStatus !== undefined) {
|
||||||
|
message.set('readStatus', newReadStatus);
|
||||||
// This is primarily to allow the conversation to mark all older
|
// This is primarily to allow the conversation to mark all older
|
||||||
// messages as read, as is done when we receive a read sync for
|
// messages as read, as is done when we receive a read sync for
|
||||||
// a message we already know about.
|
// a message we already know about.
|
||||||
|
@ -3290,16 +3320,17 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
c.onReadMessage(message);
|
c.onReadMessage(message);
|
||||||
}
|
}
|
||||||
changed = true;
|
changed = true;
|
||||||
} else if (isFirstRun) {
|
}
|
||||||
|
|
||||||
|
if (isFirstRun && !viewSyncs.length && !readSyncs.length) {
|
||||||
conversation.set({
|
conversation.set({
|
||||||
unreadCount: (conversation.get('unreadCount') || 0) + 1,
|
unreadCount: (conversation.get('unreadCount') || 0) + 1,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Check for out-of-order view once open syncs
|
// Check for out-of-order view once open syncs
|
||||||
if (type === 'incoming' && isTapToView(message.attributes)) {
|
if (isTapToView(message.attributes)) {
|
||||||
const viewOnceOpenSync = ViewOnceOpenSyncs.getSingleton().forMessage(
|
const viewOnceOpenSync = ViewOnceOpenSyncs.getSingleton().forMessage(
|
||||||
message
|
message
|
||||||
);
|
);
|
||||||
|
@ -3308,6 +3339,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Does this message have any pending, previously-received associated reactions?
|
// Does this message have any pending, previously-received associated reactions?
|
||||||
const reactions = Reactions.getSingleton().forMessage(message);
|
const reactions = Reactions.getSingleton().forMessage(message);
|
||||||
|
|
|
@ -1,16 +1,21 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { MessageAttributesType } from '../model-types.d';
|
import type { MessageAttributesType } from '../model-types.d';
|
||||||
|
import { ReadStatus, maxReadStatus } from '../messages/MessageReadStatus';
|
||||||
|
|
||||||
export function markRead(
|
function markReadOrViewed(
|
||||||
messageAttrs: MessageAttributesType,
|
messageAttrs: Readonly<MessageAttributesType>,
|
||||||
readAt?: number,
|
readStatus: ReadStatus.Read | ReadStatus.Viewed,
|
||||||
{ skipSave = false } = {}
|
timestamp: undefined | number,
|
||||||
|
skipSave: boolean
|
||||||
): MessageAttributesType {
|
): MessageAttributesType {
|
||||||
const nextMessageAttributes = {
|
const oldReadStatus = messageAttrs.readStatus ?? ReadStatus.Read;
|
||||||
|
const newReadStatus = maxReadStatus(oldReadStatus, readStatus);
|
||||||
|
|
||||||
|
const nextMessageAttributes: MessageAttributesType = {
|
||||||
...messageAttrs,
|
...messageAttrs,
|
||||||
unread: false,
|
readStatus: newReadStatus,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { id: messageId, expireTimer, expirationStartTimestamp } = messageAttrs;
|
const { id: messageId, expireTimer, expirationStartTimestamp } = messageAttrs;
|
||||||
|
@ -18,7 +23,7 @@ export function markRead(
|
||||||
if (expireTimer && !expirationStartTimestamp) {
|
if (expireTimer && !expirationStartTimestamp) {
|
||||||
nextMessageAttributes.expirationStartTimestamp = Math.min(
|
nextMessageAttributes.expirationStartTimestamp = Math.min(
|
||||||
Date.now(),
|
Date.now(),
|
||||||
readAt || Date.now()
|
timestamp || Date.now()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -30,3 +35,17 @@ export function markRead(
|
||||||
|
|
||||||
return nextMessageAttributes;
|
return nextMessageAttributes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const markRead = (
|
||||||
|
messageAttrs: Readonly<MessageAttributesType>,
|
||||||
|
readAt?: number,
|
||||||
|
{ skipSave = false } = {}
|
||||||
|
): MessageAttributesType =>
|
||||||
|
markReadOrViewed(messageAttrs, ReadStatus.Read, readAt, skipSave);
|
||||||
|
|
||||||
|
export const markViewed = (
|
||||||
|
messageAttrs: Readonly<MessageAttributesType>,
|
||||||
|
viewedAt?: number,
|
||||||
|
{ skipSave = false } = {}
|
||||||
|
): MessageAttributesType =>
|
||||||
|
markReadOrViewed(messageAttrs, ReadStatus.Viewed, viewedAt, skipSave);
|
||||||
|
|
|
@ -30,6 +30,7 @@ import {
|
||||||
pick,
|
pick,
|
||||||
} from 'lodash';
|
} from 'lodash';
|
||||||
|
|
||||||
|
import { ReadStatus } from '../messages/MessageReadStatus';
|
||||||
import { GroupV2MemberType } from '../model-types.d';
|
import { GroupV2MemberType } from '../model-types.d';
|
||||||
import { ReactionType } from '../types/Reactions';
|
import { ReactionType } from '../types/Reactions';
|
||||||
import { StoredJob } from '../jobs/types';
|
import { StoredJob } from '../jobs/types';
|
||||||
|
@ -2076,6 +2077,19 @@ function updateToSchemaVersion38(currentVersion: number, db: Database) {
|
||||||
console.log('updateToSchemaVersion38: success!');
|
console.log('updateToSchemaVersion38: success!');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateToSchemaVersion39(currentVersion: number, db: Database) {
|
||||||
|
if (currentVersion >= 39) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.transaction(() => {
|
||||||
|
db.exec('ALTER TABLE messages RENAME COLUMN unread TO readStatus;');
|
||||||
|
|
||||||
|
db.pragma('user_version = 39');
|
||||||
|
})();
|
||||||
|
console.log('updateToSchemaVersion39: success!');
|
||||||
|
}
|
||||||
|
|
||||||
const SCHEMA_VERSIONS = [
|
const SCHEMA_VERSIONS = [
|
||||||
updateToSchemaVersion1,
|
updateToSchemaVersion1,
|
||||||
updateToSchemaVersion2,
|
updateToSchemaVersion2,
|
||||||
|
@ -2115,6 +2129,7 @@ const SCHEMA_VERSIONS = [
|
||||||
updateToSchemaVersion36,
|
updateToSchemaVersion36,
|
||||||
updateToSchemaVersion37,
|
updateToSchemaVersion37,
|
||||||
updateToSchemaVersion38,
|
updateToSchemaVersion38,
|
||||||
|
updateToSchemaVersion39,
|
||||||
];
|
];
|
||||||
|
|
||||||
function updateSchema(db: Database): void {
|
function updateSchema(db: Database): void {
|
||||||
|
@ -3572,7 +3587,7 @@ function saveMessageSync(
|
||||||
sourceUuid,
|
sourceUuid,
|
||||||
sourceDevice,
|
sourceDevice,
|
||||||
type,
|
type,
|
||||||
unread,
|
readStatus,
|
||||||
expireTimer,
|
expireTimer,
|
||||||
expirationStartTimestamp,
|
expirationStartTimestamp,
|
||||||
} = data;
|
} = data;
|
||||||
|
@ -3598,7 +3613,7 @@ function saveMessageSync(
|
||||||
sourceUuid: sourceUuid || null,
|
sourceUuid: sourceUuid || null,
|
||||||
sourceDevice: sourceDevice || null,
|
sourceDevice: sourceDevice || null,
|
||||||
type: type || null,
|
type: type || null,
|
||||||
unread: unread ? 1 : 0,
|
readStatus: readStatus ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (id && !forceSave) {
|
if (id && !forceSave) {
|
||||||
|
@ -3626,7 +3641,7 @@ function saveMessageSync(
|
||||||
sourceUuid = $sourceUuid,
|
sourceUuid = $sourceUuid,
|
||||||
sourceDevice = $sourceDevice,
|
sourceDevice = $sourceDevice,
|
||||||
type = $type,
|
type = $type,
|
||||||
unread = $unread
|
readStatus = $readStatus
|
||||||
WHERE id = $id;
|
WHERE id = $id;
|
||||||
`
|
`
|
||||||
).run(payload);
|
).run(payload);
|
||||||
|
@ -3663,7 +3678,7 @@ function saveMessageSync(
|
||||||
sourceUuid,
|
sourceUuid,
|
||||||
sourceDevice,
|
sourceDevice,
|
||||||
type,
|
type,
|
||||||
unread
|
readStatus
|
||||||
) values (
|
) values (
|
||||||
$id,
|
$id,
|
||||||
$json,
|
$json,
|
||||||
|
@ -3685,7 +3700,7 @@ function saveMessageSync(
|
||||||
$sourceUuid,
|
$sourceUuid,
|
||||||
$sourceDevice,
|
$sourceDevice,
|
||||||
$type,
|
$type,
|
||||||
$unread
|
$readStatus
|
||||||
);
|
);
|
||||||
`
|
`
|
||||||
).run({
|
).run({
|
||||||
|
@ -3812,7 +3827,7 @@ async function getUnreadCountForConversation(
|
||||||
.prepare<Query>(
|
.prepare<Query>(
|
||||||
`
|
`
|
||||||
SELECT COUNT(*) AS unreadCount FROM messages
|
SELECT COUNT(*) AS unreadCount FROM messages
|
||||||
WHERE unread = 1 AND
|
WHERE readStatus = ${ReadStatus.Unread} AND
|
||||||
conversationId = $conversationId AND
|
conversationId = $conversationId AND
|
||||||
type = 'incoming';
|
type = 'incoming';
|
||||||
`
|
`
|
||||||
|
@ -3862,14 +3877,13 @@ async function getUnreadByConversationAndMarkRead(
|
||||||
SELECT id, json FROM messages
|
SELECT id, json FROM messages
|
||||||
INDEXED BY messages_unread
|
INDEXED BY messages_unread
|
||||||
WHERE
|
WHERE
|
||||||
unread = $unread AND
|
readStatus = ${ReadStatus.Unread} AND
|
||||||
conversationId = $conversationId AND
|
conversationId = $conversationId AND
|
||||||
received_at <= $newestUnreadId
|
received_at <= $newestUnreadId
|
||||||
ORDER BY received_at DESC, sent_at DESC;
|
ORDER BY received_at DESC, sent_at DESC;
|
||||||
`
|
`
|
||||||
)
|
)
|
||||||
.all({
|
.all({
|
||||||
unread: 1,
|
|
||||||
conversationId,
|
conversationId,
|
||||||
newestUnreadId,
|
newestUnreadId,
|
||||||
});
|
});
|
||||||
|
@ -3878,24 +3892,23 @@ async function getUnreadByConversationAndMarkRead(
|
||||||
`
|
`
|
||||||
UPDATE messages
|
UPDATE messages
|
||||||
SET
|
SET
|
||||||
unread = 0,
|
readStatus = ${ReadStatus.Read},
|
||||||
json = json_patch(json, $jsonPatch)
|
json = json_patch(json, $jsonPatch)
|
||||||
WHERE
|
WHERE
|
||||||
unread = $unread AND
|
readStatus = ${ReadStatus.Unread} AND
|
||||||
conversationId = $conversationId AND
|
conversationId = $conversationId AND
|
||||||
received_at <= $newestUnreadId;
|
received_at <= $newestUnreadId;
|
||||||
`
|
`
|
||||||
).run({
|
).run({
|
||||||
conversationId,
|
conversationId,
|
||||||
jsonPatch: JSON.stringify({ unread: 0 }),
|
jsonPatch: JSON.stringify({ readStatus: ReadStatus.Read }),
|
||||||
newestUnreadId,
|
newestUnreadId,
|
||||||
unread: 1,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return rows.map(row => {
|
return rows.map(row => {
|
||||||
const json = jsonToObject(row.json);
|
const json = jsonToObject(row.json);
|
||||||
return {
|
return {
|
||||||
unread: false,
|
readStatus: ReadStatus.Read,
|
||||||
...pick(json, [
|
...pick(json, [
|
||||||
'expirationStartTimestamp',
|
'expirationStartTimestamp',
|
||||||
'id',
|
'id',
|
||||||
|
@ -4313,7 +4326,7 @@ function getOldestUnreadMessageForConversation(
|
||||||
`
|
`
|
||||||
SELECT * FROM messages WHERE
|
SELECT * FROM messages WHERE
|
||||||
conversationId = $conversationId AND
|
conversationId = $conversationId AND
|
||||||
unread = 1
|
readStatus = ${ReadStatus.Unread}
|
||||||
ORDER BY received_at ASC, sent_at ASC
|
ORDER BY received_at ASC, sent_at ASC
|
||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
`
|
`
|
||||||
|
@ -4338,7 +4351,7 @@ function getTotalUnreadForConversation(conversationId: string): number {
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE
|
WHERE
|
||||||
conversationId = $conversationId AND
|
conversationId = $conversationId AND
|
||||||
unread = 1;
|
readStatus = ${ReadStatus.Unread};
|
||||||
`
|
`
|
||||||
)
|
)
|
||||||
.get({
|
.get({
|
||||||
|
@ -4469,8 +4482,9 @@ async function getMessagesUnexpectedlyMissingExpirationStartTimestamp(): Promise
|
||||||
(
|
(
|
||||||
type IS 'outgoing' OR
|
type IS 'outgoing' OR
|
||||||
(type IS 'incoming' AND (
|
(type IS 'incoming' AND (
|
||||||
unread = 0 OR
|
readStatus = ${ReadStatus.Read} OR
|
||||||
unread IS NULL
|
readStatus = ${ReadStatus.Viewed} OR
|
||||||
|
readStatus IS NULL
|
||||||
))
|
))
|
||||||
);
|
);
|
||||||
`
|
`
|
||||||
|
|
|
@ -45,6 +45,7 @@ import { ConversationColors } from '../../types/Colors';
|
||||||
import { CallMode } from '../../types/Calling';
|
import { CallMode } from '../../types/Calling';
|
||||||
import { SignalService as Proto } from '../../protobuf';
|
import { SignalService as Proto } from '../../protobuf';
|
||||||
import { AttachmentType, isVoiceMessage } from '../../types/Attachment';
|
import { AttachmentType, isVoiceMessage } from '../../types/Attachment';
|
||||||
|
import { ReadStatus } from '../../messages/MessageReadStatus';
|
||||||
|
|
||||||
import { CallingNotificationType } from '../../util/callingNotification';
|
import { CallingNotificationType } from '../../util/callingNotification';
|
||||||
import { memoizeByRoot } from '../../util/memoizeByRoot';
|
import { memoizeByRoot } from '../../util/memoizeByRoot';
|
||||||
|
@ -478,6 +479,7 @@ type ShallowPropsType = Pick<
|
||||||
| 'isTapToView'
|
| 'isTapToView'
|
||||||
| 'isTapToViewError'
|
| 'isTapToViewError'
|
||||||
| 'isTapToViewExpired'
|
| 'isTapToViewExpired'
|
||||||
|
| 'readStatus'
|
||||||
| 'selectedReaction'
|
| 'selectedReaction'
|
||||||
| 'status'
|
| 'status'
|
||||||
| 'text'
|
| 'text'
|
||||||
|
@ -544,6 +546,7 @@ const getShallowPropsForMessage = createSelectorCreator(memoizeByRoot, isEqual)(
|
||||||
isTapToViewError:
|
isTapToViewError:
|
||||||
isMessageTapToView && isIncoming(message) && message.isTapToViewInvalid,
|
isMessageTapToView && isIncoming(message) && message.isTapToViewInvalid,
|
||||||
isTapToViewExpired: isMessageTapToView && message.isErased,
|
isTapToViewExpired: isMessageTapToView && message.isErased,
|
||||||
|
readStatus: message.readStatus ?? ReadStatus.Read,
|
||||||
selectedReaction,
|
selectedReaction,
|
||||||
status: getMessagePropStatus(message, ourConversationId),
|
status: getMessagePropStatus(message, ourConversationId),
|
||||||
text: createNonBreakingLastSeparator(message.body),
|
text: createNonBreakingLastSeparator(message.body),
|
||||||
|
|
|
@ -39,6 +39,7 @@ export type Props = {
|
||||||
computePeaks(url: string, barCount: number): Promise<ComputePeaksResult>;
|
computePeaks(url: string, barCount: number): Promise<ComputePeaksResult>;
|
||||||
kickOffAttachmentDownload(): void;
|
kickOffAttachmentDownload(): void;
|
||||||
onCorrupted(): void;
|
onCorrupted(): void;
|
||||||
|
onFirstPlayed(): void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapStateToProps = (state: StateType, props: Props) => {
|
const mapStateToProps = (state: StateType, props: Props) => {
|
||||||
|
|
|
@ -30,6 +30,7 @@ export type OwnProps = Pick<
|
||||||
| 'errors'
|
| 'errors'
|
||||||
| 'kickOffAttachmentDownload'
|
| 'kickOffAttachmentDownload'
|
||||||
| 'markAttachmentAsCorrupted'
|
| 'markAttachmentAsCorrupted'
|
||||||
|
| 'markViewed'
|
||||||
| 'message'
|
| 'message'
|
||||||
| 'openConversation'
|
| 'openConversation'
|
||||||
| 'openLink'
|
| 'openLink'
|
||||||
|
@ -71,6 +72,7 @@ const mapStateToProps = (
|
||||||
doubleCheckMissingQuoteReference,
|
doubleCheckMissingQuoteReference,
|
||||||
kickOffAttachmentDownload,
|
kickOffAttachmentDownload,
|
||||||
markAttachmentAsCorrupted,
|
markAttachmentAsCorrupted,
|
||||||
|
markViewed,
|
||||||
openConversation,
|
openConversation,
|
||||||
openLink,
|
openLink,
|
||||||
reactToMessage,
|
reactToMessage,
|
||||||
|
@ -115,6 +117,7 @@ const mapStateToProps = (
|
||||||
doubleCheckMissingQuoteReference,
|
doubleCheckMissingQuoteReference,
|
||||||
kickOffAttachmentDownload,
|
kickOffAttachmentDownload,
|
||||||
markAttachmentAsCorrupted,
|
markAttachmentAsCorrupted,
|
||||||
|
markViewed,
|
||||||
openConversation,
|
openConversation,
|
||||||
openLink,
|
openLink,
|
||||||
reactToMessage,
|
reactToMessage,
|
||||||
|
|
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';
|
} from '../../../state/selectors/search';
|
||||||
import { makeLookup } from '../../../util/makeLookup';
|
import { makeLookup } from '../../../util/makeLookup';
|
||||||
import { getDefaultConversation } from '../../helpers/getDefaultConversation';
|
import { getDefaultConversation } from '../../helpers/getDefaultConversation';
|
||||||
|
import { ReadStatus } from '../../../messages/MessageReadStatus';
|
||||||
|
|
||||||
import { StateType, reducer as rootReducer } from '../../../state/reducer';
|
import { StateType, reducer as rootReducer } from '../../../state/reducer';
|
||||||
|
|
||||||
|
@ -54,7 +55,7 @@ describe('both/state/selectors/search', () => {
|
||||||
sourceUuid: 'sourceUuid',
|
sourceUuid: 'sourceUuid',
|
||||||
timestamp: NOW,
|
timestamp: NOW,
|
||||||
type: 'incoming' as const,
|
type: 'incoming' as const,
|
||||||
unread: false,
|
readStatus: ReadStatus.Read,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,20 +2,22 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { assert } from 'chai';
|
import { assert } from 'chai';
|
||||||
|
import { ReadStatus } from '../../messages/MessageReadStatus';
|
||||||
|
|
||||||
import { isMessageUnread } from '../../util/isMessageUnread';
|
import { isMessageUnread } from '../../util/isMessageUnread';
|
||||||
|
|
||||||
describe('isMessageUnread', () => {
|
describe('isMessageUnread', () => {
|
||||||
it("returns false if the message's `unread` field is undefined", () => {
|
it("returns false if the message's `readStatus` field is undefined", () => {
|
||||||
assert.isFalse(isMessageUnread({}));
|
assert.isFalse(isMessageUnread({}));
|
||||||
assert.isFalse(isMessageUnread({ unread: undefined }));
|
assert.isFalse(isMessageUnread({ readStatus: undefined }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns false if the message is read', () => {
|
it('returns false if the message is read or viewed', () => {
|
||||||
assert.isFalse(isMessageUnread({ unread: false }));
|
assert.isFalse(isMessageUnread({ readStatus: ReadStatus.Read }));
|
||||||
|
assert.isFalse(isMessageUnread({ readStatus: ReadStatus.Viewed }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns true if the message is unread', () => {
|
it('returns true if the message is unread', () => {
|
||||||
assert.isTrue(isMessageUnread({ unread: true }));
|
assert.isTrue(isMessageUnread({ readStatus: ReadStatus.Unread }));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -23,6 +23,7 @@ import {
|
||||||
reducer,
|
reducer,
|
||||||
updateConversationLookups,
|
updateConversationLookups,
|
||||||
} from '../../../state/ducks/conversations';
|
} from '../../../state/ducks/conversations';
|
||||||
|
import { ReadStatus } from '../../../messages/MessageReadStatus';
|
||||||
import { ContactSpoofingType } from '../../../util/contactSpoofing';
|
import { ContactSpoofingType } from '../../../util/contactSpoofing';
|
||||||
import { CallMode } from '../../../types/Calling';
|
import { CallMode } from '../../../types/Calling';
|
||||||
import * as groups from '../../../groups';
|
import * as groups from '../../../groups';
|
||||||
|
@ -317,7 +318,7 @@ describe('both/state/ducks/conversations', () => {
|
||||||
sourceUuid: 'sourceUuid',
|
sourceUuid: 'sourceUuid',
|
||||||
timestamp: previousTime,
|
timestamp: previousTime,
|
||||||
type: 'incoming' as const,
|
type: 'incoming' as const,
|
||||||
unread: false,
|
readStatus: ReadStatus.Read,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -91,6 +91,7 @@ import {
|
||||||
StickerPackEvent,
|
StickerPackEvent,
|
||||||
VerifiedEvent,
|
VerifiedEvent,
|
||||||
ReadSyncEvent,
|
ReadSyncEvent,
|
||||||
|
ViewSyncEvent,
|
||||||
ContactEvent,
|
ContactEvent,
|
||||||
ContactSyncEvent,
|
ContactSyncEvent,
|
||||||
GroupEvent,
|
GroupEvent,
|
||||||
|
@ -440,6 +441,11 @@ export default class MessageReceiver
|
||||||
handler: (ev: ReadSyncEvent) => void
|
handler: (ev: ReadSyncEvent) => void
|
||||||
): void;
|
): void;
|
||||||
|
|
||||||
|
public addEventListener(
|
||||||
|
name: 'viewSync',
|
||||||
|
handler: (ev: ViewSyncEvent) => void
|
||||||
|
): void;
|
||||||
|
|
||||||
public addEventListener(
|
public addEventListener(
|
||||||
name: 'contact',
|
name: 'contact',
|
||||||
handler: (ev: ContactEvent) => void
|
handler: (ev: ContactEvent) => void
|
||||||
|
@ -2206,6 +2212,9 @@ export default class MessageReceiver
|
||||||
if (syncMessage.keys) {
|
if (syncMessage.keys) {
|
||||||
return this.handleKeys(envelope, syncMessage.keys);
|
return this.handleKeys(envelope, syncMessage.keys);
|
||||||
}
|
}
|
||||||
|
if (syncMessage.viewed && syncMessage.viewed.length) {
|
||||||
|
return this.handleViewed(envelope, syncMessage.viewed);
|
||||||
|
}
|
||||||
|
|
||||||
this.removeFromCache(envelope);
|
this.removeFromCache(envelope);
|
||||||
window.log.warn(
|
window.log.warn(
|
||||||
|
@ -2388,6 +2397,32 @@ export default class MessageReceiver
|
||||||
await Promise.all(results);
|
await Promise.all(results);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleViewed(
|
||||||
|
envelope: ProcessedEnvelope,
|
||||||
|
viewed: ReadonlyArray<Proto.SyncMessage.IViewed>
|
||||||
|
): Promise<void> {
|
||||||
|
window.log.info(
|
||||||
|
'MessageReceiver.handleViewed',
|
||||||
|
this.getEnvelopeId(envelope)
|
||||||
|
);
|
||||||
|
await Promise.all(
|
||||||
|
viewed.map(async ({ timestamp, senderE164, senderUuid }) => {
|
||||||
|
const ev = new ViewSyncEvent(
|
||||||
|
{
|
||||||
|
envelopeTimestamp: envelope.timestamp,
|
||||||
|
timestamp: normalizeNumber(dropNull(timestamp)),
|
||||||
|
senderE164: dropNull(senderE164),
|
||||||
|
senderUuid: senderUuid
|
||||||
|
? normalizeUuid(senderUuid, 'handleViewed.senderUuid')
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
this.removeFromCache.bind(this, envelope)
|
||||||
|
);
|
||||||
|
await this.dispatchAndWait(ev);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private async handleContacts(
|
private async handleContacts(
|
||||||
envelope: ProcessedEnvelope,
|
envelope: ProcessedEnvelope,
|
||||||
contacts: Proto.SyncMessage.IContacts
|
contacts: Proto.SyncMessage.IContacts
|
||||||
|
|
|
@ -1297,6 +1297,33 @@ export default class MessageSender {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async syncView(
|
||||||
|
views: ReadonlyArray<{
|
||||||
|
senderUuid?: string;
|
||||||
|
senderE164?: string;
|
||||||
|
timestamp: number;
|
||||||
|
}>,
|
||||||
|
options?: SendOptionsType
|
||||||
|
): Promise<CallbackResultType> {
|
||||||
|
const myNumber = window.textsecure.storage.user.getNumber();
|
||||||
|
const myUuid = window.textsecure.storage.user.getUuid();
|
||||||
|
|
||||||
|
const syncMessage = this.createSyncMessage();
|
||||||
|
syncMessage.viewed = views.map(view => new Proto.SyncMessage.Viewed(view));
|
||||||
|
const contentMessage = new Proto.Content();
|
||||||
|
contentMessage.syncMessage = syncMessage;
|
||||||
|
|
||||||
|
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
|
||||||
|
|
||||||
|
return this.sendIndividualProto({
|
||||||
|
identifier: myUuid || myNumber,
|
||||||
|
proto: contentMessage,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
contentHint: ContentHint.RESENDABLE,
|
||||||
|
options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async syncViewOnceOpen(
|
async syncViewOnceOpen(
|
||||||
sender: string | undefined,
|
sender: string | undefined,
|
||||||
senderUuid: string,
|
senderUuid: string,
|
||||||
|
|
|
@ -399,3 +399,19 @@ export class ReadSyncEvent extends ConfirmableEvent {
|
||||||
super('readSync', confirm);
|
super('readSync', confirm);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ViewSyncEventData = Readonly<{
|
||||||
|
timestamp?: number;
|
||||||
|
envelopeTimestamp: number;
|
||||||
|
senderE164?: string;
|
||||||
|
senderUuid?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export class ViewSyncEvent extends ConfirmableEvent {
|
||||||
|
constructor(
|
||||||
|
public readonly view: ViewSyncEventData,
|
||||||
|
confirm: ConfirmCallback
|
||||||
|
) {
|
||||||
|
super('viewSync', confirm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -36,7 +36,8 @@ export type SendTypesType =
|
||||||
| 'sentSync'
|
| 'sentSync'
|
||||||
| 'typing' // excluded from send log
|
| 'typing' // excluded from send log
|
||||||
| 'verificationSync'
|
| 'verificationSync'
|
||||||
| 'viewOnceSync';
|
| 'viewOnceSync'
|
||||||
|
| 'viewSync';
|
||||||
|
|
||||||
export function shouldSaveProto(sendType: SendTypesType): boolean {
|
export function shouldSaveProto(sendType: SendTypesType): boolean {
|
||||||
if (sendType === 'callingMessage') {
|
if (sendType === 'callingMessage') {
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { ReadStatus } from '../messages/MessageReadStatus';
|
||||||
import type { MessageAttributesType } from '../model-types.d';
|
import type { MessageAttributesType } from '../model-types.d';
|
||||||
|
|
||||||
export const isMessageUnread = (
|
export const isMessageUnread = (
|
||||||
message: Readonly<Pick<MessageAttributesType, 'unread'>>
|
message: Readonly<Pick<MessageAttributesType, 'readStatus'>>
|
||||||
): boolean => Boolean(message.unread);
|
): boolean => message.readStatus === ReadStatus.Unread;
|
||||||
|
|
|
@ -63,6 +63,9 @@ import {
|
||||||
autoScale,
|
autoScale,
|
||||||
handleImageAttachment,
|
handleImageAttachment,
|
||||||
} from '../util/handleImageAttachment';
|
} from '../util/handleImageAttachment';
|
||||||
|
import { ReadStatus } from '../messages/MessageReadStatus';
|
||||||
|
import { markViewed } from '../services/MessageUpdater';
|
||||||
|
import { viewSyncJobQueue } from '../jobs/viewSyncJobQueue';
|
||||||
|
|
||||||
type AttachmentOptions = {
|
type AttachmentOptions = {
|
||||||
messageId: string;
|
messageId: string;
|
||||||
|
@ -861,6 +864,28 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
}
|
}
|
||||||
message.markAttachmentAsCorrupted(options.attachment);
|
message.markAttachmentAsCorrupted(options.attachment);
|
||||||
};
|
};
|
||||||
|
const onMarkViewed = (messageId: string): void => {
|
||||||
|
const message = window.MessageController.getById(messageId);
|
||||||
|
if (!message) {
|
||||||
|
throw new Error(`onMarkViewed: Message ${messageId} missing!`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.get('readStatus') === ReadStatus.Viewed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
message.set(markViewed(message.attributes, Date.now()));
|
||||||
|
viewSyncJobQueue.add({
|
||||||
|
viewSyncs: [
|
||||||
|
{
|
||||||
|
messageId,
|
||||||
|
senderE164: message.get('source'),
|
||||||
|
senderUuid: message.get('sourceUuid'),
|
||||||
|
timestamp: message.get('sent_at'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
const showVisualAttachment = (options: {
|
const showVisualAttachment = (options: {
|
||||||
attachment: AttachmentType;
|
attachment: AttachmentType;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
|
@ -910,6 +935,7 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
downloadNewVersion,
|
downloadNewVersion,
|
||||||
kickOffAttachmentDownload,
|
kickOffAttachmentDownload,
|
||||||
markAttachmentAsCorrupted,
|
markAttachmentAsCorrupted,
|
||||||
|
markViewed: onMarkViewed,
|
||||||
openConversation,
|
openConversation,
|
||||||
openLink,
|
openLink,
|
||||||
reactToMessage,
|
reactToMessage,
|
||||||
|
|
Loading…
Reference in a new issue