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:
Scott Nonnenberg 2018-04-16 13:10:10 -07:00 committed by GitHub
commit 8fa0912fb3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 5915 additions and 3273 deletions

View file

@ -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

View file

@ -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',

View file

@ -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"
},

View file

@ -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
View 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
View 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

View file

@ -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) => {

View file

@ -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/');

View file

@ -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()) {

View file

@ -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()) {

View file

@ -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

View file

@ -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",

View file

@ -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 =

View file

@ -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 {

View file

@ -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',
},

View file

@ -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;

View file

@ -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);

View file

@ -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;

View file

@ -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;

View file

@ -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');
}

View file

@ -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>

View file

@ -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);
});
});
});

View file

@ -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 = {};

View file

@ -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>
`,
};

View file

@ -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>
```

View file

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

View 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>
```

View 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>
);
}
}

View file

@ -1,2 +0,0 @@
This is Reply.md.

View file

@ -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>
);
}
}

View file

@ -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}

View file

@ -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();

View file

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