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/models/conversations.js
|
||||
!js/models/messages.js
|
||||
!test/backup_test.js
|
||||
!js/views/attachment_view.js
|
||||
!js/views/conversation_view.js
|
||||
!js/views/conversation_search_view.js
|
||||
!js/views/backbone_wrapper_view.js
|
||||
!js/views/debug_log_view.js
|
||||
|
|
|
@ -109,6 +109,7 @@ module.exports = function(grunt) {
|
|||
'!js/Mp3LameEncoder.min.js',
|
||||
'!js/signal_protocol_store.js',
|
||||
'!js/views/conversation_search_view.js',
|
||||
'!js/views/conversation_view.js',
|
||||
'!js/views/debug_log_view.js',
|
||||
'!js/views/message_view.js',
|
||||
'!js/models/conversations.js',
|
||||
|
@ -166,6 +167,10 @@ module.exports = function(grunt) {
|
|||
'!js/modules/**/*.js',
|
||||
'!js/models/conversations.js',
|
||||
'!js/models/messages.js',
|
||||
'!js/views/conversation_search_view.js',
|
||||
'!js/views/conversation_view.js',
|
||||
'!js/views/debug_log_view.js',
|
||||
'!js/views/message_view.js',
|
||||
'!js/Mp3LameEncoder.min.js',
|
||||
'!js/WebAudioRecorderMp3.js',
|
||||
'test/**/*.js',
|
||||
|
|
|
@ -428,6 +428,10 @@
|
|||
"selectAContact": {
|
||||
"message": "Select a contact or group to start chatting."
|
||||
},
|
||||
"replyToMessage": {
|
||||
"message": "Reply to Message",
|
||||
"description": "Shown in triple-dot menu next to message to allow user to start crafting a message with a quotation"
|
||||
},
|
||||
"replyingToYourself": {
|
||||
"message": "Replying to Yourself",
|
||||
"description": "Shown in iOS theme when you quote yourself"
|
||||
|
@ -542,6 +546,10 @@
|
|||
"message": "Secure session reset",
|
||||
"description": "This is a past tense, informational message. In other words, your secure session has been reset."
|
||||
},
|
||||
"noContents": {
|
||||
"message": "No message contents",
|
||||
"description": "Shown in a message bubble if we have nothing in the message to display, or a quote and nothing else"
|
||||
},
|
||||
"installWelcome": {
|
||||
"message": "Welcome to Signal Desktop",
|
||||
"description": "Welcome title on the install page"
|
||||
|
|
|
@ -279,11 +279,16 @@
|
|||
</div>
|
||||
<div class='tail-wrapper {{ innerBubbleClasses }}'>
|
||||
<div class='inner-bubble'>
|
||||
<div class='quote-wrapper'></div>
|
||||
<div class='attachments'></div>
|
||||
<div class='content' dir='auto'>
|
||||
{{ #message }}<div class='body'>{{ message }}</div>{{ /message }}
|
||||
</div>
|
||||
{{ #hasAttachments }}
|
||||
<div class='attachments'></div>
|
||||
{{ /hasAttachments }}
|
||||
{{ #hasBody }}
|
||||
<div class='content' dir='auto'>
|
||||
{{ #message }}
|
||||
<div class='body'>{{ message }}</div>
|
||||
{{ /message }}
|
||||
</div>
|
||||
{{ /hasBody }}
|
||||
</div>
|
||||
</div>
|
||||
<div class='meta'>
|
||||
|
@ -291,6 +296,16 @@
|
|||
<span class='status hide'></span>
|
||||
<span class='timer'></span>
|
||||
</div>
|
||||
{{ #hoverIcon }}
|
||||
<div class='menu-container menu'>
|
||||
<div class='menu-anchor'>
|
||||
<span class='dots-horizontal-icon'></span>
|
||||
<ul class='menu-list'>
|
||||
<li class='reply'>{{ reply }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{{ /hoverIcon }}
|
||||
</div>
|
||||
</script>
|
||||
<script type='text/x-tmpl-mustache' id='hourglass'>
|
||||
|
|
BIN
fixtures/1000x50-green.jpeg
Normal file
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) {
|
||||
this.messageCollection.add(message, { merge: true });
|
||||
const model = this.messageCollection.add(message, { merge: true });
|
||||
this.processQuotes(this.messageCollection);
|
||||
return model;
|
||||
},
|
||||
|
||||
onMessageError() {
|
||||
|
@ -610,7 +611,60 @@
|
|||
return _.without(this.get('members'), me);
|
||||
},
|
||||
|
||||
sendMessage(body, attachments) {
|
||||
blobToArrayBuffer(blob) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fileReader = new FileReader();
|
||||
|
||||
fileReader.onload = e => resolve(e.target.result);
|
||||
fileReader.onerror = reject;
|
||||
fileReader.onabort = reject;
|
||||
|
||||
fileReader.readAsArrayBuffer(blob);
|
||||
});
|
||||
},
|
||||
|
||||
async makeThumbnailAttachment(attachment) {
|
||||
const attachmentWithData = await loadAttachmentData(attachment);
|
||||
const { data, contentType } = attachmentWithData;
|
||||
const objectUrl = this.makeObjectUrl(data, contentType);
|
||||
const thumbnail = await Whisper.FileInputView.makeThumbnail(128, objectUrl);
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
|
||||
const arrayBuffer = await this.blobToArrayBuffer(thumbnail);
|
||||
const finalContentType = 'image/png';
|
||||
const finalObjectUrl = this.makeObjectUrl(arrayBuffer, finalContentType);
|
||||
|
||||
return {
|
||||
data: arrayBuffer,
|
||||
objectUrl: finalObjectUrl,
|
||||
contentType: finalContentType,
|
||||
};
|
||||
},
|
||||
|
||||
async makeQuote(quotedMessage) {
|
||||
const contact = quotedMessage.getContact();
|
||||
const attachments = quotedMessage.get('attachments');
|
||||
|
||||
return {
|
||||
author: contact.id,
|
||||
id: quotedMessage.get('sent_at'),
|
||||
text: quotedMessage.get('body'),
|
||||
attachments: await Promise.all((attachments || []).map(async (attachment) => {
|
||||
const { contentType } = attachment;
|
||||
const willMakeThumbnail = MIME.isImage(contentType);
|
||||
|
||||
return {
|
||||
contentType,
|
||||
fileName: attachment.fileName,
|
||||
thumbnail: willMakeThumbnail
|
||||
? await this.makeThumbnailAttachment(attachment)
|
||||
: null,
|
||||
};
|
||||
})),
|
||||
};
|
||||
},
|
||||
|
||||
sendMessage(body, attachments, quote) {
|
||||
this.queueJob(async () => {
|
||||
const now = Date.now();
|
||||
|
||||
|
@ -625,13 +679,15 @@
|
|||
type: 'outgoing',
|
||||
body,
|
||||
conversationId: this.id,
|
||||
quote,
|
||||
attachments,
|
||||
sent_at: now,
|
||||
received_at: now,
|
||||
expireTimer: this.get('expireTimer'),
|
||||
recipients: this.getRecipients(),
|
||||
});
|
||||
const message = this.messageCollection.add(messageWithSchema);
|
||||
const message = this.addSingleMessage(messageWithSchema);
|
||||
|
||||
if (this.isPrivate()) {
|
||||
message.set({ destination: this.id });
|
||||
}
|
||||
|
@ -666,6 +722,7 @@
|
|||
this.get('id'),
|
||||
body,
|
||||
attachmentsWithData,
|
||||
quote,
|
||||
now,
|
||||
this.get('expireTimer'),
|
||||
profileKey
|
||||
|
@ -1113,18 +1170,8 @@
|
|||
|
||||
const queryFirst = queryAttachments[0];
|
||||
try {
|
||||
queryMessage.attachments[0] = await loadAttachmentData(queryFirst);
|
||||
|
||||
// Note: it would be nice to take the full-size image and downsample it into
|
||||
// a true thumbnail here.
|
||||
queryMessage.updateImageUrl();
|
||||
|
||||
// We need to differentiate between messages we load from database and those
|
||||
// already in memory. More cleanup needs to happen on messages from the database
|
||||
// because they aren't tracked any other way.
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
message.quotedMessageFromDatabase = queryMessage;
|
||||
|
||||
message.quoteThumbnail = await this.makeThumbnailAttachment(queryFirst);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(
|
||||
|
@ -1155,12 +1202,9 @@
|
|||
|
||||
try {
|
||||
const queryFirst = quotedAttachments[0];
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
quotedMessage.attributes.attachments[0] = await loadAttachmentData(queryFirst);
|
||||
|
||||
// Note: it would be nice to take the full-size image and downsample it into
|
||||
// a true thumbnail here.
|
||||
quotedMessage.updateImageUrl();
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
message.quoteThumbnail = await this.makeThumbnailAttachment(queryFirst);
|
||||
} catch (error) {
|
||||
console.log(
|
||||
'Problem loading attachment data for quoted message',
|
||||
|
|
|
@ -31,6 +31,8 @@
|
|||
this.on('change:expireTimer', this.setToExpire);
|
||||
this.on('unload', this.unload);
|
||||
this.setToExpire();
|
||||
|
||||
this.VOICE_FLAG = textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE;
|
||||
},
|
||||
idForLogging() {
|
||||
return `${this.get('source')}.${this.get('sourceDevice')} ${this.get('sent_at')}`;
|
||||
|
@ -186,6 +188,16 @@
|
|||
if (this.quotedMessage) {
|
||||
this.quotedMessage = null;
|
||||
}
|
||||
const quote = this.get('quote');
|
||||
const attachments = (quote && quote.attachments) || [];
|
||||
attachments.forEach((attachment) => {
|
||||
if (attachment.thumbnail && attachment.thumbnail.objectUrl) {
|
||||
URL.revokeObjectURL(attachment.thumbnail.objectUrl);
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
attachment.thumbnail.objectUrl = null;
|
||||
}
|
||||
});
|
||||
|
||||
this.revokeImageUrl();
|
||||
},
|
||||
revokeImageUrl() {
|
||||
|
@ -200,6 +212,77 @@
|
|||
}
|
||||
return this.imageUrl;
|
||||
},
|
||||
getQuoteObjectUrl() {
|
||||
const thumbnail = this.quoteThumbnail;
|
||||
if (!thumbnail || !thumbnail.objectUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return thumbnail.objectUrl;
|
||||
},
|
||||
getQuoteContact() {
|
||||
const quote = this.get('quote');
|
||||
if (!quote) {
|
||||
return null;
|
||||
}
|
||||
const { author } = quote;
|
||||
if (!author) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ConversationController.get(author);
|
||||
},
|
||||
processAttachment(attachment, externalObjectUrl) {
|
||||
const { thumbnail } = attachment;
|
||||
const objectUrl = (thumbnail && thumbnail.objectUrl) || externalObjectUrl;
|
||||
|
||||
const thumbnailWithObjectUrl = !objectUrl
|
||||
? null
|
||||
: Object.assign({}, attachment.thumbnail || {}, {
|
||||
objectUrl,
|
||||
});
|
||||
|
||||
return Object.assign({}, attachment, {
|
||||
// eslint-disable-next-line no-bitwise
|
||||
isVoiceMessage: Boolean(attachment.flags & this.VOICE_FLAG),
|
||||
thumbnail: thumbnailWithObjectUrl,
|
||||
});
|
||||
},
|
||||
getPropsForQuote() {
|
||||
const quote = this.get('quote');
|
||||
if (!quote) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const objectUrl = this.getQuoteObjectUrl();
|
||||
const OUR_NUMBER = textsecure.storage.user.getNumber();
|
||||
const { author } = quote;
|
||||
const contact = this.getQuoteContact();
|
||||
|
||||
const authorTitle = contact ? contact.getTitle() : author;
|
||||
const authorProfileName = contact ? contact.getProfileName() : null;
|
||||
const authorColor = contact ? contact.getColor() : 'grey';
|
||||
const isFromMe = contact ? contact.id === OUR_NUMBER : false;
|
||||
const isIncoming = this.isIncoming();
|
||||
const onClick = () => {
|
||||
const { quotedMessage } = this;
|
||||
if (quotedMessage) {
|
||||
this.trigger('scroll-to-message', { id: quotedMessage.id });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
attachments: (quote.attachments || []).map(attachment =>
|
||||
this.processAttachment(attachment, objectUrl)),
|
||||
authorColor,
|
||||
authorProfileName,
|
||||
authorTitle,
|
||||
isFromMe,
|
||||
isIncoming,
|
||||
onClick: this.quotedMessage ? onClick : null,
|
||||
text: quote.text,
|
||||
};
|
||||
},
|
||||
getConversation() {
|
||||
// This needs to be an unsafe call, because this method is called during
|
||||
// initial module setup. We may be in the middle of the initial fetch to
|
||||
|
@ -207,12 +290,12 @@
|
|||
return ConversationController.getUnsafe(this.get('conversationId'));
|
||||
},
|
||||
getExpirationTimerUpdateSource() {
|
||||
if (this.isExpirationTimerUpdate()) {
|
||||
const conversationId = this.get('expirationTimerUpdate').source;
|
||||
return ConversationController.getOrCreate(conversationId, 'private');
|
||||
if (!this.isExpirationTimerUpdate()) {
|
||||
throw new Error('Message is not a timer update!');
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
const conversationId = this.get('expirationTimerUpdate').source;
|
||||
return ConversationController.getOrCreate(conversationId, 'private');
|
||||
},
|
||||
getContact() {
|
||||
let conversationId = this.get('source');
|
||||
|
|
|
@ -457,17 +457,18 @@ async function readAttachment(dir, attachment, name, options) {
|
|||
options = options || {};
|
||||
const { key } = options;
|
||||
|
||||
const anonymousName = _sanitizeFileName(name);
|
||||
const targetPath = path.join(dir, anonymousName);
|
||||
const sanitizedName = _sanitizeFileName(name);
|
||||
const targetPath = path.join(dir, sanitizedName);
|
||||
|
||||
if (!fs.existsSync(targetPath)) {
|
||||
console.log(`Warning: attachment ${anonymousName} not found`);
|
||||
console.log(`Warning: attachment ${sanitizedName} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await readFileAsArrayBuffer(targetPath);
|
||||
|
||||
const isEncrypted = !_.isUndefined(key);
|
||||
|
||||
if (isEncrypted) {
|
||||
attachment.data = await crypto.decryptSymmetric(key, data);
|
||||
} else {
|
||||
|
@ -475,6 +476,65 @@ async function readAttachment(dir, attachment, name, options) {
|
|||
}
|
||||
}
|
||||
|
||||
async function writeThumbnail(attachment, options) {
|
||||
const {
|
||||
dir,
|
||||
message,
|
||||
index,
|
||||
key,
|
||||
newKey,
|
||||
} = options;
|
||||
const filename = `${_getAnonymousAttachmentFileName(message, index)}-thumbnail`;
|
||||
const target = path.join(dir, filename);
|
||||
const { thumbnail } = attachment;
|
||||
|
||||
if (!thumbnail || !thumbnail.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
await writeEncryptedAttachment(target, thumbnail.data, {
|
||||
key,
|
||||
newKey,
|
||||
filename,
|
||||
dir,
|
||||
});
|
||||
}
|
||||
|
||||
async function writeThumbnails(rawQuotedAttachments, options) {
|
||||
const { name } = options;
|
||||
|
||||
const { loadAttachmentData } = Signal.Migrations;
|
||||
const promises = rawQuotedAttachments.map(async (attachment) => {
|
||||
if (!attachment || !attachment.thumbnail || !attachment.thumbnail.path) {
|
||||
return attachment;
|
||||
}
|
||||
|
||||
return Object.assign(
|
||||
{},
|
||||
attachment,
|
||||
{ thumbnail: await loadAttachmentData(attachment.thumbnail) }
|
||||
);
|
||||
});
|
||||
|
||||
const attachments = await Promise.all(promises);
|
||||
try {
|
||||
await Promise.all(_.map(
|
||||
attachments,
|
||||
(attachment, index) => writeThumbnail(attachment, Object.assign({}, options, {
|
||||
index,
|
||||
}))
|
||||
));
|
||||
} catch (error) {
|
||||
console.log(
|
||||
'writeThumbnails: error exporting conversation',
|
||||
name,
|
||||
':',
|
||||
error && error.stack ? error.stack : error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeAttachment(attachment, options) {
|
||||
const {
|
||||
dir,
|
||||
|
@ -485,26 +545,16 @@ async function writeAttachment(attachment, options) {
|
|||
} = options;
|
||||
const filename = _getAnonymousAttachmentFileName(message, index);
|
||||
const target = path.join(dir, filename);
|
||||
if (fs.existsSync(target)) {
|
||||
if (newKey) {
|
||||
console.log(`Deleting attachment ${filename}; key has changed`);
|
||||
fs.unlinkSync(target);
|
||||
} else {
|
||||
console.log(`Skipping attachment ${filename}; already exists`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!Attachment.hasData(attachment)) {
|
||||
throw new TypeError("'attachment.data' is required");
|
||||
}
|
||||
|
||||
const ciphertext = await crypto.encryptSymmetric(key, attachment.data);
|
||||
|
||||
const writer = await createFileAndWriter(dir, filename);
|
||||
const stream = createOutputStream(writer);
|
||||
stream.write(Buffer.from(ciphertext));
|
||||
await stream.close();
|
||||
await writeEncryptedAttachment(target, attachment.data, {
|
||||
key,
|
||||
newKey,
|
||||
filename,
|
||||
dir,
|
||||
});
|
||||
}
|
||||
|
||||
async function writeAttachments(rawAttachments, options) {
|
||||
|
@ -531,6 +581,32 @@ async function writeAttachments(rawAttachments, options) {
|
|||
}
|
||||
}
|
||||
|
||||
async function writeEncryptedAttachment(target, data, options = {}) {
|
||||
const {
|
||||
key,
|
||||
newKey,
|
||||
filename,
|
||||
dir,
|
||||
} = options;
|
||||
|
||||
if (fs.existsSync(target)) {
|
||||
if (newKey) {
|
||||
console.log(`Deleting attachment ${filename}; key has changed`);
|
||||
fs.unlinkSync(target);
|
||||
} else {
|
||||
console.log(`Skipping attachment ${filename}; already exists`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const ciphertext = await crypto.encryptSymmetric(key, data);
|
||||
|
||||
const writer = await createFileAndWriter(dir, filename);
|
||||
const stream = createOutputStream(writer);
|
||||
stream.write(Buffer.from(ciphertext));
|
||||
await stream.close();
|
||||
}
|
||||
|
||||
function _sanitizeFileName(filename) {
|
||||
return filename.toString().replace(/[^a-z0-9.,+()'#\- ]/gi, '_');
|
||||
}
|
||||
|
@ -542,6 +618,7 @@ async function exportConversation(db, conversation, options) {
|
|||
dir,
|
||||
attachmentsDir,
|
||||
key,
|
||||
newKey,
|
||||
} = options;
|
||||
if (!name) {
|
||||
throw new Error('Need a name!');
|
||||
|
@ -610,6 +687,7 @@ async function exportConversation(db, conversation, options) {
|
|||
}
|
||||
|
||||
// eliminate attachment data from the JSON, since it will go to disk
|
||||
// Note: this is for legacy messages only, which stored attachment data in the db
|
||||
message.attachments = _.map(
|
||||
attachments,
|
||||
attachment => _.omit(attachment, ['data'])
|
||||
|
@ -629,18 +707,34 @@ async function exportConversation(db, conversation, options) {
|
|||
const jsonString = JSON.stringify(stringify(message));
|
||||
stream.write(jsonString);
|
||||
|
||||
console.log({ backupMessage: message });
|
||||
if (attachments && attachments.length > 0) {
|
||||
const exportAttachments = () => writeAttachments(attachments, {
|
||||
dir: attachmentsDir,
|
||||
name,
|
||||
message,
|
||||
key,
|
||||
newKey,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line more/no-then
|
||||
promiseChain = promiseChain.then(exportAttachments);
|
||||
}
|
||||
|
||||
const quoteThumbnails = message.quote && message.quote.attachments;
|
||||
if (quoteThumbnails && quoteThumbnails.length > 0) {
|
||||
const exportQuoteThumbnails = () => writeThumbnails(quoteThumbnails, {
|
||||
dir: attachmentsDir,
|
||||
name,
|
||||
message,
|
||||
key,
|
||||
newKey,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line more/no-then
|
||||
promiseChain = promiseChain.then(exportQuoteThumbnails);
|
||||
}
|
||||
|
||||
count += 1;
|
||||
cursor.continue();
|
||||
} else {
|
||||
|
@ -701,6 +795,7 @@ function exportConversations(db, options) {
|
|||
messagesDir,
|
||||
attachmentsDir,
|
||||
key,
|
||||
newKey,
|
||||
} = options;
|
||||
|
||||
if (!messagesDir) {
|
||||
|
@ -747,6 +842,7 @@ function exportConversations(db, options) {
|
|||
dir,
|
||||
attachmentsDir,
|
||||
key,
|
||||
newKey,
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -808,11 +904,23 @@ function loadAttachments(dir, getName, options) {
|
|||
options = options || {};
|
||||
const { message } = options;
|
||||
|
||||
const promises = _.map(message.attachments, (attachment, index) => {
|
||||
const attachmentPromises = _.map(message.attachments, (attachment, index) => {
|
||||
const name = getName(message, index, attachment);
|
||||
return readAttachment(dir, attachment, name, options);
|
||||
});
|
||||
return Promise.all(promises);
|
||||
|
||||
const quoteAttachments = message.quote && message.quote.attachments;
|
||||
const thumbnailPromises = _.map(quoteAttachments, (attachment, index) => {
|
||||
const thumbnail = attachment && attachment.thumbnail;
|
||||
if (!thumbnail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const name = `${getName(message, index, thumbnail)}-thumbnail`;
|
||||
return readAttachment(dir, thumbnail, name, options);
|
||||
});
|
||||
|
||||
return Promise.all(attachmentPromises.concat(thumbnailPromises));
|
||||
}
|
||||
|
||||
function saveMessage(db, message) {
|
||||
|
@ -922,7 +1030,11 @@ async function importConversation(db, dir, options) {
|
|||
return false;
|
||||
}
|
||||
|
||||
if (message.attachments && message.attachments.length) {
|
||||
const hasAttachments = message.attachments && message.attachments.length;
|
||||
const hasQuotedAttachments = message.quote && message.quote.attachments &&
|
||||
message.quote.attachments.length > 0;
|
||||
|
||||
if (hasAttachments || hasQuotedAttachments) {
|
||||
const importMessage = async () => {
|
||||
const getName = attachmentsDir
|
||||
? _getAnonymousAttachmentFileName
|
||||
|
|
|
@ -18,6 +18,9 @@ const PRIVATE = 'private';
|
|||
// - Attachments: Sanitize Unicode order override characters.
|
||||
// Version 3
|
||||
// - Attachments: Write attachment data to disk and store relative path to it.
|
||||
// Version 4
|
||||
// - Quotes: Write thumbnail data to disk and store relative path to it.
|
||||
|
||||
|
||||
const INITIAL_SCHEMA_VERSION = 0;
|
||||
|
||||
|
@ -158,13 +161,19 @@ exports._mapQuotedAttachments = upgradeAttachment => async (message, context) =>
|
|||
}
|
||||
|
||||
const upgradeWithContext = async (attachment) => {
|
||||
if (!attachment || !attachment.thumbnail) {
|
||||
const { thumbnail } = attachment;
|
||||
if (!thumbnail) {
|
||||
return attachment;
|
||||
}
|
||||
|
||||
const thumbnail = await upgradeAttachment(attachment.thumbnail, context);
|
||||
if (!thumbnail.data) {
|
||||
console.log('Quoted attachment did not have thumbnail data; removing it');
|
||||
return omit(attachment, ['thumbnail']);
|
||||
}
|
||||
|
||||
const upgradedThumbnail = await upgradeAttachment(thumbnail, context);
|
||||
return Object.assign({}, attachment, {
|
||||
thumbnail,
|
||||
thumbnail: upgradedThumbnail,
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -243,9 +252,12 @@ exports.createAttachmentDataWriter = (writeExistingAttachmentData) => {
|
|||
|
||||
const message = exports.initializeSchemaVersion(rawMessage);
|
||||
|
||||
const { attachments } = message;
|
||||
const hasAttachments = attachments && attachments.length > 0;
|
||||
if (!hasAttachments) {
|
||||
const { attachments, quote } = message;
|
||||
const hasFilesToWrite =
|
||||
(quote && quote.attachments && quote.attachments.length > 0) ||
|
||||
(attachments && attachments.length > 0);
|
||||
|
||||
if (!hasFilesToWrite) {
|
||||
return message;
|
||||
}
|
||||
|
||||
|
@ -256,7 +268,7 @@ exports.createAttachmentDataWriter = (writeExistingAttachmentData) => {
|
|||
return message;
|
||||
}
|
||||
|
||||
attachments.forEach((attachment) => {
|
||||
(attachments || []).forEach((attachment) => {
|
||||
if (!Attachment.hasData(attachment)) {
|
||||
throw new TypeError("'attachment.data' is required during message import");
|
||||
}
|
||||
|
@ -266,13 +278,36 @@ exports.createAttachmentDataWriter = (writeExistingAttachmentData) => {
|
|||
}
|
||||
});
|
||||
|
||||
const messageWithoutAttachmentData = Object.assign({}, message, {
|
||||
attachments: await Promise.all(attachments.map(async (attachment) => {
|
||||
await writeExistingAttachmentData(attachment);
|
||||
return omit(attachment, ['data']);
|
||||
})),
|
||||
const writeThumbnails = exports._mapQuotedAttachments(async (thumbnail) => {
|
||||
const { data, path } = thumbnail;
|
||||
|
||||
// we want to be bulletproof to thumbnails without data
|
||||
if (!data || !path) {
|
||||
console.log(
|
||||
'Thumbnail had neither data nor path.',
|
||||
'id:',
|
||||
message.id,
|
||||
'source:',
|
||||
message.source
|
||||
);
|
||||
return thumbnail;
|
||||
}
|
||||
|
||||
await writeExistingAttachmentData(thumbnail);
|
||||
return omit(thumbnail, ['data']);
|
||||
});
|
||||
|
||||
const messageWithoutAttachmentData = Object.assign(
|
||||
{},
|
||||
await writeThumbnails(message),
|
||||
{
|
||||
attachments: await Promise.all((attachments || []).map(async (attachment) => {
|
||||
await writeExistingAttachmentData(attachment);
|
||||
return omit(attachment, ['data']);
|
||||
})),
|
||||
}
|
||||
);
|
||||
|
||||
return messageWithoutAttachmentData;
|
||||
};
|
||||
};
|
||||
|
|
|
@ -22,6 +22,41 @@
|
|||
template: i18n('unsupportedFileType')
|
||||
});
|
||||
|
||||
function makeThumbnail(size, objectUrl) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
var img = document.createElement('img');
|
||||
img.onerror = reject;
|
||||
img.onload = function () {
|
||||
// using components/blueimp-load-image
|
||||
|
||||
// first, make the correct size
|
||||
var canvas = loadImage.scale(img, {
|
||||
canvas: true,
|
||||
cover: true,
|
||||
maxWidth: size,
|
||||
maxHeight: size,
|
||||
minWidth: size,
|
||||
minHeight: size,
|
||||
});
|
||||
|
||||
// then crop
|
||||
canvas = loadImage.scale(canvas, {
|
||||
canvas: true,
|
||||
crop: true,
|
||||
maxWidth: size,
|
||||
maxHeight: size,
|
||||
minWidth: size,
|
||||
minHeight: size,
|
||||
});
|
||||
|
||||
var blob = window.dataURLToBlobSync(canvas.toDataURL('image/png'));
|
||||
|
||||
resolve(blob);
|
||||
};
|
||||
img.src = objectUrl;
|
||||
});
|
||||
}
|
||||
|
||||
Whisper.FileInputView = Backbone.View.extend({
|
||||
tagName: 'span',
|
||||
className: 'file-input',
|
||||
|
@ -239,29 +274,11 @@
|
|||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise(function(resolve, reject) {
|
||||
var url = URL.createObjectURL(file);
|
||||
var img = document.createElement('img');
|
||||
img.onerror = reject;
|
||||
img.onload = function () {
|
||||
URL.revokeObjectURL(url);
|
||||
// loadImage.scale -> components/blueimp-load-image
|
||||
// scale, then crop.
|
||||
var canvas = loadImage.scale(img, {
|
||||
canvas: true, maxWidth: size, maxHeight: size,
|
||||
cover: true, minWidth: size, minHeight: size
|
||||
});
|
||||
canvas = loadImage.scale(canvas, {
|
||||
canvas: true, maxWidth: size, maxHeight: size,
|
||||
crop: true, minWidth: size, minHeight: size
|
||||
});
|
||||
|
||||
var blob = window.dataURLToBlobSync(canvas.toDataURL('image/png'));
|
||||
|
||||
resolve(blob);
|
||||
};
|
||||
img.src = url;
|
||||
}).then(this.readFile);
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
return makeThumbnail(256, file).then(function(arrayBuffer) {
|
||||
URL.revokeObjectURL(url);
|
||||
return this.readFile(arrayBuffer);
|
||||
});
|
||||
},
|
||||
|
||||
// File -> Promise Attachment
|
||||
|
@ -348,4 +365,6 @@
|
|||
}
|
||||
}
|
||||
});
|
||||
|
||||
Whisper.FileInputView.makeThumbnail = makeThumbnail;
|
||||
})();
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
/* global _: false */
|
||||
/* global emoji_util: false */
|
||||
/* global Mustache: false */
|
||||
/* global ConversationController: false */
|
||||
/* global $: false */
|
||||
|
||||
// eslint-disable-next-line func-names
|
||||
(function () {
|
||||
|
@ -45,8 +45,10 @@
|
|||
tagName: 'span',
|
||||
className: 'some-failed',
|
||||
templateName: 'some-failed',
|
||||
render_attributes: {
|
||||
someFailed: i18n('someRecipientsFailed'),
|
||||
render_attributes() {
|
||||
return {
|
||||
someFailed: i18n('someRecipientsFailed'),
|
||||
};
|
||||
},
|
||||
});
|
||||
const TimerView = Whisper.View.extend({
|
||||
|
@ -215,6 +217,8 @@
|
|||
'click .status': 'select',
|
||||
'click .some-failed': 'select',
|
||||
'click .error-message': 'select',
|
||||
'click .menu-container': 'showMenu',
|
||||
'click .menu-list .reply': 'onReply',
|
||||
},
|
||||
retryMessage() {
|
||||
const retrys = _.filter(
|
||||
|
@ -225,6 +229,26 @@
|
|||
this.model.resend(number);
|
||||
});
|
||||
},
|
||||
showMenu(e) {
|
||||
if (this.menuVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.menuVisible = true;
|
||||
e.stopPropagation();
|
||||
|
||||
this.$('.menu-list').show();
|
||||
$(document).one('click', () => {
|
||||
this.hideMenu();
|
||||
});
|
||||
},
|
||||
hideMenu() {
|
||||
this.menuVisible = false;
|
||||
this.$('.menu-list').hide();
|
||||
},
|
||||
onReply() {
|
||||
this.model.trigger('reply', this.model);
|
||||
},
|
||||
onExpired() {
|
||||
this.$el.addClass('expired');
|
||||
this.$el.find('.bubble').one('webkitAnimationEnd animationend', (e) => {
|
||||
|
@ -252,8 +276,8 @@
|
|||
if (this.timeStampView) {
|
||||
this.timeStampView.remove();
|
||||
}
|
||||
if (this.replyView) {
|
||||
this.replyView.remove();
|
||||
if (this.quoteView) {
|
||||
this.quoteView.remove();
|
||||
}
|
||||
|
||||
// NOTE: We have to do this in the background (`then` instead of `await`)
|
||||
|
@ -314,7 +338,6 @@
|
|||
renderErrors() {
|
||||
const errors = this.model.get('errors');
|
||||
|
||||
|
||||
this.$('.error-icon-container').remove();
|
||||
if (this.errorIconView) {
|
||||
this.errorIconView.remove();
|
||||
|
@ -326,6 +349,12 @@
|
|||
}
|
||||
this.errorIconView = new ErrorIconView({ model: errors[0] });
|
||||
this.errorIconView.render().$el.appendTo(this.$('.bubble'));
|
||||
} else if (!this.hasContents()) {
|
||||
const el = this.$('.content');
|
||||
if (!el || el.length === 0) {
|
||||
this.$('.inner-bubble').append("<div class='content'></div>");
|
||||
}
|
||||
this.$('.content').text(i18n('noContents')).addClass('error-message');
|
||||
}
|
||||
|
||||
this.$('.meta .hasRetry').remove();
|
||||
|
@ -365,86 +394,28 @@
|
|||
this.timerView.setElement(this.$('.timer'));
|
||||
this.timerView.update();
|
||||
},
|
||||
getQuoteObjectUrl() {
|
||||
const fromDB = this.model.quotedMessageFromDatabase;
|
||||
if (fromDB && fromDB.imageUrl) {
|
||||
return fromDB.imageUrl;
|
||||
}
|
||||
|
||||
const inMemory = this.model.quotedMessage;
|
||||
if (inMemory && inMemory.imageUrl) {
|
||||
return inMemory.imageUrl;
|
||||
}
|
||||
|
||||
const thumbnail = this.model.quoteThumbnail;
|
||||
if (thumbnail && thumbnail.objectUrl) {
|
||||
return thumbnail.objectUrl;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
renderQuote() {
|
||||
const quote = this.model.get('quote');
|
||||
if (!quote) {
|
||||
const props = this.model.getPropsForQuote();
|
||||
if (!props) {
|
||||
return;
|
||||
}
|
||||
|
||||
const VOICE_FLAG = textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE;
|
||||
const objectUrl = this.getQuoteObjectUrl();
|
||||
|
||||
|
||||
function processAttachment(attachment) {
|
||||
const thumbnail = !objectUrl
|
||||
? null
|
||||
: Object.assign({}, attachment.thumbnail || {}, {
|
||||
objectUrl,
|
||||
});
|
||||
|
||||
return Object.assign({}, attachment, {
|
||||
// eslint-disable-next-line no-bitwise
|
||||
isVoiceMessage: Boolean(attachment.flags & VOICE_FLAG),
|
||||
thumbnail,
|
||||
});
|
||||
}
|
||||
|
||||
const OUR_NUMBER = textsecure.storage.user.getNumber();
|
||||
const { author } = quote;
|
||||
const contact = ConversationController.get(author);
|
||||
|
||||
const authorTitle = contact ? contact.getTitle() : author;
|
||||
const authorProfileName = contact ? contact.getProfileName() : null;
|
||||
const authorColor = contact ? contact.getColor() : 'grey';
|
||||
const isFromMe = contact ? contact.id === OUR_NUMBER : false;
|
||||
const isIncoming = this.model.isIncoming();
|
||||
const onClick = () => {
|
||||
const { quotedMessage } = this.model;
|
||||
if (quotedMessage) {
|
||||
this.model.trigger('scroll-to-message', { id: quotedMessage.id });
|
||||
}
|
||||
};
|
||||
|
||||
const props = {
|
||||
attachments: (quote.attachments || []).map(processAttachment),
|
||||
authorColor,
|
||||
authorProfileName,
|
||||
authorTitle,
|
||||
isFromMe,
|
||||
isIncoming,
|
||||
onClick: this.model.quotedMessage ? onClick : null,
|
||||
text: quote.text,
|
||||
};
|
||||
|
||||
if (this.replyView) {
|
||||
this.replyView = null;
|
||||
const contact = this.model.getQuoteContact();
|
||||
if (this.quoteView) {
|
||||
this.quoteView.remove();
|
||||
this.quoteView = null;
|
||||
} else if (contact) {
|
||||
this.listenTo(contact, 'change:color', this.renderQuote);
|
||||
}
|
||||
|
||||
this.replyView = new Whisper.ReactWrapperView({
|
||||
el: this.$('.quote-wrapper'),
|
||||
this.quoteView = new Whisper.ReactWrapperView({
|
||||
className: 'quote-wrapper',
|
||||
Component: window.Signal.Components.Quote,
|
||||
props,
|
||||
props: Object.assign({}, props, {
|
||||
text: props.text ? window.emoji.signalReplace(props.text) : null,
|
||||
}),
|
||||
});
|
||||
this.$('.inner-bubble').prepend(this.quoteView.el);
|
||||
},
|
||||
isImageWithoutCaption() {
|
||||
const attachments = this.model.get('attachments');
|
||||
|
@ -464,15 +435,44 @@
|
|||
|
||||
return false;
|
||||
},
|
||||
hasContents() {
|
||||
const attachments = this.model.get('attachments');
|
||||
const hasAttachments = attachments && attachments.length > 0;
|
||||
|
||||
return this.hasTextContents() || hasAttachments;
|
||||
},
|
||||
hasTextContents() {
|
||||
const body = this.model.get('body');
|
||||
const isGroupUpdate = this.model.isGroupUpdate();
|
||||
const isEndSession = this.model.isEndSession();
|
||||
|
||||
const errors = this.model.get('errors');
|
||||
const hasErrors = errors && errors.length > 0;
|
||||
const errorsCanBeContents = this.model.isIncoming() && hasErrors;
|
||||
|
||||
return body || isGroupUpdate || isEndSession || errorsCanBeContents;
|
||||
},
|
||||
render() {
|
||||
const contact = this.model.isIncoming() ? this.model.getContact() : null;
|
||||
const attachments = this.model.get('attachments');
|
||||
|
||||
// TODO: used for the feature flag below
|
||||
// const hasErrors = errors && errors.length > 0;
|
||||
const hasAttachments = attachments && attachments.length > 0;
|
||||
const hasBody = this.hasTextContents();
|
||||
|
||||
this.$el.html(Mustache.render(_.result(this, 'template', ''), {
|
||||
message: this.model.get('body'),
|
||||
hasBody,
|
||||
timestamp: this.model.get('sent_at'),
|
||||
sender: (contact && contact.getTitle()) || '',
|
||||
avatar: (contact && contact.getAvatar()),
|
||||
profileName: (contact && contact.getProfileName()),
|
||||
innerBubbleClasses: this.isImageWithoutCaption() ? '' : 'with-tail',
|
||||
// TODO: Turn this on when we're ready to enable sending quoted replies
|
||||
hoverIcon: false, // !hasErrors,
|
||||
hasAttachments,
|
||||
reply: i18n('replyToMessage'),
|
||||
}, this.render_partials()));
|
||||
this.timeStampView.setElement(this.$('.timestamp'));
|
||||
this.timeStampView.update();
|
||||
|
|
|
@ -17,6 +17,8 @@
|
|||
const { Component, props, onClose } = options;
|
||||
this.render();
|
||||
|
||||
this.tagName = options.tagName;
|
||||
this.className = options.className;
|
||||
this.Component = Component;
|
||||
this.onClose = onClose;
|
||||
|
||||
|
|
|
@ -17,6 +17,7 @@ function stringToArrayBuffer(str) {
|
|||
function Message(options) {
|
||||
this.body = options.body;
|
||||
this.attachments = options.attachments || [];
|
||||
this.quote = options.quote;
|
||||
this.group = options.group;
|
||||
this.flags = options.flags;
|
||||
this.recipients = options.recipients;
|
||||
|
@ -93,6 +94,28 @@ Message.prototype = {
|
|||
proto.group.id = stringToArrayBuffer(this.group.id);
|
||||
proto.group.type = this.group.type
|
||||
}
|
||||
if (this.quote) {
|
||||
var QuotedAttachment = textsecure.protobuf.DataMessage.Quote.QuotedAttachment;
|
||||
var Quote = textsecure.protobuf.DataMessage.Quote;
|
||||
|
||||
proto.quote = new Quote();
|
||||
var quote = proto.quote;
|
||||
|
||||
quote.id = this.quote.id;
|
||||
quote.author = this.quote.author;
|
||||
quote.text = this.quote.text;
|
||||
quote.attachments = (this.quote.attachments || []).map(function(attachment) {
|
||||
var quotedAttachment = new QuotedAttachment();
|
||||
|
||||
quotedAttachment.contentType = attachment.contentType;
|
||||
quotedAttachment.fileName = attachment.fileName;
|
||||
if (attachment.attachmentPointer) {
|
||||
quotedAttachment.thumbnail = attachment.attachmentPointer;
|
||||
}
|
||||
|
||||
return quotedAttachment;
|
||||
});
|
||||
}
|
||||
if (this.expireTimer) {
|
||||
proto.expireTimer = this.expireTimer;
|
||||
}
|
||||
|
@ -223,7 +246,7 @@ MessageSender.prototype = {
|
|||
}.bind(this));
|
||||
},
|
||||
|
||||
uploadMedia: function(message) {
|
||||
uploadAttachments: function(message) {
|
||||
return Promise.all(
|
||||
message.attachments.map(this.makeAttachmentPointer.bind(this))
|
||||
).then(function(attachmentPointers) {
|
||||
|
@ -237,9 +260,38 @@ MessageSender.prototype = {
|
|||
});
|
||||
},
|
||||
|
||||
uploadThumbnails: function(message) {
|
||||
var makePointer = this.makeAttachmentPointer.bind(this);
|
||||
var quote = message.quote;
|
||||
|
||||
if (!quote || !quote.attachments || quote.attachments.length === 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return Promise.all(quote.attachments.map(function(attachment) {
|
||||
const thumbnail = attachment.thumbnail;
|
||||
if (!thumbnail) {
|
||||
return;
|
||||
}
|
||||
|
||||
return makePointer(thumbnail).then(function(pointer) {
|
||||
attachment.attachmentPointer = pointer;
|
||||
});
|
||||
})).catch(function(error) {
|
||||
if (error instanceof Error && error.name === 'HTTPError') {
|
||||
throw new textsecure.MessageError(message, error);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
sendMessage: function(attrs) {
|
||||
var message = new Message(attrs);
|
||||
return this.uploadMedia(message).then(function() {
|
||||
return Promise.all([
|
||||
this.uploadAttachments(message),
|
||||
this.uploadThumbnails(message),
|
||||
]).then(function() {
|
||||
return new Promise(function(resolve, reject) {
|
||||
this.sendMessageProto(
|
||||
message.timestamp,
|
||||
|
@ -494,12 +546,13 @@ MessageSender.prototype = {
|
|||
}.bind(this));
|
||||
},
|
||||
|
||||
sendMessageToNumber: function(number, messageText, attachments, timestamp, expireTimer, profileKey) {
|
||||
sendMessageToNumber: function(number, messageText, attachments, quote, timestamp, expireTimer, profileKey) {
|
||||
return this.sendMessage({
|
||||
recipients : [number],
|
||||
body : messageText,
|
||||
timestamp : timestamp,
|
||||
attachments : attachments,
|
||||
quote : quote,
|
||||
needsSync : true,
|
||||
expireTimer : expireTimer,
|
||||
profileKey : profileKey
|
||||
|
@ -558,7 +611,7 @@ MessageSender.prototype = {
|
|||
]);
|
||||
},
|
||||
|
||||
sendMessageToGroup: function(groupId, messageText, attachments, timestamp, expireTimer, profileKey) {
|
||||
sendMessageToGroup: function(groupId, messageText, attachments, quote, timestamp, expireTimer, profileKey) {
|
||||
return textsecure.storage.groups.getNumbers(groupId).then(function(numbers) {
|
||||
if (numbers === undefined)
|
||||
return Promise.reject(new Error("Unknown Group"));
|
||||
|
@ -574,6 +627,7 @@ MessageSender.prototype = {
|
|||
body : messageText,
|
||||
timestamp : timestamp,
|
||||
attachments : attachments,
|
||||
quote : quote,
|
||||
needsSync : true,
|
||||
expireTimer : expireTimer,
|
||||
profileKey : profileKey,
|
||||
|
|
|
@ -114,6 +114,7 @@
|
|||
"eslint-plugin-mocha": "^4.12.1",
|
||||
"eslint-plugin-more": "^0.3.1",
|
||||
"extract-zip": "^1.6.6",
|
||||
"glob": "^7.1.2",
|
||||
"grunt": "^1.0.1",
|
||||
"grunt-cli": "^1.2.0",
|
||||
"grunt-contrib-concat": "^1.0.1",
|
||||
|
|
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
|
||||
// /tmp mounted as noexec on Linux.
|
||||
require('./js/spell_check');
|
||||
|
||||
if (window.config.environment === 'test') {
|
||||
/* eslint-disable global-require, import/no-extraneous-dependencies */
|
||||
window.test = {
|
||||
glob: require('glob'),
|
||||
fse: require('fs-extra'),
|
||||
tmp: require('tmp'),
|
||||
path: require('path'),
|
||||
basePath: __dirname,
|
||||
attachmentsPath,
|
||||
};
|
||||
/* eslint-enable global-require, import/no-extraneous-dependencies */
|
||||
}
|
||||
|
|
|
@ -163,7 +163,7 @@ module.exports = {
|
|||
},
|
||||
{
|
||||
// To test handling of attachments, we need arraybuffers in memory
|
||||
test: /\.(gif|mp3|mp4|txt)$/,
|
||||
test: /\.(gif|mp3|mp4|txt|jpg|jpeg|png)$/,
|
||||
loader: 'arraybuffer-loader',
|
||||
},
|
||||
],
|
||||
|
|
|
@ -353,6 +353,45 @@ li.entry .error-icon-container {
|
|||
|
||||
&:hover .error-message { display: inline-block; }
|
||||
}
|
||||
li.entry .menu-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: calc(100% + 5px);
|
||||
height: 100%;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.menu-anchor {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dots-horizontal-icon {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
li.entry:hover .dots-horizontal-icon {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
li.entry.outgoing .menu-container {
|
||||
left: auto;
|
||||
right: calc(100% + 5px);
|
||||
}
|
||||
|
||||
.incoming .menu-list {
|
||||
left: 0;
|
||||
right: auto;
|
||||
}
|
||||
|
||||
|
||||
.error-icon {
|
||||
display: inline-block;
|
||||
|
@ -362,6 +401,18 @@ li.entry .error-icon-container {
|
|||
@include color-svg('../images/warning.svg', red);
|
||||
}
|
||||
|
||||
.dots-horizontal-icon {
|
||||
display: inline-block;
|
||||
width: $error-icon-size;
|
||||
height: $error-icon-size;
|
||||
position: relative;
|
||||
@include color-svg('../images/dots-horizontal.svg', gray);
|
||||
|
||||
&:hover {
|
||||
@include color-svg('../images/dots-horizontal.svg', black);
|
||||
}
|
||||
}
|
||||
|
||||
.group {
|
||||
li.entry .unregistered-user-error {
|
||||
display: none;
|
||||
|
@ -379,10 +430,6 @@ li.entry .error-icon-container {
|
|||
display: none;
|
||||
}
|
||||
|
||||
.message-list .outgoing .bubble .quote, .private .message-list .incoming .bubble .quote {
|
||||
margin-top: $android-bubble-quote-padding - $android-bubble-padding-vertical;
|
||||
}
|
||||
|
||||
.sender {
|
||||
font-size: smaller;
|
||||
opacity: 0.8;
|
||||
|
@ -439,8 +486,6 @@ span.status {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
.bubble {
|
||||
position: relative;
|
||||
left: -2px;
|
||||
|
@ -456,142 +501,7 @@ span.status {
|
|||
max-width: calc(100% - 45px - #{$error-icon-size}); // avatar size + padding + error-icon size
|
||||
}
|
||||
|
||||
.quote {
|
||||
@include message-replies-colors;
|
||||
@include twenty-percent-colors;
|
||||
|
||||
&.no-click {
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
overflow: hidden;
|
||||
|
||||
border-radius: 2px;
|
||||
background-color: #eee;
|
||||
position: relative;
|
||||
|
||||
margin-right: $android-bubble-quote-padding - $android-bubble-padding-horizontal;
|
||||
margin-left: $android-bubble-quote-padding - $android-bubble-padding-horizontal;
|
||||
margin-bottom: 0.5em;
|
||||
|
||||
// Accent color border:
|
||||
border-left-width: 3px;
|
||||
border-left-style: solid;
|
||||
|
||||
.primary {
|
||||
flex-grow: 1;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
|
||||
// Will turn on in the iOS theme. This extra element is necessary because the iOS
|
||||
// theme requires text that isn't used at all in the Android Theme
|
||||
.ios-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.author {
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.3em;
|
||||
@include text-colors;
|
||||
|
||||
.profile-name {
|
||||
font-size: smaller;
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
white-space: pre-wrap;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
|
||||
// Note: -webkit-line-clamp doesn't work for RTL text, and it forces you to use
|
||||
// ... as the truncation indicator. That's not a solution that works well for
|
||||
// all languages. More resources:
|
||||
// - http://hackingui.com/front-end/a-pure-css-solution-for-multiline-text-truncation/
|
||||
// - https://medium.com/mofed/css-line-clamp-the-good-the-bad-and-the-straight-up-broken-865413f16e5
|
||||
}
|
||||
|
||||
.type-label {
|
||||
font-style: italic;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.filename-label {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
flex: initial;
|
||||
min-width: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
position: relative;
|
||||
|
||||
.circle-background {
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
right: 6px;
|
||||
top: 6px;
|
||||
bottom: 6px;
|
||||
|
||||
border-radius: 50%;
|
||||
@include avatar-colors;
|
||||
&.white {
|
||||
background-color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
top: 12px;
|
||||
bottom: 12px;
|
||||
|
||||
&.file {
|
||||
@include color-svg('../images/file.svg', white);
|
||||
}
|
||||
&.image {
|
||||
@include color-svg('../images/image.svg', white);
|
||||
}
|
||||
&.microphone {
|
||||
@include color-svg('../images/microphone.svg', white);
|
||||
}
|
||||
&.play {
|
||||
@include color-svg('../images/play.svg', white);
|
||||
}
|
||||
|
||||
@include avatar-colors;
|
||||
}
|
||||
|
||||
.inner {
|
||||
position: relative;
|
||||
|
||||
height: 48px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
margin-top: 0.5em;
|
||||
white-space: pre-wrap;
|
||||
|
||||
a {
|
||||
|
@ -599,6 +509,13 @@ span.status {
|
|||
}
|
||||
}
|
||||
|
||||
.attachments + .content {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
.quote-wrapper + .content {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
@ -650,13 +567,6 @@ span.status {
|
|||
.avatar, .bubble {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
.quote {
|
||||
background-color: rgba(white, 0.6);
|
||||
border-left-color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.outgoing {
|
||||
|
@ -799,6 +709,199 @@ span.status {
|
|||
}
|
||||
}
|
||||
|
||||
.quoted-message {
|
||||
@include message-replies-colors;
|
||||
@include twenty-percent-colors;
|
||||
|
||||
&.no-click {
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
overflow: hidden;
|
||||
|
||||
border-radius: 2px;
|
||||
background-color: #eee;
|
||||
position: relative;
|
||||
|
||||
margin-right: $android-bubble-quote-padding - $android-bubble-padding-horizontal;
|
||||
margin-left: $android-bubble-quote-padding - $android-bubble-padding-horizontal;
|
||||
margin-bottom: 0.5em;
|
||||
|
||||
// Accent color border:
|
||||
border-left-width: 3px;
|
||||
border-left-style: solid;
|
||||
|
||||
.primary {
|
||||
flex-grow: 1;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
|
||||
// Will turn on in the iOS theme. This extra element is necessary because the iOS
|
||||
// theme requires text that isn't used at all in the Android Theme
|
||||
.ios-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.author {
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.3em;
|
||||
@include text-colors;
|
||||
|
||||
.profile-name {
|
||||
font-size: smaller;
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
white-space: pre-wrap;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
|
||||
// Note: -webkit-line-clamp doesn't work for RTL text, and it forces you to use
|
||||
// ... as the truncation indicator. That's not a solution that works well for
|
||||
// all languages. More resources:
|
||||
// - http://hackingui.com/front-end/a-pure-css-solution-for-multiline-text-truncation/
|
||||
// - https://medium.com/mofed/css-line-clamp-the-good-the-bad-and-the-straight-up-broken-865413f16e5
|
||||
}
|
||||
|
||||
.type-label {
|
||||
font-style: italic;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.filename-label {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.close-container {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
|
||||
background-color: rgba(255, 255, 255, 0.75);
|
||||
border-radius: 50%;
|
||||
|
||||
.close-button {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@include color-svg('../images/x.svg', $grey);
|
||||
}
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
flex: initial;
|
||||
min-width: 50px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
position: relative;
|
||||
|
||||
.circle-background {
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
right: 6px;
|
||||
top: 6px;
|
||||
bottom: 6px;
|
||||
|
||||
border-radius: 50%;
|
||||
@include avatar-colors;
|
||||
&.white {
|
||||
background-color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
top: 12px;
|
||||
bottom: 12px;
|
||||
|
||||
&.file {
|
||||
@include color-svg('../images/file.svg', white);
|
||||
}
|
||||
&.image {
|
||||
@include color-svg('../images/image.svg', white);
|
||||
}
|
||||
&.microphone {
|
||||
@include color-svg('../images/microphone.svg', white);
|
||||
}
|
||||
&.play {
|
||||
@include color-svg('../images/play.svg', white);
|
||||
}
|
||||
&.movie {
|
||||
@include color-svg('../images/movie.svg', white);
|
||||
}
|
||||
|
||||
@include avatar-colors;
|
||||
}
|
||||
|
||||
.inner {
|
||||
position: relative;
|
||||
|
||||
height: 50px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
object-fit: cover;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We only add margin if there's no 'sender' element beforehand, which is only possible
|
||||
// on incoming messages, and only in groups (when we're not in a .private conversation).
|
||||
.outgoing .quoted-message,
|
||||
.private .incoming .quoted-message {
|
||||
margin-top: $android-bubble-quote-padding - $android-bubble-padding-vertical;
|
||||
}
|
||||
|
||||
.bottom-bar .quoted-message {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// We need to use the wrapper because the conversation view calculates the height of all
|
||||
// things in the composition area. A margin on an inner div won't be included in that
|
||||
// height calculation.
|
||||
.bottom-bar .quote-wrapper {
|
||||
margin-right: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.send .quote-wrapper {
|
||||
margin-left: 46px;
|
||||
margin-top: 5px;
|
||||
margin-right: 75px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.incoming .quoted-message {
|
||||
background-color: rgba(white, 0.6);
|
||||
border-top: none;
|
||||
border-bottom: none;
|
||||
border-right: none;
|
||||
border-left-color: white;
|
||||
}
|
||||
|
||||
|
||||
.message-list,
|
||||
.message-container {
|
||||
.avatar {
|
||||
|
|
|
@ -108,122 +108,182 @@ $ios-border-color: rgba(0,0,0,0.1);
|
|||
|
||||
.message-container,
|
||||
.message-list {
|
||||
.quote {
|
||||
border-top-left-radius: 15px;
|
||||
border-top-right-radius: 15px;
|
||||
border-bottom-left-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
.bubble .content {
|
||||
margin-top: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
// Not ideal, but necessary to override the specificity of the android theme color
|
||||
// classes used in conversations.scss
|
||||
background-color: white !important;
|
||||
border: 1px solid $grey_l1_5 !important;
|
||||
border-bottom: none !important;
|
||||
.quoted-message {
|
||||
// Not ideal, but necessary to override the specificity of the android theme color
|
||||
// classes used in conversations.scss
|
||||
background-color: white !important;
|
||||
border: none !important;
|
||||
border-radius: 0;
|
||||
|
||||
margin-top: 0px !important;
|
||||
margin-bottom: 0px;
|
||||
margin-left: 0px;
|
||||
margin-right: 0px;
|
||||
margin-top: 0px !important;
|
||||
margin-bottom: 0px;
|
||||
margin-left: 0px;
|
||||
margin-right: 0px;
|
||||
|
||||
.primary {
|
||||
padding: 10px;
|
||||
|
||||
.text,
|
||||
.filename-label,
|
||||
.type-label {
|
||||
border-left: 2px solid $grey_l1;
|
||||
padding: 5px;
|
||||
padding-left: 7px;
|
||||
// Without this smaller bottom padding, text beyond four lines still shows up!
|
||||
padding-bottom: 2px;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.author {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ios-label {
|
||||
display: block;
|
||||
color: $grey_l1;
|
||||
font-size: smaller;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
height: 61px;
|
||||
width: 61px;
|
||||
min-width: 61px;
|
||||
|
||||
.circle-background {
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
top: 12px;
|
||||
bottom: 12px;
|
||||
|
||||
background-color: $blue !important;
|
||||
}
|
||||
|
||||
.icon {
|
||||
left: 18px;
|
||||
right: 18px;
|
||||
top: 18px;
|
||||
bottom: 18px;
|
||||
|
||||
background-color: white !important;
|
||||
}
|
||||
|
||||
.inner {
|
||||
padding: 12px;
|
||||
height: 61px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.close-container {
|
||||
flex: initial;
|
||||
min-width: 32px;
|
||||
width: 32px;
|
||||
height: 50px;
|
||||
position: relative;
|
||||
|
||||
top: auto;
|
||||
right: auto;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
-webkit-mask: none;
|
||||
background: none;
|
||||
|
||||
.close-button {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
|
||||
@include color-svg('../images/close-circle.svg', $grey_l4);
|
||||
}
|
||||
}
|
||||
|
||||
.from-me {
|
||||
.primary {
|
||||
padding: 10px;
|
||||
|
||||
.text,
|
||||
.filename-label,
|
||||
.type-label {
|
||||
border-left: 2px solid $grey_l1;
|
||||
padding: 5px;
|
||||
padding-left: 7px;
|
||||
// Without this smaller bottom padding, text beyond four lines still shows up!
|
||||
padding-bottom: 2px;
|
||||
color: black;
|
||||
border-left: 2px solid $blue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.author {
|
||||
display: none;
|
||||
}
|
||||
.incoming .quoted-message {
|
||||
border-bottom: 1px solid lightgray !important;
|
||||
}
|
||||
|
||||
.quoted-message.from-me .primary {
|
||||
.text,
|
||||
.filename-label,
|
||||
.type-label {
|
||||
border-left: 2px solid $blue;
|
||||
}
|
||||
}
|
||||
|
||||
.outgoing .quoted-message,
|
||||
.private .incoming .quoted-message {
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.outgoing .quoted-message .icon-container .circle-background {
|
||||
background-color: lightgray !important;
|
||||
}
|
||||
|
||||
.bottom-bar {
|
||||
.quote-wrapper {
|
||||
margin-right: 0px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.quoted-message {
|
||||
background: none !important;
|
||||
|
||||
.primary {
|
||||
padding: 0px;
|
||||
|
||||
.ios-label {
|
||||
display: block;
|
||||
color: $grey_l1;
|
||||
font-size: smaller;
|
||||
margin-bottom: 3px;
|
||||
color: $grey_l4;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
height: 61px;
|
||||
width: 61px;
|
||||
min-width: 61px;
|
||||
height: 50px;
|
||||
width: 50px;
|
||||
min-width: 50px;
|
||||
|
||||
.circle-background {
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
top: 12px;
|
||||
bottom: 12px;
|
||||
left: 6px;
|
||||
right: 6px;
|
||||
top: 6px;
|
||||
bottom: 6px;
|
||||
|
||||
background-color: $blue !important;
|
||||
}
|
||||
|
||||
.icon {
|
||||
left: 18px;
|
||||
right: 18px;
|
||||
top: 18px;
|
||||
bottom: 18px;
|
||||
|
||||
background-color: white !important;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
top: 12px;
|
||||
bottom: 12px;
|
||||
}
|
||||
|
||||
.inner {
|
||||
padding: 12px;
|
||||
height: 61px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0px;
|
||||
height: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
.from-me {
|
||||
.primary {
|
||||
.text,
|
||||
.filename-label,
|
||||
.type-label {
|
||||
border-left: 2px solid $blue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.incoming {
|
||||
.bubble {
|
||||
.quote {
|
||||
border-left: none;
|
||||
border: none !important;
|
||||
border-bottom: 1px solid lightgray !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bubble {
|
||||
.quote.from-me {
|
||||
.primary {
|
||||
.text,
|
||||
.filename-label,
|
||||
.type-label {
|
||||
border-left: 2px solid $blue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.outgoing .bubble .quote,
|
||||
.private .message-list .incoming .bubble .quote {
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.outgoing .bubble .quote .icon-container .circle-background {
|
||||
background-color: lightgray !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -286,38 +346,53 @@ $ios-border-color: rgba(0,0,0,0.1);
|
|||
clear: both;
|
||||
}
|
||||
|
||||
.outgoing .with-tail.tail-wrapper {
|
||||
.outgoing .tail-wrapper {
|
||||
float: right;
|
||||
|
||||
.inner-bubble {
|
||||
.attachments {
|
||||
background-color: $blue;
|
||||
}
|
||||
.content {
|
||||
background-color: $blue;
|
||||
}
|
||||
max-width: 100%;
|
||||
&, .body, a {
|
||||
@include invert-text-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.incoming .with-tail.tail-wrapper {
|
||||
.incoming .tail-wrapper {
|
||||
float: left;
|
||||
|
||||
&:before {
|
||||
left: -1px;
|
||||
background-color: white;
|
||||
.inner-bubble {
|
||||
max-width: 100%;
|
||||
}
|
||||
&:after {
|
||||
left: -6px;
|
||||
}
|
||||
|
||||
// The browser doesn't always clip the border-radius properly, so we can get a
|
||||
// partial-pixel halo effect. Sadly, it is still needed because a quote can force the
|
||||
// bubble wider than an attached image, and we need a background color on the bottom
|
||||
// section if the image doesn't cover it all.
|
||||
.outgoing .tail-wrapper {
|
||||
.attachments {
|
||||
background-color: $blue;
|
||||
}
|
||||
.content {
|
||||
background-color: $blue;
|
||||
}
|
||||
|
||||
&, .body, a {
|
||||
@include invert-text-color;
|
||||
}
|
||||
}
|
||||
|
||||
.incoming .tail-wrapper {
|
||||
&.with-tail {
|
||||
&:before {
|
||||
left: -1px;
|
||||
background-color: white;
|
||||
}
|
||||
&:after {
|
||||
left: -6px;
|
||||
}
|
||||
}
|
||||
|
||||
.inner-bubble {
|
||||
background-color: white;
|
||||
color: black;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -338,6 +413,10 @@ $ios-border-color: rgba(0,0,0,0.1);
|
|||
a {
|
||||
border-radius: 15px;
|
||||
}
|
||||
img {
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
.hourglass {
|
||||
@include hourglass(#999);
|
||||
|
|
|
@ -225,13 +225,17 @@ $text-dark_l2: darken($text-dark, 30%);
|
|||
}
|
||||
}
|
||||
|
||||
.outgoing .bubble .quote .icon-container .icon {
|
||||
background-color: black;
|
||||
&.play.with-image {
|
||||
background-color: $text-dark;
|
||||
.outgoing .quoted-message {
|
||||
background: rgba(255, 255, 255, 0.38);
|
||||
|
||||
.icon-container .icon {
|
||||
background-color: black;
|
||||
&.play.with-image {
|
||||
background-color: $text-dark;
|
||||
}
|
||||
}
|
||||
}
|
||||
.incoming .bubble .quote {
|
||||
.incoming .quoted-message {
|
||||
border-left-color: $text-dark;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
|
||||
|
|
|
@ -3,6 +3,11 @@
|
|||
module.exports = {
|
||||
env: {
|
||||
mocha: true,
|
||||
browser: true,
|
||||
},
|
||||
|
||||
parserOptions: {
|
||||
sourceType: 'script',
|
||||
},
|
||||
|
||||
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';
|
||||
|
||||
describe('Backup', function() {
|
||||
describe('_sanitizeFileName', function() {
|
||||
it('leaves a basic string alone', function() {
|
||||
var initial = 'Hello, how are you #5 (\'fine\' + great).jpg';
|
||||
var expected = initial;
|
||||
describe('Backup', () => {
|
||||
describe('_sanitizeFileName', () => {
|
||||
it('leaves a basic string alone', () => {
|
||||
const initial = 'Hello, how are you #5 (\'fine\' + great).jpg';
|
||||
const expected = initial;
|
||||
assert.strictEqual(Signal.Backup._sanitizeFileName(initial), expected);
|
||||
});
|
||||
|
||||
it('replaces all unknown characters', function() {
|
||||
var initial = '!@$%^&*=';
|
||||
var expected = '________';
|
||||
it('replaces all unknown characters', () => {
|
||||
const initial = '!@$%^&*=';
|
||||
const expected = '________';
|
||||
assert.strictEqual(Signal.Backup._sanitizeFileName(initial), expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_trimFileName', function() {
|
||||
it('handles a file with no extension', function() {
|
||||
var initial = '0123456789012345678901234567890123456789';
|
||||
var expected = '012345678901234567890123456789';
|
||||
describe('_trimFileName', () => {
|
||||
it('handles a file with no extension', () => {
|
||||
const initial = '0123456789012345678901234567890123456789';
|
||||
const expected = '012345678901234567890123456789';
|
||||
assert.strictEqual(Signal.Backup._trimFileName(initial), expected);
|
||||
});
|
||||
|
||||
it('handles a file with a long extension', function() {
|
||||
var initial = '0123456789012345678901234567890123456789.01234567890123456789';
|
||||
var expected = '012345678901234567890123456789';
|
||||
it('handles a file with a long extension', () => {
|
||||
const initial = '0123456789012345678901234567890123456789.01234567890123456789';
|
||||
const expected = '012345678901234567890123456789';
|
||||
assert.strictEqual(Signal.Backup._trimFileName(initial), expected);
|
||||
});
|
||||
|
||||
it('handles a file with a normal extension', function() {
|
||||
var initial = '01234567890123456789012345678901234567890123456789.jpg';
|
||||
var expected = '012345678901234567890123.jpg';
|
||||
it('handles a file with a normal extension', () => {
|
||||
const initial = '01234567890123456789012345678901234567890123456789.jpg';
|
||||
const expected = '012345678901234567890123.jpg';
|
||||
assert.strictEqual(Signal.Backup._trimFileName(initial), expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_getExportAttachmentFileName', function() {
|
||||
it('uses original filename if attachment has one', function() {
|
||||
var message = {
|
||||
describe('_getExportAttachmentFileName', () => {
|
||||
it('uses original filename if attachment has one', () => {
|
||||
const message = {
|
||||
body: 'something',
|
||||
};
|
||||
var index = 0;
|
||||
var attachment = {
|
||||
fileName: 'blah.jpg'
|
||||
const index = 0;
|
||||
const attachment = {
|
||||
fileName: 'blah.jpg',
|
||||
};
|
||||
var expected = 'blah.jpg';
|
||||
const expected = 'blah.jpg';
|
||||
|
||||
var actual = Signal.Backup._getExportAttachmentFileName(
|
||||
const actual = Signal.Backup._getExportAttachmentFileName(
|
||||
message,
|
||||
index,
|
||||
attachment
|
||||
|
@ -54,36 +60,17 @@ describe('Backup', function() {
|
|||
assert.strictEqual(actual, expected);
|
||||
});
|
||||
|
||||
it('uses attachment id if no filename', function() {
|
||||
var message = {
|
||||
it('uses attachment id if no filename', () => {
|
||||
const message = {
|
||||
body: 'something',
|
||||
};
|
||||
var index = 0;
|
||||
var attachment = {
|
||||
id: '123'
|
||||
};
|
||||
var expected = '123';
|
||||
|
||||
var actual = Signal.Backup._getExportAttachmentFileName(
|
||||
message,
|
||||
index,
|
||||
attachment
|
||||
);
|
||||
assert.strictEqual(actual, expected);
|
||||
});
|
||||
|
||||
it('uses filename and contentType if available', function() {
|
||||
var message = {
|
||||
body: 'something',
|
||||
};
|
||||
var index = 0;
|
||||
var attachment = {
|
||||
const index = 0;
|
||||
const attachment = {
|
||||
id: '123',
|
||||
contentType: 'image/jpeg'
|
||||
};
|
||||
var expected = '123.jpeg';
|
||||
const expected = '123';
|
||||
|
||||
var actual = Signal.Backup._getExportAttachmentFileName(
|
||||
const actual = Signal.Backup._getExportAttachmentFileName(
|
||||
message,
|
||||
index,
|
||||
attachment
|
||||
|
@ -91,18 +78,37 @@ describe('Backup', function() {
|
|||
assert.strictEqual(actual, expected);
|
||||
});
|
||||
|
||||
it('handles strange contentType', function() {
|
||||
var message = {
|
||||
it('uses filename and contentType if available', () => {
|
||||
const message = {
|
||||
body: 'something',
|
||||
};
|
||||
var index = 0;
|
||||
var attachment = {
|
||||
const index = 0;
|
||||
const attachment = {
|
||||
id: '123',
|
||||
contentType: 'something'
|
||||
contentType: 'image/jpeg',
|
||||
};
|
||||
var expected = '123.something';
|
||||
const expected = '123.jpeg';
|
||||
|
||||
var actual = Signal.Backup._getExportAttachmentFileName(
|
||||
const actual = Signal.Backup._getExportAttachmentFileName(
|
||||
message,
|
||||
index,
|
||||
attachment
|
||||
);
|
||||
assert.strictEqual(actual, expected);
|
||||
});
|
||||
|
||||
it('handles strange contentType', () => {
|
||||
const message = {
|
||||
body: 'something',
|
||||
};
|
||||
const index = 0;
|
||||
const attachment = {
|
||||
id: '123',
|
||||
contentType: 'something',
|
||||
};
|
||||
const expected = '123.something';
|
||||
|
||||
const actual = Signal.Backup._getExportAttachmentFileName(
|
||||
message,
|
||||
index,
|
||||
attachment
|
||||
|
@ -111,19 +117,19 @@ describe('Backup', function() {
|
|||
});
|
||||
});
|
||||
|
||||
describe('_getAnonymousAttachmentFileName', function() {
|
||||
it('uses message id', function() {
|
||||
var message = {
|
||||
describe('_getAnonymousAttachmentFileName', () => {
|
||||
it('uses message id', () => {
|
||||
const message = {
|
||||
id: 'id-45',
|
||||
body: 'something',
|
||||
};
|
||||
var index = 0;
|
||||
var attachment = {
|
||||
fileName: 'blah.jpg'
|
||||
const index = 0;
|
||||
const attachment = {
|
||||
fileName: 'blah.jpg',
|
||||
};
|
||||
var expected = 'id-45';
|
||||
const expected = 'id-45';
|
||||
|
||||
var actual = Signal.Backup._getAnonymousAttachmentFileName(
|
||||
const actual = Signal.Backup._getAnonymousAttachmentFileName(
|
||||
message,
|
||||
index,
|
||||
attachment
|
||||
|
@ -131,18 +137,18 @@ describe('Backup', function() {
|
|||
assert.strictEqual(actual, expected);
|
||||
});
|
||||
|
||||
it('appends index if it is above zero', function() {
|
||||
var message = {
|
||||
it('appends index if it is above zero', () => {
|
||||
const message = {
|
||||
id: 'id-45',
|
||||
body: 'something',
|
||||
};
|
||||
var index = 1;
|
||||
var attachment = {
|
||||
fileName: 'blah.jpg'
|
||||
const index = 1;
|
||||
const attachment = {
|
||||
fileName: 'blah.jpg',
|
||||
};
|
||||
var expected = 'id-45-1';
|
||||
const expected = 'id-45-1';
|
||||
|
||||
var actual = Signal.Backup._getAnonymousAttachmentFileName(
|
||||
const actual = Signal.Backup._getAnonymousAttachmentFileName(
|
||||
message,
|
||||
index,
|
||||
attachment
|
||||
|
@ -151,64 +157,343 @@ describe('Backup', function() {
|
|||
});
|
||||
});
|
||||
|
||||
describe('_getConversationDirName', function() {
|
||||
it('uses name if available', function() {
|
||||
var conversation = {
|
||||
describe('_getConversationDirName', () => {
|
||||
it('uses name if available', () => {
|
||||
const conversation = {
|
||||
active_at: 123,
|
||||
name: '0123456789012345678901234567890123456789',
|
||||
id: 'id'
|
||||
id: 'id',
|
||||
};
|
||||
var expected = '123 (012345678901234567890123456789 id)';
|
||||
const expected = '123 (012345678901234567890123456789 id)';
|
||||
assert.strictEqual(Signal.Backup._getConversationDirName(conversation), expected);
|
||||
});
|
||||
|
||||
it('uses just id if name is not available', function() {
|
||||
var conversation = {
|
||||
it('uses just id if name is not available', () => {
|
||||
const conversation = {
|
||||
active_at: 123,
|
||||
id: 'id'
|
||||
id: 'id',
|
||||
};
|
||||
var expected = '123 (id)';
|
||||
const expected = '123 (id)';
|
||||
assert.strictEqual(Signal.Backup._getConversationDirName(conversation), expected);
|
||||
});
|
||||
|
||||
it('uses inactive for missing active_at', function() {
|
||||
var conversation = {
|
||||
it('uses inactive for missing active_at', () => {
|
||||
const conversation = {
|
||||
name: 'name',
|
||||
id: 'id'
|
||||
id: 'id',
|
||||
};
|
||||
var expected = 'inactive (name id)';
|
||||
const expected = 'inactive (name id)';
|
||||
assert.strictEqual(Signal.Backup._getConversationDirName(conversation), expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_getConversationLoggingName', function() {
|
||||
it('uses plain id if conversation is private', function() {
|
||||
var conversation = {
|
||||
describe('_getConversationLoggingName', () => {
|
||||
it('uses plain id if conversation is private', () => {
|
||||
const conversation = {
|
||||
active_at: 123,
|
||||
id: 'id',
|
||||
type: 'private'
|
||||
type: 'private',
|
||||
};
|
||||
var expected = '123 (id)';
|
||||
assert.strictEqual(Signal.Backup._getConversationLoggingName(conversation), expected);
|
||||
const expected = '123 (id)';
|
||||
assert.strictEqual(
|
||||
Signal.Backup._getConversationLoggingName(conversation),
|
||||
expected
|
||||
);
|
||||
});
|
||||
|
||||
it('uses just id if name is not available', function() {
|
||||
var conversation = {
|
||||
it('uses just id if name is not available', () => {
|
||||
const conversation = {
|
||||
active_at: 123,
|
||||
id: 'groupId',
|
||||
type: 'group'
|
||||
type: 'group',
|
||||
};
|
||||
var expected = '123 ([REDACTED_GROUP]pId)';
|
||||
assert.strictEqual(Signal.Backup._getConversationLoggingName(conversation), expected);
|
||||
const expected = '123 ([REDACTED_GROUP]pId)';
|
||||
assert.strictEqual(
|
||||
Signal.Backup._getConversationLoggingName(conversation),
|
||||
expected
|
||||
);
|
||||
});
|
||||
|
||||
it('uses inactive for missing active_at', function() {
|
||||
var conversation = {
|
||||
it('uses inactive for missing active_at', () => {
|
||||
const conversation = {
|
||||
id: 'id',
|
||||
type: 'private'
|
||||
type: 'private',
|
||||
};
|
||||
var expected = 'inactive (id)';
|
||||
assert.strictEqual(Signal.Backup._getConversationLoggingName(conversation), expected);
|
||||
const expected = 'inactive (id)';
|
||||
assert.strictEqual(
|
||||
Signal.Backup._getConversationLoggingName(conversation),
|
||||
expected
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('end-to-end', () => {
|
||||
it('exports then imports to produce the same data we started with', async () => {
|
||||
const {
|
||||
attachmentsPath,
|
||||
fse,
|
||||
glob,
|
||||
path,
|
||||
tmp,
|
||||
} = window.test;
|
||||
const {
|
||||
upgradeMessageSchema,
|
||||
loadAttachmentData,
|
||||
} = window.Signal.Migrations;
|
||||
|
||||
const key = new Uint8Array([
|
||||
1, 3, 4, 5, 6, 7, 8, 11,
|
||||
23, 34, 1, 34, 3, 5, 45, 45,
|
||||
1, 3, 4, 5, 6, 7, 8, 11,
|
||||
23, 34, 1, 34, 3, 5, 45, 45,
|
||||
]);
|
||||
const attachmentsPattern = path.join(attachmentsPath, '**');
|
||||
|
||||
const OUR_NUMBER = '+12025550000';
|
||||
const CONTACT_ONE_NUMBER = '+12025550001';
|
||||
|
||||
async function wrappedLoadAttachment(attachment) {
|
||||
return _.omit(await loadAttachmentData(attachment), ['path']);
|
||||
}
|
||||
|
||||
async function clearAllData() {
|
||||
await textsecure.storage.protocol.removeAllData();
|
||||
await fse.emptyDir(attachmentsPath);
|
||||
}
|
||||
|
||||
function removeId(model) {
|
||||
return _.omit(model, ['id']);
|
||||
}
|
||||
|
||||
// We want to know which paths have two slashes, since that tells us which files
|
||||
// in the attachment fan-out are files vs. directories.
|
||||
const TWO_SLASHES = /[^/]*\/[^/]*\/[^/]*/;
|
||||
// On windows, attachmentsPath has a normal windows path format (\ separators), but
|
||||
// glob returns only /. We normalize to / separators for our manipulations.
|
||||
const normalizedBase = attachmentsPath.replace(/\\/g, '/');
|
||||
function removeDirs(dirs) {
|
||||
return _.filter(dirs, (fullDir) => {
|
||||
const dir = fullDir.replace(normalizedBase, '');
|
||||
return TWO_SLASHES.test(dir);
|
||||
});
|
||||
}
|
||||
|
||||
function _mapQuotedAttachments(mapper) {
|
||||
return async (message, context) => {
|
||||
if (!message.quote) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const wrappedMapper = async (attachment) => {
|
||||
if (!attachment || !attachment.thumbnail) {
|
||||
return attachment;
|
||||
}
|
||||
|
||||
return Object.assign({}, attachment, {
|
||||
thumbnail: await mapper(attachment.thumbnail, context),
|
||||
});
|
||||
};
|
||||
|
||||
const quotedAttachments = (message.quote && message.quote.attachments) || [];
|
||||
|
||||
return Object.assign({}, message, {
|
||||
quote: Object.assign({}, message.quote, {
|
||||
attachments: await Promise.all(quotedAttachments.map(wrappedMapper)),
|
||||
}),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
async function loadAllFilesFromDisk(message) {
|
||||
const loadThumbnails = _mapQuotedAttachments((thumbnail) => {
|
||||
// we want to be bulletproof to thumbnails without data
|
||||
if (!thumbnail.path) {
|
||||
return thumbnail;
|
||||
}
|
||||
|
||||
return wrappedLoadAttachment(thumbnail);
|
||||
});
|
||||
|
||||
const promises = (message.attachments || []).map(attachment =>
|
||||
wrappedLoadAttachment(attachment));
|
||||
|
||||
return Object.assign(
|
||||
{},
|
||||
await loadThumbnails(message),
|
||||
{
|
||||
attachments: await Promise.all(promises),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
let backupDir;
|
||||
try {
|
||||
const ATTACHMENT_COUNT = 2;
|
||||
const MESSAGE_COUNT = 1;
|
||||
const CONVERSATION_COUNT = 1;
|
||||
|
||||
const messageWithAttachments = {
|
||||
conversationId: CONTACT_ONE_NUMBER,
|
||||
body: 'Totally!',
|
||||
source: OUR_NUMBER,
|
||||
received_at: 1524185933350,
|
||||
timestamp: 1524185933350,
|
||||
errors: [],
|
||||
attachments: [{
|
||||
contentType: 'image/gif',
|
||||
fileName: 'sad_cat.gif',
|
||||
data: new Uint8Array([
|
||||
1, 2, 3, 4, 5, 6, 7, 8,
|
||||
1, 2, 3, 4, 5, 6, 7, 8,
|
||||
1, 2, 3, 4, 5, 6, 7, 8,
|
||||
1, 2, 3, 4, 5, 6, 7, 8,
|
||||
]).buffer,
|
||||
}],
|
||||
quote: {
|
||||
text: "Isn't it cute?",
|
||||
author: CONTACT_ONE_NUMBER,
|
||||
id: 12345678,
|
||||
attachments: [{
|
||||
contentType: 'audio/mp3',
|
||||
fileName: 'song.mp3',
|
||||
}, {
|
||||
contentType: 'image/gif',
|
||||
fileName: 'happy_cat.gif',
|
||||
thumbnail: {
|
||||
contentType: 'image/png',
|
||||
data: new Uint8Array([
|
||||
2, 2, 3, 4, 5, 6, 7, 8,
|
||||
1, 2, 3, 4, 5, 6, 7, 8,
|
||||
1, 2, 3, 4, 5, 6, 7, 8,
|
||||
1, 2, 3, 4, 5, 6, 7, 8,
|
||||
]).buffer,
|
||||
},
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
console.log('Backup test: Clear all data');
|
||||
await clearAllData();
|
||||
|
||||
console.log('Backup test: Create models, save to db/disk');
|
||||
const message = await upgradeMessageSchema(messageWithAttachments);
|
||||
console.log({ message });
|
||||
const messageModel = new Whisper.Message(message);
|
||||
await window.wrapDeferred(messageModel.save());
|
||||
|
||||
const conversation = {
|
||||
active_at: 1524185933350,
|
||||
color: 'orange',
|
||||
expireTimer: 0,
|
||||
id: CONTACT_ONE_NUMBER,
|
||||
lastMessage: 'Heyo!',
|
||||
name: 'Someone Somewhere',
|
||||
profileAvatar: {
|
||||
contentType: 'image/jpeg',
|
||||
data: new Uint8Array([
|
||||
3, 2, 3, 4, 5, 6, 7, 8,
|
||||
1, 2, 3, 4, 5, 6, 7, 8,
|
||||
1, 2, 3, 4, 5, 6, 7, 8,
|
||||
1, 2, 3, 4, 5, 6, 7, 8,
|
||||
]).buffer,
|
||||
size: 64,
|
||||
},
|
||||
profileKey: new Uint8Array([
|
||||
4, 2, 3, 4, 5, 6, 7, 8,
|
||||
1, 2, 3, 4, 5, 6, 7, 8,
|
||||
1, 2, 3, 4, 5, 6, 7, 8,
|
||||
1, 2, 3, 4, 5, 6, 7, 8,
|
||||
]).buffer,
|
||||
profileName: 'Someone! 🤔',
|
||||
profileSharing: true,
|
||||
timestamp: 1524185933350,
|
||||
tokens: [
|
||||
'someone somewhere',
|
||||
'someone',
|
||||
'somewhere',
|
||||
'2025550001',
|
||||
'12025550001',
|
||||
],
|
||||
type: 'private',
|
||||
unreadCount: 0,
|
||||
verified: 0,
|
||||
};
|
||||
console.log({ conversation });
|
||||
const conversationModel = new Whisper.Conversation(conversation);
|
||||
await window.wrapDeferred(conversationModel.save());
|
||||
|
||||
console.log('Backup test: Ensure that all attachments were saved to disk');
|
||||
const attachmentFiles = removeDirs(glob.sync(attachmentsPattern));
|
||||
console.log({ attachmentFiles });
|
||||
assert.strictEqual(ATTACHMENT_COUNT, attachmentFiles.length);
|
||||
|
||||
console.log('Backup test: Export!');
|
||||
backupDir = tmp.dirSync().name;
|
||||
console.log({ backupDir });
|
||||
await Signal.Backup.exportToDirectory(backupDir, { key });
|
||||
|
||||
console.log('Backup test: Ensure that messages.zip exists');
|
||||
const zipPath = path.join(backupDir, 'messages.zip');
|
||||
const messageZipExists = fse.existsSync(zipPath);
|
||||
assert.strictEqual(true, messageZipExists);
|
||||
|
||||
console.log('Backup test: Ensure that all attachments made it to backup dir');
|
||||
const backupAttachmentPattern = path.join(backupDir, 'attachments/*');
|
||||
const backupAttachments = glob.sync(backupAttachmentPattern);
|
||||
console.log({ backupAttachments });
|
||||
assert.strictEqual(ATTACHMENT_COUNT, backupAttachments.length);
|
||||
|
||||
console.log('Backup test: Clear all data');
|
||||
await clearAllData();
|
||||
|
||||
console.log('Backup test: Import!');
|
||||
await Signal.Backup.importFromDirectory(backupDir, { key });
|
||||
|
||||
console.log('Backup test: ensure that all attachments were imported');
|
||||
const recreatedAttachmentFiles = removeDirs(glob.sync(attachmentsPattern));
|
||||
console.log({ recreatedAttachmentFiles });
|
||||
assert.strictEqual(ATTACHMENT_COUNT, recreatedAttachmentFiles.length);
|
||||
assert.deepEqual(attachmentFiles, recreatedAttachmentFiles);
|
||||
|
||||
console.log('Backup test: Check messages');
|
||||
const messageCollection = new Whisper.MessageCollection();
|
||||
await window.wrapDeferred(messageCollection.fetch());
|
||||
assert.strictEqual(messageCollection.length, MESSAGE_COUNT);
|
||||
const messageFromDB = removeId(messageCollection.at(0).attributes);
|
||||
console.log({ messageFromDB, message });
|
||||
assert.deepEqual(messageFromDB, message);
|
||||
|
||||
console.log('Backup test: check that all attachments were successfully imported');
|
||||
const messageWithAttachmentsFromDB = await loadAllFilesFromDisk(messageFromDB);
|
||||
console.log({ messageWithAttachmentsFromDB, messageWithAttachments });
|
||||
assert.deepEqual(
|
||||
_.omit(messageWithAttachmentsFromDB, ['schemaVersion']),
|
||||
messageWithAttachments
|
||||
);
|
||||
|
||||
console.log('Backup test: check conversations');
|
||||
const conversationCollection = new Whisper.ConversationCollection();
|
||||
await window.wrapDeferred(conversationCollection.fetch());
|
||||
assert.strictEqual(conversationCollection.length, CONVERSATION_COUNT);
|
||||
|
||||
const conversationFromDB = conversationCollection.at(0).attributes;
|
||||
console.log({ conversationFromDB, conversation });
|
||||
assert.deepEqual(
|
||||
conversationFromDB,
|
||||
_.omit(conversation, ['profileAvatar'])
|
||||
);
|
||||
|
||||
console.log('Backup test: Clear all data');
|
||||
await clearAllData();
|
||||
|
||||
console.log('Backup test: Complete!');
|
||||
} finally {
|
||||
if (backupDir) {
|
||||
console.log({ backupDir });
|
||||
console.log('Deleting', backupDir);
|
||||
await fse.remove(backupDir);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -2684,5 +2684,5 @@ Whisper.Fixtures = (function() {
|
|||
}
|
||||
|
||||
return conversationCollection;
|
||||
})();
|
||||
});
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@ describe("Fixtures", function() {
|
|||
// NetworkStatusView checks this method every five seconds while showing
|
||||
window.getSocketStatus = function() { return WebSocket.OPEN; };
|
||||
|
||||
Whisper.Fixtures.saveAll().then(function() {
|
||||
Whisper.Fixtures().saveAll().then(function() {
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -214,11 +214,16 @@
|
|||
</div>
|
||||
<div class='tail-wrapper {{ innerBubbleClasses }}'>
|
||||
<div class='inner-bubble'>
|
||||
<div class='quote-wrapper'></div>
|
||||
<div class='attachments'></div>
|
||||
<div class='content' dir='auto'>
|
||||
{{ #message }}<div class='body'>{{ message }}</div>{{ /message }}
|
||||
</div>
|
||||
{{ #hasAttachments }}
|
||||
<div class='attachments'></div>
|
||||
{{ /hasAttachments }}
|
||||
{{ #hasBody }}
|
||||
<div class='content' dir='auto'>
|
||||
{{ #message }}
|
||||
<div class='body'>{{ message }}</div>
|
||||
{{ /message }}
|
||||
</div>
|
||||
{{ /hasBody }}
|
||||
</div>
|
||||
</div>
|
||||
<div class='meta'>
|
||||
|
@ -226,6 +231,16 @@
|
|||
<span class='status hide'></span>
|
||||
<span class='timer'></span>
|
||||
</div>
|
||||
{{ #hoverIcon }}
|
||||
<div class='menu-container menu'>
|
||||
<div class='menu-anchor'>
|
||||
<span class='dots-horizontal-icon'></span>
|
||||
<ul class='menu-list'>
|
||||
<li class='reply'>{{ reply }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{{ /hoverIcon }}
|
||||
</div>
|
||||
</script>
|
||||
<script type='text/x-tmpl-mustache' id='hourglass'>
|
||||
|
@ -594,7 +609,7 @@
|
|||
|
||||
<script type="text/javascript" src="../js/chromium.js" data-cover></script>
|
||||
|
||||
<script type='text/javascript' src='../js/views/backbone_wrapper_view.js'></script>
|
||||
<script type='text/javascript' src='../js/views/react_wrapper_view.js'></script>
|
||||
<script type='text/javascript' src='../js/views/whisper_view.js' data-cover></script>
|
||||
<script type='text/javascript' src='../js/views/debug_log_view.js' data-cover></script>
|
||||
<script type='text/javascript' src='../js/views/toast_view.js' data-cover></script>
|
||||
|
|
|
@ -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);
|
||||
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', () => {
|
||||
|
@ -373,6 +410,37 @@ describe('Message', () => {
|
|||
assert.deepEqual(result, message);
|
||||
});
|
||||
|
||||
it('eliminates thumbnails with no data fielkd', async () => {
|
||||
const upgradeAttachment = sinon.stub().throws(new Error("Shouldn't be called"));
|
||||
const upgradeVersion = Message._mapQuotedAttachments(upgradeAttachment);
|
||||
|
||||
const message = {
|
||||
body: 'hey there!',
|
||||
quote: {
|
||||
text: 'hey!',
|
||||
attachments: [{
|
||||
fileName: 'cat.gif',
|
||||
contentType: 'image/gif',
|
||||
thumbnail: {
|
||||
fileName: 'failed to download!',
|
||||
},
|
||||
}],
|
||||
},
|
||||
};
|
||||
const expected = {
|
||||
body: 'hey there!',
|
||||
quote: {
|
||||
text: 'hey!',
|
||||
attachments: [{
|
||||
contentType: 'image/gif',
|
||||
fileName: 'cat.gif',
|
||||
}],
|
||||
},
|
||||
};
|
||||
const result = await upgradeVersion(message);
|
||||
assert.deepEqual(result, expected);
|
||||
});
|
||||
|
||||
it('calls provided async function for each quoted attachment', async () => {
|
||||
const upgradeAttachment = sinon.stub().resolves({
|
||||
path: '/new/path/on/disk',
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
'use strict';
|
||||
|
||||
/* global window: false */
|
||||
|
||||
// Because we aren't hosting the Style Guide in Electron, we can't rely on preload.js
|
||||
|
@ -35,6 +37,14 @@ window.Signal.Migrations = {
|
|||
next();
|
||||
},
|
||||
version: 1,
|
||||
}, {
|
||||
migrate: (transaction, next) => {
|
||||
console.log('migration version 2');
|
||||
const messages = transaction.db.createObjectStore('messages');
|
||||
messages.createIndex('expires_at', 'expireTimer', { unique: false });
|
||||
next();
|
||||
},
|
||||
version: 2,
|
||||
}],
|
||||
loadAttachmentData: attachment => Promise.resolve(attachment),
|
||||
};
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
'use strict';
|
||||
|
||||
/* global window: false */
|
||||
|
||||
// Taken from background.html.
|
||||
|
@ -24,29 +26,44 @@ window.Whisper.View.Templates = {
|
|||
</span>
|
||||
`,
|
||||
message: `
|
||||
{{> avatar }}
|
||||
<div class='bubble {{ avatar.color }}'>
|
||||
<div class='sender' dir='auto'>
|
||||
{{ sender }}
|
||||
{{ #profileName }}
|
||||
<span class='profileName'>{{ profileName }} </span>
|
||||
{{ /profileName }}
|
||||
</div>
|
||||
<div class='tail-wrapper {{ innerBubbleClasses }}'>
|
||||
<div class='inner-bubble'>
|
||||
<div class='quote-wrapper'></div>
|
||||
<div class='attachments'></div>
|
||||
<div class='content' dir='auto'>
|
||||
{{ #message }}<div class='body'>{{ message }}</div>{{ /message }}
|
||||
{{> avatar }}
|
||||
<div class='bubble {{ avatar.color }}'>
|
||||
<div class='sender' dir='auto'>
|
||||
{{ sender }}
|
||||
{{ #profileName }}
|
||||
<span class='profileName'>{{ profileName }} </span>
|
||||
{{ /profileName }}
|
||||
</div>
|
||||
<div class='tail-wrapper {{ innerBubbleClasses }}'>
|
||||
<div class='inner-bubble'>
|
||||
{{ #hasAttachments }}
|
||||
<div class='attachments'></div>
|
||||
{{ /hasAttachments }}
|
||||
{{ #hasBody }}
|
||||
<div class='content' dir='auto'>
|
||||
{{ #message }}
|
||||
<div class='body'>{{ message }}</div>
|
||||
{{ /message }}
|
||||
</div>
|
||||
{{ /hasBody }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class='meta'>
|
||||
<span class='timestamp' data-timestamp={{ timestamp }}></span>
|
||||
<span class='status hide'></span>
|
||||
<span class='timer'></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class='meta'>
|
||||
<span class='timestamp' data-timestamp={{ timestamp }}></span>
|
||||
<span class='status hide'></span>
|
||||
<span class='timer'></span>
|
||||
</div>
|
||||
{{ #hoverIcon }}
|
||||
<div class='menu-container menu'>
|
||||
<div class='menu-anchor'>
|
||||
<span class='dots-horizontal-icon'></span>
|
||||
<ul class='menu-list'>
|
||||
<li class='reply'>{{ reply }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{{ /hoverIcon }}
|
||||
</div>
|
||||
`,
|
||||
hourglass: `
|
||||
<span class='hourglass'><span class='sand'></span></span>
|
||||
|
@ -63,4 +80,11 @@ window.Whisper.View.Templates = {
|
|||
<div class='fileSize'>{{ fileSize }}</div>
|
||||
</div>
|
||||
`,
|
||||
'error-icon': `
|
||||
<span class='error-icon'>
|
||||
</span>
|
||||
{{ #message }}
|
||||
<span class='error-message'>{{message}}</span>
|
||||
{{ /message }}
|
||||
`,
|
||||
};
|
||||
|
|
|
@ -14,6 +14,12 @@ describe('NetworkStatusView', function() {
|
|||
|
||||
after(function() {
|
||||
window.getSocketStatus = oldGetSocketStatus;
|
||||
|
||||
// It turns out that continued calls to window.getSocketStatus happen
|
||||
// because we host NetworkStatusView in three mock interfaces, and the view
|
||||
// checks every N seconds. That results in infinite errors unless there is
|
||||
// something to call.
|
||||
window.getSocketStatus = function() { return WebSocket.OPEN; };
|
||||
});
|
||||
/* END stubbing globals */
|
||||
|
||||
|
|
|
@ -40,7 +40,7 @@ const View = Whisper.MessageView;
|
|||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
body: 'How are you doing this fine day?',
|
||||
sent_at: Date.now() - 18000,
|
||||
sent_at: Date.now() - 200000,
|
||||
});
|
||||
const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
|
||||
source: '+12025550003',
|
||||
|
@ -59,6 +59,322 @@ const View = Whisper.MessageView;
|
|||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
### With an error
|
||||
|
||||
#### General error
|
||||
|
||||
```jsx
|
||||
const error = new Error('Something went wrong!');
|
||||
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
body: "This message won't get through...",
|
||||
sent_at: Date.now() - 200000,
|
||||
errors: [error],
|
||||
});
|
||||
const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
|
||||
source: '+12025550003',
|
||||
type: 'incoming',
|
||||
body: null,
|
||||
}));
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Network error (outgoing only)
|
||||
|
||||
```jsx
|
||||
const error = new Error('Something went wrong!');
|
||||
error.name = 'MessageError';
|
||||
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
sent_at: Date.now() - 200000,
|
||||
errors: [error],
|
||||
body: "This message won't get through...",
|
||||
});
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme} type="group" >
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Network error, partial send in group (outgoing only)
|
||||
|
||||
```jsx
|
||||
const error = new Error('Something went wrong!');
|
||||
error.name = 'MessageError';
|
||||
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
sent_at: Date.now() - 200000,
|
||||
errors: [error],
|
||||
conversationId: util.groupNumber,
|
||||
body: "This message won't get through...",
|
||||
});
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme} type="group" >
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### No message contents
|
||||
|
||||
```jsx
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
sent_at: Date.now() - 200000,
|
||||
});
|
||||
const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
|
||||
source: '+12025550003',
|
||||
type: 'incoming',
|
||||
}));
|
||||
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme} >
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
### Disappearing
|
||||
|
||||
```jsx
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
sent_at: Date.now() - 200000,
|
||||
expireTimer: 120,
|
||||
expirationStartTimestamp: Date.now() - 1000,
|
||||
body: 'This message will self-destruct in two minutes',
|
||||
});
|
||||
const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
|
||||
source: '+12025550003',
|
||||
type: 'incoming',
|
||||
}));
|
||||
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme} >
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
### Notfications
|
||||
|
||||
#### Timer change
|
||||
|
||||
```jsx
|
||||
const fromOther = new Whisper.Message({
|
||||
type: 'incoming',
|
||||
flags: textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
|
||||
source: '+12025550003',
|
||||
sent_at: Date.now() - 200000,
|
||||
expireTimer: 120,
|
||||
expirationStartTimestamp: Date.now() - 1000,
|
||||
expirationTimerUpdate: {
|
||||
source: '+12025550003',
|
||||
}
|
||||
});
|
||||
const fromUpdate = new Whisper.Message({
|
||||
type: 'incoming',
|
||||
flags: textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
|
||||
source: util.ourNumber,
|
||||
sent_at: Date.now() - 200000,
|
||||
expireTimer: 120,
|
||||
expirationStartTimestamp: Date.now() - 1000,
|
||||
expirationTimerUpdate: {
|
||||
fromSync: true,
|
||||
source: util.ourNumber,
|
||||
}
|
||||
});
|
||||
const fromMe = new Whisper.Message({
|
||||
type: 'incoming',
|
||||
flags: textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
|
||||
source: util.ourNumber,
|
||||
sent_at: Date.now() - 200000,
|
||||
expireTimer: 120,
|
||||
expirationStartTimestamp: Date.now() - 1000,
|
||||
expirationTimerUpdate: {
|
||||
source: util.ourNumber,
|
||||
}
|
||||
});
|
||||
const View = Whisper.ExpirationTimerUpdateView;
|
||||
<util.ConversationContext theme={util.theme} >
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: fromOther }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: fromUpdate }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: fromMe }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Safety number change
|
||||
|
||||
```js
|
||||
const incoming = new Whisper.Message({
|
||||
type: 'keychange',
|
||||
sent_at: Date.now() - 200000,
|
||||
key_changed: '+12025550003',
|
||||
});
|
||||
const View = Whisper.KeyChangeView;
|
||||
<util.ConversationContext theme={util.theme} >
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Marking as verified
|
||||
|
||||
```js
|
||||
const fromPrimary = new Whisper.Message({
|
||||
type: 'verified-change',
|
||||
sent_at: Date.now() - 200000,
|
||||
verifiedChanged: '+12025550003',
|
||||
verified: true,
|
||||
});
|
||||
const local = new Whisper.Message({
|
||||
type: 'verified-change',
|
||||
sent_at: Date.now() - 200000,
|
||||
verifiedChanged: '+12025550003',
|
||||
local: true,
|
||||
verified: true,
|
||||
});
|
||||
|
||||
const View = Whisper.VerifiedChangeView;
|
||||
<util.ConversationContext theme={util.theme} >
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: fromPrimary }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: local }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Marking as not verified
|
||||
|
||||
```js
|
||||
const fromPrimary = new Whisper.Message({
|
||||
type: 'verified-change',
|
||||
sent_at: Date.now() - 200000,
|
||||
verifiedChanged: '+12025550003',
|
||||
});
|
||||
const local = new Whisper.Message({
|
||||
type: 'verified-change',
|
||||
sent_at: Date.now() - 200000,
|
||||
verifiedChanged: '+12025550003',
|
||||
local: true,
|
||||
});
|
||||
|
||||
const View = Whisper.VerifiedChangeView;
|
||||
<util.ConversationContext theme={util.theme} >
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: fromPrimary }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: local }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Group update
|
||||
|
||||
```js
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
sent_at: Date.now() - 200000,
|
||||
group_update: {
|
||||
joined: [
|
||||
'+12025550007',
|
||||
'+12025550008',
|
||||
'+12025550009',
|
||||
],
|
||||
},
|
||||
});
|
||||
const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
|
||||
source: '+12025550003',
|
||||
type: 'incoming',
|
||||
}));
|
||||
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme} >
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### End session
|
||||
|
||||
```js
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
sent_at: Date.now() - 200000,
|
||||
flags: textsecure.protobuf.DataMessage.Flags.END_SESSION,
|
||||
});
|
||||
const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
|
||||
source: '+12025550003',
|
||||
type: 'incoming',
|
||||
}));
|
||||
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme} >
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
### With an attachment
|
||||
|
||||
#### Image with caption
|
||||
|
@ -120,6 +436,125 @@ const View = Whisper.MessageView;
|
|||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Image with portrait aspect ratio
|
||||
|
||||
```jsx
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
sent_at: Date.now() - 18000000,
|
||||
attachments: [{
|
||||
data: util.portraitYellow,
|
||||
fileName: 'portraitYellow.png',
|
||||
contentType: 'image/png',
|
||||
}],
|
||||
});
|
||||
const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
|
||||
source: '+12025550003',
|
||||
type: 'incoming',
|
||||
}));
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
|
||||
#### Image with portrait aspect ratio and caption
|
||||
|
||||
```jsx
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
body: 'This is an odd yellow bar. Cool, huh?',
|
||||
sent_at: Date.now() - 18000000,
|
||||
attachments: [{
|
||||
data: util.portraitYellow,
|
||||
fileName: 'portraitYellow.png',
|
||||
contentType: 'image/png',
|
||||
}],
|
||||
});
|
||||
const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
|
||||
source: '+12025550003',
|
||||
type: 'incoming',
|
||||
}));
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Image with landscape aspect ratio
|
||||
|
||||
```jsx
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
sent_at: Date.now() - 18000000,
|
||||
attachments: [{
|
||||
data: util.landscapePurple,
|
||||
fileName: 'landscapePurple.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
}],
|
||||
});
|
||||
const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
|
||||
source: '+12025550003',
|
||||
type: 'incoming',
|
||||
}));
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Image with landscape aspect ratio and caption
|
||||
|
||||
```jsx
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
body: "An interesting horizontal bar. It's art.",
|
||||
sent_at: Date.now() - 18000000,
|
||||
attachments: [{
|
||||
data: util.landscapePurple,
|
||||
fileName: 'landscapePurple.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
}],
|
||||
});
|
||||
const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
|
||||
source: '+12025550003',
|
||||
type: 'incoming',
|
||||
}));
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Video with caption
|
||||
|
||||
```jsx
|
||||
|
|
|
@ -15,7 +15,6 @@ export class Message extends React.Component<{}, {}> {
|
|||
<div className="sender" dir="auto" />
|
||||
<div className="tail-wrapper with-tail">
|
||||
<div className="inner-bubble">
|
||||
<div className="attachments" />
|
||||
<p className="content" dir="auto">
|
||||
<span className="body">
|
||||
Hi there. How are you doing? Feeling pretty good? Awesome.
|
||||
|
|
|
@ -34,6 +34,39 @@ const View = Whisper.MessageView;
|
|||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### With emoji
|
||||
|
||||
```jsx
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
body: 'About 🔥six🔥',
|
||||
sent_at: Date.now() - 18000000,
|
||||
quote: {
|
||||
text: 'How many 🔥ferrets🔥 do you have? ',
|
||||
author: '+12025550011',
|
||||
id: Date.now() - 1000,
|
||||
},
|
||||
});
|
||||
const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
|
||||
source: '+12025550011',
|
||||
type: 'incoming',
|
||||
quote: Object.assign({}, outgoing.attributes.quote, {
|
||||
author: '+12025550005',
|
||||
}),
|
||||
}));
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Replies to you or yourself
|
||||
|
||||
```jsx
|
||||
|
@ -183,9 +216,8 @@ const View = Whisper.MessageView;
|
|||
#### A lot of text in quotation, with image
|
||||
|
||||
```jsx
|
||||
const quotedMessage = {
|
||||
imageUrl: util.gifObjectUrl,
|
||||
id: '3234-23423-2342',
|
||||
const thumbnail = {
|
||||
objectUrl: util.gifObjectUrl,
|
||||
};
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
|
@ -218,8 +250,8 @@ const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
|
|||
}),
|
||||
}));
|
||||
|
||||
outgoing.quotedMessage = quotedMessage;
|
||||
incoming.quotedMessage = quotedMessage;
|
||||
outgoing.quoteThumbnail = thumbnail;
|
||||
incoming.quoteThumbnail = thumbnail;
|
||||
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
|
@ -237,8 +269,8 @@ const View = Whisper.MessageView;
|
|||
#### Image with caption
|
||||
|
||||
```jsx
|
||||
const quotedMessage = {
|
||||
imageUrl: util.gifObjectUrl,
|
||||
const thumbnail = {
|
||||
objectUrl: util.gifObjectUrl,
|
||||
id: '3234-23423-2342',
|
||||
};
|
||||
const outgoing = new Whisper.Message({
|
||||
|
@ -268,8 +300,8 @@ const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
|
|||
}),
|
||||
}));
|
||||
|
||||
outgoing.quotedMessage = quotedMessage;
|
||||
incoming.quotedMessage = quotedMessage;
|
||||
outgoing.quoteThumbnail = thumbnail;
|
||||
incoming.quoteThumbnail = thumbnail;
|
||||
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
|
@ -287,8 +319,8 @@ const View = Whisper.MessageView;
|
|||
#### Image
|
||||
|
||||
```jsx
|
||||
const quotedMessage = {
|
||||
imageUrl: util.gifObjectUrl,
|
||||
const thumbnail = {
|
||||
objectUrl: util.gifObjectUrl,
|
||||
};
|
||||
|
||||
const outgoing = new Whisper.Message({
|
||||
|
@ -317,8 +349,8 @@ const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
|
|||
}),
|
||||
}));
|
||||
|
||||
outgoing.quotedMessage = quotedMessage;
|
||||
incoming.quotedMessage = quotedMessage;
|
||||
outgoing.quoteThumbnail = thumbnail;
|
||||
incoming.quoteThumbnail = thumbnail;
|
||||
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
|
@ -375,8 +407,8 @@ const View = Whisper.MessageView;
|
|||
#### Video with caption
|
||||
|
||||
```jsx
|
||||
const quotedMessage = {
|
||||
imageUrl: util.gifObjectUrl,
|
||||
const thumbnail = {
|
||||
objectUrl: util.gifObjectUrl,
|
||||
};
|
||||
|
||||
const outgoing = new Whisper.Message({
|
||||
|
@ -406,8 +438,8 @@ const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
|
|||
}),
|
||||
}));
|
||||
|
||||
outgoing.quotedMessage = quotedMessage;
|
||||
incoming.quotedMessage = quotedMessage;
|
||||
outgoing.quoteThumbnail = thumbnail;
|
||||
incoming.quoteThumbnail = thumbnail;
|
||||
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
|
@ -425,8 +457,8 @@ const View = Whisper.MessageView;
|
|||
#### Video
|
||||
|
||||
```jsx
|
||||
const quotedMessage = {
|
||||
imageUrl: util.gifObjectUrl,
|
||||
const thumbnail = {
|
||||
objectUrl: util.gifObjectUrl,
|
||||
};
|
||||
|
||||
const outgoing = new Whisper.Message({
|
||||
|
@ -456,8 +488,8 @@ const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
|
|||
}),
|
||||
}));
|
||||
|
||||
outgoing.quotedMessage = quotedMessage;
|
||||
incoming.quotedMessage = quotedMessage;
|
||||
outgoing.quoteThumbnail = thumbnail;
|
||||
incoming.quoteThumbnail = thumbnail;
|
||||
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
|
@ -782,6 +814,44 @@ const View = Whisper.MessageView;
|
|||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Quote, portrait image attachment
|
||||
|
||||
```jsx
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
sent_at: Date.now() - 18000000,
|
||||
quote: {
|
||||
text: 'How many ferrets do you have?',
|
||||
author: '+12025550011',
|
||||
id: Date.now() - 1000,
|
||||
},
|
||||
attachments: [{
|
||||
data: util.portraitYellow,
|
||||
fileName: 'pi.gif',
|
||||
contentType: 'image/gif',
|
||||
}],
|
||||
});
|
||||
const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
|
||||
source: '+12025550011',
|
||||
type: 'incoming',
|
||||
quote: Object.assign({}, outgoing.attributes.quote, {
|
||||
author: '+12025550005',
|
||||
}),
|
||||
}));
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
|
||||
#### Quote, video attachment
|
||||
|
||||
```jsx
|
||||
|
@ -893,3 +963,164 @@ const View = Whisper.MessageView;
|
|||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
#### Quote, but no message
|
||||
|
||||
```jsx
|
||||
const outgoing = new Whisper.Message({
|
||||
type: 'outgoing',
|
||||
sent_at: Date.now() - 18000000,
|
||||
quote: {
|
||||
text: 'How many ferrets do you have?',
|
||||
author: '+12025550011',
|
||||
id: Date.now() - 1000,
|
||||
},
|
||||
});
|
||||
const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
|
||||
source: '+12025550011',
|
||||
type: 'incoming',
|
||||
quote: Object.assign({}, outgoing.attributes.quote, {
|
||||
author: '+12025550005',
|
||||
}),
|
||||
}));
|
||||
const View = Whisper.MessageView;
|
||||
<util.ConversationContext theme={util.theme}>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: incoming }}
|
||||
/>
|
||||
<util.BackboneWrapper
|
||||
View={View}
|
||||
options={{ model: outgoing }}
|
||||
/>
|
||||
</util.ConversationContext>
|
||||
```
|
||||
|
||||
### In bottom bar
|
||||
|
||||
#### Plain text
|
||||
|
||||
```jsx
|
||||
<div className={util.theme}>
|
||||
<div className="bottom-bar">
|
||||
<Quote
|
||||
text="How many ferrets do you have?"
|
||||
authorColor="blue"
|
||||
authorTitle={util.ourNumber}
|
||||
authorProfileName="Mr. Blue"
|
||||
id={Date.now() - 1000}
|
||||
i18n={window.i18n}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### With an icon
|
||||
|
||||
```jsx
|
||||
<div className={util.theme}>
|
||||
<div className="bottom-bar">
|
||||
<Quote
|
||||
text="How many ferrets do you have?"
|
||||
authorColor="blue"
|
||||
authorTitle={util.ourNumber}
|
||||
authorProfileName="Mr. Blue"
|
||||
id={Date.now() - 1000}
|
||||
i18n={window.i18n}
|
||||
attachments={[{
|
||||
contentType: 'image/jpeg',
|
||||
fileName: 'llama.jpg',
|
||||
}]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### With an image
|
||||
|
||||
```jsx
|
||||
<div className={util.theme}>
|
||||
<div className="bottom-bar">
|
||||
<Quote
|
||||
text="How many ferrets do you have?"
|
||||
authorColor="blue"
|
||||
authorTitle={util.ourNumber}
|
||||
authorProfileName="Mr. Blue"
|
||||
id={Date.now() - 1000}
|
||||
i18n={window.i18n}
|
||||
attachments={[{
|
||||
contentType: 'image/gif',
|
||||
fileName: 'llama.gif',
|
||||
thumbnail: {
|
||||
objectUrl: util.gifObjectUrl
|
||||
},
|
||||
}]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### With a close button
|
||||
|
||||
```jsx
|
||||
<div className={util.theme}>
|
||||
<div className="bottom-bar">
|
||||
<Quote
|
||||
text="How many ferrets do you have?"
|
||||
authorColor="blue"
|
||||
authorTitle={util.ourNumber}
|
||||
authorProfileName="Mr. Blue"
|
||||
id={Date.now() - 1000}
|
||||
onClose={() => console.log('Close was clicked!')}
|
||||
i18n={window.i18n}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### With a close button and icon
|
||||
|
||||
```jsx
|
||||
<div className={util.theme}>
|
||||
<div className="bottom-bar">
|
||||
<Quote
|
||||
text="How many ferrets do you have?"
|
||||
authorColor="blue"
|
||||
authorTitle={util.ourNumber}
|
||||
authorProfileName="Mr. Blue"
|
||||
id={Date.now() - 1000}
|
||||
onClose={() => console.log('Close was clicked!')}
|
||||
i18n={window.i18n}
|
||||
attachments={[{
|
||||
contentType: 'image/jpeg',
|
||||
fileName: 'llama.jpg',
|
||||
}]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### With a close button and image
|
||||
|
||||
```jsx
|
||||
<div className={util.theme}>
|
||||
<div className="bottom-bar">
|
||||
<Quote
|
||||
text="How many ferrets do you have?"
|
||||
authorColor="blue"
|
||||
authorTitle={util.ourNumber}
|
||||
authorProfileName="Mr. Blue"
|
||||
id={Date.now() - 1000}
|
||||
onClose={() => console.log('Close was clicked!')}
|
||||
i18n={window.i18n}
|
||||
attachments={[{
|
||||
contentType: 'image/gif',
|
||||
fileName: 'llama.gif',
|
||||
thumbnail: {
|
||||
objectUrl: util.gifObjectUrl
|
||||
},
|
||||
}]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
|
|
@ -14,6 +14,7 @@ interface Props {
|
|||
isFromMe: string;
|
||||
isIncoming: boolean;
|
||||
onClick?: () => void;
|
||||
onClose?: () => void;
|
||||
text: string;
|
||||
}
|
||||
|
||||
|
@ -94,7 +95,7 @@ export class Quote extends React.Component<Props, {}> {
|
|||
if (Mime.isVideo(contentType)) {
|
||||
return objectUrl
|
||||
? this.renderImage(objectUrl, 'play')
|
||||
: this.renderIcon('play');
|
||||
: this.renderIcon('movie');
|
||||
}
|
||||
if (Mime.isImage(contentType)) {
|
||||
return objectUrl
|
||||
|
@ -112,7 +113,7 @@ export class Quote extends React.Component<Props, {}> {
|
|||
const { i18n, text, attachments } = this.props;
|
||||
|
||||
if (text) {
|
||||
return <div className="text">{text}</div>;
|
||||
return <div className="text" dangerouslySetInnerHTML={{ __html: text}} />;
|
||||
}
|
||||
|
||||
if (!attachments || attachments.length === 0) {
|
||||
|
@ -153,6 +154,28 @@ export class Quote extends React.Component<Props, {}> {
|
|||
return <div className="ios-label">{label}</div>;
|
||||
}
|
||||
|
||||
public renderClose() {
|
||||
const { onClose } = this.props;
|
||||
|
||||
if (!onClose) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// We don't want the overall click handler for the quote to fire, so we stop
|
||||
// propagation before handing control to the caller's callback.
|
||||
const onClick = (e: React.MouseEvent<{}>): void => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
};
|
||||
|
||||
// We need the container to give us the flexibility to implement the iOS design.
|
||||
return (
|
||||
<div className="close-container">
|
||||
<div className="close-button" onClick={onClick} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const {
|
||||
authorTitle,
|
||||
|
@ -171,7 +194,7 @@ export class Quote extends React.Component<Props, {}> {
|
|||
: null;
|
||||
const classes = classnames(
|
||||
authorColor,
|
||||
'quote',
|
||||
'quoted-message',
|
||||
isFromMe ? 'from-me' : null,
|
||||
!onClick ? 'no-click' : null,
|
||||
);
|
||||
|
@ -186,6 +209,7 @@ export class Quote extends React.Component<Props, {}> {
|
|||
{this.renderText()}
|
||||
</div>
|
||||
{this.renderIconContainer()}
|
||||
{this.renderClose()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -41,6 +41,23 @@ const txtObjectUrl = makeObjectUrl(txt, 'text/plain');
|
|||
import mp4 from '../../fixtures/pixabay-Soap-Bubble-7141.mp4';
|
||||
const mp4ObjectUrl = makeObjectUrl(mp4, 'video/mp4');
|
||||
|
||||
// @ts-ignore
|
||||
import landscapeGreen from '../../fixtures/1000x50-green.jpeg';
|
||||
const landscapeGreenObjectUrl = makeObjectUrl(landscapeGreen, 'image/jpeg');
|
||||
// @ts-ignore
|
||||
import landscapePurple from '../../fixtures/200x50-purple.png';
|
||||
const landscapePurpleObjectUrl = makeObjectUrl(landscapePurple, 'image/png');
|
||||
// @ts-ignore
|
||||
import portraitYellow from '../../fixtures/20x200-yellow.png';
|
||||
const portraitYellowObjectUrl = makeObjectUrl(portraitYellow, 'image/png');
|
||||
// @ts-ignore
|
||||
import landscapeRed from '../../fixtures/300x1-red.jpeg';
|
||||
const landscapeRedObjectUrl = makeObjectUrl(landscapeRed, 'image/png');
|
||||
// @ts-ignore
|
||||
import portraitTeal from '../../fixtures/50x1000-teal.jpeg';
|
||||
const portraitTealObjectUrl = makeObjectUrl(portraitTeal, 'image/png');
|
||||
|
||||
|
||||
function makeObjectUrl(data: ArrayBuffer, contentType: string): string {
|
||||
const blob = new Blob([data], {
|
||||
type: contentType,
|
||||
|
@ -49,6 +66,8 @@ function makeObjectUrl(data: ArrayBuffer, contentType: string): string {
|
|||
}
|
||||
|
||||
const ourNumber = '+12025559999';
|
||||
const groupNumber = '+12025550099';
|
||||
|
||||
|
||||
export {
|
||||
mp3,
|
||||
|
@ -59,7 +78,18 @@ export {
|
|||
mp4ObjectUrl,
|
||||
txt,
|
||||
txtObjectUrl,
|
||||
landscapeGreen,
|
||||
landscapeGreenObjectUrl,
|
||||
landscapePurple,
|
||||
landscapePurpleObjectUrl,
|
||||
portraitYellow,
|
||||
portraitYellowObjectUrl,
|
||||
landscapeRed,
|
||||
landscapeRedObjectUrl,
|
||||
portraitTeal,
|
||||
portraitTealObjectUrl,
|
||||
ourNumber,
|
||||
groupNumber,
|
||||
};
|
||||
|
||||
|
||||
|
@ -153,10 +183,22 @@ const me = parent.ConversationController.dangerouslyCreateAndAdd({
|
|||
color: 'light_blue',
|
||||
});
|
||||
|
||||
const group = parent.ConversationController.dangerouslyCreateAndAdd({
|
||||
id: groupNumber,
|
||||
name: 'A place for sharing cats',
|
||||
type: 'group',
|
||||
});
|
||||
|
||||
group.contactCollection.add(me);
|
||||
group.contactCollection.add(CONTACTS[0]);
|
||||
group.contactCollection.add(CONTACTS[1]);
|
||||
group.contactCollection.add(CONTACTS[2]);
|
||||
|
||||
export {
|
||||
COLORS,
|
||||
CONTACTS,
|
||||
me,
|
||||
group,
|
||||
};
|
||||
|
||||
parent.textsecure.storage.user.getNumber = () => ourNumber;
|
||||
|
@ -164,3 +206,11 @@ parent.textsecure.storage.user.getNumber = () => ourNumber;
|
|||
// Telling Lodash to relinquish _ for use by underscore
|
||||
// @ts-ignore
|
||||
_.noConflict();
|
||||
|
||||
parent.emoji.signalReplace = (html: string): string => {
|
||||
return html.replace(
|
||||
/🔥/g,
|
||||
'<img src="node_modules/emoji-datasource-apple/img/apple/64/1f525.png"' +
|
||||
'class="emoji" data-codepoints="1f525" title=":fire:">',
|
||||
);
|
||||
};
|
||||
|
|