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
|
@ -23,7 +23,9 @@ ts/**/*.js
|
||||||
!js/logging.js
|
!js/logging.js
|
||||||
!js/models/conversations.js
|
!js/models/conversations.js
|
||||||
!js/models/messages.js
|
!js/models/messages.js
|
||||||
|
!test/backup_test.js
|
||||||
!js/views/attachment_view.js
|
!js/views/attachment_view.js
|
||||||
|
!js/views/conversation_view.js
|
||||||
!js/views/conversation_search_view.js
|
!js/views/conversation_search_view.js
|
||||||
!js/views/backbone_wrapper_view.js
|
!js/views/backbone_wrapper_view.js
|
||||||
!js/views/debug_log_view.js
|
!js/views/debug_log_view.js
|
||||||
|
|
|
@ -109,6 +109,7 @@ module.exports = function(grunt) {
|
||||||
'!js/Mp3LameEncoder.min.js',
|
'!js/Mp3LameEncoder.min.js',
|
||||||
'!js/signal_protocol_store.js',
|
'!js/signal_protocol_store.js',
|
||||||
'!js/views/conversation_search_view.js',
|
'!js/views/conversation_search_view.js',
|
||||||
|
'!js/views/conversation_view.js',
|
||||||
'!js/views/debug_log_view.js',
|
'!js/views/debug_log_view.js',
|
||||||
'!js/views/message_view.js',
|
'!js/views/message_view.js',
|
||||||
'!js/models/conversations.js',
|
'!js/models/conversations.js',
|
||||||
|
@ -166,6 +167,10 @@ module.exports = function(grunt) {
|
||||||
'!js/modules/**/*.js',
|
'!js/modules/**/*.js',
|
||||||
'!js/models/conversations.js',
|
'!js/models/conversations.js',
|
||||||
'!js/models/messages.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/Mp3LameEncoder.min.js',
|
||||||
'!js/WebAudioRecorderMp3.js',
|
'!js/WebAudioRecorderMp3.js',
|
||||||
'test/**/*.js',
|
'test/**/*.js',
|
||||||
|
|
|
@ -428,6 +428,10 @@
|
||||||
"selectAContact": {
|
"selectAContact": {
|
||||||
"message": "Select a contact or group to start chatting."
|
"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": {
|
"replyingToYourself": {
|
||||||
"message": "Replying to Yourself",
|
"message": "Replying to Yourself",
|
||||||
"description": "Shown in iOS theme when you quote yourself"
|
"description": "Shown in iOS theme when you quote yourself"
|
||||||
|
@ -542,6 +546,10 @@
|
||||||
"message": "Secure session reset",
|
"message": "Secure session reset",
|
||||||
"description": "This is a past tense, informational message. In other words, your secure session has been 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": {
|
"installWelcome": {
|
||||||
"message": "Welcome to Signal Desktop",
|
"message": "Welcome to Signal Desktop",
|
||||||
"description": "Welcome title on the install page"
|
"description": "Welcome title on the install page"
|
||||||
|
|
|
@ -279,11 +279,16 @@
|
||||||
</div>
|
</div>
|
||||||
<div class='tail-wrapper {{ innerBubbleClasses }}'>
|
<div class='tail-wrapper {{ innerBubbleClasses }}'>
|
||||||
<div class='inner-bubble'>
|
<div class='inner-bubble'>
|
||||||
<div class='quote-wrapper'></div>
|
{{ #hasAttachments }}
|
||||||
<div class='attachments'></div>
|
<div class='attachments'></div>
|
||||||
<div class='content' dir='auto'>
|
{{ /hasAttachments }}
|
||||||
{{ #message }}<div class='body'>{{ message }}</div>{{ /message }}
|
{{ #hasBody }}
|
||||||
</div>
|
<div class='content' dir='auto'>
|
||||||
|
{{ #message }}
|
||||||
|
<div class='body'>{{ message }}</div>
|
||||||
|
{{ /message }}
|
||||||
|
</div>
|
||||||
|
{{ /hasBody }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class='meta'>
|
<div class='meta'>
|
||||||
|
@ -291,6 +296,16 @@
|
||||||
<span class='status hide'></span>
|
<span class='status hide'></span>
|
||||||
<span class='timer'></span>
|
<span class='timer'></span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</script>
|
</script>
|
||||||
<script type='text/x-tmpl-mustache' id='hourglass'>
|
<script type='text/x-tmpl-mustache' id='hourglass'>
|
||||||
|
|
BIN
fixtures/1000x50-green.jpeg
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
fixtures/200x50-purple.png
Normal file
After Width: | Height: | Size: 340 B |
BIN
fixtures/20x200-yellow.png
Normal file
After Width: | Height: | Size: 489 B |
BIN
fixtures/300x1-red.jpeg
Normal file
After Width: | Height: | Size: 706 B |
BIN
fixtures/50x1000-teal.jpeg
Normal file
After Width: | Height: | Size: 14 KiB |
1
images/close-circle.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="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 |
1
images/dots-horizontal.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="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
|
@ -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 |
|
@ -112,8 +112,9 @@
|
||||||
},
|
},
|
||||||
|
|
||||||
addSingleMessage(message) {
|
addSingleMessage(message) {
|
||||||
this.messageCollection.add(message, { merge: true });
|
const model = this.messageCollection.add(message, { merge: true });
|
||||||
this.processQuotes(this.messageCollection);
|
this.processQuotes(this.messageCollection);
|
||||||
|
return model;
|
||||||
},
|
},
|
||||||
|
|
||||||
onMessageError() {
|
onMessageError() {
|
||||||
|
@ -610,7 +611,60 @@
|
||||||
return _.without(this.get('members'), me);
|
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 () => {
|
this.queueJob(async () => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
|
@ -625,13 +679,15 @@
|
||||||
type: 'outgoing',
|
type: 'outgoing',
|
||||||
body,
|
body,
|
||||||
conversationId: this.id,
|
conversationId: this.id,
|
||||||
|
quote,
|
||||||
attachments,
|
attachments,
|
||||||
sent_at: now,
|
sent_at: now,
|
||||||
received_at: now,
|
received_at: now,
|
||||||
expireTimer: this.get('expireTimer'),
|
expireTimer: this.get('expireTimer'),
|
||||||
recipients: this.getRecipients(),
|
recipients: this.getRecipients(),
|
||||||
});
|
});
|
||||||
const message = this.messageCollection.add(messageWithSchema);
|
const message = this.addSingleMessage(messageWithSchema);
|
||||||
|
|
||||||
if (this.isPrivate()) {
|
if (this.isPrivate()) {
|
||||||
message.set({ destination: this.id });
|
message.set({ destination: this.id });
|
||||||
}
|
}
|
||||||
|
@ -666,6 +722,7 @@
|
||||||
this.get('id'),
|
this.get('id'),
|
||||||
body,
|
body,
|
||||||
attachmentsWithData,
|
attachmentsWithData,
|
||||||
|
quote,
|
||||||
now,
|
now,
|
||||||
this.get('expireTimer'),
|
this.get('expireTimer'),
|
||||||
profileKey
|
profileKey
|
||||||
|
@ -1113,18 +1170,8 @@
|
||||||
|
|
||||||
const queryFirst = queryAttachments[0];
|
const queryFirst = queryAttachments[0];
|
||||||
try {
|
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
|
// eslint-disable-next-line no-param-reassign
|
||||||
message.quotedMessageFromDatabase = queryMessage;
|
message.quoteThumbnail = await this.makeThumbnailAttachment(queryFirst);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(
|
console.log(
|
||||||
|
@ -1155,12 +1202,9 @@
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const queryFirst = quotedAttachments[0];
|
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
|
// eslint-disable-next-line no-param-reassign
|
||||||
// a true thumbnail here.
|
message.quoteThumbnail = await this.makeThumbnailAttachment(queryFirst);
|
||||||
quotedMessage.updateImageUrl();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(
|
console.log(
|
||||||
'Problem loading attachment data for quoted message',
|
'Problem loading attachment data for quoted message',
|
||||||
|
|
|
@ -31,6 +31,8 @@
|
||||||
this.on('change:expireTimer', this.setToExpire);
|
this.on('change:expireTimer', this.setToExpire);
|
||||||
this.on('unload', this.unload);
|
this.on('unload', this.unload);
|
||||||
this.setToExpire();
|
this.setToExpire();
|
||||||
|
|
||||||
|
this.VOICE_FLAG = textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE;
|
||||||
},
|
},
|
||||||
idForLogging() {
|
idForLogging() {
|
||||||
return `${this.get('source')}.${this.get('sourceDevice')} ${this.get('sent_at')}`;
|
return `${this.get('source')}.${this.get('sourceDevice')} ${this.get('sent_at')}`;
|
||||||
|
@ -186,6 +188,16 @@
|
||||||
if (this.quotedMessage) {
|
if (this.quotedMessage) {
|
||||||
this.quotedMessage = null;
|
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();
|
this.revokeImageUrl();
|
||||||
},
|
},
|
||||||
revokeImageUrl() {
|
revokeImageUrl() {
|
||||||
|
@ -200,6 +212,77 @@
|
||||||
}
|
}
|
||||||
return this.imageUrl;
|
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() {
|
getConversation() {
|
||||||
// This needs to be an unsafe call, because this method is called during
|
// 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
|
// initial module setup. We may be in the middle of the initial fetch to
|
||||||
|
@ -207,12 +290,12 @@
|
||||||
return ConversationController.getUnsafe(this.get('conversationId'));
|
return ConversationController.getUnsafe(this.get('conversationId'));
|
||||||
},
|
},
|
||||||
getExpirationTimerUpdateSource() {
|
getExpirationTimerUpdateSource() {
|
||||||
if (this.isExpirationTimerUpdate()) {
|
if (!this.isExpirationTimerUpdate()) {
|
||||||
const conversationId = this.get('expirationTimerUpdate').source;
|
throw new Error('Message is not a timer update!');
|
||||||
return ConversationController.getOrCreate(conversationId, 'private');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Promise.resolve();
|
const conversationId = this.get('expirationTimerUpdate').source;
|
||||||
|
return ConversationController.getOrCreate(conversationId, 'private');
|
||||||
},
|
},
|
||||||
getContact() {
|
getContact() {
|
||||||
let conversationId = this.get('source');
|
let conversationId = this.get('source');
|
||||||
|
|
|
@ -457,17 +457,18 @@ async function readAttachment(dir, attachment, name, options) {
|
||||||
options = options || {};
|
options = options || {};
|
||||||
const { key } = options;
|
const { key } = options;
|
||||||
|
|
||||||
const anonymousName = _sanitizeFileName(name);
|
const sanitizedName = _sanitizeFileName(name);
|
||||||
const targetPath = path.join(dir, anonymousName);
|
const targetPath = path.join(dir, sanitizedName);
|
||||||
|
|
||||||
if (!fs.existsSync(targetPath)) {
|
if (!fs.existsSync(targetPath)) {
|
||||||
console.log(`Warning: attachment ${anonymousName} not found`);
|
console.log(`Warning: attachment ${sanitizedName} not found`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await readFileAsArrayBuffer(targetPath);
|
const data = await readFileAsArrayBuffer(targetPath);
|
||||||
|
|
||||||
const isEncrypted = !_.isUndefined(key);
|
const isEncrypted = !_.isUndefined(key);
|
||||||
|
|
||||||
if (isEncrypted) {
|
if (isEncrypted) {
|
||||||
attachment.data = await crypto.decryptSymmetric(key, data);
|
attachment.data = await crypto.decryptSymmetric(key, data);
|
||||||
} else {
|
} 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) {
|
async function writeAttachment(attachment, options) {
|
||||||
const {
|
const {
|
||||||
dir,
|
dir,
|
||||||
|
@ -485,26 +545,16 @@ async function writeAttachment(attachment, options) {
|
||||||
} = options;
|
} = options;
|
||||||
const filename = _getAnonymousAttachmentFileName(message, index);
|
const filename = _getAnonymousAttachmentFileName(message, index);
|
||||||
const target = path.join(dir, filename);
|
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)) {
|
if (!Attachment.hasData(attachment)) {
|
||||||
throw new TypeError("'attachment.data' is required");
|
throw new TypeError("'attachment.data' is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
const ciphertext = await crypto.encryptSymmetric(key, attachment.data);
|
await writeEncryptedAttachment(target, attachment.data, {
|
||||||
|
key,
|
||||||
const writer = await createFileAndWriter(dir, filename);
|
newKey,
|
||||||
const stream = createOutputStream(writer);
|
filename,
|
||||||
stream.write(Buffer.from(ciphertext));
|
dir,
|
||||||
await stream.close();
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeAttachments(rawAttachments, options) {
|
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) {
|
function _sanitizeFileName(filename) {
|
||||||
return filename.toString().replace(/[^a-z0-9.,+()'#\- ]/gi, '_');
|
return filename.toString().replace(/[^a-z0-9.,+()'#\- ]/gi, '_');
|
||||||
}
|
}
|
||||||
|
@ -542,6 +618,7 @@ async function exportConversation(db, conversation, options) {
|
||||||
dir,
|
dir,
|
||||||
attachmentsDir,
|
attachmentsDir,
|
||||||
key,
|
key,
|
||||||
|
newKey,
|
||||||
} = options;
|
} = options;
|
||||||
if (!name) {
|
if (!name) {
|
||||||
throw new Error('Need a 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
|
// 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(
|
message.attachments = _.map(
|
||||||
attachments,
|
attachments,
|
||||||
attachment => _.omit(attachment, ['data'])
|
attachment => _.omit(attachment, ['data'])
|
||||||
|
@ -629,18 +707,34 @@ async function exportConversation(db, conversation, options) {
|
||||||
const jsonString = JSON.stringify(stringify(message));
|
const jsonString = JSON.stringify(stringify(message));
|
||||||
stream.write(jsonString);
|
stream.write(jsonString);
|
||||||
|
|
||||||
|
console.log({ backupMessage: message });
|
||||||
if (attachments && attachments.length > 0) {
|
if (attachments && attachments.length > 0) {
|
||||||
const exportAttachments = () => writeAttachments(attachments, {
|
const exportAttachments = () => writeAttachments(attachments, {
|
||||||
dir: attachmentsDir,
|
dir: attachmentsDir,
|
||||||
name,
|
name,
|
||||||
message,
|
message,
|
||||||
key,
|
key,
|
||||||
|
newKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line more/no-then
|
// eslint-disable-next-line more/no-then
|
||||||
promiseChain = promiseChain.then(exportAttachments);
|
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;
|
count += 1;
|
||||||
cursor.continue();
|
cursor.continue();
|
||||||
} else {
|
} else {
|
||||||
|
@ -701,6 +795,7 @@ function exportConversations(db, options) {
|
||||||
messagesDir,
|
messagesDir,
|
||||||
attachmentsDir,
|
attachmentsDir,
|
||||||
key,
|
key,
|
||||||
|
newKey,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
if (!messagesDir) {
|
if (!messagesDir) {
|
||||||
|
@ -747,6 +842,7 @@ function exportConversations(db, options) {
|
||||||
dir,
|
dir,
|
||||||
attachmentsDir,
|
attachmentsDir,
|
||||||
key,
|
key,
|
||||||
|
newKey,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -808,11 +904,23 @@ function loadAttachments(dir, getName, options) {
|
||||||
options = options || {};
|
options = options || {};
|
||||||
const { message } = options;
|
const { message } = options;
|
||||||
|
|
||||||
const promises = _.map(message.attachments, (attachment, index) => {
|
const attachmentPromises = _.map(message.attachments, (attachment, index) => {
|
||||||
const name = getName(message, index, attachment);
|
const name = getName(message, index, attachment);
|
||||||
return readAttachment(dir, attachment, name, options);
|
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) {
|
function saveMessage(db, message) {
|
||||||
|
@ -922,7 +1030,11 @@ async function importConversation(db, dir, options) {
|
||||||
return false;
|
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 importMessage = async () => {
|
||||||
const getName = attachmentsDir
|
const getName = attachmentsDir
|
||||||
? _getAnonymousAttachmentFileName
|
? _getAnonymousAttachmentFileName
|
||||||
|
|
|
@ -18,6 +18,9 @@ const PRIVATE = 'private';
|
||||||
// - Attachments: Sanitize Unicode order override characters.
|
// - Attachments: Sanitize Unicode order override characters.
|
||||||
// Version 3
|
// Version 3
|
||||||
// - Attachments: Write attachment data to disk and store relative path to it.
|
// - 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;
|
const INITIAL_SCHEMA_VERSION = 0;
|
||||||
|
|
||||||
|
@ -158,13 +161,19 @@ exports._mapQuotedAttachments = upgradeAttachment => async (message, context) =>
|
||||||
}
|
}
|
||||||
|
|
||||||
const upgradeWithContext = async (attachment) => {
|
const upgradeWithContext = async (attachment) => {
|
||||||
if (!attachment || !attachment.thumbnail) {
|
const { thumbnail } = attachment;
|
||||||
|
if (!thumbnail) {
|
||||||
return attachment;
|
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, {
|
return Object.assign({}, attachment, {
|
||||||
thumbnail,
|
thumbnail: upgradedThumbnail,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -243,9 +252,12 @@ exports.createAttachmentDataWriter = (writeExistingAttachmentData) => {
|
||||||
|
|
||||||
const message = exports.initializeSchemaVersion(rawMessage);
|
const message = exports.initializeSchemaVersion(rawMessage);
|
||||||
|
|
||||||
const { attachments } = message;
|
const { attachments, quote } = message;
|
||||||
const hasAttachments = attachments && attachments.length > 0;
|
const hasFilesToWrite =
|
||||||
if (!hasAttachments) {
|
(quote && quote.attachments && quote.attachments.length > 0) ||
|
||||||
|
(attachments && attachments.length > 0);
|
||||||
|
|
||||||
|
if (!hasFilesToWrite) {
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -256,7 +268,7 @@ exports.createAttachmentDataWriter = (writeExistingAttachmentData) => {
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
attachments.forEach((attachment) => {
|
(attachments || []).forEach((attachment) => {
|
||||||
if (!Attachment.hasData(attachment)) {
|
if (!Attachment.hasData(attachment)) {
|
||||||
throw new TypeError("'attachment.data' is required during message import");
|
throw new TypeError("'attachment.data' is required during message import");
|
||||||
}
|
}
|
||||||
|
@ -266,13 +278,36 @@ exports.createAttachmentDataWriter = (writeExistingAttachmentData) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const messageWithoutAttachmentData = Object.assign({}, message, {
|
const writeThumbnails = exports._mapQuotedAttachments(async (thumbnail) => {
|
||||||
attachments: await Promise.all(attachments.map(async (attachment) => {
|
const { data, path } = thumbnail;
|
||||||
await writeExistingAttachmentData(attachment);
|
|
||||||
return omit(attachment, ['data']);
|
// 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;
|
return messageWithoutAttachmentData;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -22,6 +22,41 @@
|
||||||
template: i18n('unsupportedFileType')
|
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({
|
Whisper.FileInputView = Backbone.View.extend({
|
||||||
tagName: 'span',
|
tagName: 'span',
|
||||||
className: 'file-input',
|
className: 'file-input',
|
||||||
|
@ -239,29 +274,11 @@
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise(function(resolve, reject) {
|
const objectUrl = URL.createObjectURL(file);
|
||||||
var url = URL.createObjectURL(file);
|
return makeThumbnail(256, file).then(function(arrayBuffer) {
|
||||||
var img = document.createElement('img');
|
URL.revokeObjectURL(url);
|
||||||
img.onerror = reject;
|
return this.readFile(arrayBuffer);
|
||||||
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);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// File -> Promise Attachment
|
// File -> Promise Attachment
|
||||||
|
@ -348,4 +365,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Whisper.FileInputView.makeThumbnail = makeThumbnail;
|
||||||
})();
|
})();
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
/* global _: false */
|
/* global _: false */
|
||||||
/* global emoji_util: false */
|
/* global emoji_util: false */
|
||||||
/* global Mustache: false */
|
/* global Mustache: false */
|
||||||
/* global ConversationController: false */
|
/* global $: false */
|
||||||
|
|
||||||
// eslint-disable-next-line func-names
|
// eslint-disable-next-line func-names
|
||||||
(function () {
|
(function () {
|
||||||
|
@ -45,8 +45,10 @@
|
||||||
tagName: 'span',
|
tagName: 'span',
|
||||||
className: 'some-failed',
|
className: 'some-failed',
|
||||||
templateName: 'some-failed',
|
templateName: 'some-failed',
|
||||||
render_attributes: {
|
render_attributes() {
|
||||||
someFailed: i18n('someRecipientsFailed'),
|
return {
|
||||||
|
someFailed: i18n('someRecipientsFailed'),
|
||||||
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const TimerView = Whisper.View.extend({
|
const TimerView = Whisper.View.extend({
|
||||||
|
@ -215,6 +217,8 @@
|
||||||
'click .status': 'select',
|
'click .status': 'select',
|
||||||
'click .some-failed': 'select',
|
'click .some-failed': 'select',
|
||||||
'click .error-message': 'select',
|
'click .error-message': 'select',
|
||||||
|
'click .menu-container': 'showMenu',
|
||||||
|
'click .menu-list .reply': 'onReply',
|
||||||
},
|
},
|
||||||
retryMessage() {
|
retryMessage() {
|
||||||
const retrys = _.filter(
|
const retrys = _.filter(
|
||||||
|
@ -225,6 +229,26 @@
|
||||||
this.model.resend(number);
|
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() {
|
onExpired() {
|
||||||
this.$el.addClass('expired');
|
this.$el.addClass('expired');
|
||||||
this.$el.find('.bubble').one('webkitAnimationEnd animationend', (e) => {
|
this.$el.find('.bubble').one('webkitAnimationEnd animationend', (e) => {
|
||||||
|
@ -252,8 +276,8 @@
|
||||||
if (this.timeStampView) {
|
if (this.timeStampView) {
|
||||||
this.timeStampView.remove();
|
this.timeStampView.remove();
|
||||||
}
|
}
|
||||||
if (this.replyView) {
|
if (this.quoteView) {
|
||||||
this.replyView.remove();
|
this.quoteView.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: We have to do this in the background (`then` instead of `await`)
|
// NOTE: We have to do this in the background (`then` instead of `await`)
|
||||||
|
@ -314,7 +338,6 @@
|
||||||
renderErrors() {
|
renderErrors() {
|
||||||
const errors = this.model.get('errors');
|
const errors = this.model.get('errors');
|
||||||
|
|
||||||
|
|
||||||
this.$('.error-icon-container').remove();
|
this.$('.error-icon-container').remove();
|
||||||
if (this.errorIconView) {
|
if (this.errorIconView) {
|
||||||
this.errorIconView.remove();
|
this.errorIconView.remove();
|
||||||
|
@ -326,6 +349,12 @@
|
||||||
}
|
}
|
||||||
this.errorIconView = new ErrorIconView({ model: errors[0] });
|
this.errorIconView = new ErrorIconView({ model: errors[0] });
|
||||||
this.errorIconView.render().$el.appendTo(this.$('.bubble'));
|
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();
|
this.$('.meta .hasRetry').remove();
|
||||||
|
@ -365,86 +394,28 @@
|
||||||
this.timerView.setElement(this.$('.timer'));
|
this.timerView.setElement(this.$('.timer'));
|
||||||
this.timerView.update();
|
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() {
|
renderQuote() {
|
||||||
const quote = this.model.get('quote');
|
const props = this.model.getPropsForQuote();
|
||||||
if (!quote) {
|
if (!props) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VOICE_FLAG = textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE;
|
const contact = this.model.getQuoteContact();
|
||||||
const objectUrl = this.getQuoteObjectUrl();
|
if (this.quoteView) {
|
||||||
|
this.quoteView.remove();
|
||||||
|
this.quoteView = null;
|
||||||
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;
|
|
||||||
} else if (contact) {
|
} else if (contact) {
|
||||||
this.listenTo(contact, 'change:color', this.renderQuote);
|
this.listenTo(contact, 'change:color', this.renderQuote);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.replyView = new Whisper.ReactWrapperView({
|
this.quoteView = new Whisper.ReactWrapperView({
|
||||||
el: this.$('.quote-wrapper'),
|
className: 'quote-wrapper',
|
||||||
Component: window.Signal.Components.Quote,
|
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() {
|
isImageWithoutCaption() {
|
||||||
const attachments = this.model.get('attachments');
|
const attachments = this.model.get('attachments');
|
||||||
|
@ -464,15 +435,44 @@
|
||||||
|
|
||||||
return false;
|
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() {
|
render() {
|
||||||
const contact = this.model.isIncoming() ? this.model.getContact() : null;
|
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', ''), {
|
this.$el.html(Mustache.render(_.result(this, 'template', ''), {
|
||||||
message: this.model.get('body'),
|
message: this.model.get('body'),
|
||||||
|
hasBody,
|
||||||
timestamp: this.model.get('sent_at'),
|
timestamp: this.model.get('sent_at'),
|
||||||
sender: (contact && contact.getTitle()) || '',
|
sender: (contact && contact.getTitle()) || '',
|
||||||
avatar: (contact && contact.getAvatar()),
|
avatar: (contact && contact.getAvatar()),
|
||||||
profileName: (contact && contact.getProfileName()),
|
profileName: (contact && contact.getProfileName()),
|
||||||
innerBubbleClasses: this.isImageWithoutCaption() ? '' : 'with-tail',
|
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.render_partials()));
|
||||||
this.timeStampView.setElement(this.$('.timestamp'));
|
this.timeStampView.setElement(this.$('.timestamp'));
|
||||||
this.timeStampView.update();
|
this.timeStampView.update();
|
||||||
|
|
|
@ -17,6 +17,8 @@
|
||||||
const { Component, props, onClose } = options;
|
const { Component, props, onClose } = options;
|
||||||
this.render();
|
this.render();
|
||||||
|
|
||||||
|
this.tagName = options.tagName;
|
||||||
|
this.className = options.className;
|
||||||
this.Component = Component;
|
this.Component = Component;
|
||||||
this.onClose = onClose;
|
this.onClose = onClose;
|
||||||
|
|
||||||
|
|
|
@ -17,6 +17,7 @@ function stringToArrayBuffer(str) {
|
||||||
function Message(options) {
|
function Message(options) {
|
||||||
this.body = options.body;
|
this.body = options.body;
|
||||||
this.attachments = options.attachments || [];
|
this.attachments = options.attachments || [];
|
||||||
|
this.quote = options.quote;
|
||||||
this.group = options.group;
|
this.group = options.group;
|
||||||
this.flags = options.flags;
|
this.flags = options.flags;
|
||||||
this.recipients = options.recipients;
|
this.recipients = options.recipients;
|
||||||
|
@ -93,6 +94,28 @@ Message.prototype = {
|
||||||
proto.group.id = stringToArrayBuffer(this.group.id);
|
proto.group.id = stringToArrayBuffer(this.group.id);
|
||||||
proto.group.type = this.group.type
|
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) {
|
if (this.expireTimer) {
|
||||||
proto.expireTimer = this.expireTimer;
|
proto.expireTimer = this.expireTimer;
|
||||||
}
|
}
|
||||||
|
@ -223,7 +246,7 @@ MessageSender.prototype = {
|
||||||
}.bind(this));
|
}.bind(this));
|
||||||
},
|
},
|
||||||
|
|
||||||
uploadMedia: function(message) {
|
uploadAttachments: function(message) {
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
message.attachments.map(this.makeAttachmentPointer.bind(this))
|
message.attachments.map(this.makeAttachmentPointer.bind(this))
|
||||||
).then(function(attachmentPointers) {
|
).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) {
|
sendMessage: function(attrs) {
|
||||||
var message = new Message(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) {
|
return new Promise(function(resolve, reject) {
|
||||||
this.sendMessageProto(
|
this.sendMessageProto(
|
||||||
message.timestamp,
|
message.timestamp,
|
||||||
|
@ -494,12 +546,13 @@ MessageSender.prototype = {
|
||||||
}.bind(this));
|
}.bind(this));
|
||||||
},
|
},
|
||||||
|
|
||||||
sendMessageToNumber: function(number, messageText, attachments, timestamp, expireTimer, profileKey) {
|
sendMessageToNumber: function(number, messageText, attachments, quote, timestamp, expireTimer, profileKey) {
|
||||||
return this.sendMessage({
|
return this.sendMessage({
|
||||||
recipients : [number],
|
recipients : [number],
|
||||||
body : messageText,
|
body : messageText,
|
||||||
timestamp : timestamp,
|
timestamp : timestamp,
|
||||||
attachments : attachments,
|
attachments : attachments,
|
||||||
|
quote : quote,
|
||||||
needsSync : true,
|
needsSync : true,
|
||||||
expireTimer : expireTimer,
|
expireTimer : expireTimer,
|
||||||
profileKey : profileKey
|
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) {
|
return textsecure.storage.groups.getNumbers(groupId).then(function(numbers) {
|
||||||
if (numbers === undefined)
|
if (numbers === undefined)
|
||||||
return Promise.reject(new Error("Unknown Group"));
|
return Promise.reject(new Error("Unknown Group"));
|
||||||
|
@ -574,6 +627,7 @@ MessageSender.prototype = {
|
||||||
body : messageText,
|
body : messageText,
|
||||||
timestamp : timestamp,
|
timestamp : timestamp,
|
||||||
attachments : attachments,
|
attachments : attachments,
|
||||||
|
quote : quote,
|
||||||
needsSync : true,
|
needsSync : true,
|
||||||
expireTimer : expireTimer,
|
expireTimer : expireTimer,
|
||||||
profileKey : profileKey,
|
profileKey : profileKey,
|
||||||
|
|
|
@ -114,6 +114,7 @@
|
||||||
"eslint-plugin-mocha": "^4.12.1",
|
"eslint-plugin-mocha": "^4.12.1",
|
||||||
"eslint-plugin-more": "^0.3.1",
|
"eslint-plugin-more": "^0.3.1",
|
||||||
"extract-zip": "^1.6.6",
|
"extract-zip": "^1.6.6",
|
||||||
|
"glob": "^7.1.2",
|
||||||
"grunt": "^1.0.1",
|
"grunt": "^1.0.1",
|
||||||
"grunt-cli": "^1.2.0",
|
"grunt-cli": "^1.2.0",
|
||||||
"grunt-contrib-concat": "^1.0.1",
|
"grunt-contrib-concat": "^1.0.1",
|
||||||
|
|
13
preload.js
|
@ -205,3 +205,16 @@ window.Signal.Workflow.MessageDataMigrator =
|
||||||
// We pull this in last, because the native module involved appears to be sensitive to
|
// We pull this in last, because the native module involved appears to be sensitive to
|
||||||
// /tmp mounted as noexec on Linux.
|
// /tmp mounted as noexec on Linux.
|
||||||
require('./js/spell_check');
|
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 */
|
||||||
|
}
|
||||||
|
|
|
@ -163,7 +163,7 @@ module.exports = {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// To test handling of attachments, we need arraybuffers in memory
|
// 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',
|
loader: 'arraybuffer-loader',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
@ -353,6 +353,45 @@ li.entry .error-icon-container {
|
||||||
|
|
||||||
&:hover .error-message { display: inline-block; }
|
&: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 {
|
.error-icon {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
@ -362,6 +401,18 @@ li.entry .error-icon-container {
|
||||||
@include color-svg('../images/warning.svg', red);
|
@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 {
|
.group {
|
||||||
li.entry .unregistered-user-error {
|
li.entry .unregistered-user-error {
|
||||||
display: none;
|
display: none;
|
||||||
|
@ -379,10 +430,6 @@ li.entry .error-icon-container {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-list .outgoing .bubble .quote, .private .message-list .incoming .bubble .quote {
|
|
||||||
margin-top: $android-bubble-quote-padding - $android-bubble-padding-vertical;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sender {
|
.sender {
|
||||||
font-size: smaller;
|
font-size: smaller;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
|
@ -439,8 +486,6 @@ span.status {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.bubble {
|
.bubble {
|
||||||
position: relative;
|
position: relative;
|
||||||
left: -2px;
|
left: -2px;
|
||||||
|
@ -456,142 +501,7 @@ span.status {
|
||||||
max-width: calc(100% - 45px - #{$error-icon-size}); // avatar size + padding + error-icon size
|
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 {
|
.body {
|
||||||
margin-top: 0.5em;
|
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
|
||||||
a {
|
a {
|
||||||
|
@ -599,6 +509,13 @@ span.status {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.attachments + .content {
|
||||||
|
margin-top: 0.5em;
|
||||||
|
}
|
||||||
|
.quote-wrapper + .content {
|
||||||
|
margin-top: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
@ -650,13 +567,6 @@ span.status {
|
||||||
.avatar, .bubble {
|
.avatar, .bubble {
|
||||||
float: left;
|
float: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble {
|
|
||||||
.quote {
|
|
||||||
background-color: rgba(white, 0.6);
|
|
||||||
border-left-color: white;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.outgoing {
|
.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-list,
|
||||||
.message-container {
|
.message-container {
|
||||||
.avatar {
|
.avatar {
|
||||||
|
|
|
@ -108,122 +108,182 @@ $ios-border-color: rgba(0,0,0,0.1);
|
||||||
|
|
||||||
.message-container,
|
.message-container,
|
||||||
.message-list {
|
.message-list {
|
||||||
.quote {
|
.bubble .content {
|
||||||
border-top-left-radius: 15px;
|
margin-top: 0px;
|
||||||
border-top-right-radius: 15px;
|
}
|
||||||
border-bottom-left-radius: 0px;
|
}
|
||||||
border-bottom-right-radius: 0px;
|
|
||||||
|
|
||||||
// Not ideal, but necessary to override the specificity of the android theme color
|
.quoted-message {
|
||||||
// classes used in conversations.scss
|
// Not ideal, but necessary to override the specificity of the android theme color
|
||||||
background-color: white !important;
|
// classes used in conversations.scss
|
||||||
border: 1px solid $grey_l1_5 !important;
|
background-color: white !important;
|
||||||
border-bottom: none !important;
|
border: none !important;
|
||||||
|
border-radius: 0;
|
||||||
|
|
||||||
margin-top: 0px !important;
|
margin-top: 0px !important;
|
||||||
margin-bottom: 0px;
|
margin-bottom: 0px;
|
||||||
margin-left: 0px;
|
margin-left: 0px;
|
||||||
margin-right: 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 {
|
.primary {
|
||||||
padding: 10px;
|
|
||||||
|
|
||||||
.text,
|
.text,
|
||||||
.filename-label,
|
.filename-label,
|
||||||
.type-label {
|
.type-label {
|
||||||
border-left: 2px solid $grey_l1;
|
border-left: 2px solid $blue;
|
||||||
padding: 5px;
|
|
||||||
padding-left: 7px;
|
|
||||||
// Without this smaller bottom padding, text beyond four lines still shows up!
|
|
||||||
padding-bottom: 2px;
|
|
||||||
color: black;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.author {
|
.incoming .quoted-message {
|
||||||
display: none;
|
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 {
|
.ios-label {
|
||||||
display: block;
|
color: $grey_l4;
|
||||||
color: $grey_l1;
|
|
||||||
font-size: smaller;
|
|
||||||
margin-bottom: 3px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-container {
|
.icon-container {
|
||||||
height: 61px;
|
height: 50px;
|
||||||
width: 61px;
|
width: 50px;
|
||||||
min-width: 61px;
|
min-width: 50px;
|
||||||
|
|
||||||
.circle-background {
|
.circle-background {
|
||||||
left: 12px;
|
left: 6px;
|
||||||
right: 12px;
|
right: 6px;
|
||||||
top: 12px;
|
top: 6px;
|
||||||
bottom: 12px;
|
bottom: 6px;
|
||||||
|
|
||||||
background-color: $blue !important;
|
background-color: $blue !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
left: 18px;
|
left: 12px;
|
||||||
right: 18px;
|
right: 12px;
|
||||||
top: 18px;
|
top: 12px;
|
||||||
bottom: 18px;
|
bottom: 12px;
|
||||||
|
|
||||||
background-color: white !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.inner {
|
.inner {
|
||||||
padding: 12px;
|
padding: 0px;
|
||||||
height: 61px;
|
height: 50px;
|
||||||
text-align: center;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.from-me {
|
|
||||||
.primary {
|
|
||||||
.text,
|
|
||||||
.filename-label,
|
|
||||||
.type-label {
|
|
||||||
border-left: 2px solid $blue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.incoming {
|
|
||||||
.bubble {
|
|
||||||
.quote {
|
|
||||||
border-left: none;
|
|
||||||
border: none !important;
|
|
||||||
border-bottom: 1px solid lightgray !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.bubble {
|
|
||||||
.quote.from-me {
|
|
||||||
.primary {
|
|
||||||
.text,
|
|
||||||
.filename-label,
|
|
||||||
.type-label {
|
|
||||||
border-left: 2px solid $blue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.outgoing .bubble .quote,
|
|
||||||
.private .message-list .incoming .bubble .quote {
|
|
||||||
margin-top: 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outgoing .bubble .quote .icon-container .circle-background {
|
|
||||||
background-color: lightgray !important;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -286,38 +346,53 @@ $ios-border-color: rgba(0,0,0,0.1);
|
||||||
clear: both;
|
clear: both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.outgoing .with-tail.tail-wrapper {
|
.outgoing .tail-wrapper {
|
||||||
float: right;
|
float: right;
|
||||||
|
|
||||||
.inner-bubble {
|
.inner-bubble {
|
||||||
.attachments {
|
|
||||||
background-color: $blue;
|
|
||||||
}
|
|
||||||
.content {
|
|
||||||
background-color: $blue;
|
|
||||||
}
|
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
&, .body, a {
|
|
||||||
@include invert-text-color;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.incoming .with-tail.tail-wrapper {
|
.incoming .tail-wrapper {
|
||||||
float: left;
|
float: left;
|
||||||
|
|
||||||
&:before {
|
.inner-bubble {
|
||||||
left: -1px;
|
max-width: 100%;
|
||||||
background-color: white;
|
|
||||||
}
|
}
|
||||||
&: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 {
|
.inner-bubble {
|
||||||
background-color: white;
|
background-color: white;
|
||||||
color: black;
|
color: black;
|
||||||
max-width: 100%;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -338,6 +413,10 @@ $ios-border-color: rgba(0,0,0,0.1);
|
||||||
a {
|
a {
|
||||||
border-radius: 15px;
|
border-radius: 15px;
|
||||||
}
|
}
|
||||||
|
img {
|
||||||
|
margin-right: auto;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.hourglass {
|
.hourglass {
|
||||||
@include hourglass(#999);
|
@include hourglass(#999);
|
||||||
|
|
|
@ -225,13 +225,17 @@ $text-dark_l2: darken($text-dark, 30%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.outgoing .bubble .quote .icon-container .icon {
|
.outgoing .quoted-message {
|
||||||
background-color: black;
|
background: rgba(255, 255, 255, 0.38);
|
||||||
&.play.with-image {
|
|
||||||
background-color: $text-dark;
|
.icon-container .icon {
|
||||||
|
background-color: black;
|
||||||
|
&.play.with-image {
|
||||||
|
background-color: $text-dark;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.incoming .bubble .quote {
|
.incoming .quoted-message {
|
||||||
border-left-color: $text-dark;
|
border-left-color: $text-dark;
|
||||||
background-color: rgba(0, 0, 0, 0.6);
|
background-color: rgba(0, 0, 0, 0.6);
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,11 @@
|
||||||
module.exports = {
|
module.exports = {
|
||||||
env: {
|
env: {
|
||||||
mocha: true,
|
mocha: true,
|
||||||
|
browser: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
parserOptions: {
|
||||||
|
sourceType: 'script',
|
||||||
},
|
},
|
||||||
|
|
||||||
rules: {
|
rules: {
|
||||||
|
|
12
test/app/.eslintrc.js
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
// For reference: https://github.com/airbnb/javascript
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
env: {
|
||||||
|
mocha: true,
|
||||||
|
browser: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
parserOptions: {
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
};
|
|
@ -1,52 +1,58 @@
|
||||||
|
/* global Signal: false */
|
||||||
|
/* global Whisper: false */
|
||||||
|
/* global assert: false */
|
||||||
|
/* global textsecure: false */
|
||||||
|
/* global _: false */
|
||||||
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
describe('Backup', function() {
|
describe('Backup', () => {
|
||||||
describe('_sanitizeFileName', function() {
|
describe('_sanitizeFileName', () => {
|
||||||
it('leaves a basic string alone', function() {
|
it('leaves a basic string alone', () => {
|
||||||
var initial = 'Hello, how are you #5 (\'fine\' + great).jpg';
|
const initial = 'Hello, how are you #5 (\'fine\' + great).jpg';
|
||||||
var expected = initial;
|
const expected = initial;
|
||||||
assert.strictEqual(Signal.Backup._sanitizeFileName(initial), expected);
|
assert.strictEqual(Signal.Backup._sanitizeFileName(initial), expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('replaces all unknown characters', function() {
|
it('replaces all unknown characters', () => {
|
||||||
var initial = '!@$%^&*=';
|
const initial = '!@$%^&*=';
|
||||||
var expected = '________';
|
const expected = '________';
|
||||||
assert.strictEqual(Signal.Backup._sanitizeFileName(initial), expected);
|
assert.strictEqual(Signal.Backup._sanitizeFileName(initial), expected);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('_trimFileName', function() {
|
describe('_trimFileName', () => {
|
||||||
it('handles a file with no extension', function() {
|
it('handles a file with no extension', () => {
|
||||||
var initial = '0123456789012345678901234567890123456789';
|
const initial = '0123456789012345678901234567890123456789';
|
||||||
var expected = '012345678901234567890123456789';
|
const expected = '012345678901234567890123456789';
|
||||||
assert.strictEqual(Signal.Backup._trimFileName(initial), expected);
|
assert.strictEqual(Signal.Backup._trimFileName(initial), expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles a file with a long extension', function() {
|
it('handles a file with a long extension', () => {
|
||||||
var initial = '0123456789012345678901234567890123456789.01234567890123456789';
|
const initial = '0123456789012345678901234567890123456789.01234567890123456789';
|
||||||
var expected = '012345678901234567890123456789';
|
const expected = '012345678901234567890123456789';
|
||||||
assert.strictEqual(Signal.Backup._trimFileName(initial), expected);
|
assert.strictEqual(Signal.Backup._trimFileName(initial), expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles a file with a normal extension', function() {
|
it('handles a file with a normal extension', () => {
|
||||||
var initial = '01234567890123456789012345678901234567890123456789.jpg';
|
const initial = '01234567890123456789012345678901234567890123456789.jpg';
|
||||||
var expected = '012345678901234567890123.jpg';
|
const expected = '012345678901234567890123.jpg';
|
||||||
assert.strictEqual(Signal.Backup._trimFileName(initial), expected);
|
assert.strictEqual(Signal.Backup._trimFileName(initial), expected);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('_getExportAttachmentFileName', function() {
|
describe('_getExportAttachmentFileName', () => {
|
||||||
it('uses original filename if attachment has one', function() {
|
it('uses original filename if attachment has one', () => {
|
||||||
var message = {
|
const message = {
|
||||||
body: 'something',
|
body: 'something',
|
||||||
};
|
};
|
||||||
var index = 0;
|
const index = 0;
|
||||||
var attachment = {
|
const attachment = {
|
||||||
fileName: 'blah.jpg'
|
fileName: 'blah.jpg',
|
||||||
};
|
};
|
||||||
var expected = 'blah.jpg';
|
const expected = 'blah.jpg';
|
||||||
|
|
||||||
var actual = Signal.Backup._getExportAttachmentFileName(
|
const actual = Signal.Backup._getExportAttachmentFileName(
|
||||||
message,
|
message,
|
||||||
index,
|
index,
|
||||||
attachment
|
attachment
|
||||||
|
@ -54,36 +60,17 @@ describe('Backup', function() {
|
||||||
assert.strictEqual(actual, expected);
|
assert.strictEqual(actual, expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses attachment id if no filename', function() {
|
it('uses attachment id if no filename', () => {
|
||||||
var message = {
|
const message = {
|
||||||
body: 'something',
|
body: 'something',
|
||||||
};
|
};
|
||||||
var index = 0;
|
const index = 0;
|
||||||
var attachment = {
|
const 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 = {
|
|
||||||
id: '123',
|
id: '123',
|
||||||
contentType: 'image/jpeg'
|
|
||||||
};
|
};
|
||||||
var expected = '123.jpeg';
|
const expected = '123';
|
||||||
|
|
||||||
var actual = Signal.Backup._getExportAttachmentFileName(
|
const actual = Signal.Backup._getExportAttachmentFileName(
|
||||||
message,
|
message,
|
||||||
index,
|
index,
|
||||||
attachment
|
attachment
|
||||||
|
@ -91,18 +78,37 @@ describe('Backup', function() {
|
||||||
assert.strictEqual(actual, expected);
|
assert.strictEqual(actual, expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles strange contentType', function() {
|
it('uses filename and contentType if available', () => {
|
||||||
var message = {
|
const message = {
|
||||||
body: 'something',
|
body: 'something',
|
||||||
};
|
};
|
||||||
var index = 0;
|
const index = 0;
|
||||||
var attachment = {
|
const attachment = {
|
||||||
id: '123',
|
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,
|
message,
|
||||||
index,
|
index,
|
||||||
attachment
|
attachment
|
||||||
|
@ -111,19 +117,19 @@ describe('Backup', function() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('_getAnonymousAttachmentFileName', function() {
|
describe('_getAnonymousAttachmentFileName', () => {
|
||||||
it('uses message id', function() {
|
it('uses message id', () => {
|
||||||
var message = {
|
const message = {
|
||||||
id: 'id-45',
|
id: 'id-45',
|
||||||
body: 'something',
|
body: 'something',
|
||||||
};
|
};
|
||||||
var index = 0;
|
const index = 0;
|
||||||
var attachment = {
|
const attachment = {
|
||||||
fileName: 'blah.jpg'
|
fileName: 'blah.jpg',
|
||||||
};
|
};
|
||||||
var expected = 'id-45';
|
const expected = 'id-45';
|
||||||
|
|
||||||
var actual = Signal.Backup._getAnonymousAttachmentFileName(
|
const actual = Signal.Backup._getAnonymousAttachmentFileName(
|
||||||
message,
|
message,
|
||||||
index,
|
index,
|
||||||
attachment
|
attachment
|
||||||
|
@ -131,18 +137,18 @@ describe('Backup', function() {
|
||||||
assert.strictEqual(actual, expected);
|
assert.strictEqual(actual, expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('appends index if it is above zero', function() {
|
it('appends index if it is above zero', () => {
|
||||||
var message = {
|
const message = {
|
||||||
id: 'id-45',
|
id: 'id-45',
|
||||||
body: 'something',
|
body: 'something',
|
||||||
};
|
};
|
||||||
var index = 1;
|
const index = 1;
|
||||||
var attachment = {
|
const attachment = {
|
||||||
fileName: 'blah.jpg'
|
fileName: 'blah.jpg',
|
||||||
};
|
};
|
||||||
var expected = 'id-45-1';
|
const expected = 'id-45-1';
|
||||||
|
|
||||||
var actual = Signal.Backup._getAnonymousAttachmentFileName(
|
const actual = Signal.Backup._getAnonymousAttachmentFileName(
|
||||||
message,
|
message,
|
||||||
index,
|
index,
|
||||||
attachment
|
attachment
|
||||||
|
@ -151,64 +157,343 @@ describe('Backup', function() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('_getConversationDirName', function() {
|
describe('_getConversationDirName', () => {
|
||||||
it('uses name if available', function() {
|
it('uses name if available', () => {
|
||||||
var conversation = {
|
const conversation = {
|
||||||
active_at: 123,
|
active_at: 123,
|
||||||
name: '0123456789012345678901234567890123456789',
|
name: '0123456789012345678901234567890123456789',
|
||||||
id: 'id'
|
id: 'id',
|
||||||
};
|
};
|
||||||
var expected = '123 (012345678901234567890123456789 id)';
|
const expected = '123 (012345678901234567890123456789 id)';
|
||||||
assert.strictEqual(Signal.Backup._getConversationDirName(conversation), expected);
|
assert.strictEqual(Signal.Backup._getConversationDirName(conversation), expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses just id if name is not available', function() {
|
it('uses just id if name is not available', () => {
|
||||||
var conversation = {
|
const conversation = {
|
||||||
active_at: 123,
|
active_at: 123,
|
||||||
id: 'id'
|
id: 'id',
|
||||||
};
|
};
|
||||||
var expected = '123 (id)';
|
const expected = '123 (id)';
|
||||||
assert.strictEqual(Signal.Backup._getConversationDirName(conversation), expected);
|
assert.strictEqual(Signal.Backup._getConversationDirName(conversation), expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses inactive for missing active_at', function() {
|
it('uses inactive for missing active_at', () => {
|
||||||
var conversation = {
|
const conversation = {
|
||||||
name: 'name',
|
name: 'name',
|
||||||
id: 'id'
|
id: 'id',
|
||||||
};
|
};
|
||||||
var expected = 'inactive (name id)';
|
const expected = 'inactive (name id)';
|
||||||
assert.strictEqual(Signal.Backup._getConversationDirName(conversation), expected);
|
assert.strictEqual(Signal.Backup._getConversationDirName(conversation), expected);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('_getConversationLoggingName', function() {
|
describe('_getConversationLoggingName', () => {
|
||||||
it('uses plain id if conversation is private', function() {
|
it('uses plain id if conversation is private', () => {
|
||||||
var conversation = {
|
const conversation = {
|
||||||
active_at: 123,
|
active_at: 123,
|
||||||
id: 'id',
|
id: 'id',
|
||||||
type: 'private'
|
type: 'private',
|
||||||
};
|
};
|
||||||
var expected = '123 (id)';
|
const expected = '123 (id)';
|
||||||
assert.strictEqual(Signal.Backup._getConversationLoggingName(conversation), expected);
|
assert.strictEqual(
|
||||||
|
Signal.Backup._getConversationLoggingName(conversation),
|
||||||
|
expected
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses just id if name is not available', function() {
|
it('uses just id if name is not available', () => {
|
||||||
var conversation = {
|
const conversation = {
|
||||||
active_at: 123,
|
active_at: 123,
|
||||||
id: 'groupId',
|
id: 'groupId',
|
||||||
type: 'group'
|
type: 'group',
|
||||||
};
|
};
|
||||||
var expected = '123 ([REDACTED_GROUP]pId)';
|
const expected = '123 ([REDACTED_GROUP]pId)';
|
||||||
assert.strictEqual(Signal.Backup._getConversationLoggingName(conversation), expected);
|
assert.strictEqual(
|
||||||
|
Signal.Backup._getConversationLoggingName(conversation),
|
||||||
|
expected
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses inactive for missing active_at', function() {
|
it('uses inactive for missing active_at', () => {
|
||||||
var conversation = {
|
const conversation = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
type: 'private'
|
type: 'private',
|
||||||
};
|
};
|
||||||
var expected = 'inactive (id)';
|
const expected = 'inactive (id)';
|
||||||
assert.strictEqual(Signal.Backup._getConversationLoggingName(conversation), expected);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -2684,5 +2684,5 @@ Whisper.Fixtures = (function() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return conversationCollection;
|
return conversationCollection;
|
||||||
})();
|
});
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@ describe("Fixtures", function() {
|
||||||
// NetworkStatusView checks this method every five seconds while showing
|
// NetworkStatusView checks this method every five seconds while showing
|
||||||
window.getSocketStatus = function() { return WebSocket.OPEN; };
|
window.getSocketStatus = function() { return WebSocket.OPEN; };
|
||||||
|
|
||||||
Whisper.Fixtures.saveAll().then(function() {
|
Whisper.Fixtures().saveAll().then(function() {
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -214,11 +214,16 @@
|
||||||
</div>
|
</div>
|
||||||
<div class='tail-wrapper {{ innerBubbleClasses }}'>
|
<div class='tail-wrapper {{ innerBubbleClasses }}'>
|
||||||
<div class='inner-bubble'>
|
<div class='inner-bubble'>
|
||||||
<div class='quote-wrapper'></div>
|
{{ #hasAttachments }}
|
||||||
<div class='attachments'></div>
|
<div class='attachments'></div>
|
||||||
<div class='content' dir='auto'>
|
{{ /hasAttachments }}
|
||||||
{{ #message }}<div class='body'>{{ message }}</div>{{ /message }}
|
{{ #hasBody }}
|
||||||
</div>
|
<div class='content' dir='auto'>
|
||||||
|
{{ #message }}
|
||||||
|
<div class='body'>{{ message }}</div>
|
||||||
|
{{ /message }}
|
||||||
|
</div>
|
||||||
|
{{ /hasBody }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class='meta'>
|
<div class='meta'>
|
||||||
|
@ -226,6 +231,16 @@
|
||||||
<span class='status hide'></span>
|
<span class='status hide'></span>
|
||||||
<span class='timer'></span>
|
<span class='timer'></span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</script>
|
</script>
|
||||||
<script type='text/x-tmpl-mustache' id='hourglass'>
|
<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/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/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/debug_log_view.js' data-cover></script>
|
||||||
<script type='text/javascript' src='../js/views/toast_view.js' data-cover></script>
|
<script type='text/javascript' src='../js/views/toast_view.js' data-cover></script>
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
{
|
|
||||||
"globals": {
|
|
||||||
"check": true,
|
|
||||||
"gen": true
|
|
||||||
}
|
|
||||||
}
|
|
27
test/modules/.eslintrc.js
Normal 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',
|
||||||
|
}
|
||||||
|
};
|
|
@ -67,6 +67,43 @@ describe('Message', () => {
|
||||||
await Message.createAttachmentDataWriter(writeExistingAttachmentData)(input);
|
await Message.createAttachmentDataWriter(writeExistingAttachmentData)(input);
|
||||||
assert.deepEqual(actual, expected);
|
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('It’s 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('It’s easy if you try'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const actual =
|
||||||
|
await Message.createAttachmentDataWriter(writeExistingAttachmentData)(input);
|
||||||
|
assert.deepEqual(actual, expected);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('initializeSchemaVersion', () => {
|
describe('initializeSchemaVersion', () => {
|
||||||
|
@ -373,6 +410,37 @@ describe('Message', () => {
|
||||||
assert.deepEqual(result, 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 () => {
|
it('calls provided async function for each quoted attachment', async () => {
|
||||||
const upgradeAttachment = sinon.stub().resolves({
|
const upgradeAttachment = sinon.stub().resolves({
|
||||||
path: '/new/path/on/disk',
|
path: '/new/path/on/disk',
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
/* global window: false */
|
/* global window: false */
|
||||||
|
|
||||||
// Because we aren't hosting the Style Guide in Electron, we can't rely on preload.js
|
// 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();
|
next();
|
||||||
},
|
},
|
||||||
version: 1,
|
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),
|
loadAttachmentData: attachment => Promise.resolve(attachment),
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
/* global window: false */
|
/* global window: false */
|
||||||
|
|
||||||
// Taken from background.html.
|
// Taken from background.html.
|
||||||
|
@ -24,29 +26,44 @@ window.Whisper.View.Templates = {
|
||||||
</span>
|
</span>
|
||||||
`,
|
`,
|
||||||
message: `
|
message: `
|
||||||
{{> avatar }}
|
{{> avatar }}
|
||||||
<div class='bubble {{ avatar.color }}'>
|
<div class='bubble {{ avatar.color }}'>
|
||||||
<div class='sender' dir='auto'>
|
<div class='sender' dir='auto'>
|
||||||
{{ sender }}
|
{{ sender }}
|
||||||
{{ #profileName }}
|
{{ #profileName }}
|
||||||
<span class='profileName'>{{ profileName }} </span>
|
<span class='profileName'>{{ profileName }} </span>
|
||||||
{{ /profileName }}
|
{{ /profileName }}
|
||||||
</div>
|
</div>
|
||||||
<div class='tail-wrapper {{ innerBubbleClasses }}'>
|
<div class='tail-wrapper {{ innerBubbleClasses }}'>
|
||||||
<div class='inner-bubble'>
|
<div class='inner-bubble'>
|
||||||
<div class='quote-wrapper'></div>
|
{{ #hasAttachments }}
|
||||||
<div class='attachments'></div>
|
<div class='attachments'></div>
|
||||||
<div class='content' dir='auto'>
|
{{ /hasAttachments }}
|
||||||
{{ #message }}<div class='body'>{{ message }}</div>{{ /message }}
|
{{ #hasBody }}
|
||||||
|
<div class='content' dir='auto'>
|
||||||
|
{{ #message }}
|
||||||
|
<div class='body'>{{ message }}</div>
|
||||||
|
{{ /message }}
|
||||||
|
</div>
|
||||||
|
{{ /hasBody }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class='meta'>
|
||||||
<div class='meta'>
|
<span class='timestamp' data-timestamp={{ timestamp }}></span>
|
||||||
<span class='timestamp' data-timestamp={{ timestamp }}></span>
|
<span class='status hide'></span>
|
||||||
<span class='status hide'></span>
|
<span class='timer'></span>
|
||||||
<span class='timer'></span>
|
</div>
|
||||||
</div>
|
{{ #hoverIcon }}
|
||||||
</div>
|
<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: `
|
hourglass: `
|
||||||
<span class='hourglass'><span class='sand'></span></span>
|
<span class='hourglass'><span class='sand'></span></span>
|
||||||
|
@ -63,4 +80,11 @@ window.Whisper.View.Templates = {
|
||||||
<div class='fileSize'>{{ fileSize }}</div>
|
<div class='fileSize'>{{ fileSize }}</div>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
|
'error-icon': `
|
||||||
|
<span class='error-icon'>
|
||||||
|
</span>
|
||||||
|
{{ #message }}
|
||||||
|
<span class='error-message'>{{message}}</span>
|
||||||
|
{{ /message }}
|
||||||
|
`,
|
||||||
};
|
};
|
||||||
|
|
|
@ -14,6 +14,12 @@ describe('NetworkStatusView', function() {
|
||||||
|
|
||||||
after(function() {
|
after(function() {
|
||||||
window.getSocketStatus = oldGetSocketStatus;
|
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 */
|
/* END stubbing globals */
|
||||||
|
|
||||||
|
|
|
@ -40,7 +40,7 @@ const View = Whisper.MessageView;
|
||||||
const outgoing = new Whisper.Message({
|
const outgoing = new Whisper.Message({
|
||||||
type: 'outgoing',
|
type: 'outgoing',
|
||||||
body: 'How are you doing this fine day?',
|
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, {
|
const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
|
||||||
source: '+12025550003',
|
source: '+12025550003',
|
||||||
|
@ -59,6 +59,322 @@ const View = Whisper.MessageView;
|
||||||
</util.ConversationContext>
|
</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
|
### With an attachment
|
||||||
|
|
||||||
#### Image with caption
|
#### Image with caption
|
||||||
|
@ -120,6 +436,125 @@ const View = Whisper.MessageView;
|
||||||
</util.ConversationContext>
|
</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
|
#### Video with caption
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
|
|
|
@ -15,7 +15,6 @@ export class Message extends React.Component<{}, {}> {
|
||||||
<div className="sender" dir="auto" />
|
<div className="sender" dir="auto" />
|
||||||
<div className="tail-wrapper with-tail">
|
<div className="tail-wrapper with-tail">
|
||||||
<div className="inner-bubble">
|
<div className="inner-bubble">
|
||||||
<div className="attachments" />
|
|
||||||
<p className="content" dir="auto">
|
<p className="content" dir="auto">
|
||||||
<span className="body">
|
<span className="body">
|
||||||
Hi there. How are you doing? Feeling pretty good? Awesome.
|
Hi there. How are you doing? Feeling pretty good? Awesome.
|
||||||
|
|
|
@ -34,6 +34,39 @@ const View = Whisper.MessageView;
|
||||||
</util.ConversationContext>
|
</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
|
#### Replies to you or yourself
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
|
@ -183,9 +216,8 @@ const View = Whisper.MessageView;
|
||||||
#### A lot of text in quotation, with image
|
#### A lot of text in quotation, with image
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
const quotedMessage = {
|
const thumbnail = {
|
||||||
imageUrl: util.gifObjectUrl,
|
objectUrl: util.gifObjectUrl,
|
||||||
id: '3234-23423-2342',
|
|
||||||
};
|
};
|
||||||
const outgoing = new Whisper.Message({
|
const outgoing = new Whisper.Message({
|
||||||
type: 'outgoing',
|
type: 'outgoing',
|
||||||
|
@ -218,8 +250,8 @@ const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
outgoing.quotedMessage = quotedMessage;
|
outgoing.quoteThumbnail = thumbnail;
|
||||||
incoming.quotedMessage = quotedMessage;
|
incoming.quoteThumbnail = thumbnail;
|
||||||
|
|
||||||
const View = Whisper.MessageView;
|
const View = Whisper.MessageView;
|
||||||
<util.ConversationContext theme={util.theme}>
|
<util.ConversationContext theme={util.theme}>
|
||||||
|
@ -237,8 +269,8 @@ const View = Whisper.MessageView;
|
||||||
#### Image with caption
|
#### Image with caption
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
const quotedMessage = {
|
const thumbnail = {
|
||||||
imageUrl: util.gifObjectUrl,
|
objectUrl: util.gifObjectUrl,
|
||||||
id: '3234-23423-2342',
|
id: '3234-23423-2342',
|
||||||
};
|
};
|
||||||
const outgoing = new Whisper.Message({
|
const outgoing = new Whisper.Message({
|
||||||
|
@ -268,8 +300,8 @@ const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
outgoing.quotedMessage = quotedMessage;
|
outgoing.quoteThumbnail = thumbnail;
|
||||||
incoming.quotedMessage = quotedMessage;
|
incoming.quoteThumbnail = thumbnail;
|
||||||
|
|
||||||
const View = Whisper.MessageView;
|
const View = Whisper.MessageView;
|
||||||
<util.ConversationContext theme={util.theme}>
|
<util.ConversationContext theme={util.theme}>
|
||||||
|
@ -287,8 +319,8 @@ const View = Whisper.MessageView;
|
||||||
#### Image
|
#### Image
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
const quotedMessage = {
|
const thumbnail = {
|
||||||
imageUrl: util.gifObjectUrl,
|
objectUrl: util.gifObjectUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
const outgoing = new Whisper.Message({
|
const outgoing = new Whisper.Message({
|
||||||
|
@ -317,8 +349,8 @@ const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
outgoing.quotedMessage = quotedMessage;
|
outgoing.quoteThumbnail = thumbnail;
|
||||||
incoming.quotedMessage = quotedMessage;
|
incoming.quoteThumbnail = thumbnail;
|
||||||
|
|
||||||
const View = Whisper.MessageView;
|
const View = Whisper.MessageView;
|
||||||
<util.ConversationContext theme={util.theme}>
|
<util.ConversationContext theme={util.theme}>
|
||||||
|
@ -375,8 +407,8 @@ const View = Whisper.MessageView;
|
||||||
#### Video with caption
|
#### Video with caption
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
const quotedMessage = {
|
const thumbnail = {
|
||||||
imageUrl: util.gifObjectUrl,
|
objectUrl: util.gifObjectUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
const outgoing = new Whisper.Message({
|
const outgoing = new Whisper.Message({
|
||||||
|
@ -406,8 +438,8 @@ const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
outgoing.quotedMessage = quotedMessage;
|
outgoing.quoteThumbnail = thumbnail;
|
||||||
incoming.quotedMessage = quotedMessage;
|
incoming.quoteThumbnail = thumbnail;
|
||||||
|
|
||||||
const View = Whisper.MessageView;
|
const View = Whisper.MessageView;
|
||||||
<util.ConversationContext theme={util.theme}>
|
<util.ConversationContext theme={util.theme}>
|
||||||
|
@ -425,8 +457,8 @@ const View = Whisper.MessageView;
|
||||||
#### Video
|
#### Video
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
const quotedMessage = {
|
const thumbnail = {
|
||||||
imageUrl: util.gifObjectUrl,
|
objectUrl: util.gifObjectUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
const outgoing = new Whisper.Message({
|
const outgoing = new Whisper.Message({
|
||||||
|
@ -456,8 +488,8 @@ const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
outgoing.quotedMessage = quotedMessage;
|
outgoing.quoteThumbnail = thumbnail;
|
||||||
incoming.quotedMessage = quotedMessage;
|
incoming.quoteThumbnail = thumbnail;
|
||||||
|
|
||||||
const View = Whisper.MessageView;
|
const View = Whisper.MessageView;
|
||||||
<util.ConversationContext theme={util.theme}>
|
<util.ConversationContext theme={util.theme}>
|
||||||
|
@ -782,6 +814,44 @@ const View = Whisper.MessageView;
|
||||||
</util.ConversationContext>
|
</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
|
#### Quote, video attachment
|
||||||
|
|
||||||
```jsx
|
```jsx
|
||||||
|
@ -893,3 +963,164 @@ const View = Whisper.MessageView;
|
||||||
/>
|
/>
|
||||||
</util.ConversationContext>
|
</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>
|
||||||
|
```
|
||||||
|
|
|
@ -14,6 +14,7 @@ interface Props {
|
||||||
isFromMe: string;
|
isFromMe: string;
|
||||||
isIncoming: boolean;
|
isIncoming: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
onClose?: () => void;
|
||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -94,7 +95,7 @@ export class Quote extends React.Component<Props, {}> {
|
||||||
if (Mime.isVideo(contentType)) {
|
if (Mime.isVideo(contentType)) {
|
||||||
return objectUrl
|
return objectUrl
|
||||||
? this.renderImage(objectUrl, 'play')
|
? this.renderImage(objectUrl, 'play')
|
||||||
: this.renderIcon('play');
|
: this.renderIcon('movie');
|
||||||
}
|
}
|
||||||
if (Mime.isImage(contentType)) {
|
if (Mime.isImage(contentType)) {
|
||||||
return objectUrl
|
return objectUrl
|
||||||
|
@ -112,7 +113,7 @@ export class Quote extends React.Component<Props, {}> {
|
||||||
const { i18n, text, attachments } = this.props;
|
const { i18n, text, attachments } = this.props;
|
||||||
|
|
||||||
if (text) {
|
if (text) {
|
||||||
return <div className="text">{text}</div>;
|
return <div className="text" dangerouslySetInnerHTML={{ __html: text}} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!attachments || attachments.length === 0) {
|
if (!attachments || attachments.length === 0) {
|
||||||
|
@ -153,6 +154,28 @@ export class Quote extends React.Component<Props, {}> {
|
||||||
return <div className="ios-label">{label}</div>;
|
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() {
|
public render() {
|
||||||
const {
|
const {
|
||||||
authorTitle,
|
authorTitle,
|
||||||
|
@ -171,7 +194,7 @@ export class Quote extends React.Component<Props, {}> {
|
||||||
: null;
|
: null;
|
||||||
const classes = classnames(
|
const classes = classnames(
|
||||||
authorColor,
|
authorColor,
|
||||||
'quote',
|
'quoted-message',
|
||||||
isFromMe ? 'from-me' : null,
|
isFromMe ? 'from-me' : null,
|
||||||
!onClick ? 'no-click' : null,
|
!onClick ? 'no-click' : null,
|
||||||
);
|
);
|
||||||
|
@ -186,6 +209,7 @@ export class Quote extends React.Component<Props, {}> {
|
||||||
{this.renderText()}
|
{this.renderText()}
|
||||||
</div>
|
</div>
|
||||||
{this.renderIconContainer()}
|
{this.renderIconContainer()}
|
||||||
|
{this.renderClose()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,6 +41,23 @@ const txtObjectUrl = makeObjectUrl(txt, 'text/plain');
|
||||||
import mp4 from '../../fixtures/pixabay-Soap-Bubble-7141.mp4';
|
import mp4 from '../../fixtures/pixabay-Soap-Bubble-7141.mp4';
|
||||||
const mp4ObjectUrl = makeObjectUrl(mp4, 'video/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 {
|
function makeObjectUrl(data: ArrayBuffer, contentType: string): string {
|
||||||
const blob = new Blob([data], {
|
const blob = new Blob([data], {
|
||||||
type: contentType,
|
type: contentType,
|
||||||
|
@ -49,6 +66,8 @@ function makeObjectUrl(data: ArrayBuffer, contentType: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
const ourNumber = '+12025559999';
|
const ourNumber = '+12025559999';
|
||||||
|
const groupNumber = '+12025550099';
|
||||||
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
mp3,
|
mp3,
|
||||||
|
@ -59,7 +78,18 @@ export {
|
||||||
mp4ObjectUrl,
|
mp4ObjectUrl,
|
||||||
txt,
|
txt,
|
||||||
txtObjectUrl,
|
txtObjectUrl,
|
||||||
|
landscapeGreen,
|
||||||
|
landscapeGreenObjectUrl,
|
||||||
|
landscapePurple,
|
||||||
|
landscapePurpleObjectUrl,
|
||||||
|
portraitYellow,
|
||||||
|
portraitYellowObjectUrl,
|
||||||
|
landscapeRed,
|
||||||
|
landscapeRedObjectUrl,
|
||||||
|
portraitTeal,
|
||||||
|
portraitTealObjectUrl,
|
||||||
ourNumber,
|
ourNumber,
|
||||||
|
groupNumber,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
@ -153,10 +183,22 @@ const me = parent.ConversationController.dangerouslyCreateAndAdd({
|
||||||
color: 'light_blue',
|
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 {
|
export {
|
||||||
COLORS,
|
COLORS,
|
||||||
CONTACTS,
|
CONTACTS,
|
||||||
me,
|
me,
|
||||||
|
group,
|
||||||
};
|
};
|
||||||
|
|
||||||
parent.textsecure.storage.user.getNumber = () => ourNumber;
|
parent.textsecure.storage.user.getNumber = () => ourNumber;
|
||||||
|
@ -164,3 +206,11 @@ parent.textsecure.storage.user.getNumber = () => ourNumber;
|
||||||
// Telling Lodash to relinquish _ for use by underscore
|
// Telling Lodash to relinquish _ for use by underscore
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
_.noConflict();
|
_.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:">',
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|