Move message.getPropsForBubble and friends to selectors

This commit is contained in:
Scott Nonnenberg 2021-06-17 10:15:10 -07:00 committed by GitHub
parent 03a187097f
commit 68f1023946
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
73 changed files with 3394 additions and 2576 deletions

View File

@ -337,19 +337,10 @@
<script type='text/javascript' src='libtextsecure/protobufs.js'></script>
<script type='text/javascript' src='js/notifications.js'></script>
<script type='text/javascript' src='js/delivery_receipts.js'></script>
<script type='text/javascript' src='js/read_receipts.js'></script>
<script type='text/javascript' src='js/read_syncs.js'></script>
<script type='text/javascript' src='js/view_syncs.js'></script>
<script type='text/javascript' src='js/message_requests.js'></script>
<script type='text/javascript' src='js/reactions.js'></script>
<script type='text/javascript' src='js/deletes.js'></script>
<script type='text/javascript' src='js/libphonenumber-util.js'></script>
<script type='text/javascript' src='js/expiring_messages.js'></script>
<script type='text/javascript' src='js/expiring_tap_to_view_messages.js'></script>
<script type='text/javascript' src='js/message_controller.js'></script>
<script type='text/javascript' src='js/views/react_wrapper_view.js'></script>
<script type='text/javascript' src='js/views/list_view.js'></script>
<script type='text/javascript' src='js/views/contact_list_view.js'></script>

View File

@ -1,121 +0,0 @@
// Copyright 2016-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/* global
Backbone,
Whisper,
ConversationController,
MessageController,
_
*/
/* eslint-disable more/no-then */
// eslint-disable-next-line func-names
(function () {
window.Whisper = window.Whisper || {};
Whisper.DeliveryReceipts = new (Backbone.Collection.extend({
forMessage(conversation, message) {
let recipients;
if (conversation.isPrivate()) {
recipients = [conversation.id];
} else {
recipients = conversation.getMemberIds();
}
const receipts = this.filter(
receipt =>
receipt.get('timestamp') === message.get('sent_at') &&
recipients.indexOf(receipt.get('deliveredTo')) > -1
);
this.remove(receipts);
return receipts;
},
async getTargetMessage(sourceId, messages) {
if (messages.length === 0) {
return null;
}
const message = messages.find(
item => !item.isIncoming() && sourceId === item.get('conversationId')
);
if (message) {
return MessageController.register(message.id, message);
}
const groups = await window.Signal.Data.getAllGroupsInvolvingId(
sourceId,
{
ConversationCollection: Whisper.ConversationCollection,
}
);
const ids = groups.pluck('id');
ids.push(sourceId);
const target = messages.find(
item =>
!item.isIncoming() && _.contains(ids, item.get('conversationId'))
);
if (!target) {
return null;
}
return MessageController.register(target.id, target);
},
async onReceipt(receipt) {
try {
const messages = await window.Signal.Data.getMessagesBySentAt(
receipt.get('timestamp'),
{
MessageCollection: Whisper.MessageCollection,
}
);
const message = await this.getTargetMessage(
receipt.get('deliveredTo'),
messages
);
if (!message) {
window.log.info(
'No message for delivery receipt',
receipt.get('deliveredTo'),
receipt.get('timestamp')
);
return;
}
const deliveries = message.get('delivered') || 0;
const deliveredTo = message.get('delivered_to') || [];
const expirationStartTimestamp = message.get(
'expirationStartTimestamp'
);
message.set({
delivered_to: _.union(deliveredTo, [receipt.get('deliveredTo')]),
delivered: deliveries + 1,
expirationStartTimestamp: expirationStartTimestamp || Date.now(),
sent: true,
});
window.Signal.Util.queueUpdateMessage(message.attributes);
// notify frontend listeners
const conversation = ConversationController.get(
message.get('conversationId')
);
const updateLeftPane = conversation
? conversation.debouncedUpdateLastMessage
: undefined;
if (updateLeftPane) {
updateLeftPane();
}
this.remove(receipt);
} catch (error) {
window.log.error(
'DeliveryReceipts.onReceipt error:',
error && error.stack ? error.stack : error
);
}
},
}))();
})();

View File

@ -1,118 +0,0 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/* global
Backbone,
Whisper,
ConversationController,
*/
/* eslint-disable more/no-then */
// eslint-disable-next-line func-names
(function () {
window.Whisper = window.Whisper || {};
Whisper.MessageRequests = new (Backbone.Collection.extend({
forConversation(conversation) {
if (conversation.get('e164')) {
const syncByE164 = this.findWhere({
threadE164: conversation.get('e164'),
});
if (syncByE164) {
window.log.info(
`Found early message request response for E164 ${conversation.idForLogging()}`
);
this.remove(syncByE164);
return syncByE164;
}
}
if (conversation.get('uuid')) {
const syncByUuid = this.findWhere({
threadUuid: conversation.get('uuid'),
});
if (syncByUuid) {
window.log.info(
`Found early message request response for UUID ${conversation.idForLogging()}`
);
this.remove(syncByUuid);
return syncByUuid;
}
}
// V1 Group
if (conversation.get('groupId')) {
const syncByGroupId = this.findWhere({
groupId: conversation.get('groupId'),
});
if (syncByGroupId) {
window.log.info(
`Found early message request response for group v1 ID ${conversation.idForLogging()}`
);
this.remove(syncByGroupId);
return syncByGroupId;
}
}
// V2 group
if (conversation.get('groupId')) {
const syncByGroupId = this.findWhere({
groupV2Id: conversation.get('groupId'),
});
if (syncByGroupId) {
window.log.info(
`Found early message request response for group v2 ID ${conversation.idForLogging()}`
);
this.remove(syncByGroupId);
return syncByGroupId;
}
}
return null;
},
async onResponse(sync) {
try {
const threadE164 = sync.get('threadE164');
const threadUuid = sync.get('threadUuid');
const groupId = sync.get('groupId');
const groupV2Id = sync.get('groupV2Id');
let conversation;
// We multiplex between GV1/GV2 groups here, but we don't kick off migrations
if (groupV2Id) {
conversation = ConversationController.get(groupV2Id);
}
if (!conversation && groupId) {
conversation = ConversationController.get(groupId);
}
if (!conversation && (threadE164 || threadUuid)) {
conversation = ConversationController.get(
ConversationController.ensureContactIds({
e164: threadE164,
uuid: threadUuid,
})
);
}
if (!conversation) {
window.log(
`Received message request response for unknown conversation: groupv2(${groupV2Id}) group(${groupId}) ${threadUuid} ${threadE164}`
);
return;
}
conversation.applyMessageRequestResponse(sync.get('type'), {
fromSync: true,
});
this.remove(sync);
} catch (error) {
window.log.error(
'MessageRequests.onResponse error:',
error && error.stack ? error.stack : error
);
}
},
}))();
})();

View File

@ -22,7 +22,6 @@ const Settings = require('./settings');
const RemoteConfig = require('../../ts/RemoteConfig');
const Util = require('../../ts/util');
const LinkPreviews = require('./link_previews');
const AttachmentDownloads = require('./attachment_downloads');
// Components
const {
@ -443,7 +442,6 @@ exports.setup = (options = {}) => {
};
return {
AttachmentDownloads,
Backbone,
Components,
Crypto,

View File

@ -1,122 +0,0 @@
// Copyright 2016-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/* global
Whisper,
Backbone,
_,
ConversationController,
MessageController,
*/
/* eslint-disable more/no-then */
// eslint-disable-next-line func-names
(function () {
window.Whisper = window.Whisper || {};
Whisper.ReadReceipts = new (Backbone.Collection.extend({
forMessage(conversation, message) {
if (!message.isOutgoing()) {
return [];
}
let ids = [];
if (conversation.isPrivate()) {
ids = [conversation.id];
} else {
ids = conversation.getMemberIds();
}
const receipts = this.filter(
receipt =>
receipt.get('timestamp') === message.get('sent_at') &&
_.contains(ids, receipt.get('reader'))
);
if (receipts.length) {
window.log.info('Found early read receipts for message');
this.remove(receipts);
}
return receipts;
},
async getTargetMessage(reader, messages) {
if (messages.length === 0) {
return null;
}
const message = messages.find(
item => item.isOutgoing() && reader === item.get('conversationId')
);
if (message) {
return MessageController.register(message.id, message);
}
const groups = await window.Signal.Data.getAllGroupsInvolvingId(reader, {
ConversationCollection: Whisper.ConversationCollection,
});
const ids = groups.pluck('id');
ids.push(reader);
const target = messages.find(
item => item.isOutgoing() && _.contains(ids, item.get('conversationId'))
);
if (!target) {
return null;
}
return MessageController.register(target.id, target);
},
async onReceipt(receipt) {
try {
const messages = await window.Signal.Data.getMessagesBySentAt(
receipt.get('timestamp'),
{
MessageCollection: Whisper.MessageCollection,
}
);
const message = await this.getTargetMessage(
receipt.get('reader'),
messages
);
if (!message) {
window.log.info(
'No message for read receipt',
receipt.get('reader'),
receipt.get('timestamp')
);
return;
}
const readBy = message.get('read_by') || [];
const expirationStartTimestamp = message.get(
'expirationStartTimestamp'
);
readBy.push(receipt.get('reader'));
message.set({
read_by: readBy,
expirationStartTimestamp: expirationStartTimestamp || Date.now(),
sent: true,
});
window.Signal.Util.queueUpdateMessage(message.attributes);
// notify frontend listeners
const conversation = ConversationController.get(
message.get('conversationId')
);
const updateLeftPane = conversation
? conversation.debouncedUpdateLastMessage
: undefined;
if (updateLeftPane) {
updateLeftPane();
}
this.remove(receipt);
} catch (error) {
window.log.error(
'ReadReceipts.onReceipt error:',
error && error.stack ? error.stack : error
);
}
},
}))();
})();

View File

@ -1,140 +0,0 @@
// Copyright 2017-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/* global
Backbone,
Whisper,
MessageController
*/
/* eslint-disable more/no-then */
// 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')
);
return;
}
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) {
const senderId = window.ConversationController.ensureContactIds({
e164: message.get('source'),
uuid: message.get('sourceUuid'),
});
const receipt = this.findWhere({
senderId,
timestamp: message.get('sent_at'),
});
if (receipt) {
window.log.info('Found early read sync for message');
this.remove(receipt);
return receipt;
}
return null;
},
async onReceipt(receipt) {
try {
const messages = await window.Signal.Data.getMessagesBySentAt(
receipt.get('timestamp'),
{
MessageCollection: Whisper.MessageCollection,
}
);
const found = messages.find(item => {
const senderId = window.ConversationController.ensureContactIds({
e164: item.get('source'),
uuid: item.get('sourceUuid'),
});
return item.isIncoming() && senderId === receipt.get('senderId');
});
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');
// If message is unread, we mark it read. Otherwise, we update the expiration
// timer to the time specified by the read sync if it's earlier than
// the previous read time.
if (message.isUnread()) {
// 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
// marked read. We want those messages to have the same expire timer
// start time as this one, so we pass the readAt value through.
const conversation = message.getConversation();
if (conversation) {
conversation.onReadMessage(message, readAt);
}
};
if (window.startupProcessingQueue) {
const conversation = message.getConversation();
if (conversation) {
window.startupProcessingQueue.add(
conversation.get('id'),
updateConversation
);
}
} else {
updateConversation();
}
} else {
const now = Date.now();
const existingTimestamp = message.get('expirationStartTimestamp');
const expirationStartTimestamp = Math.min(
now,
Math.min(existingTimestamp || now, readAt || now)
);
message.set({ expirationStartTimestamp });
const conversation = message.getConversation();
if (conversation) {
conversation.trigger('expiration-change', message);
}
}
window.Signal.Util.queueUpdateMessage(message.attributes);
this.remove(receipt);
} catch (error) {
window.log.error(
'ReadSyncs.onReceipt error:',
error && error.stack ? error.stack : error
);
}
},
}))();
})();

View File

@ -1,89 +0,0 @@
// Copyright 2019-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/* global
Backbone,
Whisper,
MessageController
*/
/* eslint-disable more/no-then */
// eslint-disable-next-line func-names
(function () {
window.Whisper = window.Whisper || {};
Whisper.ViewSyncs = new (Backbone.Collection.extend({
forMessage(message) {
const syncBySourceUuid = this.findWhere({
sourceUuid: message.get('sourceUuid'),
timestamp: message.get('sent_at'),
});
if (syncBySourceUuid) {
window.log.info('Found early view sync for message');
this.remove(syncBySourceUuid);
return syncBySourceUuid;
}
const syncBySource = this.findWhere({
source: message.get('source'),
timestamp: message.get('sent_at'),
});
if (syncBySource) {
window.log.info('Found early view sync for message');
this.remove(syncBySource);
return syncBySource;
}
return null;
},
async onSync(sync) {
try {
const messages = await window.Signal.Data.getMessagesBySentAt(
sync.get('timestamp'),
{
MessageCollection: Whisper.MessageCollection,
}
);
const found = messages.find(item => {
const itemSourceUuid = item.get('sourceUuid');
const syncSourceUuid = sync.get('sourceUuid');
const itemSource = item.get('source');
const syncSource = sync.get('source');
return (
(itemSourceUuid &&
syncSourceUuid &&
itemSourceUuid === syncSourceUuid) ||
(itemSource && syncSource && itemSource === syncSource)
);
});
const syncSource = sync.get('source');
const syncSourceUuid = sync.get('sourceUuid');
const syncTimestamp = sync.get('timestamp');
const wasMessageFound = Boolean(found);
window.log.info('Receive view sync:', {
syncSource,
syncSourceUuid,
syncTimestamp,
wasMessageFound,
});
if (!found) {
return;
}
const message = MessageController.register(found.id, found);
await message.markViewed({ fromSync: true });
this.remove(sync);
} catch (error) {
window.log.error(
'ViewSyncs.onSync error:',
error && error.stack ? error.stack : error
);
}
},
}))();
})();

View File

@ -489,7 +489,6 @@ try {
window.dataURLToBlobSync = require('blueimp-canvas-to-blob');
window.imageToBlurHash = imageToBlurHash;
window.emojiData = require('emoji-datasource');
window.filesize = require('filesize');
window.libphonenumber = require('google-libphonenumber').PhoneNumberUtil.getInstance();
window.libphonenumber.PhoneNumberFormat = require('google-libphonenumber').PhoneNumberFormat;
window.loadImage = require('blueimp-load-image');

View File

@ -347,7 +347,6 @@
<script type="text/javascript" src="../libtextsecure/protobufs.js"></script>
<script type="text/javascript" src="../js/libphonenumber-util.js"></script>
<script type="text/javascript" src="../js/message_controller.js" data-cover></script>
<script type="text/javascript" src="../js/keychange_listener.js" data-cover></script>
<script type='text/javascript' src='../js/expiring_messages.js' data-cover></script>
<script type='text/javascript' src='../js/expiring_tap_to_view_messages.js' data-cover></script>

View File

@ -715,7 +715,7 @@ export class ConversationController {
async getConversationForTargetMessage(
targetFromId: string,
targetTimestamp: number
): Promise<boolean | ConversationModel | null | undefined> {
): Promise<ConversationModel | null | undefined> {
const messages = await getMessagesBySentAt(targetTimestamp, {
MessageCollection: window.Whisper.MessageCollection,
});

View File

@ -43,7 +43,16 @@ import { isDirectConversation, isGroupV2 } from './util/whatTypeOfConversation';
import { getSendOptions } from './util/getSendOptions';
import { BackOff } from './util/BackOff';
import { AppViewType } from './state/ducks/app';
import { hasErrors, isIncoming } from './state/selectors/message';
import { actionCreators } from './state/actions';
import { Deletes } from './messageModifiers/Deletes';
import { DeliveryReceipts } from './messageModifiers/DeliveryReceipts';
import { MessageRequests } from './messageModifiers/MessageRequests';
import { Reactions } from './messageModifiers/Reactions';
import { ReadReceipts } from './messageModifiers/ReadReceipts';
import { ReadSyncs } from './messageModifiers/ReadSyncs';
import { ViewSyncs } from './messageModifiers/ViewSyncs';
import * as AttachmentDownloads from './messageModifiers/AttachmentDownloads';
const MAX_ATTACHMENT_DOWNLOAD_AGE = 3600 * 72 * 1000;
@ -337,13 +346,15 @@ export async function startApp(): Promise<void> {
PASSWORD
);
accountManager.addEventListener('registration', () => {
const ourDeviceId = window.textsecure.storage.user.getDeviceId();
const ourNumber = window.textsecure.storage.user.getNumber();
const ourUuid = window.textsecure.storage.user.getUuid();
const user = {
regionCode: window.storage.get('regionCode'),
ourConversationId: window.ConversationController.getOurConversationId(),
ourDeviceId,
ourNumber,
ourUuid,
ourConversationId: window.ConversationController.getOurConversationId(),
regionCode: window.storage.get('regionCode'),
};
window.Whisper.events.trigger('userChanged', user);
@ -548,7 +559,7 @@ export async function startApp(): Promise<void> {
shutdown: async () => {
window.log.info('background/shutdown');
// Stop background processing
window.Signal.AttachmentDownloads.stop();
AttachmentDownloads.stop();
if (idleDetector) {
idleDetector.stop();
}
@ -957,6 +968,7 @@ export async function startApp(): Promise<void> {
// Binding these actions to our redux store and exposing them allows us to update
// redux when things change in the backbone world.
window.reduxActions = {
accounts: bindActionCreators(actionCreators.accounts, store.dispatch),
app: bindActionCreators(actionCreators.app, store.dispatch),
audioPlayer: bindActionCreators(
actionCreators.audioPlayer,
@ -1659,7 +1671,7 @@ export async function startApp(): Promise<void> {
const delivered = message.get('delivered');
const sentAt = message.get('sent_at');
if (message.hasErrors()) {
if (hasErrors(message.attributes)) {
return;
}
@ -1887,7 +1899,7 @@ export async function startApp(): Promise<void> {
// Clear timer, since we're only called when the timer is expired
disconnectTimer = null;
window.Signal.AttachmentDownloads.stop();
AttachmentDownloads.stop();
if (messageReceiver) {
await messageReceiver.close();
}
@ -2063,7 +2075,7 @@ export async function startApp(): Promise<void> {
addQueuedEventListener('fetchLatest', onFetchLatestSync);
addQueuedEventListener('keys', onKeysSync);
window.Signal.AttachmentDownloads.start({
AttachmentDownloads.start({
getMessageReceiver: () => messageReceiver,
logger: window.log,
});
@ -2859,7 +2871,10 @@ export async function startApp(): Promise<void> {
const message = initIncomingMessage(data, messageDescriptor);
if (message.isIncoming() && message.get('unidentifiedDeliveryReceived')) {
if (
isIncoming(message.attributes) &&
message.get('unidentifiedDeliveryReceived')
) {
const sender = message.getContact();
if (!sender) {
@ -2890,7 +2905,7 @@ export async function startApp(): Promise<void> {
'Queuing incoming reaction for',
reaction.targetTimestamp
);
const reactionModel = window.Whisper.Reactions.add({
const reactionModel = Reactions.getSingleton().add({
emoji: reaction.emoji,
remove: reaction.remove,
targetAuthorUuid: reaction.targetAuthorUuid,
@ -2902,7 +2917,7 @@ export async function startApp(): Promise<void> {
}),
});
// Note: We do not wait for completion here
window.Whisper.Reactions.onReaction(reactionModel);
Reactions.getSingleton().onReaction(reactionModel);
confirm();
return Promise.resolve();
}
@ -2910,7 +2925,7 @@ export async function startApp(): Promise<void> {
if (data.message.delete) {
const { delete: del } = data.message;
window.log.info('Queuing incoming DOE for', del.targetSentTimestamp);
const deleteModel = window.Whisper.Deletes.add({
const deleteModel = Deletes.getSingleton().add({
targetSentTimestamp: del.targetSentTimestamp,
serverTimestamp: data.serverTimestamp,
fromId: window.ConversationController.ensureContactIds({
@ -2919,7 +2934,7 @@ export async function startApp(): Promise<void> {
}),
});
// Note: We do not wait for completion here
window.Whisper.Deletes.onDelete(deleteModel);
Deletes.getSingleton().onDelete(deleteModel);
confirm();
return Promise.resolve();
}
@ -3184,7 +3199,7 @@ export async function startApp(): Promise<void> {
}
window.log.info('Queuing sent reaction for', reaction.targetTimestamp);
const reactionModel = window.Whisper.Reactions.add({
const reactionModel = Reactions.getSingleton().add({
emoji: reaction.emoji,
remove: reaction.remove,
targetAuthorUuid: reaction.targetAuthorUuid,
@ -3194,7 +3209,7 @@ export async function startApp(): Promise<void> {
fromSync: true,
});
// Note: We do not wait for completion here
window.Whisper.Reactions.onReaction(reactionModel);
Reactions.getSingleton().onReaction(reactionModel);
event.confirm();
return Promise.resolve();
@ -3203,13 +3218,13 @@ export async function startApp(): Promise<void> {
if (data.message.delete) {
const { delete: del } = data.message;
window.log.info('Queuing sent DOE for', del.targetSentTimestamp);
const deleteModel = window.Whisper.Deletes.add({
const deleteModel = Deletes.getSingleton().add({
targetSentTimestamp: del.targetSentTimestamp,
serverTimestamp: del.serverTimestamp,
fromId: window.ConversationController.getOurConversationId(),
});
// Note: We do not wait for completion here
window.Whisper.Deletes.onDelete(deleteModel);
Deletes.getSingleton().onDelete(deleteModel);
confirm();
return Promise.resolve();
}
@ -3299,11 +3314,13 @@ export async function startApp(): Promise<void> {
window.Signal.Util.Registration.remove();
const NUMBER_ID_KEY = 'number_id';
const UUID_ID_KEY = 'uuid_id';
const VERSION_KEY = 'version';
const LAST_PROCESSED_INDEX_KEY = 'attachmentMigration_lastProcessedIndex';
const IS_MIGRATION_COMPLETE_KEY = 'attachmentMigration_isComplete';
const previousNumberId = window.textsecure.storage.get(NUMBER_ID_KEY);
const previousUuidId = window.textsecure.storage.get(UUID_ID_KEY);
const lastProcessedIndex = window.textsecure.storage.get(
LAST_PROCESSED_INDEX_KEY
);
@ -3327,6 +3344,9 @@ export async function startApp(): Promise<void> {
if (previousNumberId !== undefined) {
await window.textsecure.storage.put(NUMBER_ID_KEY, previousNumberId);
}
if (previousUuidId !== undefined) {
await window.textsecure.storage.put(UUID_ID_KEY, previousUuidId);
}
// These two are important to ensure we don't rip through every message
// in the database attempting to upgrade it after starting up again.
@ -3782,13 +3802,13 @@ export async function startApp(): Promise<void> {
const { source, sourceUuid, timestamp } = ev;
window.log.info(`view sync ${source} ${timestamp}`);
const sync = window.Whisper.ViewSyncs.add({
const sync = ViewSyncs.getSingleton().add({
source,
sourceUuid,
timestamp,
});
window.Whisper.ViewSyncs.onSync(sync);
ViewSyncs.getSingleton().onSync(sync);
}
async function onFetchLatestSync(ev: WhatIsThis) {
@ -3855,7 +3875,7 @@ export async function startApp(): Promise<void> {
messageRequestResponseType,
});
const sync = window.Whisper.MessageRequests.add({
const sync = MessageRequests.getSingleton().add({
threadE164,
threadUuid,
groupId,
@ -3863,7 +3883,7 @@ export async function startApp(): Promise<void> {
type: messageRequestResponseType,
});
window.Whisper.MessageRequests.onResponse(sync);
MessageRequests.getSingleton().onResponse(sync);
}
function onReadReceipt(ev: WhatIsThis) {
@ -3890,14 +3910,14 @@ export async function startApp(): Promise<void> {
return;
}
const receipt = window.Whisper.ReadReceipts.add({
const receipt = ReadReceipts.getSingleton().add({
reader,
timestamp,
read_at: readAt,
readAt,
});
// Note: We do not wait for completion here
window.Whisper.ReadReceipts.onReceipt(receipt);
ReadReceipts.getSingleton().onReceipt(receipt);
}
function onReadSync(ev: WhatIsThis) {
@ -3918,19 +3938,19 @@ export async function startApp(): Promise<void> {
timestamp
);
const receipt = window.Whisper.ReadSyncs.add({
const receipt = ReadSyncs.getSingleton().add({
senderId,
sender,
senderUuid,
timestamp,
read_at: readAt,
readAt,
});
receipt.on('remove', ev.confirm);
// Note: Here we wait, because we want read states to be in the database
// before we move on.
return window.Whisper.ReadSyncs.onReceipt(receipt);
return ReadSyncs.getSingleton().onReceipt(receipt);
}
async function onVerified(ev: WhatIsThis) {
@ -4037,13 +4057,13 @@ export async function startApp(): Promise<void> {
return;
}
const receipt = window.Whisper.DeliveryReceipts.add({
const receipt = DeliveryReceipts.getSingleton().add({
timestamp,
deliveredTo,
});
// Note: We don't wait for completion here
window.Whisper.DeliveryReceipts.onReceipt(receipt);
DeliveryReceipts.getSingleton().onReceipt(receipt);
}
}

View File

@ -109,6 +109,7 @@ story.add('media attachments', () => {
width: 112,
url: '/fixtures/kitten-4-112-112.jpg',
contentType: IMAGE_JPEG,
path: 'originalPath',
},
}),
],

View File

@ -60,6 +60,7 @@ story.add('Multiple Visual Attachments', () => {
width: 112,
url: '/fixtures/kitten-4-112-112.jpg',
contentType: IMAGE_JPEG,
path: 'originalpath',
},
},
{
@ -100,6 +101,7 @@ story.add('Multiple with Non-Visual Types', () => {
width: 112,
url: '/fixtures/kitten-4-112-112.jpg',
contentType: IMAGE_JPEG,
path: 'originalpath',
},
},
{

View File

@ -77,7 +77,7 @@ export const AttachmentList = ({
<Image
key={key}
alt={i18n('stagedImageAttachment', [
url || attachment.fileName,
attachment.fileName || url || index.toString(),
])}
i18n={i18n}
attachment={attachment}

View File

@ -11,6 +11,7 @@ import { ContactDetail, Props } from './ContactDetail';
import { AddressType, ContactFormType } from '../../types/Contact';
import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json';
import { IMAGE_GIF } from '../../types/MIME';
const i18n = setupI18n('en', enMessages);
@ -73,6 +74,7 @@ const fullContact = {
avatar: {
avatar: {
path: '/fixtures/giphy-GVNvOUpeYmI7e.gif',
contentType: IMAGE_GIF,
},
isProfile: true,
},
@ -208,6 +210,7 @@ story.add('Loading Avatar', () => {
contact: {
avatar: {
avatar: {
contentType: IMAGE_GIF,
pending: true,
},
isProfile: true,

View File

@ -11,6 +11,7 @@ import { EmbeddedContact, Props } from './EmbeddedContact';
import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json';
import { ContactFormType } from '../../types/Contact';
import { IMAGE_GIF } from '../../types/MIME';
const i18n = setupI18n('en', enMessages);
@ -36,6 +37,7 @@ const fullContact = {
avatar: {
avatar: {
path: '/fixtures/giphy-GVNvOUpeYmI7e.gif',
contentType: IMAGE_GIF,
},
isProfile: true,
},
@ -134,6 +136,7 @@ story.add('Loading Avatar', () => {
avatar: {
avatar: {
pending: true,
contentType: IMAGE_GIF,
},
isProfile: true,
},

View File

@ -23,61 +23,55 @@ export type Props = {
onClick?: () => void;
};
export class EmbeddedContact extends React.Component<Props> {
public render(): JSX.Element {
const {
contact,
i18n,
isIncoming,
onClick,
tabIndex,
withContentAbove,
withContentBelow,
} = this.props;
const module = 'embedded-contact';
const direction = isIncoming ? 'incoming' : 'outgoing';
export const EmbeddedContact: React.FC<Props> = (props: Props) => {
const {
contact,
i18n,
isIncoming,
onClick,
tabIndex,
withContentAbove,
withContentBelow,
} = props;
const module = 'embedded-contact';
const direction = isIncoming ? 'incoming' : 'outgoing';
return (
<button
type="button"
className={classNames(
'module-embedded-contact',
`module-embedded-contact--${direction}`,
withContentAbove
? 'module-embedded-contact--with-content-above'
: null,
withContentBelow
? 'module-embedded-contact--with-content-below'
: null
)}
onKeyDown={(event: React.KeyboardEvent) => {
if (event.key !== 'Enter' && event.key !== 'Space') {
return;
}
return (
<button
type="button"
className={classNames(
'module-embedded-contact',
`module-embedded-contact--${direction}`,
withContentAbove ? 'module-embedded-contact--with-content-above' : null,
withContentBelow ? 'module-embedded-contact--with-content-below' : null
)}
onKeyDown={(event: React.KeyboardEvent) => {
if (event.key !== 'Enter' && event.key !== 'Space') {
return;
}
if (onClick) {
event.stopPropagation();
event.preventDefault();
if (onClick) {
event.stopPropagation();
event.preventDefault();
onClick();
}
}}
onClick={(event: React.MouseEvent) => {
if (onClick) {
event.stopPropagation();
event.preventDefault();
onClick();
}
}}
onClick={(event: React.MouseEvent) => {
if (onClick) {
event.stopPropagation();
event.preventDefault();
onClick();
}
}}
tabIndex={tabIndex}
>
{renderAvatar({ contact, i18n, size: 52, direction })}
<div className="module-embedded-contact__text-container">
{renderName({ contact, isIncoming, module })}
{renderContactShorthand({ contact, isIncoming, module })}
</div>
</button>
);
}
}
onClick();
}
}}
tabIndex={tabIndex}
>
{renderAvatar({ contact, i18n, size: 52, direction })}
<div className="module-embedded-contact__text-container">
{renderName({ contact, isIncoming, module })}
{renderContactShorthand({ contact, isIncoming, module })}
</div>
</button>
);
};

View File

@ -260,6 +260,7 @@ story.add('Mixed Content Types', () => {
width: 112,
url: '/fixtures/kitten-4-112-112.jpg',
contentType: IMAGE_JPEG,
path: 'originalpath',
},
url: '/fixtures/pixabay-Soap-Bubble-7141.mp4',
width: 112,

View File

@ -76,6 +76,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
canReply: true,
canDownload: true,
canDeleteForEveryone: overrideProps.canDeleteForEveryone || false,
checkForAccount: action('checkForAccount'),
clearSelectedMessage: action('clearSelectedMessage'),
collapseMetadata: overrideProps.collapseMetadata,
conversationColor:
@ -90,6 +91,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
disableScroll: overrideProps.disableScroll,
direction: overrideProps.direction || 'incoming',
displayTapToViewMessage: action('displayTapToViewMessage'),
doubleCheckMissingQuoteReference: action('doubleCheckMissingQuoteReference'),
downloadAttachment: action('downloadAttachment'),
expirationLength:
number('expirationLength', overrideProps.expirationLength || 0) ||
@ -114,6 +116,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
isTapToViewExpired: overrideProps.isTapToViewExpired,
kickOffAttachmentDownload: action('kickOffAttachmentDownload'),
markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'),
onHeightChange: action('onHeightChange'),
openConversation: action('openConversation'),
openLink: action('openLink'),
previews: overrideProps.previews || [],

View File

@ -8,7 +8,11 @@ import { drop, groupBy, orderBy, take } from 'lodash';
import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu';
import { Manager, Popper, Reference } from 'react-popper';
import { ConversationType } from '../../state/ducks/conversations';
import {
ConversationType,
ConversationTypeType,
InteractionModeType,
} from '../../state/ducks/conversations';
import { Avatar } from '../Avatar';
import { Spinner } from '../Spinner';
import { MessageBody } from './MessageBody';
@ -80,15 +84,9 @@ export const MessageStatuses = [
] as const;
export type MessageStatusType = typeof MessageStatuses[number];
export const InteractionModes = ['mouse', 'keyboard'] as const;
export type InteractionModeType = typeof InteractionModes[number];
export const Directions = ['incoming', 'outgoing'] as const;
export type DirectionType = typeof Directions[number];
export const ConversationTypes = ['direct', 'group'] as const;
export type ConversationTypesType = typeof ConversationTypes[number];
export type AudioAttachmentProps = {
id: string;
i18n: LocalizerType;
@ -133,7 +131,7 @@ export type PropsData = {
| 'unblurredAvatarPath'
>;
reducedMotion?: boolean;
conversationType: ConversationTypesType;
conversationType: ConversationTypeType;
attachments?: Array<AttachmentType>;
quote?: {
conversationColor: ConversationColorType;
@ -185,6 +183,9 @@ export type PropsHousekeeping = {
export type PropsActions = {
clearSelectedMessage: () => unknown;
doubleCheckMissingQuoteReference: (messageId: string) => unknown;
onHeightChange: () => unknown;
checkForAccount: (identifier: string) => unknown;
reactToMessage: (
id: string,
@ -405,18 +406,21 @@ export class Message extends React.Component<Props, State> {
}
const { expirationLength } = this.props;
if (!expirationLength) {
return;
if (expirationLength) {
const increment = getIncrement(expirationLength);
const checkFrequency = Math.max(EXPIRATION_CHECK_MINIMUM, increment);
this.checkExpired();
this.expirationCheckInterval = setInterval(() => {
this.checkExpired();
}, checkFrequency);
}
const increment = getIncrement(expirationLength);
const checkFrequency = Math.max(EXPIRATION_CHECK_MINIMUM, increment);
this.checkExpired();
this.expirationCheckInterval = setInterval(() => {
this.checkExpired();
}, checkFrequency);
const { contact, checkForAccount } = this.props;
if (contact && contact.firstNumber && !contact.isNumberOnSignal) {
checkForAccount(contact.firstNumber);
}
}
public componentWillUnmount(): void {
@ -448,12 +452,31 @@ export class Message extends React.Component<Props, State> {
}
this.checkExpired();
this.checkForHeightChange(prevProps);
if (canDeleteForEveryone !== prevProps.canDeleteForEveryone) {
this.startDeleteForEveryoneTimer();
}
}
public checkForHeightChange(prevProps: Props): void {
const { contact, onHeightChange } = this.props;
const willRenderSendMessageButton = Boolean(
contact && contact.firstNumber && contact.isNumberOnSignal
);
const { contact: previousContact } = prevProps;
const previouslyRenderedSendMessageButton = Boolean(
previousContact &&
previousContact.firstNumber &&
previousContact.isNumberOnSignal
);
if (willRenderSendMessageButton !== previouslyRenderedSendMessageButton) {
onHeightChange();
}
}
public startSelectedTimer(): void {
const { clearSelectedMessage, interactionMode } = this.props;
const { isSelected } = this.state;
@ -1064,7 +1087,9 @@ export class Message extends React.Component<Props, State> {
customColor,
direction,
disableScroll,
doubleCheckMissingQuoteReference,
i18n,
id,
quote,
scrollToQuotedMessage,
} = this.props;
@ -1104,6 +1129,9 @@ export class Message extends React.Component<Props, State> {
referencedMessageNotFound={referencedMessageNotFound}
isFromMe={quote.isFromMe}
withContentAbove={withContentAbove}
doubleCheckMissingQuoteReference={() =>
doubleCheckMissingQuoteReference(id)
}
/>
);
}
@ -1127,7 +1155,9 @@ export class Message extends React.Component<Props, State> {
conversationType === 'group' && direction === 'incoming';
const withContentBelow = withCaption || !collapseMetadata;
const otherContent = (contact && contact.signalAccount) || withCaption;
const otherContent =
(contact && contact.firstNumber && contact.isNumberOnSignal) ||
withCaption;
const tabIndex = otherContent ? 0 : -1;
return (
@ -1136,7 +1166,7 @@ export class Message extends React.Component<Props, State> {
isIncoming={direction === 'incoming'}
i18n={i18n}
onClick={() => {
showContactDetail({ contact, signalAccount: contact.signalAccount });
showContactDetail({ contact, signalAccount: contact.firstNumber });
}}
withContentAbove={withContentAbove}
withContentBelow={withContentBelow}
@ -1147,18 +1177,18 @@ export class Message extends React.Component<Props, State> {
public renderSendMessageButton(): JSX.Element | null {
const { contact, openConversation, i18n } = this.props;
if (!contact || !contact.signalAccount) {
if (!contact) {
return null;
}
const { firstNumber, isNumberOnSignal } = contact;
if (!firstNumber || !isNumberOnSignal) {
return null;
}
return (
<button
type="button"
onClick={() => {
if (contact.signalAccount) {
openConversation(contact.signalAccount);
}
}}
onClick={() => openConversation(firstNumber)}
className="module-message__send-message-button"
>
{i18n('sendMessageToContact')}
@ -2181,15 +2211,15 @@ export class Message extends React.Component<Props, State> {
this.audioButtonRef.current.click();
}
if (contact && contact.signalAccount) {
openConversation(contact.signalAccount);
if (contact && contact.firstNumber && contact.isNumberOnSignal) {
openConversation(contact.firstNumber);
event.preventDefault();
event.stopPropagation();
}
if (contact) {
showContactDetail({ contact, signalAccount: contact.signalAccount });
showContactDetail({ contact, signalAccount: contact.firstNumber });
event.preventDefault();
event.stopPropagation();

View File

@ -61,26 +61,32 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
sendAnyway: action('onSendAnyway'),
showSafetyNumber: action('onShowSafetyNumber'),
clearSelectedMessage: () => null,
checkForAccount: action('checkForAccount'),
clearSelectedMessage: action('clearSelectedMessage'),
deleteMessage: action('deleteMessage'),
deleteMessageForEveryone: action('deleteMessageForEveryone'),
displayTapToViewMessage: () => null,
downloadAttachment: () => null,
displayTapToViewMessage: action('displayTapToViewMessage'),
downloadAttachment: action('downloadAttachment'),
doubleCheckMissingQuoteReference: action('doubleCheckMissingQuoteReference'),
kickOffAttachmentDownload: action('kickOffAttachmentDownload'),
markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'),
openConversation: () => null,
openLink: () => null,
reactToMessage: () => null,
openConversation: action('openConversation'),
openLink: action('openLink'),
reactToMessage: action('reactToMessage'),
renderAudioAttachment: () => <div>*AudioAttachment*</div>,
renderEmojiPicker: () => <div />,
replyToMessage: () => null,
retrySend: () => null,
showContactDetail: () => null,
showContactModal: () => null,
showExpiredIncomingTapToViewToast: () => null,
showExpiredOutgoingTapToViewToast: () => null,
showForwardMessageModal: () => null,
showVisualAttachment: () => null,
replyToMessage: action('replyToMessage'),
retrySend: action('retrySend'),
showContactDetail: action('showContactDetail'),
showContactModal: action('showContactModal'),
showExpiredIncomingTapToViewToast: action(
'showExpiredIncomingTapToViewToast'
),
showExpiredOutgoingTapToViewToast: action(
'showExpiredOutgoingTapToViewToast'
),
showForwardMessageModal: action('showForwardMessageModal'),
showVisualAttachment: action('showVisualAttachment'),
});
story.add('Delivered Incoming', () => {

View File

@ -4,6 +4,7 @@
import React from 'react';
import classNames from 'classnames';
import moment from 'moment';
import { noop } from 'lodash';
import { GlobalAudioProvider } from '../GlobalAudioContext';
import { Avatar } from '../Avatar';
@ -55,11 +56,13 @@ export type Props = {
i18n: LocalizerType;
} & Pick<
MessagePropsType,
| 'checkForAccount'
| 'clearSelectedMessage'
| 'deleteMessage'
| 'deleteMessageForEveryone'
| 'displayTapToViewMessage'
| 'downloadAttachment'
| 'doubleCheckMissingQuoteReference'
| 'interactionMode'
| 'kickOffAttachmentDownload'
| 'markAttachmentAsCorrupted'
@ -233,12 +236,14 @@ export class MessageDetail extends React.Component<Props> {
receivedAt,
sentAt,
checkForAccount,
clearSelectedMessage,
contactNameColor,
deleteMessage,
deleteMessageForEveryone,
displayTapToViewMessage,
downloadAttachment,
doubleCheckMissingQuoteReference,
i18n,
interactionMode,
kickOffAttachmentDownload,
@ -265,6 +270,7 @@ export class MessageDetail extends React.Component<Props> {
<GlobalAudioProvider conversationId={message.conversationId}>
<Message
{...message}
checkForAccount={checkForAccount}
clearSelectedMessage={clearSelectedMessage}
contactNameColor={contactNameColor}
deleteMessage={deleteMessage}
@ -273,10 +279,14 @@ export class MessageDetail extends React.Component<Props> {
disableScroll
displayTapToViewMessage={displayTapToViewMessage}
downloadAttachment={downloadAttachment}
doubleCheckMissingQuoteReference={
doubleCheckMissingQuoteReference
}
i18n={i18n}
interactionMode={interactionMode}
kickOffAttachmentDownload={kickOffAttachmentDownload}
markAttachmentAsCorrupted={markAttachmentAsCorrupted}
onHeightChange={noop}
openConversation={openConversation}
openLink={openLink}
reactToMessage={reactToMessage}

View File

@ -35,39 +35,48 @@ const defaultMessageProps: MessagesProps = {
canReply: true,
canDeleteForEveryone: true,
canDownload: true,
clearSelectedMessage: () => null,
checkForAccount: action('checkForAccount'),
clearSelectedMessage: action('default--clearSelectedMessage'),
conversationColor: 'crimson',
conversationId: 'conversationId',
conversationType: 'direct', // override
deleteMessage: () => null,
deleteMessageForEveryone: () => null,
deleteMessage: action('default--deleteMessage'),
deleteMessageForEveryone: action('default--deleteMessageForEveryone'),
direction: 'incoming',
displayTapToViewMessage: () => null,
downloadAttachment: () => null,
displayTapToViewMessage: action('default--displayTapToViewMessage'),
downloadAttachment: action('default--downloadAttachment'),
doubleCheckMissingQuoteReference: action(
'default--doubleCheckMissingQuoteReference'
),
i18n,
id: 'messageId',
interactionMode: 'keyboard',
isBlocked: false,
isMessageRequestAccepted: true,
kickOffAttachmentDownload: () => null,
markAttachmentAsCorrupted: () => null,
openConversation: () => null,
openLink: () => null,
kickOffAttachmentDownload: action('default--kickOffAttachmentDownload'),
markAttachmentAsCorrupted: action('default--markAttachmentAsCorrupted'),
onHeightChange: action('onHeightChange'),
openConversation: action('default--openConversation'),
openLink: action('default--openLink'),
previews: [],
reactToMessage: () => null,
reactToMessage: action('default--reactToMessage'),
renderEmojiPicker: () => <div />,
renderAudioAttachment: () => <div>*AudioAttachment*</div>,
replyToMessage: () => null,
retrySend: () => null,
scrollToQuotedMessage: () => null,
selectMessage: () => null,
showContactDetail: () => null,
showContactModal: () => null,
showExpiredIncomingTapToViewToast: () => null,
showExpiredOutgoingTapToViewToast: () => null,
showForwardMessageModal: () => null,
showMessageDetail: () => null,
showVisualAttachment: () => null,
replyToMessage: action('default--replyToMessage'),
retrySend: action('default--retrySend'),
scrollToQuotedMessage: action('default--scrollToQuotedMessage'),
selectMessage: action('default--selectMessage'),
showContactDetail: action('default--showContactDetail'),
showContactModal: action('default--showContactModal'),
showExpiredIncomingTapToViewToast: action(
'showExpiredIncomingTapToViewToast'
),
showExpiredOutgoingTapToViewToast: action(
'showExpiredOutgoingTapToViewToast'
),
showForwardMessageModal: action('default--showForwardMessageModal'),
showMessageDetail: action('default--showMessageDetail'),
showVisualAttachment: action('default--showVisualAttachment'),
status: 'sent',
text: 'This is really interesting.',
timestamp: Date.now(),
@ -125,6 +134,9 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
),
authorTitle: text('authorTitle', overrideProps.authorTitle || ''),
conversationColor: overrideProps.conversationColor || 'forest',
doubleCheckMissingQuoteReference:
overrideProps.doubleCheckMissingQuoteReference ||
action('doubleCheckMissingQuoteReference'),
i18n,
isFromMe: boolean('isFromMe', overrideProps.isFromMe || false),
isIncoming: boolean('isIncoming', overrideProps.isIncoming || false),

View File

@ -33,6 +33,7 @@ export type Props = {
rawAttachment?: QuotedAttachmentType;
isViewOnce: boolean;
referencedMessageNotFound: boolean;
doubleCheckMissingQuoteReference: () => unknown;
};
type State = {
@ -41,7 +42,7 @@ type State = {
export type QuotedAttachmentType = {
contentType: MIME.MIMEType;
fileName: string;
fileName?: string;
/** Not included in protobuf */
isVoiceMessage: boolean;
thumbnail?: Attachment;
@ -125,6 +126,17 @@ export class Quote extends React.Component<Props, State> {
};
}
componentDidMount(): void {
const {
doubleCheckMissingQuoteReference,
referencedMessageNotFound,
} = this.props;
if (referencedMessageNotFound) {
doubleCheckMissingQuoteReference();
}
}
public handleKeyDown = (
event: React.KeyboardEvent<HTMLButtonElement>
): void => {

View File

@ -298,6 +298,7 @@ const actions = () => ({
acknowledgeGroupMemberNameCollisions: action(
'acknowledgeGroupMemberNameCollisions'
),
checkForAccount: action('checkForAccount'),
clearChangedMessages: action('clearChangedMessages'),
clearInvitedConversationsForNewlyCreatedGroup: action(
'clearInvitedConversationsForNewlyCreatedGroup'
@ -327,7 +328,9 @@ const actions = () => ({
showVisualAttachment: action('showVisualAttachment'),
downloadAttachment: action('downloadAttachment'),
displayTapToViewMessage: action('displayTapToViewMessage'),
doubleCheckMissingQuoteReference: action('doubleCheckMissingQuoteReference'),
onHeightChange: action('onHeightChange'),
openLink: action('openLink'),
scrollToQuotedMessage: action('scrollToQuotedMessage'),
showExpiredIncomingTapToViewToast: action(

View File

@ -104,6 +104,7 @@ type PropsHousekeepingType = {
renderItem: (
id: string,
conversationId: string,
onHeightChange: (messageId: string) => unknown,
actions: Record<string, unknown>
) => JSX.Element;
renderLastSeenIndicator: (id: string) => JSX.Element;
@ -367,6 +368,22 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
this.resize(0);
};
public resizeMessage = (messageId: string): void => {
const { items } = this.props;
if (!items || !items.length) {
return;
}
const index = items.findIndex(item => item === messageId);
if (index < 0) {
return;
}
const row = this.fromItemIndexToRow(index);
this.resize(row);
};
public onScroll = (data: OnScrollParamsType): void => {
// Ignore scroll events generated as react-virtualized recursively scrolls and
// re-measures to get us where we want to go.
@ -711,7 +728,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
style={styleWithWidth}
role="row"
>
{renderItem(messageId, id, this.props)}
{renderItem(messageId, id, this.resizeMessage, this.props)}
</div>
);
}

View File

@ -47,6 +47,7 @@ const getDefaultProps = () => ({
interactionMode: 'keyboard' as const,
selectMessage: action('selectMessage'),
reactToMessage: action('reactToMessage'),
checkForAccount: action('checkForAccount'),
clearSelectedMessage: action('clearSelectedMessage'),
contactSupport: action('contactSupport'),
replyToMessage: action('replyToMessage'),
@ -63,12 +64,14 @@ const getDefaultProps = () => ({
showVisualAttachment: action('showVisualAttachment'),
downloadAttachment: action('downloadAttachment'),
displayTapToViewMessage: action('displayTapToViewMessage'),
doubleCheckMissingQuoteReference: action('doubleCheckMissingQuoteReference'),
showExpiredIncomingTapToViewToast: action(
'showExpiredIncomingTapToViewToast'
),
showExpiredOutgoingTapToViewToast: action(
'showExpiredIncomingTapToViewToast'
),
onHeightChange: action('onHeightChange'),
openLink: action('openLink'),
scrollToQuotedMessage: action('scrollToQuotedMessage'),
downloadNewVersion: action('downloadNewVersion'),

View File

@ -4,9 +4,9 @@
import React from 'react';
import { LocalizerType, ThemeType } from '../../types/Util';
import { InteractionModeType } from '../../state/ducks/conversations';
import {
Message,
InteractionModeType,
Props as AllMessageProps,
PropsActions as MessageActionsType,
PropsData as MessageProps,

View File

@ -1,16 +1,22 @@
// Copyright 2019-2020 Signal Messenger, LLC
// Copyright 2019-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/* global
Whisper,
Signal,
setTimeout,
clearTimeout,
MessageController
*/
import { isFunction, isNumber, omit } from 'lodash';
import { v4 as getGuid } from 'uuid';
import dataInterface from '../sql/Client';
import { downloadAttachment } from '../util/downloadAttachment';
import { stringFromBytes } from '../Crypto';
import MessageReceiver from '../textsecure/MessageReceiver';
import {
AttachmentDownloadJobType,
AttachmentDownloadJobTypeType,
} from '../sql/Interface';
import { MessageModel } from '../models/messages';
import { AttachmentType } from '../types/Attachment';
import { LoggerType } from '../window.d';
const { isFunction, isNumber, omit } = require('lodash');
const getGuid = require('uuid/v4');
const {
getMessageById,
getNextAttachmentDownloadJobs,
@ -19,15 +25,7 @@ const {
saveAttachmentDownloadJob,
saveMessage,
setAttachmentDownloadJobPending,
} = require('../../ts/sql/Client').default;
const { downloadAttachment } = require('../../ts/util/downloadAttachment');
const { stringFromBytes } = require('../../ts/Crypto');
module.exports = {
start,
stop,
addJob,
};
} = dataInterface;
const MAX_ATTACHMENT_JOB_PARALLELISM = 3;
@ -36,19 +34,27 @@ const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const TICK_INTERVAL = MINUTE;
const RETRY_BACKOFF = {
const RETRY_BACKOFF: Record<number, number> = {
1: 30 * SECOND,
2: 30 * MINUTE,
3: 6 * HOUR,
};
let enabled = false;
let timeout;
let getMessageReceiver;
let logger;
const _activeAttachmentDownloadJobs = {};
let timeout: NodeJS.Timeout | null;
let getMessageReceiver: () => MessageReceiver | undefined;
let logger: LoggerType;
const _activeAttachmentDownloadJobs: Record<
string,
Promise<void> | undefined
> = {};
async function start(options = {}) {
type StartOptionsType = {
getMessageReceiver: () => MessageReceiver | undefined;
logger: LoggerType;
};
export async function start(options: StartOptionsType): Promise<void> {
({ getMessageReceiver, logger } = options);
if (!isFunction(getMessageReceiver)) {
throw new Error(
@ -66,7 +72,7 @@ async function start(options = {}) {
_tick();
}
async function stop() {
export async function stop(): Promise<void> {
// If `.start()` wasn't called - the `logger` is `undefined`
if (logger) {
logger.info('attachment_downloads/stop: disabling');
@ -78,7 +84,10 @@ async function stop() {
}
}
async function addJob(attachment, job = {}) {
export async function addJob(
attachment: AttachmentType,
job: { messageId: string; type: AttachmentDownloadJobTypeType; index: number }
): Promise<AttachmentType> {
if (!attachment) {
throw new Error('attachments_download/addJob: attachment is required');
}
@ -96,7 +105,7 @@ async function addJob(attachment, job = {}) {
const id = getGuid();
const timestamp = Date.now();
const toSave = {
const toSave: AttachmentDownloadJobType = {
...job,
id,
attachment,
@ -116,7 +125,7 @@ async function addJob(attachment, job = {}) {
};
}
async function _tick() {
async function _tick(): Promise<void> {
if (timeout) {
clearTimeout(timeout);
timeout = null;
@ -126,7 +135,7 @@ async function _tick() {
timeout = setTimeout(_tick, TICK_INTERVAL);
}
async function _maybeStartJob() {
async function _maybeStartJob(): Promise<void> {
if (!enabled) {
logger.info('attachment_downloads/_maybeStartJob: not enabled, returning');
return;
@ -178,8 +187,13 @@ async function _maybeStartJob() {
}
}
async function _runJob(job) {
const { id, messageId, attachment, type, index, attempts } = job || {};
async function _runJob(job?: AttachmentDownloadJobType): Promise<void> {
if (!job) {
window.log.warn('_runJob: Job was missing!');
return;
}
const { id, messageId, attachment, type, index, attempts } = job;
let message;
try {
@ -192,16 +206,16 @@ async function _runJob(job) {
logger.info(`attachment_downloads/_runJob for job id ${id}`);
const found =
MessageController.getById(messageId) ||
window.MessageController.getById(messageId) ||
(await getMessageById(messageId, {
Message: Whisper.Message,
Message: window.Whisper.Message,
}));
if (!found) {
logger.error('_runJob: Source message not found, deleting job');
await _finishJob(null, id);
return;
}
message = MessageController.register(found.id, found);
message = window.MessageController.register(found.id, found);
const pending = true;
await setAttachmentDownloadJobPending(id, pending);
@ -231,7 +245,7 @@ async function _runJob(job) {
return;
}
const upgradedAttachment = await Signal.Migrations.processNewAttachment(
const upgradedAttachment = await window.Signal.Migrations.processNewAttachment(
downloaded
);
@ -239,11 +253,12 @@ async function _runJob(job) {
await _finishJob(message, id);
} catch (error) {
const logId = message ? message.idForLogging() : id || '<no id>';
const currentAttempt = (attempts || 0) + 1;
if (currentAttempt >= 3) {
logger.error(
`_runJob: ${currentAttempt} failed attempts, marking attachment ${id} from message ${message.idForLogging()} as permament error:`,
`_runJob: ${currentAttempt} failed attempts, marking attachment ${id} from message ${logId} as permament error:`,
error && error.stack ? error.stack : error
);
@ -258,7 +273,7 @@ async function _runJob(job) {
}
logger.error(
`_runJob: Failed to download attachment type ${type} for message ${message.idForLogging()}, attempt ${currentAttempt}:`,
`_runJob: Failed to download attachment type ${type} for message ${logId}, attempt ${currentAttempt}:`,
error && error.stack ? error.stack : error
);
@ -266,7 +281,8 @@ async function _runJob(job) {
...job,
pending: 0,
attempts: currentAttempt,
timestamp: Date.now() + RETRY_BACKOFF[currentAttempt],
timestamp:
Date.now() + (RETRY_BACKOFF[currentAttempt] || RETRY_BACKOFF[3]),
};
await saveAttachmentDownloadJob(failedJob);
@ -275,11 +291,14 @@ async function _runJob(job) {
}
}
async function _finishJob(message, id) {
async function _finishJob(
message: MessageModel | null | undefined,
id: string
): Promise<void> {
if (message) {
logger.info(`attachment_downloads/_finishJob for job id: ${id}`);
await saveMessage(message.attributes, {
Message: Whisper.Message,
Message: window.Whisper.Message,
});
}
@ -288,18 +307,22 @@ async function _finishJob(message, id) {
_maybeStartJob();
}
function getActiveJobCount() {
function getActiveJobCount(): number {
return Object.keys(_activeAttachmentDownloadJobs).length;
}
function _markAttachmentAsError(attachment) {
function _markAttachmentAsError(attachment: AttachmentType): AttachmentType {
return {
...omit(attachment, ['key', 'digest', 'id']),
error: true,
};
}
async function _addAttachmentToMessage(message, attachment, { type, index }) {
async function _addAttachmentToMessage(
message: MessageModel | null | undefined,
attachment: AttachmentType,
{ type, index }: { type: AttachmentDownloadJobTypeType; index: number }
): Promise<void> {
if (!message) {
return;
}
@ -308,13 +331,17 @@ async function _addAttachmentToMessage(message, attachment, { type, index }) {
if (type === 'long-message') {
try {
const { data } = await Signal.Migrations.loadAttachmentData(attachment);
const { data } = await window.Signal.Migrations.loadAttachmentData(
attachment
);
message.set({
body: attachment.isError ? message.get('body') : stringFromBytes(data),
body: attachment.error ? message.get('body') : stringFromBytes(data),
bodyPending: false,
});
} finally {
Signal.Migrations.deleteAttachmentData(attachment.path);
if (attachment.path) {
window.Signal.Migrations.deleteAttachmentData(attachment.path);
}
}
return;
}
@ -326,7 +353,7 @@ async function _addAttachmentToMessage(message, attachment, { type, index }) {
`_addAttachmentToMessage: attachments didn't exist or ${index} was too large`
);
}
_checkOldAttachment(attachments, index, attachment, logPrefix);
_checkOldAttachment(attachments, index.toString(), logPrefix);
const newAttachments = [...attachments];
newAttachments[index] = attachment;
@ -348,7 +375,7 @@ async function _addAttachmentToMessage(message, attachment, { type, index }) {
throw new Error(`_addAttachmentToMessage: preview ${index} was falsey`);
}
_checkOldAttachment(item, 'image', attachment, logPrefix);
_checkOldAttachment(item, 'image', logPrefix);
const newPreview = [...preview];
newPreview[index] = {
@ -370,13 +397,13 @@ async function _addAttachmentToMessage(message, attachment, { type, index }) {
}
const item = contact[index];
if (item && item.avatar && item.avatar.avatar) {
_checkOldAttachment(item.avatar, 'avatar', attachment, logPrefix);
_checkOldAttachment(item.avatar, 'avatar', logPrefix);
const newContact = [...contact];
newContact[index] = {
...contact[index],
...item,
avatar: {
...contact[index].avatar,
...item.avatar,
avatar: attachment,
},
};
@ -410,7 +437,7 @@ async function _addAttachmentToMessage(message, attachment, { type, index }) {
);
}
_checkOldAttachment(item, 'thumbnail', attachment, logPrefix);
_checkOldAttachment(item, 'thumbnail', logPrefix);
const newAttachments = [...attachments];
newAttachments[index] = {
@ -448,7 +475,12 @@ async function _addAttachmentToMessage(message, attachment, { type, index }) {
);
}
function _checkOldAttachment(object, key, newAttachment, logPrefix) {
function _checkOldAttachment(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
object: any,
key: string,
logPrefix: string
): void {
const oldAttachment = object[key];
if (oldAttachment && oldAttachment.path) {
logger.error(

View File

@ -0,0 +1,105 @@
// Copyright 2020-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';
type DeleteAttributesType = {
targetSentTimestamp: number;
serverTimestamp: number;
fromId: string;
};
export class DeleteModel extends Model<DeleteAttributesType> {}
let singleton: Deletes | undefined;
export class Deletes extends Collection<DeleteModel> {
static getSingleton(): Deletes {
if (!singleton) {
singleton = new Deletes();
}
return singleton;
}
forMessage(message: MessageModel): Array<DeleteModel> {
const matchingDeletes = this.filter(item => {
return (
item.get('targetSentTimestamp') === message.get('sent_at') &&
item.get('fromId') === message.getContactId()
);
});
if (matchingDeletes.length > 0) {
window.log.info('Found early DOE for message');
this.remove(matchingDeletes);
return matchingDeletes;
}
return [];
}
async onDelete(del: DeleteModel): Promise<void> {
try {
// The conversation the deleted message was in; we have to find it in the database
// to to figure that out.
const targetConversation = await window.ConversationController.getConversationForTargetMessage(
del.get('fromId'),
del.get('targetSentTimestamp')
);
if (!targetConversation) {
window.log.info(
'No target conversation for DOE',
del.get('fromId'),
del.get('targetSentTimestamp')
);
return;
}
// Do not await, since this can deadlock the queue
targetConversation.queueJob('Deletes.onDelete', async () => {
window.log.info('Handling DOE for', del.get('targetSentTimestamp'));
const messages = await window.Signal.Data.getMessagesBySentAt(
del.get('targetSentTimestamp'),
{
MessageCollection: window.Whisper.MessageCollection,
}
);
const targetMessage = messages.find(
m => del.get('fromId') === m.getContactId()
);
if (!targetMessage) {
window.log.info(
'No message for DOE',
del.get('fromId'),
del.get('targetSentTimestamp')
);
return;
}
const message = window.MessageController.register(
targetMessage.id,
targetMessage
);
await window.Signal.Util.deleteForEveryone(message, del);
this.remove(del);
});
} catch (error) {
window.log.error(
'Deletes.onDelete error:',
error && error.stack ? error.stack : error
);
}
}
}

View File

@ -0,0 +1,137 @@
// Copyright 2016-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/* eslint-disable max-classes-per-file */
import { union } from 'lodash';
import { Collection, Model } from 'backbone';
import { ConversationModel } from '../models/conversations';
import { MessageModel } from '../models/messages';
import { MessageModelCollectionType } from '../model-types.d';
import { isIncoming } from '../state/selectors/message';
type DeliveryReceiptAttributesType = {
timestamp: number;
deliveredTo: string;
};
class DeliveryReceiptModel extends Model<DeliveryReceiptAttributesType> {}
let singleton: DeliveryReceipts | undefined;
async function getTargetMessage(
sourceId: string,
messages: MessageModelCollectionType
): Promise<MessageModel | null> {
if (messages.length === 0) {
return null;
}
const message = messages.find(
item =>
!isIncoming(item.attributes) && sourceId === item.get('conversationId')
);
if (message) {
return window.MessageController.register(message.id, message);
}
const groups = await window.Signal.Data.getAllGroupsInvolvingId(sourceId, {
ConversationCollection: window.Whisper.ConversationCollection,
});
const ids = groups.pluck('id');
ids.push(sourceId);
const target = messages.find(
item =>
!isIncoming(item.attributes) && ids.includes(item.get('conversationId'))
);
if (!target) {
return null;
}
return window.MessageController.register(target.id, target);
}
export class DeliveryReceipts extends Collection<DeliveryReceiptModel> {
static getSingleton(): DeliveryReceipts {
if (!singleton) {
singleton = new DeliveryReceipts();
}
return singleton;
}
forMessage(
conversation: ConversationModel,
message: MessageModel
): Array<DeliveryReceiptModel> {
let recipients: Array<string>;
if (conversation.isPrivate()) {
recipients = [conversation.id];
} else {
recipients = conversation.getMemberIds();
}
const receipts = this.filter(
receipt =>
receipt.get('timestamp') === message.get('sent_at') &&
recipients.indexOf(receipt.get('deliveredTo')) > -1
);
this.remove(receipts);
return receipts;
}
async onReceipt(receipt: DeliveryReceiptModel): Promise<void> {
try {
const messages = await window.Signal.Data.getMessagesBySentAt(
receipt.get('timestamp'),
{
MessageCollection: window.Whisper.MessageCollection,
}
);
const message = await getTargetMessage(
receipt.get('deliveredTo'),
messages
);
if (!message) {
window.log.info(
'No message for delivery receipt',
receipt.get('deliveredTo'),
receipt.get('timestamp')
);
return;
}
const deliveries = message.get('delivered') || 0;
const deliveredTo = message.get('delivered_to') || [];
const expirationStartTimestamp = message.get('expirationStartTimestamp');
message.set({
delivered_to: union(deliveredTo, [receipt.get('deliveredTo')]),
delivered: deliveries + 1,
expirationStartTimestamp: expirationStartTimestamp || Date.now(),
sent: true,
});
window.Signal.Util.queueUpdateMessage(message.attributes);
// notify frontend listeners
const conversation = window.ConversationController.get(
message.get('conversationId')
);
const updateLeftPane = conversation
? conversation.debouncedUpdateLastMessage
: undefined;
if (updateLeftPane) {
updateLeftPane();
}
this.remove(receipt);
} catch (error) {
window.log.error(
'DeliveryReceipts.onReceipt error:',
error && error.stack ? error.stack : error
);
}
}
}

View File

@ -0,0 +1,132 @@
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/* eslint-disable max-classes-per-file */
import { Collection, Model } from 'backbone';
import { ConversationModel } from '../models/conversations';
type MessageRequestAttributesType = {
threadE164?: string;
threadUuid?: string;
groupId?: string;
groupV2Id?: string;
type: number;
};
class MessageRequestModel extends Model<MessageRequestAttributesType> {}
let singleton: MessageRequests | undefined;
export class MessageRequests extends Collection<MessageRequestModel> {
static getSingleton(): MessageRequests {
if (!singleton) {
singleton = new MessageRequests();
}
return singleton;
}
forConversation(conversation: ConversationModel): MessageRequestModel | null {
if (conversation.get('e164')) {
const syncByE164 = this.findWhere({
threadE164: conversation.get('e164'),
});
if (syncByE164) {
window.log.info(
`Found early message request response for E164 ${conversation.idForLogging()}`
);
this.remove(syncByE164);
return syncByE164;
}
}
if (conversation.get('uuid')) {
const syncByUuid = this.findWhere({
threadUuid: conversation.get('uuid'),
});
if (syncByUuid) {
window.log.info(
`Found early message request response for UUID ${conversation.idForLogging()}`
);
this.remove(syncByUuid);
return syncByUuid;
}
}
// V1 Group
if (conversation.get('groupId')) {
const syncByGroupId = this.findWhere({
groupId: conversation.get('groupId'),
});
if (syncByGroupId) {
window.log.info(
`Found early message request response for group v1 ID ${conversation.idForLogging()}`
);
this.remove(syncByGroupId);
return syncByGroupId;
}
}
// V2 group
if (conversation.get('groupId')) {
const syncByGroupId = this.findWhere({
groupV2Id: conversation.get('groupId'),
});
if (syncByGroupId) {
window.log.info(
`Found early message request response for group v2 ID ${conversation.idForLogging()}`
);
this.remove(syncByGroupId);
return syncByGroupId;
}
}
return null;
}
async onResponse(sync: MessageRequestModel): Promise<void> {
try {
const threadE164 = sync.get('threadE164');
const threadUuid = sync.get('threadUuid');
const groupId = sync.get('groupId');
const groupV2Id = sync.get('groupV2Id');
let conversation;
// We multiplex between GV1/GV2 groups here, but we don't kick off migrations
if (groupV2Id) {
conversation = window.ConversationController.get(groupV2Id);
}
if (!conversation && groupId) {
conversation = window.ConversationController.get(groupId);
}
if (!conversation && (threadE164 || threadUuid)) {
conversation = window.ConversationController.get(
window.ConversationController.ensureContactIds({
e164: threadE164,
uuid: threadUuid,
})
);
}
if (!conversation) {
window.log.warn(
`Received message request response for unknown conversation: groupv2(${groupV2Id}) group(${groupId}) ${threadUuid} ${threadE164}`
);
return;
}
conversation.applyMessageRequestResponse(sync.get('type'), {
fromSync: true,
});
this.remove(sync);
} catch (error) {
window.log.error(
'MessageRequests.onResponse error:',
error && error.stack ? error.stack : error
);
}
}
}

View File

@ -0,0 +1,167 @@
// Copyright 2020-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 { isOutgoing } from '../state/selectors/message';
type ReactionsAttributesType = {
emoji: string;
remove: boolean;
targetAuthorUuid: string;
targetTimestamp: number;
timestamp: number;
fromId: string;
};
export class ReactionModel extends Model<ReactionsAttributesType> {}
let singleton: Reactions | undefined;
export class Reactions extends Collection {
static getSingleton(): Reactions {
if (!singleton) {
singleton = new Reactions();
}
return singleton;
}
forMessage(message: MessageModel): Array<ReactionModel> {
if (isOutgoing(message.attributes)) {
const outgoingReactions = this.filter(
item => item.get('targetTimestamp') === message.get('sent_at')
);
if (outgoingReactions.length > 0) {
window.log.info('Found early reaction for outgoing message');
this.remove(outgoingReactions);
return outgoingReactions;
}
}
const senderId = message.getContactId();
const sentAt = message.get('sent_at');
const reactionsBySource = this.filter(re => {
const targetSenderId = window.ConversationController.ensureContactIds({
uuid: re.get('targetAuthorUuid'),
});
const targetTimestamp = re.get('targetTimestamp');
return targetSenderId === senderId && targetTimestamp === sentAt;
});
if (reactionsBySource.length > 0) {
window.log.info('Found early reaction for message');
this.remove(reactionsBySource);
return reactionsBySource;
}
return [];
}
async onReaction(
reaction: ReactionModel
): Promise<ReactionModel | undefined> {
try {
// The conversation the target message was in; we have to find it in the database
// to to figure that out.
const targetConversationId = window.ConversationController.ensureContactIds(
{
uuid: reaction.get('targetAuthorUuid'),
}
);
if (!targetConversationId) {
throw new Error(
'onReaction: No conversationId returned from ensureContactIds!'
);
}
const targetConversation = await window.ConversationController.getConversationForTargetMessage(
targetConversationId,
reaction.get('targetTimestamp')
);
if (!targetConversation) {
window.log.info(
'No target conversation for reaction',
reaction.get('targetAuthorUuid'),
reaction.get('targetTimestamp')
);
return undefined;
}
// awaiting is safe since `onReaction` is never called from inside the queue
return await targetConversation.queueJob(
'Reactions.onReaction',
async () => {
window.log.info(
'Handling reaction for',
reaction.get('targetTimestamp')
);
const messages = await window.Signal.Data.getMessagesBySentAt(
reaction.get('targetTimestamp'),
{
MessageCollection: window.Whisper.MessageCollection,
}
);
// Message is fetched inside the conversation queue so we have the
// most recent data
const targetMessage = messages.find(m => {
const contact = m.getContact();
if (!contact) {
return false;
}
const mcid = contact.get('id');
const recid = window.ConversationController.ensureContactIds({
uuid: reaction.get('targetAuthorUuid'),
});
return mcid === recid;
});
if (!targetMessage) {
window.log.info(
'No message for reaction',
reaction.get('targetAuthorUuid'),
reaction.get('targetTimestamp')
);
// Since we haven't received the message for which we are removing a
// reaction, we can just remove those pending reactions
if (reaction.get('remove')) {
this.remove(reaction);
const oldReaction = this.where({
targetAuthorUuid: reaction.get('targetAuthorUuid'),
targetTimestamp: reaction.get('targetTimestamp'),
emoji: reaction.get('emoji'),
});
oldReaction.forEach(r => this.remove(r));
}
return undefined;
}
const message = window.MessageController.register(
targetMessage.id,
targetMessage
);
const oldReaction = await message.handleReaction(reaction);
this.remove(reaction);
return oldReaction;
}
);
} catch (error) {
window.log.error(
'Reactions.onReaction error:',
error && error.stack ? error.stack : error
);
return undefined;
}
}
}

View File

@ -0,0 +1,139 @@
// Copyright 2016-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/* eslint-disable max-classes-per-file */
import { Collection, Model } from 'backbone';
import { ConversationModel } from '../models/conversations';
import { MessageModel } from '../models/messages';
import { MessageModelCollectionType } from '../model-types.d';
import { isOutgoing } from '../state/selectors/message';
type ReadReceiptAttributesType = {
reader: string;
timestamp: number;
readAt: number;
};
class ReadReceiptModel extends Model<ReadReceiptAttributesType> {}
let singleton: ReadReceipts | undefined;
async function getTargetMessage(
reader: string,
messages: MessageModelCollectionType
): Promise<MessageModel | null> {
if (messages.length === 0) {
return null;
}
const message = messages.find(
item => isOutgoing(item.attributes) && reader === item.get('conversationId')
);
if (message) {
return window.MessageController.register(message.id, message);
}
const groups = await window.Signal.Data.getAllGroupsInvolvingId(reader, {
ConversationCollection: window.Whisper.ConversationCollection,
});
const ids = groups.pluck('id');
ids.push(reader);
const target = messages.find(
item =>
isOutgoing(item.attributes) && ids.includes(item.get('conversationId'))
);
if (!target) {
return null;
}
return window.MessageController.register(target.id, target);
}
export class ReadReceipts extends Collection<ReadReceiptModel> {
static getSingleton(): ReadReceipts {
if (!singleton) {
singleton = new ReadReceipts();
}
return singleton;
}
forMessage(
conversation: ConversationModel,
message: MessageModel
): Array<ReadReceiptModel> {
if (!isOutgoing(message.attributes)) {
return [];
}
let ids: Array<string>;
if (conversation.isPrivate()) {
ids = [conversation.id];
} else {
ids = conversation.getMemberIds();
}
const receipts = this.filter(
receipt =>
receipt.get('timestamp') === message.get('sent_at') &&
ids.includes(receipt.get('reader'))
);
if (receipts.length) {
window.log.info('Found early read receipts for message');
this.remove(receipts);
}
return receipts;
}
async onReceipt(receipt: ReadReceiptModel): Promise<void> {
try {
const messages = await window.Signal.Data.getMessagesBySentAt(
receipt.get('timestamp'),
{
MessageCollection: window.Whisper.MessageCollection,
}
);
const message = await getTargetMessage(receipt.get('reader'), messages);
if (!message) {
window.log.info(
'No message for read receipt',
receipt.get('reader'),
receipt.get('timestamp')
);
return;
}
const readBy = message.get('read_by') || [];
const expirationStartTimestamp = message.get('expirationStartTimestamp');
readBy.push(receipt.get('reader'));
message.set({
read_by: readBy,
expirationStartTimestamp: expirationStartTimestamp || Date.now(),
sent: true,
});
window.Signal.Util.queueUpdateMessage(message.attributes);
// notify frontend listeners
const conversation = window.ConversationController.get(
message.get('conversationId')
);
const updateLeftPane = conversation
? conversation.debouncedUpdateLastMessage
: undefined;
if (updateLeftPane) {
updateLeftPane();
}
this.remove(receipt);
} catch (error) {
window.log.error(
'ReadReceipts.onReceipt error:',
error && error.stack ? error.stack : error
);
}
}
}

View File

@ -0,0 +1,162 @@
// Copyright 2017-2020 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 { isIncoming } from '../state/selectors/message';
type ReadSyncAttributesType = {
senderId: string;
sender: string;
senderUuid: string;
timestamp: number;
readAt: number;
};
class ReadSyncModel extends Model<ReadSyncAttributesType> {}
let singleton: ReadSyncs | undefined;
async function maybeItIsAReactionReadSync(
receipt: ReadSyncModel
): Promise<void> {
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')
);
return;
}
window.Whisper.Notifications.removeBy({
conversationId: readReaction.conversationId,
emoji: readReaction.emoji,
targetAuthorUuid: readReaction.targetAuthorUuid,
targetTimestamp: readReaction.targetTimestamp,
});
}
export class ReadSyncs extends Collection {
static getSingleton(): ReadSyncs {
if (!singleton) {
singleton = new ReadSyncs();
}
return singleton;
}
forMessage(message: MessageModel): ReadSyncModel | null {
const senderId = window.ConversationController.ensureContactIds({
e164: message.get('source'),
uuid: message.get('sourceUuid'),
});
const receipt = this.find(item => {
return (
item.get('senderId') === senderId &&
item.get('timestamp') === message.get('sent_at')
);
});
if (receipt) {
window.log.info('Found early read sync for message');
this.remove(receipt);
return receipt;
}
return null;
}
async onReceipt(receipt: ReadSyncModel): Promise<void> {
try {
const messages = await window.Signal.Data.getMessagesBySentAt(
receipt.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 === receipt.get('senderId')
);
});
if (!found) {
await maybeItIsAReactionReadSync(receipt);
return;
}
window.Whisper.Notifications.removeBy({ messageId: found.id });
const message = window.MessageController.register(found.id, found);
const readAt = receipt.get('readAt');
// If message is unread, we mark it read. Otherwise, we update the expiration
// timer to the time specified by the read sync if it's earlier than
// the previous read time.
if (message.isUnread()) {
// 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
// marked read. We want those messages to have the same expire timer
// start time as this one, so we pass the readAt value through.
const conversation = message.getConversation();
if (conversation) {
conversation.onReadMessage(message, readAt);
}
};
if (window.startupProcessingQueue) {
const conversation = message.getConversation();
if (conversation) {
window.startupProcessingQueue.add(
conversation.get('id'),
updateConversation
);
}
} else {
updateConversation();
}
} else {
const now = Date.now();
const existingTimestamp = message.get('expirationStartTimestamp');
const expirationStartTimestamp = Math.min(
now,
Math.min(existingTimestamp || now, readAt || now)
);
message.set({ expirationStartTimestamp });
const conversation = message.getConversation();
if (conversation) {
conversation.trigger('expiration-change', message);
}
}
window.Signal.Util.queueUpdateMessage(message.attributes);
this.remove(receipt);
} catch (error) {
window.log.error(
'ReadSyncs.onReceipt error:',
error && error.stack ? error.stack : error
);
}
}
}

View File

@ -0,0 +1,105 @@
// Copyright 2019-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';
type ViewSyncAttributesType = {
source?: string;
sourceUuid: string;
timestamp: number;
};
class ViewSyncModel extends Model<ViewSyncAttributesType> {}
let singleton: ViewSyncs | undefined;
export class ViewSyncs extends Collection<ViewSyncModel> {
static getSingleton(): ViewSyncs {
if (!singleton) {
singleton = new ViewSyncs();
}
return singleton;
}
forMessage(message: MessageModel): ViewSyncModel | null {
const syncBySourceUuid = this.find(item => {
return (
item.get('sourceUuid') === message.get('sourceUuid') &&
item.get('timestamp') === message.get('sent_at')
);
});
if (syncBySourceUuid) {
window.log.info('Found early view sync for message');
this.remove(syncBySourceUuid);
return syncBySourceUuid;
}
const syncBySource = this.find(item => {
return (
item.get('source') === message.get('source') &&
item.get('timestamp') === message.get('sent_at')
);
});
if (syncBySource) {
window.log.info('Found early view sync for message');
this.remove(syncBySource);
return syncBySource;
}
return null;
}
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 itemSourceUuid = item.get('sourceUuid');
const syncSourceUuid = sync.get('sourceUuid');
const itemSource = item.get('source');
const syncSource = sync.get('source');
return Boolean(
(itemSourceUuid &&
syncSourceUuid &&
itemSourceUuid === syncSourceUuid) ||
(itemSource && syncSource && itemSource === syncSource)
);
});
const syncSource = sync.get('source');
const syncSourceUuid = sync.get('sourceUuid');
const syncTimestamp = sync.get('timestamp');
const wasMessageFound = Boolean(found);
window.log.info('Receive view sync:', {
syncSource,
syncSourceUuid,
syncTimestamp,
wasMessageFound,
});
if (!found) {
return;
}
const message = window.MessageController.register(found.id, found);
await message.markViewed({ fromSync: true });
this.remove(sync);
} catch (error) {
window.log.error(
'ViewSyncs.onSync error:',
error && error.stack ? error.stack : error
);
}
}
}

121
ts/model-types.d.ts vendored
View File

@ -7,11 +7,6 @@ import { GroupV2ChangeType } from './groups';
import { LocalizerType, BodyRangeType, BodyRangesType } from './types/Util';
import { CallHistoryDetailsFromDiskType } from './types/Calling';
import { CustomColorType } from './types/Colors';
import {
ConversationType,
MessageType,
LastMessageStatus,
} from './state/ducks/conversations';
import { DeviceType } from './textsecure/Types';
import { SendOptionsType } from './textsecure/SendMessage';
import { SendMessageChallengeData } from './textsecure/Errors';
@ -27,19 +22,19 @@ import { ProfileNameChangeType } from './util/getStringForProfileChange';
import { CapabilitiesType } from './textsecure/WebAPI';
import { GroupNameCollisionsWithIdsByTitle } from './util/groupMemberNameCollisions';
import { ConversationColorType } from './types/Colors';
import { AttachmentType, ThumbnailType } from './types/Attachment';
import { ContactType } from './types/Contact';
export type WhatIsThis = any;
type DeletesAttributesType = {
fromId: string;
serverTimestamp: number;
targetSentTimestamp: number;
};
export declare class DeletesModelType extends Backbone.Model<DeletesAttributesType> {
forMessage(message: MessageModel): Array<DeletesModelType>;
onDelete(doe: DeletesAttributesType): Promise<void>;
}
export type LastMessageStatus =
| 'paused'
| 'error'
| 'partial-sent'
| 'sending'
| 'sent'
| 'delivered'
| 'read';
type TaskResultType = any;
@ -77,38 +72,38 @@ export type RetryOptions = Readonly<{
}>;
export type MessageAttributesType = {
bodyPending: boolean;
bodyRanges: BodyRangesType;
callHistoryDetails: CallHistoryDetailsFromDiskType;
changedId: string;
dataMessage: ArrayBuffer | null;
decrypted_at: number;
deletedForEveryone: boolean;
bodyPending?: boolean;
bodyRanges?: BodyRangesType;
callHistoryDetails?: CallHistoryDetailsFromDiskType;
changedId?: string;
dataMessage?: ArrayBuffer | null;
decrypted_at?: number;
deletedForEveryone?: boolean;
deletedForEveryoneTimestamp?: number;
delivered: number;
delivered_to: Array<string | null>;
delivered?: number;
delivered_to?: Array<string | null>;
errors?: Array<CustomError>;
expirationStartTimestamp: number | null;
expireTimer: number;
expirationStartTimestamp?: number | null;
expireTimer?: number;
groupMigration?: GroupMigrationType;
group_update: {
group_update?: {
avatarUpdated: boolean;
joined: Array<string>;
left: string | 'You';
name: string;
};
hasAttachments: boolean;
hasFileAttachments: boolean;
hasVisualMediaAttachments: boolean;
isErased: boolean;
isTapToViewInvalid: boolean;
isViewOnce: boolean;
key_changed: string;
local: boolean;
logger: unknown;
message: unknown;
messageTimer: unknown;
profileChange: ProfileNameChangeType;
hasAttachments?: boolean;
hasFileAttachments?: boolean;
hasVisualMediaAttachments?: boolean;
isErased?: boolean;
isTapToViewInvalid?: boolean;
isViewOnce?: boolean;
key_changed?: string;
local?: boolean;
logger?: unknown;
message?: unknown;
messageTimer?: unknown;
profileChange?: ProfileNameChangeType;
quote?: QuotedMessageType;
reactions?: Array<{
emoji: string;
@ -117,20 +112,19 @@ export type MessageAttributesType = {
targetTimestamp: number;
timestamp: number;
}>;
read_by: Array<string | null>;
requiredProtocolVersion: number;
read_by?: Array<string | null>;
requiredProtocolVersion?: number;
retryOptions?: RetryOptions;
sent: boolean;
sourceDevice: string | number;
snippet: unknown;
supportedVersionAtReceive: unknown;
synced: boolean;
unidentifiedDeliveryReceived: boolean;
verified: boolean;
verifiedChanged: string;
sent?: boolean;
sourceDevice?: string | number;
supportedVersionAtReceive?: unknown;
synced?: boolean;
unidentifiedDeliveryReceived?: boolean;
verified?: boolean;
verifiedChanged?: string;
id: string;
type?:
type:
| 'call-history'
| 'chat-session-refreshed'
| 'delivery-issue'
@ -145,17 +139,22 @@ export type MessageAttributesType = {
| 'timer-notification'
| 'universal-timer-notification'
| 'verified-change';
body: string;
attachments: Array<WhatIsThis>;
preview: Array<WhatIsThis>;
sticker: WhatIsThis;
body?: string;
attachments?: Array<AttachmentType>;
preview?: Array<WhatIsThis>;
sticker?: {
packId: string;
stickerId: number;
packKey: string;
data?: AttachmentType;
};
sent_at: number;
sent_to: Array<string>;
unidentifiedDeliveries: Array<string>;
contact: Array<WhatIsThis>;
sent_to?: Array<string>;
unidentifiedDeliveries?: Array<string>;
contact?: Array<ContactType>;
conversationId: string;
recipients: Array<string>;
reaction: WhatIsThis;
recipients?: Array<string>;
reaction?: WhatIsThis;
destination?: WhatIsThis;
destinationUuid?: string;
@ -174,7 +173,7 @@ export type MessageAttributesType = {
// More of a legacy feature, needed as we were updating the schema of messages in the
// background, when we were still in IndexedDB, before attachments had gone to disk
// We set this so that the idle message upgrade process doesn't pick this message up
schemaVersion: number;
schemaVersion?: number;
// This should always be set for new messages, but older messages may not have them. We
// may not have these for outbound messages, either, as we have not needed them.
serverGuid?: string;
@ -182,7 +181,7 @@ export type MessageAttributesType = {
source?: string;
sourceUuid?: string;
unread: boolean;
unread?: boolean;
timestamp: number;
// Backwards-compatibility with prerelease data schema

View File

@ -62,6 +62,14 @@ import {
isMe,
} from '../util/whatTypeOfConversation';
import { deprecated } from '../util/deprecated';
import {
hasErrors,
isIncoming,
isTapToView,
getMessagePropStatus,
} from '../state/selectors/message';
import { Deletes } from '../messageModifiers/Deletes';
import { Reactions } from '../messageModifiers/Reactions';
/* eslint-disable more/no-then */
window.Whisper = window.Whisper || {};
@ -1238,7 +1246,7 @@ export class ConversationModel extends window.Backbone
const isNewMessage = true;
messagesAdded(
this.id,
[message.getReduxData()],
[{ ...message.attributes }],
isNewMessage,
window.isActive()
);
@ -1560,7 +1568,7 @@ export class ConversationModel extends window.Backbone
}
const readMessages = messages.filter(
m => !m.hasErrors() && m.isIncoming()
m => !hasErrors(m.attributes) && isIncoming(m.attributes)
);
const receiptSpecs = readMessages.map(m => ({
senderE164: m.get('source'),
@ -1570,7 +1578,7 @@ export class ConversationModel extends window.Backbone
uuid: m.get('sourceUuid'),
}),
timestamp: m.get('sent_at'),
hasErrors: m.hasErrors(),
hasErrors: hasErrors(m.attributes),
}));
if (isLocalAction) {
@ -2324,27 +2332,6 @@ export class ConversationModel extends window.Backbone
return this.get('messageRequestResponseType') || 0;
}
isMissingRequiredProfileSharing(): boolean {
const mandatoryProfileSharingEnabled = window.Signal.RemoteConfig.isEnabled(
'desktop.mandatoryProfileSharing'
);
if (!mandatoryProfileSharingEnabled) {
return false;
}
const hasNoMessages = (this.get('messageCount') || 0) === 0;
if (hasNoMessages) {
return false;
}
if (!isGroupV1(this.attributes) && !isDirectConversation(this.attributes)) {
return false;
}
return !this.get('profileSharing');
}
getAboutText(): string | undefined {
if (!this.get('about')) {
return undefined;
@ -3006,9 +2993,9 @@ export class ConversationModel extends window.Backbone
}
async getQuoteAttachment(
attachments: Array<WhatIsThis>,
preview: Array<WhatIsThis>,
sticker: WhatIsThis
attachments?: Array<WhatIsThis>,
preview?: Array<WhatIsThis>,
sticker?: WhatIsThis
): Promise<WhatIsThis> {
if (attachments && attachments.length) {
const validAttachments = filter(
@ -3104,8 +3091,8 @@ export class ConversationModel extends window.Backbone
bodyRanges: quotedMessage.get('bodyRanges'),
id: quotedMessage.get('sent_at'),
text: body || embeddedContactName,
isViewOnce: quotedMessage.isTapToView(),
attachments: quotedMessage.isTapToView()
isViewOnce: isTapToView(quotedMessage.attributes),
attachments: isTapToView(quotedMessage.attributes)
? [{ contentType: 'image/jpeg', fileName: null }]
: await this.getQuoteAttachment(attachments, preview, sticker),
};
@ -3166,7 +3153,7 @@ export class ConversationModel extends window.Backbone
throw new Error('Cannot send DOE for a message older than three hours');
}
const deleteModel = window.Whisper.Deletes.add({
const deleteModel = Deletes.getSingleton().add({
targetSentTimestamp: targetTimestamp,
fromId: window.ConversationController.getOurConversationId(),
});
@ -3264,7 +3251,7 @@ export class ConversationModel extends window.Backbone
// send error.
throw new Error('No successful delivery for delete for everyone');
}
window.Whisper.Deletes.onDelete(deleteModel);
Deletes.getSingleton().onDelete(deleteModel);
return result;
}).catch(error => {
@ -3289,7 +3276,7 @@ export class ConversationModel extends window.Backbone
const timestamp = Date.now();
const outgoingReaction = { ...reaction, ...target };
const reactionModel = window.Whisper.Reactions.add({
const reactionModel = Reactions.getSingleton().add({
...outgoingReaction,
fromId: window.ConversationController.getOurConversationId(),
timestamp,
@ -3297,7 +3284,7 @@ export class ConversationModel extends window.Backbone
});
// Apply reaction optimistically
const oldReaction = await window.Whisper.Reactions.onReaction(
const oldReaction = await Reactions.getSingleton().onReaction(
reactionModel
);
@ -3367,7 +3354,7 @@ export class ConversationModel extends window.Backbone
timestamp,
});
const result = await message.sendSyncMessageOnly(dataMessage);
window.Whisper.Reactions.onReaction(reactionModel);
Reactions.getSingleton().onReaction(reactionModel);
return result;
}
@ -3426,7 +3413,7 @@ export class ConversationModel extends window.Backbone
let reverseReaction: ReactionModelType;
if (oldReaction) {
// Either restore old reaction
reverseReaction = window.Whisper.Reactions.add({
reverseReaction = Reactions.getSingleton().add({
...oldReaction,
fromId: window.ConversationController.getOurConversationId(),
timestamp,
@ -3437,7 +3424,7 @@ export class ConversationModel extends window.Backbone
reverseReaction.set('remove', !reverseReaction.get('remove'));
}
window.Whisper.Reactions.onReaction(reverseReaction);
Reactions.getSingleton().onReaction(reverseReaction);
});
}
@ -3762,7 +3749,12 @@ export class ConversationModel extends window.Backbone
lastMessage:
(previewMessage ? previewMessage.getNotificationText() : '') || '',
lastMessageStatus:
(previewMessage ? previewMessage.getMessagePropStatus() : null) || null,
(previewMessage
? getMessagePropStatus(
previewMessage.attributes,
window.storage.get('read-receipt-setting', false)
)
: null) || null,
timestamp,
lastMessageDeletedForEveryone: previewMessage
? previewMessage.get('deletedForEveryone')
@ -5024,7 +5016,7 @@ export class ConversationModel extends window.Backbone
return;
}
if (!message.isIncoming() && !reaction) {
if (!isIncoming(message.attributes) && !reaction) {
return;
}

File diff suppressed because it is too large Load Diff

View File

@ -37,8 +37,8 @@ export function getExpiresAt(
'expireTimer' | 'expirationStartTimestamp'
>
): number | undefined {
const expireTimerMs = messageAttrs.expireTimer * 1000;
return messageAttrs.expirationStartTimestamp
? messageAttrs.expirationStartTimestamp + expireTimerMs
const { expireTimer, expirationStartTimestamp } = messageAttrs;
return expirationStartTimestamp && expireTimer
? expirationStartTimestamp + expireTimer * 1000
: undefined;
}

View File

@ -1,15 +1,6 @@
// Copyright 2019-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// Matching Whisper.Message API
// eslint-disable-next-line max-len
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
export function getBubbleProps(attributes: any): any {
const model = new window.Whisper.Message(attributes);
return model.getPropsForBubble();
}
export function showSettings(): void {
window.showSettings();
}

View File

@ -16,12 +16,25 @@ import { StoredJob } from '../jobs/types';
import { ReactionType } from '../types/Reactions';
import { ConversationColorType, CustomColorType } from '../types/Colors';
import { StorageAccessType } from '../types/Storage.d';
import { AttachmentType } from '../types/Attachment';
export type AttachmentDownloadJobTypeType =
| 'long-message'
| 'attachment'
| 'preview'
| 'contact'
| 'quote'
| 'sticker';
export type AttachmentDownloadJobType = {
id: string;
timestamp: number;
pending: number;
attachment: AttachmentType;
attempts: number;
id: string;
index: number;
messageId: string;
pending: number;
timestamp: number;
type: AttachmentDownloadJobTypeType;
};
export type MessageMetricsType = {
id: string;

View File

@ -3061,7 +3061,7 @@ function saveMessageSync(
isErased: isErased ? 1 : 0,
isViewOnce: isViewOnce ? 1 : 0,
received_at: received_at || null,
schemaVersion,
schemaVersion: schemaVersion || 0,
serverGuid: serverGuid || null,
sent_at: sent_at || null,
source: source || null,

View File

@ -1,6 +1,7 @@
// Copyright 2019-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { actions as accounts } from './ducks/accounts';
import { actions as app } from './ducks/app';
import { actions as audioPlayer } from './ducks/audioPlayer';
import { actions as calling } from './ducks/calling';
@ -19,6 +20,7 @@ import { actions as user } from './ducks/user';
import { ReduxActions } from './types';
export const actionCreators: ReduxActions = {
accounts,
app,
audioPlayer,
calling,
@ -37,6 +39,7 @@ export const actionCreators: ReduxActions = {
};
export const mapDispatchToProps = {
...accounts,
...app,
...audioPlayer,
...calling,

View File

@ -0,0 +1,99 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { ThunkAction } from 'redux-thunk';
import { StateType as RootStateType } from '../reducer';
import { NoopActionType } from './noop';
// State
export type AccountsStateType = {
accounts: Record<string, boolean>;
};
// Actions
type AccountUpdateActionType = {
type: 'accounts/UPDATE';
payload: {
identifier: string;
hasAccount: boolean;
};
};
export type AccountsActionType = AccountUpdateActionType;
// Action Creators
export const actions = {
checkForAccount,
};
function checkForAccount(
identifier: string
): ThunkAction<
void,
RootStateType,
unknown,
AccountUpdateActionType | NoopActionType
> {
return async dispatch => {
if (!window.textsecure.messaging) {
dispatch({
type: 'NOOP',
payload: null,
});
return;
}
let hasAccount = false;
try {
await window.textsecure.messaging.getProfile(identifier);
hasAccount = true;
} catch (_error) {
// Doing nothing with this failed fetch
}
dispatch({
type: 'accounts/UPDATE',
payload: {
identifier,
hasAccount,
},
});
};
}
// Reducer
export function getEmptyState(): AccountsStateType {
return {
accounts: {},
};
}
export function reducer(
state: Readonly<AccountsStateType> = getEmptyState(),
action: Readonly<AccountsActionType>
): AccountsStateType {
if (!state) {
return getEmptyState();
}
if (action.type === 'accounts/UPDATE') {
const { payload } = action;
const { identifier, hasAccount } = payload;
return {
...state,
accounts: {
...state.accounts,
[identifier]: hasAccount,
},
};
}
return state;
}

View File

@ -21,7 +21,7 @@ import { calling } from '../../services/calling';
import { getOwn } from '../../util/getOwn';
import { assert } from '../../util/assert';
import { trigger } from '../../shims/events';
import { AttachmentType } from '../../types/Attachment';
import {
AvatarColorType,
ConversationColorType,
@ -29,9 +29,13 @@ import {
DefaultConversationColorType,
DEFAULT_CONVERSATION_COLOR,
} from '../../types/Colors';
import { ConversationAttributesType } from '../../model-types.d';
import {
LastMessageStatus,
ConversationAttributesType,
MessageAttributesType,
} from '../../model-types.d';
import { BodyRangeType } from '../../types/Util';
import { CallMode, CallHistoryDetailsFromDiskType } from '../../types/Calling';
import { CallMode } from '../../types/Calling';
import { MediaItemType } from '../../components/LightboxGallery';
import {
getGroupSizeRecommendedLimit,
@ -41,6 +45,8 @@ import { toggleSelectedContactForGroupAddition } from '../../groups/toggleSelect
import { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCollisions';
import { ContactSpoofingType } from '../../util/contactSpoofing';
import { NoopActionType } from './noop';
// State
export type DBConversationType = {
@ -50,16 +56,15 @@ export type DBConversationType = {
type: string;
};
export type LastMessageStatus =
| 'paused'
| 'error'
| 'partial-sent'
| 'sending'
| 'sent'
| 'delivered'
| 'read';
export const InteractionModes = ['mouse', 'keyboard'] as const;
export type InteractionModeType = typeof InteractionModes[number];
export type ConversationTypeType = 'direct' | 'group';
export type MessageType = MessageAttributesType & {
interactionType?: InteractionModeType;
};
export const ConversationTypes = ['direct', 'group'] as const;
export type ConversationTypeType = typeof ConversationTypes[number];
export type ConversationType = {
id: string;
@ -159,52 +164,6 @@ export type CustomError = Error & {
identifier?: string;
number?: string;
};
export type MessageType = {
id: string;
conversationId: string;
source?: string;
sourceUuid?: string;
type?:
| 'call-history'
| 'chat-session-refreshed'
| 'delivery-issue'
| 'group'
| 'group-v1-migration'
| 'group-v2-change'
| 'incoming'
| 'keychange'
| 'message-history-unsynced'
| 'outgoing'
| 'profile-change'
| 'timer-notification'
| 'universal-timer-notification'
| 'verified-change';
quote?: { author?: string; authorUuid?: string };
received_at: number;
sent_at?: number;
hasSignalAccount?: boolean;
bodyPending?: boolean;
attachments: Array<AttachmentType>;
sticker: {
data?: {
pending?: boolean;
blurHash?: string;
};
};
unread: boolean;
reactions?: Array<{
emoji: string;
timestamp: number;
}>;
deletedForEveryone?: boolean;
errors?: Array<CustomError>;
group_update?: unknown;
callHistoryDetails?: CallHistoryDetailsFromDiskType;
// No need to go beyond this; unused at this stage, since this goes into
// a reducer still in plain JavaScript and comes out well-formed
};
type MessagePointerType = {
id: string;
@ -219,7 +178,7 @@ type MessageMetricsType = {
};
export type MessageLookupType = {
[key: string]: MessageType;
[key: string]: MessageAttributesType;
};
export type ConversationMessageType = {
heightChangeMessageIds: Array<string>;
@ -460,7 +419,7 @@ export type MessageChangedActionType = {
payload: {
id: string;
conversationId: string;
data: MessageType;
data: MessageAttributesType;
};
};
export type MessageDeletedActionType = {
@ -481,7 +440,7 @@ export type MessagesAddedActionType = {
type: 'MESSAGES_ADDED';
payload: {
conversationId: string;
messages: Array<MessageType>;
messages: Array<MessageAttributesType>;
isNewMessage: boolean;
isActive: boolean;
};
@ -503,7 +462,7 @@ export type MessagesResetActionType = {
type: 'MESSAGES_RESET';
payload: {
conversationId: string;
messages: Array<MessageType>;
messages: Array<MessageAttributesType>;
metrics: MessageMetricsType;
scrollToMessageId?: string;
// The set of provided messages should be trusted, even if it conflicts with metrics,
@ -701,6 +660,7 @@ export const actions = {
conversationUnloaded,
colorSelected,
createGroup,
doubleCheckMissingQuoteReference,
messageChanged,
messageDeleted,
messagesAdded,
@ -990,7 +950,7 @@ function selectMessage(
function messageChanged(
id: string,
conversationId: string,
data: MessageType
data: MessageAttributesType
): MessageChangedActionType {
return {
type: 'MESSAGE_CHANGED',
@ -1027,7 +987,7 @@ function messageSizeChanged(
}
function messagesAdded(
conversationId: string,
messages: Array<MessageType>,
messages: Array<MessageAttributesType>,
isNewMessage: boolean,
isActive: boolean
): MessagesAddedActionType {
@ -1082,7 +1042,7 @@ function reviewMessageRequestNameCollision(
function messagesReset(
conversationId: string,
messages: Array<MessageType>,
messages: Array<MessageAttributesType>,
metrics: MessageMetricsType,
scrollToMessageId?: string,
unboundedFetch?: boolean
@ -1345,6 +1305,18 @@ function showArchivedConversations(): ShowArchivedConversationsActionType {
};
}
function doubleCheckMissingQuoteReference(messageId: string): NoopActionType {
const message = window.MessageController.getById(messageId);
if (message) {
message.doubleCheckMissingQuoteReference();
}
return {
type: 'NOOP',
payload: null,
};
}
// Reducer
export function getEmptyState(): ConversationsStateType {
@ -1363,8 +1335,8 @@ export function getEmptyState(): ConversationsStateType {
}
function hasMessageHeightChanged(
message: MessageType,
previous: MessageType
message: MessageAttributesType,
previous: MessageAttributesType
): boolean {
const messageAttachments = message.attachments || [];
const previousAttachments = previous.attachments || [];
@ -1410,13 +1382,6 @@ function hasMessageHeightChanged(
return true;
}
const signalAccountChanged =
Boolean(message.hasSignalAccount || previous.hasSignalAccount) &&
message.hasSignalAccount !== previous.hasSignalAccount;
if (signalAccountChanged) {
return true;
}
const currentReactions = message.reactions || [];
const lastReactions = previous.reactions || [];
const reactionsChanged =

View File

@ -11,7 +11,6 @@ import {
} from '../../sql/Interface';
import dataInterface from '../../sql/Client';
import { makeLookup } from '../../util/makeLookup';
import { BodyRangesType } from '../../types/Util';
import {
ConversationUnloadedActionType,
@ -32,9 +31,7 @@ const {
// State
export type MessageSearchResultType = MessageType & {
snippet: string;
body: string;
bodyRanges: BodyRangesType;
snippet?: string;
};
export type MessageSearchResultLookupType = {

View File

@ -13,6 +13,7 @@ export type UserStateType = {
stickersPath: string;
tempPath: string;
ourConversationId: string;
ourDeviceId: number;
ourUuid: string;
ourNumber: string;
platform: string;
@ -28,6 +29,7 @@ type UserChangedActionType = {
type: 'USER_CHANGED';
payload: {
ourConversationId?: string;
ourDeviceId?: number;
ourUuid?: string;
ourNumber?: string;
regionCode?: string;
@ -48,6 +50,7 @@ export const actions = {
function userChanged(attributes: {
interactionMode?: 'mouse' | 'keyboard';
ourConversationId?: string;
ourDeviceId?: number;
ourNumber?: string;
ourUuid?: string;
regionCode?: string;
@ -76,6 +79,7 @@ export function getEmptyState(): UserStateType {
stickersPath: 'missing',
tempPath: 'missing',
ourConversationId: 'missing',
ourDeviceId: 0,
ourUuid: 'missing',
ourNumber: 'missing',
regionCode: 'missing',

View File

@ -3,6 +3,7 @@
import { combineReducers } from 'redux';
import { reducer as accounts } from './ducks/accounts';
import { reducer as app } from './ducks/app';
import { reducer as audioPlayer } from './ducks/audioPlayer';
import { reducer as calling } from './ducks/calling';
@ -20,6 +21,7 @@ import { reducer as updates } from './ducks/updates';
import { reducer as user } from './ducks/user';
export const reducer = combineReducers({
accounts,
app,
audioPlayer,
calling,

View File

@ -0,0 +1,24 @@
// Copyright 2019-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { createSelector } from 'reselect';
import { StateType } from '../reducer';
import { AccountsStateType } from '../ducks/accounts';
export const getAccounts = (state: StateType): AccountsStateType =>
state.accounts;
export type AccountSelectorType = (identifier?: string) => boolean;
export const getAccountSelector = createSelector(
getAccounts,
(accounts: AccountsStateType): AccountSelectorType => {
return (identifier?: string) => {
if (!identifier) {
return false;
}
return accounts.accounts[identifier] || false;
};
}
);

View File

@ -8,16 +8,18 @@ import {
CallingStateType,
CallsByConversationType,
DirectCallStateType,
getActiveCall,
GroupCallStateType,
} from '../ducks/calling';
import { CallMode, CallState } from '../../types/Calling';
import { getOwn } from '../../util/getOwn';
export type CallStateType = DirectCallStateType | GroupCallStateType;
const getCalling = (state: StateType): CallingStateType => state.calling;
export const isInCall = createSelector(
export const getActiveCallState = createSelector(
getCalling,
(state: CallingStateType): boolean => Boolean(getActiveCall(state))
(state: CallingStateType) => state.activeCallState
);
export const getCallsByConversation = createSelector(
@ -26,10 +28,31 @@ export const getCallsByConversation = createSelector(
state.callsByConversation
);
export type CallSelectorType = (
conversationId: string
) => CallStateType | undefined;
export const getCallSelector = createSelector(
getCallsByConversation,
(callsByConversation: CallsByConversationType) => (conversationId: string) =>
getOwn(callsByConversation, conversationId)
(callsByConversation: CallsByConversationType): CallSelectorType => (
conversationId: string
) => getOwn(callsByConversation, conversationId)
);
export const getActiveCall = createSelector(
getActiveCallState,
getCallSelector,
(activeCallState, callSelector): undefined | CallStateType => {
if (activeCallState && activeCallState.conversationId) {
return callSelector(activeCallState.conversationId);
}
return undefined;
}
);
export const isInCall = createSelector(
getActiveCall,
(call: CallStateType | undefined): boolean => Boolean(call)
);
// In theory, there could be multiple incoming calls. In practice, neither RingRTC nor the

View File

@ -14,31 +14,35 @@ import {
ConversationType,
MessageLookupType,
MessagesByConversationType,
MessageType,
OneTimeModalState,
PreJoinConversationType,
} from '../ducks/conversations';
import { getOwn } from '../../util/getOwn';
import { deconstructLookup } from '../../util/deconstructLookup';
import type { CallsByConversationType } from '../ducks/calling';
import { getCallsByConversation } from './calling';
import { getBubbleProps } from '../../shims/Whisper';
import { PropsDataType as TimelinePropsType } from '../../components/conversation/Timeline';
import { TimelineItemType } from '../../components/conversation/TimelineItem';
import { assert } from '../../util/assert';
import { isConversationUnregistered } from '../../util/isConversationUnregistered';
import { filterAndSortConversationsByTitle } from '../../util/filterAndSortConversations';
import { ContactNameColors, ContactNameColorType } from '../../types/Colors';
import { isInSystemContacts } from '../../util/isInSystemContacts';
import {
getInteractionMode,
getIntl,
getRegionCode,
getUserConversationId,
getUserNumber,
getUserUuid,
} from './user';
import { getPinnedConversationIds } from './items';
import { isInSystemContacts } from '../../util/isInSystemContacts';
import { getPinnedConversationIds, getReadReceiptSetting } from './items';
import { getPropsForBubble } from './message';
import {
CallSelectorType,
CallStateType,
getActiveCall,
getCallSelector,
} from './calling';
import { getAccountSelector, AccountSelectorType } from './accounts';
let placeholderContact: ConversationType;
export const getPlaceholderContact = (): ConversationType => {
@ -640,66 +644,15 @@ export const getConversationByIdSelector = createSelector(
getOwn(conversationLookup, id)
);
// For now we use a shim, as selector logic is still happening in the Backbone Model.
// What needs to happen to pull that selector logic here?
// 1) translate ~500 lines of selector logic into TypeScript
// 2) other places still rely on that prop-gen code - need to put these under Roots:
// - quote compose
// - message details
export function _messageSelector(
message: MessageType,
_ourNumber: string,
_regionCode: string,
interactionMode: 'mouse' | 'keyboard',
_getConversationById: GetConversationByIdType,
_callsByConversation: CallsByConversationType,
selectedMessageId?: string,
selectedMessageCounter?: number
): TimelineItemType {
// Note: We don't use all of those parameters here, but the shim we call does.
// We want to call this function again if any of those parameters change.
const props = getBubbleProps(message);
if (selectedMessageId === message.id) {
return {
...props,
data: {
...props.data,
interactionMode,
isSelected: true,
isSelectedCounter: selectedMessageCounter,
},
};
}
return {
...props,
data: {
...props.data,
interactionMode,
},
};
}
// A little optimization to reset our selector cache whenever high-level application data
// changes: regionCode and userNumber.
type CachedMessageSelectorType = (
message: MessageType,
ourNumber: string,
regionCode: string,
interactionMode: 'mouse' | 'keyboard',
getConversationById: GetConversationByIdType,
callsByConversation: CallsByConversationType,
selectedMessageId?: string,
selectedMessageCounter?: number
) => TimelineItemType;
export const getCachedSelectorForMessage = createSelector(
getRegionCode,
getUserNumber,
(): CachedMessageSelectorType => {
(): typeof getPropsForBubble => {
// Note: memoizee will check all parameters provided, and only run our selector
// if any of them have changed.
return memoizee(_messageSelector, { max: 2000 });
return memoizee(getPropsForBubble, { max: 2000 });
}
);
@ -710,18 +663,26 @@ export const getMessageSelector = createSelector(
getSelectedMessage,
getConversationSelector,
getRegionCode,
getReadReceiptSetting,
getUserNumber,
getInteractionMode,
getCallsByConversation,
getUserUuid,
getUserConversationId,
getCallSelector,
getActiveCall,
getAccountSelector,
(
messageSelector: CachedMessageSelectorType,
messageSelector: typeof getPropsForBubble,
messageLookup: MessageLookupType,
selectedMessage: SelectedMessageType | undefined,
conversationSelector: GetConversationByIdType,
regionCode: string,
readReceiptSetting: boolean,
ourNumber: string,
interactionMode: 'keyboard' | 'mouse',
callsByConversation: CallsByConversationType
ourUuid: string,
ourConversationId: string,
callSelector: CallSelectorType,
activeCall: undefined | CallStateType,
accountSelector: AccountSelectorType
): GetMessageByIdType => {
return (id: string) => {
const message = messageLookup[id];
@ -731,13 +692,17 @@ export const getMessageSelector = createSelector(
return messageSelector(
message,
ourNumber,
regionCode,
interactionMode,
conversationSelector,
callsByConversation,
ourConversationId,
ourNumber,
ourUuid,
regionCode,
readReceiptSetting,
selectedMessage ? selectedMessage.id : undefined,
selectedMessage ? selectedMessage.counter : undefined
selectedMessage ? selectedMessage.counter : undefined,
callSelector,
activeCall,
accountSelector
);
};
}
@ -911,3 +876,14 @@ export const getConversationsWithCustomColorSelector = createSelector(
};
}
);
export function isMissingRequiredProfileSharing(
conversation: ConversationType
): boolean {
return Boolean(
!conversation.profileSharing &&
window.Signal.RemoteConfig.isEnabled('desktop.mandatoryProfileSharing') &&
conversation.messageCount &&
conversation.messageCount > 0
);
}

View File

@ -20,6 +20,11 @@ export const getUserAgent = createSelector(
(state: ItemsStateType): string => state.userAgent as string
);
export const getReadReceiptSetting = createSelector(
getItems,
(state: ItemsStateType): boolean => Boolean(state['read-receipt-setting'])
);
export const getPinnedConversationIds = createSelector(
getItems,
(state: ItemsStateType): Array<string> =>

File diff suppressed because it is too large Load Diff

View File

@ -143,7 +143,7 @@ export const getCachedSelectorForMessageSearchResult = createSelector(
id: message.id,
conversationId: message.conversationId,
sentAt: message.sent_at,
snippet: message.snippet,
snippet: message.snippet || '',
bodyRanges: bodyRanges.map((bodyRange: BodyRangeType) => {
const conversation = conversationSelector(bodyRange.mentionUuid);
@ -152,7 +152,7 @@ export const getCachedSelectorForMessageSearchResult = createSelector(
replacementText: conversation.title,
};
}),
body: message.body,
body: message.body || '',
isSelected: Boolean(
selectedMessageId && message.id === selectedMessageId

View File

@ -15,6 +15,11 @@ export const getUserNumber = createSelector(
(state: UserStateType): string => state.ourNumber
);
export const getUserDeviceId = createSelector(
getUser,
(state: UserStateType): number => state.ourDeviceId
);
export const getRegionCode = createSelector(
getUser,
(state: UserStateType): string => state.regionCode

View File

@ -10,7 +10,10 @@ import { isConversationSMSOnly } from '../../util/isConversationSMSOnly';
import { selectRecentEmojis } from '../selectors/emojis';
import { getIntl } from '../selectors/user';
import { getConversationSelector } from '../selectors/conversations';
import {
getConversationSelector,
isMissingRequiredProfileSharing,
} from '../selectors/conversations';
import {
getBlessedStickerPacks,
getInstalledStickerPacks,
@ -75,13 +78,8 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
conversationType: conversation.type,
isSMSOnly: Boolean(isConversationSMSOnly(conversation)),
isFetchingUUID: conversation.isFetchingUUID,
isMissingMandatoryProfileSharing: Boolean(
!conversation.profileSharing &&
window.Signal.RemoteConfig.isEnabled(
'desktop.mandatoryProfileSharing'
) &&
conversation.messageCount &&
conversation.messageCount > 0
isMissingMandatoryProfileSharing: isMissingRequiredProfileSharing(
conversation
),
};
};

View File

@ -7,7 +7,10 @@ import {
ConversationHeader,
OutgoingCallButtonStyle,
} from '../../components/conversation/ConversationHeader';
import { getConversationSelector } from '../selectors/conversations';
import {
getConversationSelector,
isMissingRequiredProfileSharing,
} from '../selectors/conversations';
import { StateType } from '../reducer';
import { CallMode } from '../../types/Calling';
import {
@ -111,13 +114,8 @@ const mapStateToProps = (state: StateType, ownProps: OwnProps) => {
'unblurredAvatarPath',
]),
conversationTitle: state.conversations.selectedConversationTitle,
isMissingMandatoryProfileSharing: Boolean(
!conversation.profileSharing &&
window.Signal.RemoteConfig.isEnabled(
'desktop.mandatoryProfileSharing'
) &&
conversation.messageCount &&
conversation.messageCount > 0
isMissingMandatoryProfileSharing: isMissingRequiredProfileSharing(
conversation
),
isSMSOnly: isConversationSMSOnly(conversation),
i18n: getIntl(state),

View File

@ -33,10 +33,12 @@ export type OwnProps = {
} & Pick<
MessageDetailProps,
| 'clearSelectedMessage'
| 'checkForAccount'
| 'deleteMessage'
| 'deleteMessageForEveryone'
| 'displayTapToViewMessage'
| 'downloadAttachment'
| 'doubleCheckMissingQuoteReference'
| 'kickOffAttachmentDownload'
| 'markAttachmentAsCorrupted'
| 'openConversation'
@ -66,11 +68,13 @@ const mapStateToProps = (
sendAnyway,
showSafetyNumber,
checkForAccount,
clearSelectedMessage,
deleteMessage,
deleteMessageForEveryone,
displayTapToViewMessage,
downloadAttachment,
doubleCheckMissingQuoteReference,
kickOffAttachmentDownload,
markAttachmentAsCorrupted,
openConversation,
@ -108,11 +112,13 @@ const mapStateToProps = (
sendAnyway,
showSafetyNumber,
checkForAccount,
clearSelectedMessage,
deleteMessage,
deleteMessageForEveryone,
displayTapToViewMessage,
downloadAttachment,
doubleCheckMissingQuoteReference,
kickOffAttachmentDownload,
markAttachmentAsCorrupted,
openConversation,

View File

@ -59,6 +59,7 @@ type ExternalProps = {
function renderItem(
messageId: string,
conversationId: string,
onHeightChange: (messageId: string) => unknown,
actionProps: Record<string, unknown>
): JSX.Element {
return (
@ -66,6 +67,7 @@ function renderItem(
{...actionProps}
conversationId={conversationId}
id={messageId}
onHeightChange={() => onHeightChange(messageId)}
renderEmojiPicker={renderEmojiPicker}
renderAudioAttachment={renderAudioAttachment}
/>

View File

@ -1,6 +1,7 @@
// Copyright 2020 Signal Messenger, LLC
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { actions as accounts } from './ducks/accounts';
import { actions as app } from './ducks/app';
import { actions as audioPlayer } from './ducks/audioPlayer';
import { actions as calling } from './ducks/calling';
@ -18,6 +19,7 @@ import { actions as updates } from './ducks/updates';
import { actions as user } from './ducks/user';
export type ReduxActions = {
accounts: typeof accounts;
app: typeof app;
audioPlayer: typeof audioPlayer;
calling: typeof calling;

View File

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import sinon from 'sinon';
import {
ConversationType,
@ -24,20 +25,35 @@ import { getDefaultConversation } from '../../helpers/getDefaultConversation';
import { StateType, reducer as rootReducer } from '../../../state/reducer';
describe('both/state/selectors/search', () => {
const NOW = 1_000_000;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let clock: any;
beforeEach(() => {
clock = sinon.useFakeTimers({
now: NOW,
});
});
afterEach(() => {
clock.restore();
});
const getEmptyRootState = (): StateType => {
return rootReducer(undefined, noopAction());
};
function getDefaultMessage(id: string): MessageType {
return {
id,
attachments: [],
conversationId: 'conversationId',
id,
received_at: NOW,
sent_at: NOW,
source: 'source',
sourceUuid: 'sourceUuid',
timestamp: NOW,
type: 'incoming' as const,
received_at: Date.now(),
attachments: [],
sticker: {},
unread: false,
};
}
@ -126,7 +142,7 @@ describe('both/state/selectors/search', () => {
id: searchId,
conversationId: toId,
sentAt: undefined,
sentAt: NOW,
snippet: 'snippet',
body: 'snippet',
bodyRanges: [],
@ -227,7 +243,7 @@ describe('both/state/selectors/search', () => {
id: searchId,
conversationId: toId,
sentAt: undefined,
sentAt: NOW,
snippet: 'snippet',
body: 'snippet',
bodyRanges: [],

View File

@ -5,6 +5,12 @@ import { assert } from 'chai';
import * as sinon from 'sinon';
import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json';
import {
isEndSession,
isGroupUpdate,
isIncoming,
isOutgoing,
} from '../../state/selectors/message';
describe('Message', () => {
const i18n = setupI18n('en', enMessages);
@ -122,9 +128,9 @@ describe('Message', () => {
it('checks if is incoming message', () => {
const messages = new window.Whisper.MessageCollection();
let message = messages.add(attributes);
assert.notOk(message.isIncoming());
assert.notOk(isIncoming(message.attributes));
message = messages.add({ type: 'incoming' });
assert.ok(message.isIncoming());
assert.ok(isIncoming(message.attributes));
});
});
@ -132,9 +138,9 @@ describe('Message', () => {
it('checks if is outgoing message', () => {
const messages = new window.Whisper.MessageCollection();
let message = messages.add(attributes);
assert.ok(message.isOutgoing());
assert.ok(isOutgoing(message.attributes));
message = messages.add({ type: 'incoming' });
assert.notOk(message.isOutgoing());
assert.notOk(isOutgoing(message.attributes));
});
});
@ -142,10 +148,10 @@ describe('Message', () => {
it('checks if is group update', () => {
const messages = new window.Whisper.MessageCollection();
let message = messages.add(attributes);
assert.notOk(message.isGroupUpdate());
assert.notOk(isGroupUpdate(message.attributes));
message = messages.add({ group_update: true });
assert.ok(message.isGroupUpdate());
assert.ok(isGroupUpdate(message.attributes));
});
});
@ -553,10 +559,10 @@ describe('Message', () => {
it('checks if it is end of the session', () => {
const messages = new window.Whisper.MessageCollection();
let message = messages.add(attributes);
assert.notOk(message.isEndSession());
assert.notOk(isEndSession(message.attributes));
message = messages.add({ type: 'incoming', source, flags: true });
assert.ok(message.isEndSession());
assert.ok(isEndSession(message.attributes));
});
});
});

View File

@ -292,6 +292,7 @@ describe('both/state/ducks/conversations', () => {
describe('reducer', () => {
const time = Date.now();
const previousTime = time - 1;
const conversationId = 'conversation-guid-1';
const messageId = 'message-guid-1';
const messageIdTwo = 'message-guid-2';
@ -299,14 +300,15 @@ describe('both/state/ducks/conversations', () => {
function getDefaultMessage(id: string): MessageType {
return {
id,
attachments: [],
conversationId: 'conversationId',
id,
received_at: previousTime,
sent_at: previousTime,
source: 'source',
sourceUuid: 'sourceUuid',
timestamp: previousTime,
type: 'incoming' as const,
received_at: Date.now(),
attachments: [],
sticker: {},
unread: false,
};
}
@ -953,6 +955,7 @@ describe('both/state/ducks/conversations', () => {
[messageId]: {
...getDefaultMessage(messageId),
received_at: time,
sent_at: time,
},
},
messagesByConversation: {
@ -972,6 +975,7 @@ describe('both/state/ducks/conversations', () => {
[messageId]: {
...getDefaultMessage(messageId),
received_at: time,
sent_at: time,
},
},
messagesByConversation: {
@ -983,6 +987,7 @@ describe('both/state/ducks/conversations', () => {
newest: {
id: messageId,
received_at: time,
sent_at: time,
},
},
},
@ -1060,6 +1065,7 @@ describe('both/state/ducks/conversations', () => {
[messageId]: {
...getDefaultMessage(messageId),
received_at: time,
sent_at: time,
},
},
messagesByConversation: {
@ -1079,6 +1085,7 @@ describe('both/state/ducks/conversations', () => {
[messageId]: {
...getDefaultMessage(messageId),
received_at: time,
sent_at: time,
},
},
messagesByConversation: {
@ -1090,6 +1097,7 @@ describe('both/state/ducks/conversations', () => {
oldest: {
id: messageId,
received_at: time,
sent_at: time,
},
},
},

View File

@ -3,6 +3,7 @@
import { assert } from 'chai';
import { IMAGE_GIF } from '../../types/MIME';
import { contactSelector, getName } from '../../types/Contact';
describe('Contact', () => {
@ -66,7 +67,8 @@ describe('Contact', () => {
});
describe('contactSelector', () => {
const regionCode = '1';
const signalAccount = '+1202555000';
const firstNumber = '+1202555000';
const isNumberOnSignal = false;
const getAbsoluteAttachmentPath = (path: string) => `absolute:${path}`;
it('eliminates avatar if it has had an attachment download error', () => {
@ -81,6 +83,7 @@ describe('Contact', () => {
isProfile: true,
avatar: {
error: true,
contentType: IMAGE_GIF,
},
},
};
@ -92,12 +95,14 @@ describe('Contact', () => {
},
organization: 'Somewhere, Inc.',
avatar: undefined,
signalAccount,
firstNumber,
isNumberOnSignal,
number: undefined,
};
const actual = contactSelector(contact, {
regionCode,
signalAccount,
firstNumber,
isNumberOnSignal,
getAbsoluteAttachmentPath,
});
assert.deepEqual(actual, expected);
@ -115,6 +120,7 @@ describe('Contact', () => {
isProfile: true,
avatar: {
pending: true,
contentType: IMAGE_GIF,
},
},
};
@ -130,14 +136,17 @@ describe('Contact', () => {
avatar: {
pending: true,
path: undefined,
contentType: IMAGE_GIF,
},
},
signalAccount,
firstNumber,
isNumberOnSignal,
number: undefined,
};
const actual = contactSelector(contact, {
regionCode,
signalAccount,
firstNumber,
isNumberOnSignal,
getAbsoluteAttachmentPath,
});
assert.deepEqual(actual, expected);
@ -155,6 +164,7 @@ describe('Contact', () => {
isProfile: true,
avatar: {
path: 'somewhere',
contentType: IMAGE_GIF,
},
},
};
@ -169,14 +179,17 @@ describe('Contact', () => {
isProfile: true,
avatar: {
path: 'absolute:somewhere',
contentType: IMAGE_GIF,
},
},
signalAccount,
firstNumber,
isNumberOnSignal: true,
number: undefined,
};
const actual = contactSelector(contact, {
regionCode,
signalAccount,
firstNumber,
isNumberOnSignal: true,
getAbsoluteAttachmentPath,
});
assert.deepEqual(actual, expected);

View File

@ -2489,10 +2489,14 @@ class MessageReceiverInner extends EventTarget {
async downloadAttachment(
attachment: AttachmentPointerClass
): Promise<DownloadAttachmentType> {
const encrypted = await this.server.getAttachment(
attachment.cdnId || attachment.cdnKey,
attachment.cdnNumber || 0
);
const cdnId = attachment.cdnId || attachment.cdnKey;
const { cdnNumber } = attachment;
if (!cdnId) {
throw new Error('downloadAttachment: Attachment was missing cdnId!');
}
const encrypted = await this.server.getAttachment(cdnId, cdnNumber);
const { key, digest, size } = attachment;
if (!digest) {

View File

@ -17,6 +17,7 @@ import {
compact,
Dictionary,
escapeRegExp,
isNumber,
mapValues,
zipObject,
} from 'lodash';
@ -933,7 +934,7 @@ export type WebAPIType = {
group: GroupClass,
options: GroupCredentialsType
) => Promise<void>;
getAttachment: (cdnKey: string, cdnNumber: number) => Promise<any>;
getAttachment: (cdnKey: string, cdnNumber?: number) => Promise<any>;
getAvatar: (path: string) => Promise<any>;
getDevices: () => Promise<any>;
getGroup: (options: GroupCredentialsType) => Promise<GroupClass>;
@ -1976,8 +1977,10 @@ export function initialize({
return packId;
}
async function getAttachment(cdnKey: string, cdnNumber: number) {
const cdnUrl = cdnUrlObject[cdnNumber] || cdnUrlObject['0'];
async function getAttachment(cdnKey: string, cdnNumber?: number) {
const cdnUrl = isNumber(cdnNumber)
? cdnUrlObject[cdnNumber] || cdnUrlObject['0']
: cdnUrlObject['0'];
// This is going to the CDN, not the service, so we use _outerAjax
return _outerAjax(`${cdnUrl}/attachments/${cdnKey}`, {
certificateAuthority,

View File

@ -21,10 +21,11 @@ const MIN_HEIGHT = 50;
// Used for display
export type AttachmentType = {
error?: boolean;
blurHash?: string;
caption?: string;
contentType: MIME.MIMEType;
fileName: string;
fileName?: string;
/** Not included in protobuf, needs to be pulled from flags */
isVoiceMessage?: boolean;
/** For messages not already on disk, this will be a data url */
@ -40,16 +41,25 @@ export type AttachmentType = {
width: number;
url: string;
contentType: MIME.MIMEType;
};
flags?: number;
thumbnail?: {
height: number;
width: number;
url: string;
contentType: MIME.MIMEType;
path: string;
};
flags?: number;
thumbnail?: ThumbnailType;
isCorrupted?: boolean;
downloadJobId?: string;
cdnNumber?: number;
cdnId?: string;
cdnKey?: string;
};
export type ThumbnailType = {
height: number;
width: number;
url: string;
contentType: MIME.MIMEType;
path: string;
// Only used when quote needed to make an in-memory thumbnail
objectUrl?: string;
};
// UI-focused functions
@ -58,7 +68,7 @@ export function getExtensionForDisplay({
fileName,
contentType,
}: {
fileName: string;
fileName?: string;
contentType: MIME.MIMEType;
}): string | undefined {
if (fileName && fileName.indexOf('.') >= 0) {
@ -354,7 +364,9 @@ export const isFile = (attachment: Attachment): boolean => {
return true;
};
export const isVoiceMessage = (attachment: Attachment): boolean => {
export const isVoiceMessage = (
attachment: Attachment | AttachmentType
): boolean => {
const flag = SignalService.AttachmentPointer.Flags.VOICE_MESSAGE;
const hasFlag =
// eslint-disable-next-line no-bitwise

View File

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { format as formatPhoneNumber } from './PhoneNumber';
import { AttachmentType } from './Attachment';
export type ContactType = {
name?: Name;
@ -10,7 +11,10 @@ export type ContactType = {
address?: Array<PostalAddress>;
avatar?: Avatar;
organization?: string;
signalAccount?: string;
// Populated by selector
firstNumber?: string;
isNumberOnSignal?: boolean;
};
type Name = {
@ -60,25 +64,25 @@ export type PostalAddress = {
};
type Avatar = {
avatar: Attachment;
avatar: AttachmentType;
isProfile: boolean;
};
type Attachment = {
path?: string;
error?: boolean;
pending?: boolean;
};
export function contactSelector(
contact: ContactType,
options: {
regionCode: string;
signalAccount?: string;
firstNumber?: string;
isNumberOnSignal?: boolean;
getAbsoluteAttachmentPath: (path: string) => string;
}
): ContactType {
const { getAbsoluteAttachmentPath, signalAccount, regionCode } = options;
const {
getAbsoluteAttachmentPath,
firstNumber,
isNumberOnSignal,
regionCode,
} = options;
let { avatar } = contact;
if (avatar && avatar.avatar) {
@ -99,7 +103,8 @@ export function contactSelector(
return {
...contact,
signalAccount,
firstNumber,
isNumberOnSignal,
avatar,
number:
contact.number &&

View File

@ -1,14 +1,14 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { DeletesModelType } from '../model-types.d';
import { DeleteModel } from '../messageModifiers/Deletes';
import { MessageModel } from '../models/messages';
const ONE_DAY = 24 * 60 * 60 * 1000;
export async function deleteForEveryone(
message: MessageModel,
doe: DeletesModelType,
doe: DeleteModel,
shouldPersist = true
): Promise<void> {
// Make sure the server timestamps for the DOE and the matching message

View File

@ -4,6 +4,7 @@
import { ConversationAttributesType } from '../model-types.d';
import { handleMessageSend } from './handleMessageSend';
import { sendReadReceiptsFor } from './sendReadReceiptsFor';
import { hasErrors } from '../state/selectors/message';
export async function markConversationRead(
conversationAttrs: ConversationAttributesType,
@ -74,7 +75,7 @@ export async function markConversationRead(
uuid: messageSyncData.sourceUuid,
}),
timestamp: messageSyncData.sent_at,
hasErrors: message ? message.hasErrors() : false,
hasErrors: message ? hasErrors(message.attributes) : false,
};
});

View File

@ -8,11 +8,11 @@ import { ConversationModel } from '../models/conversations';
import {
GroupV2PendingMemberType,
MessageModelCollectionType,
MessageAttributesType,
} from '../model-types.d';
import { LinkPreviewType } from '../types/message/LinkPreviews';
import { MediaItemType } from '../components/LightboxGallery';
import { MessageModel } from '../models/messages';
import { MessageType } from '../state/ducks/conversations';
import { assert } from '../util/assert';
import { maybeParseUrl } from '../util/url';
import { addReportSpamJob } from '../jobs/helpers/addReportSpamJob';
@ -24,6 +24,13 @@ import {
isGroupV2,
isMe,
} from '../util/whatTypeOfConversation';
import {
canReply,
getAttachmentsForMessage,
getPropsForQuote,
isOutgoing,
isTapToView,
} from '../state/selectors/message';
type GetLinkPreviewImageResult = {
data: ArrayBuffer;
@ -910,9 +917,9 @@ Whisper.ConversationView = Whisper.View.extend({
const isNewMessage = false;
messagesAdded(
id,
cleaned.map((messageModel: MessageModel) =>
messageModel.getReduxData()
),
cleaned.map((messageModel: MessageModel) => ({
...messageModel.attributes,
})),
isNewMessage,
window.isActive()
);
@ -965,9 +972,9 @@ Whisper.ConversationView = Whisper.View.extend({
const isNewMessage = false;
messagesAdded(
id,
cleaned.map((messageModel: MessageModel) =>
messageModel.getReduxData()
),
cleaned.map((messageModel: MessageModel) => ({
...messageModel.attributes,
})),
isNewMessage,
window.isActive()
);
@ -1210,9 +1217,9 @@ Whisper.ConversationView = Whisper.View.extend({
messagesReset(
conversationId,
cleaned.map((messageModel: MessageModel) =>
messageModel.getReduxData()
),
cleaned.map((messageModel: MessageModel) => ({
...messageModel.attributes,
})),
metrics,
scrollToMessageId
);
@ -1294,9 +1301,9 @@ Whisper.ConversationView = Whisper.View.extend({
const unboundedFetch = true;
messagesReset(
conversationId,
cleaned.map((messageModel: MessageModel) =>
messageModel.getReduxData()
),
cleaned.map((messageModel: MessageModel) => ({
...messageModel.attributes,
})),
metrics,
scrollToMessageId,
unboundedFetch
@ -2327,7 +2334,7 @@ Whisper.ConversationView = Whisper.View.extend({
throw new Error(`showForwardMessageModal: Message ${messageId} missing!`);
}
const attachments = message.getAttachmentsForMessage();
const attachments = getAttachmentsForMessage(message.attributes);
this.forwardMessageModal = new Whisper.ReactWrapperView({
JSX: window.Signal.State.Roots.createForwardMessageModal(
window.reduxStore,
@ -2557,7 +2564,10 @@ Whisper.ConversationView = Whisper.View.extend({
const message = rawMedia[i];
const { schemaVersion } = message;
if (schemaVersion < Message.VERSION_NEEDED_FOR_DISPLAY) {
if (
schemaVersion &&
schemaVersion < Message.VERSION_NEEDED_FOR_DISPLAY
) {
// Yep, we really do want to wait for each of these
// eslint-disable-next-line no-await-in-loop
rawMedia[i] = await upgradeMessageSchema(message);
@ -2871,7 +2881,7 @@ Whisper.ConversationView = Whisper.View.extend({
throw new Error(`displayTapToViewMessage: Message ${messageId} missing!`);
}
if (!message.isTapToView()) {
if (!isTapToView(message.attributes)) {
throw new Error(
`displayTapToViewMessage: Message ${message.idForLogging()} is not a tap to view message`
);
@ -2883,7 +2893,7 @@ Whisper.ConversationView = Whisper.View.extend({
);
}
const firstAttachment = message.get('attachments')[0];
const firstAttachment = (message.get('attachments') || [])[0];
if (!firstAttachment || !firstAttachment.path) {
throw new Error(
`displayTapToViewMessage: Message ${message.idForLogging()} had no first attachment with path`
@ -2955,7 +2965,7 @@ Whisper.ConversationView = Whisper.View.extend({
Message: Whisper.Message,
});
message.cleanup();
if (message.isOutgoing()) {
if (isOutgoing(message.attributes)) {
this.model.decrementSentMessageCount();
} else {
this.model.decrementMessageCount();
@ -3531,7 +3541,7 @@ Whisper.ConversationView = Whisper.View.extend({
async loadRecentMediaItems(limit: number): Promise<void> {
const { model }: { model: ConversationModel } = this;
const messages: Array<MessageType> = await window.Signal.Data.getMessagesWithVisualMediaAttachments(
const messages: Array<MessageAttributesType> = await window.Signal.Data.getMessagesWithVisualMediaAttachments(
model.id,
{
limit,
@ -3543,7 +3553,7 @@ Whisper.ConversationView = Whisper.View.extend({
.reduce(
(acc, message) => [
...acc,
...message.attachments.map(
...(message.attachments || []).map(
(attachment: AttachmentType, index: number): MediaItemType => {
const { thumbnail } = attachment;
@ -3792,7 +3802,12 @@ Whisper.ConversationView = Whisper.View.extend({
})
: undefined;
if (message && !message.canReply()) {
if (
message &&
!canReply(message.attributes, (id?: string) =>
message.findAndFormatContact(id)
)
) {
return;
}
@ -3855,7 +3870,11 @@ Whisper.ConversationView = Whisper.View.extend({
message.quotedMessage = this.quotedMessage;
this.quoteHolder = message;
const props = message.getPropsForQuote();
const props = getPropsForQuote(
message.attributes,
(id?: string) => message.findAndFormatContact(id),
window.ConversationController.getOurConversationIdOrThrow()
);
const contact = this.quotedMessage.getContact();

86
ts/window.d.ts vendored
View File

@ -19,7 +19,11 @@ import {
ReactionAttributesType,
ReactionModelType,
} from './model-types.d';
import { ContactRecordIdentityState, TextSecureType } from './textsecure.d';
import {
ContactRecordIdentityState,
TextSecureType,
DownloadAttachmentType,
} from './textsecure.d';
import { Storage } from './textsecure/Storage';
import {
ChallengeHandler,
@ -107,6 +111,7 @@ import { Quote } from './components/conversation/Quote';
import { StagedLinkPreview } from './components/conversation/StagedLinkPreview';
import { DisappearingTimeDialog } from './components/conversation/DisappearingTimeDialog';
import { MIMEType } from './types/MIME';
import { AttachmentType } from './types/Attachment';
import { ElectronLocaleType } from './util/mapToSupportLocale';
import { SignalProtocolStore } from './SignalProtocolStore';
import { StartupQueue } from './util/StartupQueue';
@ -222,16 +227,7 @@ declare global {
getRegionCodeForNumber: (number: string) => string;
format: (number: string, format: PhoneNumberFormat) => string;
};
log: {
fatal: LoggerType;
info: LoggerType;
warn: LoggerType;
error: LoggerType;
debug: LoggerType;
trace: LoggerType;
fetch: () => Promise<string>;
publish: typeof uploadDebugLogs;
};
log: LoggerType;
nodeSetImmediate: typeof setImmediate;
normalizeUuids: (obj: any, paths: Array<string>, context: string) => void;
onFullScreenChange: (fullScreen: boolean) => void;
@ -275,14 +271,6 @@ declare global {
};
Signal: {
Backbone: any;
AttachmentDownloads: {
addJob: <T = unknown>(
attachment: unknown,
options: unknown
) => Promise<T>;
start: (options: WhatIsThis) => void;
stop: () => void;
};
Crypto: typeof Crypto;
Curve: typeof Curve;
Data: typeof Data;
@ -317,6 +305,9 @@ declare global {
loadStickerData: (sticker: unknown) => WhatIsThis;
readStickerData: (path: string) => Promise<ArrayBuffer>;
upgradeMessageSchema: (attributes: unknown) => WhatIsThis;
processNewAttachment: (
attachment: DownloadAttachmentType
) => Promise<AttachmentType>;
copyIntoTempDirectory: any;
deleteDraftFile: any;
@ -545,13 +536,6 @@ declare global {
WebAPI: WebAPIConnectType;
Whisper: WhisperType;
AccountCache: Record<string, boolean>;
AccountJobs: Record<string, Promise<void>>;
doesAccountCheckJobExist: (number: string) => boolean;
checkForSignalAccount: (number: string) => Promise<void>;
isSignalAccountCheckComplete: (number: string) => boolean;
hasSignalAccount: (number: string) => boolean;
getServerTrustRoot: () => WhatIsThis;
readyForUpdates: () => void;
logAppLoadedEvent: (options: { processedCount?: number }) => void;
@ -648,7 +632,18 @@ export class CanvasVideoRenderer {
constructor(canvas: Ref<HTMLCanvasElement>);
}
export type LoggerType = (...args: Array<unknown>) => void;
export type LoggerType = {
fatal: LogFunctionType;
info: LogFunctionType;
warn: LogFunctionType;
error: LogFunctionType;
debug: LogFunctionType;
trace: LogFunctionType;
fetch: () => Promise<string>;
publish: typeof uploadDebugLogs;
};
export type LogFunctionType = (...args: Array<unknown>) => void;
export type WhisperType = {
events: {
@ -685,7 +680,6 @@ export type WhisperType = {
ConversationUnarchivedToast: WhatIsThis;
ConversationMarkedUnreadToast: WhatIsThis;
WallClockListener: WhatIsThis;
MessageRequests: WhatIsThis;
BannerView: any;
RecorderView: any;
GroupMemberList: any;
@ -712,42 +706,6 @@ export type WhisperType = {
) => void;
};
DeliveryReceipts: {
add: (receipt: WhatIsThis) => void;
forMessage: (conversation: unknown, message: unknown) => Array<WhatIsThis>;
onReceipt: (receipt: WhatIsThis) => void;
};
ReadReceipts: {
add: (receipt: WhatIsThis) => WhatIsThis;
forMessage: (conversation: unknown, message: unknown) => Array<WhatIsThis>;
onReceipt: (receipt: WhatIsThis) => void;
};
ReadSyncs: {
add: (sync: WhatIsThis) => WhatIsThis;
forMessage: (message: unknown) => WhatIsThis;
onReceipt: (receipt: WhatIsThis) => WhatIsThis;
};
ViewSyncs: {
add: (sync: WhatIsThis) => WhatIsThis;
forMessage: (message: unknown) => Array<WhatIsThis>;
onSync: (sync: WhatIsThis) => WhatIsThis;
};
Reactions: {
forMessage: (message: unknown) => Array<ReactionModelType>;
add: (reaction: ReactionAttributesType) => ReactionModelType;
onReaction: (reactionModel: ReactionModelType) => ReactionAttributesType;
};
Deletes: {
add: (model: WhatIsThis) => WhatIsThis;
forMessage: (message: unknown) => Array<WhatIsThis>;
onDelete: (model: WhatIsThis) => void;
};
IdenticonSVGView: WhatIsThis;
ExpiringMessagesListener: WhatIsThis;