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/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"