+
{title}
{description && (
-
+
{unescape(description)}
)}
-
) : null}
-
+ {onClose && (
+
+ )}
);
};
diff --git a/ts/mediaEditor/util/color.ts b/ts/mediaEditor/util/color.ts
index 87b75a2bc0..08ca1c0c18 100644
--- a/ts/mediaEditor/util/color.ts
+++ b/ts/mediaEditor/util/color.ts
@@ -5,29 +5,33 @@ function getRatio(min: number, max: number, value: number) {
return (value - min) / (max - min);
}
+const MAX_BLACK = 7;
+const MIN_WHITE = 95;
+
function getHSLValues(percentage: number): [number, number, number] {
- if (percentage <= 10) {
- return [0, 0, 1 - getRatio(0, 10, percentage)];
+ if (percentage <= MAX_BLACK) {
+ return [0, 0.5, 0.5 * getRatio(0, MAX_BLACK, percentage)];
}
- if (percentage < 20) {
- return [0, 0.5, 0.5 * getRatio(10, 20, percentage)];
+ if (percentage >= MIN_WHITE) {
+ return [0, 0, Math.min(1, 0.5 + getRatio(MIN_WHITE, 100, percentage))];
}
- const ratio = getRatio(20, 100, percentage);
+ const ratio = getRatio(MAX_BLACK, MIN_WHITE, percentage);
- return [360 * ratio, 1, 0.5];
-}
-
-export function getHSL(percentage: number): string {
- const [h, s, l] = getHSLValues(percentage);
- return `hsl(${h}, ${s * 100}%, ${l * 100}%)`;
+ return [338 * ratio, 1, 0.5];
}
// https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_RGB_alternative
-export function getRGBA(percentage: number, alpha = 1): string {
- const [h, s, l] = getHSLValues(percentage);
-
+function hslToRGB(
+ h: number,
+ s: number,
+ l: number
+): {
+ r: number;
+ g: number;
+ b: number;
+} {
const a = s * Math.min(l, 1 - l);
function f(n: number): number {
@@ -35,13 +39,31 @@ export function getRGBA(percentage: number, alpha = 1): string {
return l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
}
- const rgbValue = [
- Math.round(255 * f(0)),
- Math.round(255 * f(8)),
- Math.round(255 * f(4)),
- ]
- .map(String)
- .join(',');
+ return {
+ r: Math.round(255 * f(0)),
+ g: Math.round(255 * f(8)),
+ b: Math.round(255 * f(4)),
+ };
+}
+
+export function getHSL(percentage: number): string {
+ const [h, s, l] = getHSLValues(percentage);
+ return `hsl(${h}, ${s * 100}%, ${l * 100}%)`;
+}
+
+export function getRGBANumber(percentage: number): number {
+ const [h, s, l] = getHSLValues(percentage);
+ const { r, g, b } = hslToRGB(h, s, l);
+
+ // eslint-disable-next-line no-bitwise
+ return 0x100000000 + ((255 << 24) | ((255 & r) << 16) | ((255 & g) << 8) | b);
+}
+
+export function getRGBA(percentage: number, alpha = 1): string {
+ const [h, s, l] = getHSLValues(percentage);
+ const { r, g, b } = hslToRGB(h, s, l);
+
+ const rgbValue = [r, g, b].map(String).join(',');
return `rgba(${rgbValue},${alpha})`;
}
diff --git a/ts/mediaEditor/util/getTextStyleAttributes.ts b/ts/mediaEditor/util/getTextStyleAttributes.ts
index e33859b4d2..9083e3eb28 100644
--- a/ts/mediaEditor/util/getTextStyleAttributes.ts
+++ b/ts/mediaEditor/util/getTextStyleAttributes.ts
@@ -26,13 +26,13 @@ export function getTextStyleAttributes(
return { fill: color, strokeWidth: 0, textBackgroundColor: '' };
case TextStyle.Highlight:
return {
- fill: hueSliderValue <= 5 ? '#000' : '#fff',
+ fill: hueSliderValue >= 95 ? '#000' : '#fff',
strokeWidth: 0,
textBackgroundColor: color,
};
case TextStyle.Outline:
return {
- fill: hueSliderValue <= 5 ? '#000' : '#fff',
+ fill: hueSliderValue >= 95 ? '#000' : '#fff',
stroke: color,
strokeWidth: 2,
textBackgroundColor: '',
diff --git a/ts/models/messages.ts b/ts/models/messages.ts
index b99c25b4cd..a601ce997b 100644
--- a/ts/models/messages.ts
+++ b/ts/models/messages.ts
@@ -157,6 +157,7 @@ import { SeenStatus } from '../MessageSeenStatus';
import { isNewReactionReplacingPrevious } from '../reactions/util';
import { parseBoostBadgeListFromServer } from '../badges/parseBadgesFromServer';
import { GiftBadgeStates } from '../components/conversation/Message';
+import { downloadAttachment } from '../util/downloadAttachment';
/* eslint-disable more/no-then */
@@ -2451,10 +2452,7 @@ export class MessageModel extends window.Backbone.Model
{
let hash;
if (avatarAttachment) {
try {
- downloadedAvatar =
- await window.Signal.Util.downloadAttachment(
- avatarAttachment
- );
+ downloadedAvatar = await downloadAttachment(avatarAttachment);
if (downloadedAvatar) {
const loadedAttachment =
diff --git a/ts/services/LinkPreview.ts b/ts/services/LinkPreview.ts
new file mode 100644
index 0000000000..c1d557414b
--- /dev/null
+++ b/ts/services/LinkPreview.ts
@@ -0,0 +1,532 @@
+// Copyright 2022 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { debounce, omit } from 'lodash';
+
+import type { LinkPreviewType } from '../types/message/LinkPreviews';
+import type {
+ LinkPreviewImage,
+ LinkPreviewResult,
+ LinkPreviewSourceType,
+} from '../types/LinkPreview';
+import type { StickerPackType as StickerPackDBType } from '../sql/Interface';
+import type { MIMEType } from '../types/MIME';
+import * as Bytes from '../Bytes';
+import * as LinkPreview from '../types/LinkPreview';
+import * as Stickers from '../types/Stickers';
+import * as VisualAttachment from '../types/VisualAttachment';
+import * as log from '../logging/log';
+import { IMAGE_JPEG, IMAGE_WEBP, stringToMIMEType } from '../types/MIME';
+import { SECOND } from '../util/durations';
+import { autoScale } from '../util/handleImageAttachment';
+import { dropNull } from '../util/dropNull';
+import { fileToBytes } from '../util/fileToBytes';
+import { maybeParseUrl } from '../util/url';
+import { sniffImageMimeType } from '../util/sniffImageMimeType';
+
+const LINK_PREVIEW_TIMEOUT = 60 * SECOND;
+
+let currentlyMatchedLink: string | undefined;
+let disableLinkPreviews = false;
+let excludedPreviewUrls: Array = [];
+let linkPreviewAbortController: AbortController | undefined;
+let linkPreviewResult: Array | undefined;
+
+export function suspendLinkPreviews(): void {
+ disableLinkPreviews = true;
+}
+
+export function hasLinkPreviewLoaded(): boolean {
+ return Boolean(linkPreviewResult);
+}
+
+export const maybeGrabLinkPreview = debounce(_maybeGrabLinkPreview, 200);
+
+function _maybeGrabLinkPreview(
+ message: string,
+ source: LinkPreviewSourceType,
+ caretLocation?: number
+): void {
+ // Don't generate link previews if user has turned them off
+ if (!window.Events.getLinkPreviewSetting()) {
+ return;
+ }
+
+ // Do nothing if we're offline
+ const { messaging } = window.textsecure;
+ if (!messaging) {
+ return;
+ }
+ // If we're behind a user-configured proxy, we don't support link previews
+ if (window.isBehindProxy()) {
+ return;
+ }
+
+ if (!message) {
+ resetLinkPreview();
+ return;
+ }
+
+ if (disableLinkPreviews) {
+ return;
+ }
+
+ const links = LinkPreview.findLinks(message, caretLocation);
+ if (currentlyMatchedLink && links.includes(currentlyMatchedLink)) {
+ return;
+ }
+
+ currentlyMatchedLink = undefined;
+ excludedPreviewUrls = excludedPreviewUrls || [];
+
+ const link = links.find(
+ item =>
+ LinkPreview.shouldPreviewHref(item) && !excludedPreviewUrls.includes(item)
+ );
+ if (!link) {
+ removeLinkPreview();
+ return;
+ }
+
+ addLinkPreview(link, source);
+}
+
+export function resetLinkPreview(): void {
+ disableLinkPreviews = false;
+ excludedPreviewUrls = [];
+ removeLinkPreview();
+}
+
+export function removeLinkPreview(): void {
+ (linkPreviewResult || []).forEach((item: LinkPreviewResult) => {
+ if (item.url) {
+ URL.revokeObjectURL(item.url);
+ }
+ });
+ linkPreviewResult = undefined;
+ currentlyMatchedLink = undefined;
+ linkPreviewAbortController?.abort();
+ linkPreviewAbortController = undefined;
+
+ window.reduxActions.linkPreviews.removeLinkPreview();
+}
+
+export async function addLinkPreview(
+ url: string,
+ source: LinkPreviewSourceType
+): Promise {
+ if (currentlyMatchedLink === url) {
+ log.warn('addLinkPreview should not be called with the same URL like this');
+ return;
+ }
+
+ (linkPreviewResult || []).forEach((item: LinkPreviewResult) => {
+ if (item.url) {
+ URL.revokeObjectURL(item.url);
+ }
+ });
+ window.reduxActions.linkPreviews.removeLinkPreview();
+ linkPreviewResult = undefined;
+
+ // Cancel other in-flight link preview requests.
+ if (linkPreviewAbortController) {
+ log.info(
+ 'addLinkPreview: canceling another in-flight link preview request'
+ );
+ linkPreviewAbortController.abort();
+ }
+
+ const thisRequestAbortController = new AbortController();
+ linkPreviewAbortController = thisRequestAbortController;
+
+ const timeout = setTimeout(() => {
+ thisRequestAbortController.abort();
+ }, LINK_PREVIEW_TIMEOUT);
+
+ currentlyMatchedLink = url;
+ // Adding just the URL so that we get into a "loading" state
+ window.reduxActions.linkPreviews.addLinkPreview(
+ {
+ url,
+ },
+ source
+ );
+
+ try {
+ const result = await getPreview(url, thisRequestAbortController.signal);
+
+ if (!result) {
+ log.info(
+ 'addLinkPreview: failed to load preview (not necessarily a problem)'
+ );
+
+ // This helps us disambiguate between two kinds of failure:
+ //
+ // 1. We failed to fetch the preview because of (1) a network failure (2) an
+ // invalid response (3) a timeout
+ // 2. We failed to fetch the preview because we aborted the request because the
+ // user changed the link (e.g., by continuing to type the URL)
+ const failedToFetch = currentlyMatchedLink === url;
+ if (failedToFetch) {
+ excludedPreviewUrls.push(url);
+ removeLinkPreview();
+ }
+ return;
+ }
+
+ if (result.image && result.image.data) {
+ 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
+ removeLinkPreview();
+ return;
+ }
+
+ window.reduxActions.linkPreviews.addLinkPreview(
+ {
+ ...result,
+ description: dropNull(result.description),
+ date: dropNull(result.date),
+ domain: LinkPreview.getDomain(result.url),
+ isStickerPack: LinkPreview.isStickerPack(result.url),
+ },
+ source
+ );
+ linkPreviewResult = [result];
+ } catch (error) {
+ log.error(
+ 'Problem loading link preview, disabling.',
+ error && error.stack ? error.stack : error
+ );
+ disableLinkPreviews = true;
+ removeLinkPreview();
+ } finally {
+ clearTimeout(timeout);
+ }
+}
+
+export function getLinkPreviewForSend(message: string): Array {
+ // Don't generate link previews if user has turned them off
+ if (!window.storage.get('linkPreviews', false)) {
+ return [];
+ }
+
+ if (!linkPreviewResult) {
+ return [];
+ }
+
+ const urlsInMessage = new Set(LinkPreview.findLinks(message));
+
+ return (
+ linkPreviewResult
+ // This bullet-proofs against sending link previews for URLs that are no longer in
+ // the message. This can happen if you have a link preview, then quickly delete
+ // the link and send the message.
+ .filter(({ url }: Readonly<{ url: string }>) => urlsInMessage.has(url))
+ .map((item: LinkPreviewResult) => {
+ if (item.image) {
+ // We eliminate the ObjectURL here, unneeded for send or save
+ return {
+ ...item,
+ image: omit(item.image, 'url'),
+ description: dropNull(item.description),
+ date: dropNull(item.date),
+ domain: LinkPreview.getDomain(item.url),
+ isStickerPack: LinkPreview.isStickerPack(item.url),
+ };
+ }
+
+ return {
+ ...item,
+ description: dropNull(item.description),
+ date: dropNull(item.date),
+ domain: LinkPreview.getDomain(item.url),
+ isStickerPack: LinkPreview.isStickerPack(item.url),
+ };
+ })
+ );
+}
+
+async function getPreview(
+ url: string,
+ abortSignal: Readonly
+): Promise {
+ const { messaging } = window.textsecure;
+
+ if (!messaging) {
+ throw new Error('messaging is not available!');
+ }
+
+ if (LinkPreview.isStickerPack(url)) {
+ return getStickerPackPreview(url, abortSignal);
+ }
+ if (LinkPreview.isGroupLink(url)) {
+ return getGroupPreview(url, abortSignal);
+ }
+
+ // This is already checked elsewhere, but we want to be extra-careful.
+ if (!LinkPreview.shouldPreviewHref(url)) {
+ return null;
+ }
+
+ const linkPreviewMetadata = await messaging.fetchLinkPreviewMetadata(
+ url,
+ abortSignal
+ );
+ if (!linkPreviewMetadata || abortSignal.aborted) {
+ return null;
+ }
+ const { title, imageHref, description, date } = linkPreviewMetadata;
+
+ let image;
+ if (imageHref && LinkPreview.shouldPreviewHref(imageHref)) {
+ let objectUrl: void | string;
+ try {
+ const fullSizeImage = await messaging.fetchLinkPreviewImage(
+ imageHref,
+ abortSignal
+ );
+ if (abortSignal.aborted) {
+ return null;
+ }
+ 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 autoScale({
+ contentType: fullSizeImage.contentType,
+ file: new Blob([fullSizeImage.data], {
+ type: fullSizeImage.contentType,
+ }),
+ fileName: title,
+ });
+
+ const data = await fileToBytes(withBlob.file);
+ objectUrl = URL.createObjectURL(withBlob.file);
+
+ const blurHash = await window.imageToBlurHash(withBlob.file);
+
+ const dimensions = await VisualAttachment.getImageDimensions({
+ objectUrl,
+ logger: log,
+ });
+
+ image = {
+ data,
+ size: data.byteLength,
+ ...dimensions,
+ contentType: stringToMIMEType(withBlob.file.type),
+ blurHash,
+ };
+ } catch (error) {
+ // We still want to show the preview if we failed to get an image
+ log.error(
+ 'getPreview failed to get image for link preview:',
+ error.message
+ );
+ } finally {
+ if (objectUrl) {
+ URL.revokeObjectURL(objectUrl);
+ }
+ }
+ }
+
+ if (abortSignal.aborted) {
+ return null;
+ }
+
+ return {
+ date: date || null,
+ description: description || null,
+ image,
+ title,
+ url,
+ };
+}
+
+async function getStickerPackPreview(
+ url: string,
+ abortSignal: Readonly
+): Promise {
+ const isPackDownloaded = (
+ pack?: StickerPackDBType
+ ): pack is StickerPackDBType => {
+ if (!pack) {
+ return false;
+ }
+
+ return pack.status === 'downloaded' || pack.status === 'installed';
+ };
+ const isPackValid = (pack?: StickerPackDBType): pack is StickerPackDBType => {
+ if (!pack) {
+ return false;
+ }
+ return (
+ pack.status === 'ephemeral' ||
+ pack.status === 'downloaded' ||
+ pack.status === 'installed'
+ );
+ };
+
+ const dataFromLink = Stickers.getDataFromLink(url);
+ if (!dataFromLink) {
+ return null;
+ }
+ const { id, key } = dataFromLink;
+
+ try {
+ const keyBytes = Bytes.fromHex(key);
+ const keyBase64 = Bytes.toBase64(keyBytes);
+
+ const existing = Stickers.getStickerPack(id);
+ if (!isPackDownloaded(existing)) {
+ await Stickers.downloadEphemeralPack(id, keyBase64);
+ }
+
+ if (abortSignal.aborted) {
+ return null;
+ }
+
+ const pack = Stickers.getStickerPack(id);
+
+ if (!isPackValid(pack)) {
+ return null;
+ }
+ if (pack.key !== keyBase64) {
+ return null;
+ }
+
+ const { title, coverStickerId } = pack;
+ const sticker = pack.stickers[coverStickerId];
+ const data =
+ pack.status === 'ephemeral'
+ ? await window.Signal.Migrations.readTempData(sticker.path)
+ : await window.Signal.Migrations.readStickerData(sticker.path);
+
+ if (abortSignal.aborted) {
+ return null;
+ }
+
+ let contentType: MIMEType;
+ const sniffedMimeType = sniffImageMimeType(data);
+ if (sniffedMimeType) {
+ contentType = sniffedMimeType;
+ } else {
+ log.warn(
+ 'getStickerPackPreview: Unable to sniff sticker MIME type; falling back to WebP'
+ );
+ contentType = IMAGE_WEBP;
+ }
+
+ return {
+ date: null,
+ description: null,
+ image: {
+ ...sticker,
+ data,
+ size: data.byteLength,
+ contentType,
+ },
+ title,
+ url,
+ };
+ } catch (error) {
+ log.error(
+ 'getStickerPackPreview error:',
+ error && error.stack ? error.stack : error
+ );
+ return null;
+ } finally {
+ if (id) {
+ await Stickers.removeEphemeralPack(id);
+ }
+ }
+}
+
+async function getGroupPreview(
+ url: string,
+ abortSignal: Readonly
+): Promise {
+ const urlObject = maybeParseUrl(url);
+ if (!urlObject) {
+ return null;
+ }
+
+ const { hash } = urlObject;
+ if (!hash) {
+ return null;
+ }
+ const groupData = hash.slice(1);
+
+ const { inviteLinkPassword, masterKey } =
+ window.Signal.Groups.parseGroupLink(groupData);
+
+ const fields = window.Signal.Groups.deriveGroupFields(
+ Bytes.fromBase64(masterKey)
+ );
+ const id = Bytes.toBase64(fields.id);
+ const logId = `groupv2(${id})`;
+ const secretParams = Bytes.toBase64(fields.secretParams);
+
+ log.info(`getGroupPreview/${logId}: Fetching pre-join state`);
+ const result = await window.Signal.Groups.getPreJoinGroupInfo(
+ inviteLinkPassword,
+ masterKey
+ );
+
+ if (abortSignal.aborted) {
+ return null;
+ }
+
+ const title =
+ window.Signal.Groups.decryptGroupTitle(result.title, secretParams) ||
+ window.i18n('unknownGroup');
+ const description =
+ result.memberCount === 1 || result.memberCount === undefined
+ ? window.i18n('GroupV2--join--member-count--single')
+ : window.i18n('GroupV2--join--member-count--multiple', {
+ count: result.memberCount.toString(),
+ });
+ let image: undefined | LinkPreviewImage;
+
+ if (result.avatar) {
+ try {
+ const data = await window.Signal.Groups.decryptGroupAvatar(
+ result.avatar,
+ secretParams
+ );
+ image = {
+ data,
+ size: data.byteLength,
+ contentType: IMAGE_JPEG,
+ blurHash: await window.imageToBlurHash(
+ new Blob([data], {
+ type: IMAGE_JPEG,
+ })
+ ),
+ };
+ } catch (error) {
+ const errorString = error && error.stack ? error.stack : error;
+ log.error(
+ `getGroupPreview/${logId}: Failed to fetch avatar ${errorString}`
+ );
+ }
+ }
+
+ if (abortSignal.aborted) {
+ return null;
+ }
+
+ return {
+ date: null,
+ description,
+ image,
+ title,
+ url,
+ };
+}
diff --git a/ts/state/ducks/composer.ts b/ts/state/ducks/composer.ts
index b9907c320a..0552b653d0 100644
--- a/ts/state/ducks/composer.ts
+++ b/ts/state/ducks/composer.ts
@@ -11,23 +11,30 @@ import type {
InMemoryAttachmentDraftType,
} from '../../types/Attachment';
import type { MessageAttributesType } from '../../model-types.d';
-import type { LinkPreviewWithDomain } from '../../types/LinkPreview';
+import type { LinkPreviewType } from '../../types/message/LinkPreviews';
import { assignWithNoUnnecessaryAllocation } from '../../util/assignWithNoUnnecessaryAllocation';
-import type { RemoveLinkPreviewActionType } from './linkPreviews';
-import { REMOVE_PREVIEW as REMOVE_LINK_PREVIEW } from './linkPreviews';
+import type {
+ AddLinkPreviewActionType,
+ RemoveLinkPreviewActionType,
+} from './linkPreviews';
+import {
+ ADD_PREVIEW as ADD_LINK_PREVIEW,
+ REMOVE_PREVIEW as REMOVE_LINK_PREVIEW,
+} from './linkPreviews';
import { writeDraftAttachment } from '../../util/writeDraftAttachment';
import { deleteDraftAttachment } from '../../util/deleteDraftAttachment';
import { replaceIndex } from '../../util/replaceIndex';
import { resolveDraftAttachmentOnDisk } from '../../util/resolveDraftAttachmentOnDisk';
import type { HandleAttachmentsProcessingArgsType } from '../../util/handleAttachmentsProcessing';
import { handleAttachmentsProcessing } from '../../util/handleAttachmentsProcessing';
+import { LinkPreviewSourceType } from '../../types/LinkPreview';
// State
export type ComposerStateType = {
attachments: ReadonlyArray;
linkPreviewLoading: boolean;
- linkPreviewResult?: LinkPreviewWithDomain;
+ linkPreviewResult?: LinkPreviewType;
quotedMessage?: Pick;
shouldSendHighQualityAttachments: boolean;
};
@@ -38,7 +45,6 @@ const ADD_PENDING_ATTACHMENT = 'composer/ADD_PENDING_ATTACHMENT';
const REPLACE_ATTACHMENTS = 'composer/REPLACE_ATTACHMENTS';
const RESET_COMPOSER = 'composer/RESET_COMPOSER';
const SET_HIGH_QUALITY_SETTING = 'composer/SET_HIGH_QUALITY_SETTING';
-const SET_LINK_PREVIEW_RESULT = 'composer/SET_LINK_PREVIEW_RESULT';
const SET_QUOTED_MESSAGE = 'composer/SET_QUOTED_MESSAGE';
type AddPendingAttachmentActionType = {
@@ -60,26 +66,18 @@ type SetHighQualitySettingActionType = {
payload: boolean;
};
-type SetLinkPreviewResultActionType = {
- type: typeof SET_LINK_PREVIEW_RESULT;
- payload: {
- isLoading: boolean;
- linkPreview?: LinkPreviewWithDomain;
- };
-};
-
type SetQuotedMessageActionType = {
type: typeof SET_QUOTED_MESSAGE;
payload?: Pick;
};
type ComposerActionType =
+ | AddLinkPreviewActionType
| AddPendingAttachmentActionType
| RemoveLinkPreviewActionType
| ReplaceAttachmentsActionType
| ResetComposerActionType
| SetHighQualitySettingActionType
- | SetLinkPreviewResultActionType
| SetQuotedMessageActionType;
// Action Creators
@@ -91,7 +89,6 @@ export const actions = {
removeAttachment,
replaceAttachments,
resetComposer,
- setLinkPreviewResult,
setMediaQualitySetting,
setQuotedMessage,
};
@@ -266,19 +263,6 @@ function resetComposer(): ResetComposerActionType {
};
}
-function setLinkPreviewResult(
- isLoading: boolean,
- linkPreview?: LinkPreviewWithDomain
-): SetLinkPreviewResultActionType {
- return {
- type: SET_LINK_PREVIEW_RESULT,
- payload: {
- isLoading,
- linkPreview,
- },
- };
-}
-
function setMediaQualitySetting(
payload: boolean
): SetHighQualitySettingActionType {
@@ -340,10 +324,14 @@ export function reducer(
};
}
- if (action.type === SET_LINK_PREVIEW_RESULT) {
+ if (action.type === ADD_LINK_PREVIEW) {
+ if (action.payload.source !== LinkPreviewSourceType.Composer) {
+ return state;
+ }
+
return {
...state,
- linkPreviewLoading: action.payload.isLoading,
+ linkPreviewLoading: true,
linkPreviewResult: action.payload.linkPreview,
};
}
diff --git a/ts/state/ducks/linkPreviews.ts b/ts/state/ducks/linkPreviews.ts
index e63ca5c451..a19b9e3306 100644
--- a/ts/state/ducks/linkPreviews.ts
+++ b/ts/state/ducks/linkPreviews.ts
@@ -1,23 +1,34 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
+import type { ThunkAction } from 'redux-thunk';
+
+import type { NoopActionType } from './noop';
+import type { StateType as RootStateType } from '../reducer';
import type { LinkPreviewType } from '../../types/message/LinkPreviews';
+import type { LinkPreviewSourceType } from '../../types/LinkPreview';
import { assignWithNoUnnecessaryAllocation } from '../../util/assignWithNoUnnecessaryAllocation';
+import { maybeGrabLinkPreview } from '../../services/LinkPreview';
+import { useBoundActions } from '../../hooks/useBoundActions';
// State
export type LinkPreviewsStateType = {
readonly linkPreview?: LinkPreviewType;
+ readonly source?: LinkPreviewSourceType;
};
// Actions
-const ADD_PREVIEW = 'linkPreviews/ADD_PREVIEW';
+export const ADD_PREVIEW = 'linkPreviews/ADD_PREVIEW';
export const REMOVE_PREVIEW = 'linkPreviews/REMOVE_PREVIEW';
-type AddLinkPreviewActionType = {
+export type AddLinkPreviewActionType = {
type: 'linkPreviews/ADD_PREVIEW';
- payload: LinkPreviewType;
+ payload: {
+ linkPreview: LinkPreviewType;
+ source: LinkPreviewSourceType;
+ };
};
export type RemoveLinkPreviewActionType = {
@@ -30,15 +41,30 @@ type LinkPreviewsActionType =
// Action Creators
-export const actions = {
- addLinkPreview,
- removeLinkPreview,
-};
+function debouncedMaybeGrabLinkPreview(
+ message: string,
+ source: LinkPreviewSourceType
+): ThunkAction {
+ return dispatch => {
+ maybeGrabLinkPreview(message, source);
-function addLinkPreview(payload: LinkPreviewType): AddLinkPreviewActionType {
+ dispatch({
+ type: 'NOOP',
+ payload: null,
+ });
+ };
+}
+
+function addLinkPreview(
+ linkPreview: LinkPreviewType,
+ source: LinkPreviewSourceType
+): AddLinkPreviewActionType {
return {
type: ADD_PREVIEW,
- payload,
+ payload: {
+ linkPreview,
+ source,
+ },
};
}
@@ -48,6 +74,15 @@ function removeLinkPreview(): RemoveLinkPreviewActionType {
};
}
+export const actions = {
+ addLinkPreview,
+ debouncedMaybeGrabLinkPreview,
+ removeLinkPreview,
+};
+
+export const useLinkPreviewActions = (): typeof actions =>
+ useBoundActions(actions);
+
// Reducer
export function getEmptyState(): LinkPreviewsStateType {
@@ -64,13 +99,15 @@ export function reducer(
const { payload } = action;
return {
- linkPreview: payload,
+ linkPreview: payload.linkPreview,
+ source: payload.source,
};
}
if (action.type === REMOVE_PREVIEW) {
return assignWithNoUnnecessaryAllocation(state, {
linkPreview: undefined,
+ source: undefined,
});
}
diff --git a/ts/state/selectors/linkPreviews.ts b/ts/state/selectors/linkPreviews.ts
index dc4b1c0933..107218a1c3 100644
--- a/ts/state/selectors/linkPreviews.ts
+++ b/ts/state/selectors/linkPreviews.ts
@@ -6,12 +6,21 @@ import { createSelector } from 'reselect';
import { assert } from '../../util/assert';
import { getDomain } from '../../types/LinkPreview';
+import type { LinkPreviewSourceType } from '../../types/LinkPreview';
import type { StateType } from '../reducer';
export const getLinkPreview = createSelector(
- ({ linkPreviews }: StateType) => linkPreviews.linkPreview,
- linkPreview => {
- if (linkPreview) {
+ ({ linkPreviews }: StateType) => linkPreviews,
+ ({ linkPreview, source }) => {
+ return (fromSource: LinkPreviewSourceType) => {
+ if (!linkPreview) {
+ return;
+ }
+
+ if (source !== fromSource) {
+ return;
+ }
+
const domain = getDomain(linkPreview.url);
assert(domain !== undefined, "Domain of linkPreview can't be undefined");
@@ -20,8 +29,6 @@ export const getLinkPreview = createSelector(
domain,
isLoaded: true,
};
- }
-
- return undefined;
+ };
}
);
diff --git a/ts/state/smart/ForwardMessageModal.tsx b/ts/state/smart/ForwardMessageModal.tsx
index b2d1d41231..69c3ee8f3d 100644
--- a/ts/state/smart/ForwardMessageModal.tsx
+++ b/ts/state/smart/ForwardMessageModal.tsx
@@ -2,19 +2,20 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { connect } from 'react-redux';
-import { mapDispatchToProps } from '../actions';
-import type { DataPropsType } from '../../components/ForwardMessageModal';
-import { ForwardMessageModal } from '../../components/ForwardMessageModal';
-import type { StateType } from '../reducer';
-import type { BodyRangeType } from '../../types/Util';
-import type { LinkPreviewType } from '../../types/message/LinkPreviews';
-import { getPreferredBadgeSelector } from '../selectors/badges';
-import { getAllComposableConversations } from '../selectors/conversations';
-import { getLinkPreview } from '../selectors/linkPreviews';
-import { getIntl, getTheme, getRegionCode } from '../selectors/user';
-import { getEmojiSkinTone } from '../selectors/items';
-import { selectRecentEmojis } from '../selectors/emojis';
import type { AttachmentType } from '../../types/Attachment';
+import type { BodyRangeType } from '../../types/Util';
+import type { DataPropsType } from '../../components/ForwardMessageModal';
+import type { LinkPreviewType } from '../../types/message/LinkPreviews';
+import type { StateType } from '../reducer';
+import { ForwardMessageModal } from '../../components/ForwardMessageModal';
+import { LinkPreviewSourceType } from '../../types/LinkPreview';
+import { getAllComposableConversations } from '../selectors/conversations';
+import { getEmojiSkinTone } from '../selectors/items';
+import { getIntl, getTheme, getRegionCode } from '../selectors/user';
+import { getLinkPreview } from '../selectors/linkPreviews';
+import { getPreferredBadgeSelector } from '../selectors/badges';
+import { mapDispatchToProps } from '../actions';
+import { selectRecentEmojis } from '../selectors/emojis';
export type SmartForwardMessageModalProps = {
attachments?: Array;
@@ -54,7 +55,7 @@ const mapStateToProps = (
const candidateConversations = getAllComposableConversations(state);
const recentEmojis = selectRecentEmojis(state);
const skinTone = getEmojiSkinTone(state);
- const linkPreview = getLinkPreview(state);
+ const linkPreviewForSource = getLinkPreview(state);
return {
attachments,
@@ -64,7 +65,9 @@ const mapStateToProps = (
hasContact,
i18n: getIntl(state),
isSticker,
- linkPreview,
+ linkPreview: linkPreviewForSource(
+ LinkPreviewSourceType.ForwardMessageModal
+ ),
messageBody,
onClose,
onEditorStateChange,
diff --git a/ts/state/smart/Stories.tsx b/ts/state/smart/Stories.tsx
index b04e9fa1ec..4983536304 100644
--- a/ts/state/smart/Stories.tsx
+++ b/ts/state/smart/Stories.tsx
@@ -6,7 +6,9 @@ import { useSelector } from 'react-redux';
import type { LocalizerType } from '../../types/Util';
import type { StateType } from '../reducer';
+import type { PropsType as SmartStoryCreatorPropsType } from './StoryCreator';
import type { PropsType as SmartStoryViewerPropsType } from './StoryViewer';
+import { SmartStoryCreator } from './StoryCreator';
import { SmartStoryViewer } from './StoryViewer';
import { Stories } from '../../components/Stories';
import { getIntl } from '../selectors/user';
@@ -15,6 +17,12 @@ import { getStories } from '../selectors/stories';
import { useStoriesActions } from '../ducks/stories';
import { useConversationsActions } from '../ducks/conversations';
+function renderStoryCreator({
+ onClose,
+}: SmartStoryCreatorPropsType): JSX.Element {
+ return ;
+}
+
function renderStoryViewer({
conversationId,
onClose,
@@ -56,6 +64,7 @@ export function SmartStories(): JSX.Element | null {
hiddenStories={hiddenStories}
i18n={i18n}
preferredWidthFromStorage={preferredWidthFromStorage}
+ renderStoryCreator={renderStoryCreator}
renderStoryViewer={renderStoryViewer}
showConversation={showConversation}
stories={stories}
diff --git a/ts/state/smart/StoryCreator.tsx b/ts/state/smart/StoryCreator.tsx
new file mode 100644
index 0000000000..8aeb0a23d4
--- /dev/null
+++ b/ts/state/smart/StoryCreator.tsx
@@ -0,0 +1,35 @@
+// Copyright 2022 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import React from 'react';
+import { useSelector } from 'react-redux';
+import { noop } from 'lodash';
+
+import type { LocalizerType } from '../../types/Util';
+import type { StateType } from '../reducer';
+import { LinkPreviewSourceType } from '../../types/LinkPreview';
+import { StoryCreator } from '../../components/StoryCreator';
+import { getIntl } from '../selectors/user';
+import { getLinkPreview } from '../selectors/linkPreviews';
+import { useLinkPreviewActions } from '../ducks/linkPreviews';
+
+export type PropsType = {
+ onClose: () => unknown;
+};
+
+export function SmartStoryCreator({ onClose }: PropsType): JSX.Element | null {
+ const { debouncedMaybeGrabLinkPreview } = useLinkPreviewActions();
+
+ const i18n = useSelector(getIntl);
+ const linkPreviewForSource = useSelector(getLinkPreview);
+
+ return (
+
+ );
+}
diff --git a/ts/test-both/state/ducks/composer_test.ts b/ts/test-both/state/ducks/composer_test.ts
index 3842410261..2e02bff921 100644
--- a/ts/test-both/state/ducks/composer_test.ts
+++ b/ts/test-both/state/ducks/composer_test.ts
@@ -117,35 +117,6 @@ describe('both/state/ducks/composer', () => {
});
});
- describe('setLinkPreviewResult', () => {
- it('sets loading state when loading', () => {
- const { setLinkPreviewResult } = actions;
- const state = getEmptyState();
- const nextState = reducer(state, setLinkPreviewResult(true));
-
- assert.isTrue(nextState.linkPreviewLoading);
- });
-
- it('sets the link preview result', () => {
- const { setLinkPreviewResult } = actions;
- const state = getEmptyState();
- const nextState = reducer(
- state,
- setLinkPreviewResult(false, {
- domain: 'https://www.signal.org/',
- title: 'Signal >> Careers',
- url: 'https://www.signal.org/workworkwork',
- description:
- 'Join an organization that empowers users by making private communication simple.',
- date: null,
- })
- );
-
- assert.isFalse(nextState.linkPreviewLoading);
- assert.equal(nextState.linkPreviewResult?.title, 'Signal >> Careers');
- });
- });
-
describe('setMediaQualitySetting', () => {
it('toggles the media quality setting', () => {
const { setMediaQualitySetting } = actions;
diff --git a/ts/test-both/state/ducks/linkPreviews_test.ts b/ts/test-both/state/ducks/linkPreviews_test.ts
index a433071483..d31c84f4f2 100644
--- a/ts/test-both/state/ducks/linkPreviews_test.ts
+++ b/ts/test-both/state/ducks/linkPreviews_test.ts
@@ -26,7 +26,7 @@ describe('both/state/ducks/linkPreviews', () => {
it('updates linkPreview', () => {
const state = getEmptyState();
const linkPreview = getMockLinkPreview();
- const nextState = reducer(state, addLinkPreview(linkPreview));
+ const nextState = reducer(state, addLinkPreview(linkPreview, 0));
assert.strictEqual(nextState.linkPreview, linkPreview);
});
diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts
index b69094f230..1ea0ec7a06 100644
--- a/ts/textsecure/MessageReceiver.ts
+++ b/ts/textsecure/MessageReceiver.ts
@@ -1806,6 +1806,7 @@ export default class MessageReceiver
throw new Error('Text attachments must have text!');
}
+ // TODO DESKTOP-3714 we should download the story link preview image
attachments.push({
size: text.length,
contentType: APPLICATION_OCTET_STREAM,
diff --git a/ts/textsecure/Types.d.ts b/ts/textsecure/Types.d.ts
index 338e9a3162..30f83cd3bf 100644
--- a/ts/textsecure/Types.d.ts
+++ b/ts/textsecure/Types.d.ts
@@ -108,7 +108,7 @@ export type ProcessedAttachment = {
caption?: string;
blurHash?: string;
cdnNumber?: number;
- textAttachment?: TextAttachmentType;
+ textAttachment?: Omit;
};
export type ProcessedGroupContext = {
diff --git a/ts/types/Attachment.ts b/ts/types/Attachment.ts
index 73af2e1604..6a8de05ce6 100644
--- a/ts/types/Attachment.ts
+++ b/ts/types/Attachment.ts
@@ -102,8 +102,9 @@ export type TextAttachmentType = {
textForegroundColor?: number | null;
textBackgroundColor?: number | null;
preview?: {
- url?: string | null;
+ image?: AttachmentType;
title?: string | null;
+ url?: string | null;
} | null;
gradient?: {
startColor?: number | null;
diff --git a/ts/types/LinkPreview.ts b/ts/types/LinkPreview.ts
index 7f9e9fc473..7b2964a166 100644
--- a/ts/types/LinkPreview.ts
+++ b/ts/types/LinkPreview.ts
@@ -26,6 +26,12 @@ export type LinkPreviewWithDomain = {
domain: string;
} & LinkPreviewResult;
+export enum LinkPreviewSourceType {
+ Composer,
+ ForwardMessageModal,
+ StoryCreator,
+}
+
const linkify = LinkifyIt();
export function shouldPreviewHref(href: string): boolean {
diff --git a/ts/types/message/LinkPreviews.ts b/ts/types/message/LinkPreviews.ts
index 58fa5c7816..ba163b20da 100644
--- a/ts/types/message/LinkPreviews.ts
+++ b/ts/types/message/LinkPreviews.ts
@@ -4,9 +4,9 @@
import type { AttachmentType } from '../Attachment';
export type LinkPreviewType = {
- title: string;
+ title?: string;
description?: string;
- domain: string;
+ domain?: string;
url: string;
isStickerPack?: boolean;
image?: Readonly;
diff --git a/ts/util/getStoryBackground.ts b/ts/util/getStoryBackground.ts
index 1e5601f5b3..53fb02dc16 100644
--- a/ts/util/getStoryBackground.ts
+++ b/ts/util/getStoryBackground.ts
@@ -4,7 +4,8 @@
import type { AttachmentType, TextAttachmentType } from '../types/Attachment';
const COLOR_BLACK_ALPHA_90 = 'rgba(0, 0, 0, 0.9)';
-const COLOR_WHITE_INT = 4294704123;
+export const COLOR_BLACK_INT = 4278190080;
+export const COLOR_WHITE_INT = 4294704123;
export function getHexFromNumber(color: number): string {
return `#${color.toString(16).slice(2)}`;
@@ -13,11 +14,11 @@ export function getHexFromNumber(color: number): string {
export function getBackgroundColor({
color,
gradient,
-}: TextAttachmentType): string {
+}: Pick): string {
if (gradient) {
return `linear-gradient(${gradient.angle}deg, ${getHexFromNumber(
gradient.startColor || COLOR_WHITE_INT
- )}, ${getHexFromNumber(gradient.endColor || COLOR_WHITE_INT)})`;
+ )}, ${getHexFromNumber(gradient.endColor || COLOR_WHITE_INT)}) border-box`;
}
return getHexFromNumber(color || COLOR_WHITE_INT);
diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json
index ba9aef5eac..f01f8ab702 100644
--- a/ts/util/lint/exceptions.json
+++ b/ts/util/lint/exceptions.json
@@ -912,7 +912,7 @@
"rule": "jQuery-load(",
"path": "node_modules/agent-base/node_modules/debug/src/common.js",
"line": "\tcreateDebug.enable(createDebug.load());",
- "reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted",
+ "reasonCategory": "usageTrusted",
"updated": "2022-02-11T21:58:24.827Z"
},
{
@@ -7351,6 +7351,125 @@
"reasonCategory": "falseMatch",
"updated": "2022-06-04T00:50:49.405Z"
},
+ {
+ "rule": "React-useRef",
+ "path": "node_modules/react-textarea-autosize/dist/react-textarea-autosize.browser.cjs.js",
+ "line": " var libRef = React.useRef(null);",
+ "reasonCategory": "usageTrusted",
+ "updated": "2022-06-16T23:23:32.306Z"
+ },
+ {
+ "rule": "React-useRef",
+ "path": "node_modules/react-textarea-autosize/dist/react-textarea-autosize.browser.cjs.js",
+ "line": " var heightRef = React.useRef(0);",
+ "reasonCategory": "usageTrusted",
+ "updated": "2022-06-16T23:23:32.306Z"
+ },
+ {
+ "rule": "React-useRef",
+ "path": "node_modules/react-textarea-autosize/dist/react-textarea-autosize.browser.cjs.js",
+ "line": " var measurementsCacheRef = React.useRef();",
+ "reasonCategory": "usageTrusted",
+ "updated": "2022-06-16T23:23:32.306Z"
+ },
+ {
+ "rule": "React-useRef",
+ "path": "node_modules/react-textarea-autosize/dist/react-textarea-autosize.browser.esm.js",
+ "line": " var libRef = useRef(null);",
+ "reasonCategory": "usageTrusted",
+ "updated": "2022-06-16T23:23:32.306Z"
+ },
+ {
+ "rule": "React-useRef",
+ "path": "node_modules/react-textarea-autosize/dist/react-textarea-autosize.browser.esm.js",
+ "line": " var heightRef = useRef(0);",
+ "reasonCategory": "usageTrusted",
+ "updated": "2022-06-16T23:23:32.306Z"
+ },
+ {
+ "rule": "React-useRef",
+ "path": "node_modules/react-textarea-autosize/dist/react-textarea-autosize.browser.esm.js",
+ "line": " var measurementsCacheRef = useRef();",
+ "reasonCategory": "usageTrusted",
+ "updated": "2022-06-16T23:23:32.306Z"
+ },
+ {
+ "rule": "React-useRef",
+ "path": "node_modules/react-textarea-autosize/dist/react-textarea-autosize.cjs.dev.js",
+ "line": " var libRef = React.useRef(null);",
+ "reasonCategory": "usageTrusted",
+ "updated": "2022-06-16T23:23:32.306Z"
+ },
+ {
+ "rule": "React-useRef",
+ "path": "node_modules/react-textarea-autosize/dist/react-textarea-autosize.cjs.dev.js",
+ "line": " var heightRef = React.useRef(0);",
+ "reasonCategory": "usageTrusted",
+ "updated": "2022-06-16T23:23:32.306Z"
+ },
+ {
+ "rule": "React-useRef",
+ "path": "node_modules/react-textarea-autosize/dist/react-textarea-autosize.cjs.dev.js",
+ "line": " var measurementsCacheRef = React.useRef();",
+ "reasonCategory": "usageTrusted",
+ "updated": "2022-06-16T23:23:32.306Z"
+ },
+ {
+ "rule": "React-useRef",
+ "path": "node_modules/react-textarea-autosize/dist/react-textarea-autosize.cjs.prod.js",
+ "line": " var libRef = React.useRef(null);",
+ "reasonCategory": "usageTrusted",
+ "updated": "2022-06-16T23:23:32.306Z"
+ },
+ {
+ "rule": "React-useRef",
+ "path": "node_modules/react-textarea-autosize/dist/react-textarea-autosize.cjs.prod.js",
+ "line": " var heightRef = React.useRef(0);",
+ "reasonCategory": "usageTrusted",
+ "updated": "2022-06-16T23:23:32.306Z"
+ },
+ {
+ "rule": "React-useRef",
+ "path": "node_modules/react-textarea-autosize/dist/react-textarea-autosize.cjs.prod.js",
+ "line": " var measurementsCacheRef = React.useRef();",
+ "reasonCategory": "usageTrusted",
+ "updated": "2022-06-16T23:23:32.306Z"
+ },
+ {
+ "rule": "React-useRef",
+ "path": "node_modules/react-textarea-autosize/dist/react-textarea-autosize.esm.js",
+ "line": " var libRef = useRef(null);",
+ "reasonCategory": "usageTrusted",
+ "updated": "2022-06-16T23:23:32.306Z"
+ },
+ {
+ "rule": "React-useRef",
+ "path": "node_modules/react-textarea-autosize/dist/react-textarea-autosize.esm.js",
+ "line": " var heightRef = useRef(0);",
+ "reasonCategory": "usageTrusted",
+ "updated": "2022-06-16T23:23:32.306Z"
+ },
+ {
+ "rule": "React-useRef",
+ "path": "node_modules/react-textarea-autosize/dist/react-textarea-autosize.esm.js",
+ "line": " var measurementsCacheRef = useRef();",
+ "reasonCategory": "usageTrusted",
+ "updated": "2022-06-16T23:23:32.306Z"
+ },
+ {
+ "rule": "jQuery-wrap(",
+ "path": "node_modules/react-textarea-autosize/node_modules/regenerator-runtime/runtime.js",
+ "line": " function wrap(innerFn, outerFn, self, tryLocsList) {",
+ "reasonCategory": "falseMatch",
+ "updated": "2022-06-16T23:23:32.306Z"
+ },
+ {
+ "rule": "jQuery-wrap(",
+ "path": "node_modules/react-textarea-autosize/node_modules/regenerator-runtime/runtime.js",
+ "line": " wrap(innerFn, outerFn, self, tryLocsList),",
+ "reasonCategory": "falseMatch",
+ "updated": "2022-06-16T23:23:32.306Z"
+ },
{
"rule": "jQuery-wrap(",
"path": "node_modules/redux/node_modules/regenerator-runtime/runtime.js",
@@ -8108,6 +8227,41 @@
"updated": "2020-08-26T00:10:28.628Z",
"reasonDetail": "isn't jquery"
},
+ {
+ "rule": "React-useRef",
+ "path": "node_modules/use-composed-ref/dist/use-composed-ref.cjs.js",
+ "line": " var prevUserRef = React.useRef();",
+ "reasonCategory": "usageTrusted",
+ "updated": "2022-06-16T23:23:32.306Z"
+ },
+ {
+ "rule": "React-useRef",
+ "path": "node_modules/use-composed-ref/dist/use-composed-ref.esm.js",
+ "line": " var prevUserRef = useRef();",
+ "reasonCategory": "usageTrusted",
+ "updated": "2022-06-16T23:23:32.306Z"
+ },
+ {
+ "rule": "React-useRef",
+ "path": "node_modules/use-latest/dist/use-latest.cjs.dev.js",
+ "line": " var ref = React__namespace.useRef(value);",
+ "reasonCategory": "usageTrusted",
+ "updated": "2022-06-16T23:23:32.306Z"
+ },
+ {
+ "rule": "React-useRef",
+ "path": "node_modules/use-latest/dist/use-latest.cjs.prod.js",
+ "line": " var ref = React__namespace.useRef(value);",
+ "reasonCategory": "usageTrusted",
+ "updated": "2022-06-16T23:23:32.306Z"
+ },
+ {
+ "rule": "React-useRef",
+ "path": "node_modules/use-latest/dist/use-latest.esm.js",
+ "line": " var ref = React.useRef(value);",
+ "reasonCategory": "usageTrusted",
+ "updated": "2022-06-16T23:23:32.306Z"
+ },
{
"rule": "eval",
"path": "node_modules/vm2/lib/nodevm.js",
@@ -8751,6 +8905,13 @@
"reasonCategory": "usageTrusted",
"updated": "2021-11-30T10:15:33.662Z"
},
+ {
+ "rule": "React-useRef",
+ "path": "ts/components/StoryCreator.tsx",
+ "line": " const textEditorRef = useRef(null);",
+ "reasonCategory": "usageTrusted",
+ "updated": "2022-06-16T23:23:32.306Z"
+ },
{
"rule": "React-useRef",
"path": "ts/components/StoryImage.tsx",
@@ -8779,6 +8940,13 @@
"reasonCategory": "usageTrusted",
"updated": "2022-04-06T00:59:17.194Z"
},
+ {
+ "rule": "React-useRef",
+ "path": "ts/components/TextAttachment.tsx",
+ "line": " const textEditorRef = useRef(null);",
+ "reasonCategory": "usageTrusted",
+ "updated": "2022-06-16T23:23:32.306Z"
+ },
{
"rule": "React-useRef",
"path": "ts/components/Tooltip.tsx",
diff --git a/ts/util/objectMap.ts b/ts/util/objectMap.ts
new file mode 100644
index 0000000000..66a82d1e81
--- /dev/null
+++ b/ts/util/objectMap.ts
@@ -0,0 +1,10 @@
+// Copyright 2022 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+export function objectMap(
+ obj: Record,
+ f: (key: keyof typeof obj, value: typeof obj[keyof typeof obj]) => unknown
+): Array {
+ const keys: Array = Object.keys(obj);
+ return keys.map(key => f(key, obj[key]));
+}
diff --git a/ts/views/conversation_view.tsx b/ts/views/conversation_view.tsx
index bc963ba796..33b47d03a4 100644
--- a/ts/views/conversation_view.tsx
+++ b/ts/views/conversation_view.tsx
@@ -6,18 +6,15 @@
import type * as Backbone from 'backbone';
import type { ComponentProps } from 'react';
import * as React from 'react';
-import { debounce, flatten, omit, throttle } from 'lodash';
+import { debounce, flatten, throttle } from 'lodash';
import { render } from 'mustache';
import type { AttachmentType } from '../types/Attachment';
import { isGIF } from '../types/Attachment';
import * as Attachment from '../types/Attachment';
-import type { StickerPackType as StickerPackDBType } from '../sql/Interface';
import * as Stickers from '../types/Stickers';
import type { BodyRangeType, BodyRangesType } from '../types/Util';
import type { MIMEType } from '../types/MIME';
-import { IMAGE_JPEG, IMAGE_WEBP, stringToMIMEType } from '../types/MIME';
-import { sniffImageMimeType } from '../util/sniffImageMimeType';
import type { ConversationModel } from '../models/conversations';
import type {
GroupV2PendingMemberType,
@@ -31,7 +28,6 @@ import type { MessageModel } from '../models/messages';
import { getMessageById } from '../messages/getMessageById';
import { getContactId } from '../messages/helpers';
import { strictAssert } from '../util/assert';
-import { maybeParseUrl } from '../util/url';
import { enqueueReactionForSend } from '../reactions/enqueueReactionForSend';
import { addReportSpamJob } from '../jobs/helpers/addReportSpamJob';
import { reportSpamJobQueue } from '../jobs/reportSpamJobQueue';
@@ -42,7 +38,6 @@ import {
isGroupV1,
} from '../util/whatTypeOfConversation';
import { findAndFormatContact } from '../util/findAndFormatContact';
-import * as Bytes from '../Bytes';
import { getPreferredBadgeSelector } from '../state/selectors/badges';
import {
canReply,
@@ -61,13 +56,6 @@ import { ReactWrapperView } from './ReactWrapperView';
import type { Lightbox } from '../components/Lightbox';
import { ConversationDetailsMembershipList } from '../components/conversation/conversation-details/ConversationDetailsMembershipList';
import { showSafetyNumberChangeDialog } from '../shims/showSafetyNumberChangeDialog';
-import type {
- LinkPreviewResult,
- LinkPreviewImage,
- LinkPreviewWithDomain,
-} from '../types/LinkPreview';
-import * as LinkPreview from '../types/LinkPreview';
-import * as VisualAttachment from '../types/VisualAttachment';
import * as log from '../logging/log';
import type { EmbeddedContactType } from '../types/EmbeddedContact';
import { createConversationView } from '../state/roots/createConversationView';
@@ -100,13 +88,10 @@ import { ToastTapToViewExpiredIncoming } from '../components/ToastTapToViewExpir
import { ToastTapToViewExpiredOutgoing } from '../components/ToastTapToViewExpiredOutgoing';
import { ToastUnableToLoadAttachment } from '../components/ToastUnableToLoadAttachment';
import { ToastCannotOpenGiftBadge } from '../components/ToastCannotOpenGiftBadge';
-import { autoScale } from '../util/handleImageAttachment';
import { deleteDraftAttachment } from '../util/deleteDraftAttachment';
import { markAllAsApproved } from '../util/markAllAsApproved';
import { markAllAsVerifiedDefault } from '../util/markAllAsVerifiedDefault';
import { retryMessageSend } from '../util/retryMessageSend';
-import { dropNull } from '../util/dropNull';
-import { fileToBytes } from '../util/fileToBytes';
import { isNotNil } from '../util/isNotNil';
import { markViewed } from '../services/MessageUpdater';
import { openLinkInWebBrowser } from '../util/openLinkInWebBrowser';
@@ -121,6 +106,15 @@ import { retryDeleteForEveryone } from '../util/retryDeleteForEveryone';
import { ContactDetail } from '../components/conversation/ContactDetail';
import { MediaGallery } from '../components/conversation/media-gallery/MediaGallery';
import type { ItemClickEvent } from '../components/conversation/media-gallery/types/ItemClickEvent';
+import {
+ getLinkPreviewForSend,
+ hasLinkPreviewLoaded,
+ maybeGrabLinkPreview,
+ removeLinkPreview,
+ resetLinkPreview,
+ suspendLinkPreviews,
+} from '../services/LinkPreview';
+import { LinkPreviewSourceType } from '../types/LinkPreview';
import {
closeLightbox,
isLightboxOpen,
@@ -135,7 +129,6 @@ type AttachmentOptions = {
type PanelType = { view: Backbone.View; headerTitle?: string };
const FIVE_MINUTES = 1000 * 60 * 5;
-const LINK_PREVIEW_TIMEOUT = 60 * 1000;
const { Message } = window.Signal.Types;
@@ -223,11 +216,6 @@ type MediaType = {
const MAX_MESSAGE_BODY_LENGTH = 64 * 1024;
export class ConversationView extends window.Backbone.View {
- // Debounced functions
- private debouncedMaybeGrabLinkPreview: (
- message: string,
- caretLocation?: number
- ) => void;
private debouncedSaveDraft: (
messageText: string,
bodyRanges: Array
@@ -244,13 +232,6 @@ export class ConversationView extends window.Backbone.View {
private quote?: QuotedMessageType;
private quotedMessage?: MessageModel;
- // Previews
- private currentlyMatchedLink?: string;
- private disableLinkPreviews?: boolean;
- private excludedPreviewUrls: Array = [];
- private linkPreviewAbortController?: AbortController;
- private preview?: Array;
-
// Sub-views
private contactModalView?: Backbone.View;
private conversationView?: Backbone.View;
@@ -275,10 +256,6 @@ export class ConversationView extends window.Backbone.View {
this.model.throttledGetProfiles ||
throttle(this.model.getProfiles.bind(this.model), FIVE_MINUTES);
- this.debouncedMaybeGrabLinkPreview = debounce(
- this.maybeGrabLinkPreview.bind(this),
- 200
- );
this.debouncedSaveDraft = debounce(this.saveDraft.bind(this), 200);
// Events on Conversation model
@@ -312,7 +289,7 @@ export class ConversationView extends window.Backbone.View {
this.downloadAttachmentWrapper
);
this.listenTo(this.model, 'delete-message', this.deleteMessage);
- this.listenTo(this.model, 'remove-link-review', this.removeLinkPreview);
+ this.listenTo(this.model, 'remove-link-review', removeLinkPreview);
this.listenTo(
this.model,
'remove-all-draft-attachments',
@@ -647,8 +624,8 @@ export class ConversationView extends window.Backbone.View {
handleClickQuotedMessage: (id: string) => this.scrollToMessage(id),
onCloseLinkPreview: () => {
- this.disableLinkPreviews = true;
- this.removeLinkPreview();
+ suspendLinkPreviews();
+ removeLinkPreview();
},
openConversation: this.openConversation.bind(this),
@@ -1017,7 +994,7 @@ export class ConversationView extends window.Backbone.View {
const isRecording =
state.audioRecorder.recordingState === RecordingState.Recording;
- if (this.preview || isRecording) {
+ if (hasLinkPreviewLoaded() || isRecording) {
return;
}
@@ -1117,8 +1094,8 @@ export class ConversationView extends window.Backbone.View {
window.reduxActions.conversations.setSelectedConversationPanelDepth(0);
}
- this.removeLinkPreview();
- this.disableLinkPreviews = true;
+ removeLinkPreview();
+ suspendLinkPreviews();
this.remove();
}
@@ -1245,7 +1222,7 @@ export class ConversationView extends window.Backbone.View {
draftAttachments
);
if (this.hasFiles({ includePending: true })) {
- this.removeLinkPreview();
+ removeLinkPreview();
}
}
@@ -1354,7 +1331,7 @@ export class ConversationView extends window.Backbone.View {
this.forwardMessageModal.remove();
this.forwardMessageModal = undefined;
}
- this.resetLinkPreview();
+ resetLinkPreview();
},
onEditorStateChange: (
messageText: string,
@@ -1362,7 +1339,11 @@ export class ConversationView extends window.Backbone.View {
caretLocation?: number
) => {
if (!attachments.length) {
- this.debouncedMaybeGrabLinkPreview(messageText, caretLocation);
+ maybeGrabLinkPreview(
+ messageText,
+ LinkPreviewSourceType.ForwardMessageModal,
+ caretLocation
+ );
}
},
onTextTooLong: () => showToast(ToastMessageBodyTooLong),
@@ -1531,7 +1512,7 @@ export class ConversationView extends window.Backbone.View {
);
// Cancel any link still pending, even if it didn't make it into the message
- this.resetLinkPreview();
+ resetLinkPreview();
return true;
}
@@ -2920,7 +2901,7 @@ export class ConversationView extends window.Backbone.View {
body: message,
attachments,
quote: this.quote,
- preview: this.getLinkPreviewForSend(message),
+ preview: getLinkPreviewForSend(message),
mentions,
},
{
@@ -2930,7 +2911,7 @@ export class ConversationView extends window.Backbone.View {
this.compositionApi.current?.reset();
model.setMarkedUnread(false);
this.setQuoteMessage(null);
- this.resetLinkPreview();
+ resetLinkPreview();
this.clearAttachments();
window.reduxActions.composer.resetComposer();
},
@@ -2953,7 +2934,15 @@ export class ConversationView extends window.Backbone.View {
): void {
this.maybeBumpTyping(messageText);
this.debouncedSaveDraft(messageText, bodyRanges);
- this.debouncedMaybeGrabLinkPreview(messageText, caretLocation);
+
+ // If we have attachments, don't add link preview
+ if (!this.hasFiles({ includePending: true })) {
+ maybeGrabLinkPreview(
+ messageText,
+ LinkPreviewSourceType.Composer,
+ caretLocation
+ );
+ }
}
async saveDraft(
@@ -2997,511 +2986,6 @@ export class ConversationView extends window.Backbone.View {
}
}
- maybeGrabLinkPreview(message: string, caretLocation?: number): void {
- // Don't generate link previews if user has turned them off
- if (!window.Events.getLinkPreviewSetting()) {
- return;
- }
- // Do nothing if we're offline
- if (!window.textsecure.messaging) {
- return;
- }
- // If we have attachments, don't add link preview
- if (this.hasFiles({ includePending: true })) {
- return;
- }
- // If we're behind a user-configured proxy, we don't support link previews
- if (window.isBehindProxy()) {
- return;
- }
-
- if (!message) {
- this.resetLinkPreview();
- return;
- }
- if (this.disableLinkPreviews) {
- return;
- }
-
- const links = LinkPreview.findLinks(message, caretLocation);
- const { currentlyMatchedLink } = this;
- if (currentlyMatchedLink && links.includes(currentlyMatchedLink)) {
- return;
- }
-
- this.currentlyMatchedLink = undefined;
- this.excludedPreviewUrls = this.excludedPreviewUrls || [];
-
- const link = links.find(
- item =>
- LinkPreview.shouldPreviewHref(item) &&
- !this.excludedPreviewUrls.includes(item)
- );
- if (!link) {
- this.removeLinkPreview();
- return;
- }
-
- this.addLinkPreview(link);
- }
-
- resetLinkPreview(): void {
- this.disableLinkPreviews = false;
- this.excludedPreviewUrls = [];
- this.removeLinkPreview();
- }
-
- removeLinkPreview(): void {
- (this.preview || []).forEach((item: LinkPreviewResult) => {
- if (item.url) {
- URL.revokeObjectURL(item.url);
- }
- });
- this.preview = undefined;
- this.currentlyMatchedLink = undefined;
- this.linkPreviewAbortController?.abort();
- this.linkPreviewAbortController = undefined;
-
- window.reduxActions.linkPreviews.removeLinkPreview();
- }
-
- async getStickerPackPreview(
- url: string,
- abortSignal: Readonly
- ): Promise {
- const isPackDownloaded = (
- pack?: StickerPackDBType
- ): pack is StickerPackDBType => {
- if (!pack) {
- return false;
- }
-
- return pack.status === 'downloaded' || pack.status === 'installed';
- };
- const isPackValid = (
- pack?: StickerPackDBType
- ): pack is StickerPackDBType => {
- if (!pack) {
- return false;
- }
- return (
- pack.status === 'ephemeral' ||
- pack.status === 'downloaded' ||
- pack.status === 'installed'
- );
- };
-
- const dataFromLink = Stickers.getDataFromLink(url);
- if (!dataFromLink) {
- return null;
- }
- const { id, key } = dataFromLink;
-
- try {
- const keyBytes = Bytes.fromHex(key);
- const keyBase64 = Bytes.toBase64(keyBytes);
-
- const existing = Stickers.getStickerPack(id);
- if (!isPackDownloaded(existing)) {
- await Stickers.downloadEphemeralPack(id, keyBase64);
- }
-
- if (abortSignal.aborted) {
- return null;
- }
-
- const pack = Stickers.getStickerPack(id);
-
- if (!isPackValid(pack)) {
- return null;
- }
- if (pack.key !== keyBase64) {
- return null;
- }
-
- const { title, coverStickerId } = pack;
- const sticker = pack.stickers[coverStickerId];
- const data =
- pack.status === 'ephemeral'
- ? await window.Signal.Migrations.readTempData(sticker.path)
- : await window.Signal.Migrations.readStickerData(sticker.path);
-
- if (abortSignal.aborted) {
- return null;
- }
-
- let contentType: MIMEType;
- const sniffedMimeType = sniffImageMimeType(data);
- if (sniffedMimeType) {
- contentType = sniffedMimeType;
- } else {
- log.warn(
- 'getStickerPackPreview: Unable to sniff sticker MIME type; falling back to WebP'
- );
- contentType = IMAGE_WEBP;
- }
-
- return {
- date: null,
- description: null,
- image: {
- ...sticker,
- data,
- size: data.byteLength,
- contentType,
- },
- title,
- url,
- };
- } catch (error) {
- log.error(
- 'getStickerPackPreview error:',
- error && error.stack ? error.stack : error
- );
- return null;
- } finally {
- if (id) {
- await Stickers.removeEphemeralPack(id);
- }
- }
- }
-
- async getGroupPreview(
- url: string,
- abortSignal: Readonly
- ): Promise {
- const urlObject = maybeParseUrl(url);
- if (!urlObject) {
- return null;
- }
-
- const { hash } = urlObject;
- if (!hash) {
- return null;
- }
- const groupData = hash.slice(1);
-
- const { inviteLinkPassword, masterKey } =
- window.Signal.Groups.parseGroupLink(groupData);
-
- const fields = window.Signal.Groups.deriveGroupFields(
- Bytes.fromBase64(masterKey)
- );
- const id = Bytes.toBase64(fields.id);
- const logId = `groupv2(${id})`;
- const secretParams = Bytes.toBase64(fields.secretParams);
-
- log.info(`getGroupPreview/${logId}: Fetching pre-join state`);
- const result = await window.Signal.Groups.getPreJoinGroupInfo(
- inviteLinkPassword,
- masterKey
- );
-
- if (abortSignal.aborted) {
- return null;
- }
-
- const title =
- window.Signal.Groups.decryptGroupTitle(result.title, secretParams) ||
- window.i18n('unknownGroup');
- const description =
- result.memberCount === 1 || result.memberCount === undefined
- ? window.i18n('GroupV2--join--member-count--single')
- : window.i18n('GroupV2--join--member-count--multiple', {
- count: result.memberCount.toString(),
- });
- let image: undefined | LinkPreviewImage;
-
- if (result.avatar) {
- try {
- const data = await window.Signal.Groups.decryptGroupAvatar(
- result.avatar,
- secretParams
- );
- image = {
- data,
- size: data.byteLength,
- contentType: IMAGE_JPEG,
- blurHash: await window.imageToBlurHash(
- new Blob([data], {
- type: IMAGE_JPEG,
- })
- ),
- };
- } catch (error) {
- const errorString = error && error.stack ? error.stack : error;
- log.error(
- `getGroupPreview/${logId}: Failed to fetch avatar ${errorString}`
- );
- }
- }
-
- if (abortSignal.aborted) {
- return null;
- }
-
- return {
- date: null,
- description,
- image,
- title,
- url,
- };
- }
-
- async getPreview(
- url: string,
- abortSignal: Readonly
- ): Promise {
- if (LinkPreview.isStickerPack(url)) {
- return this.getStickerPackPreview(url, abortSignal);
- }
- if (LinkPreview.isGroupLink(url)) {
- return this.getGroupPreview(url, abortSignal);
- }
-
- const { messaging } = window.textsecure;
- if (!messaging) {
- throw new Error('messaging is not available!');
- }
-
- // This is already checked elsewhere, but we want to be extra-careful.
- if (!LinkPreview.shouldPreviewHref(url)) {
- return null;
- }
-
- const linkPreviewMetadata = await messaging.fetchLinkPreviewMetadata(
- url,
- abortSignal
- );
- if (!linkPreviewMetadata || abortSignal.aborted) {
- return null;
- }
- const { title, imageHref, description, date } = linkPreviewMetadata;
-
- let image;
- if (imageHref && LinkPreview.shouldPreviewHref(imageHref)) {
- let objectUrl: void | string;
- try {
- const fullSizeImage = await messaging.fetchLinkPreviewImage(
- imageHref,
- abortSignal
- );
- if (abortSignal.aborted) {
- return null;
- }
- 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 autoScale({
- contentType: fullSizeImage.contentType,
- file: new Blob([fullSizeImage.data], {
- type: fullSizeImage.contentType,
- }),
- fileName: title,
- });
-
- const data = await fileToBytes(withBlob.file);
- objectUrl = URL.createObjectURL(withBlob.file);
-
- const blurHash = await window.imageToBlurHash(withBlob.file);
-
- const dimensions = await VisualAttachment.getImageDimensions({
- objectUrl,
- logger: log,
- });
-
- image = {
- data,
- size: data.byteLength,
- ...dimensions,
- contentType: stringToMIMEType(withBlob.file.type),
- blurHash,
- };
- } catch (error) {
- // We still want to show the preview if we failed to get an image
- log.error(
- 'getPreview failed to get image for link preview:',
- error.message
- );
- } finally {
- if (objectUrl) {
- URL.revokeObjectURL(objectUrl);
- }
- }
- }
-
- if (abortSignal.aborted) {
- return null;
- }
-
- return {
- date: date || null,
- description: description || null,
- image,
- title,
- url,
- };
- }
-
- async addLinkPreview(url: string): Promise {
- if (this.currentlyMatchedLink === url) {
- log.warn(
- 'addLinkPreview should not be called with the same URL like this'
- );
- return;
- }
-
- (this.preview || []).forEach((item: LinkPreviewResult) => {
- if (item.url) {
- URL.revokeObjectURL(item.url);
- }
- });
- window.reduxActions.linkPreviews.removeLinkPreview();
- this.preview = undefined;
-
- // Cancel other in-flight link preview requests.
- if (this.linkPreviewAbortController) {
- log.info(
- 'addLinkPreview: canceling another in-flight link preview request'
- );
- this.linkPreviewAbortController.abort();
- }
-
- const thisRequestAbortController = new AbortController();
- this.linkPreviewAbortController = thisRequestAbortController;
-
- const timeout = setTimeout(() => {
- thisRequestAbortController.abort();
- }, LINK_PREVIEW_TIMEOUT);
-
- this.currentlyMatchedLink = url;
- this.renderLinkPreview();
-
- try {
- const result = await this.getPreview(
- url,
- thisRequestAbortController.signal
- );
-
- if (!result) {
- log.info(
- 'addLinkPreview: failed to load preview (not necessarily a problem)'
- );
-
- // This helps us disambiguate between two kinds of failure:
- //
- // 1. We failed to fetch the preview because of (1) a network failure (2) an
- // invalid response (3) a timeout
- // 2. We failed to fetch the preview because we aborted the request because the
- // user changed the link (e.g., by continuing to type the URL)
- const failedToFetch = this.currentlyMatchedLink === url;
- if (failedToFetch) {
- this.excludedPreviewUrls.push(url);
- this.removeLinkPreview();
- }
- return;
- }
-
- if (result.image && result.image.data) {
- 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;
- }
-
- window.reduxActions.linkPreviews.addLinkPreview({
- ...result,
- description: dropNull(result.description),
- date: dropNull(result.date),
- domain: LinkPreview.getDomain(result.url),
- isStickerPack: LinkPreview.isStickerPack(result.url),
- });
- this.preview = [result];
- this.renderLinkPreview();
- } catch (error) {
- log.error(
- 'Problem loading link preview, disabling.',
- error && error.stack ? error.stack : error
- );
- this.disableLinkPreviews = true;
- this.removeLinkPreview();
- } finally {
- clearTimeout(timeout);
- }
- }
-
- renderLinkPreview(): void {
- if (this.forwardMessageModal) {
- return;
- }
- window.reduxActions.composer.setLinkPreviewResult(
- Boolean(this.currentlyMatchedLink),
- this.getLinkPreviewWithDomain()
- );
- }
-
- getLinkPreviewForSend(message: string): Array {
- // Don't generate link previews if user has turned them off
- if (!window.storage.get('linkPreviews', false)) {
- return [];
- }
-
- if (!this.preview) {
- return [];
- }
-
- const urlsInMessage = new Set(LinkPreview.findLinks(message));
-
- return (
- this.preview
- // This bullet-proofs against sending link previews for URLs that are no longer in
- // the message. This can happen if you have a link preview, then quickly delete
- // the link and send the message.
- .filter(({ url }: Readonly<{ url: string }>) => urlsInMessage.has(url))
- .map((item: LinkPreviewResult) => {
- if (item.image) {
- // We eliminate the ObjectURL here, unneeded for send or save
- return {
- ...item,
- image: omit(item.image, 'url'),
- description: dropNull(item.description),
- date: dropNull(item.date),
- domain: LinkPreview.getDomain(item.url),
- isStickerPack: LinkPreview.isStickerPack(item.url),
- };
- }
-
- return {
- ...item,
- description: dropNull(item.description),
- date: dropNull(item.date),
- domain: LinkPreview.getDomain(item.url),
- isStickerPack: LinkPreview.isStickerPack(item.url),
- };
- })
- );
- }
-
- getLinkPreviewWithDomain(): LinkPreviewWithDomain | undefined {
- if (!this.preview || !this.preview.length) {
- return undefined;
- }
-
- const [preview] = this.preview;
- return {
- ...preview,
- domain: LinkPreview.getDomain(preview.url),
- };
- }
-
// Called whenever the user changes the message composition field. But only
// fires if there's content in the message field after the change.
maybeBumpTyping(messageText: string): void {
diff --git a/yarn.lock b/yarn.lock
index 6cdc6ed843..a0bf882d46 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -13528,6 +13528,15 @@ react-syntax-highlighter@^15.4.5:
prismjs "^1.27.0"
refractor "^3.6.0"
+react-textarea-autosize@8.3.4:
+ version "8.3.4"
+ resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-8.3.4.tgz#270a343de7ad350534141b02c9cb78903e553524"
+ integrity sha512-CdtmP8Dc19xL8/R6sWvtknD/eCXkQr30dtvC4VmGInhRsfF8X/ihXCq6+9l9qbxmKRiq407/7z5fxE7cVWQNgQ==
+ dependencies:
+ "@babel/runtime" "^7.10.2"
+ use-composed-ref "^1.3.0"
+ use-latest "^1.2.1"
+
react-transition-group@^4.3.0:
version "4.4.2"
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.2.tgz#8b59a56f09ced7b55cbd53c36768b922890d5470"
@@ -16135,6 +16144,23 @@ url@^0.11.0:
punycode "1.3.2"
querystring "0.2.0"
+use-composed-ref@^1.3.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/use-composed-ref/-/use-composed-ref-1.3.0.tgz#3d8104db34b7b264030a9d916c5e94fbe280dbda"
+ integrity sha512-GLMG0Jc/jiKov/3Ulid1wbv3r54K9HlMW29IWcDFPEqFkSO2nS0MuefWgMJpeHQ9YJeXDL3ZUF+P3jdXlZX/cQ==
+
+use-isomorphic-layout-effect@^1.1.1:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz#497cefb13d863d687b08477d9e5a164ad8c1a6fb"
+ integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==
+
+use-latest@^1.2.1:
+ version "1.2.1"
+ resolved "https://registry.yarnpkg.com/use-latest/-/use-latest-1.2.1.tgz#d13dfb4b08c28e3e33991546a2cee53e14038cf2"
+ integrity sha512-xA+AVm/Wlg3e2P/JiItTziwS7FK92LWrDB0p+hgXloIMuVCeJJ8v6f0eeHyPZaJrM+usM1FkFfbNCrJGs8A/zw==
+ dependencies:
+ use-isomorphic-layout-effect "^1.1.1"
+
use@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/use/-/use-3.1.0.tgz#14716bf03fdfefd03040aef58d8b4b85f3a7c544"