From e62a1a78125eb5bbd4d487b915a27a246fc84514 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Wed, 26 Jun 2019 12:33:13 -0700 Subject: [PATCH] Receive support for View Once photos --- _locales/en/messages.json | 20 + app/sql.js | 171 ++++++- background.html | 2 + images/play-filled-24.svg | 7 + images/play-outline-24.svg | 7 + js/background.js | 18 + js/expiring_tap_to_view_messages.js | 109 +++++ js/models/conversations.js | 8 +- js/models/messages.js | 208 ++++++++- js/modules/backup.js | 2 +- js/modules/data.js | 26 +- js/view_syncs.js | 67 +++ js/views/conversation_view.js | 69 ++- libtextsecure/message_receiver.js | 21 +- libtextsecure/sendmessage.js | 29 ++ protos/SignalService.proto | 10 +- stylesheets/_ios.scss | 77 +++- stylesheets/_modules.scss | 183 ++++++-- stylesheets/_theme_dark.scss | 73 +++ stylesheets/_variables.scss | 1 + test/index.html | 1 + ts/components/Countdown.md | 23 + ts/components/Countdown.tsx | 99 ++++ ts/components/Lightbox.md | 30 +- ts/components/Lightbox.tsx | 5 +- ts/components/Spinner.md | 33 +- ts/components/Spinner.tsx | 23 +- ts/components/conversation/ContactDetail.md | 36 ++ ts/components/conversation/ExpireTimer.tsx | 13 +- ts/components/conversation/Image.tsx | 2 +- ts/components/conversation/Message.md | 434 ++++++++++++++++++ ts/components/conversation/Message.tsx | 205 ++++++++- ts/components/conversation/Timestamp.tsx | 5 + ts/components/conversation/_contactUtil.tsx | 9 +- .../stickers/StickerPreviewModal.tsx | 4 +- ts/types/Message.ts | 2 + .../message/initializeAttachmentMetadata.ts | 3 + ts/util/lint/exceptions.json | 4 +- 38 files changed, 1937 insertions(+), 102 deletions(-) create mode 100644 images/play-filled-24.svg create mode 100644 images/play-outline-24.svg create mode 100644 js/expiring_tap_to_view_messages.js create mode 100644 js/view_syncs.js create mode 100644 ts/components/Countdown.md create mode 100644 ts/components/Countdown.tsx diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 9ad606fac..63465d743 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 86abb0264..cd1798cc3 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 d36ab91d8..bb235bd93 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 000000000..1ef1ec1cb --- /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 000000000..601da37c7 --- /dev/null +++ b/images/play-outline-24.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/js/background.js b/js/background.js index 1377d09c9..8e4c261e7 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 000000000..f8364781c --- /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 98364b786..6e60e6104 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 924979cdb..32cae463d 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 571b0e57d..edfe5af54 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 627fdc8ea..1c63f7367 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 000000000..fa0fc2fe3 --- /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 dfcdb155a..177cea9a4 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 1f4d604ed..8ec7199f5 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 5b6e070a7..73ae8c8fe 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 8c2c3bde3..cd51db361 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 b97c34dd7..d82d98cd5 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 9c3291a40..449599ec1 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 5c0f9c250..eca928bc7 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 edd888c84..7e3a2edab 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 96dec194e..479d34117 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 000000000..24a886157 --- /dev/null +++ b/ts/components/Countdown.md @@ -0,0 +1,23 @@ +#### New timer + +```jsx +
+ console.log('onComplete - new timer')} + /> +
+``` + +#### Already started + +```jsx +
+ console.log('onComplete - already started')} + /> +
+``` diff --git a/ts/components/Countdown.tsx b/ts/components/Countdown.tsx new file mode 100644 index 000000000..3c86b7774 --- /dev/null +++ b/ts/components/Countdown.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +// import classNames from 'classnames'; + +interface Props { + duration: number; + expiresAt: number; + onComplete?: () => unknown; +} +interface State { + ratio: number; +} + +const CIRCUMFERENCE = 11.013 * 2 * Math.PI; + +export class Countdown extends React.Component { + public looping = false; + + constructor(props: Props) { + super(props); + + const { duration, expiresAt } = this.props; + const ratio = getRatio(expiresAt, duration); + + this.state = { ratio }; + } + + public componentDidMount() { + this.startLoop(); + } + + public componentDidUpdate() { + this.startLoop(); + } + + public componentWillUnmount() { + this.stopLoop(); + } + + public startLoop() { + if (this.looping) { + return; + } + + this.looping = true; + requestAnimationFrame(this.loop); + } + + public stopLoop() { + this.looping = false; + } + + public loop = () => { + const { onComplete, duration, expiresAt } = this.props; + if (!this.looping) { + return; + } + + const ratio = getRatio(expiresAt, duration); + this.setState({ ratio }); + + if (ratio === 1) { + this.looping = false; + if (onComplete) { + onComplete(); + } + } else { + requestAnimationFrame(this.loop); + } + }; + + public render() { + const { ratio } = this.state; + const strokeDashoffset = ratio * CIRCUMFERENCE; + + return ( + + + + ); + } +} + +function getRatio(expiresAt: number, duration: number) { + const start = expiresAt - duration; + const end = expiresAt; + + const now = Date.now(); + const totalTime = end - start; + const elapsed = now - start; + + return Math.min(Math.max(0, elapsed / totalTime), 1); +} diff --git a/ts/components/Lightbox.md b/ts/components/Lightbox.md index a59ac4c41..e93d2e7cb 100644 --- a/ts/components/Lightbox.md +++ b/ts/components/Lightbox.md @@ -1,6 +1,6 @@ ## Image -```js +```jsx const noop = () => {};
@@ -15,7 +15,7 @@ const noop = () => {}; ## Image with caption -```js +```jsx const noop = () => {};
@@ -29,9 +29,27 @@ const noop = () => {};
; ``` +## Image with timer + +```jsx +const noop = () => {}; + +
+ console.log('close')} + i18n={util.i18n} + /> +
; +``` + ## Image (unsupported format) -```js +```jsx const noop = () => {};
@@ -46,7 +64,7 @@ const noop = () => {}; ## Video (supported format) -```js +```jsx const noop = () => {};
@@ -61,7 +79,7 @@ const noop = () => {}; ## Video (unsupported format) -```js +```jsx const noop = () => {};
@@ -76,7 +94,7 @@ const noop = () => {}; ## Unsupported file format -```js +```jsx const noop = () => {};
diff --git a/ts/components/Lightbox.tsx b/ts/components/Lightbox.tsx index 1f46fac2b..ac45bc77c 100644 --- a/ts/components/Lightbox.tsx +++ b/ts/components/Lightbox.tsx @@ -104,6 +104,9 @@ const styles = { saveButton: { marginTop: 10, }, + countdownContainer: { + padding: 8, + }, iconButtonPlaceholder: { // Dimensions match `.iconButton`: display: 'inline-block', @@ -211,11 +214,11 @@ export class Lightbox extends React.Component { const { caption, contentType, + i18n, objectURL, onNext, onPrevious, onSave, - i18n, } = this.props; return ( diff --git a/ts/components/Spinner.md b/ts/components/Spinner.md index 1924dcc65..3bf5cab9c 100644 --- a/ts/components/Spinner.md +++ b/ts/components/Spinner.md @@ -1,32 +1,47 @@ -#### Large +#### Normal, no size ```jsx - +
- +
``` -#### Small +#### Normal, with size ```jsx - +
- +
``` -#### Mini +#### Small, no size ```jsx - +
- + +
+
+``` + +#### Small, sizes + +```jsx + + +
+ +
+ +
+
``` diff --git a/ts/components/Spinner.tsx b/ts/components/Spinner.tsx index acd8e484d..663b743ed 100644 --- a/ts/components/Spinner.tsx +++ b/ts/components/Spinner.tsx @@ -2,37 +2,44 @@ import React from 'react'; import classNames from 'classnames'; interface Props { - size: 'small' | 'mini' | 'normal'; + size?: string; + svgSize: 'small' | 'normal'; direction?: string; } export class Spinner extends React.Component { public render() { - const { size, direction } = this.props; + const { size, svgSize, direction } = this.props; return (
diff --git a/ts/components/conversation/ContactDetail.md b/ts/components/conversation/ContactDetail.md index 9f87df8bf..cd2c5ae94 100644 --- a/ts/components/conversation/ContactDetail.md +++ b/ts/components/conversation/ContactDetail.md @@ -199,6 +199,42 @@ const contact = { />; ``` +### With all data types + +```jsx +const contact = { + avatar: { + avatar: { + pending: true, + }, + }, + name: { + displayName: 'Someone Somewhere', + }, + number: [ + { + value: '(202) 555-0000', + type: 3, + }, + ], + address: [ + { + street: '5 Pike Place', + city: 'Seattle', + region: 'WA', + postcode: '98101', + type: 1, + }, + ], +}; + console.log('onSendMessage')} +/>; +``` + ### Empty contact ```jsx diff --git a/ts/components/conversation/ExpireTimer.tsx b/ts/components/conversation/ExpireTimer.tsx index 05a7c6a98..bc75d6671 100644 --- a/ts/components/conversation/ExpireTimer.tsx +++ b/ts/components/conversation/ExpireTimer.tsx @@ -4,8 +4,9 @@ import classNames from 'classnames'; import { getIncrement, getTimerBucket } from '../../util/timer'; interface Props { - withImageNoCaption: boolean; - withSticker: boolean; + withImageNoCaption?: boolean; + withSticker?: boolean; + withTapToViewExpired?: boolean; expirationLength: number; expirationTimestamp: number; direction?: 'incoming' | 'outgoing'; @@ -46,6 +47,7 @@ export class ExpireTimer extends React.Component { expirationTimestamp, withImageNoCaption, withSticker, + withTapToViewExpired, } = this.props; const bucket = getTimerBucket(expirationTimestamp, expirationLength); @@ -55,8 +57,11 @@ export class ExpireTimer extends React.Component { className={classNames( 'module-expire-timer', `module-expire-timer--${bucket}`, - `module-expire-timer--${direction}`, - withImageNoCaption + direction ? `module-expire-timer--${direction}` : null, + withTapToViewExpired + ? `module-expire-timer--${direction}-with-tap-to-view-expired` + : null, + direction && withImageNoCaption ? 'module-expire-timer--with-image-no-caption' : null, withSticker ? 'module-expire-timer--with-sticker' : null diff --git a/ts/components/conversation/Image.tsx b/ts/components/conversation/Image.tsx index 376170213..a996b73df 100644 --- a/ts/components/conversation/Image.tsx +++ b/ts/components/conversation/Image.tsx @@ -99,7 +99,7 @@ export class Image extends React.Component { }} // alt={i18n('loading')} > - +
) : ( ``` +### Tap to view + +```jsx + +
  • + + console.log('displayTapToViewMessage', args) + } + authorAvatarPath={util.gifObjectUrl} + /> +
  • +
  • + + console.log('displayTapToViewMessage', args) + } + /> +
  • +
  • + + console.log('displayTapToViewMessage', args) + } + authorAvatarPath={util.gifObjectUrl} + /> +
  • +
  • + + console.log('displayTapToViewMessage', args) + } + /> +
  • +
  • + + console.log('displayTapToViewMessage', args) + } + /> +
  • +
  • + + console.log('displayTapToViewMessage', args) + } + /> +
  • +
  • + + console.log('displayTapToViewMessage', args) + } + authorAvatarPath={util.gifObjectUrl} + /> +
  • +
  • + + console.log('displayTapToViewMessage', args) + } + authorAvatarPath={util.gifObjectUrl} + /> +
  • +
  • + + console.log('displayTapToViewMessage', args) + } + authorAvatarPath={util.gifObjectUrl} + /> +
  • +
  • + + console.log('displayTapToViewMessage', args) + } + authorAvatarPath={util.gifObjectUrl} + /> +
  • +
  • + + console.log('displayTapToViewMessage', args) + } + authorAvatarPath={util.gifObjectUrl} + /> +
  • +
  • + + console.log('displayTapToViewMessage', args) + } + /> +
  • +
  • + + console.log('displayTapToViewMessage', args) + } + authorAvatarPath={util.gifObjectUrl} + /> +
  • +
  • + + console.log('displayTapToViewMessage', args) + } + authorAvatarPath={util.gifObjectUrl} + /> +
  • +
  • + + console.log('displayTapToViewMessage', args) + } + authorAvatarPath={util.gifObjectUrl} + /> +
  • +
  • + + console.log('displayTapToViewMessage', args) + } + authorAvatarPath={util.gifObjectUrl} + /> +
  • +
  • + + console.log('displayTapToViewMessage', args) + } + authorAvatarPath={util.gifObjectUrl} + /> +
  • +
    +``` + ### In a group conversation Note that the author avatar goes away if `collapseMetadata` is set. diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index d32b5e4b6..1009b57f4 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -80,6 +80,11 @@ export type PropsData = { previews: Array; authorAvatarPath?: string; isExpired: boolean; + + isTapToView?: boolean; + isTapToViewExpired?: boolean; + isTapToViewError?: boolean; + expirationLength?: number; expirationTimestamp?: number; }; @@ -112,6 +117,7 @@ export type PropsActions = { isDangerous: boolean; } ) => void; + displayTapToViewMessage: (messageId: string) => unknown; openLink: (url: string) => void; scrollToMessage: ( @@ -227,6 +233,7 @@ export class Message extends React.PureComponent { expirationTimestamp, i18n, isSticker, + isTapToViewExpired, status, text, textPending, @@ -274,6 +281,7 @@ export class Message extends React.PureComponent { direction={metadataDirection} withImageNoCaption={withImageNoCaption} withSticker={isSticker} + withTapToViewExpired={isTapToViewExpired} module="module-message__metadata__date" /> )} @@ -284,12 +292,13 @@ export class Message extends React.PureComponent { expirationTimestamp={expirationTimestamp} withImageNoCaption={withImageNoCaption} withSticker={isSticker} + withTapToViewExpired={isTapToViewExpired} /> ) : null} {textPending ? (
    - +
    ) : null} {!textPending && direction === 'outgoing' && status !== 'error' ? ( @@ -302,6 +311,9 @@ export class Message extends React.PureComponent { : null, withImageNoCaption ? 'module-message__metadata__status-icon--with-image-no-caption' + : null, + isTapToViewExpired + ? 'module-message__metadata__status-icon--with-tap-to-view-expired' : null )} /> @@ -320,6 +332,8 @@ export class Message extends React.PureComponent { direction, i18n, isSticker, + isTapToView, + isTapToViewExpired, } = this.props; if (collapseMetadata) { @@ -332,8 +346,13 @@ export class Message extends React.PureComponent { return null; } - const suffix = isSticker ? '_with_sticker' : ''; - const moduleName = `module-message__author${suffix}`; + const withTapToViewExpired = isTapToView && isTapToViewExpired; + + const stickerSuffix = isSticker ? '_with_sticker' : ''; + const tapToViewSuffix = withTapToViewExpired + ? '--with-tap-to-view-expired' + : ''; + const moduleName = `module-message__author${stickerSuffix}${tapToViewSuffix}`; return (
    @@ -452,7 +471,7 @@ export class Message extends React.PureComponent { > {pending ? (
    - +
    ) : (
    @@ -805,6 +824,7 @@ export class Message extends React.PureComponent { downloadAttachment, id, isSticker, + isTapToView, replyToMessage, timestamp, } = this.props; @@ -822,6 +842,7 @@ export class Message extends React.PureComponent { const downloadButton = !isSticker && !multipleAttachments && + !isTapToView && firstAttachment && !firstAttachment.pending ? (
    { public renderContextMenu(triggerId: string) { const { attachments, + deleteMessage, direction, downloadAttachment, i18n, id, isSticker, - deleteMessage, - showMessageDetail, + isTapToView, replyToMessage, retrySend, + showMessageDetail, status, timestamp, } = this.props; @@ -907,7 +929,11 @@ export class Message extends React.PureComponent { const menu = ( - {!isSticker && !multipleAttachments && attachments && attachments[0] ? ( + {!isSticker && + !multipleAttachments && + !isTapToView && + attachments && + attachments[0] ? ( { } public isShowingImage() { - const { attachments, previews } = this.props; + const { isTapToView, attachments, previews } = this.props; const { imageBroken } = this.state; - if (imageBroken) { + if (imageBroken || isTapToView) { return false; } @@ -1042,17 +1068,153 @@ export class Message extends React.PureComponent { return false; } + public isAttachmentPending() { + const { attachments } = this.props; + + if (!attachments || attachments.length < 1) { + return false; + } + + const first = attachments[0]; + + return Boolean(first.pending); + } + + public renderTapToViewIcon() { + const { direction, isTapToViewExpired } = this.props; + const isDownloadPending = this.isAttachmentPending(); + + return !isTapToViewExpired && isDownloadPending ? ( +
    + +
    + ) : ( +
    + ); + } + + public renderTapToViewText() { + const { + direction, + i18n, + isTapToViewExpired, + isTapToViewError, + } = this.props; + + const incomingString = isTapToViewExpired + ? i18n('Message--tap-to-view-expired') + : i18n('Message--tap-to-view--incoming'); + const outgoingString = i18n('Message--tap-to-view--outgoing'); + const isDownloadPending = this.isAttachmentPending(); + + if (isDownloadPending) { + return; + } + + return isTapToViewError + ? i18n('incomingError') + : direction === 'outgoing' + ? outgoingString + : incomingString; + } + + public renderTapToView() { + const { + collapseMetadata, + conversationType, + direction, + isTapToViewExpired, + isTapToViewError, + } = this.props; + + const withContentBelow = !collapseMetadata; + const withContentAbove = + !collapseMetadata && + conversationType === 'group' && + direction === 'incoming'; + + return ( +
    + {isTapToViewError ? null : this.renderTapToViewIcon()} +
    + {this.renderTapToViewText()} +
    +
    + ); + } + + public renderContents() { + const { isTapToView } = this.props; + + if (isTapToView) { + return ( + <> + {this.renderTapToView()} + {this.renderMetadata()} + + ); + } + + return ( + <> + {this.renderQuote()} + {this.renderAttachment()} + {this.renderPreview()} + {this.renderEmbeddedContact()} + {this.renderText()} + {this.renderMetadata()} + {this.renderSendMessageButton()} + + ); + } + + // tslint:disable-next-line cyclomatic-complexity public render() { const { authorPhoneNumber, authorColor, attachments, direction, + displayTapToViewMessage, id, isSticker, + isTapToView, + isTapToViewExpired, + isTapToViewError, timestamp, } = this.props; const { expired, expiring, imageBroken } = this.state; + const isAttachmentPending = this.isAttachmentPending(); + const isButton = isTapToView && !isTapToViewExpired && !isAttachmentPending; // This id is what connects our triple-dot click with our associated pop-up menu. // It needs to be unique. @@ -1068,6 +1230,8 @@ export class Message extends React.PureComponent { const width = this.getWidth(); const isShowingImage = this.isShowingImage(); + const role = isButton ? 'button' : undefined; + const onClick = isButton ? () => displayTapToViewMessage(id) : undefined; return (
    { 'module-message__container', isSticker ? 'module-message__container--with-sticker' : null, !isSticker ? `module-message__container--${direction}` : null, + isTapToView ? 'module-message__container--with-tap-to-view' : null, + isTapToView && isTapToViewExpired + ? 'module-message__container--with-tap-to-view-expired' + : null, !isSticker && direction === 'incoming' ? `module-message__container--incoming-${authorColor}` + : null, + isTapToView && isAttachmentPending && !isTapToViewExpired + ? 'module-message__container--with-tap-to-view-pending' + : null, + isTapToView && isAttachmentPending && !isTapToViewExpired + ? `module-message__container--${direction}-${authorColor}-tap-to-view-pending` + : null, + isTapToViewError + ? 'module-message__container--with-tap-to-view-error' : null )} style={{ width: isShowingImage ? width : undefined, }} + role={role} + onClick={onClick} > {this.renderAuthor()} - {this.renderQuote()} - {this.renderAttachment()} - {this.renderPreview()} - {this.renderEmbeddedContact()} - {this.renderText()} - {this.renderMetadata()} - {this.renderSendMessageButton()} + {this.renderContents()} {this.renderAvatar()}
    {this.renderError(direction === 'outgoing')} diff --git a/ts/components/conversation/Timestamp.tsx b/ts/components/conversation/Timestamp.tsx index 8ea68ddd6..d9a3a283d 100644 --- a/ts/components/conversation/Timestamp.tsx +++ b/ts/components/conversation/Timestamp.tsx @@ -12,6 +12,7 @@ interface Props { module?: string; withImageNoCaption?: boolean; withSticker?: boolean; + withTapToViewExpired?: boolean; direction?: 'incoming' | 'outgoing'; i18n: LocalizerType; } @@ -50,6 +51,7 @@ export class Timestamp extends React.Component { timestamp, withImageNoCaption, withSticker, + withTapToViewExpired, extended, } = this.props; const moduleName = module || 'module-timestamp'; @@ -63,6 +65,9 @@ export class Timestamp extends React.Component { className={classNames( moduleName, direction ? `${moduleName}--${direction}` : null, + withTapToViewExpired && direction + ? `${moduleName}--${direction}-with-tap-to-view-expired` + : null, withImageNoCaption ? `${moduleName}--with-image-no-caption` : null, withSticker ? `${moduleName}--with-sticker` : null )} diff --git a/ts/components/conversation/_contactUtil.tsx b/ts/components/conversation/_contactUtil.tsx index 621efddb8..9a01df86f 100644 --- a/ts/components/conversation/_contactUtil.tsx +++ b/ts/components/conversation/_contactUtil.tsx @@ -25,12 +25,17 @@ export function renderAvatar({ const avatarPath = avatar && avatar.avatar && avatar.avatar.path; const pending = avatar && avatar.avatar && avatar.avatar.pending; const name = getName(contact) || ''; - const spinnerSize = size < 50 ? 'small' : 'normal'; + const spinnerSvgSize = size < 50 ? 'small' : 'normal'; + const spinnerSize = size < 50 ? '24px' : undefined; if (pending) { return (
    - +
    ); } diff --git a/ts/components/stickers/StickerPreviewModal.tsx b/ts/components/stickers/StickerPreviewModal.tsx index 559350810..262f757d3 100644 --- a/ts/components/stickers/StickerPreviewModal.tsx +++ b/ts/components/stickers/StickerPreviewModal.tsx @@ -39,7 +39,7 @@ function renderBody({ pack, i18n }: Props) { } if (!pack || pack.stickerCount === 0 || !isNumber(pack.stickerCount)) { - return ; + return ; } return ( @@ -209,7 +209,7 @@ export const StickerPreviewModal = React.memo(
    {pack.status === 'pending' ? ( - + ) : ( ; expireTimer?: number; + messageTimer?: number; flags?: number; source?: string; sourceDevice?: number; @@ -46,6 +47,7 @@ export type OutgoingMessage = Readonly< body?: string; expires_at?: number; expireTimer?: number; + messageTimer?: number; recipients?: Array; // Array synced: boolean; } & SharedMessageProperties & diff --git a/ts/types/message/initializeAttachmentMetadata.ts b/ts/types/message/initializeAttachmentMetadata.ts index 30d679479..42365dcb1 100644 --- a/ts/types/message/initializeAttachmentMetadata.ts +++ b/ts/types/message/initializeAttachmentMetadata.ts @@ -16,6 +16,9 @@ export const initializeAttachmentMetadata = async ( if (message.type === 'verified-change') { return message; } + if (message.messageTimer) { + return message; + } const attachments = message.attachments.filter( (attachment: Attachment.Attachment) => diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 19d5a0b72..d19f8630e 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -6095,7 +6095,7 @@ "rule": "React-createRef", "path": "ts/components/Lightbox.js", "line": " this.videoRef = react_1.default.createRef();", - "lineNumber": 180, + "lineNumber": 183, "reasonCategory": "usageTrusted", "updated": "2019-03-09T00:08:44.242Z", "reasonDetail": "Used to auto-start playback on videos" @@ -6104,7 +6104,7 @@ "rule": "React-createRef", "path": "ts/components/Lightbox.tsx", "line": " this.videoRef = React.createRef();", - "lineNumber": 176, + "lineNumber": 179, "reasonCategory": "usageTrusted", "updated": "2019-03-09T00:08:44.242Z", "reasonDetail": "Used to auto-start playback on videos"