- {!isLoaded ? (
-
- {i18n('loading')}
-
- ) : null}
- {isLoaded && image && isImage ? (
-
-
-
- ) : null}
- {isLoaded ? (
-
-
{title}
+ return (
+
+ {!isLoaded ? (
+
+ {i18n('loading')}
+
+ ) : null}
+ {isLoaded && image && isImage ? (
+
+
+
+ ) : null}
+ {isLoaded ? (
+
+
{title}
+ {description && (
+
+ {description}
+
+ )}
+
- ) : null}
-
-
- );
- }
-}
+
+ ) : null}
+
+
+ );
+};
diff --git a/ts/components/session/conversation/SessionCompositionBox.tsx b/ts/components/session/conversation/SessionCompositionBox.tsx
index aa516b655..ee5157c47 100644
--- a/ts/components/session/conversation/SessionCompositionBox.tsx
+++ b/ts/components/session/conversation/SessionCompositionBox.tsx
@@ -20,6 +20,7 @@ import { Flex } from '../Flex';
import { AttachmentList } from '../../conversation/AttachmentList';
import { ToastUtils } from '../../../session/utils';
import { AttachmentUtil } from '../../../util';
+import { SessionStagedLinkPreview } from './SessionStagedLinkPreview';
export interface ReplyingToMessageProps {
convoId: string;
@@ -62,6 +63,7 @@ interface State {
mediaSetting: boolean | null;
showEmojiPanel: boolean;
voiceRecording?: Blob;
+ ignoredLink?: string;
}
export class SessionCompositionBox extends React.Component
{
@@ -92,6 +94,8 @@ export class SessionCompositionBox extends React.Component {
this.renderRecordingView = this.renderRecordingView.bind(this);
this.renderCompositionView = this.renderCompositionView.bind(this);
this.renderQuotedMessage = this.renderQuotedMessage.bind(this);
+ this.renderStagedLinkPreview = this.renderStagedLinkPreview.bind(this);
+
this.renderAttachmentsStaged = this.renderAttachmentsStaged.bind(this);
// Recording view functions
@@ -127,6 +131,7 @@ export class SessionCompositionBox extends React.Component {
return (
{this.renderQuotedMessage()}
+ {this.renderStagedLinkPreview()}
{this.renderAttachmentsStaged()}
{showRecordingView
@@ -254,6 +259,46 @@ export class SessionCompositionBox extends React.Component
{
);
}
+ private renderStagedLinkPreview(): JSX.Element {
+ // Don't generate link previews if user has turned them off
+ if (!(window.getSettingValue('link-preview-setting') || false)) {
+ return <>>;
+ }
+
+ // Do nothing if we're offline
+ if (!window.textsecure.messaging) {
+ return <>>;
+ }
+
+ const { stagedAttachments, quotedMessageProps } = this.props;
+ const { ignoredLink } = this.state;
+
+ // Don't render link previews if quoted message or attachments
+ if (stagedAttachments.length === 0 && !quotedMessageProps?.id) {
+ // we try to match the first link found in the current message
+ const links = window.Signal.LinkPreviews.findLinks(
+ this.state.message,
+ undefined
+ );
+ if (!links || links.length === 0 || ignoredLink === links[0]) {
+ return <>>;
+ }
+ const firstLink = links[0];
+ if (ignoredLink && ignoredLink !== firstLink) {
+ this.setState({ ignoredLink: undefined });
+ }
+ return (
+ {
+ this.setState({ ignoredLink: firstLink });
+ }}
+ />
+ );
+ }
+ return <>>;
+ }
+
private renderQuotedMessage() {
const { quotedMessageProps, removeQuotedMessage } = this.props;
if (quotedMessageProps && quotedMessageProps.id) {
@@ -434,13 +479,6 @@ export class SessionCompositionBox extends React.Component {
this.props.onExitVoiceNoteView();
}
- private onDrop() {
- // On drop attachments!
- // this.textarea.current?.ondrop;
- // Look into react-dropzone
- // DROP AREA COMES FROM SessionConversation NOT HERE
- }
-
private onChange(event: any) {
const message = event.target.value ?? '';
diff --git a/ts/components/session/conversation/SessionConversationMessagesList.tsx b/ts/components/session/conversation/SessionConversationMessagesList.tsx
index 9606e5dc0..4e640a359 100644
--- a/ts/components/session/conversation/SessionConversationMessagesList.tsx
+++ b/ts/components/session/conversation/SessionConversationMessagesList.tsx
@@ -81,7 +81,7 @@ export class SessionConversationMessagesList extends React.Component<
// Keep scrolled to bottom unless user scrolls up
if (this.state.isScrolledToBottom) {
this.scrollToBottom();
- this.updateReadMessages();
+ // this.updateReadMessages();
}
// New messages get from message collection.
diff --git a/ts/components/session/conversation/SessionStagedLinkPreview.tsx b/ts/components/session/conversation/SessionStagedLinkPreview.tsx
new file mode 100644
index 000000000..5a8311436
--- /dev/null
+++ b/ts/components/session/conversation/SessionStagedLinkPreview.tsx
@@ -0,0 +1,174 @@
+import React, { useEffect, useState } from 'react';
+import { arrayBufferFromFile, AttachmentType } from '../../../types/Attachment';
+import { AttachmentUtil, LinkPreviewUtil } from '../../../util';
+import { StagedLinkPreview } from '../../conversation/StagedLinkPreview';
+import fetch from 'node-fetch';
+import { fetchLinkPreviewImage } from '../../../util/linkPreviewFetch';
+import { AbortController, AbortSignal } from 'abort-controller';
+
+type Props = {
+ url: string;
+ onClose: () => void;
+};
+const LINK_PREVIEW_TIMEOUT = 60 * 1000;
+
+export interface GetLinkPreviewResultImage {
+ data: ArrayBuffer;
+ size: number;
+ contentType: string;
+ width: number;
+ height: number;
+}
+
+export interface GetLinkPreviewResult {
+ title: string;
+ url: string;
+ image?: GetLinkPreviewResultImage;
+ description: string | null;
+ date: number | null;
+}
+
+const getPreview = async (
+ url: string,
+ abortSignal: AbortSignal
+): Promise => {
+ // This is already checked elsewhere, but we want to be extra-careful.
+ if (!window.Signal.LinkPreviews.isLinkSafeToPreview(url)) {
+ return null;
+ }
+
+ const linkPreviewMetadata = await LinkPreviewUtil.fetchLinkPreviewMetadata(
+ fetch,
+ url,
+ abortSignal
+ );
+ if (!linkPreviewMetadata) {
+ return null;
+ }
+ const { title, imageHref, description, date } = linkPreviewMetadata;
+
+ let image;
+ if (imageHref && window.Signal.LinkPreviews.isLinkSafeToPreview(imageHref)) {
+ let objectUrl: void | string;
+ try {
+ const fullSizeImage = await fetchLinkPreviewImage(
+ fetch,
+ imageHref,
+ abortSignal
+ );
+ if (!fullSizeImage) {
+ throw new Error('Failed to fetch link preview image');
+ }
+
+ // Ensure that this file is either small enough or is resized to meet our
+ // requirements for attachments
+ const withBlob = await AttachmentUtil.autoScale({
+ contentType: fullSizeImage.contentType,
+ file: new Blob([fullSizeImage.data], {
+ type: fullSizeImage.contentType,
+ }),
+ });
+
+ const data = await arrayBufferFromFile(withBlob.file);
+ objectUrl = URL.createObjectURL(withBlob.file);
+
+ const dimensions = await window.Signal.Types.VisualAttachment.getImageDimensions(
+ {
+ objectUrl,
+ logger: window.log,
+ }
+ );
+
+ image = {
+ data,
+ size: data.byteLength,
+ ...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,
+ description,
+ date,
+ };
+};
+
+export const SessionStagedLinkPreview = (props: Props) => {
+ const [isLoaded, setIsLoaded] = useState(false);
+ const [title, setTitle] = useState(null);
+ const [domain, setDomain] = useState(null);
+ const [description, setDescription] = useState(null);
+ const [image, setImage] = useState(undefined);
+
+ useEffect(() => {
+ // Use this abortcontroller to stop current fetch requests when url changed
+ const abortController = new AbortController();
+ setTimeout(() => {
+ abortController.abort();
+ }, LINK_PREVIEW_TIMEOUT);
+
+ setIsLoaded(false);
+ setTitle(null);
+ setDomain(null);
+ setDescription(null);
+ setImage(undefined);
+
+ getPreview(props.url, abortController.signal)
+ .then(ret => {
+ setIsLoaded(true);
+ if (ret) {
+ setTitle(ret.title);
+ if (ret.image?.width) {
+ if (ret.image) {
+ const blob = new Blob([ret.image.data], {
+ type: ret.image.contentType,
+ });
+ const imageAttachment = {
+ ...ret.image,
+ url: URL.createObjectURL(blob),
+ fileName: 'preview',
+ };
+ setImage(imageAttachment);
+ }
+ }
+ setDomain(window.Signal.LinkPreviews.getDomain(ret.url));
+ if (ret.description) {
+ setDescription(ret.description);
+ }
+ }
+ })
+ .catch(err => {
+ abortController.abort();
+ setIsLoaded(true);
+ });
+ return () => {
+ // Cancel other in-flight link preview requests.
+ abortController.abort();
+ };
+ }, [props.url]);
+
+ return (
+
+ );
+};
diff --git a/ts/session/utils/Attachments.ts b/ts/session/utils/Attachments.ts
index 10345d500..a6cfe3d54 100644
--- a/ts/session/utils/Attachments.ts
+++ b/ts/session/utils/Attachments.ts
@@ -68,9 +68,7 @@ export class AttachmentUtils {
server = openGroupServer;
}
const pointer: AttachmentPointer = {
- contentType: attachment.contentType
- ? (attachment.contentType as string)
- : undefined,
+ contentType: attachment.contentType ? attachment.contentType : undefined,
size: attachment.size,
fileName: attachment.fileName,
flags: attachment.flags,
diff --git a/ts/test/test-electron/isLinkPreviewDateValid_test.ts b/ts/test/test-electron/isLinkPreviewDateValid_test.ts
new file mode 100644
index 000000000..1b28d618f
--- /dev/null
+++ b/ts/test/test-electron/isLinkPreviewDateValid_test.ts
@@ -0,0 +1,41 @@
+import { assert } from 'chai';
+
+import { isLinkPreviewDateValid } from '../../util/isLinkPreviewDateValid';
+
+describe('isLinkPreviewDateValid', () => {
+ it('returns false for non-numbers', () => {
+ assert.isFalse(isLinkPreviewDateValid(null));
+ assert.isFalse(isLinkPreviewDateValid(undefined));
+ assert.isFalse(isLinkPreviewDateValid(Date.now().toString()));
+ assert.isFalse(isLinkPreviewDateValid(new Date()));
+ });
+
+ it('returns false for zero', () => {
+ assert.isFalse(isLinkPreviewDateValid(0));
+ assert.isFalse(isLinkPreviewDateValid(-0));
+ });
+
+ it('returns false for NaN', () => {
+ assert.isFalse(isLinkPreviewDateValid(0 / 0));
+ });
+
+ it('returns false for any infinite value', () => {
+ assert.isFalse(isLinkPreviewDateValid(Infinity));
+ assert.isFalse(isLinkPreviewDateValid(-Infinity));
+ });
+
+ it('returns false for timestamps more than a day from now', () => {
+ const twoDays = 2 * 24 * 60 * 60 * 1000;
+ assert.isFalse(isLinkPreviewDateValid(Date.now() + twoDays));
+ });
+
+ it('returns true for timestamps before tomorrow', () => {
+ assert.isTrue(isLinkPreviewDateValid(Date.now()));
+ assert.isTrue(isLinkPreviewDateValid(Date.now() + 123));
+ assert.isTrue(isLinkPreviewDateValid(Date.now() - 123));
+ assert.isTrue(isLinkPreviewDateValid(new Date(1995, 3, 20).valueOf()));
+ assert.isTrue(isLinkPreviewDateValid(new Date(1970, 3, 20).valueOf()));
+ assert.isTrue(isLinkPreviewDateValid(new Date(1969, 3, 20).valueOf()));
+ assert.isTrue(isLinkPreviewDateValid(1));
+ });
+});
diff --git a/ts/test/test-electron/linkPreviews/linkPreviewFetch_test.ts b/ts/test/test-electron/linkPreviews/linkPreviewFetch_test.ts
new file mode 100644
index 000000000..0e3e431e8
--- /dev/null
+++ b/ts/test/test-electron/linkPreviews/linkPreviewFetch_test.ts
@@ -0,0 +1,1296 @@
+import { assert } from 'chai';
+import * as sinon from 'sinon';
+import * as fs from 'fs';
+import * as path from 'path';
+import AbortController from 'abort-controller';
+import { IMAGE_JPEG, MIMEType } from '../../../types/MIME';
+
+import {
+ fetchLinkPreviewImage,
+ fetchLinkPreviewMetadata,
+} from '../../../util/linkPreviewFetch';
+
+// tslint:disable: no-http-string
+
+describe('link preview fetching', () => {
+ // We'll use this to create a fake `fetch`. We'll want to call `.resolves` or
+ // `.rejects` on it (meaning that it needs to be a Sinon Stub type), but we'll also
+ // want it to be a fake `fetch`. `any` seems like the best "supertype" there.
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ function stub(): any {
+ return sinon.stub();
+ }
+
+ let sandbox: sinon.SinonSandbox;
+ let warn: sinon.SinonStub;
+
+ beforeEach(() => {
+ sandbox = sinon.createSandbox();
+ warn = sandbox.stub(window.log, 'warn');
+ });
+
+ afterEach(() => {
+ sandbox.restore();
+ });
+
+ // tslint:disable-next-line: max-func-body-length
+ describe('fetchLinkPreviewMetadata', () => {
+ const makeHtml = (stuffInHead: ReadonlyArray = []) => `
+
+
+ ${stuffInHead.join('\n')}
+ should be ignored
+
+ `;
+
+ const makeResponse = ({
+ status = 200,
+ headers = {},
+ body = makeHtml(['test title']),
+ url = 'https://example.com',
+ }: {
+ status?: number;
+ headers?: { [key: string]: null | string };
+ body?: null | string | Uint8Array | AsyncIterable;
+ url?: string;
+ } = {}) => {
+ let bodyLength: null | number;
+ let bodyStream: null | AsyncIterable;
+ if (!body) {
+ bodyLength = 0;
+ bodyStream = null;
+ } else if (typeof body === 'string') {
+ const asBytes = new TextEncoder().encode(body);
+ bodyLength = asBytes.length;
+ bodyStream = (async function* stream() {
+ yield asBytes;
+ })();
+ } else if (body instanceof Uint8Array) {
+ bodyLength = body.length;
+ bodyStream = (async function* stream() {
+ yield body;
+ })();
+ } else {
+ bodyLength = null;
+ bodyStream = body;
+ }
+
+ const headersObj = new Headers();
+ Object.entries({
+ 'Content-Type': 'text/html; charset=utf-8',
+ 'Content-Length': bodyLength === null ? null : String(bodyLength),
+ ...headers,
+ }).forEach(([headerName, headerValue]) => {
+ if (headerValue) {
+ headersObj.set(headerName, headerValue);
+ }
+ });
+
+ return {
+ headers: headersObj,
+ body: bodyStream,
+ ok: status >= 200 && status <= 299,
+ status,
+ url,
+ };
+ };
+
+ it('handles the "kitchen sink" of results', async () => {
+ const fakeFetch = stub().resolves(
+ makeResponse({
+ body: makeHtml([
+ '',
+ '',
+ '',
+ '',
+ ]),
+ })
+ );
+
+ assert.deepEqual(
+ await fetchLinkPreviewMetadata(
+ fakeFetch,
+ 'https://example.com',
+ new AbortController().signal
+ ),
+ {
+ title: 'test title',
+ description: 'test description',
+ date: 1587386096009,
+ imageHref: 'https://example.com/image.jpg',
+ }
+ );
+ });
+
+ it('logs no warnings if everything goes smoothly', async () => {
+ const fakeFetch = stub().resolves(
+ makeResponse({
+ body: makeHtml([
+ '',
+ '',
+ '',
+ '',
+ ]),
+ })
+ );
+
+ await fetchLinkPreviewMetadata(
+ fakeFetch,
+ 'https://example.com',
+ new AbortController().signal
+ );
+
+ sinon.assert.notCalled(warn);
+ });
+
+ it('sends "WhatsApp" as the User-Agent for compatibility', async () => {
+ const fakeFetch = stub().resolves(makeResponse());
+
+ await fetchLinkPreviewMetadata(
+ fakeFetch,
+ 'https://example.com',
+ new AbortController().signal
+ );
+
+ sinon.assert.calledWith(
+ fakeFetch,
+ 'https://example.com',
+ sinon.match({
+ headers: {
+ 'User-Agent': 'WhatsApp',
+ },
+ })
+ );
+ });
+
+ it('returns null if the request fails', async () => {
+ const fakeFetch = stub().rejects(new Error('Test request failure'));
+
+ assert.isNull(
+ await fetchLinkPreviewMetadata(
+ fakeFetch,
+ 'https://example.com',
+ new AbortController().signal
+ )
+ );
+
+ sinon.assert.calledOnce(warn);
+ sinon.assert.calledWith(
+ warn,
+ 'fetchLinkPreviewMetadata: failed to fetch link preview HTML; bailing'
+ );
+ });
+
+ it("returns null if the response status code isn't 2xx", async () => {
+ await Promise.all(
+ [100, 304, 400, 404, 500, 0, -200].map(async status => {
+ const fakeFetch = stub().resolves(makeResponse({ status }));
+
+ assert.isNull(
+ await fetchLinkPreviewMetadata(
+ fakeFetch,
+ 'https://example.com',
+ new AbortController().signal
+ )
+ );
+
+ sinon.assert.calledWith(
+ warn,
+ `fetchLinkPreviewMetadata: got a ${status} status code; bailing`
+ );
+ })
+ );
+ });
+
+ it("doesn't use fetch's automatic redirection behavior", async () => {
+ const fakeFetch = stub().resolves(makeResponse());
+
+ await fetchLinkPreviewMetadata(
+ fakeFetch,
+ 'https://example.com',
+ new AbortController().signal
+ );
+
+ sinon.assert.calledWith(
+ fakeFetch,
+ 'https://example.com',
+ sinon.match({ redirect: 'manual' })
+ );
+ });
+
+ [301, 302, 303, 307, 308].forEach(status => {
+ it(`handles ${status} redirects`, async () => {
+ const fakeFetch = stub();
+ fakeFetch.onFirstCall().resolves(
+ makeResponse({
+ status,
+ headers: { Location: 'https://example.com/2' },
+ body: null,
+ })
+ );
+ fakeFetch.onSecondCall().resolves(makeResponse());
+
+ assert.deepEqual(
+ await fetchLinkPreviewMetadata(
+ fakeFetch,
+ 'https://example.com',
+ new AbortController().signal
+ ),
+ {
+ title: 'test title',
+ description: null,
+ date: null,
+ imageHref: null,
+ }
+ );
+
+ sinon.assert.calledTwice(fakeFetch);
+ sinon.assert.calledWith(fakeFetch.getCall(0), 'https://example.com');
+ sinon.assert.calledWith(fakeFetch.getCall(1), 'https://example.com/2');
+ });
+
+ it(`returns null when seeing a ${status} status with no Location header`, async () => {
+ const fakeFetch = stub().resolves(makeResponse({ status }));
+
+ assert.isNull(
+ await fetchLinkPreviewMetadata(
+ fakeFetch,
+ 'https://example.com',
+ new AbortController().signal
+ )
+ );
+ });
+ });
+
+ it('handles relative redirects', async () => {
+ const fakeFetch = stub();
+ fakeFetch.onFirstCall().resolves(
+ makeResponse({
+ status: 301,
+ headers: { Location: '/2/' },
+ body: null,
+ })
+ );
+ fakeFetch.onSecondCall().resolves(
+ makeResponse({
+ status: 301,
+ headers: { Location: '3' },
+ body: null,
+ })
+ );
+ fakeFetch.onThirdCall().resolves(makeResponse());
+
+ assert.deepEqual(
+ await fetchLinkPreviewMetadata(
+ fakeFetch,
+ 'https://example.com',
+ new AbortController().signal
+ ),
+ {
+ title: 'test title',
+ description: null,
+ date: null,
+ imageHref: null,
+ }
+ );
+
+ sinon.assert.calledThrice(fakeFetch);
+ sinon.assert.calledWith(fakeFetch.getCall(0), 'https://example.com');
+ sinon.assert.calledWith(fakeFetch.getCall(1), 'https://example.com/2/');
+ sinon.assert.calledWith(fakeFetch.getCall(2), 'https://example.com/2/3');
+ });
+
+ it('returns null if redirecting to an insecure HTTP URL', async () => {
+ const fakeFetch = stub().resolves(
+ makeResponse({
+ status: 301,
+ headers: { Location: 'http://example.com' },
+ body: null,
+ })
+ );
+
+ assert.isNull(
+ await fetchLinkPreviewMetadata(
+ fakeFetch,
+ 'https://example.com',
+ new AbortController().signal
+ )
+ );
+
+ sinon.assert.calledOnce(fakeFetch);
+ });
+
+ it("returns null if there's a redirection loop", async () => {
+ const fakeFetch = stub();
+ fakeFetch.onFirstCall().resolves(
+ makeResponse({
+ status: 301,
+ headers: { Location: '/2/' },
+ body: null,
+ })
+ );
+ fakeFetch.onSecondCall().resolves(
+ makeResponse({
+ status: 301,
+ headers: { Location: '/start' },
+ body: null,
+ })
+ );
+
+ assert.isNull(
+ await fetchLinkPreviewMetadata(
+ fakeFetch,
+ 'https://example.com/start',
+ new AbortController().signal
+ )
+ );
+
+ sinon.assert.calledTwice(fakeFetch);
+ });
+
+ it('returns null if redirecting more than 20 times', async () => {
+ const fakeFetch = stub().callsFake(async () =>
+ makeResponse({
+ status: 301,
+ // tslint:disable-next-line: insecure-random
+ headers: { Location: `/${Math.random()}` },
+ body: null,
+ })
+ );
+
+ assert.isNull(
+ await fetchLinkPreviewMetadata(
+ fakeFetch,
+ 'https://example.com/start',
+ new AbortController().signal
+ )
+ );
+
+ sinon.assert.callCount(fakeFetch, 20);
+ });
+
+ it('returns null if the response has no body', async () => {
+ const fakeFetch = stub().resolves(makeResponse({ body: null }));
+
+ assert.isNull(
+ await fetchLinkPreviewMetadata(
+ fakeFetch,
+ 'https://example.com',
+ new AbortController().signal
+ )
+ );
+
+ sinon.assert.calledWith(
+ warn,
+ 'fetchLinkPreviewMetadata: no response body; bailing'
+ );
+ });
+
+ it('returns null if the result body is too short', async () => {
+ const fakeFetch = stub().resolves(makeResponse({ body: '' }));
+
+ assert.isNull(
+ await fetchLinkPreviewMetadata(
+ fakeFetch,
+ 'https://example.com',
+ new AbortController().signal
+ )
+ );
+
+ sinon.assert.calledOnce(warn);
+ sinon.assert.calledWith(
+ warn,
+ 'fetchLinkPreviewMetadata: Content-Length is too short; bailing'
+ );
+ });
+
+ it('returns null if the result is meant to be downloaded', async () => {
+ const fakeFetch = stub().resolves(
+ makeResponse({
+ headers: { 'Content-Disposition': 'attachment' },
+ })
+ );
+
+ assert.isNull(
+ await fetchLinkPreviewMetadata(
+ fakeFetch,
+ 'https://example.com',
+ new AbortController().signal
+ )
+ );
+
+ sinon.assert.calledOnce(warn);
+ sinon.assert.calledWith(
+ warn,
+ 'fetchLinkPreviewMetadata: Content-Disposition header is not inline; bailing'
+ );
+ });
+
+ it('allows an explitly inline Content-Disposition header', async () => {
+ const fakeFetch = stub().resolves(
+ makeResponse({
+ headers: { 'Content-Disposition': 'inline' },
+ })
+ );
+
+ assert.deepEqual(
+ await fetchLinkPreviewMetadata(
+ fakeFetch,
+ 'https://example.com',
+ new AbortController().signal
+ ),
+ {
+ title: 'test title',
+ description: null,
+ date: null,
+ imageHref: null,
+ }
+ );
+ });
+
+ it('returns null if the Content-Type is not HTML', async () => {
+ const fakeFetch = stub().resolves(
+ makeResponse({
+ headers: { 'Content-Type': 'text/plain' },
+ })
+ );
+
+ assert.isNull(
+ await fetchLinkPreviewMetadata(
+ fakeFetch,
+ 'https://example.com',
+ new AbortController().signal
+ )
+ );
+
+ sinon.assert.calledOnce(warn);
+ sinon.assert.calledWith(
+ warn,
+ 'fetchLinkPreviewMetadata: Content-Type is not HTML; bailing'
+ );
+ });
+
+ it('accepts non-lowercase Content-Type headers', async () => {
+ const fakeFetch = stub().resolves(
+ makeResponse({
+ headers: { 'Content-Type': 'TEXT/HTML; chArsEt=utf-8' },
+ })
+ );
+
+ assert.deepEqual(
+ await fetchLinkPreviewMetadata(
+ fakeFetch,
+ 'https://example.com',
+ new AbortController().signal
+ ),
+ {
+ title: 'test title',
+ description: null,
+ date: null,
+ imageHref: null,
+ }
+ );
+ });
+
+ it('parses the response as UTF-8 if the body contains a byte order mark', async () => {
+ const fakeFetch = stub().resolves(
+ makeResponse({
+ headers: {
+ 'Content-Type': 'text/html',
+ },
+ body: (async function* body() {
+ yield new Uint8Array([0xef, 0xbb, 0xbf]);
+ yield new TextEncoder().encode(
+ '\u{1F389}'
+ );
+ })(),
+ })
+ );
+
+ assert.deepEqual(
+ await fetchLinkPreviewMetadata(
+ fakeFetch,
+ 'https://example.com',
+ new AbortController().signal
+ ),
+ {
+ title: '🎉',
+ description: null,
+ date: null,
+ imageHref: null,
+ }
+ );
+ });
+
+ it('respects the UTF-8 byte order mark above the Content-Type header', async () => {
+ const bom = new Uint8Array([0xef, 0xbb, 0xbf]);
+ const titleHtml = new TextEncoder().encode('\u{1F389}');
+
+ const fakeFetch = stub().resolves(
+ makeResponse({
+ headers: {
+ 'Content-Type': 'text/html; charset=latin1',
+ },
+ body: (async function* body() {
+ yield bom;
+ yield titleHtml;
+ })(),
+ })
+ );
+ assert.propertyVal(
+ await fetchLinkPreviewMetadata(
+ fakeFetch,
+ 'https://example.com',
+ new AbortController().signal
+ ),
+ 'title',
+ '🎉'
+ );
+ });
+
+ it('respects the UTF-8 byte order mark above a in the document', async () => {
+ const bom = new Uint8Array([0xef, 0xbb, 0xbf]);
+ const titleHtml = new TextEncoder().encode('\u{1F389}');
+ const endHeadHtml = new TextEncoder().encode('');
+
+ const fakeFetch = stub().resolves(
+ makeResponse({
+ headers: {
+ 'Content-Type': 'text/html',
+ },
+ body: (async function* body() {
+ yield bom;
+ yield new TextEncoder().encode(
+ ''
+ );
+ yield titleHtml;
+ yield endHeadHtml;
+ })(),
+ })
+ );
+ assert.propertyVal(
+ await fetchLinkPreviewMetadata(
+ fakeFetch,
+ 'https://example.com',
+ new AbortController().signal
+ ),
+ 'title',
+ '🎉'
+ );
+ });
+
+ it('respects the UTF-8 byte order mark above a in the document', async () => {
+ const bom = new Uint8Array([0xef, 0xbb, 0xbf]);
+ const titleHtml = new TextEncoder().encode('\u{1F389}');
+ const endHeadHtml = new TextEncoder().encode('');
+
+ const fakeFetch = stub().resolves(
+ makeResponse({
+ headers: {
+ 'Content-Type': 'text/html',
+ },
+ body: (async function* body() {
+ yield bom;
+ yield new TextEncoder().encode(
+ ''
+ );
+ yield titleHtml;
+ yield endHeadHtml;
+ })(),
+ })
+ );
+ assert.propertyVal(
+ await fetchLinkPreviewMetadata(
+ fakeFetch,
+ 'https://example.com',
+ new AbortController().signal
+ ),
+ 'title',
+ '🎉'
+ );
+ });
+
+ it('respects the Content-Type header above anything in the HTML', async () => {
+ const titleHtml = new TextEncoder().encode('\u{1F389}');
+ const endHeadHtml = new TextEncoder().encode('');
+
+ {
+ const fakeFetch = stub().resolves(
+ makeResponse({
+ headers: {
+ 'Content-Type': 'text/html; charset=utf-8',
+ },
+ body: (async function* body() {
+ yield new TextEncoder().encode(
+ ''
+ );
+ yield titleHtml;
+ yield endHeadHtml;
+ })(),
+ })
+ );
+ assert.propertyVal(
+ await fetchLinkPreviewMetadata(
+ fakeFetch,
+ 'https://example.com',
+ new AbortController().signal
+ ),
+ 'title',
+ '🎉'
+ );
+ }
+
+ {
+ const fakeFetch = stub().resolves(
+ makeResponse({
+ headers: {
+ 'Content-Type': 'text/html; charset=utf-8',
+ },
+ body: (async function* body() {
+ yield new TextEncoder().encode(
+ ''
+ );
+ yield titleHtml;
+ yield endHeadHtml;
+ })(),
+ })
+ );
+ assert.propertyVal(
+ await fetchLinkPreviewMetadata(
+ fakeFetch,
+ 'https://example.com',
+ new AbortController().signal
+ ),
+ 'title',
+ '🎉'
+ );
+ }
+ });
+
+ it('prefers the Content-Type http-equiv in the HTML above ', async () => {
+ const fakeFetch = stub().resolves(
+ makeResponse({
+ headers: {
+ 'Content-Type': 'text/html',
+ },
+ body: makeHtml([
+ '',
+ '',
+ '\u{1F389}',
+ ]),
+ })
+ );
+
+ assert.propertyVal(
+ await fetchLinkPreviewMetadata(
+ fakeFetch,
+ 'https://example.com',
+ new AbortController().signal
+ ),
+ 'title',
+ '🎉'
+ );
+ });
+
+ it('parses non-UTF8 encodings', async () => {
+ const titleBytes = new Uint8Array([0x61, 0x71, 0x75, 0xed]);
+ assert.notDeepEqual(
+ new TextDecoder('utf8').decode(titleBytes),
+ new TextDecoder('latin1').decode(titleBytes),
+ 'Test data was not set up correctly'
+ );
+
+ const fakeFetch = stub().resolves(
+ makeResponse({
+ headers: {
+ 'Content-Type': 'text/html; charset=latin1',
+ },
+ body: (async function* body() {
+ yield new TextEncoder().encode('');
+ yield titleBytes;
+ yield new TextEncoder().encode('');
+ })(),
+ })
+ );
+
+ assert.propertyVal(
+ await fetchLinkPreviewMetadata(
+ fakeFetch,
+ 'https://example.com',
+ new AbortController().signal
+ ),
+ 'title',
+ 'aquÃ'
+ );
+ });
+
+ it('handles incomplete bodies', async () => {
+ const fakeFetch = stub().resolves(
+ makeResponse({
+ body: (async function* body() {
+ yield new TextEncoder().encode(
+ 'foo bar {
+ const shouldNeverBeCalled = sinon.stub();
+
+ const abortController = new AbortController();
+
+ const fakeFetch = stub().resolves(
+ makeResponse({
+ body: (async function* body() {
+ yield new TextEncoder().encode('');
+ abortController.abort();
+ yield new TextEncoder().encode('should be dropped');
+ shouldNeverBeCalled();
+ })(),
+ })
+ );
+
+ assert.isNull(
+ await fetchLinkPreviewMetadata(
+ fakeFetch,
+ 'https://example.com',
+ abortController.signal
+ )
+ );
+
+ sinon.assert.notCalled(shouldNeverBeCalled);
+ });
+
+ it('stops reading bodies after 500 kilobytes', async () => {
+ const shouldNeverBeCalled = sinon.stub();
+
+ const fakeFetch = stub().resolves(
+ makeResponse({
+ body: (async function* body() {
+ yield new TextEncoder().encode(
+ 'foo bar'
+ );
+ const spaces = new Uint8Array(1024).fill(32);
+ for (let i = 0; i < 500; i += 1) {
+ yield spaces;
+ }
+ shouldNeverBeCalled();
+ yield new TextEncoder().encode(
+ ''
+ );
+ })(),
+ })
+ );
+
+ assert.deepEqual(
+ await fetchLinkPreviewMetadata(
+ fakeFetch,
+ 'https://example.com',
+ new AbortController().signal
+ ),
+ {
+ title: 'foo bar',
+ description: null,
+ date: null,
+ imageHref: null,
+ }
+ );
+
+ sinon.assert.notCalled(shouldNeverBeCalled);
+ });
+
+ it("returns null if the HTML doesn't contain a title, even if it contains other values", async () => {
+ const fakeFetch = stub().resolves(
+ makeResponse({
+ body: makeHtml([
+ '',
+ '',
+ ``,
+ ]),
+ })
+ );
+
+ assert.isNull(
+ await fetchLinkPreviewMetadata(
+ fakeFetch,
+ 'https://example.com',
+ new AbortController().signal
+ )
+ );
+
+ sinon.assert.calledOnce(warn);
+ sinon.assert.calledWith(
+ warn,
+ "parseMetadata: HTML document doesn't have a title; bailing"
+ );
+ });
+
+ it('prefers og:title to document.title', async () => {
+ const fakeFetch = stub().resolves(
+ makeResponse({
+ body: makeHtml([
+ 'ignored',
+ '',
+ ]),
+ })
+ );
+
+ assert.propertyVal(
+ await fetchLinkPreviewMetadata(
+ fakeFetch,
+ 'https://example.com',
+ new AbortController().signal
+ ),
+ 'title',
+ 'foo bar'
+ );
+ });
+
+ it('prefers og:description to ', async () => {
+ const fakeFetch = stub().resolves(
+ makeResponse({
+ body: makeHtml([
+ 'foo',
+ '',
+ '',
+ ]),
+ })
+ );
+
+ assert.propertyVal(
+ await fetchLinkPreviewMetadata(
+ fakeFetch,
+ 'https://example.com',
+ new AbortController().signal
+ ),
+ 'description',
+ 'bar'
+ );
+ });
+
+ it('parses ', async () => {
+ const fakeFetch = stub().resolves(
+ makeResponse({
+ body: makeHtml([
+ 'foo',
+ '',
+ ]),
+ })
+ );
+
+ assert.propertyVal(
+ await fetchLinkPreviewMetadata(
+ fakeFetch,
+ 'https://example.com',
+ new AbortController().signal
+ ),
+ 'description',
+ 'bar'
+ );
+ });
+
+ it('ignores empty descriptions', async () => {
+ const fakeFetch = stub().resolves(
+ makeResponse({
+ body: makeHtml([
+ 'foo',
+ '',
+ ]),
+ })
+ );
+
+ assert.propertyVal(
+ await fetchLinkPreviewMetadata(
+ fakeFetch,
+ 'https://example.com',
+ new AbortController().signal
+ ),
+ 'description',
+ null
+ );
+ });
+
+ it('parses absolute image URLs', async () => {
+ const fakeFetch = stub().resolves(
+ makeResponse({
+ body: makeHtml([
+ 'foo',
+ '',
+ ]),
+ })
+ );
+
+ assert.propertyVal(
+ await fetchLinkPreviewMetadata(
+ fakeFetch,
+ 'https://example.com',
+ new AbortController().signal
+ ),
+ 'imageHref',
+ 'https://example.com/image.jpg'
+ );
+ });
+
+ it('parses relative image URLs', async () => {
+ const fakeFetch = stub().resolves(
+ makeResponse({
+ body: makeHtml([
+ 'foo',
+ '',
+ ]),
+ })
+ );
+
+ assert.propertyVal(
+ await fetchLinkPreviewMetadata(
+ fakeFetch,
+ 'https://example.com',
+ new AbortController().signal
+ ),
+ 'imageHref',
+ 'https://example.com/assets/image.jpg'
+ );
+ });
+
+ it('relative image URL resolution is relative to the final URL after redirects, not the original URL', async () => {
+ const fakeFetch = stub().resolves(
+ makeResponse({
+ body: makeHtml([
+ 'foo',
+ '',
+ ]),
+ url: 'https://bar.example/assets/',
+ })
+ );
+
+ assert.propertyVal(
+ await fetchLinkPreviewMetadata(
+ fakeFetch,
+ 'https://foo.example',
+ new AbortController().signal
+ ),
+ 'imageHref',
+ 'https://bar.example/assets/image.jpg'
+ );
+ });
+
+ it('ignores empty image URLs', async () => {
+ const fakeFetch = stub().resolves(
+ makeResponse({
+ body: makeHtml([
+ 'foo',
+ '',
+ ]),
+ })
+ );
+
+ assert.propertyVal(
+ await fetchLinkPreviewMetadata(
+ fakeFetch,
+ 'https://example.com',
+ new AbortController().signal
+ ),
+ 'imageHref',
+ null
+ );
+ });
+
+ it('ignores blank image URLs', async () => {
+ const fakeFetch = stub().resolves(
+ makeResponse({
+ body: makeHtml([
+ 'foo',
+ '',
+ ]),
+ })
+ );
+
+ assert.propertyVal(
+ await fetchLinkPreviewMetadata(
+ fakeFetch,
+ 'https://example.com',
+ new AbortController().signal
+ ),
+ 'imageHref',
+ null
+ );
+ });
+ });
+
+ // tslint:disable-next-line: max-func-body-length
+ describe('fetchLinkPreviewImage', () => {
+ const readFixture = async (filename: string): Promise => {
+ const result = await fs.promises.readFile(
+ path.join(__dirname, '..', '..', '..', 'fixtures', filename)
+ );
+ assert(result.length > 10, `Test failed to read fixture ${filename}`);
+ return result;
+ };
+
+ [
+ {
+ title: 'JPEG',
+ contentType: 'image/jpeg',
+ fixtureFilename: 'kitten-1-64-64.jpg',
+ },
+ {
+ title: 'PNG',
+ contentType: 'image/png',
+ fixtureFilename:
+ 'freepngs-2cd43b_bed7d1327e88454487397574d87b64dc_mv2.png',
+ },
+ {
+ title: 'GIF',
+ contentType: 'image/gif',
+ fixtureFilename: 'giphy-GVNvOUpeYmI7e.gif',
+ },
+ {
+ title: 'WEBP',
+ contentType: 'image/webp',
+ fixtureFilename: '512x515-thumbs-up-lincoln.webp',
+ },
+ {
+ title: 'ICO',
+ contentType: 'image/x-icon',
+ fixtureFilename: 'kitten-1-64-64.ico',
+ },
+ ].forEach(({ title, contentType, fixtureFilename }) => {
+ it(`handles ${title} images`, async () => {
+ const fixture = await readFixture(fixtureFilename);
+
+ const fakeFetch = stub().resolves(
+ new Response(fixture, {
+ headers: {
+ 'Content-Type': contentType,
+ 'Content-Length': fixture.length.toString(),
+ },
+ })
+ );
+
+ assert.deepEqual(
+ await fetchLinkPreviewImage(
+ fakeFetch,
+ 'https://example.com/img',
+ new AbortController().signal
+ ),
+ {
+ data: fixture.buffer,
+ contentType: contentType,
+ }
+ );
+ });
+ });
+
+ it('returns null if the request fails', async () => {
+ const fakeFetch = stub().rejects(new Error('Test request failure'));
+
+ assert.isNull(
+ await fetchLinkPreviewImage(
+ fakeFetch,
+ 'https://example.com/img',
+ new AbortController().signal
+ )
+ );
+
+ sinon.assert.calledOnce(warn);
+ sinon.assert.calledWith(
+ warn,
+ 'fetchLinkPreviewImage: failed to fetch image; bailing'
+ );
+ });
+
+ it("returns null if the response status code isn't 2xx", async () => {
+ const fixture = await readFixture('kitten-1-64-64.jpg');
+
+ await Promise.all(
+ [400, 404, 500, 598].map(async status => {
+ const fakeFetch = stub().resolves(
+ new Response(fixture, {
+ status,
+ headers: {
+ 'Content-Type': 'image/jpeg',
+ 'Content-Length': fixture.length.toString(),
+ },
+ })
+ );
+
+ assert.isNull(
+ await fetchLinkPreviewImage(
+ fakeFetch,
+ 'https://example.com/img',
+ new AbortController().signal
+ )
+ );
+
+ sinon.assert.calledWith(
+ warn,
+ `fetchLinkPreviewImage: got a ${status} status code; bailing`
+ );
+ })
+ );
+ });
+
+ // Most of the redirect behavior is tested above.
+ it('handles 301 redirects', async () => {
+ const fixture = await readFixture('kitten-1-64-64.jpg');
+
+ const fakeFetch = stub();
+ fakeFetch.onFirstCall().resolves(
+ new Response(null, {
+ status: 301,
+ headers: {
+ Location: '/result.jpg',
+ },
+ })
+ );
+ fakeFetch.onSecondCall().resolves(
+ new Response(fixture, {
+ headers: {
+ 'Content-Type': IMAGE_JPEG,
+ 'Content-Length': fixture.length.toString(),
+ },
+ })
+ );
+
+ assert.deepEqual(
+ await fetchLinkPreviewImage(
+ fakeFetch,
+ 'https://example.com/img',
+ new AbortController().signal
+ ),
+ {
+ data: fixture.buffer,
+ contentType: IMAGE_JPEG,
+ }
+ );
+
+ sinon.assert.calledTwice(fakeFetch);
+ sinon.assert.calledWith(fakeFetch.getCall(0), 'https://example.com/img');
+ sinon.assert.calledWith(
+ fakeFetch.getCall(1),
+ 'https://example.com/result.jpg'
+ );
+ });
+
+ it('returns null if the response is too small', async () => {
+ const fakeFetch = stub().resolves(
+ new Response(await readFixture('kitten-1-64-64.jpg'), {
+ headers: {
+ 'Content-Type': 'image/jpeg',
+ 'Content-Length': '2',
+ },
+ })
+ );
+
+ assert.isNull(
+ await fetchLinkPreviewImage(
+ fakeFetch,
+ 'https://example.com/img',
+ new AbortController().signal
+ )
+ );
+
+ sinon.assert.calledOnce(warn);
+ sinon.assert.calledWith(
+ warn,
+ 'fetchLinkPreviewImage: Content-Length is too short; bailing'
+ );
+ });
+
+ it('returns null if the response is too large', async () => {
+ const fakeFetch = stub().resolves(
+ new Response(await readFixture('kitten-1-64-64.jpg'), {
+ headers: {
+ 'Content-Type': 'image/jpeg',
+ 'Content-Length': '123456789',
+ },
+ })
+ );
+
+ assert.isNull(
+ await fetchLinkPreviewImage(
+ fakeFetch,
+ 'https://example.com/img',
+ new AbortController().signal
+ )
+ );
+
+ sinon.assert.calledOnce(warn);
+ sinon.assert.calledWith(
+ warn,
+ 'fetchLinkPreviewImage: Content-Length is too large or is unset; bailing'
+ );
+ });
+
+ it('returns null if the Content-Type is not a valid image', async () => {
+ const fixture = await readFixture('kitten-1-64-64.jpg');
+
+ await Promise.all(
+ ['', 'image/tiff', 'video/mp4', 'text/plain', 'application/html'].map(
+ async contentType => {
+ const fakeFetch = stub().resolves(
+ new Response(fixture, {
+ headers: {
+ 'Content-Type': contentType,
+ 'Content-Length': fixture.length.toString(),
+ },
+ })
+ );
+
+ assert.isNull(
+ await fetchLinkPreviewImage(
+ fakeFetch,
+ 'https://example.com/img',
+ new AbortController().signal
+ )
+ );
+
+ sinon.assert.calledWith(
+ warn,
+ 'fetchLinkPreviewImage: Content-Type is not an image; bailing'
+ );
+ }
+ )
+ );
+ });
+
+ it('sends "WhatsApp" as the User-Agent for compatibility', async () => {
+ const fakeFetch = stub().resolves(new Response(null));
+
+ await fetchLinkPreviewImage(
+ fakeFetch,
+ 'https://example.com/img',
+ new AbortController().signal
+ );
+
+ sinon.assert.calledWith(
+ fakeFetch,
+ 'https://example.com/img',
+ sinon.match({
+ headers: {
+ 'User-Agent': 'WhatsApp',
+ },
+ })
+ );
+ });
+ });
+});
diff --git a/ts/types/Attachment.ts b/ts/types/Attachment.ts
index c0d447fcf..b0d2902af 100644
--- a/ts/types/Attachment.ts
+++ b/ts/types/Attachment.ts
@@ -143,10 +143,10 @@ export function isVideo(attachments?: Array) {
return attachments && isVideoAttachment(attachments[0]);
}
-export function isVideoAttachment(attachment?: AttachmentType) {
+export function isVideoAttachment(attachment?: AttachmentType): boolean {
return (
- attachment &&
- attachment.contentType &&
+ !!attachment &&
+ !!attachment.contentType &&
isVideoTypeSupported(attachment.contentType)
);
}
@@ -166,6 +166,18 @@ type DimensionsType = {
width: number;
};
+export async function arrayBufferFromFile(file: any): Promise {
+ return new Promise((resolve, reject) => {
+ const FR = new FileReader();
+ FR.onload = (e: any) => {
+ resolve(e.target.result);
+ };
+ FR.onerror = reject;
+ FR.onabort = reject;
+ FR.readAsArrayBuffer(file);
+ });
+}
+
export function getImageDimensions(attachment: AttachmentType): DimensionsType {
const { height, width } = attachment;
if (!height || !width) {
diff --git a/ts/types/MIME.ts b/ts/types/MIME.ts
index d34ae75c0..8766c1557 100644
--- a/ts/types/MIME.ts
+++ b/ts/types/MIME.ts
@@ -1,4 +1,4 @@
-export type MIMEType = string & { _mimeTypeBrand: any };
+export type MIMEType = string;
export const APPLICATION_OCTET_STREAM = 'application/octet-stream' as MIMEType;
export const APPLICATION_JSON = 'application/json' as MIMEType;
@@ -7,13 +7,19 @@ export const AUDIO_WEBM = 'audio/webm' as MIMEType;
export const AUDIO_MP3 = 'audio/mp3' as MIMEType;
export const IMAGE_GIF = 'image/gif' as MIMEType;
export const IMAGE_JPEG = 'image/jpeg' as MIMEType;
+export const IMAGE_BMP = 'image/bmp' as MIMEType;
+export const IMAGE_ICO = 'image/x-icon' as MIMEType;
+export const IMAGE_WEBP = 'image/webp' as MIMEType;
+export const IMAGE_PNG = 'image/png' as MIMEType;
export const VIDEO_MP4 = 'video/mp4' as MIMEType;
export const VIDEO_QUICKTIME = 'video/quicktime' as MIMEType;
export const isJPEG = (value: MIMEType): boolean => value === 'image/jpeg';
-export const isImage = (value?: MIMEType): boolean =>
- !!value && value.startsWith('image/');
+export const isImage = (value: MIMEType): boolean =>
+ value?.length > 0 && value.startsWith('image/');
export const isVideo = (value: MIMEType): boolean =>
- value && value.startsWith('video/');
+ value?.length > 0 && value.startsWith('video/');
+// As of 2020-04-16 aif files do not play in Electron nor Chrome. We should only
+// recognize them as file attachments.
export const isAudio = (value: MIMEType): boolean =>
- value && value.startsWith('audio/');
+ value?.length > 0 && value.startsWith('audio/') && !value.endsWith('aiff');
diff --git a/ts/util/attachmentsUtil.ts b/ts/util/attachmentsUtil.ts
index bbd7ad3fa..f8531f7b8 100644
--- a/ts/util/attachmentsUtil.ts
+++ b/ts/util/attachmentsUtil.ts
@@ -90,7 +90,7 @@ export async function getFile(attachment: StagedAttachmentType) {
};
}
-async function readFile(attachment: any): Promise