diff --git a/app/sql.js b/app/sql.js index 7e958462e4..9dfb2bcb8d 100644 --- a/app/sql.js +++ b/app/sql.js @@ -94,7 +94,6 @@ module.exports = { getOutgoingWithoutExpiresAt, getNextExpiringMessage, getMessagesByConversation, - getNextTapToViewMessageToExpire, getNextTapToViewMessageToAgeOut, getTapToViewMessagesNeedingErase, @@ -952,6 +951,68 @@ async function updateToSchemaVersion16(currentVersion, instance) { } } +async function updateToSchemaVersion17(currentVersion, instance) { + if (currentVersion >= 17) { + return; + } + + console.log('updateToSchemaVersion17: starting...'); + await instance.run('BEGIN TRANSACTION;'); + + try { + await instance.run( + `ALTER TABLE messages + ADD COLUMN isViewOnce INTEGER;` + ); + + await instance.run('DROP INDEX messages_message_timer;'); + + await instance.run(`CREATE INDEX messages_view_once ON messages ( + isErased + ) WHERE isViewOnce = 1;`); + + // Updating full-text triggers to avoid anything with isViewOnce = 1 + + await instance.run('DROP TRIGGER messages_on_insert;'); + await instance.run('DROP TRIGGER messages_on_update;'); + + await instance.run(` + CREATE TRIGGER messages_on_insert AFTER INSERT ON messages + WHEN new.isViewOnce != 1 + BEGIN + INSERT INTO messages_fts ( + id, + body + ) VALUES ( + new.id, + new.body + ); + END; + `); + await instance.run(` + CREATE TRIGGER messages_on_update AFTER UPDATE ON messages + WHEN new.isViewOnce != 1 + BEGIN + DELETE FROM messages_fts WHERE id = old.id; + INSERT INTO messages_fts( + id, + body + ) VALUES ( + new.id, + new.body + ); + END; + `); + + await instance.run('PRAGMA schema_version = 17;'); + await instance.run('COMMIT TRANSACTION;'); + console.log('updateToSchemaVersion17: success!'); + } catch (error) { + await instance.run('ROLLBACK;'); + throw error; + } +} + const SCHEMA_VERSIONS = [ updateToSchemaVersion1, updateToSchemaVersion2, @@ -969,6 +1030,7 @@ const SCHEMA_VERSIONS = [ updateToSchemaVersion14, updateToSchemaVersion15, updateToSchemaVersion16, + updateToSchemaVersion17, ]; async function updateSchema(instance) { @@ -1566,9 +1628,7 @@ async function saveMessage(data, { forceSave } = {}) { hasVisualMediaAttachments, id, isErased, - messageTimer, - messageTimerStart, - messageTimerExpiresAt, + isViewOnce, // eslint-disable-next-line camelcase received_at, schemaVersion, @@ -1595,9 +1655,7 @@ async function saveMessage(data, { forceSave } = {}) { $hasFileAttachments: hasFileAttachments, $hasVisualMediaAttachments: hasVisualMediaAttachments, $isErased: isErased, - $messageTimer: messageTimer, - $messageTimerStart: messageTimerStart, - $messageTimerExpiresAt: messageTimerExpiresAt, + $isViewOnce: isViewOnce, $received_at: received_at, $schemaVersion: schemaVersion, $sent_at: sent_at, @@ -1622,9 +1680,7 @@ async function saveMessage(data, { forceSave } = {}) { hasFileAttachments = $hasFileAttachments, hasVisualMediaAttachments = $hasVisualMediaAttachments, isErased = $isErased, - messageTimer = $messageTimer, - messageTimerStart = $messageTimerStart, - messageTimerExpiresAt = $messageTimerExpiresAt, + isViewOnce = $isViewOnce, received_at = $received_at, schemaVersion = $schemaVersion, sent_at = $sent_at, @@ -1658,9 +1714,7 @@ async function saveMessage(data, { forceSave } = {}) { hasFileAttachments, hasVisualMediaAttachments, isErased, - messageTimer, - messageTimerStart, - messageTimerExpiresAt, + isViewOnce, received_at, schemaVersion, sent_at, @@ -1681,9 +1735,7 @@ async function saveMessage(data, { forceSave } = {}) { $hasFileAttachments, $hasVisualMediaAttachments, $isErased, - $messageTimer, - $messageTimerStart, - $messageTimerExpiresAt, + $isViewOnce, $received_at, $schemaVersion, $sent_at, @@ -1862,31 +1914,11 @@ async function getNextExpiringMessage() { return map(rows, row => jsonToObject(row.json)); } -async function getNextTapToViewMessageToExpire() { - // Note: we avoid 'IS NOT NULL' here because it does seem to bypass our index - const rows = await db.all(` - SELECT json FROM messages - WHERE - messageTimer > 0 - AND messageTimerExpiresAt > 0 - AND (isErased IS NULL OR isErased != 1) - ORDER BY messageTimerExpiresAt ASC - LIMIT 1; - `); - - if (!rows || rows.length < 1) { - return null; - } - - return jsonToObject(rows[0].json); -} - async function getNextTapToViewMessageToAgeOut() { - // Note: we avoid 'IS NOT NULL' here because it does seem to bypass our index const rows = await db.all(` SELECT json FROM messages WHERE - messageTimer > 0 + isViewOnce = 1 AND (isErased IS NULL OR isErased != 1) ORDER BY received_at ASC LIMIT 1; @@ -1903,18 +1935,12 @@ async function getTapToViewMessagesNeedingErase() { const THIRTY_DAYS_AGO = Date.now() - 30 * 24 * 60 * 60 * 1000; const NOW = Date.now(); - // Note: we avoid 'IS NOT NULL' here because it does seem to bypass our index const rows = await db.all( `SELECT json FROM messages WHERE - messageTimer > 0 + isViewOnce = 1 AND (isErased IS NULL OR isErased != 1) - AND ( - (messageTimerExpiresAt > 0 - AND messageTimerExpiresAt <= $NOW) - OR - received_at <= $THIRTY_DAYS_AGO - ) + AND received_at <= $THIRTY_DAYS_AGO ORDER BY received_at ASC;`, { $NOW: NOW, diff --git a/js/background.js b/js/background.js index 347a1dca90..e2fe47861b 100644 --- a/js/background.js +++ b/js/background.js @@ -1698,13 +1698,12 @@ } async function onViewSync(ev) { - const { viewedAt, source, timestamp } = ev; - window.log.info(`view sync ${source} ${timestamp}, viewed at ${viewedAt}`); + const { source, timestamp } = ev; + window.log.info(`view sync ${source} ${timestamp}`); const sync = Whisper.ViewSyncs.add({ source, timestamp, - viewedAt, }); sync.on('remove', ev.confirm); diff --git a/js/expiring_tap_to_view_messages.js b/js/expiring_tap_to_view_messages.js index f8364781c0..16522b51e0 100644 --- a/js/expiring_tap_to_view_messages.js +++ b/js/expiring_tap_to_view_messages.js @@ -52,22 +52,12 @@ const toAgeOut = await window.Signal.Data.getNextTapToViewMessageToAgeOut({ Message: Whisper.Message, }); - const toExpire = await window.Signal.Data.getNextTapToViewMessageToExpire({ - Message: Whisper.Message, - }); - if (!toAgeOut && !toExpire) { + if (!toAgeOut) { 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); + const nextCheck = toAgeOut.get('received_at') + THIRTY_DAYS; Whisper.TapToViewMessagesListener.nextCheck = nextCheck; window.log.info( diff --git a/js/models/messages.js b/js/models/messages.js index 32f3c84b82..404fd2e4ab 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -495,8 +495,7 @@ expirationTimestamp, isTapToView, - isTapToViewExpired: - isTapToView && (this.get('isErased') || this.isTapToViewExpired()), + isTapToViewExpired: isTapToView && this.get('isErased'), isTapToViewError: isTapToView && this.isIncoming() && this.get('isTapToViewInvalid'), @@ -870,7 +869,7 @@ } }, isTapToView() { - return Boolean(this.get('messageTimer')); + return Boolean(this.get('isViewOnce') || this.get('messageTimer')); }, isValidTapToView() { const body = this.get('body'); @@ -908,66 +907,27 @@ 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) { + async markViewed(options) { const { fromSync } = options || {}; + if (!this.isValidTapToView()) { + window.log.warn( + `markViewed: Message ${this.idForLogging()} is not a valid tap to view message!` + ); + return; + } + if (this.isErased()) { + window.log.warn( + `markViewed: Message ${this.idForLogging()} is already erased!` + ); + return; + } + 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, - }); + await this.eraseContents(); if (!fromSync) { const sender = this.getSource(); @@ -979,14 +939,13 @@ ); await wrap( - textsecure.messaging.syncMessageTimerRead( - sender, - timestamp, - sendOptions - ) + textsecure.messaging.syncViewOnceOpen(sender, timestamp, sendOptions) ); } }, + isErased() { + return Boolean(this.get('isErased')); + }, async eraseContents() { if (this.get('isErased')) { return; @@ -1940,7 +1899,7 @@ hasAttachments: dataMessage.hasAttachments, hasFileAttachments: dataMessage.hasFileAttachments, hasVisualMediaAttachments: dataMessage.hasVisualMediaAttachments, - messageTimer: dataMessage.messageTimer, + isViewOnce: Boolean(dataMessage.isViewOnce), preview, requiredProtocolVersion: dataMessage.requiredProtocolVersion || diff --git a/js/modules/backup.js b/js/modules/backup.js index edfe5af544..c9b659e091 100644 --- a/js/modules/backup.js +++ b/js/modules/backup.js @@ -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 || message.messageTimer) { + if (message.expireTimer || message.messageTimer || message.isViewOnce) { // eslint-disable-next-line no-continue continue; } diff --git a/js/modules/data.js b/js/modules/data.js index 1c63f73676..2d3dd93dc6 100644 --- a/js/modules/data.js +++ b/js/modules/data.js @@ -122,7 +122,6 @@ module.exports = { getOutgoingWithoutExpiresAt, getNextExpiringMessage, getMessagesByConversation, - getNextTapToViewMessageToExpire, getNextTapToViewMessageToAgeOut, getTapToViewMessagesNeedingErase, @@ -842,14 +841,6 @@ 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) { diff --git a/js/modules/signal.js b/js/modules/signal.js index 3aed32062e..fd0a618815 100644 --- a/js/modules/signal.js +++ b/js/modules/signal.js @@ -169,10 +169,14 @@ function initializeMigrations({ const writeNewTempData = createWriterForNew(tempPath); const deleteTempFile = Attachments.createDeleter(tempPath); const readTempData = createReader(tempPath); + const copyIntoTempDirectory = Attachments.copyIntoAttachmentsDirectory( + tempPath + ); return { attachmentsPath, copyIntoAttachmentsDirectory, + copyIntoTempDirectory, deleteAttachmentData: deleteOnDisk, deleteExternalMessageFiles: MessageType.deleteAllExternalFiles({ deleteAttachmentData: Type.deleteData(deleteOnDisk), @@ -182,6 +186,7 @@ function initializeMigrations({ deleteTempFile, getAbsoluteAttachmentPath, getAbsoluteStickerPath, + getAbsoluteTempPath, getPlaceholderMigrations, getCurrentVersion, loadAttachmentData, diff --git a/js/view_syncs.js b/js/view_syncs.js index fa0fc2fe3f..5eea0d0717 100644 --- a/js/view_syncs.js +++ b/js/view_syncs.js @@ -51,9 +51,7 @@ } const message = MessageController.register(found.id, found); - - const viewedAt = sync.get('viewedAt'); - await message.startTapToViewTimer(viewedAt, { fromSync: true }); + await message.markViewed({ fromSync: true }); this.remove(sync); } catch (error) { diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index 37a706ecde..2ef9992551 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -19,6 +19,9 @@ const { upgradeMessageSchema, getAbsoluteAttachmentPath, + copyIntoTempDirectory, + getAbsoluteTempPath, + deleteTempFile, } = window.Signal.Migrations; Whisper.ExpiredToast = Whisper.ToastView.extend({ @@ -1324,17 +1327,33 @@ if (!message.isTapToView()) { throw new Error( - `displayTapToViewMessage: Message ${message.idForLogging()} is not tap to view` + `displayTapToViewMessage: Message ${message.idForLogging()} is not a tap to view message` ); } - if (message.isTapToViewExpired()) { - return; + if (message.isErased()) { + throw new Error( + `displayTapToViewMessage: Message ${message.idForLogging()} is already erased` + ); } - await message.startTapToViewTimer(); + const firstAttachment = message.get('attachments')[0]; + if (!firstAttachment || !firstAttachment.path) { + throw new Error( + `displayTapToViewMessage: Message ${message.idForLogging()} had no first attachment with path` + ); + } - const closeLightbox = () => { + const absolutePath = getAbsoluteAttachmentPath(firstAttachment.path); + const tempPath = await copyIntoTempDirectory(absolutePath); + const tempAttachment = { + ...firstAttachment, + path: tempPath, + }; + + await message.markViewed(); + + const closeLightbox = async () => { if (!this.lightboxView) { return; } @@ -1345,6 +1364,8 @@ this.stopListening(message); Signal.Backbone.Views.Lightbox.hide(); lightboxView.remove(); + + await deleteTempFile(tempPath); }; this.listenTo(message, 'expired', closeLightbox); this.listenTo(message, 'change', () => { @@ -1354,14 +1375,11 @@ }); const getProps = () => { - const firstAttachment = message.get('attachments')[0]; - const { path, contentType } = firstAttachment; + const { path, contentType } = tempAttachment; return { - objectURL: getAbsoluteAttachmentPath(path), + objectURL: getAbsoluteTempPath(path), contentType, - timerExpiresAt: message.get('messageTimerExpiresAt'), - timerDuration: message.get('messageTimer') * 1000, }; }; this.lightboxView = new Whisper.ReactWrapperView({ diff --git a/libtextsecure/message_receiver.js b/libtextsecure/message_receiver.js index 6ab621d05f..f00c97e5fc 100644 --- a/libtextsecure/message_receiver.js +++ b/libtextsecure/message_receiver.js @@ -1090,11 +1090,8 @@ MessageReceiver.prototype.extend({ envelope, syncMessage.stickerPackOperation ); - } else if (syncMessage.messageTimerRead) { - return this.handleMessageTimerRead( - envelope, - syncMessage.messageTimerRead - ); + } else if (syncMessage.viewOnceOpen) { + return this.handleViewOnceOpen(envelope, syncMessage.viewOnceOpen); } throw new Error('Got empty SyncMessage'); }, @@ -1105,14 +1102,13 @@ MessageReceiver.prototype.extend({ ev.configuration = configuration; return this.dispatchAndWait(ev); }, - handleMessageTimerRead(envelope, sync) { - window.log.info('got message timer read sync message'); + handleViewOnceOpen(envelope, sync) { + window.log.info('got view once open sync message'); const ev = new Event('viewSync'); ev.confirm = this.removeFromCache.bind(this, envelope); ev.source = sync.sender; ev.timestamp = sync.timestamp ? sync.timestamp.toNumber() : null; - ev.viewedAt = envelope.timestamp; return this.dispatchAndWait(ev); }, diff --git a/libtextsecure/sendmessage.js b/libtextsecure/sendmessage.js index 761588d790..5716b7dede 100644 --- a/libtextsecure/sendmessage.js +++ b/libtextsecure/sendmessage.js @@ -745,7 +745,7 @@ MessageSender.prototype = { return Promise.resolve(); }, - async syncMessageTimerRead(sender, timestamp, options) { + async syncViewOnceOpen(sender, timestamp, options) { const myNumber = textsecure.storage.user.getNumber(); const myDevice = textsecure.storage.user.getDeviceId(); if (myDevice === 1 || myDevice === '1') { @@ -754,10 +754,10 @@ MessageSender.prototype = { const syncMessage = this.createSyncMessage(); - const messageTimerRead = new textsecure.protobuf.SyncMessage.MessageTimerRead(); - messageTimerRead.sender = sender; - messageTimerRead.timestamp = timestamp; - syncMessage.messageTimerRead = messageTimerRead; + const viewOnceOpen = new textsecure.protobuf.SyncMessage.ViewOnceOpen(); + viewOnceOpen.sender = sender; + viewOnceOpen.timestamp = timestamp; + syncMessage.viewOnceOpen = viewOnceOpen; const contentMessage = new textsecure.protobuf.Content(); contentMessage.syncMessage = syncMessage; @@ -1260,7 +1260,7 @@ textsecure.MessageSender = function MessageSenderWrapper(username, password) { this.getSticker = sender.getSticker.bind(sender); this.getStickerPackManifest = sender.getStickerPackManifest.bind(sender); this.sendStickerPackSync = sender.sendStickerPackSync.bind(sender); - this.syncMessageTimerRead = sender.syncMessageTimerRead.bind(sender); + this.syncViewOnceOpen = sender.syncViewOnceOpen.bind(sender); }; textsecure.MessageSender.prototype = { diff --git a/protos/SignalService.proto b/protos/SignalService.proto index cd51db361a..582f2da4da 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -174,7 +174,8 @@ message DataMessage { INITIAL = 0; MESSAGE_TIMERS = 1; - CURRENT = 1; + VIEW_ONCE = 2; + CURRENT = 2; } optional string body = 1; @@ -189,7 +190,7 @@ message DataMessage { repeated Preview preview = 10; optional Sticker sticker = 11; optional uint32 requiredProtocolVersion = 12; - optional uint32 messageTimer = 13; + optional bool isViewOnce = 14; } message NullMessage { @@ -293,7 +294,7 @@ message SyncMessage { optional Type type = 3; } - message MessageTimerRead { + message ViewOnceOpen { optional string sender = 1; optional uint64 timestamp = 2; } @@ -308,7 +309,7 @@ message SyncMessage { optional Configuration configuration = 9; optional bytes padding = 8; repeated StickerPackOperation stickerPackOperation = 10; - optional MessageTimerRead messageTimerRead = 11; + optional ViewOnceOpen viewOnceOpen = 11; } message AttachmentPointer { diff --git a/ts/types/Message.ts b/ts/types/Message.ts index 32fe2e3daa..4fb2bbdf1a 100644 --- a/ts/types/Message.ts +++ b/ts/types/Message.ts @@ -18,7 +18,8 @@ export type IncomingMessage = Readonly< decrypted_at?: number; errors?: Array; expireTimer?: number; - messageTimer?: number; + messageTimer?: number; // deprecated + isViewOnce?: number; flags?: number; source?: string; sourceDevice?: number; @@ -47,7 +48,8 @@ export type OutgoingMessage = Readonly< body?: string; expires_at?: number; expireTimer?: number; - messageTimer?: number; + messageTimer?: number; // deprecated + isViewOnce?: number; recipients?: Array; // Array synced: boolean; } & SharedMessageProperties & diff --git a/ts/types/message/initializeAttachmentMetadata.ts b/ts/types/message/initializeAttachmentMetadata.ts index 42365dcb11..56d19d45e8 100644 --- a/ts/types/message/initializeAttachmentMetadata.ts +++ b/ts/types/message/initializeAttachmentMetadata.ts @@ -16,7 +16,7 @@ export const initializeAttachmentMetadata = async ( if (message.type === 'verified-change') { return message; } - if (message.messageTimer) { + if (message.messageTimer || message.isViewOnce) { return message; }