Adds message forwarding

This commit is contained in:
Josh Perez 2021-04-27 15:35:35 -07:00 committed by GitHub
parent cd489a35fd
commit d203f125c6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 1638 additions and 139 deletions

View file

@ -1049,6 +1049,10 @@
"theirIdentityUnknown": {
"message": "You haven't exchanged any messages with this contact yet. Your safety number with them will be available after the first message."
},
"back": {
"message": "Back",
"description": "Generic label for back"
},
"goBack": {
"message": "Go back",
"description": "Label for back button in a conversation"
@ -1061,6 +1065,10 @@
"message": "Retry Send",
"description": "Shown on the drop-down menu for an individual message, but only if it is an outgoing message that failed to send"
},
"forwardMessage": {
"message": "Forward message",
"description": "Shown on the drop-down menu for an individual message, forwards a message"
},
"deleteMessage": {
"message": "Delete message for me",
"description": "Shown on the drop-down menu for an individual message, deletes single message"
@ -5152,5 +5160,9 @@
"composeIcon": {
"message": "compose button",
"description": "Shown in the left-pane when the inbox is empty. Describes the button that composes a new message."
},
"ForwardMessageModal--continue": {
"message": "Continue",
"description": "aria-label for the 'next' button in the forward a message modal dialog"
}
}

View file

@ -73,6 +73,9 @@ const {
createConversationHeader,
} = require('../../ts/state/roots/createConversationHeader');
const { createCallManager } = require('../../ts/state/roots/createCallManager');
const {
createForwardMessageModal,
} = require('../../ts/state/roots/createForwardMessageModal');
const {
createGroupLinkManagement,
} = require('../../ts/state/roots/createGroupLinkManagement');
@ -111,6 +114,7 @@ const conversationsDuck = require('../../ts/state/ducks/conversations');
const emojisDuck = require('../../ts/state/ducks/emojis');
const expirationDuck = require('../../ts/state/ducks/expiration');
const itemsDuck = require('../../ts/state/ducks/items');
const linkPreviewsDuck = require('../../ts/state/ducks/linkPreviews');
const networkDuck = require('../../ts/state/ducks/network');
const searchDuck = require('../../ts/state/ducks/search');
const stickersDuck = require('../../ts/state/ducks/stickers');
@ -344,6 +348,7 @@ exports.setup = (options = {}) => {
createContactModal,
createConversationDetails,
createConversationHeader,
createForwardMessageModal,
createGroupLinkManagement,
createGroupV1MigrationModal,
createGroupV2JoinModal,
@ -364,6 +369,7 @@ exports.setup = (options = {}) => {
emojis: emojisDuck,
expiration: expirationDuck,
items: itemsDuck,
linkPreviews: linkPreviewsDuck,
network: networkDuck,
updates: updatesDuck,
user: userDuck,

View file

@ -11064,6 +11064,23 @@ $contact-modal-padding: 18px;
}
}
&__forward-message::before {
transform: scaleX(-1);
@include light-theme {
@include color-svg(
'../images/icons/v2/reply-outline-24.svg',
$color-black
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v2/reply-solid-24.svg',
$color-gray-15
);
}
}
&__delete-message::before {
@include light-theme {
@include color-svg(

View file

@ -0,0 +1,293 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.module-ForwardMessageModal {
$padding: 16px;
@include popper-shadow();
border-radius: 8px;
display: flex;
flex-direction: column;
margin: 0 auto;
max-height: 90vh;
max-width: 360px;
width: 95%;
@include light-theme() {
background: $color-white;
color: $color-gray-90;
}
@include dark-theme() {
background: $color-gray-75;
color: $color-gray-05;
}
&--link-preview {
border-bottom: 1px solid $color-gray-15;
padding: 12px 16px;
@include dark-theme() {
border-color: $color-gray-60;
}
}
&__input {
&__input {
background: inherit;
border: none;
border-radius: 0;
height: 100%;
&:focus-within {
border: none;
}
@include dark-theme() {
border: none;
&:focus-within {
border: none;
}
}
@include keyboard-mode {
&:focus-within {
border: solid 1px $ultramarine-ui-light;
}
}
}
&__scroller {
max-height: 300px;
min-height: 300px;
padding-right: 36px;
padding: 16px;
}
}
&__header {
align-items: center;
display: flex;
justify-content: center;
position: relative;
&--edit {
border-bottom: 1px solid $color-gray-15;
@include dark-theme() {
border-color: $color-gray-60;
}
}
&--cancel {
@include button-reset;
position: absolute;
left: 16px;
@include keyboard-mode {
&:focus {
color: $ultramarine-ui-light;
}
}
}
&--back {
@include button-reset;
height: 24px;
left: 16px;
position: absolute;
width: 24px;
@include light-theme {
@include color-svg(
'../images/icons/v2/chevron-left-24.svg',
$color-gray-60
);
}
@include keyboard-mode {
&:focus {
@include color-svg(
'../images/icons/v2/chevron-left-24.svg',
$ultramarine-ui-light
);
}
}
@include dark-theme {
@include color-svg(
'../images/icons/v2/chevron-left-24.svg',
$color-gray-25
);
}
@include dark-keyboard-mode {
&:hover {
@include color-svg(
'../images/icons/v2/chevron-left-24.svg',
$ultramarine-ui-dark
);
}
}
}
h1 {
@include font-body-1-bold;
}
}
&__search {
border-radius: 8px;
border: none;
margin: 10px 16px;
padding: 5px 12px;
position: relative;
@include font-body-2;
@include light-theme {
background-color: $color-gray-02;
border: solid 1px $color-gray-02;
color: $color-gray-90;
}
@include dark-theme {
background: $color-gray-65;
border: solid 1px $color-gray-65;
color: $color-gray-05;
}
&--icon {
cursor: text;
height: 16px;
left: 8px;
position: absolute;
top: 6px;
width: 16px;
@include light-theme {
@include color-svg('../images/icons/v2/search-16.svg', $color-gray-45);
}
}
@include keyboard-mode {
&:focus-within {
border: solid 1px $ultramarine-ui-light;
outline: none;
}
}
&--input {
background: inherit;
border: none;
padding-left: 16px;
width: 100%;
&:placeholder {
color: $color-gray-45;
}
@include dark-theme {
color: $color-gray-05;
}
&:focus {
outline: none;
}
}
}
&__list-wrapper {
flex-grow: 1;
overflow: hidden;
}
&__main-body {
display: flex;
flex-direction: column;
min-height: 300px;
}
&__text-edit-area {
height: 100%;
position: relative;
}
&__no-candidate-contacts {
flex-grow: 1;
display: flex;
align-items: center;
justify-content: center;
}
&__send-button {
align-items: center;
border: none;
border-radius: 100%;
display: flex;
height: 32px;
justify-content: center;
width: 32px;
&::after {
content: '';
display: block;
flex-shrink: 0;
height: 24px;
width: 24px;
}
&--continue {
&::after {
@include color-svg(
'../images/icons/v2/arrow-down-24.svg',
$color-white
);
transform: rotate(270deg);
}
}
&--forward {
&::after {
@include color-svg('../images/icons/v2/send-24.svg', $color-white);
}
}
}
&__emoji {
position: absolute;
right: 8px;
top: 8px;
button::after {
background-color: $color-black;
}
}
&__footer {
@include font-body-2;
align-items: center;
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
display: flex;
justify-content: space-between;
margin-top: 0;
padding: $padding;
position: relative;
@include light-theme {
background-color: $color-gray-02;
color: $color-gray-60;
}
@include dark-theme() {
background: $color-gray-65;
color: $color-gray-25;
}
}
// Disable cursor since images are non-clickable
.module-image__image {
cursor: inherit;
}
}

View file

@ -34,6 +34,7 @@
@import './components/ContactPills.scss';
@import './components/ConversationHeader.scss';
@import './components/EditConversationAttributesModal.scss';
@import './components/ForwardMessageModal.scss';
@import './components/GroupDialog.scss';
@import './components/GroupTitleInput.scss';
@import './components/MessageAudio.scss';

View file

@ -831,6 +831,10 @@ export async function startApp(): Promise<void> {
window.Signal.State.Ducks.items.actions,
store.dispatch
);
actions.linkPreviews = window.Signal.State.bindActionCreators(
window.Signal.State.Ducks.linkPreviews.actions,
store.dispatch
);
actions.network = window.Signal.State.bindActionCreators(
window.Signal.State.Ducks.network.actions,
store.dispatch

View file

@ -33,3 +33,11 @@ story.add('Kitchen sink', () => (
))}
</>
));
story.add('aria-label', () => (
<Button
aria-label="hello"
className="module-ForwardMessageModal__header--back"
onClick={action('onClick')}
/>
));

View file

@ -15,7 +15,6 @@ export enum ButtonVariant {
}
type PropsType = {
children: ReactNode;
className?: string;
disabled?: boolean;
variant?: ButtonVariant;
@ -26,7 +25,21 @@ type PropsType = {
| {
type: 'submit';
}
);
) &
(
| {
'aria-label': string;
children: ReactNode;
}
| {
'aria-label'?: string;
children: ReactNode;
}
| {
'aria-label': string;
children?: ReactNode;
}
);
const VARIANT_CLASS_NAMES = new Map<ButtonVariant, string>([
[ButtonVariant.Primary, 'module-Button--primary'],
@ -50,6 +63,7 @@ export const Button = React.forwardRef<HTMLButtonElement, PropsType>(
disabled = false,
variant = ButtonVariant.Primary,
} = props;
const ariaLabel = props['aria-label'];
let onClick: undefined | MouseEventHandler<HTMLButtonElement>;
let type: 'button' | 'submit';
@ -66,6 +80,7 @@ export const Button = React.forwardRef<HTMLButtonElement, PropsType>(
return (
<button
aria-label={ariaLabel}
className={classNames('module-Button', variantClassName, className)}
disabled={disabled}
onClick={onClick}

View file

@ -63,6 +63,7 @@ export type Props = {
readonly skinTone?: EmojiPickDataType['skinTone'];
readonly draftText?: string;
readonly draftBodyRanges?: Array<BodyRangeType>;
readonly moduleClassName?: string;
sortedGroupMembers?: Array<ConversationType>;
onDirtyChange?(dirty: boolean): unknown;
onEditorStateChange?(
@ -79,12 +80,24 @@ export type Props = {
const MAX_LENGTH = 64 * 1024;
function getClassName(
moduleClassName?: string,
modifier?: string | null
): string | undefined {
if (!moduleClassName || !modifier) {
return undefined;
}
return `${moduleClassName}${modifier}`;
}
export const CompositionInput: React.ComponentType<Props> = props => {
const {
i18n,
disabled,
large,
inputApi,
moduleClassName,
onPickEmoji,
onSubmit,
skinTone,
@ -240,12 +253,12 @@ export const CompositionInput: React.ComponentType<Props> = props => {
propsRef.current = props;
}, [props]);
const onShortKeyEnter = () => {
const onShortKeyEnter = (): boolean => {
submit();
return false;
};
const onEnter = () => {
const onEnter = (): boolean => {
const quill = quillRef.current;
const emojiCompletion = emojiCompletionRef.current;
const mentionCompletion = mentionCompletionRef.current;
@ -277,7 +290,7 @@ export const CompositionInput: React.ComponentType<Props> = props => {
return false;
};
const onTab = () => {
const onTab = (): boolean => {
const quill = quillRef.current;
const emojiCompletion = emojiCompletionRef.current;
const mentionCompletion = mentionCompletionRef.current;
@ -303,7 +316,7 @@ export const CompositionInput: React.ComponentType<Props> = props => {
return true;
};
const onEscape = () => {
const onEscape = (): boolean => {
const quill = quillRef.current;
if (quill === undefined) {
@ -335,7 +348,7 @@ export const CompositionInput: React.ComponentType<Props> = props => {
return true;
};
const onBackspace = () => {
const onBackspace = (): boolean => {
const quill = quillRef.current;
if (quill === undefined) {
@ -361,7 +374,7 @@ export const CompositionInput: React.ComponentType<Props> = props => {
return false;
};
const onChange = () => {
const onChange = (): void => {
const quill = quillRef.current;
const [text, mentions] = getTextAndMentions();
@ -471,6 +484,22 @@ export const CompositionInput: React.ComponentType<Props> = props => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(memberIds)]);
// Placing all of these callbacks inside of a ref since Quill is not able
// to re-render. We want to make sure that all these callbacks are fresh
// so that the consumers of this component won't deal with stale props or
// stale state as the result of calling them.
const unstaleCallbacks = {
onBackspace,
onChange,
onEnter,
onEscape,
onPickEmoji,
onShortKeyEnter,
onTab,
};
const callbacksRef = React.useRef(unstaleCallbacks);
callbacksRef.current = unstaleCallbacks;
const reactQuill = React.useMemo(
() => {
const delta = generateDelta(draftText || '', draftBodyRanges || []);
@ -478,7 +507,7 @@ export const CompositionInput: React.ComponentType<Props> = props => {
return (
<ReactQuill
className="module-composition-input__quill"
onChange={onChange}
onChange={() => callbacksRef.current.onChange()}
defaultValue={delta}
modules={{
toolbar: false,
@ -494,19 +523,29 @@ export const CompositionInput: React.ComponentType<Props> = props => {
},
keyboard: {
bindings: {
onEnter: { key: 13, handler: onEnter }, // 13 = Enter
onEnter: {
key: 13,
handler: () => callbacksRef.current.onEnter(),
}, // 13 = Enter
onShortKeyEnter: {
key: 13, // 13 = Enter
shortKey: true,
handler: onShortKeyEnter,
handler: () => callbacksRef.current.onShortKeyEnter(),
},
onEscape: { key: 27, handler: onEscape }, // 27 = Escape
onBackspace: { key: 8, handler: onBackspace }, // 8 = Backspace
onEscape: {
key: 27,
handler: () => callbacksRef.current.onEscape(),
}, // 27 = Escape
onBackspace: {
key: 8,
handler: () => callbacksRef.current.onBackspace(),
}, // 8 = Backspace
},
},
emojiCompletion: {
setEmojiPickerElement: setEmojiCompletionElement,
onPickEmoji,
onPickEmoji: (emoji: EmojiPickDataType) =>
callbacksRef.current.onPickEmoji(emoji),
skinTone,
},
mentionCompletion: {
@ -528,7 +567,10 @@ export const CompositionInput: React.ComponentType<Props> = props => {
// force the tab handler to be prepended, otherwise it won't be
// executed: https://github.com/quilljs/quill/issues/1967
keyboard.bindings[9].unshift({ key: 9, handler: onTab }); // 9 = Tab
keyboard.bindings[9].unshift({
key: 9,
handler: () => callbacksRef.current.onTab(),
}); // 9 = Tab
// also, remove the default \t insertion binding
keyboard.bindings[9].pop();
@ -583,7 +625,13 @@ export const CompositionInput: React.ComponentType<Props> = props => {
<Manager>
<Reference>
{({ ref }) => (
<div className="module-composition-input__input" ref={ref}>
<div
className={classNames(
'module-composition-input__input',
getClassName(moduleClassName, '__input')
)}
ref={ref}
>
<div
ref={scrollerRef}
onClick={focus}
@ -591,6 +639,10 @@ export const CompositionInput: React.ComponentType<Props> = props => {
'module-composition-input__input__scroller',
large
? 'module-composition-input__input__scroller--large'
: null,
getClassName(moduleClassName, '__scroller'),
large
? getClassName(moduleClassName, '__scroller--large')
: null
)}
>

View file

@ -0,0 +1,119 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { text } from '@storybook/addon-knobs';
import enMessages from '../../_locales/en/messages.json';
import { AttachmentType } from '../types/Attachment';
import { ForwardMessageModal, PropsType } from './ForwardMessageModal';
import { IMAGE_JPEG, MIMEType, VIDEO_MP4 } from '../types/MIME';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { setup as setupI18n } from '../../js/modules/i18n';
const createAttachment = (
props: Partial<AttachmentType> = {}
): AttachmentType => ({
contentType: text(
'attachment contentType',
props.contentType || ''
) as MIMEType,
fileName: text('attachment fileName', props.fileName || ''),
screenshot: props.screenshot,
url: text('attachment url', props.url || ''),
});
const story = storiesOf('Components/ForwardMessageModal', module);
const i18n = setupI18n('en', enMessages);
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?";
const LONG_DESCRIPTION =
"You're gonna love this description. Not only does it have a lot of characters, but it will also be truncated in the UI. How cool is that??";
const candidateConversations = Array.from(Array(100), () =>
getDefaultConversation()
);
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
attachments: overrideProps.attachments,
candidateConversations,
doForwardMessage: action('doForwardMessage'),
i18n,
isSticker: Boolean(overrideProps.isSticker),
linkPreview: overrideProps.linkPreview,
messageBody: text('messageBody', overrideProps.messageBody || ''),
onClose: action('onClose'),
onEditorStateChange: action('onEditorStateChange'),
onPickEmoji: action('onPickEmoji'),
onTextTooLong: action('onTextTooLong'),
onSetSkinTone: action('onSetSkinTone'),
recentEmojis: [],
removeLinkPreview: action('removeLinkPreview'),
skinTone: 0,
});
story.add('Modal', () => {
return <ForwardMessageModal {...createProps()} />;
});
story.add('with text', () => {
return <ForwardMessageModal {...createProps({ messageBody: 'sup' })} />;
});
story.add('a sticker', () => {
return <ForwardMessageModal {...createProps({ isSticker: true })} />;
});
story.add('link preview', () => {
return (
<ForwardMessageModal
{...createProps({
linkPreview: {
description: LONG_DESCRIPTION,
date: Date.now(),
domain: 'https://www.signal.org',
url: 'signal.org',
image: createAttachment({
url: '/fixtures/kitten-4-112-112.jpg',
contentType: IMAGE_JPEG,
}),
isStickerPack: false,
title: LONG_TITLE,
},
messageBody: 'signal.org',
})}
/>
);
});
story.add('media attachments', () => {
return (
<ForwardMessageModal
{...createProps({
attachments: [
createAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
}),
createAttachment({
contentType: VIDEO_MP4,
fileName: 'pixabay-Soap-Bubble-7141.mp4',
url: '/fixtures/pixabay-Soap-Bubble-7141.mp4',
screenshot: {
height: 112,
width: 112,
url: '/fixtures/kitten-4-112-112.jpg',
contentType: IMAGE_JPEG,
},
}),
],
messageBody: 'cats',
})}
/>
);
});

View file

@ -0,0 +1,409 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, {
FunctionComponent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import Measure, { MeasuredComponentProps } from 'react-measure';
import { noop } from 'lodash';
import classNames from 'classnames';
import { AttachmentList } from './conversation/AttachmentList';
import { AttachmentType } from '../types/Attachment';
import { Button } from './Button';
import { CompositionInput, InputApi } from './CompositionInput';
import { ContactCheckboxDisabledReason } from './conversationList/ContactCheckbox';
import { ConversationList, Row, RowType } from './ConversationList';
import { ConversationType } from '../state/ducks/conversations';
import { EmojiButton, Props as EmojiButtonProps } from './emoji/EmojiButton';
import { EmojiPickDataType } from './emoji/EmojiPicker';
import { LinkPreviewType } from '../types/message/LinkPreviews';
import { BodyRangeType, LocalizerType } from '../types/Util';
import { ModalHost } from './ModalHost';
import { StagedLinkPreview } from './conversation/StagedLinkPreview';
import { assert } from '../util/assert';
import { filterAndSortConversations } from '../util/filterAndSortConversations';
export type DataPropsType = {
attachments?: Array<AttachmentType>;
candidateConversations: ReadonlyArray<ConversationType>;
doForwardMessage: (
selectedContacts: Array<string>,
messageBody?: string,
attachments?: Array<AttachmentType>,
linkPreview?: LinkPreviewType
) => void;
i18n: LocalizerType;
isSticker: boolean;
linkPreview?: LinkPreviewType;
messageBody?: string;
onClose: () => void;
onEditorStateChange: (
messageText: string,
bodyRanges: Array<BodyRangeType>,
caretLocation?: number
) => unknown;
onTextTooLong: () => void;
} & Pick<EmojiButtonProps, 'recentEmojis' | 'skinTone'>;
type ActionPropsType = Pick<
EmojiButtonProps,
'onPickEmoji' | 'onSetSkinTone'
> & {
removeLinkPreview: () => void;
};
export type PropsType = DataPropsType & ActionPropsType;
const MAX_FORWARD = 5;
export const ForwardMessageModal: FunctionComponent<PropsType> = ({
attachments,
candidateConversations,
doForwardMessage,
i18n,
isSticker,
linkPreview,
messageBody,
onClose,
onEditorStateChange,
onPickEmoji,
onSetSkinTone,
onTextTooLong,
recentEmojis,
removeLinkPreview,
skinTone,
}) => {
const inputRef = useRef<null | HTMLInputElement>(null);
const inputApiRef = React.useRef<InputApi | undefined>();
const [selectedContacts, setSelectedContacts] = useState<
Array<ConversationType>
>([]);
const [searchTerm, setSearchTerm] = useState('');
const [filteredContacts, setFilteredContacts] = useState(
filterAndSortConversations(candidateConversations, '')
);
const [attachmentsToForward, setAttachmentsToForward] = useState(attachments);
const [isEditingMessage, setIsEditingMessage] = useState(false);
const [messageBodyText, setMessageBodyText] = useState(messageBody || '');
const isMessageEditable = !isSticker;
const hasSelectedMaximumNumberOfContacts =
selectedContacts.length >= MAX_FORWARD;
const selectedConversationIdsSet: Set<string> = useMemo(
() => new Set(selectedContacts.map(contact => contact.id)),
[selectedContacts]
);
const focusTextEditInput = React.useCallback(() => {
if (inputApiRef.current) {
inputApiRef.current.focus();
}
}, [inputApiRef]);
const insertEmoji = React.useCallback(
(e: EmojiPickDataType) => {
if (inputApiRef.current) {
inputApiRef.current.insertEmoji(e);
onPickEmoji(e);
}
},
[inputApiRef, onPickEmoji]
);
const forwardMessage = React.useCallback(() => {
if (!messageBodyText) {
return;
}
doForwardMessage(
selectedContacts.map(contact => contact.id),
messageBodyText,
attachmentsToForward,
linkPreview
);
}, [
attachmentsToForward,
doForwardMessage,
linkPreview,
messageBodyText,
selectedContacts,
]);
const hasContactsSelected = Boolean(selectedContacts.length);
const canForwardMessage =
hasContactsSelected &&
(Boolean(messageBodyText) ||
isSticker ||
(attachmentsToForward && attachmentsToForward.length));
const normalizedSearchTerm = searchTerm.trim();
useEffect(() => {
const timeout = setTimeout(() => {
setFilteredContacts(
filterAndSortConversations(candidateConversations, normalizedSearchTerm)
);
}, 200);
return () => {
clearTimeout(timeout);
};
}, [candidateConversations, normalizedSearchTerm, setFilteredContacts]);
const contactLookup = useMemo(() => {
const map = new Map();
candidateConversations.forEach(contact => {
map.set(contact.id, contact);
});
return map;
}, [candidateConversations]);
const toggleSelectedContact = useCallback(
(conversationId: string) => {
let removeContact = false;
const nextSelectedContacts = selectedContacts.filter(contact => {
if (contact.id === conversationId) {
removeContact = true;
return false;
}
return true;
});
if (removeContact) {
setSelectedContacts(nextSelectedContacts);
return;
}
const selectedContact = contactLookup.get(conversationId);
if (selectedContact) {
setSelectedContacts([...nextSelectedContacts, selectedContact]);
}
},
[contactLookup, selectedContacts, setSelectedContacts]
);
const rowCount = filteredContacts.length;
const getRow = (index: number): undefined | Row => {
const contact = filteredContacts[index];
if (!contact) {
return undefined;
}
const isSelected = selectedConversationIdsSet.has(contact.id);
let disabledReason: undefined | ContactCheckboxDisabledReason;
if (hasSelectedMaximumNumberOfContacts && !isSelected) {
disabledReason = ContactCheckboxDisabledReason.MaximumContactsSelected;
}
return {
type: RowType.ContactCheckbox,
contact,
isChecked: isSelected,
disabledReason,
};
};
return (
<ModalHost onClose={onClose}>
<div className="module-ForwardMessageModal">
<div
className={classNames('module-ForwardMessageModal__header', {
'module-ForwardMessageModal__header--edit': isEditingMessage,
})}
>
{isEditingMessage ? (
<button
aria-label={i18n('back')}
className="module-ForwardMessageModal__header--back"
onClick={() => setIsEditingMessage(false)}
type="button"
>
&nbsp;
</button>
) : (
<button
aria-label={i18n('cancel')}
className="module-ForwardMessageModal__header--cancel"
onClick={onClose}
type="button"
>
{i18n('cancel')}
</button>
)}
<h1>{i18n('forwardMessage')}</h1>
</div>
{isEditingMessage ? (
<div className="module-ForwardMessageModal__main-body">
{linkPreview ? (
<div className="module-ForwardMessageModal--link-preview">
<StagedLinkPreview
date={linkPreview.date || null}
description={linkPreview.description || ''}
domain={linkPreview.url}
i18n={i18n}
image={linkPreview.image}
isLoaded
onClose={() => removeLinkPreview()}
title={linkPreview.title}
/>
</div>
) : null}
{attachmentsToForward && attachmentsToForward.length ? (
<AttachmentList
attachments={attachmentsToForward}
i18n={i18n}
onCloseAttachment={(attachment: AttachmentType) => {
const newAttachments = attachmentsToForward.filter(
currentAttachment => currentAttachment !== attachment
);
setAttachmentsToForward(newAttachments);
}}
/>
) : null}
<div className="module-ForwardMessageModal__text-edit-area">
<CompositionInput
clearQuotedMessage={shouldNeverBeCalled}
draftText={messageBodyText}
getQuotedMessage={noop}
i18n={i18n}
inputApi={inputApiRef}
large
moduleClassName="module-ForwardMessageModal__input"
onEditorStateChange={(
messageText,
bodyRanges,
caretLocation
) => {
setMessageBodyText(messageText);
onEditorStateChange(messageText, bodyRanges, caretLocation);
}}
onPickEmoji={onPickEmoji}
onSubmit={forwardMessage}
onTextTooLong={onTextTooLong}
/>
<div className="module-ForwardMessageModal__emoji">
<EmojiButton
doSend={noop}
i18n={i18n}
onClose={focusTextEditInput}
onPickEmoji={insertEmoji}
onSetSkinTone={onSetSkinTone}
recentEmojis={recentEmojis}
skinTone={skinTone}
/>
</div>
</div>
</div>
) : (
<div className="module-ForwardMessageModal__main-body">
<div className="module-ForwardMessageModal__search">
<i className="module-ForwardMessageModal__search--icon" />
<input
type="text"
className="module-ForwardMessageModal__search--input"
disabled={candidateConversations.length === 0}
placeholder={i18n('contactSearchPlaceholder')}
onChange={event => {
setSearchTerm(event.target.value);
}}
ref={inputRef}
value={searchTerm}
/>
</div>
{candidateConversations.length ? (
<Measure bounds>
{({ contentRect, measureRef }: MeasuredComponentProps) => {
// We disable this ESLint rule because we're capturing a bubbled keydown
// event. See [this note in the jsx-a11y docs][0].
//
// [0]: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/c275964f52c35775208bd00cb612c6f82e42e34f/docs/rules/no-static-element-interactions.md#case-the-event-handler-is-only-being-used-to-capture-bubbled-events
/* eslint-disable jsx-a11y/no-static-element-interactions */
return (
<div
className="module-ForwardMessageModal__list-wrapper"
ref={measureRef}
onKeyDown={event => {
if (event.key === 'Enter') {
inputRef.current?.focus();
}
}}
>
<ConversationList
dimensions={contentRect.bounds}
getRow={getRow}
i18n={i18n}
onClickArchiveButton={shouldNeverBeCalled}
onClickContactCheckbox={(
conversationId: string,
disabledReason:
| undefined
| ContactCheckboxDisabledReason
) => {
if (
disabledReason !==
ContactCheckboxDisabledReason.MaximumContactsSelected
) {
toggleSelectedContact(conversationId);
}
}}
onSelectConversation={shouldNeverBeCalled}
renderMessageSearchResult={() => {
shouldNeverBeCalled();
return <div />;
}}
rowCount={rowCount}
shouldRecomputeRowHeights={false}
showChooseGroupMembers={shouldNeverBeCalled}
startNewConversationFromPhoneNumber={
shouldNeverBeCalled
}
/>
</div>
);
/* eslint-enable jsx-a11y/no-static-element-interactions */
}}
</Measure>
) : (
<div className="module-ForwardMessageModal__no-candidate-contacts">
{i18n('noContactsFound')}
</div>
)}
</div>
)}
<div className="module-ForwardMessageModal__footer">
<div>
{Boolean(selectedContacts.length) &&
selectedContacts.map(contact => contact.title).join(', ')}
</div>
<div>
{isEditingMessage || !isMessageEditable ? (
<Button
aria-label={i18n('ForwardMessageModal--continue')}
className="module-ForwardMessageModal__send-button module-ForwardMessageModal__send-button--forward"
disabled={!canForwardMessage}
onClick={forwardMessage}
/>
) : (
<Button
aria-label={i18n('forwardMessage')}
className="module-ForwardMessageModal__send-button module-ForwardMessageModal__send-button--continue"
disabled={!hasContactsSelected}
onClick={() => setIsEditingMessage(true)}
/>
)}
</div>
</div>
</div>
</ModalHost>
);
};
function shouldNeverBeCalled(..._args: ReadonlyArray<unknown>): void {
assert(false, 'This should never be called. Doing nothing');
}

View file

@ -18,10 +18,10 @@ import {
export type Props = {
attachments: Array<AttachmentType>;
i18n: LocalizerType;
onClickAttachment: (attachment: AttachmentType) => void;
onAddAttachment?: () => void;
onClickAttachment?: (attachment: AttachmentType) => void;
onClose?: () => void;
onCloseAttachment: (attachment: AttachmentType) => void;
onAddAttachment: () => void;
onClose: () => void;
};
const IMAGE_WIDTH = 120;
@ -47,7 +47,7 @@ export const AttachmentList = ({
return (
<div className="module-attachments">
{attachments.length > 1 ? (
{onClose && attachments.length > 1 ? (
<div className="module-attachments__header">
<button
type="button"
@ -105,7 +105,7 @@ export const AttachmentList = ({
/>
);
})}
{allVisualAttachments ? (
{allVisualAttachments && onAddAttachment ? (
<StagedPlaceholderAttachment onClick={onAddAttachment} i18n={i18n} />
) : null}
</div>

View file

@ -130,6 +130,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
showExpiredOutgoingTapToViewToast: action(
'showExpiredOutgoingTapToViewToast'
),
showForwardMessageModal: action('showForwardMessageModal'),
showMessageDetail: action('showMessageDetail'),
showVisualAttachment: action('showVisualAttachment'),
status: overrideProps.status || 'sent',

View file

@ -170,6 +170,7 @@ export type PropsActions = {
) => void;
replyToMessage: (id: string) => void;
retrySend: (id: string) => void;
showForwardMessageModal: (id: string) => void;
deleteMessage: (id: string) => void;
deleteMessageForEveryone: (id: string) => void;
showMessageDetail: (id: string) => void;
@ -1401,6 +1402,7 @@ export class Message extends React.PureComponent<Props, State> {
canReply,
deleteMessage,
deleteMessageForEveryone,
deletedForEveryone,
direction,
i18n,
id,
@ -1408,10 +1410,13 @@ export class Message extends React.PureComponent<Props, State> {
isTapToView,
replyToMessage,
retrySend,
showForwardMessageModal,
showMessageDetail,
status,
} = this.props;
const canForward = !isTapToView && !deletedForEveryone;
const { canDeleteForEveryone } = this.state;
const showRetry =
@ -1499,6 +1504,22 @@ export class Message extends React.PureComponent<Props, State> {
{i18n('retrySend')}
</MenuItem>
) : null}
{canForward ? (
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__forward-message',
}}
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
showForwardMessageModal(id);
}}
>
{i18n('forwardMessage')}
</MenuItem>
) : null}
<MenuItem
attributes={{
className:

View file

@ -72,6 +72,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
showContactModal: () => null,
showExpiredIncomingTapToViewToast: () => null,
showExpiredOutgoingTapToViewToast: () => null,
showForwardMessageModal: () => null,
showVisualAttachment: () => null,
});

View file

@ -65,6 +65,7 @@ export type Props = {
| 'showContactModal'
| 'showExpiredIncomingTapToViewToast'
| 'showExpiredOutgoingTapToViewToast'
| 'showForwardMessageModal'
| 'showVisualAttachment'
>;
@ -235,6 +236,7 @@ export class MessageDetail extends React.Component<Props> {
showContactModal,
showExpiredIncomingTapToViewToast,
showExpiredOutgoingTapToViewToast,
showForwardMessageModal,
showVisualAttachment,
} = this.props;
@ -263,6 +265,7 @@ export class MessageDetail extends React.Component<Props> {
renderEmojiPicker={renderEmojiPicker}
replyToMessage={replyToMessage}
retrySend={retrySend}
showForwardMessageModal={showForwardMessageModal}
scrollToQuotedMessage={() => {
assert(
false,

View file

@ -61,6 +61,7 @@ const defaultMessageProps: MessagesProps = {
showContactModal: () => null,
showExpiredIncomingTapToViewToast: () => null,
showExpiredOutgoingTapToViewToast: () => null,
showForwardMessageModal: () => null,
showMessageDetail: () => null,
showVisualAttachment: () => null,
status: 'sent',

View file

@ -249,6 +249,7 @@ const actions = () => ({
showExpiredOutgoingTapToViewToast: action(
'showExpiredOutgoingTapToViewToast'
),
showForwardMessageModal: action('showForwardMessageModal'),
showIdentity: action('showIdentity'),

View file

@ -53,6 +53,7 @@ const getDefaultProps = () => ({
openConversation: action('openConversation'),
showContactDetail: action('showContactDetail'),
showContactModal: action('showContactModal'),
showForwardMessageModal: action('showForwardMessageModal'),
showVisualAttachment: action('showVisualAttachment'),
downloadAttachment: action('downloadAttachment'),
displayTapToViewMessage: action('displayTapToViewMessage'),

View file

@ -22,6 +22,7 @@ export type PropsDataType = {
color?: ColorType;
disabledReason?: ContactCheckboxDisabledReason;
id: string;
isMe?: boolean;
isChecked: boolean;
name?: string;
phoneNumber?: string;
@ -49,6 +50,7 @@ export const ContactCheckbox: FunctionComponent<PropsType> = React.memo(
i18n,
id,
isChecked,
isMe,
name,
onClick,
phoneNumber,
@ -58,7 +60,9 @@ export const ContactCheckbox: FunctionComponent<PropsType> = React.memo(
}) => {
const disabled = Boolean(disabledReason);
const headerName = (
const headerName = isMe ? (
i18n('noteToSelf')
) : (
<ContactName
phoneNumber={phoneNumber}
name={name}
@ -91,6 +95,7 @@ export const ContactCheckbox: FunctionComponent<PropsType> = React.memo(
headerName={headerName}
i18n={i18n}
id={id}
isMe={isMe}
isSelected={false}
messageText={messageText}
name={name}

View file

@ -1397,6 +1397,9 @@ export class ConversationModel extends window.Backbone.Model<
sortedGroupMembers,
timestamp,
title: this.getTitle()!,
searchableTitle: this.isMe()
? window.i18n('noteToSelf')
: this.getTitle(),
type: (this.isPrivate() ? 'direct' : 'group') as ConversationTypeType,
unreadCount: this.get('unreadCount')! || 0,
};
@ -2235,7 +2238,7 @@ export class ConversationModel extends window.Backbone.Model<
});
}
getUntrusted(): Backbone.Collection {
getUntrusted(): Backbone.Collection<ConversationModel> {
if (this.isPrivate()) {
if (this.isUntrusted()) {
return new window.Backbone.Collection([this]);
@ -2243,16 +2246,14 @@ export class ConversationModel extends window.Backbone.Model<
return new window.Backbone.Collection();
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const results = this.contactCollection!.map(contact => {
if (contact.isMe()) {
return [false, contact];
}
return [contact.isUntrusted(), contact];
});
return new window.Backbone.Collection(
results.filter(result => result[0]).map(result => result[1])
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.contactCollection!.filter(contact => {
if (contact.isMe()) {
return false;
}
return contact.isUntrusted();
})
);
}
@ -3320,13 +3321,21 @@ export class ConversationModel extends window.Backbone.Model<
quote: WhatIsThis,
preview: WhatIsThis,
sticker?: WhatIsThis,
mentions?: BodyRangesType
mentions?: BodyRangesType,
{ dontClearDraft = false } = {}
): void {
this.clearTypingTimers();
const { clearUnreadMetrics } = window.reduxActions.conversations;
clearUnreadMetrics(this.id);
const mandatoryProfileSharingEnabled = window.Signal.RemoteConfig.isEnabled(
'desktop.mandatoryProfileSharing'
);
if (mandatoryProfileSharingEnabled && !this.get('profileSharing')) {
this.set({ profileSharing: true });
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const destination = this.getSendTarget()!;
const expireTimer = this.get('expireTimer');
@ -3382,15 +3391,22 @@ export class ConversationModel extends window.Backbone.Model<
Message: window.Whisper.Message,
});
const draftProperties = dontClearDraft
? {}
: {
draft: null,
draftTimestamp: null,
lastMessage: model.getNotificationText(),
lastMessageStatus: 'sending' as const,
};
this.set({
lastMessage: model.getNotificationText(),
lastMessageStatus: 'sending',
...draftProperties,
active_at: now,
timestamp: now,
isArchived: false,
draft: null,
draftTimestamp: null,
});
this.incrementSentMessageCount();
window.Signal.Data.updateConversation(this.attributes);

View file

@ -46,6 +46,7 @@ import {
import { PropsType as ProfileChangeNotificationPropsType } from '../components/conversation/ProfileChangeNotification';
import { AttachmentType, isImage, isVideo } from '../types/Attachment';
import { MIMEType } from '../types/MIME';
import { LinkPreviewType } from '../types/message/LinkPreviews';
/* eslint-disable camelcase */
/* eslint-disable more/no-then */
@ -1139,7 +1140,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
};
}
getPropsForPreview(): WhatIsThis {
getPropsForPreview(): Array<LinkPreviewType> {
const previews = this.get('preview') || [];
return previews.map(preview => ({
@ -1592,6 +1593,17 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
return { text: '' };
}
getRawText(): string {
const body = (this.get('body') || '').trim();
const bodyRanges = this.processBodyRanges();
if (bodyRanges) {
return getTextWithMentions(bodyRanges, body);
}
return body;
}
getNotificationText(): string {
const { text, emoji } = this.getNotificationData();

View file

@ -7,6 +7,7 @@ import { actions as conversations } from './ducks/conversations';
import { actions as emojis } from './ducks/emojis';
import { actions as expiration } from './ducks/expiration';
import { actions as items } from './ducks/items';
import { actions as linkPreviews } from './ducks/linkPreviews';
import { actions as network } from './ducks/network';
import { actions as safetyNumber } from './ducks/safetyNumber';
import { actions as search } from './ducks/search';
@ -21,6 +22,7 @@ export const mapDispatchToProps = {
...emojis,
...expiration,
...items,
...linkPreviews,
...network,
...safetyNumber,
...search,

View file

@ -108,6 +108,7 @@ export type ConversationType = {
// This is used by the CompositionInput for @mentions
sortedGroupMembers?: Array<ConversationType>;
title: string;
searchableTitle?: string;
unreadCount?: number;
isSelected?: boolean;
typingContact?: {

View file

@ -2,11 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { omit } from 'lodash';
import { createSelector } from 'reselect';
import { useSelector } from 'react-redux';
import { StateType } from '../reducer';
import * as storageShim from '../../shims/storage';
import { isShortName } from '../../components/emoji/lib';
import { useBoundActions } from '../../util/hooks';
// State
@ -54,6 +50,7 @@ export type ItemsActionType =
// Action Creators
export const actions = {
onSetSkinTone,
putItem,
putItemExternal,
removeItem,
@ -72,6 +69,10 @@ function putItem(key: string, value: unknown): ItemPutAction {
};
}
function onSetSkinTone(tone: number): ItemPutAction {
return putItem('skinTone', tone);
}
function putItemExternal(key: string, value: unknown): ItemPutExternalAction {
return {
type: 'items/PUT_EXTERNAL',
@ -133,13 +134,3 @@ export function reducer(
return state;
}
// Selectors
const selectRecentEmojis = createSelector(
({ emojis }: StateType) => emojis.recents,
recents => recents.filter(isShortName)
);
export const useRecentEmojis = (): Array<string> =>
useSelector(selectRecentEmojis);

View file

@ -0,0 +1,77 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { LinkPreviewType } from '../../types/message/LinkPreviews';
// State
export type LinkPreviewsStateType = {
readonly linkPreview?: LinkPreviewType;
};
// Actions
const ADD_PREVIEW = 'linkPreviews/ADD_PREVIEW';
const REMOVE_PREVIEW = 'linkPreviews/REMOVE_PREVIEW';
type AddLinkPreviewActionType = {
type: 'linkPreviews/ADD_PREVIEW';
payload: LinkPreviewType;
};
type RemoveLinkPreviewActionType = {
type: 'linkPreviews/REMOVE_PREVIEW';
};
type LinkPreviewsActionType =
| AddLinkPreviewActionType
| RemoveLinkPreviewActionType;
// Action Creators
export const actions = {
addLinkPreview,
removeLinkPreview,
};
function addLinkPreview(payload: LinkPreviewType): AddLinkPreviewActionType {
return {
type: ADD_PREVIEW,
payload,
};
}
function removeLinkPreview(): RemoveLinkPreviewActionType {
return {
type: REMOVE_PREVIEW,
};
}
// Reducer
export function getEmptyState(): LinkPreviewsStateType {
return {
linkPreview: undefined,
};
}
export function reducer(
state: Readonly<LinkPreviewsStateType> = getEmptyState(),
action: Readonly<LinkPreviewsActionType>
): LinkPreviewsStateType {
if (action.type === ADD_PREVIEW) {
const { payload } = action;
return {
linkPreview: payload,
};
}
if (action.type === REMOVE_PREVIEW) {
return {
linkPreview: undefined,
};
}
return state;
}

View file

@ -9,6 +9,7 @@ import { reducer as conversations } from './ducks/conversations';
import { reducer as emojis } from './ducks/emojis';
import { reducer as expiration } from './ducks/expiration';
import { reducer as items } from './ducks/items';
import { reducer as linkPreviews } from './ducks/linkPreviews';
import { reducer as network } from './ducks/network';
import { reducer as safetyNumber } from './ducks/safetyNumber';
import { reducer as search } from './ducks/search';
@ -23,6 +24,7 @@ export const reducer = combineReducers({
emojis,
expiration,
items,
linkPreviews,
network,
safetyNumber,
search,

View file

@ -0,0 +1,21 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { Provider } from 'react-redux';
import { Store } from 'redux';
import {
SmartForwardMessageModal,
SmartForwardMessageModalProps,
} from '../smart/ForwardMessageModal';
export const createForwardMessageModal = (
store: Store,
props: SmartForwardMessageModalProps
): React.ReactElement => (
<Provider store={store}>
<SmartForwardMessageModal {...props} />
</Provider>
);

View file

@ -18,7 +18,6 @@ import {
OneTimeModalState,
PreJoinConversationType,
} from '../ducks/conversations';
import { LocalizerType } from '../../types/Util';
import { getOwn } from '../../util/getOwn';
import { deconstructLookup } from '../../util/deconstructLookup';
import type { CallsByConversationType } from '../ducks/calling';
@ -350,6 +349,29 @@ function canComposeConversation(conversation: ConversationType): boolean {
);
}
export const getAllComposableConversations = createSelector(
getConversationLookup,
(conversationLookup: ConversationLookupType): Array<ConversationType> =>
Object.values(conversationLookup).filter(
contact =>
!contact.isBlocked &&
!isConversationUnregistered(contact) &&
(isString(contact.name) || contact.profileSharing)
)
);
const getContactsAndMe = createSelector(
getConversationLookup,
(conversationLookup: ConversationLookupType): Array<ConversationType> =>
Object.values(conversationLookup).filter(
contact =>
contact.type === 'direct' &&
!contact.isBlocked &&
!isConversationUnregistered(contact) &&
(isString(contact.name) || contact.profileSharing)
)
);
/**
* This returns contacts for the composer and group members, which isn't just your primary
* system contacts. It may include false positives, which is better than missing contacts.
@ -381,29 +403,14 @@ const getNormalizedComposerConversationSearchTerm = createSelector(
(searchTerm: string): string => searchTerm.trim()
);
const getNoteToSelfTitle = createSelector(getIntl, (i18n: LocalizerType) =>
i18n('noteToSelf').toLowerCase()
);
export const getComposeContacts = createSelector(
getNormalizedComposerConversationSearchTerm,
getComposableContacts,
getMe,
getNoteToSelfTitle,
getContactsAndMe,
(
searchTerm: string,
contacts: Array<ConversationType>,
noteToSelf: ConversationType,
noteToSelfTitle: string
contacts: Array<ConversationType>
): Array<ConversationType> => {
const result: Array<ConversationType> = filterAndSortConversations(
contacts,
searchTerm
);
if (!searchTerm || noteToSelfTitle.includes(searchTerm)) {
result.push(noteToSelf);
}
return result;
return filterAndSortConversations(contacts, searchTerm);
}
);

View file

@ -0,0 +1,16 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { createSelector } from 'reselect';
import { useSelector } from 'react-redux';
import { StateType } from '../reducer';
import { isShortName } from '../../components/emoji/lib';
export const selectRecentEmojis = createSelector(
({ emojis }: StateType) => emojis.recents,
recents => recents.filter(isShortName)
);
export const useRecentEmojis = (): Array<string> =>
useSelector(selectRecentEmojis);

View file

@ -0,0 +1,21 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { createSelector } from 'reselect';
import { StateType } from '../reducer';
export const getLinkPreview = createSelector(
({ linkPreviews }: StateType) => linkPreviews.linkPreview,
linkPreview => {
if (linkPreview) {
return {
...linkPreview,
domain: window.Signal.LinkPreviews.getDomain(linkPreview.url),
isLoaded: true,
};
}
return undefined;
}
);

View file

@ -2,13 +2,12 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { get } from 'lodash';
import { mapDispatchToProps } from '../actions';
import { CompositionArea } from '../../components/CompositionArea';
import { StateType } from '../reducer';
import { isShortName } from '../../components/emoji/lib';
import { selectRecentEmojis } from '../selectors/emojis';
import { getIntl } from '../selectors/user';
import { getConversationSelector } from '../selectors/conversations';
import {
@ -24,11 +23,6 @@ type ExternalProps = {
id: string;
};
const selectRecentEmojis = createSelector(
({ emojis }: StateType) => emojis.recents,
recents => recents.filter(isShortName)
);
const mapStateToProps = (state: StateType, props: ExternalProps) => {
const { id } = props;

View file

@ -5,7 +5,7 @@ import * as React from 'react';
import { useSelector } from 'react-redux';
import { get } from 'lodash';
import { StateType } from '../reducer';
import { useActions as useItemActions, useRecentEmojis } from '../ducks/items';
import { useRecentEmojis } from '../selectors/emojis';
import { useActions as useEmojiActions } from '../ducks/emojis';
import {
@ -17,8 +17,8 @@ import { LocalizerType } from '../../types/Util';
export const SmartEmojiPicker = React.forwardRef<
HTMLDivElement,
Pick<EmojiPickerProps, 'onPickEmoji' | 'onClose' | 'style'>
>(({ onPickEmoji, onClose, style }, ref) => {
Pick<EmojiPickerProps, 'onPickEmoji' | 'onSetSkinTone' | 'onClose' | 'style'>
>(({ onPickEmoji, onSetSkinTone, onClose, style }, ref) => {
const i18n = useSelector<StateType, LocalizerType>(getIntl);
const skinTone = useSelector<StateType, number>(state =>
get(state, ['items', 'skinTone'], 0)
@ -26,15 +26,6 @@ export const SmartEmojiPicker = React.forwardRef<
const recentEmojis = useRecentEmojis();
const { putItem } = useItemActions();
const onSetSkinTone = React.useCallback(
tone => {
putItem('skinTone', tone);
},
[putItem]
);
const { onUseEmoji } = useEmojiActions();
const handlePickEmoji = React.useCallback(

View file

@ -0,0 +1,79 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { connect } from 'react-redux';
import { get } from 'lodash';
import { mapDispatchToProps } from '../actions';
import {
ForwardMessageModal,
DataPropsType,
} from '../../components/ForwardMessageModal';
import { StateType } from '../reducer';
import { BodyRangeType } from '../../types/Util';
import { LinkPreviewType } from '../../types/message/LinkPreviews';
import { getAllComposableConversations } from '../selectors/conversations';
import { getLinkPreview } from '../selectors/linkPreviews';
import { getIntl } from '../selectors/user';
import { selectRecentEmojis } from '../selectors/emojis';
import { AttachmentType } from '../../types/Attachment';
export type SmartForwardMessageModalProps = {
attachments?: Array<AttachmentType>;
doForwardMessage: (
selectedContacts: Array<string>,
messageBody?: string,
attachments?: Array<AttachmentType>,
linkPreview?: LinkPreviewType
) => void;
isSticker: boolean;
messageBody?: string;
onClose: () => void;
onEditorStateChange: (
messageText: string,
bodyRanges: Array<BodyRangeType>,
caretLocation?: number
) => unknown;
onTextTooLong: () => void;
};
const mapStateToProps = (
state: StateType,
props: SmartForwardMessageModalProps
): DataPropsType => {
const {
attachments,
doForwardMessage,
isSticker,
messageBody,
onClose,
onEditorStateChange,
onTextTooLong,
} = props;
const candidateConversations = getAllComposableConversations(state);
const recentEmojis = selectRecentEmojis(state);
const skinTone = get(state, ['items', 'skinTone'], 0);
const linkPreview = getLinkPreview(state);
return {
attachments,
candidateConversations,
doForwardMessage,
i18n: getIntl(state),
isSticker,
linkPreview,
messageBody,
onClose,
onEditorStateChange,
recentEmojis,
skinTone,
onTextTooLong,
};
};
const smart = connect(mapStateToProps, {
...mapDispatchToProps,
onPickEmoji: mapDispatchToProps.onUseEmoji,
});
export const SmartForwardMessageModal = smart(ForwardMessageModal);

View file

@ -42,6 +42,7 @@ export type OwnProps = {
| 'showContactModal'
| 'showExpiredIncomingTapToViewToast'
| 'showExpiredOutgoingTapToViewToast'
| 'showForwardMessageModal'
| 'showVisualAttachment'
>;
@ -72,6 +73,7 @@ const mapStateToProps = (
showContactModal,
showExpiredIncomingTapToViewToast,
showExpiredOutgoingTapToViewToast,
showForwardMessageModal,
showVisualAttachment,
} = props;
@ -103,6 +105,7 @@ const mapStateToProps = (
showContactModal,
showExpiredIncomingTapToViewToast,
showExpiredOutgoingTapToViewToast,
showForwardMessageModal,
showVisualAttachment,
};
};

View file

@ -7,6 +7,7 @@ import { actions as conversations } from './ducks/conversations';
import { actions as emojis } from './ducks/emojis';
import { actions as expiration } from './ducks/expiration';
import { actions as items } from './ducks/items';
import { actions as linkPreviews } from './ducks/linkPreviews';
import { actions as network } from './ducks/network';
import { actions as safetyNumber } from './ducks/safetyNumber';
import { actions as search } from './ducks/search';
@ -21,6 +22,7 @@ export type ReduxActions = {
emojis: typeof emojis;
expiration: typeof expiration;
items: typeof items;
linkPreviews: typeof linkPreviews;
network: typeof network;
safetyNumber: typeof safetyNumber;
search: typeof search;

View file

@ -0,0 +1,48 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import {
actions,
getEmptyState,
reducer,
} from '../../../state/ducks/linkPreviews';
import { LinkPreviewType } from '../../../types/message/LinkPreviews';
describe('both/state/ducks/linkPreviews', () => {
function getMockLinkPreview(): LinkPreviewType {
return {
title: 'Hello World',
domain: 'signal.org',
url: 'https://www.signal.org',
isStickerPack: false,
};
}
describe('addLinkPreview', () => {
const { addLinkPreview } = actions;
it('updates linkPreview', () => {
const state = getEmptyState();
const linkPreview = getMockLinkPreview();
const nextState = reducer(state, addLinkPreview(linkPreview));
assert.strictEqual(nextState.linkPreview, linkPreview);
});
});
describe('removeLinkPreview', () => {
const { removeLinkPreview } = actions;
it('removes linkPreview', () => {
const state = {
...getEmptyState(),
linkPreview: getMockLinkPreview(),
};
const nextState = reducer(state, removeLinkPreview());
assert.isUndefined(nextState.linkPreview);
});
});
});

View file

@ -46,6 +46,7 @@ describe('both/state/selectors/conversations', () => {
return {
id,
type: 'direct',
searchableTitle: `${id} title`,
title: `${id} title`,
};
}
@ -478,6 +479,13 @@ describe('both/state/selectors/conversations', () => {
const getRootStateWithConversations = (searchTerm = ''): StateType => {
const result = getRootState(searchTerm);
Object.assign(result.conversations.conversationLookup, {
'convo-0': {
...getDefaultConversation('convo-0'),
name: 'Me, Myself, and I',
title: 'Me, Myself, and I',
searchableTitle: 'Note to Self',
isMe: true,
},
'convo-1': {
...getDefaultConversation('convo-1'),
name: 'In System Contacts',
@ -517,32 +525,20 @@ describe('both/state/selectors/conversations', () => {
return result;
};
it('only returns Note to Self when there are no other contacts', () => {
const state = getRootState();
const result = getComposeContacts(state);
assert.lengthOf(result, 1);
assert.strictEqual(result[0]?.id, 'our-conversation-id');
});
it("returns no results when search doesn't match Note to Self and there are no other contacts", () => {
it('returns no results when there are no contacts', () => {
const state = getRootState('foo bar baz');
const result = getComposeContacts(state);
assert.isEmpty(result);
});
it('returns contacts with Note to Self at the end when there is no search term', () => {
it('includes Note to Self', () => {
const state = getRootStateWithConversations();
const result = getComposeContacts(state);
const ids = result.map(contact => contact.id);
assert.deepEqual(ids, [
'convo-1',
'convo-5',
'convo-6',
'our-conversation-id',
]);
// convo-6 is sorted last because it doesn't have a name
assert.deepEqual(ids, ['convo-1', 'convo-5', 'convo-0', 'convo-6']);
});
it('can search for contacts', () => {
@ -553,6 +549,22 @@ describe('both/state/selectors/conversations', () => {
// NOTE: convo-6 matches because you can't write "Sharing" without "in"
assert.deepEqual(ids, ['convo-1', 'convo-5', 'convo-6']);
});
it('can search for note to self', () => {
const state = getRootStateWithConversations('note');
const result = getComposeContacts(state);
const ids = result.map(contact => contact.id);
assert.deepEqual(ids, ['convo-0']);
});
it('returns not to self when searching for your own name', () => {
const state = getRootStateWithConversations('Myself');
const result = getComposeContacts(state);
const ids = result.map(contact => contact.id);
assert.deepEqual(ids, ['convo-0']);
});
});
describe('#getComposeGroups', () => {

View file

@ -11,6 +11,10 @@ const FUSE_OPTIONS: FuseOptions<ConversationType> = {
threshold: 0.05,
tokenize: true,
keys: [
{
name: 'searchableTitle',
weight: 1,
},
{
name: 'title',
weight: 1,

View file

@ -16238,7 +16238,7 @@
"rule": "React-useRef",
"path": "ts/components/CompositionInput.js",
"line": " const emojiCompletionRef = React.useRef();",
"lineNumber": 56,
"lineNumber": 62,
"reasonCategory": "falseMatch",
"updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Doesn't refer to a DOM element."
@ -16247,7 +16247,7 @@
"rule": "React-useRef",
"path": "ts/components/CompositionInput.js",
"line": " const mentionCompletionRef = React.useRef();",
"lineNumber": 57,
"lineNumber": 63,
"reasonCategory": "falseMatch",
"updated": "2020-10-26T23:54:34.273Z",
"reasonDetail": "Doesn't refer to a DOM element."
@ -16256,7 +16256,7 @@
"rule": "React-useRef",
"path": "ts/components/CompositionInput.js",
"line": " const quillRef = React.useRef();",
"lineNumber": 58,
"lineNumber": 64,
"reasonCategory": "falseMatch",
"updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Doesn't refer to a DOM element."
@ -16265,7 +16265,7 @@
"rule": "React-useRef",
"path": "ts/components/CompositionInput.js",
"line": " const scrollerRef = React.useRef(null);",
"lineNumber": 59,
"lineNumber": 65,
"reasonCategory": "usageTrusted",
"updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Used with Quill for scrolling."
@ -16274,7 +16274,7 @@
"rule": "React-useRef",
"path": "ts/components/CompositionInput.js",
"line": " const propsRef = React.useRef(props);",
"lineNumber": 60,
"lineNumber": 66,
"reasonCategory": "falseMatch",
"updated": "2020-10-26T19:12:24.410Z",
"reasonDetail": "Doesn't refer to a DOM element."
@ -16283,11 +16283,27 @@
"rule": "React-useRef",
"path": "ts/components/CompositionInput.js",
"line": " const memberRepositoryRef = React.useRef(new memberRepository_1.MemberRepository());",
"lineNumber": 61,
"lineNumber": 67,
"reasonCategory": "falseMatch",
"updated": "2020-10-26T23:56:13.482Z",
"reasonDetail": "Doesn't refer to a DOM element."
},
{
"rule": "React-useRef",
"path": "ts/components/CompositionInput.js",
"line": " const callbacksRef = React.useRef(unstaleCallbacks);",
"lineNumber": 338,
"reasonCategory": "usageTrusted",
"updated": "2021-04-21T21:35:38.757Z"
},
{
"rule": "React-useRef",
"path": "ts/components/CompositionInput.tsx",
"line": " const callbacksRef = React.useRef(unstaleCallbacks);",
"lineNumber": 500,
"reasonCategory": "usageTrusted",
"updated": "2021-04-21T21:35:38.757Z"
},
{
"rule": "React-useRef",
"path": "ts/components/ContactPills.js",
@ -16315,6 +16331,22 @@
"updated": "2020-11-11T21:56:04.179Z",
"reasonDetail": "Needed to render the remote video element."
},
{
"rule": "React-useRef",
"path": "ts/components/ForwardMessageModal.js",
"line": " const inputRef = react_1.useRef(null);",
"lineNumber": 44,
"reasonCategory": "usageTrusted",
"updated": "2021-04-19T18:13:21.664Z"
},
{
"rule": "React-useRef",
"path": "ts/components/ForwardMessageModal.js",
"line": " const inputApiRef = react_1.default.useRef();",
"lineNumber": 45,
"reasonCategory": "usageTrusted",
"updated": "2021-04-19T18:13:21.664Z"
},
{
"rule": "React-useRef",
"path": "ts/components/GroupCallOverflowArea.js",
@ -16557,7 +16589,7 @@
"rule": "React-createRef",
"path": "ts/components/conversation/Message.tsx",
"line": " public focusRef: React.RefObject<HTMLDivElement> = React.createRef();",
"lineNumber": 241,
"lineNumber": 242,
"reasonCategory": "usageTrusted",
"updated": "2021-03-05T19:57:01.431Z",
"reasonDetail": "Used for managing focus only"
@ -16566,7 +16598,7 @@
"rule": "React-createRef",
"path": "ts/components/conversation/Message.tsx",
"line": " public audioButtonRef: React.RefObject<HTMLButtonElement> = React.createRef();",
"lineNumber": 243,
"lineNumber": 244,
"reasonCategory": "usageTrusted",
"updated": "2021-03-05T19:57:01.431Z",
"reasonDetail": "Used for propagating click from the Message to MessageAudio's button"
@ -16575,7 +16607,7 @@
"rule": "React-createRef",
"path": "ts/components/conversation/Message.tsx",
"line": " > = React.createRef();",
"lineNumber": 247,
"lineNumber": 248,
"reasonCategory": "usageTrusted",
"updated": "2021-03-05T19:57:01.431Z",
"reasonDetail": "Used for detecting clicks outside reaction viewer"

View file

@ -4,11 +4,12 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { AttachmentType } from '../types/Attachment';
import { GroupV2PendingMemberType } from '../model-types.d';
import { MediaItemType } from '../components/LightboxGallery';
import { MessageType } from '../state/ducks/conversations';
import { ConversationModel } from '../models/conversations';
import { GroupV2PendingMemberType } from '../model-types.d';
import { LinkPreviewType } from '../types/message/LinkPreviews';
import { MediaItemType } from '../components/LightboxGallery';
import { MessageModel } from '../models/messages';
import { MessageType } from '../state/ducks/conversations';
import { assert } from '../util/assert';
type GetLinkPreviewImageResult = {
@ -48,6 +49,8 @@ const {
getAbsoluteAttachmentPath,
getAbsoluteDraftPath,
getAbsoluteTempPath,
loadPreviewData,
loadStickerData,
openFileInFolder,
readAttachmentData,
readDraftData,
@ -608,7 +611,7 @@ Whisper.ConversationView = Whisper.View.extend({
onEditorStateChange: (
msg: string,
bodyRanges: Array<typeof window.Whisper.BodyRangeType>,
caretLocation: number
caretLocation?: number
) => this.onEditorStateChange(msg, bodyRanges, caretLocation),
onTextTooLong: () => this.showToast(Whisper.MessageBodyTooLongToast),
onChooseAttachment: this.onChooseAttachment.bind(this),
@ -774,6 +777,7 @@ Whisper.ConversationView = Whisper.View.extend({
const showExpiredOutgoingTapToViewToast = () => {
this.showToast(Whisper.TapToViewExpiredOutgoingToast);
};
const showForwardMessageModal = this.showForwardMessageModal.bind(this);
return {
deleteMessage,
@ -792,6 +796,7 @@ Whisper.ConversationView = Whisper.View.extend({
showContactModal,
showExpiredIncomingTapToViewToast,
showExpiredOutgoingTapToViewToast,
showForwardMessageModal,
showIdentity,
showMessageDetail,
showVisualAttachment,
@ -980,14 +985,18 @@ Whisper.ConversationView = Whisper.View.extend({
this.$('.timeline-placeholder').append(this.timelineView.el);
},
showToast(ToastView: any, options: any) {
showToast(ToastView: any, options: any, element: Element) {
const toast = new ToastView(options);
const lightboxEl = $('.module-lightbox');
if (lightboxEl.length > 0) {
toast.$el.appendTo(lightboxEl);
if (element) {
toast.$el.appendTo(element);
} else {
toast.$el.appendTo(this.$el);
const lightboxEl = $('.module-lightbox');
if (lightboxEl.length > 0) {
toast.$el.appendTo(lightboxEl);
} else {
toast.$el.appendTo(this.$el);
}
}
toast.render();
@ -2139,6 +2148,196 @@ Whisper.ConversationView = Whisper.View.extend({
await message.retrySend();
},
showForwardMessageModal(messageId: string) {
const message = this.model.messageCollection.get(messageId);
if (!message) {
throw new Error(
`showForwardMessageModal: Did not find message for id ${messageId}`
);
}
const attachments = message.getAttachmentsForMessage();
this.forwardMessageModal = new Whisper.ReactWrapperView({
JSX: window.Signal.State.Roots.createForwardMessageModal(
window.reduxStore,
{
attachments,
doForwardMessage: async (
conversationIds: Array<string>,
messageBody?: string,
includedAttachments?: Array<AttachmentType>,
linkPreview?: LinkPreviewType
) => {
const didForwardSuccessfully = await this.maybeForwardMessage(
message,
conversationIds,
messageBody,
includedAttachments,
linkPreview
);
if (didForwardSuccessfully) {
this.forwardMessageModal.remove();
this.forwardMessageModal = null;
}
},
isSticker: Boolean(message.get('sticker')),
messageBody: message.getRawText(),
onClose: () => {
this.forwardMessageModal.remove();
this.forwardMessageModal = null;
this.resetLinkPreview();
},
onEditorStateChange: (
messageText: string,
_: Array<typeof window.Whisper.BodyRangeType>,
caretLocation?: number
) => {
if (!attachments.length) {
this.debouncedMaybeGrabLinkPreview(messageText, caretLocation);
}
},
onTextTooLong: () =>
this.showToast(
Whisper.MessageBodyTooLongToast,
{},
document.querySelector('.module-ForwardMessageModal')
),
}
),
});
this.forwardMessageModal.render();
},
async maybeForwardMessage(
message: MessageModel,
conversationIds: Array<string>,
messageBody?: string,
attachments?: Array<AttachmentType>,
linkPreview?: LinkPreviewType
): Promise<boolean> {
const attachmentLookup = new Set();
if (attachments) {
attachments.forEach(attachment => {
attachmentLookup.add(
`${attachment.fileName}/${attachment.contentType}`
);
});
}
const conversations = conversationIds.map(id =>
window.ConversationController.get(id)
);
// Verify that all contacts that we're forwarding
// to are verified and trusted
const unverifiedContacts: Array<ConversationModel> = [];
const untrustedContacts: Array<ConversationModel> = [];
await Promise.all(
conversations.map(async conversation => {
if (conversation) {
await conversation.updateVerified();
const unverifieds = conversation.getUnverified();
if (unverifieds.length) {
unverifieds.forEach(unverifiedConversation =>
unverifiedContacts.push(unverifiedConversation)
);
}
const untrusted = conversation.getUntrusted();
if (untrusted.length) {
untrusted.forEach(untrustedConversation =>
untrustedContacts.push(untrustedConversation)
);
}
}
})
);
// If there are any unverified or untrusted contacts, show the
// SendAnywayDialog and if we're fine with sending then mark all as
// verified and trusted and continue the send.
const iffyConversations = [...unverifiedContacts, ...untrustedContacts];
if (iffyConversations.length) {
const forwardMessageModal = document.querySelector<HTMLElement>(
'.module-ForwardMessageModal'
);
if (forwardMessageModal) {
forwardMessageModal.style.display = 'none';
}
const sendAnyway = await this.showSendAnywayDialog(iffyConversations);
if (!sendAnyway) {
if (forwardMessageModal) {
forwardMessageModal.style.display = 'block';
}
return false;
}
let verifyPromise: Promise<void> | undefined;
let approvePromise: Promise<void> | undefined;
if (unverifiedContacts.length) {
verifyPromise = this.markAllAsVerifiedDefault(unverifiedContacts);
}
if (untrustedContacts.length) {
approvePromise = this.markAllAsApproved(untrustedContacts);
}
await Promise.all([verifyPromise, approvePromise]);
}
const sendMessageOptions = { dontClearDraft: true };
// Actually send the message
// load any sticker data, attachments, or link previews that we need to
// send along with the message and do the send to each conversation.
await Promise.all(
conversations.map(async conversation => {
if (conversation) {
const sticker = message.get('sticker');
if (sticker) {
const stickerWithData = await loadStickerData(sticker);
conversation.sendMessage(
null,
[],
null,
[],
stickerWithData,
undefined,
sendMessageOptions
);
} else {
const preview = linkPreview
? await loadPreviewData([linkPreview])
: [];
const allAttachments = message.getAttachmentsForMessage();
const attachmentsToSend = allAttachments.filter(
(attachment: Partial<AttachmentType>) =>
attachmentLookup.has(
`${attachment.fileName}/${attachment.contentType}`
)
);
conversation.sendMessage(
messageBody || null,
attachmentsToSend,
null, // quote
preview,
null, // sticker
undefined, // BodyRanges
sendMessageOptions
);
}
}
})
);
if (linkPreview) {
this.resetLinkPreview();
}
return true;
},
async showAllMedia() {
// We fetch more documents than media as they dont require to be loaded
// into memory right away. Revisit this once we have infinite scrolling:
@ -3203,7 +3402,10 @@ Whisper.ConversationView = Whisper.View.extend({
return true;
},
showSendAnywayDialog(contacts: any, confirmText: any) {
showSendAnywayDialog(
contacts: Array<ConversationModel>,
confirmText?: string
) {
return new Promise(resolve => {
const dialog = new Whisper.SafetyNumberChangeDialogView({
confirmText,
@ -3255,13 +3457,6 @@ Whisper.ConversationView = Whisper.View.extend({
return;
}
const mandatoryProfileSharingEnabled = window.Signal.RemoteConfig.isEnabled(
'desktop.mandatoryProfileSharing'
);
if (mandatoryProfileSharingEnabled && !this.model.get('profileSharing')) {
this.model.set({ profileSharing: true });
}
if (this.showInvalidMessageToast()) {
return;
}
@ -3474,13 +3669,6 @@ Whisper.ConversationView = Whisper.View.extend({
return;
}
const mandatoryProfileSharingEnabled = window.Signal.RemoteConfig.isEnabled(
'desktop.mandatoryProfileSharing'
);
if (mandatoryProfileSharingEnabled && !this.model.get('profileSharing')) {
this.model.set({ profileSharing: true });
}
const attachments = await this.getFiles();
const sendDelta = Date.now() - this.sendStart;
window.log.info('Send pre-checks took', sendDelta, 'milliseconds');
@ -3607,6 +3795,7 @@ Whisper.ConversationView = Whisper.View.extend({
URL.revokeObjectURL(item.url);
}
});
window.reduxActions.linkPreviews.removeLinkPreview();
this.preview = null;
this.currentlyMatchedLink = null;
this.linkPreviewAbortController?.abort();
@ -3881,6 +4070,7 @@ Whisper.ConversationView = Whisper.View.extend({
URL.revokeObjectURL(item.url);
}
});
window.reduxActions.linkPreviews.removeLinkPreview();
this.preview = null;
// Cancel other in-flight link preview requests.
@ -3937,6 +4127,7 @@ Whisper.ConversationView = Whisper.View.extend({
return;
}
window.reduxActions.linkPreviews.addLinkPreview(result);
this.preview = [result];
this.renderLinkPreview();
} catch (error) {
@ -3952,6 +4143,9 @@ Whisper.ConversationView = Whisper.View.extend({
},
renderLinkPreview() {
if (this.forwardMessageModal) {
return;
}
if (this.previewView) {
this.previewView.remove();
this.previewView = null;

4
ts/window.d.ts vendored
View file

@ -46,6 +46,7 @@ import { createCompositionArea } from './state/roots/createCompositionArea';
import { createContactModal } from './state/roots/createContactModal';
import { createConversationDetails } from './state/roots/createConversationDetails';
import { createConversationHeader } from './state/roots/createConversationHeader';
import { createForwardMessageModal } from './state/roots/createForwardMessageModal';
import { createGroupLinkManagement } from './state/roots/createGroupLinkManagement';
import { createGroupV1MigrationModal } from './state/roots/createGroupV1MigrationModal';
import { createGroupV2JoinModal } from './state/roots/createGroupV2JoinModal';
@ -63,6 +64,7 @@ import * as conversationsDuck from './state/ducks/conversations';
import * as emojisDuck from './state/ducks/emojis';
import * as expirationDuck from './state/ducks/expiration';
import * as itemsDuck from './state/ducks/items';
import * as linkPreviewsDuck from './state/ducks/linkPreviews';
import * as networkDuck from './state/ducks/network';
import * as updatesDuck from './state/ducks/updates';
import * as userDuck from './state/ducks/user';
@ -491,6 +493,7 @@ declare global {
createContactModal: typeof createContactModal;
createConversationDetails: typeof createConversationDetails;
createConversationHeader: typeof createConversationHeader;
createForwardMessageModal: typeof createForwardMessageModal;
createGroupLinkManagement: typeof createGroupLinkManagement;
createGroupV1MigrationModal: typeof createGroupV1MigrationModal;
createGroupV2JoinModal: typeof createGroupV2JoinModal;
@ -510,6 +513,7 @@ declare global {
emojis: typeof emojisDuck;
expiration: typeof expirationDuck;
items: typeof itemsDuck;
linkPreviews: typeof linkPreviewsDuck;
network: typeof networkDuck;
updates: typeof updatesDuck;
user: typeof userDuck;