Receive support for View Once photos

This commit is contained in:
Scott Nonnenberg 2019-06-26 12:33:13 -07:00
parent fccf1eec30
commit e62a1a7812
38 changed files with 1937 additions and 102 deletions

View file

@ -652,6 +652,7 @@
Whisper.WallClockListener.init(Whisper.events);
Whisper.ExpiringMessagesListener.init(Whisper.events);
Whisper.TapToViewMessagesListener.init(Whisper.events);
if (Whisper.Import.isIncomplete()) {
window.log.info('Import was interrupted, showing import error screen');
@ -836,6 +837,7 @@
addQueuedEventListener('configuration', onConfiguration);
addQueuedEventListener('typing', onTyping);
addQueuedEventListener('sticker-pack', onStickerPack);
addQueuedEventListener('viewSync', onViewSync);
window.Signal.AttachmentDownloads.start({
getMessageReceiver: () => messageReceiver,
@ -1685,6 +1687,22 @@
throw error;
}
async function onViewSync(ev) {
const { viewedAt, source, timestamp } = ev;
window.log.info(`view sync ${source} ${timestamp}, viewed at ${viewedAt}`);
const sync = Whisper.ViewSyncs.add({
source,
timestamp,
viewedAt,
});
sync.on('remove', ev.confirm);
// Calling this directly so we can wait for completion
return Whisper.ViewSyncs.onSync(sync);
}
function onReadReceipt(ev) {
const readAt = ev.timestamp;
const { timestamp } = ev.read;

View file

@ -0,0 +1,109 @@
/* global
_,
MessageController,
Whisper
*/
// eslint-disable-next-line func-names
(function() {
'use strict';
window.Whisper = window.Whisper || {};
async function eraseTapToViewMessages() {
try {
window.log.info('eraseTapToViewMessages: Loading messages...');
const messages = await window.Signal.Data.getTapToViewMessagesNeedingErase(
{
MessageCollection: Whisper.MessageCollection,
}
);
await Promise.all(
messages.map(async fromDB => {
const message = MessageController.register(fromDB.id, fromDB);
window.log.info(
'eraseTapToViewMessages: message data erased',
message.idForLogging()
);
message.trigger('erased');
await message.eraseContents();
})
);
} catch (error) {
window.log.error(
'eraseTapToViewMessages: Error erasing messages',
error && error.stack ? error.stack : error
);
}
window.log.info('eraseTapToViewMessages: complete');
}
let timeout;
async function checkTapToViewMessages() {
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const THIRTY_DAYS = 30 * 24 * HOUR;
const toAgeOut = await window.Signal.Data.getNextTapToViewMessageToAgeOut({
Message: Whisper.Message,
});
const toExpire = await window.Signal.Data.getNextTapToViewMessageToExpire({
Message: Whisper.Message,
});
if (!toAgeOut && !toExpire) {
return;
}
const ageOutAt = toAgeOut
? toAgeOut.get('received_at') + THIRTY_DAYS
: Number.MAX_VALUE;
const expireAt = toExpire
? toExpire.get('messageTimerExpiresAt')
: Number.MAX_VALUE;
const nextCheck = Math.min(ageOutAt, expireAt);
Whisper.TapToViewMessagesListener.nextCheck = nextCheck;
window.log.info(
'checkTapToViewMessages: next check at',
new Date(nextCheck).toISOString()
);
let wait = nextCheck - Date.now();
// In the past
if (wait < 0) {
wait = 0;
}
// Too far in the future, since it's limited to a 32-bit value
if (wait > 2147483647) {
wait = 2147483647;
}
clearTimeout(timeout);
timeout = setTimeout(async () => {
await eraseTapToViewMessages();
checkTapToViewMessages();
}, wait);
}
const throttledCheckTapToViewMessages = _.throttle(
checkTapToViewMessages,
1000
);
Whisper.TapToViewMessagesListener = {
nextCheck: null,
init(events) {
checkTapToViewMessages();
events.on('timetravel', throttledCheckTapToViewMessages);
},
update: throttledCheckTapToViewMessages,
};
})();

View file

@ -857,11 +857,9 @@
author: contact.id,
id: quotedMessage.get('sent_at'),
text: body || embeddedContactName,
attachments: await this.getQuoteAttachment(
attachments,
preview,
sticker
),
attachments: quotedMessage.isTapToView()
? [{ contentType: 'image/jpeg', fileName: null }]
: await this.getQuoteAttachment(attachments, preview, sticker),
};
},

View file

@ -470,6 +470,8 @@
const isGroup = conversation && !conversation.isPrivate();
const sticker = this.get('sticker');
const isTapToView = this.isTapToView();
return {
text: this.createNonBreakingLastSeparator(this.get('body')),
textPending: this.get('bodyPending'),
@ -492,6 +494,12 @@
expirationLength,
expirationTimestamp,
isTapToView,
isTapToViewExpired:
isTapToView && (this.get('isErased') || this.isTapToViewExpired()),
isTapToViewError:
isTapToView && this.isIncoming() && this.get('isTapToViewInvalid'),
replyToMessage: id => this.trigger('reply', id),
retrySend: id => this.trigger('retry', id),
deleteMessage: id => this.trigger('delete', id),
@ -506,6 +514,8 @@
this.trigger('show-lightbox', lightboxOptions),
downloadAttachment: downloadOptions =>
this.trigger('download', downloadOptions),
displayTapToViewMessage: messageId =>
this.trigger('display-tap-to-view-message', messageId),
openLink: url => this.trigger('navigate-to', url),
downloadNewVersion: () => this.trigger('download-new-version'),
@ -727,6 +737,9 @@
if (this.isUnsupportedMessage()) {
return i18n('message--getDescription--unsupported-message');
}
if (this.isTapToView()) {
return i18n('message--getDescription--disappearing-photo');
}
if (this.isGroupUpdate()) {
const groupUpdate = this.get('group_update');
if (groupUpdate.left === 'You') {
@ -841,6 +854,9 @@
async cleanup() {
MessageController.unregister(this.id);
this.unload();
await this.deleteData();
},
async deleteData() {
await deleteExternalMessageFiles(this.attributes);
const sticker = this.get('sticker');
@ -853,6 +869,154 @@
await deletePackReference(this.id, packId);
}
},
isTapToView() {
return Boolean(this.get('messageTimer'));
},
isValidTapToView() {
const body = this.get('body');
if (body) {
return false;
}
const attachments = this.get('attachments');
if (!attachments || attachments.length !== 1) {
return false;
}
const firstAttachment = attachments[0];
if (
!window.Signal.Util.GoogleChrome.isImageTypeSupported(
firstAttachment.contentType
)
) {
return false;
}
const quote = this.get('quote');
const sticker = this.get('sticker');
const contact = this.get('contact');
const preview = this.get('preview');
if (
quote ||
sticker ||
(contact && contact.length > 0) ||
(preview && preview.length > 0)
) {
return false;
}
return true;
},
isTapToViewExpired() {
const THIRTY_DAYS = 30 * 24 * 60 * 60 * 1000;
const now = Date.now();
const receivedAt = this.get('received_at');
if (now >= receivedAt + THIRTY_DAYS) {
return true;
}
const messageTimer = this.get('messageTimer');
const messageTimerStart = this.get('messageTimerStart');
if (!messageTimerStart) {
return false;
}
const expiresAt = messageTimerStart + messageTimer * 1000;
if (now >= expiresAt) {
return true;
}
return false;
},
async startTapToViewTimer(viewedAt, options) {
const { fromSync } = options || {};
if (this.get('unread')) {
await this.markRead();
}
const messageTimer = this.get('messageTimer');
if (!messageTimer) {
window.log.warn(
`startTapToViewTimer: Message ${this.idForLogging()} has no messageTimer!`
);
return;
}
const existingTimerStart = this.get('messageTimerStart');
const messageTimerStart = Math.min(
Date.now(),
viewedAt || Date.now(),
existingTimerStart || Date.now()
);
const messageTimerExpiresAt = messageTimerStart + messageTimer * 1000;
// Because we're not using Backbone-integrated saves, we need to manually
// clear the changed fields here so our hasChanged() check below is useful.
this.changed = {};
this.set({
messageTimerStart,
messageTimerExpiresAt,
});
if (!this.hasChanged()) {
return;
}
await window.Signal.Data.saveMessage(this.attributes, {
Message: Whisper.Message,
});
if (!fromSync) {
const sender = this.getSource();
const timestamp = this.get('sent_at');
const ourNumber = textsecure.storage.user.getNumber();
const { wrap, sendOptions } = ConversationController.prepareForSend(
ourNumber,
{ syncMessage: true }
);
await wrap(
textsecure.messaging.syncMessageTimerRead(
sender,
timestamp,
sendOptions
)
);
}
},
async eraseContents() {
if (this.get('isErased')) {
return;
}
window.log.info(`Erasing data for message ${this.idForLogging()}`);
try {
await this.deleteData();
} catch (error) {
window.log.error(
`Error erasing data for message ${this.idForLogging()}:`,
error && error.stack ? error.stack : error
);
}
this.set({
isErased: true,
body: '',
attachments: [],
quote: null,
contact: [],
sticker: null,
preview: [],
});
await window.Signal.Data.saveMessage(this.attributes, {
Message: Whisper.Message,
});
},
unload() {
if (this.quotedMessage) {
this.quotedMessage = null;
@ -1581,6 +1745,16 @@
quote.referencedMessageNotFound = true;
return message;
}
if (found.isTapToView()) {
quote.text = null;
quote.attachments = [
{
contentType: 'image/jpeg',
},
];
return message;
}
const queryMessage = MessageController.register(found.id, found);
quote.text = queryMessage.get('body');
@ -1765,6 +1939,7 @@
hasAttachments: dataMessage.hasAttachments,
hasFileAttachments: dataMessage.hasFileAttachments,
hasVisualMediaAttachments: dataMessage.hasVisualMediaAttachments,
messageTimer: dataMessage.messageTimer,
preview,
requiredProtocolVersion:
dataMessage.requiredProtocolVersion ||
@ -1925,7 +2100,34 @@
message.set({ id });
MessageController.register(message.id, message);
if (!message.isUnsupportedMessage()) {
if (message.isTapToView() && type === 'outgoing') {
await message.eraseContents();
}
if (
type === 'incoming' &&
message.isTapToView() &&
!message.isValidTapToView()
) {
window.log.warn(
`Received tap to view message ${message.idForLogging()} with invalid data. Erasing contents.`
);
message.set({
isTapToViewInvalid: true,
});
await message.eraseContents();
}
// Check for out-of-order view syncs
if (type === 'incoming' && message.isTapToView()) {
const viewSync = Whisper.ViewSyncs.forMessage(message);
if (viewSync) {
await Whisper.ViewSyncs.onSync(viewSync);
}
}
if (message.isUnsupportedMessage()) {
await message.eraseContents();
} else {
// Note that this can save the message again, if jobs were queued. We need to
// call it after we have an id for this message, because the jobs refer back
// to their source message.
@ -2017,8 +2219,10 @@
};
};
Whisper.Message.refreshExpirationTimer = () =>
Whisper.Message.updateTimers = () => {
Whisper.ExpiringMessagesListener.update();
Whisper.TapToViewMessagesListener.update();
};
Whisper.MessageCollection = Backbone.Collection.extend({
model: Whisper.Message,

View file

@ -715,7 +715,7 @@ async function exportConversation(conversation, options = {}) {
count += 1;
// skip message if it is disappearing, no matter the amount of time left
if (message.expireTimer) {
if (message.expireTimer || message.messageTimer) {
// eslint-disable-next-line no-continue
continue;
}

View file

@ -122,6 +122,9 @@ module.exports = {
getOutgoingWithoutExpiresAt,
getNextExpiringMessage,
getMessagesByConversation,
getNextTapToViewMessageToExpire,
getNextTapToViewMessageToAgeOut,
getTapToViewMessagesNeedingErase,
getUnprocessedCount,
getAllUnprocessed,
@ -674,7 +677,7 @@ async function getMessageCount() {
async function saveMessage(data, { forceSave, Message } = {}) {
const id = await channels.saveMessage(_cleanData(data), { forceSave });
Message.refreshExpirationTimer();
Message.updateTimers();
return id;
}
@ -839,6 +842,27 @@ async function getNextExpiringMessage({ MessageCollection }) {
return new MessageCollection(messages);
}
async function getNextTapToViewMessageToExpire({ Message }) {
const message = await channels.getNextTapToViewMessageToExpire();
if (!message) {
return null;
}
return new Message(message);
}
async function getNextTapToViewMessageToAgeOut({ Message }) {
const message = await channels.getNextTapToViewMessageToAgeOut();
if (!message) {
return null;
}
return new Message(message);
}
async function getTapToViewMessagesNeedingErase({ MessageCollection }) {
const messages = await channels.getTapToViewMessagesNeedingErase();
return new MessageCollection(messages);
}
// Unprocessed
async function getUnprocessedCount() {

67
js/view_syncs.js Normal file
View file

@ -0,0 +1,67 @@
/* global
Backbone,
Whisper,
MessageController
*/
/* eslint-disable more/no-then */
// eslint-disable-next-line func-names
(function() {
'use strict';
window.Whisper = window.Whisper || {};
Whisper.ViewSyncs = new (Backbone.Collection.extend({
forMessage(message) {
const sync = this.findWhere({
source: message.get('source'),
timestamp: message.get('sent_at'),
});
if (sync) {
window.log.info('Found early view sync for message');
this.remove(sync);
return sync;
}
return null;
},
async onSync(sync) {
try {
const messages = await window.Signal.Data.getMessagesBySentAt(
sync.get('timestamp'),
{
MessageCollection: Whisper.MessageCollection,
}
);
const found = messages.find(
item => item.get('source') === sync.get('source')
);
const syncSource = sync.get('source');
const syncTimestamp = sync.get('timestamp');
const wasMessageFound = Boolean(found);
window.log.info('Receive view sync:', {
syncSource,
syncTimestamp,
wasMessageFound,
});
if (!found) {
return;
}
const message = MessageController.register(found.id, found);
const viewedAt = sync.get('viewedAt');
await message.startTapToViewTimer(viewedAt, { fromSync: true });
this.remove(sync);
} catch (error) {
window.log.error(
'ViewSyncs.onSync error:',
error && error.stack ? error.stack : error
);
}
},
}))();
})();

View file

@ -131,6 +131,11 @@
'download',
this.downloadAttachment
);
this.listenTo(
this.model.messageCollection,
'display-tap-to-view-message',
this.displayTapToViewMessage
);
this.listenTo(
this.model.messageCollection,
'open-conversation',
@ -461,8 +466,8 @@
if (this.quoteView) {
this.quoteView.remove();
}
if (this.lightBoxView) {
this.lightBoxView.remove();
if (this.lightboxView) {
this.lightboxView.remove();
}
if (this.lightboxGalleryView) {
this.lightboxGalleryView.remove();
@ -1344,6 +1349,66 @@
});
},
async displayTapToViewMessage(messageId) {
const message = this.model.messageCollection.get(messageId);
if (!message) {
throw new Error(
`displayTapToViewMessage: Did not find message for id ${messageId}`
);
}
if (!message.isTapToView()) {
throw new Error(
`displayTapToViewMessage: Message ${message.idForLogging()} is not tap to view`
);
}
if (message.isTapToViewExpired()) {
return;
}
await message.startTapToViewTimer();
const closeLightbox = () => {
if (!this.lightboxView) {
return;
}
const { lightboxView } = this;
this.lightboxView = null;
this.stopListening(message);
Signal.Backbone.Views.Lightbox.hide();
lightboxView.remove();
};
this.listenTo(message, 'expired', closeLightbox);
this.listenTo(message, 'change', () => {
if (this.lightBoxView) {
this.lightBoxView.update(getProps());
}
});
const getProps = () => {
const firstAttachment = message.get('attachments')[0];
const { path, contentType } = firstAttachment;
return {
objectURL: getAbsoluteAttachmentPath(path),
contentType,
timerExpiresAt: message.get('messageTimerExpiresAt'),
timerDuration: message.get('messageTimer') * 1000,
};
};
this.lightboxView = new Whisper.ReactWrapperView({
className: 'lightbox-wrapper',
Component: Signal.Components.Lightbox,
props: getProps(),
onClose: closeLightbox,
});
Signal.Backbone.Views.Lightbox.show(this.lightboxView.el);
},
deleteMessage(messageId) {
const message = this.model.messageCollection.get(messageId);
if (!message) {