diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 9ad606fac0..63465d7434 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1745,6 +1745,11 @@ "description": "Shown in notifications and in the left pane when a message has features too new for this signal install." }, + "message--getDescription--disappearing-photo": { + "message": "Disappearing photo", + "description": + "Shown in notifications and in the left pane when a message is a disappearing photo." + }, "stickers--toast--InstallFailed": { "message": "Sticker pack could not be installed", "description": @@ -1901,5 +1906,20 @@ "message": "Update Signal", "description": "Text for a button which will take user to Signal download page" + }, + "Message--tap-to-view-expired": { + "message": "Viewed", + "description": + "Text shown on messages with with individual timers, after user has viewed it" + }, + "Message--tap-to-view--outgoing": { + "message": "Photo", + "description": + "Text shown on outgoing messages with with individual timers (inaccessble)" + }, + "Message--tap-to-view--incoming": { + "message": "View Photo", + "description": + "Text shown on messages with with individual timers, before user has viewed it" } } diff --git a/app/sql.js b/app/sql.js index 86abb02640..cd1798cc34 100644 --- a/app/sql.js +++ b/app/sql.js @@ -94,6 +94,9 @@ module.exports = { getOutgoingWithoutExpiresAt, getNextExpiringMessage, getMessagesByConversation, + getNextTapToViewMessageToExpire, + getNextTapToViewMessageToAgeOut, + getTapToViewMessagesNeedingErase, getUnprocessedCount, getAllUnprocessed, @@ -868,6 +871,87 @@ async function updateToSchemaVersion15(currentVersion, instance) { } } +async function updateToSchemaVersion16(currentVersion, instance) { + if (currentVersion >= 16) { + return; + } + + console.log('updateToSchemaVersion16: starting...'); + await instance.run('BEGIN TRANSACTION;'); + + try { + await instance.run( + `ALTER TABLE messages + ADD COLUMN messageTimer INTEGER;` + ); + await instance.run( + `ALTER TABLE messages + ADD COLUMN messageTimerStart INTEGER;` + ); + await instance.run( + `ALTER TABLE messages + ADD COLUMN messageTimerExpiresAt INTEGER;` + ); + await instance.run( + `ALTER TABLE messages + ADD COLUMN isErased INTEGER;` + ); + + await instance.run(`CREATE INDEX messages_message_timer ON messages ( + messageTimer, + messageTimerStart, + messageTimerExpiresAt, + isErased + ) WHERE messageTimer IS NOT NULL;`); + + // Updating full-text triggers to avoid anything with a messageTimer set + + await instance.run('DROP TRIGGER messages_on_insert;'); + await instance.run('DROP TRIGGER messages_on_delete;'); + await instance.run('DROP TRIGGER messages_on_update;'); + + await instance.run(` + CREATE TRIGGER messages_on_insert AFTER INSERT ON messages + WHEN new.messageTimer IS NULL + BEGIN + INSERT INTO messages_fts ( + id, + body + ) VALUES ( + new.id, + new.body + ); + END; + `); + await instance.run(` + CREATE TRIGGER messages_on_delete AFTER DELETE ON messages BEGIN + DELETE FROM messages_fts WHERE id = old.id; + END; + `); + await instance.run(` + CREATE TRIGGER messages_on_update AFTER UPDATE ON messages + WHEN new.messageTimer IS NULL + 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 = 16;'); + await instance.run('COMMIT TRANSACTION;'); + console.log('updateToSchemaVersion16: success!'); + } catch (error) { + await instance.run('ROLLBACK;'); + throw error; + } +} + const SCHEMA_VERSIONS = [ updateToSchemaVersion1, updateToSchemaVersion2, @@ -884,6 +968,7 @@ const SCHEMA_VERSIONS = [ updateToSchemaVersion13, updateToSchemaVersion14, updateToSchemaVersion15, + updateToSchemaVersion16, ]; async function updateSchema(instance) { @@ -1480,6 +1565,10 @@ async function saveMessage(data, { forceSave } = {}) { hasFileAttachments, hasVisualMediaAttachments, id, + isErased, + messageTimer, + messageTimerStart, + messageTimerExpiresAt, // eslint-disable-next-line camelcase received_at, schemaVersion, @@ -1505,6 +1594,10 @@ async function saveMessage(data, { forceSave } = {}) { $hasAttachments: hasAttachments, $hasFileAttachments: hasFileAttachments, $hasVisualMediaAttachments: hasVisualMediaAttachments, + $isErased: isErased, + $messageTimer: messageTimer, + $messageTimerStart: messageTimerStart, + $messageTimerExpiresAt: messageTimerExpiresAt, $received_at: received_at, $schemaVersion: schemaVersion, $sent_at: sent_at, @@ -1517,7 +1610,9 @@ async function saveMessage(data, { forceSave } = {}) { if (id && !forceSave) { await db.run( `UPDATE messages SET + id = $id, json = $json, + body = $body, conversationId = $conversationId, expirationStartTimestamp = $expirationStartTimestamp, @@ -1526,7 +1621,10 @@ async function saveMessage(data, { forceSave } = {}) { hasAttachments = $hasAttachments, hasFileAttachments = $hasFileAttachments, hasVisualMediaAttachments = $hasVisualMediaAttachments, - id = $id, + isErased = $isErased, + messageTimer = $messageTimer, + messageTimerStart = $messageTimerStart, + messageTimerExpiresAt = $messageTimerExpiresAt, received_at = $received_at, schemaVersion = $schemaVersion, sent_at = $sent_at, @@ -1559,6 +1657,10 @@ async function saveMessage(data, { forceSave } = {}) { hasAttachments, hasFileAttachments, hasVisualMediaAttachments, + isErased, + messageTimer, + messageTimerStart, + messageTimerExpiresAt, received_at, schemaVersion, sent_at, @@ -1578,6 +1680,10 @@ async function saveMessage(data, { forceSave } = {}) { $hasAttachments, $hasFileAttachments, $hasVisualMediaAttachments, + $isErased, + $messageTimer, + $messageTimerStart, + $messageTimerExpiresAt, $received_at, $schemaVersion, $sent_at, @@ -1756,6 +1862,69 @@ 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 + AND (isErased IS NULL OR isErased != 1) + ORDER BY received_at ASC + LIMIT 1; + `); + + if (!rows || rows.length < 1) { + return null; + } + + return jsonToObject(rows[0].json); +} + +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 + AND (isErased IS NULL OR isErased != 1) + AND ( + (messageTimerExpiresAt > 0 + AND messageTimerExpiresAt <= $NOW) + OR + received_at <= $THIRTY_DAYS_AGO + ) + ORDER BY received_at ASC;`, + { + $NOW: NOW, + $THIRTY_DAYS_AGO: THIRTY_DAYS_AGO, + } + ); + + return map(rows, row => jsonToObject(row.json)); +} + async function saveUnprocessed(data, { forceSave } = {}) { const { id, timestamp, version, attempts, envelope } = data; if (!id) { diff --git a/background.html b/background.html index d36ab91d8e..bb235bd934 100644 --- a/background.html +++ b/background.html @@ -482,11 +482,13 @@ + + diff --git a/images/play-filled-24.svg b/images/play-filled-24.svg new file mode 100644 index 0000000000..1ef1ec1cb6 --- /dev/null +++ b/images/play-filled-24.svg @@ -0,0 +1,7 @@ + + + diff --git a/images/play-outline-24.svg b/images/play-outline-24.svg new file mode 100644 index 0000000000..601da37c7d --- /dev/null +++ b/images/play-outline-24.svg @@ -0,0 +1,7 @@ + + + diff --git a/js/background.js b/js/background.js index 1377d09c9c..8e4c261e77 100644 --- a/js/background.js +++ b/js/background.js @@ -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; diff --git a/js/expiring_tap_to_view_messages.js b/js/expiring_tap_to_view_messages.js new file mode 100644 index 0000000000..f8364781c0 --- /dev/null +++ b/js/expiring_tap_to_view_messages.js @@ -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, + }; +})(); diff --git a/js/models/conversations.js b/js/models/conversations.js index 98364b7865..6e60e61045 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -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), }; }, diff --git a/js/models/messages.js b/js/models/messages.js index 924979cdb6..32cae463df 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -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, diff --git a/js/modules/backup.js b/js/modules/backup.js index 571b0e57d4..edfe5af544 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) { + if (message.expireTimer || message.messageTimer) { // eslint-disable-next-line no-continue continue; } diff --git a/js/modules/data.js b/js/modules/data.js index 627fdc8ea7..1c63f73676 100644 --- a/js/modules/data.js +++ b/js/modules/data.js @@ -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() { diff --git a/js/view_syncs.js b/js/view_syncs.js new file mode 100644 index 0000000000..fa0fc2fe3f --- /dev/null +++ b/js/view_syncs.js @@ -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 + ); + } + }, + }))(); +})(); diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index dfcdb155aa..177cea9a44 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -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) { diff --git a/libtextsecure/message_receiver.js b/libtextsecure/message_receiver.js index 1f4d604ed7..8ec7199f59 100644 --- a/libtextsecure/message_receiver.js +++ b/libtextsecure/message_receiver.js @@ -1110,11 +1110,19 @@ MessageReceiver.prototype.extend({ return this.handleVerified(envelope, syncMessage.verified); } else if (syncMessage.configuration) { return this.handleConfiguration(envelope, syncMessage.configuration); - } else if (syncMessage.stickerPackOperation) { + } else if ( + syncMessage.stickerPackOperation && + syncMessage.stickerPackOperation.length > 0 + ) { return this.handleStickerPackOperation( envelope, syncMessage.stickerPackOperation ); + } else if (syncMessage.messageTimerRead) { + return this.handleMessageTimerRead( + envelope, + syncMessage.messageTimerRead + ); } throw new Error('Got empty SyncMessage'); }, @@ -1125,6 +1133,17 @@ MessageReceiver.prototype.extend({ ev.configuration = configuration; return this.dispatchAndWait(ev); }, + handleMessageTimerRead(envelope, sync) { + window.log.info('got message timer read 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); + }, handleStickerPackOperation(envelope, operations) { const ENUM = textsecure.protobuf.SyncMessage.StickerPackOperation.Type; window.log.info('got sticker pack operation sync message'); diff --git a/libtextsecure/sendmessage.js b/libtextsecure/sendmessage.js index 5b6e070a78..73ae8c8fe7 100644 --- a/libtextsecure/sendmessage.js +++ b/libtextsecure/sendmessage.js @@ -750,6 +750,34 @@ MessageSender.prototype = { return Promise.resolve(); }, + + async syncMessageTimerRead(sender, timestamp, options) { + const myNumber = textsecure.storage.user.getNumber(); + const myDevice = textsecure.storage.user.getDeviceId(); + if (myDevice === 1 || myDevice === '1') { + return null; + } + + const syncMessage = this.createSyncMessage(); + + const messageTimerRead = new textsecure.protobuf.SyncMessage.MessageTimerRead(); + messageTimerRead.sender = sender; + messageTimerRead.timestamp = timestamp; + syncMessage.messageTimerRead = messageTimerRead; + + const contentMessage = new textsecure.protobuf.Content(); + contentMessage.syncMessage = syncMessage; + + const silent = true; + return this.sendIndividualProto( + myNumber, + contentMessage, + Date.now(), + silent, + options + ); + }, + async sendStickerPackSync(operations, options) { const myDevice = textsecure.storage.user.getDeviceId(); if (myDevice === 1 || myDevice === '1') { @@ -1238,6 +1266,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); }; textsecure.MessageSender.prototype = { diff --git a/protos/SignalService.proto b/protos/SignalService.proto index 8c2c3bde3c..cd51db361a 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -173,7 +173,8 @@ message DataMessage { option allow_alias = true; INITIAL = 0; - CURRENT = 0; + MESSAGE_TIMERS = 1; + CURRENT = 1; } optional string body = 1; @@ -188,6 +189,7 @@ message DataMessage { repeated Preview preview = 10; optional Sticker sticker = 11; optional uint32 requiredProtocolVersion = 12; + optional uint32 messageTimer = 13; } message NullMessage { @@ -291,6 +293,11 @@ message SyncMessage { optional Type type = 3; } + message MessageTimerRead { + optional string sender = 1; + optional uint64 timestamp = 2; + } + optional Sent sent = 1; optional Contacts contacts = 2; optional Groups groups = 3; @@ -301,6 +308,7 @@ message SyncMessage { optional Configuration configuration = 9; optional bytes padding = 8; repeated StickerPackOperation stickerPackOperation = 10; + optional MessageTimerRead messageTimerRead = 11; } message AttachmentPointer { diff --git a/stylesheets/_ios.scss b/stylesheets/_ios.scss index b97c34dd7b..d82d98cd50 100644 --- a/stylesheets/_ios.scss +++ b/stylesheets/_ios.scss @@ -13,6 +13,48 @@ color: $color-gray-90; } + .module-message__container--with-tap-to-view-expired { + border: 1px solid $color-gray-15; + background-color: $color-white; + } + + .module-message__container--with-tap-to-view-error { + background-color: $color-white; + border: 1px solid $color-deep-red; + } + + .module-message__tap-to-view__icon { + background-color: $color-gray-90; + } + .module-message__tap-to-view__icon--outgoing { + background-color: $color-white; + } + .module-message__tap-to-view__icon--expired { + background-color: $color-gray-75; + } + .module-message__tap-to-view__text { + color: $color-gray-90; + } + .module-message__tap-to-view__text--incoming { + color: $color-gray-90; + } + .module-message__tap-to-view__text--outgoing { + color: $color-white; + } + .module-message__tap-to-view__text--outgoing-expired { + color: $color-gray-90; + } + .module-message__tap-to-view__text--incoming-expired { + color: $color-gray-90; + } + .module-message__tap-to-view__text--incoming-error { + color: $color-gray-60; + } + + .module-message__container--with-tap-to-view-pending { + background-color: $color-gray-15; + } + .module-message__author { color: $color-gray-90; } @@ -46,19 +88,22 @@ .module-message__metadata__date--with-sticker { color: $color-gray-60; } + .module-message__metadata__date--outgoing-with-tap-to-view-expired { + color: $color-gray-75; + } .module-message__metadata__status-icon--sending { - @include color-svg('../images/sending.svg', $color-white); + background-color: $color-white; } .module-message__metadata__status-icon--sent { - @include color-svg('../images/check-circle-outline.svg', $color-white-08); + background-color: $color-white-08; } .module-message__metadata__status-icon--delivered { - @include color-svg('../images/double-check.svg', $color-white-08); + background-color: $color-white-08; } .module-message__metadata__status-icon--read { - @include color-svg('../images/read.svg', $color-white-08); + background-color: $color-white-08; } .module-message__metadata__status-icon--with-image-no-caption { @@ -67,6 +112,9 @@ .module-message__metadata__status-icon--with-sticker { background-color: $color-gray-60; } + .module-message__metadata__status-icon--with-tap-to-view-expired { + background-color: $color-gray-75; + } .module-message__generic-attachment__file-name { color: $color-white; @@ -93,6 +141,9 @@ .module-expire-timer--with-sticker { background-color: $color-gray-60; } + .module-expire-timer--outgoing-with-tap-to-view-expired { + background-color: $color-gray-75; + } .module-quote--outgoing { border-left-color: $color-white; @@ -167,6 +218,16 @@ color: $color-gray-05; } + .module-message__container--with-tap-to-view-expired { + border: 1px solid $color-gray-60; + background-color: $color-black; + } + + .module-message__container--with-tap-to-view-error { + background-color: $color-black; + border: 1px solid $color-deep-red; + } + .module-message__author { color: $color-gray-05; } @@ -180,17 +241,17 @@ } .module-message__metadata__status-icon--sending { - @include color-svg('../images/sending.svg', $color-white); + background-color: $color-white; } .module-message__metadata__status-icon--sent { - @include color-svg('../images/check-circle-outline.svg', $color-white-08); + background-color: $color-white-08; } .module-message__metadata__status-icon--delivered { - @include color-svg('../images/double-check.svg', $color-white-08); + background-color: $color-white-08; } .module-message__metadata__status-icon--read { - @include color-svg('../images/read.svg', $color-white-08); + background-color: $color-white-08; } .module-message__metadata__date { diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 9c3291a400..449599ec12 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -204,6 +204,121 @@ background-color: $color-conversation-blue_grey; } +.module-message__container--with-tap-to-view { + min-width: 148px; + cursor: pointer; +} + +.module-message__container--incoming--tap-to-view-pending { + background-color: $color-conversation-grey-shade; +} +.module-message__container--incoming-red-tap-to-view-pending { + background-color: $color-conversation-red-shade; +} +.module-message__container--incoming-deep_orange-tap-to-view-pending { + background-color: $color-conversation-deep_orange-shade; +} +.module-message__container--incoming-brown-tap-to-view-pending { + background-color: $color-conversation-brown-shade; +} +.module-message__container--incoming-pink-tap-to-view-pending { + background-color: $color-conversation-pink-shade; +} +.module-message__container--incoming-purple-tap-to-view-pending { + background-color: $color-conversation-purple-shade; +} +.module-message__container--incoming-indigo-tap-to-view-pending { + background-color: $color-conversation-indigo-shade; +} +.module-message__container--incoming-blue-tap-to-view-pending { + background-color: $color-conversation-blue-shade; +} +.module-message__container--incoming-teal-tap-to-view-pending { + background-color: $color-conversation-teal-shade; +} +.module-message__container--incoming-green-tap-to-view-pending { + background-color: $color-conversation-green-shade; +} +.module-message__container--incoming-light_green-tap-to-view-pending { + background-color: $color-conversation-light_green-shade; +} +.module-message__container--incoming-blue_grey-tap-to-view-pending { + background-color: $color-conversation-blue_grey-shade; +} + +.module-message__container--with-tap-to-view-pending { + cursor: default; +} + +.module-message__container--with-tap-to-view-expired { + cursor: default; + border: 1px solid $color-gray-15; + background-color: $color-white; +} + +.module-message__container--with-tap-to-view-error { + background-color: $color-white; + border: 1px solid $color-core-red; + width: auto; + cursor: default; +} + +.module-message__tap-to-view { + margin-top: 2px; + display: flex; + flex-direction: row; + align-items: center; +} +.module-message__tap-to-view--with-content-above { + margin-top: 8px; +} +.module-message__tap-to-view--with-content-below { + margin-bottom: 8px; +} + +.module-message__tap-to-view__spinner-container { + margin-right: 6px; + + flex-grow: 0; + flex-shrink: 0; + + width: 20px; + height: 20px; +} + +.module-message__tap-to-view__icon { + margin-right: 6px; + + flex-grow: 0; + flex-shrink: 0; + width: 20px; + height: 20px; + + @include color-svg('../images/play-filled-24.svg', $color-white); +} +.module-message__tap-to-view__icon--outgoing { + background-color: $color-gray-75; +} +.module-message__tap-to-view__icon--expired { + @include color-svg('../images/play-outline-24.svg', $color-gray-75); +} +.module-message__tap-to-view__text { + font-size: 13px; + font-weight: 300; + line-height: 18px; + + color: $color-gray-90; +} +.module-message__tap-to-view__text--incoming { + color: $color-white; +} +.module-message__tap-to-view__text--incoming-expired { + color: $color-gray-90; +} +.module-message__tap-to-view__text--incoming-error { + color: $color-gray-60; +} + .module-message__attachment-container { // To ensure that images are centered if they aren't full width of bubble text-align: center; @@ -472,6 +587,22 @@ } } +.module-message__author--with-tap-to-view-expired { + color: $color-gray-75; + font-size: 13px; + font-weight: 300; + line-height: 18px; + height: 18px; + overflow-x: hidden; + overflow-y: hidden; + white-space: nowrap; + text-overflow: ellipsis; + + &__profile-name { + font-style: italic; + } +} + .module-message__author_with_sticker { color: $color-gray-90; font-size: 13px; @@ -555,6 +686,9 @@ .module-message__metadata__date--with-image-no-caption { color: $color-white; } +.module-message__metadata__date--incoming-with-tap-to-view-expired { + color: $color-gray-75; +} .module-message__metadata__spacer { flex-grow: 1; @@ -683,6 +817,9 @@ .module-expire-timer--incoming { background-color: $color-white-08; } +.module-expire-timer--incoming-with-tap-to-view-expired { + background-color: $color-gray-75; +} // When status indicators are overlaid on top of an image, they use different colors .module-expire-timer--with-image-no-caption { @@ -2813,8 +2950,8 @@ @include color-svg('../images/spinner-track-56.svg', $color-white-04); z-index: 2; - height: 56px; - width: 56px; + height: 100%; + width: 100%; } .module-spinner__arc { position: absolute; @@ -2823,8 +2960,8 @@ @include color-svg('../images/spinner-56.svg', $color-gray-60); z-index: 3; - height: 56px; - width: 56px; + height: 100%; + width: 100%; animation: spinner-arc-animation 1000ms linear infinite; } @@ -2844,38 +2981,13 @@ // In these --small and --mini sizes, we're exploding our @color-svg mixin so we don't // have to duplicate our background colors for the dark/ios/size matrix. -.module-spinner__container--small { - height: 24px; - width: 24px; -} .module-spinner__circle--small { -webkit-mask: url('../images/spinner-track-24.svg') no-repeat center; -webkit-mask-size: 100%; - height: 24px; - width: 24px; } .module-spinner__arc--small { -webkit-mask: url('../images/spinner-24.svg') no-repeat center; -webkit-mask-size: 100%; - height: 24px; - width: 24px; -} - -.module-spinner__container--mini { - height: 14px; - width: 14px; -} -.module-spinner__circle--mini { - -webkit-mask: url('../images/spinner-track-24.svg') no-repeat center; - -webkit-mask-size: 100%; - height: 14px; - width: 14px; -} -.module-spinner__arc--mini { - -webkit-mask: url('../images/spinner-24.svg') no-repeat center; - -webkit-mask-size: 100%; - height: 14px; - width: 14px; } .module-spinner__circle--incoming { @@ -4524,6 +4636,19 @@ } } +// Module: Countdown + +.module-countdown { + display: block; + width: 100%; +} + +.module-countdown__path { + fill-opacity: 0; + stroke: $color-white; + stroke-width: 2; +} + // Third-party module: react-contextmenu .react-contextmenu { diff --git a/stylesheets/_theme_dark.scss b/stylesheets/_theme_dark.scss index 5c0f9c2501..eca928bc75 100644 --- a/stylesheets/_theme_dark.scss +++ b/stylesheets/_theme_dark.scss @@ -579,6 +579,75 @@ body.dark-theme { background-color: $color-conversation-blue_grey; } + .module-message__container--incoming--tap-to-view-pending { + background-color: $color-conversation-grey-shade; + } + .module-message__container--incoming-red-tap-to-view-pending { + background-color: $color-conversation-red-shade; + } + .module-message__container--incoming-deep_orange-tap-to-view-pending { + background-color: $color-conversation-deep_orange-shade; + } + .module-message__container--incoming-brown-tap-to-view-pending { + background-color: $color-conversation-brown-shade; + } + .module-message__container--incoming-pink-tap-to-view-pending { + background-color: $color-conversation-pink-shade; + } + .module-message__container--incoming-purple-tap-to-view-pending { + background-color: $color-conversation-purple-shade; + } + .module-message__container--incoming-indigo-tap-to-view-pending { + background-color: $color-conversation-indigo-shade; + } + .module-message__container--incoming-blue-tap-to-view-pending { + background-color: $color-conversation-blue-shade; + } + .module-message__container--incoming-teal-tap-to-view-pending { + background-color: $color-conversation-teal-shade; + } + .module-message__container--incoming-green-tap-to-view-pending { + background-color: $color-conversation-green-shade; + } + .module-message__container--incoming-light_green-tap-to-view-pending { + background-color: $color-conversation-light_green-shade; + } + .module-message__container--incoming-blue_grey-tap-to-view-pending { + background-color: $color-conversation-blue_grey-shade; + } + + .module-message__container--with-tap-to-view-expired { + border: 1px solid $color-gray-60; + background-color: $color-black; + } + + .module-message__container--with-tap-to-view-error { + background-color: $color-gray-95; + border: 1px solid $color-deep-red; + } + + .module-message__tap-to-view__icon { + background-color: $color-gray-05; + } + .module-message__tap-to-view__icon--outgoing { + background-color: $color-gray-05; + } + .module-message__tap-to-view__icon--expired { + background-color: $color-gray-05; + } + .module-message__tap-to-view__text { + color: $color-gray-05; + } + .module-message__tap-to-view__text--incoming { + color: $color-gray-05; + } + .module-message__tap-to-view__text--incoming-expired { + color: $color-gray-05; + } + .module-message__tap-to-view__text--incoming-error { + color: $color-gray-25; + } + .module-message__attachment-container { background-color: $color-gray-95; } @@ -674,6 +743,10 @@ body.dark-theme { color: $color-white; } + .module-message__author--with-tap-to-view-expired { + color: $color-white; + } + .module-message__author_with_sticker { color: $color-gray-05; } diff --git a/stylesheets/_variables.scss b/stylesheets/_variables.scss index edd888c84a..7e3a2edab2 100644 --- a/stylesheets/_variables.scss +++ b/stylesheets/_variables.scss @@ -35,6 +35,7 @@ $roboto-light: Roboto-Light, 'Helvetica Neue', 'Source Sans Pro Light', $color-signal-blue: #2090ea; $color-core-green: #4caf50; $color-core-red: #f44336; +$color-deep-red: #ff261f; $color-signal-blue-025: rgba($color-signal-blue, 0.25); $color-signal-blue-050: rgba($color-signal-blue, 0.5); diff --git a/test/index.html b/test/index.html index 96dec194e6..479d341176 100644 --- a/test/index.html +++ b/test/index.html @@ -474,6 +474,7 @@ + diff --git a/ts/components/Countdown.md b/ts/components/Countdown.md new file mode 100644 index 0000000000..24a8861577 --- /dev/null +++ b/ts/components/Countdown.md @@ -0,0 +1,23 @@ +#### New timer + +```jsx +