Link Previews
This commit is contained in:
parent
91ef39e482
commit
813924685e
36 changed files with 2298 additions and 134 deletions
|
@ -551,8 +551,35 @@
|
||||||
"description":
|
"description":
|
||||||
"Shown in toast when user attempts to send .exe file, for example"
|
"Shown in toast when user attempts to send .exe file, for example"
|
||||||
},
|
},
|
||||||
|
"loadingPreview": {
|
||||||
|
"message": "Loading Preview...",
|
||||||
|
"description":
|
||||||
|
"Shown while Signal Desktop is fetching metadata for a url in composition area"
|
||||||
|
},
|
||||||
|
"stagedPreviewThumbnail": {
|
||||||
|
"message": "Draft thumbnail link preview for $domain$",
|
||||||
|
"description":
|
||||||
|
"Shown while Signal Desktop is fetching metadata for a url in composition area",
|
||||||
|
"placeholders": {
|
||||||
|
"path": {
|
||||||
|
"content": "$1",
|
||||||
|
"example": "instagram.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"previewThumbnail": {
|
||||||
|
"message": "Thumbnail link preview for $domain$",
|
||||||
|
"description":
|
||||||
|
"Shown while Signal Desktop is fetching metadata for a url in composition area",
|
||||||
|
"placeholders": {
|
||||||
|
"path": {
|
||||||
|
"content": "$1",
|
||||||
|
"example": "instagram.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"stagedImageAttachment": {
|
"stagedImageAttachment": {
|
||||||
"message": "Staged image attachment: $path$",
|
"message": "Draft image attachment: $path$",
|
||||||
"description": "Alt text for staged attachments",
|
"description": "Alt text for staged attachments",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"path": {
|
"path": {
|
||||||
|
@ -1045,9 +1072,20 @@
|
||||||
"message": "Allow access to camera and microphone",
|
"message": "Allow access to camera and microphone",
|
||||||
"description": "Description of the media permission description"
|
"description": "Description of the media permission description"
|
||||||
},
|
},
|
||||||
"spellCheck": {
|
"general": {
|
||||||
"message": "Spell Check",
|
"message": "General",
|
||||||
"description": "Description of the media permission description"
|
"description": "Header for general options on the settings screen"
|
||||||
|
},
|
||||||
|
"sendLinkPreviews": {
|
||||||
|
"message": "Send Link Previews",
|
||||||
|
"description":
|
||||||
|
"Option to control creation and send of link previews in setting screen"
|
||||||
|
},
|
||||||
|
"linkPreviewsDescription": {
|
||||||
|
"message":
|
||||||
|
"Previews are supported for Imgur, Instagram, Reddit, and YouTube links.",
|
||||||
|
"description":
|
||||||
|
"Additional detail provided for Link Previews option in settings screen"
|
||||||
},
|
},
|
||||||
"spellCheckDescription": {
|
"spellCheckDescription": {
|
||||||
"message": "Enable spell check of text entered in message composition box",
|
"message": "Enable spell check of text entered in message composition box",
|
||||||
|
|
12
app/sql.js
12
app/sql.js
|
@ -1569,7 +1569,7 @@ async function getMessagesWithFileAttachments(conversationId, { limit }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getExternalFilesForMessage(message) {
|
function getExternalFilesForMessage(message) {
|
||||||
const { attachments, contact, quote } = message;
|
const { attachments, contact, quote, preview } = message;
|
||||||
const files = [];
|
const files = [];
|
||||||
|
|
||||||
forEach(attachments, attachment => {
|
forEach(attachments, attachment => {
|
||||||
|
@ -1607,6 +1607,16 @@ function getExternalFilesForMessage(message) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (preview && preview.length) {
|
||||||
|
forEach(preview, item => {
|
||||||
|
const { image } = item;
|
||||||
|
|
||||||
|
if (image && image.path) {
|
||||||
|
files.push(image.path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"serverUrl": "https://textsecure-service-staging.whispersystems.org",
|
"serverUrl": "https://textsecure-service-staging.whispersystems.org",
|
||||||
"cdnUrl": "https://cdn-staging.signal.org",
|
"cdnUrl": "https://cdn-staging.signal.org",
|
||||||
|
"contentProxyUrl": "contentproxy.signal.org",
|
||||||
"disableAutoUpdate": false,
|
"disableAutoUpdate": false,
|
||||||
"openDevTools": false,
|
"openDevTools": false,
|
||||||
"buildExpiration": 0,
|
"buildExpiration": 0,
|
||||||
|
|
|
@ -795,6 +795,7 @@
|
||||||
readReceipts,
|
readReceipts,
|
||||||
typingIndicators,
|
typingIndicators,
|
||||||
unidentifiedDeliveryIndicators,
|
unidentifiedDeliveryIndicators,
|
||||||
|
linkPreviews,
|
||||||
} = configuration;
|
} = configuration;
|
||||||
|
|
||||||
storage.put('read-receipt-setting', readReceipts);
|
storage.put('read-receipt-setting', readReceipts);
|
||||||
|
@ -813,6 +814,10 @@
|
||||||
storage.put('typingIndicators', typingIndicators);
|
storage.put('typingIndicators', typingIndicators);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (linkPreviews === true || linkPreviews === false) {
|
||||||
|
storage.put('linkPreviews', linkPreviews);
|
||||||
|
}
|
||||||
|
|
||||||
ev.confirm();
|
ev.confirm();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1107,7 +1112,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (queryMessage.get('schemaVersion') < Message.CURRENT_SCHEMA_VERSION) {
|
if (
|
||||||
|
queryMessage.get('schemaVersion') < Message.VERSION_NEEDED_FOR_DISPLAY
|
||||||
|
) {
|
||||||
const upgradedMessage = await upgradeMessageSchema(
|
const upgradedMessage = await upgradeMessageSchema(
|
||||||
queryMessage.attributes
|
queryMessage.attributes
|
||||||
);
|
);
|
||||||
|
@ -1126,15 +1133,23 @@
|
||||||
|
|
||||||
const queryAttachments = queryMessage.get('attachments') || [];
|
const queryAttachments = queryMessage.get('attachments') || [];
|
||||||
|
|
||||||
if (queryAttachments.length === 0) {
|
if (queryAttachments.length > 0) {
|
||||||
return message;
|
const queryFirst = queryAttachments[0];
|
||||||
|
const { thumbnail } = queryFirst;
|
||||||
|
|
||||||
|
if (thumbnail && thumbnail.path) {
|
||||||
|
firstAttachment.thumbnail = thumbnail;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const queryFirst = queryAttachments[0];
|
const queryPreview = queryMessage.get('preview') || [];
|
||||||
const { thumbnail } = queryFirst;
|
if (queryPreview.length > 0) {
|
||||||
|
const queryFirst = queryPreview[0];
|
||||||
|
const { image } = queryFirst;
|
||||||
|
|
||||||
if (thumbnail && thumbnail.path) {
|
if (image && image.path) {
|
||||||
firstAttachment.thumbnail = thumbnail;
|
firstAttachment.thumbnail = image;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return message;
|
return message;
|
||||||
|
|
|
@ -759,6 +759,7 @@
|
||||||
const { getName } = Contact;
|
const { getName } = Contact;
|
||||||
const contact = quotedMessage.getContact();
|
const contact = quotedMessage.getContact();
|
||||||
const attachments = quotedMessage.get('attachments');
|
const attachments = quotedMessage.get('attachments');
|
||||||
|
const preview = quotedMessage.get('preview');
|
||||||
|
|
||||||
const body = quotedMessage.get('body');
|
const body = quotedMessage.get('body');
|
||||||
const embeddedContact = quotedMessage.get('contact');
|
const embeddedContact = quotedMessage.get('contact');
|
||||||
|
@ -767,32 +768,45 @@
|
||||||
? getName(embeddedContact[0])
|
? getName(embeddedContact[0])
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
|
const media =
|
||||||
|
attachments && attachments.length ? attachments : preview || [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
author: contact.id,
|
author: contact.id,
|
||||||
id: quotedMessage.get('sent_at'),
|
id: quotedMessage.get('sent_at'),
|
||||||
text: body || embeddedContactName,
|
text: body || embeddedContactName,
|
||||||
attachments: await Promise.all(
|
attachments: await Promise.all(
|
||||||
(attachments || []).map(async attachment => {
|
media
|
||||||
const { contentType, fileName, thumbnail } = attachment;
|
.filter(
|
||||||
|
attachment =>
|
||||||
|
(attachment && attachment.thumbnail) || attachment.message
|
||||||
|
)
|
||||||
|
.map(async attachment => {
|
||||||
|
const { fileName } = attachment;
|
||||||
|
|
||||||
return {
|
const thumbnail = attachment.thumbnail || attachment.image;
|
||||||
contentType,
|
const contentType =
|
||||||
// Our protos library complains about this field being undefined, so we
|
attachment.contentType ||
|
||||||
// force it to null
|
(attachment.image && attachment.image.contentType);
|
||||||
fileName: fileName || null,
|
|
||||||
thumbnail: thumbnail
|
return {
|
||||||
? {
|
contentType,
|
||||||
...(await loadAttachmentData(thumbnail)),
|
// Our protos library complains about this field being undefined, so we
|
||||||
objectUrl: getAbsoluteAttachmentPath(thumbnail.path),
|
// force it to null
|
||||||
}
|
fileName: fileName || null,
|
||||||
: null,
|
thumbnail: thumbnail
|
||||||
};
|
? {
|
||||||
})
|
...(await loadAttachmentData(thumbnail)),
|
||||||
|
objectUrl: getAbsoluteAttachmentPath(thumbnail.path),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
})
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
sendMessage(body, attachments, quote) {
|
sendMessage(body, attachments, quote, preview) {
|
||||||
this.clearTypingTimers();
|
this.clearTypingTimers();
|
||||||
|
|
||||||
const destination = this.id;
|
const destination = this.id;
|
||||||
|
@ -819,6 +833,7 @@
|
||||||
body,
|
body,
|
||||||
conversationId: destination,
|
conversationId: destination,
|
||||||
quote,
|
quote,
|
||||||
|
preview,
|
||||||
attachments,
|
attachments,
|
||||||
sent_at: now,
|
sent_at: now,
|
||||||
received_at: now,
|
received_at: now,
|
||||||
|
@ -885,6 +900,7 @@
|
||||||
body,
|
body,
|
||||||
attachmentsWithData,
|
attachmentsWithData,
|
||||||
quote,
|
quote,
|
||||||
|
preview,
|
||||||
now,
|
now,
|
||||||
expireTimer,
|
expireTimer,
|
||||||
profileKey,
|
profileKey,
|
||||||
|
@ -1621,7 +1637,7 @@
|
||||||
const { attributes } = message;
|
const { attributes } = message;
|
||||||
const { schemaVersion } = attributes;
|
const { schemaVersion } = attributes;
|
||||||
|
|
||||||
if (schemaVersion < Message.CURRENT_SCHEMA_VERSION) {
|
if (schemaVersion < Message.VERSION_NEEDED_FOR_DISPLAY) {
|
||||||
// Yep, we really do want to wait for each of these
|
// Yep, we really do want to wait for each of these
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
const upgradedMessage = await upgradeMessageSchema(attributes);
|
const upgradedMessage = await upgradeMessageSchema(attributes);
|
||||||
|
|
|
@ -24,6 +24,7 @@
|
||||||
getAbsoluteAttachmentPath,
|
getAbsoluteAttachmentPath,
|
||||||
loadAttachmentData,
|
loadAttachmentData,
|
||||||
loadQuoteData,
|
loadQuoteData,
|
||||||
|
loadPreviewData,
|
||||||
writeNewAttachmentData,
|
writeNewAttachmentData,
|
||||||
} = window.Signal.Migrations;
|
} = window.Signal.Migrations;
|
||||||
|
|
||||||
|
@ -425,6 +426,7 @@
|
||||||
attachments: attachments.map(attachment =>
|
attachments: attachments.map(attachment =>
|
||||||
this.getPropsForAttachment(attachment)
|
this.getPropsForAttachment(attachment)
|
||||||
),
|
),
|
||||||
|
previews: this.getPropsForPreview(),
|
||||||
quote: this.getPropsForQuote(),
|
quote: this.getPropsForQuote(),
|
||||||
authorAvatarPath,
|
authorAvatarPath,
|
||||||
isExpired: this.hasExpired,
|
isExpired: this.hasExpired,
|
||||||
|
@ -434,6 +436,7 @@
|
||||||
onRetrySend: () => this.retrySend(),
|
onRetrySend: () => this.retrySend(),
|
||||||
onShowDetail: () => this.trigger('show-message-detail', this),
|
onShowDetail: () => this.trigger('show-message-detail', this),
|
||||||
onDelete: () => this.trigger('delete', this),
|
onDelete: () => this.trigger('delete', this),
|
||||||
|
onClickLinkPreview: url => this.trigger('navigate-to', url),
|
||||||
onClickAttachment: attachment =>
|
onClickAttachment: attachment =>
|
||||||
this.trigger('show-lightbox', {
|
this.trigger('show-lightbox', {
|
||||||
attachment,
|
attachment,
|
||||||
|
@ -526,6 +529,15 @@
|
||||||
thumbnail: thumbnailWithObjectUrl,
|
thumbnail: thumbnailWithObjectUrl,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
getPropsForPreview() {
|
||||||
|
const previews = this.get('preview') || [];
|
||||||
|
|
||||||
|
return previews.map(preview => ({
|
||||||
|
...preview,
|
||||||
|
domain: window.Signal.LinkPreviews.getDomain(preview.url),
|
||||||
|
image: preview.image ? this.getPropsForAttachment(preview.image) : null,
|
||||||
|
}));
|
||||||
|
},
|
||||||
getPropsForQuote() {
|
getPropsForQuote() {
|
||||||
const quote = this.get('quote');
|
const quote = this.get('quote');
|
||||||
if (!quote) {
|
if (!quote) {
|
||||||
|
@ -712,6 +724,7 @@
|
||||||
(this.get('attachments') || []).map(loadAttachmentData)
|
(this.get('attachments') || []).map(loadAttachmentData)
|
||||||
);
|
);
|
||||||
const quoteWithData = await loadQuoteData(this.get('quote'));
|
const quoteWithData = await loadQuoteData(this.get('quote'));
|
||||||
|
const previewWithData = await loadPreviewData(this.get('preview'));
|
||||||
|
|
||||||
const conversation = this.getConversation();
|
const conversation = this.getConversation();
|
||||||
const options = conversation.getSendOptions();
|
const options = conversation.getSendOptions();
|
||||||
|
@ -725,6 +738,7 @@
|
||||||
this.get('body'),
|
this.get('body'),
|
||||||
attachmentsWithData,
|
attachmentsWithData,
|
||||||
quoteWithData,
|
quoteWithData,
|
||||||
|
previewWithData,
|
||||||
this.get('sent_at'),
|
this.get('sent_at'),
|
||||||
this.get('expireTimer'),
|
this.get('expireTimer'),
|
||||||
profileKey,
|
profileKey,
|
||||||
|
@ -741,6 +755,7 @@
|
||||||
timestamp: this.get('sent_at'),
|
timestamp: this.get('sent_at'),
|
||||||
attachments: attachmentsWithData,
|
attachments: attachmentsWithData,
|
||||||
quote: quoteWithData,
|
quote: quoteWithData,
|
||||||
|
preview: previewWithData,
|
||||||
needsSync: !this.get('synced'),
|
needsSync: !this.get('synced'),
|
||||||
expireTimer: this.get('expireTimer'),
|
expireTimer: this.get('expireTimer'),
|
||||||
profileKey,
|
profileKey,
|
||||||
|
@ -775,6 +790,7 @@
|
||||||
(this.get('attachments') || []).map(loadAttachmentData)
|
(this.get('attachments') || []).map(loadAttachmentData)
|
||||||
);
|
);
|
||||||
const quoteWithData = await loadQuoteData(this.get('quote'));
|
const quoteWithData = await loadQuoteData(this.get('quote'));
|
||||||
|
const previewWithData = await loadPreviewData(this.get('preview'));
|
||||||
|
|
||||||
const { wrap, sendOptions } = ConversationController.prepareForSend(
|
const { wrap, sendOptions } = ConversationController.prepareForSend(
|
||||||
number
|
number
|
||||||
|
@ -784,6 +800,7 @@
|
||||||
this.get('body'),
|
this.get('body'),
|
||||||
attachmentsWithData,
|
attachmentsWithData,
|
||||||
quoteWithData,
|
quoteWithData,
|
||||||
|
previewWithData,
|
||||||
this.get('sent_at'),
|
this.get('sent_at'),
|
||||||
this.get('expireTimer'),
|
this.get('expireTimer'),
|
||||||
profileKey,
|
profileKey,
|
||||||
|
@ -1146,6 +1163,22 @@
|
||||||
message.set({ group_update: groupUpdate });
|
message.set({ group_update: groupUpdate });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const urls = window.Signal.LinkPreviews.findLinks(dataMessage.body);
|
||||||
|
const incomingPreview = dataMessage.preview || [];
|
||||||
|
const preview = incomingPreview.filter(
|
||||||
|
item =>
|
||||||
|
(item.image || item.title) &&
|
||||||
|
urls.includes(item.url) &&
|
||||||
|
window.Signal.LinkPreviews.isLinkInWhitelist(item.url)
|
||||||
|
);
|
||||||
|
if (preview.length > incomingPreview.length) {
|
||||||
|
window.log.info(
|
||||||
|
`${message.idForLogging()}: Eliminated ${preview.length -
|
||||||
|
incomingPreview.length} previews with invalid urls'`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
message.set({
|
message.set({
|
||||||
attachments: dataMessage.attachments,
|
attachments: dataMessage.attachments,
|
||||||
body: dataMessage.body,
|
body: dataMessage.body,
|
||||||
|
@ -1158,6 +1191,7 @@
|
||||||
hasFileAttachments: dataMessage.hasFileAttachments,
|
hasFileAttachments: dataMessage.hasFileAttachments,
|
||||||
hasVisualMediaAttachments: dataMessage.hasVisualMediaAttachments,
|
hasVisualMediaAttachments: dataMessage.hasVisualMediaAttachments,
|
||||||
quote: dataMessage.quote,
|
quote: dataMessage.quote,
|
||||||
|
preview,
|
||||||
schemaVersion: dataMessage.schemaVersion,
|
schemaVersion: dataMessage.schemaVersion,
|
||||||
});
|
});
|
||||||
if (type === 'outgoing') {
|
if (type === 'outgoing') {
|
||||||
|
|
|
@ -616,6 +616,50 @@ async function writeContactAvatars(contact, options) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function writePreviewImage(preview, options) {
|
||||||
|
const { image } = preview || {};
|
||||||
|
if (!image || !image.path) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { dir, message, index, key, newKey } = options;
|
||||||
|
const name = _getAnonymousAttachmentFileName(message, index);
|
||||||
|
const filename = `${name}-preview`;
|
||||||
|
const target = path.join(dir, filename);
|
||||||
|
|
||||||
|
await writeEncryptedAttachment(target, image.path, {
|
||||||
|
key,
|
||||||
|
newKey,
|
||||||
|
filename,
|
||||||
|
dir,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writePreviews(preview, options) {
|
||||||
|
const { name } = options;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all(
|
||||||
|
_.map(preview, (item, index) =>
|
||||||
|
writePreviewImage(
|
||||||
|
item,
|
||||||
|
Object.assign({}, options, {
|
||||||
|
index,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
window.log.error(
|
||||||
|
'writePreviews: error exporting conversation',
|
||||||
|
name,
|
||||||
|
':',
|
||||||
|
error && error.stack ? error.stack : error
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function writeEncryptedAttachment(target, source, options = {}) {
|
async function writeEncryptedAttachment(target, source, options = {}) {
|
||||||
const { key, newKey, filename, dir } = options;
|
const { key, newKey, filename, dir } = options;
|
||||||
|
|
||||||
|
@ -752,6 +796,18 @@ async function exportConversation(conversation, options = {}) {
|
||||||
newKey,
|
newKey,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { preview } = message;
|
||||||
|
if (preview && preview.length > 0) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await writePreviews(preview, {
|
||||||
|
dir: attachmentsDir,
|
||||||
|
name,
|
||||||
|
message,
|
||||||
|
key,
|
||||||
|
newKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const last = messages.length > 0 ? messages[messages.length - 1] : null;
|
const last = messages.length > 0 ? messages[messages.length - 1] : null;
|
||||||
|
@ -925,7 +981,18 @@ async function loadAttachments(dir, getName, options) {
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// TODO: Handle video screenshots, and image/video thumbnails
|
const { preview } = message;
|
||||||
|
await Promise.all(
|
||||||
|
_.map(preview, (item, index) => {
|
||||||
|
const image = item && item.image;
|
||||||
|
if (!image) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = `${getName(message, index)}-preview`;
|
||||||
|
return readEncryptedAttachment(dir, image, name, options);
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveMessage(message) {
|
function saveMessage(message) {
|
||||||
|
@ -1013,8 +1080,9 @@ async function importConversation(dir, options) {
|
||||||
message.quote.attachments &&
|
message.quote.attachments &&
|
||||||
message.quote.attachments.length > 0;
|
message.quote.attachments.length > 0;
|
||||||
const hasContacts = message.contact && message.contact.length;
|
const hasContacts = message.contact && message.contact.length;
|
||||||
|
const hasPreviews = message.preview && message.preview.length;
|
||||||
|
|
||||||
if (hasAttachments || hasQuotedAttachments || hasContacts) {
|
if (hasAttachments || hasQuotedAttachments || hasContacts || hasPreviews) {
|
||||||
const importMessage = async () => {
|
const importMessage = async () => {
|
||||||
const getName = attachmentsDir
|
const getName = attachmentsDir
|
||||||
? _getAnonymousAttachmentFileName
|
? _getAnonymousAttachmentFileName
|
||||||
|
|
176
js/modules/link_previews.js
Normal file
176
js/modules/link_previews.js
Normal file
|
@ -0,0 +1,176 @@
|
||||||
|
/* global URL */
|
||||||
|
|
||||||
|
const he = require('he');
|
||||||
|
const LinkifyIt = require('linkify-it');
|
||||||
|
|
||||||
|
const linkify = LinkifyIt();
|
||||||
|
const { concatenateBytes, getViewOfArrayBuffer } = require('./crypto');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
assembleChunks,
|
||||||
|
findLinks,
|
||||||
|
getChunkPattern,
|
||||||
|
getDomain,
|
||||||
|
getTitleMetaTag,
|
||||||
|
getImageMetaTag,
|
||||||
|
isLinkInWhitelist,
|
||||||
|
isMediaLinkInWhitelist,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SUPPORTED_DOMAINS = [
|
||||||
|
'youtube.com',
|
||||||
|
'www.youtube.com',
|
||||||
|
'm.youtube.com',
|
||||||
|
'youtu.be',
|
||||||
|
'reddit.com',
|
||||||
|
'www.reddit.com',
|
||||||
|
'm.reddit.com',
|
||||||
|
'imgur.com',
|
||||||
|
'www.imgur.com',
|
||||||
|
'm.imgur.com',
|
||||||
|
'instagram.com',
|
||||||
|
'www.instagram.com',
|
||||||
|
'm.instagram.com',
|
||||||
|
];
|
||||||
|
function isLinkInWhitelist(link) {
|
||||||
|
try {
|
||||||
|
const url = new URL(link);
|
||||||
|
|
||||||
|
if (url.protocol !== 'https:') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!url.pathname || url.pathname.length < 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowercase = url.host.toLowerCase();
|
||||||
|
if (!SUPPORTED_DOMAINS.includes(lowercase)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUPPORTED_MEDIA_DOMAINS = /^([^.]+\.)*(ytimg.com|cdninstagram.com|redd.it|imgur.com)$/i;
|
||||||
|
function isMediaLinkInWhitelist(link) {
|
||||||
|
try {
|
||||||
|
const url = new URL(link);
|
||||||
|
|
||||||
|
if (url.protocol !== 'https:') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!url.pathname || url.pathname.length < 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SUPPORTED_MEDIA_DOMAINS.test(url.host)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const META_TITLE = /<meta\s+property="og:title"\s+content="([\s\S]+?)"\s*\/?\s*>/im;
|
||||||
|
const META_IMAGE = /<meta\s+property="og:image"\s+content="([\s\S]+?)"\s*\/?\s*>/im;
|
||||||
|
function _getMetaTag(html, regularExpression) {
|
||||||
|
const match = regularExpression.exec(html);
|
||||||
|
if (match && match[1]) {
|
||||||
|
return he.decode(match[1]).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTitleMetaTag(html) {
|
||||||
|
return _getMetaTag(html, META_TITLE);
|
||||||
|
}
|
||||||
|
function getImageMetaTag(html) {
|
||||||
|
return _getMetaTag(html, META_IMAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLinks(text) {
|
||||||
|
const matches = linkify.match(text || '') || [];
|
||||||
|
return matches.map(match => match.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDomain(url) {
|
||||||
|
try {
|
||||||
|
const urlObject = new URL(url);
|
||||||
|
return urlObject.hostname;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MB = 1024 * 1024;
|
||||||
|
const KB = 1024;
|
||||||
|
|
||||||
|
function getChunkPattern(size) {
|
||||||
|
if (size > MB) {
|
||||||
|
return _getRequestPattern(size, MB);
|
||||||
|
} else if (size > 500 * KB) {
|
||||||
|
return _getRequestPattern(size, 500 * KB);
|
||||||
|
} else if (size > 100 * KB) {
|
||||||
|
return _getRequestPattern(size, 100 * KB);
|
||||||
|
} else if (size > 50 * KB) {
|
||||||
|
return _getRequestPattern(size, 50 * KB);
|
||||||
|
} else if (size > 10 * KB) {
|
||||||
|
return _getRequestPattern(size, 10 * KB);
|
||||||
|
} else if (size > KB) {
|
||||||
|
return _getRequestPattern(size, KB);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`getChunkPattern: Unsupported size: ${size}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _getRequestPattern(size, increment) {
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
let offset = 0;
|
||||||
|
while (size - offset > increment) {
|
||||||
|
results.push({
|
||||||
|
start: offset,
|
||||||
|
end: offset + increment - 1,
|
||||||
|
overlap: 0,
|
||||||
|
});
|
||||||
|
offset += increment;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (size - offset > 0) {
|
||||||
|
results.push({
|
||||||
|
start: size - increment,
|
||||||
|
end: size - 1,
|
||||||
|
overlap: increment - (size - offset),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assembleChunks(chunkDescriptors) {
|
||||||
|
const chunks = chunkDescriptors.map((chunk, index) => {
|
||||||
|
if (index !== chunkDescriptors.length - 1) {
|
||||||
|
return chunk.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!chunk.overlap) {
|
||||||
|
return chunk.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getViewOfArrayBuffer(
|
||||||
|
chunk.data,
|
||||||
|
chunk.overlap,
|
||||||
|
chunk.data.byteLength
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return concatenateBytes(...chunks);
|
||||||
|
}
|
|
@ -13,6 +13,7 @@ const Util = require('../../ts/util');
|
||||||
const { migrateToSQL } = require('./migrate_to_sql');
|
const { migrateToSQL } = require('./migrate_to_sql');
|
||||||
const Metadata = require('./metadata/SecretSessionCipher');
|
const Metadata = require('./metadata/SecretSessionCipher');
|
||||||
const RefreshSenderCertificate = require('./refresh_sender_certificate');
|
const RefreshSenderCertificate = require('./refresh_sender_certificate');
|
||||||
|
const LinkPreviews = require('./link_previews');
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
const {
|
const {
|
||||||
|
@ -55,6 +56,9 @@ const {
|
||||||
const {
|
const {
|
||||||
SafetyNumberNotification,
|
SafetyNumberNotification,
|
||||||
} = require('../../ts/components/conversation/SafetyNumberNotification');
|
} = require('../../ts/components/conversation/SafetyNumberNotification');
|
||||||
|
const {
|
||||||
|
StagedLinkPreview,
|
||||||
|
} = require('../../ts/components/conversation/StagedLinkPreview');
|
||||||
const {
|
const {
|
||||||
TimerNotification,
|
TimerNotification,
|
||||||
} = require('../../ts/components/conversation/TimerNotification');
|
} = require('../../ts/components/conversation/TimerNotification');
|
||||||
|
@ -120,6 +124,7 @@ function initializeMigrations({
|
||||||
const attachmentsPath = getPath(userDataPath);
|
const attachmentsPath = getPath(userDataPath);
|
||||||
const readAttachmentData = createReader(attachmentsPath);
|
const readAttachmentData = createReader(attachmentsPath);
|
||||||
const loadAttachmentData = Type.loadData(readAttachmentData);
|
const loadAttachmentData = Type.loadData(readAttachmentData);
|
||||||
|
const loadPreviewData = MessageType.loadPreviewData(readAttachmentData);
|
||||||
const loadQuoteData = MessageType.loadQuoteData(readAttachmentData);
|
const loadQuoteData = MessageType.loadQuoteData(readAttachmentData);
|
||||||
const getAbsoluteAttachmentPath = createAbsolutePathGetter(attachmentsPath);
|
const getAbsoluteAttachmentPath = createAbsolutePathGetter(attachmentsPath);
|
||||||
const deleteOnDisk = Attachments.createDeleter(attachmentsPath);
|
const deleteOnDisk = Attachments.createDeleter(attachmentsPath);
|
||||||
|
@ -135,8 +140,9 @@ function initializeMigrations({
|
||||||
getPlaceholderMigrations,
|
getPlaceholderMigrations,
|
||||||
getCurrentVersion,
|
getCurrentVersion,
|
||||||
loadAttachmentData,
|
loadAttachmentData,
|
||||||
loadQuoteData,
|
|
||||||
loadMessage: MessageType.createAttachmentLoader(loadAttachmentData),
|
loadMessage: MessageType.createAttachmentLoader(loadAttachmentData),
|
||||||
|
loadPreviewData,
|
||||||
|
loadQuoteData,
|
||||||
readAttachmentData,
|
readAttachmentData,
|
||||||
run,
|
run,
|
||||||
upgradeMessageSchema: (message, options = {}) => {
|
upgradeMessageSchema: (message, options = {}) => {
|
||||||
|
@ -196,6 +202,7 @@ exports.setup = (options = {}) => {
|
||||||
Quote,
|
Quote,
|
||||||
ResetSessionNotification,
|
ResetSessionNotification,
|
||||||
SafetyNumberNotification,
|
SafetyNumberNotification,
|
||||||
|
StagedLinkPreview,
|
||||||
TimerNotification,
|
TimerNotification,
|
||||||
Types: {
|
Types: {
|
||||||
Message: MediaGalleryMessage,
|
Message: MediaGalleryMessage,
|
||||||
|
@ -226,7 +233,6 @@ exports.setup = (options = {}) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
Metadata,
|
|
||||||
Backbone,
|
Backbone,
|
||||||
Components,
|
Components,
|
||||||
Crypto,
|
Crypto,
|
||||||
|
@ -234,6 +240,9 @@ exports.setup = (options = {}) => {
|
||||||
Database,
|
Database,
|
||||||
Emoji,
|
Emoji,
|
||||||
IndexedDB,
|
IndexedDB,
|
||||||
|
LinkPreviews,
|
||||||
|
Metadata,
|
||||||
|
migrateToSQL,
|
||||||
Migrations,
|
Migrations,
|
||||||
Notifications,
|
Notifications,
|
||||||
OS,
|
OS,
|
||||||
|
@ -243,6 +252,5 @@ exports.setup = (options = {}) => {
|
||||||
Util,
|
Util,
|
||||||
Views,
|
Views,
|
||||||
Workflow,
|
Workflow,
|
||||||
migrateToSQL,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -47,6 +47,8 @@ const PRIVATE = 'private';
|
||||||
// Version 9
|
// Version 9
|
||||||
// - Attachments: Expand the set of unicode characters we filter out of
|
// - Attachments: Expand the set of unicode characters we filter out of
|
||||||
// attachment filenames
|
// attachment filenames
|
||||||
|
// Version 10
|
||||||
|
// - Preview: A new type of attachment can be included in a message.
|
||||||
|
|
||||||
const INITIAL_SCHEMA_VERSION = 0;
|
const INITIAL_SCHEMA_VERSION = 0;
|
||||||
|
|
||||||
|
@ -232,6 +234,46 @@ exports._mapQuotedAttachments = upgradeAttachment => async (
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// _mapPreviewAttachments :: (PreviewAttachment -> Promise PreviewAttachment) ->
|
||||||
|
// (Message, Context) ->
|
||||||
|
// Promise Message
|
||||||
|
exports._mapPreviewAttachments = upgradeAttachment => async (
|
||||||
|
message,
|
||||||
|
context
|
||||||
|
) => {
|
||||||
|
if (!message.preview) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
if (!context || !isObject(context.logger)) {
|
||||||
|
throw new Error('_mapPreviewAttachments: context must have logger object');
|
||||||
|
}
|
||||||
|
const { logger } = context;
|
||||||
|
|
||||||
|
const upgradeWithContext = async preview => {
|
||||||
|
const { image } = preview;
|
||||||
|
if (!image) {
|
||||||
|
return preview;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!image.data && !image.path) {
|
||||||
|
logger.warn('Preview did not have image data; removing it');
|
||||||
|
return omit(preview, ['image']);
|
||||||
|
}
|
||||||
|
|
||||||
|
const upgradedImage = await upgradeAttachment(image, context);
|
||||||
|
return Object.assign({}, preview, {
|
||||||
|
image: upgradedImage,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const preview = await Promise.all(
|
||||||
|
(message.preview || []).map(upgradeWithContext)
|
||||||
|
);
|
||||||
|
return Object.assign({}, message, {
|
||||||
|
preview,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const toVersion0 = async (message, context) =>
|
const toVersion0 = async (message, context) =>
|
||||||
exports.initializeSchemaVersion({ message, logger: context.logger });
|
exports.initializeSchemaVersion({ message, logger: context.logger });
|
||||||
const toVersion1 = exports._withSchemaVersion({
|
const toVersion1 = exports._withSchemaVersion({
|
||||||
|
@ -277,6 +319,10 @@ const toVersion9 = exports._withSchemaVersion({
|
||||||
schemaVersion: 9,
|
schemaVersion: 9,
|
||||||
upgrade: exports._mapAttachments(Attachment.replaceUnicodeV2),
|
upgrade: exports._mapAttachments(Attachment.replaceUnicodeV2),
|
||||||
});
|
});
|
||||||
|
const toVersion10 = exports._withSchemaVersion({
|
||||||
|
schemaVersion: 10,
|
||||||
|
upgrade: exports._mapPreviewAttachments(Attachment.migrateDataToFileSystem),
|
||||||
|
});
|
||||||
|
|
||||||
const VERSIONS = [
|
const VERSIONS = [
|
||||||
toVersion0,
|
toVersion0,
|
||||||
|
@ -289,9 +335,13 @@ const VERSIONS = [
|
||||||
toVersion7,
|
toVersion7,
|
||||||
toVersion8,
|
toVersion8,
|
||||||
toVersion9,
|
toVersion9,
|
||||||
|
toVersion10,
|
||||||
];
|
];
|
||||||
exports.CURRENT_SCHEMA_VERSION = VERSIONS.length - 1;
|
exports.CURRENT_SCHEMA_VERSION = VERSIONS.length - 1;
|
||||||
|
|
||||||
|
// We need dimensions and screenshots for images for proper display
|
||||||
|
exports.VERSION_NEEDED_FOR_DISPLAY = 9;
|
||||||
|
|
||||||
// UpgradeStep
|
// UpgradeStep
|
||||||
exports.upgradeSchema = async (
|
exports.upgradeSchema = async (
|
||||||
rawMessage,
|
rawMessage,
|
||||||
|
@ -408,6 +458,31 @@ exports.loadQuoteData = loadAttachmentData => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
exports.loadPreviewData = loadAttachmentData => {
|
||||||
|
if (!isFunction(loadAttachmentData)) {
|
||||||
|
throw new TypeError('loadPreviewData: loadAttachmentData is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
return async preview => {
|
||||||
|
if (!preview || !preview.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
preview.map(async () => {
|
||||||
|
if (!preview.image) {
|
||||||
|
return preview;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...preview,
|
||||||
|
image: await loadAttachmentData(preview.image),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
exports.deleteAllExternalFiles = ({ deleteAttachmentData, deleteOnDisk }) => {
|
exports.deleteAllExternalFiles = ({ deleteAttachmentData, deleteOnDisk }) => {
|
||||||
if (!isFunction(deleteAttachmentData)) {
|
if (!isFunction(deleteAttachmentData)) {
|
||||||
throw new TypeError(
|
throw new TypeError(
|
||||||
|
@ -422,7 +497,7 @@ exports.deleteAllExternalFiles = ({ deleteAttachmentData, deleteOnDisk }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return async message => {
|
return async message => {
|
||||||
const { attachments, quote, contact } = message;
|
const { attachments, quote, contact, preview } = message;
|
||||||
|
|
||||||
if (attachments && attachments.length) {
|
if (attachments && attachments.length) {
|
||||||
await Promise.all(attachments.map(deleteAttachmentData));
|
await Promise.all(attachments.map(deleteAttachmentData));
|
||||||
|
@ -451,6 +526,18 @@ exports.deleteAllExternalFiles = ({ deleteAttachmentData, deleteOnDisk }) => {
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (preview && preview.length) {
|
||||||
|
await Promise.all(
|
||||||
|
preview.map(async item => {
|
||||||
|
const { image } = item;
|
||||||
|
|
||||||
|
if (image && image.path) {
|
||||||
|
await deleteOnDisk(image.path);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -480,11 +567,12 @@ exports.createAttachmentDataWriter = ({
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { attachments, quote, contact } = message;
|
const { attachments, quote, contact, preview } = message;
|
||||||
const hasFilesToWrite =
|
const hasFilesToWrite =
|
||||||
(quote && quote.attachments && quote.attachments.length > 0) ||
|
(quote && quote.attachments && quote.attachments.length > 0) ||
|
||||||
(attachments && attachments.length > 0) ||
|
(attachments && attachments.length > 0) ||
|
||||||
(contact && contact.length > 0);
|
(contact && contact.length > 0) ||
|
||||||
|
(preview && preview.length > 0);
|
||||||
|
|
||||||
if (!hasFilesToWrite) {
|
if (!hasFilesToWrite) {
|
||||||
return message;
|
return message;
|
||||||
|
@ -545,11 +633,25 @@ exports.createAttachmentDataWriter = ({
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const writePreviewImage = async item => {
|
||||||
|
const { image } = item;
|
||||||
|
if (!image) {
|
||||||
|
return omit(item, ['image']);
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeExistingAttachmentData(image);
|
||||||
|
|
||||||
|
return Object.assign({}, item, {
|
||||||
|
image: omit(image, ['data']),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const messageWithoutAttachmentData = Object.assign(
|
const messageWithoutAttachmentData = Object.assign(
|
||||||
{},
|
{},
|
||||||
await writeThumbnails(message, { logger }),
|
await writeThumbnails(message, { logger }),
|
||||||
{
|
{
|
||||||
contact: await Promise.all((contact || []).map(writeContactAvatar)),
|
contact: await Promise.all((contact || []).map(writeContactAvatar)),
|
||||||
|
preview: await Promise.all((preview || []).map(writePreviewImage)),
|
||||||
attachments: await Promise.all(
|
attachments: await Promise.all(
|
||||||
(attachments || []).map(async attachment => {
|
(attachments || []).map(async attachment => {
|
||||||
await writeExistingAttachmentData(attachment);
|
await writeExistingAttachmentData(attachment);
|
||||||
|
|
|
@ -5,9 +5,7 @@ const { Agent } = require('https');
|
||||||
|
|
||||||
const is = require('@sindresorhus/is');
|
const is = require('@sindresorhus/is');
|
||||||
|
|
||||||
/* global Buffer: false */
|
/* global Buffer, setTimeout, log, _ */
|
||||||
/* global setTimeout: false */
|
|
||||||
/* global log: false */
|
|
||||||
|
|
||||||
/* eslint-disable more/no-then, no-bitwise, no-nested-ternary */
|
/* eslint-disable more/no-then, no-bitwise, no-nested-ternary */
|
||||||
|
|
||||||
|
@ -166,12 +164,28 @@ const agents = {
|
||||||
auth: null,
|
auth: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getContentType(response) {
|
||||||
|
if (response.headers && response.headers.get) {
|
||||||
|
return response.headers.get('content-type');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function _promiseAjax(providedUrl, options) {
|
function _promiseAjax(providedUrl, options) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const url = providedUrl || `${options.host}/${options.path}`;
|
const url = providedUrl || `${options.host}/${options.path}`;
|
||||||
log.info(
|
if (options.disableLogs) {
|
||||||
`${options.type} ${url}${options.unauthenticated ? ' (unauth)' : ''}`
|
log.info(
|
||||||
);
|
`${options.type} [REDACTED_URL]${
|
||||||
|
options.unauthenticated ? ' (unauth)' : ''
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log.info(
|
||||||
|
`${options.type} ${url}${options.unauthenticated ? ' (unauth)' : ''}`
|
||||||
|
);
|
||||||
|
}
|
||||||
const timeout =
|
const timeout =
|
||||||
typeof options.timeout !== 'undefined' ? options.timeout : 10000;
|
typeof options.timeout !== 'undefined' ? options.timeout : 10000;
|
||||||
|
|
||||||
|
@ -195,7 +209,12 @@ function _promiseAjax(providedUrl, options) {
|
||||||
const fetchOptions = {
|
const fetchOptions = {
|
||||||
method: options.type,
|
method: options.type,
|
||||||
body: options.data || null,
|
body: options.data || null,
|
||||||
headers: { 'X-Signal-Agent': 'OWD' },
|
headers: {
|
||||||
|
'User-Agent': 'Signal Desktop (+https://signal.org/download)',
|
||||||
|
'X-Signal-Agent': 'OWD',
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
redirect: options.redirect,
|
||||||
agent,
|
agent,
|
||||||
ca: options.certificateAuthority,
|
ca: options.certificateAuthority,
|
||||||
timeout,
|
timeout,
|
||||||
|
@ -238,13 +257,20 @@ function _promiseAjax(providedUrl, options) {
|
||||||
response.headers.get('Content-Type') === 'application/json'
|
response.headers.get('Content-Type') === 'application/json'
|
||||||
) {
|
) {
|
||||||
resultPromise = response.json();
|
resultPromise = response.json();
|
||||||
} else if (options.responseType === 'arraybuffer') {
|
} else if (
|
||||||
|
options.responseType === 'arraybuffer' ||
|
||||||
|
options.responseType === 'arraybufferwithdetails'
|
||||||
|
) {
|
||||||
resultPromise = response.buffer();
|
resultPromise = response.buffer();
|
||||||
} else {
|
} else {
|
||||||
resultPromise = response.text();
|
resultPromise = response.text();
|
||||||
}
|
}
|
||||||
|
|
||||||
return resultPromise.then(result => {
|
return resultPromise.then(result => {
|
||||||
if (options.responseType === 'arraybuffer') {
|
if (
|
||||||
|
options.responseType === 'arraybuffer' ||
|
||||||
|
options.responseType === 'arraybufferwithdetails'
|
||||||
|
) {
|
||||||
// eslint-disable-next-line no-param-reassign
|
// eslint-disable-next-line no-param-reassign
|
||||||
result = result.buffer.slice(
|
result = result.buffer.slice(
|
||||||
result.byteOffset,
|
result.byteOffset,
|
||||||
|
@ -254,8 +280,17 @@ function _promiseAjax(providedUrl, options) {
|
||||||
if (options.responseType === 'json') {
|
if (options.responseType === 'json') {
|
||||||
if (options.validateResponse) {
|
if (options.validateResponse) {
|
||||||
if (!_validateResponse(result, options.validateResponse)) {
|
if (!_validateResponse(result, options.validateResponse)) {
|
||||||
log.error(options.type, url, response.status, 'Error');
|
if (options.disableLogs) {
|
||||||
reject(
|
log.info(
|
||||||
|
options.type,
|
||||||
|
'[REDACTED_URL]',
|
||||||
|
response.status,
|
||||||
|
'Error'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log.error(options.type, url, response.status, 'Error');
|
||||||
|
}
|
||||||
|
return reject(
|
||||||
HTTPError(
|
HTTPError(
|
||||||
'promiseAjax: invalid response',
|
'promiseAjax: invalid response',
|
||||||
response.status,
|
response.status,
|
||||||
|
@ -267,23 +302,47 @@ function _promiseAjax(providedUrl, options) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (response.status >= 0 && response.status < 400) {
|
if (response.status >= 0 && response.status < 400) {
|
||||||
log.info(options.type, url, response.status, 'Success');
|
if (options.disableLogs) {
|
||||||
resolve(result, response.status);
|
log.info(
|
||||||
|
options.type,
|
||||||
|
'[REDACTED_URL]',
|
||||||
|
response.status,
|
||||||
|
'Success'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log.info(options.type, url, response.status, 'Success');
|
||||||
|
}
|
||||||
|
if (options.responseType === 'arraybufferwithdetails') {
|
||||||
|
return resolve({
|
||||||
|
data: result,
|
||||||
|
contentType: getContentType(response),
|
||||||
|
response,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return resolve(result, response.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.disableLogs) {
|
||||||
|
log.info(options.type, '[REDACTED_URL]', response.status, 'Error');
|
||||||
} else {
|
} else {
|
||||||
log.error(options.type, url, response.status, 'Error');
|
log.error(options.type, url, response.status, 'Error');
|
||||||
reject(
|
|
||||||
HTTPError(
|
|
||||||
'promiseAjax: error response',
|
|
||||||
response.status,
|
|
||||||
result,
|
|
||||||
options.stack
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
return reject(
|
||||||
|
HTTPError(
|
||||||
|
'promiseAjax: error response',
|
||||||
|
response.status,
|
||||||
|
result,
|
||||||
|
options.stack
|
||||||
|
)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.catch(e => {
|
||||||
log.error(options.type, url, 0, 'Error');
|
if (options.disableLogs) {
|
||||||
|
log.error(options.type, '[REDACTED_URL]', 0, 'Error');
|
||||||
|
} else {
|
||||||
|
log.error(options.type, url, 0, 'Error');
|
||||||
|
}
|
||||||
const stack = `${e.stack}\nInitial stack:\n${options.stack}`;
|
const stack = `${e.stack}\nInitial stack:\n${options.stack}`;
|
||||||
reject(HTTPError('promiseAjax catch', 0, e.toString(), stack));
|
reject(HTTPError('promiseAjax catch', 0, e.toString(), stack));
|
||||||
});
|
});
|
||||||
|
@ -342,7 +401,13 @@ module.exports = {
|
||||||
};
|
};
|
||||||
|
|
||||||
// We first set up the data that won't change during this session of the app
|
// We first set up the data that won't change during this session of the app
|
||||||
function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) {
|
function initialize({
|
||||||
|
url,
|
||||||
|
cdnUrl,
|
||||||
|
certificateAuthority,
|
||||||
|
contentProxyUrl,
|
||||||
|
proxyUrl,
|
||||||
|
}) {
|
||||||
if (!is.string(url)) {
|
if (!is.string(url)) {
|
||||||
throw new Error('WebAPI.initialize: Invalid server url');
|
throw new Error('WebAPI.initialize: Invalid server url');
|
||||||
}
|
}
|
||||||
|
@ -352,6 +417,9 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) {
|
||||||
if (!is.string(certificateAuthority)) {
|
if (!is.string(certificateAuthority)) {
|
||||||
throw new Error('WebAPI.initialize: Invalid certificateAuthority');
|
throw new Error('WebAPI.initialize: Invalid certificateAuthority');
|
||||||
}
|
}
|
||||||
|
if (!is.string(contentProxyUrl)) {
|
||||||
|
throw new Error('WebAPI.initialize: Invalid contentProxyUrl');
|
||||||
|
}
|
||||||
|
|
||||||
// Thanks to function-hoisting, we can put this return statement before all of the
|
// Thanks to function-hoisting, we can put this return statement before all of the
|
||||||
// below function definitions.
|
// below function definitions.
|
||||||
|
@ -372,8 +440,6 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) {
|
||||||
getAttachment,
|
getAttachment,
|
||||||
getAvatar,
|
getAvatar,
|
||||||
getDevices,
|
getDevices,
|
||||||
getSenderCertificate,
|
|
||||||
registerSupportForUnauthenticatedDelivery,
|
|
||||||
getKeysForNumber,
|
getKeysForNumber,
|
||||||
getKeysForNumberUnauth,
|
getKeysForNumberUnauth,
|
||||||
getMessageSocket,
|
getMessageSocket,
|
||||||
|
@ -381,15 +447,19 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) {
|
||||||
getProfile,
|
getProfile,
|
||||||
getProfileUnauth,
|
getProfileUnauth,
|
||||||
getProvisioningSocket,
|
getProvisioningSocket,
|
||||||
|
getProxiedSize,
|
||||||
|
getSenderCertificate,
|
||||||
|
makeProxiedRequest,
|
||||||
putAttachment,
|
putAttachment,
|
||||||
registerKeys,
|
registerKeys,
|
||||||
|
registerSupportForUnauthenticatedDelivery,
|
||||||
|
removeSignalingKey,
|
||||||
requestVerificationSMS,
|
requestVerificationSMS,
|
||||||
requestVerificationVoice,
|
requestVerificationVoice,
|
||||||
sendMessages,
|
sendMessages,
|
||||||
sendMessagesUnauth,
|
sendMessagesUnauth,
|
||||||
setSignedPreKey,
|
setSignedPreKey,
|
||||||
updateDeviceName,
|
updateDeviceName,
|
||||||
removeSignalingKey,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function _ajax(param) {
|
function _ajax(param) {
|
||||||
|
@ -799,6 +869,47 @@ function initialize({ url, cdnUrl, certificateAuthority, proxyUrl }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { response } = result;
|
||||||
|
if (!response.headers || !response.headers.get) {
|
||||||
|
throw new Error('getProxiedSize: Problem retrieving header value');
|
||||||
|
}
|
||||||
|
|
||||||
|
const size = response.headers.get('content-length');
|
||||||
|
return parseInt(size, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-shadow
|
||||||
|
function makeProxiedRequest(url, options = {}) {
|
||||||
|
const { returnArrayBuffer, start, end } = options;
|
||||||
|
let headers;
|
||||||
|
|
||||||
|
if (_.isNumber(start) && _.isNumber(end)) {
|
||||||
|
headers = {
|
||||||
|
Range: `bytes=${start}-${end}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return _outerAjax(url, {
|
||||||
|
processData: false,
|
||||||
|
responseType: returnArrayBuffer ? 'arraybufferwithdetails' : null,
|
||||||
|
proxyUrl: contentProxyUrl,
|
||||||
|
type: 'GET',
|
||||||
|
redirect: 'follow',
|
||||||
|
disableLogs: true,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getMessageSocket() {
|
function getMessageSocket() {
|
||||||
log.info('opening message socket', url);
|
log.info('opening message socket', url);
|
||||||
const fixedScheme = url
|
const fixedScheme = url
|
||||||
|
|
|
@ -32,10 +32,21 @@ const getInitialData = async () => ({
|
||||||
window.initialRequest = getInitialData();
|
window.initialRequest = getInitialData();
|
||||||
|
|
||||||
// eslint-disable-next-line more/no-then
|
// eslint-disable-next-line more/no-then
|
||||||
window.initialRequest.then(data => {
|
window.initialRequest.then(
|
||||||
'use strict';
|
data => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
window.initialData = data;
|
window.initialData = data;
|
||||||
window.view = new Whisper.SettingsView();
|
window.view = new Whisper.SettingsView();
|
||||||
window.view.$el.appendTo($body);
|
window.view.$el.appendTo($body);
|
||||||
});
|
},
|
||||||
|
error => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
window.log.error(
|
||||||
|
'settings.initialRequest error:',
|
||||||
|
error && error.stack ? error.stack : error
|
||||||
|
);
|
||||||
|
window.closeSettings();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
|
@ -1,14 +1,15 @@
|
||||||
/* global
|
/* global
|
||||||
$,
|
$,
|
||||||
_,
|
_,
|
||||||
|
ConversationController
|
||||||
emojiData,
|
emojiData,
|
||||||
EmojiPanel,
|
EmojiPanel,
|
||||||
extension,
|
extension,
|
||||||
i18n,
|
i18n,
|
||||||
Signal,
|
Signal,
|
||||||
storage,
|
storage,
|
||||||
|
textsecure,
|
||||||
Whisper,
|
Whisper,
|
||||||
ConversationController
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// eslint-disable-next-line func-names
|
// eslint-disable-next-line func-names
|
||||||
|
@ -131,6 +132,9 @@
|
||||||
'show-message-detail',
|
'show-message-detail',
|
||||||
this.showMessageDetail
|
this.showMessageDetail
|
||||||
);
|
);
|
||||||
|
this.listenTo(this.model.messageCollection, 'navigate-to', url => {
|
||||||
|
window.location = url;
|
||||||
|
});
|
||||||
|
|
||||||
this.lazyUpdateVerified = _.debounce(
|
this.lazyUpdateVerified = _.debounce(
|
||||||
this.model.updateVerified.bind(this.model),
|
this.model.updateVerified.bind(this.model),
|
||||||
|
@ -140,6 +144,10 @@
|
||||||
this.model.getProfiles.bind(this.model),
|
this.model.getProfiles.bind(this.model),
|
||||||
1000 * 60 * 5 // five minutes
|
1000 * 60 * 5 // five minutes
|
||||||
);
|
);
|
||||||
|
this.debouncedMaybeGrabLinkPreview = _.debounce(
|
||||||
|
this.maybeGrabLinkPreview.bind(this),
|
||||||
|
200
|
||||||
|
);
|
||||||
|
|
||||||
this.render();
|
this.render();
|
||||||
|
|
||||||
|
@ -157,8 +165,11 @@
|
||||||
this.onChooseAttachment
|
this.onChooseAttachment
|
||||||
);
|
);
|
||||||
this.listenTo(this.fileInput, 'staged-attachments-changed', () => {
|
this.listenTo(this.fileInput, 'staged-attachments-changed', () => {
|
||||||
this.view.resetScrollPosition();
|
this.view.restoreBottomOffset();
|
||||||
this.toggleMicrophone();
|
this.toggleMicrophone();
|
||||||
|
if (this.fileInput.hasFiles()) {
|
||||||
|
this.removeLinkPreview();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const getHeaderProps = () => {
|
const getHeaderProps = () => {
|
||||||
|
@ -253,7 +264,7 @@
|
||||||
'submit .send': 'checkUnverifiedSendMessage',
|
'submit .send': 'checkUnverifiedSendMessage',
|
||||||
'input .send-message': 'updateMessageFieldSize',
|
'input .send-message': 'updateMessageFieldSize',
|
||||||
'keydown .send-message': 'updateMessageFieldSize',
|
'keydown .send-message': 'updateMessageFieldSize',
|
||||||
'keyup .send-message': 'maybeBumpTyping',
|
'keyup .send-message': 'onKeyUp',
|
||||||
click: 'onClick',
|
click: 'onClick',
|
||||||
'click .bottom-bar': 'focusMessageField',
|
'click .bottom-bar': 'focusMessageField',
|
||||||
'click .capture-audio .microphone': 'captureAudio',
|
'click .capture-audio .microphone': 'captureAudio',
|
||||||
|
@ -776,7 +787,7 @@
|
||||||
const message = rawMedia[i];
|
const message = rawMedia[i];
|
||||||
const { schemaVersion } = message;
|
const { schemaVersion } = message;
|
||||||
|
|
||||||
if (schemaVersion < Message.CURRENT_SCHEMA_VERSION) {
|
if (schemaVersion < Message.VERSION_NEEDED_FOR_DISPLAY) {
|
||||||
// Yep, we really do want to wait for each of these
|
// Yep, we really do want to wait for each of these
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
rawMedia[i] = await upgradeMessageSchema(message);
|
rawMedia[i] = await upgradeMessageSchema(message);
|
||||||
|
@ -1634,10 +1645,16 @@
|
||||||
const sendDelta = Date.now() - this.sendStart;
|
const sendDelta = Date.now() - this.sendStart;
|
||||||
window.log.info('Send pre-checks took', sendDelta, 'milliseconds');
|
window.log.info('Send pre-checks took', sendDelta, 'milliseconds');
|
||||||
|
|
||||||
this.model.sendMessage(message, attachments, this.quote);
|
this.model.sendMessage(
|
||||||
|
message,
|
||||||
|
attachments,
|
||||||
|
this.quote,
|
||||||
|
this.getLinkPreview()
|
||||||
|
);
|
||||||
|
|
||||||
input.val('');
|
input.val('');
|
||||||
this.setQuoteMessage(null);
|
this.setQuoteMessage(null);
|
||||||
|
this.resetLinkPreview();
|
||||||
this.focusMessageFieldAndClearDisabled();
|
this.focusMessageFieldAndClearDisabled();
|
||||||
this.forceUpdateMessageFieldSize(e);
|
this.forceUpdateMessageFieldSize(e);
|
||||||
this.fileInput.clearAttachments();
|
this.fileInput.clearAttachments();
|
||||||
|
@ -1651,6 +1668,311 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
onKeyUp() {
|
||||||
|
this.maybeBumpTyping();
|
||||||
|
this.debouncedMaybeGrabLinkPreview();
|
||||||
|
},
|
||||||
|
|
||||||
|
maybeGrabLinkPreview() {
|
||||||
|
// Don't generate link previews if user has turned them off
|
||||||
|
if (!storage.get('linkPreviews', false)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Do nothing if we're offline
|
||||||
|
if (!textsecure.messaging) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If we have attachments, don't add link preview
|
||||||
|
if (this.fileInput.hasFiles()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If we're behind a user-configured proxy, we don't support link previews
|
||||||
|
if (window.isBehindProxy()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageText = this.$messageField.val().trim();
|
||||||
|
|
||||||
|
if (!messageText) {
|
||||||
|
this.resetLinkPreview();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.disableLinkPreviews) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const links = window.Signal.LinkPreviews.findLinks(messageText);
|
||||||
|
const { currentlyMatchedLink } = this;
|
||||||
|
if (links.includes(currentlyMatchedLink)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentlyMatchedLink = null;
|
||||||
|
this.excludedPreviewUrls = this.excludedPreviewUrls || [];
|
||||||
|
|
||||||
|
const link = links.find(
|
||||||
|
item =>
|
||||||
|
window.Signal.LinkPreviews.isLinkInWhitelist(item) &&
|
||||||
|
!this.excludedPreviewUrls.includes(item)
|
||||||
|
);
|
||||||
|
if (!link) {
|
||||||
|
this.removeLinkPreview();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentlyMatchedLink = link;
|
||||||
|
this.addLinkPreview(link);
|
||||||
|
},
|
||||||
|
|
||||||
|
resetLinkPreview() {
|
||||||
|
this.disableLinkPreviews = false;
|
||||||
|
this.excludedPreviewUrls = [];
|
||||||
|
this.removeLinkPreview();
|
||||||
|
},
|
||||||
|
|
||||||
|
removeLinkPreview() {
|
||||||
|
(this.preview || []).forEach(item => {
|
||||||
|
if (item.url) {
|
||||||
|
URL.revokeObjectURL(item.url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.preview = null;
|
||||||
|
this.previewLoading = null;
|
||||||
|
this.currentlyMatchedLink = false;
|
||||||
|
this.renderLinkPreview();
|
||||||
|
},
|
||||||
|
|
||||||
|
async makeChunkedRequest(url) {
|
||||||
|
const PARALLELISM = 3;
|
||||||
|
const size = await textsecure.messaging.getProxiedSize(url);
|
||||||
|
const chunks = await Signal.LinkPreviews.getChunkPattern(size);
|
||||||
|
|
||||||
|
let results = [];
|
||||||
|
const jobs = chunks.map(chunk => async () => {
|
||||||
|
const { start, end } = chunk;
|
||||||
|
|
||||||
|
const result = await textsecure.messaging.makeProxiedRequest(url, {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
returnArrayBuffer: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...chunk,
|
||||||
|
...result,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
while (jobs.length > 0) {
|
||||||
|
const activeJobs = [];
|
||||||
|
for (let i = 0, max = PARALLELISM; i < max; i += 1) {
|
||||||
|
if (!jobs.length) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const job = jobs.shift();
|
||||||
|
activeJobs.push(job());
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
results = results.concat(await Promise.all(activeJobs));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!results.length) {
|
||||||
|
throw new Error('No responses received');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { contentType } = results[0];
|
||||||
|
const data = Signal.LinkPreviews.assembleChunks(results);
|
||||||
|
|
||||||
|
return {
|
||||||
|
contentType,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async getPreview(url) {
|
||||||
|
let html;
|
||||||
|
try {
|
||||||
|
html = await textsecure.messaging.makeProxiedRequest(url);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code >= 300) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = window.Signal.LinkPreviews.getTitleMetaTag(html);
|
||||||
|
const imageUrl = window.Signal.LinkPreviews.getImageMetaTag(html);
|
||||||
|
|
||||||
|
let image;
|
||||||
|
let objectUrl;
|
||||||
|
try {
|
||||||
|
if (imageUrl) {
|
||||||
|
if (!Signal.LinkPreviews.isMediaLinkInWhitelist(imageUrl)) {
|
||||||
|
const primaryDomain = Signal.LinkPreviews.getDomain(url);
|
||||||
|
const imageDomain = Signal.LinkPreviews.getDomain(imageUrl);
|
||||||
|
throw new Error(
|
||||||
|
`imageUrl for domain ${primaryDomain} did not match media whitelist. Domain: ${imageDomain}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await this.makeChunkedRequest(imageUrl);
|
||||||
|
|
||||||
|
// Ensure that this file is either small enough or is resized to meet our
|
||||||
|
// requirements for attachments
|
||||||
|
const withBlob = await this.fileInput.autoScale({
|
||||||
|
contentType: data.contentType,
|
||||||
|
file: new Blob([data.data], {
|
||||||
|
type: data.contentType,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const attachment = await this.fileInput.readFile(withBlob);
|
||||||
|
objectUrl = URL.createObjectURL(withBlob.file);
|
||||||
|
|
||||||
|
const dimensions = await Signal.Types.VisualAttachment.getImageDimensions(
|
||||||
|
{
|
||||||
|
objectUrl,
|
||||||
|
logger: window.log,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
image = {
|
||||||
|
...attachment,
|
||||||
|
...dimensions,
|
||||||
|
contentType: withBlob.file.type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// We still want to show the preview if we failed to get an image
|
||||||
|
window.log.error(
|
||||||
|
'getPreview failed to get image for link preview:',
|
||||||
|
error.message
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (objectUrl) {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
url,
|
||||||
|
image,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async addLinkPreview(url) {
|
||||||
|
(this.preview || []).forEach(item => {
|
||||||
|
if (item.url) {
|
||||||
|
URL.revokeObjectURL(item.url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.preview = null;
|
||||||
|
|
||||||
|
this.currentlyMatchedLink = url;
|
||||||
|
this.previewLoading = this.getPreview(url);
|
||||||
|
const promise = this.previewLoading;
|
||||||
|
this.renderLinkPreview();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await promise;
|
||||||
|
|
||||||
|
if (
|
||||||
|
url !== this.currentlyMatchedLink ||
|
||||||
|
promise !== this.previewLoading
|
||||||
|
) {
|
||||||
|
// another request was started, or this was canceled
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we couldn't pull down the initial URL
|
||||||
|
if (!result) {
|
||||||
|
this.excludedPreviewUrls.push(url);
|
||||||
|
this.removeLinkPreview();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.image) {
|
||||||
|
const blob = new Blob([result.image.data], {
|
||||||
|
type: result.image.contentType,
|
||||||
|
});
|
||||||
|
result.image.url = URL.createObjectURL(blob);
|
||||||
|
} else if (!result.title) {
|
||||||
|
// A link preview isn't worth showing unless we have either a title or an image
|
||||||
|
this.removeLinkPreview();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.preview = [result];
|
||||||
|
this.renderLinkPreview();
|
||||||
|
} catch (error) {
|
||||||
|
window.log.error(
|
||||||
|
'Problem loading link preview, disabling.',
|
||||||
|
error && error.stack ? error.stack : error
|
||||||
|
);
|
||||||
|
this.disableLinkPreviews = true;
|
||||||
|
this.removeLinkPreview();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderLinkPreview() {
|
||||||
|
if (this.previewView) {
|
||||||
|
this.previewView.remove();
|
||||||
|
this.previewView = null;
|
||||||
|
}
|
||||||
|
if (!this.currentlyMatchedLink) {
|
||||||
|
this.view.restoreBottomOffset();
|
||||||
|
this.updateMessageFieldSize({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = (this.preview && this.preview[0]) || null;
|
||||||
|
const props = {
|
||||||
|
...first,
|
||||||
|
domain: first && window.Signal.LinkPreviews.getDomain(first.url),
|
||||||
|
isLoaded: Boolean(first),
|
||||||
|
onClose: () => {
|
||||||
|
this.disableLinkPreviews = true;
|
||||||
|
this.removeLinkPreview();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
this.previewView = new Whisper.ReactWrapperView({
|
||||||
|
className: 'preview-wrapper',
|
||||||
|
Component: window.Signal.Components.StagedLinkPreview,
|
||||||
|
elCallback: el => this.$('.send').prepend(el),
|
||||||
|
props,
|
||||||
|
onInitialRender: () => {
|
||||||
|
this.view.restoreBottomOffset();
|
||||||
|
this.updateMessageFieldSize({});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getLinkPreview() {
|
||||||
|
// Don't generate link previews if user has turned them off
|
||||||
|
if (!storage.get('linkPreviews', false)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.preview) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.preview.map(item => {
|
||||||
|
if (item.image) {
|
||||||
|
// We eliminate the ObjectURL here, unneeded for send or save
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
image: _.omit(item.image, 'url'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
// Called whenever the user changes the message composition field. But only
|
// Called whenever the user changes the message composition field. But only
|
||||||
// fires if there's content in the message field after the change.
|
// fires if there's content in the message field after the change.
|
||||||
maybeBumpTyping() {
|
maybeBumpTyping() {
|
||||||
|
|
|
@ -148,8 +148,10 @@
|
||||||
clearDataExplanation: i18n('clearDataExplanation'),
|
clearDataExplanation: i18n('clearDataExplanation'),
|
||||||
permissions: i18n('permissions'),
|
permissions: i18n('permissions'),
|
||||||
mediaPermissionsDescription: i18n('mediaPermissionsDescription'),
|
mediaPermissionsDescription: i18n('mediaPermissionsDescription'),
|
||||||
spellCheckHeader: i18n('spellCheck'),
|
generalHeader: i18n('general'),
|
||||||
spellCheckDescription: i18n('spellCheckDescription'),
|
spellCheckDescription: i18n('spellCheckDescription'),
|
||||||
|
sendLinkPreviews: i18n('sendLinkPreviews'),
|
||||||
|
linkPreviewsDescription: i18n('linkPreviewsDescription'),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
onClose() {
|
onClose() {
|
||||||
|
|
|
@ -1371,6 +1371,14 @@ MessageReceiver.prototype.extend({
|
||||||
promises.push(this.handleAttachment(attachment));
|
promises.push(this.handleAttachment(attachment));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const previewCount = (decrypted.preview || []).length;
|
||||||
|
for (let i = 0; i < previewCount; i += 1) {
|
||||||
|
const preview = decrypted.preview[i];
|
||||||
|
if (preview.image) {
|
||||||
|
promises.push(this.handleAttachment(preview.image));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (decrypted.contact && decrypted.contact.length) {
|
if (decrypted.contact && decrypted.contact.length) {
|
||||||
const contacts = decrypted.contact;
|
const contacts = decrypted.contact;
|
||||||
|
|
||||||
|
|
|
@ -18,6 +18,7 @@ function Message(options) {
|
||||||
this.body = options.body;
|
this.body = options.body;
|
||||||
this.attachments = options.attachments || [];
|
this.attachments = options.attachments || [];
|
||||||
this.quote = options.quote;
|
this.quote = options.quote;
|
||||||
|
this.preview = options.preview;
|
||||||
this.group = options.group;
|
this.group = options.group;
|
||||||
this.flags = options.flags;
|
this.flags = options.flags;
|
||||||
this.recipients = options.recipients;
|
this.recipients = options.recipients;
|
||||||
|
@ -102,6 +103,15 @@ Message.prototype = {
|
||||||
proto.group.id = stringToArrayBuffer(this.group.id);
|
proto.group.id = stringToArrayBuffer(this.group.id);
|
||||||
proto.group.type = this.group.type;
|
proto.group.type = this.group.type;
|
||||||
}
|
}
|
||||||
|
if (Array.isArray(this.preview)) {
|
||||||
|
proto.preview = this.preview.map(preview => {
|
||||||
|
const item = new textsecure.protobuf.DataMessage.Preview();
|
||||||
|
item.title = preview.title;
|
||||||
|
item.url = preview.url;
|
||||||
|
item.image = preview.image;
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
}
|
||||||
if (this.quote) {
|
if (this.quote) {
|
||||||
const { QuotedAttachment } = textsecure.protobuf.DataMessage.Quote;
|
const { QuotedAttachment } = textsecure.protobuf.DataMessage.Quote;
|
||||||
const { Quote } = textsecure.protobuf.DataMessage;
|
const { Quote } = textsecure.protobuf.DataMessage;
|
||||||
|
@ -238,6 +248,25 @@ MessageSender.prototype = {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async uploadLinkPreviews(message) {
|
||||||
|
try {
|
||||||
|
const preview = await Promise.all(
|
||||||
|
(message.preview || []).map(async item => ({
|
||||||
|
...item,
|
||||||
|
image: await this.makeAttachmentPointer(item.image),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
message.preview = preview;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name === 'HTTPError') {
|
||||||
|
throw new textsecure.MessageError(message, error);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
uploadThumbnails(message) {
|
uploadThumbnails(message) {
|
||||||
const makePointer = this.makeAttachmentPointer.bind(this);
|
const makePointer = this.makeAttachmentPointer.bind(this);
|
||||||
const { quote } = message;
|
const { quote } = message;
|
||||||
|
@ -274,6 +303,7 @@ MessageSender.prototype = {
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
this.uploadAttachments(message),
|
this.uploadAttachments(message),
|
||||||
this.uploadThumbnails(message),
|
this.uploadThumbnails(message),
|
||||||
|
this.uploadLinkPreviews(message),
|
||||||
]).then(
|
]).then(
|
||||||
() =>
|
() =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
|
@ -734,6 +764,7 @@ MessageSender.prototype = {
|
||||||
messageText,
|
messageText,
|
||||||
attachments,
|
attachments,
|
||||||
quote,
|
quote,
|
||||||
|
preview,
|
||||||
timestamp,
|
timestamp,
|
||||||
expireTimer,
|
expireTimer,
|
||||||
profileKey,
|
profileKey,
|
||||||
|
@ -746,6 +777,7 @@ MessageSender.prototype = {
|
||||||
timestamp,
|
timestamp,
|
||||||
attachments,
|
attachments,
|
||||||
quote,
|
quote,
|
||||||
|
preview,
|
||||||
needsSync: true,
|
needsSync: true,
|
||||||
expireTimer,
|
expireTimer,
|
||||||
profileKey,
|
profileKey,
|
||||||
|
@ -822,6 +854,7 @@ MessageSender.prototype = {
|
||||||
messageText,
|
messageText,
|
||||||
attachments,
|
attachments,
|
||||||
quote,
|
quote,
|
||||||
|
preview,
|
||||||
timestamp,
|
timestamp,
|
||||||
expireTimer,
|
expireTimer,
|
||||||
profileKey,
|
profileKey,
|
||||||
|
@ -845,6 +878,7 @@ MessageSender.prototype = {
|
||||||
timestamp,
|
timestamp,
|
||||||
attachments,
|
attachments,
|
||||||
quote,
|
quote,
|
||||||
|
preview,
|
||||||
needsSync: true,
|
needsSync: true,
|
||||||
expireTimer,
|
expireTimer,
|
||||||
profileKey,
|
profileKey,
|
||||||
|
@ -1023,6 +1057,12 @@ MessageSender.prototype = {
|
||||||
options
|
options
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
makeProxiedRequest(url, options) {
|
||||||
|
return this.server.makeProxiedRequest(url, options);
|
||||||
|
},
|
||||||
|
getProxiedSize(url) {
|
||||||
|
return this.server.getProxiedSize(url);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
window.textsecure = window.textsecure || {};
|
window.textsecure = window.textsecure || {};
|
||||||
|
@ -1068,6 +1108,8 @@ textsecure.MessageSender = function MessageSenderWrapper(
|
||||||
this.syncVerification = sender.syncVerification.bind(sender);
|
this.syncVerification = sender.syncVerification.bind(sender);
|
||||||
this.sendDeliveryReceipt = sender.sendDeliveryReceipt.bind(sender);
|
this.sendDeliveryReceipt = sender.sendDeliveryReceipt.bind(sender);
|
||||||
this.sendReadReceipts = sender.sendReadReceipts.bind(sender);
|
this.sendReadReceipts = sender.sendReadReceipts.bind(sender);
|
||||||
|
this.makeProxiedRequest = sender.makeProxiedRequest.bind(sender);
|
||||||
|
this.getProxiedSize = sender.getProxiedSize.bind(sender);
|
||||||
};
|
};
|
||||||
|
|
||||||
textsecure.MessageSender.prototype = {
|
textsecure.MessageSender.prototype = {
|
||||||
|
|
1
main.js
1
main.js
|
@ -148,6 +148,7 @@ function prepareURL(pathSegments, moreKeys) {
|
||||||
hostname: os.hostname(),
|
hostname: os.hostname(),
|
||||||
appInstance: process.env.NODE_APP_INSTANCE,
|
appInstance: process.env.NODE_APP_INSTANCE,
|
||||||
proxyUrl: process.env.HTTPS_PROXY || process.env.https_proxy,
|
proxyUrl: process.env.HTTPS_PROXY || process.env.https_proxy,
|
||||||
|
contentProxyUrl: config.contentProxyUrl,
|
||||||
importMode: importMode ? true : undefined, // for stringify()
|
importMode: importMode ? true : undefined, // for stringify()
|
||||||
serverTrustRoot: config.get('serverTrustRoot'),
|
serverTrustRoot: config.get('serverTrustRoot'),
|
||||||
...moreKeys,
|
...moreKeys,
|
||||||
|
|
|
@ -64,6 +64,7 @@
|
||||||
"glob": "7.1.2",
|
"glob": "7.1.2",
|
||||||
"google-libphonenumber": "3.0.7",
|
"google-libphonenumber": "3.0.7",
|
||||||
"got": "8.2.0",
|
"got": "8.2.0",
|
||||||
|
"he": "1.2.0",
|
||||||
"intl-tel-input": "12.1.15",
|
"intl-tel-input": "12.1.15",
|
||||||
"jquery": "3.3.1",
|
"jquery": "3.3.1",
|
||||||
"linkify-it": "2.0.3",
|
"linkify-it": "2.0.3",
|
||||||
|
|
48
preload.js
48
preload.js
|
@ -28,6 +28,7 @@ window.getExpiration = () => config.buildExpiration;
|
||||||
window.getNodeVersion = () => config.node_version;
|
window.getNodeVersion = () => config.node_version;
|
||||||
window.getHostName = () => config.hostname;
|
window.getHostName = () => config.hostname;
|
||||||
window.getServerTrustRoot = () => config.serverTrustRoot;
|
window.getServerTrustRoot = () => config.serverTrustRoot;
|
||||||
|
window.isBehindProxy = () => Boolean(config.proxyUrl);
|
||||||
|
|
||||||
window.isBeforeVersion = (toCheck, baseVersion) => {
|
window.isBeforeVersion = (toCheck, baseVersion) => {
|
||||||
try {
|
try {
|
||||||
|
@ -173,16 +174,20 @@ ipc.on('get-ready-for-shutdown', async () => {
|
||||||
function installGetter(name, functionName) {
|
function installGetter(name, functionName) {
|
||||||
ipc.on(`get-${name}`, async () => {
|
ipc.on(`get-${name}`, async () => {
|
||||||
const getFn = window.Events[functionName];
|
const getFn = window.Events[functionName];
|
||||||
if (getFn) {
|
if (!getFn) {
|
||||||
// eslint-disable-next-line no-param-reassign
|
ipc.send(
|
||||||
try {
|
`get-success-${name}`,
|
||||||
ipc.send(`get-success-${name}`, null, await getFn());
|
`installGetter: ${functionName} not found for event ${name}`
|
||||||
} catch (error) {
|
);
|
||||||
ipc.send(
|
return;
|
||||||
`get-success-${name}`,
|
}
|
||||||
error && error.stack ? error.stack : error
|
try {
|
||||||
);
|
ipc.send(`get-success-${name}`, null, await getFn());
|
||||||
}
|
} catch (error) {
|
||||||
|
ipc.send(
|
||||||
|
`get-success-${name}`,
|
||||||
|
error && error.stack ? error.stack : error
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -190,13 +195,21 @@ function installGetter(name, functionName) {
|
||||||
function installSetter(name, functionName) {
|
function installSetter(name, functionName) {
|
||||||
ipc.on(`set-${name}`, async (_event, value) => {
|
ipc.on(`set-${name}`, async (_event, value) => {
|
||||||
const setFn = window.Events[functionName];
|
const setFn = window.Events[functionName];
|
||||||
if (setFn) {
|
if (!setFn) {
|
||||||
try {
|
ipc.send(
|
||||||
await setFn(value);
|
`set-success-${name}`,
|
||||||
ipc.send(`set-success-${name}`);
|
`installSetter: ${functionName} not found for event ${name}`
|
||||||
} catch (error) {
|
);
|
||||||
ipc.send(`set-success-${name}`, error);
|
return;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
await setFn(value);
|
||||||
|
ipc.send(`set-success-${name}`);
|
||||||
|
} catch (error) {
|
||||||
|
ipc.send(
|
||||||
|
`set-success-${name}`,
|
||||||
|
error && error.stack ? error.stack : error
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -220,6 +233,7 @@ window.WebAPI = initializeWebAPI({
|
||||||
url: config.serverUrl,
|
url: config.serverUrl,
|
||||||
cdnUrl: config.cdnUrl,
|
cdnUrl: config.cdnUrl,
|
||||||
certificateAuthority: config.certificateAuthority,
|
certificateAuthority: config.certificateAuthority,
|
||||||
|
contentProxyUrl: config.contentProxyUrl,
|
||||||
proxyUrl: config.proxyUrl,
|
proxyUrl: config.proxyUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -156,6 +156,12 @@ message DataMessage {
|
||||||
optional string organization = 7;
|
optional string organization = 7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message Preview {
|
||||||
|
optional string url = 1;
|
||||||
|
optional string title = 2;
|
||||||
|
optional AttachmentPointer image = 3;
|
||||||
|
}
|
||||||
|
|
||||||
optional string body = 1;
|
optional string body = 1;
|
||||||
repeated AttachmentPointer attachments = 2;
|
repeated AttachmentPointer attachments = 2;
|
||||||
optional GroupContext group = 3;
|
optional GroupContext group = 3;
|
||||||
|
@ -165,6 +171,7 @@ message DataMessage {
|
||||||
optional uint64 timestamp = 7;
|
optional uint64 timestamp = 7;
|
||||||
optional Quote quote = 8;
|
optional Quote quote = 8;
|
||||||
repeated Contact contact = 9;
|
repeated Contact contact = 9;
|
||||||
|
repeated Preview preview = 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
message NullMessage {
|
message NullMessage {
|
||||||
|
@ -254,6 +261,7 @@ message SyncMessage {
|
||||||
optional bool readReceipts = 1;
|
optional bool readReceipts = 1;
|
||||||
optional bool unidentifiedDeliveryIndicators = 2;
|
optional bool unidentifiedDeliveryIndicators = 2;
|
||||||
optional bool typingIndicators = 3;
|
optional bool typingIndicators = 3;
|
||||||
|
optional bool linkPreviews = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
optional Sent sent = 1;
|
optional Sent sent = 1;
|
||||||
|
|
|
@ -87,11 +87,11 @@
|
||||||
</div>
|
</div>
|
||||||
{{ /isAudioNotificationSupported }}
|
{{ /isAudioNotificationSupported }}
|
||||||
<hr>
|
<hr>
|
||||||
<div class='spell-check-setting'>
|
<h3>{{ generalHeader }}</h3>
|
||||||
<h3>{{ spellCheckHeader }}</h3>
|
<div class='spell-check-setting'>
|
||||||
<input type='checkbox' name='spell-check-setting' id='spell-check-setting' />
|
<input type='checkbox' name='spell-check-setting' id='spell-check-setting' />
|
||||||
<label for='spell-check-setting'>{{ spellCheckDescription }}</label>
|
<label for='spell-check-setting'>{{ spellCheckDescription }}</label>
|
||||||
</div>
|
</div>
|
||||||
<hr>
|
<hr>
|
||||||
<div class='permissions-setting'>
|
<div class='permissions-setting'>
|
||||||
<h3>{{ permissions }}</h3>
|
<h3>{{ permissions }}</h3>
|
||||||
|
|
|
@ -190,6 +190,13 @@
|
||||||
margin-bottom: -5px;
|
margin-bottom: -5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bottom-bar .preview-wrapper {
|
||||||
|
margin-top: 3px;
|
||||||
|
margin-left: 37px;
|
||||||
|
margin-right: 73px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.bottom-bar {
|
.bottom-bar {
|
||||||
box-sizing: content-box;
|
box-sizing: content-box;
|
||||||
$button-width: 36px;
|
$button-width: 36px;
|
||||||
|
|
|
@ -200,8 +200,6 @@
|
||||||
background-color: $color-conversation-blue_grey;
|
background-color: $color-conversation-blue_grey;
|
||||||
}
|
}
|
||||||
|
|
||||||
// START
|
|
||||||
|
|
||||||
.module-message__attachment-container {
|
.module-message__attachment-container {
|
||||||
// Entirely to ensure that images are centered if they aren't full width of bubble
|
// Entirely to ensure that images are centered if they aren't full width of bubble
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
@ -357,6 +355,77 @@
|
||||||
color: $color-white;
|
color: $color-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.module-message__generic-attachment__file-size--incoming {
|
||||||
|
color: $color-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-message__link-preview {
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: -12px;
|
||||||
|
margin-right: -12px;
|
||||||
|
margin-top: -10px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
border-top-left-radius: 16px;
|
||||||
|
border-top-right-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-message__link-preview--with-content-above {
|
||||||
|
margin-top: 4px;
|
||||||
|
border-top-left-radius: 0px;
|
||||||
|
border-top-right-radius: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-message__link-preview__content {
|
||||||
|
padding: 8px;
|
||||||
|
border-top-left-radius: 16px;
|
||||||
|
border-top-right-radius: 16px;
|
||||||
|
background-color: $color-white;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-start;
|
||||||
|
border: 1px solid $color-black-015;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-message__link-preview__content--with-content-above {
|
||||||
|
border-top: none;
|
||||||
|
border-bottom: none;
|
||||||
|
border-top-left-radius: 0px;
|
||||||
|
border-top-right-radius: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-message__link-preview__icon_container {
|
||||||
|
margin: -2px;
|
||||||
|
margin-right: 8px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-message__link-preview__text--with-icon {
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-message__link-preview__title {
|
||||||
|
color: $color-gray-90;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 300;
|
||||||
|
letter-spacing: 0.15px;
|
||||||
|
line-height: 22px;
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-message__link-preview__location {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: $color-gray-60;
|
||||||
|
font-size: 12px;
|
||||||
|
height: 16px;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
line-height: 16px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
.module-message__author {
|
.module-message__author {
|
||||||
color: $color-white;
|
color: $color-white;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
@ -2064,6 +2133,9 @@
|
||||||
.module-image--curved-bottom-right {
|
.module-image--curved-bottom-right {
|
||||||
border-bottom-right-radius: 16px;
|
border-bottom-right-radius: 16px;
|
||||||
}
|
}
|
||||||
|
.module-image--small-curved-top-left {
|
||||||
|
border-top-left-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.module-image__border-overlay {
|
.module-image__border-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
@ -2544,6 +2616,65 @@
|
||||||
@include color-svg('../images/plus-36.svg', $color-gray-45);
|
@include color-svg('../images/plus-36.svg', $color-gray-45);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Module: Staged Link Preview
|
||||||
|
|
||||||
|
.module-staged-link-preview {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
min-height: 65px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-staged-link-preview--is-loading {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.module-staged-link-preview__loading {
|
||||||
|
color: $color-gray-60;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-staged-link-preview__icon-container {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
.module-staged-link-preview__content {
|
||||||
|
margin-right: 20px;
|
||||||
|
}
|
||||||
|
.module-staged-link-preview__title {
|
||||||
|
color: $color-gray-90;
|
||||||
|
font-weight: 300;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 18px;
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
.module-staged-link-preview__location {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: $color-gray-60;
|
||||||
|
font-size: 11px;
|
||||||
|
height: 16px;
|
||||||
|
letter-spacing: 0.25px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.module-staged-link-preview__close-button {
|
||||||
|
cursor: pointer;
|
||||||
|
position: absolute;
|
||||||
|
top: 0px;
|
||||||
|
right: 0px;
|
||||||
|
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
|
||||||
|
@include color-svg('../images/x-16.svg', $color-gray-60);
|
||||||
|
}
|
||||||
|
|
||||||
// Third-party module: react-contextmenu
|
// Third-party module: react-contextmenu
|
||||||
|
|
||||||
.react-contextmenu {
|
.react-contextmenu {
|
||||||
|
@ -2632,7 +2763,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// To limit messages with things forcing them wider, like long attachment names
|
// To limit messages with things forcing them wider, like long attachment names
|
||||||
.module-message {
|
.module-message__container {
|
||||||
max-width: 300px;
|
max-width: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2641,6 +2772,9 @@
|
||||||
.module-message {
|
.module-message {
|
||||||
max-width: 374px;
|
max-width: 374px;
|
||||||
}
|
}
|
||||||
|
.module-message__container {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
// Spec: container < 438px
|
// Spec: container < 438px
|
||||||
.module-message--incoming {
|
.module-message--incoming {
|
||||||
|
@ -2674,6 +2808,9 @@
|
||||||
.module-message {
|
.module-message {
|
||||||
max-width: 66%;
|
max-width: 66%;
|
||||||
}
|
}
|
||||||
|
.module-message__container {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.module-message--incoming {
|
.module-message--incoming {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
|
|
|
@ -56,4 +56,12 @@
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.send-link-previews-setting {
|
||||||
|
margin-top: 0.75em;
|
||||||
|
}
|
||||||
|
.description {
|
||||||
|
margin-top: 0.3em;
|
||||||
|
margin-left: 1.5em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -680,6 +680,24 @@ body.dark-theme {
|
||||||
color: $color-white;
|
color: $color-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.module-message__link-preview__content {
|
||||||
|
background-color: $color-gray-95;
|
||||||
|
border: 1px solid $color-gray-60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-message__link-preview__content--with-content-above {
|
||||||
|
border-top: none;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-message__link-preview__title {
|
||||||
|
color: $color-gray-05;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-message__link-preview__location {
|
||||||
|
color: $color-gray-25;
|
||||||
|
}
|
||||||
|
|
||||||
.module-message__author {
|
.module-message__author {
|
||||||
color: $color-white;
|
color: $color-white;
|
||||||
}
|
}
|
||||||
|
@ -1308,6 +1326,10 @@ body.dark-theme {
|
||||||
|
|
||||||
// Module: Image
|
// Module: Image
|
||||||
|
|
||||||
|
.module-image__border-overlay {
|
||||||
|
box-shadow: inset 0px 0px 0px 1px $color-white-015;
|
||||||
|
}
|
||||||
|
|
||||||
// Module: Image Grid
|
// Module: Image Grid
|
||||||
|
|
||||||
// Module: Typing Animation
|
// Module: Typing Animation
|
||||||
|
@ -1364,6 +1386,21 @@ body.dark-theme {
|
||||||
@include color-svg('../images/plus-36.svg', $color-gray-60);
|
@include color-svg('../images/plus-36.svg', $color-gray-60);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Module: Staged Link Preview
|
||||||
|
|
||||||
|
.module-staged-link-preview__loading {
|
||||||
|
color: $color-gray-25;
|
||||||
|
}
|
||||||
|
.module-staged-link-preview__title {
|
||||||
|
color: $color-gray-05;
|
||||||
|
}
|
||||||
|
.module-staged-link-preview__location {
|
||||||
|
color: $color-gray-25;
|
||||||
|
}
|
||||||
|
.module-staged-link-preview__close-button {
|
||||||
|
@include color-svg('../images/x-16.svg', $color-gray-25);
|
||||||
|
}
|
||||||
|
|
||||||
// Third-party module: react-contextmenu
|
// Third-party module: react-contextmenu
|
||||||
|
|
||||||
.react-contextmenu {
|
.react-contextmenu {
|
||||||
|
|
|
@ -372,6 +372,15 @@ describe('Backup', () => {
|
||||||
return attachment;
|
return attachment;
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
|
preview: await Promise.all(
|
||||||
|
(message.preview || []).map(async item => {
|
||||||
|
if (item.image) {
|
||||||
|
await wrappedLoadAttachment(item.image);
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
})
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -380,8 +389,9 @@ describe('Backup', () => {
|
||||||
// Seven total:
|
// Seven total:
|
||||||
// - Five from image/video attachments
|
// - Five from image/video attachments
|
||||||
// - One from embedded contact avatar
|
// - One from embedded contact avatar
|
||||||
// - Another from embedded quoted attachment thumbnail
|
// - One from embedded quoted attachment thumbnail
|
||||||
const ATTACHMENT_COUNT = 7;
|
// - One from a link preview image
|
||||||
|
const ATTACHMENT_COUNT = 8;
|
||||||
const MESSAGE_COUNT = 1;
|
const MESSAGE_COUNT = 1;
|
||||||
const CONVERSATION_COUNT = 1;
|
const CONVERSATION_COUNT = 1;
|
||||||
|
|
||||||
|
@ -447,6 +457,17 @@ describe('Backup', () => {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
preview: [
|
||||||
|
{
|
||||||
|
url: 'https://www.instagram.com/p/BsOGulcndj-/',
|
||||||
|
title:
|
||||||
|
'EGG GANG 🌍 on Instagram: “Let’s set a world record together and get the most liked post on Instagram. Beating the current world record held by Kylie Jenner (18…”',
|
||||||
|
image: {
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
data: FIXTURES.jpg,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('Backup test: Clear all data');
|
console.log('Backup test: Clear all data');
|
||||||
|
|
231
test/modules/link_previews_test.js
Normal file
231
test/modules/link_previews_test.js
Normal file
|
@ -0,0 +1,231 @@
|
||||||
|
const { assert } = require('chai');
|
||||||
|
|
||||||
|
const {
|
||||||
|
getTitleMetaTag,
|
||||||
|
getImageMetaTag,
|
||||||
|
isLinkInWhitelist,
|
||||||
|
isMediaLinkInWhitelist,
|
||||||
|
} = require('../../js/modules/link_previews');
|
||||||
|
|
||||||
|
describe('Link previews', () => {
|
||||||
|
describe('#isLinkInWhitelist', () => {
|
||||||
|
it('returns true for valid links', () => {
|
||||||
|
assert.strictEqual(isLinkInWhitelist('https://youtube.com/blah'), true);
|
||||||
|
assert.strictEqual(
|
||||||
|
isLinkInWhitelist('https://www.youtube.com/blah'),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.strictEqual(isLinkInWhitelist('https://m.youtube.com/blah'), true);
|
||||||
|
assert.strictEqual(isLinkInWhitelist('https://youtu.be/blah'), true);
|
||||||
|
assert.strictEqual(isLinkInWhitelist('https://reddit.com/blah'), true);
|
||||||
|
assert.strictEqual(
|
||||||
|
isLinkInWhitelist('https://www.reddit.com/blah'),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.strictEqual(isLinkInWhitelist('https://m.reddit.com/blah'), true);
|
||||||
|
assert.strictEqual(isLinkInWhitelist('https://imgur.com/blah'), true);
|
||||||
|
assert.strictEqual(isLinkInWhitelist('https://www.imgur.com/blah'), true);
|
||||||
|
assert.strictEqual(isLinkInWhitelist('https://m.imgur.com/blah'), true);
|
||||||
|
assert.strictEqual(isLinkInWhitelist('https://instagram.com/blah'), true);
|
||||||
|
assert.strictEqual(
|
||||||
|
isLinkInWhitelist('https://www.instagram.com/blah'),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
isLinkInWhitelist('https://m.instagram.com/blah'),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for subdomains', () => {
|
||||||
|
assert.strictEqual(
|
||||||
|
isLinkInWhitelist('https://any.subdomain.youtube.com/blah'),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
isLinkInWhitelist('https://any.subdomain.instagram.com/blah'),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for http links', () => {
|
||||||
|
assert.strictEqual(isLinkInWhitelist('http://instagram.com/blah'), false);
|
||||||
|
assert.strictEqual(isLinkInWhitelist('http://youtube.com/blah'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for links with no protocol', () => {
|
||||||
|
assert.strictEqual(isLinkInWhitelist('instagram.com/blah'), false);
|
||||||
|
assert.strictEqual(isLinkInWhitelist('youtube.com/blah'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for link to root path', () => {
|
||||||
|
assert.strictEqual(isLinkInWhitelist('https://instagram.com'), false);
|
||||||
|
assert.strictEqual(isLinkInWhitelist('https://youtube.com'), false);
|
||||||
|
|
||||||
|
assert.strictEqual(isLinkInWhitelist('https://instagram.com/'), false);
|
||||||
|
assert.strictEqual(isLinkInWhitelist('https://youtube.com/'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for other well-known sites', () => {
|
||||||
|
assert.strictEqual(isLinkInWhitelist('https://facebook.com/blah'), false);
|
||||||
|
assert.strictEqual(isLinkInWhitelist('https://twitter.com/blah'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for links that look like our target links', () => {
|
||||||
|
assert.strictEqual(
|
||||||
|
isLinkInWhitelist('https://evil.site.com/.instagram.com/blah'),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
isLinkInWhitelist('https://evil.site.com/.instagram.com/blah'),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
isLinkInWhitelist('https://sinstagram.com/blah'),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#isMediaLinkInWhitelist', () => {
|
||||||
|
it('returns true for valid links', () => {
|
||||||
|
assert.strictEqual(
|
||||||
|
isMediaLinkInWhitelist(
|
||||||
|
'https://i.ytimg.com/vi/bZHShcCEH3I/hqdefault.jpg'
|
||||||
|
),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
isMediaLinkInWhitelist('https://random.cdninstagram.com/blah'),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
isMediaLinkInWhitelist('https://preview.redd.it/something'),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
isMediaLinkInWhitelist('https://i.imgur.com/something'),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for insecure protocol', () => {
|
||||||
|
assert.strictEqual(
|
||||||
|
isMediaLinkInWhitelist(
|
||||||
|
'http://i.ytimg.com/vi/bZHShcCEH3I/hqdefault.jpg'
|
||||||
|
),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
isMediaLinkInWhitelist('http://random.cdninstagram.com/blah'),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
isMediaLinkInWhitelist('http://preview.redd.it/something'),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
isMediaLinkInWhitelist('http://i.imgur.com/something'),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for other domains', () => {
|
||||||
|
assert.strictEqual(
|
||||||
|
isMediaLinkInWhitelist('https://www.youtube.com/something'),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
isMediaLinkInWhitelist('https://youtu.be/something'),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
isMediaLinkInWhitelist('https://www.instagram.com/something'),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
isMediaLinkInWhitelist('https://cnn.com/something'),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#_getMetaTag', () => {
|
||||||
|
it('returns html-decoded tag contents from Youtube', () => {
|
||||||
|
const youtube = `
|
||||||
|
<meta property="og:site_name" content="YouTube">
|
||||||
|
<meta property="og:url" content="https://www.youtube.com/watch?v=tP-Ipsat90c">
|
||||||
|
<meta property="og:type" content="video.other">
|
||||||
|
<meta property="og:title" content="Randomness is Random - Numberphile">
|
||||||
|
<meta property="og:image" content="https://i.ytimg.com/vi/tP-Ipsat90c/maxresdefault.jpg">
|
||||||
|
`;
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
'Randomness is Random - Numberphile',
|
||||||
|
getTitleMetaTag(youtube)
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
'https://i.ytimg.com/vi/tP-Ipsat90c/maxresdefault.jpg',
|
||||||
|
getImageMetaTag(youtube)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns html-decoded tag contents from Instagram', () => {
|
||||||
|
const instagram = `
|
||||||
|
<meta property="og:site_name" content="Instagram" />
|
||||||
|
<meta property="og:url" content="https://www.instagram.com/p/BrgpsUjF9Jo/" />
|
||||||
|
<meta property="og:type" content="instapp:photo" />
|
||||||
|
<meta property="og:title" content="Walter "MFPallytime" on Instagram: “Lol gg”" />
|
||||||
|
<meta property="og:description" content="632 Likes, 56 Comments - Walter "MFPallytime" (@mfpallytime) on Instagram: “Lol gg ”" />
|
||||||
|
<meta property="og:image" content="https://scontent-lax3-1.cdninstagram.com/vp/1c69aa381c2201720c29a6c28de42ffd/5CD49B5B/t51.2885-15/e35/47690175_2275988962411653_1145978227188801192_n.jpg?_nc_ht=scontent-lax3-1.cdninstagram.com" />
|
||||||
|
`;
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
'Walter "MFPallytime" on Instagram: “Lol gg”',
|
||||||
|
getTitleMetaTag(instagram)
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
'https://scontent-lax3-1.cdninstagram.com/vp/1c69aa381c2201720c29a6c28de42ffd/5CD49B5B/t51.2885-15/e35/47690175_2275988962411653_1145978227188801192_n.jpg?_nc_ht=scontent-lax3-1.cdninstagram.com',
|
||||||
|
getImageMetaTag(instagram)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns html-decoded tag contents from Instagram', () => {
|
||||||
|
const imgur = `
|
||||||
|
<meta property="og:site_name" content="Imgur">
|
||||||
|
<meta property="og:url" content="https://imgur.com/gallery/KFCL8fm">
|
||||||
|
<meta property="og:type" content="article">
|
||||||
|
<meta property="og:title" content=" ">
|
||||||
|
<meta property="og:description" content="13246 views and 482 votes on Imgur">
|
||||||
|
<meta property="og:image" content="https://i.imgur.com/Y3wjlwY.jpg?fb">
|
||||||
|
<meta property="og:image:width" content="600">
|
||||||
|
<meta property="og:image:height" content="315">
|
||||||
|
`;
|
||||||
|
|
||||||
|
assert.strictEqual('', getTitleMetaTag(imgur));
|
||||||
|
assert.strictEqual(
|
||||||
|
'https://i.imgur.com/Y3wjlwY.jpg?fb',
|
||||||
|
getImageMetaTag(imgur)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns only the first tag', () => {
|
||||||
|
const html = `
|
||||||
|
<meta property="og:title" content="First Second Third"><meta property="og:title" content="Fourth Fifth Sixth">
|
||||||
|
`;
|
||||||
|
|
||||||
|
assert.strictEqual('First Second Third', getTitleMetaTag(html));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles a newline in attribute value', () => {
|
||||||
|
const html = `
|
||||||
|
<meta property="og:title" content="First thing\r\nSecond thing\nThird thing">
|
||||||
|
`;
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
'First thing\r\nSecond thing\nThird thing',
|
||||||
|
getTitleMetaTag(html)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -72,6 +72,7 @@ describe('Message', () => {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
contact: [],
|
contact: [],
|
||||||
|
preview: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const writeExistingAttachmentData = attachment => {
|
const writeExistingAttachmentData = attachment => {
|
||||||
|
@ -119,6 +120,7 @@ describe('Message', () => {
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
contact: [],
|
contact: [],
|
||||||
|
preview: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const writeExistingAttachmentData = attachment => {
|
const writeExistingAttachmentData = attachment => {
|
||||||
|
@ -169,6 +171,7 @@ describe('Message', () => {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
preview: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const writeExistingAttachmentData = attachment => {
|
const writeExistingAttachmentData = attachment => {
|
||||||
|
|
|
@ -20,6 +20,9 @@ interface Props {
|
||||||
curveBottomRight?: boolean;
|
curveBottomRight?: boolean;
|
||||||
curveTopLeft?: boolean;
|
curveTopLeft?: boolean;
|
||||||
curveTopRight?: boolean;
|
curveTopRight?: boolean;
|
||||||
|
|
||||||
|
smallCurveTopLeft?: boolean;
|
||||||
|
|
||||||
darkOverlay?: boolean;
|
darkOverlay?: boolean;
|
||||||
playIconOverlay?: boolean;
|
playIconOverlay?: boolean;
|
||||||
softCorners?: boolean;
|
softCorners?: boolean;
|
||||||
|
@ -50,6 +53,7 @@ export class Image extends React.Component<Props> {
|
||||||
onError,
|
onError,
|
||||||
overlayText,
|
overlayText,
|
||||||
playIconOverlay,
|
playIconOverlay,
|
||||||
|
smallCurveTopLeft,
|
||||||
softCorners,
|
softCorners,
|
||||||
url,
|
url,
|
||||||
width,
|
width,
|
||||||
|
@ -72,6 +76,7 @@ export class Image extends React.Component<Props> {
|
||||||
curveBottomRight ? 'module-image--curved-bottom-right' : null,
|
curveBottomRight ? 'module-image--curved-bottom-right' : null,
|
||||||
curveTopLeft ? 'module-image--curved-top-left' : null,
|
curveTopLeft ? 'module-image--curved-top-left' : null,
|
||||||
curveTopRight ? 'module-image--curved-top-right' : null,
|
curveTopRight ? 'module-image--curved-top-right' : null,
|
||||||
|
smallCurveTopLeft ? 'module-image--small-curved-top-left' : null,
|
||||||
softCorners ? 'module-image--soft-corners' : null
|
softCorners ? 'module-image--soft-corners' : null
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
@ -97,6 +102,7 @@ export class Image extends React.Component<Props> {
|
||||||
curveTopRight ? 'module-image--curved-top-right' : null,
|
curveTopRight ? 'module-image--curved-top-right' : null,
|
||||||
curveBottomLeft ? 'module-image--curved-bottom-left' : null,
|
curveBottomLeft ? 'module-image--curved-bottom-left' : null,
|
||||||
curveBottomRight ? 'module-image--curved-bottom-right' : null,
|
curveBottomRight ? 'module-image--curved-bottom-right' : null,
|
||||||
|
smallCurveTopLeft ? 'module-image--small-curved-top-left' : null,
|
||||||
softCorners ? 'module-image--soft-corners' : null,
|
softCorners ? 'module-image--soft-corners' : null,
|
||||||
darkOverlay ? 'module-image__border-overlay--dark' : null
|
darkOverlay ? 'module-image__border-overlay--dark' : null
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -11,8 +11,8 @@ import { Localizer } from '../../types/Util';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
attachments: Array<AttachmentType>;
|
attachments: Array<AttachmentType>;
|
||||||
withContentAbove: boolean;
|
withContentAbove?: boolean;
|
||||||
withContentBelow: boolean;
|
withContentBelow?: boolean;
|
||||||
bottomOverlay?: boolean;
|
bottomOverlay?: boolean;
|
||||||
|
|
||||||
i18n: Localizer;
|
i18n: Localizer;
|
||||||
|
@ -370,7 +370,7 @@ type DimensionsType = {
|
||||||
width: number;
|
width: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getImageDimensions(attachment: AttachmentType): DimensionsType {
|
export function getImageDimensions(attachment: AttachmentType): DimensionsType {
|
||||||
const { height, width } = attachment;
|
const { height, width } = attachment;
|
||||||
if (!height || !width) {
|
if (!height || !width) {
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -202,12 +202,21 @@ Note that timestamp and status can be hidden with the `collapseMetadata` boolean
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Message
|
<Message
|
||||||
direction="incoming"
|
direction="outgoing"
|
||||||
status="error"
|
status="error"
|
||||||
authorColor="purple"
|
authorColor="purple"
|
||||||
timestamp={Date.now()}
|
timestamp={Date.now() - 56}
|
||||||
text="Error!"
|
text="Error!"
|
||||||
|
attachments={[
|
||||||
|
{
|
||||||
|
url: util.gifObjectUrl,
|
||||||
|
contentType: 'image/gif',
|
||||||
|
width: 320,
|
||||||
|
height: 240,
|
||||||
|
},
|
||||||
|
]}
|
||||||
i18n={util.i18n}
|
i18n={util.i18n}
|
||||||
|
onRetrySend={() => console.log('onRetrySend')}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
|
@ -261,6 +270,25 @@ Note that timestamp and status can be hidden with the `collapseMetadata` boolean
|
||||||
onRetrySend={() => console.log('onRetrySend')}
|
onRetrySend={() => console.log('onRetrySend')}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<Message
|
||||||
|
direction="outgoing"
|
||||||
|
status="error"
|
||||||
|
authorColor="purple"
|
||||||
|
timestamp={Date.now() - 57}
|
||||||
|
attachments={[
|
||||||
|
{
|
||||||
|
url: util.gifObjectUrl,
|
||||||
|
contentType: 'image/gif',
|
||||||
|
width: 320,
|
||||||
|
height: 240,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
text="🔥"
|
||||||
|
i18n={util.i18n}
|
||||||
|
onRetrySend={() => console.log('onRetrySend')}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Message
|
<Message
|
||||||
direction="incoming"
|
direction="incoming"
|
||||||
|
@ -271,6 +299,24 @@ Note that timestamp and status can be hidden with the `collapseMetadata` boolean
|
||||||
i18n={util.i18n}
|
i18n={util.i18n}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<Message
|
||||||
|
direction="incoming"
|
||||||
|
status="error"
|
||||||
|
authorColor="purple"
|
||||||
|
timestamp={Date.now()}
|
||||||
|
text="🔥"
|
||||||
|
attachments={[
|
||||||
|
{
|
||||||
|
url: util.gifObjectUrl,
|
||||||
|
contentType: 'image/gif',
|
||||||
|
width: 320,
|
||||||
|
height: 240,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
i18n={util.i18n}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
</util.ConversationContext>
|
</util.ConversationContext>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -2533,6 +2579,313 @@ Voice notes are not shown any differently from audio attachments.
|
||||||
</util.ConversationContext>
|
</util.ConversationContext>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Link previews, full-size image
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<util.ConversationContext theme={util.theme} ios={util.ios}>
|
||||||
|
<li>
|
||||||
|
<Message
|
||||||
|
authorColor="green"
|
||||||
|
direction="incoming"
|
||||||
|
i18n={util.i18n}
|
||||||
|
timestamp={Date.now()}
|
||||||
|
text="Pretty sweet link: https://instagram.com/something"
|
||||||
|
previews={[
|
||||||
|
{
|
||||||
|
title: 'This is a really sweet post',
|
||||||
|
domain: 'instagram.com',
|
||||||
|
image: {
|
||||||
|
url: util.pngObjectUrl,
|
||||||
|
contentType: 'image/png',
|
||||||
|
width: 800,
|
||||||
|
height: 1200,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onClickLinkPreview={url => console.log('onClickLinkPreview', url)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Message
|
||||||
|
authorColor="green"
|
||||||
|
direction="outgoing"
|
||||||
|
i18n={util.i18n}
|
||||||
|
timestamp={Date.now()}
|
||||||
|
status="sent"
|
||||||
|
text="Pretty sweet link: https://instagram.com/something"
|
||||||
|
previews={[
|
||||||
|
{
|
||||||
|
title: 'This is a really sweet post',
|
||||||
|
domain: 'instagram.com',
|
||||||
|
image: {
|
||||||
|
url: util.pngObjectUrl,
|
||||||
|
contentType: 'image/png',
|
||||||
|
width: 800,
|
||||||
|
height: 1200,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onClickLinkPreview={url => console.log('onClickLinkPreview', url)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Message
|
||||||
|
authorColor="green"
|
||||||
|
direction="incoming"
|
||||||
|
i18n={util.i18n}
|
||||||
|
timestamp={Date.now()}
|
||||||
|
quote={{
|
||||||
|
authorColor: 'purple',
|
||||||
|
text: 'How many ferrets do you have?',
|
||||||
|
authorPhoneNumber: '(202) 555-0011',
|
||||||
|
onClick: () => console.log('onClick'),
|
||||||
|
}}
|
||||||
|
text="Pretty sweet link: https://instagram.com/something"
|
||||||
|
previews={[
|
||||||
|
{
|
||||||
|
title: 'This is a really sweet post',
|
||||||
|
domain: 'instagram.com',
|
||||||
|
image: {
|
||||||
|
url: util.pngObjectUrl,
|
||||||
|
contentType: 'image/png',
|
||||||
|
width: 800,
|
||||||
|
height: 1200,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onClickLinkPreview={url => console.log('onClickLinkPreview', url)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Message
|
||||||
|
authorColor="green"
|
||||||
|
direction="outgoing"
|
||||||
|
i18n={util.i18n}
|
||||||
|
timestamp={Date.now()}
|
||||||
|
status="sent"
|
||||||
|
quote={{
|
||||||
|
authorColor: 'purple',
|
||||||
|
text: 'How many ferrets do you have?',
|
||||||
|
authorPhoneNumber: '(202) 555-0011',
|
||||||
|
onClick: () => console.log('onClick'),
|
||||||
|
}}
|
||||||
|
text="Pretty sweet link: https://instagram.com/something"
|
||||||
|
previews={[
|
||||||
|
{
|
||||||
|
title: 'This is a really sweet post',
|
||||||
|
domain: 'instagram.com',
|
||||||
|
image: {
|
||||||
|
url: util.pngObjectUrl,
|
||||||
|
contentType: 'image/png',
|
||||||
|
width: 800,
|
||||||
|
height: 1200,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onClickLinkPreview={url => console.log('onClickLinkPreview', url)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</util.ConversationContext>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Link previews, small image
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<util.ConversationContext theme={util.theme} ios={util.ios}>
|
||||||
|
<li>
|
||||||
|
<Message
|
||||||
|
authorColor="green"
|
||||||
|
direction="incoming"
|
||||||
|
i18n={util.i18n}
|
||||||
|
timestamp={Date.now()}
|
||||||
|
text="Pretty sweet link: https://instagram.com/something"
|
||||||
|
previews={[
|
||||||
|
{
|
||||||
|
title: 'This is a really sweet post',
|
||||||
|
domain: 'instagram.com',
|
||||||
|
image: {
|
||||||
|
url: util.pngObjectUrl,
|
||||||
|
contentType: 'image/png',
|
||||||
|
width: 160,
|
||||||
|
height: 120,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onClickLinkPreview={url => console.log('onClickLinkPreview', url)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Message
|
||||||
|
authorColor="green"
|
||||||
|
direction="outgoing"
|
||||||
|
i18n={util.i18n}
|
||||||
|
timestamp={Date.now()}
|
||||||
|
status="sent"
|
||||||
|
text="Pretty sweet link: https://instagram.com/something"
|
||||||
|
previews={[
|
||||||
|
{
|
||||||
|
title: 'This is a really sweet post',
|
||||||
|
domain: 'instagram.com',
|
||||||
|
image: {
|
||||||
|
url: util.pngObjectUrl,
|
||||||
|
contentType: 'image/png',
|
||||||
|
width: 160,
|
||||||
|
height: 120,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onClickLinkPreview={url => console.log('onClickLinkPreview', url)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Message
|
||||||
|
authorColor="green"
|
||||||
|
direction="incoming"
|
||||||
|
i18n={util.i18n}
|
||||||
|
timestamp={Date.now()}
|
||||||
|
quote={{
|
||||||
|
authorColor: 'purple',
|
||||||
|
text: 'How many ferrets do you have?',
|
||||||
|
authorPhoneNumber: '(202) 555-0011',
|
||||||
|
onClick: () => console.log('onClick'),
|
||||||
|
}}
|
||||||
|
text="Pretty sweet link: https://instagram.com/something"
|
||||||
|
previews={[
|
||||||
|
{
|
||||||
|
title:
|
||||||
|
'This is a really sweet post with a really long name. Gotta restrict that to just two lines, you know how that goes...',
|
||||||
|
domain: 'instagram.com',
|
||||||
|
image: {
|
||||||
|
url: util.pngObjectUrl,
|
||||||
|
contentType: 'image/png',
|
||||||
|
width: 160,
|
||||||
|
height: 120,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onClickLinkPreview={url => console.log('onClickLinkPreview', url)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Message
|
||||||
|
authorColor="green"
|
||||||
|
direction="outgoing"
|
||||||
|
i18n={util.i18n}
|
||||||
|
timestamp={Date.now()}
|
||||||
|
status="sent"
|
||||||
|
quote={{
|
||||||
|
authorColor: 'purple',
|
||||||
|
text: 'How many ferrets do you have?',
|
||||||
|
authorPhoneNumber: '(202) 555-0011',
|
||||||
|
onClick: () => console.log('onClick'),
|
||||||
|
}}
|
||||||
|
text="Pretty sweet link: https://instagram.com/something"
|
||||||
|
previews={[
|
||||||
|
{
|
||||||
|
title:
|
||||||
|
'This is a really sweet post with a really long name. Gotta restrict that to just two lines, you know how that goes...',
|
||||||
|
domain: 'instagram.com',
|
||||||
|
image: {
|
||||||
|
url: util.pngObjectUrl,
|
||||||
|
contentType: 'image/png',
|
||||||
|
width: 160,
|
||||||
|
height: 120,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onClickLinkPreview={url => console.log('onClickLinkPreview', url)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</util.ConversationContext>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Link previews, no image
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<util.ConversationContext theme={util.theme} ios={util.ios}>
|
||||||
|
<li>
|
||||||
|
<Message
|
||||||
|
authorColor="green"
|
||||||
|
direction="incoming"
|
||||||
|
i18n={util.i18n}
|
||||||
|
timestamp={Date.now()}
|
||||||
|
text="Pretty sweet link: https://instagram.com/something"
|
||||||
|
previews={[
|
||||||
|
{
|
||||||
|
title: 'This is a really sweet post',
|
||||||
|
domain: 'instagram.com',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onClickLinkPreview={url => console.log('onClickLinkPreview', url)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Message
|
||||||
|
authorColor="green"
|
||||||
|
direction="outgoing"
|
||||||
|
i18n={util.i18n}
|
||||||
|
timestamp={Date.now()}
|
||||||
|
status="sent"
|
||||||
|
text="Pretty sweet link: https://instagram.com/something"
|
||||||
|
previews={[
|
||||||
|
{
|
||||||
|
title: 'This is a really sweet post',
|
||||||
|
domain: 'instagram.com',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onClickLinkPreview={url => console.log('onClickLinkPreview', url)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Message
|
||||||
|
authorColor="green"
|
||||||
|
direction="incoming"
|
||||||
|
i18n={util.i18n}
|
||||||
|
timestamp={Date.now()}
|
||||||
|
quote={{
|
||||||
|
authorColor: 'purple',
|
||||||
|
text: 'How many ferrets do you have?',
|
||||||
|
authorPhoneNumber: '(202) 555-0011',
|
||||||
|
onClick: () => console.log('onClick'),
|
||||||
|
}}
|
||||||
|
text="Pretty sweet link: https://instagram.com/something"
|
||||||
|
previews={[
|
||||||
|
{
|
||||||
|
title:
|
||||||
|
'This is a really sweet post with a really long name. Gotta restrict that to just two lines, you know how that goes...',
|
||||||
|
domain: 'instagram.com',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onClickLinkPreview={url => console.log('onClickLinkPreview', url)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Message
|
||||||
|
authorColor="green"
|
||||||
|
direction="outgoing"
|
||||||
|
i18n={util.i18n}
|
||||||
|
timestamp={Date.now()}
|
||||||
|
status="sent"
|
||||||
|
quote={{
|
||||||
|
authorColor: 'purple',
|
||||||
|
text: 'How many ferrets do you have?',
|
||||||
|
authorPhoneNumber: '(202) 555-0011',
|
||||||
|
onClick: () => console.log('onClick'),
|
||||||
|
}}
|
||||||
|
text="Pretty sweet link: https://instagram.com/something"
|
||||||
|
previews={[
|
||||||
|
{
|
||||||
|
title:
|
||||||
|
'This is a really sweet post with a really long name. Gotta restrict that to just two lines, you know how that goes...',
|
||||||
|
domain: 'instagram.com',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onClickLinkPreview={url => console.log('onClickLinkPreview', url)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</util.ConversationContext>
|
||||||
|
```
|
||||||
|
|
||||||
### In a group conversation
|
### In a group conversation
|
||||||
|
|
||||||
Note that the author avatar goes away if `collapseMetadata` is set.
|
Note that the author avatar goes away if `collapseMetadata` is set.
|
||||||
|
@ -2713,6 +3066,48 @@ Note that the author avatar goes away if `collapseMetadata` is set.
|
||||||
i18n={util.i18n}
|
i18n={util.i18n}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<Message
|
||||||
|
authorColor="green"
|
||||||
|
authorName="Mr. Fire"
|
||||||
|
conversationType="group"
|
||||||
|
direction="incoming"
|
||||||
|
i18n={util.i18n}
|
||||||
|
timestamp={Date.now()}
|
||||||
|
text="Pretty sweet link: https://instagram.com/something"
|
||||||
|
previews={[
|
||||||
|
{
|
||||||
|
title: 'This is a really sweet post',
|
||||||
|
domain: 'instagram.com',
|
||||||
|
image: {
|
||||||
|
url: util.gifObjectUrl,
|
||||||
|
contentType: 'image/gif',
|
||||||
|
width: 320,
|
||||||
|
height: 240,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onClickLinkPreview={url => console.log('onClickLinkPreview', url)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Message
|
||||||
|
authorColor="green"
|
||||||
|
authorName="Mr. Fire"
|
||||||
|
conversationType="group"
|
||||||
|
direction="incoming"
|
||||||
|
i18n={util.i18n}
|
||||||
|
timestamp={Date.now()}
|
||||||
|
text="Pretty sweet link: https://instagram.com/something"
|
||||||
|
previews={[
|
||||||
|
{
|
||||||
|
title: 'This is a really sweet post',
|
||||||
|
domain: 'instagram.com',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onClickLinkPreview={url => console.log('onClickLinkPreview', url)}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Message
|
<Message
|
||||||
direction="outgoing"
|
direction="outgoing"
|
||||||
|
|
|
@ -6,12 +6,15 @@ import { MessageBody } from './MessageBody';
|
||||||
import { ExpireTimer, getIncrement } from './ExpireTimer';
|
import { ExpireTimer, getIncrement } from './ExpireTimer';
|
||||||
import {
|
import {
|
||||||
getGridDimensions,
|
getGridDimensions,
|
||||||
|
getImageDimensions,
|
||||||
hasImage,
|
hasImage,
|
||||||
hasVideoScreenshot,
|
hasVideoScreenshot,
|
||||||
ImageGrid,
|
ImageGrid,
|
||||||
isImage,
|
isImage,
|
||||||
|
isImageAttachment,
|
||||||
isVideo,
|
isVideo,
|
||||||
} from './ImageGrid';
|
} from './ImageGrid';
|
||||||
|
import { Image } from './Image';
|
||||||
import { Timestamp } from './Timestamp';
|
import { Timestamp } from './Timestamp';
|
||||||
import { ContactName } from './ContactName';
|
import { ContactName } from './ContactName';
|
||||||
import { Quote, QuotedAttachmentType } from './Quote';
|
import { Quote, QuotedAttachmentType } from './Quote';
|
||||||
|
@ -28,6 +31,16 @@ interface Trigger {
|
||||||
handleContextClick: (event: React.MouseEvent<HTMLDivElement>) => void;
|
handleContextClick: (event: React.MouseEvent<HTMLDivElement>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same as MIN_WIDTH in ImageGrid.tsx
|
||||||
|
const MINIMUM_LINK_PREVIEW_IMAGE_WIDTH = 200;
|
||||||
|
|
||||||
|
interface LinkPreviewType {
|
||||||
|
title: string;
|
||||||
|
domain: string;
|
||||||
|
url: string;
|
||||||
|
image?: AttachmentType;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
disableMenu?: boolean;
|
disableMenu?: boolean;
|
||||||
text?: string;
|
text?: string;
|
||||||
|
@ -61,11 +74,13 @@ export interface Props {
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
referencedMessageNotFound: boolean;
|
referencedMessageNotFound: boolean;
|
||||||
};
|
};
|
||||||
|
previews: Array<LinkPreviewType>;
|
||||||
authorAvatarPath?: string;
|
authorAvatarPath?: string;
|
||||||
isExpired: boolean;
|
isExpired: boolean;
|
||||||
expirationLength?: number;
|
expirationLength?: number;
|
||||||
expirationTimestamp?: number;
|
expirationTimestamp?: number;
|
||||||
onClickAttachment?: (attachment: AttachmentType) => void;
|
onClickAttachment?: (attachment: AttachmentType) => void;
|
||||||
|
onClickLinkPreview?: (url: string) => void;
|
||||||
onReply?: () => void;
|
onReply?: () => void;
|
||||||
onRetrySend?: () => void;
|
onRetrySend?: () => void;
|
||||||
onDownload?: (isDangerous: boolean) => void;
|
onDownload?: (isDangerous: boolean) => void;
|
||||||
|
@ -173,7 +188,6 @@ export class Message extends React.Component<Props, State> {
|
||||||
|
|
||||||
public renderMetadata() {
|
public renderMetadata() {
|
||||||
const {
|
const {
|
||||||
attachments,
|
|
||||||
collapseMetadata,
|
collapseMetadata,
|
||||||
direction,
|
direction,
|
||||||
expirationLength,
|
expirationLength,
|
||||||
|
@ -183,20 +197,13 @@ export class Message extends React.Component<Props, State> {
|
||||||
text,
|
text,
|
||||||
timestamp,
|
timestamp,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const { imageBroken } = this.state;
|
|
||||||
|
|
||||||
if (collapseMetadata) {
|
if (collapseMetadata) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const canDisplayAttachment = canDisplayImage(attachments);
|
const isShowingImage = this.isShowingImage();
|
||||||
const withImageNoCaption = Boolean(
|
const withImageNoCaption = Boolean(!text && isShowingImage);
|
||||||
!text &&
|
|
||||||
canDisplayAttachment &&
|
|
||||||
!imageBroken &&
|
|
||||||
((isImage(attachments) && hasImage(attachments)) ||
|
|
||||||
(isVideo(attachments) && hasVideoScreenshot(attachments)))
|
|
||||||
);
|
|
||||||
const showError = status === 'error' && direction === 'outgoing';
|
const showError = status === 'error' && direction === 'outgoing';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -409,6 +416,107 @@ export class Message extends React.Component<Props, State> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tslint:disable-next-line cyclomatic-complexity
|
||||||
|
public renderPreview() {
|
||||||
|
const {
|
||||||
|
attachments,
|
||||||
|
conversationType,
|
||||||
|
direction,
|
||||||
|
i18n,
|
||||||
|
onClickLinkPreview,
|
||||||
|
previews,
|
||||||
|
quote,
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
// Attachments take precedence over Link Previews
|
||||||
|
if (attachments && attachments.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!previews || previews.length < 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = previews[0];
|
||||||
|
if (!first) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const withContentAbove =
|
||||||
|
Boolean(quote) ||
|
||||||
|
(conversationType === 'group' && direction === 'incoming');
|
||||||
|
|
||||||
|
const previewHasImage = first.image && isImageAttachment(first.image);
|
||||||
|
const width = first.image && first.image.width;
|
||||||
|
const isFullSizeImage = width && width >= MINIMUM_LINK_PREVIEW_IMAGE_WIDTH;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
className={classNames(
|
||||||
|
'module-message__link-preview',
|
||||||
|
withContentAbove
|
||||||
|
? 'module-message__link-preview--with-content-above'
|
||||||
|
: null
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (onClickLinkPreview) {
|
||||||
|
onClickLinkPreview(first.url);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{first.image && previewHasImage && isFullSizeImage ? (
|
||||||
|
<ImageGrid
|
||||||
|
attachments={[first.image]}
|
||||||
|
withContentAbove={withContentAbove}
|
||||||
|
withContentBelow={true}
|
||||||
|
onError={this.handleImageErrorBound}
|
||||||
|
i18n={i18n}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'module-message__link-preview__content',
|
||||||
|
withContentAbove || isFullSizeImage
|
||||||
|
? 'module-message__link-preview__content--with-content-above'
|
||||||
|
: null
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{first.image && previewHasImage && !isFullSizeImage ? (
|
||||||
|
<div className="module-message__link-preview__icon_container">
|
||||||
|
<Image
|
||||||
|
smallCurveTopLeft={!withContentAbove}
|
||||||
|
softCorners={true}
|
||||||
|
alt={i18n('previewThumbnail', [first.domain])}
|
||||||
|
height={72}
|
||||||
|
width={72}
|
||||||
|
url={first.image.url}
|
||||||
|
attachment={first.image}
|
||||||
|
onError={this.handleImageErrorBound}
|
||||||
|
i18n={i18n}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'module-message__link-preview__text',
|
||||||
|
previewHasImage && !isFullSizeImage
|
||||||
|
? 'module-message__link-preview__text--with-icon'
|
||||||
|
: null
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="module-message__link-preview__title">
|
||||||
|
{first.title}
|
||||||
|
</div>
|
||||||
|
<div className="module-message__link-preview__location">
|
||||||
|
{first.domain}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public renderQuote() {
|
public renderQuote() {
|
||||||
const {
|
const {
|
||||||
conversationType,
|
conversationType,
|
||||||
|
@ -734,16 +842,80 @@ export class Message extends React.Component<Props, State> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getWidth(): Number | undefined {
|
||||||
|
const { attachments, previews } = this.props;
|
||||||
|
|
||||||
|
if (attachments && attachments.length) {
|
||||||
|
const dimensions = getGridDimensions(attachments);
|
||||||
|
if (dimensions) {
|
||||||
|
return dimensions.width;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previews && previews.length) {
|
||||||
|
const first = previews[0];
|
||||||
|
|
||||||
|
if (!first || !first.image) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { width } = first.image;
|
||||||
|
|
||||||
|
if (
|
||||||
|
isImageAttachment(first.image) &&
|
||||||
|
width &&
|
||||||
|
width >= MINIMUM_LINK_PREVIEW_IMAGE_WIDTH
|
||||||
|
) {
|
||||||
|
const dimensions = getImageDimensions(first.image);
|
||||||
|
if (dimensions) {
|
||||||
|
return dimensions.width;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
public isShowingImage() {
|
||||||
|
const { attachments, previews } = this.props;
|
||||||
|
const { imageBroken } = this.state;
|
||||||
|
|
||||||
|
if (imageBroken) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachments && attachments.length) {
|
||||||
|
const displayImage = canDisplayImage(attachments);
|
||||||
|
|
||||||
|
return (
|
||||||
|
displayImage &&
|
||||||
|
((isImage(attachments) && hasImage(attachments)) ||
|
||||||
|
(isVideo(attachments) && hasVideoScreenshot(attachments)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previews && previews.length) {
|
||||||
|
const first = previews[0];
|
||||||
|
const { image } = first;
|
||||||
|
|
||||||
|
if (!image) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isImageAttachment(image);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
const {
|
const {
|
||||||
attachments,
|
|
||||||
authorPhoneNumber,
|
authorPhoneNumber,
|
||||||
authorColor,
|
authorColor,
|
||||||
direction,
|
direction,
|
||||||
id,
|
id,
|
||||||
timestamp,
|
timestamp,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const { expired, expiring, imageBroken } = this.state;
|
const { expired, expiring } = this.state;
|
||||||
|
|
||||||
// This id is what connects our triple-dot click with our associated pop-up menu.
|
// This id is what connects our triple-dot click with our associated pop-up menu.
|
||||||
// It needs to be unique.
|
// It needs to be unique.
|
||||||
|
@ -753,15 +925,8 @@ export class Message extends React.Component<Props, State> {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayImage = canDisplayImage(attachments);
|
const width = this.getWidth();
|
||||||
|
const isShowingImage = this.isShowingImage();
|
||||||
const showingImage =
|
|
||||||
displayImage &&
|
|
||||||
!imageBroken &&
|
|
||||||
((isImage(attachments) && hasImage(attachments)) ||
|
|
||||||
(isVideo(attachments) && hasVideoScreenshot(attachments)));
|
|
||||||
|
|
||||||
const { width } = getGridDimensions(attachments) || { width: undefined };
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
@ -770,9 +935,6 @@ export class Message extends React.Component<Props, State> {
|
||||||
`module-message--${direction}`,
|
`module-message--${direction}`,
|
||||||
expiring ? 'module-message--expired' : null
|
expiring ? 'module-message--expired' : null
|
||||||
)}
|
)}
|
||||||
style={{
|
|
||||||
width: showingImage ? width : undefined,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{this.renderError(direction === 'incoming')}
|
{this.renderError(direction === 'incoming')}
|
||||||
{this.renderMenu(direction === 'outgoing', triggerId)}
|
{this.renderMenu(direction === 'outgoing', triggerId)}
|
||||||
|
@ -784,10 +946,14 @@ export class Message extends React.Component<Props, State> {
|
||||||
? `module-message__container--incoming-${authorColor}`
|
? `module-message__container--incoming-${authorColor}`
|
||||||
: null
|
: null
|
||||||
)}
|
)}
|
||||||
|
style={{
|
||||||
|
width: isShowingImage ? width : undefined,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{this.renderAuthor()}
|
{this.renderAuthor()}
|
||||||
{this.renderQuote()}
|
{this.renderQuote()}
|
||||||
{this.renderAttachment()}
|
{this.renderAttachment()}
|
||||||
|
{this.renderPreview()}
|
||||||
{this.renderEmbeddedContact()}
|
{this.renderEmbeddedContact()}
|
||||||
{this.renderText()}
|
{this.renderText()}
|
||||||
{this.renderMetadata()}
|
{this.renderMetadata()}
|
||||||
|
|
92
ts/components/conversation/StagedLinkPreview.md
Normal file
92
ts/components/conversation/StagedLinkPreview.md
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
#### Still loading
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<util.ConversationContext theme={util.theme}>
|
||||||
|
<StagedLinkPreview
|
||||||
|
isLoaded={false}
|
||||||
|
onClose={() => console.log('onClose')}
|
||||||
|
i18n={util.i18n}
|
||||||
|
/>
|
||||||
|
</util.ConversationContext>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### No image
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<util.ConversationContext theme={util.theme}>
|
||||||
|
<StagedLinkPreview
|
||||||
|
isLoaded={true}
|
||||||
|
title="This is a super-sweet site"
|
||||||
|
domain="instagram.com"
|
||||||
|
onClose={() => console.log('onClose')}
|
||||||
|
i18n={util.i18n}
|
||||||
|
/>
|
||||||
|
</util.ConversationContext>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Image
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<util.ConversationContext theme={util.theme}>
|
||||||
|
<StagedLinkPreview
|
||||||
|
isLoaded={true}
|
||||||
|
title="This is a super-sweet site"
|
||||||
|
domain="instagram.com"
|
||||||
|
image={{
|
||||||
|
url: util.gifObjectUrl,
|
||||||
|
contentType: 'image/gif',
|
||||||
|
}}
|
||||||
|
onClose={() => console.log('onClose')}
|
||||||
|
i18n={util.i18n}
|
||||||
|
/>
|
||||||
|
</util.ConversationContext>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Image, no title
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<util.ConversationContext theme={util.theme}>
|
||||||
|
<StagedLinkPreview
|
||||||
|
isLoaded={true}
|
||||||
|
domain="instagram.com"
|
||||||
|
image={{
|
||||||
|
url: util.gifObjectUrl,
|
||||||
|
contentType: 'image/gif',
|
||||||
|
}}
|
||||||
|
onClose={() => console.log('onClose')}
|
||||||
|
i18n={util.i18n}
|
||||||
|
/>
|
||||||
|
</util.ConversationContext>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### No image, long title
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<util.ConversationContext theme={util.theme}>
|
||||||
|
<StagedLinkPreview
|
||||||
|
isLoaded={true}
|
||||||
|
title="This is a super-sweet site. And it's got some really amazing content in store for you if you just click that link. Can you click that link for me?"
|
||||||
|
domain="instagram.com"
|
||||||
|
onClose={() => console.log('onClose')}
|
||||||
|
i18n={util.i18n}
|
||||||
|
/>
|
||||||
|
</util.ConversationContext>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Image, long title
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<util.ConversationContext theme={util.theme}>
|
||||||
|
<StagedLinkPreview
|
||||||
|
isLoaded={true}
|
||||||
|
title="This is a super-sweet site. And it's got some really amazing content in store for you if you just click that link. Can you click that link for me?"
|
||||||
|
domain="instagram.com"
|
||||||
|
image={{
|
||||||
|
url: util.gifObjectUrl,
|
||||||
|
contentType: 'image/gif',
|
||||||
|
}}
|
||||||
|
onClose={() => console.log('onClose')}
|
||||||
|
i18n={util.i18n}
|
||||||
|
/>
|
||||||
|
</util.ConversationContext>
|
||||||
|
```
|
65
ts/components/conversation/StagedLinkPreview.tsx
Normal file
65
ts/components/conversation/StagedLinkPreview.tsx
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
import React from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import { isImageAttachment } from './ImageGrid';
|
||||||
|
import { Image } from './Image';
|
||||||
|
import { AttachmentType } from './types';
|
||||||
|
|
||||||
|
import { Localizer } from '../../types/Util';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isLoaded: boolean;
|
||||||
|
title: string;
|
||||||
|
domain: string;
|
||||||
|
image?: AttachmentType;
|
||||||
|
|
||||||
|
i18n: Localizer;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StagedLinkPreview extends React.Component<Props> {
|
||||||
|
public render() {
|
||||||
|
const { isLoaded, onClose, i18n, title, image, domain } = this.props;
|
||||||
|
|
||||||
|
const isImage = image && isImageAttachment(image);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'module-staged-link-preview',
|
||||||
|
!isLoaded ? 'module-staged-link-preview--is-loading' : null
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!isLoaded ? (
|
||||||
|
<div className="module-staged-link-preview__loading">
|
||||||
|
{i18n('loadingPreview')}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{isLoaded && image && isImage ? (
|
||||||
|
<div className="module-staged-link-preview__icon-container">
|
||||||
|
<Image
|
||||||
|
alt={i18n('stagedPreviewThumbnail', [domain])}
|
||||||
|
softCorners={true}
|
||||||
|
height={72}
|
||||||
|
width={72}
|
||||||
|
url={image.url}
|
||||||
|
attachment={image}
|
||||||
|
i18n={i18n}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{isLoaded ? (
|
||||||
|
<div className="module-staged-link-preview__content">
|
||||||
|
<div className="module-staged-link-preview__title">{title}</div>
|
||||||
|
<div className="module-staged-link-preview__location">{domain}</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
className="module-staged-link-preview__close-button"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -10,6 +10,9 @@ export const VIDEO_MP4 = 'video/mp4' as MIMEType;
|
||||||
export const VIDEO_QUICKTIME = 'video/quicktime' as MIMEType;
|
export const VIDEO_QUICKTIME = 'video/quicktime' as MIMEType;
|
||||||
|
|
||||||
export const isJPEG = (value: MIMEType): boolean => value === 'image/jpeg';
|
export const isJPEG = (value: MIMEType): boolean => value === 'image/jpeg';
|
||||||
export const isImage = (value: MIMEType): boolean => value.startsWith('image/');
|
export const isImage = (value: MIMEType): boolean =>
|
||||||
export const isVideo = (value: MIMEType): boolean => value.startsWith('video/');
|
value && value.startsWith('image/');
|
||||||
export const isAudio = (value: MIMEType): boolean => value.startsWith('audio/');
|
export const isVideo = (value: MIMEType): boolean =>
|
||||||
|
value && value.startsWith('video/');
|
||||||
|
export const isAudio = (value: MIMEType): boolean =>
|
||||||
|
value && value.startsWith('audio/');
|
||||||
|
|
|
@ -3980,6 +3980,11 @@ he@1.1.1:
|
||||||
version "1.1.1"
|
version "1.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/he/-/he-1.1.1.tgz#93410fd21b009735151f8868c2f271f3427e23fd"
|
resolved "https://registry.yarnpkg.com/he/-/he-1.1.1.tgz#93410fd21b009735151f8868c2f271f3427e23fd"
|
||||||
|
|
||||||
|
he@1.2.0:
|
||||||
|
version "1.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
|
||||||
|
integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
|
||||||
|
|
||||||
highlight.js@^9.12.0:
|
highlight.js@^9.12.0:
|
||||||
version "9.12.0"
|
version "9.12.0"
|
||||||
resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-9.12.0.tgz#e6d9dbe57cbefe60751f02af336195870c90c01e"
|
resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-9.12.0.tgz#e6d9dbe57cbefe60751f02af336195870c90c01e"
|
||||||
|
|
Loading…
Reference in a new issue