Full support for quotations in Android theme
This commit is contained in:
parent
47a3acd5c9
commit
1cc0633786
13 changed files with 734 additions and 128 deletions
|
@ -17,7 +17,7 @@
|
|||
|
||||
window.Whisper = window.Whisper || {};
|
||||
|
||||
const { Message } = window.Signal.Types;
|
||||
const { Message, MIME } = window.Signal.Types;
|
||||
const { upgradeMessageSchema, loadAttachmentData } = window.Signal.Migrations;
|
||||
|
||||
// TODO: Factor out private and group subclasses of Conversation
|
||||
|
@ -1027,15 +1027,173 @@
|
|||
});
|
||||
},
|
||||
|
||||
fetchMessages() {
|
||||
if (!this.id) {
|
||||
return Promise.reject(new Error('This conversation has no id!'));
|
||||
makeKey(author, id) {
|
||||
return `${author}-${id}`;
|
||||
},
|
||||
doMessagesMatch(left, right) {
|
||||
if (left.get('source') !== right.get('source')) {
|
||||
return false;
|
||||
}
|
||||
return this.messageCollection.fetchConversation(
|
||||
if (left.get('sent_at') !== right.get('sent_at')) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
needData(attachments) {
|
||||
if (!attachments || attachments.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const first = attachments[0];
|
||||
const { thumbnail, contentType } = first;
|
||||
|
||||
return thumbnail || MIME.isVideo(contentType) || MIME.isImage(contentType);
|
||||
},
|
||||
forceRender(message) {
|
||||
message.trigger('change', message);
|
||||
},
|
||||
makeObjectUrl(data, contentType) {
|
||||
const blob = new Blob([data], {
|
||||
type: contentType,
|
||||
});
|
||||
return URL.createObjectURL(blob);
|
||||
},
|
||||
makeMessagesLookup(messages) {
|
||||
return messages.reduce((acc, message) => {
|
||||
const { source, sent_at: sentAt } = message.attributes;
|
||||
const key = this.makeKey(source, sentAt);
|
||||
|
||||
acc[key] = message;
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
},
|
||||
async loadQuotedMessageFromDatabase(message) {
|
||||
const { quote } = message.attributes;
|
||||
const { attachments, id } = quote;
|
||||
const first = attachments[0];
|
||||
|
||||
// Maybe in the future we could try to pull the thumbnail from a video ourselves,
|
||||
// but for now we will rely on incoming thumbnails only.
|
||||
if (!MIME.isImage(first.contentType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const collection = new Whisper.MessageCollection();
|
||||
await collection.fetchSentAt(id);
|
||||
const queryMessage = collection.find(m => this.doMessagesMatch(message, m));
|
||||
|
||||
if (!queryMessage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const queryAttachments = queryMessage.attachments || [];
|
||||
if (queryAttachments.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const queryFirst = queryAttachments[0];
|
||||
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.
|
||||
// Note: if the attachment is a video, then this object URL won't make any sense
|
||||
// when we try to use it in an img tag.
|
||||
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;
|
||||
|
||||
this.forceRender(message);
|
||||
return true;
|
||||
},
|
||||
async loadQuoteThumbnail(message) {
|
||||
const { quote } = message.attributes;
|
||||
const { attachments } = quote;
|
||||
const first = attachments[0];
|
||||
const { thumbnail } = first;
|
||||
|
||||
if (!thumbnail) {
|
||||
return false;
|
||||
}
|
||||
const thumbnailWithData = await loadAttachmentData(thumbnail);
|
||||
thumbnailWithData.objectUrl = this.makeObjectUrl(
|
||||
thumbnailWithData.data,
|
||||
thumbnailWithData.contentType
|
||||
);
|
||||
|
||||
// If we update this data in place, there's the risk that this data could be
|
||||
// saved back to the database
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
message.quoteThumbnail = thumbnailWithData;
|
||||
|
||||
this.forceRender(message);
|
||||
return true;
|
||||
},
|
||||
|
||||
async processQuotes(messages) {
|
||||
const lookup = this.makeMessagesLookup(messages);
|
||||
|
||||
const promises = messages.map(async (message) => {
|
||||
const { quote } = message.attributes;
|
||||
if (!quote) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { attachments } = quote;
|
||||
if (!this.needData(attachments)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// We've already gone through this method once for this message
|
||||
if (message.quoteIsProcessed) {
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
message.quoteIsProcessed = true;
|
||||
|
||||
// First, check to see if we've already loaded the target message into memory
|
||||
const { author, id } = quote;
|
||||
const key = this.makeKey(author, id);
|
||||
const quotedMessage = lookup[key];
|
||||
|
||||
if (quotedMessage) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
message.quotedMessage = quotedMessage;
|
||||
this.forceRender(message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Then go to the database for the real referenced attachment
|
||||
const loaded = await this.loadQuotedMessageFromDatabase(message, id);
|
||||
if (loaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Finally, use the provided thumbnail
|
||||
await this.loadQuoteThumbnail(message, quote);
|
||||
});
|
||||
|
||||
return Promise.all(promises);
|
||||
},
|
||||
|
||||
async fetchMessages() {
|
||||
if (!this.id) {
|
||||
throw new Error('This conversation has no id!');
|
||||
}
|
||||
|
||||
await this.messageCollection.fetchConversation(
|
||||
this.id,
|
||||
null,
|
||||
this.get('unreadCount')
|
||||
);
|
||||
|
||||
// We kick this process off, but don't wait for it. If async updates happen on a
|
||||
// given Message, 'change' will be triggered
|
||||
this.processQuotes(this.messageCollection);
|
||||
},
|
||||
|
||||
hasMember(number) {
|
||||
|
|
|
@ -29,7 +29,7 @@
|
|||
this.on('destroy', this.onDestroy);
|
||||
this.on('change:expirationStartTimestamp', this.setToExpire);
|
||||
this.on('change:expireTimer', this.setToExpire);
|
||||
this.on('unload', this.revokeImageUrl);
|
||||
this.on('unload', this.unload);
|
||||
this.setToExpire();
|
||||
},
|
||||
idForLogging() {
|
||||
|
@ -174,6 +174,20 @@
|
|||
this.imageUrl = null;
|
||||
}
|
||||
},
|
||||
unload() {
|
||||
if (this.quoteThumbnail) {
|
||||
URL.revokeObjectURL(this.quoteThumbnail.objectUrl);
|
||||
this.quoteThumbnail = null;
|
||||
}
|
||||
if (this.quotedMessageFromDatabase) {
|
||||
this.quotedMessageFromDatabase.unload();
|
||||
this.quotedMessageFromDatabase = null;
|
||||
}
|
||||
if (this.quotedMessage) {
|
||||
this.quotedMessage = null;
|
||||
}
|
||||
this.revokeImageUrl();
|
||||
},
|
||||
revokeImageUrl() {
|
||||
if (this.imageUrl) {
|
||||
URL.revokeObjectURL(this.imageUrl);
|
||||
|
|
|
@ -114,6 +114,7 @@
|
|||
this.listenTo(this.model, 'expired', this.onExpired);
|
||||
this.listenTo(this.model, 'prune', this.onPrune);
|
||||
this.listenTo(this.model.messageCollection, 'expired', this.onExpiredCollection);
|
||||
this.listenTo(this.model.messageCollection, 'scroll-to-message', this.scrollToMessage);
|
||||
|
||||
this.lazyUpdateVerified = _.debounce(
|
||||
this.model.updateVerified.bind(this.model),
|
||||
|
@ -529,6 +530,22 @@
|
|||
}
|
||||
},
|
||||
|
||||
scrollToMessage: function(providedOptions) {
|
||||
const options = providedOptions || options;
|
||||
const { id } = options;
|
||||
|
||||
if (id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const el = this.$(`#${id}`);
|
||||
if (!el || el.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
el.scrollIntoView();
|
||||
},
|
||||
|
||||
scrollToBottom: function() {
|
||||
// If we're above the last seen indicator, we should scroll there instead
|
||||
// Note: if we don't end up at the bottom of the conversation, button will not go away!
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
/* global _: false */
|
||||
/* global emoji_util: false */
|
||||
/* global Mustache: false */
|
||||
/* global ConversationController: false */
|
||||
|
||||
// eslint-disable-next-line func-names
|
||||
(function () {
|
||||
|
@ -360,44 +361,74 @@
|
|||
this.timerView.setElement(this.$('.timer'));
|
||||
this.timerView.update();
|
||||
},
|
||||
getQuoteObjectUrl() {
|
||||
// Potential sources of objectUrl, as provided in Conversation.processQuotes
|
||||
// 1. model.quotedMessage.imageUrl
|
||||
// 2. model.quoteThumbnail.objectUrl
|
||||
|
||||
if (this.model.quotedMessageFromDatabase) {
|
||||
return this.model.quotedMessageFromDatabase.imageUrl;
|
||||
}
|
||||
if (this.model.quotedMessage) {
|
||||
return this.model.quotedMessage.imageUrl;
|
||||
}
|
||||
if (this.model.quoteThumbnail) {
|
||||
return this.model.quoteThumbnail.objectUrl;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
renderReply() {
|
||||
const VOICE_MESSAGE_FLAG =
|
||||
textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE;
|
||||
function addVoiceMessageFlag(attachment) {
|
||||
return Object.assign({}, attachment, {
|
||||
// eslint-disable-next-line no-bitwise
|
||||
isVoiceMessage: attachment.flags & VOICE_MESSAGE_FLAG,
|
||||
});
|
||||
}
|
||||
function getObjectUrl(attachment) {
|
||||
if (!attachment || attachment.objectUrl) {
|
||||
return attachment;
|
||||
}
|
||||
|
||||
const blob = new Blob([attachment.data], {
|
||||
type: attachment.contentType,
|
||||
});
|
||||
return Object.assign({}, attachment, {
|
||||
objectUrl: URL.createObjectURL(blob),
|
||||
});
|
||||
}
|
||||
function processAttachment(attachment) {
|
||||
return getObjectUrl(addVoiceMessageFlag(attachment));
|
||||
}
|
||||
|
||||
const VOICE_FLAG = textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE;
|
||||
const objectUrl = this.getQuoteObjectUrl();
|
||||
const quote = this.model.get('quote');
|
||||
if (!quote) {
|
||||
return;
|
||||
}
|
||||
|
||||
function processAttachment(attachment) {
|
||||
const thumbnail = !attachment.thumbnail
|
||||
? null
|
||||
: Object.assign({}, attachment.thumbnail, {
|
||||
objectUrl,
|
||||
});
|
||||
|
||||
return Object.assign({}, attachment, {
|
||||
// eslint-disable-next-line no-bitwise
|
||||
isVoiceMessage: attachment.flags & VOICE_FLAG,
|
||||
thumbnail,
|
||||
});
|
||||
}
|
||||
|
||||
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 isIncoming = this.model.isIncoming();
|
||||
const quoterContact = this.model.getContact();
|
||||
const quoterAuthorColor = quoterContact ? quoterContact.getColor() : null;
|
||||
|
||||
const props = {
|
||||
authorName: 'someone',
|
||||
authorColor: 'indigo',
|
||||
authorTitle,
|
||||
authorProfileName,
|
||||
authorColor,
|
||||
isIncoming,
|
||||
quoterAuthorColor,
|
||||
openQuotedMessage: () => {
|
||||
const { quotedMessage } = this.model;
|
||||
if (quotedMessage) {
|
||||
this.trigger('scroll-to-message', { id: quotedMessage.id });
|
||||
}
|
||||
},
|
||||
text: quote.text,
|
||||
attachments: quote.attachments && quote.attachments.map(processAttachment),
|
||||
};
|
||||
|
||||
if (!this.replyView) {
|
||||
if (contact) {
|
||||
this.listenTo(contact, 'change:color', this.renderReply);
|
||||
}
|
||||
this.replyView = new Whisper.ReactWrapperView({
|
||||
el: this.$('.quote-wrapper'),
|
||||
Component: window.Signal.Components.Quote,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue