Receive support for View Once photos
This commit is contained in:
parent
fccf1eec30
commit
e62a1a7812
38 changed files with 1937 additions and 102 deletions
|
@ -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;
|
||||
|
|
109
js/expiring_tap_to_view_messages.js
Normal file
109
js/expiring_tap_to_view_messages.js
Normal 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,
|
||||
};
|
||||
})();
|
|
@ -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),
|
||||
};
|
||||
},
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
67
js/view_syncs.js
Normal 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
|
||||
);
|
||||
}
|
||||
},
|
||||
}))();
|
||||
})();
|
|
@ -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) {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue