Receive quoted replies, collapsed iOS bubbles (#2244)
Complete support for receiving quoted replies, and a big change to the iOS theme. Instead of attachments showing up in a separate bubble from their associated caption, they are now in the same bubble.
This commit is contained in:
commit
8fa0912fb3
36 changed files with 5915 additions and 3273 deletions
|
@ -2,9 +2,9 @@ build/**
|
|||
components/**
|
||||
coverage/**
|
||||
dist/**
|
||||
libtextsecure/**
|
||||
|
||||
# these aren't ready yet, pulling files in one-by-one
|
||||
libtextsecure/**
|
||||
js/*.js
|
||||
js/models/**/*.js
|
||||
js/views/**/*.js
|
||||
|
@ -22,6 +22,7 @@ ts/**/*.js
|
|||
!js/database.js
|
||||
!js/logging.js
|
||||
!js/models/conversations.js
|
||||
!js/models/messages.js
|
||||
!js/views/attachment_view.js
|
||||
!js/views/conversation_search_view.js
|
||||
!js/views/backbone_wrapper_view.js
|
||||
|
@ -30,6 +31,7 @@ ts/**/*.js
|
|||
!js/views/inbox_view.js
|
||||
!js/views/message_view.js
|
||||
!js/views/settings_view.js
|
||||
!libtextsecure/message_receiver.js
|
||||
!main.js
|
||||
!preload.js
|
||||
!prepare_build.js
|
||||
|
|
|
@ -110,7 +110,11 @@ module.exports = function(grunt) {
|
|||
'!js/signal_protocol_store.js',
|
||||
'!js/views/conversation_search_view.js',
|
||||
'!js/views/debug_log_view.js',
|
||||
'!js/views/message_view.js',
|
||||
'!js/models/conversations.js',
|
||||
'!js/models/messages.js',
|
||||
'!js/WebAudioRecorderMp3.js',
|
||||
'!libtextsecure/message_receiver.js',
|
||||
'_locales/**/*'
|
||||
],
|
||||
options: { jshintrc: '.jshintrc' },
|
||||
|
@ -160,6 +164,8 @@ module.exports = function(grunt) {
|
|||
'!js/libsignal-protocol-worker.js',
|
||||
'!js/libtextsecure.js',
|
||||
'!js/modules/**/*.js',
|
||||
'!js/models/conversations.js',
|
||||
'!js/models/messages.js',
|
||||
'!js/Mp3LameEncoder.min.js',
|
||||
'!js/WebAudioRecorderMp3.js',
|
||||
'test/**/*.js',
|
||||
|
|
|
@ -428,6 +428,36 @@
|
|||
"selectAContact": {
|
||||
"message": "Select a contact or group to start chatting."
|
||||
},
|
||||
"replyingToYourself": {
|
||||
"message": "Replying to Yourself",
|
||||
"description": "Shown in iOS theme when you quote yourself"
|
||||
},
|
||||
"replyingToYou": {
|
||||
"message": "Replying to You",
|
||||
"description": "Shown in iOS theme when someone else quotes a message from you"
|
||||
},
|
||||
"replyingTo": {
|
||||
"message": "Replying to $name$",
|
||||
"description": "Shown in iOS theme when you or someone quotes to a message which is not from you",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"content": "$1",
|
||||
"example": "John"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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"
|
||||
},
|
||||
|
|
|
@ -277,10 +277,15 @@
|
|||
<span class='profileName'>{{ profileName }} </span>
|
||||
{{ /profileName }}
|
||||
</div>
|
||||
<div class='attachments'></div>
|
||||
<p class='content' dir='auto'>
|
||||
{{ #message }}<span class='body'>{{ message }}</span>{{ /message }}
|
||||
</p>
|
||||
<div class='tail-wrapper {{ innerBubbleClasses }}'>
|
||||
<div class='inner-bubble'>
|
||||
<div class='quote-wrapper'></div>
|
||||
<div class='attachments'></div>
|
||||
<div class='content' dir='auto'>
|
||||
{{ #message }}<div class='body'>{{ message }}</div>{{ /message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='meta'>
|
||||
<span class='timestamp' data-timestamp={{ timestamp }}></span>
|
||||
<span class='status hide'></span>
|
||||
|
|
1
images/image.svg
Normal file
1
images/image.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M8.5,13.5L11,16.5L14.5,12L19,18H5M21,19V5C21,3.89 20.1,3 19,3H5A2,2 0 0,0 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19Z" /></svg>
|
After Width: | Height: | Size: 410 B |
1
images/play.svg
Normal file
1
images/play.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M8,5.14V19.14L19,12.14L8,5.14Z" /></svg>
|
After Width: | Height: | Size: 325 B |
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
@ -26,7 +26,7 @@ const INITIAL_SCHEMA_VERSION = 0;
|
|||
// add more upgrade steps, we could design a pipeline that does this
|
||||
// incrementally, e.g. from version 0 / unknown -> 1, 1 --> 2, etc., similar to
|
||||
// how we do database migrations:
|
||||
exports.CURRENT_SCHEMA_VERSION = 3;
|
||||
exports.CURRENT_SCHEMA_VERSION = 4;
|
||||
|
||||
|
||||
// Public API
|
||||
|
@ -149,6 +149,35 @@ exports._mapAttachments = upgradeAttachment => async (message, context) => {
|
|||
return Object.assign({}, message, { attachments });
|
||||
};
|
||||
|
||||
// _mapQuotedAttachments :: (QuotedAttachment -> Promise QuotedAttachment) ->
|
||||
// (Message, Context) ->
|
||||
// Promise Message
|
||||
exports._mapQuotedAttachments = upgradeAttachment => async (message, context) => {
|
||||
if (!message.quote) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const upgradeWithContext = async (attachment) => {
|
||||
if (!attachment || !attachment.thumbnail) {
|
||||
return attachment;
|
||||
}
|
||||
|
||||
const thumbnail = await upgradeAttachment(attachment.thumbnail, context);
|
||||
return Object.assign({}, attachment, {
|
||||
thumbnail,
|
||||
});
|
||||
};
|
||||
|
||||
const quotedAttachments = (message.quote && message.quote.attachments) || [];
|
||||
|
||||
const attachments = await Promise.all(quotedAttachments.map(upgradeWithContext));
|
||||
return Object.assign({}, message, {
|
||||
quote: Object.assign({}, message.quote, {
|
||||
attachments,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const toVersion0 = async message =>
|
||||
exports.initializeSchemaVersion(message);
|
||||
|
||||
|
@ -164,17 +193,29 @@ const toVersion3 = exports._withSchemaVersion(
|
|||
3,
|
||||
exports._mapAttachments(Attachment.migrateDataToFileSystem)
|
||||
);
|
||||
const toVersion4 = exports._withSchemaVersion(
|
||||
4,
|
||||
exports._mapQuotedAttachments(Attachment.migrateDataToFileSystem)
|
||||
);
|
||||
|
||||
// UpgradeStep
|
||||
exports.upgradeSchema = async (message, { writeNewAttachmentData } = {}) => {
|
||||
exports.upgradeSchema = async (rawMessage, { writeNewAttachmentData } = {}) => {
|
||||
if (!isFunction(writeNewAttachmentData)) {
|
||||
throw new TypeError('`context.writeNewAttachmentData` is required');
|
||||
}
|
||||
|
||||
return toVersion3(
|
||||
await toVersion2(await toVersion1(await toVersion0(message))),
|
||||
{ writeNewAttachmentData }
|
||||
);
|
||||
let message = rawMessage;
|
||||
const versions = [toVersion0, toVersion1, toVersion2, toVersion3, toVersion4];
|
||||
|
||||
for (let i = 0, max = versions.length; i < max; i += 1) {
|
||||
const currentVersion = versions[i];
|
||||
// We really do want this intra-loop await because this is a chained async action,
|
||||
// each step dependent on the previous
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
message = await currentVersion(message, { writeNewAttachmentData });
|
||||
}
|
||||
|
||||
return message;
|
||||
};
|
||||
|
||||
exports.createAttachmentLoader = (loadAttachmentData) => {
|
||||
|
|
|
@ -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/');
|
||||
|
|
|
@ -12,6 +12,7 @@
|
|||
'use strict';
|
||||
|
||||
const ESCAPE_KEY_CODE = 27;
|
||||
const { Signal } = window;
|
||||
|
||||
const FileView = Whisper.View.extend({
|
||||
tagName: 'div',
|
||||
|
@ -69,7 +70,7 @@
|
|||
];
|
||||
|
||||
Whisper.AttachmentView = Backbone.View.extend({
|
||||
tagName: 'span',
|
||||
tagName: 'div',
|
||||
className() {
|
||||
if (this.isImage()) {
|
||||
return 'attachment';
|
||||
|
@ -133,14 +134,16 @@
|
|||
return false;
|
||||
},
|
||||
isAudio() {
|
||||
return this.model.contentType.startsWith('audio/');
|
||||
const { contentType } = this.model;
|
||||
return Signal.Types.MIME.isAudio(contentType);
|
||||
},
|
||||
isVideo() {
|
||||
return this.model.contentType.startsWith('video/');
|
||||
const { contentType } = this.model;
|
||||
return Signal.Types.MIME.isVideo(contentType);
|
||||
},
|
||||
isImage() {
|
||||
const type = this.model.contentType;
|
||||
return type.startsWith('image/') && type !== 'image/tiff';
|
||||
const { contentType } = this.model;
|
||||
return Signal.Types.MIME.isImage(contentType);
|
||||
},
|
||||
mediaType() {
|
||||
if (this.isVoiceMessage()) {
|
||||
|
|
|
@ -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),
|
||||
|
@ -191,7 +192,7 @@
|
|||
'click' : 'onClick',
|
||||
'click .bottom-bar': 'focusMessageField',
|
||||
'click .back': 'resetPanel',
|
||||
'click .microphone': 'captureAudio',
|
||||
'click .capture-audio .microphone': 'captureAudio',
|
||||
'click .disappearing-messages': 'enableDisappearingMessages',
|
||||
'click .scroll-down-button-view': 'scrollToBottom',
|
||||
'click button.emoji': 'toggleEmojiPanel',
|
||||
|
@ -529,6 +530,21 @@
|
|||
}
|
||||
},
|
||||
|
||||
scrollToMessage: function(options = {}) {
|
||||
const { id } = options;
|
||||
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const el = this.$(`#${id}`);
|
||||
if (!el || el.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
el[0].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!
|
||||
|
@ -669,7 +685,7 @@
|
|||
// This is debounced, so it won't hit the database too often.
|
||||
this.lazyUpdateVerified();
|
||||
|
||||
this.model.messageCollection.add(message, {merge: true});
|
||||
this.model.addSingleMessage(message);
|
||||
message.setToExpire();
|
||||
|
||||
if (message.isOutgoing()) {
|
||||
|
|
|
@ -1,417 +1,523 @@
|
|||
/* eslint-disable */
|
||||
|
||||
/* global Whisper: false */
|
||||
/* global i18n: false */
|
||||
/* global textsecure: false */
|
||||
/* global _: false */
|
||||
/* global emoji_util: false */
|
||||
/* global Mustache: false */
|
||||
/* global ConversationController: false */
|
||||
|
||||
// eslint-disable-next-line func-names
|
||||
(function () {
|
||||
'use strict';
|
||||
window.Whisper = window.Whisper || {};
|
||||
'use strict';
|
||||
|
||||
const { HTML } = window.Signal;
|
||||
const { Attachment } = window.Signal.Types;
|
||||
const { loadAttachmentData } = window.Signal.Migrations;
|
||||
const { Signal } = window;
|
||||
const { loadAttachmentData } = window.Signal.Migrations;
|
||||
|
||||
var URL_REGEX = /(^|[\s\n]|<br\/?>)((?:https?|ftp):\/\/[\-A-Z0-9\u00A0-\uD7FF\uE000-\uFDCF\uFDF0-\uFFFD+\u0026\u2019@#\/%?=()~_|!:,.;]*[\-A-Z0-9+\u0026@#\/%=~()_|])/gi;
|
||||
window.Whisper = window.Whisper || {};
|
||||
|
||||
var ErrorIconView = Whisper.View.extend({
|
||||
templateName: 'error-icon',
|
||||
className: 'error-icon-container',
|
||||
initialize: function() {
|
||||
if (this.model.name === 'UnregisteredUserError') {
|
||||
this.$el.addClass('unregistered-user-error');
|
||||
}
|
||||
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: {
|
||||
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({
|
||||
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);
|
||||
},
|
||||
render_attributes() {
|
||||
const seconds = this.model.get('expirationTimerUpdate').expireTimer;
|
||||
let timerMessage;
|
||||
|
||||
const timerUpdate = this.model.get('expirationTimerUpdate');
|
||||
const prettySeconds = Whisper.ExpirationTimerOptions.getName(seconds);
|
||||
|
||||
if (timerUpdate && timerUpdate.fromSync) {
|
||||
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 };
|
||||
},
|
||||
});
|
||||
|
||||
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';
|
||||
}
|
||||
});
|
||||
var NetworkErrorView = Whisper.View.extend({
|
||||
tagName: 'span',
|
||||
className: 'hasRetry',
|
||||
templateName: 'hasRetry',
|
||||
render_attributes: function() {
|
||||
var messageNotSent;
|
||||
return {
|
||||
icon: 'verified',
|
||||
content: i18n(key, this.conversation.getTitle()),
|
||||
};
|
||||
}
|
||||
|
||||
if (!this.model.someRecipientsFailed()) {
|
||||
messageNotSent = i18n('messageNotSent');
|
||||
}
|
||||
if (this.model.get('local')) {
|
||||
key = 'youMarkedAsNotVerified';
|
||||
} else {
|
||||
key = 'youMarkedAsNotVerifiedOtherDevice';
|
||||
}
|
||||
|
||||
return {
|
||||
messageNotSent: messageNotSent,
|
||||
resend: i18n('resend')
|
||||
};
|
||||
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',
|
||||
},
|
||||
retryMessage() {
|
||||
const retrys = _.filter(
|
||||
this.model.get('errors'),
|
||||
this.model.isReplayableError.bind(this.model)
|
||||
);
|
||||
_.map(retrys, 'number').forEach((number) => {
|
||||
this.model.resend(number);
|
||||
});
|
||||
},
|
||||
onExpired() {
|
||||
this.$el.addClass('expired');
|
||||
this.$el.find('.bubble').one('webkitAnimationEnd animationend', (e) => {
|
||||
if (e.target === this.$('.bubble')[0]) {
|
||||
this.remove();
|
||||
}
|
||||
});
|
||||
var SomeFailedView = Whisper.View.extend({
|
||||
tagName: 'span',
|
||||
className: 'some-failed',
|
||||
templateName: 'some-failed',
|
||||
render_attributes: {
|
||||
someFailed: i18n('someRecipientsFailed')
|
||||
});
|
||||
|
||||
// Failsafe: if in the background, animation events don't fire
|
||||
setTimeout(this.remove.bind(this), 1000);
|
||||
},
|
||||
onUnload() {
|
||||
if (this.avatarView) {
|
||||
this.avatarView.remove();
|
||||
}
|
||||
if (this.errorIconView) {
|
||||
this.errorIconView.remove();
|
||||
}
|
||||
if (this.networkErrorView) {
|
||||
this.networkErrorView.remove();
|
||||
}
|
||||
if (this.someFailedView) {
|
||||
this.someFailedView.remove();
|
||||
}
|
||||
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
|
||||
// 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();
|
||||
},
|
||||
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');
|
||||
|
||||
|
||||
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');
|
||||
}
|
||||
});
|
||||
var TimerView = Whisper.View.extend({
|
||||
templateName: 'hourglass',
|
||||
initialize: function() {
|
||||
this.listenTo(this.model, 'unload', this.remove);
|
||||
},
|
||||
update: function() {
|
||||
if (this.timeout) {
|
||||
clearTimeout(this.timeout);
|
||||
this.timeout = null;
|
||||
}
|
||||
if (this.model.isExpired()) {
|
||||
return this;
|
||||
}
|
||||
if (this.model.isExpiring()) {
|
||||
this.render();
|
||||
var totalTime = this.model.get('expireTimer') * 1000;
|
||||
var remainingTime = this.model.msTilExpire();
|
||||
var 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;
|
||||
this.errorIconView = new ErrorIconView({ model: errors[0] });
|
||||
this.errorIconView.render().$el.appendTo(this.$('.bubble'));
|
||||
}
|
||||
|
||||
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');
|
||||
const content = this.$('.content');
|
||||
content.text(this.model.getDescription());
|
||||
emoji_util.parse(content);
|
||||
} else {
|
||||
this.$el.removeClass('control');
|
||||
}
|
||||
},
|
||||
renderExpiring() {
|
||||
if (!this.timerView) {
|
||||
this.timerView = new TimerView({ model: this.model });
|
||||
}
|
||||
this.timerView.setElement(this.$('.timer'));
|
||||
this.timerView.update();
|
||||
},
|
||||
getQuoteObjectUrl() {
|
||||
const fromDB = this.model.quotedMessageFromDatabase;
|
||||
if (fromDB && fromDB.imageUrl) {
|
||||
return fromDB.imageUrl;
|
||||
}
|
||||
|
||||
const inMemory = this.model.quotedMessage;
|
||||
if (inMemory && inMemory.imageUrl) {
|
||||
return inMemory.imageUrl;
|
||||
}
|
||||
|
||||
const thumbnail = this.model.quoteThumbnail;
|
||||
if (thumbnail && thumbnail.objectUrl) {
|
||||
return thumbnail.objectUrl;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
renderQuote() {
|
||||
const quote = this.model.get('quote');
|
||||
if (!quote) {
|
||||
return;
|
||||
}
|
||||
|
||||
const VOICE_FLAG = textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE;
|
||||
const objectUrl = this.getQuoteObjectUrl();
|
||||
|
||||
|
||||
function processAttachment(attachment) {
|
||||
const thumbnail = !objectUrl
|
||||
? null
|
||||
: Object.assign({}, attachment.thumbnail || {}, {
|
||||
objectUrl,
|
||||
});
|
||||
|
||||
return Object.assign({}, attachment, {
|
||||
// eslint-disable-next-line no-bitwise
|
||||
isVoiceMessage: Boolean(attachment.flags & VOICE_FLAG),
|
||||
thumbnail,
|
||||
});
|
||||
}
|
||||
|
||||
const OUR_NUMBER = textsecure.storage.user.getNumber();
|
||||
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 isFromMe = contact ? contact.id === OUR_NUMBER : false;
|
||||
const isIncoming = this.model.isIncoming();
|
||||
const onClick = () => {
|
||||
const { quotedMessage } = this.model;
|
||||
if (quotedMessage) {
|
||||
this.model.trigger('scroll-to-message', { id: quotedMessage.id });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
Whisper.ExpirationTimerUpdateView = Whisper.View.extend({
|
||||
tagName: 'li',
|
||||
className: 'expirationTimerUpdate advisory',
|
||||
templateName: 'expirationTimerUpdate',
|
||||
id: function() {
|
||||
return this.model.id;
|
||||
},
|
||||
initialize: function() {
|
||||
this.conversation = this.model.getExpirationTimerUpdateSource();
|
||||
this.listenTo(this.conversation, 'change', this.render);
|
||||
this.listenTo(this.model, 'unload', this.remove);
|
||||
},
|
||||
render_attributes: function() {
|
||||
var seconds = this.model.get('expirationTimerUpdate').expireTimer;
|
||||
var timerMessage;
|
||||
const props = {
|
||||
attachments: (quote.attachments || []).map(processAttachment),
|
||||
authorColor,
|
||||
authorProfileName,
|
||||
authorTitle,
|
||||
isFromMe,
|
||||
isIncoming,
|
||||
onClick: this.model.quotedMessage ? onClick : null,
|
||||
text: quote.text,
|
||||
};
|
||||
|
||||
var timerUpdate = this.model.get('expirationTimerUpdate');
|
||||
var prettySeconds = Whisper.ExpirationTimerOptions.getName(seconds);
|
||||
if (this.replyView) {
|
||||
this.replyView = null;
|
||||
} else if (contact) {
|
||||
this.listenTo(contact, 'change:color', this.renderQuote);
|
||||
}
|
||||
|
||||
if (timerUpdate && timerUpdate.fromSync) {
|
||||
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.replyView = new Whisper.ReactWrapperView({
|
||||
el: this.$('.quote-wrapper'),
|
||||
Component: window.Signal.Components.Quote,
|
||||
props,
|
||||
});
|
||||
},
|
||||
isImageWithoutCaption() {
|
||||
const attachments = this.model.get('attachments');
|
||||
const body = this.model.get('body');
|
||||
if (!attachments || attachments.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Whisper.KeyChangeView = Whisper.View.extend({
|
||||
tagName: 'li',
|
||||
className: 'keychange advisory',
|
||||
templateName: 'keychange',
|
||||
id: function() {
|
||||
return this.model.id;
|
||||
},
|
||||
initialize: function() {
|
||||
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: function() {
|
||||
return {
|
||||
content: this.model.getNotificationText()
|
||||
};
|
||||
},
|
||||
showIdentity: function() {
|
||||
this.$el.trigger('show-identity', this.conversation);
|
||||
}
|
||||
});
|
||||
if (body && body.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Whisper.VerifiedChangeView = Whisper.View.extend({
|
||||
tagName: 'li',
|
||||
className: 'verified-change advisory',
|
||||
templateName: 'verified-change',
|
||||
id: function() {
|
||||
return this.model.id;
|
||||
},
|
||||
initialize: function() {
|
||||
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: function() {
|
||||
var key;
|
||||
const first = attachments[0];
|
||||
if (Signal.Types.MIME.isImage(first.contentType)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.model.get('verified')) {
|
||||
if (this.model.get('local')) {
|
||||
key = 'youMarkedAsVerified';
|
||||
} else {
|
||||
key = 'youMarkedAsVerifiedOtherDevice';
|
||||
}
|
||||
return {
|
||||
icon: 'verified',
|
||||
content: i18n(key, this.conversation.getTitle())
|
||||
};
|
||||
}
|
||||
return false;
|
||||
},
|
||||
render() {
|
||||
const contact = this.model.isIncoming() ? this.model.getContact() : null;
|
||||
this.$el.html(Mustache.render(_.result(this, 'template', ''), {
|
||||
message: this.model.get('body'),
|
||||
timestamp: this.model.get('sent_at'),
|
||||
sender: (contact && contact.getTitle()) || '',
|
||||
avatar: (contact && contact.getAvatar()),
|
||||
profileName: (contact && contact.getProfileName()),
|
||||
innerBubbleClasses: this.isImageWithoutCaption() ? '' : 'with-tail',
|
||||
}, this.render_partials()));
|
||||
this.timeStampView.setElement(this.$('.timestamp'));
|
||||
this.timeStampView.update();
|
||||
|
||||
if (this.model.get('local')) {
|
||||
key = 'youMarkedAsNotVerified';
|
||||
} else {
|
||||
key = 'youMarkedAsNotVerifiedOtherDevice';
|
||||
}
|
||||
this.renderControl();
|
||||
|
||||
return {
|
||||
icon: 'shield',
|
||||
content: i18n(key, this.conversation.getTitle())
|
||||
};
|
||||
},
|
||||
showIdentity: function() {
|
||||
this.$el.trigger('show-identity', this.conversation);
|
||||
}
|
||||
});
|
||||
const body = this.$('.body');
|
||||
|
||||
Whisper.MessageView = Whisper.View.extend({
|
||||
tagName: 'li',
|
||||
templateName: 'message',
|
||||
id: function() {
|
||||
return this.model.id;
|
||||
},
|
||||
initialize: function() {
|
||||
// loadedAttachmentViews :: Promise (Array AttachmentView) | null
|
||||
this.loadedAttachmentViews = null;
|
||||
emoji_util.parse(body);
|
||||
|
||||
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.renderSent);
|
||||
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();
|
||||
if (body.length > 0) {
|
||||
const escapedBody = body.html();
|
||||
body.html(Signal.HTML.render(escapedBody));
|
||||
}
|
||||
|
||||
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'
|
||||
},
|
||||
retryMessage: function() {
|
||||
var retrys = _.filter(this.model.get('errors'),
|
||||
this.model.isReplayableError.bind(this.model));
|
||||
_.map(retrys, 'number').forEach(function(number) {
|
||||
this.model.resend(number);
|
||||
}.bind(this));
|
||||
},
|
||||
onExpired: function() {
|
||||
this.$el.addClass('expired');
|
||||
this.$el.find('.bubble').one('webkitAnimationEnd animationend', function(e) {
|
||||
if (e.target === this.$('.bubble')[0]) {
|
||||
this.remove();
|
||||
}
|
||||
}.bind(this));
|
||||
this.renderSent();
|
||||
this.renderDelivered();
|
||||
this.renderRead();
|
||||
this.renderErrors();
|
||||
this.renderExpiring();
|
||||
this.renderQuote();
|
||||
|
||||
// Failsafe: if in the background, animation events don't fire
|
||||
setTimeout(this.remove.bind(this), 1000);
|
||||
},
|
||||
/* jshint ignore:start */
|
||||
onUnload: function() {
|
||||
if (this.avatarView) {
|
||||
this.avatarView.remove();
|
||||
}
|
||||
if (this.errorIconView) {
|
||||
this.errorIconView.remove();
|
||||
}
|
||||
if (this.networkErrorView) {
|
||||
this.networkErrorView.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 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));
|
||||
|
||||
// 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()));
|
||||
return this;
|
||||
},
|
||||
updateColor() {
|
||||
const bubble = this.$('.bubble');
|
||||
|
||||
// No need to handle this one, since it listens to 'unload' itself:
|
||||
// this.timerView
|
||||
|
||||
this.remove();
|
||||
},
|
||||
/* jshint ignore:end */
|
||||
onDestroy: function() {
|
||||
if (this.$el.hasClass('expired')) {
|
||||
return;
|
||||
}
|
||||
this.onUnload();
|
||||
},
|
||||
select: function(e) {
|
||||
this.$el.trigger('select', {message: this.model});
|
||||
e.stopPropagation();
|
||||
},
|
||||
className: function() {
|
||||
return ['entry', this.model.get('type')].join(' ');
|
||||
},
|
||||
renderPending: function() {
|
||||
this.$el.addClass('pending');
|
||||
},
|
||||
renderDone: function() {
|
||||
this.$el.removeClass('pending');
|
||||
},
|
||||
renderSent: function() {
|
||||
if (this.model.isOutgoing()) {
|
||||
this.$el.toggleClass('sent', !!this.model.get('sent'));
|
||||
}
|
||||
},
|
||||
renderDelivered: function() {
|
||||
if (this.model.get('delivered')) { this.$el.addClass('delivered'); }
|
||||
},
|
||||
renderRead: function() {
|
||||
if (!_.isEmpty(this.model.get('read_by'))) {
|
||||
this.$el.addClass('read');
|
||||
}
|
||||
},
|
||||
onErrorsChanged: function() {
|
||||
if (this.model.isIncoming()) {
|
||||
this.render();
|
||||
} else {
|
||||
this.renderErrors();
|
||||
}
|
||||
},
|
||||
renderErrors: function() {
|
||||
var errors = this.model.get('errors');
|
||||
|
||||
|
||||
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'));
|
||||
}
|
||||
|
||||
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: function() {
|
||||
if (this.model.isEndSession() || this.model.isGroupUpdate()) {
|
||||
this.$el.addClass('control');
|
||||
var content = this.$('.content');
|
||||
content.text(this.model.getDescription());
|
||||
emoji_util.parse(content);
|
||||
} else {
|
||||
this.$el.removeClass('control');
|
||||
}
|
||||
},
|
||||
renderExpiring: function() {
|
||||
if (!this.timerView) {
|
||||
this.timerView = new TimerView({ model: this.model });
|
||||
}
|
||||
this.timerView.setElement(this.$('.timer'));
|
||||
this.timerView.update();
|
||||
},
|
||||
render: function() {
|
||||
var contact = this.model.isIncoming() ? this.model.getContact() : null;
|
||||
this.$el.html(
|
||||
Mustache.render(_.result(this, 'template', ''), {
|
||||
message: this.model.get('body'),
|
||||
timestamp: this.model.get('sent_at'),
|
||||
sender: (contact && contact.getTitle()) || '',
|
||||
avatar: (contact && contact.getAvatar()),
|
||||
profileName: (contact && contact.getProfileName()),
|
||||
}, this.render_partials())
|
||||
);
|
||||
this.timeStampView.setElement(this.$('.timestamp'));
|
||||
this.timeStampView.update();
|
||||
|
||||
this.renderControl();
|
||||
|
||||
var body = this.$('.body');
|
||||
|
||||
emoji_util.parse(body);
|
||||
|
||||
if (body.length > 0) {
|
||||
const escapedBody = body.html();
|
||||
body.html(HTML.render(escapedBody));
|
||||
}
|
||||
|
||||
this.renderSent();
|
||||
this.renderDelivered();
|
||||
this.renderRead();
|
||||
this.renderErrors();
|
||||
this.renderExpiring();
|
||||
|
||||
|
||||
// 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));
|
||||
|
||||
return this;
|
||||
},
|
||||
updateColor: function() {
|
||||
var bubble = this.$('.bubble');
|
||||
|
||||
// this.contact is known to be non-null if we're registered for color changes
|
||||
var 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'));
|
||||
},
|
||||
/* eslint-enable */
|
||||
/* jshint ignore:start */
|
||||
// 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;
|
||||
|
@ -464,7 +570,5 @@
|
|||
view.setElement(view.el);
|
||||
this.trigger('afterChangeHeight');
|
||||
},
|
||||
/* jshint ignore:end */
|
||||
/* eslint-disable */
|
||||
});
|
||||
})();
|
||||
});
|
||||
}());
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -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",
|
||||
|
|
|
@ -161,7 +161,11 @@ window.Signal.Debug = require('./js/modules/debug');
|
|||
window.Signal.HTML = require('./ts/html');
|
||||
window.Signal.Logs = require('./js/modules/logs');
|
||||
|
||||
window.Signal.Components = {};
|
||||
const { Quote } = require('./ts/components/conversation/Quote');
|
||||
|
||||
window.Signal.Components = {
|
||||
Quote,
|
||||
};
|
||||
|
||||
window.Signal.Migrations = {};
|
||||
window.Signal.Migrations.deleteAttachmentData =
|
||||
|
|
|
@ -71,6 +71,19 @@ message DataMessage {
|
|||
PROFILE_KEY_UPDATE = 4;
|
||||
}
|
||||
|
||||
message Quote {
|
||||
message QuotedAttachment {
|
||||
optional string contentType = 1;
|
||||
optional string fileName = 2;
|
||||
optional AttachmentPointer thumbnail = 3;
|
||||
}
|
||||
|
||||
optional uint64 id = 1;
|
||||
optional string author = 2;
|
||||
optional string text = 3;
|
||||
repeated QuotedAttachment attachments = 4;
|
||||
}
|
||||
|
||||
optional string body = 1;
|
||||
repeated AttachmentPointer attachments = 2;
|
||||
optional GroupContext group = 3;
|
||||
|
@ -78,6 +91,7 @@ message DataMessage {
|
|||
optional uint32 expireTimer = 5;
|
||||
optional bytes profileKey = 6;
|
||||
optional uint64 timestamp = 7;
|
||||
optional Quote quote = 8;
|
||||
}
|
||||
|
||||
message NullMessage {
|
||||
|
|
|
@ -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/styleguide'),
|
||||
],
|
||||
// We don't want one long, single page
|
||||
pagePerSection: true,
|
||||
// Expose entire repository to the styleguidist server, primarily for stylesheets
|
||||
|
@ -126,6 +129,9 @@ module.exports = {
|
|||
{
|
||||
src: 'js/views/timestamp_view.js',
|
||||
},
|
||||
{
|
||||
src: 'js/views/attachment_view.js',
|
||||
},
|
||||
{
|
||||
src: 'js/views/message_view.js',
|
||||
},
|
||||
|
|
|
@ -379,6 +379,10 @@ li.entry .error-icon-container {
|
|||
display: none;
|
||||
}
|
||||
|
||||
.message-list .outgoing .bubble .quote, .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 +439,8 @@ span.status {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
.bubble {
|
||||
position: relative;
|
||||
left: -2px;
|
||||
|
@ -450,7 +456,142 @@ span.status {
|
|||
max-width: calc(100% - 45px - #{$error-icon-size}); // avatar size + padding + error-icon size
|
||||
}
|
||||
|
||||
.quote {
|
||||
@include message-replies-colors;
|
||||
@include twenty-percent-colors;
|
||||
|
||||
&.no-click {
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
overflow: hidden;
|
||||
|
||||
border-radius: 2px;
|
||||
background-color: #eee;
|
||||
position: relative;
|
||||
|
||||
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: 3px;
|
||||
border-left-style: solid;
|
||||
|
||||
.primary {
|
||||
flex-grow: 1;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
|
||||
// Will turn on in the iOS theme. This extra element is necessary because the iOS
|
||||
// theme requires text that isn't used at all in the Android Theme
|
||||
.ios-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.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 {
|
||||
font-style: italic;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.filename-label {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
flex: initial;
|
||||
min-width: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
position: relative;
|
||||
|
||||
.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', white);
|
||||
}
|
||||
&.image {
|
||||
@include color-svg('../images/image.svg', white);
|
||||
}
|
||||
&.microphone {
|
||||
@include color-svg('../images/microphone.svg', white);
|
||||
}
|
||||
&.play {
|
||||
@include color-svg('../images/play.svg', white);
|
||||
}
|
||||
|
||||
@include avatar-colors;
|
||||
}
|
||||
|
||||
.inner {
|
||||
position: relative;
|
||||
|
||||
height: 48px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
margin-top: 0.5em;
|
||||
white-space: pre-wrap;
|
||||
|
||||
a {
|
||||
|
@ -509,6 +650,13 @@ span.status {
|
|||
.avatar, .bubble {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
.quote {
|
||||
background-color: rgba(white, 0.6);
|
||||
border-left-color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.outgoing {
|
||||
|
@ -569,6 +717,7 @@ span.status {
|
|||
}
|
||||
|
||||
img, audio, video {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 300px;
|
||||
}
|
||||
|
@ -591,6 +740,7 @@ span.status {
|
|||
position: relative;
|
||||
padding: 5px;
|
||||
padding-right: 10px;
|
||||
padding-bottom: 0px;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
|
|
|
@ -106,13 +106,158 @@ $ios-border-color: rgba(0,0,0,0.1);
|
|||
padding: 10px;
|
||||
}
|
||||
|
||||
.message-container,
|
||||
.message-list {
|
||||
.quote {
|
||||
border-top-left-radius: 15px;
|
||||
border-top-right-radius: 15px;
|
||||
border-bottom-left-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
|
||||
// Not ideal, but necessary to override the specificity of the android theme color
|
||||
// classes used in conversations.scss
|
||||
background-color: white !important;
|
||||
border: 1px solid $grey_l1_5 !important;
|
||||
border-bottom: none !important;
|
||||
|
||||
margin-top: 0px !important;
|
||||
margin-bottom: 0px;
|
||||
margin-left: 0px;
|
||||
margin-right: 0px;
|
||||
|
||||
.primary {
|
||||
padding: 10px;
|
||||
|
||||
.text,
|
||||
.filename-label,
|
||||
.type-label {
|
||||
border-left: 2px solid $grey_l1;
|
||||
padding: 5px;
|
||||
padding-left: 7px;
|
||||
// Without this smaller bottom padding, text beyond four lines still shows up!
|
||||
padding-bottom: 2px;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.author {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ios-label {
|
||||
display: block;
|
||||
color: $grey_l1;
|
||||
font-size: smaller;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
height: 61px;
|
||||
width: 61px;
|
||||
min-width: 61px;
|
||||
|
||||
.circle-background {
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
top: 12px;
|
||||
bottom: 12px;
|
||||
|
||||
background-color: $blue !important;
|
||||
}
|
||||
|
||||
.icon {
|
||||
left: 18px;
|
||||
right: 18px;
|
||||
top: 18px;
|
||||
bottom: 18px;
|
||||
|
||||
background-color: white !important;
|
||||
}
|
||||
|
||||
.inner {
|
||||
padding: 12px;
|
||||
height: 61px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.from-me {
|
||||
.primary {
|
||||
.text,
|
||||
.filename-label,
|
||||
.type-label {
|
||||
border-left: 2px solid $blue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.incoming {
|
||||
.bubble {
|
||||
.quote {
|
||||
border-left: none;
|
||||
border: none !important;
|
||||
border-bottom: 1px solid lightgray !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bubble {
|
||||
.quote.from-me {
|
||||
.primary {
|
||||
.text,
|
||||
.filename-label,
|
||||
.type-label {
|
||||
border-left: 2px solid $blue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.outgoing .bubble .quote,
|
||||
.private .message-list .incoming .bubble .quote {
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.outgoing .bubble .quote .icon-container .circle-background {
|
||||
background-color: lightgray !important;
|
||||
}
|
||||
}
|
||||
|
||||
.attachments .bubbled {
|
||||
border-radius: 15px;
|
||||
margin-bottom: 0.25em;
|
||||
|
||||
padding: 10px;
|
||||
padding-top: 0px;
|
||||
padding-bottom: 5px;
|
||||
|
||||
video, audio {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tail-wrapper {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.inner-bubble {
|
||||
border-radius: 15px;
|
||||
overflow: hidden;
|
||||
|
||||
.body {
|
||||
margin-top: 0;
|
||||
display: inline-block;
|
||||
padding: 10px;
|
||||
position: relative;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.tail-wrapper.with-tail {
|
||||
position: relative;
|
||||
|
||||
&:before, &:after {
|
||||
|
@ -137,53 +282,29 @@ $ios-border-color: rgba(0,0,0,0.1);
|
|||
}
|
||||
}
|
||||
|
||||
.bubble {
|
||||
.content {
|
||||
margin-bottom: 5px;
|
||||
.body {
|
||||
display: inline-block;
|
||||
padding: 10px;
|
||||
position: relative;
|
||||
word-break: break-word;
|
||||
.meta {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
&:before, &:after {
|
||||
content: '';
|
||||
display: block;
|
||||
border-radius: 20px;
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
}
|
||||
&:before {
|
||||
right: -1px;
|
||||
bottom: -3px;
|
||||
height: 10px;
|
||||
border-radius: 20px;
|
||||
background: $blue;
|
||||
}
|
||||
&:after {
|
||||
height: 11px;
|
||||
right: -6px;
|
||||
bottom: -3px;
|
||||
background: #eee;
|
||||
}
|
||||
.outgoing .with-tail.tail-wrapper {
|
||||
float: right;
|
||||
|
||||
.inner-bubble {
|
||||
.attachments {
|
||||
background-color: $blue;
|
||||
}
|
||||
.content {
|
||||
background-color: $blue;
|
||||
}
|
||||
max-width: 100%;
|
||||
&, .body, a {
|
||||
@include invert-text-color;
|
||||
}
|
||||
}
|
||||
.content, .attachments img {
|
||||
border-radius: 15px;
|
||||
}
|
||||
.attachments img {
|
||||
background-color: white;
|
||||
}
|
||||
.meta {
|
||||
clear: both;
|
||||
}
|
||||
}
|
||||
|
||||
.incoming .bubbled {
|
||||
background-color: white;
|
||||
color: black;
|
||||
.incoming .with-tail.tail-wrapper {
|
||||
float: left;
|
||||
max-width: 100%;
|
||||
|
||||
&:before {
|
||||
left: -1px;
|
||||
|
@ -192,30 +313,11 @@ $ios-border-color: rgba(0,0,0,0.1);
|
|||
&:after {
|
||||
left: -6px;
|
||||
}
|
||||
}
|
||||
|
||||
.incoming .content {
|
||||
background-color: white;
|
||||
color: black;
|
||||
float: left;
|
||||
.body {
|
||||
&:before {
|
||||
left: -1px;
|
||||
background-color: white;
|
||||
}
|
||||
&:after {
|
||||
left: -6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.outgoing {
|
||||
.content, .attachments .bubbled {
|
||||
background-color: $blue;
|
||||
.inner-bubble {
|
||||
background-color: white;
|
||||
color: black;
|
||||
max-width: 100%;
|
||||
&, .body, a {
|
||||
@include invert-text-color;
|
||||
}
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -236,7 +338,6 @@ $ios-border-color: rgba(0,0,0,0.1);
|
|||
a {
|
||||
border-radius: 15px;
|
||||
}
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
.hourglass {
|
||||
@include hourglass(#999);
|
||||
|
|
|
@ -50,9 +50,88 @@
|
|||
&.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?
|
||||
@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;
|
||||
|
||||
|
|
|
@ -2,6 +2,8 @@
|
|||
$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%);
|
||||
|
@ -82,3 +84,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;
|
||||
|
|
|
@ -225,6 +225,26 @@ $text-dark_l2: darken($text-dark, 30%);
|
|||
}
|
||||
}
|
||||
|
||||
.outgoing .bubble .quote .icon-container .icon {
|
||||
background-color: black;
|
||||
&.play.with-image {
|
||||
background-color: $text-dark;
|
||||
}
|
||||
}
|
||||
.incoming .bubble .quote {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button.clock {
|
||||
@include header-icon-white('../images/clock.svg');
|
||||
}
|
||||
|
|
|
@ -206,14 +206,25 @@
|
|||
<script type='text/x-tmpl-mustache' id='message'>
|
||||
{{> avatar }}
|
||||
<div class='bubble {{ avatar.color }}'>
|
||||
<div class='sender' dir='auto'>{{ sender }}</div>
|
||||
<div class='attachments'></div>
|
||||
<p class='content' dir='auto'>
|
||||
{{ #message }}<span class='body'>{{ message }}</span>{{ /message }}
|
||||
</p>
|
||||
<div class='sender' dir='auto'>
|
||||
{{ sender }}
|
||||
{{ #profileName }}
|
||||
<span class='profileName'>{{ profileName }} </span>
|
||||
{{ /profileName }}
|
||||
</div>
|
||||
<div class='tail-wrapper {{ innerBubbleClasses }}'>
|
||||
<div class='inner-bubble'>
|
||||
<div class='quote-wrapper'></div>
|
||||
<div class='attachments'></div>
|
||||
<div class='content' dir='auto'>
|
||||
{{ #message }}<div class='body'>{{ message }}</div>{{ /message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='meta'>
|
||||
<span class='timestamp' data-timestamp={{ timestamp }}></span>
|
||||
<span class='status hide'></span>
|
||||
<span class='timer'></span>
|
||||
</div>
|
||||
</div>
|
||||
</script>
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
const { assert } = require('chai');
|
||||
const sinon = require('sinon');
|
||||
|
||||
const Message = require('../../../js/modules/types/message');
|
||||
const { stringToArrayBuffer } = require('../../../js/modules/string_to_array_buffer');
|
||||
|
@ -308,4 +309,100 @@ describe('Message', () => {
|
|||
assert.deepEqual(actual, expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_mapQuotedAttachments', () => {
|
||||
it('handles message with no quote', async () => {
|
||||
const upgradeAttachment = sinon.stub().throws(new Error("Shouldn't be called"));
|
||||
const upgradeVersion = Message._mapQuotedAttachments(upgradeAttachment);
|
||||
|
||||
const message = {
|
||||
body: 'hey there!',
|
||||
};
|
||||
const result = await upgradeVersion(message);
|
||||
assert.deepEqual(result, message);
|
||||
});
|
||||
|
||||
it('handles quote with no attachments', async () => {
|
||||
const upgradeAttachment = sinon.stub().throws(new Error("Shouldn't be called"));
|
||||
const upgradeVersion = Message._mapQuotedAttachments(upgradeAttachment);
|
||||
|
||||
const message = {
|
||||
body: 'hey there!',
|
||||
quote: {
|
||||
text: 'hey!',
|
||||
},
|
||||
};
|
||||
const expected = {
|
||||
body: 'hey there!',
|
||||
quote: {
|
||||
text: 'hey!',
|
||||
attachments: [],
|
||||
},
|
||||
};
|
||||
const result = await upgradeVersion(message);
|
||||
assert.deepEqual(result, expected);
|
||||
});
|
||||
|
||||
it('handles zero attachments', async () => {
|
||||
const upgradeAttachment = sinon.stub().throws(new Error("Shouldn't be called"));
|
||||
const upgradeVersion = Message._mapQuotedAttachments(upgradeAttachment);
|
||||
|
||||
const message = {
|
||||
body: 'hey there!',
|
||||
quote: {
|
||||
text: 'hey!',
|
||||
attachments: [],
|
||||
},
|
||||
};
|
||||
const result = await upgradeVersion(message);
|
||||
assert.deepEqual(result, message);
|
||||
});
|
||||
|
||||
it('handles attachments with no thumbnail', async () => {
|
||||
const upgradeAttachment = sinon.stub().throws(new Error("Shouldn't be called"));
|
||||
const upgradeVersion = Message._mapQuotedAttachments(upgradeAttachment);
|
||||
|
||||
const message = {
|
||||
body: 'hey there!',
|
||||
quote: {
|
||||
text: 'hey!',
|
||||
attachments: [],
|
||||
},
|
||||
};
|
||||
const result = await upgradeVersion(message);
|
||||
assert.deepEqual(result, message);
|
||||
});
|
||||
|
||||
it('calls provided async function for each quoted attachment', async () => {
|
||||
const upgradeAttachment = sinon.stub().resolves({
|
||||
path: '/new/path/on/disk',
|
||||
});
|
||||
const upgradeVersion = Message._mapQuotedAttachments(upgradeAttachment);
|
||||
|
||||
const message = {
|
||||
body: 'hey there!',
|
||||
quote: {
|
||||
text: 'hey!',
|
||||
attachments: [{
|
||||
thumbnail: {
|
||||
data: 'data is here',
|
||||
},
|
||||
}],
|
||||
},
|
||||
};
|
||||
const expected = {
|
||||
body: 'hey there!',
|
||||
quote: {
|
||||
text: 'hey!',
|
||||
attachments: [{
|
||||
thumbnail: {
|
||||
path: '/new/path/on/disk',
|
||||
},
|
||||
}],
|
||||
},
|
||||
};
|
||||
const result = await upgradeVersion(message);
|
||||
assert.deepEqual(result, expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -10,12 +10,33 @@
|
|||
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: () => {},
|
||||
getPlaceholderMigrations: () => [{
|
||||
migrate: (transaction, next) => {
|
||||
console.log('migration version 1');
|
||||
transaction.db.createObjectStore('conversations');
|
||||
next();
|
||||
},
|
||||
version: 1,
|
||||
}],
|
||||
loadAttachmentData: attachment => Promise.resolve(attachment),
|
||||
};
|
||||
|
||||
window.Signal.Components = {};
|
||||
|
@ -30,6 +51,9 @@ window.EmojiConvertor.prototype.img_sets = {
|
|||
|
||||
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 = {};
|
||||
|
|
|
@ -32,10 +32,15 @@ window.Whisper.View.Templates = {
|
|||
<span class='profileName'>{{ profileName }} </span>
|
||||
{{ /profileName }}
|
||||
</div>
|
||||
<div class='attachments'></div>
|
||||
<p class='content' dir='auto'>
|
||||
{{ #message }}<span class='body'>{{ message }}</span>{{ /message }}
|
||||
</p>
|
||||
<div class='tail-wrapper {{ innerBubbleClasses }}'>
|
||||
<div class='inner-bubble'>
|
||||
<div class='quote-wrapper'></div>
|
||||
<div class='attachments'></div>
|
||||
<div class='content' dir='auto'>
|
||||
{{ #message }}<div class='body'>{{ message }}</div>{{ /message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='meta'>
|
||||
<span class='timestamp' data-timestamp={{ timestamp }}></span>
|
||||
<span class='status hide'></span>
|
||||
|
@ -49,4 +54,13 @@ window.Whisper.View.Templates = {
|
|||
expirationTimerUpdate: `
|
||||
<span class='content'><span class='icon clock'></span> {{ content }}</span>
|
||||
`,
|
||||
'file-view': `
|
||||
<div class='icon {{ mediaType }}'></div>
|
||||
<div class='text'>
|
||||
<div class='fileName' title='{{ altText }}'>
|
||||
{{ fileName }}
|
||||
</div>
|
||||
<div class='fileSize'>{{ fileSize }}</div>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
|
|
|
@ -1,6 +1,328 @@
|
|||
|
||||
Placeholder component:
|
||||
|
||||
```jsx
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<Message />
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
## MessageView (Backbone)
|
||||
|
||||
### Plain messages
|
||||
|
||||
```jsx
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
body: 'How are you doing this fine day?',
|
||||
sent_at: Date.now() - 18000,
|
||||
});
|
||||
const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
|
||||
source: '+12025550003',
|
||||
type: 'incoming',
|
||||
}));
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
### In a group conversation
|
||||
|
||||
```jsx
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
body: 'How are you doing this fine day?',
|
||||
sent_at: Date.now() - 18000,
|
||||
});
|
||||
const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
|
||||
source: '+12025550003',
|
||||
type: 'incoming',
|
||||
}));
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme} type="group" >
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
### With an attachment
|
||||
|
||||
#### Image with caption
|
||||
|
||||
```jsx
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
body: 'I am pretty confused about Pi.',
|
||||
sent_at: Date.now() - 18000000,
|
||||
attachments: [{
|
||||
data: util.gif,
|
||||
fileName: 'pi.gif',
|
||||
contentType: 'image/gif',
|
||||
}],
|
||||
});
|
||||
const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
|
||||
source: '+12025550003',
|
||||
type: 'incoming',
|
||||
}));
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Image
|
||||
|
||||
```jsx
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
sent_at: Date.now() - 18000000,
|
||||
attachments: [{
|
||||
data: util.gif,
|
||||
fileName: 'pi.gif',
|
||||
contentType: 'image/gif',
|
||||
}],
|
||||
});
|
||||
const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
|
||||
source: '+12025550003',
|
||||
type: 'incoming',
|
||||
}));
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Video with caption
|
||||
|
||||
```jsx
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
body: "Beautiful, isn't it?",
|
||||
sent_at: Date.now() - 10000,
|
||||
attachments: [{
|
||||
data: util.mp4,
|
||||
fileName: 'freezing_bubble.mp4',
|
||||
contentType: 'video/mp4',
|
||||
}],
|
||||
});
|
||||
const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
|
||||
source: '+12025550003',
|
||||
type: 'incoming',
|
||||
}));
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Video
|
||||
|
||||
```jsx
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
sent_at: Date.now() - 10000,
|
||||
attachments: [{
|
||||
data: util.mp4,
|
||||
fileName: 'freezing_bubble.mp4',
|
||||
contentType: 'video/mp4',
|
||||
}],
|
||||
});
|
||||
const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
|
||||
source: '+12025550003',
|
||||
type: 'incoming',
|
||||
}));
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Audio with caption
|
||||
|
||||
```jsx
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
body: 'This is a nice song',
|
||||
sent_at: Date.now() - 15000,
|
||||
attachments: [{
|
||||
data: util.mp3,
|
||||
fileName: 'agnus_dei.mp3',
|
||||
contentType: 'audio/mp3',
|
||||
}],
|
||||
});
|
||||
const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
|
||||
source: '+12025550003',
|
||||
type: 'incoming',
|
||||
}));
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Audio
|
||||
|
||||
```jsx
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
sent_at: Date.now() - 15000,
|
||||
attachments: [{
|
||||
data: util.mp3,
|
||||
fileName: 'agnus_dei.mp3',
|
||||
contentType: 'audio/mp3',
|
||||
}],
|
||||
});
|
||||
const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
|
||||
source: '+12025550003',
|
||||
type: 'incoming',
|
||||
}));
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Voice message
|
||||
|
||||
```jsx
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
sent_at: Date.now() - 15000,
|
||||
attachments: [{
|
||||
flags: textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE,
|
||||
data: util.mp3,
|
||||
fileName: 'agnus_dei.mp3',
|
||||
contentType: 'audio/mp3',
|
||||
}],
|
||||
});
|
||||
const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
|
||||
source: '+12025550003',
|
||||
type: 'incoming',
|
||||
}));
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Other file type with caption
|
||||
|
||||
```jsx
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
body: 'My manifesto is now complete!',
|
||||
sent_at: Date.now() - 15000,
|
||||
attachments: [{
|
||||
data: util.txt,
|
||||
fileName: 'lorum_ipsum.txt',
|
||||
contentType: 'text/plain',
|
||||
}],
|
||||
});
|
||||
const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
|
||||
source: '+12025550003',
|
||||
type: 'incoming',
|
||||
}));
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Other file type
|
||||
|
||||
```jsx
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
sent_at: Date.now() - 15000,
|
||||
attachments: [{
|
||||
data: util.txt,
|
||||
fileName: 'lorum_ipsum.txt',
|
||||
contentType: 'text/plain',
|
||||
}],
|
||||
});
|
||||
const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
|
||||
source: '+12025550003',
|
||||
type: 'incoming',
|
||||
}));
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
|
|
@ -2,8 +2,9 @@ import React from 'react';
|
|||
|
||||
|
||||
/**
|
||||
* A placeholder Message component, giving the structure of a plain message with none of
|
||||
* the dynamic functionality. We can build off of this going forward.
|
||||
* A placeholder Message component for now, giving the structure of a plain message with
|
||||
* none of the dynamic functionality. This page will be used to build up our corpus of
|
||||
* permutations before we start moving all message functionality to React.
|
||||
*/
|
||||
export class Message extends React.Component<{}, {}> {
|
||||
public render() {
|
||||
|
@ -12,12 +13,16 @@ export class Message extends React.Component<{}, {}> {
|
|||
<span className="avatar" />
|
||||
<div className="bubble">
|
||||
<div className="sender" dir="auto" />
|
||||
<div className="attachments" />
|
||||
<p className="content" dir="auto">
|
||||
<span className="body">
|
||||
Hi there. How are you doing? Feeling pretty good? Awesome.
|
||||
</span>
|
||||
</p>
|
||||
<div className="tail-wrapper with-tail">
|
||||
<div className="inner-bubble">
|
||||
<div className="attachments" />
|
||||
<p className="content" dir="auto">
|
||||
<span className="body">
|
||||
Hi there. How are you doing? Feeling pretty good? Awesome.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="meta">
|
||||
<span
|
||||
className="timestamp"
|
||||
|
|
895
ts/components/conversation/Quote.md
Normal file
895
ts/components/conversation/Quote.md
Normal file
|
@ -0,0 +1,895 @@
|
|||
|
||||
### With a quotation, text-only replies
|
||||
|
||||
#### Plain text
|
||||
|
||||
```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: '+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;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Replies to you or yourself
|
||||
|
||||
```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: util.ourNumber,
|
||||
id: Date.now() - 1000,
|
||||
},
|
||||
});
|
||||
const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
|
||||
source: '+12025550011',
|
||||
type: 'incoming',
|
||||
quote: Object.assign({}, outgoing.attributes.quote, {
|
||||
author: util.ourNumber,
|
||||
}),
|
||||
}));
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### 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;
|
||||
<util.ConversationContext theme={util.theme} type="group">
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### A lot of text in quotation
|
||||
|
||||
```jsx
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
body: 'Woo, otters!',
|
||||
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;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### A lot of text in quotation, with icon
|
||||
|
||||
```jsx
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
body: 'Woo, otters!',
|
||||
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,
|
||||
attachments: [
|
||||
{
|
||||
contentType: 'text/plain',
|
||||
fileName: 'lorum_ipsum.txt',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
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;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### A lot of text in quotation, with image
|
||||
|
||||
```jsx
|
||||
const quotedMessage = {
|
||||
imageUrl: util.gifObjectUrl,
|
||||
id: '3234-23423-2342',
|
||||
};
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
body: 'Woo, otters!',
|
||||
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,
|
||||
attachments: [
|
||||
{
|
||||
contentType: 'image/gif',
|
||||
fileName: 'pi.gif',
|
||||
thumbnail: {
|
||||
contentType: 'image/gif',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
|
||||
source: '+12025550011',
|
||||
type: 'incoming',
|
||||
quote: Object.assign({}, outgoing.attributes.quote, {
|
||||
author: '+12025550005',
|
||||
}),
|
||||
}));
|
||||
|
||||
outgoing.quotedMessage = quotedMessage;
|
||||
incoming.quotedMessage = quotedMessage;
|
||||
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### 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: '+12025550011',
|
||||
id: Date.now() - 1000,
|
||||
attachments: [
|
||||
{
|
||||
contentType: 'image/gif',
|
||||
fileName: 'pi.gif',
|
||||
thumbnail: {
|
||||
contentType: 'image/gif',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
|
||||
source: '+12025550011',
|
||||
type: 'incoming',
|
||||
quote: Object.assign({}, outgoing.attributes.quote, {
|
||||
author: '+12025550005',
|
||||
}),
|
||||
}));
|
||||
|
||||
outgoing.quotedMessage = quotedMessage;
|
||||
incoming.quotedMessage = quotedMessage;
|
||||
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Image
|
||||
|
||||
```jsx
|
||||
const quotedMessage = {
|
||||
imageUrl: util.gifObjectUrl,
|
||||
};
|
||||
|
||||
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',
|
||||
thumbnail: {
|
||||
contentType: 'image/gif',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
|
||||
source: '+12025550011',
|
||||
type: 'incoming',
|
||||
quote: Object.assign({}, outgoing.attributes.quote, {
|
||||
author: '+12025550005',
|
||||
}),
|
||||
}));
|
||||
|
||||
outgoing.quotedMessage = quotedMessage;
|
||||
incoming.quotedMessage = quotedMessage;
|
||||
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### 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: '+12025550005',
|
||||
}),
|
||||
}));
|
||||
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Video with caption
|
||||
|
||||
```jsx
|
||||
const quotedMessage = {
|
||||
imageUrl: util.gifObjectUrl,
|
||||
};
|
||||
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
body: "Sweet the way the video sneaks up on you!",
|
||||
sent_at: Date.now() - 18000000,
|
||||
quote: {
|
||||
author: '+12025550011',
|
||||
text: 'Check out this video I found!',
|
||||
id: Date.now() - 1000,
|
||||
attachments: [
|
||||
{
|
||||
contentType: 'video/mp4',
|
||||
fileName: 'freezing_bubble.mp4',
|
||||
thumbnail: {
|
||||
contentType: 'image/gif',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
|
||||
source: '+12025550011',
|
||||
type: 'incoming',
|
||||
quote: Object.assign({}, outgoing.attributes.quote, {
|
||||
author: '+12025550005',
|
||||
}),
|
||||
}));
|
||||
|
||||
outgoing.quotedMessage = quotedMessage;
|
||||
incoming.quotedMessage = quotedMessage;
|
||||
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Video
|
||||
|
||||
```jsx
|
||||
const quotedMessage = {
|
||||
imageUrl: util.gifObjectUrl,
|
||||
};
|
||||
|
||||
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',
|
||||
thumbnail: {
|
||||
contentType: 'image/gif',
|
||||
data: util.gif,
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
|
||||
source: '+12025550011',
|
||||
type: 'incoming',
|
||||
quote: Object.assign({}, outgoing.attributes.quote, {
|
||||
author: '+12025550005',
|
||||
}),
|
||||
}));
|
||||
|
||||
outgoing.quotedMessage = quotedMessage;
|
||||
incoming.quotedMessage = quotedMessage;
|
||||
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### 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: '+12025550005',
|
||||
}),
|
||||
}));
|
||||
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Audio with caption
|
||||
|
||||
```jsx
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
body: 'I really like it!',
|
||||
sent_at: Date.now() - 18000000,
|
||||
quote: {
|
||||
author: '+12025550011',
|
||||
text: 'Check out this beautiful song!',
|
||||
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;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Audio
|
||||
|
||||
```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;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### 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: [
|
||||
{
|
||||
// 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, {
|
||||
source: '+12025550011',
|
||||
type: 'incoming',
|
||||
quote: Object.assign({}, outgoing.attributes.quote, {
|
||||
author: '+12025550005',
|
||||
}),
|
||||
}));
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Other file type with caption
|
||||
|
||||
```jsx
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
body: "I can't read latin.",
|
||||
sent_at: Date.now() - 18000000,
|
||||
quote: {
|
||||
author: '+12025550011',
|
||||
text: 'This is my manifesto. Tell me what you think!',
|
||||
id: Date.now() - 1000,
|
||||
attachments: [
|
||||
{
|
||||
contentType: 'text/plain',
|
||||
fileName: 'lorum_ipsum.txt',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
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;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Other file type
|
||||
|
||||
```jsx
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
body: "Sorry, I can't read latin!",
|
||||
sent_at: Date.now() - 18000000,
|
||||
quote: {
|
||||
author: '+12025550011',
|
||||
id: Date.now() - 1000,
|
||||
attachments: [
|
||||
{
|
||||
contentType: 'text/plain',
|
||||
fileName: 'lorum_ipsum.txt',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
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;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
### With a quotation, including attachment
|
||||
|
||||
#### Quote, image attachment, and caption
|
||||
|
||||
```jsx
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
body: 'Like pi or so?',
|
||||
sent_at: Date.now() - 18000000,
|
||||
quote: {
|
||||
text: 'How many ferrets do you have?',
|
||||
author: '+12025550011',
|
||||
id: Date.now() - 1000,
|
||||
},
|
||||
attachments: [{
|
||||
data: util.gif,
|
||||
fileName: 'pi.gif',
|
||||
contentType: 'image/gif',
|
||||
}],
|
||||
});
|
||||
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;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Quote, image attachment
|
||||
|
||||
```jsx
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
sent_at: Date.now() - 18000000,
|
||||
quote: {
|
||||
text: 'How many ferrets do you have?',
|
||||
author: '+12025550011',
|
||||
id: Date.now() - 1000,
|
||||
},
|
||||
attachments: [{
|
||||
data: util.gif,
|
||||
fileName: 'pi.gif',
|
||||
contentType: 'image/gif',
|
||||
}],
|
||||
});
|
||||
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;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Quote, video attachment
|
||||
|
||||
```jsx
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
sent_at: Date.now() - 18000000,
|
||||
quote: {
|
||||
text: 'How many ferrets do you have?',
|
||||
author: '+12025550011',
|
||||
id: Date.now() - 1000,
|
||||
},
|
||||
attachments: [{
|
||||
data: util.mp4,
|
||||
fileName: 'freezing_bubble.mp4',
|
||||
contentType: 'video/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;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Quote, audio attachment
|
||||
|
||||
```jsx
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
sent_at: Date.now() - 18000000,
|
||||
quote: {
|
||||
text: 'How many ferrets do you have?',
|
||||
author: '+12025550011',
|
||||
id: Date.now() - 1000,
|
||||
},
|
||||
attachments: [{
|
||||
data: util.mp3,
|
||||
fileName: 'agnus_dei.mp3',
|
||||
contentType: 'audio/mp3',
|
||||
}],
|
||||
});
|
||||
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;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Quote, file attachment
|
||||
|
||||
```jsx
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
sent_at: Date.now() - 18000000,
|
||||
quote: {
|
||||
text: 'How many ferrets do you have?',
|
||||
author: '+12025550011',
|
||||
id: Date.now() - 1000,
|
||||
},
|
||||
attachments: [{
|
||||
data: util.txt,
|
||||
fileName: 'lorum_ipsum.txt',
|
||||
contentType: 'text/plain',
|
||||
}],
|
||||
});
|
||||
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;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
192
ts/components/conversation/Quote.tsx
Normal file
192
ts/components/conversation/Quote.tsx
Normal file
|
@ -0,0 +1,192 @@
|
|||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
|
||||
// @ts-ignore
|
||||
import Mime from '../../../js/modules/types/mime';
|
||||
|
||||
|
||||
interface Props {
|
||||
attachments: Array<QuotedAttachment>;
|
||||
authorColor: string;
|
||||
authorProfileName?: string;
|
||||
authorTitle: string;
|
||||
i18n: (key: string, values?: Array<string>) => string;
|
||||
isFromMe: string;
|
||||
isIncoming: boolean;
|
||||
onClick?: () => void;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface QuotedAttachment {
|
||||
contentType: string;
|
||||
fileName: string;
|
||||
/* Not included in protobuf */
|
||||
isVoiceMessage: boolean;
|
||||
thumbnail?: Attachment;
|
||||
}
|
||||
|
||||
interface Attachment {
|
||||
contentType: string;
|
||||
/* Not included in protobuf, and is loaded asynchronously */
|
||||
objectUrl?: string;
|
||||
}
|
||||
|
||||
function validateQuote(quote: Props): boolean {
|
||||
if (quote.text) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (quote.attachments && quote.attachments.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function getObjectUrl(thumbnail: Attachment | undefined): string | null {
|
||||
if (thumbnail && thumbnail.objectUrl) {
|
||||
return thumbnail.objectUrl;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export class Quote extends React.Component<Props, {}> {
|
||||
public renderImage(url: string, icon?: string) {
|
||||
const iconElement = icon
|
||||
? <div className={classnames('icon', 'with-image', icon)} />
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="icon-container">
|
||||
<div className="inner">
|
||||
<img src={url} />
|
||||
{iconElement}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public renderIcon(icon: string) {
|
||||
const { authorColor, isIncoming } = this.props;
|
||||
|
||||
const backgroundColor = isIncoming ? 'white' : authorColor;
|
||||
const iconColor = isIncoming ? authorColor : 'white';
|
||||
|
||||
return (
|
||||
<div className="icon-container">
|
||||
<div className={classnames('circle-background', backgroundColor)} />
|
||||
<div className={classnames('icon', icon, iconColor)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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('file');
|
||||
}
|
||||
|
||||
public renderText() {
|
||||
const { i18n, text, attachments } = this.props;
|
||||
|
||||
if (text) {
|
||||
return <div className="text">{text}</div>;
|
||||
}
|
||||
|
||||
if (!attachments || attachments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const first = attachments[0];
|
||||
const { contentType, fileName, isVoiceMessage } = first;
|
||||
|
||||
if (Mime.isVideo(contentType)) {
|
||||
return <div className="type-label">{i18n('video')}</div>;
|
||||
}
|
||||
if (Mime.isImage(contentType)) {
|
||||
return <div className="type-label">{i18n('photo')}</div>;
|
||||
}
|
||||
if (Mime.isAudio(contentType) && isVoiceMessage) {
|
||||
return <div className="type-label">{i18n('voiceMessage')}</div>;
|
||||
}
|
||||
if (Mime.isAudio(contentType)) {
|
||||
return <div className="type-label">{i18n('audio')}</div>;
|
||||
}
|
||||
|
||||
return <div className="filename-label">{fileName}</div>;
|
||||
}
|
||||
|
||||
public renderIOSLabel() {
|
||||
const { i18n, isIncoming, isFromMe, authorTitle, authorProfileName } = this.props;
|
||||
|
||||
const profileString = authorProfileName ? ` ~${authorProfileName}` : '';
|
||||
const authorName = `${authorTitle}${profileString}`;
|
||||
|
||||
const label = isFromMe
|
||||
? isIncoming
|
||||
? i18n('replyingToYou')
|
||||
: i18n('replyingToYourself')
|
||||
: i18n('replyingTo', [authorName]);
|
||||
|
||||
return <div className="ios-label">{label}</div>;
|
||||
}
|
||||
|
||||
public render() {
|
||||
const {
|
||||
authorTitle,
|
||||
authorProfileName,
|
||||
authorColor,
|
||||
onClick,
|
||||
isFromMe,
|
||||
} = this.props;
|
||||
|
||||
if (!validateQuote(this.props)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const authorProfileElement = authorProfileName
|
||||
? <span className="profile-name">~{authorProfileName}</span>
|
||||
: null;
|
||||
const classes = classnames(
|
||||
authorColor,
|
||||
'quote',
|
||||
isFromMe ? 'from-me' : null,
|
||||
!onClick ? 'no-click' : null,
|
||||
);
|
||||
|
||||
return (
|
||||
<div onClick={onClick} className={classes}>
|
||||
<div className="primary">
|
||||
{this.renderIOSLabel()}
|
||||
<div className={classnames(authorColor, 'author')}>
|
||||
{authorTitle}{' '}{authorProfileElement}
|
||||
</div>
|
||||
{this.renderText()}
|
||||
</div>
|
||||
{this.renderIconContainer()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,2 +0,0 @@
|
|||
|
||||
This is Reply.md.
|
|
@ -1,14 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
|
||||
interface Props { name: string; }
|
||||
|
||||
interface State { count: number; }
|
||||
|
||||
export class Reply extends React.Component<Props, State> {
|
||||
public render() {
|
||||
return (
|
||||
<div>Placeholder</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
type: 'private' | 'group';
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -14,11 +16,11 @@ interface Props {
|
|||
*/
|
||||
export class ConversationContext extends React.Component<Props, {}> {
|
||||
public render() {
|
||||
const { theme } = this.props;
|
||||
const { theme, type } = this.props;
|
||||
|
||||
return (
|
||||
<div className={theme}>
|
||||
<div className="conversation">
|
||||
<div className={theme || 'android'}>
|
||||
<div className={classnames('conversation', type || 'private')}>
|
||||
<div className="discussion-container" style={{padding: '0.5em'}}>
|
||||
<ul className="message-list">
|
||||
{this.props.children}
|
||||
|
|
|
@ -3,6 +3,10 @@ import qs from 'qs';
|
|||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import {
|
||||
padStart,
|
||||
sample,
|
||||
} from 'lodash';
|
||||
|
||||
|
||||
// Helper components used in the Style Guide, exposed at 'util' in the global scope via
|
||||
|
@ -13,9 +17,11 @@ export { BackboneWrapper } from '../components/utility/BackboneWrapper';
|
|||
|
||||
// Here we can make things inside Webpack available to Backbone views like preload.js.
|
||||
|
||||
import { Message } from '../components/conversation/Message';
|
||||
import { Reply } from '../components/conversation/Reply';
|
||||
import { Quote } from '../components/conversation/Quote';
|
||||
import * as HTML from '../html';
|
||||
|
||||
// @ts-ignore
|
||||
import MIME from '../../js/modules/types/mime';
|
||||
|
||||
// TypeScript wants two things when you import:
|
||||
// 1) a normal typescript file
|
||||
|
@ -24,18 +30,36 @@ import { Reply } from '../components/conversation/Reply';
|
|||
|
||||
// @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,
|
||||
};
|
||||
|
||||
|
||||
|
@ -77,8 +101,66 @@ parent.moment.locale(locale);
|
|||
parent.React = React;
|
||||
parent.ReactDOM = ReactDOM;
|
||||
|
||||
parent.Signal.HTML = HTML;
|
||||
parent.Signal.Types.MIME = MIME;
|
||||
parent.Signal.Components = {
|
||||
Message,
|
||||
Reply,
|
||||
Quote,
|
||||
};
|
||||
|
||||
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',
|
||||
});
|
||||
|
||||
export {
|
||||
COLORS,
|
||||
CONTACTS,
|
||||
me,
|
||||
};
|
||||
|
||||
parent.textsecure.storage.user.getNumber = () => ourNumber;
|
||||
|
||||
// Telling Lodash to relinquish _ for use by underscore
|
||||
// @ts-ignore
|
||||
_.noConflict();
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue