From 1cc0633786eb841d97ba6299cc6950ff0ae9acfa Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Wed, 11 Apr 2018 23:55:32 -0700 Subject: [PATCH] Full support for quotations in Android theme --- images/image.svg | 1 + js/models/conversations.js | 166 ++++++++++++++++- js/models/messages.js | 16 +- js/views/conversation_view.js | 17 ++ js/views/message_view.js | 77 +++++--- styleguide.config.js | 3 + stylesheets/_conversation.scss | 85 ++++++++- stylesheets/_mixins.scss | 42 ++++- ts/components/conversation/Message.tsx | 2 +- ts/components/conversation/Quote.md | 243 +++++++++++++++++++++---- ts/components/conversation/Quote.tsx | 128 ++++++++----- ts/styleguide/ConversationContext.tsx | 8 +- ts/styleguide/StyleGuideUtil.ts | 66 +++++++ 13 files changed, 730 insertions(+), 124 deletions(-) create mode 100644 images/image.svg diff --git a/images/image.svg b/images/image.svg new file mode 100644 index 000000000..5c61724ff --- /dev/null +++ b/images/image.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/models/conversations.js b/js/models/conversations.js index 2e8599b41..a5e63549b 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -17,7 +17,7 @@ window.Whisper = window.Whisper || {}; - const { Message } = window.Signal.Types; + const { Message, MIME } = window.Signal.Types; const { upgradeMessageSchema, loadAttachmentData } = window.Signal.Migrations; // TODO: Factor out private and group subclasses of Conversation @@ -1027,15 +1027,173 @@ }); }, - fetchMessages() { + makeKey(author, id) { + return `${author}-${id}`; + }, + doMessagesMatch(left, right) { + if (left.get('source') !== right.get('source')) { + return false; + } + if (left.get('sent_at') !== right.get('sent_at')) { + return false; + } + return true; + }, + needData(attachments) { + if (!attachments || attachments.length === 0) { + return false; + } + + const first = attachments[0]; + const { thumbnail, contentType } = first; + + return thumbnail || MIME.isVideo(contentType) || MIME.isImage(contentType); + }, + forceRender(message) { + message.trigger('change', message); + }, + makeObjectUrl(data, contentType) { + const blob = new Blob([data], { + type: contentType, + }); + return URL.createObjectURL(blob); + }, + makeMessagesLookup(messages) { + return messages.reduce((acc, message) => { + const { source, sent_at: sentAt } = message.attributes; + const key = this.makeKey(source, sentAt); + + acc[key] = message; + + return acc; + }, {}); + }, + async loadQuotedMessageFromDatabase(message) { + const { quote } = message.attributes; + const { attachments, id } = quote; + const first = attachments[0]; + + // Maybe in the future we could try to pull the thumbnail from a video ourselves, + // but for now we will rely on incoming thumbnails only. + if (!MIME.isImage(first.contentType)) { + return false; + } + + const collection = new Whisper.MessageCollection(); + await collection.fetchSentAt(id); + const queryMessage = collection.find(m => this.doMessagesMatch(message, m)); + + if (!queryMessage) { + return false; + } + + const queryAttachments = queryMessage.attachments || []; + if (queryAttachments.length === 0) { + return false; + } + + const queryFirst = queryAttachments[0]; + queryMessage.attachments[0] = await loadAttachmentData(queryFirst); + + // Note: it would be nice to take the full-size image and downsample it into + // a true thumbnail here. + // Note: if the attachment is a video, then this object URL won't make any sense + // when we try to use it in an img tag. + queryMessage.updateImageUrl(); + + // We need to differentiate between messages we load from database and those already + // in memory. More cleanup needs to happen on messages from the database because + // they aren't tracked any other way. + // eslint-disable-next-line no-param-reassign + message.quotedMessageFromDatabase = queryMessage; + + this.forceRender(message); + return true; + }, + async loadQuoteThumbnail(message) { + const { quote } = message.attributes; + const { attachments } = quote; + const first = attachments[0]; + const { thumbnail } = first; + + if (!thumbnail) { + return false; + } + const thumbnailWithData = await loadAttachmentData(thumbnail); + thumbnailWithData.objectUrl = this.makeObjectUrl( + thumbnailWithData.data, + thumbnailWithData.contentType + ); + + // If we update this data in place, there's the risk that this data could be + // saved back to the database + // eslint-disable-next-line no-param-reassign + message.quoteThumbnail = thumbnailWithData; + + this.forceRender(message); + return true; + }, + + async processQuotes(messages) { + const lookup = this.makeMessagesLookup(messages); + + const promises = messages.map(async (message) => { + const { quote } = message.attributes; + if (!quote) { + return; + } + + const { attachments } = quote; + if (!this.needData(attachments)) { + return; + } + + // We've already gone through this method once for this message + if (message.quoteIsProcessed) { + return; + } + // eslint-disable-next-line no-param-reassign + message.quoteIsProcessed = true; + + // First, check to see if we've already loaded the target message into memory + const { author, id } = quote; + const key = this.makeKey(author, id); + const quotedMessage = lookup[key]; + + if (quotedMessage) { + // eslint-disable-next-line no-param-reassign + message.quotedMessage = quotedMessage; + this.forceRender(message); + return; + } + + // Then go to the database for the real referenced attachment + const loaded = await this.loadQuotedMessageFromDatabase(message, id); + if (loaded) { + return; + } + + // Finally, use the provided thumbnail + await this.loadQuoteThumbnail(message, quote); + }); + + return Promise.all(promises); + }, + + async fetchMessages() { if (!this.id) { - return Promise.reject(new Error('This conversation has no id!')); + throw new Error('This conversation has no id!'); } - return this.messageCollection.fetchConversation( + + await this.messageCollection.fetchConversation( this.id, null, this.get('unreadCount') ); + + // 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); }, hasMember(number) { diff --git a/js/models/messages.js b/js/models/messages.js index a56a3bbb5..fbaada39a 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -29,7 +29,7 @@ this.on('destroy', this.onDestroy); this.on('change:expirationStartTimestamp', this.setToExpire); this.on('change:expireTimer', this.setToExpire); - this.on('unload', this.revokeImageUrl); + this.on('unload', this.unload); this.setToExpire(); }, idForLogging() { @@ -174,6 +174,20 @@ this.imageUrl = null; } }, + unload() { + if (this.quoteThumbnail) { + URL.revokeObjectURL(this.quoteThumbnail.objectUrl); + this.quoteThumbnail = null; + } + if (this.quotedMessageFromDatabase) { + this.quotedMessageFromDatabase.unload(); + this.quotedMessageFromDatabase = null; + } + if (this.quotedMessage) { + this.quotedMessage = null; + } + this.revokeImageUrl(); + }, revokeImageUrl() { if (this.imageUrl) { URL.revokeObjectURL(this.imageUrl); diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index d19ceb636..db0e29852 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -114,6 +114,7 @@ this.listenTo(this.model, 'expired', this.onExpired); this.listenTo(this.model, 'prune', this.onPrune); this.listenTo(this.model.messageCollection, 'expired', this.onExpiredCollection); + this.listenTo(this.model.messageCollection, 'scroll-to-message', this.scrollToMessage); this.lazyUpdateVerified = _.debounce( this.model.updateVerified.bind(this.model), @@ -529,6 +530,22 @@ } }, + scrollToMessage: function(providedOptions) { + const options = providedOptions || options; + const { id } = options; + + if (id) { + return; + } + + const el = this.$(`#${id}`); + if (!el || el.length === 0) { + return; + } + + el.scrollIntoView(); + }, + scrollToBottom: function() { // If we're above the last seen indicator, we should scroll there instead // Note: if we don't end up at the bottom of the conversation, button will not go away! diff --git a/js/views/message_view.js b/js/views/message_view.js index f2512793f..ae37c451d 100644 --- a/js/views/message_view.js +++ b/js/views/message_view.js @@ -4,6 +4,7 @@ /* global _: false */ /* global emoji_util: false */ /* global Mustache: false */ +/* global ConversationController: false */ // eslint-disable-next-line func-names (function () { @@ -360,44 +361,74 @@ this.timerView.setElement(this.$('.timer')); this.timerView.update(); }, - renderReply() { - const VOICE_MESSAGE_FLAG = - textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE; - function addVoiceMessageFlag(attachment) { - return Object.assign({}, attachment, { - // eslint-disable-next-line no-bitwise - isVoiceMessage: attachment.flags & VOICE_MESSAGE_FLAG, - }); - } - function getObjectUrl(attachment) { - if (!attachment || attachment.objectUrl) { - return attachment; - } + getQuoteObjectUrl() { + // Potential sources of objectUrl, as provided in Conversation.processQuotes + // 1. model.quotedMessage.imageUrl + // 2. model.quoteThumbnail.objectUrl - const blob = new Blob([attachment.data], { - type: attachment.contentType, - }); - return Object.assign({}, attachment, { - objectUrl: URL.createObjectURL(blob), - }); + if (this.model.quotedMessageFromDatabase) { + return this.model.quotedMessageFromDatabase.imageUrl; } - function processAttachment(attachment) { - return getObjectUrl(addVoiceMessageFlag(attachment)); + if (this.model.quotedMessage) { + return this.model.quotedMessage.imageUrl; + } + if (this.model.quoteThumbnail) { + return this.model.quoteThumbnail.objectUrl; } + return null; + }, + renderReply() { + const VOICE_FLAG = textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE; + const objectUrl = this.getQuoteObjectUrl(); const quote = this.model.get('quote'); if (!quote) { return; } + function processAttachment(attachment) { + const thumbnail = !attachment.thumbnail + ? null + : Object.assign({}, attachment.thumbnail, { + objectUrl, + }); + + return Object.assign({}, attachment, { + // eslint-disable-next-line no-bitwise + isVoiceMessage: attachment.flags & VOICE_FLAG, + thumbnail, + }); + } + + const { author } = quote; + const contact = ConversationController.get(author); + const authorTitle = contact ? contact.getTitle() : author; + const authorProfileName = contact ? contact.getProfileName() : null; + const authorColor = contact ? contact.getColor() : 'grey'; + const isIncoming = this.model.isIncoming(); + const quoterContact = this.model.getContact(); + const quoterAuthorColor = quoterContact ? quoterContact.getColor() : null; + const props = { - authorName: 'someone', - authorColor: 'indigo', + authorTitle, + authorProfileName, + authorColor, + isIncoming, + quoterAuthorColor, + openQuotedMessage: () => { + const { quotedMessage } = this.model; + if (quotedMessage) { + this.trigger('scroll-to-message', { id: quotedMessage.id }); + } + }, text: quote.text, attachments: quote.attachments && quote.attachments.map(processAttachment), }; if (!this.replyView) { + if (contact) { + this.listenTo(contact, 'change:color', this.renderReply); + } this.replyView = new Whisper.ReactWrapperView({ el: this.$('.quote-wrapper'), Component: window.Signal.Components.Quote, diff --git a/styleguide.config.js b/styleguide.config.js index ca80d421e..778a887f3 100644 --- a/styleguide.config.js +++ b/styleguide.config.js @@ -27,6 +27,9 @@ module.exports = { // Exposes necessary utilities in the global scope for all readme code snippets util: 'ts/styleguide/StyleGuideUtil', }, + contextDependencies: [ + path.join(__dirname, 'ts/test'), + ], // We don't want one long, single page pagePerSection: true, // Expose entire repository to the styleguidist server, primarily for stylesheets diff --git a/stylesheets/_conversation.scss b/stylesheets/_conversation.scss index db31620b6..9aeb17a4b 100644 --- a/stylesheets/_conversation.scss +++ b/stylesheets/_conversation.scss @@ -379,6 +379,14 @@ li.entry .error-icon-container { display: none; } +.message-list .outgoing .bubble .quote { + margin-top: $android-bubble-quote-padding - $android-bubble-padding-vertical; +} + +.private .message-list .incoming .bubble .quote { + margin-top: $android-bubble-quote-padding - $android-bubble-padding-vertical; +} + .sender { font-size: smaller; opacity: 0.8; @@ -435,6 +443,8 @@ span.status { } } + + .bubble { position: relative; left: -2px; @@ -452,16 +462,18 @@ span.status { .quote { @include message-replies-colors; + @include twenty-percent-colors; + cursor: pointer; display: flex; flex-direction: row; align-items: stretch; + overflow: hidden; border-radius: 2px; background-color: #eee; position: relative; - margin-top: $android-bubble-quote-padding - $android-bubble-padding-vertical; margin-right: $android-bubble-quote-padding - $android-bubble-padding-horizontal; margin-left: $android-bubble-quote-padding - $android-bubble-padding-horizontal; margin-bottom: 0.5em; @@ -480,10 +492,25 @@ span.status { .author { font-weight: bold; margin-bottom: 0.3em; + @include text-colors; + + .profile-name { + font-size: smaller; + } } .text { white-space: pre-wrap; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + + // Note: -webkit-line-clamp doesn't work for RTL text, and it forces you to use + // ... as the truncation indicator. That's not a solution that works well for + // all languages. More resources: + // - http://hackingui.com/front-end/a-pure-css-solution-for-multiline-text-truncation/ + // - https://medium.com/mofed/css-line-clamp-the-good-the-bad-and-the-straight-up-broken-865413f16e5 } .type-label { @@ -498,22 +525,53 @@ span.status { .icon-container { flex: initial; - min-width: 48px; - @include aspect-ratio(1, 1); + width: 48px; + height: 48px; + position: relative; - .inner { - border: 1px red solid; - max-height: 48px; - max-width: 48px; + .circle-background { + position: absolute; + left: 6px; + right: 6px; + top: 6px; + bottom: 6px; + + border-radius: 50%; + @include avatar-colors; + &.white { + background-color: white; + } + } + + .icon { + position: absolute; + left: 12px; + right: 12px; + top: 12px; + bottom: 12px; &.file { - @include color-svg('../images/file.svg', $grey_d); + @include color-svg('../images/file.svg', white); + } + &.image { + @include color-svg('../images/image.svg', white); } &.microphone { - @include color-svg('../images/microphone.svg', $grey_d); + @include color-svg('../images/microphone.svg', white); } &.play { - @include color-svg('../images/play.svg', $grey_d); + @include color-svg('../images/play.svg', white); + } + + @include avatar-colors; + } + + .inner { + position: relative; + + img { + max-width: 100%; + max-height: 100%; } } } @@ -579,6 +637,13 @@ span.status { .avatar, .bubble { float: left; } + + .bubble { + .quote { + background-color: rgba(white, 0.6); + border-left-color: white; + } + } } .outgoing { diff --git a/stylesheets/_mixins.scss b/stylesheets/_mixins.scss index 33d8dfb5f..e66e274c4 100644 --- a/stylesheets/_mixins.scss +++ b/stylesheets/_mixins.scss @@ -67,8 +67,46 @@ &.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 ; } + &.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? diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 87eb3147a..2c92e08fc 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -13,7 +13,7 @@ export class Message extends React.Component<{}, {}> {
-
+

diff --git a/ts/components/conversation/Quote.md b/ts/components/conversation/Quote.md index 69bc0d7a5..a15790f39 100644 --- a/ts/components/conversation/Quote.md +++ b/ts/components/conversation/Quote.md @@ -10,15 +10,85 @@ const outgoing = new Whisper.Message({ sent_at: Date.now() - 18000000, quote: { text: 'How many ferrets do you have?', - author: '+12025550100', + author: '+12025550011', id: Date.now() - 1000, }, }); const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { - source: '+12025550100', + source: '+12025550011', type: 'incoming', quote: Object.assign({}, outgoing.attributes.quote, { - author: '+12025550200', + author: '+12025550005', + }), +})); +const View = Whisper.MessageView; + + + + +``` + +#### In a group conversation + +```jsx +const outgoing = new Whisper.Message({ + type: 'outgoing', + body: 'About six', + sent_at: Date.now() - 18000000, + quote: { + text: 'How many ferrets do you have?', + author: '+12025550010', + id: Date.now() - 1000, + }, +}); +const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { + source: '+12025550007', + type: 'incoming', + quote: Object.assign({}, outgoing.attributes.quote, { + author: '+12025550002', + }), +})); +const View = Whisper.MessageView; + + + + +``` + +#### A lot of text in quotation + +```jsx +const outgoing = new Whisper.Message({ + type: 'outgoing', + body: 'About six', + sent_at: Date.now() - 18000000, + quote: { + text: + 'I have lots of things to say. First, I enjoy otters. Second best are cats. ' + + 'After that, probably dogs. And then, you know, reptiles of all types. ' + + 'Then birds. They are dinosaurs, after all. Then cephalapods, because they are ' + + 'really smart.', + author: '+12025550011', + id: Date.now() - 1000, + }, +}); +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; @@ -37,13 +107,17 @@ const View = Whisper.MessageView; #### Image with caption ```jsx +const quotedMessage = { + imageUrl: util.gifObjectUrl, + id: '3234-23423-2342', +}; const outgoing = new Whisper.Message({ type: 'outgoing', body: "Totally, it's a pretty unintuitive concept.", sent_at: Date.now() - 18000000, quote: { text: 'I am pretty confused about Pi.', - author: '+12025550100', + author: '+12025550011', id: Date.now() - 1000, attachments: [ { @@ -51,19 +125,22 @@ const outgoing = new Whisper.Message({ fileName: 'pi.gif', thumbnail: { contentType: 'image/gif', - data: util.gif, }, }, ], }, }); const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { - source: '+12025550100', + source: '+12025550011', type: 'incoming', quote: Object.assign({}, outgoing.attributes.quote, { - author: '+12025550200', + author: '+12025550005', }), })); + +outgoing.quotedMessage = quotedMessage; +incoming.quotedMessage = quotedMessage; + const View = Whisper.MessageView; + + + +``` + +#### Image with no thumbnail + +```jsx +const outgoing = new Whisper.Message({ + type: 'outgoing', + body: "Yeah, pi. Tough to wrap your head around.", + sent_at: Date.now() - 18000000, + quote: { + author: '+12025550011', + id: Date.now() - 1000, + attachments: [ + { + contentType: 'image/gif', + fileName: 'pi.gif', + }, + ], + }, +}); +const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { + source: '+12025550011', type: 'incoming', quote: Object.assign({}, outgoing.attributes.quote, { - author: '+12025550200', + author: '+12025550005', }), })); + const View = Whisper.MessageView; + + + +``` + +#### Video with no thumbnail + +```jsx +const outgoing = new Whisper.Message({ + type: 'outgoing', + body: "Awesome!", + sent_at: Date.now() - 18000000, + quote: { + author: '+12025550011', + id: Date.now() - 1000, + attachments: [ + { + contentType: 'video/mp4', + fileName: 'freezing_bubble.mp4', + }, + ], + }, +}); +const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { + source: '+12025550011', type: 'incoming', quote: Object.assign({}, outgoing.attributes.quote, { - author: '+12025550200', + author: '+12025550005', }), })); + const View = Whisper.MessageView; ) => string; - authorName: string; + authorTitle: string; + authorProfileName?: string; authorColor: string; - attachments: Array; text: string; + attachments: Array; + openQuotedMessage?: () => void; + quoterAuthorColor?: string, + isIncoming: boolean, } interface QuotedAttachment { fileName: string; contentType: string; + thumbnail?: Attachment, + /* Not included in protobuf */ isVoiceMessage: boolean; - objectUrl: string; - thumbnail: { - contentType: string; - data: ArrayBuffer; - } +} + +interface Attachment { + contentType: string; + /* Not included in protobuf, and is loaded asynchronously */ + objectUrl?: string; } function validateQuote(quote: Props): boolean { @@ -36,51 +43,68 @@ function validateQuote(quote: Props): boolean { return false; } -function getContentType(attachments: Array): string | null { - if (!attachments || attachments.length === 0) { - return null; +function getObjectUrl(thumbnail: Attachment | undefined): string | null { + if (thumbnail && thumbnail.objectUrl) { + return thumbnail.objectUrl; } - const first = attachments[0]; - return first.contentType; + return null; } export class Quote extends React.Component { - public renderIcon(first: QuotedAttachment) { - const contentType = first.contentType; - const objectUrl = first.objectUrl; + public renderImage(url: string, icon?: string) { + return ( +

+
+ + {icon + ?
+ : null + } +
+
+ ); + } - if (Mime.isVideo(contentType)) { - // Render play icon on top of thumbnail - // We'd have to generate our own thumbnail from a local video?? - return
Video
; - } else if (Mime.isImage(contentType)) { - if (objectUrl) { - return
; - } else { - return
Loading Widget
- } - } else if (Mime.isAudio(contentType)) { - // Show microphone inner in circle - return
Audio
; - } else { - // Show file icon - return
File
; - } + public renderIcon(icon: string) { + const { authorColor, isIncoming, quoterAuthorColor } = this.props; + + const backgroundColor = isIncoming ? 'white' : authorColor; + const iconColor = isIncoming ? quoterAuthorColor : 'white'; + + return ( +
+
+
+
+ ); } public renderIconContainer() { const { attachments } = this.props; - if (!attachments || attachments.length === 0) { return null; } const first = attachments[0]; + const { contentType, thumbnail } = first; + const objectUrl = getObjectUrl(thumbnail); + + if (Mime.isVideo(contentType)) { + return objectUrl + ? this.renderImage(objectUrl, 'play') + : this.renderIcon('play'); + } + if (Mime.isImage(contentType)) { + return objectUrl + ? this.renderImage(objectUrl) + : this.renderIcon('image'); + } + if (Mime.isAudio(contentType)) { + return this.renderIcon('microphone'); + } - return
- {this.renderIcon(first)} -
+ return this.renderIcon('file'); } public renderText() { @@ -94,20 +118,19 @@ export class Quote extends React.Component { return null; } - const contentType = getContentType(attachments); const first = attachments[0]; - const fileName = first.fileName; - - console.log(contentType); + const { contentType, fileName, isVoiceMessage } = first; if (Mime.isVideo(contentType)) { return
{i18n('video')}
; - } else if (Mime.isImage(contentType)) { + } + if (Mime.isImage(contentType)) { return
{i18n('photo')}
; - } else if (Mime.isAudio(contentType) && first.isVoiceMessage) { + } + if (Mime.isAudio(contentType) && isVoiceMessage) { return
{i18n('voiceMessage')}
; - } else if (Mime.isAudio(contentType)) { - console.log(first); + } + if (Mime.isAudio(contentType)) { return
{i18n('audio')}
; } @@ -115,16 +138,27 @@ export class Quote extends React.Component { } public render() { - const { authorName, authorColor } = this.props; + const { + authorTitle, + authorProfileName, + authorColor, + openQuotedMessage, + } = this.props; if (!validateQuote(this.props)) { return null; } return ( -
+
-
{authorName}
+
+ {authorTitle}{' '} + {authorProfileName + ? ~{authorProfileName} + : null + } +
{this.renderText()}
{this.renderIconContainer()} diff --git a/ts/styleguide/ConversationContext.tsx b/ts/styleguide/ConversationContext.tsx index d05f32e50..7efce4cec 100644 --- a/ts/styleguide/ConversationContext.tsx +++ b/ts/styleguide/ConversationContext.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import classnames from 'classnames'; interface Props { @@ -6,6 +7,7 @@ interface Props { * Corresponds to the theme setting in the app, and the class added to the root element. */ theme: 'ios' | 'android' | 'android-dark'; + conversationType: 'private' | 'group'; } /** @@ -14,11 +16,11 @@ interface Props { */ export class ConversationContext extends React.Component { public render() { - const { theme } = this.props; + const { theme, conversationType } = this.props; return ( -
-
+
+
    {this.props.children} diff --git a/ts/styleguide/StyleGuideUtil.ts b/ts/styleguide/StyleGuideUtil.ts index 5c7b235fd..856674b05 100644 --- a/ts/styleguide/StyleGuideUtil.ts +++ b/ts/styleguide/StyleGuideUtil.ts @@ -1,6 +1,7 @@ import moment from 'moment'; import qs from 'qs'; +import { sample, padStart } from 'lodash'; import React from 'react'; import ReactDOM from 'react-dom'; @@ -23,18 +24,36 @@ import { Quote } from '../components/conversation/Quote'; // @ts-ignore import gif from '../../fixtures/giphy-GVNvOUpeYmI7e.gif'; +const gifObjectUrl = makeObjectUrl(gif, 'image/gif'); // @ts-ignore import mp3 from '../../fixtures/incompetech-com-Agnus-Dei-X.mp3'; +const mp3ObjectUrl = makeObjectUrl(mp3, 'audio/mp3'); // @ts-ignore import txt from '../../fixtures/lorem-ipsum.txt'; +const txtObjectUrl = makeObjectUrl(txt, 'text/plain'); // @ts-ignore import mp4 from '../../fixtures/pixabay-Soap-Bubble-7141.mp4'; +const mp4ObjectUrl = makeObjectUrl(mp4, 'video/mp4'); + +function makeObjectUrl(data: ArrayBuffer, contentType: string): string { + const blob = new Blob([data], { + type: contentType, + }); + return URL.createObjectURL(blob); +} + +const ourNumber = '+12025559999'; export { mp3, + mp3ObjectUrl, gif, + gifObjectUrl, mp4, + mp4ObjectUrl, txt, + txtObjectUrl, + ourNumber }; @@ -82,3 +101,50 @@ parent.Signal.Components = { 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); +}); + +export { + COLORS, + CONTACTS, +} + +parent.textsecure.storage.user.getNumber = () => ourNumber; + +// Telling Lodash to relinquish _ for use by underscore +// @ts-ignore +_.noConflict();