Stickers
Co-authored-by: scott@signal.org Co-authored-by: ken@signal.org
This commit is contained in:
parent
8c8856785b
commit
29de50c12a
100 changed files with 7572 additions and 693 deletions
126
js/background.js
126
js/background.js
|
@ -296,6 +296,24 @@
|
|||
// Shut down the data interface cleanly
|
||||
await window.Signal.Data.shutdown();
|
||||
},
|
||||
|
||||
installStickerPack: async (id, key) => {
|
||||
const status = window.Signal.Stickers.getStickerPackStatus(id);
|
||||
|
||||
if (status === 'installed') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === 'advertised') {
|
||||
await window.reduxActions.stickers.installStickerPack(id, key, {
|
||||
fromSync: true,
|
||||
});
|
||||
} else {
|
||||
await window.Signal.Stickers.downloadStickerPack(id, key, {
|
||||
finalStatus: 'installed',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const currentVersion = window.getVersion();
|
||||
|
@ -303,18 +321,23 @@
|
|||
newVersion = !lastVersion || currentVersion !== lastVersion;
|
||||
await storage.put('version', currentVersion);
|
||||
|
||||
if (newVersion) {
|
||||
if (
|
||||
lastVersion &&
|
||||
window.isBeforeVersion(lastVersion, 'v1.15.0-beta.5')
|
||||
) {
|
||||
await window.Signal.Logs.deleteAll();
|
||||
window.restart();
|
||||
}
|
||||
|
||||
if (newVersion && lastVersion) {
|
||||
window.log.info(
|
||||
`New version detected: ${currentVersion}; previous: ${lastVersion}`
|
||||
);
|
||||
|
||||
if (window.isBeforeVersion(lastVersion, 'v1.25.0')) {
|
||||
// Stickers flags
|
||||
await Promise.all([
|
||||
storage.put('showStickersIntroduction', true),
|
||||
storage.put('showStickerPickerHint', true),
|
||||
]);
|
||||
}
|
||||
|
||||
if (window.isBeforeVersion(lastVersion, 'v1.15.0-beta.5')) {
|
||||
await window.Signal.Logs.deleteAll();
|
||||
window.restart();
|
||||
}
|
||||
}
|
||||
|
||||
if (isIndexedDBPresent) {
|
||||
|
@ -395,6 +418,7 @@
|
|||
try {
|
||||
await Promise.all([
|
||||
ConversationController.load(),
|
||||
Signal.Stickers.load(),
|
||||
textsecure.storage.protocol.hydrateCaches(),
|
||||
]);
|
||||
} catch (error) {
|
||||
|
@ -418,7 +442,11 @@
|
|||
conversations: {
|
||||
conversationLookup: Signal.Util.makeLookup(conversations, 'id'),
|
||||
},
|
||||
items: storage.getItemsState(),
|
||||
stickers: Signal.Stickers.getInitialState(),
|
||||
user: {
|
||||
attachmentsPath: window.baseAttachmentsPath,
|
||||
stickersPath: window.baseStickersPath,
|
||||
regionCode: window.storage.get('regionCode'),
|
||||
ourNumber: textsecure.storage.user.getNumber(),
|
||||
i18n: window.i18n,
|
||||
|
@ -437,10 +465,18 @@
|
|||
Signal.State.Ducks.conversations.actions,
|
||||
store.dispatch
|
||||
);
|
||||
actions.items = Signal.State.bindActionCreators(
|
||||
Signal.State.Ducks.items.actions,
|
||||
store.dispatch
|
||||
);
|
||||
actions.user = Signal.State.bindActionCreators(
|
||||
Signal.State.Ducks.user.actions,
|
||||
store.dispatch
|
||||
);
|
||||
actions.stickers = Signal.State.bindActionCreators(
|
||||
Signal.State.Ducks.stickers.actions,
|
||||
store.dispatch
|
||||
);
|
||||
|
||||
const {
|
||||
conversationAdded,
|
||||
|
@ -759,6 +795,7 @@
|
|||
messageReceiver.addEventListener('progress', onProgress);
|
||||
messageReceiver.addEventListener('configuration', onConfiguration);
|
||||
messageReceiver.addEventListener('typing', onTyping);
|
||||
messageReceiver.addEventListener('sticker-pack', onStickerPack);
|
||||
|
||||
window.Signal.AttachmentDownloads.start({
|
||||
getMessageReceiver: () => messageReceiver,
|
||||
|
@ -770,6 +807,10 @@
|
|||
PASSWORD
|
||||
);
|
||||
|
||||
if (connectCount === 1) {
|
||||
window.Signal.Stickers.downloadQueuedPacks();
|
||||
}
|
||||
|
||||
// On startup after upgrading to a new version, request a contact sync
|
||||
// (but only if we're not the primary device)
|
||||
if (
|
||||
|
@ -831,11 +872,34 @@
|
|||
Whisper.events.trigger('contactsync');
|
||||
});
|
||||
|
||||
const ourNumber = textsecure.storage.user.getNumber();
|
||||
const { wrap, sendOptions } = ConversationController.prepareForSend(
|
||||
ourNumber,
|
||||
{ syncMessage: true }
|
||||
);
|
||||
|
||||
const installedStickerPacks = window.Signal.Stickers.getInstalledStickerPacks();
|
||||
if (installedStickerPacks.length) {
|
||||
const operations = installedStickerPacks.map(pack => ({
|
||||
packId: pack.id,
|
||||
packKey: pack.key,
|
||||
installed: true,
|
||||
}));
|
||||
|
||||
wrap(
|
||||
window.textsecure.messaging.sendStickerPackSync(
|
||||
operations,
|
||||
sendOptions
|
||||
)
|
||||
).catch(error => {
|
||||
window.log.error(
|
||||
'Failed to send installed sticker packs via sync message',
|
||||
error && error.stack ? error.stack : error
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (Whisper.Import.isComplete()) {
|
||||
const { wrap, sendOptions } = ConversationController.prepareForSend(
|
||||
textsecure.storage.user.getNumber(),
|
||||
{ syncMessage: true }
|
||||
);
|
||||
wrap(
|
||||
textsecure.messaging.sendRequestConfigurationSyncMessage(sendOptions)
|
||||
).catch(error => {
|
||||
|
@ -942,6 +1006,42 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function onStickerPack(ev) {
|
||||
const packs = ev.stickerPacks || [];
|
||||
|
||||
packs.forEach(pack => {
|
||||
const { id, key, isInstall, isRemove } = pack || {};
|
||||
|
||||
if (!id || !key || (!isInstall && !isRemove)) {
|
||||
window.log.warn(
|
||||
'Received malformed sticker pack operation sync message'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const status = window.Signal.Stickers.getStickerPackStatus(id);
|
||||
|
||||
if (status === 'installed' && isRemove) {
|
||||
window.reduxActions.stickers.uninstallStickerPack(id, key, {
|
||||
fromSync: true,
|
||||
});
|
||||
} else if (isInstall) {
|
||||
if (status === 'advertised') {
|
||||
window.reduxActions.stickers.installStickerPack(id, key, {
|
||||
fromSync: true,
|
||||
});
|
||||
} else {
|
||||
window.Signal.Stickers.downloadStickerPack(id, key, {
|
||||
finalStatus: 'installed',
|
||||
fromSync: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ev.confirm();
|
||||
}
|
||||
|
||||
async function onContactReceived(ev) {
|
||||
const details = ev.contactDetails;
|
||||
|
||||
|
|
|
@ -12,6 +12,10 @@
|
|||
const HOUR = MINUTE * 60;
|
||||
|
||||
function register(id, message) {
|
||||
if (!id || !message) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const existing = messageLookup[id];
|
||||
if (existing) {
|
||||
messageLookup[id] = {
|
||||
|
|
|
@ -35,12 +35,14 @@
|
|||
PhoneNumber,
|
||||
} = window.Signal.Types;
|
||||
const {
|
||||
upgradeMessageSchema,
|
||||
loadAttachmentData,
|
||||
getAbsoluteAttachmentPath,
|
||||
writeNewAttachmentData,
|
||||
deleteAttachmentData,
|
||||
getAbsoluteAttachmentPath,
|
||||
loadAttachmentData,
|
||||
readStickerData,
|
||||
upgradeMessageSchema,
|
||||
writeNewAttachmentData,
|
||||
} = window.Signal.Migrations;
|
||||
const { addStickerPackReference } = window.Signal.Data;
|
||||
|
||||
const COLORS = [
|
||||
'red',
|
||||
|
@ -761,7 +763,7 @@
|
|||
return _.without(this.get('members'), me);
|
||||
},
|
||||
|
||||
async getQuoteAttachment(attachments, preview) {
|
||||
async getQuoteAttachment(attachments, preview, sticker) {
|
||||
if (attachments && attachments.length) {
|
||||
return Promise.all(
|
||||
attachments
|
||||
|
@ -817,6 +819,23 @@
|
|||
);
|
||||
}
|
||||
|
||||
if (sticker && sticker.data && sticker.data.path) {
|
||||
const { path, contentType } = sticker.data;
|
||||
|
||||
return [
|
||||
{
|
||||
contentType,
|
||||
// Our protos library complains about this field being undefined, so we
|
||||
// force it to null
|
||||
fileName: null,
|
||||
thumbnail: {
|
||||
...(await loadAttachmentData(sticker.data)),
|
||||
objectUrl: getAbsoluteAttachmentPath(path),
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
|
||||
|
@ -825,6 +844,7 @@
|
|||
const contact = quotedMessage.getContact();
|
||||
const attachments = quotedMessage.get('attachments');
|
||||
const preview = quotedMessage.get('preview');
|
||||
const sticker = quotedMessage.get('sticker');
|
||||
|
||||
const body = quotedMessage.get('body');
|
||||
const embeddedContact = quotedMessage.get('contact');
|
||||
|
@ -837,11 +857,46 @@
|
|||
author: contact.id,
|
||||
id: quotedMessage.get('sent_at'),
|
||||
text: body || embeddedContactName,
|
||||
attachments: await this.getQuoteAttachment(attachments, preview),
|
||||
attachments: await this.getQuoteAttachment(
|
||||
attachments,
|
||||
preview,
|
||||
sticker
|
||||
),
|
||||
};
|
||||
},
|
||||
|
||||
sendMessage(body, attachments, quote, preview) {
|
||||
async sendStickerMessage(packId, stickerId) {
|
||||
const packData = window.Signal.Stickers.getStickerPack(packId);
|
||||
const stickerData = window.Signal.Stickers.getSticker(packId, stickerId);
|
||||
if (!stickerData || !packData) {
|
||||
window.log.warn(
|
||||
`Attempted to send nonexistent (${packId}, ${stickerId}) sticker!`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { key } = packData;
|
||||
const { path, width, height } = stickerData;
|
||||
const arrayBuffer = await readStickerData(path);
|
||||
|
||||
const sticker = {
|
||||
packId,
|
||||
stickerId,
|
||||
packKey: key,
|
||||
data: {
|
||||
size: arrayBuffer.byteLength,
|
||||
data: arrayBuffer,
|
||||
contentType: 'image/webp',
|
||||
width,
|
||||
height,
|
||||
},
|
||||
};
|
||||
|
||||
this.sendMessage(null, [], null, [], sticker);
|
||||
window.reduxActions.stickers.useSticker(packId, stickerId);
|
||||
},
|
||||
|
||||
sendMessage(body, attachments, quote, preview, sticker) {
|
||||
this.clearTypingTimers();
|
||||
|
||||
const destination = this.id;
|
||||
|
@ -863,6 +918,7 @@
|
|||
now
|
||||
);
|
||||
|
||||
// Here we move attachments to disk
|
||||
const messageWithSchema = await upgradeMessageSchema({
|
||||
type: 'outgoing',
|
||||
body,
|
||||
|
@ -874,6 +930,7 @@
|
|||
received_at: now,
|
||||
expireTimer,
|
||||
recipients,
|
||||
sticker,
|
||||
});
|
||||
|
||||
if (this.isPrivate()) {
|
||||
|
@ -885,6 +942,9 @@
|
|||
};
|
||||
|
||||
const model = this.addSingleMessage(attributes);
|
||||
if (sticker) {
|
||||
await addStickerPackReference(model.id, sticker.packId);
|
||||
}
|
||||
const message = MessageController.register(model.id, model);
|
||||
await window.Signal.Data.saveMessage(message.attributes, {
|
||||
forceSave: true,
|
||||
|
@ -935,6 +995,7 @@
|
|||
finalAttachments,
|
||||
quote,
|
||||
preview,
|
||||
sticker,
|
||||
now,
|
||||
expireTimer,
|
||||
profileKey
|
||||
|
@ -955,6 +1016,7 @@
|
|||
finalAttachments,
|
||||
quote,
|
||||
preview,
|
||||
sticker,
|
||||
now,
|
||||
expireTimer,
|
||||
profileKey,
|
||||
|
@ -968,6 +1030,7 @@
|
|||
finalAttachments,
|
||||
quote,
|
||||
preview,
|
||||
sticker,
|
||||
now,
|
||||
expireTimer,
|
||||
profileKey,
|
||||
|
@ -1271,6 +1334,7 @@
|
|||
[],
|
||||
null,
|
||||
[],
|
||||
null,
|
||||
message.get('sent_at'),
|
||||
expireTimer,
|
||||
profileKey,
|
||||
|
|
|
@ -27,8 +27,16 @@
|
|||
loadAttachmentData,
|
||||
loadQuoteData,
|
||||
loadPreviewData,
|
||||
loadStickerData,
|
||||
upgradeMessageSchema,
|
||||
} = window.Signal.Migrations;
|
||||
const {
|
||||
copyStickerToAttachments,
|
||||
deletePackReference,
|
||||
downloadStickerPack,
|
||||
getStickerPackStatus,
|
||||
} = window.Signal.Stickers;
|
||||
const { addStickerPackReference } = window.Signal.Data;
|
||||
const { bytesFromString } = window.Signal.Crypto;
|
||||
|
||||
window.AccountCache = Object.create(null);
|
||||
|
@ -389,6 +397,29 @@
|
|||
// It doesn't need anything right now!
|
||||
return {};
|
||||
},
|
||||
getAttachmentsForMessage() {
|
||||
const sticker = this.get('sticker');
|
||||
if (sticker && sticker.data) {
|
||||
const { data } = sticker;
|
||||
|
||||
// We don't show anything if we're still loading a sticker
|
||||
if (data.pending || !data.path) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
...data,
|
||||
url: getAbsoluteAttachmentPath(data.path),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const attachments = this.get('attachments') || [];
|
||||
return attachments
|
||||
.filter(attachment => !attachment.error)
|
||||
.map(attachment => this.getPropsForAttachment(attachment));
|
||||
},
|
||||
getPropsForMessage() {
|
||||
const phoneNumber = this.getSource();
|
||||
const contact = this.findAndFormatContact(phoneNumber);
|
||||
|
@ -408,12 +439,13 @@
|
|||
|
||||
const conversation = this.getConversation();
|
||||
const isGroup = conversation && !conversation.isPrivate();
|
||||
const attachments = this.get('attachments') || [];
|
||||
const sticker = this.get('sticker');
|
||||
|
||||
return {
|
||||
text: this.createNonBreakingLastSeparator(this.get('body')),
|
||||
textPending: this.get('bodyPending'),
|
||||
id: this.id,
|
||||
isSticker: Boolean(sticker),
|
||||
direction: this.isIncoming() ? 'incoming' : 'outgoing',
|
||||
timestamp: this.get('sent_at'),
|
||||
status: this.getMessagePropStatus(),
|
||||
|
@ -423,9 +455,7 @@
|
|||
authorProfileName: contact.profileName,
|
||||
authorPhoneNumber: contact.phoneNumber,
|
||||
conversationType: isGroup ? 'group' : 'direct',
|
||||
attachments: attachments
|
||||
.filter(attachment => !attachment.error)
|
||||
.map(attachment => this.getPropsForAttachment(attachment)),
|
||||
attachments: this.getAttachmentsForMessage(),
|
||||
previews: this.getPropsForPreview(),
|
||||
quote: this.getPropsForQuote(),
|
||||
authorAvatarPath,
|
||||
|
@ -584,6 +614,7 @@
|
|||
|
||||
return previews.map(preview => ({
|
||||
...preview,
|
||||
isStickerPack: window.Signal.LinkPreviews.isStickerPack(preview.url),
|
||||
domain: window.Signal.LinkPreviews.getDomain(preview.url),
|
||||
image: preview.image ? this.getPropsForAttachment(preview.image) : null,
|
||||
}));
|
||||
|
@ -708,6 +739,9 @@
|
|||
if (this.get('attachments').length > 0) {
|
||||
return i18n('mediaMessage');
|
||||
}
|
||||
if (this.get('sticker')) {
|
||||
return i18n('message--getNotificationText--stickers');
|
||||
}
|
||||
if (this.isExpirationTimerUpdate()) {
|
||||
const { expireTimer } = this.get('expirationTimerUpdate');
|
||||
if (!expireTimer) {
|
||||
|
@ -775,6 +809,16 @@
|
|||
MessageController.unregister(this.id);
|
||||
this.unload();
|
||||
await deleteExternalMessageFiles(this.attributes);
|
||||
|
||||
const sticker = this.get('sticker');
|
||||
if (!sticker) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { packId } = sticker;
|
||||
if (packId) {
|
||||
await deletePackReference(this.id, packId);
|
||||
}
|
||||
},
|
||||
unload() {
|
||||
if (this.quotedMessage) {
|
||||
|
@ -968,6 +1012,7 @@
|
|||
|
||||
const quoteWithData = await loadQuoteData(this.get('quote'));
|
||||
const previewWithData = await loadPreviewData(this.get('preview'));
|
||||
const stickerWithData = await loadStickerData(this.get('sticker'));
|
||||
|
||||
// Special-case the self-send case - we send only a sync message
|
||||
if (recipients.length === 1 && recipients[0] === this.OUR_NUMBER) {
|
||||
|
@ -978,6 +1023,7 @@
|
|||
attachments,
|
||||
quoteWithData,
|
||||
previewWithData,
|
||||
stickerWithData,
|
||||
this.get('sent_at'),
|
||||
this.get('expireTimer'),
|
||||
profileKey
|
||||
|
@ -996,6 +1042,7 @@
|
|||
attachments,
|
||||
quoteWithData,
|
||||
previewWithData,
|
||||
stickerWithData,
|
||||
this.get('sent_at'),
|
||||
this.get('expireTimer'),
|
||||
profileKey,
|
||||
|
@ -1013,6 +1060,7 @@
|
|||
attachments,
|
||||
quote: quoteWithData,
|
||||
preview: previewWithData,
|
||||
sticker: stickerWithData,
|
||||
needsSync: !this.get('synced'),
|
||||
expireTimer: this.get('expireTimer'),
|
||||
profileKey,
|
||||
|
@ -1058,6 +1106,7 @@
|
|||
|
||||
const quoteWithData = await loadQuoteData(this.get('quote'));
|
||||
const previewWithData = await loadPreviewData(this.get('preview'));
|
||||
const stickerWithData = await loadStickerData(this.get('sticker'));
|
||||
|
||||
// Special-case the self-send case - we send only a sync message
|
||||
if (number === this.OUR_NUMBER) {
|
||||
|
@ -1067,6 +1116,7 @@
|
|||
attachments,
|
||||
quoteWithData,
|
||||
previewWithData,
|
||||
stickerWithData,
|
||||
this.get('sent_at'),
|
||||
this.get('expireTimer'),
|
||||
profileKey
|
||||
|
@ -1083,6 +1133,7 @@
|
|||
attachments,
|
||||
quoteWithData,
|
||||
previewWithData,
|
||||
stickerWithData,
|
||||
this.get('sent_at'),
|
||||
this.get('expireTimer'),
|
||||
profileKey,
|
||||
|
@ -1405,8 +1456,62 @@
|
|||
};
|
||||
}
|
||||
|
||||
let sticker = this.get('sticker');
|
||||
if (sticker) {
|
||||
count += 1;
|
||||
const { packId, stickerId, packKey } = sticker;
|
||||
|
||||
const status = getStickerPackStatus(packId);
|
||||
let data;
|
||||
|
||||
if (status && status !== 'pending' && status !== 'error') {
|
||||
try {
|
||||
const copiedSticker = await copyStickerToAttachments(
|
||||
packId,
|
||||
stickerId
|
||||
);
|
||||
data = {
|
||||
...copiedSticker,
|
||||
contentType: 'image/webp',
|
||||
};
|
||||
} catch (error) {
|
||||
window.log.error(
|
||||
`Problem copying sticker (${packId}, ${stickerId}) to attachments:`,
|
||||
error && error.stack ? error.stack : error
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!data) {
|
||||
data = await window.Signal.AttachmentDownloads.addJob(sticker.data, {
|
||||
messageId,
|
||||
type: 'sticker',
|
||||
index: 0,
|
||||
});
|
||||
}
|
||||
if (!status) {
|
||||
// kick off the download without waiting
|
||||
downloadStickerPack(packId, packKey, { messageId });
|
||||
} else {
|
||||
await addStickerPackReference(messageId, packId);
|
||||
}
|
||||
|
||||
sticker = {
|
||||
...sticker,
|
||||
packId,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
this.set({ bodyPending, attachments, preview, contact, quote, group });
|
||||
this.set({
|
||||
bodyPending,
|
||||
attachments,
|
||||
preview,
|
||||
contact,
|
||||
quote,
|
||||
group,
|
||||
sticker,
|
||||
});
|
||||
|
||||
await window.Signal.Data.saveMessage(this.attributes, {
|
||||
Message: Whisper.Message,
|
||||
|
@ -1481,7 +1586,6 @@
|
|||
}
|
||||
|
||||
const queryAttachments = queryMessage.get('attachments') || [];
|
||||
|
||||
if (queryAttachments.length > 0) {
|
||||
const queryFirst = queryAttachments[0];
|
||||
const { thumbnail } = queryFirst;
|
||||
|
@ -1507,6 +1611,14 @@
|
|||
}
|
||||
}
|
||||
|
||||
const sticker = queryMessage.get('sticker');
|
||||
if (sticker && sticker.data && sticker.data.path) {
|
||||
firstAttachment.thumbnail = {
|
||||
...sticker.data,
|
||||
copied: true,
|
||||
};
|
||||
}
|
||||
|
||||
return message;
|
||||
},
|
||||
|
||||
|
@ -1617,9 +1729,10 @@
|
|||
hasAttachments: dataMessage.hasAttachments,
|
||||
hasFileAttachments: dataMessage.hasFileAttachments,
|
||||
hasVisualMediaAttachments: dataMessage.hasVisualMediaAttachments,
|
||||
quote: dataMessage.quote,
|
||||
preview,
|
||||
quote: dataMessage.quote,
|
||||
schemaVersion: dataMessage.schemaVersion,
|
||||
sticker: dataMessage.sticker,
|
||||
});
|
||||
if (type === 'outgoing') {
|
||||
const receipts = Whisper.DeliveryReceipts.forMessage(
|
||||
|
@ -1841,7 +1954,7 @@
|
|||
Whisper.Message.LONG_MESSAGE_CONTENT_TYPE = 'text/x-signal-plain';
|
||||
|
||||
Whisper.Message.getLongMessageAttachment = ({ body, attachments, now }) => {
|
||||
if (body.length <= 2048) {
|
||||
if (!body || body.length <= 2048) {
|
||||
return {
|
||||
body,
|
||||
attachments,
|
||||
|
|
|
@ -1,6 +1,14 @@
|
|||
/* global Whisper, Signal, setTimeout, clearTimeout, MessageController */
|
||||
/* global
|
||||
ConversationController,
|
||||
Whisper,
|
||||
Signal,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
MessageController
|
||||
*/
|
||||
|
||||
const { isFunction, isNumber, omit } = require('lodash');
|
||||
const { computeHash } = require('./types/conversation');
|
||||
const getGuid = require('uuid/v4');
|
||||
const {
|
||||
getMessageById,
|
||||
|
@ -356,17 +364,41 @@ async function _addAttachmentToMessage(message, attachment, { type, index }) {
|
|||
}
|
||||
|
||||
if (type === 'group-avatar') {
|
||||
const group = message.get('group');
|
||||
if (!group) {
|
||||
throw new Error("_addAttachmentToMessage: group didn't exist");
|
||||
const conversationId = message.get('conversationid');
|
||||
const conversation = ConversationController.get(conversationId);
|
||||
if (!conversation) {
|
||||
logger.warn("_addAttachmentToMessage: conversation didn't exist");
|
||||
}
|
||||
|
||||
const existingAvatar = group.avatar;
|
||||
const existingAvatar = conversation.get('avatar');
|
||||
if (existingAvatar && existingAvatar.path) {
|
||||
await Signal.Migrations.deleteAttachmentData(existingAvatar.path);
|
||||
}
|
||||
|
||||
_replaceAttachment(group, 'avatar', attachment, logPrefix);
|
||||
const data = await Signal.Migrations.loadAttachmentData(attachment.path);
|
||||
conversation.set({
|
||||
avatar: {
|
||||
...attachment,
|
||||
hash: await computeHash(data),
|
||||
},
|
||||
});
|
||||
await Signal.Data.updateConversation(
|
||||
conversationId,
|
||||
conversation.attributes,
|
||||
{
|
||||
Conversation: Whisper.Conversation,
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'sticker') {
|
||||
const sticker = message.get('sticker');
|
||||
if (!sticker) {
|
||||
throw new Error("_addAttachmentToMessage: sticker didn't exist");
|
||||
}
|
||||
|
||||
_replaceAttachment(sticker, 'data', attachment, logPrefix);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ module.exports = {
|
|||
arrayBufferToBase64,
|
||||
typedArrayToArrayBuffer,
|
||||
base64ToArrayBuffer,
|
||||
bytesFromHexString,
|
||||
bytesFromString,
|
||||
concatenateBytes,
|
||||
constantTimeEqual,
|
||||
|
@ -16,6 +17,7 @@ module.exports = {
|
|||
decryptFile,
|
||||
decryptSymmetric,
|
||||
deriveAccessKey,
|
||||
deriveStickerPackKey,
|
||||
encryptAesCtr,
|
||||
encryptDeviceName,
|
||||
encryptAttachment,
|
||||
|
@ -25,8 +27,10 @@ module.exports = {
|
|||
getAccessKeyVerifier,
|
||||
getFirstBytes,
|
||||
getRandomBytes,
|
||||
getRandomValue,
|
||||
getViewOfArrayBuffer,
|
||||
getZeroes,
|
||||
hexFromBytes,
|
||||
highBitsToInt,
|
||||
hmacSha256,
|
||||
intsToByteHighAndLow,
|
||||
|
@ -58,6 +62,25 @@ function bytesFromString(string) {
|
|||
function stringFromBytes(buffer) {
|
||||
return dcodeIO.ByteBuffer.wrap(buffer).toString('utf8');
|
||||
}
|
||||
function hexFromBytes(buffer) {
|
||||
return dcodeIO.ByteBuffer.wrap(buffer).toString('hex');
|
||||
}
|
||||
function bytesFromHexString(string) {
|
||||
return dcodeIO.ByteBuffer.wrap(string, 'hex').toArrayBuffer();
|
||||
}
|
||||
|
||||
async function deriveStickerPackKey(packKey) {
|
||||
const salt = getZeroes(32);
|
||||
const info = bytesFromString('Sticker Pack');
|
||||
|
||||
const [part1, part2] = await libsignal.HKDF.deriveSecrets(
|
||||
packKey,
|
||||
salt,
|
||||
info
|
||||
);
|
||||
|
||||
return concatenateBytes(part1, part2);
|
||||
}
|
||||
|
||||
// High-level Operations
|
||||
|
||||
|
@ -366,6 +389,16 @@ function getRandomBytes(n) {
|
|||
return bytes;
|
||||
}
|
||||
|
||||
function getRandomValue(low, high) {
|
||||
const diff = high - low;
|
||||
const bytes = new Uint32Array(1);
|
||||
window.crypto.getRandomValues(bytes);
|
||||
|
||||
// Because high and low are inclusive
|
||||
const mod = diff + 1;
|
||||
return bytes[0] % mod + low;
|
||||
}
|
||||
|
||||
function getZeroes(n) {
|
||||
const result = new Uint8Array(n);
|
||||
|
||||
|
|
18
js/modules/data.d.ts
vendored
18
js/modules/data.d.ts
vendored
|
@ -1,2 +1,20 @@
|
|||
export function searchMessages(query: string): Promise<Array<any>>;
|
||||
export function searchConversations(query: string): Promise<Array<any>>;
|
||||
|
||||
export function updateStickerLastUsed(
|
||||
packId: string,
|
||||
stickerId: number,
|
||||
time: number
|
||||
): Promise<void>;
|
||||
export function updateStickerPackStatus(
|
||||
packId: string,
|
||||
status: 'advertised' | 'installed' | 'error' | 'pending',
|
||||
options?: { timestamp: number }
|
||||
): Promise<void>;
|
||||
|
||||
export function getRecentStickers(): Promise<
|
||||
Array<{
|
||||
id: number;
|
||||
packId: string;
|
||||
}>
|
||||
>;
|
||||
|
|
|
@ -27,6 +27,7 @@ const DATABASE_UPDATE_TIMEOUT = 2 * 60 * 1000; // two minutes
|
|||
const SQL_CHANNEL_KEY = 'sql-channel';
|
||||
const ERASE_SQL_KEY = 'erase-sql-key';
|
||||
const ERASE_ATTACHMENTS_KEY = 'erase-attachments';
|
||||
const ERASE_STICKERS_KEY = 'erase-stickers';
|
||||
const CLEANUP_ORPHANED_ATTACHMENTS_KEY = 'cleanup-orphaned-attachments';
|
||||
|
||||
const _jobs = Object.create(null);
|
||||
|
@ -138,6 +139,17 @@ module.exports = {
|
|||
removeAttachmentDownloadJob,
|
||||
removeAllAttachmentDownloadJobs,
|
||||
|
||||
createOrUpdateStickerPack,
|
||||
updateStickerPackStatus,
|
||||
createOrUpdateSticker,
|
||||
updateStickerLastUsed,
|
||||
addStickerPackReference,
|
||||
deleteStickerPackReference,
|
||||
deleteStickerPack,
|
||||
getAllStickerPacks,
|
||||
getAllStickers,
|
||||
getRecentStickers,
|
||||
|
||||
removeAll,
|
||||
removeAllConfiguration,
|
||||
|
||||
|
@ -884,6 +896,44 @@ async function removeAllAttachmentDownloadJobs() {
|
|||
await channels.removeAllAttachmentDownloadJobs();
|
||||
}
|
||||
|
||||
// Stickers
|
||||
|
||||
async function createOrUpdateStickerPack(pack) {
|
||||
await channels.createOrUpdateStickerPack(pack);
|
||||
}
|
||||
async function updateStickerPackStatus(packId, status, options) {
|
||||
await channels.updateStickerPackStatus(packId, status, options);
|
||||
}
|
||||
async function createOrUpdateSticker(sticker) {
|
||||
await channels.createOrUpdateSticker(sticker);
|
||||
}
|
||||
async function updateStickerLastUsed(packId, stickerId, timestamp) {
|
||||
await channels.updateStickerLastUsed(packId, stickerId, timestamp);
|
||||
}
|
||||
async function addStickerPackReference(messageId, packId) {
|
||||
await channels.addStickerPackReference(messageId, packId);
|
||||
}
|
||||
async function deleteStickerPackReference(messageId, packId) {
|
||||
const paths = await channels.deleteStickerPackReference(messageId, packId);
|
||||
return paths;
|
||||
}
|
||||
async function deleteStickerPack(packId) {
|
||||
const paths = await channels.deleteStickerPack(packId);
|
||||
return paths;
|
||||
}
|
||||
async function getAllStickerPacks() {
|
||||
const packs = await channels.getAllStickerPacks();
|
||||
return packs;
|
||||
}
|
||||
async function getAllStickers() {
|
||||
const stickers = await channels.getAllStickers();
|
||||
return stickers;
|
||||
}
|
||||
async function getRecentStickers() {
|
||||
const recentStickers = await channels.getRecentStickers();
|
||||
return recentStickers;
|
||||
}
|
||||
|
||||
// Other
|
||||
|
||||
async function removeAll() {
|
||||
|
@ -903,6 +953,7 @@ async function removeOtherData() {
|
|||
await Promise.all([
|
||||
callChannel(ERASE_SQL_KEY),
|
||||
callChannel(ERASE_ATTACHMENTS_KEY),
|
||||
callChannel(ERASE_STICKERS_KEY),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
@ -18,6 +18,7 @@ module.exports = {
|
|||
isLinkInWhitelist,
|
||||
isMediaLinkInWhitelist,
|
||||
isLinkSneaky,
|
||||
isStickerPack,
|
||||
};
|
||||
|
||||
const SUPPORTED_DOMAINS = [
|
||||
|
@ -37,7 +38,9 @@ const SUPPORTED_DOMAINS = [
|
|||
'pinterest.com',
|
||||
'www.pinterest.com',
|
||||
'pin.it',
|
||||
'signal.org',
|
||||
];
|
||||
|
||||
function isLinkInWhitelist(link) {
|
||||
try {
|
||||
const url = new URL(link);
|
||||
|
@ -61,6 +64,10 @@ function isLinkInWhitelist(link) {
|
|||
}
|
||||
}
|
||||
|
||||
function isStickerPack(link) {
|
||||
return (link || '').startsWith('https://signal.org/addstickers/');
|
||||
}
|
||||
|
||||
const SUPPORTED_MEDIA_DOMAINS = /^([^.]+\.)*(ytimg.com|cdninstagram.com|redd.it|imgur.com|fbcdn.net|pinimg.com)$/i;
|
||||
function isMediaLinkInWhitelist(link) {
|
||||
try {
|
||||
|
@ -138,28 +145,33 @@ function getDomain(url) {
|
|||
const MB = 1024 * 1024;
|
||||
const KB = 1024;
|
||||
|
||||
function getChunkPattern(size) {
|
||||
function getChunkPattern(size, initialOffset) {
|
||||
if (size > MB) {
|
||||
return _getRequestPattern(size, MB);
|
||||
return _getRequestPattern(size, MB, initialOffset);
|
||||
} else if (size > 500 * KB) {
|
||||
return _getRequestPattern(size, 500 * KB);
|
||||
return _getRequestPattern(size, 500 * KB, initialOffset);
|
||||
} else if (size > 100 * KB) {
|
||||
return _getRequestPattern(size, 100 * KB);
|
||||
return _getRequestPattern(size, 100 * KB, initialOffset);
|
||||
} else if (size > 50 * KB) {
|
||||
return _getRequestPattern(size, 50 * KB);
|
||||
return _getRequestPattern(size, 50 * KB, initialOffset);
|
||||
} else if (size > 10 * KB) {
|
||||
return _getRequestPattern(size, 10 * KB);
|
||||
return _getRequestPattern(size, 10 * KB, initialOffset);
|
||||
} else if (size > KB) {
|
||||
return _getRequestPattern(size, KB);
|
||||
return _getRequestPattern(size, KB, initialOffset);
|
||||
}
|
||||
|
||||
throw new Error(`getChunkPattern: Unsupported size: ${size}`);
|
||||
return {
|
||||
start: {
|
||||
start: initialOffset,
|
||||
end: size - 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function _getRequestPattern(size, increment) {
|
||||
function _getRequestPattern(size, increment, initialOffset) {
|
||||
const results = [];
|
||||
|
||||
let offset = 0;
|
||||
let offset = initialOffset || 0;
|
||||
while (size - offset > increment) {
|
||||
results.push({
|
||||
start: offset,
|
||||
|
|
|
@ -9,6 +9,7 @@ const Emoji = require('../../ts/util/emoji');
|
|||
const IndexedDB = require('./indexeddb');
|
||||
const Notifications = require('../../ts/notifications');
|
||||
const OS = require('../../ts/OS');
|
||||
const Stickers = require('./stickers');
|
||||
const Settings = require('./settings');
|
||||
const Util = require('../../ts/util');
|
||||
const { migrateToSQL } = require('./migrate_to_sql');
|
||||
|
@ -69,8 +70,20 @@ const {
|
|||
|
||||
// State
|
||||
const { createLeftPane } = require('../../ts/state/roots/createLeftPane');
|
||||
const {
|
||||
createStickerButton,
|
||||
} = require('../../ts/state/roots/createStickerButton');
|
||||
const {
|
||||
createStickerManager,
|
||||
} = require('../../ts/state/roots/createStickerManager');
|
||||
const {
|
||||
createStickerPreviewModal,
|
||||
} = require('../../ts/state/roots/createStickerPreviewModal');
|
||||
|
||||
const { createStore } = require('../../ts/state/createStore');
|
||||
const conversationsDuck = require('../../ts/state/ducks/conversations');
|
||||
const itemsDuck = require('../../ts/state/ducks/items');
|
||||
const stickersDuck = require('../../ts/state/ducks/stickers');
|
||||
const userDuck = require('../../ts/state/ducks/user');
|
||||
|
||||
// Migrations
|
||||
|
@ -112,6 +125,7 @@ function initializeMigrations({
|
|||
}
|
||||
const {
|
||||
getPath,
|
||||
getStickersPath,
|
||||
createReader,
|
||||
createAbsolutePathGetter,
|
||||
createWriterForNew,
|
||||
|
@ -130,25 +144,40 @@ function initializeMigrations({
|
|||
const loadAttachmentData = Type.loadData(readAttachmentData);
|
||||
const loadPreviewData = MessageType.loadPreviewData(loadAttachmentData);
|
||||
const loadQuoteData = MessageType.loadQuoteData(loadAttachmentData);
|
||||
const loadStickerData = MessageType.loadStickerData(loadAttachmentData);
|
||||
const getAbsoluteAttachmentPath = createAbsolutePathGetter(attachmentsPath);
|
||||
const deleteOnDisk = Attachments.createDeleter(attachmentsPath);
|
||||
const writeNewAttachmentData = createWriterForNew(attachmentsPath);
|
||||
const copyIntoAttachmentsDirectory = Attachments.copyIntoAttachmentsDirectory(
|
||||
attachmentsPath
|
||||
);
|
||||
|
||||
const stickersPath = getStickersPath(userDataPath);
|
||||
const writeNewStickerData = createWriterForNew(stickersPath);
|
||||
const getAbsoluteStickerPath = createAbsolutePathGetter(stickersPath);
|
||||
const deleteSticker = Attachments.createDeleter(stickersPath);
|
||||
const readStickerData = createReader(stickersPath);
|
||||
|
||||
return {
|
||||
attachmentsPath,
|
||||
copyIntoAttachmentsDirectory,
|
||||
deleteAttachmentData: deleteOnDisk,
|
||||
deleteExternalMessageFiles: MessageType.deleteAllExternalFiles({
|
||||
deleteAttachmentData: Type.deleteData(deleteOnDisk),
|
||||
deleteOnDisk,
|
||||
}),
|
||||
deleteSticker,
|
||||
getAbsoluteAttachmentPath,
|
||||
getAbsoluteStickerPath,
|
||||
getPlaceholderMigrations,
|
||||
getCurrentVersion,
|
||||
loadAttachmentData,
|
||||
loadMessage: MessageType.createAttachmentLoader(loadAttachmentData),
|
||||
loadPreviewData,
|
||||
loadQuoteData,
|
||||
loadStickerData,
|
||||
readAttachmentData,
|
||||
readStickerData,
|
||||
run,
|
||||
processNewAttachment: attachment =>
|
||||
MessageType.processNewAttachment(attachment, {
|
||||
|
@ -161,6 +190,13 @@ function initializeMigrations({
|
|||
makeVideoScreenshot,
|
||||
logger,
|
||||
}),
|
||||
processNewSticker: stickerData =>
|
||||
MessageType.processNewSticker(stickerData, {
|
||||
writeNewStickerData,
|
||||
getAbsoluteStickerPath,
|
||||
getImageDimensions,
|
||||
logger,
|
||||
}),
|
||||
upgradeMessageSchema: (message, options = {}) => {
|
||||
const { maxVersion } = options;
|
||||
|
||||
|
@ -227,10 +263,15 @@ exports.setup = (options = {}) => {
|
|||
|
||||
const Roots = {
|
||||
createLeftPane,
|
||||
createStickerButton,
|
||||
createStickerManager,
|
||||
createStickerPreviewModal,
|
||||
};
|
||||
const Ducks = {
|
||||
conversations: conversationsDuck,
|
||||
items: itemsDuck,
|
||||
user: userDuck,
|
||||
stickers: stickersDuck,
|
||||
};
|
||||
const State = {
|
||||
bindActionCreators,
|
||||
|
@ -278,6 +319,7 @@ exports.setup = (options = {}) => {
|
|||
RefreshSenderCertificate,
|
||||
Settings,
|
||||
State,
|
||||
Stickers,
|
||||
Types,
|
||||
Util,
|
||||
Views,
|
||||
|
|
1
js/modules/stickers.d.ts
vendored
Normal file
1
js/modules/stickers.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
export function maybeDeletePack(packId: string): Promise<void>;
|
495
js/modules/stickers.js
Normal file
495
js/modules/stickers.js
Normal file
|
@ -0,0 +1,495 @@
|
|||
/* global
|
||||
textsecure,
|
||||
Signal,
|
||||
log,
|
||||
reduxStore,
|
||||
reduxActions,
|
||||
URL
|
||||
*/
|
||||
|
||||
const BLESSED_PACKS = {};
|
||||
|
||||
const { isNumber, pick, reject, groupBy } = require('lodash');
|
||||
const pMap = require('p-map');
|
||||
const Queue = require('p-queue');
|
||||
const qs = require('qs');
|
||||
|
||||
const { makeLookup } = require('../../ts/util/makeLookup');
|
||||
const { base64ToArrayBuffer, deriveStickerPackKey } = require('./crypto');
|
||||
const {
|
||||
addStickerPackReference,
|
||||
createOrUpdateSticker,
|
||||
createOrUpdateStickerPack,
|
||||
deleteStickerPack,
|
||||
deleteStickerPackReference,
|
||||
getAllStickerPacks,
|
||||
getAllStickers,
|
||||
getRecentStickers,
|
||||
updateStickerPackStatus,
|
||||
} = require('./data');
|
||||
|
||||
module.exports = {
|
||||
BLESSED_PACKS,
|
||||
copyStickerToAttachments,
|
||||
deletePack,
|
||||
deletePackReference,
|
||||
downloadStickerPack,
|
||||
getDataFromLink,
|
||||
getInitialState,
|
||||
getInstalledStickerPacks,
|
||||
getSticker,
|
||||
getStickerPack,
|
||||
getStickerPackStatus,
|
||||
load,
|
||||
maybeDeletePack,
|
||||
downloadQueuedPacks,
|
||||
redactPackId,
|
||||
};
|
||||
|
||||
let initialState = null;
|
||||
let packsToDownload = null;
|
||||
const downloadQueue = new Queue({ concurrency: 1 });
|
||||
|
||||
async function load() {
|
||||
const [packs, recentStickers] = await Promise.all([
|
||||
getPacksForRedux(),
|
||||
getRecentStickersForRedux(),
|
||||
]);
|
||||
|
||||
initialState = {
|
||||
packs,
|
||||
recentStickers,
|
||||
blessedPacks: BLESSED_PACKS,
|
||||
};
|
||||
|
||||
packsToDownload = capturePacksToDownload(packs);
|
||||
}
|
||||
|
||||
function getDataFromLink(link) {
|
||||
const { hash } = new URL(link);
|
||||
if (!hash) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = hash.slice(1);
|
||||
const params = qs.parse(data);
|
||||
|
||||
return {
|
||||
id: params.pack_id,
|
||||
key: params.pack_key,
|
||||
};
|
||||
}
|
||||
|
||||
function getInstalledStickerPacks() {
|
||||
const state = reduxStore.getState();
|
||||
const { stickers } = state;
|
||||
const { packs } = stickers;
|
||||
if (!packs) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const values = Object.values(packs);
|
||||
return values.filter(pack => pack.status === 'installed');
|
||||
}
|
||||
|
||||
function downloadQueuedPacks() {
|
||||
const ids = Object.keys(packsToDownload);
|
||||
ids.forEach(id => {
|
||||
const { key, status } = packsToDownload[id];
|
||||
|
||||
// The queuing is done inside this function, no need to await here
|
||||
downloadStickerPack(id, key, { finalStatus: status });
|
||||
});
|
||||
|
||||
packsToDownload = {};
|
||||
}
|
||||
|
||||
function capturePacksToDownload(existingPackLookup) {
|
||||
const toDownload = Object.create(null);
|
||||
|
||||
// First, ensure that blessed packs are in good shape
|
||||
const blessedIds = Object.keys(BLESSED_PACKS);
|
||||
blessedIds.forEach(id => {
|
||||
const existing = existingPackLookup[id];
|
||||
if (
|
||||
!existing ||
|
||||
(existing.status !== 'advertised' && existing.status !== 'installed')
|
||||
) {
|
||||
toDownload[id] = {
|
||||
id,
|
||||
...BLESSED_PACKS[id],
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Then, find error cases in packs we already know about
|
||||
const existingIds = Object.keys(existingPackLookup);
|
||||
existingIds.forEach(id => {
|
||||
if (toDownload[id]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = existingPackLookup[id];
|
||||
if (doesPackNeedDownload(existing)) {
|
||||
toDownload[id] = {
|
||||
id,
|
||||
key: existing.key,
|
||||
status: existing.attemptedStatus,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return toDownload;
|
||||
}
|
||||
|
||||
function doesPackNeedDownload(pack) {
|
||||
if (!pack) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const stickerCount = Object.keys(pack.stickers || {}).length;
|
||||
return (
|
||||
!pack.status ||
|
||||
pack.status === 'error' ||
|
||||
pack.status === 'pending' ||
|
||||
!pack.stickerCount ||
|
||||
stickerCount < pack.stickerCount
|
||||
);
|
||||
}
|
||||
|
||||
async function getPacksForRedux() {
|
||||
const [packs, stickers] = await Promise.all([
|
||||
getAllStickerPacks(),
|
||||
getAllStickers(),
|
||||
]);
|
||||
|
||||
const stickersByPack = groupBy(stickers, sticker => sticker.packId);
|
||||
const fullSet = packs.map(pack => ({
|
||||
...pack,
|
||||
stickers: makeLookup(stickersByPack[pack.id] || [], 'id'),
|
||||
}));
|
||||
|
||||
return makeLookup(fullSet, 'id');
|
||||
}
|
||||
|
||||
async function getRecentStickersForRedux() {
|
||||
const recent = await getRecentStickers();
|
||||
return recent.map(sticker => ({
|
||||
packId: sticker.packId,
|
||||
stickerId: sticker.id,
|
||||
}));
|
||||
}
|
||||
|
||||
function getInitialState() {
|
||||
return initialState;
|
||||
}
|
||||
|
||||
function redactPackId(packId) {
|
||||
return `[REDACTED]${packId.slice(-3)}`;
|
||||
}
|
||||
|
||||
function getReduxStickerActions() {
|
||||
const actions = reduxActions;
|
||||
|
||||
if (actions && actions.stickers) {
|
||||
return actions.stickers;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
async function decryptSticker(packKey, ciphertext) {
|
||||
const binaryKey = base64ToArrayBuffer(packKey);
|
||||
const derivedKey = await deriveStickerPackKey(binaryKey);
|
||||
const plaintext = await textsecure.crypto.decryptAttachment(
|
||||
ciphertext,
|
||||
derivedKey
|
||||
);
|
||||
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
async function downloadSticker(packId, packKey, proto) {
|
||||
const ciphertext = await textsecure.messaging.getSticker(packId, proto.id);
|
||||
const plaintext = await decryptSticker(packKey, ciphertext);
|
||||
const sticker = await Signal.Migrations.processNewSticker(plaintext);
|
||||
|
||||
return {
|
||||
...pick(proto, ['id', 'emoji']),
|
||||
...sticker,
|
||||
packId,
|
||||
};
|
||||
}
|
||||
|
||||
async function downloadStickerPack(packId, packKey, options = {}) {
|
||||
// This will ensure that only one download process is in progress at any given time
|
||||
return downloadQueue.add(async () => {
|
||||
try {
|
||||
await doDownloadStickerPack(packId, packKey, options);
|
||||
} catch (error) {
|
||||
log.error(
|
||||
'doDownloadStickerPack threw an error:',
|
||||
error && error.stack ? error.stack : error
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function doDownloadStickerPack(packId, packKey, options = {}) {
|
||||
const { messageId, fromSync } = options;
|
||||
const {
|
||||
stickerAdded,
|
||||
stickerPackAdded,
|
||||
stickerPackUpdated,
|
||||
installStickerPack,
|
||||
} = getReduxStickerActions();
|
||||
|
||||
const finalStatus = options.finalStatus || 'advertised';
|
||||
|
||||
const existing = getStickerPack(packId);
|
||||
if (!doesPackNeedDownload(existing)) {
|
||||
log.warn(
|
||||
`Download for pack ${redactPackId(
|
||||
packId
|
||||
)} requested, but it does not need re-download. Skipping.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const downloadAttempts = (existing ? existing.downloadAttempts || 0 : 0) + 1;
|
||||
if (downloadAttempts > 3) {
|
||||
log.warn(
|
||||
`Refusing to attempt another download for pack ${redactPackId(
|
||||
packId
|
||||
)}, attempt number ${downloadAttempts}`
|
||||
);
|
||||
|
||||
if (existing.status !== 'error') {
|
||||
await updateStickerPackStatus(packId, 'error');
|
||||
stickerPackUpdated(packId, {
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let coverProto;
|
||||
let coverStickerId;
|
||||
let coverIncludedInList;
|
||||
let nonCoverStickers;
|
||||
|
||||
try {
|
||||
const ciphertext = await textsecure.messaging.getStickerPackManifest(
|
||||
packId
|
||||
);
|
||||
const plaintext = await decryptSticker(packKey, ciphertext);
|
||||
const proto = textsecure.protobuf.StickerPack.decode(plaintext);
|
||||
const firstStickerProto = proto.stickers ? proto.stickers[0] : null;
|
||||
const stickerCount = proto.stickers.length;
|
||||
|
||||
coverProto = proto.cover || firstStickerProto;
|
||||
coverStickerId = coverProto ? coverProto.id : null;
|
||||
|
||||
if (!coverProto || !isNumber(coverStickerId)) {
|
||||
throw new Error(
|
||||
`Sticker pack ${redactPackId(
|
||||
packId
|
||||
)} is malformed - it has no cover, and no stickers`
|
||||
);
|
||||
}
|
||||
|
||||
nonCoverStickers = reject(
|
||||
proto.stickers,
|
||||
sticker => !isNumber(sticker.id) || sticker.id === coverStickerId
|
||||
);
|
||||
|
||||
coverIncludedInList = nonCoverStickers.length < stickerCount;
|
||||
|
||||
// status can be:
|
||||
// - 'pending'
|
||||
// - 'advertised'
|
||||
// - 'error'
|
||||
// - 'installed'
|
||||
const pack = {
|
||||
id: packId,
|
||||
key: packKey,
|
||||
attemptedStatus: finalStatus,
|
||||
coverStickerId,
|
||||
downloadAttempts,
|
||||
stickerCount,
|
||||
status: 'pending',
|
||||
...pick(proto, ['title', 'author']),
|
||||
};
|
||||
await createOrUpdateStickerPack(pack);
|
||||
stickerPackAdded(pack);
|
||||
|
||||
if (messageId) {
|
||||
await addStickerPackReference(messageId, packId);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(
|
||||
`Error downloading manifest for sticker pack ${redactPackId(packId)}:`,
|
||||
error && error.stack ? error.stack : error
|
||||
);
|
||||
|
||||
const pack = {
|
||||
id: packId,
|
||||
key: packKey,
|
||||
attemptedStatus: finalStatus,
|
||||
downloadAttempts,
|
||||
status: 'error',
|
||||
};
|
||||
await createOrUpdateStickerPack(pack);
|
||||
stickerPackAdded(pack);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// We have a separate try/catch here because we're starting to download stickers here
|
||||
// and we want to preserve more of the pack on an error.
|
||||
try {
|
||||
const downloadStickerJob = async stickerProto => {
|
||||
const stickerInfo = await downloadSticker(packId, packKey, stickerProto);
|
||||
const sticker = {
|
||||
...stickerInfo,
|
||||
isCoverOnly: !coverIncludedInList && stickerInfo.id === coverStickerId,
|
||||
};
|
||||
await createOrUpdateSticker(sticker);
|
||||
stickerAdded(sticker);
|
||||
};
|
||||
|
||||
// Download the cover first
|
||||
await downloadStickerJob(coverProto);
|
||||
|
||||
// Then the rest
|
||||
await pMap(nonCoverStickers, downloadStickerJob, { concurrency: 3 });
|
||||
|
||||
if (finalStatus === 'installed') {
|
||||
await installStickerPack(packId, packKey, { fromSync });
|
||||
} else {
|
||||
// Mark the pack as complete
|
||||
await updateStickerPackStatus(packId, finalStatus);
|
||||
stickerPackUpdated(packId, {
|
||||
status: finalStatus,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(
|
||||
`Error downloading stickers for sticker pack ${redactPackId(packId)}:`,
|
||||
error && error.stack ? error.stack : error
|
||||
);
|
||||
|
||||
const errorState = 'error';
|
||||
await updateStickerPackStatus(packId, errorState);
|
||||
if (stickerPackUpdated) {
|
||||
stickerPackUpdated(packId, {
|
||||
state: errorState,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getStickerPack(packId) {
|
||||
const state = reduxStore.getState();
|
||||
const { stickers } = state;
|
||||
const { packs } = stickers;
|
||||
if (!packs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return packs[packId];
|
||||
}
|
||||
|
||||
function getStickerPackStatus(packId) {
|
||||
const pack = getStickerPack(packId);
|
||||
if (!pack) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return pack.status;
|
||||
}
|
||||
|
||||
function getSticker(packId, stickerId) {
|
||||
const state = reduxStore.getState();
|
||||
const { stickers } = state;
|
||||
const { packs } = stickers;
|
||||
const pack = packs[packId];
|
||||
|
||||
if (!pack || !pack.stickers) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return pack.stickers[stickerId];
|
||||
}
|
||||
|
||||
async function copyStickerToAttachments(packId, stickerId) {
|
||||
const sticker = getSticker(packId, stickerId);
|
||||
if (!sticker) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { path } = sticker;
|
||||
const absolutePath = Signal.Migrations.getAbsoluteStickerPath(path);
|
||||
const newPath = await Signal.Migrations.copyIntoAttachmentsDirectory(
|
||||
absolutePath
|
||||
);
|
||||
|
||||
return {
|
||||
...sticker,
|
||||
path: newPath,
|
||||
};
|
||||
}
|
||||
|
||||
// In the case where a sticker pack is uninstalled, we want to delete it if there are no
|
||||
// more references left. We'll delete a nonexistent reference, then check if there are
|
||||
// any references left, just like usual.
|
||||
async function maybeDeletePack(packId) {
|
||||
// This hardcoded string is fine because message ids are GUIDs
|
||||
await deletePackReference('NOT-USED', packId);
|
||||
}
|
||||
|
||||
// We don't generally delete packs outright; we just remove references to them, and if
|
||||
// the last reference is deleted, we finally then remove the pack itself from database
|
||||
// and from disk.
|
||||
async function deletePackReference(messageId, packId) {
|
||||
const isBlessed = Boolean(BLESSED_PACKS[packId]);
|
||||
if (isBlessed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// This call uses locking to prevent race conditions with other reference removals,
|
||||
// or an incoming message creating a new message->pack reference
|
||||
const paths = await deleteStickerPackReference(messageId, packId);
|
||||
|
||||
// If we don't get a list of paths back, then the sticker pack was not deleted
|
||||
if (!paths) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { removeStickerPack } = getReduxStickerActions();
|
||||
removeStickerPack(packId);
|
||||
|
||||
await pMap(paths, Signal.Migrations.deleteSticker, {
|
||||
concurrency: 3,
|
||||
});
|
||||
}
|
||||
|
||||
// The override; doesn't honor our ref-counting scheme - just deletes it all.
|
||||
async function deletePack(packId) {
|
||||
const isBlessed = Boolean(BLESSED_PACKS[packId]);
|
||||
if (isBlessed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// This call uses locking to prevent race conditions with other reference removals,
|
||||
// or an incoming message creating a new message->pack reference
|
||||
const paths = await deleteStickerPack(packId);
|
||||
|
||||
const { removeStickerPack } = getReduxStickerActions();
|
||||
removeStickerPack(packId);
|
||||
|
||||
await pMap(paths, Signal.Migrations.deleteSticker, {
|
||||
concurrency: 3,
|
||||
});
|
||||
}
|
|
@ -179,7 +179,7 @@ exports.loadData = readAttachmentData => {
|
|||
}
|
||||
|
||||
const data = await readAttachmentData(attachment.path);
|
||||
return Object.assign({}, attachment, { data });
|
||||
return Object.assign({}, attachment, { data, size: data.byteLength });
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -140,11 +140,12 @@ async function deleteExternalFiles(conversation, options = {}) {
|
|||
}
|
||||
|
||||
module.exports = {
|
||||
deleteExternalFiles,
|
||||
migrateConversation,
|
||||
maybeUpdateAvatar,
|
||||
maybeUpdateProfileAvatar,
|
||||
createLastMessageUpdate,
|
||||
arrayBufferToBase64,
|
||||
base64ToArrayBuffer,
|
||||
computeHash,
|
||||
createLastMessageUpdate,
|
||||
deleteExternalFiles,
|
||||
maybeUpdateAvatar,
|
||||
maybeUpdateProfileAvatar,
|
||||
migrateConversation,
|
||||
};
|
||||
|
|
|
@ -308,7 +308,33 @@ const toVersion9 = exports._withSchemaVersion({
|
|||
});
|
||||
const toVersion10 = exports._withSchemaVersion({
|
||||
schemaVersion: 10,
|
||||
upgrade: exports._mapPreviewAttachments(Attachment.migrateDataToFileSystem),
|
||||
upgrade: async (message, context) => {
|
||||
const processPreviews = exports._mapPreviewAttachments(
|
||||
Attachment.migrateDataToFileSystem
|
||||
);
|
||||
const processSticker = async (stickerMessage, stickerContext) => {
|
||||
const { sticker } = stickerMessage;
|
||||
if (!sticker || !sticker.data || !sticker.data.data) {
|
||||
return stickerMessage;
|
||||
}
|
||||
|
||||
return {
|
||||
...stickerMessage,
|
||||
sticker: {
|
||||
...sticker,
|
||||
data: await Attachment.migrateDataToFileSystem(
|
||||
sticker.data,
|
||||
stickerContext
|
||||
),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const previewProcessed = await processPreviews(message, context);
|
||||
const stickerProcessed = await processSticker(previewProcessed, context);
|
||||
|
||||
return stickerProcessed;
|
||||
},
|
||||
});
|
||||
|
||||
const VERSIONS = [
|
||||
|
@ -462,6 +488,44 @@ exports.processNewAttachment = async (
|
|||
return finalAttachment;
|
||||
};
|
||||
|
||||
exports.processNewSticker = async (
|
||||
stickerData,
|
||||
{
|
||||
writeNewStickerData,
|
||||
getAbsoluteStickerPath,
|
||||
getImageDimensions,
|
||||
logger,
|
||||
} = {}
|
||||
) => {
|
||||
if (!isFunction(writeNewStickerData)) {
|
||||
throw new TypeError('context.writeNewStickerData is required');
|
||||
}
|
||||
if (!isFunction(getAbsoluteStickerPath)) {
|
||||
throw new TypeError('context.getAbsoluteStickerPath is required');
|
||||
}
|
||||
if (!isFunction(getImageDimensions)) {
|
||||
throw new TypeError('context.getImageDimensions is required');
|
||||
}
|
||||
if (!isObject(logger)) {
|
||||
throw new TypeError('context.logger is required');
|
||||
}
|
||||
|
||||
const path = await writeNewStickerData(stickerData);
|
||||
const absolutePath = await getAbsoluteStickerPath(path);
|
||||
|
||||
const { width, height } = await getImageDimensions({
|
||||
objectUrl: absolutePath,
|
||||
logger,
|
||||
});
|
||||
|
||||
return {
|
||||
contentType: 'image/webp',
|
||||
path,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
};
|
||||
|
||||
exports.createAttachmentLoader = loadAttachmentData => {
|
||||
if (!isFunction(loadAttachmentData)) {
|
||||
throw new TypeError(
|
||||
|
@ -532,6 +596,23 @@ exports.loadPreviewData = loadAttachmentData => {
|
|||
};
|
||||
};
|
||||
|
||||
exports.loadStickerData = loadAttachmentData => {
|
||||
if (!isFunction(loadAttachmentData)) {
|
||||
throw new TypeError('loadStickerData: loadAttachmentData is required');
|
||||
}
|
||||
|
||||
return async sticker => {
|
||||
if (!sticker || !sticker.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...sticker,
|
||||
data: await loadAttachmentData(sticker.data),
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
exports.deleteAllExternalFiles = ({ deleteAttachmentData, deleteOnDisk }) => {
|
||||
if (!isFunction(deleteAttachmentData)) {
|
||||
throw new TypeError(
|
||||
|
@ -546,7 +627,7 @@ exports.deleteAllExternalFiles = ({ deleteAttachmentData, deleteOnDisk }) => {
|
|||
}
|
||||
|
||||
return async message => {
|
||||
const { attachments, quote, contact, preview } = message;
|
||||
const { attachments, quote, contact, preview, sticker } = message;
|
||||
|
||||
if (attachments && attachments.length) {
|
||||
await Promise.all(attachments.map(deleteAttachmentData));
|
||||
|
@ -590,6 +671,14 @@ exports.deleteAllExternalFiles = ({ deleteAttachmentData, deleteOnDisk }) => {
|
|||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (sticker && sticker.data && sticker.data.path) {
|
||||
await deleteOnDisk(sticker.data.path);
|
||||
|
||||
if (sticker.data.thumbnail && sticker.data.thumbnail.path) {
|
||||
await deleteOnDisk(sticker.data.thumbnail.path);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -4,8 +4,9 @@ const ProxyAgent = require('proxy-agent');
|
|||
const { Agent } = require('https');
|
||||
|
||||
const is = require('@sindresorhus/is');
|
||||
const { redactPackId } = require('./stickers');
|
||||
|
||||
/* global Buffer, setTimeout, log, _, getGuid */
|
||||
/* global Signal, Buffer, setTimeout, log, _, getGuid */
|
||||
|
||||
/* eslint-disable more/no-then, no-bitwise, no-nested-ternary */
|
||||
|
||||
|
@ -175,16 +176,12 @@ function getContentType(response) {
|
|||
function _promiseAjax(providedUrl, options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = providedUrl || `${options.host}/${options.path}`;
|
||||
if (options.disableLogs) {
|
||||
log.info(
|
||||
`${options.type} [REDACTED_URL]${
|
||||
options.unauthenticated ? ' (unauth)' : ''
|
||||
}`
|
||||
);
|
||||
|
||||
const unauthLabel = options.unauthenticated ? ' (unauth)' : '';
|
||||
if (options.redactUrl) {
|
||||
log.info(`${options.type} ${options.redactUrl(url)}${unauthLabel}`);
|
||||
} else {
|
||||
log.info(
|
||||
`${options.type} ${url}${options.unauthenticated ? ' (unauth)' : ''}`
|
||||
);
|
||||
log.info(`${options.type} ${url}${unauthLabel}`);
|
||||
}
|
||||
|
||||
const timeout =
|
||||
|
@ -282,10 +279,10 @@ function _promiseAjax(providedUrl, options) {
|
|||
if (options.responseType === 'json') {
|
||||
if (options.validateResponse) {
|
||||
if (!_validateResponse(result, options.validateResponse)) {
|
||||
if (options.disableLogs) {
|
||||
if (options.redactUrl) {
|
||||
log.info(
|
||||
options.type,
|
||||
'[REDACTED_URL]',
|
||||
options.redactUrl(url),
|
||||
response.status,
|
||||
'Error'
|
||||
);
|
||||
|
@ -304,10 +301,10 @@ function _promiseAjax(providedUrl, options) {
|
|||
}
|
||||
}
|
||||
if (response.status >= 0 && response.status < 400) {
|
||||
if (options.disableLogs) {
|
||||
if (options.redactUrl) {
|
||||
log.info(
|
||||
options.type,
|
||||
'[REDACTED_URL]',
|
||||
options.redactUrl(url),
|
||||
response.status,
|
||||
'Success'
|
||||
);
|
||||
|
@ -324,8 +321,13 @@ function _promiseAjax(providedUrl, options) {
|
|||
return resolve(result, response.status);
|
||||
}
|
||||
|
||||
if (options.disableLogs) {
|
||||
log.info(options.type, '[REDACTED_URL]', response.status, 'Error');
|
||||
if (options.redactUrl) {
|
||||
log.info(
|
||||
options.type,
|
||||
options.redactUrl(url),
|
||||
response.status,
|
||||
'Error'
|
||||
);
|
||||
} else {
|
||||
log.error(options.type, url, response.status, 'Error');
|
||||
}
|
||||
|
@ -340,8 +342,8 @@ function _promiseAjax(providedUrl, options) {
|
|||
});
|
||||
})
|
||||
.catch(e => {
|
||||
if (options.disableLogs) {
|
||||
log.error(options.type, '[REDACTED_URL]', 0, 'Error');
|
||||
if (options.redactUrl) {
|
||||
log.error(options.type, options.redactUrl(url), 0, 'Error');
|
||||
} else {
|
||||
log.error(options.type, url, 0, 'Error');
|
||||
}
|
||||
|
@ -435,6 +437,7 @@ function initialize({
|
|||
function connect({ username: initialUsername, password: initialPassword }) {
|
||||
let username = initialUsername;
|
||||
let password = initialPassword;
|
||||
const PARSE_RANGE_HEADER = /\/(\d+)$/;
|
||||
|
||||
// Thanks, function hoisting!
|
||||
return {
|
||||
|
@ -449,8 +452,9 @@ function initialize({
|
|||
getProfile,
|
||||
getProfileUnauth,
|
||||
getProvisioningSocket,
|
||||
getProxiedSize,
|
||||
getSenderCertificate,
|
||||
getSticker,
|
||||
getStickerPackManifest,
|
||||
makeProxiedRequest,
|
||||
putAttachment,
|
||||
registerKeys,
|
||||
|
@ -834,6 +838,33 @@ function initialize({
|
|||
});
|
||||
}
|
||||
|
||||
function redactStickerUrl(stickerUrl) {
|
||||
return stickerUrl.replace(
|
||||
/(\/stickers\/)([^/]+)(\/)/,
|
||||
(match, begin, packId, end) => `${begin}${redactPackId(packId)}${end}`
|
||||
);
|
||||
}
|
||||
|
||||
function getSticker(packId, stickerId) {
|
||||
return _outerAjax(`${cdnUrl}/stickers/${packId}/full/${stickerId}`, {
|
||||
certificateAuthority,
|
||||
proxyUrl,
|
||||
responseType: 'arraybuffer',
|
||||
type: 'GET',
|
||||
redactUrl: redactStickerUrl,
|
||||
});
|
||||
}
|
||||
|
||||
function getStickerPackManifest(packId) {
|
||||
return _outerAjax(`${cdnUrl}/stickers/${packId}/manifest.proto`, {
|
||||
certificateAuthority,
|
||||
proxyUrl,
|
||||
responseType: 'arraybuffer',
|
||||
type: 'GET',
|
||||
redactUrl: redactStickerUrl,
|
||||
});
|
||||
}
|
||||
|
||||
async function getAttachment(id) {
|
||||
// This is going to the CDN, not the service, so we use _outerAjax
|
||||
return _outerAjax(`${cdnUrl}/attachments/${id}`, {
|
||||
|
@ -918,45 +949,64 @@ function initialize({
|
|||
return attachmentIdString;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-shadow
|
||||
async function getProxiedSize(url) {
|
||||
const result = await _outerAjax(url, {
|
||||
processData: false,
|
||||
responseType: 'arraybufferwithdetails',
|
||||
proxyUrl: contentProxyUrl,
|
||||
type: 'HEAD',
|
||||
disableLogs: true,
|
||||
});
|
||||
function getHeaderPadding() {
|
||||
const length = Signal.Crypto.getRandomValue(1, 64);
|
||||
let characters = '';
|
||||
|
||||
const { response } = result;
|
||||
if (!response.headers || !response.headers.get) {
|
||||
throw new Error('getProxiedSize: Problem retrieving header value');
|
||||
for (let i = 0, max = length; i < max; i += 1) {
|
||||
characters += String.fromCharCode(
|
||||
Signal.Crypto.getRandomValue(65, 122)
|
||||
);
|
||||
}
|
||||
|
||||
const size = response.headers.get('content-length');
|
||||
return parseInt(size, 10);
|
||||
return characters;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-shadow
|
||||
function makeProxiedRequest(url, options = {}) {
|
||||
async function makeProxiedRequest(url, options = {}) {
|
||||
const { returnArrayBuffer, start, end } = options;
|
||||
let headers;
|
||||
const headers = {
|
||||
'X-SignalPadding': getHeaderPadding(),
|
||||
};
|
||||
|
||||
if (_.isNumber(start) && _.isNumber(end)) {
|
||||
headers = {
|
||||
Range: `bytes=${start}-${end}`,
|
||||
};
|
||||
headers.Range = `bytes=${start}-${end}`;
|
||||
}
|
||||
|
||||
return _outerAjax(url, {
|
||||
const result = await _outerAjax(url, {
|
||||
processData: false,
|
||||
responseType: returnArrayBuffer ? 'arraybufferwithdetails' : null,
|
||||
proxyUrl: contentProxyUrl,
|
||||
type: 'GET',
|
||||
redirect: 'follow',
|
||||
disableLogs: true,
|
||||
redactUrl: () => '[REDACTED_URL]',
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!returnArrayBuffer) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const { response } = result;
|
||||
if (!response.headers || !response.headers.get) {
|
||||
throw new Error('makeProxiedRequest: Problem retrieving header value');
|
||||
}
|
||||
|
||||
const range = response.headers.get('content-range');
|
||||
const match = PARSE_RANGE_HEADER.exec(range);
|
||||
|
||||
if (!match || !match[1]) {
|
||||
throw new Error(
|
||||
`makeProxiedRequest: Unable to parse total size from ${range}`
|
||||
);
|
||||
}
|
||||
|
||||
const totalSize = parseInt(match[1], 10);
|
||||
|
||||
return {
|
||||
totalSize,
|
||||
result,
|
||||
};
|
||||
}
|
||||
|
||||
function getMessageSocket() {
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
/* global _ */
|
||||
/* eslint-disable more/no-then */
|
||||
|
||||
// eslint-disable-next-line func-names
|
||||
|
@ -24,6 +25,10 @@
|
|||
|
||||
items[key] = data;
|
||||
await window.Signal.Data.createOrUpdateItem(data);
|
||||
|
||||
if (_.has(window, ['reduxActions', 'items', 'putItemExternal'])) {
|
||||
window.reduxActions.items.putItemExternal(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
function get(key, defaultValue) {
|
||||
|
@ -46,6 +51,10 @@
|
|||
|
||||
delete items[key];
|
||||
await window.Signal.Data.removeItemById(key);
|
||||
|
||||
if (_.has(window, ['reduxActions', 'items', 'removeItemExternal'])) {
|
||||
window.reduxActions.items.removeItemExternal(key);
|
||||
}
|
||||
}
|
||||
|
||||
function onready(callback) {
|
||||
|
@ -77,6 +86,10 @@
|
|||
callListeners();
|
||||
}
|
||||
|
||||
function getItemsState() {
|
||||
return _.clone(items);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
ready = false;
|
||||
items = Object.create(null);
|
||||
|
@ -86,6 +99,7 @@
|
|||
fetch,
|
||||
put,
|
||||
get,
|
||||
getItemsState,
|
||||
remove,
|
||||
onready,
|
||||
reset,
|
||||
|
|
|
@ -104,6 +104,9 @@
|
|||
);
|
||||
this.listenTo(this.model.messageCollection, 'force-send', this.forceSend);
|
||||
this.listenTo(this.model.messageCollection, 'delete', this.deleteMessage);
|
||||
this.listenTo(this.model.messageCollection, 'height-changed', () =>
|
||||
this.view.scrollToBottomIfNeeded()
|
||||
);
|
||||
this.listenTo(
|
||||
this.model.messageCollection,
|
||||
'scroll-to-message',
|
||||
|
@ -276,15 +279,18 @@
|
|||
this.$('.send-message').blur(this.unfocusBottomBar.bind(this));
|
||||
|
||||
this.$emojiPanelContainer = this.$('.emoji-panel-container');
|
||||
|
||||
this.setupStickerPickerButton();
|
||||
},
|
||||
|
||||
events: {
|
||||
keydown: 'onKeyDown',
|
||||
'submit .send': 'checkUnverifiedSendMessage',
|
||||
'submit .send': 'clickSend',
|
||||
'input .send-message': 'updateMessageFieldSize',
|
||||
'keydown .send-message': 'updateMessageFieldSize',
|
||||
'keyup .send-message': 'onKeyUp',
|
||||
click: 'onClick',
|
||||
'click .sticker-button-placeholder': 'onClickStickerButtonPlaceholder',
|
||||
'click .bottom-bar': 'focusMessageField',
|
||||
'click .capture-audio .microphone': 'captureAudio',
|
||||
'click .module-scroll-down': 'scrollToBottom',
|
||||
|
@ -308,6 +314,28 @@
|
|||
paste: 'onPaste',
|
||||
},
|
||||
|
||||
setupStickerPickerButton() {
|
||||
const props = {
|
||||
onClickAddPack: () => this.showStickerManager(),
|
||||
onPickSticker: (packId, stickerId) =>
|
||||
this.sendStickerMessage({ packId, stickerId }),
|
||||
};
|
||||
|
||||
this.stickerButtonView = new Whisper.ReactWrapperView({
|
||||
className: 'sticker-button-wrapper',
|
||||
JSX: Signal.State.Roots.createStickerButton(window.reduxStore, props),
|
||||
});
|
||||
|
||||
// Finally, add it to the DOM
|
||||
this.$('.sticker-button-placeholder').append(this.stickerButtonView.el);
|
||||
},
|
||||
|
||||
// We need this, or clicking the sticker button will submit the form and send any
|
||||
// mid-composition message content.
|
||||
onClickStickerButtonPlaceholder(e) {
|
||||
e.preventDefault();
|
||||
},
|
||||
|
||||
onChooseAttachment(e) {
|
||||
if (e) {
|
||||
e.stopPropagation();
|
||||
|
@ -366,6 +394,13 @@
|
|||
|
||||
this.fileInput.remove();
|
||||
this.titleView.remove();
|
||||
if (this.stickerButtonView) {
|
||||
this.stickerButtonView.remove();
|
||||
}
|
||||
|
||||
if (this.stickerPreviewModalView) {
|
||||
this.stickerPreviewModalView.remove();
|
||||
}
|
||||
|
||||
if (this.captureAudioView) {
|
||||
this.captureAudioView.remove();
|
||||
|
@ -1282,6 +1317,26 @@
|
|||
dialog.focusCancel();
|
||||
},
|
||||
|
||||
showStickerPackPreview(packId) {
|
||||
const props = {
|
||||
packId,
|
||||
onClose: () => {
|
||||
this.stickerPreviewModalView.remove();
|
||||
},
|
||||
};
|
||||
|
||||
this.stickerPreviewModalView = new Whisper.ReactWrapperView({
|
||||
className: 'sticker-preview-modal-wrapper',
|
||||
JSX: Signal.State.Roots.createStickerPreviewModal(
|
||||
window.reduxStore,
|
||||
props
|
||||
),
|
||||
onClose: () => {
|
||||
this.stickerPreviewModalView = null;
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
showLightbox({ attachment, messageId }) {
|
||||
const message = this.model.messageCollection.get(messageId);
|
||||
if (!message) {
|
||||
|
@ -1289,6 +1344,13 @@
|
|||
`showLightbox: did not find message for id ${messageId}`
|
||||
);
|
||||
}
|
||||
const sticker = message.get('sticker');
|
||||
if (sticker) {
|
||||
const { packId } = sticker;
|
||||
this.showStickerPackPreview(packId);
|
||||
return;
|
||||
}
|
||||
|
||||
const { contentType, path } = attachment;
|
||||
|
||||
if (
|
||||
|
@ -1400,6 +1462,21 @@
|
|||
view.render();
|
||||
},
|
||||
|
||||
showStickerManager() {
|
||||
const view = new Whisper.ReactWrapperView({
|
||||
className: ['sticker-manager-wrapper', 'panel'].join(' '),
|
||||
JSX: Signal.State.Roots.createStickerManager(window.reduxStore),
|
||||
onClose: () => {
|
||||
this.resetPanel();
|
||||
this.updateHeader();
|
||||
},
|
||||
});
|
||||
|
||||
this.listenBack(view);
|
||||
this.updateHeader();
|
||||
view.render();
|
||||
},
|
||||
|
||||
showContactDetail({ contact, signalAccount }) {
|
||||
const view = new Whisper.ReactWrapperView({
|
||||
Component: Signal.Components.ContactDetail,
|
||||
|
@ -1449,6 +1526,8 @@
|
|||
|
||||
if (this.panels.length === 0) {
|
||||
this.$el.trigger('force-resize');
|
||||
// Make sure poppers are positioned properly
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -1482,99 +1561,121 @@
|
|||
}
|
||||
},
|
||||
|
||||
showSendConfirmationDialog(e, contacts) {
|
||||
let message;
|
||||
const isUnverified = this.model.isUnverified();
|
||||
showSendAnywayDialog(contacts) {
|
||||
return new Promise(resolve => {
|
||||
let message;
|
||||
const isUnverified = this.model.isUnverified();
|
||||
|
||||
if (contacts.length > 1) {
|
||||
if (isUnverified) {
|
||||
message = i18n('changedSinceVerifiedMultiple');
|
||||
if (contacts.length > 1) {
|
||||
if (isUnverified) {
|
||||
message = i18n('changedSinceVerifiedMultiple');
|
||||
} else {
|
||||
message = i18n('changedRecentlyMultiple');
|
||||
}
|
||||
} else {
|
||||
message = i18n('changedRecentlyMultiple');
|
||||
const contactName = contacts.at(0).getTitle();
|
||||
if (isUnverified) {
|
||||
message = i18n('changedSinceVerified', [contactName, contactName]);
|
||||
} else {
|
||||
message = i18n('changedRecently', [contactName, contactName]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const contactName = contacts.at(0).getTitle();
|
||||
if (isUnverified) {
|
||||
message = i18n('changedSinceVerified', [contactName, contactName]);
|
||||
} else {
|
||||
message = i18n('changedRecently', [contactName, contactName]);
|
||||
}
|
||||
}
|
||||
|
||||
const dialog = new Whisper.ConfirmationDialogView({
|
||||
message,
|
||||
okText: i18n('sendAnyway'),
|
||||
resolve: () => {
|
||||
this.checkUnverifiedSendMessage(e, { force: true });
|
||||
},
|
||||
reject: () => {
|
||||
this.focusMessageFieldAndClearDisabled();
|
||||
},
|
||||
const dialog = new Whisper.ConfirmationDialogView({
|
||||
message,
|
||||
okText: i18n('sendAnyway'),
|
||||
resolve: () => resolve(true),
|
||||
reject: () => resolve(false),
|
||||
});
|
||||
|
||||
this.$el.prepend(dialog.el);
|
||||
dialog.focusCancel();
|
||||
});
|
||||
|
||||
this.$el.prepend(dialog.el);
|
||||
dialog.focusCancel();
|
||||
},
|
||||
|
||||
async checkUnverifiedSendMessage(e, options = {}) {
|
||||
async clickSend(e, options) {
|
||||
e.preventDefault();
|
||||
|
||||
this.sendStart = Date.now();
|
||||
this.$messageField.attr('disabled', true);
|
||||
|
||||
_.defaults(options, { force: false });
|
||||
|
||||
// This will go to the trust store for the latest identity key information,
|
||||
// and may result in the display of a new banner for this conversation.
|
||||
try {
|
||||
await this.model.updateVerified();
|
||||
const contacts = this.model.getUnverified();
|
||||
if (!contacts.length) {
|
||||
this.checkUntrustedSendMessage(e, options);
|
||||
const contacts = await this.getUntrustedContacts(options);
|
||||
|
||||
if (contacts && contacts.length) {
|
||||
const sendAnyway = await this.showSendAnywayDialog(contacts);
|
||||
if (sendAnyway) {
|
||||
this.clickSend(e, { force: true });
|
||||
return;
|
||||
}
|
||||
|
||||
this.focusMessageFieldAndClearDisabled();
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.force) {
|
||||
await this.markAllAsVerifiedDefault(contacts);
|
||||
this.checkUnverifiedSendMessage(e, options);
|
||||
return;
|
||||
}
|
||||
|
||||
this.showSendConfirmationDialog(e, contacts);
|
||||
this.sendMessage(e);
|
||||
} catch (error) {
|
||||
this.focusMessageFieldAndClearDisabled();
|
||||
window.log.error(
|
||||
'checkUnverifiedSendMessage error:',
|
||||
'clickSend error:',
|
||||
error && error.stack ? error.stack : error
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
async checkUntrustedSendMessage(e, options = {}) {
|
||||
_.defaults(options, { force: false });
|
||||
|
||||
async sendStickerMessage(options = {}) {
|
||||
try {
|
||||
const contacts = await this.model.getUntrusted();
|
||||
if (!contacts.length) {
|
||||
this.sendMessage(e);
|
||||
const contacts = await this.getUntrustedContacts(options);
|
||||
|
||||
if (contacts && contacts.length) {
|
||||
const sendAnyway = await this.showSendAnywayDialog(contacts);
|
||||
if (sendAnyway) {
|
||||
this.sendStickerMessage({ ...options, force: true });
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.force) {
|
||||
await this.markAllAsApproved(contacts);
|
||||
this.sendMessage(e);
|
||||
return;
|
||||
}
|
||||
|
||||
this.showSendConfirmationDialog(e, contacts);
|
||||
const { packId, stickerId } = options;
|
||||
this.model.sendStickerMessage(packId, stickerId);
|
||||
} catch (error) {
|
||||
this.focusMessageFieldAndClearDisabled();
|
||||
window.log.error(
|
||||
'checkUntrustedSendMessage error:',
|
||||
'clickSend error:',
|
||||
error && error.stack ? error.stack : error
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
async getUntrustedContacts(options = {}) {
|
||||
// This will go to the trust store for the latest identity key information,
|
||||
// and may result in the display of a new banner for this conversation.
|
||||
await this.model.updateVerified();
|
||||
const unverifiedContacts = this.model.getUnverified();
|
||||
|
||||
if (options.force) {
|
||||
if (unverifiedContacts.length) {
|
||||
await this.markAllAsVerifiedDefault(unverifiedContacts);
|
||||
// We only want force to break us through one layer of checks
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
options.force = false;
|
||||
}
|
||||
} else if (unverifiedContacts.length) {
|
||||
return unverifiedContacts;
|
||||
}
|
||||
|
||||
const untrustedContacts = await this.model.getUntrusted();
|
||||
|
||||
if (options.force) {
|
||||
if (untrustedContacts.length) {
|
||||
await this.markAllAsApproved(untrustedContacts);
|
||||
}
|
||||
} else if (untrustedContacts.length) {
|
||||
return untrustedContacts;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
toggleEmojiPanel(e) {
|
||||
e.preventDefault();
|
||||
if (!this.emojiPanel) {
|
||||
|
@ -1839,14 +1940,29 @@
|
|||
|
||||
async makeChunkedRequest(url) {
|
||||
const PARALLELISM = 3;
|
||||
const size = await textsecure.messaging.getProxiedSize(url);
|
||||
const chunks = await Signal.LinkPreviews.getChunkPattern(size);
|
||||
const first = await textsecure.messaging.makeProxiedRequest(url, {
|
||||
start: 0,
|
||||
end: Signal.Crypto.getRandomValue(1023, 2047),
|
||||
returnArrayBuffer: true,
|
||||
});
|
||||
const { totalSize, result } = first;
|
||||
const initialOffset = result.data.byteLength;
|
||||
const firstChunk = {
|
||||
start: 0,
|
||||
end: initialOffset,
|
||||
...result,
|
||||
};
|
||||
|
||||
const chunks = await Signal.LinkPreviews.getChunkPattern(
|
||||
totalSize,
|
||||
initialOffset
|
||||
);
|
||||
|
||||
let results = [];
|
||||
const jobs = chunks.map(chunk => async () => {
|
||||
const { start, end } = chunk;
|
||||
|
||||
const result = await textsecure.messaging.makeProxiedRequest(url, {
|
||||
const jobResult = await textsecure.messaging.makeProxiedRequest(url, {
|
||||
start,
|
||||
end,
|
||||
returnArrayBuffer: true,
|
||||
|
@ -1854,7 +1970,7 @@
|
|||
|
||||
return {
|
||||
...chunk,
|
||||
...result,
|
||||
...jobResult.result,
|
||||
};
|
||||
});
|
||||
|
||||
|
@ -1878,7 +1994,9 @@
|
|||
}
|
||||
|
||||
const { contentType } = results[0];
|
||||
const data = Signal.LinkPreviews.assembleChunks(results);
|
||||
const data = Signal.LinkPreviews.assembleChunks(
|
||||
[firstChunk].concat(results)
|
||||
);
|
||||
|
||||
return {
|
||||
contentType,
|
||||
|
@ -1886,7 +2004,58 @@
|
|||
};
|
||||
},
|
||||
|
||||
async getStickerPackPreview(url) {
|
||||
const isPackValid = pack =>
|
||||
pack && (pack.status === 'advertised' || pack.status === 'installed');
|
||||
|
||||
try {
|
||||
const { id, key } = window.Signal.Stickers.getDataFromLink(url);
|
||||
const keyBytes = window.Signal.Crypto.bytesFromHexString(key);
|
||||
const keyBase64 = window.Signal.Crypto.arrayBufferToBase64(keyBytes);
|
||||
|
||||
const existing = window.Signal.Stickers.getStickerPack(id);
|
||||
if (!isPackValid(existing)) {
|
||||
await window.Signal.Stickers.downloadStickerPack(id, keyBase64);
|
||||
}
|
||||
|
||||
const pack = window.Signal.Stickers.getStickerPack(id);
|
||||
if (!isPackValid(pack)) {
|
||||
return null;
|
||||
}
|
||||
if (pack.key !== keyBase64) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { title, coverStickerId } = pack;
|
||||
const sticker = pack.stickers[coverStickerId];
|
||||
const data = await window.Signal.Migrations.readStickerData(
|
||||
sticker.path
|
||||
);
|
||||
|
||||
return {
|
||||
title,
|
||||
url,
|
||||
image: {
|
||||
...sticker,
|
||||
data,
|
||||
size: data.byteLength,
|
||||
contentType: 'image/webp',
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
window.log.error(
|
||||
'getStickerPackPreview error:',
|
||||
error && error.stack ? error.stack : error
|
||||
);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async getPreview(url) {
|
||||
if (window.Signal.LinkPreviews.isStickerPack(url)) {
|
||||
return this.getStickerPackPreview(url);
|
||||
}
|
||||
|
||||
let html;
|
||||
try {
|
||||
html = await textsecure.messaging.makeProxiedRequest(url);
|
||||
|
|
|
@ -13,6 +13,12 @@
|
|||
|
||||
window.Whisper = window.Whisper || {};
|
||||
|
||||
Whisper.StickerPackInstallFailedToast = Whisper.ToastView.extend({
|
||||
render_attributes() {
|
||||
return { toastMessage: i18n('stickers--toast--InstallFailed') };
|
||||
},
|
||||
});
|
||||
|
||||
Whisper.ConversationStack = Whisper.View.extend({
|
||||
className: 'conversation-stack',
|
||||
open(conversation) {
|
||||
|
@ -36,6 +42,8 @@
|
|||
$el.prependTo(this.el);
|
||||
}
|
||||
conversation.trigger('opened');
|
||||
// Make sure poppers are positioned properly
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -92,6 +100,12 @@
|
|||
this.$el.addClass('expired');
|
||||
}
|
||||
|
||||
Whisper.events.on('pack-install-failed', () => {
|
||||
const toast = new Whisper.StickerPackInstallFailedToast();
|
||||
toast.$el.appendTo(this.$el);
|
||||
toast.render();
|
||||
});
|
||||
|
||||
this.setupLeftPane();
|
||||
},
|
||||
render_attributes: {
|
||||
|
|
|
@ -16,6 +16,12 @@
|
|||
this.listenTo(this.model, 'destroy', this.onDestroy);
|
||||
this.listenTo(this.model, 'unload', this.onUnload);
|
||||
this.listenTo(this.model, 'expired', this.onExpired);
|
||||
|
||||
this.updateHiddenSticker();
|
||||
},
|
||||
updateHiddenSticker() {
|
||||
const sticker = this.model.get('sticker');
|
||||
this.isHiddenSticker = sticker && (!sticker.data || !sticker.data.path);
|
||||
},
|
||||
onChange() {
|
||||
this.addId();
|
||||
|
@ -94,7 +100,17 @@
|
|||
|
||||
const update = () => {
|
||||
const info = this.getRenderInfo();
|
||||
this.childView.update(info.props);
|
||||
this.childView.update(info.props, () => {
|
||||
if (!this.isHiddenSticker) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateHiddenSticker();
|
||||
|
||||
if (!this.isHiddenSticker) {
|
||||
this.model.trigger('height-changed');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
this.listenTo(this.model, 'change', update);
|
||||
|
|
|
@ -38,12 +38,23 @@
|
|||
|
||||
this.hasRendered = false;
|
||||
},
|
||||
update(props) {
|
||||
update(props, cb) {
|
||||
const updatedProps = this.augmentProps(props);
|
||||
const reactElement = this.JSX
|
||||
? this.JSX
|
||||
: React.createElement(this.Component, updatedProps);
|
||||
ReactDOM.render(reactElement, this.el, () => {
|
||||
if (cb) {
|
||||
try {
|
||||
cb();
|
||||
} catch (error) {
|
||||
window.log.error(
|
||||
'ReactWrapperView.update error:',
|
||||
error && error.stack ? error.stack : error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.hasRendered) {
|
||||
return;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue