From 381cb06be8a528d476bf79e8afda5fd9773499d1 Mon Sep 17 00:00:00 2001 From: sha-265 <4103710+sha-265@users.noreply.github.com> Date: Fri, 3 Aug 2018 19:26:36 +0300 Subject: [PATCH 01/11] Fix text alignment for RTL messages (#2597) --- stylesheets/_modules.scss | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 1371119a9..ab0d418ad 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -446,12 +446,13 @@ color: $color-light-90; font-size: 14px; line-height: 18px; + text-align: start; overflow-wrap: break-word; word-wrap: break-word; word-break: break-word; white-space: pre-wrap; - + a { text-decoration: underline; color: $color-light-90; From 59f955ff302d60ab070e0d66d87b1bf0e0eaf428 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Fri, 3 Aug 2018 11:55:43 -0700 Subject: [PATCH 02/11] On error handling cached message, show error then delete --- libtextsecure/message_receiver.js | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/libtextsecure/message_receiver.js b/libtextsecure/message_receiver.js index 438c14017..a68f794e3 100644 --- a/libtextsecure/message_receiver.js +++ b/libtextsecure/message_receiver.js @@ -265,7 +265,7 @@ MessageReceiver.prototype.extend({ } }); }, - queueCached(item) { + async queueCached(item) { try { let envelopePlaintext = item.envelope; @@ -287,7 +287,24 @@ MessageReceiver.prototype.extend({ this.queueEnvelope(envelope); } } catch (error) { - window.log.error('queueCached error handling item', item.id); + window.log.error( + 'queueCached error handling item', + item.id, + 'removing it. Error:', + error && error.stack ? error.stack : error + ); + + try { + const { id } = item; + await textsecure.storage.unprocessed.remove(id); + } catch (deleteError) { + window.log.error( + 'queueCached error deleting item', + item.id, + 'Error:', + deleteError && deleteError.stack ? deleteError.stack : deleteError + ); + } } }, getEnvelopeId(envelope) { From 1d18c5686d5e7eb411945bcff529a78053049766 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Fri, 3 Aug 2018 12:07:09 -0700 Subject: [PATCH 03/11] Fix problems with transparency on some macOS computers --- stylesheets/_index.scss | 2 +- stylesheets/_modules.scss | 4 ++-- stylesheets/_variables.scss | 2 ++ 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/stylesheets/_index.scss b/stylesheets/_index.scss index f040262eb..16cb6852d 100644 --- a/stylesheets/_index.scss +++ b/stylesheets/_index.scss @@ -19,7 +19,7 @@ } .gutter { - background-color: $color-black-008; + background-color: $color-black-008-no-tranparency; float: left; width: 300px; .content { diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index ab0d418ad..cc52e0d1f 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -1947,7 +1947,7 @@ cursor: pointer; &:hover { - background-color: $color-black-008; + background-color: $color-black-016-no-tranparency; } } @@ -1957,7 +1957,7 @@ } .module-conversation-list-item--is-selected { - background-color: $color-black-008; + background-color: $color-black-016-no-tranparency; } .module-conversation-list-item__avatar { diff --git a/stylesheets/_variables.scss b/stylesheets/_variables.scss index 921150af4..31a83d998 100644 --- a/stylesheets/_variables.scss +++ b/stylesheets/_variables.scss @@ -50,6 +50,8 @@ $color-dark-70: #414347; $color-dark-85: #1a1c20; $color-black: #000000; $color-black-008: rgba($color-black, 0.08); +$color-black-008-no-tranparency: #ededed; +$color-black-016-no-tranparency: #d9d9d9; $color-black-012: rgba($color-black, 0.12); $color-black-02: rgba($color-black, 0.2); $color-black-04: rgba($color-black, 0.4); From 4b3ddef95543b67774d6ffe6f496cd10ce465cf9 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Mon, 6 Aug 2018 09:04:27 -0700 Subject: [PATCH 04/11] Allow for 'error' status on incoming messages not just outgoing --- js/models/messages.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/js/models/messages.js b/js/models/messages.js index e16fdb8cc..0c553b472 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -389,13 +389,12 @@ }; }, getMessagePropStatus() { - if (!this.isOutgoing()) { - return null; - } - if (this.hasErrors()) { return 'error'; } + if (!this.isOutgoing()) { + return null; + } const readBy = this.get('read_by') || []; if (readBy.length > 0) { From a01db40e278431102f1fa6df6c35f0ceb17d05c5 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Mon, 6 Aug 2018 11:12:37 -0700 Subject: [PATCH 05/11] Ensure that a SQL command timeout results in an error --- js/modules/data.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/js/modules/data.js b/js/modules/data.js index 04d5300d8..2aae4d14a 100644 --- a/js/modules/data.js +++ b/js/modules/data.js @@ -178,8 +178,8 @@ function makeChannel(fnName) { }); setTimeout( - () => resolve(new Error(`Request to ${fnName} timed out`)), - 5000 + () => reject(new Error(`Request to ${fnName} timed out`)), + 10000 ); }); }; From 9ed1ee90f84e11c891a9d287a7b6ec480c1200b7 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Mon, 6 Aug 2018 11:31:41 -0700 Subject: [PATCH 06/11] Move expiring message time earlier if read sync has earlier time --- js/models/conversations.js | 1 + js/models/messages.js | 4 +-- js/read_syncs.js | 53 +++++++++++++++++++++++++------------- 3 files changed, 38 insertions(+), 20 deletions(-) diff --git a/js/models/conversations.js b/js/models/conversations.js index aa8b9c342..cba3f5e29 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -139,6 +139,7 @@ // Listening for out-of-band data updates this.on('delivered', this.updateAndMerge); this.on('read', this.updateAndMerge); + this.on('expiration-change', this.updateAndMerge); this.on('expired', this.onExpired); }, diff --git a/js/models/messages.js b/js/models/messages.js index 0c553b472..b8e485508 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -1289,8 +1289,8 @@ } return msFromNow; }, - async setToExpire() { - if (this.isExpiring() && !this.get('expires_at')) { + async setToExpire(force = false) { + if (this.isExpiring() && (force || !this.get('expires_at'))) { const start = this.get('expirationStartTimestamp'); const delta = this.get('expireTimer') * 1000; const expiresAt = start + delta; diff --git a/js/read_syncs.js b/js/read_syncs.js index 81a0c0705..4732445c3 100644 --- a/js/read_syncs.js +++ b/js/read_syncs.js @@ -1,4 +1,4 @@ -/* global Backbone, Whisper, ConversationController */ +/* global Backbone, Whisper */ /* eslint-disable more/no-then */ @@ -32,9 +32,7 @@ const message = messages.find( item => - item.isIncoming() && - item.isUnread() && - item.get('source') === receipt.get('sender') + item.isIncoming() && item.get('source') === receipt.get('sender') ); const notificationForMessage = message ? Whisper.Notifications.findWhere({ messageId: message.id }) @@ -59,11 +57,39 @@ return; } - await message.markRead(receipt.get('read_at')); - // This notification may result in messages older than this one being - // marked read. We want those messages to have the same expire timer - // start time as this one, so we pass the read_at value through. - this.notifyConversation(message, receipt.get('read_at')); + const readAt = receipt.get('read_at'); + + // If message is unread, we mark it read. Otherwise, we update the expiration + // timer to the time specified by the read sync if it's earlier than + // the previous read time. + if (message.isUnread()) { + await message.markRead(readAt); + + // onReadMessage may result in messages older than this one being + // marked read. We want those messages to have the same expire timer + // start time as this one, so we pass the readAt value through. + const conversation = message.getConversation(); + if (conversation) { + conversation.onReadMessage(message, readAt); + } + } else { + const now = Date.now(); + const existingTimestamp = message.get('expirationStartTimestamp'); + const expirationStartTimestamp = Math.min( + now, + Math.min(existingTimestamp || now, readAt || now) + ); + message.set({ expirationStartTimestamp }); + + const force = true; + await message.setToExpire(force); + + const conversation = message.getConversation(); + if (conversation) { + conversation.trigger('expiration-change', message); + } + } + this.remove(receipt); } catch (error) { window.log.error( @@ -72,14 +98,5 @@ ); } }, - notifyConversation(message, readAt) { - const conversation = ConversationController.get({ - id: message.get('conversationId'), - }); - - if (conversation) { - conversation.onReadMessage(message, readAt); - } - }, }))(); })(); From 9ff80469a53076e5d5eef9c5158fd33e18c94518 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Mon, 6 Aug 2018 11:33:51 -0700 Subject: [PATCH 07/11] In partially-successful group send, don't start expire timer --- js/models/messages.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/js/models/messages.js b/js/models/messages.js index b8e485508..07d5b7db7 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -824,7 +824,6 @@ this.trigger('pending'); return promise .then(async result => { - const now = Date.now(); this.trigger('done'); // This is used by sendSyncMessage, then set to null @@ -836,7 +835,7 @@ this.set({ sent_to: _.union(sentTo, result.successfulNumbers), sent: true, - expirationStartTimestamp: now, + expirationStartTimestamp: Date.now(), }); await window.Signal.Data.saveMessage(this.attributes, { @@ -846,7 +845,6 @@ this.sendSyncMessage(); }) .catch(result => { - const now = Date.now(); this.trigger('done'); if (result.dataMessage) { @@ -867,10 +865,12 @@ this.saveErrors(result.errors); if (result.successfulNumbers.length > 0) { const sentTo = this.get('sent_to') || []; + + // Note: In a partially-successful group send, we do not start + // the expiration timer. this.set({ sent_to: _.union(sentTo, result.successfulNumbers), sent: true, - expirationStartTimestamp: now, }); promises.push(this.sendSyncMessage()); } From 44dec45995e4f8dd9bda86793080ebf2d30f3225 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Mon, 6 Aug 2018 12:11:02 -0700 Subject: [PATCH 08/11] Ensure that all messages in cache are migrated properly --- js/background.js | 1 + js/modules/migrate_to_sql.js | 22 ++++++++++++++++++++-- libtextsecure/message_receiver.js | 26 +++++++++++++------------- 3 files changed, 34 insertions(+), 15 deletions(-) diff --git a/js/background.js b/js/background.js index b1a02af86..65c5ef8bc 100644 --- a/js/background.js +++ b/js/background.js @@ -338,6 +338,7 @@ db, clearStores: Whisper.Database.clearStores, handleDOMException: Whisper.Database.handleDOMException, + arrayBufferToString: textsecure.MessageReceiver.arrayBufferToString, countCallback: count => { window.log.info(`Migration: ${count} messages complete`); showMigrationStatus(count); diff --git a/js/modules/migrate_to_sql.js b/js/modules/migrate_to_sql.js index 36c8cb015..fff080d78 100644 --- a/js/modules/migrate_to_sql.js +++ b/js/modules/migrate_to_sql.js @@ -1,6 +1,6 @@ /* global window, IDBKeyRange */ -const { includes, isFunction, isString, last } = require('lodash'); +const { includes, isFunction, isString, last, forEach } = require('lodash'); const { saveMessages, _removeMessages, @@ -25,6 +25,7 @@ async function migrateToSQL({ clearStores, handleDOMException, countCallback, + arrayBufferToString, }) { if (!db) { throw new Error('Need db for IndexedDB connection!'); @@ -32,6 +33,9 @@ async function migrateToSQL({ if (!isFunction(clearStores)) { throw new Error('Need clearStores function!'); } + if (!isFunction(arrayBufferToString)) { + throw new Error('Need arrayBufferToString function!'); + } if (!isFunction(handleDOMException)) { throw new Error('Need handleDOMException function!'); } @@ -78,7 +82,21 @@ async function migrateToSQL({ // eslint-disable-next-line no-await-in-loop const status = await migrateStoreToSQLite({ db, - save: saveUnprocesseds, + save: async array => { + forEach(array, item => { + // In the new database, we can't store ArrayBuffers, so we turn these two fields + // into strings like MessageReceiver now does before save. + if (item.envelope) { + // eslint-disable-next-line no-param-reassign + item.envelope = arrayBufferToString(item.envelope); + } + if (item.decrypted) { + // eslint-disable-next-line no-param-reassign + item.decrypted = arrayBufferToString(item.decrypted); + } + }); + await saveUnprocesseds(array); + }, remove: removeUnprocessed, storeName: 'unprocessed', handleDOMException, diff --git a/libtextsecure/message_receiver.js b/libtextsecure/message_receiver.js index a68f794e3..871b69e2e 100644 --- a/libtextsecure/message_receiver.js +++ b/libtextsecure/message_receiver.js @@ -31,6 +31,11 @@ function MessageReceiver(username, password, signalingKey, options = {}) { } } +MessageReceiver.stringToArrayBuffer = string => + dcodeIO.ByteBuffer.wrap(string, 'binary').toArrayBuffer(); +MessageReceiver.arrayBufferToString = arrayBuffer => + dcodeIO.ByteBuffer.wrap(arrayBuffer).toString('binary'); + MessageReceiver.prototype = new textsecure.EventTarget(); MessageReceiver.prototype.extend({ constructor: MessageReceiver, @@ -269,10 +274,10 @@ MessageReceiver.prototype.extend({ try { let envelopePlaintext = item.envelope; - // Up until 0.42.6 we stored envelope and decrypted as strings in IndexedDB, - // so we need to be ready for them. if (typeof envelopePlaintext === 'string') { - envelopePlaintext = this.stringToArrayBuffer(envelopePlaintext); + envelopePlaintext = MessageReceiver.stringToArrayBuffer( + envelopePlaintext + ); } const envelope = textsecure.protobuf.Envelope.decode(envelopePlaintext); @@ -280,7 +285,9 @@ MessageReceiver.prototype.extend({ if (decrypted) { let payloadPlaintext = decrypted; if (typeof payloadPlaintext === 'string') { - payloadPlaintext = this.stringToArrayBuffer(payloadPlaintext); + payloadPlaintext = MessageReceiver.stringToArrayBuffer( + payloadPlaintext + ); } this.queueDecryptedEnvelope(envelope, payloadPlaintext); } else { @@ -312,13 +319,6 @@ MessageReceiver.prototype.extend({ envelope.sourceDevice } ${envelope.timestamp.toNumber()}`; }, - stringToArrayBuffer(string) { - // eslint-disable-next-line new-cap - return dcodeIO.ByteBuffer.wrap(string, 'binary').toArrayBuffer(); - }, - arrayBufferToString(arrayBuffer) { - return dcodeIO.ByteBuffer.wrap(arrayBuffer).toString('binary'); - }, getAllFromCache() { window.log.info('getAllFromCache'); return textsecure.storage.unprocessed.getAll().then(items => { @@ -356,7 +356,7 @@ MessageReceiver.prototype.extend({ const id = this.getEnvelopeId(envelope); const data = { id, - envelope: this.arrayBufferToString(plaintext), + envelope: MessageReceiver.arrayBufferToString(plaintext), timestamp: Date.now(), attempts: 1, }; @@ -365,7 +365,7 @@ MessageReceiver.prototype.extend({ updateCache(envelope, plaintext) { const id = this.getEnvelopeId(envelope); const data = { - decrypted: this.arrayBufferToString(plaintext), + decrypted: MessageReceiver.arrayBufferToString(plaintext), }; return textsecure.storage.unprocessed.update(id, data); }, From 6e193456f9aefdeb29b2cd848acbd46cec676477 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Mon, 6 Aug 2018 16:18:58 -0700 Subject: [PATCH 09/11] Eliminate orphaned external message files on startup Attachments, visual attachment thumbnails, video attachment screenshots Quote thumbnails Contact avatars --- app/attachments.js | 24 ++++++++++- app/sql.js | 104 ++++++++++++++++++++++++++++++++++++++++++++- js/modules/data.js | 5 +++ main.js | 8 ++++ package.json | 2 +- 5 files changed, 140 insertions(+), 3 deletions(-) diff --git a/app/attachments.js b/app/attachments.js index b1691e0a9..f8b8627fe 100644 --- a/app/attachments.js +++ b/app/attachments.js @@ -1,12 +1,22 @@ const crypto = require('crypto'); const path = require('path'); +const pify = require('pify'); +const glob = require('glob'); const fse = require('fs-extra'); const toArrayBuffer = require('to-arraybuffer'); -const { isArrayBuffer, isString } = require('lodash'); +const { map, isArrayBuffer, isString } = require('lodash'); const PATH = 'attachments.noindex'; +exports.getAllAttachments = async userDataPath => { + const dir = exports.getPath(userDataPath); + const pattern = path.join(dir, '**', '*'); + + const files = await pify(glob)(pattern, { nodir: true }); + return map(files, file => path.relative(dir, file)); +}; + // getPath :: AbsolutePath -> AbsolutePath exports.getPath = userDataPath => { if (!isString(userDataPath)) { @@ -120,6 +130,18 @@ exports.createDeleter = root => { }; }; +exports.deleteAll = async ({ userDataPath, attachments }) => { + const deleteFromDisk = exports.createDeleter(exports.getPath(userDataPath)); + + for (let index = 0, max = attachments.length; index < max; index += 1) { + const file = attachments[index]; + // eslint-disable-next-line no-await-in-loop + await deleteFromDisk(file); + } + + console.log(`deleteAll: deleted ${attachments.length} files`); +}; + // createName :: Unit -> IO String exports.createName = () => { const buffer = crypto.randomBytes(32); diff --git a/app/sql.js b/app/sql.js index 80b09627e..c4bfc9288 100644 --- a/app/sql.js +++ b/app/sql.js @@ -4,7 +4,7 @@ const rimraf = require('rimraf'); const sql = require('@journeyapps/sqlcipher'); const pify = require('pify'); const uuidv4 = require('uuid/v4'); -const { map, isString } = require('lodash'); +const { map, isString, fromPairs, forEach, last } = require('lodash'); // To get long stack traces // https://github.com/mapbox/node-sqlite3/wiki/API#sqlite3verbose @@ -15,6 +15,7 @@ module.exports = { close, removeDB, + getMessageCount, saveMessage, saveMessages, removeMessage, @@ -39,6 +40,8 @@ module.exports = { getMessagesNeedingUpgrade, getMessagesWithVisualMediaAttachments, getMessagesWithFileAttachments, + + removeKnownAttachments, }; function generateUUID() { @@ -260,6 +263,16 @@ async function removeDB() { rimraf.sync(filePath); } +async function getMessageCount() { + const row = await db.get('SELECT count(*) from messages;'); + + if (!row) { + throw new Error('getMessageCount: Unable to get count of messages'); + } + + return row['count(*)']; +} + async function saveMessage(data, { forceSave } = {}) { const { conversationId, @@ -710,3 +723,92 @@ async function getMessagesWithFileAttachments(conversationId, { limit }) { return map(rows, row => jsonToObject(row.json)); } + +function getExternalFilesForMessage(message) { + const { attachments, contact, quote } = message; + const files = []; + + forEach(attachments, attachment => { + const { path: file, thumbnail, screenshot } = attachment; + if (file) { + files.push(file); + } + + if (thumbnail && thumbnail.path) { + files.push(thumbnail.path); + } + + if (screenshot && screenshot.path) { + files.push(screenshot.path); + } + }); + + if (quote && quote.attachments && quote.attachments.length) { + forEach(quote.attachments, attachment => { + const { thumbnail } = attachment; + + if (thumbnail && thumbnail.path) { + files.push(thumbnail.path); + } + }); + } + + if (contact && contact.length) { + forEach(contact, item => { + const { avatar } = item; + + if (avatar && avatar.avatar && avatar.avatar.path) { + files.push(avatar.avatar.path); + } + }); + } + + return files; +} + +async function removeKnownAttachments(allAttachments) { + const lookup = fromPairs(map(allAttachments, file => [file, true])); + const chunkSize = 50; + + const total = await getMessageCount(); + console.log( + `removeKnownAttachments: About to iterate through ${total} messages` + ); + + let count = 0; + let complete = false; + let id = ''; + + while (!complete) { + // eslint-disable-next-line no-await-in-loop + const rows = await db.all( + `SELECT json FROM messages + WHERE id > $id + ORDER BY id ASC + LIMIT $chunkSize;`, + { + $id: id, + $chunkSize: chunkSize, + } + ); + + const messages = map(rows, row => jsonToObject(row.json)); + forEach(messages, message => { + const externalFiles = getExternalFilesForMessage(message); + forEach(externalFiles, file => { + delete lookup[file]; + }); + }); + + const lastMessage = last(messages); + if (lastMessage) { + ({ id } = lastMessage); + } + complete = messages.length < chunkSize; + count += messages.length; + } + + console.log(`removeKnownAttachments: Done processing ${count} messages`); + + return Object.keys(lookup); +} diff --git a/js/modules/data.js b/js/modules/data.js index 2aae4d14a..1ec904eb8 100644 --- a/js/modules/data.js +++ b/js/modules/data.js @@ -34,6 +34,7 @@ module.exports = { close, removeDB, + getMessageCount, saveMessage, saveLegacyMessage, saveMessages, @@ -201,6 +202,10 @@ async function removeDB() { await channels.removeDB(); } +async function getMessageCount() { + return channels.getMessageCount(); +} + async function saveMessage(data, { forceSave, Message } = {}) { const id = await channels.saveMessage(_cleanData(data), { forceSave }); Message.refreshExpirationTimer(); diff --git a/main.js b/main.js index 4a80ccd84..6d7c11fb9 100644 --- a/main.js +++ b/main.js @@ -26,6 +26,7 @@ const packageJson = require('./package.json'); const sql = require('./app/sql'); const sqlChannels = require('./app/sql_channel'); +const attachments = require('./app/attachments'); const attachmentChannel = require('./app/attachment_channel'); const autoUpdate = require('./app/auto_update'); const createTrayIcon = require('./app/tray_icon'); @@ -630,6 +631,13 @@ app.on('ready', async () => { await sql.initialize({ configDir: userDataPath, key }); await sqlChannels.initialize({ userConfig }); + const allAttachments = await attachments.getAllAttachments(userDataPath); + const orphanedAttachments = await sql.removeKnownAttachments(allAttachments); + await attachments.deleteAll({ + userDataPath, + attachments: orphanedAttachments, + }); + ready = true; autoUpdate.initialize(getMainWindow, locale.messages); diff --git a/package.json b/package.json index af002244f..b404abe05 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "firstline": "^1.2.1", "form-data": "^2.3.2", "fs-extra": "^5.0.0", + "glob": "^7.1.2", "google-libphonenumber": "^3.0.7", "got": "^8.2.0", "intl-tel-input": "^12.1.15", @@ -119,7 +120,6 @@ "eslint-plugin-mocha": "^4.12.1", "eslint-plugin-more": "^0.3.1", "extract-zip": "^1.6.6", - "glob": "^7.1.2", "grunt": "^1.0.1", "grunt-cli": "^1.2.0", "grunt-contrib-concat": "^1.0.1", From e2e575210fb3c71bcc0176be32da1241bf4a2045 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Mon, 6 Aug 2018 18:22:30 -0700 Subject: [PATCH 10/11] Fix lint issue in _modules.scss --- stylesheets/_modules.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index cc52e0d1f..986ba446d 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -452,7 +452,7 @@ word-wrap: break-word; word-break: break-word; white-space: pre-wrap; - + a { text-decoration: underline; color: $color-light-90; From f926a08aa8b3c5bb09f88f62e91da1314b0b7dfc Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Mon, 6 Aug 2018 18:37:23 -0700 Subject: [PATCH 11/11] v1.15.1-beta.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b404abe05..70094fdd7 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "productName": "Signal", "description": "Private messaging from your desktop", "repository": "https://github.com/signalapp/Signal-Desktop.git", - "version": "1.15.0-beta.10", + "version": "1.15.1-beta.1", "license": "GPL-3.0", "author": { "name": "Open Whisper Systems",