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='libtextsecure/protobufs.js'></script>
<script type='text/javascript' src='js/notifications.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/libphonenumber-util.js'></script>
<script type='text/javascript' src='js/expiring_messages.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/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/react_wrapper_view.js'></script>
<script type='text/javascript' src='js/views/list_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> <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 RemoteConfig = require('../../ts/RemoteConfig');
const Util = require('../../ts/util'); const Util = require('../../ts/util');
const LinkPreviews = require('./link_previews'); const LinkPreviews = require('./link_previews');
const AttachmentDownloads = require('./attachment_downloads');
// Components // Components
const { const {
@ -443,7 +442,6 @@ exports.setup = (options = {}) => {
}; };
return { return {
AttachmentDownloads,
Backbone, Backbone,
Components, Components,
Crypto, 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.dataURLToBlobSync = require('blueimp-canvas-to-blob');
window.imageToBlurHash = imageToBlurHash; window.imageToBlurHash = imageToBlurHash;
window.emojiData = require('emoji-datasource'); window.emojiData = require('emoji-datasource');
window.filesize = require('filesize');
window.libphonenumber = require('google-libphonenumber').PhoneNumberUtil.getInstance(); window.libphonenumber = require('google-libphonenumber').PhoneNumberUtil.getInstance();
window.libphonenumber.PhoneNumberFormat = require('google-libphonenumber').PhoneNumberFormat; window.libphonenumber.PhoneNumberFormat = require('google-libphonenumber').PhoneNumberFormat;
window.loadImage = require('blueimp-load-image'); 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="../libtextsecure/protobufs.js"></script>
<script type="text/javascript" src="../js/libphonenumber-util.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/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_messages.js' data-cover></script>
<script type='text/javascript' src='../js/expiring_tap_to_view_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( async getConversationForTargetMessage(
targetFromId: string, targetFromId: string,
targetTimestamp: number targetTimestamp: number
): Promise<boolean | ConversationModel | null | undefined> { ): Promise<ConversationModel | null | undefined> {
const messages = await getMessagesBySentAt(targetTimestamp, { const messages = await getMessagesBySentAt(targetTimestamp, {
MessageCollection: window.Whisper.MessageCollection, MessageCollection: window.Whisper.MessageCollection,
}); });

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -33,6 +33,7 @@ export type Props = {
rawAttachment?: QuotedAttachmentType; rawAttachment?: QuotedAttachmentType;
isViewOnce: boolean; isViewOnce: boolean;
referencedMessageNotFound: boolean; referencedMessageNotFound: boolean;
doubleCheckMissingQuoteReference: () => unknown;
}; };
type State = { type State = {
@ -41,7 +42,7 @@ type State = {
export type QuotedAttachmentType = { export type QuotedAttachmentType = {
contentType: MIME.MIMEType; contentType: MIME.MIMEType;
fileName: string; fileName?: string;
/** Not included in protobuf */ /** Not included in protobuf */
isVoiceMessage: boolean; isVoiceMessage: boolean;
thumbnail?: Attachment; 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 = ( public handleKeyDown = (
event: React.KeyboardEvent<HTMLButtonElement> event: React.KeyboardEvent<HTMLButtonElement>
): void => { ): void => {

View file

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

View file

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

View file

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

View file

@ -4,9 +4,9 @@
import React from 'react'; import React from 'react';
import { LocalizerType, ThemeType } from '../../types/Util'; import { LocalizerType, ThemeType } from '../../types/Util';
import { InteractionModeType } from '../../state/ducks/conversations';
import { import {
Message, Message,
InteractionModeType,
Props as AllMessageProps, Props as AllMessageProps,
PropsActions as MessageActionsType, PropsActions as MessageActionsType,
PropsData as MessageProps, 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 // SPDX-License-Identifier: AGPL-3.0-only
/* global import { isFunction, isNumber, omit } from 'lodash';
Whisper, import { v4 as getGuid } from 'uuid';
Signal,
setTimeout, import dataInterface from '../sql/Client';
clearTimeout, import { downloadAttachment } from '../util/downloadAttachment';
MessageController 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 { const {
getMessageById, getMessageById,
getNextAttachmentDownloadJobs, getNextAttachmentDownloadJobs,
@ -19,15 +25,7 @@ const {
saveAttachmentDownloadJob, saveAttachmentDownloadJob,
saveMessage, saveMessage,
setAttachmentDownloadJobPending, setAttachmentDownloadJobPending,
} = require('../../ts/sql/Client').default; } = dataInterface;
const { downloadAttachment } = require('../../ts/util/downloadAttachment');
const { stringFromBytes } = require('../../ts/Crypto');
module.exports = {
start,
stop,
addJob,
};
const MAX_ATTACHMENT_JOB_PARALLELISM = 3; const MAX_ATTACHMENT_JOB_PARALLELISM = 3;
@ -36,19 +34,27 @@ const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE; const HOUR = 60 * MINUTE;
const TICK_INTERVAL = MINUTE; const TICK_INTERVAL = MINUTE;
const RETRY_BACKOFF = { const RETRY_BACKOFF: Record<number, number> = {
1: 30 * SECOND, 1: 30 * SECOND,
2: 30 * MINUTE, 2: 30 * MINUTE,
3: 6 * HOUR, 3: 6 * HOUR,
}; };
let enabled = false; let enabled = false;
let timeout; let timeout: NodeJS.Timeout | null;
let getMessageReceiver; let getMessageReceiver: () => MessageReceiver | undefined;
let logger; let logger: LoggerType;
const _activeAttachmentDownloadJobs = {}; 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); ({ getMessageReceiver, logger } = options);
if (!isFunction(getMessageReceiver)) { if (!isFunction(getMessageReceiver)) {
throw new Error( throw new Error(
@ -66,7 +72,7 @@ async function start(options = {}) {
_tick(); _tick();
} }
async function stop() { export async function stop(): Promise<void> {
// If `.start()` wasn't called - the `logger` is `undefined` // If `.start()` wasn't called - the `logger` is `undefined`
if (logger) { if (logger) {
logger.info('attachment_downloads/stop: disabling'); 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) { if (!attachment) {
throw new Error('attachments_download/addJob: attachment is required'); throw new Error('attachments_download/addJob: attachment is required');
} }
@ -96,7 +105,7 @@ async function addJob(attachment, job = {}) {
const id = getGuid(); const id = getGuid();
const timestamp = Date.now(); const timestamp = Date.now();
const toSave = { const toSave: AttachmentDownloadJobType = {
...job, ...job,
id, id,
attachment, attachment,
@ -116,7 +125,7 @@ async function addJob(attachment, job = {}) {
}; };
} }
async function _tick() { async function _tick(): Promise<void> {
if (timeout) { if (timeout) {
clearTimeout(timeout); clearTimeout(timeout);
timeout = null; timeout = null;
@ -126,7 +135,7 @@ async function _tick() {
timeout = setTimeout(_tick, TICK_INTERVAL); timeout = setTimeout(_tick, TICK_INTERVAL);
} }
async function _maybeStartJob() { async function _maybeStartJob(): Promise<void> {
if (!enabled) { if (!enabled) {
logger.info('attachment_downloads/_maybeStartJob: not enabled, returning'); logger.info('attachment_downloads/_maybeStartJob: not enabled, returning');
return; return;
@ -178,8 +187,13 @@ async function _maybeStartJob() {
} }
} }
async function _runJob(job) { async function _runJob(job?: AttachmentDownloadJobType): Promise<void> {
const { id, messageId, attachment, type, index, attempts } = job || {}; if (!job) {
window.log.warn('_runJob: Job was missing!');
return;
}
const { id, messageId, attachment, type, index, attempts } = job;
let message; let message;
try { try {
@ -192,16 +206,16 @@ async function _runJob(job) {
logger.info(`attachment_downloads/_runJob for job id ${id}`); logger.info(`attachment_downloads/_runJob for job id ${id}`);
const found = const found =
MessageController.getById(messageId) || window.MessageController.getById(messageId) ||
(await getMessageById(messageId, { (await getMessageById(messageId, {
Message: Whisper.Message, Message: window.Whisper.Message,
})); }));
if (!found) { if (!found) {
logger.error('_runJob: Source message not found, deleting job'); logger.error('_runJob: Source message not found, deleting job');
await _finishJob(null, id); await _finishJob(null, id);
return; return;
} }
message = MessageController.register(found.id, found); message = window.MessageController.register(found.id, found);
const pending = true; const pending = true;
await setAttachmentDownloadJobPending(id, pending); await setAttachmentDownloadJobPending(id, pending);
@ -231,7 +245,7 @@ async function _runJob(job) {
return; return;
} }
const upgradedAttachment = await Signal.Migrations.processNewAttachment( const upgradedAttachment = await window.Signal.Migrations.processNewAttachment(
downloaded downloaded
); );
@ -239,11 +253,12 @@ async function _runJob(job) {
await _finishJob(message, id); await _finishJob(message, id);
} catch (error) { } catch (error) {
const logId = message ? message.idForLogging() : id || '<no id>';
const currentAttempt = (attempts || 0) + 1; const currentAttempt = (attempts || 0) + 1;
if (currentAttempt >= 3) { if (currentAttempt >= 3) {
logger.error( 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 error && error.stack ? error.stack : error
); );
@ -258,7 +273,7 @@ async function _runJob(job) {
} }
logger.error( 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 error && error.stack ? error.stack : error
); );
@ -266,7 +281,8 @@ async function _runJob(job) {
...job, ...job,
pending: 0, pending: 0,
attempts: currentAttempt, attempts: currentAttempt,
timestamp: Date.now() + RETRY_BACKOFF[currentAttempt], timestamp:
Date.now() + (RETRY_BACKOFF[currentAttempt] || RETRY_BACKOFF[3]),
}; };
await saveAttachmentDownloadJob(failedJob); 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) { if (message) {
logger.info(`attachment_downloads/_finishJob for job id: ${id}`); logger.info(`attachment_downloads/_finishJob for job id: ${id}`);
await saveMessage(message.attributes, { await saveMessage(message.attributes, {
Message: Whisper.Message, Message: window.Whisper.Message,
}); });
} }
@ -288,18 +307,22 @@ async function _finishJob(message, id) {
_maybeStartJob(); _maybeStartJob();
} }
function getActiveJobCount() { function getActiveJobCount(): number {
return Object.keys(_activeAttachmentDownloadJobs).length; return Object.keys(_activeAttachmentDownloadJobs).length;
} }
function _markAttachmentAsError(attachment) { function _markAttachmentAsError(attachment: AttachmentType): AttachmentType {
return { return {
...omit(attachment, ['key', 'digest', 'id']), ...omit(attachment, ['key', 'digest', 'id']),
error: true, 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) { if (!message) {
return; return;
} }
@ -308,13 +331,17 @@ async function _addAttachmentToMessage(message, attachment, { type, index }) {
if (type === 'long-message') { if (type === 'long-message') {
try { try {
const { data } = await Signal.Migrations.loadAttachmentData(attachment); const { data } = await window.Signal.Migrations.loadAttachmentData(
attachment
);
message.set({ message.set({
body: attachment.isError ? message.get('body') : stringFromBytes(data), body: attachment.error ? message.get('body') : stringFromBytes(data),
bodyPending: false, bodyPending: false,
}); });
} finally { } finally {
Signal.Migrations.deleteAttachmentData(attachment.path); if (attachment.path) {
window.Signal.Migrations.deleteAttachmentData(attachment.path);
}
} }
return; return;
} }
@ -326,7 +353,7 @@ async function _addAttachmentToMessage(message, attachment, { type, index }) {
`_addAttachmentToMessage: attachments didn't exist or ${index} was too large` `_addAttachmentToMessage: attachments didn't exist or ${index} was too large`
); );
} }
_checkOldAttachment(attachments, index, attachment, logPrefix); _checkOldAttachment(attachments, index.toString(), logPrefix);
const newAttachments = [...attachments]; const newAttachments = [...attachments];
newAttachments[index] = attachment; newAttachments[index] = attachment;
@ -348,7 +375,7 @@ async function _addAttachmentToMessage(message, attachment, { type, index }) {
throw new Error(`_addAttachmentToMessage: preview ${index} was falsey`); throw new Error(`_addAttachmentToMessage: preview ${index} was falsey`);
} }
_checkOldAttachment(item, 'image', attachment, logPrefix); _checkOldAttachment(item, 'image', logPrefix);
const newPreview = [...preview]; const newPreview = [...preview];
newPreview[index] = { newPreview[index] = {
@ -370,13 +397,13 @@ async function _addAttachmentToMessage(message, attachment, { type, index }) {
} }
const item = contact[index]; const item = contact[index];
if (item && item.avatar && item.avatar.avatar) { if (item && item.avatar && item.avatar.avatar) {
_checkOldAttachment(item.avatar, 'avatar', attachment, logPrefix); _checkOldAttachment(item.avatar, 'avatar', logPrefix);
const newContact = [...contact]; const newContact = [...contact];
newContact[index] = { newContact[index] = {
...contact[index], ...item,
avatar: { avatar: {
...contact[index].avatar, ...item.avatar,
avatar: attachment, 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]; const newAttachments = [...attachments];
newAttachments[index] = { 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]; const oldAttachment = object[key];
if (oldAttachment && oldAttachment.path) { if (oldAttachment && oldAttachment.path) {
logger.error( 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 { LocalizerType, BodyRangeType, BodyRangesType } from './types/Util';
import { CallHistoryDetailsFromDiskType } from './types/Calling'; import { CallHistoryDetailsFromDiskType } from './types/Calling';
import { CustomColorType } from './types/Colors'; import { CustomColorType } from './types/Colors';
import {
ConversationType,
MessageType,
LastMessageStatus,
} from './state/ducks/conversations';
import { DeviceType } from './textsecure/Types'; import { DeviceType } from './textsecure/Types';
import { SendOptionsType } from './textsecure/SendMessage'; import { SendOptionsType } from './textsecure/SendMessage';
import { SendMessageChallengeData } from './textsecure/Errors'; import { SendMessageChallengeData } from './textsecure/Errors';
@ -27,19 +22,19 @@ import { ProfileNameChangeType } from './util/getStringForProfileChange';
import { CapabilitiesType } from './textsecure/WebAPI'; import { CapabilitiesType } from './textsecure/WebAPI';
import { GroupNameCollisionsWithIdsByTitle } from './util/groupMemberNameCollisions'; import { GroupNameCollisionsWithIdsByTitle } from './util/groupMemberNameCollisions';
import { ConversationColorType } from './types/Colors'; import { ConversationColorType } from './types/Colors';
import { AttachmentType, ThumbnailType } from './types/Attachment';
import { ContactType } from './types/Contact';
export type WhatIsThis = any; export type WhatIsThis = any;
type DeletesAttributesType = { export type LastMessageStatus =
fromId: string; | 'paused'
serverTimestamp: number; | 'error'
targetSentTimestamp: number; | 'partial-sent'
}; | 'sending'
| 'sent'
export declare class DeletesModelType extends Backbone.Model<DeletesAttributesType> { | 'delivered'
forMessage(message: MessageModel): Array<DeletesModelType>; | 'read';
onDelete(doe: DeletesAttributesType): Promise<void>;
}
type TaskResultType = any; type TaskResultType = any;
@ -77,38 +72,38 @@ export type RetryOptions = Readonly<{
}>; }>;
export type MessageAttributesType = { export type MessageAttributesType = {
bodyPending: boolean; bodyPending?: boolean;
bodyRanges: BodyRangesType; bodyRanges?: BodyRangesType;
callHistoryDetails: CallHistoryDetailsFromDiskType; callHistoryDetails?: CallHistoryDetailsFromDiskType;
changedId: string; changedId?: string;
dataMessage: ArrayBuffer | null; dataMessage?: ArrayBuffer | null;
decrypted_at: number; decrypted_at?: number;
deletedForEveryone: boolean; deletedForEveryone?: boolean;
deletedForEveryoneTimestamp?: number; deletedForEveryoneTimestamp?: number;
delivered: number; delivered?: number;
delivered_to: Array<string | null>; delivered_to?: Array<string | null>;
errors?: Array<CustomError>; errors?: Array<CustomError>;
expirationStartTimestamp: number | null; expirationStartTimestamp?: number | null;
expireTimer: number; expireTimer?: number;
groupMigration?: GroupMigrationType; groupMigration?: GroupMigrationType;
group_update: { group_update?: {
avatarUpdated: boolean; avatarUpdated: boolean;
joined: Array<string>; joined: Array<string>;
left: string | 'You'; left: string | 'You';
name: string; name: string;
}; };
hasAttachments: boolean; hasAttachments?: boolean;
hasFileAttachments: boolean; hasFileAttachments?: boolean;
hasVisualMediaAttachments: boolean; hasVisualMediaAttachments?: boolean;
isErased: boolean; isErased?: boolean;
isTapToViewInvalid: boolean; isTapToViewInvalid?: boolean;
isViewOnce: boolean; isViewOnce?: boolean;
key_changed: string; key_changed?: string;
local: boolean; local?: boolean;
logger: unknown; logger?: unknown;
message: unknown; message?: unknown;
messageTimer: unknown; messageTimer?: unknown;
profileChange: ProfileNameChangeType; profileChange?: ProfileNameChangeType;
quote?: QuotedMessageType; quote?: QuotedMessageType;
reactions?: Array<{ reactions?: Array<{
emoji: string; emoji: string;
@ -117,20 +112,19 @@ export type MessageAttributesType = {
targetTimestamp: number; targetTimestamp: number;
timestamp: number; timestamp: number;
}>; }>;
read_by: Array<string | null>; read_by?: Array<string | null>;
requiredProtocolVersion: number; requiredProtocolVersion?: number;
retryOptions?: RetryOptions; retryOptions?: RetryOptions;
sent: boolean; sent?: boolean;
sourceDevice: string | number; sourceDevice?: string | number;
snippet: unknown; supportedVersionAtReceive?: unknown;
supportedVersionAtReceive: unknown; synced?: boolean;
synced: boolean; unidentifiedDeliveryReceived?: boolean;
unidentifiedDeliveryReceived: boolean; verified?: boolean;
verified: boolean; verifiedChanged?: string;
verifiedChanged: string;
id: string; id: string;
type?: type:
| 'call-history' | 'call-history'
| 'chat-session-refreshed' | 'chat-session-refreshed'
| 'delivery-issue' | 'delivery-issue'
@ -145,17 +139,22 @@ export type MessageAttributesType = {
| 'timer-notification' | 'timer-notification'
| 'universal-timer-notification' | 'universal-timer-notification'
| 'verified-change'; | 'verified-change';
body: string; body?: string;
attachments: Array<WhatIsThis>; attachments?: Array<AttachmentType>;
preview: Array<WhatIsThis>; preview?: Array<WhatIsThis>;
sticker: WhatIsThis; sticker?: {
packId: string;
stickerId: number;
packKey: string;
data?: AttachmentType;
};
sent_at: number; sent_at: number;
sent_to: Array<string>; sent_to?: Array<string>;
unidentifiedDeliveries: Array<string>; unidentifiedDeliveries?: Array<string>;
contact: Array<WhatIsThis>; contact?: Array<ContactType>;
conversationId: string; conversationId: string;
recipients: Array<string>; recipients?: Array<string>;
reaction: WhatIsThis; reaction?: WhatIsThis;
destination?: WhatIsThis; destination?: WhatIsThis;
destinationUuid?: string; 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 // 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 // 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 // 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 // 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. // may not have these for outbound messages, either, as we have not needed them.
serverGuid?: string; serverGuid?: string;
@ -182,7 +181,7 @@ export type MessageAttributesType = {
source?: string; source?: string;
sourceUuid?: string; sourceUuid?: string;
unread: boolean; unread?: boolean;
timestamp: number; timestamp: number;
// Backwards-compatibility with prerelease data schema // Backwards-compatibility with prerelease data schema

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,15 +1,6 @@
// Copyright 2019-2020 Signal Messenger, LLC // Copyright 2019-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // 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 { export function showSettings(): void {
window.showSettings(); window.showSettings();
} }

View file

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

View file

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

View file

@ -1,6 +1,7 @@
// Copyright 2019-2020 Signal Messenger, LLC // Copyright 2019-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { actions as accounts } from './ducks/accounts';
import { actions as app } from './ducks/app'; import { actions as app } from './ducks/app';
import { actions as audioPlayer } from './ducks/audioPlayer'; import { actions as audioPlayer } from './ducks/audioPlayer';
import { actions as calling } from './ducks/calling'; import { actions as calling } from './ducks/calling';
@ -19,6 +20,7 @@ import { actions as user } from './ducks/user';
import { ReduxActions } from './types'; import { ReduxActions } from './types';
export const actionCreators: ReduxActions = { export const actionCreators: ReduxActions = {
accounts,
app, app,
audioPlayer, audioPlayer,
calling, calling,
@ -37,6 +39,7 @@ export const actionCreators: ReduxActions = {
}; };
export const mapDispatchToProps = { export const mapDispatchToProps = {
...accounts,
...app, ...app,
...audioPlayer, ...audioPlayer,
...calling, ...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 { getOwn } from '../../util/getOwn';
import { assert } from '../../util/assert'; import { assert } from '../../util/assert';
import { trigger } from '../../shims/events'; import { trigger } from '../../shims/events';
import { AttachmentType } from '../../types/Attachment';
import { import {
AvatarColorType, AvatarColorType,
ConversationColorType, ConversationColorType,
@ -29,9 +29,13 @@ import {
DefaultConversationColorType, DefaultConversationColorType,
DEFAULT_CONVERSATION_COLOR, DEFAULT_CONVERSATION_COLOR,
} from '../../types/Colors'; } from '../../types/Colors';
import { ConversationAttributesType } from '../../model-types.d'; import {
LastMessageStatus,
ConversationAttributesType,
MessageAttributesType,
} from '../../model-types.d';
import { BodyRangeType } from '../../types/Util'; import { BodyRangeType } from '../../types/Util';
import { CallMode, CallHistoryDetailsFromDiskType } from '../../types/Calling'; import { CallMode } from '../../types/Calling';
import { MediaItemType } from '../../components/LightboxGallery'; import { MediaItemType } from '../../components/LightboxGallery';
import { import {
getGroupSizeRecommendedLimit, getGroupSizeRecommendedLimit,
@ -41,6 +45,8 @@ import { toggleSelectedContactForGroupAddition } from '../../groups/toggleSelect
import { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCollisions'; import { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCollisions';
import { ContactSpoofingType } from '../../util/contactSpoofing'; import { ContactSpoofingType } from '../../util/contactSpoofing';
import { NoopActionType } from './noop';
// State // State
export type DBConversationType = { export type DBConversationType = {
@ -50,16 +56,15 @@ export type DBConversationType = {
type: string; type: string;
}; };
export type LastMessageStatus = export const InteractionModes = ['mouse', 'keyboard'] as const;
| 'paused' export type InteractionModeType = typeof InteractionModes[number];
| 'error'
| 'partial-sent'
| 'sending'
| 'sent'
| 'delivered'
| 'read';
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 = { export type ConversationType = {
id: string; id: string;
@ -159,52 +164,6 @@ export type CustomError = Error & {
identifier?: string; identifier?: string;
number?: 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 = { type MessagePointerType = {
id: string; id: string;
@ -219,7 +178,7 @@ type MessageMetricsType = {
}; };
export type MessageLookupType = { export type MessageLookupType = {
[key: string]: MessageType; [key: string]: MessageAttributesType;
}; };
export type ConversationMessageType = { export type ConversationMessageType = {
heightChangeMessageIds: Array<string>; heightChangeMessageIds: Array<string>;
@ -460,7 +419,7 @@ export type MessageChangedActionType = {
payload: { payload: {
id: string; id: string;
conversationId: string; conversationId: string;
data: MessageType; data: MessageAttributesType;
}; };
}; };
export type MessageDeletedActionType = { export type MessageDeletedActionType = {
@ -481,7 +440,7 @@ export type MessagesAddedActionType = {
type: 'MESSAGES_ADDED'; type: 'MESSAGES_ADDED';
payload: { payload: {
conversationId: string; conversationId: string;
messages: Array<MessageType>; messages: Array<MessageAttributesType>;
isNewMessage: boolean; isNewMessage: boolean;
isActive: boolean; isActive: boolean;
}; };
@ -503,7 +462,7 @@ export type MessagesResetActionType = {
type: 'MESSAGES_RESET'; type: 'MESSAGES_RESET';
payload: { payload: {
conversationId: string; conversationId: string;
messages: Array<MessageType>; messages: Array<MessageAttributesType>;
metrics: MessageMetricsType; metrics: MessageMetricsType;
scrollToMessageId?: string; scrollToMessageId?: string;
// The set of provided messages should be trusted, even if it conflicts with metrics, // The set of provided messages should be trusted, even if it conflicts with metrics,
@ -701,6 +660,7 @@ export const actions = {
conversationUnloaded, conversationUnloaded,
colorSelected, colorSelected,
createGroup, createGroup,
doubleCheckMissingQuoteReference,
messageChanged, messageChanged,
messageDeleted, messageDeleted,
messagesAdded, messagesAdded,
@ -990,7 +950,7 @@ function selectMessage(
function messageChanged( function messageChanged(
id: string, id: string,
conversationId: string, conversationId: string,
data: MessageType data: MessageAttributesType
): MessageChangedActionType { ): MessageChangedActionType {
return { return {
type: 'MESSAGE_CHANGED', type: 'MESSAGE_CHANGED',
@ -1027,7 +987,7 @@ function messageSizeChanged(
} }
function messagesAdded( function messagesAdded(
conversationId: string, conversationId: string,
messages: Array<MessageType>, messages: Array<MessageAttributesType>,
isNewMessage: boolean, isNewMessage: boolean,
isActive: boolean isActive: boolean
): MessagesAddedActionType { ): MessagesAddedActionType {
@ -1082,7 +1042,7 @@ function reviewMessageRequestNameCollision(
function messagesReset( function messagesReset(
conversationId: string, conversationId: string,
messages: Array<MessageType>, messages: Array<MessageAttributesType>,
metrics: MessageMetricsType, metrics: MessageMetricsType,
scrollToMessageId?: string, scrollToMessageId?: string,
unboundedFetch?: boolean 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 // Reducer
export function getEmptyState(): ConversationsStateType { export function getEmptyState(): ConversationsStateType {
@ -1363,8 +1335,8 @@ export function getEmptyState(): ConversationsStateType {
} }
function hasMessageHeightChanged( function hasMessageHeightChanged(
message: MessageType, message: MessageAttributesType,
previous: MessageType previous: MessageAttributesType
): boolean { ): boolean {
const messageAttachments = message.attachments || []; const messageAttachments = message.attachments || [];
const previousAttachments = previous.attachments || []; const previousAttachments = previous.attachments || [];
@ -1410,13 +1382,6 @@ function hasMessageHeightChanged(
return true; return true;
} }
const signalAccountChanged =
Boolean(message.hasSignalAccount || previous.hasSignalAccount) &&
message.hasSignalAccount !== previous.hasSignalAccount;
if (signalAccountChanged) {
return true;
}
const currentReactions = message.reactions || []; const currentReactions = message.reactions || [];
const lastReactions = previous.reactions || []; const lastReactions = previous.reactions || [];
const reactionsChanged = const reactionsChanged =

View file

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

View file

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

View file

@ -3,6 +3,7 @@
import { combineReducers } from 'redux'; import { combineReducers } from 'redux';
import { reducer as accounts } from './ducks/accounts';
import { reducer as app } from './ducks/app'; import { reducer as app } from './ducks/app';
import { reducer as audioPlayer } from './ducks/audioPlayer'; import { reducer as audioPlayer } from './ducks/audioPlayer';
import { reducer as calling } from './ducks/calling'; 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'; import { reducer as user } from './ducks/user';
export const reducer = combineReducers({ export const reducer = combineReducers({
accounts,
app, app,
audioPlayer, audioPlayer,
calling, 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, CallingStateType,
CallsByConversationType, CallsByConversationType,
DirectCallStateType, DirectCallStateType,
getActiveCall, GroupCallStateType,
} from '../ducks/calling'; } from '../ducks/calling';
import { CallMode, CallState } from '../../types/Calling'; import { CallMode, CallState } from '../../types/Calling';
import { getOwn } from '../../util/getOwn'; import { getOwn } from '../../util/getOwn';
export type CallStateType = DirectCallStateType | GroupCallStateType;
const getCalling = (state: StateType): CallingStateType => state.calling; const getCalling = (state: StateType): CallingStateType => state.calling;
export const isInCall = createSelector( export const getActiveCallState = createSelector(
getCalling, getCalling,
(state: CallingStateType): boolean => Boolean(getActiveCall(state)) (state: CallingStateType) => state.activeCallState
); );
export const getCallsByConversation = createSelector( export const getCallsByConversation = createSelector(
@ -26,10 +28,31 @@ export const getCallsByConversation = createSelector(
state.callsByConversation state.callsByConversation
); );
export type CallSelectorType = (
conversationId: string
) => CallStateType | undefined;
export const getCallSelector = createSelector( export const getCallSelector = createSelector(
getCallsByConversation, getCallsByConversation,
(callsByConversation: CallsByConversationType) => (conversationId: string) => (callsByConversation: CallsByConversationType): CallSelectorType => (
getOwn(callsByConversation, conversationId) 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 // In theory, there could be multiple incoming calls. In practice, neither RingRTC nor the

View file

@ -14,31 +14,35 @@ import {
ConversationType, ConversationType,
MessageLookupType, MessageLookupType,
MessagesByConversationType, MessagesByConversationType,
MessageType,
OneTimeModalState, OneTimeModalState,
PreJoinConversationType, PreJoinConversationType,
} from '../ducks/conversations'; } from '../ducks/conversations';
import { getOwn } from '../../util/getOwn'; import { getOwn } from '../../util/getOwn';
import { deconstructLookup } from '../../util/deconstructLookup'; 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 { PropsDataType as TimelinePropsType } from '../../components/conversation/Timeline';
import { TimelineItemType } from '../../components/conversation/TimelineItem'; import { TimelineItemType } from '../../components/conversation/TimelineItem';
import { assert } from '../../util/assert'; import { assert } from '../../util/assert';
import { isConversationUnregistered } from '../../util/isConversationUnregistered'; import { isConversationUnregistered } from '../../util/isConversationUnregistered';
import { filterAndSortConversationsByTitle } from '../../util/filterAndSortConversations'; import { filterAndSortConversationsByTitle } from '../../util/filterAndSortConversations';
import { ContactNameColors, ContactNameColorType } from '../../types/Colors'; import { ContactNameColors, ContactNameColorType } from '../../types/Colors';
import { isInSystemContacts } from '../../util/isInSystemContacts';
import { import {
getInteractionMode,
getIntl, getIntl,
getRegionCode, getRegionCode,
getUserConversationId, getUserConversationId,
getUserNumber, getUserNumber,
getUserUuid,
} from './user'; } from './user';
import { getPinnedConversationIds } from './items'; import { getPinnedConversationIds, getReadReceiptSetting } from './items';
import { isInSystemContacts } from '../../util/isInSystemContacts'; import { getPropsForBubble } from './message';
import {
CallSelectorType,
CallStateType,
getActiveCall,
getCallSelector,
} from './calling';
import { getAccountSelector, AccountSelectorType } from './accounts';
let placeholderContact: ConversationType; let placeholderContact: ConversationType;
export const getPlaceholderContact = (): ConversationType => { export const getPlaceholderContact = (): ConversationType => {
@ -640,66 +644,15 @@ export const getConversationByIdSelector = createSelector(
getOwn(conversationLookup, id) 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 // A little optimization to reset our selector cache whenever high-level application data
// changes: regionCode and userNumber. // 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( export const getCachedSelectorForMessage = createSelector(
getRegionCode, getRegionCode,
getUserNumber, getUserNumber,
(): CachedMessageSelectorType => { (): typeof getPropsForBubble => {
// Note: memoizee will check all parameters provided, and only run our selector // Note: memoizee will check all parameters provided, and only run our selector
// if any of them have changed. // 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, getSelectedMessage,
getConversationSelector, getConversationSelector,
getRegionCode, getRegionCode,
getReadReceiptSetting,
getUserNumber, getUserNumber,
getInteractionMode, getUserUuid,
getCallsByConversation, getUserConversationId,
getCallSelector,
getActiveCall,
getAccountSelector,
( (
messageSelector: CachedMessageSelectorType, messageSelector: typeof getPropsForBubble,
messageLookup: MessageLookupType, messageLookup: MessageLookupType,
selectedMessage: SelectedMessageType | undefined, selectedMessage: SelectedMessageType | undefined,
conversationSelector: GetConversationByIdType, conversationSelector: GetConversationByIdType,
regionCode: string, regionCode: string,
readReceiptSetting: boolean,
ourNumber: string, ourNumber: string,
interactionMode: 'keyboard' | 'mouse', ourUuid: string,
callsByConversation: CallsByConversationType ourConversationId: string,
callSelector: CallSelectorType,
activeCall: undefined | CallStateType,
accountSelector: AccountSelectorType
): GetMessageByIdType => { ): GetMessageByIdType => {
return (id: string) => { return (id: string) => {
const message = messageLookup[id]; const message = messageLookup[id];
@ -731,13 +692,17 @@ export const getMessageSelector = createSelector(
return messageSelector( return messageSelector(
message, message,
ourNumber,
regionCode,
interactionMode,
conversationSelector, conversationSelector,
callsByConversation, ourConversationId,
ourNumber,
ourUuid,
regionCode,
readReceiptSetting,
selectedMessage ? selectedMessage.id : undefined, 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 (state: ItemsStateType): string => state.userAgent as string
); );
export const getReadReceiptSetting = createSelector(
getItems,
(state: ItemsStateType): boolean => Boolean(state['read-receipt-setting'])
);
export const getPinnedConversationIds = createSelector( export const getPinnedConversationIds = createSelector(
getItems, getItems,
(state: ItemsStateType): Array<string> => (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, id: message.id,
conversationId: message.conversationId, conversationId: message.conversationId,
sentAt: message.sent_at, sentAt: message.sent_at,
snippet: message.snippet, snippet: message.snippet || '',
bodyRanges: bodyRanges.map((bodyRange: BodyRangeType) => { bodyRanges: bodyRanges.map((bodyRange: BodyRangeType) => {
const conversation = conversationSelector(bodyRange.mentionUuid); const conversation = conversationSelector(bodyRange.mentionUuid);
@ -152,7 +152,7 @@ export const getCachedSelectorForMessageSearchResult = createSelector(
replacementText: conversation.title, replacementText: conversation.title,
}; };
}), }),
body: message.body, body: message.body || '',
isSelected: Boolean( isSelected: Boolean(
selectedMessageId && message.id === selectedMessageId selectedMessageId && message.id === selectedMessageId

View file

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

View file

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

View file

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

View file

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

View file

@ -59,6 +59,7 @@ type ExternalProps = {
function renderItem( function renderItem(
messageId: string, messageId: string,
conversationId: string, conversationId: string,
onHeightChange: (messageId: string) => unknown,
actionProps: Record<string, unknown> actionProps: Record<string, unknown>
): JSX.Element { ): JSX.Element {
return ( return (
@ -66,6 +67,7 @@ function renderItem(
{...actionProps} {...actionProps}
conversationId={conversationId} conversationId={conversationId}
id={messageId} id={messageId}
onHeightChange={() => onHeightChange(messageId)}
renderEmojiPicker={renderEmojiPicker} renderEmojiPicker={renderEmojiPicker}
renderAudioAttachment={renderAudioAttachment} 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 // SPDX-License-Identifier: AGPL-3.0-only
import { actions as accounts } from './ducks/accounts';
import { actions as app } from './ducks/app'; import { actions as app } from './ducks/app';
import { actions as audioPlayer } from './ducks/audioPlayer'; import { actions as audioPlayer } from './ducks/audioPlayer';
import { actions as calling } from './ducks/calling'; 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'; import { actions as user } from './ducks/user';
export type ReduxActions = { export type ReduxActions = {
accounts: typeof accounts;
app: typeof app; app: typeof app;
audioPlayer: typeof audioPlayer; audioPlayer: typeof audioPlayer;
calling: typeof calling; calling: typeof calling;

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai'; import { assert } from 'chai';
import sinon from 'sinon';
import { import {
ConversationType, ConversationType,
@ -24,20 +25,35 @@ import { getDefaultConversation } from '../../helpers/getDefaultConversation';
import { StateType, reducer as rootReducer } from '../../../state/reducer'; import { StateType, reducer as rootReducer } from '../../../state/reducer';
describe('both/state/selectors/search', () => { 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 => { const getEmptyRootState = (): StateType => {
return rootReducer(undefined, noopAction()); return rootReducer(undefined, noopAction());
}; };
function getDefaultMessage(id: string): MessageType { function getDefaultMessage(id: string): MessageType {
return { return {
id, attachments: [],
conversationId: 'conversationId', conversationId: 'conversationId',
id,
received_at: NOW,
sent_at: NOW,
source: 'source', source: 'source',
sourceUuid: 'sourceUuid', sourceUuid: 'sourceUuid',
timestamp: NOW,
type: 'incoming' as const, type: 'incoming' as const,
received_at: Date.now(),
attachments: [],
sticker: {},
unread: false, unread: false,
}; };
} }
@ -126,7 +142,7 @@ describe('both/state/selectors/search', () => {
id: searchId, id: searchId,
conversationId: toId, conversationId: toId,
sentAt: undefined, sentAt: NOW,
snippet: 'snippet', snippet: 'snippet',
body: 'snippet', body: 'snippet',
bodyRanges: [], bodyRanges: [],
@ -227,7 +243,7 @@ describe('both/state/selectors/search', () => {
id: searchId, id: searchId,
conversationId: toId, conversationId: toId,
sentAt: undefined, sentAt: NOW,
snippet: 'snippet', snippet: 'snippet',
body: 'snippet', body: 'snippet',
bodyRanges: [], bodyRanges: [],

View file

@ -5,6 +5,12 @@ import { assert } from 'chai';
import * as sinon from 'sinon'; import * as sinon from 'sinon';
import { setup as setupI18n } from '../../../js/modules/i18n'; import { setup as setupI18n } from '../../../js/modules/i18n';
import enMessages from '../../../_locales/en/messages.json'; import enMessages from '../../../_locales/en/messages.json';
import {
isEndSession,
isGroupUpdate,
isIncoming,
isOutgoing,
} from '../../state/selectors/message';
describe('Message', () => { describe('Message', () => {
const i18n = setupI18n('en', enMessages); const i18n = setupI18n('en', enMessages);
@ -122,9 +128,9 @@ describe('Message', () => {
it('checks if is incoming message', () => { it('checks if is incoming message', () => {
const messages = new window.Whisper.MessageCollection(); const messages = new window.Whisper.MessageCollection();
let message = messages.add(attributes); let message = messages.add(attributes);
assert.notOk(message.isIncoming()); assert.notOk(isIncoming(message.attributes));
message = messages.add({ type: 'incoming' }); 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', () => { it('checks if is outgoing message', () => {
const messages = new window.Whisper.MessageCollection(); const messages = new window.Whisper.MessageCollection();
let message = messages.add(attributes); let message = messages.add(attributes);
assert.ok(message.isOutgoing()); assert.ok(isOutgoing(message.attributes));
message = messages.add({ type: 'incoming' }); 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', () => { it('checks if is group update', () => {
const messages = new window.Whisper.MessageCollection(); const messages = new window.Whisper.MessageCollection();
let message = messages.add(attributes); let message = messages.add(attributes);
assert.notOk(message.isGroupUpdate()); assert.notOk(isGroupUpdate(message.attributes));
message = messages.add({ group_update: true }); 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', () => { it('checks if it is end of the session', () => {
const messages = new window.Whisper.MessageCollection(); const messages = new window.Whisper.MessageCollection();
let message = messages.add(attributes); let message = messages.add(attributes);
assert.notOk(message.isEndSession()); assert.notOk(isEndSession(message.attributes));
message = messages.add({ type: 'incoming', source, flags: true }); 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', () => { describe('reducer', () => {
const time = Date.now(); const time = Date.now();
const previousTime = time - 1;
const conversationId = 'conversation-guid-1'; const conversationId = 'conversation-guid-1';
const messageId = 'message-guid-1'; const messageId = 'message-guid-1';
const messageIdTwo = 'message-guid-2'; const messageIdTwo = 'message-guid-2';
@ -299,14 +300,15 @@ describe('both/state/ducks/conversations', () => {
function getDefaultMessage(id: string): MessageType { function getDefaultMessage(id: string): MessageType {
return { return {
id, attachments: [],
conversationId: 'conversationId', conversationId: 'conversationId',
id,
received_at: previousTime,
sent_at: previousTime,
source: 'source', source: 'source',
sourceUuid: 'sourceUuid', sourceUuid: 'sourceUuid',
timestamp: previousTime,
type: 'incoming' as const, type: 'incoming' as const,
received_at: Date.now(),
attachments: [],
sticker: {},
unread: false, unread: false,
}; };
} }
@ -953,6 +955,7 @@ describe('both/state/ducks/conversations', () => {
[messageId]: { [messageId]: {
...getDefaultMessage(messageId), ...getDefaultMessage(messageId),
received_at: time, received_at: time,
sent_at: time,
}, },
}, },
messagesByConversation: { messagesByConversation: {
@ -972,6 +975,7 @@ describe('both/state/ducks/conversations', () => {
[messageId]: { [messageId]: {
...getDefaultMessage(messageId), ...getDefaultMessage(messageId),
received_at: time, received_at: time,
sent_at: time,
}, },
}, },
messagesByConversation: { messagesByConversation: {
@ -983,6 +987,7 @@ describe('both/state/ducks/conversations', () => {
newest: { newest: {
id: messageId, id: messageId,
received_at: time, received_at: time,
sent_at: time,
}, },
}, },
}, },
@ -1060,6 +1065,7 @@ describe('both/state/ducks/conversations', () => {
[messageId]: { [messageId]: {
...getDefaultMessage(messageId), ...getDefaultMessage(messageId),
received_at: time, received_at: time,
sent_at: time,
}, },
}, },
messagesByConversation: { messagesByConversation: {
@ -1079,6 +1085,7 @@ describe('both/state/ducks/conversations', () => {
[messageId]: { [messageId]: {
...getDefaultMessage(messageId), ...getDefaultMessage(messageId),
received_at: time, received_at: time,
sent_at: time,
}, },
}, },
messagesByConversation: { messagesByConversation: {
@ -1090,6 +1097,7 @@ describe('both/state/ducks/conversations', () => {
oldest: { oldest: {
id: messageId, id: messageId,
received_at: time, received_at: time,
sent_at: time,
}, },
}, },
}, },

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,14 +1,14 @@
// Copyright 2020 Signal Messenger, LLC // Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { DeletesModelType } from '../model-types.d'; import { DeleteModel } from '../messageModifiers/Deletes';
import { MessageModel } from '../models/messages'; import { MessageModel } from '../models/messages';
const ONE_DAY = 24 * 60 * 60 * 1000; const ONE_DAY = 24 * 60 * 60 * 1000;
export async function deleteForEveryone( export async function deleteForEveryone(
message: MessageModel, message: MessageModel,
doe: DeletesModelType, doe: DeleteModel,
shouldPersist = true shouldPersist = true
): Promise<void> { ): Promise<void> {
// Make sure the server timestamps for the DOE and the matching message // 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 { ConversationAttributesType } from '../model-types.d';
import { handleMessageSend } from './handleMessageSend'; import { handleMessageSend } from './handleMessageSend';
import { sendReadReceiptsFor } from './sendReadReceiptsFor'; import { sendReadReceiptsFor } from './sendReadReceiptsFor';
import { hasErrors } from '../state/selectors/message';
export async function markConversationRead( export async function markConversationRead(
conversationAttrs: ConversationAttributesType, conversationAttrs: ConversationAttributesType,
@ -74,7 +75,7 @@ export async function markConversationRead(
uuid: messageSyncData.sourceUuid, uuid: messageSyncData.sourceUuid,
}), }),
timestamp: messageSyncData.sent_at, 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 { import {
GroupV2PendingMemberType, GroupV2PendingMemberType,
MessageModelCollectionType, MessageModelCollectionType,
MessageAttributesType,
} from '../model-types.d'; } from '../model-types.d';
import { LinkPreviewType } from '../types/message/LinkPreviews'; import { LinkPreviewType } from '../types/message/LinkPreviews';
import { MediaItemType } from '../components/LightboxGallery'; import { MediaItemType } from '../components/LightboxGallery';
import { MessageModel } from '../models/messages'; import { MessageModel } from '../models/messages';
import { MessageType } from '../state/ducks/conversations';
import { assert } from '../util/assert'; import { assert } from '../util/assert';
import { maybeParseUrl } from '../util/url'; import { maybeParseUrl } from '../util/url';
import { addReportSpamJob } from '../jobs/helpers/addReportSpamJob'; import { addReportSpamJob } from '../jobs/helpers/addReportSpamJob';
@ -24,6 +24,13 @@ import {
isGroupV2, isGroupV2,
isMe, isMe,
} from '../util/whatTypeOfConversation'; } from '../util/whatTypeOfConversation';
import {
canReply,
getAttachmentsForMessage,
getPropsForQuote,
isOutgoing,
isTapToView,
} from '../state/selectors/message';
type GetLinkPreviewImageResult = { type GetLinkPreviewImageResult = {
data: ArrayBuffer; data: ArrayBuffer;
@ -910,9 +917,9 @@ Whisper.ConversationView = Whisper.View.extend({
const isNewMessage = false; const isNewMessage = false;
messagesAdded( messagesAdded(
id, id,
cleaned.map((messageModel: MessageModel) => cleaned.map((messageModel: MessageModel) => ({
messageModel.getReduxData() ...messageModel.attributes,
), })),
isNewMessage, isNewMessage,
window.isActive() window.isActive()
); );
@ -965,9 +972,9 @@ Whisper.ConversationView = Whisper.View.extend({
const isNewMessage = false; const isNewMessage = false;
messagesAdded( messagesAdded(
id, id,
cleaned.map((messageModel: MessageModel) => cleaned.map((messageModel: MessageModel) => ({
messageModel.getReduxData() ...messageModel.attributes,
), })),
isNewMessage, isNewMessage,
window.isActive() window.isActive()
); );
@ -1210,9 +1217,9 @@ Whisper.ConversationView = Whisper.View.extend({
messagesReset( messagesReset(
conversationId, conversationId,
cleaned.map((messageModel: MessageModel) => cleaned.map((messageModel: MessageModel) => ({
messageModel.getReduxData() ...messageModel.attributes,
), })),
metrics, metrics,
scrollToMessageId scrollToMessageId
); );
@ -1294,9 +1301,9 @@ Whisper.ConversationView = Whisper.View.extend({
const unboundedFetch = true; const unboundedFetch = true;
messagesReset( messagesReset(
conversationId, conversationId,
cleaned.map((messageModel: MessageModel) => cleaned.map((messageModel: MessageModel) => ({
messageModel.getReduxData() ...messageModel.attributes,
), })),
metrics, metrics,
scrollToMessageId, scrollToMessageId,
unboundedFetch unboundedFetch
@ -2327,7 +2334,7 @@ Whisper.ConversationView = Whisper.View.extend({
throw new Error(`showForwardMessageModal: Message ${messageId} missing!`); throw new Error(`showForwardMessageModal: Message ${messageId} missing!`);
} }
const attachments = message.getAttachmentsForMessage(); const attachments = getAttachmentsForMessage(message.attributes);
this.forwardMessageModal = new Whisper.ReactWrapperView({ this.forwardMessageModal = new Whisper.ReactWrapperView({
JSX: window.Signal.State.Roots.createForwardMessageModal( JSX: window.Signal.State.Roots.createForwardMessageModal(
window.reduxStore, window.reduxStore,
@ -2557,7 +2564,10 @@ Whisper.ConversationView = Whisper.View.extend({
const message = rawMedia[i]; const message = rawMedia[i];
const { schemaVersion } = message; 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 // Yep, we really do want to wait for each of these
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
rawMedia[i] = await upgradeMessageSchema(message); rawMedia[i] = await upgradeMessageSchema(message);
@ -2871,7 +2881,7 @@ Whisper.ConversationView = Whisper.View.extend({
throw new Error(`displayTapToViewMessage: Message ${messageId} missing!`); throw new Error(`displayTapToViewMessage: Message ${messageId} missing!`);
} }
if (!message.isTapToView()) { if (!isTapToView(message.attributes)) {
throw new Error( throw new Error(
`displayTapToViewMessage: Message ${message.idForLogging()} is not a tap to view message` `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) { if (!firstAttachment || !firstAttachment.path) {
throw new Error( throw new Error(
`displayTapToViewMessage: Message ${message.idForLogging()} had no first attachment with path` `displayTapToViewMessage: Message ${message.idForLogging()} had no first attachment with path`
@ -2955,7 +2965,7 @@ Whisper.ConversationView = Whisper.View.extend({
Message: Whisper.Message, Message: Whisper.Message,
}); });
message.cleanup(); message.cleanup();
if (message.isOutgoing()) { if (isOutgoing(message.attributes)) {
this.model.decrementSentMessageCount(); this.model.decrementSentMessageCount();
} else { } else {
this.model.decrementMessageCount(); this.model.decrementMessageCount();
@ -3531,7 +3541,7 @@ Whisper.ConversationView = Whisper.View.extend({
async loadRecentMediaItems(limit: number): Promise<void> { async loadRecentMediaItems(limit: number): Promise<void> {
const { model }: { model: ConversationModel } = this; 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, model.id,
{ {
limit, limit,
@ -3543,7 +3553,7 @@ Whisper.ConversationView = Whisper.View.extend({
.reduce( .reduce(
(acc, message) => [ (acc, message) => [
...acc, ...acc,
...message.attachments.map( ...(message.attachments || []).map(
(attachment: AttachmentType, index: number): MediaItemType => { (attachment: AttachmentType, index: number): MediaItemType => {
const { thumbnail } = attachment; const { thumbnail } = attachment;
@ -3792,7 +3802,12 @@ Whisper.ConversationView = Whisper.View.extend({
}) })
: undefined; : undefined;
if (message && !message.canReply()) { if (
message &&
!canReply(message.attributes, (id?: string) =>
message.findAndFormatContact(id)
)
) {
return; return;
} }
@ -3855,7 +3870,11 @@ Whisper.ConversationView = Whisper.View.extend({
message.quotedMessage = this.quotedMessage; message.quotedMessage = this.quotedMessage;
this.quoteHolder = message; 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(); const contact = this.quotedMessage.getContact();

86
ts/window.d.ts vendored
View file

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