Send quoted replies and message/quote visual updates (#2273)

Currently disabled: the ability to send quoted replies.

Quote-related functionality changes:
-  We generate thumbnails for quoted attachments, so a receiving client doesn't need the original message to see the thumbnail
- Support for exporting and importing messages with quote thumbnails

Other visual changes:
- Big refactor of CSS to ensure that quote rules apply both inside of a message and in the message composition area:
- Emoji support in quotations consistent with normal message rendering
- Android theme: Tightened up spacing at the top of chat bubbles (fixes #2259)
- iOS theme: Center attachment images
- iOS theme: Proper treatment of small image attachments with no caption
- iOS theme: Proper treatment of small image attachments with quote
- When quote thumbnails are not square, make them fill the whole square space
- Better icon for videos when we don't have a thumbnail
- Android dark theme: Improved contrast for outgoing quotes

Dev changes:
- conversation_view.js and backup_test.js were eslint-ified
- Quite a few more message examples in the style guide: all of the visual issues addressed above, messages with errors, notifications (timer changes, safety number warnings, etc.)
- Full end-to-end test for export and import
This commit is contained in:
Scott Nonnenberg 2018-04-24 10:06:39 -07:00 committed by GitHub
commit 0738a43ead
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
44 changed files with 3606 additions and 1724 deletions

View file

@ -23,7 +23,9 @@ ts/**/*.js
!js/logging.js
!js/models/conversations.js
!js/models/messages.js
!test/backup_test.js
!js/views/attachment_view.js
!js/views/conversation_view.js
!js/views/conversation_search_view.js
!js/views/backbone_wrapper_view.js
!js/views/debug_log_view.js

View file

@ -109,6 +109,7 @@ module.exports = function(grunt) {
'!js/Mp3LameEncoder.min.js',
'!js/signal_protocol_store.js',
'!js/views/conversation_search_view.js',
'!js/views/conversation_view.js',
'!js/views/debug_log_view.js',
'!js/views/message_view.js',
'!js/models/conversations.js',
@ -166,6 +167,10 @@ module.exports = function(grunt) {
'!js/modules/**/*.js',
'!js/models/conversations.js',
'!js/models/messages.js',
'!js/views/conversation_search_view.js',
'!js/views/conversation_view.js',
'!js/views/debug_log_view.js',
'!js/views/message_view.js',
'!js/Mp3LameEncoder.min.js',
'!js/WebAudioRecorderMp3.js',
'test/**/*.js',

View file

@ -428,6 +428,10 @@
"selectAContact": {
"message": "Select a contact or group to start chatting."
},
"replyToMessage": {
"message": "Reply to Message",
"description": "Shown in triple-dot menu next to message to allow user to start crafting a message with a quotation"
},
"replyingToYourself": {
"message": "Replying to Yourself",
"description": "Shown in iOS theme when you quote yourself"
@ -542,6 +546,10 @@
"message": "Secure session reset",
"description": "This is a past tense, informational message. In other words, your secure session has been reset."
},
"noContents": {
"message": "No message contents",
"description": "Shown in a message bubble if we have nothing in the message to display, or a quote and nothing else"
},
"installWelcome": {
"message": "Welcome to Signal Desktop",
"description": "Welcome title on the install page"

View file

@ -279,11 +279,16 @@
</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>
{{ #hasAttachments }}
<div class='attachments'></div>
{{ /hasAttachments }}
{{ #hasBody }}
<div class='content' dir='auto'>
{{ #message }}
<div class='body'>{{ message }}</div>
{{ /message }}
</div>
{{ /hasBody }}
</div>
</div>
<div class='meta'>
@ -291,6 +296,16 @@
<span class='status hide'></span>
<span class='timer'></span>
</div>
{{ #hoverIcon }}
<div class='menu-container menu'>
<div class='menu-anchor'>
<span class='dots-horizontal-icon'></span>
<ul class='menu-list'>
<li class='reply'>{{ reply }}</li>
</ul>
</div>
</div>
{{ /hoverIcon }}
</div>
</script>
<script type='text/x-tmpl-mustache' id='hourglass'>

BIN
fixtures/1000x50-green.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
fixtures/200x50-purple.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 B

BIN
fixtures/20x200-yellow.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 489 B

BIN
fixtures/300x1-red.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 706 B

BIN
fixtures/50x1000-teal.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

1
images/close-circle.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="M12,2C17.53,2 22,6.47 22,12C22,17.53 17.53,22 12,22C6.47,22 2,17.53 2,12C2,6.47 6.47,2 12,2M15.59,7L12,10.59L8.41,7L7,8.41L10.59,12L7,15.59L8.41,17L12,13.41L15.59,17L17,15.59L13.41,12L17,8.41L15.59,7Z" /></svg>

After

Width:  |  Height:  |  Size: 495 B

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="M16,12A2,2 0 0,1 18,10A2,2 0 0,1 20,12A2,2 0 0,1 18,14A2,2 0 0,1 16,12M10,12A2,2 0 0,1 12,10A2,2 0 0,1 14,12A2,2 0 0,1 12,14A2,2 0 0,1 10,12M4,12A2,2 0 0,1 6,10A2,2 0 0,1 8,12A2,2 0 0,1 6,14A2,2 0 0,1 4,12Z" /></svg>

After

Width:  |  Height:  |  Size: 501 B

1
images/movie.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="M18,4L20,8H17L15,4H13L15,8H12L10,4H8L10,8H7L5,4H4A2,2 0 0,0 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V4H18Z" /></svg>

After

Width:  |  Height:  |  Size: 401 B

View file

@ -112,8 +112,9 @@
},
addSingleMessage(message) {
this.messageCollection.add(message, { merge: true });
const model = this.messageCollection.add(message, { merge: true });
this.processQuotes(this.messageCollection);
return model;
},
onMessageError() {
@ -610,7 +611,60 @@
return _.without(this.get('members'), me);
},
sendMessage(body, attachments) {
blobToArrayBuffer(blob) {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = e => resolve(e.target.result);
fileReader.onerror = reject;
fileReader.onabort = reject;
fileReader.readAsArrayBuffer(blob);
});
},
async makeThumbnailAttachment(attachment) {
const attachmentWithData = await loadAttachmentData(attachment);
const { data, contentType } = attachmentWithData;
const objectUrl = this.makeObjectUrl(data, contentType);
const thumbnail = await Whisper.FileInputView.makeThumbnail(128, objectUrl);
URL.revokeObjectURL(objectUrl);
const arrayBuffer = await this.blobToArrayBuffer(thumbnail);
const finalContentType = 'image/png';
const finalObjectUrl = this.makeObjectUrl(arrayBuffer, finalContentType);
return {
data: arrayBuffer,
objectUrl: finalObjectUrl,
contentType: finalContentType,
};
},
async makeQuote(quotedMessage) {
const contact = quotedMessage.getContact();
const attachments = quotedMessage.get('attachments');
return {
author: contact.id,
id: quotedMessage.get('sent_at'),
text: quotedMessage.get('body'),
attachments: await Promise.all((attachments || []).map(async (attachment) => {
const { contentType } = attachment;
const willMakeThumbnail = MIME.isImage(contentType);
return {
contentType,
fileName: attachment.fileName,
thumbnail: willMakeThumbnail
? await this.makeThumbnailAttachment(attachment)
: null,
};
})),
};
},
sendMessage(body, attachments, quote) {
this.queueJob(async () => {
const now = Date.now();
@ -625,13 +679,15 @@
type: 'outgoing',
body,
conversationId: this.id,
quote,
attachments,
sent_at: now,
received_at: now,
expireTimer: this.get('expireTimer'),
recipients: this.getRecipients(),
});
const message = this.messageCollection.add(messageWithSchema);
const message = this.addSingleMessage(messageWithSchema);
if (this.isPrivate()) {
message.set({ destination: this.id });
}
@ -666,6 +722,7 @@
this.get('id'),
body,
attachmentsWithData,
quote,
now,
this.get('expireTimer'),
profileKey
@ -1113,18 +1170,8 @@
const queryFirst = queryAttachments[0];
try {
queryMessage.attachments[0] = await loadAttachmentData(queryFirst);
// Note: it would be nice to take the full-size image and downsample it into
// a true thumbnail here.
queryMessage.updateImageUrl();
// We need to differentiate between messages we load from database and those
// already in memory. More cleanup needs to happen on messages from the database
// because they aren't tracked any other way.
// eslint-disable-next-line no-param-reassign
message.quotedMessageFromDatabase = queryMessage;
message.quoteThumbnail = await this.makeThumbnailAttachment(queryFirst);
return true;
} catch (error) {
console.log(
@ -1155,12 +1202,9 @@
try {
const queryFirst = quotedAttachments[0];
// eslint-disable-next-line no-param-reassign
quotedMessage.attributes.attachments[0] = await loadAttachmentData(queryFirst);
// Note: it would be nice to take the full-size image and downsample it into
// a true thumbnail here.
quotedMessage.updateImageUrl();
// eslint-disable-next-line no-param-reassign
message.quoteThumbnail = await this.makeThumbnailAttachment(queryFirst);
} catch (error) {
console.log(
'Problem loading attachment data for quoted message',

View file

@ -31,6 +31,8 @@
this.on('change:expireTimer', this.setToExpire);
this.on('unload', this.unload);
this.setToExpire();
this.VOICE_FLAG = textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE;
},
idForLogging() {
return `${this.get('source')}.${this.get('sourceDevice')} ${this.get('sent_at')}`;
@ -186,6 +188,16 @@
if (this.quotedMessage) {
this.quotedMessage = null;
}
const quote = this.get('quote');
const attachments = (quote && quote.attachments) || [];
attachments.forEach((attachment) => {
if (attachment.thumbnail && attachment.thumbnail.objectUrl) {
URL.revokeObjectURL(attachment.thumbnail.objectUrl);
// eslint-disable-next-line no-param-reassign
attachment.thumbnail.objectUrl = null;
}
});
this.revokeImageUrl();
},
revokeImageUrl() {
@ -200,6 +212,77 @@
}
return this.imageUrl;
},
getQuoteObjectUrl() {
const thumbnail = this.quoteThumbnail;
if (!thumbnail || !thumbnail.objectUrl) {
return null;
}
return thumbnail.objectUrl;
},
getQuoteContact() {
const quote = this.get('quote');
if (!quote) {
return null;
}
const { author } = quote;
if (!author) {
return null;
}
return ConversationController.get(author);
},
processAttachment(attachment, externalObjectUrl) {
const { thumbnail } = attachment;
const objectUrl = (thumbnail && thumbnail.objectUrl) || externalObjectUrl;
const thumbnailWithObjectUrl = !objectUrl
? null
: Object.assign({}, attachment.thumbnail || {}, {
objectUrl,
});
return Object.assign({}, attachment, {
// eslint-disable-next-line no-bitwise
isVoiceMessage: Boolean(attachment.flags & this.VOICE_FLAG),
thumbnail: thumbnailWithObjectUrl,
});
},
getPropsForQuote() {
const quote = this.get('quote');
if (!quote) {
return null;
}
const objectUrl = this.getQuoteObjectUrl();
const OUR_NUMBER = textsecure.storage.user.getNumber();
const { author } = quote;
const contact = this.getQuoteContact();
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.isIncoming();
const onClick = () => {
const { quotedMessage } = this;
if (quotedMessage) {
this.trigger('scroll-to-message', { id: quotedMessage.id });
}
};
return {
attachments: (quote.attachments || []).map(attachment =>
this.processAttachment(attachment, objectUrl)),
authorColor,
authorProfileName,
authorTitle,
isFromMe,
isIncoming,
onClick: this.quotedMessage ? onClick : null,
text: quote.text,
};
},
getConversation() {
// This needs to be an unsafe call, because this method is called during
// initial module setup. We may be in the middle of the initial fetch to
@ -207,12 +290,12 @@
return ConversationController.getUnsafe(this.get('conversationId'));
},
getExpirationTimerUpdateSource() {
if (this.isExpirationTimerUpdate()) {
const conversationId = this.get('expirationTimerUpdate').source;
return ConversationController.getOrCreate(conversationId, 'private');
if (!this.isExpirationTimerUpdate()) {
throw new Error('Message is not a timer update!');
}
return Promise.resolve();
const conversationId = this.get('expirationTimerUpdate').source;
return ConversationController.getOrCreate(conversationId, 'private');
},
getContact() {
let conversationId = this.get('source');

View file

@ -457,17 +457,18 @@ async function readAttachment(dir, attachment, name, options) {
options = options || {};
const { key } = options;
const anonymousName = _sanitizeFileName(name);
const targetPath = path.join(dir, anonymousName);
const sanitizedName = _sanitizeFileName(name);
const targetPath = path.join(dir, sanitizedName);
if (!fs.existsSync(targetPath)) {
console.log(`Warning: attachment ${anonymousName} not found`);
console.log(`Warning: attachment ${sanitizedName} not found`);
return;
}
const data = await readFileAsArrayBuffer(targetPath);
const isEncrypted = !_.isUndefined(key);
if (isEncrypted) {
attachment.data = await crypto.decryptSymmetric(key, data);
} else {
@ -475,6 +476,65 @@ async function readAttachment(dir, attachment, name, options) {
}
}
async function writeThumbnail(attachment, options) {
const {
dir,
message,
index,
key,
newKey,
} = options;
const filename = `${_getAnonymousAttachmentFileName(message, index)}-thumbnail`;
const target = path.join(dir, filename);
const { thumbnail } = attachment;
if (!thumbnail || !thumbnail.data) {
return;
}
await writeEncryptedAttachment(target, thumbnail.data, {
key,
newKey,
filename,
dir,
});
}
async function writeThumbnails(rawQuotedAttachments, options) {
const { name } = options;
const { loadAttachmentData } = Signal.Migrations;
const promises = rawQuotedAttachments.map(async (attachment) => {
if (!attachment || !attachment.thumbnail || !attachment.thumbnail.path) {
return attachment;
}
return Object.assign(
{},
attachment,
{ thumbnail: await loadAttachmentData(attachment.thumbnail) }
);
});
const attachments = await Promise.all(promises);
try {
await Promise.all(_.map(
attachments,
(attachment, index) => writeThumbnail(attachment, Object.assign({}, options, {
index,
}))
));
} catch (error) {
console.log(
'writeThumbnails: error exporting conversation',
name,
':',
error && error.stack ? error.stack : error
);
throw error;
}
}
async function writeAttachment(attachment, options) {
const {
dir,
@ -485,26 +545,16 @@ async function writeAttachment(attachment, options) {
} = options;
const filename = _getAnonymousAttachmentFileName(message, index);
const target = path.join(dir, filename);
if (fs.existsSync(target)) {
if (newKey) {
console.log(`Deleting attachment ${filename}; key has changed`);
fs.unlinkSync(target);
} else {
console.log(`Skipping attachment ${filename}; already exists`);
return;
}
}
if (!Attachment.hasData(attachment)) {
throw new TypeError("'attachment.data' is required");
}
const ciphertext = await crypto.encryptSymmetric(key, attachment.data);
const writer = await createFileAndWriter(dir, filename);
const stream = createOutputStream(writer);
stream.write(Buffer.from(ciphertext));
await stream.close();
await writeEncryptedAttachment(target, attachment.data, {
key,
newKey,
filename,
dir,
});
}
async function writeAttachments(rawAttachments, options) {
@ -531,6 +581,32 @@ async function writeAttachments(rawAttachments, options) {
}
}
async function writeEncryptedAttachment(target, data, options = {}) {
const {
key,
newKey,
filename,
dir,
} = options;
if (fs.existsSync(target)) {
if (newKey) {
console.log(`Deleting attachment ${filename}; key has changed`);
fs.unlinkSync(target);
} else {
console.log(`Skipping attachment ${filename}; already exists`);
return;
}
}
const ciphertext = await crypto.encryptSymmetric(key, data);
const writer = await createFileAndWriter(dir, filename);
const stream = createOutputStream(writer);
stream.write(Buffer.from(ciphertext));
await stream.close();
}
function _sanitizeFileName(filename) {
return filename.toString().replace(/[^a-z0-9.,+()'#\- ]/gi, '_');
}
@ -542,6 +618,7 @@ async function exportConversation(db, conversation, options) {
dir,
attachmentsDir,
key,
newKey,
} = options;
if (!name) {
throw new Error('Need a name!');
@ -610,6 +687,7 @@ async function exportConversation(db, conversation, options) {
}
// eliminate attachment data from the JSON, since it will go to disk
// Note: this is for legacy messages only, which stored attachment data in the db
message.attachments = _.map(
attachments,
attachment => _.omit(attachment, ['data'])
@ -629,18 +707,34 @@ async function exportConversation(db, conversation, options) {
const jsonString = JSON.stringify(stringify(message));
stream.write(jsonString);
console.log({ backupMessage: message });
if (attachments && attachments.length > 0) {
const exportAttachments = () => writeAttachments(attachments, {
dir: attachmentsDir,
name,
message,
key,
newKey,
});
// eslint-disable-next-line more/no-then
promiseChain = promiseChain.then(exportAttachments);
}
const quoteThumbnails = message.quote && message.quote.attachments;
if (quoteThumbnails && quoteThumbnails.length > 0) {
const exportQuoteThumbnails = () => writeThumbnails(quoteThumbnails, {
dir: attachmentsDir,
name,
message,
key,
newKey,
});
// eslint-disable-next-line more/no-then
promiseChain = promiseChain.then(exportQuoteThumbnails);
}
count += 1;
cursor.continue();
} else {
@ -701,6 +795,7 @@ function exportConversations(db, options) {
messagesDir,
attachmentsDir,
key,
newKey,
} = options;
if (!messagesDir) {
@ -747,6 +842,7 @@ function exportConversations(db, options) {
dir,
attachmentsDir,
key,
newKey,
});
};
@ -808,11 +904,23 @@ function loadAttachments(dir, getName, options) {
options = options || {};
const { message } = options;
const promises = _.map(message.attachments, (attachment, index) => {
const attachmentPromises = _.map(message.attachments, (attachment, index) => {
const name = getName(message, index, attachment);
return readAttachment(dir, attachment, name, options);
});
return Promise.all(promises);
const quoteAttachments = message.quote && message.quote.attachments;
const thumbnailPromises = _.map(quoteAttachments, (attachment, index) => {
const thumbnail = attachment && attachment.thumbnail;
if (!thumbnail) {
return null;
}
const name = `${getName(message, index, thumbnail)}-thumbnail`;
return readAttachment(dir, thumbnail, name, options);
});
return Promise.all(attachmentPromises.concat(thumbnailPromises));
}
function saveMessage(db, message) {
@ -922,7 +1030,11 @@ async function importConversation(db, dir, options) {
return false;
}
if (message.attachments && message.attachments.length) {
const hasAttachments = message.attachments && message.attachments.length;
const hasQuotedAttachments = message.quote && message.quote.attachments &&
message.quote.attachments.length > 0;
if (hasAttachments || hasQuotedAttachments) {
const importMessage = async () => {
const getName = attachmentsDir
? _getAnonymousAttachmentFileName

View file

@ -18,6 +18,9 @@ const PRIVATE = 'private';
// - Attachments: Sanitize Unicode order override characters.
// Version 3
// - Attachments: Write attachment data to disk and store relative path to it.
// Version 4
// - Quotes: Write thumbnail data to disk and store relative path to it.
const INITIAL_SCHEMA_VERSION = 0;
@ -158,13 +161,19 @@ exports._mapQuotedAttachments = upgradeAttachment => async (message, context) =>
}
const upgradeWithContext = async (attachment) => {
if (!attachment || !attachment.thumbnail) {
const { thumbnail } = attachment;
if (!thumbnail) {
return attachment;
}
const thumbnail = await upgradeAttachment(attachment.thumbnail, context);
if (!thumbnail.data) {
console.log('Quoted attachment did not have thumbnail data; removing it');
return omit(attachment, ['thumbnail']);
}
const upgradedThumbnail = await upgradeAttachment(thumbnail, context);
return Object.assign({}, attachment, {
thumbnail,
thumbnail: upgradedThumbnail,
});
};
@ -243,9 +252,12 @@ exports.createAttachmentDataWriter = (writeExistingAttachmentData) => {
const message = exports.initializeSchemaVersion(rawMessage);
const { attachments } = message;
const hasAttachments = attachments && attachments.length > 0;
if (!hasAttachments) {
const { attachments, quote } = message;
const hasFilesToWrite =
(quote && quote.attachments && quote.attachments.length > 0) ||
(attachments && attachments.length > 0);
if (!hasFilesToWrite) {
return message;
}
@ -256,7 +268,7 @@ exports.createAttachmentDataWriter = (writeExistingAttachmentData) => {
return message;
}
attachments.forEach((attachment) => {
(attachments || []).forEach((attachment) => {
if (!Attachment.hasData(attachment)) {
throw new TypeError("'attachment.data' is required during message import");
}
@ -266,13 +278,36 @@ exports.createAttachmentDataWriter = (writeExistingAttachmentData) => {
}
});
const messageWithoutAttachmentData = Object.assign({}, message, {
attachments: await Promise.all(attachments.map(async (attachment) => {
await writeExistingAttachmentData(attachment);
return omit(attachment, ['data']);
})),
const writeThumbnails = exports._mapQuotedAttachments(async (thumbnail) => {
const { data, path } = thumbnail;
// we want to be bulletproof to thumbnails without data
if (!data || !path) {
console.log(
'Thumbnail had neither data nor path.',
'id:',
message.id,
'source:',
message.source
);
return thumbnail;
}
await writeExistingAttachmentData(thumbnail);
return omit(thumbnail, ['data']);
});
const messageWithoutAttachmentData = Object.assign(
{},
await writeThumbnails(message),
{
attachments: await Promise.all((attachments || []).map(async (attachment) => {
await writeExistingAttachmentData(attachment);
return omit(attachment, ['data']);
})),
}
);
return messageWithoutAttachmentData;
};
};

File diff suppressed because it is too large Load diff

View file

@ -22,6 +22,41 @@
template: i18n('unsupportedFileType')
});
function makeThumbnail(size, objectUrl) {
return new Promise(function(resolve, reject) {
var img = document.createElement('img');
img.onerror = reject;
img.onload = function () {
// using components/blueimp-load-image
// first, make the correct size
var canvas = loadImage.scale(img, {
canvas: true,
cover: true,
maxWidth: size,
maxHeight: size,
minWidth: size,
minHeight: size,
});
// then crop
canvas = loadImage.scale(canvas, {
canvas: true,
crop: true,
maxWidth: size,
maxHeight: size,
minWidth: size,
minHeight: size,
});
var blob = window.dataURLToBlobSync(canvas.toDataURL('image/png'));
resolve(blob);
};
img.src = objectUrl;
});
}
Whisper.FileInputView = Backbone.View.extend({
tagName: 'span',
className: 'file-input',
@ -239,29 +274,11 @@
return Promise.resolve();
}
return new Promise(function(resolve, reject) {
var url = URL.createObjectURL(file);
var img = document.createElement('img');
img.onerror = reject;
img.onload = function () {
URL.revokeObjectURL(url);
// loadImage.scale -> components/blueimp-load-image
// scale, then crop.
var canvas = loadImage.scale(img, {
canvas: true, maxWidth: size, maxHeight: size,
cover: true, minWidth: size, minHeight: size
});
canvas = loadImage.scale(canvas, {
canvas: true, maxWidth: size, maxHeight: size,
crop: true, minWidth: size, minHeight: size
});
var blob = window.dataURLToBlobSync(canvas.toDataURL('image/png'));
resolve(blob);
};
img.src = url;
}).then(this.readFile);
const objectUrl = URL.createObjectURL(file);
return makeThumbnail(256, file).then(function(arrayBuffer) {
URL.revokeObjectURL(url);
return this.readFile(arrayBuffer);
});
},
// File -> Promise Attachment
@ -348,4 +365,6 @@
}
}
});
Whisper.FileInputView.makeThumbnail = makeThumbnail;
})();

View file

@ -4,7 +4,7 @@
/* global _: false */
/* global emoji_util: false */
/* global Mustache: false */
/* global ConversationController: false */
/* global $: false */
// eslint-disable-next-line func-names
(function () {
@ -45,8 +45,10 @@
tagName: 'span',
className: 'some-failed',
templateName: 'some-failed',
render_attributes: {
someFailed: i18n('someRecipientsFailed'),
render_attributes() {
return {
someFailed: i18n('someRecipientsFailed'),
};
},
});
const TimerView = Whisper.View.extend({
@ -215,6 +217,8 @@
'click .status': 'select',
'click .some-failed': 'select',
'click .error-message': 'select',
'click .menu-container': 'showMenu',
'click .menu-list .reply': 'onReply',
},
retryMessage() {
const retrys = _.filter(
@ -225,6 +229,26 @@
this.model.resend(number);
});
},
showMenu(e) {
if (this.menuVisible) {
return;
}
this.menuVisible = true;
e.stopPropagation();
this.$('.menu-list').show();
$(document).one('click', () => {
this.hideMenu();
});
},
hideMenu() {
this.menuVisible = false;
this.$('.menu-list').hide();
},
onReply() {
this.model.trigger('reply', this.model);
},
onExpired() {
this.$el.addClass('expired');
this.$el.find('.bubble').one('webkitAnimationEnd animationend', (e) => {
@ -252,8 +276,8 @@
if (this.timeStampView) {
this.timeStampView.remove();
}
if (this.replyView) {
this.replyView.remove();
if (this.quoteView) {
this.quoteView.remove();
}
// NOTE: We have to do this in the background (`then` instead of `await`)
@ -314,7 +338,6 @@
renderErrors() {
const errors = this.model.get('errors');
this.$('.error-icon-container').remove();
if (this.errorIconView) {
this.errorIconView.remove();
@ -326,6 +349,12 @@
}
this.errorIconView = new ErrorIconView({ model: errors[0] });
this.errorIconView.render().$el.appendTo(this.$('.bubble'));
} else if (!this.hasContents()) {
const el = this.$('.content');
if (!el || el.length === 0) {
this.$('.inner-bubble').append("<div class='content'></div>");
}
this.$('.content').text(i18n('noContents')).addClass('error-message');
}
this.$('.meta .hasRetry').remove();
@ -365,86 +394,28 @@
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) {
const props = this.model.getPropsForQuote();
if (!props) {
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 });
}
};
const props = {
attachments: (quote.attachments || []).map(processAttachment),
authorColor,
authorProfileName,
authorTitle,
isFromMe,
isIncoming,
onClick: this.model.quotedMessage ? onClick : null,
text: quote.text,
};
if (this.replyView) {
this.replyView = null;
const contact = this.model.getQuoteContact();
if (this.quoteView) {
this.quoteView.remove();
this.quoteView = null;
} else if (contact) {
this.listenTo(contact, 'change:color', this.renderQuote);
}
this.replyView = new Whisper.ReactWrapperView({
el: this.$('.quote-wrapper'),
this.quoteView = new Whisper.ReactWrapperView({
className: 'quote-wrapper',
Component: window.Signal.Components.Quote,
props,
props: Object.assign({}, props, {
text: props.text ? window.emoji.signalReplace(props.text) : null,
}),
});
this.$('.inner-bubble').prepend(this.quoteView.el);
},
isImageWithoutCaption() {
const attachments = this.model.get('attachments');
@ -464,15 +435,44 @@
return false;
},
hasContents() {
const attachments = this.model.get('attachments');
const hasAttachments = attachments && attachments.length > 0;
return this.hasTextContents() || hasAttachments;
},
hasTextContents() {
const body = this.model.get('body');
const isGroupUpdate = this.model.isGroupUpdate();
const isEndSession = this.model.isEndSession();
const errors = this.model.get('errors');
const hasErrors = errors && errors.length > 0;
const errorsCanBeContents = this.model.isIncoming() && hasErrors;
return body || isGroupUpdate || isEndSession || errorsCanBeContents;
},
render() {
const contact = this.model.isIncoming() ? this.model.getContact() : null;
const attachments = this.model.get('attachments');
// TODO: used for the feature flag below
// const hasErrors = errors && errors.length > 0;
const hasAttachments = attachments && attachments.length > 0;
const hasBody = this.hasTextContents();
this.$el.html(Mustache.render(_.result(this, 'template', ''), {
message: this.model.get('body'),
hasBody,
timestamp: this.model.get('sent_at'),
sender: (contact && contact.getTitle()) || '',
avatar: (contact && contact.getAvatar()),
profileName: (contact && contact.getProfileName()),
innerBubbleClasses: this.isImageWithoutCaption() ? '' : 'with-tail',
// TODO: Turn this on when we're ready to enable sending quoted replies
hoverIcon: false, // !hasErrors,
hasAttachments,
reply: i18n('replyToMessage'),
}, this.render_partials()));
this.timeStampView.setElement(this.$('.timestamp'));
this.timeStampView.update();

View file

@ -17,6 +17,8 @@
const { Component, props, onClose } = options;
this.render();
this.tagName = options.tagName;
this.className = options.className;
this.Component = Component;
this.onClose = onClose;

View file

@ -17,6 +17,7 @@ function stringToArrayBuffer(str) {
function Message(options) {
this.body = options.body;
this.attachments = options.attachments || [];
this.quote = options.quote;
this.group = options.group;
this.flags = options.flags;
this.recipients = options.recipients;
@ -93,6 +94,28 @@ Message.prototype = {
proto.group.id = stringToArrayBuffer(this.group.id);
proto.group.type = this.group.type
}
if (this.quote) {
var QuotedAttachment = textsecure.protobuf.DataMessage.Quote.QuotedAttachment;
var Quote = textsecure.protobuf.DataMessage.Quote;
proto.quote = new Quote();
var quote = proto.quote;
quote.id = this.quote.id;
quote.author = this.quote.author;
quote.text = this.quote.text;
quote.attachments = (this.quote.attachments || []).map(function(attachment) {
var quotedAttachment = new QuotedAttachment();
quotedAttachment.contentType = attachment.contentType;
quotedAttachment.fileName = attachment.fileName;
if (attachment.attachmentPointer) {
quotedAttachment.thumbnail = attachment.attachmentPointer;
}
return quotedAttachment;
});
}
if (this.expireTimer) {
proto.expireTimer = this.expireTimer;
}
@ -223,7 +246,7 @@ MessageSender.prototype = {
}.bind(this));
},
uploadMedia: function(message) {
uploadAttachments: function(message) {
return Promise.all(
message.attachments.map(this.makeAttachmentPointer.bind(this))
).then(function(attachmentPointers) {
@ -237,9 +260,38 @@ MessageSender.prototype = {
});
},
uploadThumbnails: function(message) {
var makePointer = this.makeAttachmentPointer.bind(this);
var quote = message.quote;
if (!quote || !quote.attachments || quote.attachments.length === 0) {
return Promise.resolve();
}
return Promise.all(quote.attachments.map(function(attachment) {
const thumbnail = attachment.thumbnail;
if (!thumbnail) {
return;
}
return makePointer(thumbnail).then(function(pointer) {
attachment.attachmentPointer = pointer;
});
})).catch(function(error) {
if (error instanceof Error && error.name === 'HTTPError') {
throw new textsecure.MessageError(message, error);
} else {
throw error;
}
});
},
sendMessage: function(attrs) {
var message = new Message(attrs);
return this.uploadMedia(message).then(function() {
return Promise.all([
this.uploadAttachments(message),
this.uploadThumbnails(message),
]).then(function() {
return new Promise(function(resolve, reject) {
this.sendMessageProto(
message.timestamp,
@ -494,12 +546,13 @@ MessageSender.prototype = {
}.bind(this));
},
sendMessageToNumber: function(number, messageText, attachments, timestamp, expireTimer, profileKey) {
sendMessageToNumber: function(number, messageText, attachments, quote, timestamp, expireTimer, profileKey) {
return this.sendMessage({
recipients : [number],
body : messageText,
timestamp : timestamp,
attachments : attachments,
quote : quote,
needsSync : true,
expireTimer : expireTimer,
profileKey : profileKey
@ -558,7 +611,7 @@ MessageSender.prototype = {
]);
},
sendMessageToGroup: function(groupId, messageText, attachments, timestamp, expireTimer, profileKey) {
sendMessageToGroup: function(groupId, messageText, attachments, quote, timestamp, expireTimer, profileKey) {
return textsecure.storage.groups.getNumbers(groupId).then(function(numbers) {
if (numbers === undefined)
return Promise.reject(new Error("Unknown Group"));
@ -574,6 +627,7 @@ MessageSender.prototype = {
body : messageText,
timestamp : timestamp,
attachments : attachments,
quote : quote,
needsSync : true,
expireTimer : expireTimer,
profileKey : profileKey,

View file

@ -114,6 +114,7 @@
"eslint-plugin-mocha": "^4.12.1",
"eslint-plugin-more": "^0.3.1",
"extract-zip": "^1.6.6",
"glob": "^7.1.2",
"grunt": "^1.0.1",
"grunt-cli": "^1.2.0",
"grunt-contrib-concat": "^1.0.1",

View file

@ -205,3 +205,16 @@ window.Signal.Workflow.MessageDataMigrator =
// We pull this in last, because the native module involved appears to be sensitive to
// /tmp mounted as noexec on Linux.
require('./js/spell_check');
if (window.config.environment === 'test') {
/* eslint-disable global-require, import/no-extraneous-dependencies */
window.test = {
glob: require('glob'),
fse: require('fs-extra'),
tmp: require('tmp'),
path: require('path'),
basePath: __dirname,
attachmentsPath,
};
/* eslint-enable global-require, import/no-extraneous-dependencies */
}

View file

@ -163,7 +163,7 @@ module.exports = {
},
{
// To test handling of attachments, we need arraybuffers in memory
test: /\.(gif|mp3|mp4|txt)$/,
test: /\.(gif|mp3|mp4|txt|jpg|jpeg|png)$/,
loader: 'arraybuffer-loader',
},
],

View file

@ -353,6 +353,45 @@ li.entry .error-icon-container {
&:hover .error-message { display: inline-block; }
}
li.entry .menu-container {
position: absolute;
top: 0;
left: calc(100% + 5px);
height: 100%;
display: flex;
align-items: center;
justify-content: center;
.menu-anchor {
position: relative;
}
li {
margin: 0px;
}
cursor: pointer;
}
.dots-horizontal-icon {
visibility: hidden;
}
li.entry:hover .dots-horizontal-icon {
visibility: visible;
}
li.entry.outgoing .menu-container {
left: auto;
right: calc(100% + 5px);
}
.incoming .menu-list {
left: 0;
right: auto;
}
.error-icon {
display: inline-block;
@ -362,6 +401,18 @@ li.entry .error-icon-container {
@include color-svg('../images/warning.svg', red);
}
.dots-horizontal-icon {
display: inline-block;
width: $error-icon-size;
height: $error-icon-size;
position: relative;
@include color-svg('../images/dots-horizontal.svg', gray);
&:hover {
@include color-svg('../images/dots-horizontal.svg', black);
}
}
.group {
li.entry .unregistered-user-error {
display: none;
@ -379,10 +430,6 @@ 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;
@ -439,8 +486,6 @@ span.status {
}
}
.bubble {
position: relative;
left: -2px;
@ -456,142 +501,7 @@ 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 {
@ -599,6 +509,13 @@ span.status {
}
}
.attachments + .content {
margin-top: 0.5em;
}
.quote-wrapper + .content {
margin-top: 0.5em;
}
p {
margin: 0;
}
@ -650,13 +567,6 @@ span.status {
.avatar, .bubble {
float: left;
}
.bubble {
.quote {
background-color: rgba(white, 0.6);
border-left-color: white;
}
}
}
.outgoing {
@ -799,6 +709,199 @@ span.status {
}
}
.quoted-message {
@include message-replies-colors;
@include twenty-percent-colors;
&.no-click {
cursor: auto;
}
position: relative;
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;
}
}
.close-container {
position: absolute;
top: 4px;
right: 4px;
height: 16px;
width: 16px;
background-color: rgba(255, 255, 255, 0.75);
border-radius: 50%;
.close-button {
width: 100%;
height: 100%;
@include color-svg('../images/x.svg', $grey);
}
}
.icon-container {
flex: initial;
min-width: 50px;
width: 50px;
height: 50px;
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);
}
&.movie {
@include color-svg('../images/movie.svg', white);
}
@include avatar-colors;
}
.inner {
position: relative;
height: 50px;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
object-fit: cover;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
}
}
// We only add margin if there's no 'sender' element beforehand, which is only possible
// on incoming messages, and only in groups (when we're not in a .private conversation).
.outgoing .quoted-message,
.private .incoming .quoted-message {
margin-top: $android-bubble-quote-padding - $android-bubble-padding-vertical;
}
.bottom-bar .quoted-message {
margin: 0;
}
// We need to use the wrapper because the conversation view calculates the height of all
// things in the composition area. A margin on an inner div won't be included in that
// height calculation.
.bottom-bar .quote-wrapper {
margin-right: 5px;
margin-bottom: 5px;
}
.send .quote-wrapper {
margin-left: 46px;
margin-top: 5px;
margin-right: 75px;
margin-bottom: 0px;
}
.incoming .quoted-message {
background-color: rgba(white, 0.6);
border-top: none;
border-bottom: none;
border-right: none;
border-left-color: white;
}
.message-list,
.message-container {
.avatar {

View file

@ -108,122 +108,182 @@ $ios-border-color: rgba(0,0,0,0.1);
.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;
.bubble .content {
margin-top: 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;
.quoted-message {
// Not ideal, but necessary to override the specificity of the android theme color
// classes used in conversations.scss
background-color: white !important;
border: none !important;
border-radius: 0;
margin-top: 0px !important;
margin-bottom: 0px;
margin-left: 0px;
margin-right: 0px;
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;
}
}
.close-container {
flex: initial;
min-width: 32px;
width: 32px;
height: 50px;
position: relative;
top: auto;
right: auto;
display: flex;
align-items: center;
justify-content: center;
-webkit-mask: none;
background: none;
.close-button {
height: 20px;
width: 20px;
@include color-svg('../images/close-circle.svg', $grey_l4);
}
}
.from-me {
.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;
border-left: 2px solid $blue;
}
}
}
}
.author {
display: none;
}
.incoming .quoted-message {
border-bottom: 1px solid lightgray !important;
}
.quoted-message.from-me .primary {
.text,
.filename-label,
.type-label {
border-left: 2px solid $blue;
}
}
.outgoing .quoted-message,
.private .incoming .quoted-message {
margin-top: 0px;
}
.outgoing .quoted-message .icon-container .circle-background {
background-color: lightgray !important;
}
.bottom-bar {
.quote-wrapper {
margin-right: 0px;
margin-bottom: 15px;
}
.quoted-message {
background: none !important;
.primary {
padding: 0px;
.ios-label {
display: block;
color: $grey_l1;
font-size: smaller;
margin-bottom: 3px;
color: $grey_l4;
}
}
.icon-container {
height: 61px;
width: 61px;
min-width: 61px;
height: 50px;
width: 50px;
min-width: 50px;
.circle-background {
left: 12px;
right: 12px;
top: 12px;
bottom: 12px;
left: 6px;
right: 6px;
top: 6px;
bottom: 6px;
background-color: $blue !important;
}
.icon {
left: 18px;
right: 18px;
top: 18px;
bottom: 18px;
background-color: white !important;
left: 12px;
right: 12px;
top: 12px;
bottom: 12px;
}
.inner {
padding: 12px;
height: 61px;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
padding: 0px;
height: 50px;
}
}
.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;
}
}
@ -286,38 +346,53 @@ $ios-border-color: rgba(0,0,0,0.1);
clear: both;
}
.outgoing .with-tail.tail-wrapper {
.outgoing .tail-wrapper {
float: right;
.inner-bubble {
.attachments {
background-color: $blue;
}
.content {
background-color: $blue;
}
max-width: 100%;
&, .body, a {
@include invert-text-color;
}
}
}
.incoming .with-tail.tail-wrapper {
.incoming .tail-wrapper {
float: left;
&:before {
left: -1px;
background-color: white;
.inner-bubble {
max-width: 100%;
}
&:after {
left: -6px;
}
// The browser doesn't always clip the border-radius properly, so we can get a
// partial-pixel halo effect. Sadly, it is still needed because a quote can force the
// bubble wider than an attached image, and we need a background color on the bottom
// section if the image doesn't cover it all.
.outgoing .tail-wrapper {
.attachments {
background-color: $blue;
}
.content {
background-color: $blue;
}
&, .body, a {
@include invert-text-color;
}
}
.incoming .tail-wrapper {
&.with-tail {
&:before {
left: -1px;
background-color: white;
}
&:after {
left: -6px;
}
}
.inner-bubble {
background-color: white;
color: black;
max-width: 100%;
}
}
@ -338,6 +413,10 @@ $ios-border-color: rgba(0,0,0,0.1);
a {
border-radius: 15px;
}
img {
margin-right: auto;
margin-left: auto;
}
}
.hourglass {
@include hourglass(#999);

View file

@ -225,13 +225,17 @@ $text-dark_l2: darken($text-dark, 30%);
}
}
.outgoing .bubble .quote .icon-container .icon {
background-color: black;
&.play.with-image {
background-color: $text-dark;
.outgoing .quoted-message {
background: rgba(255, 255, 255, 0.38);
.icon-container .icon {
background-color: black;
&.play.with-image {
background-color: $text-dark;
}
}
}
.incoming .bubble .quote {
.incoming .quoted-message {
border-left-color: $text-dark;
background-color: rgba(0, 0, 0, 0.6);

View file

@ -3,6 +3,11 @@
module.exports = {
env: {
mocha: true,
browser: true,
},
parserOptions: {
sourceType: 'script',
},
rules: {

12
test/app/.eslintrc.js Normal file
View file

@ -0,0 +1,12 @@
// For reference: https://github.com/airbnb/javascript
module.exports = {
env: {
mocha: true,
browser: false,
},
parserOptions: {
sourceType: 'module',
},
};

View file

@ -1,52 +1,58 @@
/* global Signal: false */
/* global Whisper: false */
/* global assert: false */
/* global textsecure: false */
/* global _: false */
'use strict';
describe('Backup', function() {
describe('_sanitizeFileName', function() {
it('leaves a basic string alone', function() {
var initial = 'Hello, how are you #5 (\'fine\' + great).jpg';
var expected = initial;
describe('Backup', () => {
describe('_sanitizeFileName', () => {
it('leaves a basic string alone', () => {
const initial = 'Hello, how are you #5 (\'fine\' + great).jpg';
const expected = initial;
assert.strictEqual(Signal.Backup._sanitizeFileName(initial), expected);
});
it('replaces all unknown characters', function() {
var initial = '!@$%^&*=';
var expected = '________';
it('replaces all unknown characters', () => {
const initial = '!@$%^&*=';
const expected = '________';
assert.strictEqual(Signal.Backup._sanitizeFileName(initial), expected);
});
});
describe('_trimFileName', function() {
it('handles a file with no extension', function() {
var initial = '0123456789012345678901234567890123456789';
var expected = '012345678901234567890123456789';
describe('_trimFileName', () => {
it('handles a file with no extension', () => {
const initial = '0123456789012345678901234567890123456789';
const expected = '012345678901234567890123456789';
assert.strictEqual(Signal.Backup._trimFileName(initial), expected);
});
it('handles a file with a long extension', function() {
var initial = '0123456789012345678901234567890123456789.01234567890123456789';
var expected = '012345678901234567890123456789';
it('handles a file with a long extension', () => {
const initial = '0123456789012345678901234567890123456789.01234567890123456789';
const expected = '012345678901234567890123456789';
assert.strictEqual(Signal.Backup._trimFileName(initial), expected);
});
it('handles a file with a normal extension', function() {
var initial = '01234567890123456789012345678901234567890123456789.jpg';
var expected = '012345678901234567890123.jpg';
it('handles a file with a normal extension', () => {
const initial = '01234567890123456789012345678901234567890123456789.jpg';
const expected = '012345678901234567890123.jpg';
assert.strictEqual(Signal.Backup._trimFileName(initial), expected);
});
});
describe('_getExportAttachmentFileName', function() {
it('uses original filename if attachment has one', function() {
var message = {
describe('_getExportAttachmentFileName', () => {
it('uses original filename if attachment has one', () => {
const message = {
body: 'something',
};
var index = 0;
var attachment = {
fileName: 'blah.jpg'
const index = 0;
const attachment = {
fileName: 'blah.jpg',
};
var expected = 'blah.jpg';
const expected = 'blah.jpg';
var actual = Signal.Backup._getExportAttachmentFileName(
const actual = Signal.Backup._getExportAttachmentFileName(
message,
index,
attachment
@ -54,36 +60,17 @@ describe('Backup', function() {
assert.strictEqual(actual, expected);
});
it('uses attachment id if no filename', function() {
var message = {
it('uses attachment id if no filename', () => {
const message = {
body: 'something',
};
var index = 0;
var attachment = {
id: '123'
};
var expected = '123';
var actual = Signal.Backup._getExportAttachmentFileName(
message,
index,
attachment
);
assert.strictEqual(actual, expected);
});
it('uses filename and contentType if available', function() {
var message = {
body: 'something',
};
var index = 0;
var attachment = {
const index = 0;
const attachment = {
id: '123',
contentType: 'image/jpeg'
};
var expected = '123.jpeg';
const expected = '123';
var actual = Signal.Backup._getExportAttachmentFileName(
const actual = Signal.Backup._getExportAttachmentFileName(
message,
index,
attachment
@ -91,18 +78,37 @@ describe('Backup', function() {
assert.strictEqual(actual, expected);
});
it('handles strange contentType', function() {
var message = {
it('uses filename and contentType if available', () => {
const message = {
body: 'something',
};
var index = 0;
var attachment = {
const index = 0;
const attachment = {
id: '123',
contentType: 'something'
contentType: 'image/jpeg',
};
var expected = '123.something';
const expected = '123.jpeg';
var actual = Signal.Backup._getExportAttachmentFileName(
const actual = Signal.Backup._getExportAttachmentFileName(
message,
index,
attachment
);
assert.strictEqual(actual, expected);
});
it('handles strange contentType', () => {
const message = {
body: 'something',
};
const index = 0;
const attachment = {
id: '123',
contentType: 'something',
};
const expected = '123.something';
const actual = Signal.Backup._getExportAttachmentFileName(
message,
index,
attachment
@ -111,19 +117,19 @@ describe('Backup', function() {
});
});
describe('_getAnonymousAttachmentFileName', function() {
it('uses message id', function() {
var message = {
describe('_getAnonymousAttachmentFileName', () => {
it('uses message id', () => {
const message = {
id: 'id-45',
body: 'something',
};
var index = 0;
var attachment = {
fileName: 'blah.jpg'
const index = 0;
const attachment = {
fileName: 'blah.jpg',
};
var expected = 'id-45';
const expected = 'id-45';
var actual = Signal.Backup._getAnonymousAttachmentFileName(
const actual = Signal.Backup._getAnonymousAttachmentFileName(
message,
index,
attachment
@ -131,18 +137,18 @@ describe('Backup', function() {
assert.strictEqual(actual, expected);
});
it('appends index if it is above zero', function() {
var message = {
it('appends index if it is above zero', () => {
const message = {
id: 'id-45',
body: 'something',
};
var index = 1;
var attachment = {
fileName: 'blah.jpg'
const index = 1;
const attachment = {
fileName: 'blah.jpg',
};
var expected = 'id-45-1';
const expected = 'id-45-1';
var actual = Signal.Backup._getAnonymousAttachmentFileName(
const actual = Signal.Backup._getAnonymousAttachmentFileName(
message,
index,
attachment
@ -151,64 +157,343 @@ describe('Backup', function() {
});
});
describe('_getConversationDirName', function() {
it('uses name if available', function() {
var conversation = {
describe('_getConversationDirName', () => {
it('uses name if available', () => {
const conversation = {
active_at: 123,
name: '0123456789012345678901234567890123456789',
id: 'id'
id: 'id',
};
var expected = '123 (012345678901234567890123456789 id)';
const expected = '123 (012345678901234567890123456789 id)';
assert.strictEqual(Signal.Backup._getConversationDirName(conversation), expected);
});
it('uses just id if name is not available', function() {
var conversation = {
it('uses just id if name is not available', () => {
const conversation = {
active_at: 123,
id: 'id'
id: 'id',
};
var expected = '123 (id)';
const expected = '123 (id)';
assert.strictEqual(Signal.Backup._getConversationDirName(conversation), expected);
});
it('uses inactive for missing active_at', function() {
var conversation = {
it('uses inactive for missing active_at', () => {
const conversation = {
name: 'name',
id: 'id'
id: 'id',
};
var expected = 'inactive (name id)';
const expected = 'inactive (name id)';
assert.strictEqual(Signal.Backup._getConversationDirName(conversation), expected);
});
});
describe('_getConversationLoggingName', function() {
it('uses plain id if conversation is private', function() {
var conversation = {
describe('_getConversationLoggingName', () => {
it('uses plain id if conversation is private', () => {
const conversation = {
active_at: 123,
id: 'id',
type: 'private'
type: 'private',
};
var expected = '123 (id)';
assert.strictEqual(Signal.Backup._getConversationLoggingName(conversation), expected);
const expected = '123 (id)';
assert.strictEqual(
Signal.Backup._getConversationLoggingName(conversation),
expected
);
});
it('uses just id if name is not available', function() {
var conversation = {
it('uses just id if name is not available', () => {
const conversation = {
active_at: 123,
id: 'groupId',
type: 'group'
type: 'group',
};
var expected = '123 ([REDACTED_GROUP]pId)';
assert.strictEqual(Signal.Backup._getConversationLoggingName(conversation), expected);
const expected = '123 ([REDACTED_GROUP]pId)';
assert.strictEqual(
Signal.Backup._getConversationLoggingName(conversation),
expected
);
});
it('uses inactive for missing active_at', function() {
var conversation = {
it('uses inactive for missing active_at', () => {
const conversation = {
id: 'id',
type: 'private'
type: 'private',
};
var expected = 'inactive (id)';
assert.strictEqual(Signal.Backup._getConversationLoggingName(conversation), expected);
const expected = 'inactive (id)';
assert.strictEqual(
Signal.Backup._getConversationLoggingName(conversation),
expected
);
});
});
describe('end-to-end', () => {
it('exports then imports to produce the same data we started with', async () => {
const {
attachmentsPath,
fse,
glob,
path,
tmp,
} = window.test;
const {
upgradeMessageSchema,
loadAttachmentData,
} = window.Signal.Migrations;
const key = new Uint8Array([
1, 3, 4, 5, 6, 7, 8, 11,
23, 34, 1, 34, 3, 5, 45, 45,
1, 3, 4, 5, 6, 7, 8, 11,
23, 34, 1, 34, 3, 5, 45, 45,
]);
const attachmentsPattern = path.join(attachmentsPath, '**');
const OUR_NUMBER = '+12025550000';
const CONTACT_ONE_NUMBER = '+12025550001';
async function wrappedLoadAttachment(attachment) {
return _.omit(await loadAttachmentData(attachment), ['path']);
}
async function clearAllData() {
await textsecure.storage.protocol.removeAllData();
await fse.emptyDir(attachmentsPath);
}
function removeId(model) {
return _.omit(model, ['id']);
}
// We want to know which paths have two slashes, since that tells us which files
// in the attachment fan-out are files vs. directories.
const TWO_SLASHES = /[^/]*\/[^/]*\/[^/]*/;
// On windows, attachmentsPath has a normal windows path format (\ separators), but
// glob returns only /. We normalize to / separators for our manipulations.
const normalizedBase = attachmentsPath.replace(/\\/g, '/');
function removeDirs(dirs) {
return _.filter(dirs, (fullDir) => {
const dir = fullDir.replace(normalizedBase, '');
return TWO_SLASHES.test(dir);
});
}
function _mapQuotedAttachments(mapper) {
return async (message, context) => {
if (!message.quote) {
return message;
}
const wrappedMapper = async (attachment) => {
if (!attachment || !attachment.thumbnail) {
return attachment;
}
return Object.assign({}, attachment, {
thumbnail: await mapper(attachment.thumbnail, context),
});
};
const quotedAttachments = (message.quote && message.quote.attachments) || [];
return Object.assign({}, message, {
quote: Object.assign({}, message.quote, {
attachments: await Promise.all(quotedAttachments.map(wrappedMapper)),
}),
});
};
}
async function loadAllFilesFromDisk(message) {
const loadThumbnails = _mapQuotedAttachments((thumbnail) => {
// we want to be bulletproof to thumbnails without data
if (!thumbnail.path) {
return thumbnail;
}
return wrappedLoadAttachment(thumbnail);
});
const promises = (message.attachments || []).map(attachment =>
wrappedLoadAttachment(attachment));
return Object.assign(
{},
await loadThumbnails(message),
{
attachments: await Promise.all(promises),
}
);
}
let backupDir;
try {
const ATTACHMENT_COUNT = 2;
const MESSAGE_COUNT = 1;
const CONVERSATION_COUNT = 1;
const messageWithAttachments = {
conversationId: CONTACT_ONE_NUMBER,
body: 'Totally!',
source: OUR_NUMBER,
received_at: 1524185933350,
timestamp: 1524185933350,
errors: [],
attachments: [{
contentType: 'image/gif',
fileName: 'sad_cat.gif',
data: new Uint8Array([
1, 2, 3, 4, 5, 6, 7, 8,
1, 2, 3, 4, 5, 6, 7, 8,
1, 2, 3, 4, 5, 6, 7, 8,
1, 2, 3, 4, 5, 6, 7, 8,
]).buffer,
}],
quote: {
text: "Isn't it cute?",
author: CONTACT_ONE_NUMBER,
id: 12345678,
attachments: [{
contentType: 'audio/mp3',
fileName: 'song.mp3',
}, {
contentType: 'image/gif',
fileName: 'happy_cat.gif',
thumbnail: {
contentType: 'image/png',
data: new Uint8Array([
2, 2, 3, 4, 5, 6, 7, 8,
1, 2, 3, 4, 5, 6, 7, 8,
1, 2, 3, 4, 5, 6, 7, 8,
1, 2, 3, 4, 5, 6, 7, 8,
]).buffer,
},
}],
},
};
console.log('Backup test: Clear all data');
await clearAllData();
console.log('Backup test: Create models, save to db/disk');
const message = await upgradeMessageSchema(messageWithAttachments);
console.log({ message });
const messageModel = new Whisper.Message(message);
await window.wrapDeferred(messageModel.save());
const conversation = {
active_at: 1524185933350,
color: 'orange',
expireTimer: 0,
id: CONTACT_ONE_NUMBER,
lastMessage: 'Heyo!',
name: 'Someone Somewhere',
profileAvatar: {
contentType: 'image/jpeg',
data: new Uint8Array([
3, 2, 3, 4, 5, 6, 7, 8,
1, 2, 3, 4, 5, 6, 7, 8,
1, 2, 3, 4, 5, 6, 7, 8,
1, 2, 3, 4, 5, 6, 7, 8,
]).buffer,
size: 64,
},
profileKey: new Uint8Array([
4, 2, 3, 4, 5, 6, 7, 8,
1, 2, 3, 4, 5, 6, 7, 8,
1, 2, 3, 4, 5, 6, 7, 8,
1, 2, 3, 4, 5, 6, 7, 8,
]).buffer,
profileName: 'Someone! 🤔',
profileSharing: true,
timestamp: 1524185933350,
tokens: [
'someone somewhere',
'someone',
'somewhere',
'2025550001',
'12025550001',
],
type: 'private',
unreadCount: 0,
verified: 0,
};
console.log({ conversation });
const conversationModel = new Whisper.Conversation(conversation);
await window.wrapDeferred(conversationModel.save());
console.log('Backup test: Ensure that all attachments were saved to disk');
const attachmentFiles = removeDirs(glob.sync(attachmentsPattern));
console.log({ attachmentFiles });
assert.strictEqual(ATTACHMENT_COUNT, attachmentFiles.length);
console.log('Backup test: Export!');
backupDir = tmp.dirSync().name;
console.log({ backupDir });
await Signal.Backup.exportToDirectory(backupDir, { key });
console.log('Backup test: Ensure that messages.zip exists');
const zipPath = path.join(backupDir, 'messages.zip');
const messageZipExists = fse.existsSync(zipPath);
assert.strictEqual(true, messageZipExists);
console.log('Backup test: Ensure that all attachments made it to backup dir');
const backupAttachmentPattern = path.join(backupDir, 'attachments/*');
const backupAttachments = glob.sync(backupAttachmentPattern);
console.log({ backupAttachments });
assert.strictEqual(ATTACHMENT_COUNT, backupAttachments.length);
console.log('Backup test: Clear all data');
await clearAllData();
console.log('Backup test: Import!');
await Signal.Backup.importFromDirectory(backupDir, { key });
console.log('Backup test: ensure that all attachments were imported');
const recreatedAttachmentFiles = removeDirs(glob.sync(attachmentsPattern));
console.log({ recreatedAttachmentFiles });
assert.strictEqual(ATTACHMENT_COUNT, recreatedAttachmentFiles.length);
assert.deepEqual(attachmentFiles, recreatedAttachmentFiles);
console.log('Backup test: Check messages');
const messageCollection = new Whisper.MessageCollection();
await window.wrapDeferred(messageCollection.fetch());
assert.strictEqual(messageCollection.length, MESSAGE_COUNT);
const messageFromDB = removeId(messageCollection.at(0).attributes);
console.log({ messageFromDB, message });
assert.deepEqual(messageFromDB, message);
console.log('Backup test: check that all attachments were successfully imported');
const messageWithAttachmentsFromDB = await loadAllFilesFromDisk(messageFromDB);
console.log({ messageWithAttachmentsFromDB, messageWithAttachments });
assert.deepEqual(
_.omit(messageWithAttachmentsFromDB, ['schemaVersion']),
messageWithAttachments
);
console.log('Backup test: check conversations');
const conversationCollection = new Whisper.ConversationCollection();
await window.wrapDeferred(conversationCollection.fetch());
assert.strictEqual(conversationCollection.length, CONVERSATION_COUNT);
const conversationFromDB = conversationCollection.at(0).attributes;
console.log({ conversationFromDB, conversation });
assert.deepEqual(
conversationFromDB,
_.omit(conversation, ['profileAvatar'])
);
console.log('Backup test: Clear all data');
await clearAllData();
console.log('Backup test: Complete!');
} finally {
if (backupDir) {
console.log({ backupDir });
console.log('Deleting', backupDir);
await fse.remove(backupDir);
}
}
});
});
});

View file

@ -2684,5 +2684,5 @@ Whisper.Fixtures = (function() {
}
return conversationCollection;
})();
});

View file

@ -5,7 +5,7 @@ describe("Fixtures", function() {
// NetworkStatusView checks this method every five seconds while showing
window.getSocketStatus = function() { return WebSocket.OPEN; };
Whisper.Fixtures.saveAll().then(function() {
Whisper.Fixtures().saveAll().then(function() {
done();
});
});

View file

@ -214,11 +214,16 @@
</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>
{{ #hasAttachments }}
<div class='attachments'></div>
{{ /hasAttachments }}
{{ #hasBody }}
<div class='content' dir='auto'>
{{ #message }}
<div class='body'>{{ message }}</div>
{{ /message }}
</div>
{{ /hasBody }}
</div>
</div>
<div class='meta'>
@ -226,6 +231,16 @@
<span class='status hide'></span>
<span class='timer'></span>
</div>
{{ #hoverIcon }}
<div class='menu-container menu'>
<div class='menu-anchor'>
<span class='dots-horizontal-icon'></span>
<ul class='menu-list'>
<li class='reply'>{{ reply }}</li>
</ul>
</div>
</div>
{{ /hoverIcon }}
</div>
</script>
<script type='text/x-tmpl-mustache' id='hourglass'>
@ -594,7 +609,7 @@
<script type="text/javascript" src="../js/chromium.js" data-cover></script>
<script type='text/javascript' src='../js/views/backbone_wrapper_view.js'></script>
<script type='text/javascript' src='../js/views/react_wrapper_view.js'></script>
<script type='text/javascript' src='../js/views/whisper_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/debug_log_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/toast_view.js' data-cover></script>

View file

@ -1,6 +0,0 @@
{
"globals": {
"check": true,
"gen": true
}
}

27
test/modules/.eslintrc.js Normal file
View file

@ -0,0 +1,27 @@
// For reference: https://github.com/airbnb/javascript
module.exports = {
env: {
mocha: true,
browser: true,
},
"globals": {
check: true,
gen: true,
},
parserOptions: {
sourceType: 'module',
},
rules: {
// We still get the value of this rule, it just allows for dev deps
'import/no-extraneous-dependencies': ['error', {
devDependencies: true
}],
// We want to keep each test structured the same, even if its contents are tiny
'arrow-body-style': 'off',
}
};

View file

@ -67,6 +67,43 @@ describe('Message', () => {
await Message.createAttachmentDataWriter(writeExistingAttachmentData)(input);
assert.deepEqual(actual, expected);
});
it('should process quote attachment thumbnails', async () => {
const input = {
body: 'Imagine there is no heaven…',
schemaVersion: 4,
attachments: [],
quote: {
attachments: [{
thumbnail: {
path: 'ab/abcdefghi',
data: stringToArrayBuffer('Its easy if you try'),
},
}],
},
};
const expected = {
body: 'Imagine there is no heaven…',
schemaVersion: 4,
attachments: [],
quote: {
attachments: [{
thumbnail: {
path: 'ab/abcdefghi',
},
}],
},
};
const writeExistingAttachmentData = (attachment) => {
assert.equal(attachment.path, 'ab/abcdefghi');
assert.deepEqual(attachment.data, stringToArrayBuffer('Its easy if you try'));
};
const actual =
await Message.createAttachmentDataWriter(writeExistingAttachmentData)(input);
assert.deepEqual(actual, expected);
});
});
describe('initializeSchemaVersion', () => {
@ -373,6 +410,37 @@ describe('Message', () => {
assert.deepEqual(result, message);
});
it('eliminates thumbnails with no data fielkd', 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: [{
fileName: 'cat.gif',
contentType: 'image/gif',
thumbnail: {
fileName: 'failed to download!',
},
}],
},
};
const expected = {
body: 'hey there!',
quote: {
text: 'hey!',
attachments: [{
contentType: 'image/gif',
fileName: 'cat.gif',
}],
},
};
const result = await upgradeVersion(message);
assert.deepEqual(result, expected);
});
it('calls provided async function for each quoted attachment', async () => {
const upgradeAttachment = sinon.stub().resolves({
path: '/new/path/on/disk',

View file

@ -1,3 +1,5 @@
'use strict';
/* global window: false */
// Because we aren't hosting the Style Guide in Electron, we can't rely on preload.js
@ -35,6 +37,14 @@ window.Signal.Migrations = {
next();
},
version: 1,
}, {
migrate: (transaction, next) => {
console.log('migration version 2');
const messages = transaction.db.createObjectStore('messages');
messages.createIndex('expires_at', 'expireTimer', { unique: false });
next();
},
version: 2,
}],
loadAttachmentData: attachment => Promise.resolve(attachment),
};

View file

@ -1,3 +1,5 @@
'use strict';
/* global window: false */
// Taken from background.html.
@ -24,29 +26,44 @@ window.Whisper.View.Templates = {
</span>
`,
message: `
{{> avatar }}
<div class='bubble {{ avatar.color }}'>
<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 }}
{{> avatar }}
<div class='bubble {{ avatar.color }}'>
<div class='sender' dir='auto'>
{{ sender }}
{{ #profileName }}
<span class='profileName'>{{ profileName }} </span>
{{ /profileName }}
</div>
<div class='tail-wrapper {{ innerBubbleClasses }}'>
<div class='inner-bubble'>
{{ #hasAttachments }}
<div class='attachments'></div>
{{ /hasAttachments }}
{{ #hasBody }}
<div class='content' dir='auto'>
{{ #message }}
<div class='body'>{{ message }}</div>
{{ /message }}
</div>
{{ /hasBody }}
</div>
</div>
</div>
<div class='meta'>
<span class='timestamp' data-timestamp={{ timestamp }}></span>
<span class='status hide'></span>
<span class='timer'></span>
</div>
</div>
<div class='meta'>
<span class='timestamp' data-timestamp={{ timestamp }}></span>
<span class='status hide'></span>
<span class='timer'></span>
</div>
{{ #hoverIcon }}
<div class='menu-container menu'>
<div class='menu-anchor'>
<span class='dots-horizontal-icon'></span>
<ul class='menu-list'>
<li class='reply'>{{ reply }}</li>
</ul>
</div>
</div>
{{ /hoverIcon }}
</div>
`,
hourglass: `
<span class='hourglass'><span class='sand'></span></span>
@ -63,4 +80,11 @@ window.Whisper.View.Templates = {
<div class='fileSize'>{{ fileSize }}</div>
</div>
`,
'error-icon': `
<span class='error-icon'>
</span>
{{ #message }}
<span class='error-message'>{{message}}</span>
{{ /message }}
`,
};

View file

@ -14,6 +14,12 @@ describe('NetworkStatusView', function() {
after(function() {
window.getSocketStatus = oldGetSocketStatus;
// It turns out that continued calls to window.getSocketStatus happen
// because we host NetworkStatusView in three mock interfaces, and the view
// checks every N seconds. That results in infinite errors unless there is
// something to call.
window.getSocketStatus = function() { return WebSocket.OPEN; };
});
/* END stubbing globals */

View file

@ -40,7 +40,7 @@ const View = Whisper.MessageView;
const outgoing = new Whisper.Message({
type: 'outgoing',
body: 'How are you doing this fine day?',
sent_at: Date.now() - 18000,
sent_at: Date.now() - 200000,
});
const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
source: '+12025550003',
@ -59,6 +59,322 @@ const View = Whisper.MessageView;
</util.ConversationContext>
```
### With an error
#### General error
```jsx
const error = new Error('Something went wrong!');
const outgoing = new Whisper.Message({
type: 'outgoing',
body: "This message won't get through...",
sent_at: Date.now() - 200000,
errors: [error],
});
const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
source: '+12025550003',
type: 'incoming',
body: null,
}));
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>
```
#### Network error (outgoing only)
```jsx
const error = new Error('Something went wrong!');
error.name = 'MessageError';
const outgoing = new Whisper.Message({
type: 'outgoing',
sent_at: Date.now() - 200000,
errors: [error],
body: "This message won't get through...",
});
const View = Whisper.MessageView;
<util.ConversationContext theme={util.theme} type="group" >
<util.BackboneWrapper
View={View}
options={{ model: outgoing }}
/>
</util.ConversationContext>
```
#### Network error, partial send in group (outgoing only)
```jsx
const error = new Error('Something went wrong!');
error.name = 'MessageError';
const outgoing = new Whisper.Message({
type: 'outgoing',
sent_at: Date.now() - 200000,
errors: [error],
conversationId: util.groupNumber,
body: "This message won't get through...",
});
const View = Whisper.MessageView;
<util.ConversationContext theme={util.theme} type="group" >
<util.BackboneWrapper
View={View}
options={{ model: outgoing }}
/>
</util.ConversationContext>
```
#### No message contents
```jsx
const outgoing = new Whisper.Message({
type: 'outgoing',
sent_at: Date.now() - 200000,
});
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>
```
### Disappearing
```jsx
const outgoing = new Whisper.Message({
type: 'outgoing',
sent_at: Date.now() - 200000,
expireTimer: 120,
expirationStartTimestamp: Date.now() - 1000,
body: 'This message will self-destruct in two minutes',
});
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>
```
### Notfications
#### Timer change
```jsx
const fromOther = new Whisper.Message({
type: 'incoming',
flags: textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
source: '+12025550003',
sent_at: Date.now() - 200000,
expireTimer: 120,
expirationStartTimestamp: Date.now() - 1000,
expirationTimerUpdate: {
source: '+12025550003',
}
});
const fromUpdate = new Whisper.Message({
type: 'incoming',
flags: textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
source: util.ourNumber,
sent_at: Date.now() - 200000,
expireTimer: 120,
expirationStartTimestamp: Date.now() - 1000,
expirationTimerUpdate: {
fromSync: true,
source: util.ourNumber,
}
});
const fromMe = new Whisper.Message({
type: 'incoming',
flags: textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
source: util.ourNumber,
sent_at: Date.now() - 200000,
expireTimer: 120,
expirationStartTimestamp: Date.now() - 1000,
expirationTimerUpdate: {
source: util.ourNumber,
}
});
const View = Whisper.ExpirationTimerUpdateView;
<util.ConversationContext theme={util.theme} >
<util.BackboneWrapper
View={View}
options={{ model: fromOther }}
/>
<util.BackboneWrapper
View={View}
options={{ model: fromUpdate }}
/>
<util.BackboneWrapper
View={View}
options={{ model: fromMe }}
/>
</util.ConversationContext>
```
#### Safety number change
```js
const incoming = new Whisper.Message({
type: 'keychange',
sent_at: Date.now() - 200000,
key_changed: '+12025550003',
});
const View = Whisper.KeyChangeView;
<util.ConversationContext theme={util.theme} >
<util.BackboneWrapper
View={View}
options={{ model: incoming }}
/>
</util.ConversationContext>
```
#### Marking as verified
```js
const fromPrimary = new Whisper.Message({
type: 'verified-change',
sent_at: Date.now() - 200000,
verifiedChanged: '+12025550003',
verified: true,
});
const local = new Whisper.Message({
type: 'verified-change',
sent_at: Date.now() - 200000,
verifiedChanged: '+12025550003',
local: true,
verified: true,
});
const View = Whisper.VerifiedChangeView;
<util.ConversationContext theme={util.theme} >
<util.BackboneWrapper
View={View}
options={{ model: fromPrimary }}
/>
<util.BackboneWrapper
View={View}
options={{ model: local }}
/>
</util.ConversationContext>
```
#### Marking as not verified
```js
const fromPrimary = new Whisper.Message({
type: 'verified-change',
sent_at: Date.now() - 200000,
verifiedChanged: '+12025550003',
});
const local = new Whisper.Message({
type: 'verified-change',
sent_at: Date.now() - 200000,
verifiedChanged: '+12025550003',
local: true,
});
const View = Whisper.VerifiedChangeView;
<util.ConversationContext theme={util.theme} >
<util.BackboneWrapper
View={View}
options={{ model: fromPrimary }}
/>
<util.BackboneWrapper
View={View}
options={{ model: local }}
/>
</util.ConversationContext>
```
#### Group update
```js
const outgoing = new Whisper.Message({
type: 'outgoing',
sent_at: Date.now() - 200000,
group_update: {
joined: [
'+12025550007',
'+12025550008',
'+12025550009',
],
},
});
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>
```
#### End session
```js
const outgoing = new Whisper.Message({
type: 'outgoing',
sent_at: Date.now() - 200000,
flags: textsecure.protobuf.DataMessage.Flags.END_SESSION,
});
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>
```
### With an attachment
#### Image with caption
@ -120,6 +436,125 @@ const View = Whisper.MessageView;
</util.ConversationContext>
```
#### Image with portrait aspect ratio
```jsx
const outgoing = new Whisper.Message({
type: 'outgoing',
sent_at: Date.now() - 18000000,
attachments: [{
data: util.portraitYellow,
fileName: 'portraitYellow.png',
contentType: 'image/png',
}],
});
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 with portrait aspect ratio and caption
```jsx
const outgoing = new Whisper.Message({
type: 'outgoing',
body: 'This is an odd yellow bar. Cool, huh?',
sent_at: Date.now() - 18000000,
attachments: [{
data: util.portraitYellow,
fileName: 'portraitYellow.png',
contentType: 'image/png',
}],
});
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 with landscape aspect ratio
```jsx
const outgoing = new Whisper.Message({
type: 'outgoing',
sent_at: Date.now() - 18000000,
attachments: [{
data: util.landscapePurple,
fileName: 'landscapePurple.jpg',
contentType: 'image/jpeg',
}],
});
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 with landscape aspect ratio and caption
```jsx
const outgoing = new Whisper.Message({
type: 'outgoing',
body: "An interesting horizontal bar. It's art.",
sent_at: Date.now() - 18000000,
attachments: [{
data: util.landscapePurple,
fileName: 'landscapePurple.jpg',
contentType: 'image/jpeg',
}],
});
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

View file

@ -15,7 +15,6 @@ export class Message extends React.Component<{}, {}> {
<div className="sender" dir="auto" />
<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.

View file

@ -34,6 +34,39 @@ const View = Whisper.MessageView;
</util.ConversationContext>
```
#### With emoji
```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
@ -183,9 +216,8 @@ const View = Whisper.MessageView;
#### A lot of text in quotation, with image
```jsx
const quotedMessage = {
imageUrl: util.gifObjectUrl,
id: '3234-23423-2342',
const thumbnail = {
objectUrl: util.gifObjectUrl,
};
const outgoing = new Whisper.Message({
type: 'outgoing',
@ -218,8 +250,8 @@ const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
}),
}));
outgoing.quotedMessage = quotedMessage;
incoming.quotedMessage = quotedMessage;
outgoing.quoteThumbnail = thumbnail;
incoming.quoteThumbnail = thumbnail;
const View = Whisper.MessageView;
<util.ConversationContext theme={util.theme}>
@ -237,8 +269,8 @@ const View = Whisper.MessageView;
#### Image with caption
```jsx
const quotedMessage = {
imageUrl: util.gifObjectUrl,
const thumbnail = {
objectUrl: util.gifObjectUrl,
id: '3234-23423-2342',
};
const outgoing = new Whisper.Message({
@ -268,8 +300,8 @@ const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
}),
}));
outgoing.quotedMessage = quotedMessage;
incoming.quotedMessage = quotedMessage;
outgoing.quoteThumbnail = thumbnail;
incoming.quoteThumbnail = thumbnail;
const View = Whisper.MessageView;
<util.ConversationContext theme={util.theme}>
@ -287,8 +319,8 @@ const View = Whisper.MessageView;
#### Image
```jsx
const quotedMessage = {
imageUrl: util.gifObjectUrl,
const thumbnail = {
objectUrl: util.gifObjectUrl,
};
const outgoing = new Whisper.Message({
@ -317,8 +349,8 @@ const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
}),
}));
outgoing.quotedMessage = quotedMessage;
incoming.quotedMessage = quotedMessage;
outgoing.quoteThumbnail = thumbnail;
incoming.quoteThumbnail = thumbnail;
const View = Whisper.MessageView;
<util.ConversationContext theme={util.theme}>
@ -375,8 +407,8 @@ const View = Whisper.MessageView;
#### Video with caption
```jsx
const quotedMessage = {
imageUrl: util.gifObjectUrl,
const thumbnail = {
objectUrl: util.gifObjectUrl,
};
const outgoing = new Whisper.Message({
@ -406,8 +438,8 @@ const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
}),
}));
outgoing.quotedMessage = quotedMessage;
incoming.quotedMessage = quotedMessage;
outgoing.quoteThumbnail = thumbnail;
incoming.quoteThumbnail = thumbnail;
const View = Whisper.MessageView;
<util.ConversationContext theme={util.theme}>
@ -425,8 +457,8 @@ const View = Whisper.MessageView;
#### Video
```jsx
const quotedMessage = {
imageUrl: util.gifObjectUrl,
const thumbnail = {
objectUrl: util.gifObjectUrl,
};
const outgoing = new Whisper.Message({
@ -456,8 +488,8 @@ const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
}),
}));
outgoing.quotedMessage = quotedMessage;
incoming.quotedMessage = quotedMessage;
outgoing.quoteThumbnail = thumbnail;
incoming.quoteThumbnail = thumbnail;
const View = Whisper.MessageView;
<util.ConversationContext theme={util.theme}>
@ -782,6 +814,44 @@ const View = Whisper.MessageView;
</util.ConversationContext>
```
#### Quote, portrait 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.portraitYellow,
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
@ -893,3 +963,164 @@ const View = Whisper.MessageView;
/>
</util.ConversationContext>
```
#### Quote, but no message
```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,
},
});
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>
```
### In bottom bar
#### Plain text
```jsx
<div className={util.theme}>
<div className="bottom-bar">
<Quote
text="How many ferrets do you have?"
authorColor="blue"
authorTitle={util.ourNumber}
authorProfileName="Mr. Blue"
id={Date.now() - 1000}
i18n={window.i18n}
/>
</div>
</div>
```
#### With an icon
```jsx
<div className={util.theme}>
<div className="bottom-bar">
<Quote
text="How many ferrets do you have?"
authorColor="blue"
authorTitle={util.ourNumber}
authorProfileName="Mr. Blue"
id={Date.now() - 1000}
i18n={window.i18n}
attachments={[{
contentType: 'image/jpeg',
fileName: 'llama.jpg',
}]}
/>
</div>
</div>
```
#### With an image
```jsx
<div className={util.theme}>
<div className="bottom-bar">
<Quote
text="How many ferrets do you have?"
authorColor="blue"
authorTitle={util.ourNumber}
authorProfileName="Mr. Blue"
id={Date.now() - 1000}
i18n={window.i18n}
attachments={[{
contentType: 'image/gif',
fileName: 'llama.gif',
thumbnail: {
objectUrl: util.gifObjectUrl
},
}]}
/>
</div>
</div>
```
#### With a close button
```jsx
<div className={util.theme}>
<div className="bottom-bar">
<Quote
text="How many ferrets do you have?"
authorColor="blue"
authorTitle={util.ourNumber}
authorProfileName="Mr. Blue"
id={Date.now() - 1000}
onClose={() => console.log('Close was clicked!')}
i18n={window.i18n}
/>
</div>
</div>
```
#### With a close button and icon
```jsx
<div className={util.theme}>
<div className="bottom-bar">
<Quote
text="How many ferrets do you have?"
authorColor="blue"
authorTitle={util.ourNumber}
authorProfileName="Mr. Blue"
id={Date.now() - 1000}
onClose={() => console.log('Close was clicked!')}
i18n={window.i18n}
attachments={[{
contentType: 'image/jpeg',
fileName: 'llama.jpg',
}]}
/>
</div>
</div>
```
#### With a close button and image
```jsx
<div className={util.theme}>
<div className="bottom-bar">
<Quote
text="How many ferrets do you have?"
authorColor="blue"
authorTitle={util.ourNumber}
authorProfileName="Mr. Blue"
id={Date.now() - 1000}
onClose={() => console.log('Close was clicked!')}
i18n={window.i18n}
attachments={[{
contentType: 'image/gif',
fileName: 'llama.gif',
thumbnail: {
objectUrl: util.gifObjectUrl
},
}]}
/>
</div>
</div>
```

View file

@ -14,6 +14,7 @@ interface Props {
isFromMe: string;
isIncoming: boolean;
onClick?: () => void;
onClose?: () => void;
text: string;
}
@ -94,7 +95,7 @@ export class Quote extends React.Component<Props, {}> {
if (Mime.isVideo(contentType)) {
return objectUrl
? this.renderImage(objectUrl, 'play')
: this.renderIcon('play');
: this.renderIcon('movie');
}
if (Mime.isImage(contentType)) {
return objectUrl
@ -112,7 +113,7 @@ export class Quote extends React.Component<Props, {}> {
const { i18n, text, attachments } = this.props;
if (text) {
return <div className="text">{text}</div>;
return <div className="text" dangerouslySetInnerHTML={{ __html: text}} />;
}
if (!attachments || attachments.length === 0) {
@ -153,6 +154,28 @@ export class Quote extends React.Component<Props, {}> {
return <div className="ios-label">{label}</div>;
}
public renderClose() {
const { onClose } = this.props;
if (!onClose) {
return null;
}
// We don't want the overall click handler for the quote to fire, so we stop
// propagation before handing control to the caller's callback.
const onClick = (e: React.MouseEvent<{}>): void => {
e.stopPropagation();
onClose();
};
// We need the container to give us the flexibility to implement the iOS design.
return (
<div className="close-container">
<div className="close-button" onClick={onClick} />
</div>
);
}
public render() {
const {
authorTitle,
@ -171,7 +194,7 @@ export class Quote extends React.Component<Props, {}> {
: null;
const classes = classnames(
authorColor,
'quote',
'quoted-message',
isFromMe ? 'from-me' : null,
!onClick ? 'no-click' : null,
);
@ -186,6 +209,7 @@ export class Quote extends React.Component<Props, {}> {
{this.renderText()}
</div>
{this.renderIconContainer()}
{this.renderClose()}
</div>
);
}

View file

@ -41,6 +41,23 @@ const txtObjectUrl = makeObjectUrl(txt, 'text/plain');
import mp4 from '../../fixtures/pixabay-Soap-Bubble-7141.mp4';
const mp4ObjectUrl = makeObjectUrl(mp4, 'video/mp4');
// @ts-ignore
import landscapeGreen from '../../fixtures/1000x50-green.jpeg';
const landscapeGreenObjectUrl = makeObjectUrl(landscapeGreen, 'image/jpeg');
// @ts-ignore
import landscapePurple from '../../fixtures/200x50-purple.png';
const landscapePurpleObjectUrl = makeObjectUrl(landscapePurple, 'image/png');
// @ts-ignore
import portraitYellow from '../../fixtures/20x200-yellow.png';
const portraitYellowObjectUrl = makeObjectUrl(portraitYellow, 'image/png');
// @ts-ignore
import landscapeRed from '../../fixtures/300x1-red.jpeg';
const landscapeRedObjectUrl = makeObjectUrl(landscapeRed, 'image/png');
// @ts-ignore
import portraitTeal from '../../fixtures/50x1000-teal.jpeg';
const portraitTealObjectUrl = makeObjectUrl(portraitTeal, 'image/png');
function makeObjectUrl(data: ArrayBuffer, contentType: string): string {
const blob = new Blob([data], {
type: contentType,
@ -49,6 +66,8 @@ function makeObjectUrl(data: ArrayBuffer, contentType: string): string {
}
const ourNumber = '+12025559999';
const groupNumber = '+12025550099';
export {
mp3,
@ -59,7 +78,18 @@ export {
mp4ObjectUrl,
txt,
txtObjectUrl,
landscapeGreen,
landscapeGreenObjectUrl,
landscapePurple,
landscapePurpleObjectUrl,
portraitYellow,
portraitYellowObjectUrl,
landscapeRed,
landscapeRedObjectUrl,
portraitTeal,
portraitTealObjectUrl,
ourNumber,
groupNumber,
};
@ -153,10 +183,22 @@ const me = parent.ConversationController.dangerouslyCreateAndAdd({
color: 'light_blue',
});
const group = parent.ConversationController.dangerouslyCreateAndAdd({
id: groupNumber,
name: 'A place for sharing cats',
type: 'group',
});
group.contactCollection.add(me);
group.contactCollection.add(CONTACTS[0]);
group.contactCollection.add(CONTACTS[1]);
group.contactCollection.add(CONTACTS[2]);
export {
COLORS,
CONTACTS,
me,
group,
};
parent.textsecure.storage.user.getNumber = () => ourNumber;
@ -164,3 +206,11 @@ parent.textsecure.storage.user.getNumber = () => ourNumber;
// Telling Lodash to relinquish _ for use by underscore
// @ts-ignore
_.noConflict();
parent.emoji.signalReplace = (html: string): string => {
return html.replace(
/🔥/g,
'<img src="node_modules/emoji-datasource-apple/img/apple/64/1f525.png"' +
'class="emoji" data-codepoints="1f525" title=":fire:">',
);
};