Adds message forwarding
This commit is contained in:
parent
cd489a35fd
commit
d203f125c6
42 changed files with 1638 additions and 139 deletions
|
@ -1049,6 +1049,10 @@
|
||||||
"theirIdentityUnknown": {
|
"theirIdentityUnknown": {
|
||||||
"message": "You haven't exchanged any messages with this contact yet. Your safety number with them will be available after the first message."
|
"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": {
|
"goBack": {
|
||||||
"message": "Go back",
|
"message": "Go back",
|
||||||
"description": "Label for back button in a conversation"
|
"description": "Label for back button in a conversation"
|
||||||
|
@ -1061,6 +1065,10 @@
|
||||||
"message": "Retry Send",
|
"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"
|
"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": {
|
"deleteMessage": {
|
||||||
"message": "Delete message for me",
|
"message": "Delete message for me",
|
||||||
"description": "Shown on the drop-down menu for an individual message, deletes single message"
|
"description": "Shown on the drop-down menu for an individual message, deletes single message"
|
||||||
|
@ -5152,5 +5160,9 @@
|
||||||
"composeIcon": {
|
"composeIcon": {
|
||||||
"message": "compose button",
|
"message": "compose button",
|
||||||
"description": "Shown in the left-pane when the inbox is empty. Describes the button that composes a new message."
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -73,6 +73,9 @@ const {
|
||||||
createConversationHeader,
|
createConversationHeader,
|
||||||
} = require('../../ts/state/roots/createConversationHeader');
|
} = require('../../ts/state/roots/createConversationHeader');
|
||||||
const { createCallManager } = require('../../ts/state/roots/createCallManager');
|
const { createCallManager } = require('../../ts/state/roots/createCallManager');
|
||||||
|
const {
|
||||||
|
createForwardMessageModal,
|
||||||
|
} = require('../../ts/state/roots/createForwardMessageModal');
|
||||||
const {
|
const {
|
||||||
createGroupLinkManagement,
|
createGroupLinkManagement,
|
||||||
} = require('../../ts/state/roots/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 emojisDuck = require('../../ts/state/ducks/emojis');
|
||||||
const expirationDuck = require('../../ts/state/ducks/expiration');
|
const expirationDuck = require('../../ts/state/ducks/expiration');
|
||||||
const itemsDuck = require('../../ts/state/ducks/items');
|
const itemsDuck = require('../../ts/state/ducks/items');
|
||||||
|
const linkPreviewsDuck = require('../../ts/state/ducks/linkPreviews');
|
||||||
const networkDuck = require('../../ts/state/ducks/network');
|
const networkDuck = require('../../ts/state/ducks/network');
|
||||||
const searchDuck = require('../../ts/state/ducks/search');
|
const searchDuck = require('../../ts/state/ducks/search');
|
||||||
const stickersDuck = require('../../ts/state/ducks/stickers');
|
const stickersDuck = require('../../ts/state/ducks/stickers');
|
||||||
|
@ -344,6 +348,7 @@ exports.setup = (options = {}) => {
|
||||||
createContactModal,
|
createContactModal,
|
||||||
createConversationDetails,
|
createConversationDetails,
|
||||||
createConversationHeader,
|
createConversationHeader,
|
||||||
|
createForwardMessageModal,
|
||||||
createGroupLinkManagement,
|
createGroupLinkManagement,
|
||||||
createGroupV1MigrationModal,
|
createGroupV1MigrationModal,
|
||||||
createGroupV2JoinModal,
|
createGroupV2JoinModal,
|
||||||
|
@ -364,6 +369,7 @@ exports.setup = (options = {}) => {
|
||||||
emojis: emojisDuck,
|
emojis: emojisDuck,
|
||||||
expiration: expirationDuck,
|
expiration: expirationDuck,
|
||||||
items: itemsDuck,
|
items: itemsDuck,
|
||||||
|
linkPreviews: linkPreviewsDuck,
|
||||||
network: networkDuck,
|
network: networkDuck,
|
||||||
updates: updatesDuck,
|
updates: updatesDuck,
|
||||||
user: userDuck,
|
user: userDuck,
|
||||||
|
|
|
@ -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 {
|
&__delete-message::before {
|
||||||
@include light-theme {
|
@include light-theme {
|
||||||
@include color-svg(
|
@include color-svg(
|
||||||
|
|
293
stylesheets/components/ForwardMessageModal.scss
Normal file
293
stylesheets/components/ForwardMessageModal.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -34,6 +34,7 @@
|
||||||
@import './components/ContactPills.scss';
|
@import './components/ContactPills.scss';
|
||||||
@import './components/ConversationHeader.scss';
|
@import './components/ConversationHeader.scss';
|
||||||
@import './components/EditConversationAttributesModal.scss';
|
@import './components/EditConversationAttributesModal.scss';
|
||||||
|
@import './components/ForwardMessageModal.scss';
|
||||||
@import './components/GroupDialog.scss';
|
@import './components/GroupDialog.scss';
|
||||||
@import './components/GroupTitleInput.scss';
|
@import './components/GroupTitleInput.scss';
|
||||||
@import './components/MessageAudio.scss';
|
@import './components/MessageAudio.scss';
|
||||||
|
|
|
@ -831,6 +831,10 @@ export async function startApp(): Promise<void> {
|
||||||
window.Signal.State.Ducks.items.actions,
|
window.Signal.State.Ducks.items.actions,
|
||||||
store.dispatch
|
store.dispatch
|
||||||
);
|
);
|
||||||
|
actions.linkPreviews = window.Signal.State.bindActionCreators(
|
||||||
|
window.Signal.State.Ducks.linkPreviews.actions,
|
||||||
|
store.dispatch
|
||||||
|
);
|
||||||
actions.network = window.Signal.State.bindActionCreators(
|
actions.network = window.Signal.State.bindActionCreators(
|
||||||
window.Signal.State.Ducks.network.actions,
|
window.Signal.State.Ducks.network.actions,
|
||||||
store.dispatch
|
store.dispatch
|
||||||
|
|
|
@ -33,3 +33,11 @@ story.add('Kitchen sink', () => (
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
));
|
));
|
||||||
|
|
||||||
|
story.add('aria-label', () => (
|
||||||
|
<Button
|
||||||
|
aria-label="hello"
|
||||||
|
className="module-ForwardMessageModal__header--back"
|
||||||
|
onClick={action('onClick')}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
|
@ -15,7 +15,6 @@ export enum ButtonVariant {
|
||||||
}
|
}
|
||||||
|
|
||||||
type PropsType = {
|
type PropsType = {
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
|
@ -26,7 +25,21 @@ type PropsType = {
|
||||||
| {
|
| {
|
||||||
type: 'submit';
|
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>([
|
const VARIANT_CLASS_NAMES = new Map<ButtonVariant, string>([
|
||||||
[ButtonVariant.Primary, 'module-Button--primary'],
|
[ButtonVariant.Primary, 'module-Button--primary'],
|
||||||
|
@ -50,6 +63,7 @@ export const Button = React.forwardRef<HTMLButtonElement, PropsType>(
|
||||||
disabled = false,
|
disabled = false,
|
||||||
variant = ButtonVariant.Primary,
|
variant = ButtonVariant.Primary,
|
||||||
} = props;
|
} = props;
|
||||||
|
const ariaLabel = props['aria-label'];
|
||||||
|
|
||||||
let onClick: undefined | MouseEventHandler<HTMLButtonElement>;
|
let onClick: undefined | MouseEventHandler<HTMLButtonElement>;
|
||||||
let type: 'button' | 'submit';
|
let type: 'button' | 'submit';
|
||||||
|
@ -66,6 +80,7 @@ export const Button = React.forwardRef<HTMLButtonElement, PropsType>(
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
aria-label={ariaLabel}
|
||||||
className={classNames('module-Button', variantClassName, className)}
|
className={classNames('module-Button', variantClassName, className)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
|
|
@ -63,6 +63,7 @@ export type Props = {
|
||||||
readonly skinTone?: EmojiPickDataType['skinTone'];
|
readonly skinTone?: EmojiPickDataType['skinTone'];
|
||||||
readonly draftText?: string;
|
readonly draftText?: string;
|
||||||
readonly draftBodyRanges?: Array<BodyRangeType>;
|
readonly draftBodyRanges?: Array<BodyRangeType>;
|
||||||
|
readonly moduleClassName?: string;
|
||||||
sortedGroupMembers?: Array<ConversationType>;
|
sortedGroupMembers?: Array<ConversationType>;
|
||||||
onDirtyChange?(dirty: boolean): unknown;
|
onDirtyChange?(dirty: boolean): unknown;
|
||||||
onEditorStateChange?(
|
onEditorStateChange?(
|
||||||
|
@ -79,12 +80,24 @@ export type Props = {
|
||||||
|
|
||||||
const MAX_LENGTH = 64 * 1024;
|
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 => {
|
export const CompositionInput: React.ComponentType<Props> = props => {
|
||||||
const {
|
const {
|
||||||
i18n,
|
i18n,
|
||||||
disabled,
|
disabled,
|
||||||
large,
|
large,
|
||||||
inputApi,
|
inputApi,
|
||||||
|
moduleClassName,
|
||||||
onPickEmoji,
|
onPickEmoji,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
skinTone,
|
skinTone,
|
||||||
|
@ -240,12 +253,12 @@ export const CompositionInput: React.ComponentType<Props> = props => {
|
||||||
propsRef.current = props;
|
propsRef.current = props;
|
||||||
}, [props]);
|
}, [props]);
|
||||||
|
|
||||||
const onShortKeyEnter = () => {
|
const onShortKeyEnter = (): boolean => {
|
||||||
submit();
|
submit();
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onEnter = () => {
|
const onEnter = (): boolean => {
|
||||||
const quill = quillRef.current;
|
const quill = quillRef.current;
|
||||||
const emojiCompletion = emojiCompletionRef.current;
|
const emojiCompletion = emojiCompletionRef.current;
|
||||||
const mentionCompletion = mentionCompletionRef.current;
|
const mentionCompletion = mentionCompletionRef.current;
|
||||||
|
@ -277,7 +290,7 @@ export const CompositionInput: React.ComponentType<Props> = props => {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onTab = () => {
|
const onTab = (): boolean => {
|
||||||
const quill = quillRef.current;
|
const quill = quillRef.current;
|
||||||
const emojiCompletion = emojiCompletionRef.current;
|
const emojiCompletion = emojiCompletionRef.current;
|
||||||
const mentionCompletion = mentionCompletionRef.current;
|
const mentionCompletion = mentionCompletionRef.current;
|
||||||
|
@ -303,7 +316,7 @@ export const CompositionInput: React.ComponentType<Props> = props => {
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onEscape = () => {
|
const onEscape = (): boolean => {
|
||||||
const quill = quillRef.current;
|
const quill = quillRef.current;
|
||||||
|
|
||||||
if (quill === undefined) {
|
if (quill === undefined) {
|
||||||
|
@ -335,7 +348,7 @@ export const CompositionInput: React.ComponentType<Props> = props => {
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onBackspace = () => {
|
const onBackspace = (): boolean => {
|
||||||
const quill = quillRef.current;
|
const quill = quillRef.current;
|
||||||
|
|
||||||
if (quill === undefined) {
|
if (quill === undefined) {
|
||||||
|
@ -361,7 +374,7 @@ export const CompositionInput: React.ComponentType<Props> = props => {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onChange = () => {
|
const onChange = (): void => {
|
||||||
const quill = quillRef.current;
|
const quill = quillRef.current;
|
||||||
|
|
||||||
const [text, mentions] = getTextAndMentions();
|
const [text, mentions] = getTextAndMentions();
|
||||||
|
@ -471,6 +484,22 @@ export const CompositionInput: React.ComponentType<Props> = props => {
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [JSON.stringify(memberIds)]);
|
}, [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 reactQuill = React.useMemo(
|
||||||
() => {
|
() => {
|
||||||
const delta = generateDelta(draftText || '', draftBodyRanges || []);
|
const delta = generateDelta(draftText || '', draftBodyRanges || []);
|
||||||
|
@ -478,7 +507,7 @@ export const CompositionInput: React.ComponentType<Props> = props => {
|
||||||
return (
|
return (
|
||||||
<ReactQuill
|
<ReactQuill
|
||||||
className="module-composition-input__quill"
|
className="module-composition-input__quill"
|
||||||
onChange={onChange}
|
onChange={() => callbacksRef.current.onChange()}
|
||||||
defaultValue={delta}
|
defaultValue={delta}
|
||||||
modules={{
|
modules={{
|
||||||
toolbar: false,
|
toolbar: false,
|
||||||
|
@ -494,19 +523,29 @@ export const CompositionInput: React.ComponentType<Props> = props => {
|
||||||
},
|
},
|
||||||
keyboard: {
|
keyboard: {
|
||||||
bindings: {
|
bindings: {
|
||||||
onEnter: { key: 13, handler: onEnter }, // 13 = Enter
|
onEnter: {
|
||||||
|
key: 13,
|
||||||
|
handler: () => callbacksRef.current.onEnter(),
|
||||||
|
}, // 13 = Enter
|
||||||
onShortKeyEnter: {
|
onShortKeyEnter: {
|
||||||
key: 13, // 13 = Enter
|
key: 13, // 13 = Enter
|
||||||
shortKey: true,
|
shortKey: true,
|
||||||
handler: onShortKeyEnter,
|
handler: () => callbacksRef.current.onShortKeyEnter(),
|
||||||
},
|
},
|
||||||
onEscape: { key: 27, handler: onEscape }, // 27 = Escape
|
onEscape: {
|
||||||
onBackspace: { key: 8, handler: onBackspace }, // 8 = Backspace
|
key: 27,
|
||||||
|
handler: () => callbacksRef.current.onEscape(),
|
||||||
|
}, // 27 = Escape
|
||||||
|
onBackspace: {
|
||||||
|
key: 8,
|
||||||
|
handler: () => callbacksRef.current.onBackspace(),
|
||||||
|
}, // 8 = Backspace
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
emojiCompletion: {
|
emojiCompletion: {
|
||||||
setEmojiPickerElement: setEmojiCompletionElement,
|
setEmojiPickerElement: setEmojiCompletionElement,
|
||||||
onPickEmoji,
|
onPickEmoji: (emoji: EmojiPickDataType) =>
|
||||||
|
callbacksRef.current.onPickEmoji(emoji),
|
||||||
skinTone,
|
skinTone,
|
||||||
},
|
},
|
||||||
mentionCompletion: {
|
mentionCompletion: {
|
||||||
|
@ -528,7 +567,10 @@ export const CompositionInput: React.ComponentType<Props> = props => {
|
||||||
|
|
||||||
// force the tab handler to be prepended, otherwise it won't be
|
// force the tab handler to be prepended, otherwise it won't be
|
||||||
// executed: https://github.com/quilljs/quill/issues/1967
|
// 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
|
// also, remove the default \t insertion binding
|
||||||
keyboard.bindings[9].pop();
|
keyboard.bindings[9].pop();
|
||||||
|
|
||||||
|
@ -583,7 +625,13 @@ export const CompositionInput: React.ComponentType<Props> = props => {
|
||||||
<Manager>
|
<Manager>
|
||||||
<Reference>
|
<Reference>
|
||||||
{({ ref }) => (
|
{({ ref }) => (
|
||||||
<div className="module-composition-input__input" ref={ref}>
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'module-composition-input__input',
|
||||||
|
getClassName(moduleClassName, '__input')
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
ref={scrollerRef}
|
ref={scrollerRef}
|
||||||
onClick={focus}
|
onClick={focus}
|
||||||
|
@ -591,6 +639,10 @@ export const CompositionInput: React.ComponentType<Props> = props => {
|
||||||
'module-composition-input__input__scroller',
|
'module-composition-input__input__scroller',
|
||||||
large
|
large
|
||||||
? 'module-composition-input__input__scroller--large'
|
? 'module-composition-input__input__scroller--large'
|
||||||
|
: null,
|
||||||
|
getClassName(moduleClassName, '__scroller'),
|
||||||
|
large
|
||||||
|
? getClassName(moduleClassName, '__scroller--large')
|
||||||
: null
|
: null
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
119
ts/components/ForwardMessageModal.stories.tsx
Normal file
119
ts/components/ForwardMessageModal.stories.tsx
Normal 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',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
409
ts/components/ForwardMessageModal.tsx
Normal file
409
ts/components/ForwardMessageModal.tsx
Normal 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"
|
||||||
|
>
|
||||||
|
|
||||||
|
</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');
|
||||||
|
}
|
|
@ -18,10 +18,10 @@ import {
|
||||||
export type Props = {
|
export type Props = {
|
||||||
attachments: Array<AttachmentType>;
|
attachments: Array<AttachmentType>;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
onClickAttachment: (attachment: AttachmentType) => void;
|
onAddAttachment?: () => void;
|
||||||
|
onClickAttachment?: (attachment: AttachmentType) => void;
|
||||||
|
onClose?: () => void;
|
||||||
onCloseAttachment: (attachment: AttachmentType) => void;
|
onCloseAttachment: (attachment: AttachmentType) => void;
|
||||||
onAddAttachment: () => void;
|
|
||||||
onClose: () => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const IMAGE_WIDTH = 120;
|
const IMAGE_WIDTH = 120;
|
||||||
|
@ -47,7 +47,7 @@ export const AttachmentList = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="module-attachments">
|
<div className="module-attachments">
|
||||||
{attachments.length > 1 ? (
|
{onClose && attachments.length > 1 ? (
|
||||||
<div className="module-attachments__header">
|
<div className="module-attachments__header">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -105,7 +105,7 @@ export const AttachmentList = ({
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{allVisualAttachments ? (
|
{allVisualAttachments && onAddAttachment ? (
|
||||||
<StagedPlaceholderAttachment onClick={onAddAttachment} i18n={i18n} />
|
<StagedPlaceholderAttachment onClick={onAddAttachment} i18n={i18n} />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -130,6 +130,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
showExpiredOutgoingTapToViewToast: action(
|
showExpiredOutgoingTapToViewToast: action(
|
||||||
'showExpiredOutgoingTapToViewToast'
|
'showExpiredOutgoingTapToViewToast'
|
||||||
),
|
),
|
||||||
|
showForwardMessageModal: action('showForwardMessageModal'),
|
||||||
showMessageDetail: action('showMessageDetail'),
|
showMessageDetail: action('showMessageDetail'),
|
||||||
showVisualAttachment: action('showVisualAttachment'),
|
showVisualAttachment: action('showVisualAttachment'),
|
||||||
status: overrideProps.status || 'sent',
|
status: overrideProps.status || 'sent',
|
||||||
|
|
|
@ -170,6 +170,7 @@ export type PropsActions = {
|
||||||
) => void;
|
) => void;
|
||||||
replyToMessage: (id: string) => void;
|
replyToMessage: (id: string) => void;
|
||||||
retrySend: (id: string) => void;
|
retrySend: (id: string) => void;
|
||||||
|
showForwardMessageModal: (id: string) => void;
|
||||||
deleteMessage: (id: string) => void;
|
deleteMessage: (id: string) => void;
|
||||||
deleteMessageForEveryone: (id: string) => void;
|
deleteMessageForEveryone: (id: string) => void;
|
||||||
showMessageDetail: (id: string) => void;
|
showMessageDetail: (id: string) => void;
|
||||||
|
@ -1401,6 +1402,7 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
canReply,
|
canReply,
|
||||||
deleteMessage,
|
deleteMessage,
|
||||||
deleteMessageForEveryone,
|
deleteMessageForEveryone,
|
||||||
|
deletedForEveryone,
|
||||||
direction,
|
direction,
|
||||||
i18n,
|
i18n,
|
||||||
id,
|
id,
|
||||||
|
@ -1408,10 +1410,13 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
isTapToView,
|
isTapToView,
|
||||||
replyToMessage,
|
replyToMessage,
|
||||||
retrySend,
|
retrySend,
|
||||||
|
showForwardMessageModal,
|
||||||
showMessageDetail,
|
showMessageDetail,
|
||||||
status,
|
status,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
|
const canForward = !isTapToView && !deletedForEveryone;
|
||||||
|
|
||||||
const { canDeleteForEveryone } = this.state;
|
const { canDeleteForEveryone } = this.state;
|
||||||
|
|
||||||
const showRetry =
|
const showRetry =
|
||||||
|
@ -1499,6 +1504,22 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
{i18n('retrySend')}
|
{i18n('retrySend')}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
) : null}
|
) : 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
|
<MenuItem
|
||||||
attributes={{
|
attributes={{
|
||||||
className:
|
className:
|
||||||
|
|
|
@ -72,6 +72,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
showContactModal: () => null,
|
showContactModal: () => null,
|
||||||
showExpiredIncomingTapToViewToast: () => null,
|
showExpiredIncomingTapToViewToast: () => null,
|
||||||
showExpiredOutgoingTapToViewToast: () => null,
|
showExpiredOutgoingTapToViewToast: () => null,
|
||||||
|
showForwardMessageModal: () => null,
|
||||||
showVisualAttachment: () => null,
|
showVisualAttachment: () => null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -65,6 +65,7 @@ export type Props = {
|
||||||
| 'showContactModal'
|
| 'showContactModal'
|
||||||
| 'showExpiredIncomingTapToViewToast'
|
| 'showExpiredIncomingTapToViewToast'
|
||||||
| 'showExpiredOutgoingTapToViewToast'
|
| 'showExpiredOutgoingTapToViewToast'
|
||||||
|
| 'showForwardMessageModal'
|
||||||
| 'showVisualAttachment'
|
| 'showVisualAttachment'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
@ -235,6 +236,7 @@ export class MessageDetail extends React.Component<Props> {
|
||||||
showContactModal,
|
showContactModal,
|
||||||
showExpiredIncomingTapToViewToast,
|
showExpiredIncomingTapToViewToast,
|
||||||
showExpiredOutgoingTapToViewToast,
|
showExpiredOutgoingTapToViewToast,
|
||||||
|
showForwardMessageModal,
|
||||||
showVisualAttachment,
|
showVisualAttachment,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
|
@ -263,6 +265,7 @@ export class MessageDetail extends React.Component<Props> {
|
||||||
renderEmojiPicker={renderEmojiPicker}
|
renderEmojiPicker={renderEmojiPicker}
|
||||||
replyToMessage={replyToMessage}
|
replyToMessage={replyToMessage}
|
||||||
retrySend={retrySend}
|
retrySend={retrySend}
|
||||||
|
showForwardMessageModal={showForwardMessageModal}
|
||||||
scrollToQuotedMessage={() => {
|
scrollToQuotedMessage={() => {
|
||||||
assert(
|
assert(
|
||||||
false,
|
false,
|
||||||
|
|
|
@ -61,6 +61,7 @@ const defaultMessageProps: MessagesProps = {
|
||||||
showContactModal: () => null,
|
showContactModal: () => null,
|
||||||
showExpiredIncomingTapToViewToast: () => null,
|
showExpiredIncomingTapToViewToast: () => null,
|
||||||
showExpiredOutgoingTapToViewToast: () => null,
|
showExpiredOutgoingTapToViewToast: () => null,
|
||||||
|
showForwardMessageModal: () => null,
|
||||||
showMessageDetail: () => null,
|
showMessageDetail: () => null,
|
||||||
showVisualAttachment: () => null,
|
showVisualAttachment: () => null,
|
||||||
status: 'sent',
|
status: 'sent',
|
||||||
|
|
|
@ -249,6 +249,7 @@ const actions = () => ({
|
||||||
showExpiredOutgoingTapToViewToast: action(
|
showExpiredOutgoingTapToViewToast: action(
|
||||||
'showExpiredOutgoingTapToViewToast'
|
'showExpiredOutgoingTapToViewToast'
|
||||||
),
|
),
|
||||||
|
showForwardMessageModal: action('showForwardMessageModal'),
|
||||||
|
|
||||||
showIdentity: action('showIdentity'),
|
showIdentity: action('showIdentity'),
|
||||||
|
|
||||||
|
|
|
@ -53,6 +53,7 @@ const getDefaultProps = () => ({
|
||||||
openConversation: action('openConversation'),
|
openConversation: action('openConversation'),
|
||||||
showContactDetail: action('showContactDetail'),
|
showContactDetail: action('showContactDetail'),
|
||||||
showContactModal: action('showContactModal'),
|
showContactModal: action('showContactModal'),
|
||||||
|
showForwardMessageModal: action('showForwardMessageModal'),
|
||||||
showVisualAttachment: action('showVisualAttachment'),
|
showVisualAttachment: action('showVisualAttachment'),
|
||||||
downloadAttachment: action('downloadAttachment'),
|
downloadAttachment: action('downloadAttachment'),
|
||||||
displayTapToViewMessage: action('displayTapToViewMessage'),
|
displayTapToViewMessage: action('displayTapToViewMessage'),
|
||||||
|
|
|
@ -22,6 +22,7 @@ export type PropsDataType = {
|
||||||
color?: ColorType;
|
color?: ColorType;
|
||||||
disabledReason?: ContactCheckboxDisabledReason;
|
disabledReason?: ContactCheckboxDisabledReason;
|
||||||
id: string;
|
id: string;
|
||||||
|
isMe?: boolean;
|
||||||
isChecked: boolean;
|
isChecked: boolean;
|
||||||
name?: string;
|
name?: string;
|
||||||
phoneNumber?: string;
|
phoneNumber?: string;
|
||||||
|
@ -49,6 +50,7 @@ export const ContactCheckbox: FunctionComponent<PropsType> = React.memo(
|
||||||
i18n,
|
i18n,
|
||||||
id,
|
id,
|
||||||
isChecked,
|
isChecked,
|
||||||
|
isMe,
|
||||||
name,
|
name,
|
||||||
onClick,
|
onClick,
|
||||||
phoneNumber,
|
phoneNumber,
|
||||||
|
@ -58,7 +60,9 @@ export const ContactCheckbox: FunctionComponent<PropsType> = React.memo(
|
||||||
}) => {
|
}) => {
|
||||||
const disabled = Boolean(disabledReason);
|
const disabled = Boolean(disabledReason);
|
||||||
|
|
||||||
const headerName = (
|
const headerName = isMe ? (
|
||||||
|
i18n('noteToSelf')
|
||||||
|
) : (
|
||||||
<ContactName
|
<ContactName
|
||||||
phoneNumber={phoneNumber}
|
phoneNumber={phoneNumber}
|
||||||
name={name}
|
name={name}
|
||||||
|
@ -91,6 +95,7 @@ export const ContactCheckbox: FunctionComponent<PropsType> = React.memo(
|
||||||
headerName={headerName}
|
headerName={headerName}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
id={id}
|
id={id}
|
||||||
|
isMe={isMe}
|
||||||
isSelected={false}
|
isSelected={false}
|
||||||
messageText={messageText}
|
messageText={messageText}
|
||||||
name={name}
|
name={name}
|
||||||
|
|
|
@ -1397,6 +1397,9 @@ export class ConversationModel extends window.Backbone.Model<
|
||||||
sortedGroupMembers,
|
sortedGroupMembers,
|
||||||
timestamp,
|
timestamp,
|
||||||
title: this.getTitle()!,
|
title: this.getTitle()!,
|
||||||
|
searchableTitle: this.isMe()
|
||||||
|
? window.i18n('noteToSelf')
|
||||||
|
: this.getTitle(),
|
||||||
type: (this.isPrivate() ? 'direct' : 'group') as ConversationTypeType,
|
type: (this.isPrivate() ? 'direct' : 'group') as ConversationTypeType,
|
||||||
unreadCount: this.get('unreadCount')! || 0,
|
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.isPrivate()) {
|
||||||
if (this.isUntrusted()) {
|
if (this.isUntrusted()) {
|
||||||
return new window.Backbone.Collection([this]);
|
return new window.Backbone.Collection([this]);
|
||||||
|
@ -2243,16 +2246,14 @@ export class ConversationModel extends window.Backbone.Model<
|
||||||
return new window.Backbone.Collection();
|
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(
|
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,
|
quote: WhatIsThis,
|
||||||
preview: WhatIsThis,
|
preview: WhatIsThis,
|
||||||
sticker?: WhatIsThis,
|
sticker?: WhatIsThis,
|
||||||
mentions?: BodyRangesType
|
mentions?: BodyRangesType,
|
||||||
|
{ dontClearDraft = false } = {}
|
||||||
): void {
|
): void {
|
||||||
this.clearTypingTimers();
|
this.clearTypingTimers();
|
||||||
|
|
||||||
const { clearUnreadMetrics } = window.reduxActions.conversations;
|
const { clearUnreadMetrics } = window.reduxActions.conversations;
|
||||||
clearUnreadMetrics(this.id);
|
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
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
const destination = this.getSendTarget()!;
|
const destination = this.getSendTarget()!;
|
||||||
const expireTimer = this.get('expireTimer');
|
const expireTimer = this.get('expireTimer');
|
||||||
|
@ -3382,15 +3391,22 @@ export class ConversationModel extends window.Backbone.Model<
|
||||||
Message: window.Whisper.Message,
|
Message: window.Whisper.Message,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const draftProperties = dontClearDraft
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
draft: null,
|
||||||
|
draftTimestamp: null,
|
||||||
|
lastMessage: model.getNotificationText(),
|
||||||
|
lastMessageStatus: 'sending' as const,
|
||||||
|
};
|
||||||
|
|
||||||
this.set({
|
this.set({
|
||||||
lastMessage: model.getNotificationText(),
|
...draftProperties,
|
||||||
lastMessageStatus: 'sending',
|
|
||||||
active_at: now,
|
active_at: now,
|
||||||
timestamp: now,
|
timestamp: now,
|
||||||
isArchived: false,
|
isArchived: false,
|
||||||
draft: null,
|
|
||||||
draftTimestamp: null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.incrementSentMessageCount();
|
this.incrementSentMessageCount();
|
||||||
window.Signal.Data.updateConversation(this.attributes);
|
window.Signal.Data.updateConversation(this.attributes);
|
||||||
|
|
||||||
|
|
|
@ -46,6 +46,7 @@ import {
|
||||||
import { PropsType as ProfileChangeNotificationPropsType } from '../components/conversation/ProfileChangeNotification';
|
import { PropsType as ProfileChangeNotificationPropsType } from '../components/conversation/ProfileChangeNotification';
|
||||||
import { AttachmentType, isImage, isVideo } from '../types/Attachment';
|
import { AttachmentType, isImage, isVideo } from '../types/Attachment';
|
||||||
import { MIMEType } from '../types/MIME';
|
import { MIMEType } from '../types/MIME';
|
||||||
|
import { LinkPreviewType } from '../types/message/LinkPreviews';
|
||||||
|
|
||||||
/* eslint-disable camelcase */
|
/* eslint-disable camelcase */
|
||||||
/* eslint-disable more/no-then */
|
/* 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') || [];
|
const previews = this.get('preview') || [];
|
||||||
|
|
||||||
return previews.map(preview => ({
|
return previews.map(preview => ({
|
||||||
|
@ -1592,6 +1593,17 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
return { text: '' };
|
return { text: '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getRawText(): string {
|
||||||
|
const body = (this.get('body') || '').trim();
|
||||||
|
|
||||||
|
const bodyRanges = this.processBodyRanges();
|
||||||
|
if (bodyRanges) {
|
||||||
|
return getTextWithMentions(bodyRanges, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
getNotificationText(): string {
|
getNotificationText(): string {
|
||||||
const { text, emoji } = this.getNotificationData();
|
const { text, emoji } = this.getNotificationData();
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { actions as conversations } from './ducks/conversations';
|
||||||
import { actions as emojis } from './ducks/emojis';
|
import { actions as emojis } from './ducks/emojis';
|
||||||
import { actions as expiration } from './ducks/expiration';
|
import { actions as expiration } from './ducks/expiration';
|
||||||
import { actions as items } from './ducks/items';
|
import { actions as items } from './ducks/items';
|
||||||
|
import { actions as linkPreviews } from './ducks/linkPreviews';
|
||||||
import { actions as network } from './ducks/network';
|
import { actions as network } from './ducks/network';
|
||||||
import { actions as safetyNumber } from './ducks/safetyNumber';
|
import { actions as safetyNumber } from './ducks/safetyNumber';
|
||||||
import { actions as search } from './ducks/search';
|
import { actions as search } from './ducks/search';
|
||||||
|
@ -21,6 +22,7 @@ export const mapDispatchToProps = {
|
||||||
...emojis,
|
...emojis,
|
||||||
...expiration,
|
...expiration,
|
||||||
...items,
|
...items,
|
||||||
|
...linkPreviews,
|
||||||
...network,
|
...network,
|
||||||
...safetyNumber,
|
...safetyNumber,
|
||||||
...search,
|
...search,
|
||||||
|
|
|
@ -108,6 +108,7 @@ export type ConversationType = {
|
||||||
// This is used by the CompositionInput for @mentions
|
// This is used by the CompositionInput for @mentions
|
||||||
sortedGroupMembers?: Array<ConversationType>;
|
sortedGroupMembers?: Array<ConversationType>;
|
||||||
title: string;
|
title: string;
|
||||||
|
searchableTitle?: string;
|
||||||
unreadCount?: number;
|
unreadCount?: number;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
typingContact?: {
|
typingContact?: {
|
||||||
|
|
|
@ -2,11 +2,7 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { omit } from 'lodash';
|
import { omit } from 'lodash';
|
||||||
import { createSelector } from 'reselect';
|
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
import { StateType } from '../reducer';
|
|
||||||
import * as storageShim from '../../shims/storage';
|
import * as storageShim from '../../shims/storage';
|
||||||
import { isShortName } from '../../components/emoji/lib';
|
|
||||||
import { useBoundActions } from '../../util/hooks';
|
import { useBoundActions } from '../../util/hooks';
|
||||||
|
|
||||||
// State
|
// State
|
||||||
|
@ -54,6 +50,7 @@ export type ItemsActionType =
|
||||||
// Action Creators
|
// Action Creators
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
|
onSetSkinTone,
|
||||||
putItem,
|
putItem,
|
||||||
putItemExternal,
|
putItemExternal,
|
||||||
removeItem,
|
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 {
|
function putItemExternal(key: string, value: unknown): ItemPutExternalAction {
|
||||||
return {
|
return {
|
||||||
type: 'items/PUT_EXTERNAL',
|
type: 'items/PUT_EXTERNAL',
|
||||||
|
@ -133,13 +134,3 @@ export function reducer(
|
||||||
|
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Selectors
|
|
||||||
|
|
||||||
const selectRecentEmojis = createSelector(
|
|
||||||
({ emojis }: StateType) => emojis.recents,
|
|
||||||
recents => recents.filter(isShortName)
|
|
||||||
);
|
|
||||||
|
|
||||||
export const useRecentEmojis = (): Array<string> =>
|
|
||||||
useSelector(selectRecentEmojis);
|
|
||||||
|
|
77
ts/state/ducks/linkPreviews.ts
Normal file
77
ts/state/ducks/linkPreviews.ts
Normal 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;
|
||||||
|
}
|
|
@ -9,6 +9,7 @@ import { reducer as conversations } from './ducks/conversations';
|
||||||
import { reducer as emojis } from './ducks/emojis';
|
import { reducer as emojis } from './ducks/emojis';
|
||||||
import { reducer as expiration } from './ducks/expiration';
|
import { reducer as expiration } from './ducks/expiration';
|
||||||
import { reducer as items } from './ducks/items';
|
import { reducer as items } from './ducks/items';
|
||||||
|
import { reducer as linkPreviews } from './ducks/linkPreviews';
|
||||||
import { reducer as network } from './ducks/network';
|
import { reducer as network } from './ducks/network';
|
||||||
import { reducer as safetyNumber } from './ducks/safetyNumber';
|
import { reducer as safetyNumber } from './ducks/safetyNumber';
|
||||||
import { reducer as search } from './ducks/search';
|
import { reducer as search } from './ducks/search';
|
||||||
|
@ -23,6 +24,7 @@ export const reducer = combineReducers({
|
||||||
emojis,
|
emojis,
|
||||||
expiration,
|
expiration,
|
||||||
items,
|
items,
|
||||||
|
linkPreviews,
|
||||||
network,
|
network,
|
||||||
safetyNumber,
|
safetyNumber,
|
||||||
search,
|
search,
|
||||||
|
|
21
ts/state/roots/createForwardMessageModal.tsx
Normal file
21
ts/state/roots/createForwardMessageModal.tsx
Normal 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>
|
||||||
|
);
|
|
@ -18,7 +18,6 @@ import {
|
||||||
OneTimeModalState,
|
OneTimeModalState,
|
||||||
PreJoinConversationType,
|
PreJoinConversationType,
|
||||||
} from '../ducks/conversations';
|
} from '../ducks/conversations';
|
||||||
import { LocalizerType } from '../../types/Util';
|
|
||||||
import { getOwn } from '../../util/getOwn';
|
import { getOwn } from '../../util/getOwn';
|
||||||
import { deconstructLookup } from '../../util/deconstructLookup';
|
import { deconstructLookup } from '../../util/deconstructLookup';
|
||||||
import type { CallsByConversationType } from '../ducks/calling';
|
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
|
* 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.
|
* 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()
|
(searchTerm: string): string => searchTerm.trim()
|
||||||
);
|
);
|
||||||
|
|
||||||
const getNoteToSelfTitle = createSelector(getIntl, (i18n: LocalizerType) =>
|
|
||||||
i18n('noteToSelf').toLowerCase()
|
|
||||||
);
|
|
||||||
|
|
||||||
export const getComposeContacts = createSelector(
|
export const getComposeContacts = createSelector(
|
||||||
getNormalizedComposerConversationSearchTerm,
|
getNormalizedComposerConversationSearchTerm,
|
||||||
getComposableContacts,
|
getContactsAndMe,
|
||||||
getMe,
|
|
||||||
getNoteToSelfTitle,
|
|
||||||
(
|
(
|
||||||
searchTerm: string,
|
searchTerm: string,
|
||||||
contacts: Array<ConversationType>,
|
contacts: Array<ConversationType>
|
||||||
noteToSelf: ConversationType,
|
|
||||||
noteToSelfTitle: string
|
|
||||||
): Array<ConversationType> => {
|
): Array<ConversationType> => {
|
||||||
const result: Array<ConversationType> = filterAndSortConversations(
|
return filterAndSortConversations(contacts, searchTerm);
|
||||||
contacts,
|
|
||||||
searchTerm
|
|
||||||
);
|
|
||||||
if (!searchTerm || noteToSelfTitle.includes(searchTerm)) {
|
|
||||||
result.push(noteToSelf);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
16
ts/state/selectors/emojis.ts
Normal file
16
ts/state/selectors/emojis.ts
Normal 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);
|
21
ts/state/selectors/linkPreviews.ts
Normal file
21
ts/state/selectors/linkPreviews.ts
Normal 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;
|
||||||
|
}
|
||||||
|
);
|
|
@ -2,13 +2,12 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { createSelector } from 'reselect';
|
|
||||||
import { get } from 'lodash';
|
import { get } from 'lodash';
|
||||||
import { mapDispatchToProps } from '../actions';
|
import { mapDispatchToProps } from '../actions';
|
||||||
import { CompositionArea } from '../../components/CompositionArea';
|
import { CompositionArea } from '../../components/CompositionArea';
|
||||||
import { StateType } from '../reducer';
|
import { StateType } from '../reducer';
|
||||||
|
|
||||||
import { isShortName } from '../../components/emoji/lib';
|
import { selectRecentEmojis } from '../selectors/emojis';
|
||||||
import { getIntl } from '../selectors/user';
|
import { getIntl } from '../selectors/user';
|
||||||
import { getConversationSelector } from '../selectors/conversations';
|
import { getConversationSelector } from '../selectors/conversations';
|
||||||
import {
|
import {
|
||||||
|
@ -24,11 +23,6 @@ type ExternalProps = {
|
||||||
id: string;
|
id: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectRecentEmojis = createSelector(
|
|
||||||
({ emojis }: StateType) => emojis.recents,
|
|
||||||
recents => recents.filter(isShortName)
|
|
||||||
);
|
|
||||||
|
|
||||||
const mapStateToProps = (state: StateType, props: ExternalProps) => {
|
const mapStateToProps = (state: StateType, props: ExternalProps) => {
|
||||||
const { id } = props;
|
const { id } = props;
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@ import * as React from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { get } from 'lodash';
|
import { get } from 'lodash';
|
||||||
import { StateType } from '../reducer';
|
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 { useActions as useEmojiActions } from '../ducks/emojis';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
@ -17,8 +17,8 @@ import { LocalizerType } from '../../types/Util';
|
||||||
|
|
||||||
export const SmartEmojiPicker = React.forwardRef<
|
export const SmartEmojiPicker = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
Pick<EmojiPickerProps, 'onPickEmoji' | 'onClose' | 'style'>
|
Pick<EmojiPickerProps, 'onPickEmoji' | 'onSetSkinTone' | 'onClose' | 'style'>
|
||||||
>(({ onPickEmoji, onClose, style }, ref) => {
|
>(({ onPickEmoji, onSetSkinTone, onClose, style }, ref) => {
|
||||||
const i18n = useSelector<StateType, LocalizerType>(getIntl);
|
const i18n = useSelector<StateType, LocalizerType>(getIntl);
|
||||||
const skinTone = useSelector<StateType, number>(state =>
|
const skinTone = useSelector<StateType, number>(state =>
|
||||||
get(state, ['items', 'skinTone'], 0)
|
get(state, ['items', 'skinTone'], 0)
|
||||||
|
@ -26,15 +26,6 @@ export const SmartEmojiPicker = React.forwardRef<
|
||||||
|
|
||||||
const recentEmojis = useRecentEmojis();
|
const recentEmojis = useRecentEmojis();
|
||||||
|
|
||||||
const { putItem } = useItemActions();
|
|
||||||
|
|
||||||
const onSetSkinTone = React.useCallback(
|
|
||||||
tone => {
|
|
||||||
putItem('skinTone', tone);
|
|
||||||
},
|
|
||||||
[putItem]
|
|
||||||
);
|
|
||||||
|
|
||||||
const { onUseEmoji } = useEmojiActions();
|
const { onUseEmoji } = useEmojiActions();
|
||||||
|
|
||||||
const handlePickEmoji = React.useCallback(
|
const handlePickEmoji = React.useCallback(
|
||||||
|
|
79
ts/state/smart/ForwardMessageModal.tsx
Normal file
79
ts/state/smart/ForwardMessageModal.tsx
Normal 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);
|
|
@ -42,6 +42,7 @@ export type OwnProps = {
|
||||||
| 'showContactModal'
|
| 'showContactModal'
|
||||||
| 'showExpiredIncomingTapToViewToast'
|
| 'showExpiredIncomingTapToViewToast'
|
||||||
| 'showExpiredOutgoingTapToViewToast'
|
| 'showExpiredOutgoingTapToViewToast'
|
||||||
|
| 'showForwardMessageModal'
|
||||||
| 'showVisualAttachment'
|
| 'showVisualAttachment'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
@ -72,6 +73,7 @@ const mapStateToProps = (
|
||||||
showContactModal,
|
showContactModal,
|
||||||
showExpiredIncomingTapToViewToast,
|
showExpiredIncomingTapToViewToast,
|
||||||
showExpiredOutgoingTapToViewToast,
|
showExpiredOutgoingTapToViewToast,
|
||||||
|
showForwardMessageModal,
|
||||||
showVisualAttachment,
|
showVisualAttachment,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
@ -103,6 +105,7 @@ const mapStateToProps = (
|
||||||
showContactModal,
|
showContactModal,
|
||||||
showExpiredIncomingTapToViewToast,
|
showExpiredIncomingTapToViewToast,
|
||||||
showExpiredOutgoingTapToViewToast,
|
showExpiredOutgoingTapToViewToast,
|
||||||
|
showForwardMessageModal,
|
||||||
showVisualAttachment,
|
showVisualAttachment,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { actions as conversations } from './ducks/conversations';
|
||||||
import { actions as emojis } from './ducks/emojis';
|
import { actions as emojis } from './ducks/emojis';
|
||||||
import { actions as expiration } from './ducks/expiration';
|
import { actions as expiration } from './ducks/expiration';
|
||||||
import { actions as items } from './ducks/items';
|
import { actions as items } from './ducks/items';
|
||||||
|
import { actions as linkPreviews } from './ducks/linkPreviews';
|
||||||
import { actions as network } from './ducks/network';
|
import { actions as network } from './ducks/network';
|
||||||
import { actions as safetyNumber } from './ducks/safetyNumber';
|
import { actions as safetyNumber } from './ducks/safetyNumber';
|
||||||
import { actions as search } from './ducks/search';
|
import { actions as search } from './ducks/search';
|
||||||
|
@ -21,6 +22,7 @@ export type ReduxActions = {
|
||||||
emojis: typeof emojis;
|
emojis: typeof emojis;
|
||||||
expiration: typeof expiration;
|
expiration: typeof expiration;
|
||||||
items: typeof items;
|
items: typeof items;
|
||||||
|
linkPreviews: typeof linkPreviews;
|
||||||
network: typeof network;
|
network: typeof network;
|
||||||
safetyNumber: typeof safetyNumber;
|
safetyNumber: typeof safetyNumber;
|
||||||
search: typeof search;
|
search: typeof search;
|
||||||
|
|
48
ts/test-both/state/ducks/linkPreviews_test.ts
Normal file
48
ts/test-both/state/ducks/linkPreviews_test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -46,6 +46,7 @@ describe('both/state/selectors/conversations', () => {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
type: 'direct',
|
type: 'direct',
|
||||||
|
searchableTitle: `${id} title`,
|
||||||
title: `${id} title`,
|
title: `${id} title`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -478,6 +479,13 @@ describe('both/state/selectors/conversations', () => {
|
||||||
const getRootStateWithConversations = (searchTerm = ''): StateType => {
|
const getRootStateWithConversations = (searchTerm = ''): StateType => {
|
||||||
const result = getRootState(searchTerm);
|
const result = getRootState(searchTerm);
|
||||||
Object.assign(result.conversations.conversationLookup, {
|
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': {
|
'convo-1': {
|
||||||
...getDefaultConversation('convo-1'),
|
...getDefaultConversation('convo-1'),
|
||||||
name: 'In System Contacts',
|
name: 'In System Contacts',
|
||||||
|
@ -517,32 +525,20 @@ describe('both/state/selectors/conversations', () => {
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
it('only returns Note to Self when there are no other contacts', () => {
|
it('returns no results when there are no 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", () => {
|
|
||||||
const state = getRootState('foo bar baz');
|
const state = getRootState('foo bar baz');
|
||||||
const result = getComposeContacts(state);
|
const result = getComposeContacts(state);
|
||||||
|
|
||||||
assert.isEmpty(result);
|
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 state = getRootStateWithConversations();
|
||||||
const result = getComposeContacts(state);
|
const result = getComposeContacts(state);
|
||||||
|
|
||||||
const ids = result.map(contact => contact.id);
|
const ids = result.map(contact => contact.id);
|
||||||
assert.deepEqual(ids, [
|
// convo-6 is sorted last because it doesn't have a name
|
||||||
'convo-1',
|
assert.deepEqual(ids, ['convo-1', 'convo-5', 'convo-0', 'convo-6']);
|
||||||
'convo-5',
|
|
||||||
'convo-6',
|
|
||||||
'our-conversation-id',
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('can search for contacts', () => {
|
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"
|
// NOTE: convo-6 matches because you can't write "Sharing" without "in"
|
||||||
assert.deepEqual(ids, ['convo-1', 'convo-5', 'convo-6']);
|
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', () => {
|
describe('#getComposeGroups', () => {
|
||||||
|
|
|
@ -11,6 +11,10 @@ const FUSE_OPTIONS: FuseOptions<ConversationType> = {
|
||||||
threshold: 0.05,
|
threshold: 0.05,
|
||||||
tokenize: true,
|
tokenize: true,
|
||||||
keys: [
|
keys: [
|
||||||
|
{
|
||||||
|
name: 'searchableTitle',
|
||||||
|
weight: 1,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'title',
|
name: 'title',
|
||||||
weight: 1,
|
weight: 1,
|
||||||
|
|
|
@ -16238,7 +16238,7 @@
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/CompositionInput.js",
|
"path": "ts/components/CompositionInput.js",
|
||||||
"line": " const emojiCompletionRef = React.useRef();",
|
"line": " const emojiCompletionRef = React.useRef();",
|
||||||
"lineNumber": 56,
|
"lineNumber": 62,
|
||||||
"reasonCategory": "falseMatch",
|
"reasonCategory": "falseMatch",
|
||||||
"updated": "2020-10-26T19:12:24.410Z",
|
"updated": "2020-10-26T19:12:24.410Z",
|
||||||
"reasonDetail": "Doesn't refer to a DOM element."
|
"reasonDetail": "Doesn't refer to a DOM element."
|
||||||
|
@ -16247,7 +16247,7 @@
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/CompositionInput.js",
|
"path": "ts/components/CompositionInput.js",
|
||||||
"line": " const mentionCompletionRef = React.useRef();",
|
"line": " const mentionCompletionRef = React.useRef();",
|
||||||
"lineNumber": 57,
|
"lineNumber": 63,
|
||||||
"reasonCategory": "falseMatch",
|
"reasonCategory": "falseMatch",
|
||||||
"updated": "2020-10-26T23:54:34.273Z",
|
"updated": "2020-10-26T23:54:34.273Z",
|
||||||
"reasonDetail": "Doesn't refer to a DOM element."
|
"reasonDetail": "Doesn't refer to a DOM element."
|
||||||
|
@ -16256,7 +16256,7 @@
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/CompositionInput.js",
|
"path": "ts/components/CompositionInput.js",
|
||||||
"line": " const quillRef = React.useRef();",
|
"line": " const quillRef = React.useRef();",
|
||||||
"lineNumber": 58,
|
"lineNumber": 64,
|
||||||
"reasonCategory": "falseMatch",
|
"reasonCategory": "falseMatch",
|
||||||
"updated": "2020-10-26T19:12:24.410Z",
|
"updated": "2020-10-26T19:12:24.410Z",
|
||||||
"reasonDetail": "Doesn't refer to a DOM element."
|
"reasonDetail": "Doesn't refer to a DOM element."
|
||||||
|
@ -16265,7 +16265,7 @@
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/CompositionInput.js",
|
"path": "ts/components/CompositionInput.js",
|
||||||
"line": " const scrollerRef = React.useRef(null);",
|
"line": " const scrollerRef = React.useRef(null);",
|
||||||
"lineNumber": 59,
|
"lineNumber": 65,
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2020-10-26T19:12:24.410Z",
|
"updated": "2020-10-26T19:12:24.410Z",
|
||||||
"reasonDetail": "Used with Quill for scrolling."
|
"reasonDetail": "Used with Quill for scrolling."
|
||||||
|
@ -16274,7 +16274,7 @@
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/CompositionInput.js",
|
"path": "ts/components/CompositionInput.js",
|
||||||
"line": " const propsRef = React.useRef(props);",
|
"line": " const propsRef = React.useRef(props);",
|
||||||
"lineNumber": 60,
|
"lineNumber": 66,
|
||||||
"reasonCategory": "falseMatch",
|
"reasonCategory": "falseMatch",
|
||||||
"updated": "2020-10-26T19:12:24.410Z",
|
"updated": "2020-10-26T19:12:24.410Z",
|
||||||
"reasonDetail": "Doesn't refer to a DOM element."
|
"reasonDetail": "Doesn't refer to a DOM element."
|
||||||
|
@ -16283,11 +16283,27 @@
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/CompositionInput.js",
|
"path": "ts/components/CompositionInput.js",
|
||||||
"line": " const memberRepositoryRef = React.useRef(new memberRepository_1.MemberRepository());",
|
"line": " const memberRepositoryRef = React.useRef(new memberRepository_1.MemberRepository());",
|
||||||
"lineNumber": 61,
|
"lineNumber": 67,
|
||||||
"reasonCategory": "falseMatch",
|
"reasonCategory": "falseMatch",
|
||||||
"updated": "2020-10-26T23:56:13.482Z",
|
"updated": "2020-10-26T23:56:13.482Z",
|
||||||
"reasonDetail": "Doesn't refer to a DOM element."
|
"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",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/ContactPills.js",
|
"path": "ts/components/ContactPills.js",
|
||||||
|
@ -16315,6 +16331,22 @@
|
||||||
"updated": "2020-11-11T21:56:04.179Z",
|
"updated": "2020-11-11T21:56:04.179Z",
|
||||||
"reasonDetail": "Needed to render the remote video element."
|
"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",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/GroupCallOverflowArea.js",
|
"path": "ts/components/GroupCallOverflowArea.js",
|
||||||
|
@ -16557,7 +16589,7 @@
|
||||||
"rule": "React-createRef",
|
"rule": "React-createRef",
|
||||||
"path": "ts/components/conversation/Message.tsx",
|
"path": "ts/components/conversation/Message.tsx",
|
||||||
"line": " public focusRef: React.RefObject<HTMLDivElement> = React.createRef();",
|
"line": " public focusRef: React.RefObject<HTMLDivElement> = React.createRef();",
|
||||||
"lineNumber": 241,
|
"lineNumber": 242,
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2021-03-05T19:57:01.431Z",
|
"updated": "2021-03-05T19:57:01.431Z",
|
||||||
"reasonDetail": "Used for managing focus only"
|
"reasonDetail": "Used for managing focus only"
|
||||||
|
@ -16566,7 +16598,7 @@
|
||||||
"rule": "React-createRef",
|
"rule": "React-createRef",
|
||||||
"path": "ts/components/conversation/Message.tsx",
|
"path": "ts/components/conversation/Message.tsx",
|
||||||
"line": " public audioButtonRef: React.RefObject<HTMLButtonElement> = React.createRef();",
|
"line": " public audioButtonRef: React.RefObject<HTMLButtonElement> = React.createRef();",
|
||||||
"lineNumber": 243,
|
"lineNumber": 244,
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2021-03-05T19:57:01.431Z",
|
"updated": "2021-03-05T19:57:01.431Z",
|
||||||
"reasonDetail": "Used for propagating click from the Message to MessageAudio's button"
|
"reasonDetail": "Used for propagating click from the Message to MessageAudio's button"
|
||||||
|
@ -16575,7 +16607,7 @@
|
||||||
"rule": "React-createRef",
|
"rule": "React-createRef",
|
||||||
"path": "ts/components/conversation/Message.tsx",
|
"path": "ts/components/conversation/Message.tsx",
|
||||||
"line": " > = React.createRef();",
|
"line": " > = React.createRef();",
|
||||||
"lineNumber": 247,
|
"lineNumber": 248,
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2021-03-05T19:57:01.431Z",
|
"updated": "2021-03-05T19:57:01.431Z",
|
||||||
"reasonDetail": "Used for detecting clicks outside reaction viewer"
|
"reasonDetail": "Used for detecting clicks outside reaction viewer"
|
||||||
|
|
|
@ -4,11 +4,12 @@
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
import { AttachmentType } from '../types/Attachment';
|
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 { 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 { MessageModel } from '../models/messages';
|
||||||
|
import { MessageType } from '../state/ducks/conversations';
|
||||||
import { assert } from '../util/assert';
|
import { assert } from '../util/assert';
|
||||||
|
|
||||||
type GetLinkPreviewImageResult = {
|
type GetLinkPreviewImageResult = {
|
||||||
|
@ -48,6 +49,8 @@ const {
|
||||||
getAbsoluteAttachmentPath,
|
getAbsoluteAttachmentPath,
|
||||||
getAbsoluteDraftPath,
|
getAbsoluteDraftPath,
|
||||||
getAbsoluteTempPath,
|
getAbsoluteTempPath,
|
||||||
|
loadPreviewData,
|
||||||
|
loadStickerData,
|
||||||
openFileInFolder,
|
openFileInFolder,
|
||||||
readAttachmentData,
|
readAttachmentData,
|
||||||
readDraftData,
|
readDraftData,
|
||||||
|
@ -608,7 +611,7 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
onEditorStateChange: (
|
onEditorStateChange: (
|
||||||
msg: string,
|
msg: string,
|
||||||
bodyRanges: Array<typeof window.Whisper.BodyRangeType>,
|
bodyRanges: Array<typeof window.Whisper.BodyRangeType>,
|
||||||
caretLocation: number
|
caretLocation?: number
|
||||||
) => this.onEditorStateChange(msg, bodyRanges, caretLocation),
|
) => this.onEditorStateChange(msg, bodyRanges, caretLocation),
|
||||||
onTextTooLong: () => this.showToast(Whisper.MessageBodyTooLongToast),
|
onTextTooLong: () => this.showToast(Whisper.MessageBodyTooLongToast),
|
||||||
onChooseAttachment: this.onChooseAttachment.bind(this),
|
onChooseAttachment: this.onChooseAttachment.bind(this),
|
||||||
|
@ -774,6 +777,7 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
const showExpiredOutgoingTapToViewToast = () => {
|
const showExpiredOutgoingTapToViewToast = () => {
|
||||||
this.showToast(Whisper.TapToViewExpiredOutgoingToast);
|
this.showToast(Whisper.TapToViewExpiredOutgoingToast);
|
||||||
};
|
};
|
||||||
|
const showForwardMessageModal = this.showForwardMessageModal.bind(this);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
deleteMessage,
|
deleteMessage,
|
||||||
|
@ -792,6 +796,7 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
showContactModal,
|
showContactModal,
|
||||||
showExpiredIncomingTapToViewToast,
|
showExpiredIncomingTapToViewToast,
|
||||||
showExpiredOutgoingTapToViewToast,
|
showExpiredOutgoingTapToViewToast,
|
||||||
|
showForwardMessageModal,
|
||||||
showIdentity,
|
showIdentity,
|
||||||
showMessageDetail,
|
showMessageDetail,
|
||||||
showVisualAttachment,
|
showVisualAttachment,
|
||||||
|
@ -980,14 +985,18 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
this.$('.timeline-placeholder').append(this.timelineView.el);
|
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 toast = new ToastView(options);
|
||||||
|
|
||||||
const lightboxEl = $('.module-lightbox');
|
if (element) {
|
||||||
if (lightboxEl.length > 0) {
|
toast.$el.appendTo(element);
|
||||||
toast.$el.appendTo(lightboxEl);
|
|
||||||
} else {
|
} 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();
|
toast.render();
|
||||||
|
@ -2139,6 +2148,196 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
await message.retrySend();
|
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() {
|
async showAllMedia() {
|
||||||
// We fetch more documents than media as they don’t require to be loaded
|
// We fetch more documents than media as they don’t require to be loaded
|
||||||
// into memory right away. Revisit this once we have infinite scrolling:
|
// into memory right away. Revisit this once we have infinite scrolling:
|
||||||
|
@ -3203,7 +3402,10 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
showSendAnywayDialog(contacts: any, confirmText: any) {
|
showSendAnywayDialog(
|
||||||
|
contacts: Array<ConversationModel>,
|
||||||
|
confirmText?: string
|
||||||
|
) {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
const dialog = new Whisper.SafetyNumberChangeDialogView({
|
const dialog = new Whisper.SafetyNumberChangeDialogView({
|
||||||
confirmText,
|
confirmText,
|
||||||
|
@ -3255,13 +3457,6 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mandatoryProfileSharingEnabled = window.Signal.RemoteConfig.isEnabled(
|
|
||||||
'desktop.mandatoryProfileSharing'
|
|
||||||
);
|
|
||||||
if (mandatoryProfileSharingEnabled && !this.model.get('profileSharing')) {
|
|
||||||
this.model.set({ profileSharing: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.showInvalidMessageToast()) {
|
if (this.showInvalidMessageToast()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -3474,13 +3669,6 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
return;
|
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 attachments = await this.getFiles();
|
||||||
const sendDelta = Date.now() - this.sendStart;
|
const sendDelta = Date.now() - this.sendStart;
|
||||||
window.log.info('Send pre-checks took', sendDelta, 'milliseconds');
|
window.log.info('Send pre-checks took', sendDelta, 'milliseconds');
|
||||||
|
@ -3607,6 +3795,7 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
URL.revokeObjectURL(item.url);
|
URL.revokeObjectURL(item.url);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
window.reduxActions.linkPreviews.removeLinkPreview();
|
||||||
this.preview = null;
|
this.preview = null;
|
||||||
this.currentlyMatchedLink = null;
|
this.currentlyMatchedLink = null;
|
||||||
this.linkPreviewAbortController?.abort();
|
this.linkPreviewAbortController?.abort();
|
||||||
|
@ -3881,6 +4070,7 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
URL.revokeObjectURL(item.url);
|
URL.revokeObjectURL(item.url);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
window.reduxActions.linkPreviews.removeLinkPreview();
|
||||||
this.preview = null;
|
this.preview = null;
|
||||||
|
|
||||||
// Cancel other in-flight link preview requests.
|
// Cancel other in-flight link preview requests.
|
||||||
|
@ -3937,6 +4127,7 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.reduxActions.linkPreviews.addLinkPreview(result);
|
||||||
this.preview = [result];
|
this.preview = [result];
|
||||||
this.renderLinkPreview();
|
this.renderLinkPreview();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -3952,6 +4143,9 @@ Whisper.ConversationView = Whisper.View.extend({
|
||||||
},
|
},
|
||||||
|
|
||||||
renderLinkPreview() {
|
renderLinkPreview() {
|
||||||
|
if (this.forwardMessageModal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.previewView) {
|
if (this.previewView) {
|
||||||
this.previewView.remove();
|
this.previewView.remove();
|
||||||
this.previewView = null;
|
this.previewView = null;
|
||||||
|
|
4
ts/window.d.ts
vendored
4
ts/window.d.ts
vendored
|
@ -46,6 +46,7 @@ import { createCompositionArea } from './state/roots/createCompositionArea';
|
||||||
import { createContactModal } from './state/roots/createContactModal';
|
import { createContactModal } from './state/roots/createContactModal';
|
||||||
import { createConversationDetails } from './state/roots/createConversationDetails';
|
import { createConversationDetails } from './state/roots/createConversationDetails';
|
||||||
import { createConversationHeader } from './state/roots/createConversationHeader';
|
import { createConversationHeader } from './state/roots/createConversationHeader';
|
||||||
|
import { createForwardMessageModal } from './state/roots/createForwardMessageModal';
|
||||||
import { createGroupLinkManagement } from './state/roots/createGroupLinkManagement';
|
import { createGroupLinkManagement } from './state/roots/createGroupLinkManagement';
|
||||||
import { createGroupV1MigrationModal } from './state/roots/createGroupV1MigrationModal';
|
import { createGroupV1MigrationModal } from './state/roots/createGroupV1MigrationModal';
|
||||||
import { createGroupV2JoinModal } from './state/roots/createGroupV2JoinModal';
|
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 emojisDuck from './state/ducks/emojis';
|
||||||
import * as expirationDuck from './state/ducks/expiration';
|
import * as expirationDuck from './state/ducks/expiration';
|
||||||
import * as itemsDuck from './state/ducks/items';
|
import * as itemsDuck from './state/ducks/items';
|
||||||
|
import * as linkPreviewsDuck from './state/ducks/linkPreviews';
|
||||||
import * as networkDuck from './state/ducks/network';
|
import * as networkDuck from './state/ducks/network';
|
||||||
import * as updatesDuck from './state/ducks/updates';
|
import * as updatesDuck from './state/ducks/updates';
|
||||||
import * as userDuck from './state/ducks/user';
|
import * as userDuck from './state/ducks/user';
|
||||||
|
@ -491,6 +493,7 @@ declare global {
|
||||||
createContactModal: typeof createContactModal;
|
createContactModal: typeof createContactModal;
|
||||||
createConversationDetails: typeof createConversationDetails;
|
createConversationDetails: typeof createConversationDetails;
|
||||||
createConversationHeader: typeof createConversationHeader;
|
createConversationHeader: typeof createConversationHeader;
|
||||||
|
createForwardMessageModal: typeof createForwardMessageModal;
|
||||||
createGroupLinkManagement: typeof createGroupLinkManagement;
|
createGroupLinkManagement: typeof createGroupLinkManagement;
|
||||||
createGroupV1MigrationModal: typeof createGroupV1MigrationModal;
|
createGroupV1MigrationModal: typeof createGroupV1MigrationModal;
|
||||||
createGroupV2JoinModal: typeof createGroupV2JoinModal;
|
createGroupV2JoinModal: typeof createGroupV2JoinModal;
|
||||||
|
@ -510,6 +513,7 @@ declare global {
|
||||||
emojis: typeof emojisDuck;
|
emojis: typeof emojisDuck;
|
||||||
expiration: typeof expirationDuck;
|
expiration: typeof expirationDuck;
|
||||||
items: typeof itemsDuck;
|
items: typeof itemsDuck;
|
||||||
|
linkPreviews: typeof linkPreviewsDuck;
|
||||||
network: typeof networkDuck;
|
network: typeof networkDuck;
|
||||||
updates: typeof updatesDuck;
|
updates: typeof updatesDuck;
|
||||||
user: typeof userDuck;
|
user: typeof userDuck;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue