diff --git a/Gruntfile.js b/Gruntfile.js index d54ed509c..8abd86961 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -311,17 +311,21 @@ module.exports = grunt => { }); } - grunt.registerTask('unit-tests', 'Run unit tests w/Electron', () => { - const environment = grunt.option('env') || 'test'; - const done = this.async(); + grunt.registerTask( + 'unit-tests', + 'Run unit tests w/Electron', + function thisNeeded() { + const environment = grunt.option('env') || 'test'; + const done = this.async(); - runTests(environment, done); - }); + runTests(environment, done); + } + ); grunt.registerTask( 'lib-unit-tests', 'Run libtextsecure unit tests w/Electron', - () => { + function thisNeeded() { const environment = grunt.option('env') || 'test-lib'; const done = this.async(); @@ -329,82 +333,86 @@ module.exports = grunt => { } ); - grunt.registerMultiTask('test-release', 'Test packaged releases', () => { - const dir = grunt.option('dir') || 'dist'; - const environment = grunt.option('env') || 'production'; - const config = this.data; - const archive = [dir, config.archive].join('/'); - const files = [ - 'config/default.json', - `config/${environment}.json`, - `config/local-${environment}.json`, - ]; + grunt.registerMultiTask( + 'test-release', + 'Test packaged releases', + function thisNeeded() { + const dir = grunt.option('dir') || 'dist'; + const environment = grunt.option('env') || 'production'; + const config = this.data; + const archive = [dir, config.archive].join('/'); + const files = [ + 'config/default.json', + `config/${environment}.json`, + `config/local-${environment}.json`, + ]; - console.log(this.target, archive); - const releaseFiles = files.concat(config.files || []); - releaseFiles.forEach(fileName => { - console.log(fileName); - try { - asar.statFile(archive, fileName); - return true; - } catch (e) { - console.log(e); - throw new Error(`Missing file ${fileName}`); - } - }); + console.log(this.target, archive); + const releaseFiles = files.concat(config.files || []); + releaseFiles.forEach(fileName => { + console.log(fileName); + try { + asar.statFile(archive, fileName); + return true; + } catch (e) { + console.log(e); + throw new Error(`Missing file ${fileName}`); + } + }); - if (config.appUpdateYML) { - const appUpdateYML = [dir, config.appUpdateYML].join('/'); - if (fs.existsSync(appUpdateYML)) { - console.log('auto update ok'); - } else { - throw new Error(`Missing auto update config ${appUpdateYML}`); + if (config.appUpdateYML) { + const appUpdateYML = [dir, config.appUpdateYML].join('/'); + if (fs.existsSync(appUpdateYML)) { + console.log('auto update ok'); + } else { + throw new Error(`Missing auto update config ${appUpdateYML}`); + } } + + const done = this.async(); + // A simple test to verify a visible window is opened with a title + const { Application } = spectron; + + const app = new Application({ + path: [dir, config.exe].join('/'), + requireName: 'unused', + }); + + app + .start() + .then(() => app.client.getWindowCount()) + .then(count => { + assert.equal(count, 1); + console.log('window opened'); + }) + .then(() => + // Get the window's title + app.client.getTitle() + ) + .then(title => { + // Verify the window's title + assert.equal(title, packageJson.productName); + console.log('title ok'); + }) + .then(() => { + assert( + app.chromeDriver.logLines.indexOf(`NODE_ENV ${environment}`) > -1 + ); + console.log('environment ok'); + }) + .then( + () => + // Successfully completed test + app.stop(), + error => + // Test failed! + app.stop().then(() => { + grunt.fail.fatal(`Test failed: ${error.message} ${error.stack}`); + }) + ) + .then(done); } - - const done = this.async(); - // A simple test to verify a visible window is opened with a title - const { Application } = spectron; - - const app = new Application({ - path: [dir, config.exe].join('/'), - requireName: 'unused', - }); - - app - .start() - .then(() => app.client.getWindowCount()) - .then(count => { - assert.equal(count, 1); - console.log('window opened'); - }) - .then(() => - // Get the window's title - app.client.getTitle() - ) - .then(title => { - // Verify the window's title - assert.equal(title, packageJson.productName); - console.log('title ok'); - }) - .then(() => { - assert( - app.chromeDriver.logLines.indexOf(`NODE_ENV ${environment}`) > -1 - ); - console.log('environment ok'); - }) - .then( - () => - // Successfully completed test - app.stop(), - error => - // Test failed! - app.stop().then(() => { - grunt.fail.fatal(`Test failed: ${error.message} ${error.stack}`); - }) - ) - .then(done); - }); + ); grunt.registerTask('tx', ['exec:tx-pull', 'locale-patch']); grunt.registerTask('dev', ['default', 'watch']); diff --git a/_locales/en/messages.json b/_locales/en/messages.json index d0b038a0b..997883fd6 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -279,7 +279,7 @@ } }, "youMarkedAsVerified": { - "message": "You marked your safety number with $name$ as verified.", + "message": "You marked your Safety Number with $name$ as verified", "description": "Shown in the conversation history when the user marks a contact as verified.", "placeholders": { @@ -290,9 +290,9 @@ } }, "youMarkedAsNotVerified": { - "message": "You marked your safety number with $name$ as unverified.", + "message": "You marked your Safety Number with $name$ as not verified", "description": - "Shown in the conversation history when the user marks a contact as not verified, whether on the safety number screen or by dismissing a banner or dialog.", + "Shown in the conversation history when the user marks a contact as not verified, whether on the Safety Number screen or by dismissing a banner or dialog.", "placeholders": { "name": { "content": "$1", @@ -302,7 +302,7 @@ }, "youMarkedAsVerifiedOtherDevice": { "message": - "You marked your safety number with $name$ as verified from another device.", + "You marked your Safety Number with $name$ as verified from another device", "description": "Shown in the conversation history when we discover that the user marked a contact as verified on another device.", "placeholders": { @@ -314,7 +314,7 @@ }, "youMarkedAsNotVerifiedOtherDevice": { "message": - "You marked your safety number with $name$ as not verified from another device.", + "You marked your Safety Number with $name$ as not verified from another device", "description": "Shown in the conversation history when we discover that the user marked a contact as not verified on another device.", "placeholders": { @@ -473,7 +473,7 @@ "Your safety number with this contact has changed. This could either mean that someone is trying to intercept your communication, or this contact simply reinstalled Signal. You may wish to verify the new safety number below." }, "incomingError": { - "message": "Error handling incoming message." + "message": "Error handling incoming message" }, "media": { "message": "Media", @@ -754,9 +754,6 @@ "error": { "message": "Error" }, - "resend": { - "message": "Resend" - }, "messageDetail": { "message": "Message Detail" }, @@ -767,7 +764,7 @@ "message": "Are you sure? Clicking 'delete' will permanently remove this message from this device." }, - "deleteMessage": { + "deleteThisMessage": { "message": "Delete this message" }, "from": { @@ -823,6 +820,21 @@ "message": "You haven't exchanged any messages with this contact yet. Your safety number with them will be available after the first message." }, + "moreInfo": { + "message": "More Info...", + "description": + "Shown on the drop-down menu for an individual message, takes you to message detail screen" + }, + "retrySend": { + "message": "Retry Send", + "description": + "Shown on the drop-down menu for an indinvidaul message, but only if it is an outgoing message that failed to send" + }, + "deleteMessage": { + "message": "Delete Message", + "description": + "Shown on the drop-down menu for an individual message, deletes single message" + }, "deleteMessages": { "message": "Delete messages", "description": "Menu item for deleting messages, title case." @@ -842,10 +854,23 @@ "description": "Used in alt tag of thumbnail images inside of an embedded message quote" }, + "imageFailedToLoad": { + "message": "Image failed to load", + "description": "When an image attachment is missing, this message is shown" + }, + "videoScreenshotFailedToLoad": { + "message": "Video screenshot failed to load", + "description": + "When a attachment video screenshot is missing, this message is shown" + }, "imageAttachmentAlt": { "message": "Image attached to message", "description": "Used in alt tag of image attachment" }, + "videoAttachmentAlt": { + "message": "Screenshot of video attached to message", + "description": "Used in alt tag of video attachment preview" + }, "lightboxImageAlt": { "message": "Image sent in conversation", "description": @@ -866,11 +891,6 @@ } } }, - "noContents": { - "message": "No message contents", - "description": - "Shown in a message bubble if we have nothing in the message to display, or a quote and nothing else" - }, "installWelcome": { "message": "Welcome to Signal Desktop", "description": "Welcome title on the install page" @@ -1032,15 +1052,9 @@ "description": "Displayed in notifications when setting is 'name and message' and more than one message is waiting" }, - "messageNotSent": { - "message": "Message not sent.", - "description": - "Informational label, appears on messages that failed to send" - }, - "someRecipientsFailed": { - "message": "Some recipients failed.", - "description": - "When you send to multiple recipients via a group, and the message went to some recipients but not others." + "sendFailed": { + "message": "Send failed", + "description": "Shown on outgoing message if it fails to send" }, "showMore": { "message": "Details", @@ -1159,7 +1173,7 @@ "description": "Brief message shown when trying to message a blocked number" }, "youChangedTheTimer": { - "message": "You set the timer to $time$.", + "message": "You set the disappearing message timer to $time$", "description": "Message displayed when you change the message expiration timer in a conversation.", "placeholders": { @@ -1170,7 +1184,7 @@ } }, "timerSetOnSync": { - "message": "Updating timer to $time$.", + "message": "Updated disappearing message timer to $time$", "description": "Message displayed when timer is set on initial link of desktop device.", "placeholders": { @@ -1181,7 +1195,7 @@ } }, "theyChangedTheTimer": { - "message": "$name$ set the timer to $time$.", + "message": "$name$ set the disappearing message timer to $time$", "description": "Message displayed when someone else changes the message expiration timer in a conversation.", "placeholders": { @@ -1334,9 +1348,15 @@ "message": "Play audio notification", "description": "Description for audio notification setting" }, - "keychanged": { - "message": "Your safety number with $name$ has changed. Click to show.", - "description": "", + "safetyNumberChanged": { + "message": "Safety Number has changed", + "description": + "A notification shown in the conversation when a contact reinstalls" + }, + "safetyNumberChangedGroup": { + "message": "Safety Number with $name$ has changed", + "description": + "A notification shown in a group conversation when a contact reinstalls, showing the contact name", "placeholders": { "name": { "content": "$1", @@ -1344,6 +1364,11 @@ } } }, + "verifyNewNumber": { + "message": "Verify New Number", + "description": + "Label on button included with safety number change notification in the conversation" + }, "yourSafetyNumberWith": { "message": "Your safety number with $name$:", "description": "Heading for safety number view", @@ -1405,7 +1430,7 @@ "message": "Later" }, "leftTheGroup": { - "message": "$name$ left the group.", + "message": "$name$ left the group", "description": "Shown in the conversation history when a single person leaves the group", "placeholders": { @@ -1415,13 +1440,24 @@ } } }, + "multipleLeftTheGroup": { + "message": "$name$ left the group", + "description": + "Shown in the conversation history when multiple people leave the group", + "placeholders": { + "name": { + "content": "$1", + "example": "Alice, Bob" + } + } + }, "updatedTheGroup": { - "message": "Updated the group.", + "message": "Group updated", "description": "Shown in the conversation history when someone updates the group" }, "titleIsNow": { - "message": "Title is now '$name$'.", + "message": "Title is now '$name$'", "description": "Shown in the conversation history when someone changes the title of the group", "placeholders": { @@ -1432,7 +1468,7 @@ } }, "joinedTheGroup": { - "message": "$name$ joined the group.", + "message": "$name$ joined the group", "description": "Shown in the conversation history when a single person joins the group", "placeholders": { @@ -1443,7 +1479,7 @@ } }, "multipleJoinedTheGroup": { - "message": "$names$ joined the group.", + "message": "$names$ joined the group", "description": "Shown in the conversation history when more than one person joins the group", "placeholders": { diff --git a/app/attachments.js b/app/attachments.js index 0966bc906..b1691e0a9 100644 --- a/app/attachments.js +++ b/app/attachments.js @@ -136,7 +136,7 @@ exports.getRelativePath = name => { return path.join(prefix, name); }; -// createAbsolutePathGetter :: RoothPath -> RelativePath -> AbsolutePath +// createAbsolutePathGetter :: RootPath -> RelativePath -> AbsolutePath exports.createAbsolutePathGetter = rootPath => relativePath => { const absolutePath = path.join(rootPath, relativePath); const normalized = path.normalize(absolutePath); diff --git a/background.html b/background.html index 5b26d1013..8d2db706c 100644 --- a/background.html +++ b/background.html @@ -23,9 +23,8 @@ - - - - - - - - - - - - @@ -818,7 +639,6 @@ - diff --git a/images/download.svg b/images/download.svg new file mode 100644 index 000000000..fd25ff70c --- /dev/null +++ b/images/download.svg @@ -0,0 +1,22 @@ + + + + Icons/Download/download-24 + Created with Sketch. + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/images/ellipsis.svg b/images/ellipsis.svg new file mode 100644 index 000000000..f0608e299 --- /dev/null +++ b/images/ellipsis.svg @@ -0,0 +1,22 @@ + + + + Icons/Ellipses/ellipses-24 + Created with Sketch. + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/images/error.svg b/images/error.svg new file mode 100644 index 000000000..6c0264a7c --- /dev/null +++ b/images/error.svg @@ -0,0 +1,10 @@ + + + + Icons/Error/error-20 + Created with Sketch. + + + + + \ No newline at end of file diff --git a/images/gear.svg b/images/gear.svg new file mode 100644 index 000000000..7b0b78f3b --- /dev/null +++ b/images/gear.svg @@ -0,0 +1,22 @@ + + + + Gear/gear-20 + Created with Sketch. + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/images/read.svg b/images/read.svg new file mode 100644 index 000000000..cedd36d0c --- /dev/null +++ b/images/read.svg @@ -0,0 +1,22 @@ + + + + Icons/Read/read-18x12 + Created with Sketch. + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/images/reply.svg b/images/reply.svg new file mode 100644 index 000000000..a385ea50e --- /dev/null +++ b/images/reply.svg @@ -0,0 +1,22 @@ + + + + Icons/Reply/reply-24 + Created with Sketch. + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/images/timer.svg b/images/timer.svg new file mode 100644 index 000000000..88d7413aa --- /dev/null +++ b/images/timer.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/js/background.js b/js/background.js index 1b786ddad..3548f0bbd 100644 --- a/js/background.js +++ b/js/background.js @@ -116,6 +116,9 @@ function mapOldThemeToNew(theme) { switch (theme) { + case 'dark': + case 'light': + return theme; case 'android-dark': return 'dark'; case 'android': diff --git a/js/models/conversations.js b/js/models/conversations.js index 18efe75bb..1a9be8e5d 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -5,7 +5,6 @@ /* global ConversationController: false */ /* global libsignal: false */ -/* global Signal: false */ /* global storage: false */ /* global textsecure: false */ /* global Whisper: false */ @@ -19,7 +18,15 @@ window.Whisper = window.Whisper || {}; - const { Message } = window.Signal.Types; + const { Util } = window.Signal; + const { GoogleChrome } = Util; + const { + Conversation, + Contact, + Errors, + Message, + VisualAttachment, + } = window.Signal.Types; const { upgradeMessageSchema, loadAttachmentData } = window.Signal.Migrations; // TODO: Factor out private and group subclasses of Conversation @@ -108,7 +115,10 @@ this.on('change:profileKey', this.onChangeProfileKey); this.on('destroy', this.revokeAvatarUrl); + // Listening for out-of-band data updates this.on('newmessage', this.addSingleMessage); + this.on('delivered', this.updateMessage); + this.on('read', this.updateMessage); this.on('expired', this.onExpired); this.listenTo( this.messageCollection, @@ -127,6 +137,7 @@ mine.trigger('expired', mine); } }, + async onExpiredCollection(message) { console.log('onExpiredCollection', message.attributes); const removeMessage = () => { @@ -144,6 +155,12 @@ removeMessage(); }, + // Used to update existing messages when updated from out-of-band db access, + // like read and delivery receipts. + updateMessage(message) { + this.messageCollection.add(message, { merge: true }); + }, + addSingleMessage(message) { const model = this.messageCollection.add(message, { merge: true }); model.setToExpire(); @@ -716,24 +733,23 @@ }, async makeThumbnailAttachment(attachment) { + const { arrayBufferToObjectURL } = Util; const attachmentWithData = await loadAttachmentData(attachment); const { data, contentType } = attachmentWithData; - const objectUrl = Signal.Util.arrayBufferToObjectURL({ + const objectUrl = arrayBufferToObjectURL({ data, type: contentType, }); - const thumbnail = Signal.Util.GoogleChrome.isImageTypeSupported( - contentType - ) - ? await Whisper.FileInputView.makeImageThumbnail(128, objectUrl) - : await Whisper.FileInputView.makeVideoThumbnail(128, objectUrl); + const thumbnail = GoogleChrome.isImageTypeSupported(contentType) + ? await VisualAttachment.makeImageThumbnail(128, objectUrl) + : await VisualAttachment.makeVideoThumbnail(128, objectUrl); URL.revokeObjectURL(objectUrl); const arrayBuffer = await this.blobToArrayBuffer(thumbnail); const finalContentType = 'image/png'; - const finalObjectUrl = Signal.Util.arrayBufferToObjectURL({ + const finalObjectUrl = arrayBufferToObjectURL({ data: arrayBuffer, type: finalContentType, }); @@ -746,7 +762,7 @@ }, async makeQuote(quotedMessage) { - const { getName } = Signal.Types.Contact; + const { getName } = Contact; const contact = quotedMessage.getContact(); const attachments = quotedMessage.get('attachments'); @@ -765,8 +781,8 @@ (attachments || []).map(async attachment => { const { contentType } = attachment; const willMakeThumbnail = - Signal.Util.GoogleChrome.isImageTypeSupported(contentType) || - Signal.Util.GoogleChrome.isVideoTypeSupported(contentType); + GoogleChrome.isImageTypeSupported(contentType) || + GoogleChrome.isVideoTypeSupported(contentType); const makeThumbnail = async () => { try { if (willMakeThumbnail) { @@ -873,16 +889,14 @@ const lastMessage = collection.at(0); const lastMessageJSON = lastMessage ? lastMessage.toJSON() : null; - const lastMessageUpdate = window.Signal.Types.Conversation.createLastMessageUpdate( - { - currentLastMessageText: this.get('lastMessage') || null, - currentTimestamp: this.get('timestamp') || null, - lastMessage: lastMessageJSON, - lastMessageNotificationText: lastMessage - ? lastMessage.getNotificationText() - : null, - } - ); + const lastMessageUpdate = Conversation.createLastMessageUpdate({ + currentLastMessageText: this.get('lastMessage') || null, + currentTimestamp: this.get('timestamp') || null, + lastMessage: lastMessageJSON, + lastMessageNotificationText: lastMessage + ? lastMessage.getNotificationText() + : null, + }); console.log('Conversation: Update last message:', { id: this.idForLogging() || null, @@ -1284,8 +1298,8 @@ return ( thumbnail || - Signal.Util.GoogleChrome.isImageTypeSupported(contentType) || - Signal.Util.GoogleChrome.isVideoTypeSupported(contentType) + GoogleChrome.isImageTypeSupported(contentType) || + GoogleChrome.isVideoTypeSupported(contentType) ); }, forceRender(message) { @@ -1323,8 +1337,8 @@ } if ( - !Signal.Util.GoogleChrome.isImageTypeSupported(first.contentType) && - !Signal.Util.GoogleChrome.isVideoTypeSupported(first.contentType) + !GoogleChrome.isImageTypeSupported(first.contentType) && + !GoogleChrome.isVideoTypeSupported(first.contentType) ) { return false; } @@ -1352,7 +1366,7 @@ } catch (error) { console.log( 'Problem loading attachment data for quoted message from database', - Signal.Types.Errors.toLogFormat(error) + Errors.toLogFormat(error) ); return false; } @@ -1370,8 +1384,8 @@ } if ( - !Signal.Util.GoogleChrome.isImageTypeSupported(first.contentType) && - !Signal.Util.GoogleChrome.isVideoTypeSupported(first.contentType) + !GoogleChrome.isImageTypeSupported(first.contentType) && + !GoogleChrome.isVideoTypeSupported(first.contentType) ) { return; } @@ -1410,7 +1424,7 @@ try { const thumbnailWithData = await loadAttachmentData(thumbnail); const { data, contentType } = thumbnailWithData; - thumbnailWithData.objectUrl = Signal.Util.arrayBufferToObjectURL({ + thumbnailWithData.objectUrl = Util.arrayBufferToObjectURL({ data, type: contentType, }); @@ -1489,10 +1503,30 @@ return Promise.all(promises); }, + async upgradeMessages(messages) { + for (let max = messages.length, i = 0; i < max; i += 1) { + const message = messages.at(i); + const { attributes } = message; + const { schemaVersion } = attributes; + + if (schemaVersion < Message.CURRENT_SCHEMA_VERSION) { + const upgradedMessage = upgradeMessageSchema(attributes); + message.set(upgradedMessage); + // Yep, we really do want to wait for each of these + // eslint-disable-next-line no-await-in-loop + await wrapDeferred(message.save()); + } + } + }, + async fetchMessages() { if (!this.id) { throw new Error('This conversation has no id!'); } + if (this.inProgressFetch) { + console.log('Attempting to start a parallel fetchMessages() call'); + return; + } this.inProgressFetch = this.messageCollection.fetchConversation( this.id, @@ -1501,11 +1535,24 @@ ); await this.inProgressFetch; - this.inProgressFetch = null; + + try { + // We are now doing the work to upgrade messages before considering the load from + // the database complete. Note that we do save messages back, so it is a + // one-time hit. We do this so we have guarantees about message structure. + await this.upgradeMessages(this.messageCollection); + } catch (error) { + console.log( + 'fetchMessages: failed to upgrade messages', + Errors.toLogFormat(error) + ); + } // We kick this process off, but don't wait for it. If async updates happen on a // given Message, 'change' will be triggered this.processQuotes(this.messageCollection); + + this.inProgressFetch = null; }, hasMember(number) { @@ -1534,28 +1581,36 @@ }); }, - destroyMessages() { - this.messageCollection - .fetch({ - index: { - // 'conversation' index on [conversationId, received_at] - name: 'conversation', - lower: [this.id], - upper: [this.id, Number.MAX_VALUE], - }, - }) - .then(() => { - const { models } = this.messageCollection; - this.messageCollection.reset([]); - _.each(models, message => { - message.destroy(); - }); - this.save({ - lastMessage: null, - timestamp: null, - active_at: null, - }); + async destroyMessages() { + let loaded; + do { + // Yes, we really want the await in the loop. We're deleting 100 at a + // time so we don't use too much memory. + // eslint-disable-next-line no-await-in-loop + await wrapDeferred( + this.messageCollection.fetch({ + limit: 100, + index: { + // 'conversation' index on [conversationId, received_at] + name: 'conversation', + lower: [this.id], + upper: [this.id, Number.MAX_VALUE], + }, + }) + ); + + loaded = this.messageCollection.models; + this.messageCollection.reset([]); + _.each(loaded, message => { + message.destroy(); }); + } while (loaded.length > 0); + + this.save({ + lastMessage: null, + timestamp: null, + active_at: null, + }); }, getName() { @@ -1646,20 +1701,8 @@ } }, getColor() { - const title = this.get('name'); - let color = this.get('color'); - if (!color) { - if (this.isPrivate()) { - if (title) { - color = COLORS[Math.abs(this.hashCode()) % 15]; - } else { - color = 'grey'; - } - } else { - color = 'default'; - } - } - return color; + const { migrateColor } = Util; + return migrateColor(this.get('color')); }, getAvatar() { if (this.avatarUrl === undefined) { @@ -1705,9 +1748,7 @@ const messageJSON = message.toJSON(); const messageSentAt = messageJSON.sent_at; const messageId = message.id; - const isExpiringMessage = Signal.Types.Message.hasExpiration( - messageJSON - ); + const isExpiringMessage = Message.hasExpiration(messageJSON); console.log('Add notification', { conversationId: this.idForLogging(), diff --git a/js/models/messages.js b/js/models/messages.js index c841cae21..c26d08005 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -1,6 +1,7 @@ /* global _: false */ /* global Backbone: false */ - +/* global storage: false */ +/* global filesize: false */ /* global ConversationController: false */ /* global getAccountManager: false */ /* global i18n: false */ @@ -17,8 +18,41 @@ window.Whisper = window.Whisper || {}; - const { Message: TypedMessage, Contact } = Signal.Types; - const { deleteAttachmentData } = Signal.Migrations; + const { Message: TypedMessage, Contact, PhoneNumber } = Signal.Types; + const { + // loadAttachmentData, + deleteAttachmentData, + getAbsoluteAttachmentPath, + } = Signal.Migrations; + + window.AccountCache = Object.create(null); + window.AccountJobs = Object.create(null); + + window.doesAcountCheckJobExist = number => + Boolean(window.AccountJobs[number]); + window.checkForSignalAccount = number => { + if (window.AccountJobs[number]) { + return window.AccountJobs[number]; + } + + // eslint-disable-next-line more/no-then + const job = textsecure.messaging + .getProfile(number) + .then(() => { + window.AccountCache[number] = true; + }) + .catch(() => { + window.AccountCache[number] = false; + }); + + window.AccountJobs[number] = job; + + return job; + }; + + window.isSignalAccountCheckComplete = number => + window.AccountCache[number] !== undefined; + window.hasSignalAccount = number => window.AccountCache[number]; window.Whisper.Message = Backbone.Model.extend({ database: Whisper.Database, @@ -28,6 +62,8 @@ this.set(TypedMessage.initializeSchemaVersion(attributes)); } + this.OUR_NUMBER = textsecure.storage.user.getNumber(); + this.on('change:attachments', this.updateImageUrl); this.on('destroy', this.onDestroy); this.on('change:expirationStartTimestamp', this.setToExpire); @@ -113,7 +149,7 @@ return i18n('leftTheGroup', this.getNameForNumber(groupUpdate.left)); } - const messages = [i18n('updatedTheGroup')]; + const messages = []; if (groupUpdate.name) { messages.push(i18n('titleIsNow', groupUpdate.name)); } @@ -129,7 +165,7 @@ } } - return messages.join(' '); + return messages.join(', '); } if (this.isEndSession()) { return i18n('sessionEnded'); @@ -139,6 +175,9 @@ } return this.get('body'); }, + isVerifiedChange() { + return this.get('type') === 'verified-change'; + }, isKeyChange() { return this.get('type') === 'keychange'; }, @@ -158,8 +197,12 @@ ); } if (this.isKeyChange()) { - const conversation = this.getModelForKeyChange(); - return i18n('keychanged', conversation.getTitle()); + const phoneNumber = this.get('key_changed'); + const conversation = this.findContact(phoneNumber); + return i18n( + 'safetyNumberChangedGroup', + conversation ? conversation.getTitle() : null + ); } const contacts = this.get('contact'); if (contacts && contacts.length) { @@ -252,6 +295,268 @@ thumbnail: thumbnailWithObjectUrl, }); }, + getPropsForTimerNotification() { + const { expireTimer, fromSync, source } = this.get( + 'expirationTimerUpdate' + ); + const timespan = Whisper.ExpirationTimerOptions.getName(expireTimer || 0); + + const basicProps = { + type: 'fromOther', + ...this.findAndFormatContact(source), + timespan, + }; + + if (source === this.OUR_NUMBER) { + return { + ...basicProps, + type: 'fromMe', + }; + } else if (fromSync) { + return { + ...basicProps, + type: 'fromSync', + }; + } + + return basicProps; + }, + getPropsForSafetyNumberNotification() { + const conversation = this.getConversation(); + const isGroup = conversation && !conversation.isPrivate(); + const phoneNumber = this.get('key_changed'); + const onVerify = () => + this.trigger('show-identity', this.findContact(phoneNumber)); + + return { + isGroup, + contact: this.findAndFormatContact(phoneNumber), + onVerify, + }; + }, + getPropsForVerificationNotification() { + const type = this.get('verified') ? 'markVerified' : 'markNotVerified'; + const isLocal = this.get('local'); + const phoneNumber = this.get('verifiedChanged'); + + return { + type, + isLocal, + contact: this.findAndFormatContact(phoneNumber), + }; + }, + getPropsForResetSessionNotification() { + // It doesn't need anything right now! + return {}; + }, + findContact(phoneNumber) { + return ConversationController.get(phoneNumber); + }, + findAndFormatContact(phoneNumber) { + const { format } = PhoneNumber; + const regionCode = storage.get('regionCode'); + + const contactModel = this.findContact(phoneNumber); + const avatar = contactModel ? contactModel.getAvatar() : null; + const color = contactModel ? contactModel.getColor() : null; + + return { + phoneNumber: format(phoneNumber, { + ourRegionCode: regionCode, + }), + color, + avatarPath: avatar ? avatar.url : null, + name: contactModel ? contactModel.getName() : null, + profileName: contactModel ? contactModel.getProfileName() : null, + title: contactModel ? contactModel.getTitle() : null, + }; + }, + getPropsForGroupNotification() { + const groupUpdate = this.get('group_update'); + const changes = []; + + if (!groupUpdate.name && !groupUpdate.left && !groupUpdate.joined) { + changes.push({ + type: 'general', + }); + } + + if (groupUpdate.joined) { + changes.push({ + type: 'add', + contacts: _.map( + Array.isArray(groupUpdate.joined) + ? groupUpdate.joined + : [groupUpdate.joined], + phoneNumber => this.findAndFormatContact(phoneNumber) + ), + }); + } + + if (groupUpdate.left === 'You') { + changes.push({ + type: 'remove', + isMe: true, + }); + } else if (groupUpdate.left) { + changes.push({ + type: 'remove', + contacts: _.map( + Array.isArray(groupUpdate.left) + ? groupUpdate.left + : [groupUpdate.left], + phoneNumber => this.findAndFormatContact(phoneNumber) + ), + }); + } + + if (groupUpdate.name) { + changes.push({ + type: 'name', + newName: groupUpdate.name, + }); + } + + return { + changes, + }; + }, + getMessagePropStatus() { + if (this.hasErrors()) { + return 'error'; + } + + const readBy = this.get('read_by') || []; + if (readBy.length > 0) { + return 'read'; + } + const delivered = this.get('delivered'); + const deliveredTo = this.get('delivered_to') || []; + if (delivered || deliveredTo.length > 0) { + return 'delivered'; + } + const sent = this.get('sent'); + const sentTo = this.get('sent_to') || []; + if (sent || sentTo.length > 0) { + return 'sent'; + } + + return 'sending'; + }, + getPropsForMessage() { + const phoneNumber = this.getSource(); + const contact = this.findAndFormatContact(phoneNumber); + const contactModel = this.findContact(phoneNumber); + + const authorColor = contactModel ? contactModel.getColor() : null; + const authorAvatar = contactModel ? contactModel.getAvatar() : null; + const authorAvatarPath = authorAvatar.url; + + const expirationLength = this.get('expireTimer') * 1000; + const expireTimerStart = this.get('expirationStartTimestamp'); + const expirationTimestamp = + expirationLength && expireTimerStart + ? expireTimerStart + expirationLength + : null; + + const conversation = this.getConversation(); + const isGroup = conversation && !conversation.isPrivate(); + + const attachments = this.get('attachments'); + const firstAttachment = attachments && attachments[0]; + + return { + text: this.createNonBreakingLastSeparator(this.get('body')), + id: this.id, + direction: this.isIncoming() ? 'incoming' : 'outgoing', + timestamp: this.get('sent_at'), + status: this.getMessagePropStatus(), + contact: this.getPropsForEmbeddedContact(), + authorName: contact.name, + authorProfileName: contact.profileName, + authorPhoneNumber: contact.phoneNumber, + authorColor, + conversationType: isGroup ? 'group' : 'direct', + attachment: this.getPropsForAttachment(firstAttachment), + quote: this.getPropsForQuote(), + authorAvatarPath, + expirationLength, + expirationTimestamp, + onReply: () => this.trigger('reply', this), + onRetrySend: () => this.retrySend(), + onShowDetail: () => this.trigger('show-message-detail', this), + onDelete: () => this.trigger('delete', this), + onClickAttachment: () => + this.trigger('show-lightbox', { + attachment: firstAttachment, + message: this, + }), + + onDownload: () => + this.trigger('download', { + attachment: firstAttachment, + message: this, + }), + }; + }, + createNonBreakingLastSeparator(text) { + if (!text) { + return null; + } + + const nbsp = '\xa0'; + const regex = /(\S)( +)(\S+\s*)$/; + return text.replace(regex, (match, start, spaces, end) => { + const newSpaces = _.reduce( + spaces, + accumulator => accumulator + nbsp, + '' + ); + return `${start}${newSpaces}${end}`; + }); + }, + getPropsForEmbeddedContact() { + const regionCode = storage.get('regionCode'); + const { contactSelector } = Contact; + + const contacts = this.get('contact'); + if (!contacts || !contacts.length) { + return null; + } + + const contact = contacts[0]; + const firstNumber = + contact.number && contact.number[0] && contact.number[0].value; + const onSendMessage = firstNumber + ? () => { + this.trigger('open-conversation', firstNumber); + } + : null; + const onClick = async () => { + // First let's be sure that the signal account check is complete. + await window.checkForSignalAccount(firstNumber); + + this.trigger('show-contact-detail', { + contact, + hasSignalAccount: window.hasSignalAccount(firstNumber), + }); + }; + + // Would be nice to do this before render, on initial load of message + if (!window.isSignalAccountCheckComplete(firstNumber)) { + window.checkForSignalAccount(firstNumber).then(() => { + this.trigger('change'); + }); + } + + return contactSelector(contact, { + regionCode, + getAbsoluteAttachmentPath, + onSendMessage, + onClick, + hasSignalAccount: window.hasSignalAccount(firstNumber), + }); + }, getPropsForQuote() { const quote = this.get('quote'); if (!quote) { @@ -259,15 +564,14 @@ } const objectUrl = this.getQuoteObjectUrl(); - const OUR_NUMBER = textsecure.storage.user.getNumber(); const { author } = quote; const contact = this.getQuoteContact(); - const authorTitle = contact ? contact.getTitle() : author; + const authorPhoneNumber = author; const authorProfileName = contact ? contact.getProfileName() : null; + const authorName = contact ? contact.getName() : null; const authorColor = contact ? contact.getColor() : 'grey'; - const isFromMe = contact ? contact.id === OUR_NUMBER : false; - const isIncoming = this.isIncoming(); + const isFromMe = contact ? contact.id === this.OUR_NUMBER : false; const onClick = () => { const { quotedMessage } = this; if (quotedMessage) { @@ -275,55 +579,150 @@ } }; + const firstAttachment = quote.attachments && quote.attachments[1]; + return { - attachments: (quote.attachments || []).map(attachment => - this.processAttachment(attachment, objectUrl) - ), - authorColor, - authorProfileName, - authorTitle, + text: this.createNonBreakingLastSeparator(quote.text), + attachment: firstAttachment + ? this.processAttachment(firstAttachment, objectUrl) + : null, isFromMe, - isIncoming, + authorPhoneNumber, + authorProfileName, + authorName, + authorColor, onClick: this.quotedMessage ? onClick : null, - text: quote.text, }; }, + getPropsForAttachment(attachment) { + if (!attachment) { + return null; + } + + const { path, flags, size, screenshot, thumbnail } = attachment; + + return { + ...attachment, + fileSize: size ? filesize(size) : null, + isVoiceMessage: + flags && + // eslint-disable-next-line no-bitwise + flags & textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE, + url: getAbsoluteAttachmentPath(path), + screenshot: screenshot + ? { + ...screenshot, + url: getAbsoluteAttachmentPath(screenshot.path), + } + : null, + thumbnail: thumbnail + ? { + ...thumbnail, + url: getAbsoluteAttachmentPath(thumbnail.path), + } + : null, + }; + }, + getPropsForMessageDetail() { + const newIdentity = i18n('newIdentity'); + const OUTGOING_KEY_ERROR = 'OutgoingIdentityKeyError'; + + // Older messages don't have the recipients included on the message, so we fall + // back to the conversation's current recipients + const phoneNumbers = this.isIncoming() + ? [this.get('source')] + : this.get('recipients') || this.conversation.getRecipients(); + + // This will make the error message for outgoing key errors a bit nicer + const allErrors = (this.get('errors') || []).map(error => { + if (error.name === OUTGOING_KEY_ERROR) { + // eslint-disable-next-line no-param-reassign + error.message = newIdentity; + } + + return error; + }); + + // If an error has a specific number it's associated with, we'll show it next to + // that contact. Otherwise, it will be a standalone entry. + const errors = _.reject(allErrors, error => Boolean(error.number)); + const errorsGroupedById = _.groupBy(allErrors, 'number'); + const finalContacts = (phoneNumbers || []).map(id => { + const errorsForContact = errorsGroupedById[id]; + const isOutgoingKeyError = Boolean( + _.find(errorsForContact, error => error.name === OUTGOING_KEY_ERROR) + ); + + return { + ...this.findAndFormatContact(id), + status: this.getStatus(id), + errors: errorsForContact, + isOutgoingKeyError, + onSendAnyway: () => + this.trigger('force-send', { + contact: this.findContact(id), + message: this, + }), + onShowSafetyNumber: () => + this.trigger('show-identity', this.findContact(id)), + }; + }); + + // The prefix created here ensures that contacts with errors are listed + // first; otherwise it's alphabetical + const sortedContacts = _.sortBy( + finalContacts, + contact => `${contact.errors ? '0' : '1'}${contact.title}` + ); + + return { + sentAt: this.get('sent_at'), + receivedAt: this.get('received_at'), + message: { + ...this.getPropsForMessage(), + disableMenu: true, + // To ensure that group avatar doesn't show up + conversationType: 'direct', + }, + errors, + contacts: sortedContacts, + }; + }, + retrySend() { + const retries = _.filter( + this.get('errors'), + this.isReplayableError.bind(this) + ); + _.map(retries, 'number').forEach(number => { + this.resend(number); + }); + }, getConversation() { // This needs to be an unsafe call, because this method is called during // initial module setup. We may be in the middle of the initial fetch to // the database. return ConversationController.getUnsafe(this.get('conversationId')); }, - getExpirationTimerUpdateSource() { - if (!this.isExpirationTimerUpdate()) { - throw new Error('Message is not a timer update!'); + getIncomingContact() { + if (!this.isIncoming()) { + return null; + } + const source = this.get('source'); + if (!source) { + return null; } - const conversationId = this.get('expirationTimerUpdate').source; - return ConversationController.getOrCreate(conversationId, 'private'); + return ConversationController.getOrCreate(source, 'private'); + }, + getSource() { + if (this.isIncoming()) { + return this.get('source'); + } + + return this.OUR_NUMBER; }, getContact() { - let conversationId = this.get('source'); - if (!this.isIncoming()) { - conversationId = textsecure.storage.user.getNumber(); - } - return ConversationController.getOrCreate(conversationId, 'private'); - }, - getModelForKeyChange() { - const id = this.get('key_changed'); - if (!this.modelForKeyChange) { - const c = ConversationController.getOrCreate(id, 'private'); - this.modelForKeyChange = c; - } - return this.modelForKeyChange; - }, - getModelForVerifiedChange() { - const id = this.get('verifiedChanged'); - if (!this.modelForVerifiedChange) { - const c = ConversationController.getOrCreate(id, 'private'); - this.modelForVerifiedChange = c; - } - return this.modelForVerifiedChange; + return ConversationController.getOrCreate(this.getSource(), 'private'); }, isOutgoing() { return this.get('type') === 'outgoing'; diff --git a/js/modules/signal.js b/js/modules/signal.js index 0c32434c3..6fa6608d2 100644 --- a/js/modules/signal.js +++ b/js/modules/signal.js @@ -4,7 +4,6 @@ const Backbone = require('../../ts/backbone'); const Crypto = require('./crypto'); const Database = require('./database'); const Emoji = require('../../ts/util/emoji'); -const Message = require('./types/message'); const Notifications = require('../../ts/notifications'); const OS = require('../../ts/OS'); const Settings = require('./settings'); @@ -18,19 +17,38 @@ const { const { ContactListItem } = require('../../ts/components/ContactListItem'); const { ContactName } = require('../../ts/components/conversation/ContactName'); const { - ConversationTitle, -} = require('../../ts/components/conversation/ConversationTitle'); + ConversationHeader, +} = require('../../ts/components/conversation/ConversationHeader'); const { EmbeddedContact, } = require('../../ts/components/conversation/EmbeddedContact'); const { Emojify } = require('../../ts/components/conversation/Emojify'); +const { + GroupNotification, +} = require('../../ts/components/conversation/GroupNotification'); const { Lightbox } = require('../../ts/components/Lightbox'); const { LightboxGallery } = require('../../ts/components/LightboxGallery'); const { MediaGallery, } = require('../../ts/components/conversation/media-gallery/MediaGallery'); +const { Message } = require('../../ts/components/conversation/Message'); const { MessageBody } = require('../../ts/components/conversation/MessageBody'); +const { + MessageDetail, +} = require('../../ts/components/conversation/MessageDetail'); const { Quote } = require('../../ts/components/conversation/Quote'); +const { + ResetSessionNotification, +} = require('../../ts/components/conversation/ResetSessionNotification'); +const { + SafetyNumberNotification, +} = require('../../ts/components/conversation/SafetyNumberNotification'); +const { + TimerNotification, +} = require('../../ts/components/conversation/TimerNotification'); +const { + VerificationNotification, +} = require('../../ts/components/conversation/VerificationNotification'); // Migrations const { @@ -42,11 +60,14 @@ const Migrations1DatabaseWithoutAttachmentData = require('./migrations/migration // Types const AttachmentType = require('./types/attachment'); +const VisualAttachment = require('./types/visual_attachment'); const Contact = require('../../ts/types/Contact'); const Conversation = require('../../ts/types/Conversation'); const Errors = require('./types/errors'); const MediaGalleryMessage = require('../../ts/components/conversation/media-gallery/types/Message'); +const MessageType = require('./types/message'); const MIME = require('../../ts/types/MIME'); +const PhoneNumber = require('../../ts/types/PhoneNumber'); const SettingsType = require('../../ts/types/Settings'); // Views @@ -57,39 +78,59 @@ const { IdleDetector } = require('./idle_detector'); const MessageDataMigrator = require('./messages_data_migrator'); function initializeMigrations({ - Attachments, userDataPath, - Type, getRegionCode, + Attachments, + Type, + VisualType, }) { if (!Attachments) { return null; } + const { + getPath, + createReader, + createAbsolutePathGetter, + createWriterForNew, + createWriterForExisting, + } = Attachments; + const { + makeObjectUrl, + revokeObjectUrl, + getImageDimensions, + makeImageThumbnail, + makeVideoScreenshot, + } = VisualType; - const attachmentsPath = Attachments.getPath(userDataPath); - const readAttachmentData = Attachments.createReader(attachmentsPath); + const attachmentsPath = getPath(userDataPath); + const readAttachmentData = createReader(attachmentsPath); const loadAttachmentData = Type.loadData(readAttachmentData); + const getAbsoluteAttachmentPath = createAbsolutePathGetter(attachmentsPath); return { attachmentsPath, deleteAttachmentData: Type.deleteData( Attachments.createDeleter(attachmentsPath) ), - getAbsoluteAttachmentPath: Attachments.createAbsolutePathGetter( - attachmentsPath - ), + getAbsoluteAttachmentPath, getPlaceholderMigrations, loadAttachmentData, - loadMessage: Message.createAttachmentLoader(loadAttachmentData), + loadMessage: MessageType.createAttachmentLoader(loadAttachmentData), Migrations0DatabaseWithAttachmentData, Migrations1DatabaseWithoutAttachmentData, upgradeMessageSchema: message => - Message.upgradeSchema(message, { - writeNewAttachmentData: Attachments.createWriterForNew(attachmentsPath), + MessageType.upgradeSchema(message, { + writeNewAttachmentData: createWriterForNew(attachmentsPath), getRegionCode, + getAbsoluteAttachmentPath, + makeObjectUrl, + revokeObjectUrl, + getImageDimensions, + makeImageThumbnail, + makeVideoScreenshot, }), - writeMessageAttachments: Message.createAttachmentDataWriter( - Attachments.createWriterForExisting(attachmentsPath) + writeMessageAttachments: MessageType.createAttachmentDataWriter( + createWriterForExisting(attachmentsPath) ), }; } @@ -98,27 +139,35 @@ exports.setup = (options = {}) => { const { Attachments, userDataPath, getRegionCode } = options; const Migrations = initializeMigrations({ - Attachments, userDataPath, - Type: AttachmentType, getRegionCode, + Attachments, + Type: AttachmentType, + VisualType: VisualAttachment, }); const Components = { ContactDetail, ContactListItem, ContactName, - ConversationTitle, + ConversationHeader, EmbeddedContact, Emojify, + GroupNotification, Lightbox, LightboxGallery, MediaGallery, + Message, MessageBody, + MessageDetail, + Quote, + ResetSessionNotification, + SafetyNumberNotification, + TimerNotification, Types: { Message: MediaGalleryMessage, }, - Quote, + VerificationNotification, }; const Types = { @@ -126,9 +175,11 @@ exports.setup = (options = {}) => { Contact, Conversation, Errors, - Message, + Message: MessageType, MIME, + PhoneNumber, Settings: SettingsType, + VisualAttachment, }; const Views = { diff --git a/js/modules/types/attachment.js b/js/modules/types/attachment.js index 142015265..5b0ed5f92 100644 --- a/js/modules/types/attachment.js +++ b/js/modules/types/attachment.js @@ -1,7 +1,9 @@ const is = require('@sindresorhus/is'); const AttachmentTS = require('../../../ts/types/Attachment'); +const GoogleChrome = require('../../../ts/util/GoogleChrome'); const MIME = require('../../../ts/types/MIME'); +const { toLogFormat } = require('./errors'); const { arrayBufferToBlob, blobToArrayBuffer, @@ -181,3 +183,112 @@ exports.deleteData = deleteAttachmentData => { exports.isVoiceMessage = AttachmentTS.isVoiceMessage; exports.save = AttachmentTS.save; + +const THUMBNAIL_SIZE = 150; +const THUMBNAIL_CONTENT_TYPE = 'image/png'; + +exports.captureDimensionsAndScreenshot = async ( + attachment, + { + writeNewAttachmentData, + getAbsoluteAttachmentPath, + makeObjectUrl, + revokeObjectUrl, + getImageDimensions, + makeImageThumbnail, + makeVideoScreenshot, + } +) => { + const { contentType } = attachment; + + if ( + !GoogleChrome.isImageTypeSupported(contentType) && + !GoogleChrome.isVideoTypeSupported(contentType) + ) { + return attachment; + } + + const absolutePath = await getAbsoluteAttachmentPath(attachment.path); + + if (GoogleChrome.isImageTypeSupported(contentType)) { + try { + const { width, height } = await getImageDimensions(absolutePath); + const thumbnailBuffer = await blobToArrayBuffer( + await makeImageThumbnail( + THUMBNAIL_SIZE, + absolutePath, + THUMBNAIL_CONTENT_TYPE + ) + ); + + const thumbnailPath = await writeNewAttachmentData(thumbnailBuffer); + return { + ...attachment, + width, + height, + thumbnail: { + path: thumbnailPath, + contentType: THUMBNAIL_CONTENT_TYPE, + width: THUMBNAIL_SIZE, + height: THUMBNAIL_SIZE, + }, + }; + } catch (error) { + console.log( + 'captureDimensionsAndScreenshot:', + 'error processing image; skipping screenshot generation', + toLogFormat(error) + ); + return attachment; + } + } + + let screenshotObjectUrl; + try { + const screenshotBuffer = await blobToArrayBuffer( + await makeVideoScreenshot(absolutePath, THUMBNAIL_CONTENT_TYPE) + ); + screenshotObjectUrl = makeObjectUrl( + screenshotBuffer, + THUMBNAIL_CONTENT_TYPE + ); + const { width, height } = await getImageDimensions(screenshotObjectUrl); + const screenshotPath = await writeNewAttachmentData(screenshotBuffer); + + const thumbnailBuffer = await blobToArrayBuffer( + await makeImageThumbnail( + THUMBNAIL_SIZE, + screenshotObjectUrl, + THUMBNAIL_CONTENT_TYPE + ) + ); + + const thumbnailPath = await writeNewAttachmentData(thumbnailBuffer); + + return { + ...attachment, + screenshot: { + contentType: THUMBNAIL_CONTENT_TYPE, + path: screenshotPath, + width, + height, + }, + thumbnail: { + path: thumbnailPath, + contentType: THUMBNAIL_CONTENT_TYPE, + width: THUMBNAIL_SIZE, + height: THUMBNAIL_SIZE, + }, + width, + height, + }; + } catch (error) { + console.log( + 'captureDimensionsAndScreenshot: error processing video; skipping screenshot generation', + toLogFormat(error) + ); + return attachment; + } finally { + revokeObjectUrl(screenshotObjectUrl); + } +}; diff --git a/js/modules/types/message.js b/js/modules/types/message.js index ee1ed87db..4a554b221 100644 --- a/js/modules/types/message.js +++ b/js/modules/types/message.js @@ -41,6 +41,9 @@ const PRIVATE = 'private'; // - `hasVisualMediaAttachments`: Include all images and video regardless of // whether Chromium can render it or not. // - `hasFileAttachments`: Exclude voice messages. +// Version 8 +// - Attachments: Capture video/image dimensions and thumbnails, as well as a +// full-size screenshot for video. const INITIAL_SCHEMA_VERSION = 0; @@ -128,7 +131,7 @@ exports._withSchemaVersion = (schemaVersion, upgrade) => { upgradedMessage = await upgrade(message, context); } catch (error) { console.log( - 'Message._withSchemaVersion: error:', + `Message._withSchemaVersion: error updating message ${message.id}:`, Errors.toLogFormat(error) ); return message; @@ -242,6 +245,11 @@ const toVersion6 = exports._withSchemaVersion( // classified: const toVersion7 = exports._withSchemaVersion(7, initializeAttachmentMetadata); +const toVersion8 = exports._withSchemaVersion( + 8, + exports._mapAttachments(Attachment.captureDimensionsAndScreenshot) +); + const VERSIONS = [ toVersion0, toVersion1, @@ -251,19 +259,47 @@ const VERSIONS = [ toVersion5, toVersion6, toVersion7, + toVersion8, ]; exports.CURRENT_SCHEMA_VERSION = VERSIONS.length - 1; // UpgradeStep exports.upgradeSchema = async ( rawMessage, - { writeNewAttachmentData, getRegionCode } = {} + { + writeNewAttachmentData, + getRegionCode, + getAbsoluteAttachmentPath, + makeObjectUrl, + revokeObjectUrl, + getImageDimensions, + makeImageThumbnail, + makeVideoScreenshot, + } = {} ) => { if (!isFunction(writeNewAttachmentData)) { - throw new TypeError('`context.writeNewAttachmentData` is required'); + throw new TypeError('context.writeNewAttachmentData is required'); } if (!isFunction(getRegionCode)) { - throw new TypeError('`context.getRegionCode` is required'); + throw new TypeError('context.getRegionCode is required'); + } + if (!isFunction(getAbsoluteAttachmentPath)) { + throw new TypeError('context.getAbsoluteAttachmentPath is required'); + } + if (!isFunction(makeObjectUrl)) { + throw new TypeError('context.makeObjectUrl is required'); + } + if (!isFunction(revokeObjectUrl)) { + throw new TypeError('context.revokeObjectUrl is required'); + } + if (!isFunction(getImageDimensions)) { + throw new TypeError('context.getImageDimensions is required'); + } + if (!isFunction(makeImageThumbnail)) { + throw new TypeError('context.makeImageThumbnail is required'); + } + if (!isFunction(makeVideoScreenshot)) { + throw new TypeError('context.makeVideoScreenshot is required'); } let message = rawMessage; @@ -275,6 +311,12 @@ exports.upgradeSchema = async ( message = await currentVersion(message, { writeNewAttachmentData, regionCode: getRegionCode(), + getAbsoluteAttachmentPath, + makeObjectUrl, + revokeObjectUrl, + getImageDimensions, + makeImageThumbnail, + makeVideoScreenshot, }); } diff --git a/js/modules/types/visual_attachment.js b/js/modules/types/visual_attachment.js new file mode 100644 index 000000000..f715b18ce --- /dev/null +++ b/js/modules/types/visual_attachment.js @@ -0,0 +1,126 @@ +/* global document, URL, Blob */ + +const loadImage = require('blueimp-load-image'); +const { toLogFormat } = require('./errors'); +const dataURLToBlobSync = require('blueimp-canvas-to-blob'); +const { blobToArrayBuffer } = require('blob-util'); +const { + arrayBufferToObjectURL, +} = require('../../../ts/util/arrayBufferToObjectURL'); + +exports.blobToArrayBuffer = blobToArrayBuffer; + +exports.getImageDimensions = objectUrl => + new Promise((resolve, reject) => { + const image = document.createElement('img'); + + image.addEventListener('load', () => { + resolve({ + height: image.naturalHeight, + width: image.naturalWidth, + }); + }); + image.addEventListener('error', error => { + console.log('getImageDimensions error', toLogFormat(error)); + reject(error); + }); + + image.src = objectUrl; + }); + +exports.makeImageThumbnail = (size, objectUrl, contentType = 'image/png') => + new Promise((resolve, reject) => { + const image = document.createElement('img'); + + image.addEventListener('load', () => { + // using components/blueimp-load-image + + // first, make the correct size + let canvas = loadImage.scale(image, { + canvas: true, + cover: true, + maxWidth: size, + maxHeight: size, + minWidth: size, + minHeight: size, + }); + + // then crop + canvas = loadImage.scale(canvas, { + canvas: true, + crop: true, + maxWidth: size, + maxHeight: size, + minWidth: size, + minHeight: size, + }); + + const blob = dataURLToBlobSync(canvas.toDataURL(contentType)); + + resolve(blob); + }); + + image.addEventListener('error', error => { + console.log('makeImageThumbnail error', toLogFormat(error)); + reject(error); + }); + + image.src = objectUrl; + }); + +exports.makeVideoScreenshot = (objectUrl, contentType = 'image/png') => + new Promise((resolve, reject) => { + const video = document.createElement('video'); + + function capture() { + const canvas = document.createElement('canvas'); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + canvas + .getContext('2d') + .drawImage(video, 0, 0, canvas.width, canvas.height); + + const image = dataURLToBlobSync(canvas.toDataURL(contentType)); + + video.removeEventListener('canplay', capture); + + resolve(image); + } + + video.addEventListener('canplay', capture); + video.addEventListener('error', error => { + console.log('makeVideoThumbnail error', toLogFormat(error)); + reject(error); + }); + + video.src = objectUrl; + }); + +exports.makeVideoThumbnail = async (size, videoObjectUrl) => { + let screenshotObjectUrl; + try { + const type = 'image/png'; + const blob = await exports.makeVideoScreenshot(videoObjectUrl, type); + const data = await blobToArrayBuffer(blob); + screenshotObjectUrl = arrayBufferToObjectURL({ + data, + type, + }); + + return exports.makeImageThumbnail(size, screenshotObjectUrl); + } finally { + exports.revokeObjectUrl(screenshotObjectUrl); + } +}; + +exports.makeObjectUrl = (data, contentType) => { + const blob = new Blob([data], { + type: contentType, + }); + + return URL.createObjectURL(blob); +}; + +exports.revokeObjectUrl = objectUrl => { + URL.revokeObjectURL(objectUrl); +}; diff --git a/js/views/contact_list_view.js b/js/views/contact_list_view.js index b9fb836c8..bb813e545 100644 --- a/js/views/contact_list_view.js +++ b/js/views/contact_list_view.js @@ -13,9 +13,6 @@ tagName: 'div', className: 'contact', templateName: 'contact', - events: { - click: 'showIdentity', - }, initialize(options) { this.ourNumber = textsecure.storage.user.getNumber(); this.listenBack = options.listenBack; diff --git a/js/views/conversation_list_view.js b/js/views/conversation_list_view.js index ab9dea207..6db1c209d 100644 --- a/js/views/conversation_list_view.js +++ b/js/views/conversation_list_view.js @@ -1,4 +1,4 @@ -/* global Whisper, getInboxCollection */ +/* global Whisper, getInboxCollection, $ */ // eslint-disable-next-line func-names (function() { diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index 38fb26d34..895e9cba6 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -2,7 +2,6 @@ /* global _: false */ /* global emojiData: false */ /* global EmojiPanel: false */ -/* global moment: false */ /* global extension: false */ /* global i18n: false */ /* global Signal: false */ @@ -14,6 +13,7 @@ 'use strict'; window.Whisper = window.Whisper || {}; + const { Migrations } = Signal; Whisper.ExpiredToast = Whisper.ToastView.extend({ render_attributes() { @@ -31,42 +31,6 @@ }, }); - const MenuView = Whisper.View.extend({ - toggleMenu() { - this.$('.menu-list').toggle(); - }, - }); - - const TimerMenuView = MenuView.extend({ - initialize() { - this.render(); - this.listenTo(this.model, 'change:expireTimer', this.render); - }, - events: { - 'click button': 'toggleMenu', - 'click li': 'setTimer', - }, - setTimer(e) { - const { seconds } = this.$(e.target).data(); - if (seconds > 0) { - this.model.updateExpirationTimer(seconds); - } else { - this.model.updateExpirationTimer(null); - } - }, - render() { - const seconds = this.model.get('expireTimer'); - if (seconds) { - const s = Whisper.ExpirationTimerOptions.getAbbreviated(seconds); - this.$el.attr('data-time', s); - this.$el.show(); - } else { - this.$el.attr('data-time', null); - this.$el.hide(); - } - }, - }); - Whisper.ConversationLoadingScreen = Whisper.View.extend({ templateName: 'conversation-loading-screen', className: 'conversation-loading-screen', @@ -82,35 +46,23 @@ template: $('#conversation').html(), render_attributes() { return { - group: this.model.get('type') === 'group', - isMe: this.model.isMe(), - avatar: this.model.getAvatar(), - expireTimer: this.model.get('expireTimer'), - 'show-members': i18n('showMembers'), - 'end-session': i18n('resetSession'), - 'show-identity': i18n('showSafetyNumber'), - destroy: i18n('deleteMessages'), 'send-message': i18n('sendMessage'), - 'disappearing-messages': i18n('disappearingMessages'), 'android-length-warning': i18n('androidMessageLengthWarning'), - timer_options: Whisper.ExpirationTimerOptions.models, - 'view-all-media': i18n('viewAllMedia'), }; }, initialize(options) { this.listenTo(this.model, 'destroy', this.stopListening); this.listenTo(this.model, 'change:verified', this.onVerifiedChange); - this.listenTo(this.model, 'change:color', this.updateColor); - this.listenTo( - this.model, - 'change:avatar change:profileAvatar', - this.updateAvatar - ); this.listenTo(this.model, 'newmessage', this.addMessage); - this.listenTo(this.model, 'delivered', this.updateMessage); - this.listenTo(this.model, 'read', this.updateMessage); this.listenTo(this.model, 'opened', this.onOpened); this.listenTo(this.model, 'prune', this.onPrune); + this.listenTo( + this.model.messageCollection, + 'show-identity', + this.showSafetyNumber + ); + this.listenTo(this.model.messageCollection, 'force-send', this.forceSend); + this.listenTo(this.model.messageCollection, 'delete', this.deleteMessage); this.listenTo( this.model.messageCollection, 'scroll-to-message', @@ -126,11 +78,26 @@ 'show-contact-detail', this.showContactDetail ); + this.listenTo( + this.model.messageCollection, + 'show-lightbox', + this.showLightbox + ); + this.listenTo( + this.model.messageCollection, + 'download', + this.downloadAttachment + ); this.listenTo( this.model.messageCollection, 'open-conversation', this.openConversation ); + this.listenTo( + this.model.messageCollection, + 'show-message-detail', + this.showMessageDetail + ); this.lazyUpdateVerified = _.debounce( this.model.updateVerified.bind(this.model), @@ -145,12 +112,7 @@ this.loadingScreen = new Whisper.ConversationLoadingScreen(); this.loadingScreen.render(); - this.loadingScreen.$el.prependTo(this.el); - - this.timerMenu = new TimerMenuView({ - el: this.$('.timer-menu'), - model: this.model, - }); + this.loadingScreen.$el.prependTo(this.$('.discussion-container')); this.window = options.window; this.fileInput = new Whisper.FileInputView({ @@ -158,21 +120,64 @@ window: this.window, }); - const getTitleProps = model => ({ - isVerified: model.isVerified(), - name: model.getName(), - phoneNumber: model.getNumber(), - profileName: model.getProfileName(), - }); + const getHeaderProps = () => { + const avatar = this.model.getAvatar(); + const avatarPath = avatar ? avatar.url : null; + const expireTimer = this.model.get('expireTimer'); + const expirationSettingName = expireTimer + ? Whisper.ExpirationTimerOptions.getName(expireTimer || 0) + : null; + + return { + id: this.model.id, + name: this.model.getName(), + phoneNumber: this.model.getNumber(), + profileName: this.model.getProfileName(), + color: this.model.getColor(), + avatarPath, + isVerified: this.model.isVerified(), + isMe: this.model.isMe(), + isGroup: !this.model.isPrivate(), + expirationSettingName, + showBackButton: Boolean(this.panels && this.panels.length), + timerOptions: Whisper.ExpirationTimerOptions.map(item => ({ + name: item.getName(), + value: item.get('seconds'), + })), + + onSetDisappearingMessages: seconds => + this.setDisappearingMessages(seconds), + onDeleteMessages: () => this.destroyMessages(), + onResetSession: () => this.endSession(), + + // These are view only and done update the Conversation model, so they + // need a manual update call. + onShowSafetyNumber: () => { + this.showSafetyNumber(); + this.updateHeader(); + }, + onShowAllMedia: async () => { + await this.showAllMedia(); + this.updateHeader(); + }, + onShowGroupMembers: () => { + this.showMembers(); + this.updateHeader(); + }, + onGoBack: () => { + this.resetPanel(); + this.updateHeader(); + }, + }; + }; this.titleView = new Whisper.ReactWrapperView({ className: 'title-wrapper', - Component: window.Signal.Components.ConversationTitle, - props: getTitleProps(this.model), + Component: window.Signal.Components.ConversationHeader, + props: getHeaderProps(this.model), }); - this.listenTo(this.model, 'change', () => - this.titleView.update(getTitleProps(this.model)) - ); - this.$('.conversation-title').prepend(this.titleView.el); + this.updateHeader = () => this.titleView.update(getHeaderProps()); + this.listenTo(this.model, 'change', this.updateHeader); + this.$('.conversation-header').append(this.titleView.el); this.view = new Whisper.MessageListView({ collection: this.model.messageCollection, @@ -210,20 +215,10 @@ 'submit .send': 'checkUnverifiedSendMessage', 'input .send-message': 'updateMessageFieldSize', 'keydown .send-message': 'updateMessageFieldSize', - 'click .destroy': 'destroyMessages', - 'click .end-session': 'endSession', - 'click .leave-group': 'leaveGroup', - 'click .update-group': 'newGroupUpdate', - 'click .show-identity': 'showSafetyNumber', - 'click .show-members': 'showMembers', - 'click .view-all-media': 'viewAllMedia', - 'click .conversation-menu .hamburger': 'toggleMenu', click: 'onClick', 'click .bottom-bar': 'focusMessageField', - 'click .back': 'resetPanel', 'click .capture-audio .microphone': 'captureAudio', - 'click .disappearing-messages': 'enableDisappearingMessages', - 'click .scroll-down-button-view': 'scrollToBottom', + 'click .module-scroll-down': 'scrollToBottom', 'click button.emoji': 'toggleEmojiPanel', 'focus .send-message': 'focusBottomBar', 'change .file-input': 'toggleMicrophone', @@ -233,10 +228,7 @@ 'atBottom .message-list': 'removeScrollDownButton', 'farFromBottom .message-list': 'addScrollDownButton', 'lazyScroll .message-list': 'onLazyScroll', - 'close .menu': 'closeMenu', - 'select .message-list .entry': 'messageDetail', 'force-resize': 'forceUpdateMessageFieldSize', - 'show-identity': 'showSafetyNumber', dragover: 'sendToFileInput', drop: 'sendToFileInput', dragleave: 'sendToFileInput', @@ -269,7 +261,6 @@ reason ); - this.timerMenu.remove(); this.fileInput.remove(); this.titleView.remove(); @@ -288,6 +279,9 @@ if (this.quoteView) { this.quoteView.remove(); } + if (this.lightBoxView) { + this.lightBoxView.remove(); + } if (this.lightboxGalleryView) { this.lightboxGalleryView.remove(); } @@ -362,7 +356,7 @@ openSafetyNumberScreens(unverified) { if (unverified.length === 1) { - this.showSafetyNumber(null, unverified.at(0)); + this.showSafetyNumber(unverified.at(0)); return; } @@ -406,11 +400,6 @@ } }, - enableDisappearingMessages() { - if (!this.model.get('expireTimer')) { - this.model.updateExpirationTimer(moment.duration(1, 'day').asSeconds()); - } - }, toggleMicrophone() { if ( this.$('.send-message').val().length > 0 || @@ -591,11 +580,7 @@ el[0].scrollIntoView(); }, - async viewAllMedia() { - // We have to do this manually, since our React component will not propagate click - // events up to its parent elements in the DOM. - this.closeMenu(); - + async showAllMedia() { // We fetch more documents than media as they don’t require to be loaded // into memory right away. Revisit this once we have infinite scrolling: const DEFAULT_MEDIA_FETCH_COUNT = 50; @@ -620,7 +605,7 @@ // NOTE: Could we show grid previews from disk as well? const loadMessages = Signal.Components.Types.Message.loadWithObjectURL( - Signal.Migrations.loadMessage + Migrations.loadMessage ); const media = await loadMessages(rawMedia); @@ -655,6 +640,7 @@ mediaMessage => mediaMessage.id === message.id ); this.lightboxGalleryView = new Whisper.ReactWrapperView({ + className: 'lightbox-wrapper', Component: Signal.Components.LightboxGallery, props: { messages: mediaWithObjectURL, @@ -673,6 +659,7 @@ }; const view = new Whisper.ReactWrapperView({ + className: 'panel-wrapper', Component: Signal.Components.MediaGallery, props: { documents, @@ -840,14 +827,10 @@ } } }, - updateMessage(message) { - this.model.messageCollection.add(message, { merge: true }); - }, - onClick(e) { + onClick() { // If there are sub-panels open, we don't want to respond to clicks if (!this.panels || !this.panels.length) { - this.closeMenu(e); this.markRead(); } }, @@ -930,7 +913,31 @@ this.listenBack(view); }, - showSafetyNumber(e, providedModel) { + forceSend({ contact, message }) { + const dialog = new Whisper.ConfirmationDialogView({ + message: i18n('identityKeyErrorOnSend'), + okText: i18n('sendAnyway'), + resolve: async () => { + await contact.updateVerified(); + + if (contact.isUnverified()) { + await contact.setVerifiedDefault(); + } + + const untrusted = await contact.isUntrusted(); + if (untrusted) { + await contact.setApproved(); + } + + message.resend(contact.id); + }, + }); + + this.$el.prepend(dialog.el); + dialog.focusCancel(); + }, + + showSafetyNumber(providedModel) { let model = providedModel; if (!model && this.model.isPrivate()) { @@ -945,26 +952,78 @@ } }, - messageDetail(e, data) { - const view = new Whisper.MessageDetailView({ - model: data.message, - conversation: this.model, - // we pass these in to allow nested panels - listenBack: this.listenBack.bind(this), - resetPanel: this.resetPanel.bind(this), + downloadAttachment({ attachment, message }) { + const { getAbsoluteAttachmentPath } = Migrations; + + Signal.Types.Attachment.save({ + attachment, + document, + getAbsolutePath: getAbsoluteAttachmentPath, + timestamp: message.get('sent_at'), }); - this.listenBack(view); - view.render(); }, - // not currently in use - newGroupUpdate() { - const view = new Whisper.NewGroupUpdateView({ - model: this.model, - window: this.window, + deleteMessage(message) { + const dialog = new Whisper.ConfirmationDialogView({ + message: i18n('deleteWarning'), + okText: i18n('delete'), + resolve: () => { + message.destroy(); + this.resetPanel(); + this.updateHeader(); + }, }); - view.render(); + + this.$el.prepend(dialog.el); + dialog.focusCancel(); + }, + + showLightbox({ attachment, message }) { + const { getAbsoluteAttachmentPath } = Migrations; + const { contentType, path } = attachment; + + if ( + !Signal.Util.GoogleChrome.isImageTypeSupported(contentType) && + !Signal.Util.GoogleChrome.isVideoTypeSupported(contentType) + ) { + this.downloadAttachment({ attachment, message }); + return; + } + + const props = { + objectURL: getAbsoluteAttachmentPath(path), + contentType, + onSave: () => this.downloadAttachment({ attachment, message }), + }; + this.lightboxView = new Whisper.ReactWrapperView({ + className: 'lightbox-wrapper', + Component: Signal.Components.Lightbox, + props, + onClose: () => Signal.Backbone.Views.Lightbox.hide(), + }); + Signal.Backbone.Views.Lightbox.show(this.lightboxView.el); + }, + + showMessageDetail(message) { + const props = message.getPropsForMessageDetail(); + const view = new Whisper.ReactWrapperView({ + className: 'message-detail-wrapper', + Component: Signal.Components.MessageDetail, + props, + onClose: () => { + this.stopListening(message, 'change', update); + this.resetPanel(); + this.updateHeader(); + }, + }); + + const update = () => view.update(message.getPropsForMessageDetail()); + this.listenTo(message, 'change', update); + // We could listen to all involved contacts, but we'll call that overkill + this.listenBack(view); + this.updateHeader(); + view.render(); }, showContactDetail({ contact, hasSignalAccount }) { @@ -989,7 +1048,10 @@ } }, }, - onClose: () => this.resetPanel(), + onClose: () => { + this.resetPanel(); + this.updateHeader(); + }, }); this.listenBack(view); @@ -1009,57 +1071,45 @@ this.panels[0].$el.hide(); } this.panels.unshift(view); - - if (this.panels.length === 1) { - this.$('.main.panel, .header-buttons.right').hide(); - this.$('.back').show(); - } - view.$el.insertBefore(this.$('.panel').first()); }, resetPanel() { + if (!this.panels || !this.panels.length) { + return; + } + const view = this.panels.shift(); + if (this.panels.length > 0) { this.panels[0].$el.show(); } view.remove(); if (this.panels.length === 0) { - this.$('.main.panel, .header-buttons.right').show(); - this.$('.back').hide(); this.$el.trigger('force-resize'); } }, - closeMenu(e) { - if (e && !$(e.target).hasClass('hamburger')) { - this.$('.conversation-menu .menu-list').hide(); - } - if (e && !$(e.target).hasClass('clock')) { - this.$('.timer-menu .menu-list').hide(); - } - }, - endSession() { this.model.endSession(); - this.$('.menu-list').hide(); }, - leaveGroup() { - this.model.leaveGroup(); - this.$('.menu-list').hide(); - }, - - toggleMenu() { - this.$('.conversation-menu .menu-list').toggle(); + setDisappearingMessages(seconds) { + if (seconds > 0) { + this.model.updateExpirationTimer(seconds); + } else { + this.model.updateExpirationTimer(null); + } }, async destroyMessages() { - this.$('.menu-list').hide(); - - await this.confirm(i18n('deleteConversationConfirmation')); - this.model.destroyMessages(); - this.remove(); + try { + await this.confirm(i18n('deleteConversationConfirmation')); + await this.model.destroyMessages(); + this.remove(); + } catch (error) { + // nothing to see here + } }, showSendConfirmationDialog(e, contacts) { @@ -1247,24 +1297,21 @@ const contact = this.quotedMessage.getContact(); if (contact) { - this.listenTo(contact, 'change:color', this.renderQuotedMesage); + this.listenTo(contact, 'change', this.renderQuotedMesage); } this.quoteView = new Whisper.ReactWrapperView({ className: 'quote-wrapper', Component: window.Signal.Components.Quote, props: Object.assign({}, props, { - text: props.text, + withContentAbove: true, onClose: () => { this.setQuoteMessage(null); }, }), }); - const selector = - storage.get('theme-setting') === 'ios' ? '.bottom-bar' : '.send'; - - this.$(selector).prepend(this.quoteView.el); + this.$('.send').prepend(this.quoteView.el); this.updateMessageFieldSize({}); }, @@ -1319,28 +1366,6 @@ } }, - updateColor(model, color) { - const header = this.$('.conversation-header'); - header.removeClass(Whisper.Conversation.COLORS); - if (color) { - header.addClass(color); - } - const avatarView = new (Whisper.View.extend({ - templateName: 'avatar', - render_attributes: { avatar: this.model.getAvatar() }, - }))(); - header.find('.avatar').replaceWith(avatarView.render().$('.avatar')); - }, - - updateAvatar() { - const header = this.$('.conversation-header'); - const avatarView = new (Whisper.View.extend({ - templateName: 'avatar', - render_attributes: { avatar: this.model.getAvatar() }, - }))(); - header.find('.avatar').replaceWith(avatarView.render().$('.avatar')); - }, - updateMessageFieldSize(event) { const keyCode = event.which || event.keyCode; diff --git a/js/views/file_input_view.js b/js/views/file_input_view.js index bfdb2fb5e..47c70e93c 100644 --- a/js/views/file_input_view.js +++ b/js/views/file_input_view.js @@ -12,7 +12,7 @@ window.Whisper = window.Whisper || {}; - const { MIME } = window.Signal.Types; + const { MIME, VisualAttachment } = window.Signal.Types; Whisper.FileSizeToast = Whisper.ToastView.extend({ templateName: 'file-size-modal', @@ -28,98 +28,6 @@ template: i18n('unsupportedFileType'), }); - function makeImageThumbnail(size, objectUrl) { - return new Promise((resolve, reject) => { - const img = document.createElement('img'); - img.onerror = reject; - img.onload = () => { - // using components/blueimp-load-image - - // first, make the correct size - let canvas = loadImage.scale(img, { - canvas: true, - cover: true, - maxWidth: size, - maxHeight: size, - minWidth: size, - minHeight: size, - }); - - // then crop - canvas = loadImage.scale(canvas, { - canvas: true, - crop: true, - maxWidth: size, - maxHeight: size, - minWidth: size, - minHeight: size, - }); - - const blob = window.dataURLToBlobSync(canvas.toDataURL('image/png')); - - resolve(blob); - }; - img.src = objectUrl; - }); - } - - function makeVideoScreenshot(objectUrl) { - return new Promise((resolve, reject) => { - const video = document.createElement('video'); - - function capture() { - const canvas = document.createElement('canvas'); - canvas.width = video.videoWidth; - canvas.height = video.videoHeight; - canvas - .getContext('2d') - .drawImage(video, 0, 0, canvas.width, canvas.height); - - const image = window.dataURLToBlobSync(canvas.toDataURL('image/png')); - - video.removeEventListener('canplay', capture); - - resolve(image); - } - - video.addEventListener('canplay', capture); - video.addEventListener('error', error => { - console.log( - 'makeVideoThumbnail error', - Signal.Types.Errors.toLogFormat(error) - ); - reject(error); - }); - - video.src = objectUrl; - }); - } - - function blobToArrayBuffer(blob) { - return new Promise((resolve, reject) => { - const fileReader = new FileReader(); - - fileReader.onload = e => resolve(e.target.result); - fileReader.onerror = reject; - fileReader.onabort = reject; - - fileReader.readAsArrayBuffer(blob); - }); - } - - async function makeVideoThumbnail(size, videoObjectUrl) { - const blob = await makeVideoScreenshot(videoObjectUrl); - const data = await blobToArrayBuffer(blob); - const screenshotObjectUrl = Signal.Util.arrayBufferToObjectURL({ - data, - type: 'image/png', - }); - - const thumbnail = await makeImageThumbnail(size, screenshotObjectUrl); - URL.revokeObjectURL(screenshotObjectUrl); - return thumbnail; - } - Whisper.FileInputView = Backbone.View.extend({ tagName: 'span', className: 'file-input', @@ -252,10 +160,14 @@ const renderVideoPreview = async () => { // we use the variable on this here to ensure cleanup if we're interrupted this.previewObjectUrl = URL.createObjectURL(file); - const thumbnail = await makeVideoScreenshot(this.previewObjectUrl); + const type = 'image/png'; + const thumbnail = await VisualAttachment.makeVideoScreenshot( + this.previewObjectUrl, + type + ); URL.revokeObjectURL(this.previewObjectUrl); - const data = await blobToArrayBuffer(thumbnail); + const data = await VisualAttachment.blobToArrayBuffer(thumbnail); this.previewObjectUrl = Signal.Util.arrayBufferToObjectURL({ data, type: 'image/png', @@ -385,7 +297,10 @@ const objectUrl = URL.createObjectURL(file); - const arrayBuffer = await makeImageThumbnail(size, objectUrl); + const arrayBuffer = await VisualAttachment.makeImageThumbnail( + size, + objectUrl + ); URL.revokeObjectURL(objectUrl); return this.readFile(arrayBuffer); @@ -482,8 +397,4 @@ } }, }); - - Whisper.FileInputView.makeImageThumbnail = makeImageThumbnail; - Whisper.FileInputView.makeVideoThumbnail = makeVideoThumbnail; - Whisper.FileInputView.makeVideoScreenshot = makeVideoScreenshot; })(); diff --git a/js/views/identity_key_send_error_view.js b/js/views/identity_key_send_error_view.js deleted file mode 100644 index f067af3f2..000000000 --- a/js/views/identity_key_send_error_view.js +++ /dev/null @@ -1,55 +0,0 @@ -/* global Whisper, i18n */ - -// eslint-disable-next-line func-names -(function() { - 'use strict'; - - window.Whisper = window.Whisper || {}; - - Whisper.IdentityKeySendErrorPanelView = Whisper.View.extend({ - className: 'identity-key-send-error panel', - templateName: 'identity-key-send-error', - initialize(options) { - this.listenBack = options.listenBack; - this.resetPanel = options.resetPanel; - - this.wasUnverified = this.model.isUnverified(); - this.listenTo(this.model, 'change', this.render); - }, - events: { - 'click .show-safety-number': 'showSafetyNumber', - 'click .send-anyway': 'sendAnyway', - 'click .cancel': 'cancel', - }, - showSafetyNumber() { - const view = new Whisper.KeyVerificationPanelView({ - model: this.model, - }); - this.listenBack(view); - }, - sendAnyway() { - this.resetPanel(); - this.trigger('send-anyway'); - }, - cancel() { - this.resetPanel(); - }, - render_attributes() { - let send = i18n('sendAnyway'); - if (this.wasUnverified && !this.model.isUnverified()) { - send = i18n('resend'); - } - - const errorExplanation = i18n('identityKeyErrorOnSend', [ - this.model.getTitle(), - this.model.getTitle(), - ]); - return { - errorExplanation, - showSafetyNumber: i18n('showSafetyNumber'), - sendAnyway: send, - cancel: i18n('cancel'), - }; - }, - }); -})(); diff --git a/js/views/inbox_view.js b/js/views/inbox_view.js index 50bea5744..0f7ca1f14 100644 --- a/js/views/inbox_view.js +++ b/js/views/inbox_view.js @@ -108,7 +108,9 @@ const inboxCollection = getInboxCollection(); inboxCollection.on('messageError', () => { - this.networkStatusView.render(); + if (this.networkStatusView) { + this.networkStatusView.render(); + } }); this.inboxListView = new Whisper.ConversationListView({ diff --git a/js/views/last_seen_indicator_view.js b/js/views/last_seen_indicator_view.js index 5c95398a2..12049d59e 100644 --- a/js/views/last_seen_indicator_view.js +++ b/js/views/last_seen_indicator_view.js @@ -7,7 +7,7 @@ window.Whisper = window.Whisper || {}; Whisper.LastSeenIndicatorView = Whisper.View.extend({ - className: 'last-seen-indicator-view', + className: 'module-last-seen-indicator', templateName: 'last-seen-indicator-view', initialize(options = {}) { this.count = options.count || 0; diff --git a/js/views/message_detail_view.js b/js/views/message_detail_view.js deleted file mode 100644 index 02a93b561..000000000 --- a/js/views/message_detail_view.js +++ /dev/null @@ -1,182 +0,0 @@ -/* global Whisper, i18n, _, ConversationController, Mustache, moment */ - -/* eslint-disable more/no-then */ - -// eslint-disable-next-line func-names -(function() { - 'use strict'; - - window.Whisper = window.Whisper || {}; - - const ContactView = Whisper.View.extend({ - className: 'contact-detail', - templateName: 'contact-detail', - initialize(options) { - this.listenBack = options.listenBack; - this.resetPanel = options.resetPanel; - this.message = options.message; - - const newIdentity = i18n('newIdentity'); - this.errors = _.map(options.errors, error => { - if (error.name === 'OutgoingIdentityKeyError') { - // eslint-disable-next-line no-param-reassign - error.message = newIdentity; - } - return error; - }); - this.outgoingKeyError = _.find( - this.errors, - error => error.name === 'OutgoingIdentityKeyError' - ); - }, - events: { - click: 'onClick', - }, - onClick() { - if (this.outgoingKeyError) { - const view = new Whisper.IdentityKeySendErrorPanelView({ - model: this.model, - listenBack: this.listenBack, - resetPanel: this.resetPanel, - }); - - this.listenTo(view, 'send-anyway', this.onSendAnyway); - - view.render(); - - this.listenBack(view); - view.$('.cancel').focus(); - } - }, - forceSend() { - this.model - .updateVerified() - .then(() => { - if (this.model.isUnverified()) { - return this.model.setVerifiedDefault(); - } - return null; - }) - .then(() => this.model.isUntrusted()) - .then(untrusted => { - if (untrusted) { - return this.model.setApproved(); - } - return null; - }) - .then(() => { - this.message.resend(this.outgoingKeyError.number); - }); - }, - onSendAnyway() { - if (this.outgoingKeyError) { - this.forceSend(); - } - }, - render_attributes() { - const showButton = Boolean(this.outgoingKeyError); - - return { - status: this.message.getStatus(this.model.id), - name: this.model.getTitle(), - avatar: this.model.getAvatar(), - errors: this.errors, - showErrorButton: showButton, - errorButtonLabel: i18n('view'), - }; - }, - }); - - Whisper.MessageDetailView = Whisper.View.extend({ - className: 'message-detail panel', - templateName: 'message-detail', - initialize(options) { - this.listenBack = options.listenBack; - this.resetPanel = options.resetPanel; - - this.view = new Whisper.MessageView({ model: this.model }); - this.view.render(); - this.conversation = options.conversation; - - this.listenTo(this.model, 'change', this.render); - }, - events: { - 'click button.delete': 'onDelete', - }, - onDelete() { - const dialog = new Whisper.ConfirmationDialogView({ - message: i18n('deleteWarning'), - okText: i18n('delete'), - resolve: () => { - this.model.destroy(); - this.resetPanel(); - }, - }); - - this.$el.prepend(dialog.el); - dialog.focusCancel(); - }, - getContacts() { - // Return the set of models to be rendered in this view - let ids; - if (this.model.isIncoming()) { - ids = [this.model.get('source')]; - } else if (this.model.isOutgoing()) { - ids = this.model.get('recipients'); - if (!ids) { - // older messages have no recipients field - // use the current set of recipients - ids = this.conversation.getRecipients(); - } - } - return Promise.all( - ids.map(number => - ConversationController.getOrCreateAndWait(number, 'private') - ) - ); - }, - renderContact(contact) { - const view = new ContactView({ - model: contact, - errors: this.grouped[contact.id], - listenBack: this.listenBack, - resetPanel: this.resetPanel, - message: this.model, - }).render(); - this.$('.contacts').append(view.el); - }, - render() { - const errorsWithoutNumber = _.reject(this.model.get('errors'), error => - Boolean(error.number) - ); - - this.$el.html( - Mustache.render(_.result(this, 'template', ''), { - sent_at: moment(this.model.get('sent_at')).format('LLLL'), - received_at: this.model.isIncoming() - ? moment(this.model.get('received_at')).format('LLLL') - : null, - tofrom: this.model.isIncoming() ? i18n('from') : i18n('to'), - errors: errorsWithoutNumber, - title: i18n('messageDetail'), - sent: i18n('sent'), - received: i18n('received'), - errorLabel: i18n('error'), - deleteLabel: i18n('deleteMessage'), - }) - ); - this.view.$el.prependTo(this.$('.message-container')); - - this.grouped = _.groupBy(this.model.get('errors'), 'number'); - - this.getContacts().then(contacts => { - _.sortBy(contacts, c => { - const prefix = this.grouped[c.id] ? '0' : '1'; - // this prefix ensures that contacts with errors are listed first; - // otherwise it's alphabetical - return prefix + c.getTitle(); - }).forEach(this.renderContact.bind(this)); - }); - }, - }); -})(); diff --git a/js/views/message_list_view.js b/js/views/message_list_view.js index 947cf3feb..10a016113 100644 --- a/js/views/message_list_view.js +++ b/js/views/message_list_view.js @@ -64,19 +64,10 @@ this.measureScrollPosition(); }, addOne(model) { - let view; - if (model.isExpirationTimerUpdate()) { - view = new Whisper.ExpirationTimerUpdateView({ model }).render(); - } else if (model.get('type') === 'keychange') { - view = new Whisper.KeyChangeView({ model }).render(); - } else if (model.get('type') === 'verified-change') { - view = new Whisper.VerifiedChangeView({ model }).render(); - } else { - // eslint-disable-next-line new-cap - view = new this.itemView({ model }).render(); - this.listenTo(view, 'beforeChangeHeight', this.measureScrollPosition); - this.listenTo(view, 'afterChangeHeight', this.scrollToBottomIfNeeded); - } + // eslint-disable-next-line new-cap + const view = new this.itemView({ model }).render(); + this.listenTo(view, 'beforeChangeHeight', this.measureScrollPosition); + this.listenTo(view, 'afterChangeHeight', this.scrollToBottomIfNeeded); const index = this.collection.indexOf(model); this.measureScrollPosition(); diff --git a/js/views/message_view.js b/js/views/message_view.js index 8972d8af5..26f5e74ba 100644 --- a/js/views/message_view.js +++ b/js/views/message_view.js @@ -1,743 +1,115 @@ /* global Whisper: false */ -/* global i18n: false */ -/* global textsecure: false */ -/* global _: false */ -/* global Mustache: false */ -/* global $: false */ -/* global storage: false */ -/* global Signal: false */ // eslint-disable-next-line func-names (function() { 'use strict'; - const { - loadAttachmentData, - getAbsoluteAttachmentPath, - } = window.Signal.Migrations; - window.Whisper = window.Whisper || {}; - const ErrorIconView = Whisper.View.extend({ - templateName: 'error-icon', - className: 'error-icon-container', - initialize() { - if (this.model.name === 'UnregisteredUserError') { - this.$el.addClass('unregistered-user-error'); - } - }, - }); - const NetworkErrorView = Whisper.View.extend({ - tagName: 'span', - className: 'hasRetry', - templateName: 'hasRetry', - render_attributes() { - let messageNotSent; - - if (!this.model.someRecipientsFailed()) { - messageNotSent = i18n('messageNotSent'); - } - - return { - messageNotSent, - resend: i18n('resend'), - }; - }, - }); - const SomeFailedView = Whisper.View.extend({ - tagName: 'span', - className: 'some-failed', - templateName: 'some-failed', - render_attributes() { - return { - someFailed: i18n('someRecipientsFailed'), - }; - }, - }); - const TimerView = Whisper.View.extend({ - templateName: 'hourglass', - initialize() { - this.listenTo(this.model, 'unload', this.remove); - }, - update() { - if (this.timeout) { - clearTimeout(this.timeout); - this.timeout = null; - } - if (this.model.isExpired()) { - return this; - } - if (this.model.isExpiring()) { - this.render(); - const totalTime = this.model.get('expireTimer') * 1000; - const remainingTime = this.model.msTilExpire(); - const elapsed = (totalTime - remainingTime) / totalTime; - this.$('.sand').css('transform', `translateY(${elapsed * 100}%)`); - this.$el.css('display', 'inline-block'); - this.timeout = setTimeout( - this.update.bind(this), - Math.max(totalTime / 100, 500) - ); - } - return this; - }, - }); - - Whisper.ExpirationTimerUpdateView = Whisper.View.extend({ + Whisper.MessageView = Whisper.View.extend({ tagName: 'li', - className: 'expirationTimerUpdate advisory', - templateName: 'expirationTimerUpdate', id() { return this.model.id; }, initialize() { - this.conversation = this.model.getExpirationTimerUpdateSource(); - this.listenTo(this.conversation, 'change', this.render); - this.listenTo(this.model, 'unload', this.remove); this.listenTo(this.model, 'change', this.onChange); - }, - render_attributes() { - const seconds = this.model.get('expirationTimerUpdate').expireTimer; - let timerMessage; - - const timerUpdate = this.model.get('expirationTimerUpdate'); - const prettySeconds = Whisper.ExpirationTimerOptions.getName( - seconds || 0 - ); - - if ( - timerUpdate && - (timerUpdate.fromSync || timerUpdate.fromGroupUpdate) - ) { - timerMessage = i18n('timerSetOnSync', prettySeconds); - } else if (this.conversation.id === textsecure.storage.user.getNumber()) { - timerMessage = i18n('youChangedTheTimer', prettySeconds); - } else { - timerMessage = i18n('theyChangedTheTimer', [ - this.conversation.getTitle(), - prettySeconds, - ]); - } - return { content: timerMessage }; + this.listenTo(this.model, 'destroy', this.onDestroy); + this.listenTo(this.model, 'unload', this.onUnload); }, onChange() { this.addId(); }, addId() { - // This is important to enable the lastSeenIndicator when it's just been added. - this.$el.attr('id', this.id()); - }, - }); - - Whisper.KeyChangeView = Whisper.View.extend({ - tagName: 'li', - className: 'keychange advisory', - templateName: 'keychange', - id() { - return this.model.id; - }, - initialize() { - this.conversation = this.model.getModelForKeyChange(); - this.listenTo(this.conversation, 'change', this.render); - this.listenTo(this.model, 'unload', this.remove); - }, - events: { - 'click .content': 'showIdentity', - }, - render_attributes() { - return { - content: this.model.getNotificationText(), - }; - }, - showIdentity() { - this.$el.trigger('show-identity', this.conversation); - }, - }); - - Whisper.VerifiedChangeView = Whisper.View.extend({ - tagName: 'li', - className: 'verified-change advisory', - templateName: 'verified-change', - id() { - return this.model.id; - }, - initialize() { - this.conversation = this.model.getModelForVerifiedChange(); - this.listenTo(this.conversation, 'change', this.render); - this.listenTo(this.model, 'unload', this.remove); - }, - events: { - 'click .content': 'showIdentity', - }, - render_attributes() { - let key; - - if (this.model.get('verified')) { - if (this.model.get('local')) { - key = 'youMarkedAsVerified'; - } else { - key = 'youMarkedAsVerifiedOtherDevice'; - } - return { - icon: 'verified', - content: i18n(key, this.conversation.getTitle()), - }; - } - - if (this.model.get('local')) { - key = 'youMarkedAsNotVerified'; - } else { - key = 'youMarkedAsNotVerifiedOtherDevice'; - } - - return { - icon: 'shield', - content: i18n(key, this.conversation.getTitle()), - }; - }, - showIdentity() { - this.$el.trigger('show-identity', this.conversation); - }, - }); - - Whisper.MessageView = Whisper.View.extend({ - tagName: 'li', - templateName: 'message', - id() { - return this.model.id; - }, - initialize() { - // loadedAttachmentViews :: Promise (Array AttachmentView) | null - this.loadedAttachmentViews = null; - - this.listenTo(this.model, 'change:errors', this.onErrorsChanged); - this.listenTo(this.model, 'change:body', this.render); - this.listenTo(this.model, 'change:delivered', this.renderDelivered); - this.listenTo(this.model, 'change:read_by', this.renderRead); - this.listenTo( - this.model, - 'change:expirationStartTimestamp', - this.renderExpiring - ); - this.listenTo(this.model, 'change', this.onChange); - this.listenTo( - this.model, - 'change:flags change:group_update', - this.renderControl - ); - this.listenTo(this.model, 'destroy', this.onDestroy); - this.listenTo(this.model, 'unload', this.onUnload); - this.listenTo(this.model, 'expired', this.onExpired); - this.listenTo(this.model, 'pending', this.renderPending); - this.listenTo(this.model, 'done', this.renderDone); - this.timeStampView = new Whisper.ExtendedTimestampView(); - - this.contact = this.model.isIncoming() ? this.model.getContact() : null; - if (this.contact) { - this.listenTo(this.contact, 'change:color', this.updateColor); - } - }, - events: { - 'click .retry': 'retryMessage', - 'click .error-icon': 'select', - 'click .timestamp': 'select', - 'click .status': 'select', - 'click .some-failed': 'select', - 'click .error-message': 'select', - 'click .menu-container': 'showMenu', - 'click .menu-list .reply': 'onReply', - }, - retryMessage() { - const retrys = _.filter( - this.model.get('errors'), - this.model.isReplayableError.bind(this.model) - ); - _.map(retrys, 'number').forEach(number => { - this.model.resend(number); - }); - }, - showMenu(e) { - if (this.menuVisible) { - return; - } - - this.menuVisible = true; - e.stopPropagation(); - - this.$('.menu-list').show(); - $(document).one('click', () => { - this.hideMenu(); - }); - }, - hideMenu() { - this.menuVisible = false; - this.$('.menu-list').hide(); - }, - onReply() { - this.model.trigger('reply', this.model); - }, - onExpired() { - this.$el.addClass('expired'); - this.$el.find('.bubble').one('webkitAnimationEnd animationend', e => { - if (e.target === this.$('.bubble')[0]) { - this.remove(); - } - }); - - // Failsafe: if in the background, animation events don't fire - setTimeout(this.remove.bind(this), 1000); + // The ID is important for other items inserting themselves into the DOM. Because + // of ReactWrapperView and this view, there are two layers of DOM elements + // between the parent and the elements returned by the React component, so this is + // necessary. + const { id } = this.model; + this.$el.attr('id', id); }, onUnload() { - if (this.avatarView) { - this.avatarView.remove(); + if (this.childView) { + this.childView.remove(); } - if (this.bodyView) { - this.bodyView.remove(); - } - if (this.contactView) { - this.contactView.remove(); - } - if (this.controlView) { - this.controlView.remove(); - } - if (this.errorIconView) { - this.errorIconView.remove(); - } - if (this.networkErrorView) { - this.networkErrorView.remove(); - } - if (this.quoteView) { - this.quoteView.remove(); - } - if (this.someFailedView) { - this.someFailedView.remove(); - } - if (this.timeStampView) { - this.timeStampView.remove(); - } - - // NOTE: We have to do this in the background (`then` instead of `await`) - // as our tests rely on `onUnload` synchronously removing the view from - // the DOM. - // eslint-disable-next-line more/no-then - this.loadAttachmentViews().then(views => - views.forEach(view => view.unload()) - ); - - // No need to handle this one, since it listens to 'unload' itself: - // this.timerView this.remove(); }, onDestroy() { - if (this.$el.hasClass('expired')) { - return; - } this.onUnload(); }, - onChange() { - this.renderSent(); - this.renderQuote(); - this.addId(); - }, - select(e) { - this.$el.trigger('select', { message: this.model }); - e.stopPropagation(); - }, - className() { - return ['entry', this.model.get('type')].join(' '); - }, - renderPending() { - this.$el.addClass('pending'); - }, - renderDone() { - this.$el.removeClass('pending'); - }, - renderSent() { - if (this.model.isOutgoing()) { - this.$el.toggleClass('sent', !!this.model.get('sent')); - } - }, - renderDelivered() { - if (this.model.get('delivered')) { - this.$el.addClass('delivered'); - } - }, - renderRead() { - if (!_.isEmpty(this.model.get('read_by'))) { - this.$el.addClass('read'); - } - }, - onErrorsChanged() { - if (this.model.isIncoming()) { - this.render(); - } else { - this.renderErrors(); - } - }, - renderErrors() { - const errors = this.model.get('errors'); + getRenderInfo() { + const { Components } = window.Signal; - this.$('.error-icon-container').remove(); - if (this.errorIconView) { - this.errorIconView.remove(); - this.errorIconView = null; - } - if (_.size(errors) > 0) { - if (this.model.isIncoming()) { - this.$('.content') - .text(this.model.getDescription()) - .addClass('error-message'); - } - this.errorIconView = new ErrorIconView({ model: errors[0] }); - this.errorIconView.render().$el.appendTo(this.$('.bubble')); - } else if (!this.hasContents()) { - const el = this.$('.content'); - if (!el || el.length === 0) { - this.$('.inner-bubble').append("
"); - } - this.$('.content') - .text(i18n('noContents')) - .addClass('error-message'); + if (this.model.isExpirationTimerUpdate()) { + return { + Component: Components.TimerNotification, + props: this.model.getPropsForTimerNotification(), + }; + } else if (this.model.isKeyChange()) { + return { + Component: Components.SafetyNumberNotification, + props: this.model.getPropsForSafetyNumberNotification(), + }; + } else if (this.model.isVerifiedChange()) { + return { + Component: Components.VerificationNotification, + props: this.model.getPropsForVerificationNotification(), + }; + } else if (this.model.isEndSession()) { + return { + Component: Components.ResetSessionNotification, + props: this.model.getPropsForResetSessionNotification(), + }; + } else if (this.model.isGroupUpdate()) { + return { + Component: Components.GroupNotification, + props: this.model.getPropsForGroupNotification(), + }; } - this.$('.meta .hasRetry').remove(); - if (this.networkErrorView) { - this.networkErrorView.remove(); - this.networkErrorView = null; - } - if (this.model.hasNetworkError()) { - this.networkErrorView = new NetworkErrorView({ model: this.model }); - this.$('.meta').prepend(this.networkErrorView.render().el); - } - - this.$('.meta .some-failed').remove(); - if (this.someFailedView) { - this.someFailedView.remove(); - this.someFailedView = null; - } - if (this.model.someRecipientsFailed()) { - this.someFailedView = new SomeFailedView(); - this.$('.meta').prepend(this.someFailedView.render().el); - } - }, - renderControl() { - if (this.model.isEndSession() || this.model.isGroupUpdate()) { - this.$el.addClass('control'); - - if (this.controlView) { - this.controlView.remove(); - this.controlView = null; - } - - this.controlView = new Whisper.ReactWrapperView({ - className: 'content-wrapper', - Component: window.Signal.Components.Emojify, - props: { - text: this.model.getDescription(), - }, - }); - this.$('.content').prepend(this.controlView.el); - } else { - this.$el.removeClass('control'); - } - }, - renderExpiring() { - if (!this.timerView) { - this.timerView = new TimerView({ model: this.model }); - } - this.timerView.setElement(this.$('.timer')); - this.timerView.update(); - }, - renderQuote() { - const props = this.model.getPropsForQuote(); - if (!props) { - return; - } - - const contact = this.model.getQuoteContact(); - if (this.quoteView) { - this.quoteView.remove(); - this.quoteView = null; - } else if (contact) { - this.listenTo(contact, 'change:color', this.renderQuote); - } - - this.quoteView = new Whisper.ReactWrapperView({ - className: 'quote-wrapper', - Component: window.Signal.Components.Quote, - props: Object.assign({}, props, { - text: props.text, - }), - }); - this.$('.inner-bubble').prepend(this.quoteView.el); - }, - renderContact() { - const contacts = this.model.get('contact'); - if (!contacts || !contacts.length) { - return; - } - const contact = contacts[0]; - - const regionCode = storage.get('regionCode'); - const { contactSelector } = Signal.Types.Contact; - - const number = - contact.number && contact.number[0] && contact.number[0].value; - const haveConversation = - number && Boolean(window.ConversationController.get(number)); - const hasLocalSignalAccount = - this.contactHasSignalAccount || (number && haveConversation); - - // We store this value on this. because a re-render shouldn't kick off another - // profile check, going to the web. - this.contactHasSignalAccount = hasLocalSignalAccount; - - const onSendMessage = number - ? () => { - this.model.trigger('open-conversation', number); - } - : null; - const onOpenContact = async () => { - // First let's finish our check with the central server to see if this user has - // a signal account. Then we won't have to do it a second time for the detail - // screen. - await this.checkingProfile; - this.model.trigger('show-contact-detail', { - contact, - hasSignalAccount: this.contactHasSignalAccount, - }); + return { + Component: Components.Message, + props: this.model.getPropsForMessage(), }; - - const getProps = ({ hasSignalAccount }) => ({ - contact: contactSelector(contact, { - regionCode, - getAbsoluteAttachmentPath, - }), - hasSignalAccount, - onSendMessage, - onOpenContact, - }); - - if (this.contactView) { - this.contactView.remove(); - this.contactView = null; - } - - this.contactView = new Whisper.ReactWrapperView({ - className: 'contact-wrapper', - Component: window.Signal.Components.EmbeddedContact, - props: getProps({ - hasSignalAccount: hasLocalSignalAccount, - }), - }); - - this.$('.inner-bubble').prepend(this.contactView.el); - - // If we can't verify a signal account locally, we'll go to the Signal Server. - if (number && !hasLocalSignalAccount) { - // eslint-disable-next-line more/no-then - this.checkingProfile = window.textsecure.messaging - .getProfile(number) - .then(() => { - this.contactHasSignalAccount = true; - - if (!this.contactView) { - return; - } - this.contactView.update(getProps({ hasSignalAccount: true })); - }) - .catch(() => { - // No account available, or network connectivity problem - }); - } else { - this.checkingProfile = Promise.resolve(); - } - }, - isImageWithoutCaption() { - const attachments = this.model.get('attachments'); - const body = this.model.get('body'); - if (!attachments || attachments.length === 0) { - return false; - } - - if (body && body.trim()) { - return false; - } - - const first = attachments[0]; - if (Signal.Util.GoogleChrome.isImageTypeSupported(first.contentType)) { - return true; - } - - return false; - }, - hasContents() { - const attachments = this.model.get('attachments'); - const hasAttachments = attachments && attachments.length > 0; - - const contacts = this.model.get('contact'); - const hasContact = contacts && contacts.length > 0; - - return this.hasTextContents() || hasAttachments || hasContact; - }, - hasTextContents() { - const body = this.model.get('body'); - const isGroupUpdate = this.model.isGroupUpdate(); - const isEndSession = this.model.isEndSession(); - - const errors = this.model.get('errors'); - const hasErrors = errors && errors.length > 0; - const errorsCanBeContents = this.model.isIncoming() && hasErrors; - - return body || isGroupUpdate || isEndSession || errorsCanBeContents; - }, - addId() { - // Because we initially render a sent Message before we've roundtripped with the - // database, we don't have its id for that first render. We do get a change event, - // however, and can add the id manually. - const { id } = this.model; - this.$el.attr('id', id); }, render() { - const contact = this.model.isIncoming() ? this.model.getContact() : null; - const attachments = this.model.get('attachments'); + this.addId(); - const errors = this.model.get('errors'); - const hasErrors = errors && errors.length > 0; - const hasAttachments = attachments && attachments.length > 0; - const hasBody = this.hasTextContents(); - - const messageBody = this.model.get('body'); - - this.$el.html( - Mustache.render( - _.result(this, 'template', ''), - { - message: Boolean(messageBody), - hasBody, - timestamp: this.model.get('sent_at'), - sender: (contact && contact.getTitle()) || '', - avatar: contact && contact.getAvatar(), - profileName: contact && contact.getProfileName(), - innerBubbleClasses: this.isImageWithoutCaption() ? '' : 'with-tail', - hoverIcon: !hasErrors, - hasAttachments, - reply: i18n('replyToMessage'), - }, - this.render_partials() - ) - ); - this.timeStampView.setElement(this.$('.timestamp')); - this.timeStampView.update(); - - this.renderControl(); - - if (messageBody) { - if (this.bodyView) { - this.bodyView.remove(); - this.bodyView = null; - } - this.bodyView = new Whisper.ReactWrapperView({ - className: 'body-wrapper', - Component: window.Signal.Components.MessageBody, - props: { - text: messageBody, - }, - }); - this.$('.body').append(this.bodyView.el); + if (this.childView) { + this.childView.remove(); + this.childView = null; } - this.renderSent(); - this.renderDelivered(); - this.renderRead(); - this.renderErrors(); - this.renderExpiring(); - this.renderQuote(); - this.renderContact(); + const { Component, props } = this.getRenderInfo(); + this.childView = new Whisper.ReactWrapperView({ + className: 'message-wrapper', + Component, + props, + }); - // NOTE: We have to do this in the background (`then` instead of `await`) - // as our code / Backbone seems to rely on `render` synchronously returning - // `this` instead of `Promise MessageView` (this): - // eslint-disable-next-line more/no-then - this.loadAttachmentViews().then(views => - this.renderAttachmentViews(views) - ); + const update = () => { + const info = this.getRenderInfo(); + this.childView.update(info.props); + }; + + this.listenTo(this.model, 'change', update); + + this.conversation = this.model.getConversation(); + this.listenTo(this.conversation, 'change', update); + + this.fromContact = this.model.getIncomingContact(); + if (this.fromContact) { + this.listenTo(this.fromContact, 'change', update); + } + + this.quotedContact = this.model.getQuoteContact(); + if (this.quotedContact) { + this.listenTo(this.quotedContact, 'change', update); + } + + this.$el.append(this.childView.el); return this; }, - updateColor() { - const bubble = this.$('.bubble'); - - // this.contact is known to be non-null if we're registered for color changes - const color = this.contact.getColor(); - if (color) { - bubble.removeClass(Whisper.Conversation.COLORS); - bubble.addClass(color); - } - this.avatarView = new (Whisper.View.extend({ - templateName: 'avatar', - render_attributes: { avatar: this.contact.getAvatar() }, - }))(); - this.$('.avatar').replaceWith(this.avatarView.render().$('.avatar')); - }, - loadAttachmentViews() { - if (this.loadedAttachmentViews !== null) { - return this.loadedAttachmentViews; - } - - const attachments = this.model.get('attachments') || []; - const loadedAttachmentViews = Promise.all( - attachments.map( - attachment => - new Promise(async resolve => { - const attachmentWithData = await loadAttachmentData(attachment); - const view = new Whisper.AttachmentView({ - model: attachmentWithData, - timestamp: this.model.get('sent_at'), - }); - - this.listenTo(view, 'update', () => { - // NOTE: Can we do without `updated` flag now that we use promises? - view.updated = true; - resolve(view); - }); - - view.render(); - }) - ) - ); - - // Memoize attachment views to avoid double loading: - this.loadedAttachmentViews = loadedAttachmentViews; - - return loadedAttachmentViews; - }, - renderAttachmentViews(views) { - views.forEach(view => this.renderAttachmentView(view)); - }, - renderAttachmentView(view) { - if (!view.updated) { - throw new Error( - 'Invariant violation:' + - ' Cannot render an attachment view that isn’t ready' - ); - } - - const parent = this.$('.attachments')[0]; - const isViewAlreadyChild = parent === view.el.parentNode; - if (isViewAlreadyChild) { - return; - } - - if (view.el.parentNode) { - view.el.parentNode.removeChild(view.el); - } - - this.trigger('beforeChangeHeight'); - this.$('.attachments').append(view.el); - view.setElement(view.el); - this.trigger('afterChangeHeight'); - }, }); })(); diff --git a/js/views/scroll_down_button_view.js b/js/views/scroll_down_button_view.js index 741342c4f..3f98be35d 100644 --- a/js/views/scroll_down_button_view.js +++ b/js/views/scroll_down_button_view.js @@ -7,7 +7,7 @@ window.Whisper = window.Whisper || {}; Whisper.ScrollDownButtonView = Whisper.View.extend({ - className: 'scroll-down-button-view', + className: 'module-scroll-down', templateName: 'scroll-down-button-view', initialize(options = {}) { @@ -20,7 +20,8 @@ }, render_attributes() { - const cssClass = this.count > 0 ? 'new-messages' : ''; + const buttonClass = + this.count > 0 ? 'module-scroll-down__button--new-messages' : ''; let moreBelow = i18n('scrollDown'); if (this.count > 1) { @@ -30,7 +31,7 @@ } return { - cssClass, + buttonClass, moreBelow, }; }, diff --git a/package.json b/package.json index 2ff7f1a5a..0cefc0160 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "protobufjs": "^6.8.6", "proxy-agent": "^2.1.0", "react": "^16.2.0", + "react-contextmenu": "^2.9.2", "react-dom": "^16.2.0", "read-last-lines": "^1.3.0", "rimraf": "^2.6.2", diff --git a/preload.js b/preload.js index 32bd943db..4325a97f0 100644 --- a/preload.js +++ b/preload.js @@ -202,13 +202,6 @@ window.libphonenumber = require('google-libphonenumber').PhoneNumberUtil.getInst window.libphonenumber.PhoneNumberFormat = require('google-libphonenumber').PhoneNumberFormat; window.loadImage = require('blueimp-load-image'); -// Note: when modifying this file, consider whether our React Components or Backbone Views -// will need these things to render in the Style Guide. If so, go update one of these -// two locations: -// -// 1) test/styleguide/legacy_bridge.js -// 2) ts/styleguide/StyleGuideUtil.js - window.React = require('react'); window.ReactDOM = require('react-dom'); window.moment = require('moment'); diff --git a/styleguide.config.js b/styleguide.config.js index 3989a8947..07157d868 100644 --- a/styleguide.config.js +++ b/styleguide.config.js @@ -54,98 +54,6 @@ module.exports = { }, ], }, - body: { - // Brings in all the necessary components to boostrap Backbone views - // Mirrors the order used in background.js. - scripts: [ - { - src: 'test/styleguide/legacy_bridge.js', - }, - { - src: 'node_modules/moment/min/moment-with-locales.min.js', - }, - { - src: 'js/components.js', - }, - { - src: 'js/reliable_trigger.js', - }, - { - src: 'js/database.js', - }, - { - src: 'js/storage.js', - }, - { - src: 'js/signal_protocol_store.js', - }, - { - src: 'js/libtextsecure.js', - }, - { - src: 'js/focus_listener.js', - }, - { - src: 'js/notifications.js', - }, - { - src: 'js/delivery_receipts.js', - }, - { - src: 'js/read_receipts.js', - }, - { - src: 'js/read_syncs.js', - }, - { - src: 'js/libphonenumber-util.js', - }, - { - src: 'js/models/messages.js', - }, - { - src: 'js/models/conversations.js', - }, - { - src: 'js/models/blockedNumbers.js', - }, - { - src: 'js/expiring_messages.js', - }, - { - src: 'js/chromium.js', - }, - { - src: 'js/registration.js', - }, - { - src: 'js/expire.js', - }, - { - src: 'js/conversation_controller.js', - }, - // Select Backbone views - { - src: 'js/views/react_wrapper_view.js', - }, - { - src: 'js/views/whisper_view.js', - }, - { - src: 'js/views/timestamp_view.js', - }, - { - src: 'js/views/attachment_view.js', - }, - { - src: 'js/views/message_view.js', - }, - // Hacky way of including templates for Backbone components - { - src: 'test/styleguide/legacy_templates.js', - }, - ], - }, }, propsParser, webpackConfig: { diff --git a/stylesheets/_conversation.scss b/stylesheets/_conversation.scss index e6c7d3ed8..40ad18354 100644 --- a/stylesheets/_conversation.scss +++ b/stylesheets/_conversation.scss @@ -1,85 +1,10 @@ -.conversation-title { - display: block; - line-height: 36px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - padding: 0 46px; - -webkit-user-select: text; -} -.conversation-name + .conversation-number { - &:before { - content: '\00b7'; // · - font-weight: bold; - padding: 0 5px 0 4px; - } -} -.conversation-title .verified { - &:before { - content: '\00b7'; // · - font-weight: bold; - padding: 0 5px 0 4px; - } -} -.conversation-title .verified-icon { - @include color-svg('../images/verified-check.svg', white); - display: inline-block; - width: 1.25em; - height: 1.25em; - vertical-align: text-bottom; -} - .conversation { - background-color: white; + background-color: $color-white; height: 100%; position: relative; - .conversation-loading-screen { - z-index: 99; - position: absolute; - left: 0; - right: 0; - top: 0; - bottom: 0; - background-color: #eee; - display: flex; - align-items: center; - - .content { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - } - - .container { - position: absolute; - left: 50%; - width: 78px; - transform: translate(-50%, 0); - } - - .dot { - width: 14px; - height: 14px; - border: 3px solid $blue; - border-radius: 50%; - float: left; - margin: 0 6px; - transform: scale(0); - - animation: loading 1500ms ease infinite 0ms; - &:nth-child(2) { - animation: loading 1500ms ease infinite 333ms; - } - &:nth-child(3) { - animation: loading 1500ms ease infinite 666ms; - } - } - } - .panel, - .react-wrapper { + .panel-wrapper { height: calc(100% - #{$header-height}); overflow-y: scroll; } @@ -94,7 +19,7 @@ } .main.panel, - .react-wrapper { + .panel-wrapper { display: flex; flex-direction: column; overflow: initial; @@ -124,8 +49,13 @@ } } +.message-detail-wrapper { + height: calc(100% - 48px); + width: 100%; +} + .discussion-container { - background-color: 'white'; + background-color: $color-white; } .key-verification { @@ -142,10 +72,10 @@ display: inline-block; &.verified { - @include color-svg('../images/verified-check.svg', $grey_d); + @include color-svg('../images/verified-check.svg', $color-light-90); } &.shield { - @include color-svg('../images/shield.svg', $grey_d); + @include color-svg('../images/shield.svg', $color-light-90); } } @@ -199,291 +129,20 @@ } } -.identity-key-send-error { - button { - margin-top: 0px; - margin-bottom: 0px; - } - .explanation { - margin-top: 20px; - } - .safety-number { - margin-top: 30px; - text-align: center; - } - .actions { - margin-top: 30px; - text-align: center; - } -} - -.message-detail { - background-color: #eee; - - .message-container { - padding: 20px 0; - - .sender { - display: none; - } - } - - .info { - padding: 1em; - - .label { - font-weight: bold; - padding-right: 1em; - vertical-align: top; - } - - button { - border: none; - border-radius: $border-radius; - color: white; - padding: 0.5em; - font-weight: bold; - - span { - vertical-align: middle; - } - } - } - - .retries { - padding: 1em; - } - button.retry { - margin: 0.5em; - } - - .contacts .contact-detail { - padding: 0 36px; - margin-bottom: 5px; - - .status-icon-container, - .error-icon-container { - float: right; - } - - button.error { - background-color: red; - color: white; - - span.icon.error { - display: inline-block; - width: 1.25em; - height: 1.25em; - position: relative; - vertical-align: middle; - @include color-svg('../images/warning.svg', white); - } - } - - .error-message { - margin: 6px 0 0; - font-size: $font-size-small; - font-weight: bold; - color: red; - } - } - - h3 { - font-size: 1em; - padding: 5px; - } - - button.cancel { - float: right; - color: $grey_d; - border: solid 1px #ccc; - } - - .delete-container { - text-align: center; - - button.delete { - background-color: red; - color: white; - } - } -} -.message-list { - .error-icon { - cursor: pointer; - } - - .advisory { - text-align: center; - .content { - display: inline-block; - padding: 5px 10px; - background: #fff5c4; - border-radius: $border-radius; - } - } -} -li.entry .error-icon-container { - position: absolute; - top: 0; - left: calc(100% + 5px); - height: 100%; - - .error-icon { - display: block; - height: 100%; - } - - .error-message { - display: none; - position: absolute; - background: black; - color: white; - border-radius: $border-radius; - padding: 0.5em; - font-weight: normal; - bottom: calc(50% + 18px); - left: -84px; - width: 180px; - z-index: 10; - - &:before { - display: block; - content: ''; - position: absolute; - bottom: -16px; - left: 50%; - border: 6px solid transparent; - border-top: 10px solid #000000; - } - } - - &:hover .error-message { - display: inline-block; - } -} -li.entry .menu-container { - position: absolute; - top: 0; - left: calc(100% + 5px); - height: 100%; - - display: flex; - align-items: center; - justify-content: center; - - .menu-anchor { - position: relative; - } - - li { - margin: 0px; - } - - cursor: pointer; -} - -.dots-horizontal-icon { - visibility: hidden; -} - -li.entry:hover .dots-horizontal-icon { - visibility: visible; -} - -li.entry.outgoing .menu-container { - left: auto; - right: calc(100% + 5px); -} - -.incoming .menu-list { - left: 0; - right: auto; -} - -.error-icon { - display: inline-block; - width: $error-icon-size; - height: $error-icon-size; - position: relative; - @include color-svg('../images/warning.svg', red); -} - -.dots-horizontal-icon { - display: inline-block; - width: $error-icon-size; - height: $error-icon-size; - position: relative; - @include color-svg('../images/dots-horizontal.svg', gray); - - &:hover { - @include color-svg('../images/dots-horizontal.svg', black); - } -} - -.group { - li.entry .unregistered-user-error { - display: none; - } -} - -.group-update { - font-size: smaller; -} - -.private .entry .avatar, -.private .sender, -.outgoing .sender { - display: none; -} - -.sender { - font-size: smaller; - opacity: 0.8; - margin-bottom: 5px; - font-weight: bold; -} - -.timestamp { - margin-right: 3px; - white-space: nowrap; -} - -// There's a p.status used in the onboarding screen, so this needs to be more specific -span.status { - width: 18px; - height: 18px; -} -.sent span.status { - display: inline-block; - @include color-svg('../images/check.svg', black); -} -.delivered span.status { - display: inline-block; - @include color-svg('../images/double-check.svg', black); -} -.read span.status { - display: inline-block; - @include color-svg('../images/double-check.svg', $blue); -} -.pending span.status { - display: inline-block; - background: none; - &:before { - content: '...'; - } -} - .message-container, .message-list { list-style: none; li { - max-width: 800px; - margin: 0 auto 10px; - padding-left: 1em; - // we need more padding on right side because scroll bar overlaps - padding-right: 1.5em; + max-width: 736px; + margin-left: auto; + margin-right: auto; + margin-bottom: 10px; + + .message-wrapper { + margin-left: 16px; + margin-right: 16px; + } &::after { visibility: hidden; @@ -494,252 +153,14 @@ span.status { height: 0; } } +} - .bubble { - position: relative; - left: -2px; - display: inline-block; - vertical-align: top; - word-wrap: break-word; - margin-left: 8px; - max-width: 30em; - text-align: -webkit-auto; - -webkit-user-select: text; - - @media (max-width: 825px) { - max-width: calc( - 100% - 45px - #{$error-icon-size} - ); // avatar size + padding + error-icon size +.group { + .message-container, + .message-list { + li .message-wrapper { + margin-left: 44px; } - - .body { - white-space: pre-wrap; - - a { - word-break: break-all; - } - } - - .attachments + .content { - margin-top: 0.5em; - } - .quote-wrapper + .content { - margin-top: 0.5em; - } - .contact-wrapper + .content { - margin-top: 0.5em; - } - - p { - margin: 0; - } - } - - .meta { - font-size: smaller; - margin-top: 3px; - text-align: right; - line-height: 18px; - - .hasRetry + .timestamp { - &:before { - content: '\00b7'; // · - font-weight: bold; - padding: 0 5px 0 4px; - text-decoration: none; - opacity: 0.5; - } - } - - .retry { - text-decoration: underline; - cursor: pointer; - } - - .some-failed { - float: left; - margin-left: 6px; - margin-right: 6px; - cursor: pointer; - } - - .hasRetry, - .timestamp, - .status, - .timer { - float: left; - } - - .timestamp, - .status { - cursor: pointer; - opacity: 0.5; - - &:hover { - opacity: 1; - } - } - } - - .incoming { - .avatar, - .bubble { - float: left; - } - } - - .outgoing { - .meta { - float: right; - } - .error-icon-container { - left: auto; - right: calc(100% + 5px); - } - - .avatar, - .bubble { - float: right; - } - - .bubble { - clear: left; - } - } - - @keyframes shake { - 0% { - transform: translateX(0px); - } - 25% { - transform: translateX(-5px); - } - 50% { - transform: translateX(0px); - } - 75% { - transform: translateX(5px); - } - 100% { - transform: translateX(0px); - } - } - - .expired .bubble { - animation: shake 0.2s linear 3; - } - - .timer { - display: none; - .hourglass { - vertical-align: middle; - } - } - - .control { - .bubble { - .content { - font-style: italic; - } - - &::before, - &::after { - display: none; - } - } - } - - .attachments { - a { - font-style: italic; - display: block; - padding: 1em; - background-color: #ccc; - } - - img, - audio, - video { - display: block; - max-width: 100%; - max-height: 300px; - } - - video { - background: black; - min-height: 300px; - min-width: 280px; - } - - img { - cursor: pointer; - } - - .fileView { - display: flex; - align-items: center; - overflow: hidden; - - position: relative; - padding: 5px; - padding-right: 10px; - padding-bottom: 0px; - - cursor: pointer; - - .fileName { - font-weight: bold; - margin-bottom: 0.25em; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } - - .text { - overflow: hidden; - } - - .icon, - .text { - opacity: 0.75; - } - - &:hover { - .icon, - .text { - opacity: 1; - } - } - - .icon { - margin-left: -0.5em; - margin-right: 0.5em; - display: inline-block; - vertical-align: middle; - width: $button-height * 2; - height: $button-height * 2; - @include color-svg('../images/file.svg', $grey_d); - - &.audio { - @include color-svg('../images/audio.svg', $grey_d); - } - &.video { - @include color-svg('../images/video.svg', $grey_d); - } - &.voice { - @include color-svg('../images/voice.svg', $grey_d); - } - } - } - } - - .outgoing .avatar { - display: none; - } - - .bubble .content.error-message { - cursor: pointer; - font-style: italic; } } @@ -762,45 +183,23 @@ span.status { } .send .quote-wrapper { - margin-left: 46px; - margin-top: 5px; - margin-right: 75px; - margin-bottom: 0px; -} - -.incoming .quoted-message { - background-color: rgba(white, 0.6); - border-top: none; - border-bottom: none; - border-right: none; - border-left-color: white; -} - -.message-list, -.message-container { - .avatar { - height: 36px; - width: 36px; - line-height: 36px; - } + margin-left: 37px; + margin-right: 73px; + margin-bottom: 5px; } .bottom-bar { box-sizing: content-box; $button-width: 36px; - padding: 5px 0px 5px 0; - background: $grey_l; - - .compose { - padding-right: 5px; - } form.active { - outline: solid 1px $blue; + textarea { + border: solid 1px $blue; + } } form.send { - background: #ffffff; + background: $color-white; &.video-attachment { .image-container { @@ -829,13 +228,9 @@ span.status { } } - input, - textarea { - color: $grey_d; - } - .attachment-previews { padding: 0 36px; + margin-bottom: 3px; .attachment-preview { padding: 13px 10px 0; @@ -875,8 +270,11 @@ span.status { display: block; max-height: 100px; padding: 10px; - margin: 0 5px; - border: 0; + margin-bottom: 6px; + border-radius: 20px; + background-color: $color-light-02; + color: $color-light-90; + border: 1px solid rgba(0, 0, 0, 0.2); outline: 0; z-index: 5; resize: none; @@ -903,7 +301,7 @@ span.status { margin: 0 2em 3em; padding: 0.5em 1.5em; background: rgba(0, 0, 0, 0.75); - color: white; + color: $color-white; box-shadow: 0 0 5px 0 black; border-radius: $border-radius; font-size: $font-size-small; @@ -957,109 +355,105 @@ span.status { } } -.advisory .icon { - height: 1.25em; - width: 1.25em; - vertical-align: text-bottom; - display: inline-block; - - &.verified { - @include color-svg('../images/verified-check.svg', $grey_d); - } - &.shield { - @include color-svg('../images/shield.svg', $grey_d); - } - &.clock { - @include color-svg('../images/clock.svg', $grey_d); - } -} - -.keychange { - text-align: center; - .content { - cursor: pointer; - display: inline-block; - padding: 5px 10px; - background: #fff5c4; - border-radius: $border-radius; - } -} - -.verified-change { - text-align: center; +.conversation-loading-screen { + z-index: 99; + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + display: flex; + align-items: center; + background-color: $color-white; .content { - cursor: pointer; - display: inline-block; - padding: 5px 10px; - background: #fff5c4; - border-radius: $border-radius; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + + .container { + position: absolute; + left: 50%; + width: 120px; + transform: translate(-50%, 0); + } + + .dot { + width: 14px; + height: 14px; + border: 3px solid $blue; + border-radius: 50%; + float: left; + margin: 0 6px; + transform: scale(0); + + animation: loading 1500ms ease infinite 0ms; + &:nth-child(2) { + animation: loading 1500ms ease infinite 333ms; + } + &:nth-child(3) { + animation: loading 1500ms ease infinite 666ms; + } } } -.message-list .last-seen-indicator-view { - // This padding is large so we clear the avatar circle extending into the conversation - // window.scrollIntoView() doesn't honor margins, so we're using padding - // padding-top is less to account for the 10px margin at the bottom of messages +.module-last-seen-indicator { padding-top: 25px; padding-bottom: 35px; - - .bar { - display: flex; - flex-direction: column; - align-items: center; - - padding: 5px; - - border-top: 1px solid rgba(255, 255, 255, 0.15); - border-bottom: 1px solid rgba(0, 0, 0, 0.055); - - background-color: rgba(0, 0, 0, 0.05); - } - - .text { - font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.06em; - background-color: white; - border-radius: 1.5em; - padding: 10px 21px 9px 21px; - } + margin-left: 28px; + margin-right: 28px; } -.discussion-container .scroll-down-button-view { +.module-last-seen-indicator__bar { + background-color: $color-light-60; + width: 100%; + height: 4px; +} + +.module-last-seen-indicator__text { + margin-top: 3px; + font-size: 11px; + line-height: 16px; + letter-spacing: 0.3px; + text-transform: uppercase; + + text-align: center; + color: $color-light-90; +} + +.module-scroll-down { position: absolute; right: 20px; bottom: 10px; +} - button { - height: 44px; - width: 44px; - border-radius: 22px; - text-align: center; - background-color: white; - border: none; - box-shadow: 0px 3px 5px 0px rgba(0, 0, 0, 0.2); - outline: none; +.module-scroll-down__button { + height: 44px; + width: 44px; + border-radius: 22px; + text-align: center; + background-color: $color-light-35; + border: none; + box-shadow: 0px 3px 5px 0px rgba(0, 0, 0, 0.2); + outline: none; - .icon { - @include color-svg('../images/down.svg', $grey_l3); - height: 100%; - width: 100%; - } - .icon:hover { - background-color: #616161; - } - - &.new-messages { - background-color: $blue; - .icon { - @include color-svg('../images/down.svg', white); - } - - &:hover { - background-color: #1472bd; - } - } + &:hover { + background-color: $color-light-45; } } + +.module-scroll-down__button--new-messages { + background-color: $color-signal-blue; + + &:hover { + background-color: #1472bd; + } +} + +.module-scroll-down__icon { + @include color-svg('../images/down.svg', $color-white); + height: 100%; + width: 100%; +} diff --git a/stylesheets/_emoji.scss b/stylesheets/_emoji.scss index f41bd7d3b..dd944b780 100644 --- a/stylesheets/_emoji.scss +++ b/stylesheets/_emoji.scss @@ -114,6 +114,17 @@ button.emoji { .emoji-panel-container { height: 0px; + margin-bottom: 3px; + + .ep-emojies { + background-color: $color-white; + } + + .ep-categories { + background-color: $color-light-10; + margin-bottom: 6px; + } + .ep-e { background-image: url('../node_modules/emoji-datasource-apple/img/apple/sheets/64.png'); background-size: 1734px; diff --git a/stylesheets/_global.scss b/stylesheets/_global.scss index 842427c94..e959d7cf8 100644 --- a/stylesheets/_global.scss +++ b/stylesheets/_global.scss @@ -13,7 +13,7 @@ body { margin: 0; font-family: $roboto; font-size: 14px; - color: $grey_d; + color: $color-light-90; } .dark-overlay { @@ -22,7 +22,7 @@ body { left: 0; right: 0; bottom: 0; - background-color: black; + background-color: $color-black; opacity: 0.25; z-index: 200; } @@ -40,23 +40,21 @@ body { display: none; } -#header { - h1 { - margin: 0; - line-height: $header-height; - padding-left: 20px; - font-size: 22px; - font-weight: normal; - } +.title-bar { + color: $color-light-90; + + height: $header-height; + display: flex; + flex-direction: row; + align-items: center; } -.conversation-header button, -.title-bar button { - width: $button-height; - height: $button-height; - line-height: $button-height; - padding: 0; - border: 0; +.logo { + margin-left: 16px; + font-size: 16px; + line-height: 24px; + font-weight: 300; + color: $color-light-90; } button { @@ -92,103 +90,6 @@ a { color: $blue; } -button.back { - @include header-icon-black('../images/back.svg'); -} -button.clock { - @include header-icon-black('../images/clock.svg'); -} -button.hamburger { - @include header-icon-black('../images/menu.svg'); -} - -::-webkit-scrollbar { - width: 10px; -} - -::-webkit-scrollbar-track { - background: transparent; -} - -::-webkit-scrollbar-thumb { - background: rgba(0, 0, 0, 0.15); - border-radius: $border-radius; - - &:hover { - background: rgba(0, 0, 0, 0.25); - } -} - -.header-buttons { - &.left { - float: left; - padding-left: 10px; - } - &.right { - float: right; - padding-right: 10px; - } - height: 0; - - .vertical-align { - height: $header-height; - vertical-align: middle; - display: table-cell; - } -} - -.conversation-header .timer-menu { - margin-right: 10px; - - &:before { - content: attr(data-time); - display: inline-block; - position: absolute; - bottom: -10px; - height: 10px; - width: 100%; - text-align: center; - font-size: 8px; - font-weight: bold; - } -} - -.menu { - position: relative; - float: right; - - .hamburger { - width: $button-height; - height: $button-height; - vertical-align: middle; - } - .menu-list { - display: none; - position: absolute; - color: $grey_d; - z-index: 50; - text-align: initial; - - top: 100%; - right: 0; - margin: 0; - padding: 0; - background-color: white; - box-shadow: 0 0 1px 1px rgba(0, 0, 0, 0.2); - - li { - display: block; - white-space: nowrap; - cursor: pointer; - padding: 5px 15px 5px 10px; - - &:hover { - background-color: $grey_l; - } - } - } -} - .file-input { position: relative; .choose-file { @@ -248,13 +149,13 @@ $avatar-size: 44px; line-height: $avatar-size; overflow-x: hidden; text-overflow: ellipsis; - color: white; + color: $color-white; font-size: 18px; - @include avatar-colors; + background-color: $grey; } .group-info-input { - background: white; + background: $color-white; .group-avatar { display: inline-block; @@ -303,23 +204,15 @@ $avatar-size: 44px; } } } -// the old way -.profileName { - font-size: smaller; - &:before { - content: '~'; - } -} -// the new way -.profile-name { - font-size: smaller; -} +$unread-badge-size: 21px; .conversation-list-item { cursor: pointer; + color: $color-light-90; + &:hover { - background: #f8f8f8; + background: $color-black-008; } .number { @@ -351,12 +244,6 @@ $avatar-size: 44px; padding: 12px; white-space: nowrap; overflow: hidden; - background: rgba(255, 255, 255, 0.6); - margin: 1px; - - &.selected { - background: rgb(236, 243, 252); - } &:first-child { margin-top: 0; @@ -511,6 +398,8 @@ $avatar-size: 44px; content: 'Add: '; } +$loading-height: 16px; + .loading { position: relative; &::before { diff --git a/stylesheets/_hourglass.scss b/stylesheets/_hourglass.scss deleted file mode 100644 index 3fe1f5a1e..000000000 --- a/stylesheets/_hourglass.scss +++ /dev/null @@ -1,29 +0,0 @@ -@mixin hourglass($color) { - display: inline-block; - position: relative; - @include color-svg('../images/hourglass_full.svg', transparent); - background-size: 100%; - - &, - .sand, - &:before, - &:after { - width: 13px; - height: 11px; - } - .sand, - &:before, - &:after { - content: ''; - display: inline-block; - position: absolute; - top: 0; - left: 0; - } - .sand { - background: $color; - } - &:after { - @include color-svg('../images/hourglass_empty.svg', $color); - } -} diff --git a/stylesheets/_index.scss b/stylesheets/_index.scss index 590fc280c..4bcd7d22a 100644 --- a/stylesheets/_index.scss +++ b/stylesheets/_index.scss @@ -3,12 +3,13 @@ .inbox, .gutter { height: 100%; + overflow: hidden; } .expired { .conversation-stack, .gutter { - height: calc(100% - 56px); + height: calc(100% - 48px); } } @@ -18,16 +19,12 @@ } .gutter { - color: $grey_d; + background-color: $color-black-008; float: left; width: 300px; - display: flex; - flex-direction: column; - .content { - background-color: $grey_l; - flex-grow: 1; - overflow-y: auto; + overflow-y: scroll; + max-height: calc(100% - 88px); } } .network-status-container { @@ -66,8 +63,6 @@ } .conversation-stack { - padding-left: 300px; - .conversation { display: none; } @@ -76,62 +71,49 @@ } } -.conversation-header { - height: $header-height; - text-align: center; - color: white; - background-color: #999999; - transition: background-color 0.5s; - border-bottom: 1px solid rgba(0, 0, 0, 0.2); - - .avatar { - margin-bottom: -30px; - border: solid 2px white; - z-index: 10; - width: 48px; - height: 48px; - line-height: 44px; - position: relative; - } -} -.inactive .conversation-header { - background-color: $grey_l !important; - color: $grey_d; - border-color: rgba(0, 0, 0, 0.05); - .verified-icon { - @include color-svg('../images/verified-check.svg', $grey_d); - } -} - .tool-bar { + color: $color-light-90; + + padding: 8px; + padding-top: 0px; + margin-top: -1px; + position: relative; .search-icon { content: ''; display: inline-block; float: left; width: 24px; - height: 100%; + height: 33px; -webkit-mask: url('../images/search.svg') no-repeat left center; -webkit-mask-size: 100%; - background-color: #ccc; + background-color: $color-light-35; position: absolute; left: 20px; top: 0; } } +$search-x-size: 16px; +$search-padding-right: 12px; +$search-padding-left: 30px; + input.search { - border: none; + border: 1px solid $color-black-02; padding: 0 $search-padding-right 0 $search-padding-left; - margin: 0; + margin-left: 8px; + margin-right: 8px; outline: 0; - height: $search-height; - line-height: $search-height; - width: 100%; - border: solid 1px $grey_l; + height: 32px; + width: calc(100% - 16px); outline-offset: -2px; - font-size: inherit; + font-size: 14px; + line-height: 18px; + font-weight: normal; + color: $color-light-35; + position: relative; + border-radius: 4px; &.active { outline: solid 1px $blue; diff --git a/stylesheets/_lightbox.scss b/stylesheets/_lightbox.scss index 213e39853..6fe5200f5 100644 --- a/stylesheets/_lightbox.scss +++ b/stylesheets/_lightbox.scss @@ -5,7 +5,7 @@ left: 0; width: 100%; height: 100%; - z-index: $z-index-modal; + z-index: 100; } .iconButton { diff --git a/stylesheets/_mixins.scss b/stylesheets/_mixins.scss index 00c327d68..f47a758e0 100644 --- a/stylesheets/_mixins.scss +++ b/stylesheets/_mixins.scss @@ -28,333 +28,3 @@ @include color-svg($svg, black); } } - -@mixin avatar-colors { - &.red { - background-color: $material_red; - } - &.pink { - background-color: $material_pink; - } - &.purple { - background-color: $material_purple; - } - &.deep_purple { - background-color: $material_deep_purple; - } - &.indigo { - background-color: $material_indigo; - } - &.blue { - background-color: $material_blue; - } - &.light_blue { - background-color: $material_light_blue; - } - &.cyan { - background-color: $material_cyan; - } - &.teal { - background-color: $material_teal; - } - &.green { - background-color: $material_green; - } - &.light_green { - background-color: $material_light_green; - } - &.orange { - background-color: $material_orange; - } - &.deep_orange { - background-color: $material_deep_orange; - } - &.amber { - background-color: $material_amber; - } - &.blue_grey { - background-color: $material_blue_grey; - } - &.grey { - background-color: #999999; - } - &.default { - background-color: $blue; - } -} -@mixin dark-avatar-colors { - &.red { - background-color: $dark_material_red; - } - &.pink { - background-color: $dark_material_pink; - } - &.purple { - background-color: $dark_material_purple; - } - &.deep_purple { - background-color: $dark_material_deep_purple; - } - &.indigo { - background-color: $dark_material_indigo; - } - &.blue { - background-color: $dark_material_blue; - } - &.light_blue { - background-color: $dark_material_light_blue; - } - &.cyan { - background-color: $dark_material_cyan; - } - &.teal { - background-color: $dark_material_teal; - } - &.green { - background-color: $dark_material_green; - } - &.light_green { - background-color: $dark_material_light_green; - } - &.orange { - background-color: $dark_material_orange; - } - &.deep_orange { - background-color: $dark_material_deep_orange; - } - &.amber { - background-color: $dark_material_amber; - } - &.blue_grey { - background-color: $dark_material_blue_grey; - } - &.grey { - background-color: #666666; - } - &.default { - background-color: $blue; - } -} -@mixin twenty-percent-colors { - &.red { - background-color: rgba($dark_material_red, 0.2); - } - &.pink { - background-color: rgba($dark_material_pink, 0.2); - } - &.purple { - background-color: rgba($dark_material_purple, 0.2); - } - &.deep_purple { - background-color: rgba($dark_material_deep_purple, 0.2); - } - &.indigo { - background-color: rgba($dark_material_indigo, 0.2); - } - &.blue { - background-color: rgba($dark_material_blue, 0.2); - } - &.light_blue { - background-color: rgba($dark_material_light_blue, 0.2); - } - &.cyan { - background-color: rgba($dark_material_cyan, 0.2); - } - &.teal { - background-color: rgba($dark_material_teal, 0.2); - } - &.green { - background-color: rgba($dark_material_green, 0.2); - } - &.light_green { - background-color: rgba($dark_material_light_green, 0.2); - } - &.orange { - background-color: rgba($dark_material_orange, 0.2); - } - &.deep_orange { - background-color: rgba($dark_material_deep_orange, 0.2); - } - &.amber { - background-color: rgba($dark_material_amber, 0.2); - } - &.blue_grey { - background-color: rgba($dark_material_blue_grey, 0.2); - } - &.grey { - background-color: rgba(#666666, 0.2); - } - &.default { - background-color: rgba($blue, 0.2); - } -} -@mixin text-colors { - &.red { - color: $material_red; - } - &.pink { - color: $material_pink; - } - &.purple { - color: $material_purple; - } - &.deep_purple { - color: $material_deep_purple; - } - &.indigo { - color: $material_indigo; - } - &.blue { - color: $material_blue; - } - &.light_blue { - color: $material_light_blue; - } - &.cyan { - color: $material_cyan; - } - &.teal { - color: $material_teal; - } - &.green { - color: $material_green; - } - &.light_green { - color: $material_light_green; - } - &.orange { - color: $material_orange; - } - &.deep_orange { - color: $material_deep_orange; - } - &.amber { - color: $material_amber; - } - &.blue_grey { - color: $material_blue_grey; - } - &.grey { - color: #999999; - } - &.default { - color: $blue; - } -} - -// TODO: Deduplicate these! Can SASS functions generate property names? -@mixin message-replies-colors { - &.red { - border-left-color: $material_red; - } - &.pink { - border-left-color: $material_pink; - } - &.purple { - border-left-color: $material_purple; - } - &.deep_purple { - border-left-color: $material_deep_purple; - } - &.indigo { - border-left-color: $material_indigo; - } - &.blue { - border-left-color: $material_blue; - } - &.light_blue { - border-left-color: $material_light_blue; - } - &.cyan { - border-left-color: $material_cyan; - } - &.teal { - border-left-color: $material_teal; - } - &.green { - border-left-color: $material_green; - } - &.light_green { - border-left-color: $material_light_green; - } - &.orange { - border-left-color: $material_orange; - } - &.deep_orange { - border-left-color: $material_deep_orange; - } - &.amber { - border-left-color: $material_amber; - } - &.blue_grey { - border-left-color: $material_blue_grey; - } - &.grey { - border-left-color: #999999; - } - &.default { - border-left-color: $blue; - } -} -@mixin dark-message-replies-colors { - &.red { - border-left-color: $dark_material_red; - } - &.pink { - border-left-color: $dark_material_pink; - } - &.purple { - border-left-color: $dark_material_purple; - } - &.deep_purple { - border-left-color: $dark_material_deep_purple; - } - &.indigo { - border-left-color: $dark_material_indigo; - } - &.blue { - border-left-color: $dark_material_blue; - } - &.light_blue { - border-left-color: $dark_material_light_blue; - } - &.cyan { - border-left-color: $dark_material_cyan; - } - &.teal { - border-left-color: $dark_material_teal; - } - &.green { - border-left-color: $dark_material_green; - } - &.light_green { - border-left-color: $dark_material_light_green; - } - &.orange { - border-left-color: $dark_material_orange; - } - &.deep_orange { - border-left-color: $dark_material_deep_orange; - } - &.amber { - border-left-color: $dark_material_amber; - } - &.blue_grey { - border-left-color: $dark_material_blue_grey; - } - &.grey { - border-left-color: #666666; - } - &.default { - border-left-color: $blue; - } -} - -@mixin invert-text-color { - color: white; - - &::selection { - background: white; - color: $grey_d; - } -} diff --git a/stylesheets/_modal.scss b/stylesheets/_modal.scss index a222c14b8..428cc46f3 100644 --- a/stylesheets/_modal.scss +++ b/stylesheets/_modal.scss @@ -14,7 +14,7 @@ max-width: 350px; margin: 100px auto; padding: 1em; - background: white; + background-color: $color-white; border-radius: $border-radius; overflow: auto; box-shadow: 0px 3px 5px 0px rgba(0, 0, 0, 0.2); diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 1bd24ca4d..15c82f281 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -1,43 +1,134 @@ // Using BEM syntax explained here: https://csswizardry.com/2013/01/mindbemding-getting-your-head-round-bem-syntax/ -$color-signal-blue: #2090ea; -$color-core-green: #4caf50; -$color-core-red: #f44336; +// Module: Contact Name -$color-white: #ffffff; -$color-white-07: rgba($color-white, 0.7); -$color-white-075: rgba($color-white, 0.75); -$color-light-02: #f9fafa; -$color-light-10: #eeefef; -$color-light-35: #a4a6a9; -$color-light-45: #8b8e91; -$color-light-60: #62656a; -$color-light-90: #070c14; - -$color-dark-05: #efefef; -$color-dark-30: #a8a9aa; -$color-dark-55: #88898c; -$color-dark-60: #797a7c; -$color-dark-70: #414347; -$color-dark-85: #1a1c20; -$color-black: #000000; -$color-black-012: rgba($color-black, 0.12); - -// TODO: need the final color for grey conversations -$color-conversation-grey: #757575; -$color-conversation-blue: #1976d2; -$color-conversation-cyan: #00838f; -$color-conversation-deep_orange: #bf360c; -$color-conversation-green: #2e7d32; -$color-conversation-indigo: #3949ab; -$color-conversation-pink: #d81b60; -$color-conversation-purple: #8e24aa; -$color-conversation-red: #d32f2f; -$color-conversation-teal: #00796b; +.module-contact-name__profile-name { + font-style: italic; +} // Module: Message .module-message { + display: inline-flex; + flex-direction: row; + align-items: stretch; +} + +.module-message--expired { + animation: module-message__shake 0.2s linear infinite; +} + +@keyframes module-message__shake { + 0% { + transform: translateX(0px); + } + 25% { + transform: translateX(-5px); + } + 50% { + transform: translateX(0px); + } + 75% { + transform: translateX(5px); + } + 100% { + transform: translateX(0px); + } +} + +.module-message--outgoing { + float: right; +} + +.module-message__buttons { + display: inline-flex; + flex-direction: row; + align-items: center; + opacity: 0; +} + +.module-message:hover .module-message__buttons { + opacity: 1; +} + +.module-message__buttons__download { + height: 24px; + width: 24px; + display: inline-block; + cursor: pointer; + @include color-svg('../images/download.svg', $color-light-45); + &:hover { + @include color-svg('../images/download.svg', $color-light-90); + } +} + +.module-message__buttons__download--incoming { + margin-left: 12px; +} +.module-message__buttons__download--outgoing { + margin-right: 12px; +} + +.module-message__buttons__reply { + height: 24px; + width: 24px; + display: inline-block; + cursor: pointer; + @include color-svg('../images/reply.svg', $color-light-45); + &:hover { + @include color-svg('../images/reply.svg', $color-light-90); + } +} + +.module-message__buttons__reply--incoming { + margin-left: 12px; +} +.module-message__buttons__reply--outgoing { + margin-right: 12px; +} + +.module-message__buttons__menu { + height: 24px; + width: 24px; + display: inline-block; + cursor: pointer; + @include color-svg('../images/ellipsis.svg', $color-light-45); + &:hover { + @include color-svg('../images/ellipsis.svg', $color-light-90); + } +} + +.module-message__buttons__menu--incoming { + margin-left: 12px; +} + +.module_message__buttons__menu--outgoing { + margin-right: 12px; +} + +.module-message__error-container { + width: 28px; + position: relative; +} + +.module-message__error { + width: 20px; + height: 20px; + display: inline-block; + position: absolute; + bottom: 4px; + @include color-svg('../images/error.svg', $color-core-red); +} + +.module-message__error--outgoing { + left: 8px; +} + +.module-message__error--incoming { + right: 8px; +} + +.module-message__container { position: relative; display: inline-block; border-radius: 16px; @@ -45,17 +136,58 @@ $color-conversation-teal: #00796b; padding-left: 12px; padding-top: 10px; padding-bottom: 10px; - max-width: 370px; // 350 + left/right padding + max-width: 386px; +} + +.module-message__container--outgoing { + background-color: $color-light-10; +} + +// In case the color gets messed up +.module-message__container--incoming { + background-color: $color-conversation-grey; +} + +.module-message__container--incoming-grey { + background-color: $color-conversation-grey; +} +.module-message__container--incoming-blue { + background-color: $color-conversation-blue; +} +.module-message__container--incoming-cyan { + background-color: $color-conversation-cyan; +} +.module-message__container--incoming-deep_orange { + background-color: $color-conversation-deep_orange; +} +.module-message__container--incoming-green { + background-color: $color-conversation-green; +} +.module-message__container--incoming-indigo { + background-color: $color-conversation-indigo; +} +.module-message__container--incoming-pink { + background-color: $color-conversation-pink; +} +.module-message__container--incoming-purple { + background-color: $color-conversation-purple; +} +.module-message__container--incoming-red { + background-color: $color-conversation-red; +} +.module-message__container--incoming-teal { + background-color: $color-conversation-teal; } .module-message__attachment-container { // Entirely to ensure that images are centered if they aren't full width of bubble text-align: center; position: relative; + cursor: pointer; } .module-message__img-attachment { - max-width: 370px; + max-width: 386px; margin-left: -12px; margin-right: -12px; margin-top: -10px; @@ -63,13 +195,16 @@ $color-conversation-teal: #00796b; // bottom, so this doesn't match up with the padding numbers above. margin-bottom: -13px; border-radius: 16px; - box-shadow: 0 4px 20px 0 rgba(0, 0, 0, 0.2), 0 0 4px 0 rgba(0, 0, 0, 0.08); + border: 1px solid $color-black-02; + background-color: $color-white; object-fit: cover; width: calc(100% + 24px); min-width: 200px; min-height: 150px; max-height: 300px; + + // redundant with attachment-container, but we get cursor flashing on move otherwise cursor: pointer; } @@ -117,6 +252,61 @@ $color-conversation-teal: #00796b; border-top-right-radius: 0px; } +.module-message__broken-image { + font-size: 11px; + line-height: 16px; + letter-spacing: 0.3px; + + padding: 10px; + text-align: center; + text-transform: uppercase; + color: $color-light-90; +} + +.module-message__broken-image--incoming { + color: $color-white; +} + +.module-message__video-overlay__circle { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + + width: 48px; + height: 48px; + background-color: $color-white; + border-radius: 24px; +} + +.module-message__video-overlay__play-icon { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + + height: 36px; + width: 36px; + @include color-svg('../images/play.svg', $color-signal-blue); +} + +.module-message__broken-video-screenshot { + font-size: 11px; + line-height: 16px; + letter-spacing: 0.3px; + + padding: 10px; + text-align: center; + text-transform: uppercase; + color: $color-light-90; + + cursor: pointer; +} + +.module-message__broken-video-screenshot--incoming { + color: $color-white; +} + .module-message__audio-attachment { margin-top: 2px; } @@ -223,61 +413,19 @@ $color-conversation-teal: #00796b; } .module-message__author__profile-name { - color: $color-white-07; - // TODO: finalize this font - font-size: 11px; - line-height: 18px; -} - -.module-message--outgoing { - background-color: $color-light-10; - float: right; -} - -// In case the color gets messed up -.module-message--incoming { - background-color: $color-conversation-grey; -} - -.module-message--incoming-grey { - background-color: $color-conversation-grey; -} -.module-message--incoming-blue { - background-color: $color-conversation-blue; -} -.module-message--incoming-cyan { - background-color: $color-conversation-cyan; -} -.module-message--incoming-deep_orange { - background-color: $color-conversation-deep_orange; -} -.module-message--incoming-green { - background-color: $color-conversation-green; -} -.module-message--incoming-indigo { - background-color: $color-conversation-indigo; -} -.module-message--incoming-pink { - background-color: $color-conversation-pink; -} -.module-message--incoming-purple { - background-color: $color-conversation-purple; -} -.module-message--incoming-red { - background-color: $color-conversation-red; -} -.module-message--incoming-teal { - background-color: $color-conversation-teal; -} - -.module-message--with-image-only { - background-color: transparent; + font-style: italic; } .module-message__text { color: $color-light-90; font-size: 14px; line-height: 18px; + + overflow-wrap: break-word; + word-wrap: break-word; + word-break: break-word; + white-space: pre-wrap; + a { text-decoration: underline; color: $color-light-90; @@ -292,6 +440,10 @@ $color-conversation-teal: #00796b; } } +.module-message__text--error { + font-style: italic; +} + .module-message__metadata { display: flex; flex-direction: row; @@ -326,61 +478,6 @@ $color-conversation-teal: #00796b; color: $color-white; } -.module-message__metadata__timer { - width: 12px; - height: 12px; - display: inline-block; - margin-left: 6px; - margin-bottom: 2px; - @include color-svg('../images/timer-60.svg', $color-light-45); -} - -.module-message__metadata__timer--55 { - @include color-svg('../images/timer-55.svg', $color-light-45); -} -.module-message__metadata__timer--50 { - @include color-svg('../images/timer-50.svg', $color-light-45); -} -.module-message__metadata__timer--45 { - @include color-svg('../images/timer-45.svg', $color-light-45); -} -.module-message__metadata__timer--40 { - @include color-svg('../images/timer-40.svg', $color-light-45); -} -.module-message__metadata__timer--35 { - @include color-svg('../images/timer-35.svg', $color-light-45); -} -.module-message__metadata__timer--30 { - @include color-svg('../images/timer-30.svg', $color-light-45); -} -.module-message__metadata__timer--25 { - @include color-svg('../images/timer-25.svg', $color-light-45); -} -.module-message__metadata__timer--20 { - @include color-svg('../images/timer-20.svg', $color-light-45); -} -.module-message__metadata__timer--15 { - @include color-svg('../images/timer-15.svg', $color-light-45); -} -.module-message__metadata__timer--10 { - @include color-svg('../images/timer-10.svg', $color-light-45); -} -.module-message__metadata__timer--05 { - @include color-svg('../images/timer-05.svg', $color-light-45); -} -.module-message__metadata__timer--00 { - @include color-svg('../images/timer-00.svg', $color-light-45); -} - -.module-message__metadata__timer--incoming { - background-color: $color-white-07; -} - -// When status indicators are overlaid on top of an image, they use different colors -.module-message__metadata__timer--with-image-no-caption { - background-color: white; -} - .module-message__metadata__spacer { flex-grow: 1; } @@ -393,7 +490,7 @@ $color-conversation-teal: #00796b; margin-bottom: 2px; } -.module-message__metadata__status-icon-sending { +.module-message__metadata__status-icon--sending { @include color-svg('../images/sending.svg', $color-light-60); animation: module-message__metdata__status-icon--spinning 4s linear infinite; } @@ -405,56 +502,21 @@ $color-conversation-teal: #00796b; } } -.module-message__metadata__status-icon-sent { +.module-message__metadata__status-icon--sent { @include color-svg('../images/check-circle-outline.svg', $color-light-35); } -.module-message__metadata__status-icon-delivered { +.module-message__metadata__status-icon--delivered { @include color-svg('../images/double-check.svg', $color-light-35); width: 18px; } -.module-message__metadata__status-icon-read { - @include color-svg('../images/double-check.svg', $color-light-35); +.module-message__metadata__status-icon--read { + @include color-svg('../images/read.svg', $color-light-35); width: 18px; } -.module-message__metadata__status-icon-grey { - background-color: $color-conversation-grey; -} -.module-message__metadata__status-icon-blue { - background-color: $color-conversation-blue; -} -.module-message__metadata__status-icon-cyan { - background-color: $color-conversation-cyan; -} -.module-message__metadata__status-icon-deep_orange { - background-color: $color-conversation-deep_orange; -} -.module-message__metadata__status-icon-green { - background-color: $color-conversation-green; -} -.module-message__metadata__status-icon-indigo { - background-color: $color-conversation-indigo; -} -.module-message__metadata__status-icon-pink { - background-color: $color-conversation-pink; -} -.module-message__metadata__status-icon-purple { - background-color: $color-conversation-purple; -} -.module-message__metadata__status-icon-red { - background-color: $color-conversation-red; -} -.module-message__metadata__status-icon-teal { - background-color: $color-conversation-teal; -} - // When status indicators are overlaid on top of an image, they use different colors .module-message__metadata__status-icon--with-image-no-caption { - background-color: white; -} - -.module-message__metadata__status-icon--read-with-image-no-caption { - background-color: $color-signal-blue; + background-color: $color-white; } .module-message__send-message-button { @@ -499,7 +561,6 @@ $color-conversation-teal: #00796b; height: 36px; width: 36px; - background-color: $color-conversation-grey; border-radius: 18px; display: flex; @@ -508,6 +569,37 @@ $color-conversation-teal: #00796b; text-align: center; } +.module-message__author-default-avatar--grey { + background-color: $color-conversation-grey; +} +.module-message__author-default-avatar--blue { + background-color: $color-conversation-blue; +} +.module-message__author-default-avatar--cyan { + background-color: $color-conversation-cyan; +} +.module-message__author-default-avatar--deep_orange { + background-color: $color-conversation-deep_orange; +} +.module-message__author-default-avatar--green { + background-color: $color-conversation-green; +} +.module-message__author-default-avatar--indigo { + background-color: $color-conversation-indigo; +} +.module-message__author-default-avatar--pink { + background-color: $color-conversation-pink; +} +.module-message__author-default-avatar--purple { + background-color: $color-conversation-purple; +} +.module-message__author-default-avatar--red { + background-color: $color-conversation-red; +} +.module-message__author-default-avatar--teal { + background-color: $color-conversation-teal; +} + .module-message__author-default-avatar__label { width: 100%; font-size: 18px; @@ -517,9 +609,67 @@ $color-conversation-teal: #00796b; padding-right: 1px; } +// Module: Expire Timer + +.module-expire-timer { + width: 12px; + height: 12px; + display: inline-block; + margin-left: 6px; + margin-bottom: 2px; + @include color-svg('../images/timer-60.svg', $color-light-45); +} + +.module-expire-timer--55 { + @include color-svg('../images/timer-55.svg', $color-light-45); +} +.module-expire-timer--50 { + @include color-svg('../images/timer-50.svg', $color-light-45); +} +.module-expire-timer--45 { + @include color-svg('../images/timer-45.svg', $color-light-45); +} +.module-expire-timer--40 { + @include color-svg('../images/timer-40.svg', $color-light-45); +} +.module-expire-timer--35 { + @include color-svg('../images/timer-35.svg', $color-light-45); +} +.module-expire-timer--30 { + @include color-svg('../images/timer-30.svg', $color-light-45); +} +.module-expire-timer--25 { + @include color-svg('../images/timer-25.svg', $color-light-45); +} +.module-expire-timer--20 { + @include color-svg('../images/timer-20.svg', $color-light-45); +} +.module-expire-timer--15 { + @include color-svg('../images/timer-15.svg', $color-light-45); +} +.module-expire-timer--10 { + @include color-svg('../images/timer-10.svg', $color-light-45); +} +.module-expire-timer--05 { + @include color-svg('../images/timer-05.svg', $color-light-45); +} +.module-expire-timer--00 { + @include color-svg('../images/timer-00.svg', $color-light-45); +} + +.module-expire-timer--incoming { + background-color: $color-white-07; +} + +// When status indicators are overlaid on top of an image, they use different colors +.module-expire-timer--with-image-no-caption { + background-color: $color-white; +} + // Module: Quoted Reply .module-quote { + position: relative; border-radius: 4px; border-top-left-radius: 10px; border-top-right-radius: 10px; @@ -554,6 +704,10 @@ $color-conversation-teal: #00796b; border-left-color: $color-white; } +.module-quote--outgoing-you { + border-left-color: $color-light-35; + background-color: $color-light-02; +} .module-quote--outgoing-grey { border-left-color: $color-conversation-grey; background-color: rgba($color-conversation-grey, 0.25); @@ -601,6 +755,8 @@ $color-conversation-teal: #00796b; padding-right: 8px; padding-top: 7px; padding-bottom: 7px; + + max-width: 100%; } .module-quote__primary__author { @@ -608,10 +764,45 @@ $color-conversation-teal: #00796b; line-height: 18px; font-weight: 300; color: $color-light-90; + + overflow-x: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.module-quote__primary__author--grey { + color: $color-conversation-grey; +} +.module-quote__primary__author--blue { + color: $color-conversation-blue; +} +.module-quote__primary__author--cyan { + color: $color-conversation-cyan; +} +.module-quote__primary__author--deep_orange { + color: $color-conversation-deep_orange; +} +.module-quote__primary__author--green { + color: $color-conversation-green; +} +.module-quote__primary__author--indigo { + color: $color-conversation-indigo; +} +.module-quote__primary__author--pink { + color: $color-conversation-pink; +} +.module-quote__primary__author--purple { + color: $color-conversation-purple; +} +.module-quote__primary__author--red { + color: $color-conversation-red; +} +.module-quote__primary__author--teal { + color: $color-conversation-teal; } .module-quote__primary__profile-name { - font-size: smaller; + font-style: italic; } .module-quote__primary__text { @@ -619,7 +810,11 @@ $color-conversation-teal: #00796b; line-height: 18px; color: $color-light-90; + overflow-wrap: break-word; + word-wrap: break-word; + word-break: break-word; white-space: pre-wrap; + overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; @@ -657,6 +852,7 @@ $color-conversation-teal: #00796b; .module-quote__close-button { width: 100%; height: 100%; + cursor: pointer; @include color-svg('../images/x.svg', $grey); } @@ -694,7 +890,7 @@ $color-conversation-teal: #00796b; height: 32px; width: 32px; border-radius: 50%; - background-color: white; + background-color: $color-white; } .module-quote__icon-container__icon { @@ -737,6 +933,11 @@ $color-conversation-teal: #00796b; font-size: 14px; line-height: 18px; color: $color-light-90; + + max-width: calc(100% - 26px); + overflow-x: hidden; + white-space: nowrap; + text-overflow: ellipsis; } // Module: Embedded Contact @@ -782,8 +983,8 @@ $color-conversation-teal: #00796b; border-radius: 50%; width: 100%; height: 100%; - background-color: gray; - color: white; + background-color: $color-conversation-grey; + color: $color-white; font-size: 25px; line-height: 52px; } @@ -859,8 +1060,8 @@ $color-conversation-teal: #00796b; border-radius: 50%; width: 100%; height: 100%; - background-color: gray; - color: white; + background-color: $color-conversation-grey; + color: $color-white; font-size: 50px; line-height: 82px; } @@ -884,7 +1085,7 @@ $color-conversation-teal: #00796b; padding: 6px; margin-top: 20px; - color: white; + color: $color-white; flex-direction: column; align-items: center; @@ -920,36 +1121,162 @@ $color-conversation-teal: #00796b; margin-bottom: 3px; } -// Module: Notification +// Module: Group Notification -.module-notification { - font-size: 13px; - line-height: 18px; +.module-group-notification { + margin-top: 14px; + font-size: 14px; + line-height: 20px; + letter-spacing: 0.3px; color: $color-light-60; text-align: center; } +.module-group-notification__change { + margin-top: 10px; +} + +.module-group-notification__contact { + font-weight: 300; +} + +// Module: Reset Session Notification + +.module-reset-session-notification { + margin-top: 14px; + font-size: 14px; + line-height: 20px; + letter-spacing: 0.3px; + color: $color-light-60; + text-align: center; +} + +// Module: Safety Number Notification + +.module-safety-number-notification { + margin-top: 14px; + text-align: center; +} + +.module-safety-number-notification__icon { + height: 24px; + width: 24px; + margin-left: auto; + margin-right: auto; + margin-bottom: 7px; + @include color-svg('../images/shield.svg', $color-light-60); +} + +.module-safety-number-notification__text { + font-size: 14px; + line-height: 20px; + letter-spacing: 0.3px; + color: $color-light-60; +} + +.module-safety-number-notification__contact { + font-weight: 300; +} + +.module-verification-notification__button { + margin-top: 5px; + display: inline-block; + cursor: pointer; + font-size: 13px; + font-weight: 300; + line-height: 18px; + padding: 12px; + color: $color-signal-blue; + background-color: $color-light-02; + border-radius: 4px; +} + +// Module: Verification Notification + +.module-verification-notification { + margin-top: 14px; + font-size: 14px; + line-height: 20px; + letter-spacing: 0.3px; + color: $color-light-60; + text-align: center; +} + +.module-verification-notification__contact { + font-weight: 300; +} + +.module-verification-notification__icon--mark-verified { + height: 24px; + width: 24px; + margin-left: auto; + margin-right: auto; + margin-bottom: 4px; + @include color-svg('../images/verified-check.svg', $color-light-60); +} + +.module-verification-notification__icon--mark-not-verified { + height: 24px; + width: 24px; + margin-left: auto; + margin-right: auto; + margin-bottom: 7px; + @include color-svg('../images/shield.svg', $color-light-60); +} + +// Module: Timer Notification + +.module-timer-notification { + margin-top: 14px; + font-size: 14px; + line-height: 20px; + letter-spacing: 0.3px; + color: $color-light-60; + text-align: center; +} + +.module-timer-notification__icon-container { + margin-left: auto; + margin-right: auto; + display: inline-flex; + flex-direction: row; + align-items: center; + margin-bottom: 4px; +} + +.module-timer-notification__icon { + height: 20px; + width: 20px; + display: inline-block; + @include color-svg('../images/timer.svg', $color-light-60); +} + +.module-timer-notification__icon-label { + font-size: 11px; + line-height: 16px; + letter-spacing: 0.3px; + margin-left: 6px; + text-transform: uppercase; + + // Didn't seem centered otherwise + margin-top: 1px; +} + +.module-timer-notification__message { + font-size: 14px; + line-height: 20px; + letter-spacing: 0.3px; +} + .module-notification--with-click-handler { cursor: pointer; } .module-notification__icon { - height: 1.25em; - width: 1.25em; - vertical-align: text-bottom; - display: inline-block; -} - -.module-notification__icon--verified { - @include color-svg('../images/verified-check.svg', $color-light-60); -} - -.module-notification__icon--shield { - @include color-svg('../images/shield.svg', $color-light-60); -} - -.module-notification__icon--clock { - @include color-svg('../images/clock.svg', $color-light-60); + height: 24px; + width: 24px; + margin-left: auto; + margin-right: auto; } // Module: Contact List Item @@ -1036,8 +1363,7 @@ $color-conversation-teal: #00796b; } .module-contact-list-item__text__profile-name { - font-size: 12px; - font-weight: normal; + font-style: italic; } .module-contact-list-item__text__additional-data { @@ -1055,3 +1381,378 @@ $color-conversation-teal: #00796b; // Trying to account for the whitespace around the check mark margin-bottom: -1px; } + +// Module: Conversation Header + +.module-conversation-header { + padding-left: 16px; + padding-right: 16px; + display: flex; + flex-direction: row; + align-items: center; + + height: $header-height; + color: $color-light-90; + + background-color: $color-white; + border-bottom: 1px solid $color-black-02; +} + +.module-conversation-header__back-icon { + @include color-svg('../images/back.svg', $color-light-90); + display: inline-block; + margin-left: -10px; + margin-right: -2px; + width: 35px; + height: 35px; + min-width: 35px; + vertical-align: text-bottom; + cursor: pointer; +} + +.module-conversation-header___avatar { + height: 32px; + width: 32px; + min-width: 32px; + border-radius: 16px; +} + +.module-conversation-header___default-avatar { + background-color: $color-conversation-grey; + + line-height: 32px; + font-size: 16px; + color: $color-white; + text-align: center; +} + +.module-conversation-header___default-avatar--blue { + background-color: $color-conversation-blue; +} +.module-conversation-header___default-avatar--cyan { + background-color: $color-conversation-cyan; +} +.module-conversation-header___default-avatar--deep_orange { + background-color: $color-conversation-deep_orange; +} +.module-conversation-header___default-avatar--green { + background-color: $color-conversation-green; +} +.module-conversation-header___default-avatar--indigo { + background-color: $color-conversation-indigo; +} +.module-conversation-header___default-avatar--pink { + background-color: $color-conversation-pink; +} +.module-conversation-header___default-avatar--purple { + background-color: $color-conversation-purple; +} +.module-conversation-header___default-avatar--red { + background-color: $color-conversation-red; +} +.module-conversation-header___default-avatar--teal { + background-color: $color-conversation-teal; +} + +.module-conversation-header__title { + flex-grow: 1; + display: block; + + margin-left: 8px; + + font-size: 16px; + line-height: 24px; + font-weight: 300; + color: $color-light-90; + + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + -webkit-user-select: text; +} + +.module-conversation-header__title__profile-name { + font-style: italic; +} + +.module-conversation-header__title__verified-icon { + @include color-svg('../images/verified-check.svg', $color-light-90); + display: inline-block; + width: 1.25em; + height: 1.25em; + vertical-align: text-bottom; +} + +.module-conversation-header__expiration { + display: flex; + flex-direction: row; + align-items: center; + padding-left: 8px; + padding-right: 8px; +} + +.module-conversation-header__expiration__clock-icon { + @include color-svg('../images/timer.svg', $color-light-60); + height: 20px; + width: 20px; + display: inline-block; +} + +.module-conversation-header__expiration__setting { + margin-left: 5px; +} + +.module-conversation-header__gear-icon { + @include color-svg('../images/gear.svg', $color-light-60); + height: 20px; + width: 20px; + padding-left: 4px; + cursor: pointer; +} + +// Module: Message Detail + +.module-message-detail { + max-width: 650px; + margin-left: auto; + margin-right: auto; + padding: 20px; +} + +.module-message-detail__message-container { + padding-top: 20px; + padding-bottom: 20px; + + &:after { + content: '.'; + visibility: hidden; + display: block; + height: 0; + clear: both; + } +} + +.module-message-detail__label { + font-weight: 300; + padding-right: 5px; +} + +.module-message-detail__unix-timestamp { + color: $color-light-10; +} + +.module-message-detail__delete-button-container { + text-align: center; + margin-top: 10px; +} + +.module-message-detail__delete-button { + @include button-reset; + + background-color: $color-core-red; + color: $color-white; + box-shadow: 0 0 10px -3px rgba(97, 97, 97, 0.7); + border-radius: 5px; + border: solid 1px $color-light-35; + cursor: pointer; + margin: 1em auto; + padding: 1em; +} + +.module-message-detail__contact-container { + margin: 20px; +} + +.module-message-detail__contact { + margin-bottom: 8px; + display: flex; + flex-direction: row; + align-items: center; +} + +.module-message-detail__contact__avatar { + height: 44px; + width: 44px; + min-width: 44px; + border-radius: 22px; +} + +.module-message-detail__contact__default-avatar { + background-color: $color-conversation-grey; + + line-height: 44px; + font-size: 20px; + color: $color-white; + text-align: center; +} + +.module-message-detail__contact__default-avatar--blue { + background-color: $color-conversation-blue; +} +.module-message-detail__contact__default-avatar--cyan { + background-color: $color-conversation-cyan; +} +.module-message-detail__contact__default-avatar--deep_orange { + background-color: $color-conversation-deep_orange; +} +.module-message-detail__contact__default-avatar--green { + background-color: $color-conversation-green; +} +.module-message-detail__contact__default-avatar--indigo { + background-color: $color-conversation-indigo; +} +.module-message-detail__contact__default-avatar--pink { + background-color: $color-conversation-pink; +} +.module-message-detail__contact__default-avatar--purple { + background-color: $color-conversation-purple; +} +.module-message-detail__contact__default-avatar--red { + background-color: $color-conversation-red; +} +.module-message-detail__contact__default-avatar--teal { + background-color: $color-conversation-teal; +} + +.module-message-detail__contact__text { + margin-left: 10px; + flex-grow: 1; +} + +.module-message-detail__contact__error { + color: $color-core-red; + font-weight: 300; +} + +.module-message-detail__contact__status-icon { + width: 12px; + height: 12px; + display: inline-block; + margin-left: 6px; + margin-bottom: 2px; +} + +.module-message-detail__contact__status-icon--sending { + @include color-svg('../images/sending.svg', $color-light-60); + animation: module-message-detail__contact__status-icon--spinning 4s linear + infinite; +} + +@keyframes module-message-detail__contact__status-icon--spinning { + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} + +.module-message-detail__contact__status-icon--sent { + @include color-svg('../images/check-circle-outline.svg', $color-light-35); +} +.module-message-detail__contact__status-icon--delivered { + @include color-svg('../images/double-check.svg', $color-light-35); + width: 18px; +} +.module-message-detail__contact__status-icon--read { + @include color-svg('../images/read.svg', $color-light-35); + width: 18px; +} +.module-message-detail__contact__status-icon--error { + @include color-svg('../images/error.svg', $color-core-red); +} + +.module-message-detail__contact__error-buttons { + text-align: right; +} + +.module-message-detail__contact__show-safety-number { + @include button-reset; + padding: 4px; + color: $color-white; + background-color: $color-light-35; + border-radius: 4px; +} +.module-message-detail__contact__send-anyway { + @include button-reset; + color: $color-white; + background-color: $color-core-red; + margin-left: 5px; + margin-top: 5px; + padding: 4px; + border-radius: 4px; +} + +// Third-party module: react-contextmenu + +.react-contextmenu { + background-color: $color-light-02; + border-radius: 4px; + min-width: 160px; + padding: 0px; + padding-top: 8px; + padding-bottom: 8px; + border: 1px solid $color-dark-05; + opacity: 0; +} + +.react-contextmenu--visible { + z-index: 1000; + opacity: 1; +} + +.react-contextmenu-item { + color: $color-light-90; + + cursor: pointer; + font-size: 13px; + line-height: 18px; + white-space: nowrap; + + padding-left: 16px; + padding-top: 3px; + padding-bottom: 2px; + padding-right: 16px; +} + +.react-contextmenu-item--checked:before { + content: '✓'; + display: inline-block; + position: absolute; + right: 7px; + color: $color-light-90; +} + +.react-contextmenu-item.react-contextmenu-submenu { + padding: 0; +} + +.react-contextmenu-item.react-contextmenu-submenu > .react-contextmenu-item { + padding-right: 36px; +} + +.react-contextmenu-item.react-contextmenu-submenu + > .react-contextmenu-item:after { + content: '\25B6'; + display: inline-block; + position: absolute; + right: 7px; + color: $color-light-90; +} + +.react-contextmenu-item.react-contextmenu-item--active, +.react-contextmenu-item.react-contextmenu-item--selected { + color: $color-white; + background-color: $color-light-35; +} + +.react-contextmenu-item.react-contextmenu-item--active.react-contextmenu-item--checked:before, +.react-contextmenu-item.react-contextmenu-item--selected.react-contextmenu-item--checked:before { + color: $color-white; +} + +.react-contextmenu-item.react-contextmenu-submenu + > .react-contextmenu-item.react-contextmenu-item--active:after, +.react-contextmenu-item.react-contextmenu-submenu + > .react-contextmenu-item.react-contextmenu-item--selected:after { + color: $color-white; +} diff --git a/stylesheets/_recorder.scss b/stylesheets/_recorder.scss index 39ba79756..6c1e36065 100644 --- a/stylesheets/_recorder.scss +++ b/stylesheets/_recorder.scss @@ -25,7 +25,7 @@ } } .recorder { - background: $grey_l; + background: $color-white; button { float: right; @@ -50,20 +50,20 @@ } .finish { - background: lighten($green, 20%); - border: 1px solid $green; + background: lighten($color-core-green, 20%); + border: 1px solid $color-core-green; .icon { - @include color-svg('../images/check.svg', $green); + @include color-svg('../images/check.svg', $color-core-green); } } .close { - background: lighten($red, 20%); - border: 1px solid $red; + background: lighten($color-core-red, 20%); + border: 1px solid $color-core-red; .icon { - @include color-svg('../images/x.svg', $red); + @include color-svg('../images/x.svg', $color-core-red); } } diff --git a/stylesheets/_theme_dark.scss b/stylesheets/_theme_dark.scss index 4e2a462c2..cd2ebb274 100644 --- a/stylesheets/_theme_dark.scss +++ b/stylesheets/_theme_dark.scss @@ -1,409 +1,1370 @@ -$grey-dark: #333333; -$grey-dark_l2: darken($grey-dark, 4%); -$grey-dark_l3: darken($grey-dark_l2, 7%); -$grey-dark_l4: darken($grey-dark_l3, 8%); -$button-dark: #ccc; -$text-dark: #cccccc; -$text-dark_l2: darken($text-dark, 30%); +// Don't forget to handle the background of the popup windows! body.dark-theme { - background-color: $grey-dark; + background-color: $color-black; + color: $color-dark-05; } .dark-theme { - .app-loading-screen { - background-color: $grey-dark; + // _conversation + + .conversation { + background-color: $color-black; } - .gutter .content { - background-color: $grey-dark; - } - color: $text-dark; - a { - color: #57a5e5; - } - hr { - border-color: $grey-dark; - } - .expiredAlert { - color: $grey-dark; - button { - color: $grey-dark; - } - } - #header { - background-color: $grey-dark_l2; - color: white; - transition: background-color 0.5s; - &.inactive { - background-color: $grey-dark; - color: $text-dark; - } - } - .confirmation-dialog .content .buttons button { - background-color: $button-dark; - border: 1px solid $grey-dark_l2; - &:hover { - background-color: darken($button-dark, 8%); - } - } - .message-detail, - .message-container, - .conversation, + .discussion-container { - background-color: $grey-dark_l3; - } - .modal .content { - background-color: $grey-dark; - } - .lightbox .content { - background-color: rgba(0, 0, 0, 0); - } - .key-verification .key { - background-color: $grey-dark_l4; - border-color: $grey-dark_l2; - } - .key-verification .icon { - &.verified { - @include color-svg('../images/verified-check.svg', $text-dark); - } - &.shield { - @include color-svg('../images/shield.svg', $text-dark); - } - } - .menu-list { - background-color: $grey-dark_l2; - color: $text-dark; - li:hover { - background-color: $grey-dark; - } - } - .content textarea { - background-color: $grey-dark_l3; - border-width: 0px; - @include invert-text-color; - } - .flex { - background-color: $grey-dark_l3; - .send-message { - background-color: $grey-dark_l3; - color: $text-dark; - } - } - .contact-details .name { - font-weight: 400; - } - .contact-details .number { - color: $text-dark_l2; - .verified-icon { - @include color-svg('../images/verified-check.svg', $text-dark_l2); - } - } - .group-member-list .members .contact, - .new-group-update .members .contact, - .attachment-previews img { - background-color: $grey-dark_l3; - border-color: $grey-dark; - } - .conversation.placeholder .conversation-header { - display: none; - } - .conversation .conversation-loading-screen { - background-color: $grey-dark_l3; - } - .avatar, - .conversation-header, - .bubble { - @include dark-avatar-colors; - } - .message-list .advisory { - .content { - background-color: $grey-dark; - } - .shield { - @include color-svg('../images/shield.svg', $text-dark); - } - .verified { - @include color-svg('../images/verified-check.svg', $text-dark); - } - .clock { - @include color-svg('../images/clock.svg', $text-dark); - } + background-color: $color-black; } - .inactive .conversation-header { - background-color: $grey-dark !important; - color: $text-dark; - .verified-icon { - @include color-svg('../images/verified-check.svg', $text-dark); + .key-verification { + .key { + color: $color-dark-05; + background: $color-dark-85; + border: solid 1px $color-dark-60; + border-radius: $border-radius; } - } - .sent span.status { - display: inline-block; - @include color-svg('../images/check.svg', white); - } - .delivered span.status { - display: inline-block; - @include color-svg('../images/double-check.svg', white); - } - .read span.status { - display: inline-block; - @include color-svg('../images/double-check.svg', $blue); - } - .file-input .paperclip:before { - content: ''; - display: inline-block; - width: $button-height; - height: $button-height; - @include color-svg('../images/paperclip.svg', white); - transform: rotateZ(-45deg); - } - .capture-audio .microphone:before { - @include color-svg('../images/microphone.svg', white); - } - .conversations { - background-color: $grey-dark_l2; - .conversation-list-item { - background-color: $grey-dark_l3; - color: $text-dark; - } - } - .bottom-bar { - min-height: 10px; - background-color: $grey-dark_l2; - form.send { - background: $grey-dark_l3; - } - } - .search { - background-color: $grey-dark_l3; - border-color: $grey-dark_l2; - @include invert-text-color; - &.active.ltr, - &.active.rtl { - background-image: url('../images/x_white.svg'); - } - } - .bubble { - padding: 9px 12px; - border-radius: $border-radius; - box-shadow: 0 3px 3px -4px black; - } - .outgoing .bubble { - background-color: $grey-dark; - @include invert-text-color; - color: $text-dark; - } - .outgoing .hourglass { - @include hourglass(#999); - } - .incoming .hourglass { - @include hourglass(#fff); - } - - .incoming .bubble { - .sender, - .content, - .body, - .meta, - a, - .fileView { - @include invert-text-color; - } - .content { - a { - color: $grey_l; + .icon { + &.verified { + @include color-svg('../images/verified-check.svg', $color-dark-05); + } + &.shield { + @include color-svg('../images/shield.svg', $color-dark-05); } } } - .incoming .bubble .fileView .icon { - @include color-svg('../images/file.svg', white); - &.audio { - @include color-svg('../images/audio.svg', white); + .bottom-bar { + form.active { + textarea { + border: solid 1px $blue; + } } - &.video { - @include color-svg('../images/video.svg', white); + + form.send { + background-color: $color-black; + + &.video-attachment { + .outer { + .play.icon { + @include color-svg('../images/play.svg', white); + } + } + } } - &.voice { - @include color-svg('../images/voice.svg', white); + + .send-message { + background-color: $color-dark-85; + color: $color-dark-05; + border: 1px solid $color-light-60; + outline: 0; } } - .outgoing .bubble .fileView .icon { - @include color-svg('../images/file.svg', #cccccc); - &.audio { - @include color-svg('../images/audio.svg', #cccccc); - } - &.video { - @include color-svg('../images/video.svg', #cccccc); - } - &.voice { - @include color-svg('../images/voice.svg', #cccccc); + .toast { + background: rgba(0, 0, 0, 0.75); + color: $color-white; + box-shadow: 0 0 5px 0 black; + } + + .confirmation-dialog { + .content { + background: $color-black; + color: $color-dark-05; + + .buttons { + button { + background-color: $color-dark-85; + border-radius: $border-radius; + border: 1px solid $color-dark-60; + color: $color-dark-05; + + &:hover { + background-color: $color-dark-70; + border-color: $color-dark-55; + } + } + } } } - .embedded-contact { - .first-line { - .image-container { - .default-avatar { - background-color: gray; - color: white; + .conversation-loading-screen { + background-color: $color-black; + } + + .module-last-seen-indicator__bar { + background-color: $color-dark-30; + } + + .module-last-seen-indicator__text { + color: $color-dark-30; + } + + .module-scroll-down__button { + background-color: $color-light-35; + box-shadow: 0px 3px 5px 0px rgba(0, 0, 0, 0.2); + + &:hover { + background-color: $color-light-45; + } + } + + .module-scroll-down__button--new-messages { + background-color: $color-signal-blue; + + &:hover { + background-color: #1472bd; + } + } + + .module-scroll-down__icon { + @include color-svg('../images/down.svg', $color-white); + } + + // _debugLog + + .debug-log { + &.modal { + .content { + textarea { + background-color: $color-dark-85; + border: 1px solid $color-dark-60; + color: $color-dark-05; + } + } + } + + .result { + .open { + border: solid 1px $color-dark-60; + background-color: $color-dark-85; + color: $color-dark-05; + + &:before { + @include header-icon-white('../images/open_link.svg'); } } - .text-container .contact-name { - color: $blue; - } - } - - .send-message { - color: $blue; - border-top: 1px solid $grey; - border-bottom: 1px solid $grey; - - .bubble-icon { - @include color-svg('../images/chat-bubble.svg', $blue); + .link { + color: $color-dark-05; + border: solid 1px $color-dark-60; + border-right: none; + background-color: $color-dark-85; } } } - .incoming .embedded-contact { - color: white; + // _emoji - .text-container .contact-name { - color: white; - } + .ep-emojies { + background-color: $color-black; + } - .send-message { - color: white; - // Note: would like to use transparency here, but Chromium in Electron doesn't - // render the borders when they are transparent. - border-top: 1px solid $grey_l1_5; - border-bottom: 1px solid $grey_l1_5; + .ep-categories { + background-color: $color-dark-85; + } - .bubble-icon { - background-color: white; - } + button.emoji { + &:before { + @include color-svg('../images/smile.svg', $color-dark-30); } } - .contact-detail { - .additional-contact .type { - color: rgba(255, 255, 255, 0.5); - } + // _global + + .dark-overlay { + background-color: $color-black; } - .outgoing .quoted-message { - background: rgba(255, 255, 255, 0.38); - - .icon-container .icon { - background-color: black; - &.play.with-image { - background-color: $text-dark; - } - } - } - .incoming .quoted-message { - border-left-color: $text-dark; - background-color: rgba(0, 0, 0, 0.6); - - .icon-container { - .circle-background { - background-color: $text-dark; - } - .icon.play.with-image { - background-color: $text-dark; - } - } + .title-bar { + color: $color-dark-05; } - button.clock { - @include header-icon-white('../images/clock.svg'); - } - button.hamburger { - @include header-icon-white('../images/menu.svg'); - } - button.back { - @include header-icon-white('../images/back.svg'); + .logo { + color: $color-dark-05; } - ::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.15); + button.grey { + border: solid 1px #ccc; + color: $grey; + background: $grey_l; + box-shadow: 0 0 10px -5px rgba($grey, 0.5); &:hover { - background: rgba(255, 255, 255, 0.25); + box-shadow: 0 0 10px -3px rgba($grey, 0.7); } } - ::-webkit-scrollbar-track { - background-color: transparent; + + a { + color: $blue; + } + + .file-input { + .paperclip { + &:before { + @include color-svg('../images/paperclip.svg', $color-dark-30); + } + } + } + + .dropoff { + outline: solid 1px $blue; + } + + .avatar { + color: $color-white; + background-color: $grey; + } + + .group-info-input { + background: $color-white; + + .thumbnail:after { + border-bottom: 10px solid $grey; + border-left: 10px solid transparent; + } + + input.name { + border: solid 1px #ccc; + } + } + + .group-member-list, + .new-group-update { + .members .contact { + border-bottom: 1px solid $color-dark-60; + } + } + + .conversation-list-item { + color: $color-dark-05; + + &:hover { + background: $color-dark-70; + } + + .unread-count { + background-color: $blue; + color: white; + border: solid 1px rgba(255, 255, 255, 0.6); + } + } + + .banner { + // what's the right color? + background-color: $blue_l; + color: black; + box-shadow: 0px 3px 5px 0px rgba(0, 0, 0, 0.2); + + .warning { + @include color-svg('../images/warning.svg', black); + } + + .dismiss { + @include color-svg('../images/x.svg', black); + } + } + + .contact-details { + .number { + color: $grey; + } + + .verified-icon { + @include color-svg('../images/verified-check.svg', $grey); + } + } + + .recipients-input { + .recipients-container { + background-color: white; + border-bottom: 1px solid #f2f2f2; + } + + .recipient { + background-color: $blue; + color: white; + + &.error { + background-color: #f00; + } + } + + .results { + box-shadow: 0px 0px 1px rgba(#aaa, 0.8); + } + } + + .loading { + position: relative; + &::before { + border: solid 3px; + border-color: $blue_l $blue_l $grey_l $grey_l !important; + } + } + + .x { + &:before { + @include color-svg('../images/x.svg', white); + } + } + + input[type='text'], + input[type='search'], + textarea { + &:active, + &:focus { + outline: 1px solid $blue; + } + } + + .expiredAlert { + background: #f3f3a7; + + button { + color: white; + background: $blue; + } + } + + .app-loading-screen { + background-color: $color-black; + + .dot { + border: 3px solid $blue; + } + } + + .full-screen-flow { + color: black; + a { + color: $blue; + } + background: linear-gradient( + to bottom, + /* (1 - 0.41) * 255 + 0.41 * 213*/ rgb(238, 238, 238) 0%, + /* (1 - 0.19) * 255 + 0.19 * 191*/ rgb(243, 243, 243) 12%, + rgb(255, 255, 255) 27%, + rgb(255, 255, 255) 60%, + /* (1 - 0.19) * 255 + 0.19 * 222*/ rgb(249, 249, 249) 85%, + /* (1 - 0.27) * 255 + 0.27 * 98 */ rgb(213, 213, 213) 100% + ); + input { + border: 2px solid $blue; + } + + #qr { + &.ready { + border: 5px solid $blue; + box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5); + } + + img { + border: 5px solid white; + } + + .dot { + border: 3px solid $blue; + } + } + + .os-icon { + &.apple { + @include color-svg('../images/apple.svg', black); + } + &.android { + @include color-svg('../images/android.svg', black); + } + } + + .banner-icon { + // generic + &.check-circle-outline { + @include color-svg('../images/check-circle-outline.svg', #dedede); + } + &.alert-outline { + @include color-svg('../images/alert-outline.svg', #dedede); + } + + // import and export + &.folder-outline { + @include color-svg('../images/folder-outline.svg', #dedede); + } + &.import { + @include color-svg('../images/import.svg', #dedede); + } + &.export { + @include color-svg('../images/export.svg', #dedede); + } + + // registration process + &.lead-pencil { + @include color-svg('../images/lead-pencil.svg', #dedede); + } + &.sync { + @include color-svg('../images/sync.svg', #dedede); + } + + // delete + &.alert-outline-red { + @include color-svg('../images/alert-outline.svg', red); + } + &.delete { + @include color-svg('../images/delete.svg', #dedede); + } + } + + .button { + color: white; + background: $blue; + box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5); + + &.neutral { + color: black; + background: #dedede; + } + &.destructive { + background: red; + } + } + a.link { + color: #2090ea; + } + + .progress { + .bar-container { + background-color: $grey_l; + } + .bar { + background-color: $blue_l; + } + } + } + + // _index + + .gutter { + background-color: $color-dark-85; + } + .network-status-container { + .network-status { + background: url('../images/error_red.svg') no-repeat left 10px center; + background-color: #fcd156; + + .action { + button { + border: solid 1px #ccc; + color: white; + background: $blue; + } + } + } + } + + .tool-bar { + color: $color-dark-05; + + .search-icon { + background-color: $color-light-35; + } + } + + input.search { + border: 1px solid $color-light-60; + color: $color-dark-05; + background-color: $color-black; + + &.active { + outline: solid 1px $blue; + } + } + + .last-timestamp { + color: $grey; + } + + .index { + .gutter .timestamp { + color: $grey; + } + } + + .hint { + color: white; + border: 2px dashed white; + + &.firstRun { + &:before, + &:after { + border: solid 10px white; + border-color: transparent white transparent transparent; + } + &:after { + border-color: transparent #2eace0 transparent transparent; + } + } + } + + .contact.placeholder { + color: white; + border: 2px dashed white; + p { + color: white; + } + &:before, + &:after { + border: solid 10px white; + border-color: transparent transparent white transparent; + } + &:after { + border-color: transparent transparent #2eace0 transparent; + } + } + + // _lightbox + + .iconButton { + background: transparent; + + &:hover { + background: $grey; + } + + &.save { + &:before { + @include color-svg('../images/save.svg', white); + } + } + + &.close { + &:before { + @include color-svg('../images/x.svg', white); + } + } + + &.previous { + &:before { + @include color-svg('../images/back.svg', white); + } + } + + &.next { + &:before { + @include color-svg('../images/forward.svg', white); + } + } + } + + // _modal + + .modal { + background-color: rgba(0, 0, 0, 0.3); + + .content { + background-color: $color-black; + box-shadow: 0px 3px 5px 0px rgba(0, 0, 0, 0.2); + } + } + + // _modules + + // Module: Message + + .module-message__buttons__download { + @include color-svg('../images/download.svg', $color-light-45); + &:hover { + @include color-svg('../images/download.svg', $color-dark-05); + } + } + + .module-message__buttons__reply { + @include color-svg('../images/reply.svg', $color-light-45); + &:hover { + @include color-svg('../images/reply.svg', $color-dark-05); + } + } + + .module-message__buttons__menu { + @include color-svg('../images/ellipsis.svg', $color-light-45); + &:hover { + @include color-svg('../images/ellipsis.svg', $color-dark-05); + } + } + + .module-message__error { + @include color-svg('../images/error.svg', $color-core-red); + } + + .module-message__container--outgoing { + background-color: $color-dark-70; + } + + // In case the color gets messed up + .module-message__container--incoming { + background-color: $color-conversation-grey; + } + + .module-message__container--incoming-grey { + background-color: $color-conversation-grey; + } + .module-message__container--incoming-blue { + background-color: $color-conversation-blue; + } + .module-message__container--incoming-cyan { + background-color: $color-conversation-cyan; + } + .module-message__container--incoming-deep_orange { + background-color: $color-conversation-deep_orange; + } + .module-message__container--incoming-green { + background-color: $color-conversation-green; + } + .module-message__container--incoming-indigo { + background-color: $color-conversation-indigo; + } + .module-message__container--incoming-pink { + background-color: $color-conversation-pink; + } + .module-message__container--incoming-purple { + background-color: $color-conversation-purple; + } + .module-message__container--incoming-red { + background-color: $color-conversation-red; + } + .module-message__container--incoming-teal { + background-color: $color-conversation-teal; + } + + .module-message__img-attachment { + border: 1px solid $color-white-02; + background-color: $color-black; + } + + .module-message__img-overlay { + background-image: linear-gradient( + to bottom, + rgba(0, 0, 0, 0), + rgba(0, 0, 0, 0) 9%, + rgba(0, 0, 0, 0.02) 17%, + rgba(0, 0, 0, 0.05) 24%, + rgba(0, 0, 0, 0.08) 31%, + rgba(0, 0, 0, 0.12) 37%, + rgba(0, 0, 0, 0.16) 44%, + rgba(0, 0, 0, 0.2) 50%, + rgba(0, 0, 0, 0.24) 56%, + rgba(0, 0, 0, 0.28) 63%, + rgba(0, 0, 0, 0.32) 69%, + rgba(0, 0, 0, 0.35) 76%, + rgba(0, 0, 0, 0.38) 83%, + rgba(0, 0, 0, 0.4) 91%, + rgba(0, 0, 0, 0.4) + ); + } + + .module-message__broken-image { + color: $color-dark-05; + } + + .module-message__broken-image--incoming { + color: $color-white; + } + + .module-message__video-overlay__circle { + background-color: $color-white; + } + + .module-message__video-overlay__play-icon { + @include color-svg('../images/play.svg', $color-signal-blue); + } + + .module-message__broken-video-screenshot { + color: $color-dark-05; + } + + .module-message__broken-video-screenshot--incoming { + color: $color-white; + } + + .module-message__generic-attachment__icon { + // TODO: this will eventually be a different image + background: url('../images/file-gradient.svg') no-repeat center; + } + + .module-message__generic-attachment__icon__extension { + // TODO: probably need color + } + + .module-message__generic-attachment__file-name { + color: $color-dark-05; + } + + .module-message__generic-attachment__file-name--incoming { + color: $color-white; + } + + .module-message__generic-attachment__file-size { + color: $color-dark-05; + } + + .module-message__generic-attachment__file-size--incoming { + color: $color-white; + } + + .module-message__author { + color: $color-white; + } + + .module-message__text { + color: $color-dark-05; + a { + color: $color-dark-05; + } + } + + .module-message__text--incoming { + color: $color-white; + a { + color: $color-white; + } + } + + .module-message__metadata__date { + color: $color-dark-30; + } + .module-message__metadata__date--incoming { + color: $color-white-07; + } + .module-message__metadata__date--with-image-no-caption { + color: $color-dark-05; + } + + .module-message__metadata__status-icon--sending { + @include color-svg('../images/sending.svg', $color-dark-30); + } + + .module-message__metadata__status-icon--sent { + @include color-svg('../images/check-circle-outline.svg', $color-light-35); + } + + .module-message__metadata__status-icon--delivered { + @include color-svg('../images/double-check.svg', $color-light-35); + } + + .module-message__metadata__status-icon--read { + @include color-svg('../images/read.svg', $color-light-35); + } + + // When status indicators are overlaid on top of an image, they use different colors + .module-message__metadata__status-icon--with-image-no-caption { + background-color: $color-black; + } + + .module-message__send-message-button { + color: $color-signal-blue; + background-color: $color-light-02; + border: 1px solid $color-black-012; + } + + .module-message__author-default-avatar--grey { + background-color: $color-conversation-grey; + } + .module-message__author-default-avatar--blue { + background-color: $color-conversation-blue; + } + .module-message__author-default-avatar--cyan { + background-color: $color-conversation-cyan; + } + .module-message__author-default-avatar--deep_orange { + background-color: $color-conversation-deep_orange; + } + .module-message__author-default-avatar--green { + background-color: $color-conversation-green; + } + .module-message__author-default-avatar--indigo { + background-color: $color-conversation-indigo; + } + .module-message__author-default-avatar--pink { + background-color: $color-conversation-pink; + } + .module-message__author-default-avatar--purple { + background-color: $color-conversation-purple; + } + .module-message__author-default-avatar--red { + background-color: $color-conversation-red; + } + .module-message__author-default-avatar--teal { + background-color: $color-conversation-teal; + } + + .module-message__author-default-avatar__label { + color: $color-white; + } + + // Module: Expire Timer + + .module-expire-timer { + @include color-svg('../images/timer-60.svg', $color-light-45); + } + + .module-expire-timer--55 { + @include color-svg('../images/timer-55.svg', $color-light-45); + } + .module-expire-timer--50 { + @include color-svg('../images/timer-50.svg', $color-light-45); + } + .module-expire-timer--45 { + @include color-svg('../images/timer-45.svg', $color-light-45); + } + .module-expire-timer--40 { + @include color-svg('../images/timer-40.svg', $color-light-45); + } + .module-expire-timer--35 { + @include color-svg('../images/timer-35.svg', $color-light-45); + } + .module-expire-timer--30 { + @include color-svg('../images/timer-30.svg', $color-light-45); + } + .module-expire-timer--25 { + @include color-svg('../images/timer-25.svg', $color-light-45); + } + .module-expire-timer--20 { + @include color-svg('../images/timer-20.svg', $color-light-45); + } + .module-expire-timer--15 { + @include color-svg('../images/timer-15.svg', $color-light-45); + } + .module-expire-timer--10 { + @include color-svg('../images/timer-10.svg', $color-light-45); + } + .module-expire-timer--05 { + @include color-svg('../images/timer-05.svg', $color-light-45); + } + .module-expire-timer--00 { + @include color-svg('../images/timer-00.svg', $color-light-45); + } + + .module-expire-timer--incoming { + background-color: $color-white-07; + } + + // When status indicators are overlaid on top of an image, they use different colors + .module-expire-timer--with-image-no-caption { + background-color: $color-black; + } + + // Module: Quoted Reply + + .module-quote--incoming { + background-color: $color-black-04; + border-left-color: $color-black; + } + + .module-quote--outgoing-you { + border-left-color: $color-light-35; + background-color: $color-dark-60; + } + .module-quote--outgoing-grey { + border-left-color: $color-conversation-grey; + background-color: rgba($color-conversation-grey, 0.6); + } + .module-quote--outgoing-blue { + border-left-color: $color-conversation-blue; + background-color: rgba($color-conversation-blue, 0.6); + } + .module-quote--outgoing-cyan { + border-left-color: $color-conversation-cyan; + background-color: rgba($color-conversation-cyan, 0.6); + } + .module-quote--outgoing-deep_orange { + border-left-color: $color-conversation-deep_orange; + background-color: rgba($color-conversation-deep_orange, 0.6); + } + .module-quote--outgoing-green { + border-left-color: $color-conversation-green; + background-color: rgba($color-conversation-green, 0.6); + } + .module-quote--outgoing-indigo { + border-left-color: $color-conversation-indigo; + background-color: rgba($color-conversation-indigo, 0.6); + } + .module-quote--outgoing-pink { + border-left-color: $color-conversation-pink; + background-color: rgba($color-conversation-pink, 0.6); + } + .module-quote--outgoing-purple { + border-left-color: $color-conversation-purple; + background-color: rgba($color-conversation-purple, 0.6); + } + .module-quote--outgoing-red { + border-left-color: $color-conversation-red; + background-color: rgba($color-conversation-red, 0.6); + } + .module-quote--outgoing-teal { + border-left-color: $color-conversation-teal; + background-color: rgba($color-conversation-teal, 0.6); + } + + .module-quote__primary__author { + color: $color-dark-05; + } + + .module-quote__primary__author--grey { + color: $color-dark-05; + } + .module-quote__primary__author--blue { + color: $color-dark-05; + } + .module-quote__primary__author--cyan { + color: $color-dark-05; + } + .module-quote__primary__author--deep_orange { + color: $color-dark-05; + } + .module-quote__primary__author--green { + color: $color-dark-05; + } + .module-quote__primary__author--indigo { + color: $color-dark-05; + } + .module-quote__primary__author--pink { + color: $color-dark-05; + } + .module-quote__primary__author--purple { + color: $color-dark-05; + } + .module-quote__primary__author--red { + color: $color-dark-05; + } + .module-quote__primary__author--teal { + color: $color-dark-05; + } + + .module-quote__primary__text { + color: $color-dark-05; + } + + .module-quote__primary__type-label { + color: $color-dark-05; + } + + .module-quote__close-container { + background-color: rgba(255, 255, 255, 0.75); + } + + .module-quote__close-button { + @include color-svg('../images/x.svg', $grey); + } + + .module-quote__icon-container__circle-background { + background-color: $color-black; + } + + .module-quote__icon-container__icon--file { + @include color-svg('../images/file.svg', $color-signal-blue); + } + .module-quote__icon-container__icon--image { + @include color-svg('../images/image.svg', $color-signal-blue); + } + .module-quote__icon-container__icon--microphone { + @include color-svg('../images/microphone.svg', $color-signal-blue); + } + .module-quote__icon-container__icon--play { + @include color-svg('../images/play.svg', $color-signal-blue); + } + .module-quote__icon-container__icon--movie { + @include color-svg('../images/movie.svg', $color-signal-blue); + } + + .module-quote__generic-file__icon { + // TODO: this will eventually be a different icon + background: url('../images/file-gradient.svg'); + } + .module-quote__generic-file__text { + color: $color-dark-05; + } + + // Module: Embedded Contact + + .module-embedded-contact__image-container__default-avatar { + background-color: $color-conversation-grey; + color: $color-white; + } + + .module-embedded-contact__contact-name { + color: $color-dark-05; + } + + .module-embedded-contact__contact-name--incoming { + color: $color-white; + } + + .module-embedded-contact__contact-method { + color: $color-dark-30; + } + + .module-embedded-contact__contact-method--incoming { + color: $color-white-07; + } + + // Module: Contact Detail + + .module-contact-detail__image-container__default-avatar { + background-color: $color-conversation-grey; + color: $color-white; + } + + .module-contact-detail__send-message { + background-color: $blue; + color: $color-white; + } + + .module-contact-detail__send-message__bubble-icon { + @include color-svg('../images/chat-bubble.svg', white); + } + + .module-contact-detail__additional-contact__type { + color: rgba(0, 0, 0, 0.5); + } + + // Module: Group Notification + + .module-group-notification { + color: $color-dark-30; + } + + // Module: Reset Session Notification + + .module-reset-session-notification { + color: $color-dark-30; + } + + // Module: Safety Number Notification + + .module-safety-number-notification__icon { + @include color-svg('../images/shield.svg', $color-dark-30); + } + + .module-safety-number-notification__text { + color: $color-dark-30; + } + + .module-verification-notification__button { + color: $color-signal-blue; + background-color: $color-light-02; + } + + // Module: Verification Notification + + .module-verification-notification { + color: $color-dark-30; + } + + .module-verification-notification__icon--mark-verified { + @include color-svg('../images/verified-check.svg', $color-dark-30); + } + + .module-verification-notification__icon--mark-not-verified { + @include color-svg('../images/shield.svg', $color-dark-30); + } + + // Module: Timer Notification + + .module-timer-notification { + color: $color-dark-30; + } + + .module-timer-notification__icon { + @include color-svg('../images/timer.svg', $color-dark-30); + } + + // Module: Contact List Item + + .module-contact-list-item { + color: $color-dark-30; + } + + .module-contact-list-item__avatar-default { + background-color: $color-conversation-grey; + } + + .module-contact-list-item__avatar-default--grey { + background-color: $color-conversation-grey; + } + .module-contact-list-item__avatar-default--blue { + background-color: $color-conversation-blue; + } + .module-contact-list-item__avatar-default--cyan { + background-color: $color-conversation-cyan; + } + .module-contact-list-item__avatar-default--deep_orange { + background-color: $color-conversation-deep_orange; + } + .module-contact-list-item__avatar-default--green { + background-color: $color-conversation-green; + } + .module-contact-list-item__avatar-default--indigo { + background-color: $color-conversation-indigo; + } + .module-contact-list-item__avatar-default--pink { + background-color: $color-conversation-pink; + } + .module-contact-list-item__avatar-default--purple { + background-color: $color-conversation-purple; + } + .module-contact-list-item__avatar-default--red { + background-color: $color-conversation-red; + } + .module-contact-list-item__avatar-default--teal { + background-color: $color-conversation-teal; + } + + .module-contact-list-item__avatar-default__label { + color: $color-white; + } + + .module-contact-list-item__text__verified-icon { + @include color-svg('../images/verified-check.svg', $color-dark-30); + } + + // Module: Conversation Header + + .module-conversation-header { + color: $color-dark-05; + background-color: $color-black; + border-bottom: 1px solid $color-light-60; + } + + .module-conversation-header__back-icon { + @include color-svg('../images/back.svg', $color-dark-05); + } + + .module-conversation-header___default-avatar { + background-color: $color-conversation-grey; + color: $color-white; + } + + .module-conversation-header___default-avatar--blue { + background-color: $color-conversation-blue; + } + .module-conversation-header___default-avatar--cyan { + background-color: $color-conversation-cyan; + } + .module-conversation-header___default-avatar--deep_orange { + background-color: $color-conversation-deep_orange; + } + .module-conversation-header___default-avatar--green { + background-color: $color-conversation-green; + } + .module-conversation-header___default-avatar--indigo { + background-color: $color-conversation-indigo; + } + .module-conversation-header___default-avatar--pink { + background-color: $color-conversation-pink; + } + .module-conversation-header___default-avatar--purple { + background-color: $color-conversation-purple; + } + .module-conversation-header___default-avatar--red { + background-color: $color-conversation-red; + } + .module-conversation-header___default-avatar--teal { + background-color: $color-conversation-teal; + } + + .module-conversation-header__title { + color: $color-dark-05; + } + + .module-conversation-header__title__verified-icon { + @include color-svg('../images/verified-check.svg', $color-dark-05); + } + + .module-conversation-header__expiration__clock-icon { + @include color-svg('../images/timer.svg', $color-dark-30); + } + + .module-conversation-header__gear-icon { + @include color-svg('../images/gear.svg', $color-dark-30); + } + + // Module: Message Detail + + .module-message-detail__unix-timestamp { + color: $color-dark-55; + } + + .module-message-detail__delete-button { + background-color: $color-core-red; + color: $color-white; + box-shadow: 0 0 10px -3px rgba(97, 97, 97, 0.7); + border: solid 1px $color-light-35; + } + + .module-message-detail__contact__default-avatar { + background-color: $color-conversation-grey; + color: $color-white; + } + + .module-message-detail__contact__default-avatar--blue { + background-color: $color-conversation-blue; + } + .module-message-detail__contact__default-avatar--cyan { + background-color: $color-conversation-cyan; + } + .module-message-detail__contact__default-avatar--deep_orange { + background-color: $color-conversation-deep_orange; + } + .module-message-detail__contact__default-avatar--green { + background-color: $color-conversation-green; + } + .module-message-detail__contact__default-avatar--indigo { + background-color: $color-conversation-indigo; + } + .module-message-detail__contact__default-avatar--pink { + background-color: $color-conversation-pink; + } + .module-message-detail__contact__default-avatar--purple { + background-color: $color-conversation-purple; + } + .module-message-detail__contact__default-avatar--red { + background-color: $color-conversation-red; + } + .module-message-detail__contact__default-avatar--teal { + background-color: $color-conversation-teal; + } + + .module-message-detail__contact__error { + color: $color-core-red; + } + + .module-message-detail__contact__status-icon--sending { + @include color-svg('../images/sending.svg', $color-dark-30); + } + + .module-message-detail__contact__status-icon--sent { + @include color-svg('../images/check-circle-outline.svg', $color-light-35); + } + .module-message-detail__contact__status-icon--delivered { + @include color-svg('../images/double-check.svg', $color-light-35); + } + .module-message-detail__contact__status-icon--read { + @include color-svg('../images/read.svg', $color-light-35); + } + .module-message-detail__contact__status-icon--error { + @include color-svg('../images/error.svg', $color-core-red); + } + + .module-message-detail__contact__show-safety-number { + color: $color-white; + background-color: $color-light-35; + } + .module-message-detail__contact__send-anyway { + color: $color-white; + background-color: $color-core-red; + } + + // Third-party module: react-contextmenu + + .react-contextmenu { + background-color: $color-dark-85; + border: 1px solid $color-light-60; + } + + .react-contextmenu-item { + color: $color-dark-05; + } + + .react-contextmenu-item--checked:before { + color: $color-dark-05; + } + + .react-contextmenu-item.react-contextmenu-submenu + > .react-contextmenu-item:after { + color: $color-dark-05; + } + + .react-contextmenu-item.react-contextmenu-item--active, + .react-contextmenu-item.react-contextmenu-item--selected { + color: $color-white; + background-color: $color-light-35; + } + + .react-contextmenu-item.react-contextmenu-item--active.react-contextmenu-item--checked:before, + .react-contextmenu-item.react-contextmenu-item--selected.react-contextmenu-item--checked:before { + color: $color-white; + } + + .react-contextmenu-item.react-contextmenu-submenu + > .react-contextmenu-item.react-contextmenu-item--active:after, + .react-contextmenu-item.react-contextmenu-submenu + > .react-contextmenu-item.react-contextmenu-item--selected:after { + color: $color-white; + } + + // _options + + .intl-tel-input .country-list .country .country-name { + color: #000; + } + + // _progress + + // Not sure we need to change anything there - it's blue + + // _recorder + + .capture-audio { + .microphone { + &:before { + @include color-svg('../images/microphone.svg', $color-dark-30); + } + } } .recorder { - background: $grey-dark_l2; - } + background: $color-black; - .message-list .last-seen-indicator-view { - .bar { - border-top: 1px solid rgba(255, 255, 255, 0.0625); - border-bottom: 1px solid rgba(0, 0, 0, 0.15); - - background-color: rgba(255, 255, 255, 0.1); - } - - .text { - background-color: $grey-dark_l3; - } - } - - .discussion-container .scroll-down-button-view { - button { - background-color: $grey_l4; + .finish { + background: lighten($color-core-green, 20%); + border: 1px solid $color-core-green; .icon { - @include color-svg('../images/down.svg', black); - } - .icon:hover { - background-color: white; + @include color-svg('../images/check.svg', $color-core-green); } + } - &.new-messages { - background-color: $blue; - .icon { - @include color-svg('../images/down.svg', white); - } - &:hover { - background-color: #1472bd; - } + .close { + background: lighten($color-core-red, 20%); + border: 1px solid $color-core-red; + + .icon { + @include color-svg('../images/x.svg', $color-core-red); } } - } - .choose-file button:hover { - background-color: $grey-dark; - } - .capture-audio button:hover { - background-color: $grey-dark; - } - button.emoji { - &:hover { - background-color: $grey-dark; - } - &:before { - @include color-svg('../images/smile.svg', white); + + .time { + color: $grey; } } - .emoji-panel-container { - .ep-categories { - background-color: $grey-dark_l3; + // _settings + + hr { + border-color: $color-dark-60; + } + + .syncSettings { + .synced_at { + color: $grey; } - .ep-emojies { - background-color: $grey-dark_l2; + .sync_failed { + color: red; } } - .dots-horizontal-icon { - &:hover { - @include color-svg('../images/dots-horizontal.svg', $grey_l); + .clear-data-settings { + .destructive { + background-color: red; + color: white; } } } diff --git a/stylesheets/_theme_light.scss b/stylesheets/_theme_light.scss deleted file mode 100644 index 7a3aa043e..000000000 --- a/stylesheets/_theme_light.scss +++ /dev/null @@ -1,89 +0,0 @@ -.light-theme { - #header { - background-color: $blue; - color: white; - transition: background-color 0.5s; - - &.inactive { - background-color: $grey_l; - color: $grey_d; - } - } - .contact-details .name { - font-weight: 400; - } - .conversation.placeholder .conversation-header { - display: none; - } - .conversation-header, - .bubble { - @include avatar-colors; - } - .bottom-bar { - min-height: 10px; - } - .bubble { - padding: 9px 12px; - border-radius: $border-radius; - box-shadow: 0 3px 3px -4px black; - } - - .outgoing .bubble { - background-color: white; - } - .outgoing .hourglass { - @include hourglass(#999); - } - .incoming .hourglass { - @include hourglass(#fff); - } - - .incoming .bubble { - .sender, - .content, - .body, - .meta, - a, - .fileView { - @include invert-text-color; - } - .attachments, - .content { - a { - color: $grey_l; - } - } - } - - .incoming .bubble .fileView .icon { - @include color-svg('../images/file.svg', white); - &.audio { - @include color-svg('../images/audio.svg', white); - } - &.video { - @include color-svg('../images/video.svg', white); - } - &.voice { - @include color-svg('../images/voice.svg', white); - } - } - - button.clock { - @include header-icon-white('../images/clock.svg'); - } - .inactive button.clock { - @include header-icon-black('../images/clock.svg'); - } - button.hamburger { - @include header-icon-white('../images/menu.svg'); - } - .inactive button.hamburger { - @include header-icon-black('../images/menu.svg'); - } - button.back { - @include header-icon-white('../images/back.svg'); - } - .inactive button.back { - @include header-icon-black('../images/back.svg'); - } -} diff --git a/stylesheets/_variables.scss b/stylesheets/_variables.scss index 21b08bb8a..921150af4 100644 --- a/stylesheets/_variables.scss +++ b/stylesheets/_variables.scss @@ -1,19 +1,3 @@ -// colors -$blue_l: #a2d2f4; -$blue: #2090ea; -$grey_l: #f3f3f3; -$grey_l1: #bdbdbd; -$grey_l1_5: #e6e6e6; -$grey_l2: #d9d9d9; // ~ Equivalent to darken($grey_l, 10%), unreliably compiles -$grey_l3: darken($grey_l, 20%); -$grey_l4: darken($grey_l, 40%); -$grey: #616161; -$grey_d: #454545; -$green: #47d647; -$red: #ef8989; - -$z-index-modal: 100; - @font-face { font-family: 'Roboto-Light'; src: url('../fonts/Roboto-Light.ttf') format('truetype'); @@ -37,62 +21,69 @@ $z-index-modal: 100; src: url('../fonts/Roboto-Bold.ttf') format('truetype'); font-weight: bold; } + $roboto: Roboto, 'Helvetica Neue', Arial, Helvetica, sans-serif; $roboto-light: Roboto-Light, 'Helvetica Neue', Arial, Helvetica, sans-serif; -$header-height: 64px; +// New colors + +$color-signal-blue: #2090ea; +$color-core-green: #4caf50; +$color-core-red: #f44336; + +$color-white: #ffffff; +$color-white-02: rgba($color-white, 0.2); +$color-white-07: rgba($color-white, 0.7); +$color-white-075: rgba($color-white, 0.75); +$color-light-02: #f9fafa; +$color-light-10: #eeefef; +$color-light-35: #a4a6a9; +$color-light-45: #8b8e91; +$color-light-60: #62656a; +$color-light-90: #070c14; + +$color-dark-05: #efefef; +$color-dark-30: #a8a9aa; +$color-dark-55: #88898c; +$color-dark-60: #797a7c; +$color-dark-70: #414347; +$color-dark-85: #1a1c20; +$color-black: #000000; +$color-black-008: rgba($color-black, 0.08); +$color-black-012: rgba($color-black, 0.12); +$color-black-02: rgba($color-black, 0.2); +$color-black-04: rgba($color-black, 0.4); + +$color-conversation-grey: #757575; +$color-conversation-blue: #1976d2; +$color-conversation-cyan: #00838f; +$color-conversation-deep_orange: #bf360c; +$color-conversation-green: #2e7d32; +$color-conversation-indigo: #3949ab; +$color-conversation-pink: #d81b60; +$color-conversation-purple: #8e24aa; +$color-conversation-red: #d32f2f; +$color-conversation-teal: #00796b; + +// Old colors + +$blue_l: #a2d2f4; +$blue: #2090ea; +$grey_l: #f3f3f3; +$grey_l1: #bdbdbd; +$grey_l1_5: #e6e6e6; +$grey_l2: #d9d9d9; // ~ Equivalent to darken($grey_l, 10%), unreliably compiles +$grey_l3: darken($grey_l, 20%); +$grey_l4: darken($grey_l, 40%); +$grey: #616161; +$grey_d: #454545; + +// A few layout variables used cross-file + +$header-height: 48px; $button-height: 24px; -$header-color: $blue; - -$search-height: 36px; -$search-padding-right: 10px; -$search-padding-left: 65px; -$search-padding-left-ios: 30px; -$search-x-size: 16px; - -$unread-badge-size: 21px; -$loading-height: 16px; $border-radius: 5px; -$error-icon-size: 24px; - $font-size: 14px; $font-size-small: (13/14) + em; - -$material_red: #ef5350; -$material_pink: #ec407a; -$material_purple: #ab47bc; -$material_deep_purple: #7e57c2; -$material_indigo: #5c6bc0; -$material_blue: #2196f3; -$material_light_blue: #03a9f4; -$material_cyan: #00bcd4; -$material_teal: #009688; -$material_green: #4caf50; -$material_light_green: #7cb342; -$material_orange: #ff9800; -$material_deep_orange: #ff5722; -$material_amber: #ffb300; -$material_blue_grey: #607d8b; - -$dark_material_red: #d32f2f; -$dark_material_pink: #c2185b; -$dark_material_purple: #7b1fa2; -$dark_material_deep_purple: #512da8; -$dark_material_indigo: #303f9f; -$dark_material_blue: #1976d2; -$dark_material_light_blue: #0288d1; -$dark_material_cyan: #0097a7; -$dark_material_teal: #00796b; -$dark_material_green: #388e3c; -$dark_material_light_green: #689f38; -$dark_material_orange: #f57c00; -$dark_material_deep_orange: #e64a19; -$dark_material_amber: #ffa000; -$dark_material_blue_grey: #455a64; - -// Android -$android-bubble-padding-horizontal: 12px; -$android-bubble-padding-vertical: 9px; -$android-bubble-quote-padding: 4px; diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 41e7cddb8..0cddebd2a 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -5,7 +5,6 @@ // Components @import 'progress'; -@import 'hourglass'; @import 'modal'; @import 'debugLog'; @import 'lightbox'; @@ -16,7 +15,6 @@ // Build the main view @import 'index'; @import 'conversation'; -@import 'theme_light'; @import 'theme_dark'; // New CSS diff --git a/test/i18n_test.js b/test/i18n_test.js index 8ab1885db..16e7c0fdd 100644 --- a/test/i18n_test.js +++ b/test/i18n_test.js @@ -12,7 +12,10 @@ describe('i18n', function() { }); it('returns message with multiple substitutions', function() { const actual = i18n('theyChangedTheTimer', ['Someone', '5 minutes']); - assert.equal(actual, 'Someone set the timer to 5 minutes.'); + assert.equal( + actual, + 'Someone set the disappearing message timer to 5 minutes' + ); }); }); diff --git a/test/index.html b/test/index.html index a15e21ac5..a4cc93b76 100644 --- a/test/index.html +++ b/test/index.html @@ -96,37 +96,7 @@

{{ content }}

- - - - - - - - - - @@ -528,12 +381,10 @@ - - diff --git a/test/models/messages_test.js b/test/models/messages_test.js index 8992e2220..78536c467 100644 --- a/test/models/messages_test.js +++ b/test/models/messages_test.js @@ -164,21 +164,21 @@ message = messages.add({ group_update: { left: 'Alice' } }); assert.equal( message.getDescription(), - 'Alice left the group.', + 'Alice left the group', 'Notes one person leaving the group.' ); message = messages.add({ group_update: { name: 'blerg' } }); assert.equal( message.getDescription(), - "Updated the group. Title is now 'blerg'.", + "Title is now 'blerg'", 'Returns a single notice if only group_updates.name changes.' ); message = messages.add({ group_update: { joined: ['Bob'] } }); assert.equal( message.getDescription(), - 'Updated the group. Bob joined the group.', + 'Bob joined the group', 'Returns a single notice if only group_updates.joined changes.' ); @@ -187,7 +187,7 @@ }); assert.equal( message.getDescription(), - 'Updated the group. Bob, Alice, Eve joined the group.', + 'Bob, Alice, Eve joined the group', 'Notes when >1 person joins the group.' ); @@ -196,7 +196,7 @@ }); assert.equal( message.getDescription(), - "Updated the group. Title is now 'blerg'. Bob joined the group.", + "Title is now 'blerg', Bob joined the group", 'Notes when there are multiple changes to group_updates properties.' ); diff --git a/test/modules/types/message_test.js b/test/modules/types/message_test.js index 614c6e62d..b555f34c3 100644 --- a/test/modules/types/message_test.js +++ b/test/modules/types/message_test.js @@ -278,6 +278,12 @@ describe('Message', () => { return 'abc/abcdefg'; }, getRegionCode: () => 'US', + getAbsoluteAttachmentPath: () => 'some/path/on/disk', + makeObjectUrl: () => 'blob://FAKE', + revokeObjectUrl: () => null, + getImageDimensions: () => ({ height: 10, width: 15 }), + makeImageThumbnail: () => new Blob(), + makeVideoScreenshot: () => new Blob(), }; const actual = await Message.upgradeSchema(input, context); assert.deepEqual(actual, expected); diff --git a/test/styleguide/legacy_bridge.js b/test/styleguide/legacy_bridge.js deleted file mode 100644 index caeb5e70c..000000000 --- a/test/styleguide/legacy_bridge.js +++ /dev/null @@ -1,84 +0,0 @@ -'use strict'; - -/* global window: false */ - -// Because we aren't hosting the Style Guide in Electron, we can't rely on preload.js -// to set things up for us. This gives us the minimum bar shims for everything it -// provdes. -// -// Remember, the idea here is just to enable visual testing, no full functionality. Most -// of thise can be very simple. - -window.PROTO_ROOT = '/protos'; -window.nodeSetImmediate = () => {}; - -window.libphonenumber = { - parse: number => ({ - e164: number, - isValidNumber: true, - getCountryCode: () => '1', - getNationalNumber: () => number, - }), - isValidNumber: () => true, - getRegionCodeForNumber: () => '1', - format: number => number.e164, - PhoneNumberFormat: {}, -}; - -window.Signal = {}; -window.Signal.Backup = {}; -window.Signal.Crypto = {}; -window.Signal.Logs = {}; -window.Signal.Migrations = { - getPlaceholderMigrations: () => [ - { - migrate: (transaction, next) => { - console.log('migration version 1'); - transaction.db.createObjectStore('conversations'); - next(); - }, - version: 1, - }, - { - migrate: (transaction, next) => { - console.log('migration version 2'); - const messages = transaction.db.createObjectStore('messages'); - messages.createIndex('expires_at', 'expireTimer', { unique: false }); - next(); - }, - version: 2, - }, - { - migrate: (transaction, next) => { - console.log('migration version 3'); - transaction.db.createObjectStore('items'); - next(); - }, - version: 3, - }, - ], - loadAttachmentData: attachment => Promise.resolve(attachment), - getAbsoluteAttachmentPath: path => path, -}; - -window.Signal.Components = {}; - -window.i18n = () => ''; - -// Ideally we don't need to add things here. We want to add them in StyleGuideUtil, which -// means that references to these things can't be early-bound, not capturing the direct -// reference to the function on file load. -window.Signal.Migrations.V17 = {}; -window.Signal.OS = {}; -window.Signal.Types = {}; -window.Signal.Types.Attachment = {}; -window.Signal.Types.Conversation = {}; -window.Signal.Types.Errors = {}; -window.Signal.Types.Message = { - initializeSchemaVersion: attributes => attributes, -}; -window.Signal.Types.MIME = {}; -window.Signal.Types.Settings = {}; -window.Signal.Views = {}; -window.Signal.Views.Initialization = {}; -window.Signal.Workflow = {}; diff --git a/test/styleguide/legacy_templates.js b/test/styleguide/legacy_templates.js deleted file mode 100644 index 7440922e7..000000000 --- a/test/styleguide/legacy_templates.js +++ /dev/null @@ -1,90 +0,0 @@ -'use strict'; - -/* global window: false */ - -// Taken from background.html. -// Templates are here solely to support the Backbone views rendered in the Style Guide. - -// Note: Any change here must be reflected in background.html to be reflected in the app -// and test/index.html to be reflected in the unit tests. - -window.Whisper.View.Templates = { - hasRetry: ` - {{ messageNotSent }} {{ resend }} - `, - 'some-failed': ` - {{ someFailed }} - `, - keychange: ` - - {{ content }} - - `, - 'verified-change': ` - - {{ content }} - - `, - message: ` - {{> avatar }} -
-
- {{ sender }} - {{ #profileName }} - {{ profileName }} - {{ /profileName }} -
-
-
- {{ #hasAttachments }} -
- {{ /hasAttachments }} - {{ #hasBody }} -
- {{ #message }} -
- {{ /message }} -
- {{ /hasBody }} -
-
-
- - - -
- {{ #hoverIcon }} - - {{ /hoverIcon }} -
- `, - hourglass: ` - - `, - expirationTimerUpdate: ` - {{ content }} - `, - 'file-view': ` -
-
-
- {{ fileName }} -
-
{{ fileSize }}
-
- `, - 'error-icon': ` - - - {{ #message }} - {{message}} - {{ /message }} - `, -}; diff --git a/test/views/attachment_view_test.js b/test/views/attachment_view_test.js index afbd4c99a..4d8279ab0 100644 --- a/test/views/attachment_view_test.js +++ b/test/views/attachment_view_test.js @@ -5,6 +5,22 @@ 'use strict'; describe('AttachmentView', () => { + var convo, message; + + before(async () => { + await clearDatabase(); + convo = new Whisper.Conversation({ id: 'foo' }); + message = convo.messageCollection.add({ + conversationId: convo.id, + body: 'hello world', + type: 'outgoing', + source: '+14158675309', + received_at: Date.now(), + }); + + await storage.put('number_id', '+18088888888.1'); + }); + describe('with arbitrary files', () => { it('should render a file view', () => { const attachment = { diff --git a/test/views/message_view_test.js b/test/views/message_view_test.js deleted file mode 100644 index 9c5692286..000000000 --- a/test/views/message_view_test.js +++ /dev/null @@ -1,90 +0,0 @@ -describe('MessageView', function() { - var convo, message; - - before(async () => { - await clearDatabase(); - convo = new Whisper.Conversation({ id: 'foo' }); - message = convo.messageCollection.add({ - conversationId: convo.id, - body: 'hello world', - type: 'outgoing', - source: '+14158675309', - received_at: Date.now(), - }); - - await storage.put('number_id', '+18088888888.1'); - }); - - it('should display the message text', function() { - var view = new Whisper.MessageView({ model: message }).render(); - assert.match(view.$el.text(), /hello world/); - }); - - it('should auto-update the message text', function() { - var view = new Whisper.MessageView({ model: message }).render(); - message.set('body', 'goodbye world'); - assert.match(view.$el.html(), /goodbye world/); - }); - - it('should have a nice timestamp', function() { - var view = new Whisper.MessageView({ model: message }); - message.set({ sent_at: Date.now() - 5000 }); - view.render(); - assert.match(view.$el.html(), /now/); - - message.set({ sent_at: Date.now() - 60000 }); - view.render(); - assert.match(view.$el.html(), /min/); - - message.set({ sent_at: Date.now() - 3600000 }); - view.render(); - assert.match(view.$el.html(), /hour/); - }); - it('should not imply messages are from the future', function() { - var view = new Whisper.MessageView({ model: message }); - message.set({ sent_at: Date.now() + 60000 }); - view.render(); - assert.match(view.$el.html(), /now/); - }); - - it('should go away when the model is destroyed', function() { - var view = new Whisper.MessageView({ model: message }); - var div = $('
').append(view.$el); - message.destroy(); - assert.strictEqual(div.find(view.$el).length, 0); - }); - - it('allows links', function() { - var url = 'http://example.com'; - message.set('body', url); - var view = new Whisper.MessageView({ model: message }); - view.render(); - var link = view.$el.find('.body a'); - assert.strictEqual(link.length, 1); - assert.strictEqual(link.text(), url); - assert.strictEqual(link.attr('href'), url); - }); - - it('disallows xss', function() { - var xss = ''; - message.set('body', xss); - var view = new Whisper.MessageView({ model: message }); - view.render(); - assert.include(view.$el.text(), xss); // should appear as escaped text - assert.strictEqual(view.$el.find('script').length, 0); // should not appear as html - }); - - it('supports emoji', function() { - message.set('body', 'I \u2764\uFE0F emoji!'); - var view = new Whisper.MessageView({ model: message }); - view.render(); - var img = view.$el.find('.content img'); - assert.strictEqual(img.length, 1); - assert.strictEqual( - img.attr('src'), - 'node_modules/emoji-datasource-apple/img/apple/64/2764-fe0f.png' - ); - assert.strictEqual(img.attr('title'), ':heart:'); - assert.strictEqual(img.attr('class'), 'emoji'); - }); -}); diff --git a/test/views/scroll_down_button_view_test.js b/test/views/scroll_down_button_view_test.js index 5abad01d6..6adf5b5d4 100644 --- a/test/views/scroll_down_button_view_test.js +++ b/test/views/scroll_down_button_view_test.js @@ -10,7 +10,6 @@ describe('ScrollDownButtonView', function() { var view = new Whisper.ScrollDownButtonView({ count: 1 }); view.render(); assert.equal(view.count, 1); - assert.match(view.$el.html(), /new-messages/); assert.match(view.$el.html(), /New message below/); }); @@ -19,7 +18,6 @@ describe('ScrollDownButtonView', function() { view.render(); assert.equal(view.count, 2); - assert.match(view.$el.html(), /new-messages/); assert.match(view.$el.html(), /New messages below/); }); @@ -27,9 +25,9 @@ describe('ScrollDownButtonView', function() { var view = new Whisper.ScrollDownButtonView(); view.render(); assert.equal(view.count, 0); - assert.notMatch(view.$el.html(), /new-messages/); + assert.notMatch(view.$el.html(), /New message below/); view.increment(1); assert.equal(view.count, 1); - assert.match(view.$el.html(), /new-messages/); + assert.match(view.$el.html(), /New message below/); }); }); diff --git a/ts/components/Intl.md b/ts/components/Intl.md new file mode 100644 index 000000000..624ad89fb --- /dev/null +++ b/ts/components/Intl.md @@ -0,0 +1,61 @@ +#### No replacements + +```jsx + +``` + +#### Single string replacement + +```jsx + +``` + +#### Single tag replacement + +```jsx + + Alice + , + ]} +/> +``` + +#### Multiple string replacement + +```jsx + +``` + +#### Multiple tag replacement + +```jsx + + Alice + , + , + ]} +/> +``` diff --git a/ts/components/Intl.tsx b/ts/components/Intl.tsx new file mode 100644 index 000000000..00389469e --- /dev/null +++ b/ts/components/Intl.tsx @@ -0,0 +1,79 @@ +import React from 'react'; + +import { Localizer, RenderTextCallback } from '../types/Util'; + +type FullJSX = Array | JSX.Element | string; + +interface Props { + /** The translation string id */ + id: string; + i18n: Localizer; + components?: Array; + renderText?: RenderTextCallback; +} + +export class Intl extends React.Component { + public static defaultProps: Partial = { + renderText: ({ text }) => text, + }; + + public getComponent(index: number): FullJSX | null { + const { id, components } = this.props; + + if (!components || !components.length || components.length <= index) { + // tslint:disable-next-line no-console + console.log( + `Error: Intl missing provided components for id ${id}, index ${index}` + ); + + return null; + } + + return components[index]; + } + + public render() { + const { id, i18n, renderText } = this.props; + + const text = i18n(id); + const results: Array = []; + const FIND_REPLACEMENTS = /\$[^$]+\$/g; + + // We have to do this, because renderText is not required in our Props object, + // but it is always provided via defaultProps. + if (!renderText) { + return; + } + + let componentIndex = 0; + let key = 0; + let lastTextIndex = 0; + let match = FIND_REPLACEMENTS.exec(text); + + if (!match) { + return renderText({ text, key: 0 }); + } + + while (match) { + if (lastTextIndex < match.index) { + const textWithNoReplacements = text.slice(lastTextIndex, match.index); + results.push(renderText({ text: textWithNoReplacements, key: key })); + key += 1; + } + + results.push(this.getComponent(componentIndex)); + componentIndex += 1; + + // @ts-ignore + lastTextIndex = FIND_REPLACEMENTS.lastIndex; + match = FIND_REPLACEMENTS.exec(text); + } + + if (lastTextIndex < text.length) { + results.push(renderText({ text: text.slice(lastTextIndex), key: key })); + key += 1; + } + + return results; + } +} diff --git a/ts/components/LightboxGallery.md b/ts/components/LightboxGallery.md index 7fff37fca..46b2ff518 100644 --- a/ts/components/LightboxGallery.md +++ b/ts/components/LightboxGallery.md @@ -3,17 +3,23 @@ const noop = () => {}; const messages = [ { - objectURL: 'https://placekitten.com/800/600', + objectURL: 'https://placekitten.com/799/600', attachments: [{ contentType: 'image/jpeg' }], }, { objectURL: 'https://placekitten.com/900/600', attachments: [{ contentType: 'image/jpeg' }], }, + // Unsupported image type { objectURL: 'foo.tif', attachments: [{ contentType: 'image/tiff' }], }, + // Video + { + objectURL: util.mp4ObjectUrl, + attachments: [{ contentType: 'video/mp4' }], + }, { objectURL: 'https://placekitten.com/980/800', attachments: [{ contentType: 'image/jpeg' }], diff --git a/ts/components/conversation/ContactName.md b/ts/components/conversation/ContactName.md index f078461c9..a12091883 100644 --- a/ts/components/conversation/ContactName.md +++ b/ts/components/conversation/ContactName.md @@ -1,32 +1,26 @@ -#### With name and profile +#### Number, name and profile ```jsx -
- -
+ ``` -#### Profile, no name +#### Number and profile, no name ```jsx -
- -
+ ``` #### No name, no profile ```jsx -
- -
+ ``` diff --git a/ts/components/conversation/ContactName.tsx b/ts/components/conversation/ContactName.tsx index 880d86506..404a80583 100644 --- a/ts/components/conversation/ContactName.tsx +++ b/ts/components/conversation/ContactName.tsx @@ -9,23 +9,27 @@ interface Props { name?: string; profileName?: string; i18n: Localizer; + module?: string; } export class ContactName extends React.Component { public render() { - const { phoneNumber, name, profileName, i18n } = this.props; + const { phoneNumber, name, profileName, i18n, module } = this.props; + const prefix = module ? module : 'module-contact-name'; const title = name ? name : phoneNumber; - const profileElement = - profileName && !name ? ( - - ~ - - ) : null; + const shouldShowProfile = Boolean(profileName && !name); + const profileElement = shouldShowProfile ? ( + + ~ + + ) : null; return ( - - {profileElement} + + + {shouldShowProfile ? ' ' : null} + {profileElement} ); } diff --git a/ts/components/conversation/ConversationHeader.md b/ts/components/conversation/ConversationHeader.md new file mode 100644 index 000000000..7d1bc187f --- /dev/null +++ b/ts/components/conversation/ConversationHeader.md @@ -0,0 +1,139 @@ +### Name variations, 1:1 conversation + +Note the five items in gear menu, and the second-level menu with disappearing messages options. Disappearing message set to 'off'. + +#### With name and profile, verified + +```jsx + + console.log('onSetDisappearingMessages', seconds) + } + onDeleteMessages={() => console.log('onDeleteMessages')} + onResetSession={() => console.log('onResetSession')} + onShowSafetyNumber={() => console.log('onShowSafetyNumber')} + onShowAllMedia={() => console.log('onShowAllMedia')} + onShowGroupMembers={() => console.log('onShowGroupMembers')} + onGoBack={() => console.log('onGoBack')} +/> +``` + +#### With name, not verified, no avatar + +```jsx + +``` + +#### Profile, no name + +```jsx + +``` + +#### No name, no profile, no color + +```jsx + +``` + +### With back button + +```jsx + +``` + +### Disappearing messages set + +```jsx + + console.log('onSetDisappearingMessages', seconds) + } + onDeleteMessages={() => console.log('onDeleteMessages')} + onResetSession={() => console.log('onResetSession')} + onShowSafetyNumber={() => console.log('onShowSafetyNumber')} + onShowAllMedia={() => console.log('onShowAllMedia')} + onShowGroupMembers={() => console.log('onShowGroupMembers')} + onGoBack={() => console.log('onGoBack')} +/> +``` + +### In a group + +Note that the menu should includes 'Show Members' instead of 'Show Safety Number' + +```jsx + + console.log('onSetDisappearingMessages', seconds) + } + onDeleteMessages={() => console.log('onDeleteMessages')} + onResetSession={() => console.log('onResetSession')} + onShowSafetyNumber={() => console.log('onShowSafetyNumber')} + onShowAllMedia={() => console.log('onShowAllMedia')} + onShowGroupMembers={() => console.log('onShowGroupMembers')} + onGoBack={() => console.log('onGoBack')} +/> +``` + +### In chat with yourself + +Note that the menu should not have a 'Show Safety Number' entry. + +```jsx + +``` diff --git a/ts/components/conversation/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx new file mode 100644 index 000000000..ea6266fc0 --- /dev/null +++ b/ts/components/conversation/ConversationHeader.tsx @@ -0,0 +1,253 @@ +import React from 'react'; +import classNames from 'classnames'; + +import { Emojify } from './Emojify'; +import { Localizer } from '../../types/Util'; +import { + ContextMenu, + ContextMenuTrigger, + MenuItem, + SubMenu, +} from 'react-contextmenu'; + +interface TimerOption { + name: string; + value: number; +} + +interface Trigger { + handleContextClick: (event: React.MouseEvent) => void; +} + +interface Props { + i18n: Localizer; + isVerified: boolean; + name?: string; + id: string; + phoneNumber: string; + profileName?: string; + color: string; + + avatarPath?: string; + isMe: boolean; + isGroup: boolean; + expirationSettingName?: string; + showBackButton: boolean; + timerOptions: Array; + + onSetDisappearingMessages: (seconds: number) => void; + onDeleteMessages: () => void; + onResetSession: () => void; + + onShowSafetyNumber: () => void; + onShowAllMedia: () => void; + onShowGroupMembers: () => void; + onGoBack: () => void; +} + +function getInitial(name: string): string { + return name.trim()[0] || '#'; +} + +export class ConversationHeader extends React.Component { + public captureMenuTriggerBound: (trigger: any) => void; + public showMenuBound: (event: React.MouseEvent) => void; + public menuTriggerRef: Trigger | null; + + public constructor(props: Props) { + super(props); + + this.captureMenuTriggerBound = this.captureMenuTrigger.bind(this); + this.showMenuBound = this.showMenu.bind(this); + this.menuTriggerRef = null; + } + + public captureMenuTrigger(triggerRef: Trigger) { + this.menuTriggerRef = triggerRef; + } + public showMenu(event: React.MouseEvent) { + if (this.menuTriggerRef) { + this.menuTriggerRef.handleContextClick(event); + } + } + + public renderBackButton() { + const { onGoBack, showBackButton } = this.props; + + if (!showBackButton) { + return null; + } + + return ( +
+ ); + } + + public renderTitle() { + const { name, phoneNumber, i18n, profileName, isVerified } = this.props; + + return ( +
+ {name ? : null} + {name && phoneNumber ? ' · ' : null} + {phoneNumber ? phoneNumber : null}{' '} + {profileName && !name ? ( + + + + ) : null} + {isVerified ? ' · ' : null} + {isVerified ? ( + + + {i18n('verified')} + + ) : null} +
+ ); + } + + public renderAvatar() { + const { + avatarPath, + color, + i18n, + name, + phoneNumber, + profileName, + } = this.props; + + if (!avatarPath) { + const initial = getInitial(name || ''); + + return ( +
+ {initial} +
+ ); + } + + const title = `${name || phoneNumber}${ + !name && profileName ? ` ~${profileName}` : '' + }`; + + return ( + {i18n('contactAvatarAlt', + ); + } + + public renderExpirationLength() { + const { expirationSettingName } = this.props; + + if (!expirationSettingName) { + return null; + } + + return ( +
+
+
+ {expirationSettingName} +
+
+ ); + } + + public renderGear(triggerId: string) { + const { showBackButton } = this.props; + + if (showBackButton) { + return null; + } + + return ( + +
+ + ); + } + + /* tslint:disable:jsx-no-lambda react-this-binding-issue */ + public renderMenu(triggerId: string) { + const { + i18n, + isMe, + isGroup, + onDeleteMessages, + onResetSession, + onSetDisappearingMessages, + onShowAllMedia, + onShowGroupMembers, + onShowSafetyNumber, + timerOptions, + } = this.props; + + const title = i18n('disappearingMessages') as any; + + return ( + + + {(timerOptions || []).map(item => ( + { + onSetDisappearingMessages(item.value); + }} + > + {item.name} + + ))} + + {i18n('viewAllMedia')} + {isGroup ? ( + + {i18n('showMembers')} + + ) : null} + {!isGroup && !isMe ? ( + + {i18n('showSafetyNumber')} + + ) : null} + {!isGroup ? ( + {i18n('resetSession')} + ) : null} + {i18n('deleteMessages')} + + ); + } + /* tslint:enable */ + + public render() { + const { id } = this.props; + + return ( +
+ {this.renderBackButton()} + {this.renderAvatar()} + {this.renderTitle()} + {this.renderExpirationLength()} + {this.renderGear(id)} + {this.renderMenu(id)} +
+ ); + } +} diff --git a/ts/components/conversation/ConversationTitle.md b/ts/components/conversation/ConversationTitle.md deleted file mode 100644 index 109f2f5fc..000000000 --- a/ts/components/conversation/ConversationTitle.md +++ /dev/null @@ -1,45 +0,0 @@ -#### With name and profile, verified - -```jsx -
- -
-``` - -#### With name, not verified - -```jsx -
- -
-``` - -#### Profile, no name - -```jsx -
- -
-``` - -#### No name, no profile - -```jsx -
- -
-``` diff --git a/ts/components/conversation/ConversationTitle.tsx b/ts/components/conversation/ConversationTitle.tsx deleted file mode 100644 index 0e5da0375..000000000 --- a/ts/components/conversation/ConversationTitle.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React from 'react'; - -import { Emojify } from './Emojify'; -import { Localizer } from '../../types/Util'; - -interface Props { - i18n: Localizer; - isVerified: boolean; - name?: string; - phoneNumber: string; - profileName?: string; -} - -export class ConversationTitle extends React.Component { - public render() { - const { name, phoneNumber, i18n, profileName, isVerified } = this.props; - - return ( - - {name ? ( - - - - ) : null} - {phoneNumber ? ( - {phoneNumber} - ) : null}{' '} - {profileName && !name ? ( - - - - ) : null} - {isVerified ? ( - - - {i18n('verified')} - - ) : null} - - ); - } -} diff --git a/ts/components/conversation/EmbeddedContact.md b/ts/components/conversation/EmbeddedContact.md index 641a4c145..2b3059e39 100644 --- a/ts/components/conversation/EmbeddedContact.md +++ b/ts/components/conversation/EmbeddedContact.md @@ -3,516 +3,533 @@ #### Including all data types ```jsx -const contacts = [ - { - name: { - displayName: 'Someone Somewhere', +const contact = { + name: { + displayName: 'Someone Somewhere', + }, + number: [ + { + value: '(202) 555-0000', + type: 1, }, - number: [ - { - value: '(202) 555-0000', - type: 1, - }, - ], + ], + avatar: { avatar: { - avatar: { - path: util.gifObjectUrl, - }, + path: util.gifObjectUrl, }, }, -]; + onClick: () => console.log('onClick'), + onSendMessage: () => console.log('onSendMessage'), + hasSignalAccount: true, +}; - console.log('onClickContact')} - onSendMessageToContact={() => console.log('onSendMessageToContact')} - /> - console.log('onClickContact')} - onSendMessageToContact={() => console.log('onSendMessageToContact')} - /> - console.log('onClickContact')} - onSendMessageToContact={() => console.log('onSendMessageToContact')} - /> - console.log('onClickContact')} - onSendMessageToContact={() => console.log('onSendMessageToContact')} - /> +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • ; ``` -#### Really long long data +#### Really long data ``` -const contacts = [ - { - name: { - displayName: 'Dr. First Middle Last Junior Senior and all that and a bag of chips', +const contact = { + name: { + displayName: 'Dr. First Middle Last Junior Senior and all that and a bag of chips', + }, + number: [ + { + value: '(202) 555-0000 0000 0000 0000 0000 0000 0000 0000 0000 0000', + type: 1, }, - number: [ - { - value: '(202) 555-0000 0000 0000 0000 0000 0000 0000 0000 0000 0000', - type: 1, - }, - ], + ], + avatar: { avatar: { - avatar: { - path: util.gifObjectUrl, - }, + path: util.gifObjectUrl, }, }, -]; + hasSignalAccount: true, +}; - console.log('onClickContact')} - onSendMessageToContact={() => console.log('onSendMessageToContact')} - /> - +
  • console.log('onClickContact')} - onSendMessageToContact={() => console.log('onSendMessageToContact')} - /> + contact={contact}/>
  • ; ``` #### In group conversation ```jsx -const contacts = [ - { - name: { - displayName: 'Someone Somewhere', +const contact = { + name: { + displayName: 'Someone Somewhere', + }, + number: [ + { + value: '(202) 555-0000', + type: 1, }, - number: [ - { - value: '(202) 555-0000', - type: 1, - }, - ], + ], + avatar: { avatar: { - avatar: { - path: util.gifObjectUrl, - }, + path: util.gifObjectUrl, }, }, -]; + hasSignalAccount: true, +}; - console.log('onClickContact')} - onSendMessageToContact={() => console.log('onSendMessageToContact')} - /> - console.log('onClickContact')} - onSendMessageToContact={() => console.log('onSendMessageToContact')} - /> - console.log('onClickContact')} - onSendMessageToContact={() => console.log('onSendMessageToContact')} - /> +
  • + +
  • +
  • + +
  • +
  • + +
  • ; ``` #### If contact has no signal account ```jsx -const contacts = [ - { - name: { - displayName: 'Someone Somewhere', +const contact = { + name: { + displayName: 'Someone Somewhere', + }, + number: [ + { + value: '(202) 555-0000', + type: 1, }, - number: [ - { - value: '(202) 555-0000', - type: 1, - }, - ], + ], + avatar: { avatar: { - avatar: { - path: util.gifObjectUrl, - }, + path: util.gifObjectUrl, }, }, -]; + hasSignalAccount: false, +}; - console.log('onClickContact')} - /> - console.log('onClickContact')} - /> - console.log('onClickContact')} - /> - console.log('onClickContact')} - /> +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • ; ``` #### With organization name instead of name ```jsx -const contacts = [ - { - organization: 'United Somewheres, Inc.', - email: [ - { - value: 'someone@somewheres.com', - type: 2, - }, - ], +const contact = { + organization: 'United Somewheres, Inc.', + email: [ + { + value: 'someone@somewheres.com', + type: 2, + }, + ], + avatar: { avatar: { - avatar: { - path: util.gifObjectUrl, - }, + path: util.gifObjectUrl, }, }, -]; + hasSignalAccount: false, +}; - console.log('onClickContact')} - /> - console.log('onClickContact')} - /> - - console.log('onClickContact')} - /> +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • ; ``` #### No displayName or organization ```jsx -const contacts = [ - { - name: { - givenName: 'Someone', +const contact = { + name: { + givenName: 'Someone', + }, + number: [ + { + value: '(202) 555-1000', + type: 1, }, - number: [ - { - value: '+12025551000', - type: 1, - }, - ], + ], + avatar: { avatar: { - avatar: { - path: util.gifObjectUrl, - }, + path: util.gifObjectUrl, }, }, -]; + hasSignalAccount: false, +}; - console.log('onClickContact')} - /> - console.log('onClickContact')} - /> - console.log('onClickContact')} - /> - console.log('onClickContact')} - /> +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • ; ``` #### Default avatar ```jsx -const contacts = [ - { - name: { - displayName: 'Someone Somewhere', - }, - number: [ - { - value: util.CONTACTS[0].id, - type: 1, - }, - ], +const contact = { + name: { + displayName: 'Someone Somewhere', }, -]; + number: [ + { + value: '(202) 555-1001', + type: 1, + }, + ], + hasSignalAccount: true, +}; - console.log('onClickContact')} - /> - console.log('onClickContact')} - /> - console.log('onClickContact')} - /> - console.log('onClickContact')} - /> +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • ; ``` #### Empty contact ```jsx -const contacts = [{}]; +const contact = {}; - console.log('onClickContact')} - /> - console.log('onClickContact')} - /> - console.log('onClickContact')} - /> - console.log('onClickContact')} - /> +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • ; ``` #### Contact with caption (cannot currently be sent) ```jsx -const contacts = [ - { - name: { - displayName: 'Someone Somewhere', +const contactWithAccount = { + name: { + displayName: 'Someone Somewhere', + }, + number: [ + { + value: '(202) 555-0000', + type: 1, }, - number: [ - { - value: '(202) 555-0000', - type: 1, - }, - ], + ], + avatar: { avatar: { - avatar: { - path: util.gifObjectUrl, - }, + path: util.gifObjectUrl, }, }, -]; + hasSignalAccount: true, +}; +const contactWithoutAccount = { + name: { + displayName: 'Someone Somewhere', + }, + number: [ + { + value: '(202) 555-0000', + type: 1, + }, + ], + avatar: { + avatar: { + path: util.gifObjectUrl, + }, + }, + hasSignalAccount: false, +}; - console.log('onClickContact')} - /> - console.log('onClickContact')} - /> - console.log('onClickContact')} - contactHasSignalAccount - onSendMessageToContact={() => console.log('onSendMessageToContact')} - /> - console.log('onClickContact')} - contactHasSignalAccount - onSendMessageToContact={() => console.log('onSendMessageToContact')} - /> - console.log('onClickContact')} - /> - console.log('onClickContact')} - /> - console.log('onClickContact')} - contactHasSignalAccount - onSendMessageToContact={() => console.log('onSendMessageToContact')} - /> - console.log('onClickContact')} - contactHasSignalAccount - onSendMessageToContact={() => console.log('onSendMessageToContact')} - /> +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • ; ``` diff --git a/ts/components/conversation/EmbeddedContact.tsx b/ts/components/conversation/EmbeddedContact.tsx index 22e86cb48..607a2b554 100644 --- a/ts/components/conversation/EmbeddedContact.tsx +++ b/ts/components/conversation/EmbeddedContact.tsx @@ -12,8 +12,7 @@ interface Props { isIncoming: boolean; withContentAbove: boolean; withContentBelow: boolean; - onSendMessage?: () => void; - onClickContact?: () => void; + onClick?: () => void; } export class EmbeddedContact extends React.Component { @@ -22,7 +21,7 @@ export class EmbeddedContact extends React.Component { contact, i18n, isIncoming, - onClickContact, + onClick, withContentAbove, withContentBelow, } = this.props; @@ -40,7 +39,7 @@ export class EmbeddedContact extends React.Component { : null )} role="button" - onClick={onClickContact} + onClick={onClick} > {renderAvatar({ contact, i18n, module })}
    diff --git a/ts/components/conversation/Emojify.tsx b/ts/components/conversation/Emojify.tsx index fa8158db5..1250a4697 100644 --- a/ts/components/conversation/Emojify.tsx +++ b/ts/components/conversation/Emojify.tsx @@ -34,10 +34,13 @@ function getImageTag({ const title = getTitle(result.value); return ( + // tslint:disable-next-line react-a11y-img-has-alt {i18n('emojiAlt', +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • + +``` + +### Timer calculations + +```jsx + +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
    +``` diff --git a/ts/components/conversation/ExpireTimer.tsx b/ts/components/conversation/ExpireTimer.tsx new file mode 100644 index 000000000..7b0daef8e --- /dev/null +++ b/ts/components/conversation/ExpireTimer.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import classNames from 'classnames'; + +import { padStart } from 'lodash'; + +interface Props { + withImageNoCaption: boolean; + expirationLength: number; + expirationTimestamp: number; + direction: 'incoming' | 'outgoing'; +} + +export class ExpireTimer extends React.Component { + private interval: any; + + constructor(props: Props) { + super(props); + + this.interval = null; + } + + public componentDidMount() { + const { expirationLength } = this.props; + const increment = getIncrement(expirationLength); + const updateFrequency = Math.max(increment, 500); + + const update = () => { + this.setState({ + lastUpdated: Date.now(), + }); + }; + this.interval = setInterval(update, updateFrequency); + } + + public componentWillUnmount() { + if (this.interval) { + clearInterval(this.interval); + } + } + + public render() { + const { + direction, + expirationLength, + expirationTimestamp, + withImageNoCaption, + } = this.props; + + const bucket = getTimerBucket(expirationTimestamp, expirationLength); + + return ( +
    + ); + } +} + +export function getIncrement(length: number): number { + if (length < 0) { + return 1000; + } + + return Math.ceil(length / 12); +} + +function getTimerBucket(expiration: number, length: number): string { + const delta = expiration - Date.now(); + if (delta < 0) { + return '00'; + } + if (delta > length) { + return '60'; + } + + const bucket = Math.round(delta / length * 12); + + return padStart(String(bucket * 5), 2, '0'); +} diff --git a/ts/components/conversation/GroupNotification.md b/ts/components/conversation/GroupNotification.md new file mode 100644 index 000000000..5acbd50fc --- /dev/null +++ b/ts/components/conversation/GroupNotification.md @@ -0,0 +1,171 @@ +### Three changes, all types + +```js + + + +``` + +### Joined group + +```js + + + + +``` + +### Left group + +```js + + + + + +``` + +### Title changed + +```js + + + +``` + +### Generic group update + +```js + + + +``` diff --git a/ts/components/conversation/GroupNotification.tsx b/ts/components/conversation/GroupNotification.tsx new file mode 100644 index 000000000..79f330eb4 --- /dev/null +++ b/ts/components/conversation/GroupNotification.tsx @@ -0,0 +1,109 @@ +import React from 'react'; +// import classNames from 'classnames'; +import { compact, flatten } from 'lodash'; + +import { ContactName } from './ContactName'; +import { Intl } from '../Intl'; +import { Localizer } from '../../types/Util'; + +import { missingCaseError } from '../../util/missingCaseError'; + +interface Contact { + phoneNumber: string; + profileName?: string; + name?: string; +} + +interface Change { + type: 'add' | 'remove' | 'name' | 'general'; + isMe: boolean; + newName?: string; + contacts?: Array; +} + +interface Props { + changes: Array; + i18n: Localizer; +} + +export class GroupNotification extends React.Component { + public renderChange(change: Change) { + const { isMe, contacts, type, newName } = change; + const { i18n } = this.props; + + const people = compact( + flatten( + (contacts || []).map((contact, index) => { + const element = ( + + + + ); + + return [index > 0 ? ', ' : null, element]; + }) + ) + ); + + switch (type) { + case 'name': + return i18n('titleIsNow', [newName || '']); + case 'add': + if (!contacts || !contacts.length) { + throw new Error('Group update is missing contacts'); + } + + return ( + 1 ? 'multipleJoinedTheGroup' : 'joinedTheGroup' + } + components={[people]} + /> + ); + case 'remove': + if (!contacts || !contacts.length) { + throw new Error('Group update is missing contacts'); + } + + if (isMe) { + return i18n('youLeftTheGroup'); + } + + return ( + 1 ? 'multipleLeftTheGroup' : 'leftTheGroup'} + components={[people]} + /> + ); + case 'general': + return i18n('updatedTheGroup'); + default: + throw missingCaseError(type); + } + } + + public render() { + const { changes } = this.props; + + return ( +
    + {(changes || []).map(change => ( +
    + {this.renderChange(change)} +
    + ))} +
    + ); + } +} diff --git a/ts/components/conversation/Message.md b/ts/components/conversation/Message.md index dfd98ef76..26a5e7256 100644 --- a/ts/components/conversation/Message.md +++ b/ts/components/conversation/Message.md @@ -4,473 +4,324 @@ Note that timestamp and status can be hidden with the `collapseMetadata` boolean ```jsx - - - - - - - - - - +
  • + console.log('onDownload')} + onReply={() => console.log('onReply')} + onShowDetail={() => console.log('onShowDetail')} + onDelete={() => console.log('onDelete')} + /> +
  • +
  • + console.log('onDownload')} + onReply={() => console.log('onReply')} + onShowDetail={() => console.log('onShowDetail')} + onDelete={() => console.log('onDelete')} + /> +
  • +
  • + console.log('onDownload')} + onReply={() => console.log('onReply')} + onShowDetail={() => console.log('onShowDetail')} + onDelete={() => console.log('onDelete')} + /> +
  • +
  • + console.log('onDownload')} + onReply={() => console.log('onReply')} + onShowDetail={() => console.log('onShowDetail')} + onDelete={() => console.log('onDelete')} + /> +
  • +
  • + console.log('onDownload')} + onReply={() => console.log('onReply')} + onShowDetail={() => console.log('onShowDetail')} + onDelete={() => console.log('onDelete')} + /> +
  • +
  • + console.log('onDownload')} + onReply={() => console.log('onReply')} + onShowDetail={() => console.log('onShowDetail')} + onDelete={() => console.log('onDelete')} + /> +
  • +
  • + console.log('onDownload')} + onReply={() => console.log('onReply')} + onShowDetail={() => console.log('onShowDetail')} + onDelete={() => console.log('onDelete')} + /> +
  • +
  • + console.log('onDownload')} + onReply={() => console.log('onReply')} + onShowDetail={() => console.log('onShowDetail')} + onDelete={() => console.log('onDelete')} + /> +
  • +
  • + console.log('onDownload')} + onReply={() => console.log('onReply')} + onShowDetail={() => console.log('onShowDetail')} + onDelete={() => console.log('onDelete')} + /> +
  • +
  • + console.log('onDownload')} + onReply={() => console.log('onReply')} + onShowDetail={() => console.log('onShowDetail')} + onDelete={() => console.log('onDelete')} + /> +
  • ``` -### Timestamps - -```jsx -function get1201() { - const d = new Date(); - d.setHours(0, 0, 1, 0); - return d.getTime(); -} -function getYesterday1159() { - return get1201() - 2 * 60 * 1000; -} -function getJanuary1201() { - const now = new Date(); - const d = new Date(now.getFullYear(), 0, 1, 0, 1); - return d.getTime(); -} -function getDecember1159() { - return getJanuary1201() - 2 * 60 * 1000; -} - - - - - - - - - - - - - - - - - - -; -``` - ### Status ```jsx - - - - - - - - +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + console.log('onRetrySend')} + /> +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + console.log('onRetrySend')} + /> +
  • +
  • + +
  • ``` -### With an error - -#### General error - -```jsx -const error = new Error('Something went wrong!'); - -const outgoing = new Whisper.Message({ - type: 'outgoing', - body: "This message won't get through...", - sent_at: Date.now() - 200000, - errors: [error], -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550003', - type: 'incoming', - body: null, - }) -); -const View = Whisper.MessageView; - - - -; -``` - -#### Network error (outgoing only) - -```jsx -const error = new Error('Something went wrong!'); -error.name = 'MessageError'; - -const outgoing = new Whisper.Message({ - type: 'outgoing', - sent_at: Date.now() - 200000, - errors: [error], - body: "This message won't get through...", -}); -const View = Whisper.MessageView; - - -; -``` - -#### Network error, partial send in group (outgoing only) - -```jsx -const error = new Error('Something went wrong!'); -error.name = 'MessageError'; - -const outgoing = new Whisper.Message({ - type: 'outgoing', - sent_at: Date.now() - 200000, - errors: [error], - conversationId: util.groupNumber, - body: "This message won't get through...", -}); -const View = Whisper.MessageView; - - -; -``` - -### Disappearing messages +### Long data ```jsx - - - - - - - - - - - - - - +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • ``` @@ -480,39 +331,55 @@ const View = Whisper.MessageView; ```jsx - console.log('onClickAttachment')} - /> - console.log('onClickAttachment')} - /> - console.log('onClickAttachment')} - /> - console.log('onClickAttachment')} - /> +
  • + console.log('onClickAttachment')} + onDownload={() => console.log('onDownload')} + onReply={() => console.log('onReply')} + /> +
  • +
  • + console.log('onClickAttachment')} + onDownload={() => console.log('onDownload')} + onReply={() => console.log('onReply')} + /> +
  • +
  • + console.log('onClickAttachment')} + onDownload={() => console.log('onDownload')} + onReply={() => console.log('onReply')} + /> +
  • +
  • + console.log('onClickAttachment')} + onDownload={() => console.log('onDownload')} + onReply={() => console.log('onReply')} + /> +
  • ``` @@ -522,54 +389,66 @@ First, showing the metadata overlay on dark and light images, then a message wit ```jsx - console.log('onClickAttachment')} - /> - console.log('onClickAttachment')} - /> - console.log('onClickAttachment')} - /> - console.log('onClickAttachment')} - /> - console.log('onClickAttachment')} - /> - console.log('onClickAttachment')} - /> +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • ``` @@ -579,42 +458,50 @@ Note that the delivered indicator is always Signal Blue, not the conversation co ```jsx - console.log('onClickAttachment')} - /> - console.log('onClickAttachment')} - /> - console.log('onClickAttachment')} - /> - console.log('onClickAttachment')} - /> +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • ``` @@ -622,35 +509,55 @@ Note that the delivered indicator is always Signal Blue, not the conversation co ```jsx - console.log('onClickAttachment')} - /> - console.log('onClickAttachment')} - /> - console.log('onClickAttachment')} - /> - console.log('onClickAttachment')} - /> +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • ``` @@ -658,39 +565,59 @@ Note that the delivered indicator is always Signal Blue, not the conversation co ```jsx - console.log('onClickAttachment')} - /> - console.log('onClickAttachment')} - /> - console.log('onClickAttachment')} - /> - console.log('onClickAttachment')} - /> +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • ``` @@ -698,47 +625,55 @@ Note that the delivered indicator is always Signal Blue, not the conversation co ```jsx - console.log('onClickAttachment')} - /> - console.log('onClickAttachment')} - /> - console.log('onClickAttachment')} - /> - console.log('onClickAttachment')} - /> +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • ``` @@ -746,51 +681,59 @@ Note that the delivered indicator is always Signal Blue, not the conversation co ```jsx - console.log('onClickAttachment')} - /> - console.log('onClickAttachment')} - /> - console.log('onClickAttachment')} - /> - console.log('onClickAttachment')} - /> +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • ``` @@ -798,80 +741,370 @@ Note that the delivered indicator is always Signal Blue, not the conversation co ```jsx - console.log('onClickAttachment')} - /> - console.log('onClickAttachment')} - /> - console.log('onClickAttachment')} - /> - console.log('onClickAttachment')} - /> +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • ``` #### Video -We don't currently overlay message metadata on top of videos like we do with images. +```jsx + +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
    +``` + +#### Missing images and videos ```jsx - console.log('onClickAttachment')} - /> - console.log('onClickAttachment')} - /> +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
    +``` + +#### Broken source URL images and videos + +```jsx + +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • ``` @@ -879,51 +1112,59 @@ We don't currently overlay message metadata on top of videos like we do with ima ```jsx - console.log('onClickAttachment')} - /> - console.log('onClickAttachment')} - /> - console.log('onClickAttachment')} - /> - console.log('onClickAttachment')} - /> +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • ``` @@ -931,47 +1172,55 @@ We don't currently overlay message metadata on top of videos like we do with ima ```jsx - console.log('onClickAttachment')} - /> - console.log('onClickAttachment')} - /> - console.log('onClickAttachment')} - /> - console.log('onClickAttachment')} - /> +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • ``` @@ -983,99 +1232,113 @@ Voice notes are not shown any differently from audio attachments. ```jsx - console.log('onClickAttachment')} - /> - console.log('onClickAttachment')} - /> - console.log('onClickAttachment')} - /> - console.log('onClickAttachment')} - /> - console.log('onClickAttachment')} - /> - console.log('onClickAttachment')} - /> - +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + +
  • ``` @@ -1083,55 +1346,63 @@ Voice notes are not shown any differently from audio attachments. ```jsx - console.log('onClickAttachment')} - /> - console.log('onClickAttachment')} - /> - console.log('onClickAttachment')} - /> - console.log('onClickAttachment')} - /> +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • ``` @@ -1141,144 +1412,171 @@ Note that the author avatar goes away if `collapseMetadata` is set. ```jsx - - - - - console.log('onClickAttachment')} - authorAvatarPath={util.gifObjectUrl} - /> - console.log('onClickAttachment')} - authorAvatarPath={util.gifObjectUrl} - /> - console.log('onClickAttachment')} - authorAvatarPath={util.gifObjectUrl} - /> - console.log('onClickAttachment')} - authorAvatarPath={util.gifObjectUrl} - /> - - - - +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + console.log('onClickAttachment')} + authorAvatarPath={util.gifObjectUrl} + /> +
  • +
  • + console.log('onClickAttachment')} + authorAvatarPath={util.gifObjectUrl} + /> +
  • +
  • + console.log('onClickAttachment')} + authorAvatarPath={util.gifObjectUrl} + /> +
  • +
  • + console.log('onClickAttachment')} + /> +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • ``` diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 1e36d0fef..b2fe8ed33 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -1,23 +1,28 @@ import React from 'react'; import classNames from 'classnames'; -import moment from 'moment'; -import { padStart } from 'lodash'; -import { formatRelativeTime } from '../../util/formatRelativeTime'; import { isImageTypeSupported, isVideoTypeSupported, } from '../../util/GoogleChrome'; import { MessageBody } from './MessageBody'; -import { Emojify } from './Emojify'; +import { ExpireTimer, getIncrement } from './ExpireTimer'; +import { Timestamp } from './Timestamp'; +import { ContactName } from './ContactName'; import { Quote, QuotedAttachment } from './Quote'; import { EmbeddedContact } from './EmbeddedContact'; import { Contact } from '../../types/Contact'; -import { Localizer } from '../../types/Util'; +import { Color, Localizer } from '../../types/Util'; +import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu'; + import * as MIME from '../../../ts/types/MIME'; +interface Trigger { + handleContextClick: (event: React.MouseEvent) => void; +} + interface Attachment { contentType: MIME.MIMEType; fileName: string; @@ -26,50 +31,69 @@ interface Attachment { /** For messages not already on disk, this will be a data url */ url: string; fileSize?: string; + width: number; + height: number; + screenshot?: { + height: number; + width: number; + url: string; + contentType: MIME.MIMEType; + }; + thumbnail?: { + height: number; + width: number; + url: string; + contentType: MIME.MIMEType; + }; } -interface Props { +export interface Props { + disableMenu?: boolean; text?: string; id?: string; collapseMetadata?: boolean; direction: 'incoming' | 'outgoing'; timestamp: number; - status?: 'sending' | 'sent' | 'delivered' | 'read'; - contacts?: Array; - color: - | 'gray' - | 'blue' - | 'cyan' - | 'deep-orange' - | 'green' - | 'indigo' - | 'pink' - | 'purple' - | 'red' - | 'teal'; + status?: 'sending' | 'sent' | 'delivered' | 'read' | 'error'; + // What if changed this over to a single contact like quote, and put the events on it? + contact?: Contact & { + hasSignalAccount: boolean; + onSendMessage?: () => void; + onClick?: () => void; + }; i18n: Localizer; authorName?: string; authorProfileName?: string; /** Note: this should be formatted for display */ - authorPhoneNumber?: string; + authorPhoneNumber: string; + authorColor: Color; conversationType: 'group' | 'direct'; attachment?: Attachment; quote?: { text: string; - attachments: Array; + attachment?: QuotedAttachment; isFromMe: boolean; - authorName?: string; - authorPhoneNumber?: string; + authorPhoneNumber: string; authorProfileName?: string; + authorName?: string; + authorColor: Color; + onClick?: () => void; }; authorAvatarPath?: string; - contactHasSignalAccount: boolean; expirationLength?: number; expirationTimestamp?: number; - onClickQuote?: () => void; - onSendMessageToContact?: () => void; - onClickContact?: () => void; onClickAttachment?: () => void; + onReply?: () => void; + onRetrySend?: () => void; + onDownload?: () => void; + onDelete?: () => void; + onShowDetail: () => void; +} + +interface State { + expiring: boolean; + expired: boolean; + imageBroken: boolean; } function isImage(attachment?: Attachment) { @@ -80,6 +104,10 @@ function isImage(attachment?: Attachment) { ); } +function hasImage(attachment?: Attachment) { + return attachment && attachment.url; +} + function isVideo(attachment?: Attachment) { return ( attachment && @@ -88,24 +116,18 @@ function isVideo(attachment?: Attachment) { ); } +function hasVideoScreenshot(attachment?: Attachment) { + return attachment && attachment.screenshot && attachment.screenshot.url; +} + function isAudio(attachment?: Attachment) { return ( attachment && attachment.contentType && MIME.isAudio(attachment.contentType) ); } -function getTimerBucket(expiration: number, length: number): string { - const delta = expiration - Date.now(); - if (delta < 0) { - return '00'; - } - if (delta > length) { - return '60'; - } - - const increment = Math.round(delta / length * 12); - - return padStart(String(increment * 5), 2, '0'); +function getInitial(name: string): string { + return name.trim()[0] || '#'; } function getExtension({ @@ -131,58 +153,118 @@ function getExtension({ return null; } -export class Message extends React.Component { - public renderTimer() { - const { - attachment, - direction, - expirationLength, - expirationTimestamp, - text, - } = this.props; +const MINIMUM_IMG_HEIGHT = 150; +const MAXIMUM_IMG_HEIGHT = 300; +const EXPIRATION_CHECK_MINIMUM = 2000; +const EXPIRED_DELAY = 600; - if (!expirationLength || !expirationTimestamp) { - return null; +export class Message extends React.Component { + public captureMenuTriggerBound: (trigger: any) => void; + public showMenuBound: (event: React.MouseEvent) => void; + public handleImageErrorBound: () => void; + + public menuTriggerRef: Trigger | null; + public expirationCheckInterval: any; + public expiredTimeout: any; + + public constructor(props: Props) { + super(props); + + this.captureMenuTriggerBound = this.captureMenuTrigger.bind(this); + this.showMenuBound = this.showMenu.bind(this); + this.handleImageErrorBound = this.handleImageError.bind(this); + + this.menuTriggerRef = null; + this.expirationCheckInterval = null; + this.expiredTimeout = null; + + this.state = { + expiring: false, + expired: false, + imageBroken: false, + }; + } + + public componentDidMount() { + const { expirationLength } = this.props; + if (!expirationLength) { + return; } - const withImageNoCaption = !text && isImage(attachment); - const bucket = getTimerBucket(expirationTimestamp, expirationLength); + const increment = getIncrement(expirationLength); + const checkFrequency = Math.max(EXPIRATION_CHECK_MINIMUM, increment); - return ( -
    - ); + this.checkExpired(); + + this.expirationCheckInterval = setInterval(() => { + this.checkExpired(); + }, checkFrequency); + } + + public componentWillUnmount() { + if (this.expirationCheckInterval) { + clearInterval(this.expirationCheckInterval); + } + if (this.expiredTimeout) { + clearTimeout(this.expiredTimeout); + } + } + + public checkExpired() { + const now = Date.now(); + const { expirationTimestamp, expirationLength } = this.props; + + if (!expirationTimestamp || !expirationLength) { + return; + } + + if (now >= expirationTimestamp) { + this.setState({ + expiring: true, + }); + + const setExpired = () => { + this.setState({ + expired: true, + }); + }; + this.expiredTimeout = setTimeout(setExpired, EXPIRED_DELAY); + } + } + + public handleImageError() { + // tslint:disable-next-line no-console + console.log('Message: Image failed to load; failing over to placeholder'); + this.setState({ + imageBroken: true, + }); } public renderMetadata() { const { + attachment, collapseMetadata, - color, direction, + expirationLength, + expirationTimestamp, i18n, status, - timestamp, text, - attachment, + timestamp, } = this.props; + const { imageBroken } = this.state; if (collapseMetadata) { return null; } - // We're not showing metadata on top of videos since they still have native controls - if (!text && isVideo(attachment)) { - return null; - } - const withImageNoCaption = !text && isImage(attachment); + const withImageNoCaption = Boolean( + !text && + !imageBroken && + ((isImage(attachment) && hasImage(attachment)) || + (isVideo(attachment) && hasVideoScreenshot(attachment))) + ); + const showError = status === 'error' && direction === 'outgoing'; return (
    { : null )} > - - {formatRelativeTime(timestamp, { i18n, extended: true })} - - {this.renderTimer()} + {showError ? ( + + {i18n('sendFailed')} + + ) : ( + + )} + {expirationLength && expirationTimestamp ? ( + + ) : null} - {direction === 'outgoing' ? ( + {direction === 'outgoing' && status !== 'error' ? (
    @@ -231,11 +323,11 @@ export class Message extends React.Component { public renderAuthor() { const { authorName, + authorPhoneNumber, + authorProfileName, conversationType, direction, i18n, - authorPhoneNumber, - authorProfileName, } = this.props; const title = authorName ? authorName : authorPhoneNumber; @@ -244,21 +336,20 @@ export class Message extends React.Component { return null; } - const profileElement = - authorProfileName && !authorName ? ( - - ~ - - ) : null; - return (
    - {profileElement} +
    ); } - // tslint:disable-next-line max-func-body-length + // tslint:disable-next-line max-func-body-length cyclomatic-complexity public renderAttachment() { const { i18n, @@ -270,6 +361,7 @@ export class Message extends React.Component { quote, onClickAttachment, } = this.props; + const { imageBroken } = this.state; if (!attachment) { return null; @@ -282,9 +374,30 @@ export class Message extends React.Component { quote || (conversationType === 'group' && direction === 'incoming'); if (isImage(attachment)) { + if (imageBroken || !attachment.url) { + return ( +
    + {i18n('imageFailedToLoad')} +
    + ); + } + + // Calculating height to prevent reflow when image loads + const height = Math.max(MINIMUM_IMG_HEIGHT, attachment.height || 0); + return ( -
    +
    { ? 'module-message__img-attachment--with-content-above' : null )} + height={Math.min(MAXIMUM_IMG_HEIGHT, height)} src={attachment.url} alt={i18n('imageAttachmentAlt')} - onClick={onClickAttachment} /> {!withCaption && !collapseMetadata ? (
    @@ -304,21 +417,53 @@ export class Message extends React.Component {
    ); } else if (isVideo(attachment)) { + const { screenshot } = attachment; + if (imageBroken || !screenshot || !screenshot.url) { + return ( +
    + {i18n('videoScreenshotFailedToLoad')} +
    + ); + } + + // Calculating height to prevent reflow when image loads + const height = Math.max(MINIMUM_IMG_HEIGHT, screenshot.height || 0); + return ( - + {i18n('videoAttachmentAlt')} + {!withCaption && !collapseMetadata ? ( +
    + ) : null} +
    +
    +
    +
    ); } else if (isAudio(attachment)) { return ( @@ -384,38 +529,26 @@ export class Message extends React.Component { } public renderQuote() { - const { - color, - conversationType, - direction, - i18n, - onClickQuote, - quote, - } = this.props; + const { conversationType, direction, i18n, quote } = this.props; if (!quote) { return null; } - const authorTitle = quote.authorName - ? quote.authorName - : quote.authorPhoneNumber; - const authorProfileName = !quote.authorName - ? quote.authorProfileName - : undefined; const withContentAbove = conversationType === 'group' && direction === 'incoming'; return ( @@ -425,18 +558,13 @@ export class Message extends React.Component { public renderEmbeddedContact() { const { collapseMetadata, - contactHasSignalAccount, - contacts, + contact, conversationType, direction, i18n, - onClickContact, - onSendMessageToContact, text, } = this.props; - const first = contacts && contacts[0]; - - if (!first) { + if (!contact) { return null; } @@ -447,12 +575,11 @@ export class Message extends React.Component { return ( @@ -460,22 +587,15 @@ export class Message extends React.Component { } public renderSendMessageButton() { - const { - contactHasSignalAccount, - contacts, - i18n, - onSendMessageToContact, - } = this.props; - const first = contacts && contacts[0]; - - if (!first || !contactHasSignalAccount) { + const { contact, i18n } = this.props; + if (!contact || !contact.hasSignalAccount) { return null; } return (
    {i18n('sendMessageToContact')} @@ -489,8 +609,8 @@ export class Message extends React.Component { authorPhoneNumber, authorProfileName, authorAvatarPath, + authorColor, collapseMetadata, - color, conversationType, direction, i18n, @@ -509,14 +629,18 @@ export class Message extends React.Component { } if (!authorAvatarPath) { + const label = authorName ? getInitial(authorName) : '#'; + return (
    -
    #
    +
    + {label} +
    ); } @@ -529,9 +653,14 @@ export class Message extends React.Component { } public renderText() { - const { text, i18n, direction } = this.props; + const { text, i18n, direction, status } = this.props; - if (!text) { + const contents = + direction === 'incoming' && status === 'error' + ? i18n('incomingError') + : text; + + if (!contents) { return null; } @@ -539,38 +668,162 @@ export class Message extends React.Component {
    - +
    ); } + public renderError(isCorrectSide: boolean) { + const { status, direction } = this.props; + + if (!isCorrectSide || status !== 'error') { + return null; + } + + return ( +
    +
    +
    + ); + } + + public captureMenuTrigger(triggerRef: Trigger) { + this.menuTriggerRef = triggerRef; + } + public showMenu(event: React.MouseEvent) { + if (this.menuTriggerRef) { + this.menuTriggerRef.handleContextClick(event); + } + } + + public renderMenu(isCorrectSide: boolean, triggerId: string) { + const { + attachment, + direction, + disableMenu, + onDownload, + onReply, + } = this.props; + + if (!isCorrectSide || disableMenu) { + return null; + } + + const downloadButton = attachment ? ( +
    + ) : null; + + const replyButton = ( +
    + ); + + const menuButton = ( + +
    + + ); + + const first = direction === 'incoming' ? downloadButton : menuButton; + const last = direction === 'incoming' ? menuButton : downloadButton; + + return ( +
    + {first} + {replyButton} + {last} +
    + ); + } + + public renderContextMenu(triggerId: string) { + const { + direction, + status, + onDelete, + onRetrySend, + onShowDetail, + i18n, + } = this.props; + + const showRetry = status === 'error' && direction === 'outgoing'; + + return ( + + {i18n('moreInfo')} + {showRetry ? ( + {i18n('retrySend')} + ) : null} + {i18n('deleteMessage')} + + ); + } + public render() { const { - attachment, - color, - conversationType, + authorPhoneNumber, + authorColor, direction, id, - quote, - text, + timestamp, } = this.props; + const { expired, expiring } = this.state; - const imageAndNothingElse = - !text && isImage(attachment) && conversationType !== 'group' && !quote; + // This id is what connects our triple-dot click with our associated pop-up menu. + // It needs to be unique. + const triggerId = String(id || `${authorPhoneNumber}-${timestamp}`); + + if (expired) { + return null; + } return ( -
  • +
    + {this.renderError(direction === 'incoming')} + {this.renderMenu(direction === 'outgoing', triggerId)}
    @@ -583,7 +836,10 @@ export class Message extends React.Component { {this.renderSendMessageButton()} {this.renderAvatar()}
    -
  • + {this.renderError(direction === 'outgoing')} + {this.renderMenu(direction === 'incoming', triggerId)} + {this.renderContextMenu(triggerId)} +
    ); } } diff --git a/ts/components/conversation/MessageDetail.md b/ts/components/conversation/MessageDetail.md new file mode 100644 index 000000000..e0d09fd54 --- /dev/null +++ b/ts/components/conversation/MessageDetail.md @@ -0,0 +1,128 @@ +### Incoming message + +```jsx + console.log('onDelete'), + }} + sentAt={Date.now() - 2 * 60 * 1000} + receivedAt={Date.now() - 10 * 1000} + contacts={[ + { + phoneNumber: '(202) 555-1001', + avatarPath: util.gifObjectUrl, + }, + ]} + i18n={util.i18n} +/> +``` + +### Message to group, multiple contacts + +```jsx + console.log('onDelete'), + }} + sentAt={Date.now()} + contacts={[ + { + phoneNumber: '(202) 555-1001', + profileName: 'Mr. Fire', + avatarPath: util.gifObjectUrl, + status: 'sending', + }, + { + phoneNumber: '(202) 555-1002', + avatarPath: util.pngObjectUrl, + status: 'delivered', + }, + { + phoneNumber: '(202) 555-1003', + color: 'teal', + status: 'read', + }, + ]} + i18n={util.i18n} +/> +``` + +### 1:1 conversation, just one recipient + +```jsx + console.log('onDelete'), + }} + contacts={[ + { + phoneNumber: '(202) 555-1001', + avatarPath: util.gifObjectUrl, + status: 'sending', + }, + ]} + sentAt={Date.now()} + i18n={util.i18n} +/> +``` + +### Errors for some users, including on OutgoingKeyError + +```jsx + console.log('onDelete'), + }} + contacts={[ + { + phoneNumber: '(202) 555-1001', + avatarPath: util.gifObjectUrl, + status: 'error', + errors: [new Error('Something went wrong'), new Error('Bad things')], + }, + { + phoneNumber: '(202) 555-1002', + avatarPath: util.pngObjectUrl, + status: 'error', + isOutgoingKeyError: true, + errors: [new Error(util.i18n('newIdentity'))], + onShowSafetyNumber: () => console.log('onShowSafetyNumber'), + onSendAnyway: () => console.log('onSendAnyway'), + }, + { + phoneNumber: '(202) 555-1003', + color: 'teal', + status: 'read', + }, + ]} + sentAt={Date.now()} + i18n={util.i18n} +/> +``` diff --git a/ts/components/conversation/MessageDetail.tsx b/ts/components/conversation/MessageDetail.tsx new file mode 100644 index 000000000..7d369d351 --- /dev/null +++ b/ts/components/conversation/MessageDetail.tsx @@ -0,0 +1,209 @@ +import React from 'react'; +import classNames from 'classnames'; +import moment from 'moment'; + +import { ContactName } from './ContactName'; +import { Message, Props as MessageProps } from './Message'; +import { Localizer } from '../../types/Util'; + +interface Contact { + status: string; + phoneNumber: string; + name?: string; + profileName?: string; + avatarPath?: string; + color: string; + isOutgoingKeyError: boolean; + + errors?: Array; + onSendAnyway: () => void; + onShowSafetyNumber: () => void; +} + +interface Props { + sentAt: number; + receivedAt: number; + + message: MessageProps; + errors: Array; + contacts: Array; + + i18n: Localizer; +} + +function getInitial(name: string): string { + return name.trim()[0] || '#'; +} + +export class MessageDetail extends React.Component { + public renderAvatar(contact: Contact) { + const { i18n } = this.props; + const { avatarPath, color, phoneNumber, name, profileName } = contact; + + if (!avatarPath) { + const initial = getInitial(name || ''); + + return ( +
    + {initial} +
    + ); + } + + const title = `${name || phoneNumber}${ + !name && profileName ? ` ~${profileName}` : '' + }`; + + return ( + {i18n('contactAvatarAlt', + ); + } + + public renderDeleteButton() { + const { i18n, message } = this.props; + + return ( +
    + +
    + ); + } + + public renderContact(contact: Contact) { + const { i18n } = this.props; + const errors = contact.errors || []; + + const errorComponent = contact.isOutgoingKeyError ? ( +
    + + +
    + ) : null; + const statusComponent = !contact.isOutgoingKeyError ? ( +
    + ) : null; + + return ( +
    + {this.renderAvatar(contact)} +
    +
    + +
    + {errors.map((error, index) => ( +
    + {error.message} +
    + ))} +
    + {errorComponent} + {statusComponent} +
    + ); + } + + public renderContacts() { + const { contacts } = this.props; + + if (!contacts || !contacts.length) { + return null; + } + + return ( +
    + {contacts.map(contact => this.renderContact(contact))} +
    + ); + } + + public render() { + const { errors, message, receivedAt, sentAt, i18n } = this.props; + + return ( +
    +
    + +
    + + + {(errors || []).map(error => ( + + + + + ))} + + + + + {receivedAt ? ( + + + + + ) : null} + + + + +
    + {i18n('error')} + + {' '} + {error.message}{' '} +
    {i18n('sent')} + {moment(sentAt).format('LLLL')}{' '} + + ({sentAt}) + +
    + {i18n('received')} + + {moment(receivedAt).format('LLLL')}{' '} + + ({receivedAt}) + +
    + {message.direction === 'incoming' ? i18n('from') : i18n('to')} +
    + {this.renderContacts()} + {this.renderDeleteButton()} +
    + ); + } +} diff --git a/ts/components/conversation/Notification.md b/ts/components/conversation/Notification.md deleted file mode 100644 index 3ec261087..000000000 --- a/ts/components/conversation/Notification.md +++ /dev/null @@ -1,151 +0,0 @@ -### Timer change - -```jsx -const fromOther = new Whisper.Message({ - type: 'incoming', - flags: textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, - source: '+12025550003', - sent_at: Date.now() - 200000, - expireTimer: 120, - expirationStartTimestamp: Date.now() - 1000, - expirationTimerUpdate: { - source: '+12025550003', - }, -}); -const fromUpdate = new Whisper.Message({ - type: 'incoming', - flags: textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, - source: util.ourNumber, - sent_at: Date.now() - 200000, - expireTimer: 120, - expirationStartTimestamp: Date.now() - 1000, - expirationTimerUpdate: { - fromSync: true, - source: util.ourNumber, - }, -}); -const fromMe = new Whisper.Message({ - type: 'incoming', - flags: textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, - source: util.ourNumber, - sent_at: Date.now() - 200000, - expireTimer: 120, - expirationStartTimestamp: Date.now() - 1000, - expirationTimerUpdate: { - source: util.ourNumber, - }, -}); -const View = Whisper.ExpirationTimerUpdateView; - - - - - console.log('onClick')} /> -; -``` - -### Safety number change - -```js -const incoming = new Whisper.Message({ - type: 'keychange', - sent_at: Date.now() - 200000, - key_changed: '+12025550003', -}); -const View = Whisper.KeyChangeView; - - -; -``` - -### Marking as verified - -```js -const fromPrimary = new Whisper.Message({ - type: 'verified-change', - sent_at: Date.now() - 200000, - verifiedChanged: '+12025550003', - verified: true, -}); -const local = new Whisper.Message({ - type: 'verified-change', - sent_at: Date.now() - 200000, - verifiedChanged: '+12025550003', - local: true, - verified: true, -}); - -const View = Whisper.VerifiedChangeView; - - - -; -``` - -### Marking as not verified - -```js -const fromPrimary = new Whisper.Message({ - type: 'verified-change', - sent_at: Date.now() - 200000, - verifiedChanged: '+12025550003', -}); -const local = new Whisper.Message({ - type: 'verified-change', - sent_at: Date.now() - 200000, - verifiedChanged: '+12025550003', - local: true, -}); - -const View = Whisper.VerifiedChangeView; - - - -; -``` - -### Group update - -```js -const outgoing = new Whisper.Message({ - type: 'outgoing', - sent_at: Date.now() - 200000, - group_update: { - joined: ['+12025550007', '+12025550008', '+12025550009'], - }, -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550003', - type: 'incoming', - }) -); - -const View = Whisper.MessageView; - - - -; -``` - -### End session - -```js -const outgoing = new Whisper.Message({ - type: 'outgoing', - sent_at: Date.now() - 200000, - flags: textsecure.protobuf.DataMessage.Flags.END_SESSION, -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550003', - type: 'incoming', - }) -); - -const View = Whisper.MessageView; - - - -; -``` diff --git a/ts/components/conversation/Notification.tsx b/ts/components/conversation/Notification.tsx deleted file mode 100644 index 18b4c0809..000000000 --- a/ts/components/conversation/Notification.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react'; -import classNames from 'classnames'; - -interface Props { - type: string; - onClick: () => void; -} - -export class Notification extends React.Component { - public renderContents() { - const { type } = this.props; - - return Notification of type {type}; - } - - public render() { - const { onClick } = this.props; - - return ( -
    - {this.renderContents()} -
    - ); - } -} diff --git a/ts/components/conversation/Quote.md b/ts/components/conversation/Quote.md index b3308a05e..0638a14a0 100644 --- a/ts/components/conversation/Quote.md +++ b/ts/components/conversation/Quote.md @@ -4,33 +4,108 @@ ```jsx - console.log('onClickQuote')} - /> - console.log('onClickQuote')} - /> +
  • + console.log('onClick'), + }} + /> +
  • +
  • + console.log('onClick'), + }} + /> +
  • +
    +``` + +#### Name variations + +```jsx + +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • ``` @@ -38,33 +113,35 @@ ```jsx - console.log('onClickQuote')} - /> - console.log('onClickQuote')} - /> +
  • + +
  • +
  • + +
  • ``` @@ -72,35 +149,37 @@ ```jsx - console.log('onClickQuote')} - /> - console.log('onClickQuote')} - /> +
  • + +
  • +
  • + +
  • ``` @@ -108,39 +187,83 @@ ```jsx - console.log('onClickQuote')} - authorAvatarPath={util.gifObjectUrl} - /> - console.log('onClickQuote')} - authorAvatarPath={util.gifObjectUrl} - /> +
  • + +
  • +
  • + +
  • +
    +``` + +#### Long names and context + +```jsx + +
  • + +
  • +
  • + +
  • ``` @@ -148,41 +271,43 @@ ```jsx - console.log('onClickQuote')} - /> - console.log('onClickQuote')} - /> +
  • + +
  • +
  • + +
  • ``` @@ -190,51 +315,51 @@ ```jsx - + console.log('onClickQuote')} - /> - + +
  • + console.log('onClickQuote')} - /> + authorPhoneNumber: '(202) 555-0011', + }} + /> +
  • ``` @@ -242,20 +367,21 @@ ```jsx - + console.log('onClickQuote')} - /> - + +
  • + console.log('onClickQuote')} - /> + authorPhoneNumber: '(202) 555-0011', + }} + /> +
  • ``` @@ -302,16 +427,17 @@ ```jsx - + console.log('onClickQuote')} - /> - + +
  • + console.log('onClickQuote')} - /> + authorPhoneNumber: '(202) 555-0011', + }} + /> +
  • ``` @@ -354,15 +479,16 @@ ```jsx - + console.log('onClickQuote')} - /> - + +
  • + console.log('onClickQuote')} - /> + authorPhoneNumber: '(202) 555-0011', + }} + /> +
  • ``` @@ -404,41 +529,41 @@ ```jsx - + console.log('onClickQuote')} - /> - + +
  • + console.log('onClickQuote')} - /> + authorPhoneNumber: '(202) 555-0011', + }} + /> +
  • ``` @@ -446,16 +571,17 @@ ```jsx - + console.log('onClickQuote')} - /> - + +
  • + console.log('onClickQuote')} - /> + authorPhoneNumber: '(202) 555-0011', + }} + /> +
  • ``` @@ -498,15 +623,16 @@ ```jsx - + console.log('onClickQuote')} - /> - + +
  • + console.log('onClickQuote')} - /> + authorPhoneNumber: '(202) 555-0011', + }} + /> +
  • ``` @@ -548,47 +673,47 @@ ```jsx - + console.log('onClickQuote')} - /> - + +
  • + console.log('onClickQuote')} - /> + authorPhoneNumber: '(202) 555-0011', + }} + /> +
  • ``` @@ -596,43 +721,43 @@ ```jsx - + console.log('onClickQuote')} - /> - + +
  • + console.log('onClickQuote')} - /> + authorPhoneNumber: '(202) 555-0011', + }} + /> +
  • ``` @@ -640,156 +765,170 @@ ```jsx - + console.log('onClickQuote')} - /> - + +
  • + console.log('onClickQuote')} - /> + authorPhoneNumber: '(202) 555-0011', + }} + /> +
  • ``` #### Voice message ```jsx -const outgoing = new Whisper.Message({ - type: 'outgoing', - body: 'I really like it!', - sent_at: Date.now() - 18000000, - quote: { - author: '+12025550011', - id: Date.now() - 1000, - attachments: [ - { - contentType: 'audio/mp3', - fileName: 'agnus_dei.mp4', - }, - ], - }, -}); -const incoming = new Whisper.Message( - Object.assign({}, outgoing.attributes, { - source: '+12025550011', - type: 'incoming', - quote: Object.assign({}, outgoing.attributes.quote, { - author: '+12025550005', - }), - }) -); -const View = Whisper.MessageView; - + console.log('onClickQuote')} - /> - + +
  • + console.log('onClickQuote')} - /> -; + authorPhoneNumber: '(202) 555-0011', + }} + /> +
  • +
    ``` #### Other file type with caption ```jsx - + console.log('onClickQuote')} - /> - + +
  • + console.log('onClickQuote')} - /> + authorPhoneNumber: '(202) 555-0011', + }} + /> +
  • +
  • + +
  • +
  • + +
  • ``` @@ -797,41 +936,41 @@ const View = Whisper.MessageView; ```jsx - + console.log('onClickQuote')} - /> - + +
  • + console.log('onClickQuote')} - /> + authorPhoneNumber: '(202) 555-0011', + }} + /> +
  • ``` @@ -841,45 +980,45 @@ const View = Whisper.MessageView; ```jsx - console.log('onClickQuote')} - text="About six" - i18n={util.i18n} - quote={{ - text: 'How many ferrets do you have?', - attachments: [], - authorPhoneNumber: '(202) 555-0011', - }} - onClickQuote={() => console.log('onClickQuote')} - /> - console.log('onClickQuote')} - color="green" - text="About six" - i18n={util.i18n} - quote={{ - text: 'How many ferrets do you have?', - attachments: [], - authorPhoneNumber: '(202) 555-0011', - }} - onClickQuote={() => console.log('onClickQuote')} - /> +
  • + +
  • +
  • + +
  • ``` @@ -887,43 +1026,43 @@ const View = Whisper.MessageView; ```jsx - console.log('onClickQuote')} - i18n={util.i18n} - quote={{ - text: 'How many ferrets do you have?', - attachments: [], - authorPhoneNumber: '(202) 555-0011', - }} - onClickQuote={() => console.log('onClickQuote')} - /> - console.log('onClickQuote')} - color="green" - i18n={util.i18n} - quote={{ - text: 'How many ferrets do you have?', - attachments: [], - authorPhoneNumber: '(202) 555-0011', - }} - onClickQuote={() => console.log('onClickQuote')} - /> +
  • + +
  • +
  • + +
  • ``` @@ -931,43 +1070,43 @@ const View = Whisper.MessageView; ```jsx - console.log('onClickQuote')} - i18n={util.i18n} - quote={{ - text: 'How many ferrets do you have?', - attachments: [], - authorPhoneNumber: '(202) 555-0011', - }} - onClickQuote={() => console.log('onClickQuote')} - /> - console.log('onClickQuote')} - color="green" - i18n={util.i18n} - quote={{ - text: 'How many ferrets do you have?', - attachments: [], - authorPhoneNumber: '(202) 555-0011', - }} - onClickQuote={() => console.log('onClickQuote')} - /> +
  • + +
  • +
  • + +
  • ``` @@ -975,43 +1114,43 @@ const View = Whisper.MessageView; ```jsx - console.log('onClickQuote')} - i18n={util.i18n} - quote={{ - text: 'How many ferrets do you have?', - attachments: [], - authorPhoneNumber: '(202) 555-0011', - }} - onClickQuote={() => console.log('onClickQuote')} - /> - console.log('onClickQuote')} - color="green" - i18n={util.i18n} - quote={{ - text: 'How many ferrets do you have?', - attachments: [], - authorPhoneNumber: '(202) 555-0011', - }} - onClickQuote={() => console.log('onClickQuote')} - /> +
  • + +
  • +
  • + +
  • ``` @@ -1019,43 +1158,43 @@ const View = Whisper.MessageView; ```jsx - console.log('onClickQuote')} - i18n={util.i18n} - quote={{ - text: 'How many ferrets do you have?', - attachments: [], - authorPhoneNumber: '(202) 555-0011', - }} - onClickQuote={() => console.log('onClickQuote')} - /> - console.log('onClickQuote')} - color="green" - i18n={util.i18n} - quote={{ - text: 'How many ferrets do you have?', - attachments: [], - authorPhoneNumber: '(202) 555-0011', - }} - onClickQuote={() => console.log('onClickQuote')} - /> +
  • + +
  • +
  • + +
  • ``` @@ -1063,45 +1202,45 @@ const View = Whisper.MessageView; ```jsx - console.log('onClickQuote')} - i18n={util.i18n} - quote={{ - text: 'How many ferrets do you have?', - attachments: [], - authorPhoneNumber: '(202) 555-0011', - }} - onClickQuote={() => console.log('onClickQuote')} - /> - console.log('onClickQuote')} - color="green" - i18n={util.i18n} - quote={{ - text: 'How many ferrets do you have?', - attachments: [], - authorPhoneNumber: '(202) 555-0011', - }} - onClickQuote={() => console.log('onClickQuote')} - /> +
  • + +
  • +
  • + +
  • ``` @@ -1115,10 +1254,11 @@ const View = Whisper.MessageView; console.log('onClick')} />
    @@ -1132,16 +1272,15 @@ const View = Whisper.MessageView; console.log('onClick')} />
    @@ -1155,19 +1294,18 @@ const View = Whisper.MessageView; console.log('onClick')} />
    @@ -1181,10 +1319,11 @@ const View = Whisper.MessageView; console.log('Close was clicked!')} + onClose={() => console.log('onClose')} + onClick={() => console.log('onClick')} i18n={window.i18n} />
    @@ -1199,17 +1338,16 @@ const View = Whisper.MessageView; console.log('Close was clicked!')} + onClose={() => console.log('onClose')} + onClick={() => console.log('onClick')} i18n={window.i18n} - attachments={[ - { - contentType: 'image/jpeg', - fileName: 'llama.jpg', - }, - ]} + attachment={{ + contentType: 'image/jpeg', + fileName: 'llama.jpg', + }} />
    @@ -1223,20 +1361,19 @@ const View = Whisper.MessageView; console.log('Close was clicked!')} - i18n={window.i18n} - attachments={[ - { - contentType: 'image/gif', - fileName: 'llama.gif', - thumbnail: { - objectUrl: util.gifObjectUrl, - }, + onClose={() => console.log('onClose')} + onClick={() => console.log('onClick')} + i18n={util.i18n} + attachment={{ + contentType: 'image/gif', + fileName: 'llama.gif', + thumbnail: { + objectUrl: util.gifObjectUrl, }, - ]} + }} />
    diff --git a/ts/components/conversation/Quote.tsx b/ts/components/conversation/Quote.tsx index 3c4e21d35..87be71200 100644 --- a/ts/components/conversation/Quote.tsx +++ b/ts/components/conversation/Quote.tsx @@ -6,15 +6,16 @@ import classNames from 'classnames'; import * as MIME from '../../../ts/types/MIME'; import * as GoogleChrome from '../../../ts/util/GoogleChrome'; -import { Emojify } from './Emojify'; import { MessageBody } from './MessageBody'; -import { Localizer } from '../../types/Util'; +import { Color, Localizer } from '../../types/Util'; +import { ContactName } from './ContactName'; interface Props { - attachments: Array; - color: string; + attachment?: QuotedAttachment; + authorPhoneNumber: string; authorProfileName?: string; - authorTitle: string; + authorName?: string; + authorColor: Color; i18n: Localizer; isFromMe: boolean; isIncoming: boolean; @@ -43,7 +44,7 @@ function validateQuote(quote: Props): boolean { return true; } - if (quote.attachments && quote.attachments.length > 0) { + if (quote.attachment) { return true; } @@ -124,14 +125,13 @@ export class Quote extends React.Component { } public renderGenericFile() { - const { attachments } = this.props; + const { attachment } = this.props; - if (!attachments || !attachments.length) { + if (!attachment) { return; } - const first = attachments[0]; - const { fileName, contentType } = first; + const { fileName, contentType } = attachment; const isGenericFile = !GoogleChrome.isVideoTypeSupported(contentType) && !GoogleChrome.isImageTypeSupported(contentType) && @@ -150,13 +150,12 @@ export class Quote extends React.Component { } public renderIconContainer() { - const { attachments, i18n } = this.props; - if (!attachments || attachments.length === 0) { + const { attachment, i18n } = this.props; + if (!attachment) { return null; } - const first = attachments[0]; - const { contentType, thumbnail } = first; + const { contentType, thumbnail } = attachment; const objectUrl = getObjectUrl(thumbnail); if (GoogleChrome.isVideoTypeSupported(contentType)) { @@ -177,7 +176,7 @@ export class Quote extends React.Component { } public renderText() { - const { i18n, text, attachments } = this.props; + const { i18n, text, attachment } = this.props; if (text) { return ( @@ -187,12 +186,11 @@ export class Quote extends React.Component { ); } - if (!attachments || attachments.length === 0) { + if (!attachment) { return null; } - const first = attachments[0]; - const { contentType, isVoiceMessage } = first; + const { contentType, isVoiceMessage } = attachment; const typeLabel = getTypeLabel({ i18n, contentType, isVoiceMessage }); if (typeLabel) { @@ -231,29 +229,44 @@ export class Quote extends React.Component { } public renderAuthor() { - const { authorProfileName, authorTitle, i18n, isFromMe } = this.props; - - const authorProfileElement = authorProfileName ? ( - - ~ - - ) : null; + const { + authorProfileName, + authorPhoneNumber, + authorName, + authorColor, + i18n, + isFromMe, + } = this.props; return ( -
    +
    {isFromMe ? ( i18n('you') ) : ( - - {authorProfileElement} - + )}
    ); } public render() { - const { color, isIncoming, onClick, withContentAbove } = this.props; + const { + authorColor, + isFromMe, + isIncoming, + onClick, + withContentAbove, + } = this.props; if (!validateQuote(this.props)) { return null; @@ -266,7 +279,10 @@ export class Quote extends React.Component { className={classNames( 'module-quote', isIncoming ? 'module-quote--incoming' : 'module-quote--outgoing', - !isIncoming ? `module-quote--outgoing-${color}` : null, + !isIncoming && !isFromMe + ? `module-quote--outgoing-${authorColor}` + : null, + !isIncoming && isFromMe ? 'module-quote--outgoing-you' : null, !onClick ? 'module-quote--no-click' : null, withContentAbove ? 'module-quote--with-content-above' : null )} diff --git a/ts/components/conversation/ResetSessionNotification.md b/ts/components/conversation/ResetSessionNotification.md new file mode 100644 index 000000000..0083f3552 --- /dev/null +++ b/ts/components/conversation/ResetSessionNotification.md @@ -0,0 +1,7 @@ +### End session + +```js + + + +``` diff --git a/ts/components/conversation/ResetSessionNotification.tsx b/ts/components/conversation/ResetSessionNotification.tsx new file mode 100644 index 000000000..19141a869 --- /dev/null +++ b/ts/components/conversation/ResetSessionNotification.tsx @@ -0,0 +1,19 @@ +import React from 'react'; + +import { Localizer } from '../../types/Util'; + +interface Props { + i18n: Localizer; +} + +export class ResetSessionNotification extends React.Component { + public render() { + const { i18n } = this.props; + + return ( +
    + {i18n('sessionEnded')} +
    + ); + } +} diff --git a/ts/components/conversation/SafetyNumberNotification.md b/ts/components/conversation/SafetyNumberNotification.md new file mode 100644 index 000000000..54249bcdf --- /dev/null +++ b/ts/components/conversation/SafetyNumberNotification.md @@ -0,0 +1,25 @@ +### In group conversation + +```js + + console.log('onVerify')} + /> + +``` + +### In one-on-one conversation + +```js + + console.log('onVerify')} + /> + +``` diff --git a/ts/components/conversation/SafetyNumberNotification.tsx b/ts/components/conversation/SafetyNumberNotification.tsx new file mode 100644 index 000000000..11ffe70af --- /dev/null +++ b/ts/components/conversation/SafetyNumberNotification.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +// import classNames from 'classnames'; + +import { ContactName } from './ContactName'; +import { Intl } from '../Intl'; +import { Localizer } from '../../types/Util'; + +interface Contact { + phoneNumber: string; + profileName?: string; + name?: string; +} + +interface Props { + isGroup: boolean; + contact: Contact; + i18n: Localizer; + onVerify: () => void; +} + +export class SafetyNumberNotification extends React.Component { + public render() { + const { contact, isGroup, i18n, onVerify } = this.props; + + return ( +
    +
    +
    + + + , + ]} + i18n={i18n} + /> +
    +
    + {i18n('verifyNewNumber')} +
    +
    + ); + } +} diff --git a/ts/components/conversation/TimerNotification.md b/ts/components/conversation/TimerNotification.md new file mode 100644 index 000000000..a468031e1 --- /dev/null +++ b/ts/components/conversation/TimerNotification.md @@ -0,0 +1,39 @@ +### From other + +```jsx + + + +``` + +### You changed + +```jsx + + + +``` + +### Changed via sync + +```jsx + + + +``` diff --git a/ts/components/conversation/TimerNotification.tsx b/ts/components/conversation/TimerNotification.tsx new file mode 100644 index 000000000..019be28b1 --- /dev/null +++ b/ts/components/conversation/TimerNotification.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +// import classNames from 'classnames'; + +import { ContactName } from './ContactName'; +import { Intl } from '../Intl'; +import { Localizer } from '../../types/Util'; + +import { missingCaseError } from '../../util/missingCaseError'; + +interface Props { + type: 'fromOther' | 'fromMe' | 'fromSync'; + phoneNumber: string; + profileName?: string; + name?: string; + timespan: string; + i18n: Localizer; +} + +export class TimerNotification extends React.Component { + public renderContents() { + const { i18n, name, phoneNumber, profileName, timespan, type } = this.props; + + switch (type) { + case 'fromOther': + return ( + , + timespan, + ]} + /> + ); + case 'fromMe': + return i18n('youChangedTheTimer', [timespan]); + case 'fromSync': + return i18n('timerSetOnSync', [timespan]); + default: + throw missingCaseError(type); + } + } + + public render() { + const { timespan } = this.props; + + return ( +
    +
    +
    +
    + {timespan} +
    +
    +
    + {this.renderContents()} +
    +
    + ); + } +} diff --git a/ts/components/conversation/Timestamp.md b/ts/components/conversation/Timestamp.md new file mode 100644 index 000000000..29ab84cc1 --- /dev/null +++ b/ts/components/conversation/Timestamp.md @@ -0,0 +1,167 @@ +### All major transitions + +```jsx +function get1201() { + const d = new Date(); + d.setHours(0, 0, 1, 0); + return d.getTime(); +} +function getYesterday1159() { + return get1201() - 2 * 60 * 1000; +} +function getJanuary1201() { + const now = new Date(); + const d = new Date(now.getFullYear(), 0, 1, 0, 1); + return d.getTime(); +} +function getDecember1159() { + return getJanuary1201() - 2 * 60 * 1000; +} + + +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
    ; +``` diff --git a/ts/components/conversation/Timestamp.tsx b/ts/components/conversation/Timestamp.tsx new file mode 100644 index 000000000..99a020384 --- /dev/null +++ b/ts/components/conversation/Timestamp.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import classNames from 'classnames'; +import moment from 'moment'; + +import { formatRelativeTime } from '../../util/formatRelativeTime'; + +import { Localizer } from '../../types/Util'; + +interface Props { + timestamp: number; + withImageNoCaption: boolean; + direction: 'incoming' | 'outgoing'; + module?: string; + i18n: Localizer; +} + +const UPDATE_FREQUENCY = 60 * 1000; + +export class Timestamp extends React.Component { + private interval: any; + + constructor(props: Props) { + super(props); + + this.interval = null; + } + + public componentDidMount() { + const update = () => { + this.setState({ + lastUpdated: Date.now(), + }); + }; + this.interval = setInterval(update, UPDATE_FREQUENCY); + } + + public componentWillUnmount() { + if (this.interval) { + clearInterval(this.interval); + } + } + + public render() { + const { + direction, + i18n, + module, + timestamp, + withImageNoCaption, + } = this.props; + const moduleName = module || 'module-timestamp'; + + return ( + + {formatRelativeTime(timestamp, { i18n, extended: true })} + + ); + } +} diff --git a/ts/components/conversation/VerificationNotification.md b/ts/components/conversation/VerificationNotification.md new file mode 100644 index 000000000..82818f0ee --- /dev/null +++ b/ts/components/conversation/VerificationNotification.md @@ -0,0 +1,49 @@ +### Marking as verified + +```js + + + + +``` + +### Marking as not verified + +```js + + + + +``` diff --git a/ts/components/conversation/VerificationNotification.tsx b/ts/components/conversation/VerificationNotification.tsx new file mode 100644 index 000000000..3079500b2 --- /dev/null +++ b/ts/components/conversation/VerificationNotification.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +// import classNames from 'classnames'; + +import { ContactName } from './ContactName'; +import { Intl } from '../Intl'; +import { Localizer } from '../../types/Util'; + +import { missingCaseError } from '../../util/missingCaseError'; + +interface Contact { + phoneNumber: string; + profileName?: string; + name?: string; +} + +interface Props { + type: 'markVerified' | 'markNotVerified'; + isLocal: boolean; + contact: Contact; + i18n: Localizer; +} + +export class VerificationNotification extends React.Component { + public getStringId() { + const { isLocal, type } = this.props; + + switch (type) { + case 'markVerified': + return isLocal + ? 'youMarkedAsVerified' + : 'youMarkedAsVerifiedOtherDevice'; + case 'markNotVerified': + return isLocal + ? 'youMarkedAsNotVerified' + : 'youMarkedAsNotVerifiedOtherDevice'; + default: + throw missingCaseError(type); + } + } + + public renderContents() { + const { contact, i18n } = this.props; + const id = this.getStringId(); + + return ( + , + ]} + i18n={i18n} + /> + ); + } + + public render() { + const { type } = this.props; + const suffix = + type === 'markVerified' ? 'mark-verified' : 'mark-not-verified'; + + return ( +
    +
    + {this.renderContents()} +
    + ); + } +} diff --git a/ts/components/conversation/media-gallery/AttachmentSection.md b/ts/components/conversation/media-gallery/AttachmentSection.md index c590cc916..4c5bae2bc 100644 --- a/ts/components/conversation/media-gallery/AttachmentSection.md +++ b/ts/components/conversation/media-gallery/AttachmentSection.md @@ -1,6 +1,7 @@ ```jsx const messages = [ { + id: '1', attachments: [ { fileName: 'foo.json', @@ -10,6 +11,7 @@ const messages = [ ], }, { + id: '2', attachments: [ { fileName: 'bar.txt', diff --git a/ts/components/conversation/media-gallery/EmptyState.md b/ts/components/conversation/media-gallery/EmptyState.md index ec354d3da..47a148cc9 100644 --- a/ts/components/conversation/media-gallery/EmptyState.md +++ b/ts/components/conversation/media-gallery/EmptyState.md @@ -6,10 +6,10 @@ display: 'flex', position: 'relative', width: '100%', - height: 300, + height: 200, }} > - +
    ``` @@ -24,6 +24,6 @@ height: 500, }} > - +
    ``` diff --git a/ts/components/conversation/media-gallery/MediaGallery.md b/ts/components/conversation/media-gallery/MediaGallery.md index f0845e789..1cb8dbe85 100644 --- a/ts/components/conversation/media-gallery/MediaGallery.md +++ b/ts/components/conversation/media-gallery/MediaGallery.md @@ -14,6 +14,7 @@ ### Media gallery with media and documents ```jsx +const _ = util._; const DAY_MS = 24 * 60 * 60 * 1000; const MONTH_MS = 30 * DAY_MS; const YEAR_MS = 12 * MONTH_MS; @@ -81,7 +82,14 @@ const messages = _.sortBy( ```jsx const messages = [ { - attachments: [{ fileName: 'foo.jpg', contentType: 'application/json' }], + id: '1', + objectURL: 'https://placekitten.com/76/67', + attachments: [ + { + fileName: 'foo.jpg', + contentType: 'application/json', + }, + ], }, ]; ; diff --git a/ts/components/conversation/media-gallery/MediaGridItem.md b/ts/components/conversation/media-gallery/MediaGridItem.md new file mode 100644 index 000000000..bd7e3cb0d --- /dev/null +++ b/ts/components/conversation/media-gallery/MediaGridItem.md @@ -0,0 +1,30 @@ +## With image + +```jsx +const message = { + id: '1', + objectURL: 'https://placekitten.com/76/67', + attachments: [ + { + fileName: 'foo.jpg', + contentType: 'application/json', + }, + ], +}; +; +``` + +## Without image + +```jsx +const message = { + id: '1', + attachments: [ + { + fileName: 'foo.jpg', + contentType: 'application/json', + }, + ], +}; +; +``` diff --git a/ts/components/utility/BackboneWrapper.md b/ts/components/utility/BackboneWrapper.md deleted file mode 100644 index 63ddd8ae6..000000000 --- a/ts/components/utility/BackboneWrapper.md +++ /dev/null @@ -1,17 +0,0 @@ -Rendering a real `Whisper.MessageView` using `` and -``. - -```jsx -const model = new Whisper.Message({ - type: 'outgoing', - body: 'text', - sent_at: Date.now() - 5000, -}); -const View = Whisper.MessageView; -const options = { - model, -}; - - -; -``` diff --git a/ts/components/utility/BackboneWrapper.tsx b/ts/components/utility/BackboneWrapper.tsx deleted file mode 100644 index 6ed15ceb9..000000000 --- a/ts/components/utility/BackboneWrapper.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import React from 'react'; - -interface Props { - /** The View class, which will be instantiated then treated like a Backbone View */ - readonly View: BackboneViewConstructor; - /** Options to be passed along to the view when constructed */ - readonly options: object; -} - -interface BackboneView { - remove: () => void; - render: () => void; - el: HTMLElement; -} - -interface BackboneViewConstructor { - new (options: object): BackboneView; -} - -/** - * Allows Backbone Views to be rendered inside of React (primarily for the Style Guide) - * while we slowly replace the internals of a given Backbone view with React. - */ -export class BackboneWrapper extends React.Component { - protected el: HTMLElement | null = null; - protected view: BackboneView | null = null; - - public componentWillUnmount() { - this.teardown(); - } - - public shouldComponentUpdate() { - // we're handling all updates manually - return false; - } - - public render() { - return
    ; - } - - protected setEl = (element: HTMLDivElement | null) => { - this.el = element; - this.setup(); - }; - - protected setup = () => { - const { View, options } = this.props; - - if (!this.el) { - return; - } - this.view = new View(options); - this.view.render(); - - // It's important to let the view create its own root DOM element. This ensures that - // its tagName property actually takes effect. - this.el.appendChild(this.view.el); - }; - - protected teardown() { - if (!this.view) { - return; - } - - this.view.remove(); - this.view = null; - } -} diff --git a/ts/selectors/message.ts b/ts/selectors/message.ts new file mode 100644 index 000000000..059ac1c05 --- /dev/null +++ b/ts/selectors/message.ts @@ -0,0 +1,127 @@ +export function messageSelector({ model, view }: { model: any; view: any }) { + // tslint:disable-next-line + console.log({ model, view }); + + return null; + // const avatar = this.model.getAvatar(); + // const avatarPath = avatar && avatar.url; + // const color = avatar && avatar.color; + // const isMe = this.ourNumber === this.model.id; + + // const attachments = this.model.get('attachments') || []; + // const loadedAttachmentViews = Promise.all( + // attachments.map( + // attachment => + // new Promise(async resolve => { + // const attachmentWithData = await loadAttachmentData(attachment); + // const view = new Whisper.AttachmentView({ + // model: attachmentWithData, + // timestamp: this.model.get('sent_at'), + // }); + + // this.listenTo(view, 'update', () => { + // // NOTE: Can we do without `updated` flag now that we use promises? + // view.updated = true; + // resolve(view); + // }); + + // view.render(); + // }) + // ) + // ); + + // Wiring up TimerNotification + + // this.conversation = this.model.getExpirationTimerUpdateSource(); + // this.listenTo(this.conversation, 'change', this.render); + // this.listenTo(this.model, 'unload', this.remove); + // this.listenTo(this.model, 'change', this.onChange); + + // Wiring up SafetyNumberNotification + + // this.conversation = this.model.getModelForKeyChange(); + // this.listenTo(this.conversation, 'change', this.render); + // this.listenTo(this.model, 'unload', this.remove); + + // Wiring up VerificationNotification + + // this.conversation = this.model.getModelForVerifiedChange(); + // this.listenTo(this.conversation, 'change', this.render); + // this.listenTo(this.model, 'unload', this.remove); + + // this.contactView = new Whisper.ReactWrapperView({ + // className: 'contact-wrapper', + // Component: window.Signal.Components.ContactListItem, + // props: { + // isMe, + // color, + // avatarPath, + // phoneNumber: model.getNumber(), + // name: model.getName(), + // profileName: model.getProfileName(), + // verified: model.isVerified(), + // onClick: showIdentity, + // }, + // }); + + // this.$el.append(this.contactView.el); +} + +// We actually don't listen to the model telling us that it's gone if it's disappearing +// onDestroy() { +// if (this.$el.hasClass('expired')) { +// return; +// } +// this.onUnload(); +// }, + +// The backflips required to maintain scroll position when loading images +// Key is only adding the img to the DOM when the image has loaded. +// +// How might we get similar behavior with React? +// +// this.trigger('beforeChangeHeight'); +// this.$('.attachments').append(view.el); +// view.setElement(view.el); +// this.trigger('afterChangeHeight'); + +// Timer code + +// if (this.model.isExpired()) { +// return this; +// } +// if (this.model.isExpiring()) { +// this.render(); +// const totalTime = this.model.get('expireTimer') * 1000; +// const remainingTime = this.model.msTilExpire(); +// const elapsed = (totalTime - remainingTime) / totalTime; +// this.$('.sand').css('transform', `translateY(${elapsed * 100}%)`); +// this.$el.css('display', 'inline-block'); +// this.timeout = setTimeout( +// this.update.bind(this), +// Math.max(totalTime / 100, 500) +// ); +// } + +// Expiring message + +// this.$el.addClass('expired'); +// this.$el.find('.bubble').one('webkitAnimationEnd animationend', e => { +// if (e.target === this.$('.bubble')[0]) { +// this.remove(); +// } +// }); + +// // Failsafe: if in the background, animation events don't fire +// setTimeout(this.remove.bind(this), 1000); + +// Retrying a message +// retryMessage() { +// const retrys = _.filter( +// this.model.get('errors'), +// this.model.isReplayableError.bind(this.model) +// ); +// _.map(retrys, 'number').forEach(number => { +// this.model.resend(number); +// }); +// }, diff --git a/ts/styleguide/StyleGuideUtil.ts b/ts/styleguide/StyleGuideUtil.ts index 912862661..8a144a2f8 100644 --- a/ts/styleguide/StyleGuideUtil.ts +++ b/ts/styleguide/StyleGuideUtil.ts @@ -1,22 +1,12 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import { default as _, padStart, sample } from 'lodash'; -import libphonenumber from 'google-libphonenumber'; - -import moment from 'moment'; import QueryString from 'qs'; -export { _ }; - -// Helper components used in the Style Guide, exposed at 'util' in the global scope via -// the 'context' option in react-styleguidist. +// This file provides helpers for the Style Guide, exposed at 'util' in the global scope +// via the 'context' option in react-styleguidist. +import { default as _ } from 'lodash'; export { ConversationContext } from './ConversationContext'; -export { BackboneWrapper } from '../components/utility/BackboneWrapper'; -// @ts-ignore -import * as Signal from '../../js/modules/signal'; -import { SignalService } from '../protobuf'; +export { _ }; // TypeScript wants two things when you import: // 1) a normal typescript file @@ -25,6 +15,7 @@ import { SignalService } from '../protobuf'; // @ts-ignore import gif from '../../fixtures/giphy-GVNvOUpeYmI7e.gif'; +// 320x240 const gifObjectUrl = makeObjectUrl(gif, 'image/gif'); // @ts-ignore import mp3 from '../../fixtures/incompetech-com-Agnus-Dei-X.mp3'; @@ -37,6 +28,7 @@ import mp4 from '../../fixtures/pixabay-Soap-Bubble-7141.mp4'; const mp4ObjectUrl = makeObjectUrl(mp4, 'video/mp4'); // @ts-ignore import png from '../../fixtures/freepngs-2cd43b_bed7d1327e88454487397574d87b64dc_mv2.png'; +// 800×1200 const pngObjectUrl = makeObjectUrl(png, 'image/png'); // @ts-ignore @@ -63,9 +55,6 @@ function makeObjectUrl(data: ArrayBuffer, contentType: string): string { return URL.createObjectURL(blob); } -const ourNumber = '+12025559999'; -const groupNumber = '+12025550099'; - export { mp3, mp3ObjectUrl, @@ -87,13 +76,8 @@ export { landscapeRedObjectUrl, portraitTeal, portraitTealObjectUrl, - ourNumber, - groupNumber, }; -// Required, or TypeScript complains about adding keys to window -const parent = window as any; - const query = window.location.search.replace(/^\?/, ''); const urlOptions = QueryString.parse(query); const theme = urlOptions.theme || 'light-theme'; @@ -104,123 +88,10 @@ import localeMessages from '../../_locales/en/messages.json'; // @ts-ignore import { setup } from '../../js/modules/i18n'; -import fileSize from 'filesize'; - const i18n = setup(locale, localeMessages); -parent.filesize = fileSize; - -parent.i18n = i18n; -parent.React = React; -parent.ReactDOM = ReactDOM; -parent.moment = moment; - -parent.moment.updateLocale(locale, { - relativeTime: { - h: parent.i18n('timestamp_h'), - m: parent.i18n('timestamp_m'), - s: parent.i18n('timestamp_s'), - }, -}); -parent.moment.locale(locale); - export { theme, locale, i18n }; -// Used by signal.js to set up code that deals with message attachments/avatars -const Attachments = { - createAbsolutePathGetter: () => () => '/fake/path', - createDeleter: () => async () => undefined, - createReader: () => async () => new ArrayBuffer(10), - createWriterForExisting: () => async () => '/fake/path', - createWriterForNew: () => async () => ({ - data: new ArrayBuffer(10), - path: '/fake/path', - }), - getPath: (path: string) => path, -}; - -parent.Signal = Signal.setup({ - Attachments, - userDataPath: '/', - // tslint:disable-next-line:no-backbone-get-set-outside-model - getRegionCode: () => parent.storage.get('regionCode'), -}); -parent.SignalService = SignalService; - -parent.ConversationController._initialFetchComplete = true; -parent.ConversationController._initialPromise = Promise.resolve(); - -const COLORS = [ - 'red', - 'pink', - 'purple', - 'deep_purple', - 'indigo', - 'blue', - 'light_blue', - 'cyan', - 'teal', - 'green', - 'light_green', - 'orange', - 'deep_orange', - 'amber', - 'blue_grey', - 'grey', - 'default', -]; - -const CONTACTS = COLORS.map((color, index) => { - const title = `${sample(['Mr.', 'Mrs.', 'Ms.', 'Unknown'])} ${color} 🔥`; - const key = sample(['name', 'profileName']) as string; - const id = `+1202555${padStart(index.toString(), 4, '0')}`; - - const contact = { - color, - [key]: title, - id, - type: 'private', - }; - - return parent.ConversationController.dangerouslyCreateAndAdd(contact); -}); - -const me = parent.ConversationController.dangerouslyCreateAndAdd({ - id: ourNumber, - name: 'Me!', - type: 'private', - color: 'light_blue', -}); - -const group = parent.ConversationController.dangerouslyCreateAndAdd({ - id: groupNumber, - name: 'A place for sharing cats', - type: 'group', -}); - -group.contactCollection.add(me); -group.contactCollection.add(CONTACTS[0]); -group.contactCollection.add(CONTACTS[1]); -group.contactCollection.add(CONTACTS[2]); - -export { COLORS, CONTACTS, me, group }; - -parent.textsecure.storage.user.getNumber = () => ourNumber; -parent.textsecure.messaging = { - getProfile: async (phoneNumber: string): Promise => { - if (parent.ConversationController.get(phoneNumber)) { - return true; - } - - throw new Error('User does not have Signal account'); - }, -}; - -parent.libphonenumber = libphonenumber.PhoneNumberUtil.getInstance(); -parent.libphonenumber.PhoneNumberFormat = libphonenumber.PhoneNumberFormat; - -parent.storage.put('regionCode', 'US'); - // Telling Lodash to relinquish _ for use by underscore // @ts-ignore _.noConflict(); diff --git a/ts/types/Contact.ts b/ts/types/Contact.ts index bdc7badfa..af63a2282 100644 --- a/ts/types/Contact.ts +++ b/ts/types/Contact.ts @@ -70,10 +70,19 @@ export function contactSelector( contact: Contact, options: { regionCode: string; + hasSignalAccount: boolean; getAbsoluteAttachmentPath: (path: string) => string; + onSendMessage: () => void; + onClick: () => void; } ) { - const { regionCode, getAbsoluteAttachmentPath } = options; + const { + getAbsoluteAttachmentPath, + hasSignalAccount, + onClick, + onSendMessage, + regionCode, + } = options; let { avatar } = contact; if (avatar && avatar.avatar && avatar.avatar.path) { @@ -88,6 +97,9 @@ export function contactSelector( return { ...contact, + hasSignalAccount, + onSendMessage, + onClick, avatar, number: contact.number && diff --git a/ts/types/Util.ts b/ts/types/Util.ts index abe06251b..79826adc7 100644 --- a/ts/types/Util.ts +++ b/ts/types/Util.ts @@ -6,3 +6,15 @@ export type RenderTextCallback = ( ) => JSX.Element | string; export type Localizer = (key: string, values?: Array) => string; + +export type Color = + | 'gray' + | 'blue' + | 'cyan' + | 'deep_orange' + | 'green' + | 'indigo' + | 'pink' + | 'purple' + | 'red' + | 'teal'; diff --git a/ts/util/index.ts b/ts/util/index.ts index f20bceff0..d5af9febc 100644 --- a/ts/util/index.ts +++ b/ts/util/index.ts @@ -1,5 +1,6 @@ import * as GoogleChrome from './GoogleChrome'; import { arrayBufferToObjectURL } from './arrayBufferToObjectURL'; import { missingCaseError } from './missingCaseError'; +import { migrateColor } from './migrateColor'; -export { arrayBufferToObjectURL, GoogleChrome, missingCaseError }; +export { arrayBufferToObjectURL, GoogleChrome, missingCaseError, migrateColor }; diff --git a/tslint.json b/tslint.json index 3e15ab7ad..90c789e6b 100644 --- a/tslint.json +++ b/tslint.json @@ -3,14 +3,8 @@ "extends": ["tslint:recommended", "tslint-react", "tslint-microsoft-contrib"], "jsRules": {}, "rules": { - "align": [ - true, - "arguments", - "elements", - "members", - "parameters", - "statements" - ], + // prettier is handling this + "align": [false], "array-type": [true, "generic"], // Preferred by Prettier: diff --git a/yarn.lock b/yarn.lock index 7e1dc4ca1..319fe602f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6947,6 +6947,13 @@ react-codemirror2@^4.2.1: version "4.2.1" resolved "https://registry.yarnpkg.com/react-codemirror2/-/react-codemirror2-4.2.1.tgz#4ad3c5c60ebbcb34880f961721b51527324ec021" +react-contextmenu@^2.9.2: + version "2.9.2" + resolved "https://registry.yarnpkg.com/react-contextmenu/-/react-contextmenu-2.9.2.tgz#7076075f09e4cad023a1252da347d9e6782d003a" + dependencies: + classnames "^2.2.5" + object-assign "^4.1.0" + react-dev-utils@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-5.0.0.tgz#425ac7c9c40c2603bc4f7ab8836c1406e96bb473"