Send/Receive support for reaction read syncs

This commit is contained in:
Josh Perez 2021-05-06 18:15:25 -07:00 committed by GitHub
parent 82a9705010
commit e0c324e4ba
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 1188 additions and 498 deletions

View file

@ -97,6 +97,7 @@
});
if (message.isExpiring() && !expirationStartTimestamp) {
// TODO DESKTOP-1509: use setToExpire once this is TS
await message.setToExpire(false, { skipSave: true });
}

View file

@ -67,7 +67,7 @@
function getById(id) {
const existing = messageLookup[id];
return existing && existing.message ? existing.message : null;
return existing && existing.message ? existing.message : undefined;
}
function findBySentAt(sentAt) {

View file

@ -60,8 +60,14 @@
// Remove the last notification if both conditions hold:
//
// 1. Either `conversationId` or `messageId` matches (if present)
// 2. `reactionFromId` matches (if present)
removeBy({ conversationId, messageId, reactionFromId }) {
// 2. `emoji`, `targetAuthorUuid`, `targetTimestamp` matches (if present)
removeBy({
conversationId,
messageId,
emoji,
targetAuthorUuid,
targetTimestamp,
}) {
if (!this.notificationData) {
return;
}
@ -81,10 +87,15 @@
return;
}
const { reaction } = this.notificationData;
if (
reactionFromId &&
this.notificationData.reaction &&
this.notificationData.reaction.fromId !== reactionFromId
reaction &&
emoji &&
targetAuthorUuid &&
targetTimestamp &&
(reaction.emoji !== emoji ||
reaction.targetAuthorUuid !== targetAuthorUuid ||
reaction.targetTimestamp !== targetTimestamp)
) {
return;
}

View file

@ -98,6 +98,7 @@
});
if (message.isExpiring() && !expirationStartTimestamp) {
// TODO DESKTOP-1509: use setToExpire once this is TS
await message.setToExpire(false, { skipSave: true });
}

View file

@ -11,6 +11,30 @@
// eslint-disable-next-line func-names
(function () {
async function maybeItIsAReactionReadSync(receipt) {
const readReaction = await window.Signal.Data.markReactionAsRead(
receipt.get('senderUuid'),
Number(receipt.get('timestamp'))
);
if (!readReaction) {
window.log.info(
'Nothing found for read sync',
receipt.get('senderId'),
receipt.get('sender'),
receipt.get('senderUuid'),
receipt.get('timestamp')
);
}
Whisper.Notifications.removeBy({
conversationId: readReaction.conversationId,
emoji: readReaction.emoji,
targetAuthorUuid: readReaction.targetAuthorUuid,
targetTimestamp: readReaction.targetTimestamp,
});
}
window.Whisper = window.Whisper || {};
Whisper.ReadSyncs = new (Backbone.Collection.extend({
forMessage(message) {
@ -47,19 +71,14 @@
return item.isIncoming() && senderId === receipt.get('senderId');
});
if (found) {
Whisper.Notifications.removeBy({ messageId: found.id });
} else {
window.log.info(
'No message for read sync',
receipt.get('senderId'),
receipt.get('sender'),
receipt.get('senderUuid'),
receipt.get('timestamp')
);
if (!found) {
await maybeItIsAReactionReadSync(receipt);
return;
}
Whisper.Notifications.removeBy({ messageId: found.id });
const message = MessageController.register(found.id, found);
const readAt = receipt.get('read_at');
@ -67,7 +86,8 @@
// timer to the time specified by the read sync if it's earlier than
// the previous read time.
if (message.isUnread()) {
await message.markRead(readAt, { skipSave: true });
// TODO DESKTOP-1509: use MessageUpdater.markRead once this is TS
message.markRead(readAt, { skipSave: true });
const updateConversation = () => {
// onReadMessage may result in messages older than this one being
@ -100,6 +120,7 @@
message.set({ expirationStartTimestamp });
const force = true;
// TODO DESKTOP-1509: use setToExpire once this is TS
await message.setToExpire(force, { skipSave: true });
const conversation = message.getConversation();

View file

@ -723,7 +723,7 @@ export class ConversationController {
async prepareForSend(
id: string | undefined,
options?: { syncMessage?: boolean; disableMeCheck?: boolean }
options?: { syncMessage?: boolean }
): Promise<{
wrap: (
promise: Promise<CallbackResultType | void | null>

View file

@ -20,6 +20,7 @@ import { initializeAllJobQueues } from './jobs/initializeAllJobQueues';
import { removeStorageKeyJobQueue } from './jobs/removeStorageKeyJobQueue';
import { ourProfileKeyService } from './services/ourProfileKey';
import { shouldRespondWithProfileKey } from './util/shouldRespondWithProfileKey';
import { setToExpire } from './services/MessageUpdater';
const MAX_ATTACHMENT_DOWNLOAD_AGE = 3600 * 72 * 1000;
@ -1525,10 +1526,11 @@ export async function startApp(): Promise<void> {
`Cleanup: Starting timer for delivered message ${sentAt}`
);
message.set(
'expirationStartTimestamp',
expirationStartTimestamp || sentAt
setToExpire({
...message.attributes,
expirationStartTimestamp: expirationStartTimestamp || sentAt,
})
);
await message.setToExpire();
return;
}

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

@ -109,8 +109,6 @@ export type MessageAttributesType = {
quote?: QuotedMessageType;
reactions?: Array<{
emoji: string;
timestamp: number;
fromId: string;
from: {
id: string;
color?: string;
@ -120,6 +118,10 @@ export type MessageAttributesType = {
isMe?: boolean;
phoneNumber?: string;
};
fromId: string;
targetAuthorUuid: string;
targetTimestamp: number;
timestamp: number;
}>;
read_by: Array<string | null>;
requiredProtocolVersion: number;

View file

@ -4,6 +4,7 @@
/* eslint-disable class-methods-use-this */
/* eslint-disable camelcase */
import { ProfileKeyCredentialRequestContext } from 'zkgroup';
import { compact } from 'lodash';
import {
MessageModelCollectionType,
WhatIsThis,
@ -15,7 +16,6 @@ import { CallMode, CallHistoryDetailsType } from '../types/Calling';
import {
CallbackResultType,
GroupV2InfoType,
SendMetadataType,
SendOptionsType,
} from '../textsecure/SendMessage';
import {
@ -26,7 +26,6 @@ import { ColorType } from '../types/Colors';
import { MessageModel } from './messages';
import { isMuted } from '../util/isMuted';
import { isConversationUnregistered } from '../util/isConversationUnregistered';
import { assert } from '../util/assert';
import { missingCaseError } from '../util/missingCaseError';
import { sniffImageMimeType } from '../util/sniffImageMimeType';
import { MIMEType, IMAGE_WEBP } from '../types/MIME';
@ -35,7 +34,6 @@ import {
base64ToArrayBuffer,
deriveAccessKey,
fromEncodedBinaryToArrayBuffer,
getRandomBytes,
stringFromBytes,
trimForDisplay,
verifyAccessKey,
@ -45,16 +43,13 @@ import { BodyRangesType } from '../types/Util';
import { getTextWithMentions } from '../util';
import { migrateColor } from '../util/migrateColor';
import { isNotNil } from '../util/isNotNil';
import {
PhoneNumberSharingMode,
parsePhoneNumberSharingMode,
} from '../util/phoneNumberSharingMode';
import {
SenderCertificateMode,
SerializedCertificateType,
} from '../textsecure/OutgoingMessage';
import { senderCertificateService } from '../services/senderCertificate';
import { ourProfileKeyService } from '../services/ourProfileKey';
import { getSendOptions } from '../util/getSendOptions';
import { isConversationAccepted } from '../util/isConversationAccepted';
import { markConversationRead } from '../util/markConversationRead';
import { handleMessageSend } from '../util/handleMessageSend';
import { getConversationMembers } from '../util/getConversationMembers';
import { sendReadReceiptsFor } from '../util/sendReadReceiptsFor';
/* eslint-disable more/no-then */
window.Whisper = window.Whisper || {};
@ -1211,6 +1206,7 @@ export class ConversationModel extends window.Backbone
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const model = this.messageCollection!.add(message, { merge: true });
// TODO use MessageUpdater.setToExpire
model.setToExpire();
if (!existing) {
@ -1535,7 +1531,7 @@ export class ConversationModel extends window.Backbone
if (isLocalAction) {
// eslint-disable-next-line no-await-in-loop
await this.sendReadReceiptsFor(receiptSpecs);
await sendReadReceiptsFor(this.attributes, receiptSpecs);
}
// eslint-disable-next-line no-await-in-loop
@ -2304,43 +2300,7 @@ export class ConversationModel extends window.Backbone
* of message requests
*/
getAccepted(): boolean {
const messageRequestsEnabled = window.Signal.RemoteConfig.isEnabled(
'desktop.messageRequests'
);
if (!messageRequestsEnabled) {
return true;
}
if (this.isMe()) {
return true;
}
const messageRequestEnum =
window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type;
if (this.getMessageRequestResponseType() === messageRequestEnum.ACCEPT) {
return true;
}
const isFromOrAddedByTrustedContact = this.isFromOrAddedByTrustedContact();
const hasSentMessages = this.getSentMessageCount() > 0;
const hasMessagesBeforeMessageRequests =
(this.get('messageCountBeforeMessageRequests') || 0) > 0;
const hasNoMessages = (this.get('messageCount') || 0) === 0;
const isEmptyPrivateConvo = hasNoMessages && this.isPrivate();
const isEmptyWhitelistedGroup =
hasNoMessages && !this.isPrivate() && this.get('profileSharing');
return (
isFromOrAddedByTrustedContact ||
hasSentMessages ||
hasMessagesBeforeMessageRequests ||
// an empty group is the scenario where we need to rely on
// whether the profile has already been shared or not
isEmptyPrivateConvo ||
isEmptyWhitelistedGroup
);
return isConversationAccepted(this.attributes);
}
onMemberVerifiedChange(): void {
@ -2631,12 +2591,6 @@ export class ConversationModel extends window.Backbone
);
}
getUnread(): Promise<MessageModelCollectionType> {
return window.Signal.Data.getUnreadByConversation(this.id, {
MessageCollection: window.Whisper.MessageCollection,
});
}
validate(attributes = this.attributes): string | null {
const required = ['type'];
const missing = window._.filter(required, attr => !attributes[attr]);
@ -2785,50 +2739,11 @@ export class ConversationModel extends window.Backbone
getMembers(
options: { includePendingMembers?: boolean } = {}
): Array<ConversationModel> {
if (this.isPrivate()) {
return [this];
}
if (this.get('membersV2')) {
const { includePendingMembers } = options;
const members: Array<{ conversationId: string }> = includePendingMembers
? [
...(this.get('membersV2') || []),
...(this.get('pendingMembersV2') || []),
]
: this.get('membersV2') || [];
return window._.compact(
members.map(member => {
const c = window.ConversationController.get(member.conversationId);
// In groups we won't sent to contacts we believe are unregistered
if (c && c.isUnregistered()) {
return null;
}
return c;
})
);
}
if (this.get('members')) {
return window._.compact(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.get('members')!.map(id => {
const c = window.ConversationController.get(id);
// In groups we won't send to contacts we believe are unregistered
if (c && c.isUnregistered()) {
return null;
}
return c;
})
);
}
return [];
return compact(
getConversationMembers(this.attributes, options).map(conversationAttrs =>
window.ConversationController.get(conversationAttrs.id)
)
);
}
getMemberIds(): Array<string> {
@ -3477,198 +3392,13 @@ export class ConversationModel extends window.Backbone
async wrapSend(
promise: Promise<CallbackResultType | void | null>
): Promise<CallbackResultType | void | null> {
return promise.then(
async result => {
// success
if (result) {
await this.handleMessageSendResult(
result.failoverIdentifiers,
result.unidentifiedDeliveries
);
}
return result;
},
async result => {
// failure
if (result) {
await this.handleMessageSendResult(
result.failoverIdentifiers,
result.unidentifiedDeliveries
);
}
throw result;
}
);
return handleMessageSend(promise);
}
async handleMessageSendResult(
failoverIdentifiers: Array<string> | undefined,
unidentifiedDeliveries: Array<string> | undefined
): Promise<void> {
await Promise.all(
(failoverIdentifiers || []).map(async identifier => {
const conversation = window.ConversationController.get(identifier);
if (
conversation &&
conversation.get('sealedSender') !== SEALED_SENDER.DISABLED
) {
window.log.info(
`Setting sealedSender to DISABLED for conversation ${conversation.idForLogging()}`
);
conversation.set({
sealedSender: SEALED_SENDER.DISABLED,
});
window.Signal.Data.updateConversation(conversation.attributes);
}
})
);
await Promise.all(
(unidentifiedDeliveries || []).map(async identifier => {
const conversation = window.ConversationController.get(identifier);
if (
conversation &&
conversation.get('sealedSender') === SEALED_SENDER.UNKNOWN
) {
if (conversation.get('accessKey')) {
window.log.info(
`Setting sealedSender to ENABLED for conversation ${conversation.idForLogging()}`
);
conversation.set({
sealedSender: SEALED_SENDER.ENABLED,
});
} else {
window.log.info(
`Setting sealedSender to UNRESTRICTED for conversation ${conversation.idForLogging()}`
);
conversation.set({
sealedSender: SEALED_SENDER.UNRESTRICTED,
});
}
window.Signal.Data.updateConversation(conversation.attributes);
}
})
);
}
async getSendOptions(options = {}): Promise<SendOptionsType> {
const sendMetadata = await this.getSendMetadata(options);
return {
sendMetadata,
};
}
async getSendMetadata(
options: { syncMessage?: boolean; disableMeCheck?: boolean } = {}
): Promise<SendMetadataType | undefined> {
const { syncMessage, disableMeCheck } = options;
// START: this code has an Expiration date of ~2018/11/21
// We don't want to enable unidentified delivery for send unless it is
// also enabled for our own account.
const myId = window.ConversationController.getOurConversationId();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const me = window.ConversationController.get(myId)!;
if (!disableMeCheck && me.get('sealedSender') === SEALED_SENDER.DISABLED) {
return undefined;
}
// END
if (!this.isPrivate()) {
assert(
this.contactCollection,
'getSendMetadata: expected contactCollection to be defined'
);
const result: SendMetadataType = {};
await Promise.all(
this.contactCollection.map(async conversation => {
const sendMetadata =
(await conversation.getSendMetadata(options)) || {};
Object.assign(result, sendMetadata);
})
);
return result;
}
const accessKey = this.get('accessKey');
const sealedSender = this.get('sealedSender');
// We never send sync messages as sealed sender
if (syncMessage && this.isMe()) {
return undefined;
}
const e164 = this.get('e164');
const uuid = this.get('uuid');
const senderCertificate = await this.getSenderCertificateForDirectConversation();
// If we've never fetched user's profile, we default to what we have
if (sealedSender === SEALED_SENDER.UNKNOWN) {
const info = {
accessKey: accessKey || arrayBufferToBase64(getRandomBytes(16)),
senderCertificate,
};
return {
...(e164 ? { [e164]: info } : {}),
...(uuid ? { [uuid]: info } : {}),
};
}
if (sealedSender === SEALED_SENDER.DISABLED) {
return undefined;
}
const info = {
accessKey:
accessKey && sealedSender === SEALED_SENDER.ENABLED
? accessKey
: arrayBufferToBase64(getRandomBytes(16)),
senderCertificate,
};
return {
...(e164 ? { [e164]: info } : {}),
...(uuid ? { [uuid]: info } : {}),
};
}
private getSenderCertificateForDirectConversation(): Promise<
undefined | SerializedCertificateType
> {
if (!this.isPrivate()) {
throw new Error(
'getSenderCertificateForDirectConversation should only be called for direct conversations'
);
}
const phoneNumberSharingMode = parsePhoneNumberSharingMode(
window.storage.get('phoneNumberSharingMode')
);
let certificateMode: SenderCertificateMode;
switch (phoneNumberSharingMode) {
case PhoneNumberSharingMode.Everybody:
certificateMode = SenderCertificateMode.WithE164;
break;
case PhoneNumberSharingMode.ContactsOnly: {
const isInSystemContacts = Boolean(this.get('name'));
certificateMode = isInSystemContacts
? SenderCertificateMode.WithE164
: SenderCertificateMode.WithoutE164;
break;
}
case PhoneNumberSharingMode.Nobody:
certificateMode = SenderCertificateMode.WithoutE164;
break;
default:
throw missingCaseError(phoneNumberSharingMode);
}
return senderCertificateService.get(certificateMode);
async getSendOptions(
options: { syncMessage?: boolean } = {}
): Promise<SendOptionsType> {
return getSendOptions(this.attributes, options);
}
// Is this someone who is a contact, or are we sharing our profile with them?
@ -4234,100 +3964,18 @@ export class ConversationModel extends window.Backbone
}
async markRead(
newestUnreadDate: number,
providedOptions: { readAt?: number; sendReadReceipts: boolean }
newestUnreadId: number,
options: { readAt?: number; sendReadReceipts: boolean } = {
sendReadReceipts: true,
}
): Promise<void> {
const options = providedOptions || {};
window._.defaults(options, { sendReadReceipts: true });
const conversationId = this.id;
window.Whisper.Notifications.removeBy({ conversationId });
let unreadMessages:
| MessageModelCollectionType
| Array<MessageModel> = await this.getUnread();
const oldUnread = unreadMessages.filter(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
message => message.get('received_at')! <= newestUnreadDate
const unreadCount = await markConversationRead(
this.attributes,
newestUnreadId,
options
);
let read = await Promise.all(
window._.map(oldUnread, async providedM => {
const m = window.MessageController.register(providedM.id, providedM);
// Note that this will update the message in the database
await m.markRead(options.readAt);
return {
senderE164: m.get('source'),
senderUuid: m.get('sourceUuid'),
senderId: window.ConversationController.ensureContactIds({
e164: m.get('source'),
uuid: m.get('sourceUuid'),
}),
timestamp: m.get('sent_at'),
hasErrors: m.hasErrors(),
};
})
);
// Some messages we're marking read are local notifications with no sender
read = window._.filter(read, m => Boolean(m.senderId));
unreadMessages = unreadMessages.filter(m => Boolean(m.isIncoming()));
const unreadCount = unreadMessages.length - read.length;
this.set({ unreadCount });
window.Signal.Data.updateConversation(this.attributes);
// If a message has errors, we don't want to send anything out about it.
// read syncs - let's wait for a client that really understands the message
// to mark it read. we'll mark our local error read locally, though.
// read receipts - here we can run into infinite loops, where each time the
// conversation is viewed, another error message shows up for the contact
read = read.filter(item => !item.hasErrors);
if (read.length && options.sendReadReceipts) {
window.log.info(`Sending ${read.length} read syncs`);
// Because syncReadMessages sends to our other devices, and sendReadReceipts goes
// to a contact, we need accessKeys for both.
const {
sendOptions,
} = await window.ConversationController.prepareForSend(
window.ConversationController.getOurConversationId(),
{ syncMessage: true }
);
await this.wrapSend(
window.textsecure.messaging.syncReadMessages(read, sendOptions)
);
await this.sendReadReceiptsFor(read);
}
}
async sendReadReceiptsFor(items: Array<unknown>): Promise<void> {
// Only send read receipts for accepted conversations
if (window.storage.get('read-receipt-setting') && this.getAccepted()) {
window.log.info(`Sending ${items.length} read receipts`);
const convoSendOptions = await this.getSendOptions();
const receiptsBySender = window._.groupBy(items, 'senderId');
await Promise.all(
window._.map(receiptsBySender, async (receipts, senderId) => {
const timestamps = window._.map(receipts, 'timestamp');
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const c = window.ConversationController.get(senderId)!;
await this.wrapSend(
window.textsecure.messaging.sendReadReceipts(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
c.get('e164')!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
c.get('uuid')!,
timestamps,
convoSendOptions
)
);
})
);
}
}
// This is an expensive operation we use to populate the message request hero row. It
@ -4443,8 +4091,7 @@ export class ConversationModel extends window.Backbone
));
}
const sendMetadata =
(await c.getSendMetadata({ disableMeCheck: true })) || {};
const { sendMetadata = {} } = await c.getSendOptions();
const getInfo =
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
sendMetadata[c.get('uuid')!] || sendMetadata[c.get('e164')!] || {};

View file

@ -28,6 +28,7 @@ import { missingCaseError } from '../util/missingCaseError';
import { ColorType } from '../types/Colors';
import { CallMode } from '../types/Calling';
import { BodyRangesType } from '../types/Util';
import { ReactionType } from '../types/Reactions';
import { PropsDataType as GroupsV2Props } from '../components/conversation/GroupV2Change';
import {
PropsData as TimerNotificationProps,
@ -50,6 +51,7 @@ import { AttachmentType, isImage, isVideo } from '../types/Attachment';
import { MIMEType } from '../types/MIME';
import { LinkPreviewType } from '../types/message/LinkPreviews';
import { ourProfileKeyService } from '../services/ourProfileKey';
import { markRead, setToExpire } from '../services/MessageUpdater';
/* eslint-disable camelcase */
/* eslint-disable more/no-then */
@ -1755,7 +1757,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
}
if (this.get('unread')) {
await this.markRead();
this.set(markRead(this.attributes));
}
await this.eraseContents();
@ -2030,33 +2032,18 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
}
}
async markRead(
readAt?: number,
options: { skipSave?: boolean } = {}
): Promise<void> {
const { skipSave } = options;
this.unset('unread');
if (this.get('expireTimer') && !this.get('expirationStartTimestamp')) {
const expirationStartTimestamp = Math.min(
Date.now(),
readAt || Date.now()
);
this.set({ expirationStartTimestamp });
}
window.Whisper.Notifications.removeBy({ messageId: this.id });
if (!skipSave) {
window.Signal.Util.queueUpdateMessage(this.attributes);
}
markRead(readAt?: number, options = {}): void {
this.set(markRead(this.attributes, readAt, options));
}
isExpiring(): number | null {
return this.get('expireTimer') && this.get('expirationStartTimestamp');
}
setToExpire(force = false, options = {}): void {
this.set(setToExpire(this.attributes, { ...options, force }));
}
isExpired(): boolean {
return this.msTilExpire() <= 0;
}
@ -2076,33 +2063,6 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
return msFromNow;
}
async setToExpire(
force = false,
options: { skipSave?: boolean } = {}
): Promise<void> {
const { skipSave } = options || {};
if (this.isExpiring() && (force || !this.get('expires_at'))) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const start = this.get('expirationStartTimestamp')!;
const delta = this.get('expireTimer') * 1000;
const expiresAt = start + delta;
this.set({ expires_at: expiresAt });
window.log.info('Set message expiration', {
start,
expiresAt,
sentAt: this.get('sent_at'),
});
const id = this.get('id');
if (id && !skipSave) {
window.Signal.Util.queueUpdateMessage(this.attributes);
}
}
}
getIncomingContact(): ConversationModel | undefined | null {
if (!this.isIncoming()) {
return null;
@ -4110,7 +4070,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
this.get('conversationId')
);
let staleReactionFromId: string | undefined;
let reactionToRemove: Partial<ReactionType> | undefined;
if (reaction.get('remove')) {
window.log.info('Removing reaction for message', messageId);
@ -4121,7 +4081,18 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
);
this.set({ reactions: newReactions });
staleReactionFromId = reaction.get('fromId');
reactionToRemove = {
emoji: reaction.get('emoji'),
targetAuthorUuid: reaction.get('targetAuthorUuid'),
targetTimestamp: reaction.get('targetTimestamp'),
};
await window.Signal.Data.removeReactionFromConversation({
emoji: reaction.get('emoji'),
fromId: reaction.get('fromId'),
targetAuthorUuid: reaction.get('targetAuthorUuid'),
targetTimestamp: reaction.get('targetTimestamp'),
});
} else {
window.log.info('Adding reaction for message', messageId);
const newReactions = reactions.filter(
@ -4134,17 +4105,30 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
re => re.fromId === reaction.get('fromId')
);
if (oldReaction) {
staleReactionFromId = oldReaction.fromId;
reactionToRemove = {
emoji: oldReaction.emoji,
targetAuthorUuid: oldReaction.targetAuthorUuid,
targetTimestamp: oldReaction.targetTimestamp,
};
}
await window.Signal.Data.addReaction({
conversationId: this.get('conversationId'),
emoji: reaction.get('emoji'),
fromId: reaction.get('fromId'),
messageReceivedAt: this.get('received_at'),
targetAuthorUuid: reaction.get('targetAuthorUuid'),
targetTimestamp: reaction.get('targetTimestamp'),
});
// Only notify for reactions to our own messages
if (conversation && this.isOutgoing() && !reaction.get('fromSync')) {
conversation.notify(this, reaction);
}
}
if (staleReactionFromId) {
this.clearNotifications(reaction.get('fromId'));
if (reactionToRemove) {
this.clearNotifications(reactionToRemove);
}
const newCount = (this.get('reactions') || []).length;
@ -4184,10 +4168,10 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
this.getConversation()!.updateLastMessage();
}
clearNotifications(reactionFromId?: string): void {
clearNotifications(reaction: Partial<ReactionType> = {}): void {
window.Whisper.Notifications.removeBy({
...reaction,
messageId: this.id,
reactionFromId,
});
}
}

View file

@ -0,0 +1,87 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { MessageAttributesType } from '../model-types.d';
export function markRead(
messageAttrs: MessageAttributesType,
readAt?: number,
{ skipSave = false } = {}
): MessageAttributesType {
const nextMessageAttributes = {
...messageAttrs,
unread: false,
};
const { id: messageId, expireTimer, expirationStartTimestamp } = messageAttrs;
if (expireTimer && !expirationStartTimestamp) {
nextMessageAttributes.expirationStartTimestamp = Math.min(
Date.now(),
readAt || Date.now()
);
}
window.Whisper.Notifications.removeBy({ messageId });
if (!skipSave) {
window.Signal.Util.queueUpdateMessage(nextMessageAttributes);
}
return nextMessageAttributes;
}
export function getExpiresAt(
messageAttrs: Pick<
MessageAttributesType,
'expireTimer' | 'expirationStartTimestamp'
>
): number | undefined {
const expireTimerMs = messageAttrs.expireTimer * 1000;
return messageAttrs.expirationStartTimestamp
? messageAttrs.expirationStartTimestamp + expireTimerMs
: undefined;
}
export function setToExpire(
messageAttrs: MessageAttributesType,
{ force = false, skipSave = false } = {}
): MessageAttributesType {
if (!isExpiring(messageAttrs) || (!force && messageAttrs.expires_at)) {
return messageAttrs;
}
const expiresAt = getExpiresAt(messageAttrs);
if (!expiresAt) {
return messageAttrs;
}
const nextMessageAttributes = {
...messageAttrs,
expires_at: expiresAt,
};
window.log.info('Set message expiration', {
start: messageAttrs.expirationStartTimestamp,
expiresAt,
sentAt: messageAttrs.sent_at,
});
if (messageAttrs.id && !skipSave) {
window.Signal.Util.queueUpdateMessage(nextMessageAttributes);
}
return nextMessageAttributes;
}
function isExpiring(
messageAttrs: Pick<
MessageAttributesType,
'expireTimer' | 'expirationStartTimestamp'
>
): boolean {
return Boolean(
messageAttrs.expireTimer && messageAttrs.expirationStartTimestamp
);
}

View file

@ -28,6 +28,7 @@ import { CURRENT_SCHEMA_VERSION } from '../../js/modules/types/message';
import { createBatcher } from '../util/batcher';
import { assert } from '../util/assert';
import { cleanDataForIpc } from './cleanDataForIpc';
import { ReactionType } from '../types/Reactions';
import {
ConversationModelCollectionType,
@ -166,7 +167,11 @@ const dataInterface: ClientInterface = {
saveMessages,
removeMessage,
removeMessages,
getUnreadByConversation,
getUnreadByConversationAndMarkRead,
getUnreadReactionsAndMarkRead,
markReactionAsRead,
removeReactionFromConversation,
addReaction,
getMessageBySender,
getMessageById,
@ -1041,15 +1046,43 @@ async function getMessageBySender(
return new Message(messages[0]);
}
async function getUnreadByConversation(
async function getUnreadByConversationAndMarkRead(
conversationId: string,
{
MessageCollection,
}: { MessageCollection: typeof MessageModelCollectionType }
newestUnreadId: number,
readAt?: number
) {
const messages = await channels.getUnreadByConversation(conversationId);
return channels.getUnreadByConversationAndMarkRead(
conversationId,
newestUnreadId,
readAt
);
}
return new MessageCollection(messages);
async function getUnreadReactionsAndMarkRead(
conversationId: string,
newestUnreadId: number
) {
return channels.getUnreadReactionsAndMarkRead(conversationId, newestUnreadId);
}
async function markReactionAsRead(
targetAuthorUuid: string,
targetTimestamp: number
) {
return channels.markReactionAsRead(targetAuthorUuid, targetTimestamp);
}
async function removeReactionFromConversation(reaction: {
emoji: string;
fromId: string;
targetAuthorUuid: string;
targetTimestamp: number;
}) {
return channels.removeReactionFromConversation(reaction);
}
async function addReaction(reactionObj: ReactionType) {
return channels.addReaction(reactionObj);
}
function handleMessageJSON(messages: Array<MessageTypeUnhydrated>) {

View file

@ -4,7 +4,6 @@
/* eslint-disable @typescript-eslint/ban-types */
/* eslint-disable camelcase */
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
ConversationAttributesType,
ConversationModelCollectionType,
@ -14,6 +13,7 @@ import {
import { MessageModel } from '../models/messages';
import { ConversationModel } from '../models/conversations';
import { StoredJob } from '../jobs/types';
import { ReactionType } from '../types/Reactions';
export type AttachmentDownloadJobType = {
id: string;
@ -343,9 +343,32 @@ export type ServerInterface = DataInterface & {
getNextTapToViewMessageToAgeOut: () => Promise<MessageType | undefined>;
getOutgoingWithoutExpiresAt: () => Promise<Array<MessageType>>;
getTapToViewMessagesNeedingErase: () => Promise<Array<MessageType>>;
getUnreadByConversation: (
conversationId: string
) => Promise<Array<MessageType>>;
getUnreadByConversationAndMarkRead: (
conversationId: string,
newestUnreadId: number,
readAt?: number
) => Promise<
Array<
Pick<MessageType, 'id' | 'source' | 'sourceUuid' | 'sent_at' | 'type'>
>
>;
getUnreadReactionsAndMarkRead: (
conversationId: string,
newestUnreadId: number
) => Promise<
Array<Pick<ReactionType, 'targetAuthorUuid' | 'targetTimestamp'>>
>;
markReactionAsRead: (
targetAuthorUuid: string,
targetTimestamp: number
) => Promise<ReactionType | undefined>;
removeReactionFromConversation: (reaction: {
emoji: string;
fromId: string;
targetAuthorUuid: string;
targetTimestamp: number;
}) => Promise<void>;
addReaction: (reactionObj: ReactionType) => Promise<void>;
removeConversation: (id: Array<string> | string) => Promise<void>;
removeMessage: (id: string) => Promise<void>;
removeMessages: (ids: Array<string>) => Promise<void>;
@ -463,10 +486,32 @@ export type ClientInterface = DataInterface & {
getTapToViewMessagesNeedingErase: (options: {
MessageCollection: typeof MessageModelCollectionType;
}) => Promise<MessageModelCollectionType>;
getUnreadByConversation: (
getUnreadByConversationAndMarkRead: (
conversationId: string,
options: { MessageCollection: typeof MessageModelCollectionType }
) => Promise<MessageModelCollectionType>;
newestUnreadId: number,
readAt?: number
) => Promise<
Array<
Pick<MessageType, 'id' | 'source' | 'sourceUuid' | 'sent_at' | 'type'>
>
>;
getUnreadReactionsAndMarkRead: (
conversationId: string,
newestUnreadId: number
) => Promise<
Array<Pick<ReactionType, 'targetAuthorUuid' | 'targetTimestamp'>>
>;
markReactionAsRead: (
targetAuthorUuid: string,
targetTimestamp: number
) => Promise<ReactionType | undefined>;
removeReactionFromConversation: (reaction: {
emoji: string;
fromId: string;
targetAuthorUuid: string;
targetTimestamp: number;
}) => Promise<void>;
addReaction: (reactionObj: ReactionType) => Promise<void>;
removeConversation: (
id: string,
options: { Conversation: typeof ConversationModel }

View file

@ -28,13 +28,14 @@ import {
omit,
} from 'lodash';
import { assert } from '../util/assert';
import { isNormalNumber } from '../util/isNormalNumber';
import { combineNames } from '../util/combineNames';
import { isNotNil } from '../util/isNotNil';
import { GroupV2MemberType } from '../model-types.d';
import { ReactionType } from '../types/Reactions';
import { StoredJob } from '../jobs/types';
import { assert } from '../util/assert';
import { combineNames } from '../util/combineNames';
import { getExpiresAt } from '../services/MessageUpdater';
import { isNormalNumber } from '../util/isNormalNumber';
import { isNotNil } from '../util/isNotNil';
import {
AttachmentDownloadJobType,
@ -156,7 +157,11 @@ const dataInterface: ServerInterface = {
saveMessages,
removeMessage,
removeMessages,
getUnreadByConversation,
getUnreadByConversationAndMarkRead,
getUnreadReactionsAndMarkRead,
markReactionAsRead,
addReaction,
removeReactionFromConversation,
getMessageBySender,
getMessageById,
_getAllMessages,
@ -1714,6 +1719,39 @@ function updateToSchemaVersion28(currentVersion: number, db: Database) {
})();
}
function updateToSchemaVersion29(currentVersion: number, db: Database) {
if (currentVersion >= 29) {
return;
}
db.transaction(() => {
db.exec(`
CREATE TABLE reactions(
conversationId STRING,
emoji STRING,
fromId STRING,
messageReceivedAt INTEGER,
targetAuthorUuid STRING,
targetTimestamp INTEGER,
unread INTEGER
);
CREATE INDEX reactions_unread ON reactions (
unread,
conversationId
);
CREATE INDEX reaction_identifier ON reactions (
emoji,
targetAuthorUuid,
targetTimestamp
);
`);
db.pragma('user_version = 29');
})();
}
const SCHEMA_VERSIONS = [
updateToSchemaVersion1,
updateToSchemaVersion2,
@ -1743,6 +1781,7 @@ const SCHEMA_VERSIONS = [
updateToSchemaVersion26,
updateToSchemaVersion27,
updateToSchemaVersion28,
updateToSchemaVersion29,
];
function updateSchema(db: Database): void {
@ -2961,25 +3000,298 @@ async function getMessageBySender({
return rows.map(row => jsonToObject(row.json));
}
async function getUnreadByConversation(
conversationId: string
): Promise<Array<MessageType>> {
function getExpireData(
messageExpireTimer: number,
readAt?: number
): {
expirationStartTimestamp: number;
expiresAt: number;
} {
const expirationStartTimestamp = Math.min(Date.now(), readAt || Date.now());
const expiresAt = getExpiresAt({
expireTimer: messageExpireTimer,
expirationStartTimestamp,
});
// We are guaranteeing an expirationStartTimestamp above so this should
// definitely return a number.
if (!expiresAt || typeof expiresAt !== 'number') {
assert(false, 'Expected expiresAt to be a number');
}
return {
expirationStartTimestamp,
expiresAt,
};
}
function updateExpirationTimers(
messageExpireTimer: number,
messagesWithExpireTimer: Set<string>,
readAt?: number
) {
const { expirationStartTimestamp, expiresAt } = getExpireData(
messageExpireTimer,
readAt
);
const db = getInstance();
const rows: JSONRows = db
.prepare<Query>(
`
SELECT json FROM messages WHERE
unread = $unread AND
conversationId = $conversationId
ORDER BY received_at DESC, sent_at DESC;
`
)
.all({
unread: 1,
conversationId,
const stmt = db.prepare<Query>(
`
UPDATE messages
SET
unread = 0,
expires_at = $expiresAt,
expirationStartTimestamp = $expirationStartTimestamp,
json = json_patch(json, $jsonPatch)
WHERE
id = $id
`
);
messagesWithExpireTimer.forEach(id => {
stmt.run({
id,
expirationStartTimestamp,
expiresAt,
jsonPatch: JSON.stringify({
expirationStartTimestamp,
expires_at: expiresAt,
unread: 0,
}),
});
});
}
async function getUnreadByConversationAndMarkRead(
conversationId: string,
newestUnreadId: number,
readAt?: number
): Promise<
Array<Pick<MessageType, 'id' | 'source' | 'sourceUuid' | 'sent_at' | 'type'>>
> {
const db = getInstance();
return db.transaction(() => {
const rows = db
.prepare<Query>(
`
SELECT id, expireTimer, expirationStartTimestamp, json
FROM messages WHERE
unread = $unread AND
conversationId = $conversationId AND
received_at <= $newestUnreadId
ORDER BY received_at DESC, sent_at DESC;
`
)
.all({
unread: 1,
conversationId,
newestUnreadId,
});
let messageExpireTimer: number | undefined;
const messagesWithExpireTimer: Set<string> = new Set();
const messagesToMarkRead: Array<string> = [];
rows.forEach(row => {
if (row.expireTimer && !row.expirationStartTimestamp) {
messageExpireTimer = row.expireTimer;
messagesWithExpireTimer.add(row.id);
}
messagesToMarkRead.push(row.id);
});
return rows.map(row => jsonToObject(row.json));
if (messagesToMarkRead.length) {
const stmt = db.prepare<Query>(
`
UPDATE messages
SET
unread = 0,
json = json_patch(json, $jsonPatch)
WHERE
id = $id;
`
);
messagesToMarkRead.forEach(id =>
stmt.run({
id,
jsonPatch: JSON.stringify({ unread: 0 }),
})
);
}
if (messageExpireTimer && messagesWithExpireTimer.size) {
// We use the messageExpireTimer set above from whichever row we have
// in the database. Since this is the same conversation the expireTimer
// should be the same for all messages within it.
updateExpirationTimers(
messageExpireTimer,
messagesWithExpireTimer,
readAt
);
}
return rows.map(row => {
const json = jsonToObject(row.json);
const expireAttrs = {};
if (messageExpireTimer && messagesWithExpireTimer.has(row.id)) {
const { expirationStartTimestamp, expiresAt } = getExpireData(
messageExpireTimer,
readAt
);
Object.assign(expireAttrs, {
expirationStartTimestamp,
expires_at: expiresAt,
});
}
return {
unread: false,
...pick(json, ['id', 'sent_at', 'source', 'sourceUuid', 'type']),
...expireAttrs,
};
});
})();
}
async function getUnreadReactionsAndMarkRead(
conversationId: string,
newestUnreadId: number
): Promise<Array<Pick<ReactionType, 'targetAuthorUuid' | 'targetTimestamp'>>> {
const db = getInstance();
return db.transaction(() => {
const unreadMessages = db
.prepare<Query>(
`
SELECT targetAuthorUuid, targetTimestamp
FROM reactions WHERE
unread = 1 AND
conversationId = $conversationId AND
messageReceivedAt <= $newestUnreadId;
`
)
.all({
conversationId,
newestUnreadId,
});
db.exec(`
UPDATE reactions SET
unread = 0 WHERE
$conversationId = conversationId AND
$messageReceivedAt <= messageReceivedAt;
`);
return unreadMessages;
})();
}
async function markReactionAsRead(
targetAuthorUuid: string,
targetTimestamp: number
): Promise<ReactionType | undefined> {
const db = getInstance();
return db.transaction(() => {
const readReaction = db
.prepare(
`
SELECT *
FROM reactions
WHERE
targetAuthorUuid = $targetAuthorUuid AND
targetTimestamp = $targetTimestamp AND
unread = 1
ORDER BY rowId DESC
LIMIT 1;
`
)
.get({
targetAuthorUuid,
targetTimestamp,
});
db.prepare(
`
UPDATE reactions SET
unread = 0 WHERE
$targetAuthorUuid = targetAuthorUuid AND
$targetTimestamp = targetTimestamp;
`
).run({
targetAuthorUuid,
targetTimestamp,
});
return readReaction;
})();
}
async function addReaction({
conversationId,
emoji,
fromId,
messageReceivedAt,
targetAuthorUuid,
targetTimestamp,
}: ReactionType): Promise<void> {
const db = getInstance();
await db
.prepare(
`INSERT INTO reactions (
conversationId,
emoji,
fromId,
messageReceivedAt,
targetAuthorUuid,
targetTimestamp,
unread
) VALUES (
$conversationId,
$emoji,
$fromId,
$messageReceivedAt,
$targetAuthorUuid,
$targetTimestamp,
$unread
);`
)
.run({
conversationId,
emoji,
fromId,
messageReceivedAt,
targetAuthorUuid,
targetTimestamp,
unread: 1,
});
}
async function removeReactionFromConversation({
emoji,
fromId,
targetAuthorUuid,
targetTimestamp,
}: {
emoji: string;
fromId: string;
targetAuthorUuid: string;
targetTimestamp: number;
}): Promise<void> {
const db = getInstance();
await db
.prepare(
`DELETE FROM reactions WHERE
emoji = $emoji AND
fromId = $fromId AND
targetAuthorUuid = $targetAuthorUuid AND
targetTimestamp = $targetTimestamp;`
)
.run({
emoji,
fromId,
targetAuthorUuid,
targetTimestamp,
});
}
async function getOlderMessagesByConversation(

11
ts/types/Reactions.ts Normal file
View file

@ -0,0 +1,11 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export type ReactionType = Readonly<{
conversationId: string;
emoji: string;
fromId: string;
messageReceivedAt: number;
targetAuthorUuid: string;
targetTimestamp: number;
}>;

View file

@ -0,0 +1,57 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { compact } from 'lodash';
import { ConversationAttributesType } from '../model-types.d';
import { isDirectConversation } from './whatTypeOfConversation';
export function getConversationMembers(
conversationAttrs: ConversationAttributesType,
options: { includePendingMembers?: boolean } = {}
): Array<ConversationAttributesType> {
if (isDirectConversation(conversationAttrs)) {
return [conversationAttrs];
}
if (conversationAttrs.membersV2) {
const { includePendingMembers } = options;
const members: Array<{ conversationId: string }> = includePendingMembers
? [
...(conversationAttrs.membersV2 || []),
...(conversationAttrs.pendingMembersV2 || []),
]
: conversationAttrs.membersV2 || [];
return compact(
members.map(member => {
const conversation = window.ConversationController.get(
member.conversationId
);
// In groups we won't sent to contacts we believe are unregistered
if (conversation && conversation.isUnregistered()) {
return null;
}
return conversation?.attributes;
})
);
}
if (conversationAttrs.members) {
return compact(
conversationAttrs.members.map(id => {
const conversation = window.ConversationController.get(id);
// In groups we won't send to contacts we believe are unregistered
if (conversation && conversation.isUnregistered()) {
return null;
}
return conversation?.attributes;
})
);
}
return [];
}

135
ts/util/getSendOptions.ts Normal file
View file

@ -0,0 +1,135 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { ConversationAttributesType } from '../model-types.d';
import { SendMetadataType, SendOptionsType } from '../textsecure/SendMessage';
import { arrayBufferToBase64, getRandomBytes } from '../Crypto';
import { getConversationMembers } from './getConversationMembers';
import { isDirectConversation, isMe } from './whatTypeOfConversation';
import { missingCaseError } from './missingCaseError';
import { senderCertificateService } from '../services/senderCertificate';
import {
PhoneNumberSharingMode,
parsePhoneNumberSharingMode,
} from './phoneNumberSharingMode';
import {
SenderCertificateMode,
SerializedCertificateType,
} from '../textsecure/OutgoingMessage';
const SEALED_SENDER = {
UNKNOWN: 0,
ENABLED: 1,
DISABLED: 2,
UNRESTRICTED: 3,
};
export async function getSendOptions(
conversationAttrs: ConversationAttributesType,
options: { syncMessage?: boolean } = {}
): Promise<SendOptionsType> {
const { syncMessage } = options;
if (!isDirectConversation(conversationAttrs)) {
const contactCollection = getConversationMembers(conversationAttrs);
const sendMetadata: SendMetadataType = {};
await Promise.all(
contactCollection.map(async contactAttrs => {
const conversation = window.ConversationController.get(contactAttrs.id);
if (!conversation) {
return;
}
const {
sendMetadata: conversationSendMetadata,
} = await conversation.getSendOptions(options);
Object.assign(sendMetadata, conversationSendMetadata || {});
})
);
return { sendMetadata };
}
const { accessKey, sealedSender } = conversationAttrs;
// We never send sync messages as sealed sender
if (syncMessage && isMe(conversationAttrs)) {
return {
sendMetadata: undefined,
};
}
const { e164, uuid } = conversationAttrs;
const senderCertificate = await getSenderCertificateForDirectConversation(
conversationAttrs
);
// If we've never fetched user's profile, we default to what we have
if (sealedSender === SEALED_SENDER.UNKNOWN) {
const identifierData = {
accessKey: accessKey || arrayBufferToBase64(getRandomBytes(16)),
senderCertificate,
};
return {
sendMetadata: {
...(e164 ? { [e164]: identifierData } : {}),
...(uuid ? { [uuid]: identifierData } : {}),
},
};
}
if (sealedSender === SEALED_SENDER.DISABLED) {
return {
sendMetadata: undefined,
};
}
const identifierData = {
accessKey:
accessKey && sealedSender === SEALED_SENDER.ENABLED
? accessKey
: arrayBufferToBase64(getRandomBytes(16)),
senderCertificate,
};
return {
sendMetadata: {
...(e164 ? { [e164]: identifierData } : {}),
...(uuid ? { [uuid]: identifierData } : {}),
},
};
}
function getSenderCertificateForDirectConversation(
conversationAttrs: ConversationAttributesType
): Promise<undefined | SerializedCertificateType> {
if (!isDirectConversation(conversationAttrs)) {
throw new Error(
'getSenderCertificateForDirectConversation should only be called for direct conversations'
);
}
const phoneNumberSharingMode = parsePhoneNumberSharingMode(
window.storage.get('phoneNumberSharingMode')
);
let certificateMode: SenderCertificateMode;
switch (phoneNumberSharingMode) {
case PhoneNumberSharingMode.Everybody:
certificateMode = SenderCertificateMode.WithE164;
break;
case PhoneNumberSharingMode.ContactsOnly: {
const isInSystemContacts = Boolean(conversationAttrs.name);
certificateMode = isInSystemContacts
? SenderCertificateMode.WithE164
: SenderCertificateMode.WithoutE164;
break;
}
case PhoneNumberSharingMode.Nobody:
certificateMode = SenderCertificateMode.WithoutE164;
break;
default:
throw missingCaseError(phoneNumberSharingMode);
}
return senderCertificateService.get(certificateMode);
}

View file

@ -0,0 +1,86 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { CallbackResultType } from '../textsecure/SendMessage';
const SEALED_SENDER = {
UNKNOWN: 0,
ENABLED: 1,
DISABLED: 2,
UNRESTRICTED: 3,
};
export async function handleMessageSend(
promise: Promise<CallbackResultType | void | null>
): Promise<CallbackResultType | void | null> {
try {
const result = await promise;
if (result) {
await handleMessageSendResult(
result.failoverIdentifiers,
result.unidentifiedDeliveries
);
}
return result;
} catch (err) {
if (err) {
await handleMessageSendResult(
err.failoverIdentifiers,
err.unidentifiedDeliveries
);
}
throw err;
}
}
async function handleMessageSendResult(
failoverIdentifiers: Array<string> | undefined,
unidentifiedDeliveries: Array<string> | undefined
): Promise<void> {
await Promise.all(
(failoverIdentifiers || []).map(async identifier => {
const conversation = window.ConversationController.get(identifier);
if (
conversation &&
conversation.get('sealedSender') !== SEALED_SENDER.DISABLED
) {
window.log.info(
`Setting sealedSender to DISABLED for conversation ${conversation.idForLogging()}`
);
conversation.set({
sealedSender: SEALED_SENDER.DISABLED,
});
window.Signal.Data.updateConversation(conversation.attributes);
}
})
);
await Promise.all(
(unidentifiedDeliveries || []).map(async identifier => {
const conversation = window.ConversationController.get(identifier);
if (
conversation &&
conversation.get('sealedSender') === SEALED_SENDER.UNKNOWN
) {
if (conversation.get('accessKey')) {
window.log.info(
`Setting sealedSender to ENABLED for conversation ${conversation.idForLogging()}`
);
conversation.set({
sealedSender: SEALED_SENDER.ENABLED,
});
} else {
window.log.info(
`Setting sealedSender to UNRESTRICTED for conversation ${conversation.idForLogging()}`
);
conversation.set({
sealedSender: SEALED_SENDER.UNRESTRICTED,
});
}
window.Signal.Data.updateConversation(conversation.attributes);
}
})
);
}

View file

@ -0,0 +1,84 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { ConversationAttributesType } from '../model-types.d';
import { isDirectConversation, isMe } from './whatTypeOfConversation';
/**
* Determine if this conversation should be considered "accepted" in terms
* of message requests
*/
export function isConversationAccepted(
conversationAttrs: ConversationAttributesType
): boolean {
const messageRequestsEnabled = window.Signal.RemoteConfig.isEnabled(
'desktop.messageRequests'
);
if (!messageRequestsEnabled) {
return true;
}
if (isMe(conversationAttrs)) {
return true;
}
const messageRequestEnum =
window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type;
const { messageRequestResponseType } = conversationAttrs;
if (messageRequestResponseType === messageRequestEnum.ACCEPT) {
return true;
}
const { sentMessageCount } = conversationAttrs;
const hasSentMessages = sentMessageCount > 0;
const hasMessagesBeforeMessageRequests =
(conversationAttrs.messageCountBeforeMessageRequests || 0) > 0;
const hasNoMessages = (conversationAttrs.messageCount || 0) === 0;
const isEmptyPrivateConvo =
hasNoMessages && isDirectConversation(conversationAttrs);
const isEmptyWhitelistedGroup =
hasNoMessages &&
!isDirectConversation(conversationAttrs) &&
conversationAttrs.profileSharing;
return (
isFromOrAddedByTrustedContact(conversationAttrs) ||
hasSentMessages ||
hasMessagesBeforeMessageRequests ||
// an empty group is the scenario where we need to rely on
// whether the profile has already been shared or not
isEmptyPrivateConvo ||
isEmptyWhitelistedGroup
);
}
// Is this someone who is a contact, or are we sharing our profile with them?
// Or is the person who added us to this group a contact or are we sharing profile
// with them?
function isFromOrAddedByTrustedContact(
conversationAttrs: ConversationAttributesType
): boolean {
if (isDirectConversation(conversationAttrs)) {
return Boolean(conversationAttrs.name || conversationAttrs.profileSharing);
}
const { addedBy } = conversationAttrs;
if (!addedBy) {
return false;
}
const conversation = window.ConversationController.get(addedBy);
if (!conversation) {
return false;
}
return Boolean(
isMe(conversation.attributes) ||
conversation.get('name') ||
conversation.get('profileSharing')
);
}

View file

@ -0,0 +1,111 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { ConversationAttributesType } from '../model-types.d';
import { handleMessageSend } from './handleMessageSend';
import { sendReadReceiptsFor } from './sendReadReceiptsFor';
export async function markConversationRead(
conversationAttrs: ConversationAttributesType,
newestUnreadId: number,
options: { readAt?: number; sendReadReceipts: boolean } = {
sendReadReceipts: true,
}
): Promise<number> {
const { id: conversationId } = conversationAttrs;
window.Whisper.Notifications.removeBy({ conversationId });
const [unreadMessages, unreadReactions] = await Promise.all([
window.Signal.Data.getUnreadByConversationAndMarkRead(
conversationId,
newestUnreadId,
options.readAt
),
window.Signal.Data.getUnreadReactionsAndMarkRead(
conversationId,
newestUnreadId
),
]);
const unreadReactionSyncData = new Map<
string,
{
senderUuid?: string;
senderE164?: string;
timestamp: number;
}
>();
unreadReactions.forEach(reaction => {
const targetKey = `${reaction.targetAuthorUuid}/${reaction.targetTimestamp}`;
if (unreadReactionSyncData.has(targetKey)) {
return;
}
unreadReactionSyncData.set(targetKey, {
senderE164: undefined,
senderUuid: reaction.targetAuthorUuid,
timestamp: reaction.targetTimestamp,
});
});
const allReadMessagesSync = unreadMessages.map(messageSyncData => {
const message = window.MessageController.getById(messageSyncData.id);
// we update the in-memory MessageModel with the fresh database call data
if (message) {
message.set(messageSyncData);
}
return {
senderE164: messageSyncData.source,
senderUuid: messageSyncData.sourceUuid,
senderId: window.ConversationController.ensureContactIds({
e164: messageSyncData.source,
uuid: messageSyncData.sourceUuid,
}),
timestamp: messageSyncData.sent_at,
hasErrors: message ? message.hasErrors() : false,
};
});
// Some messages we're marking read are local notifications with no sender
const messagesWithSenderId = allReadMessagesSync.filter(syncMessage =>
Boolean(syncMessage.senderId)
);
const incomingUnreadMessages = unreadMessages.filter(
message => message.type === 'incoming'
);
const unreadCount =
incomingUnreadMessages.length - messagesWithSenderId.length;
// If a message has errors, we don't want to send anything out about it.
// read syncs - let's wait for a client that really understands the message
// to mark it read. we'll mark our local error read locally, though.
// read receipts - here we can run into infinite loops, where each time the
// conversation is viewed, another error message shows up for the contact
const unreadMessagesSyncData = messagesWithSenderId.filter(
item => !item.hasErrors
);
const readSyncs = [
...unreadMessagesSyncData,
...Array.from(unreadReactionSyncData.values()),
];
if (readSyncs.length && options.sendReadReceipts) {
window.log.info(`Sending ${readSyncs.length} read syncs`);
// Because syncReadMessages sends to our other devices, and sendReadReceipts goes
// to a contact, we need accessKeys for both.
const {
sendOptions,
} = await window.ConversationController.prepareForSend(
window.ConversationController.getOurConversationId(),
{ syncMessage: true }
);
await handleMessageSend(
window.textsecure.messaging.syncReadMessages(readSyncs, sendOptions)
);
await sendReadReceiptsFor(conversationAttrs, unreadMessagesSyncData);
}
return unreadCount;
}

View file

@ -0,0 +1,43 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { groupBy, map } from 'lodash';
import { ConversationAttributesType } from '../model-types.d';
import { getSendOptions } from './getSendOptions';
import { handleMessageSend } from './handleMessageSend';
import { isConversationAccepted } from './isConversationAccepted';
export async function sendReadReceiptsFor(
conversationAttrs: ConversationAttributesType,
items: Array<unknown>
): Promise<void> {
// Only send read receipts for accepted conversations
if (
window.storage.get('read-receipt-setting') &&
isConversationAccepted(conversationAttrs)
) {
window.log.info(`Sending ${items.length} read receipts`);
const convoSendOptions = await getSendOptions(conversationAttrs);
const receiptsBySender = groupBy(items, 'senderId');
await Promise.all(
map(receiptsBySender, async (receipts, senderId) => {
const timestamps = map(receipts, 'timestamp');
const conversation = window.ConversationController.get(senderId);
if (conversation) {
await handleMessageSend(
window.textsecure.messaging.sendReadReceipts(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
conversation.get('e164')!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
conversation.get('uuid')!,
timestamps,
convoSendOptions
)
);
}
})
);
}
}

View file

@ -0,0 +1,17 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { ConversationAttributesType } from '../model-types.d';
export function isDirectConversation(
conversationAttrs: ConversationAttributesType
): boolean {
return conversationAttrs.type === 'private';
}
export function isMe(conversationAttrs: ConversationAttributesType): boolean {
const { e164, uuid } = conversationAttrs;
const ourNumber = window.textsecure.storage.user.getNumber();
const ourUuid = window.textsecure.storage.user.getUuid();
return Boolean((e164 && e164 === ourNumber) || (uuid && uuid === ourUuid));
}

2
ts/window.d.ts vendored
View file

@ -587,9 +587,9 @@ export type DCodeIOType = {
};
type MessageControllerType = {
getById: (id: string) => MessageModel | undefined;
findBySender: (sender: string) => MessageModel | null;
findBySentAt: (sentAt: number) => MessageModel | null;
getById: (id: string) => MessageModel | undefined;
register: (id: string, model: MessageModel) => MessageModel;
unregister: (id: string) => void;
};