Create text stories
This commit is contained in:
parent
973b2264fe
commit
d970d427f8
53 changed files with 2433 additions and 1106 deletions
|
@ -41,7 +41,7 @@ import { AudioCapture } from './conversation/AudioCapture';
|
|||
import { CompositionUpload } from './CompositionUpload';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
import type { EmojiPickDataType } from './emoji/EmojiPicker';
|
||||
import type { LinkPreviewWithDomain } from '../types/LinkPreview';
|
||||
import type { LinkPreviewType } from '../types/message/LinkPreviews';
|
||||
|
||||
import { MandatoryProfileSharingActions } from './conversation/MandatoryProfileSharingActions';
|
||||
import { MediaQualitySelector } from './MediaQualitySelector';
|
||||
|
@ -102,7 +102,7 @@ export type OwnProps = Readonly<{
|
|||
isSMSOnly?: boolean;
|
||||
left?: boolean;
|
||||
linkPreviewLoading: boolean;
|
||||
linkPreviewResult?: LinkPreviewWithDomain;
|
||||
linkPreviewResult?: LinkPreviewType;
|
||||
messageRequestsEnabled?: boolean;
|
||||
onClearAttachments(): unknown;
|
||||
onClickQuotedMessage(): unknown;
|
||||
|
@ -631,10 +631,10 @@ export const CompositionArea = ({
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
{linkPreviewLoading && (
|
||||
{linkPreviewLoading && linkPreviewResult && (
|
||||
<div className="preview-wrapper">
|
||||
<StagedLinkPreview
|
||||
{...(linkPreviewResult || {})}
|
||||
{...linkPreviewResult}
|
||||
i18n={i18n}
|
||||
onClose={onCloseLinkPreview}
|
||||
/>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright 2018-2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import type { CSSProperties, KeyboardEvent } from 'react';
|
||||
import type { Options } from '@popperjs/core';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
@ -35,6 +35,7 @@ export type ContextMenuPropsType<T> = {
|
|||
|
||||
export type PropsType<T> = {
|
||||
readonly buttonClassName?: string;
|
||||
readonly buttonStyle?: CSSProperties;
|
||||
readonly i18n: LocalizerType;
|
||||
} & Pick<
|
||||
ContextMenuPropsType<T>,
|
||||
|
@ -139,6 +140,7 @@ export function ContextMenuPopper<T>({
|
|||
|
||||
export function ContextMenu<T>({
|
||||
buttonClassName,
|
||||
buttonStyle,
|
||||
i18n,
|
||||
menuOptions,
|
||||
popperOptions,
|
||||
|
@ -208,24 +210,27 @@ export function ContextMenu<T>({
|
|||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
ref={setReferenceElement}
|
||||
style={buttonStyle}
|
||||
type="button"
|
||||
/>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
allowOutsideClick: true,
|
||||
}}
|
||||
>
|
||||
<ContextMenuPopper
|
||||
focusedIndex={focusedIndex}
|
||||
isMenuShowing={menuShowing}
|
||||
menuOptions={menuOptions}
|
||||
onClose={() => setMenuShowing(false)}
|
||||
popperOptions={popperOptions}
|
||||
referenceElement={referenceElement}
|
||||
title={title}
|
||||
value={value}
|
||||
/>
|
||||
</FocusTrap>
|
||||
{menuShowing && (
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
allowOutsideClick: true,
|
||||
}}
|
||||
>
|
||||
<ContextMenuPopper
|
||||
focusedIndex={focusedIndex}
|
||||
isMenuShowing={menuShowing}
|
||||
menuOptions={menuOptions}
|
||||
onClose={() => setMenuShowing(false)}
|
||||
popperOptions={popperOptions}
|
||||
referenceElement={referenceElement}
|
||||
title={title}
|
||||
value={value}
|
||||
/>
|
||||
</FocusTrap>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -322,13 +322,14 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
|
|||
{linkPreview ? (
|
||||
<div className="module-ForwardMessageModal--link-preview">
|
||||
<StagedLinkPreview
|
||||
date={linkPreview.date || null}
|
||||
date={linkPreview.date}
|
||||
description={linkPreview.description || ''}
|
||||
domain={linkPreview.url}
|
||||
i18n={i18n}
|
||||
image={linkPreview.image}
|
||||
onClose={() => removeLinkPreview()}
|
||||
title={linkPreview.title}
|
||||
url={linkPreview.url}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
@ -564,7 +564,7 @@ export const MediaEditor = ({
|
|||
<Slider
|
||||
handleStyle={{ backgroundColor: getHSL(sliderValue) }}
|
||||
label={i18n('CustomColorEditor__hue')}
|
||||
moduleClassName="MediaEditor__hue-slider MediaEditor__tools__tool"
|
||||
moduleClassName="HueSlider MediaEditor__tools__tool"
|
||||
onChange={setSliderValue}
|
||||
value={sliderValue}
|
||||
/>
|
||||
|
@ -623,7 +623,7 @@ export const MediaEditor = ({
|
|||
<Slider
|
||||
handleStyle={{ backgroundColor: getHSL(sliderValue) }}
|
||||
label={i18n('CustomColorEditor__hue')}
|
||||
moduleClassName="MediaEditor__tools__tool MediaEditor__hue-slider"
|
||||
moduleClassName="HueSlider MediaEditor__tools__tool"
|
||||
onChange={setSliderValue}
|
||||
value={sliderValue}
|
||||
/>
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { Meta, Story } from '@storybook/react';
|
||||
import React from 'react';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
@ -22,7 +23,8 @@ const i18n = setupI18n('en', enMessages);
|
|||
|
||||
export default {
|
||||
title: 'Components/Stories',
|
||||
};
|
||||
component: Stories,
|
||||
} as Meta;
|
||||
|
||||
function createStory({
|
||||
attachment,
|
||||
|
@ -83,6 +85,7 @@ const getDefaultProps = (): PropsType => ({
|
|||
i18n,
|
||||
preferredWidthFromStorage: 380,
|
||||
queueStoryDownload: action('queueStoryDownload'),
|
||||
renderStoryCreator: () => <div />,
|
||||
renderStoryViewer: () => <div />,
|
||||
showConversation: action('showConversation'),
|
||||
stories: [
|
||||
|
@ -127,7 +130,13 @@ const getDefaultProps = (): PropsType => ({
|
|||
toggleStoriesView: action('toggleStoriesView'),
|
||||
});
|
||||
|
||||
export const Blank = (): JSX.Element => (
|
||||
<Stories {...getDefaultProps()} stories={[]} />
|
||||
);
|
||||
export const Many = (): JSX.Element => <Stories {...getDefaultProps()} />;
|
||||
const Template: Story<PropsType> = args => <Stories {...args} />;
|
||||
|
||||
export const Blank = Template.bind({});
|
||||
Blank.args = {
|
||||
...getDefaultProps(),
|
||||
stories: [],
|
||||
};
|
||||
|
||||
export const Many = Template.bind({});
|
||||
Many.args = getDefaultProps();
|
||||
|
|
|
@ -6,6 +6,7 @@ import React, { useCallback, useState } from 'react';
|
|||
import classNames from 'classnames';
|
||||
import type { ConversationStoryType } from './StoryListItem';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import type { PropsType as SmartStoryCreatorPropsType } from '../state/smart/StoryCreator';
|
||||
import type { PropsType as SmartStoryViewerPropsType } from '../state/smart/StoryViewer';
|
||||
import type { ShowConversationType } from '../state/ducks/conversations';
|
||||
import { StoriesPane } from './StoriesPane';
|
||||
|
@ -18,6 +19,7 @@ export type PropsType = {
|
|||
i18n: LocalizerType;
|
||||
preferredWidthFromStorage: number;
|
||||
queueStoryDownload: (storyId: string) => unknown;
|
||||
renderStoryCreator: (props: SmartStoryCreatorPropsType) => JSX.Element;
|
||||
renderStoryViewer: (props: SmartStoryViewerPropsType) => JSX.Element;
|
||||
showConversation: ShowConversationType;
|
||||
stories: Array<ConversationStoryType>;
|
||||
|
@ -30,6 +32,7 @@ export const Stories = ({
|
|||
i18n,
|
||||
preferredWidthFromStorage,
|
||||
queueStoryDownload,
|
||||
renderStoryCreator,
|
||||
renderStoryViewer,
|
||||
showConversation,
|
||||
stories,
|
||||
|
@ -96,8 +99,14 @@ export const Stories = ({
|
|||
setConversationIdToView(prevStory.conversationId);
|
||||
}, [conversationIdToView, stories]);
|
||||
|
||||
const [isShowingStoryCreator, setIsShowingStoryCreator] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={classNames('Stories', themeClassName(Theme.Dark))}>
|
||||
{isShowingStoryCreator &&
|
||||
renderStoryCreator({
|
||||
onClose: () => setIsShowingStoryCreator(false),
|
||||
})}
|
||||
{conversationIdToView &&
|
||||
renderStoryViewer({
|
||||
conversationId: conversationIdToView,
|
||||
|
@ -110,6 +119,7 @@ export const Stories = ({
|
|||
<StoriesPane
|
||||
hiddenStories={hiddenStories}
|
||||
i18n={i18n}
|
||||
onAddStory={() => setIsShowingStoryCreator(true)}
|
||||
onStoryClicked={clickedIdToView => {
|
||||
const storyIndex = stories.findIndex(
|
||||
x => x.conversationId === clickedIdToView
|
||||
|
|
|
@ -4,12 +4,13 @@
|
|||
import Fuse from 'fuse.js';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { isNotNil } from '../util/isNotNil';
|
||||
|
||||
import type { ConversationStoryType, StoryViewType } from './StoryListItem';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import type { ShowConversationType } from '../state/ducks/conversations';
|
||||
import { SearchInput } from './SearchInput';
|
||||
import { StoryListItem } from './StoryListItem';
|
||||
import { isNotNil } from '../util/isNotNil';
|
||||
|
||||
const FUSE_OPTIONS: Fuse.IFuseOptions<ConversationStoryType> = {
|
||||
getFn: (obj, path) => {
|
||||
|
@ -53,6 +54,7 @@ function getNewestStory(story: ConversationStoryType): StoryViewType {
|
|||
export type PropsType = {
|
||||
hiddenStories: Array<ConversationStoryType>;
|
||||
i18n: LocalizerType;
|
||||
onAddStory: () => unknown;
|
||||
onStoryClicked: (conversationId: string) => unknown;
|
||||
queueStoryDownload: (storyId: string) => unknown;
|
||||
showConversation: ShowConversationType;
|
||||
|
@ -64,6 +66,7 @@ export type PropsType = {
|
|||
export const StoriesPane = ({
|
||||
hiddenStories,
|
||||
i18n,
|
||||
onAddStory,
|
||||
onStoryClicked,
|
||||
queueStoryDownload,
|
||||
showConversation,
|
||||
|
@ -97,6 +100,12 @@ export const StoriesPane = ({
|
|||
<div className="Stories__pane__header--title">
|
||||
{i18n('Stories__title')}
|
||||
</div>
|
||||
<button
|
||||
aria-label={i18n('Stories__add')}
|
||||
className="Stories__pane__header--camera"
|
||||
onClick={onAddStory}
|
||||
type="button"
|
||||
/>
|
||||
</div>
|
||||
<SearchInput
|
||||
i18n={i18n}
|
||||
|
|
50
ts/components/StoryCreator.stories.tsx
Normal file
50
ts/components/StoryCreator.stories.tsx
Normal file
|
@ -0,0 +1,50 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { Meta, Story } from '@storybook/react';
|
||||
import React from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
import type { PropsType } from './StoryCreator';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
import { StoryCreator } from './StoryCreator';
|
||||
import { fakeAttachment } from '../test-both/helpers/fakeAttachment';
|
||||
import { setupI18n } from '../util/setupI18n';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
export default {
|
||||
title: 'Components/StoryCreator',
|
||||
component: StoryCreator,
|
||||
} as Meta;
|
||||
|
||||
const getDefaultProps = (): PropsType => ({
|
||||
debouncedMaybeGrabLinkPreview: action('debouncedMaybeGrabLinkPreview'),
|
||||
i18n,
|
||||
onClose: action('onClose'),
|
||||
onNext: action('onNext'),
|
||||
});
|
||||
|
||||
const Template: Story<PropsType> = args => <StoryCreator {...args} />;
|
||||
|
||||
export const Default = Template.bind({});
|
||||
Default.args = getDefaultProps();
|
||||
Default.story = {
|
||||
name: 'w/o Link Preview available',
|
||||
};
|
||||
|
||||
export const LinkPreview = Template.bind({});
|
||||
LinkPreview.args = {
|
||||
...getDefaultProps(),
|
||||
linkPreview: {
|
||||
domain: 'www.catsandkittens.lolcats',
|
||||
image: fakeAttachment({
|
||||
url: '/fixtures/kitten-4-112-112.jpg',
|
||||
}),
|
||||
title: 'Cats & Kittens LOL',
|
||||
url: 'https://www.catsandkittens.lolcats/kittens/page/1',
|
||||
},
|
||||
};
|
||||
LinkPreview.story = {
|
||||
name: 'with Link Preview ready to be applied',
|
||||
};
|
485
ts/components/StoryCreator.tsx
Normal file
485
ts/components/StoryCreator.tsx
Normal file
|
@ -0,0 +1,485 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { get, has } from 'lodash';
|
||||
import { usePopper } from 'react-popper';
|
||||
|
||||
import type { LinkPreviewType } from '../types/message/LinkPreviews';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import type { TextAttachmentType } from '../types/Attachment';
|
||||
|
||||
import { Button, ButtonVariant } from './Button';
|
||||
import { ContextMenu } from './ContextMenu';
|
||||
import { LinkPreviewSourceType, findLinks } from '../types/LinkPreview';
|
||||
import { Input } from './Input';
|
||||
import { Slider } from './Slider';
|
||||
import { StagedLinkPreview } from './conversation/StagedLinkPreview';
|
||||
import { TextAttachment } from './TextAttachment';
|
||||
import { Theme, themeClassName } from '../util/theme';
|
||||
import { getRGBA, getRGBANumber } from '../mediaEditor/util/color';
|
||||
import {
|
||||
COLOR_BLACK_INT,
|
||||
COLOR_WHITE_INT,
|
||||
getBackgroundColor,
|
||||
} from '../util/getStoryBackground';
|
||||
import { objectMap } from '../util/objectMap';
|
||||
|
||||
export type PropsType = {
|
||||
debouncedMaybeGrabLinkPreview: (
|
||||
message: string,
|
||||
source: LinkPreviewSourceType
|
||||
) => unknown;
|
||||
i18n: LocalizerType;
|
||||
linkPreview?: LinkPreviewType;
|
||||
onClose: () => unknown;
|
||||
onNext: () => unknown;
|
||||
};
|
||||
|
||||
enum TextStyle {
|
||||
Default,
|
||||
Regular,
|
||||
Bold,
|
||||
Serif,
|
||||
Script,
|
||||
Condensed,
|
||||
}
|
||||
|
||||
enum TextBackground {
|
||||
None,
|
||||
Background,
|
||||
Inverse,
|
||||
}
|
||||
|
||||
const BackgroundStyle = {
|
||||
BG1099: { angle: 191, endColor: 4282529679, startColor: 4294260804 },
|
||||
BG1098: { startColor: 4293938406, endColor: 4279119837, angle: 192 },
|
||||
BG1031: { startColor: 4294950980, endColor: 4294859832, angle: 175 },
|
||||
BG1101: { startColor: 4278227945, endColor: 4286632135, angle: 180 },
|
||||
BG1100: { startColor: 4284861868, endColor: 4278884698, angle: 180 },
|
||||
BG1070: { color: 4294951251 },
|
||||
BG1080: { color: 4291607859 },
|
||||
BG1079: { color: 4286869806 },
|
||||
BG1083: { color: 4278825851 },
|
||||
BG1095: { color: 4287335417 },
|
||||
BG1088: { color: 4283519478 },
|
||||
BG1077: { color: 4294405742 },
|
||||
BG1094: { color: 4291315265 },
|
||||
BG1097: { color: 4291216549 },
|
||||
BG1074: { color: 4288976277 },
|
||||
BG1092: { color: 4280887593 },
|
||||
};
|
||||
|
||||
type BackgroundStyleType = typeof BackgroundStyle[keyof typeof BackgroundStyle];
|
||||
|
||||
function getBackground(
|
||||
bgStyle: BackgroundStyleType
|
||||
): Pick<TextAttachmentType, 'color' | 'gradient'> {
|
||||
if (has(bgStyle, 'color')) {
|
||||
return { color: get(bgStyle, 'color') };
|
||||
}
|
||||
|
||||
const angle = get(bgStyle, 'angle');
|
||||
const startColor = get(bgStyle, 'startColor');
|
||||
const endColor = get(bgStyle, 'endColor');
|
||||
|
||||
return {
|
||||
gradient: { angle, startColor, endColor },
|
||||
};
|
||||
}
|
||||
|
||||
export const StoryCreator = ({
|
||||
debouncedMaybeGrabLinkPreview,
|
||||
i18n,
|
||||
linkPreview,
|
||||
onClose,
|
||||
onNext,
|
||||
}: PropsType): JSX.Element => {
|
||||
const [isEditingText, setIsEditingText] = useState(false);
|
||||
const [selectedBackground, setSelectedBackground] =
|
||||
useState<BackgroundStyleType>(BackgroundStyle.BG1099);
|
||||
const [textStyle, setTextStyle] = useState<TextStyle>(TextStyle.Regular);
|
||||
const [textBackground, setTextBackground] = useState<TextBackground>(
|
||||
TextBackground.None
|
||||
);
|
||||
const [sliderValue, setSliderValue] = useState<number>(0);
|
||||
const [text, setText] = useState<string>('');
|
||||
|
||||
const textEditorRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditingText) {
|
||||
textEditorRef.current?.focus();
|
||||
} else {
|
||||
textEditorRef.current?.blur();
|
||||
}
|
||||
}, [isEditingText]);
|
||||
|
||||
const [isColorPickerShowing, setIsColorPickerShowing] = useState(false);
|
||||
const [colorPickerPopperButtonRef, setColorPickerPopperButtonRef] =
|
||||
useState<HTMLButtonElement | null>(null);
|
||||
const [colorPickerPopperRef, setColorPickerPopperRef] =
|
||||
useState<HTMLDivElement | null>(null);
|
||||
|
||||
const colorPickerPopper = usePopper(
|
||||
colorPickerPopperButtonRef,
|
||||
colorPickerPopperRef,
|
||||
{
|
||||
modifiers: [
|
||||
{
|
||||
name: 'arrow',
|
||||
},
|
||||
],
|
||||
placement: 'top',
|
||||
strategy: 'fixed',
|
||||
}
|
||||
);
|
||||
|
||||
const [hasLinkPreviewApplied, setHasLinkPreviewApplied] = useState(false);
|
||||
const [linkPreviewInputValue, setLinkPreviewInputValue] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!linkPreviewInputValue) {
|
||||
return;
|
||||
}
|
||||
debouncedMaybeGrabLinkPreview(
|
||||
linkPreviewInputValue,
|
||||
LinkPreviewSourceType.StoryCreator
|
||||
);
|
||||
}, [debouncedMaybeGrabLinkPreview, linkPreviewInputValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
debouncedMaybeGrabLinkPreview(text, LinkPreviewSourceType.StoryCreator);
|
||||
}, [debouncedMaybeGrabLinkPreview, text]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!linkPreview || !text) {
|
||||
return;
|
||||
}
|
||||
|
||||
const links = findLinks(text);
|
||||
|
||||
const shouldApplyLinkPreview = links.includes(linkPreview.url);
|
||||
setHasLinkPreviewApplied(shouldApplyLinkPreview);
|
||||
}, [linkPreview, text]);
|
||||
|
||||
const [isLinkPreviewInputShowing, setIsLinkPreviewInputShowing] =
|
||||
useState(false);
|
||||
const [linkPreviewInputPopperButtonRef, setLinkPreviewInputPopperButtonRef] =
|
||||
useState<HTMLButtonElement | null>(null);
|
||||
const [linkPreviewInputPopperRef, setLinkPreviewInputPopperRef] =
|
||||
useState<HTMLDivElement | null>(null);
|
||||
|
||||
const linkPreviewInputPopper = usePopper(
|
||||
linkPreviewInputPopperButtonRef,
|
||||
linkPreviewInputPopperRef,
|
||||
{
|
||||
modifiers: [
|
||||
{
|
||||
name: 'arrow',
|
||||
},
|
||||
],
|
||||
placement: 'top',
|
||||
strategy: 'fixed',
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOutsideClick = (event: MouseEvent) => {
|
||||
if (!colorPickerPopperButtonRef?.contains(event.target as Node)) {
|
||||
setIsColorPickerShowing(false);
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsColorPickerShowing(false);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', handleOutsideClick);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleOutsideClick);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [isColorPickerShowing, colorPickerPopperButtonRef]);
|
||||
|
||||
const sliderColorNumber = getRGBANumber(sliderValue);
|
||||
|
||||
let textForegroundColor = sliderColorNumber;
|
||||
let textBackgroundColor: number | undefined;
|
||||
|
||||
if (textBackground === TextBackground.Background) {
|
||||
textBackgroundColor = COLOR_WHITE_INT;
|
||||
textForegroundColor =
|
||||
sliderValue >= 95 ? COLOR_BLACK_INT : sliderColorNumber;
|
||||
} else if (textBackground === TextBackground.Inverse) {
|
||||
textBackgroundColor =
|
||||
sliderValue >= 95 ? COLOR_BLACK_INT : sliderColorNumber;
|
||||
textForegroundColor = COLOR_WHITE_INT;
|
||||
}
|
||||
|
||||
return (
|
||||
<FocusTrap focusTrapOptions={{ allowOutsideClick: true }}>
|
||||
<div className="StoryCreator">
|
||||
<div className="StoryCreator__container">
|
||||
<TextAttachment
|
||||
i18n={i18n}
|
||||
isEditingText={isEditingText}
|
||||
onChange={setText}
|
||||
textAttachment={{
|
||||
...getBackground(selectedBackground),
|
||||
text,
|
||||
textStyle,
|
||||
textForegroundColor,
|
||||
textBackgroundColor,
|
||||
preview: hasLinkPreviewApplied ? linkPreview : undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="StoryCreator__toolbar">
|
||||
{isEditingText ? (
|
||||
<div className="StoryCreator__tools">
|
||||
<Slider
|
||||
handleStyle={{ backgroundColor: getRGBA(sliderValue) }}
|
||||
label={i18n('CustomColorEditor__hue')}
|
||||
moduleClassName="HueSlider StoryCreator__tools__tool"
|
||||
onChange={setSliderValue}
|
||||
value={sliderValue}
|
||||
/>
|
||||
<ContextMenu
|
||||
buttonClassName={classNames('StoryCreator__tools__tool', {
|
||||
'StoryCreator__tools__button--font-regular':
|
||||
textStyle === TextStyle.Regular,
|
||||
'StoryCreator__tools__button--font-bold':
|
||||
textStyle === TextStyle.Bold,
|
||||
'StoryCreator__tools__button--font-serif':
|
||||
textStyle === TextStyle.Serif,
|
||||
'StoryCreator__tools__button--font-script':
|
||||
textStyle === TextStyle.Script,
|
||||
'StoryCreator__tools__button--font-condensed':
|
||||
textStyle === TextStyle.Condensed,
|
||||
})}
|
||||
i18n={i18n}
|
||||
menuOptions={[
|
||||
{
|
||||
icon: 'StoryCreator__icon--font-regular',
|
||||
label: i18n('StoryCreator__text--regular'),
|
||||
onClick: () => setTextStyle(TextStyle.Regular),
|
||||
value: TextStyle.Regular,
|
||||
},
|
||||
{
|
||||
icon: 'StoryCreator__icon--font-bold',
|
||||
label: i18n('StoryCreator__text--bold'),
|
||||
onClick: () => setTextStyle(TextStyle.Bold),
|
||||
value: TextStyle.Bold,
|
||||
},
|
||||
{
|
||||
icon: 'StoryCreator__icon--font-serif',
|
||||
label: i18n('StoryCreator__text--serif'),
|
||||
onClick: () => setTextStyle(TextStyle.Serif),
|
||||
value: TextStyle.Serif,
|
||||
},
|
||||
{
|
||||
icon: 'StoryCreator__icon--font-script',
|
||||
label: i18n('StoryCreator__text--script'),
|
||||
onClick: () => setTextStyle(TextStyle.Script),
|
||||
value: TextStyle.Script,
|
||||
},
|
||||
{
|
||||
icon: 'StoryCreator__icon--font-condensed',
|
||||
label: i18n('StoryCreator__text--condensed'),
|
||||
onClick: () => setTextStyle(TextStyle.Condensed),
|
||||
value: TextStyle.Condensed,
|
||||
},
|
||||
]}
|
||||
theme={Theme.Dark}
|
||||
value={textStyle}
|
||||
/>
|
||||
<button
|
||||
aria-label={i18n('StoryCreator__text-bg')}
|
||||
className={classNames('StoryCreator__tools__tool', {
|
||||
'StoryCreator__tools__button--bg-none':
|
||||
textBackground === TextBackground.None,
|
||||
'StoryCreator__tools__button--bg':
|
||||
textBackground === TextBackground.Background,
|
||||
'StoryCreator__tools__button--bg-inverse':
|
||||
textBackground === TextBackground.Inverse,
|
||||
})}
|
||||
onClick={() => {
|
||||
if (textBackground === TextBackground.None) {
|
||||
setTextBackground(TextBackground.Background);
|
||||
} else if (textBackground === TextBackground.Background) {
|
||||
setTextBackground(TextBackground.Inverse);
|
||||
} else {
|
||||
setTextBackground(TextBackground.None);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="StoryCreator__toolbar--space" />
|
||||
)}
|
||||
<div className="StoryCreator__toolbar--buttons">
|
||||
<Button
|
||||
onClick={onClose}
|
||||
theme={Theme.Dark}
|
||||
variant={ButtonVariant.Secondary}
|
||||
>
|
||||
{i18n('discard')}
|
||||
</Button>
|
||||
<div className="StoryCreator__controls">
|
||||
<button
|
||||
aria-label={i18n('StoryCreator__story-bg')}
|
||||
className={classNames({
|
||||
StoryCreator__control: true,
|
||||
'StoryCreator__control--bg': true,
|
||||
'StoryCreator__control--bg--selected': isColorPickerShowing,
|
||||
})}
|
||||
onClick={() => setIsColorPickerShowing(!isColorPickerShowing)}
|
||||
ref={setColorPickerPopperButtonRef}
|
||||
style={{
|
||||
background: getBackgroundColor(
|
||||
getBackground(selectedBackground)
|
||||
),
|
||||
}}
|
||||
type="button"
|
||||
/>
|
||||
{isColorPickerShowing && (
|
||||
<div
|
||||
className="StoryCreator__popper"
|
||||
ref={setColorPickerPopperRef}
|
||||
style={colorPickerPopper.styles.popper}
|
||||
{...colorPickerPopper.attributes.popper}
|
||||
>
|
||||
<div
|
||||
data-popper-arrow
|
||||
className="StoryCreator__popper__arrow"
|
||||
/>
|
||||
{objectMap<BackgroundStyleType>(
|
||||
BackgroundStyle,
|
||||
(bg, backgroundValue) => (
|
||||
<button
|
||||
aria-label={i18n('StoryCreator__story-bg')}
|
||||
className={classNames({
|
||||
StoryCreator__bg: true,
|
||||
'StoryCreator__bg--selected':
|
||||
selectedBackground === backgroundValue,
|
||||
})}
|
||||
key={String(bg)}
|
||||
onClick={() => {
|
||||
setSelectedBackground(backgroundValue);
|
||||
setIsColorPickerShowing(false);
|
||||
}}
|
||||
type="button"
|
||||
style={{
|
||||
background: getBackgroundColor(
|
||||
getBackground(backgroundValue)
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
aria-label={i18n('StoryCreator__control--draw')}
|
||||
className={classNames({
|
||||
StoryCreator__control: true,
|
||||
'StoryCreator__control--text': true,
|
||||
'StoryCreator__control--selected': isEditingText,
|
||||
})}
|
||||
onClick={() => {
|
||||
setIsEditingText(!isEditingText);
|
||||
}}
|
||||
type="button"
|
||||
/>
|
||||
<button
|
||||
aria-label={i18n('StoryCreator__control--link')}
|
||||
className="StoryCreator__control StoryCreator__control--link"
|
||||
onClick={() =>
|
||||
setIsLinkPreviewInputShowing(!isLinkPreviewInputShowing)
|
||||
}
|
||||
ref={setLinkPreviewInputPopperButtonRef}
|
||||
type="button"
|
||||
/>
|
||||
{isLinkPreviewInputShowing && (
|
||||
<div
|
||||
className={classNames(
|
||||
'StoryCreator__popper StoryCreator__link-preview-input-popper',
|
||||
themeClassName(Theme.Dark)
|
||||
)}
|
||||
ref={setLinkPreviewInputPopperRef}
|
||||
style={linkPreviewInputPopper.styles.popper}
|
||||
{...linkPreviewInputPopper.attributes.popper}
|
||||
>
|
||||
<div
|
||||
data-popper-arrow
|
||||
className="StoryCreator__popper__arrow"
|
||||
/>
|
||||
<Input
|
||||
disableSpellcheck
|
||||
i18n={i18n}
|
||||
moduleClassName="StoryCreator__link-preview-input"
|
||||
onChange={setLinkPreviewInputValue}
|
||||
placeholder={i18n('StoryCreator__link-preview-placeholder')}
|
||||
ref={el => el?.focus()}
|
||||
value={linkPreviewInputValue}
|
||||
/>
|
||||
<div className="StoryCreator__link-preview-container">
|
||||
{linkPreview ? (
|
||||
<>
|
||||
<StagedLinkPreview
|
||||
domain={linkPreview.domain}
|
||||
i18n={i18n}
|
||||
image={linkPreview.image}
|
||||
moduleClassName="StoryCreator__link-preview"
|
||||
title={linkPreview.title}
|
||||
url={linkPreview.url}
|
||||
/>
|
||||
<Button
|
||||
className="StoryCreator__link-preview-button"
|
||||
onClick={() => {
|
||||
setHasLinkPreviewApplied(true);
|
||||
setIsLinkPreviewInputShowing(false);
|
||||
}}
|
||||
theme={Theme.Dark}
|
||||
variant={ButtonVariant.Primary}
|
||||
>
|
||||
{i18n('StoryCreator__add-link')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<div className="StoryCreator__link-preview-empty">
|
||||
<div className="StoryCreator__link-preview-empty__icon" />
|
||||
{i18n('StoryCreator__link-preview-empty')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={onNext}
|
||||
theme={Theme.Dark}
|
||||
variant={ButtonVariant.Primary}
|
||||
>
|
||||
{i18n('StoryCreator__next')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FocusTrap>
|
||||
);
|
||||
};
|
|
@ -2,18 +2,21 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import Measure from 'react-measure';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import type { LocalizerType, RenderTextCallbackType } from '../types/Util';
|
||||
import type { TextAttachmentType } from '../types/Attachment';
|
||||
import { AddNewLines } from './conversation/AddNewLines';
|
||||
import { Emojify } from './conversation/Emojify';
|
||||
import { StagedLinkPreview } from './conversation/StagedLinkPreview';
|
||||
import { TextAttachmentStyleType } from '../types/Attachment';
|
||||
import { count } from '../util/grapheme';
|
||||
import { getDomain } from '../types/LinkPreview';
|
||||
import { getFontNameByTextScript } from '../util/getFontNameByTextScript';
|
||||
import {
|
||||
COLOR_WHITE_INT,
|
||||
getHexFromNumber,
|
||||
getBackgroundColor,
|
||||
} from '../util/getStoryBackground';
|
||||
|
@ -27,7 +30,6 @@ const renderNewLines: RenderTextCallbackType = ({
|
|||
|
||||
const CHAR_LIMIT_TEXT_LARGE = 50;
|
||||
const CHAR_LIMIT_TEXT_MEDIUM = 200;
|
||||
const COLOR_WHITE_INT = 4294704123;
|
||||
const FONT_SIZE_LARGE = 64;
|
||||
const FONT_SIZE_MEDIUM = 42;
|
||||
const FONT_SIZE_SMALL = 32;
|
||||
|
@ -40,7 +42,9 @@ enum TextSize {
|
|||
|
||||
export type PropsType = {
|
||||
i18n: LocalizerType;
|
||||
isEditingText?: boolean;
|
||||
isThumbnail?: boolean;
|
||||
onChange?: (text: string) => unknown;
|
||||
textAttachment: TextAttachmentType;
|
||||
};
|
||||
|
||||
|
@ -84,9 +88,24 @@ function getFont(
|
|||
return `${fontWeight}${fontSize}pt ${fontName}`;
|
||||
}
|
||||
|
||||
function getTextStyles(
|
||||
textContent: string,
|
||||
textForegroundColor?: number | null,
|
||||
textStyle?: TextAttachmentStyleType | null,
|
||||
i18n?: LocalizerType
|
||||
): { color: string; font: string; textAlign: 'left' | 'center' } {
|
||||
return {
|
||||
color: getHexFromNumber(textForegroundColor || COLOR_WHITE_INT),
|
||||
font: getFont(textContent, getTextSize(textContent), textStyle, i18n),
|
||||
textAlign: getTextSize(textContent) === TextSize.Small ? 'left' : 'center',
|
||||
};
|
||||
}
|
||||
|
||||
export const TextAttachment = ({
|
||||
i18n,
|
||||
isEditingText,
|
||||
isThumbnail,
|
||||
onChange,
|
||||
textAttachment,
|
||||
}: PropsType): JSX.Element | null => {
|
||||
const linkPreview = useRef<HTMLDivElement | null>(null);
|
||||
|
@ -94,6 +113,20 @@ export const TextAttachment = ({
|
|||
number | undefined
|
||||
>();
|
||||
|
||||
const textContent = textAttachment.text || '';
|
||||
|
||||
const textEditorRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const node = textEditorRef.current;
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
node.focus();
|
||||
node.setSelectionRange(node.value.length, node.value.length);
|
||||
}, [isEditingText]);
|
||||
|
||||
return (
|
||||
<Measure bounds>
|
||||
{({ contentRect, measureRef }) => (
|
||||
|
@ -119,62 +152,72 @@ export const TextAttachment = ({
|
|||
transform: `scale(${(contentRect.bounds?.height || 1) / 1280})`,
|
||||
}}
|
||||
>
|
||||
{textAttachment.text && (
|
||||
{(textContent || onChange) && (
|
||||
<div
|
||||
className="TextAttachment__text"
|
||||
style={{
|
||||
backgroundColor: textAttachment.textBackgroundColor
|
||||
? getHexFromNumber(textAttachment.textBackgroundColor)
|
||||
: 'none',
|
||||
color: getHexFromNumber(
|
||||
textAttachment.textForegroundColor || COLOR_WHITE_INT
|
||||
),
|
||||
font: getFont(
|
||||
textAttachment.text,
|
||||
getTextSize(textAttachment.text),
|
||||
textAttachment.textStyle,
|
||||
i18n
|
||||
),
|
||||
textAlign:
|
||||
getTextSize(textAttachment.text) === TextSize.Small
|
||||
? 'left'
|
||||
: 'center',
|
||||
: 'transparent',
|
||||
}}
|
||||
>
|
||||
<div className="TextAttachment__text__container">
|
||||
<Emojify
|
||||
text={textAttachment.text}
|
||||
renderNonEmoji={renderNewLines}
|
||||
{onChange ? (
|
||||
<TextareaAutosize
|
||||
className="TextAttachment__text__container TextAttachment__text__textarea"
|
||||
disabled={!isEditingText}
|
||||
onChange={ev => onChange(ev.currentTarget.value)}
|
||||
placeholder={i18n('TextAttachment__placeholder')}
|
||||
ref={textEditorRef}
|
||||
style={getTextStyles(
|
||||
textContent,
|
||||
textAttachment.textForegroundColor,
|
||||
textAttachment.textStyle,
|
||||
i18n
|
||||
)}
|
||||
value={textContent}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="TextAttachment__text__container"
|
||||
style={getTextStyles(
|
||||
textContent,
|
||||
textAttachment.textForegroundColor,
|
||||
textAttachment.textStyle,
|
||||
i18n
|
||||
)}
|
||||
>
|
||||
<Emojify
|
||||
text={textContent}
|
||||
renderNonEmoji={renderNewLines}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{textAttachment.preview && (
|
||||
{textAttachment.preview && textAttachment.preview.url && (
|
||||
<>
|
||||
{linkPreviewOffsetTop &&
|
||||
!isThumbnail &&
|
||||
textAttachment.preview.url && (
|
||||
<a
|
||||
className="TextAttachment__preview__tooltip"
|
||||
href={textAttachment.preview.url}
|
||||
rel="noreferrer"
|
||||
style={{
|
||||
top: linkPreviewOffsetTop - 150,
|
||||
}}
|
||||
target="_blank"
|
||||
>
|
||||
<div>
|
||||
<div>{i18n('TextAttachment__preview__link')}</div>
|
||||
<div className="TextAttachment__preview__tooltip__url">
|
||||
{textAttachment.preview.url}
|
||||
</div>
|
||||
{linkPreviewOffsetTop && !isThumbnail && (
|
||||
<a
|
||||
className="TextAttachment__preview__tooltip"
|
||||
href={textAttachment.preview.url}
|
||||
rel="noreferrer"
|
||||
style={{
|
||||
top: linkPreviewOffsetTop - 150,
|
||||
}}
|
||||
target="_blank"
|
||||
>
|
||||
<div>
|
||||
<div>{i18n('TextAttachment__preview__link')}</div>
|
||||
<div className="TextAttachment__preview__tooltip__url">
|
||||
{textAttachment.preview.url}
|
||||
</div>
|
||||
<div className="TextAttachment__preview__tooltip__arrow" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="TextAttachment__preview__tooltip__arrow" />
|
||||
</a>
|
||||
)}
|
||||
<div
|
||||
className={classNames('TextAttachment__preview', {
|
||||
'TextAttachment__preview--large': Boolean(
|
||||
className={classNames('TextAttachment__preview-container', {
|
||||
'TextAttachment__preview-container--large': Boolean(
|
||||
textAttachment.preview.title
|
||||
),
|
||||
})}
|
||||
|
@ -186,17 +229,14 @@ export const TextAttachment = ({
|
|||
setLinkPreviewOffsetTop(linkPreview?.current?.offsetTop)
|
||||
}
|
||||
>
|
||||
<div className="TextAttachment__preview__image" />
|
||||
<div className="TextAttachment__preview__title">
|
||||
{textAttachment.preview.title && (
|
||||
<div className="TextAttachment__preview__title__container">
|
||||
{textAttachment.preview.title}
|
||||
</div>
|
||||
)}
|
||||
<div className="TextAttachment__preview__url">
|
||||
{getDomain(String(textAttachment.preview.url))}
|
||||
</div>
|
||||
</div>
|
||||
<StagedLinkPreview
|
||||
domain={getDomain(String(textAttachment.preview.url))}
|
||||
i18n={i18n}
|
||||
image={textAttachment.preview.image}
|
||||
moduleClassName="TextAttachment__preview"
|
||||
title={textAttachment.preview.title || undefined}
|
||||
url={textAttachment.preview.url}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -1192,7 +1192,10 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
/>
|
||||
) : null}
|
||||
<div className="module-message__link-preview__content">
|
||||
{first.image && previewHasImage && !isFullSizeImage ? (
|
||||
{first.image &&
|
||||
first.domain &&
|
||||
previewHasImage &&
|
||||
!isFullSizeImage ? (
|
||||
<div className="module-message__link-preview__icon_container">
|
||||
<Image
|
||||
noBorder
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
// Copyright 2020-2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { Meta, Story } from '@storybook/react';
|
||||
import * as React from 'react';
|
||||
import { date, text } from '@storybook/addon-knobs';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
import type { AttachmentType } from '../../types/Attachment';
|
||||
import { stringToMIMEType } from '../../types/MIME';
|
||||
import { setupI18n } from '../../util/setupI18n';
|
||||
import enMessages from '../../../_locales/en/messages.json';
|
||||
import type { Props } from './StagedLinkPreview';
|
||||
import enMessages from '../../../_locales/en/messages.json';
|
||||
import { StagedLinkPreview } from './StagedLinkPreview';
|
||||
import { fakeAttachment } from '../../test-both/helpers/fakeAttachment';
|
||||
import { setupI18n } from '../../util/setupI18n';
|
||||
import { IMAGE_JPEG } from '../../types/MIME';
|
||||
|
||||
const LONG_TITLE =
|
||||
"This is a super-sweet site. And it's got some really amazing content in store for you if you just click that link. Can you click that link for me?";
|
||||
|
@ -21,150 +21,109 @@ const i18n = setupI18n('en', enMessages);
|
|||
|
||||
export default {
|
||||
title: 'Components/Conversation/StagedLinkPreview',
|
||||
};
|
||||
component: StagedLinkPreview,
|
||||
} as Meta;
|
||||
|
||||
const createAttachment = (
|
||||
props: Partial<AttachmentType> = {}
|
||||
): AttachmentType => ({
|
||||
contentType: stringToMIMEType(
|
||||
text('attachment contentType', props.contentType || '')
|
||||
),
|
||||
fileName: text('attachment fileName', props.fileName || ''),
|
||||
url: text('attachment url', props.url || ''),
|
||||
size: 24325,
|
||||
});
|
||||
|
||||
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||
title: text(
|
||||
'title',
|
||||
typeof overrideProps.title === 'string'
|
||||
? overrideProps.title
|
||||
: 'This is a super-sweet site'
|
||||
),
|
||||
description: text(
|
||||
'description',
|
||||
typeof overrideProps.description === 'string'
|
||||
? overrideProps.description
|
||||
: 'This is a description'
|
||||
),
|
||||
date: date('date', new Date(overrideProps.date || 0)),
|
||||
domain: text('domain', overrideProps.domain || 'signal.org'),
|
||||
image: overrideProps.image,
|
||||
const getDefaultProps = (): Props => ({
|
||||
date: Date.now(),
|
||||
description: 'This is a description',
|
||||
domain: 'signal.org',
|
||||
i18n,
|
||||
onClose: action('onClose'),
|
||||
title: 'This is a super-sweet site',
|
||||
url: 'https://www.signal.org',
|
||||
});
|
||||
|
||||
export const Loading = (): JSX.Element => {
|
||||
const props = createProps({ domain: '' });
|
||||
const Template: Story<Props> = args => <StagedLinkPreview {...args} />;
|
||||
|
||||
return <StagedLinkPreview {...props} />;
|
||||
export const Loading = Template.bind({});
|
||||
Loading.args = {
|
||||
...getDefaultProps(),
|
||||
domain: '',
|
||||
};
|
||||
|
||||
export const NoImage = (): JSX.Element => {
|
||||
return <StagedLinkPreview {...createProps()} />;
|
||||
export const NoImage = Template.bind({});
|
||||
|
||||
export const Image = Template.bind({});
|
||||
Image.args = {
|
||||
...getDefaultProps(),
|
||||
image: fakeAttachment({
|
||||
url: '/fixtures/kitten-4-112-112.jpg',
|
||||
contentType: IMAGE_JPEG,
|
||||
}),
|
||||
};
|
||||
|
||||
export const Image = (): JSX.Element => {
|
||||
const props = createProps({
|
||||
image: createAttachment({
|
||||
url: '/fixtures/kitten-4-112-112.jpg',
|
||||
contentType: stringToMIMEType('image/jpeg'),
|
||||
}),
|
||||
});
|
||||
|
||||
return <StagedLinkPreview {...props} />;
|
||||
export const ImageNoTitleOrDescription = Template.bind({});
|
||||
ImageNoTitleOrDescription.args = {
|
||||
...getDefaultProps(),
|
||||
title: '',
|
||||
description: '',
|
||||
domain: 'instagram.com',
|
||||
image: fakeAttachment({
|
||||
url: '/fixtures/kitten-4-112-112.jpg',
|
||||
contentType: IMAGE_JPEG,
|
||||
}),
|
||||
};
|
||||
|
||||
export const ImageNoTitleOrDescription = (): JSX.Element => {
|
||||
const props = createProps({
|
||||
title: '',
|
||||
description: '',
|
||||
domain: 'instagram.com',
|
||||
image: createAttachment({
|
||||
url: '/fixtures/kitten-4-112-112.jpg',
|
||||
contentType: stringToMIMEType('image/jpeg'),
|
||||
}),
|
||||
});
|
||||
|
||||
return <StagedLinkPreview {...props} />;
|
||||
};
|
||||
|
||||
ImageNoTitleOrDescription.story = {
|
||||
name: 'Image, No Title Or Description',
|
||||
};
|
||||
|
||||
export const NoImageLongTitleWithDescription = (): JSX.Element => {
|
||||
const props = createProps({
|
||||
title: LONG_TITLE,
|
||||
});
|
||||
|
||||
return <StagedLinkPreview {...props} />;
|
||||
export const NoImageLongTitleWithDescription = Template.bind({});
|
||||
NoImageLongTitleWithDescription.args = {
|
||||
...getDefaultProps(),
|
||||
title: LONG_TITLE,
|
||||
};
|
||||
|
||||
NoImageLongTitleWithDescription.story = {
|
||||
name: 'No Image, Long Title With Description',
|
||||
};
|
||||
|
||||
export const NoImageLongTitleWithoutDescription = (): JSX.Element => {
|
||||
const props = createProps({
|
||||
title: LONG_TITLE,
|
||||
description: '',
|
||||
});
|
||||
|
||||
return <StagedLinkPreview {...props} />;
|
||||
export const NoImageLongTitleWithoutDescription = Template.bind({});
|
||||
NoImageLongTitleWithoutDescription.args = {
|
||||
...getDefaultProps(),
|
||||
title: LONG_TITLE,
|
||||
description: '',
|
||||
};
|
||||
|
||||
NoImageLongTitleWithoutDescription.story = {
|
||||
name: 'No Image, Long Title Without Description',
|
||||
};
|
||||
|
||||
export const ImageLongTitleWithoutDescription = (): JSX.Element => {
|
||||
const props = createProps({
|
||||
title: LONG_TITLE,
|
||||
image: createAttachment({
|
||||
url: '/fixtures/kitten-4-112-112.jpg',
|
||||
contentType: stringToMIMEType('image/jpeg'),
|
||||
}),
|
||||
});
|
||||
|
||||
return <StagedLinkPreview {...props} />;
|
||||
export const ImageLongTitleWithoutDescription = Template.bind({});
|
||||
ImageLongTitleWithoutDescription.args = {
|
||||
...getDefaultProps(),
|
||||
title: LONG_TITLE,
|
||||
image: fakeAttachment({
|
||||
url: '/fixtures/kitten-4-112-112.jpg',
|
||||
contentType: IMAGE_JPEG,
|
||||
}),
|
||||
};
|
||||
|
||||
ImageLongTitleWithoutDescription.story = {
|
||||
name: 'Image, Long Title Without Description',
|
||||
};
|
||||
|
||||
export const ImageLongTitleAndDescription = (): JSX.Element => {
|
||||
const props = createProps({
|
||||
title: LONG_TITLE,
|
||||
description: LONG_DESCRIPTION,
|
||||
image: createAttachment({
|
||||
url: '/fixtures/kitten-4-112-112.jpg',
|
||||
contentType: stringToMIMEType('image/jpeg'),
|
||||
}),
|
||||
});
|
||||
|
||||
return <StagedLinkPreview {...props} />;
|
||||
export const ImageLongTitleAndDescription = Template.bind({});
|
||||
ImageLongTitleAndDescription.args = {
|
||||
...getDefaultProps(),
|
||||
title: LONG_TITLE,
|
||||
description: LONG_DESCRIPTION,
|
||||
image: fakeAttachment({
|
||||
url: '/fixtures/kitten-4-112-112.jpg',
|
||||
contentType: IMAGE_JPEG,
|
||||
}),
|
||||
};
|
||||
|
||||
ImageLongTitleAndDescription.story = {
|
||||
name: 'Image, Long Title And Description',
|
||||
};
|
||||
|
||||
export const EverythingImageTitleDescriptionAndDate = (): JSX.Element => {
|
||||
const props = createProps({
|
||||
title: LONG_TITLE,
|
||||
description: LONG_DESCRIPTION,
|
||||
date: Date.now(),
|
||||
image: createAttachment({
|
||||
url: '/fixtures/kitten-4-112-112.jpg',
|
||||
contentType: stringToMIMEType('image/jpeg'),
|
||||
}),
|
||||
});
|
||||
|
||||
return <StagedLinkPreview {...props} />;
|
||||
export const EverythingImageTitleDescriptionAndDate = Template.bind({});
|
||||
EverythingImageTitleDescriptionAndDate.args = {
|
||||
...getDefaultProps(),
|
||||
title: LONG_TITLE,
|
||||
description: LONG_DESCRIPTION,
|
||||
image: fakeAttachment({
|
||||
url: '/fixtures/kitten-4-112-112.jpg',
|
||||
contentType: IMAGE_JPEG,
|
||||
}),
|
||||
};
|
||||
|
||||
EverythingImageTitleDescriptionAndDate.story = {
|
||||
name: 'Everything: image, title, description, and date',
|
||||
};
|
||||
|
|
|
@ -8,84 +8,86 @@ import { unescape } from 'lodash';
|
|||
import { CurveType, Image } from './Image';
|
||||
import { LinkPreviewDate } from './LinkPreviewDate';
|
||||
|
||||
import type { AttachmentType } from '../../types/Attachment';
|
||||
import { isImageAttachment } from '../../types/Attachment';
|
||||
import type { LinkPreviewType } from '../../types/message/LinkPreviews';
|
||||
import type { LocalizerType } from '../../types/Util';
|
||||
import { getClassNamesFor } from '../../util/getClassNamesFor';
|
||||
import { isImageAttachment } from '../../types/Attachment';
|
||||
|
||||
export type Props = {
|
||||
title?: string;
|
||||
description?: null | string;
|
||||
date?: null | number;
|
||||
domain?: string;
|
||||
image?: AttachmentType;
|
||||
|
||||
export type Props = LinkPreviewType & {
|
||||
i18n: LocalizerType;
|
||||
moduleClassName?: string;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export const StagedLinkPreview: React.FC<Props> = ({
|
||||
onClose,
|
||||
i18n,
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
date,
|
||||
description,
|
||||
domain,
|
||||
i18n,
|
||||
image,
|
||||
moduleClassName,
|
||||
onClose,
|
||||
title,
|
||||
}: Props) => {
|
||||
const isImage = isImageAttachment(image);
|
||||
const isLoaded = Boolean(domain);
|
||||
|
||||
const getClassName = getClassNamesFor(
|
||||
'module-staged-link-preview',
|
||||
moduleClassName
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'module-staged-link-preview',
|
||||
!isLoaded ? 'module-staged-link-preview--is-loading' : null
|
||||
getClassName(''),
|
||||
!isLoaded ? getClassName('--is-loading') : null
|
||||
)}
|
||||
>
|
||||
{!isLoaded ? (
|
||||
<div className="module-staged-link-preview__loading">
|
||||
<div className={getClassName('__loading')}>
|
||||
{i18n('loadingPreview')}
|
||||
</div>
|
||||
) : null}
|
||||
{isLoaded && image && isImage && domain ? (
|
||||
<div className="module-staged-link-preview__icon-container">
|
||||
<div className={getClassName('__icon-container')}>
|
||||
<Image
|
||||
alt={i18n('stagedPreviewThumbnail', [domain])}
|
||||
attachment={image}
|
||||
curveBottomLeft={CurveType.Tiny}
|
||||
curveBottomRight={CurveType.Tiny}
|
||||
curveTopRight={CurveType.Tiny}
|
||||
curveTopLeft={CurveType.Tiny}
|
||||
curveTopRight={CurveType.Tiny}
|
||||
height={72}
|
||||
width={72}
|
||||
url={image.url}
|
||||
attachment={image}
|
||||
i18n={i18n}
|
||||
url={image.url}
|
||||
width={72}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{isLoaded && !image && <div className={getClassName('__no-image')} />}
|
||||
{isLoaded ? (
|
||||
<div className="module-staged-link-preview__content">
|
||||
<div className="module-staged-link-preview__title">{title}</div>
|
||||
<div className={getClassName('__content')}>
|
||||
<div className={getClassName('__title')}>{title}</div>
|
||||
{description && (
|
||||
<div className="module-staged-link-preview__description">
|
||||
<div className={getClassName('__description')}>
|
||||
{unescape(description)}
|
||||
</div>
|
||||
)}
|
||||
<div className="module-staged-link-preview__footer">
|
||||
<div className="module-staged-link-preview__location">{domain}</div>
|
||||
<LinkPreviewDate
|
||||
date={date}
|
||||
className="module-message__link-preview__date"
|
||||
/>
|
||||
<div className={getClassName('__footer')}>
|
||||
<div className={getClassName('__location')}>{domain}</div>
|
||||
<LinkPreviewDate date={date} className={getClassName('__date')} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="module-staged-link-preview__close-button"
|
||||
onClick={onClose}
|
||||
aria-label={i18n('close')}
|
||||
/>
|
||||
{onClose && (
|
||||
<button
|
||||
aria-label={i18n('close')}
|
||||
className={getClassName('__close-button')}
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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})`;
|
||||
}
|
||||
|
|
|
@ -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: '',
|
||||
|
|
|
@ -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<MessageAttributesType> {
|
|||
let hash;
|
||||
if (avatarAttachment) {
|
||||
try {
|
||||
downloadedAvatar =
|
||||
await window.Signal.Util.downloadAttachment(
|
||||
avatarAttachment
|
||||
);
|
||||
downloadedAvatar = await downloadAttachment(avatarAttachment);
|
||||
|
||||
if (downloadedAvatar) {
|
||||
const loadedAttachment =
|
||||
|
|
532
ts/services/LinkPreview.ts
Normal file
532
ts/services/LinkPreview.ts
Normal file
|
@ -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<string> = [];
|
||||
let linkPreviewAbortController: AbortController | undefined;
|
||||
let linkPreviewResult: Array<LinkPreviewResult> | 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<void> {
|
||||
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<LinkPreviewType> {
|
||||
// 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<string>(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<AbortSignal>
|
||||
): Promise<null | LinkPreviewResult> {
|
||||
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<AbortSignal>
|
||||
): Promise<null | LinkPreviewResult> {
|
||||
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<AbortSignal>
|
||||
): Promise<null | LinkPreviewResult> {
|
||||
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,
|
||||
};
|
||||
}
|
|
@ -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<AttachmentDraftType>;
|
||||
linkPreviewLoading: boolean;
|
||||
linkPreviewResult?: LinkPreviewWithDomain;
|
||||
linkPreviewResult?: LinkPreviewType;
|
||||
quotedMessage?: Pick<MessageAttributesType, 'conversationId' | 'quote'>;
|
||||
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<MessageAttributesType, 'conversationId' | 'quote'>;
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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<void, RootStateType, unknown, NoopActionType> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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<AttachmentType>;
|
||||
|
@ -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,
|
||||
|
|
|
@ -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 <SmartStoryCreator onClose={onClose} />;
|
||||
}
|
||||
|
||||
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}
|
||||
|
|
35
ts/state/smart/StoryCreator.tsx
Normal file
35
ts/state/smart/StoryCreator.tsx
Normal file
|
@ -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<StateType, LocalizerType>(getIntl);
|
||||
const linkPreviewForSource = useSelector(getLinkPreview);
|
||||
|
||||
return (
|
||||
<StoryCreator
|
||||
debouncedMaybeGrabLinkPreview={debouncedMaybeGrabLinkPreview}
|
||||
i18n={i18n}
|
||||
linkPreview={linkPreviewForSource(LinkPreviewSourceType.StoryCreator)}
|
||||
onClose={onClose}
|
||||
onNext={noop}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
2
ts/textsecure/Types.d.ts
vendored
2
ts/textsecure/Types.d.ts
vendored
|
@ -108,7 +108,7 @@ export type ProcessedAttachment = {
|
|||
caption?: string;
|
||||
blurHash?: string;
|
||||
cdnNumber?: number;
|
||||
textAttachment?: TextAttachmentType;
|
||||
textAttachment?: Omit<TextAttachmentType, 'preview'>;
|
||||
};
|
||||
|
||||
export type ProcessedGroupContext = {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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<AttachmentType>;
|
||||
|
|
|
@ -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<TextAttachmentType, 'color' | 'gradient'>): 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);
|
||||
|
|
|
@ -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<HTMLInputElement | null>(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<HTMLTextAreaElement | null>(null);",
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2022-06-16T23:23:32.306Z"
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
"path": "ts/components/Tooltip.tsx",
|
||||
|
|
10
ts/util/objectMap.ts
Normal file
10
ts/util/objectMap.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
export function objectMap<T>(
|
||||
obj: Record<string, T>,
|
||||
f: (key: keyof typeof obj, value: typeof obj[keyof typeof obj]) => unknown
|
||||
): Array<unknown> {
|
||||
const keys: Array<keyof typeof obj> = Object.keys(obj);
|
||||
return keys.map(key => f(key, obj[key]));
|
||||
}
|
|
@ -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<ConversationModel> {
|
||||
// Debounced functions
|
||||
private debouncedMaybeGrabLinkPreview: (
|
||||
message: string,
|
||||
caretLocation?: number
|
||||
) => void;
|
||||
private debouncedSaveDraft: (
|
||||
messageText: string,
|
||||
bodyRanges: Array<BodyRangeType>
|
||||
|
@ -244,13 +232,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
|
|||
private quote?: QuotedMessageType;
|
||||
private quotedMessage?: MessageModel;
|
||||
|
||||
// Previews
|
||||
private currentlyMatchedLink?: string;
|
||||
private disableLinkPreviews?: boolean;
|
||||
private excludedPreviewUrls: Array<string> = [];
|
||||
private linkPreviewAbortController?: AbortController;
|
||||
private preview?: Array<LinkPreviewResult>;
|
||||
|
||||
// Sub-views
|
||||
private contactModalView?: Backbone.View;
|
||||
private conversationView?: Backbone.View;
|
||||
|
@ -275,10 +256,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
|
|||
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<ConversationModel> {
|
|||
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<ConversationModel> {
|
|||
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<ConversationModel> {
|
|||
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<ConversationModel> {
|
|||
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<ConversationModel> {
|
|||
draftAttachments
|
||||
);
|
||||
if (this.hasFiles({ includePending: true })) {
|
||||
this.removeLinkPreview();
|
||||
removeLinkPreview();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1354,7 +1331,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
|
|||
this.forwardMessageModal.remove();
|
||||
this.forwardMessageModal = undefined;
|
||||
}
|
||||
this.resetLinkPreview();
|
||||
resetLinkPreview();
|
||||
},
|
||||
onEditorStateChange: (
|
||||
messageText: string,
|
||||
|
@ -1362,7 +1339,11 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
|
|||
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<ConversationModel> {
|
|||
);
|
||||
|
||||
// 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<ConversationModel> {
|
|||
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<ConversationModel> {
|
|||
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<ConversationModel> {
|
|||
): 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<ConversationModel> {
|
|||
}
|
||||
}
|
||||
|
||||
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<AbortSignal>
|
||||
): Promise<null | LinkPreviewResult> {
|
||||
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<AbortSignal>
|
||||
): Promise<null | LinkPreviewResult> {
|
||||
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<AbortSignal>
|
||||
): Promise<null | LinkPreviewResult> {
|
||||
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<void> {
|
||||
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<LinkPreviewType> {
|
||||
// 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<string>(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 {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue