diff --git a/images/image.svg b/images/image.svg new file mode 100644 index 0000000000..5c61724ff1 --- /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 2e8599b41e..a5e63549b8 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() { - if (!this.id) { - return Promise.reject(new Error('This conversation has no id!')); + makeKey(author, id) { + return `${author}-${id}`; + }, + doMessagesMatch(left, right) { + if (left.get('source') !== right.get('source')) { + return false; } - return this.messageCollection.fetchConversation( + 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) { + throw new Error('This conversation has no id!'); + } + + 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 a56a3bbb53..fbaada39af 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 d19ceb6360..db0e29852a 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 f2512793f8..ae37c451d8 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(); }, + getQuoteObjectUrl() { + // Potential sources of objectUrl, as provided in Conversation.processQuotes + // 1. model.quotedMessage.imageUrl + // 2. model.quoteThumbnail.objectUrl + + if (this.model.quotedMessageFromDatabase) { + return this.model.quotedMessageFromDatabase.imageUrl; + } + if (this.model.quotedMessage) { + return this.model.quotedMessage.imageUrl; + } + if (this.model.quoteThumbnail) { + return this.model.quoteThumbnail.objectUrl; + } + + return null; + }, 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; - } - - const blob = new Blob([attachment.data], { - type: attachment.contentType, - }); - return Object.assign({}, attachment, { - objectUrl: URL.createObjectURL(blob), - }); - } - function processAttachment(attachment) { - return getObjectUrl(addVoiceMessageFlag(attachment)); - } - + 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 ca80d421ea..778a887f33 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 db31620b63..9aeb17a4b9 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 33d8dfb5fb..e66e274c47 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 87eb3147a2..2c92e08fcf 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 69bc0d7a52..a15790f39e 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;
+
+ {icon
+ ?
+ : null
+ }
+
{this.props.children}
diff --git a/ts/styleguide/StyleGuideUtil.ts b/ts/styleguide/StyleGuideUtil.ts
index 5c7b235fd3..856674b054 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();