From 21bf02c94db3ff9339a76b445fe2a523d5b94e00 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Mon, 9 Apr 2018 18:31:52 -0700 Subject: [PATCH] Fixed examples in Quote.md, rough Android visuals --- _locales/en/messages.json | 12 +++ background.html | 1 + images/play.svg | 1 + js/modules/types/mime.js | 8 ++ js/views/attachment_view.js | 3 +- js/views/message_view.js | 53 ++++++++++- package.json | 1 + stylesheets/_conversation.scss | 69 +++++++++++++++ stylesheets/_mixins.scss | 58 ++++++++++++ stylesheets/_variables.scss | 5 ++ test/index.html | 1 + test/styleguide/legacy_templates.js | 1 + ts/components/conversation/Quote.md | 122 ++++++++++++++----------- ts/components/conversation/Quote.tsx | 128 ++++++++++++++++++++++++++- yarn.lock | 4 + 15 files changed, 408 insertions(+), 59 deletions(-) create mode 100644 images/play.svg diff --git a/_locales/en/messages.json b/_locales/en/messages.json index de3771b9c..762177d7e 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -428,6 +428,18 @@ "selectAContact": { "message": "Select a contact or group to start chatting." }, + "audio": { + "message": "Audio", + "description": "Shown in a quotation of a message containing an audio attachment if no text was originally provided with that attachment" + }, + "video": { + "message": "Video", + "description": "Shown in a quotation of a message containing a video if no text was originally provided with that video" + }, + "photo": { + "message": "Photo", + "description": "Shown in a quotation of a message containing a photo if no text was originally provided with that image" + }, "ok": { "message": "OK" }, diff --git a/background.html b/background.html index 58e1b1946..83d5740a7 100644 --- a/background.html +++ b/background.html @@ -278,6 +278,7 @@ {{ /profileName }}
+

{{ #message }}

{{ message }}
{{ /message }} diff --git a/images/play.svg b/images/play.svg new file mode 100644 index 000000000..87a70f2d1 --- /dev/null +++ b/images/play.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/modules/types/mime.js b/js/modules/types/mime.js index 82228f9dc..b149aead4 100644 --- a/js/modules/types/mime.js +++ b/js/modules/types/mime.js @@ -1,2 +1,10 @@ exports.isJPEG = mimeType => mimeType === 'image/jpeg'; + +exports.isVideo = mimeType => + mimeType.startsWith('video/') && mimeType !== 'video/wmv'; + +exports.isImage = mimeType => + mimeType.startsWith('image/') && mimeType !== 'image/tiff'; + +exports.isAudio = mimeType => mimeType.startsWith('audio/'); diff --git a/js/views/attachment_view.js b/js/views/attachment_view.js index 2fbcc7e1c..b7ae7d982 100644 --- a/js/views/attachment_view.js +++ b/js/views/attachment_view.js @@ -136,7 +136,8 @@ return this.model.contentType.startsWith('audio/'); }, isVideo() { - return this.model.contentType.startsWith('video/'); + const type = this.model.contentType; + return type.startsWith('video/') && type !== 'image/wmv'; }, isImage() { const type = this.model.contentType; diff --git a/js/views/message_view.js b/js/views/message_view.js index a40761854..f2512793f 100644 --- a/js/views/message_view.js +++ b/js/views/message_view.js @@ -235,7 +235,6 @@ // Failsafe: if in the background, animation events don't fire setTimeout(this.remove.bind(this), 1000); }, - /* jshint ignore:start */ onUnload() { if (this.avatarView) { this.avatarView.remove(); @@ -252,6 +251,9 @@ if (this.timeStampView) { this.timeStampView.remove(); } + if (this.replyView) { + this.replyView.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 @@ -265,7 +267,6 @@ this.remove(); }, - /* jshint ignore:end */ onDestroy() { if (this.$el.hasClass('expired')) { return; @@ -359,6 +360,53 @@ 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; + } + + 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 quote = this.model.get('quote'); + if (!quote) { + return; + } + + const props = { + authorName: 'someone', + authorColor: 'indigo', + text: quote.text, + attachments: quote.attachments && quote.attachments.map(processAttachment), + }; + + if (!this.replyView) { + this.replyView = new Whisper.ReactWrapperView({ + el: this.$('.quote-wrapper'), + Component: window.Signal.Components.Quote, + props, + }); + } else { + this.replyView.update(props); + } + }, isImageWithoutCaption() { const attachments = this.model.get('attachments'); const body = this.model.get('body'); @@ -406,6 +454,7 @@ this.renderRead(); this.renderErrors(); this.renderExpiring(); + this.renderReply(); // NOTE: We have to do this in the background (`then` instead of `await`) diff --git a/package.json b/package.json index 8a5396ece..3d5226c71 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ }, "devDependencies": { "@types/chai": "^4.1.2", + "@types/classnames": "^2.2.3", "@types/lodash": "^4.14.106", "@types/mocha": "^5.0.0", "@types/qs": "^6.5.1", diff --git a/stylesheets/_conversation.scss b/stylesheets/_conversation.scss index 89f36953d..db31620b6 100644 --- a/stylesheets/_conversation.scss +++ b/stylesheets/_conversation.scss @@ -450,6 +450,75 @@ span.status { max-width: calc(100% - 45px - #{$error-icon-size}); // avatar size + padding + error-icon size } + .quote { + @include message-replies-colors; + + display: flex; + flex-direction: row; + align-items: stretch; + + 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; + + // Accent color border: + border-left-width: 3; + border-left-style: solid; + + .primary { + flex-grow: 1; + padding-left: 10px; + padding-right: 10px; + padding-top: 6px; + padding-bottom: 6px; + + .author { + font-weight: bold; + margin-bottom: 0.3em; + } + + .text { + white-space: pre-wrap; + } + + .type-label { + font-style: italic; + font-size: 12px; + } + + .filename-label { + font-size: 12px; + } + } + + .icon-container { + flex: initial; + min-width: 48px; + @include aspect-ratio(1, 1); + + .inner { + border: 1px red solid; + max-height: 48px; + max-width: 48px; + + &.file { + @include color-svg('../images/file.svg', $grey_d); + } + &.microphone { + @include color-svg('../images/microphone.svg', $grey_d); + } + &.play { + @include color-svg('../images/play.svg', $grey_d); + } + } + } + } + .body { margin-top: 0.5em; white-space: pre-wrap; diff --git a/stylesheets/_mixins.scss b/stylesheets/_mixins.scss index 1b81f1958..33d8dfb5f 100644 --- a/stylesheets/_mixins.scss +++ b/stylesheets/_mixins.scss @@ -1,3 +1,20 @@ +@mixin aspect-ratio($width, $height) { + position: relative; + &:before { + display: block; + content: ""; + width: 100%; + padding-top: ($height / $width) * 100%; + } + > .inner { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + } +} + @mixin color-svg($svg, $color) { -webkit-mask: url($svg) no-repeat center; -webkit-mask-size: 100%; @@ -53,6 +70,47 @@ &.grey { background-color: #666666 ; } &.default { background-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; diff --git a/stylesheets/_variables.scss b/stylesheets/_variables.scss index 9c9e6420e..70364d890 100644 --- a/stylesheets/_variables.scss +++ b/stylesheets/_variables.scss @@ -82,3 +82,8 @@ $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/test/index.html b/test/index.html index 0b92f4d65..3a5aef198 100644 --- a/test/index.html +++ b/test/index.html @@ -213,6 +213,7 @@ {{ /profileName }}
+

{{ #message }}

{{ message }}
{{ /message }} diff --git a/test/styleguide/legacy_templates.js b/test/styleguide/legacy_templates.js index 04fc8df60..4c7f7e14d 100644 --- a/test/styleguide/legacy_templates.js +++ b/test/styleguide/legacy_templates.js @@ -33,6 +33,7 @@ window.Whisper.View.Templates = { {{ /profileName }}
+

{{ #message }}

{{ message }}
{{ /message }} diff --git a/ts/components/conversation/Quote.md b/ts/components/conversation/Quote.md index 8a930eb1c..69bc0d7a5 100644 --- a/ts/components/conversation/Quote.md +++ b/ts/components/conversation/Quote.md @@ -45,14 +45,16 @@ const outgoing = new Whisper.Message({ text: 'I am pretty confused about Pi.', author: '+12025550100', id: Date.now() - 1000, - attachments: { - contentType: 'image/gif', - fileName: 'pi.gif', - thumbnail: { + attachments: [ + { contentType: 'image/gif', - data: util.gif, - } - } + fileName: 'pi.gif', + thumbnail: { + contentType: 'image/gif', + data: util.gif, + }, + }, + ], }, }); const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { @@ -85,14 +87,16 @@ const outgoing = new Whisper.Message({ quote: { author: '+12025550100', id: Date.now() - 1000, - attachments: { - contentType: 'image/gif', - fileName: 'pi.gif', - thumbnail: { + attachments: [ + { contentType: 'image/gif', - data: util.gif, - } - } + fileName: 'pi.gif', + thumbnail: { + contentType: 'image/gif', + data: util.gif, + }, + }, + ], }, }); const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { @@ -126,14 +130,16 @@ const outgoing = new Whisper.Message({ author: '+12025550100', text: 'Check out this video I found!', id: Date.now() - 1000, - attachments: { - contentType: 'video/mp4', - fileName: 'freezing_bubble.mp4', - thumbnail: { - contentType: 'image/gif', - data: util.gif, - } - } + attachments: [ + { + contentType: 'video/mp4', + fileName: 'freezing_bubble.mp4', + thumbnail: { + contentType: 'image/gif', + data: util.gif, + }, + }, + ], }, }); const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { @@ -166,14 +172,16 @@ const outgoing = new Whisper.Message({ quote: { author: '+12025550100', id: Date.now() - 1000, - attachments: { - contentType: 'video/mp4', - fileName: 'freezing_bubble.mp4', - thumbnail: { - contentType: 'image/gif', - data: util.gif, - } - } + attachments: [ + { + contentType: 'video/mp4', + fileName: 'freezing_bubble.mp4', + thumbnail: { + contentType: 'image/gif', + data: util.gif, + } + }, + ], }, }); const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { @@ -207,10 +215,12 @@ const outgoing = new Whisper.Message({ author: '+12025550100', text: 'Check out this beautiful song!', id: Date.now() - 1000, - attachments: { - contentType: 'audio/mp3', - fileName: 'agnus_dei.mp4', - } + attachments: [ + { + contentType: 'audio/mp3', + fileName: 'agnus_dei.mp4', + }, + ], }, }); const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { @@ -243,10 +253,12 @@ const outgoing = new Whisper.Message({ quote: { author: '+12025550100', id: Date.now() - 1000, - attachments: { - contentType: 'audio/mp3', - fileName: 'agnus_dei.mp4', - } + attachments: [ + { + contentType: 'audio/mp3', + fileName: 'agnus_dei.mp4', + }, + ], }, }); const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { @@ -279,12 +291,14 @@ const outgoing = new Whisper.Message({ quote: { author: '+12025550100', id: Date.now() - 1000, - attachments: { - // proposed as of afternoon of 4/6 in Quoted Replies group - flags: textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE, - contentType: 'audio/mp3', - fileName: 'agnus_dei.mp4', - } + attachments: [ + { + // proposed as of afternoon of 4/6 in Quoted Replies group + flags: textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE, + contentType: 'audio/mp3', + fileName: 'agnus_dei.mp4', + }, + ], }, }); const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { @@ -318,10 +332,12 @@ const outgoing = new Whisper.Message({ author: '+12025550100', text: 'This is my manifesto. Tell me what you think!', id: Date.now() - 1000, - attachments: { - contentType: 'text/plain', - fileName: 'lorum_ipsum.txt', - } + attachments: [ + { + contentType: 'text/plain', + fileName: 'lorum_ipsum.txt', + }, + ], }, }); const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { @@ -354,10 +370,12 @@ const outgoing = new Whisper.Message({ quote: { author: '+12025550100', id: Date.now() - 1000, - attachments: { - contentType: 'text/plain', - fileName: 'lorum_ipsum.txt', - } + attachments: [ + { + contentType: 'text/plain', + fileName: 'lorum_ipsum.txt', + }, + ], }, }); const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, { diff --git a/ts/components/conversation/Quote.tsx b/ts/components/conversation/Quote.tsx index 58ff7773e..830253f70 100644 --- a/ts/components/conversation/Quote.tsx +++ b/ts/components/conversation/Quote.tsx @@ -1,14 +1,134 @@ import React from 'react'; +import classnames from 'classnames'; + +// @ts-ignore +import Mime from '../../../js/modules/types/mime'; -interface Props { name: string; } +interface Props { + i18n: (key: string, values?: Array) => string; + authorName: string; + authorColor: string; + attachments: Array; + text: string; +} -interface State { count: number; } +interface QuotedAttachment { + fileName: string; + contentType: string; + isVoiceMessage: boolean; + objectUrl: string; + thumbnail: { + contentType: string; + data: ArrayBuffer; + } +} + +function validateQuote(quote: Props): boolean { + if (quote.text) { + return true; + } + + if (quote.attachments && quote.attachments.length > 0) { + return true; + } + + return false; +} + +function getContentType(attachments: Array): string | null { + if (!attachments || attachments.length === 0) { + return null; + } + + const first = attachments[0]; + return first.contentType; +} + +export class Quote extends React.Component { + public renderIcon(first: QuotedAttachment) { + const contentType = first.contentType; + const objectUrl = first.objectUrl; + + 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 renderIconContainer() { + const { attachments } = this.props; + + if (!attachments || attachments.length === 0) { + return null; + } + + const first = attachments[0]; + + return
+ {this.renderIcon(first)} +
+ } + + public renderText() { + const { i18n, text, attachments } = this.props; + + if (text) { + return
{text}
; + } + + if (!attachments || attachments.length === 0) { + return null; + } + + const contentType = getContentType(attachments); + const first = attachments[0]; + const fileName = first.fileName; + + console.log(contentType); + + if (Mime.isVideo(contentType)) { + return
{i18n('video')}
; + } else if (Mime.isImage(contentType)) { + return
{i18n('photo')}
; + } else if (Mime.isAudio(contentType) && first.isVoiceMessage) { + return
{i18n('voiceMessage')}
; + } else if (Mime.isAudio(contentType)) { + console.log(first); + return
{i18n('audio')}
; + } + + return
{fileName}
; + } -export class Reply extends React.Component { public render() { + const { authorName, authorColor } = this.props; + + if (!validateQuote(this.props)) { + return null; + } + return ( -
Placeholder
+
+
+
{authorName}
+ {this.renderText()} +
+ {this.renderIconContainer()} +
); } } diff --git a/yarn.lock b/yarn.lock index 9a9c20ed8..3748ca6bb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -40,6 +40,10 @@ version "4.1.2" resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.1.2.tgz#f1af664769cfb50af805431c407425ed619daa21" +"@types/classnames@^2.2.3": + version "2.2.3" + resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.3.tgz#3f0ff6873da793870e20a260cada55982f38a9e5" + "@types/lodash@^4.14.106": version "4.14.106" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.106.tgz#6093e9a02aa567ddecfe9afadca89e53e5dce4dd"