Multi-select forwarding and deleting

This commit is contained in:
Jamie Kyle 2023-03-20 15:23:53 -07:00 committed by GitHub
parent d986356eea
commit 1d549a9991
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
82 changed files with 2607 additions and 991 deletions

View file

@ -315,6 +315,10 @@
"message": "Mark as unread", "message": "Mark as unread",
"description": "Shown in menu for conversation, and marks conversation as unread" "description": "Shown in menu for conversation, and marks conversation as unread"
}, },
"icu:ConversationHeader__menu__selectMessages": {
"messageformat": "Select messages",
"description": "Shown in menu for conversation, allows the user to start selecting multiple messages in the conversation"
},
"moveConversationToInbox": { "moveConversationToInbox": {
"message": "Unarchive", "message": "Unarchive",
"description": "Undoes Archive Conversation action, and moves archived conversation back to the main conversation list" "description": "Undoes Archive Conversation action, and moves archived conversation back to the main conversation list"
@ -1173,6 +1177,10 @@
"message": "More Info", "message": "More Info",
"description": "Shown on the drop-down menu for an individual message, takes you to message detail screen" "description": "Shown on the drop-down menu for an individual message, takes you to message detail screen"
}, },
"icu:MessageContextMenu__select": {
"messageformat": "Select",
"description": "Shown on the drop-down menu for an individual message, opens the conversation in select mode with the current message selected"
},
"retrySend": { "retrySend": {
"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"
@ -2371,6 +2379,14 @@
"messageformat": "Redeemed", "messageformat": "Redeemed",
"description": "Shown when you've redeemed the donation badge on another device" "description": "Shown when you've redeemed the donation badge on another device"
}, },
"icu:messageAccessibilityLabel--outgoing": {
"messageformat": "Message sent by you",
"description": "Accessibility label for outgoing messages"
},
"icu:messageAccessibilityLabel--incoming": {
"messageformat": "Message sent by {author}",
"description": "Accessibility label for incoming messages"
},
"icu:modal--donation--title": { "icu:modal--donation--title": {
"messageformat": "Thanks for your support!", "messageformat": "Thanks for your support!",
"description": "The title of the outgoing donation badge detail dialog" "description": "The title of the outgoing donation badge detail dialog"
@ -4619,6 +4635,34 @@
"message": "Block Request", "message": "Block Request",
"description": "Confirmation button of dialog to block a user from requesting to join via the link again" "description": "Confirmation button of dialog to block a user from requesting to join via the link again"
}, },
"icu:SelectModeActions--exitSelectMode": {
"messageformat": "Exit select mode",
"description": "conversation > in select mode > composition area actions > exit select mode > accessibility label"
},
"icu:SelectModeActions--selectedMessages": {
"messageformat": "{count} selected",
"description": "conversation > in select mode > composition area actions > count of selected messsages"
},
"icu:SelectModeActions--deleteSelectedMessages": {
"messageformat": "Delete selected messages",
"description": "conversation > in select mode > composition area actions > delete selected messsages action > accessibility label"
},
"icu:SelectModeActions--forwardSelectedMessages": {
"messageformat": "Forward selected messages",
"description": "conversation > in select mode > composition area actions > forward selected messsages action > accessibility label"
},
"icu:SelectModeActions__confirmDelete--title": {
"messageformat": "Delete {count, plural, one {message} other {# messages}}",
"description": "conversation > in select mode > composition area actions > delete selected messages > confirmation modal > title"
},
"icu:SelectModeActions__confirmDelete--confirm": {
"messageformat": "Delete for me",
"description": "conversation > in select mode > composition area actions > delete selected messages > confirmation modal > button"
},
"icu:SelectModeActions__toast--TooManyMessagesToForward": {
"messageformat": "You can only forward up to 30 messages",
"description": "conversation > in select mode > composition area actions > forward selected messages (disabled) > toast message when too many messages"
},
"AvatarInput--no-photo-label--group": { "AvatarInput--no-photo-label--group": {
"message": "Add a group photo", "message": "Add a group photo",
"description": "The label for the avatar uploader when no group photo is selected" "description": "The label for the avatar uploader when no group photo is selected"
@ -4759,10 +4803,18 @@
"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."
}, },
"icu:ForwardMessageModal__title": {
"messageformat": "Forward To",
"description": "Title for the forward a message modal dialog"
},
"ForwardMessageModal--continue": { "ForwardMessageModal--continue": {
"message": "Continue", "message": "Continue",
"description": "aria-label for the 'next' button in the forward a message modal dialog" "description": "aria-label for the 'next' button in the forward a message modal dialog"
}, },
"icu:ForwardMessagesModal__toast--CannotForwardEmptyMessage": {
"messageformat": "Cannot forward empty or deleted messages",
"description": "Toast message shown when trying to forward an empty or deleted message"
},
"TimelineDateHeader--date-in-last-6-months": { "TimelineDateHeader--date-in-last-6-months": {
"message": "ddd, MMM D", "message": "ddd, MMM D",
"description": "(deleted 01/25/2023) Moment.js format for date headers in the message timeline, for dates <6 months old. See https://momentjs.com/docs/#/displaying/format/." "description": "(deleted 01/25/2023) Moment.js format for date headers in the message timeline, for dates <6 months old. See https://momentjs.com/docs/#/displaying/format/."

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path d="m8.53,14.17l-3.39-3.39.88-.88,2.5,2.5,5.45-5.45.89.89-6.34,6.33Z"/>
</svg>

After

Width:  |  Height:  |  Size: 218 B

View file

@ -728,3 +728,27 @@
background: $color-gray-80; background: $color-gray-80;
} }
} }
@mixin sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
@mixin disabled {
&:is(:disabled, [aria-disabled='true']) {
@content;
}
}
@mixin not-disabled {
&:not(:disabled):not([aria-disabled='true']) {
@content;
}
}

View file

@ -62,7 +62,9 @@
outline: none; outline: none;
padding-left: 16px; padding-left: 16px;
padding-right: 16px; padding-right: 16px;
transition: background 0.1s ease-out; transition-property: background, translate;
transition-duration: 0.1s;
transition-timing-function: ease-out;
} }
.module-message__quote-story-reaction-header { .module-message__quote-story-reaction-header {
@ -187,7 +189,7 @@
} }
} }
.module-message--selected & { .module-message--targeted & {
@include mouse-mode { @include mouse-mode {
background-color: $color-gray-60; background-color: $color-gray-60;
} }
@ -290,7 +292,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 0; min-width: 0;
max-width: 306px; max-width: min(306px, calc(100% - 16px - 22px));
.module-timeline--width-wide &, .module-timeline--width-wide &,
.module-message-detail & { .module-message-detail & {
@ -347,18 +349,76 @@ $message-padding-horizontal: 12px;
} }
} }
.module-message__container--selected { .module-message__container--targeted {
@include mouse-mode { @include mouse-mode {
animation: module-message__highlight 1.2s cubic-bezier(0.17, 0.17, 0, 1); animation: module-message__highlight 1.2s cubic-bezier(0.17, 0.17, 0, 1);
} }
} }
.module-message__container--selected-lighter { .module-message__container--targeted-lighter {
@include mouse-mode { @include mouse-mode {
animation: module-message__highlight-lighter 1.2s animation: module-message__highlight-lighter 1.2s
cubic-bezier(0.17, 0.17, 0, 1); cubic-bezier(0.17, 0.17, 0, 1);
} }
} }
.module-message__wrapper {
position: relative;
transition: background 0.1s ease-out;
}
.module-message__wrapper--select-mode {
.module-message--incoming {
translate: calc(16px + 22px) 0;
}
}
.module-message__alt-accessibility-tree {
@include sr-only;
}
.module-message__wrapper--selected {
background: rgba($color-ultramarine, 8%);
}
.module-message__select-checkbox {
position: absolute;
top: 50%;
left: 16px;
translate: 0 -50%;
width: 18px;
height: 18px;
border-radius: 9999px;
background: transparent;
border: 1px solid $color-gray-20;
animation: module-message__select-checkbox--fadeIn 0.2s ease-out;
transition: background 0.1s ease-out, border-color 0.1s ease-out;
&::before {
content: '';
display: block;
width: 20px;
height: 20px;
margin: -2px;
@include color-svg('../images/icons/v2/check-20.svg', $color-white);
opacity: 0;
transition: opacity 0.1s ease-out;
}
.module-message__wrapper--selected & {
background: $color-ultramarine;
border-color: $color-ultramarine;
&::before {
opacity: 1;
}
}
}
@keyframes module-message__select-checkbox--fadeIn {
from {
opacity: 0;
}
}
.module-message:focus-within { .module-message:focus-within {
@include keyboard-mode { @include keyboard-mode {
background: $color-selected-message-background-light; background: $color-selected-message-background-light;
@ -7590,6 +7650,22 @@ button.module-image__border-overlay:focus {
} }
} }
&__select::before {
@include light-theme {
@include color-svg(
'../images/icons/v2/check-circle-outline-24.svg',
$color-black
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v2/check-circle-outline-24.svg',
$color-gray-15
);
}
}
&__retry-send::before { &__retry-send::before {
@include light-theme { @include light-theme {
@include color-svg('../images/icons/v2/send-24.svg', $color-black); @include color-svg('../images/icons/v2/send-24.svg', $color-black);

View file

@ -9,11 +9,15 @@
} }
@mixin hover-and-active-states($background-color, $mix-color) { @mixin hover-and-active-states($background-color, $mix-color) {
&:hover:not(:disabled) { &:hover {
background: mix($background-color, $mix-color, 85%); @include not-disabled {
background: mix($background-color, $mix-color, 85%);
}
} }
&:active:not(:disabled) { &:active {
background: mix($background-color, $mix-color, 75%); @include not-disabled {
background: mix($background-color, $mix-color, 75%);
}
} }
} }
@ -31,7 +35,7 @@
@include focus-box-shadow($color-black, $color-ultramarine-icon); @include focus-box-shadow($color-black, $color-ultramarine-icon);
} }
&:disabled { @include disabled {
cursor: not-allowed; cursor: not-allowed;
} }
@ -57,7 +61,7 @@
color: $color; color: $color;
background: $background-color; background: $background-color;
&:disabled { @include disabled {
color: fade-out($color, 0.4); color: fade-out($color, 0.4);
background: fade-out($background-color, 0.6); background: fade-out($background-color, 0.6);
} }
@ -79,7 +83,7 @@
color: $color; color: $color;
background: $background-color; background: $background-color;
&:disabled { @include disabled {
color: $color-black-alpha-40; color: $color-black-alpha-40;
background: fade-out($background-color, 0.6); background: fade-out($background-color, 0.6);
} }
@ -102,7 +106,7 @@
color: $color; color: $color;
background: $background-color; background: $background-color;
&:disabled { @include disabled {
color: $color-white-alpha-20; color: $color-white-alpha-20;
background: fade-out($background-color, 0.6); background: fade-out($background-color, 0.6);
} }
@ -126,7 +130,7 @@
color: $color; color: $color;
background: $background-color; background: $background-color;
&:disabled { @include disabled {
color: fade-out($color, 0.4); color: fade-out($color, 0.4);
background: fade-out($background-color, 0.6); background: fade-out($background-color, 0.6);
} }
@ -148,7 +152,7 @@
color: $color; color: $color;
background: $background-color; background: $background-color;
&:disabled { @include disabled {
color: fade-out($color, 0.4); color: fade-out($color, 0.4);
background: fade-out($background-color, 0.6); background: fade-out($background-color, 0.6);
} }
@ -180,7 +184,7 @@
color: $color; color: $color;
background: $background-color; background: $background-color;
&:disabled { @include disabled {
color: fade-out($color, 0.4); color: fade-out($color, 0.4);
background: fade-out($background-color, 0.6); background: fade-out($background-color, 0.6);
} }
@ -194,7 +198,7 @@
color: $color; color: $color;
background: $background-color; background: $background-color;
&:disabled { @include disabled {
color: fade-out($color, 0.4); color: fade-out($color, 0.4);
background: fade-out($background-color, 0.6); background: fade-out($background-color, 0.6);
} }

View file

@ -0,0 +1,61 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.SelectModeActions {
display: flex;
align-items: center;
width: 100%;
}
.SelectModeActions__selectedMessages {
flex: 1;
padding: 17px 10px;
@include font-body-1;
@include light-theme() {
color: $color-gray-60;
}
@include dark-theme() {
color: $color-gray-25;
}
}
.SelectModeActions__button {
appearance: none;
padding: 15px;
border: none;
background: transparent;
}
.SelectModeActions__icon {
display: block;
width: 24px;
height: 24px;
@include light-theme {
color: $color-gray-75;
}
@include dark-theme {
color: $color-gray-15;
}
.SelectModeActions__button--disabled & {
@include light-theme {
color: $color-gray-25;
}
@include dark-theme {
color: $color-gray-60;
}
}
}
.SelectModeActions__icon--exitSelectMode {
@include color-svg('../images/icons/v2/x-24.svg', currentColor);
}
.SelectModeActions__icon--forwardSelectedMessages {
@include color-svg('../images/icons/v2/reply-outline-24.svg', currentColor);
transform: scaleX(-1);
}
.SelectModeActions__icon--deleteSelectedMessages {
@include color-svg('../images/icons/v2/trash-outline-24.svg', currentColor);
}

View file

@ -117,6 +117,7 @@
@import './components/SearchResultsLoadingFakeHeader.scss'; @import './components/SearchResultsLoadingFakeHeader.scss';
@import './components/SearchResultsLoadingFakeRow.scss'; @import './components/SearchResultsLoadingFakeRow.scss';
@import './components/Select.scss'; @import './components/Select.scss';
@import './components/SelectModeActions.scss';
@import './components/SendStoryModal.scss'; @import './components/SendStoryModal.scss';
@import './components/SignalConnectionsModal.scss'; @import './components/SignalConnectionsModal.scss';
@import './components/Slider.scss'; @import './components/Slider.scss';

View file

@ -1649,15 +1649,15 @@ export async function startApp(): Promise<void> {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
const { selectedMessage } = state.conversations; const { targetedMessage } = state.conversations;
if (!selectedMessage) { if (!targetedMessage) {
return; return;
} }
window.reduxActions.conversations.pushPanelForConversation({ window.reduxActions.conversations.pushPanelForConversation({
type: PanelType.MessageDetails, type: PanelType.MessageDetails,
args: { args: {
messageId: selectedMessage, messageId: targetedMessage,
}, },
}); });
return; return;
@ -1673,14 +1673,14 @@ export async function startApp(): Promise<void> {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
const { selectedMessage } = state.conversations; const { targetedMessage } = state.conversations;
const quotedMessageSelector = getQuotedMessageSelector(state); const quotedMessageSelector = getQuotedMessageSelector(state);
const quote = quotedMessageSelector(conversation.id); const quote = quotedMessageSelector(conversation.id);
window.reduxActions.composer.setQuoteByMessageId( window.reduxActions.composer.setQuoteByMessageId(
conversation.id, conversation.id,
quote ? undefined : selectedMessage quote ? undefined : targetedMessage
); );
return; return;
@ -1696,11 +1696,11 @@ export async function startApp(): Promise<void> {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
const { selectedMessage } = state.conversations; const { targetedMessage } = state.conversations;
if (selectedMessage) { if (targetedMessage) {
window.reduxActions.conversations.saveAttachmentFromMessage( window.reduxActions.conversations.saveAttachmentFromMessage(
selectedMessage targetedMessage
); );
return; return;
} }
@ -1712,9 +1712,9 @@ export async function startApp(): Promise<void> {
shiftKey && shiftKey &&
(key === 'd' || key === 'D') (key === 'd' || key === 'D')
) { ) {
const { selectedMessage } = state.conversations; const { targetedMessage } = state.conversations;
if (selectedMessage) { if (targetedMessage) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
@ -1724,9 +1724,9 @@ export async function startApp(): Promise<void> {
message: window.i18n('deleteWarning'), message: window.i18n('deleteWarning'),
okText: window.i18n('delete'), okText: window.i18n('delete'),
resolve: () => { resolve: () => {
window.reduxActions.conversations.deleteMessage({ window.reduxActions.conversations.deleteMessages({
conversationId: conversation.id, conversationId: conversation.id,
messageId: selectedMessage, messageIds: [targetedMessage],
}); });
}, },
}); });

View file

@ -33,7 +33,7 @@ export function AvatarLightbox({
isViewOnce isViewOnce
media={[]} media={[]}
saveAttachment={noop} saveAttachment={noop}
toggleForwardMessageModal={noop} toggleForwardMessagesModal={noop}
onMediaPlaybackStart={noop} onMediaPlaybackStart={noop}
onNextAttachment={noop} onNextAttachment={noop}
onPrevAttachment={noop} onPrevAttachment={noop}

View file

@ -50,6 +50,7 @@ type PropsType = {
tabIndex?: number; tabIndex?: number;
theme?: Theme; theme?: Theme;
variant?: ButtonVariant; variant?: ButtonVariant;
'aria-disabled'?: boolean;
} & ( } & (
| { | {
onClick: MouseEventHandler<HTMLButtonElement>; onClick: MouseEventHandler<HTMLButtonElement>;
@ -115,6 +116,7 @@ export const Button = React.forwardRef<HTMLButtonElement, PropsType>(
: ButtonSize.Medium, : ButtonSize.Medium,
} = props; } = props;
const ariaLabel = props['aria-label']; const ariaLabel = props['aria-label'];
const ariaDisabled = props['aria-disabled'];
let onClick: undefined | MouseEventHandler<HTMLButtonElement>; let onClick: undefined | MouseEventHandler<HTMLButtonElement>;
let type: 'button' | 'submit'; let type: 'button' | 'submit';
@ -137,6 +139,7 @@ export const Button = React.forwardRef<HTMLButtonElement, PropsType>(
const buttonElement = ( const buttonElement = (
<button <button
aria-label={ariaLabel} aria-label={ariaLabel}
aria-disabled={ariaDisabled}
className={classNames( className={classNames(
'module-Button', 'module-Button',
sizeClassName, sizeClassName,

View file

@ -44,6 +44,7 @@ const useProps = (overrideProps: Partial<Props> = {}): Props => ({
theme: React.useContext(StorybookThemeContext), theme: React.useContext(StorybookThemeContext),
setComposerFocus: action('setComposerFocus'), setComposerFocus: action('setComposerFocus'),
setQuoteByMessageId: action('setQuoteByMessageId'), setQuoteByMessageId: action('setQuoteByMessageId'),
showToast: action('showToast'),
// AttachmentList // AttachmentList
draftAttachments: overrideProps.draftAttachments || [], draftAttachments: overrideProps.draftAttachments || [],
@ -128,6 +129,11 @@ const useProps = (overrideProps: Partial<Props> = {}): Props => ({
isFetchingUUID: overrideProps.isFetchingUUID || false, isFetchingUUID: overrideProps.isFetchingUUID || false,
renderSmartCompositionRecording: _ => <div>RECORDING</div>, renderSmartCompositionRecording: _ => <div>RECORDING</div>,
renderSmartCompositionRecordingDraft: _ => <div>RECORDING DRAFT</div>, renderSmartCompositionRecordingDraft: _ => <div>RECORDING DRAFT</div>,
// Select mode
selectedMessageIds: undefined,
lastSelectedMessage: undefined,
toggleSelectMode: action('toggleSelectMode'),
toggleForwardMessagesModal: action('toggleForwardMessagesModal'),
}); });
export function Default(): JSX.Element { export function Default(): JSX.Element {

View file

@ -42,6 +42,7 @@ import { AudioCapture } from './conversation/AudioCapture';
import { CompositionUpload } from './CompositionUpload'; import { CompositionUpload } from './CompositionUpload';
import type { import type {
ConversationType, ConversationType,
MessageTimestamps,
PushPanelForConversationActionType, PushPanelForConversationActionType,
ShowConversationType, ShowConversationType,
} from '../state/ducks/conversations'; } from '../state/ducks/conversations';
@ -65,6 +66,8 @@ import { PanelType } from '../types/Panels';
import type { SmartCompositionRecordingDraftProps } from '../state/smart/CompositionRecordingDraft'; import type { SmartCompositionRecordingDraftProps } from '../state/smart/CompositionRecordingDraft';
import { useEscapeHandling } from '../hooks/useEscapeHandling'; import { useEscapeHandling } from '../hooks/useEscapeHandling';
import type { SmartCompositionRecordingProps } from '../state/smart/CompositionRecording'; import type { SmartCompositionRecordingProps } from '../state/smart/CompositionRecording';
import SelectModeActions from './conversation/SelectModeActions';
import type { ShowToastAction } from '../state/ducks/toast';
export type OwnProps = Readonly<{ export type OwnProps = Readonly<{
acceptedMessageRequest?: boolean; acceptedMessageRequest?: boolean;
@ -105,6 +108,7 @@ export type OwnProps = Readonly<{
messageRequestsEnabled?: boolean; messageRequestsEnabled?: boolean;
onClearAttachments(conversationId: string): unknown; onClearAttachments(conversationId: string): unknown;
onCloseLinkPreview(conversationId: string): unknown; onCloseLinkPreview(conversationId: string): unknown;
showToast: ShowToastAction;
processAttachments: (options: { processAttachments: (options: {
conversationId: string; conversationId: string;
files: ReadonlyArray<File>; files: ReadonlyArray<File>;
@ -146,6 +150,13 @@ export type OwnProps = Readonly<{
renderSmartCompositionRecordingDraft: ( renderSmartCompositionRecordingDraft: (
props: SmartCompositionRecordingDraftProps props: SmartCompositionRecordingDraftProps
) => JSX.Element | null; ) => JSX.Element | null;
selectedMessageIds: ReadonlyArray<string> | undefined;
lastSelectedMessage: MessageTimestamps | undefined;
toggleSelectMode: (on: boolean) => void;
toggleForwardMessagesModal: (
messageIds: ReadonlyArray<string>,
onForward: () => void
) => void;
}>; }>;
export type Props = Pick< export type Props = Pick<
@ -192,6 +203,7 @@ export function CompositionArea({
isDisabled, isDisabled,
isSignalConversation, isSignalConversation,
messageCompositionId, messageCompositionId,
showToast,
pushPanelForConversation, pushPanelForConversation,
processAttachments, processAttachments,
removeAttachment, removeAttachment,
@ -272,6 +284,11 @@ export function CompositionArea({
isFetchingUUID, isFetchingUUID,
renderSmartCompositionRecording, renderSmartCompositionRecording,
renderSmartCompositionRecordingDraft, renderSmartCompositionRecordingDraft,
// Selected messages
selectedMessageIds,
lastSelectedMessage,
toggleSelectMode,
toggleForwardMessagesModal,
}: Props): JSX.Element | null { }: Props): JSX.Element | null {
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
const [large, setLarge] = useState(false); const [large, setLarge] = useState(false);
@ -529,6 +546,34 @@ export function CompositionArea({
return <div />; return <div />;
} }
if (selectedMessageIds != null) {
return (
<SelectModeActions
i18n={i18n}
selectedMessageIds={selectedMessageIds}
onExitSelectMode={() => {
toggleSelectMode(false);
}}
onDeleteMessages={() => {
window.reduxActions.conversations.deleteMessages({
conversationId,
lastSelectedMessage,
messageIds: selectedMessageIds,
});
toggleSelectMode(false);
}}
onForwardMessages={() => {
if (selectedMessageIds.length > 0) {
toggleForwardMessagesModal(selectedMessageIds, () => {
toggleSelectMode(false);
});
}
}}
showToast={showToast}
/>
);
}
if ( if (
isBlocked || isBlocked ||
areWePending || areWePending ||

View file

@ -8,13 +8,14 @@ import { text } from '@storybook/addon-knobs';
import enMessages from '../../_locales/en/messages.json'; import enMessages from '../../_locales/en/messages.json';
import type { AttachmentType } from '../types/Attachment'; import type { AttachmentType } from '../types/Attachment';
import type { PropsType } from './ForwardMessageModal'; import type { PropsType } from './ForwardMessagesModal';
import { ForwardMessageModal } from './ForwardMessageModal'; import { ForwardMessagesModal } from './ForwardMessagesModal';
import { IMAGE_JPEG, VIDEO_MP4, stringToMIMEType } from '../types/MIME'; import { IMAGE_JPEG, VIDEO_MP4, stringToMIMEType } from '../types/MIME';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation'; import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { setupI18n } from '../util/setupI18n'; import { setupI18n } from '../util/setupI18n';
import { StorybookThemeContext } from '../../.storybook/StorybookThemeContext'; import { StorybookThemeContext } from '../../.storybook/StorybookThemeContext';
import { CompositionTextArea } from './CompositionTextArea'; import { CompositionTextArea } from './CompositionTextArea';
import type { MessageForwardDraft } from '../util/maybeForwardMessages';
const createAttachment = ( const createAttachment = (
props: Partial<AttachmentType> = {} props: Partial<AttachmentType> = {}
@ -45,17 +46,14 @@ const candidateConversations = Array.from(Array(100), () =>
); );
const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
attachments: overrideProps.attachments, drafts: overrideProps.drafts ?? [],
candidateConversations, candidateConversations,
doForwardMessage: action('doForwardMessage'), doForwardMessages: action('doForwardMessages'),
getPreferredBadge: () => undefined, getPreferredBadge: () => undefined,
i18n, i18n,
hasContact: Boolean(overrideProps.hasContact), linkPreviewForSource: () => undefined,
isSticker: Boolean(overrideProps.isSticker),
linkPreview: overrideProps.linkPreview,
messageBody: text('messageBody', overrideProps.messageBody || ''),
onClose: action('onClose'), onClose: action('onClose'),
onEditorStateChange: action('onEditorStateChange'), onChange: action('onChange'),
removeLinkPreview: action('removeLinkPreview'), removeLinkPreview: action('removeLinkPreview'),
RenderCompositionTextArea: props => ( RenderCompositionTextArea: props => (
<CompositionTextArea <CompositionTextArea
@ -68,16 +66,36 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
getPreferredBadge={() => undefined} getPreferredBadge={() => undefined}
/> />
), ),
showToast: action('showToast'),
theme: React.useContext(StorybookThemeContext), theme: React.useContext(StorybookThemeContext),
regionCode: 'US', regionCode: 'US',
}); });
function getMessageForwardDraft(
overrideProps: Partial<MessageForwardDraft>
): MessageForwardDraft {
return {
attachments: overrideProps.attachments,
hasContact: Boolean(overrideProps.hasContact),
isSticker: Boolean(overrideProps.isSticker),
messageBody: text('messageBody', overrideProps.messageBody || ''),
originalMessageId: '123',
previews: overrideProps.previews ?? [],
};
}
export function Modal(): JSX.Element { export function Modal(): JSX.Element {
return <ForwardMessageModal {...useProps()} />; return <ForwardMessagesModal {...useProps()} />;
} }
export function WithText(): JSX.Element { export function WithText(): JSX.Element {
return <ForwardMessageModal {...useProps({ messageBody: 'sup' })} />; return (
<ForwardMessagesModal
{...useProps({
drafts: [getMessageForwardDraft({ messageBody: 'sup' })],
})}
/>
);
} }
WithText.story = { WithText.story = {
@ -85,7 +103,13 @@ WithText.story = {
}; };
export function ASticker(): JSX.Element { export function ASticker(): JSX.Element {
return <ForwardMessageModal {...useProps({ isSticker: true })} />; return (
<ForwardMessagesModal
{...useProps({
drafts: [getMessageForwardDraft({ isSticker: true })],
})}
/>
);
} }
ASticker.story = { ASticker.story = {
@ -93,7 +117,13 @@ ASticker.story = {
}; };
export function WithAContact(): JSX.Element { export function WithAContact(): JSX.Element {
return <ForwardMessageModal {...useProps({ hasContact: true })} />; return (
<ForwardMessagesModal
{...useProps({
drafts: [getMessageForwardDraft({ hasContact: true })],
})}
/>
);
} }
WithAContact.story = { WithAContact.story = {
@ -102,21 +132,27 @@ WithAContact.story = {
export function LinkPreview(): JSX.Element { export function LinkPreview(): JSX.Element {
return ( return (
<ForwardMessageModal <ForwardMessagesModal
{...useProps({ {...useProps({
linkPreview: { drafts: [
description: LONG_DESCRIPTION, getMessageForwardDraft({
date: Date.now(), messageBody: 'signal.org',
domain: 'https://www.signal.org', previews: [
url: 'signal.org', {
image: createAttachment({ description: LONG_DESCRIPTION,
url: '/fixtures/kitten-4-112-112.jpg', date: Date.now(),
contentType: IMAGE_JPEG, 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,
},
],
}), }),
isStickerPack: false, ],
title: LONG_TITLE,
},
messageBody: 'signal.org',
})} })}
/> />
); );
@ -128,25 +164,29 @@ LinkPreview.story = {
export function MediaAttachments(): JSX.Element { export function MediaAttachments(): JSX.Element {
return ( return (
<ForwardMessageModal <ForwardMessagesModal
{...useProps({ {...useProps({
attachments: [ drafts: [
createAttachment({ getMessageForwardDraft({
pending: true, messageBody: 'cats',
}), attachments: [
createAttachment({ createAttachment({
contentType: IMAGE_JPEG, pending: true,
fileName: 'tina-rolf-269345-unsplash.jpg', }),
url: '/fixtures/tina-rolf-269345-unsplash.jpg', createAttachment({
}), contentType: IMAGE_JPEG,
createAttachment({ fileName: 'tina-rolf-269345-unsplash.jpg',
contentType: VIDEO_MP4, url: '/fixtures/tina-rolf-269345-unsplash.jpg',
fileName: 'pixabay-Soap-Bubble-7141.mp4', }),
url: '/fixtures/pixabay-Soap-Bubble-7141.mp4', createAttachment({
screenshotPath: '/fixtures/kitten-4-112-112.jpg', contentType: VIDEO_MP4,
fileName: 'pixabay-Soap-Bubble-7141.mp4',
url: '/fixtures/pixabay-Soap-Bubble-7141.mp4',
screenshotPath: '/fixtures/kitten-4-112-112.jpg',
}),
],
}), }),
], ],
messageBody: 'cats',
})} })}
/> />
); );
@ -158,7 +198,7 @@ MediaAttachments.story = {
export function AnnouncementOnlyGroupsNonAdmin(): JSX.Element { export function AnnouncementOnlyGroupsNonAdmin(): JSX.Element {
return ( return (
<ForwardMessageModal <ForwardMessagesModal
{...useProps()} {...useProps()}
candidateConversations={[ candidateConversations={[
getDefaultConversation({ getDefaultConversation({

View file

@ -22,12 +22,7 @@ import type { Row } from './ConversationList';
import { ConversationList, RowType } from './ConversationList'; import { ConversationList, RowType } from './ConversationList';
import type { ConversationType } from '../state/ducks/conversations'; import type { ConversationType } from '../state/ducks/conversations';
import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
import type { LinkPreviewType } from '../types/message/LinkPreviews'; import type { LocalizerType, ThemeType } from '../types/Util';
import type {
DraftBodyRangesType,
LocalizerType,
ThemeType,
} from '../types/Util';
import type { SmartCompositionTextAreaProps } from '../state/smart/CompositionTextArea'; import type { SmartCompositionTextAreaProps } from '../state/smart/CompositionTextArea';
import { ModalHost } from './ModalHost'; import { ModalHost } from './ModalHost';
import { SearchInput } from './SearchInput'; import { SearchInput } from './SearchInput';
@ -39,34 +34,40 @@ import {
asyncShouldNeverBeCalled, asyncShouldNeverBeCalled,
} from '../util/shouldNeverBeCalled'; } from '../util/shouldNeverBeCalled';
import { Emojify } from './conversation/Emojify'; import { Emojify } from './conversation/Emojify';
import type { MessageForwardDraft } from '../util/maybeForwardMessages';
import {
isDraftEditable,
isDraftForwardable,
} from '../util/maybeForwardMessages';
import type { LinkPreviewType } from '../types/message/LinkPreviews';
import { LinkPreviewSourceType } from '../types/LinkPreview';
import { ToastType } from '../types/Toast';
import type { ShowToastAction } from '../state/ducks/toast';
export type DataPropsType = { export type DataPropsType = {
attachments?: ReadonlyArray<AttachmentType>;
candidateConversations: ReadonlyArray<ConversationType>; candidateConversations: ReadonlyArray<ConversationType>;
doForwardMessage: ( doForwardMessages: (
selectedContacts: Array<string>, conversationIds: ReadonlyArray<string>,
messageBody?: string, drafts: ReadonlyArray<MessageForwardDraft>
attachments?: ReadonlyArray<AttachmentType>,
linkPreview?: LinkPreviewType
) => void; ) => void;
drafts: ReadonlyArray<MessageForwardDraft>;
getPreferredBadge: PreferredBadgeSelectorType; getPreferredBadge: PreferredBadgeSelectorType;
hasContact: boolean;
i18n: LocalizerType; i18n: LocalizerType;
isSticker: boolean;
linkPreview?: LinkPreviewType; linkPreviewForSource: (
messageBody?: string; source: LinkPreviewSourceType
) => LinkPreviewType | void;
onClose: () => void; onClose: () => void;
onEditorStateChange: ( onChange: (
conversationId: string | undefined, updatedDrafts: ReadonlyArray<MessageForwardDraft>,
messageText: string,
bodyRanges: DraftBodyRangesType,
caretLocation?: number caretLocation?: number
) => unknown; ) => unknown;
theme: ThemeType;
regionCode: string | undefined; regionCode: string | undefined;
RenderCompositionTextArea: ( RenderCompositionTextArea: (
props: SmartCompositionTextAreaProps props: SmartCompositionTextAreaProps
) => JSX.Element; ) => JSX.Element;
showToast: ShowToastAction;
theme: ThemeType;
}; };
type ActionPropsType = { type ActionPropsType = {
@ -77,20 +78,18 @@ export type PropsType = DataPropsType & ActionPropsType;
const MAX_FORWARD = 5; const MAX_FORWARD = 5;
export function ForwardMessageModal({ export function ForwardMessagesModal({
attachments, drafts,
candidateConversations, candidateConversations,
doForwardMessage, doForwardMessages,
linkPreviewForSource,
getPreferredBadge, getPreferredBadge,
hasContact,
i18n, i18n,
isSticker,
linkPreview,
messageBody,
onClose, onClose,
onEditorStateChange, onChange,
removeLinkPreview, removeLinkPreview,
RenderCompositionTextArea, RenderCompositionTextArea,
showToast,
theme, theme,
regionCode, regionCode,
}: PropsType): JSX.Element { }: PropsType): JSX.Element {
@ -102,14 +101,16 @@ export function ForwardMessageModal({
const [filteredConversations, setFilteredConversations] = useState( const [filteredConversations, setFilteredConversations] = useState(
filterAndSortConversationsByRecent(candidateConversations, '', regionCode) filterAndSortConversationsByRecent(candidateConversations, '', regionCode)
); );
const [attachmentsToForward, setAttachmentsToForward] = useState<
ReadonlyArray<AttachmentType>
>(attachments || []);
const [isEditingMessage, setIsEditingMessage] = useState(false); const [isEditingMessage, setIsEditingMessage] = useState(false);
const [messageBodyText, setMessageBodyText] = useState(messageBody || '');
const [cannotMessage, setCannotMessage] = useState(false); const [cannotMessage, setCannotMessage] = useState(false);
const isMessageEditable = !isSticker && !hasContact; const isLonelyDraft = drafts.length === 1;
const lonelyDraft = isLonelyDraft ? drafts[0] : null;
const isLonelyDraftEditable =
lonelyDraft != null && isDraftEditable(lonelyDraft);
const lonelyLinkPreview = isLonelyDraft
? linkPreviewForSource(LinkPreviewSourceType.ForwardMessageModal)
: null;
const hasSelectedMaximumNumberOfContacts = const hasSelectedMaximumNumberOfContacts =
selectedContacts.length >= MAX_FORWARD; selectedContacts.length >= MAX_FORWARD;
@ -121,31 +122,29 @@ export function ForwardMessageModal({
const hasContactsSelected = Boolean(selectedContacts.length); const hasContactsSelected = Boolean(selectedContacts.length);
const canForwardMessage = const canForwardMessages =
hasContactsSelected && hasContactsSelected && drafts.every(isDraftForwardable);
(Boolean(messageBodyText) ||
isSticker ||
hasContact ||
(attachmentsToForward && attachmentsToForward.length));
const forwardMessage = React.useCallback(() => { const forwardMessages = React.useCallback(() => {
if (!canForwardMessage) { if (!canForwardMessages) {
showToast(ToastType.CannotForwardEmptyMessage);
return; return;
} }
const conversationIds = selectedContacts.map(contact => contact.id);
doForwardMessage( if (lonelyDraft != null) {
selectedContacts.map(contact => contact.id), const previews = lonelyLinkPreview ? [lonelyLinkPreview] : [];
messageBodyText, doForwardMessages(conversationIds, [{ ...lonelyDraft, previews }]);
attachmentsToForward, } else {
linkPreview doForwardMessages(conversationIds, drafts);
); }
}, [ }, [
attachmentsToForward, drafts,
canForwardMessage, lonelyDraft,
doForwardMessage, lonelyLinkPreview,
linkPreview, doForwardMessages,
messageBodyText,
selectedContacts, selectedContacts,
canForwardMessages,
showToast,
]); ]);
const normalizedSearchTerm = searchTerm.trim(); const normalizedSearchTerm = searchTerm.trim();
@ -299,52 +298,21 @@ export function ForwardMessageModal({
type="button" type="button"
/> />
)} )}
<h1>{i18n('forwardMessage')}</h1> <h1>{i18n('icu:ForwardMessageModal__title')}</h1>
</div> </div>
{isEditingMessage ? ( {isEditingMessage && lonelyDraft != null ? (
<div className="module-ForwardMessageModal__main-body"> <ForwardMessageEditor
{linkPreview ? ( draft={lonelyDraft}
<div className="module-ForwardMessageModal--link-preview"> linkPreview={lonelyLinkPreview}
<StagedLinkPreview onChange={messageBody => {
date={linkPreview.date} onChange([{ ...lonelyDraft, messageBody }]);
description={linkPreview.description || ''} }}
domain={linkPreview.url} removeLinkPreview={removeLinkPreview}
i18n={i18n} theme={theme}
image={linkPreview.image} i18n={i18n}
onClose={() => removeLinkPreview()} RenderCompositionTextArea={RenderCompositionTextArea}
title={linkPreview.title} onSubmit={forwardMessages}
url={linkPreview.url} />
/>
</div>
) : null}
{attachmentsToForward && attachmentsToForward.length ? (
<AttachmentList
attachments={attachmentsToForward}
i18n={i18n}
onCloseAttachment={(attachment: AttachmentType) => {
const newAttachments = attachmentsToForward.filter(
currentAttachment => currentAttachment !== attachment
);
setAttachmentsToForward(newAttachments);
}}
/>
) : null}
<RenderCompositionTextArea
draftText={messageBodyText}
onChange={(messageText, bodyRanges, caretLocation?) => {
setMessageBodyText(messageText);
onEditorStateChange(
undefined,
messageText,
bodyRanges,
caretLocation
);
}}
onSubmit={forwardMessage}
theme={theme}
/>
</div>
) : ( ) : (
<div className="module-ForwardMessageModal__main-body"> <div className="module-ForwardMessageModal__main-body">
<SearchInput <SearchInput
@ -418,12 +386,12 @@ export function ForwardMessageModal({
)} )}
</div> </div>
<div> <div>
{isEditingMessage || !isMessageEditable ? ( {isEditingMessage || !isLonelyDraftEditable ? (
<Button <Button
aria-label={i18n('ForwardMessageModal--continue')} aria-label={i18n('ForwardMessageModal--continue')}
className="module-ForwardMessageModal__send-button module-ForwardMessageModal__send-button--forward" className="module-ForwardMessageModal__send-button module-ForwardMessageModal__send-button--forward"
disabled={!canForwardMessage} aria-disabled={!canForwardMessages}
onClick={forwardMessage} onClick={forwardMessages}
/> />
) : ( ) : (
<Button <Button
@ -440,3 +408,71 @@ export function ForwardMessageModal({
</> </>
); );
} }
type ForwardMessageEditorProps = Readonly<{
draft: MessageForwardDraft;
linkPreview: LinkPreviewType | null | void;
removeLinkPreview(): void;
RenderCompositionTextArea: (
props: SmartCompositionTextAreaProps
) => JSX.Element;
onChange: (messageText: string, caretLocation?: number) => unknown;
onSubmit: () => unknown;
theme: ThemeType;
i18n: LocalizerType;
}>;
function ForwardMessageEditor({
draft,
linkPreview,
i18n,
RenderCompositionTextArea,
removeLinkPreview,
onChange,
onSubmit,
theme,
}: ForwardMessageEditorProps): JSX.Element {
const [attachmentsToForward, setAttachmentsToForward] = useState<
ReadonlyArray<AttachmentType>
>(draft.attachments ?? []);
return (
<div className="module-ForwardMessageModal__main-body">
{linkPreview ? (
<div className="module-ForwardMessageModal--link-preview">
<StagedLinkPreview
date={linkPreview.date}
description={linkPreview.description ?? ''}
domain={linkPreview.url}
i18n={i18n}
image={linkPreview.image}
onClose={removeLinkPreview}
title={linkPreview.title}
url={linkPreview.url}
/>
</div>
) : null}
{attachmentsToForward && attachmentsToForward.length ? (
<AttachmentList
attachments={attachmentsToForward}
i18n={i18n}
onCloseAttachment={(attachment: AttachmentType) => {
const newAttachments = attachmentsToForward.filter(
currentAttachment => currentAttachment !== attachment
);
setAttachmentsToForward(newAttachments);
}}
/>
) : null}
<RenderCompositionTextArea
draftText={draft.messageBody ?? ''}
onChange={(messageText, _bodyRanges, caretLocation) => {
onChange(messageText, caretLocation);
}}
onSubmit={onSubmit}
theme={theme}
/>
</div>
);
}

View file

@ -4,10 +4,10 @@
import React from 'react'; import React from 'react';
import type { import type {
ContactModalStateType, ContactModalStateType,
ForwardMessagePropsType,
UserNotFoundModalStateType, UserNotFoundModalStateType,
SafetyNumberChangedBlockingDataType, SafetyNumberChangedBlockingDataType,
AuthorizeArtCreatorDataType, AuthorizeArtCreatorDataType,
ForwardMessagesPropsType,
} from '../state/ducks/globalModals'; } from '../state/ducks/globalModals';
import type { LocalizerType, ThemeType } from '../types/Util'; import type { LocalizerType, ThemeType } from '../types/Util';
import { missingCaseError } from '../util/missingCaseError'; import { missingCaseError } from '../util/missingCaseError';
@ -35,8 +35,8 @@ export type PropsType = {
title?: string; title?: string;
}) => JSX.Element; }) => JSX.Element;
// ForwardMessageModal // ForwardMessageModal
forwardMessageProps: ForwardMessagePropsType | undefined; forwardMessagesProps: ForwardMessagesPropsType | undefined;
renderForwardMessageModal: () => JSX.Element; renderForwardMessagesModal: () => JSX.Element;
// ProfileEditor // ProfileEditor
isProfileEditorVisible: boolean; isProfileEditorVisible: boolean;
renderProfileEditor: () => JSX.Element; renderProfileEditor: () => JSX.Element;
@ -86,8 +86,8 @@ export function GlobalModalContainer({
errorModalProps, errorModalProps,
renderErrorModal, renderErrorModal,
// ForwardMessageModal // ForwardMessageModal
forwardMessageProps, forwardMessagesProps,
renderForwardMessageModal, renderForwardMessagesModal,
// ProfileEditor // ProfileEditor
isProfileEditorVisible, isProfileEditorVisible,
renderProfileEditor, renderProfileEditor,
@ -147,8 +147,8 @@ export function GlobalModalContainer({
return renderContactModal(); return renderContactModal();
} }
if (forwardMessageProps) { if (forwardMessagesProps) {
return renderForwardMessageModal(); return renderForwardMessagesModal();
} }
if (isProfileEditorVisible) { if (isProfileEditorVisible) {

View file

@ -13,7 +13,7 @@ import { ToastStickerPackInstallFailed } from './ToastStickerPackInstallFailed';
import { WhatsNewLink } from './WhatsNewLink'; import { WhatsNewLink } from './WhatsNewLink';
import { showToast } from '../util/showToast'; import { showToast } from '../util/showToast';
import { strictAssert } from '../util/assert'; import { strictAssert } from '../util/assert';
import { SelectedMessageSource } from '../state/ducks/conversationsEnums'; import { TargetedMessageSource } from '../state/ducks/conversationsEnums';
import { usePrevious } from '../hooks/usePrevious'; import { usePrevious } from '../hooks/usePrevious';
export type PropsType = { export type PropsType = {
@ -28,8 +28,8 @@ export type PropsType = {
renderMiniPlayer: (options: { shouldFlow: boolean }) => JSX.Element; renderMiniPlayer: (options: { shouldFlow: boolean }) => JSX.Element;
scrollToMessage: (conversationId: string, messageId: string) => unknown; scrollToMessage: (conversationId: string, messageId: string) => unknown;
selectedConversationId?: string; selectedConversationId?: string;
selectedMessage?: string; targetedMessage?: string;
selectedMessageSource?: SelectedMessageSource; targetedMessageSource?: TargetedMessageSource;
showConversation: ShowConversationType; showConversation: ShowConversationType;
showWhatsNewModal: () => unknown; showWhatsNewModal: () => unknown;
}; };
@ -46,8 +46,8 @@ export function Inbox({
renderMiniPlayer, renderMiniPlayer,
scrollToMessage, scrollToMessage,
selectedConversationId, selectedConversationId,
selectedMessage, targetedMessage,
selectedMessageSource, targetedMessageSource,
showConversation, showConversation,
showWhatsNewModal, showWhatsNewModal,
}: PropsType): JSX.Element { }: PropsType): JSX.Element {
@ -67,14 +67,14 @@ export function Inbox({
} }
if (selectedConversationId) { if (selectedConversationId) {
onConversationOpened(selectedConversationId, selectedMessage); onConversationOpened(selectedConversationId, targetedMessage);
} }
} else if ( } else if (
selectedConversationId && selectedConversationId &&
selectedMessage && targetedMessage &&
selectedMessageSource !== SelectedMessageSource.Focus targetedMessageSource !== TargetedMessageSource.Focus
) { ) {
scrollToMessage(selectedConversationId, selectedMessage); scrollToMessage(selectedConversationId, targetedMessage);
} }
if (!selectedConversationId) { if (!selectedConversationId) {
@ -93,8 +93,8 @@ export function Inbox({
prevConversationId, prevConversationId,
scrollToMessage, scrollToMessage,
selectedConversationId, selectedConversationId,
selectedMessage, targetedMessage,
selectedMessageSource, targetedMessageSource,
]); ]);
useEffect(() => { useEffect(() => {

View file

@ -242,7 +242,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
/> />
), ),
selectedConversationId: undefined, selectedConversationId: undefined,
selectedMessageId: undefined, targetedMessageId: undefined,
savePreferredLeftPaneWidth: action('savePreferredLeftPaneWidth'), savePreferredLeftPaneWidth: action('savePreferredLeftPaneWidth'),
searchInConversation: action('searchInConversation'), searchInConversation: action('searchInConversation'),
setComposeSearchTerm: action('setComposeSearchTerm'), setComposeSearchTerm: action('setComposeSearchTerm'),

View file

@ -96,7 +96,7 @@ export type PropsType = {
isMacOS: boolean; isMacOS: boolean;
preferredWidthFromStorage: number; preferredWidthFromStorage: number;
selectedConversationId: undefined | string; selectedConversationId: undefined | string;
selectedMessageId: undefined | string; targetedMessageId: undefined | string;
regionCode: string | undefined; regionCode: string | undefined;
challengeStatus: 'idle' | 'required' | 'pending'; challengeStatus: 'idle' | 'required' | 'pending';
setChallengeStatus: (status: 'idle') => void; setChallengeStatus: (status: 'idle') => void;
@ -185,7 +185,7 @@ export function LeftPane({
savePreferredLeftPaneWidth, savePreferredLeftPaneWidth,
searchInConversation, searchInConversation,
selectedConversationId, selectedConversationId,
selectedMessageId, targetedMessageId,
setChallengeStatus, setChallengeStatus,
setComposeGroupAvatar, setComposeGroupAvatar,
setComposeGroupExpireTimer, setComposeGroupExpireTimer,
@ -372,7 +372,7 @@ export function LeftPane({
conversationToOpen = helper.getConversationAndMessageInDirection( conversationToOpen = helper.getConversationAndMessageInDirection(
toFind, toFind,
selectedConversationId, selectedConversationId,
selectedMessageId targetedMessageId
); );
} }
} }
@ -404,7 +404,7 @@ export function LeftPane({
isMacOS, isMacOS,
searchInConversation, searchInConversation,
selectedConversationId, selectedConversationId,
selectedMessageId, targetedMessageId,
showChooseGroupMembers, showChooseGroupMembers,
showConversation, showConversation,
showInbox, showInbox,

View file

@ -68,7 +68,7 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => {
media, media,
saveAttachment: action('saveAttachment'), saveAttachment: action('saveAttachment'),
selectedIndex, selectedIndex,
toggleForwardMessageModal: action('toggleForwardMessageModal'), toggleForwardMessagesModal: action('toggleForwardMessagesModal'),
onMediaPlaybackStart: noop, onMediaPlaybackStart: noop,
onPrevAttachment: () => { onPrevAttachment: () => {
setSelectedIndex(Math.max(0, selectedIndex - 1)); setSelectedIndex(Math.max(0, selectedIndex - 1));

View file

@ -34,7 +34,7 @@ export type PropsType = {
media: ReadonlyArray<ReadonlyDeep<MediaItemType>>; media: ReadonlyArray<ReadonlyDeep<MediaItemType>>;
saveAttachment: SaveAttachmentActionCreatorType; saveAttachment: SaveAttachmentActionCreatorType;
selectedIndex: number; selectedIndex: number;
toggleForwardMessageModal: (messageId: string) => unknown; toggleForwardMessagesModal: (messageIds: ReadonlyArray<string>) => unknown;
onMediaPlaybackStart: () => void; onMediaPlaybackStart: () => void;
onNextAttachment: () => void; onNextAttachment: () => void;
onPrevAttachment: () => void; onPrevAttachment: () => void;
@ -77,7 +77,7 @@ export function Lightbox({
isViewOnce = false, isViewOnce = false,
saveAttachment, saveAttachment,
selectedIndex, selectedIndex,
toggleForwardMessageModal, toggleForwardMessagesModal,
onMediaPlaybackStart, onMediaPlaybackStart,
onNextAttachment, onNextAttachment,
onPrevAttachment, onPrevAttachment,
@ -186,7 +186,7 @@ export function Lightbox({
closeLightbox(); closeLightbox();
const mediaItem = media[selectedIndex]; const mediaItem = media[selectedIndex];
toggleForwardMessageModal(mediaItem.message.id); toggleForwardMessagesModal([mediaItem.message.id]);
}; };
const onKeyDown = useCallback( const onKeyDown = useCallback(

View file

@ -43,11 +43,15 @@ import { ConfirmationDialog } from './ConfirmationDialog';
const MESSAGE_DEFAULT_PROPS = { const MESSAGE_DEFAULT_PROPS = {
canDeleteForEveryone: false, canDeleteForEveryone: false,
checkForAccount: shouldNeverBeCalled, checkForAccount: shouldNeverBeCalled,
clearSelectedMessage: shouldNeverBeCalled, clearTargetedMessage: shouldNeverBeCalled,
containerWidthBreakpoint: WidthBreakpoint.Medium, containerWidthBreakpoint: WidthBreakpoint.Medium,
doubleCheckMissingQuoteReference: shouldNeverBeCalled, doubleCheckMissingQuoteReference: shouldNeverBeCalled,
isBlocked: false, isBlocked: false,
isMessageRequestAccepted: true, isMessageRequestAccepted: true,
isSelected: false,
isSelectMode: false,
onToggleSelect: shouldNeverBeCalled,
onReplyToMessage: shouldNeverBeCalled,
kickOffAttachmentDownload: shouldNeverBeCalled, kickOffAttachmentDownload: shouldNeverBeCalled,
markAttachmentAsCorrupted: shouldNeverBeCalled, markAttachmentAsCorrupted: shouldNeverBeCalled,
messageExpanded: shouldNeverBeCalled, messageExpanded: shouldNeverBeCalled,

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React from 'react'; import React from 'react';
import { get } from 'lodash';
import type { LocalizerType, ReplacementValuesType } from '../types/Util'; import type { LocalizerType, ReplacementValuesType } from '../types/Util';
import { SECOND } from '../util/durations'; import { SECOND } from '../util/durations';
import { Toast } from './Toast'; import { Toast } from './Toast';
@ -71,6 +72,14 @@ export function ToastManager({
return <Toast onClose={hideToast}>{i18n('unblockGroupToSend')}</Toast>; return <Toast onClose={hideToast}>{i18n('unblockGroupToSend')}</Toast>;
} }
if (toastType === ToastType.CannotForwardEmptyMessage) {
return (
<Toast onClose={hideToast}>
{i18n('icu:ForwardMessagesModal__toast--CannotForwardEmptyMessage')}
</Toast>
);
}
if (toastType === ToastType.CannotMixMultiAndNonMultiAttachments) { if (toastType === ToastType.CannotMixMultiAndNonMultiAttachments) {
return ( return (
<Toast onClose={hideToast}> <Toast onClose={hideToast}>
@ -302,6 +311,16 @@ export function ToastManager({
); );
} }
if (toastType === ToastType.TooManyMessagesToForward) {
return (
<Toast onClose={hideToast}>
{i18n('icu:SelectModeActions__toast--TooManyMessagesToForward', {
count: get(toast.parameters, 'count'),
})}
</Toast>
);
}
if (toastType === ToastType.UnableToLoadAttachment) { if (toastType === ToastType.UnableToLoadAttachment) {
return <Toast onClose={hideToast}>{i18n('unableToLoadAttachment')}</Toast>; return <Toast onClose={hideToast}>{i18n('unableToLoadAttachment')}</Toast>;
} }

View file

@ -48,6 +48,7 @@ const commonProps = {
onArchive: action('onArchive'), onArchive: action('onArchive'),
onMarkUnread: action('onMarkUnread'), onMarkUnread: action('onMarkUnread'),
toggleSelectMode: action('toggleSelectMode'),
onMoveToInbox: action('onMoveToInbox'), onMoveToInbox: action('onMoveToInbox'),
pushPanelForConversation: action('pushPanelForConversation'), pushPanelForConversation: action('pushPanelForConversation'),
popPanelForConversation: action('popPanelForConversation'), popPanelForConversation: action('popPanelForConversation'),

View file

@ -88,6 +88,7 @@ export type PropsActionsType = {
destroyMessages: (conversationId: string) => void; destroyMessages: (conversationId: string) => void;
onArchive: (conversationId: string) => void; onArchive: (conversationId: string) => void;
onMarkUnread: (conversationId: string) => void; onMarkUnread: (conversationId: string) => void;
toggleSelectMode: (on: boolean) => void;
onMoveToInbox: (conversationId: string) => void; onMoveToInbox: (conversationId: string) => void;
onOutgoingAudioCallInConversation: (conversationId: string) => void; onOutgoingAudioCallInConversation: (conversationId: string) => void;
onOutgoingVideoCallInConversation: (conversationId: string) => void; onOutgoingVideoCallInConversation: (conversationId: string) => void;
@ -350,6 +351,7 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
muteExpiresAt, muteExpiresAt,
onArchive, onArchive,
onMarkUnread, onMarkUnread,
toggleSelectMode,
onMoveToInbox, onMoveToInbox,
pushPanelForConversation, pushPanelForConversation,
setDisappearingMessages, setDisappearingMessages,
@ -505,6 +507,13 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
{i18n('markUnread')} {i18n('markUnread')}
</MenuItem> </MenuItem>
) : null} ) : null}
<MenuItem
onClick={() => {
toggleSelectMode(true);
}}
>
{i18n('icu:ConversationHeader__menu__selectMessages')}
</MenuItem>
{isArchived ? ( {isArchived ? (
<MenuItem onClick={() => onMoveToInbox(id)}> <MenuItem onClick={() => onMoveToInbox(id)}>
{i18n('moveConversationToInbox')} {i18n('moveConversationToInbox')}

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React from 'react'; import React from 'react';
import { useEscapeHandling } from '../../hooks/useEscapeHandling';
export type PropsType = { export type PropsType = {
conversationId: string; conversationId: string;
@ -13,6 +14,9 @@ export type PropsType = {
renderConversationHeader: () => JSX.Element; renderConversationHeader: () => JSX.Element;
renderTimeline: () => JSX.Element; renderTimeline: () => JSX.Element;
renderPanel: () => JSX.Element | undefined; renderPanel: () => JSX.Element | undefined;
isSelectMode: boolean;
isForwardModalOpen: boolean;
onExitSelectMode: () => void;
}; };
export function ConversationView({ export function ConversationView({
@ -22,6 +26,9 @@ export function ConversationView({
renderConversationHeader, renderConversationHeader,
renderTimeline, renderTimeline,
renderPanel, renderPanel,
isSelectMode,
isForwardModalOpen,
onExitSelectMode,
}: PropsType): JSX.Element { }: PropsType): JSX.Element {
const onDrop = React.useCallback( const onDrop = React.useCallback(
(event: React.DragEvent<HTMLDivElement>) => { (event: React.DragEvent<HTMLDivElement>) => {
@ -80,6 +87,10 @@ export function ConversationView({
[conversationId, processAttachments] [conversationId, processAttachments]
); );
useEscapeHandling(
isSelectMode && !isForwardModalOpen ? onExitSelectMode : undefined
);
return ( return (
<div className="ConversationView" onDrop={onDrop} onPaste={onPaste}> <div className="ConversationView" onDrop={onDrop} onPaste={onPaste}>
<div className="ConversationView__header"> <div className="ConversationView__header">

View file

@ -7,8 +7,8 @@ import { getInteractionMode } from '../../services/InteractionMode';
export type PropsType = { export type PropsType = {
id: string; id: string;
conversationId: string; conversationId: string;
isSelected: boolean; isTargeted: boolean;
selectMessage?: (messageId: string, conversationId: string) => unknown; targetMessage?: (messageId: string, conversationId: string) => unknown;
}; };
export class InlineNotificationWrapper extends React.Component<PropsType> { export class InlineNotificationWrapper extends React.Component<PropsType> {
@ -24,29 +24,29 @@ export class InlineNotificationWrapper extends React.Component<PropsType> {
public handleFocus = (): void => { public handleFocus = (): void => {
if (getInteractionMode() === 'keyboard') { if (getInteractionMode() === 'keyboard') {
this.setSelected(); this.setTargeted();
} }
}; };
public setSelected = (): void => { public setTargeted = (): void => {
const { id, conversationId, selectMessage } = this.props; const { id, conversationId, targetMessage } = this.props;
if (selectMessage) { if (targetMessage) {
selectMessage(id, conversationId); targetMessage(id, conversationId);
} }
}; };
public override componentDidMount(): void { public override componentDidMount(): void {
const { isSelected } = this.props; const { isTargeted } = this.props;
if (isSelected) { if (isTargeted) {
this.setFocus(); this.setFocus();
} }
} }
public override componentDidUpdate(prevProps: PropsType): void { public override componentDidUpdate(prevProps: PropsType): void {
const { isSelected } = this.props; const { isTargeted } = this.props;
if (!prevProps.isSelected && isSelected) { if (!prevProps.isTargeted && isTargeted) {
this.setFocus(); this.setFocus();
} }
} }

View file

@ -3,7 +3,12 @@
/* eslint-disable react/jsx-pascal-case */ /* eslint-disable react/jsx-pascal-case */
import type { ReactNode, RefObject } from 'react'; import type {
DetailedHTMLProps,
HTMLAttributes,
ReactNode,
RefObject,
} from 'react';
import React from 'react'; import React from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import classNames from 'classnames'; import classNames from 'classnames';
@ -113,7 +118,7 @@ const GROUP_AVATAR_SIZE = AvatarSize.TWENTY_EIGHT;
const STICKER_SIZE = 200; const STICKER_SIZE = 200;
const GIF_SIZE = 300; const GIF_SIZE = 300;
// Note: this needs to match the animation time // Note: this needs to match the animation time
const SELECTED_TIMEOUT = 1200; const TARGETED_TIMEOUT = 1200;
const THREE_HOURS = 3 * 60 * 60 * 1000; const THREE_HOURS = 3 * 60 * 60 * 1000;
const SENT_STATUSES = new Set<MessageStatusType>([ const SENT_STATUSES = new Set<MessageStatusType>([
'delivered', 'delivered',
@ -202,8 +207,10 @@ export type PropsData = {
textDirection: TextDirection; textDirection: TextDirection;
textAttachment?: AttachmentType; textAttachment?: AttachmentType;
isSticker?: boolean; isSticker?: boolean;
isSelected?: boolean; isTargeted?: boolean;
isSelectedCounter?: number; isTargetedCounter?: number;
isSelected: boolean;
isSelectMode: boolean;
direction: DirectionType; direction: DirectionType;
timestamp: number; timestamp: number;
status?: MessageStatusType; status?: MessageStatusType;
@ -297,7 +304,7 @@ export type PropsHousekeeping = {
}; };
export type PropsActions = { export type PropsActions = {
clearSelectedMessage: () => unknown; clearTargetedMessage: () => unknown;
doubleCheckMissingQuoteReference: (messageId: string) => unknown; doubleCheckMissingQuoteReference: (messageId: string) => unknown;
messageExpanded: (id: string, displayLimit: number) => unknown; messageExpanded: (id: string, displayLimit: number) => unknown;
checkForAccount: (phoneNumber: string) => unknown; checkForAccount: (phoneNumber: string) => unknown;
@ -328,11 +335,14 @@ export type PropsActions = {
conversationId: string; conversationId: string;
sentAt: number; sentAt: number;
}) => void; }) => void;
selectMessage?: (messageId: string, conversationId: string) => unknown; targetMessage?: (messageId: string, conversationId: string) => unknown;
showExpiredIncomingTapToViewToast: () => unknown; showExpiredIncomingTapToViewToast: () => unknown;
showExpiredOutgoingTapToViewToast: () => unknown; showExpiredOutgoingTapToViewToast: () => unknown;
viewStory: ViewStoryActionCreatorType; viewStory: ViewStoryActionCreatorType;
onToggleSelect: (selected: boolean, shift: boolean) => void;
onReplyToMessage: () => void;
}; };
export type Props = PropsData & PropsHousekeeping & PropsActions; export type Props = PropsData & PropsHousekeeping & PropsActions;
@ -344,8 +354,8 @@ type State = {
expired: boolean; expired: boolean;
imageBroken: boolean; imageBroken: boolean;
isSelected?: boolean; isTargeted?: boolean;
prevSelectedCounter?: number; prevTargetedCounter?: number;
reactionViewerRoot: HTMLDivElement | null; reactionViewerRoot: HTMLDivElement | null;
reactionViewerOutsideClickDestructor?: () => void; reactionViewerOutsideClickDestructor?: () => void;
@ -372,7 +382,7 @@ export class Message extends React.PureComponent<Props, State> {
public expiredTimeout: NodeJS.Timeout | undefined; public expiredTimeout: NodeJS.Timeout | undefined;
public selectedTimeout: NodeJS.Timeout | undefined; public targetedTimeout: NodeJS.Timeout | undefined;
public deleteForEveryoneTimeout: NodeJS.Timeout | undefined; public deleteForEveryoneTimeout: NodeJS.Timeout | undefined;
@ -386,8 +396,8 @@ export class Message extends React.PureComponent<Props, State> {
expired: false, expired: false,
imageBroken: false, imageBroken: false,
isSelected: props.isSelected, isTargeted: props.isTargeted,
prevSelectedCounter: props.isSelectedCounter, prevTargetedCounter: props.isTargetedCounter,
reactionViewerRoot: null, reactionViewerRoot: null,
@ -400,22 +410,22 @@ export class Message extends React.PureComponent<Props, State> {
} }
public static getDerivedStateFromProps(props: Props, state: State): State { public static getDerivedStateFromProps(props: Props, state: State): State {
if (!props.isSelected) { if (!props.isTargeted) {
return { return {
...state, ...state,
isSelected: false, isTargeted: false,
prevSelectedCounter: 0, prevTargetedCounter: 0,
}; };
} }
if ( if (
props.isSelected && props.isTargeted &&
props.isSelectedCounter !== state.prevSelectedCounter props.isTargetedCounter !== state.prevTargetedCounter
) { ) {
return { return {
...state, ...state,
isSelected: props.isSelected, isTargeted: props.isTargeted,
prevSelectedCounter: props.isSelectedCounter, prevTargetedCounter: props.isTargetedCounter,
}; };
} }
@ -428,10 +438,10 @@ export class Message extends React.PureComponent<Props, State> {
} }
public handleFocus = (): void => { public handleFocus = (): void => {
const { interactionMode, isSelected } = this.props; const { interactionMode, isTargeted } = this.props;
if (interactionMode === 'keyboard' && !isSelected) { if (interactionMode === 'keyboard' && !isTargeted) {
this.setSelected(); this.setTargeted();
} }
}; };
@ -445,11 +455,11 @@ export class Message extends React.PureComponent<Props, State> {
}); });
}; };
public setSelected = (): void => { public setTargeted = (): void => {
const { id, conversationId, selectMessage } = this.props; const { id, conversationId, targetMessage } = this.props;
if (selectMessage) { if (targetMessage) {
selectMessage(id, conversationId); targetMessage(id, conversationId);
} }
}; };
@ -465,12 +475,12 @@ export class Message extends React.PureComponent<Props, State> {
const { conversationId } = this.props; const { conversationId } = this.props;
window.ConversationController?.onConvoMessageMount(conversationId); window.ConversationController?.onConvoMessageMount(conversationId);
this.startSelectedTimer(); this.startTargetedTimer();
this.startDeleteForEveryoneTimerIfApplicable(); this.startDeleteForEveryoneTimerIfApplicable();
this.startGiftBadgeInterval(); this.startGiftBadgeInterval();
const { isSelected } = this.props; const { isTargeted } = this.props;
if (isSelected) { if (isTargeted) {
this.setFocus(); this.setFocus();
} }
@ -493,7 +503,7 @@ export class Message extends React.PureComponent<Props, State> {
} }
public override componentWillUnmount(): void { public override componentWillUnmount(): void {
clearTimeoutIfNecessary(this.selectedTimeout); clearTimeoutIfNecessary(this.targetedTimeout);
clearTimeoutIfNecessary(this.expirationCheckInterval); clearTimeoutIfNecessary(this.expirationCheckInterval);
clearTimeoutIfNecessary(this.expiredTimeout); clearTimeoutIfNecessary(this.expiredTimeout);
clearTimeoutIfNecessary(this.deleteForEveryoneTimeout); clearTimeoutIfNecessary(this.deleteForEveryoneTimeout);
@ -502,12 +512,12 @@ export class Message extends React.PureComponent<Props, State> {
} }
public override componentDidUpdate(prevProps: Readonly<Props>): void { public override componentDidUpdate(prevProps: Readonly<Props>): void {
const { isSelected, status, timestamp } = this.props; const { isTargeted, status, timestamp } = this.props;
this.startSelectedTimer(); this.startTargetedTimer();
this.startDeleteForEveryoneTimerIfApplicable(); this.startDeleteForEveryoneTimerIfApplicable();
if (!prevProps.isSelected && isSelected) { if (!prevProps.isTargeted && isTargeted) {
this.setFocus(); this.setFocus();
} }
@ -610,20 +620,20 @@ export class Message extends React.PureComponent<Props, State> {
return result; return result;
} }
public startSelectedTimer(): void { public startTargetedTimer(): void {
const { clearSelectedMessage, interactionMode } = this.props; const { clearTargetedMessage, interactionMode } = this.props;
const { isSelected } = this.state; const { isTargeted } = this.state;
if (interactionMode === 'keyboard' || !isSelected) { if (interactionMode === 'keyboard' || !isTargeted) {
return; return;
} }
if (!this.selectedTimeout) { if (!this.targetedTimeout) {
this.selectedTimeout = setTimeout(() => { this.targetedTimeout = setTimeout(() => {
this.selectedTimeout = undefined; this.targetedTimeout = undefined;
this.setState({ isSelected: false }); this.setState({ isTargeted: false });
clearSelectedMessage(); clearTargetedMessage();
}, SELECTED_TIMEOUT); }, TARGETED_TIMEOUT);
} }
} }
@ -2450,7 +2460,7 @@ export class Message extends React.PureComponent<Props, State> {
onKeyDown, onKeyDown,
text, text,
} = this.props; } = this.props;
const { isSelected } = this.state; const { isTargeted } = this.state;
const isAttachmentPending = this.isAttachmentPending(); const isAttachmentPending = this.isAttachmentPending();
@ -2462,7 +2472,7 @@ export class Message extends React.PureComponent<Props, State> {
// If it's a mostly-normal gray incoming text box, we don't want to darken it as much // If it's a mostly-normal gray incoming text box, we don't want to darken it as much
const lighterSelect = const lighterSelect =
isSelected && isTargeted &&
direction === 'incoming' && direction === 'incoming' &&
!isStickerLike && !isStickerLike &&
(text || (!isVideo(attachments) && !isImage(attachments))); (text || (!isVideo(attachments) && !isImage(attachments)));
@ -2470,8 +2480,8 @@ export class Message extends React.PureComponent<Props, State> {
const containerClassnames = classNames( const containerClassnames = classNames(
'module-message__container', 'module-message__container',
isGIF(attachments) ? 'module-message__container--gif' : null, isGIF(attachments) ? 'module-message__container--gif' : null,
isSelected ? 'module-message__container--selected' : null, isTargeted ? 'module-message__container--targeted' : null,
lighterSelect ? 'module-message__container--selected-lighter' : null, lighterSelect ? 'module-message__container--targeted-lighter' : null,
!isStickerLike ? `module-message__container--${direction}` : null, !isStickerLike ? `module-message__container--${direction}` : null,
isEmojiOnly ? 'module-message__container--emoji' : null, isEmojiOnly ? 'module-message__container--emoji' : null,
isTapToView ? 'module-message__container--with-tap-to-view' : null, isTapToView ? 'module-message__container--with-tap-to-view' : null,
@ -2525,18 +2535,42 @@ export class Message extends React.PureComponent<Props, State> {
); );
} }
renderAltAccessibilityTree(): JSX.Element {
const { id, i18n, author } = this.props;
return (
<span className="module-message__alt-accessibility-tree">
<span id={`message-accessibility-label:${id}`}>
{author.isMe
? i18n('icu:messageAccessibilityLabel--outgoing', {})
: i18n('icu:messageAccessibilityLabel--incoming', {
author: author.title,
})}
</span>
<span id={`message-accessibility-description:${id}`}>
{this.renderText()}
</span>
</span>
);
}
public override render(): JSX.Element | null { public override render(): JSX.Element | null {
const { const {
id,
attachments, attachments,
direction, direction,
isSticker, isSticker,
isSelected,
isSelectMode,
onKeyDown, onKeyDown,
renderMenu, renderMenu,
shouldCollapseAbove, shouldCollapseAbove,
shouldCollapseBelow, shouldCollapseBelow,
timestamp, timestamp,
onToggleSelect,
onReplyToMessage,
} = this.props; } = this.props;
const { expired, expiring, isSelected, imageBroken } = this.state; const { expired, expiring, isTargeted, imageBroken } = this.state;
if (expired) { if (expired) {
return null; return null;
@ -2546,29 +2580,85 @@ export class Message extends React.PureComponent<Props, State> {
return null; return null;
} }
let wrapperProps: DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
>;
if (isSelectMode) {
wrapperProps = {
role: 'checkbox',
'aria-checked': isSelected,
'aria-labelledby': `message-accessibility-label:${id}`,
'aria-describedby': `message-accessibility-description:${id}`,
tabIndex: 0,
onClick: event => {
event.preventDefault();
onToggleSelect(!isSelected, event.shiftKey);
},
onKeyDown: event => {
if (event.code === 'Space') {
event.preventDefault();
onToggleSelect(!isSelected, event.shiftKey);
}
},
};
} else {
wrapperProps = {
onDoubleClick: event => {
event.stopPropagation();
event.preventDefault();
if (!isSelectMode) {
onReplyToMessage();
}
},
};
}
return ( return (
<div <div
className={classNames( className={classNames(
'module-message', 'module-message__wrapper',
`module-message--${direction}`, isSelectMode && 'module-message__wrapper--select-mode',
shouldCollapseAbove && 'module-message--collapsed-above', isSelected && 'module-message__wrapper--selected'
shouldCollapseBelow && 'module-message--collapsed-below',
isSelected ? 'module-message--selected' : null,
expiring ? 'module-message--expired' : null
)} )}
data-testid={timestamp} {...wrapperProps}
tabIndex={0}
// We need to have a role because screenreaders need to be able to focus here to
// read the message, but we can't be a button; that would break inner buttons.
role="row"
onKeyDown={onKeyDown}
onFocus={this.handleFocus}
ref={this.focusRef}
> >
{this.renderError()} {isSelectMode && (
{this.renderAvatar()} <>
{this.renderContainer()} <span
{renderMenu?.()} role="presentation"
className="module-message__select-checkbox"
/>
{this.renderAltAccessibilityTree()}
</>
)}
<div
className={classNames(
'module-message',
`module-message--${direction}`,
shouldCollapseAbove && 'module-message--collapsed-above',
shouldCollapseBelow && 'module-message--collapsed-below',
isTargeted ? 'module-message--targeted' : null,
expiring ? 'module-message--expired' : null
)}
data-testid={timestamp}
tabIndex={0}
// We need to have a role because screenreaders need to be able to focus here to
// read the message, but we can't be a button; that would break inner buttons.
role="row"
onKeyDown={onKeyDown}
onFocus={this.handleFocus}
ref={this.focusRef}
// @ts-expect-error -- React/TS doesn't know about inert
// eslint-disable-next-line react/no-unknown-property
inert={isSelectMode ? '' : undefined}
>
{this.renderError()}
{this.renderAvatar()}
{this.renderContainer()}
{renderMenu?.()}
</div>
</div> </div>
); );
} }

View file

@ -40,6 +40,8 @@ const defaultMessage: MessageDataPropsType = {
renderMenu: undefined, renderMenu: undefined,
isBlocked: false, isBlocked: false,
isMessageRequestAccepted: true, isMessageRequestAccepted: true,
isSelected: false,
isSelectMode: false,
previews: [], previews: [],
readStatus: ReadStatus.Read, readStatus: ReadStatus.Read,
status: 'sent', status: 'sent',
@ -72,7 +74,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
toggleSafetyNumberModal: action('toggleSafetyNumberModal'), toggleSafetyNumberModal: action('toggleSafetyNumberModal'),
checkForAccount: action('checkForAccount'), checkForAccount: action('checkForAccount'),
clearSelectedMessage: action('clearSelectedMessage'), clearTargetedMessage: action('clearTargetedMessage'),
showLightboxForViewOnceMedia: action('showLightboxForViewOnceMedia'), showLightboxForViewOnceMedia: action('showLightboxForViewOnceMedia'),
doubleCheckMissingQuoteReference: action('doubleCheckMissingQuoteReference'), doubleCheckMissingQuoteReference: action('doubleCheckMissingQuoteReference'),
kickOffAttachmentDownload: action('kickOffAttachmentDownload'), kickOffAttachmentDownload: action('kickOffAttachmentDownload'),

View file

@ -75,7 +75,7 @@ export type PropsSmartActions = Pick<MessagePropsType, 'renderAudioAttachment'>;
export type PropsReduxActions = Pick< export type PropsReduxActions = Pick<
MessagePropsType, MessagePropsType,
| 'checkForAccount' | 'checkForAccount'
| 'clearSelectedMessage' | 'clearTargetedMessage'
| 'doubleCheckMissingQuoteReference' | 'doubleCheckMissingQuoteReference'
| 'kickOffAttachmentDownload' | 'kickOffAttachmentDownload'
| 'markAttachmentAsCorrupted' | 'markAttachmentAsCorrupted'
@ -268,7 +268,7 @@ export class MessageDetail extends React.Component<Props> {
sentAt, sentAt,
checkForAccount, checkForAccount,
clearSelectedMessage, clearTargetedMessage,
contactNameColor, contactNameColor,
showLightboxForViewOnceMedia, showLightboxForViewOnceMedia,
doubleCheckMissingQuoteReference, doubleCheckMissingQuoteReference,
@ -306,7 +306,7 @@ export class MessageDetail extends React.Component<Props> {
{...message} {...message}
renderingContext="conversation/MessageDetail" renderingContext="conversation/MessageDetail"
checkForAccount={checkForAccount} checkForAccount={checkForAccount}
clearSelectedMessage={clearSelectedMessage} clearTargetedMessage={clearTargetedMessage}
contactNameColor={contactNameColor} contactNameColor={contactNameColor}
containerElementRef={this.messageContainerRef} containerElementRef={this.messageContainerRef}
containerWidthBreakpoint={WidthBreakpoint.Wide} containerWidthBreakpoint={WidthBreakpoint.Wide}
@ -343,6 +343,8 @@ export class MessageDetail extends React.Component<Props> {
startConversation={startConversation} startConversation={startConversation}
theme={theme} theme={theme}
viewStory={viewStory} viewStory={viewStory}
onToggleSelect={noop}
onReplyToMessage={noop}
/> />
</div> </div>
<table className="module-message-detail__info"> <table className="module-message-detail__info">

View file

@ -87,14 +87,14 @@ const defaultMessageProps: TimelineMessagesProps = {
canDeleteForEveryone: true, canDeleteForEveryone: true,
canDownload: true, canDownload: true,
checkForAccount: action('checkForAccount'), checkForAccount: action('checkForAccount'),
clearSelectedMessage: action('default--clearSelectedMessage'), clearTargetedMessage: action('default--clearTargetedMessage'),
containerElementRef: React.createRef<HTMLElement>(), containerElementRef: React.createRef<HTMLElement>(),
containerWidthBreakpoint: WidthBreakpoint.Wide, containerWidthBreakpoint: WidthBreakpoint.Wide,
conversationColor: 'crimson', conversationColor: 'crimson',
conversationId: 'conversationId', conversationId: 'conversationId',
conversationTitle: 'Conversation Title', conversationTitle: 'Conversation Title',
conversationType: 'direct', // override conversationType: 'direct', // override
deleteMessage: action('default--deleteMessage'), deleteMessages: action('default--deleteMessages'),
deleteMessageForEveryone: action('default--deleteMessageForEveryone'), deleteMessageForEveryone: action('default--deleteMessageForEveryone'),
direction: 'incoming', direction: 'incoming',
showLightboxForViewOnceMedia: action('default--showLightboxForViewOnceMedia'), showLightboxForViewOnceMedia: action('default--showLightboxForViewOnceMedia'),
@ -108,6 +108,9 @@ const defaultMessageProps: TimelineMessagesProps = {
interactionMode: 'keyboard', interactionMode: 'keyboard',
isBlocked: false, isBlocked: false,
isMessageRequestAccepted: true, isMessageRequestAccepted: true,
isSelected: false,
isSelectMode: false,
toggleSelectMessage: action('toggleSelectMessage'),
kickOffAttachmentDownload: action('default--kickOffAttachmentDownload'), kickOffAttachmentDownload: action('default--kickOffAttachmentDownload'),
markAttachmentAsCorrupted: action('default--markAttachmentAsCorrupted'), markAttachmentAsCorrupted: action('default--markAttachmentAsCorrupted'),
messageExpanded: action('default--message-expanded'), messageExpanded: action('default--message-expanded'),
@ -124,7 +127,7 @@ const defaultMessageProps: TimelineMessagesProps = {
retryDeleteForEveryone: action('default--retryDeleteForEveryone'), retryDeleteForEveryone: action('default--retryDeleteForEveryone'),
saveAttachment: action('saveAttachment'), saveAttachment: action('saveAttachment'),
scrollToQuotedMessage: action('default--scrollToQuotedMessage'), scrollToQuotedMessage: action('default--scrollToQuotedMessage'),
selectMessage: action('default--selectMessage'), targetMessage: action('default--targetMessage'),
shouldCollapseAbove: false, shouldCollapseAbove: false,
shouldCollapseBelow: false, shouldCollapseBelow: false,
shouldHideMetadata: false, shouldHideMetadata: false,
@ -136,7 +139,7 @@ const defaultMessageProps: TimelineMessagesProps = {
showExpiredOutgoingTapToViewToast: action( showExpiredOutgoingTapToViewToast: action(
'showExpiredOutgoingTapToViewToast' 'showExpiredOutgoingTapToViewToast'
), ),
toggleForwardMessageModal: action('default--toggleForwardMessageModal'), toggleForwardMessagesModal: action('default--toggleForwardMessagesModal'),
showLightbox: action('default--showLightbox'), showLightbox: action('default--showLightbox'),
startConversation: action('default--startConversation'), startConversation: action('default--startConversation'),
status: 'sent', status: 'sent',

View file

@ -0,0 +1,122 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import classNames from 'classnames';
import React, { useState } from 'react';
import type { ShowToastAction } from '../../state/ducks/toast';
import { ToastType } from '../../types/Toast';
import type { LocalizerType } from '../../types/Util';
import { ConfirmationDialog } from '../ConfirmationDialog';
// Keep this in sync with iOS and Android
const MAX_FORWARD_COUNT = 30;
type SelectModeActionsProps = Readonly<{
selectedMessageIds: ReadonlyArray<string>;
onExitSelectMode: () => void;
onDeleteMessages: () => void;
onForwardMessages: () => void;
showToast: ShowToastAction;
i18n: LocalizerType;
}>;
export default function SelectModeActions({
selectedMessageIds,
onExitSelectMode,
onDeleteMessages,
onForwardMessages,
showToast,
i18n,
}: SelectModeActionsProps): JSX.Element {
const [confirmDelete, setConfirmDelete] = useState(false);
const hasSelectedMessages = selectedMessageIds.length >= 1;
const tooManyMessagesToForward =
selectedMessageIds.length > MAX_FORWARD_COUNT;
const canForward = hasSelectedMessages && !tooManyMessagesToForward;
const canDelete = hasSelectedMessages;
return (
<>
<div className="SelectModeActions">
<button
type="button"
className="SelectModeActions__button"
onClick={onExitSelectMode}
aria-label={i18n('icu:SelectModeActions--exitSelectMode')}
>
<span
role="presentation"
className="SelectModeActions__icon SelectModeActions__icon--exitSelectMode"
/>
</button>
<div className="SelectModeActions__selectedMessages">
{i18n('icu:SelectModeActions--selectedMessages', {
count: selectedMessageIds.length,
})}
</div>
<button
type="button"
className={classNames('SelectModeActions__button', {
'SelectModeActions__button--disabled': !canDelete,
})}
disabled={!canDelete}
onClick={() => {
setConfirmDelete(true);
}}
aria-label={i18n('icu:SelectModeActions--deleteSelectedMessages')}
>
<span
role="presentation"
className="SelectModeActions__icon SelectModeActions__icon--deleteSelectedMessages"
/>
</button>
<button
type="button"
className={classNames('SelectModeActions__button', {
'SelectModeActions__button--disabled': !canForward,
})}
aria-disabled={!canForward}
onClick={() => {
if (canForward) {
onForwardMessages();
} else if (tooManyMessagesToForward) {
showToast(ToastType.TooManyMessagesToForward, {
count: MAX_FORWARD_COUNT,
});
}
}}
aria-label={i18n('icu:SelectModeActions--forwardSelectedMessages')}
>
<span
role="presentation"
className="SelectModeActions__icon SelectModeActions__icon--forwardSelectedMessages"
/>
</button>
</div>
{confirmDelete && (
<ConfirmationDialog
actions={[
{
action: () => {
onDeleteMessages();
},
style: 'negative',
text: i18n('icu:SelectModeActions__confirmDelete--confirm'),
},
]}
dialogName="TimelineMessage/deleteMessage"
i18n={i18n}
onClose={() => {
setConfirmDelete(false);
}}
>
{i18n('icu:SelectModeActions__confirmDelete--title', {
count: selectedMessageIds.length,
})}
</ConfirmationDialog>
)}
</>
);
}

View file

@ -61,6 +61,8 @@ function mockMessageTimelineItem(
text: 'Hello there from the new world!', text: 'Hello there from the new world!',
isBlocked: false, isBlocked: false,
isMessageRequestAccepted: true, isMessageRequestAccepted: true,
isSelected: false,
isSelectMode: false,
previews: [], previews: [],
readStatus: ReadStatus.Read, readStatus: ReadStatus.Read,
canRetryDeleteForEveryone: true, canRetryDeleteForEveryone: true,
@ -270,15 +272,16 @@ const actions = () => ({
loadNewerMessages: action('loadNewerMessages'), loadNewerMessages: action('loadNewerMessages'),
loadNewestMessages: action('loadNewestMessages'), loadNewestMessages: action('loadNewestMessages'),
markMessageRead: action('markMessageRead'), markMessageRead: action('markMessageRead'),
selectMessage: action('selectMessage'), toggleSelectMessage: action('toggleSelectMessage'),
clearSelectedMessage: action('clearSelectedMessage'), targetMessage: action('targetMessage'),
clearTargetedMessage: action('clearTargetedMessage'),
updateSharedGroups: action('updateSharedGroups'), updateSharedGroups: action('updateSharedGroups'),
reactToMessage: action('reactToMessage'), reactToMessage: action('reactToMessage'),
setQuoteByMessageId: action('setQuoteByMessageId'), setQuoteByMessageId: action('setQuoteByMessageId'),
retryDeleteForEveryone: action('retryDeleteForEveryone'), retryDeleteForEveryone: action('retryDeleteForEveryone'),
retryMessageSend: action('retryMessageSend'), retryMessageSend: action('retryMessageSend'),
deleteMessage: action('deleteMessage'), deleteMessages: action('deleteMessages'),
deleteMessageForEveryone: action('deleteMessageForEveryone'), deleteMessageForEveryone: action('deleteMessageForEveryone'),
saveAttachment: action('saveAttachment'), saveAttachment: action('saveAttachment'),
pushPanelForConversation: action('pushPanelForConversation'), pushPanelForConversation: action('pushPanelForConversation'),
@ -300,7 +303,7 @@ const actions = () => ({
showExpiredOutgoingTapToViewToast: action( showExpiredOutgoingTapToViewToast: action(
'showExpiredOutgoingTapToViewToast' 'showExpiredOutgoingTapToViewToast'
), ),
toggleForwardMessageModal: action('toggleForwardMessageModal'), toggleForwardMessagesModal: action('toggleForwardMessagesModal'),
toggleSafetyNumberModal: action('toggleSafetyNumberModal'), toggleSafetyNumberModal: action('toggleSafetyNumberModal'),
@ -320,6 +323,8 @@ const actions = () => ({
peekGroupCallIfItHasMembers: action('peekGroupCallIfItHasMembers'), peekGroupCallIfItHasMembers: action('peekGroupCallIfItHasMembers'),
viewStory: action('viewStory'), viewStory: action('viewStory'),
onReplyToMessage: action('onReplyToMessage'),
}); });
const renderItem = ({ const renderItem = ({
@ -334,7 +339,7 @@ const renderItem = ({
<TimelineItem <TimelineItem
getPreferredBadge={() => undefined} getPreferredBadge={() => undefined}
id="" id=""
isSelected={false} isTargeted={false}
i18n={i18n} i18n={i18n}
interactionMode="keyboard" interactionMode="keyboard"
isNextItemCallingNotification={false} isNextItemCallingNotification={false}

View file

@ -100,6 +100,7 @@ type PropsHousekeepingType = {
isSomeoneTyping: boolean; isSomeoneTyping: boolean;
unreadCount?: number; unreadCount?: number;
targetedMessageId?: string;
invitedContactsForNewlyCreatedGroup: Array<ConversationType>; invitedContactsForNewlyCreatedGroup: Array<ConversationType>;
selectedMessageId?: string; selectedMessageId?: string;
shouldShowMiniPlayer: boolean; shouldShowMiniPlayer: boolean;
@ -146,7 +147,7 @@ export type PropsActionsType = {
groupNameCollisions: ReadonlyDeep<GroupNameCollisionsWithIdsByTitle> groupNameCollisions: ReadonlyDeep<GroupNameCollisionsWithIdsByTitle>
) => void; ) => void;
clearInvitedUuidsForNewlyCreatedGroup: () => void; clearInvitedUuidsForNewlyCreatedGroup: () => void;
clearSelectedMessage: () => unknown; clearTargetedMessage: () => unknown;
closeContactSpoofingReview: () => void; closeContactSpoofingReview: () => void;
loadOlderMessages: (conversationId: string, messageId: string) => unknown; loadOlderMessages: (conversationId: string, messageId: string) => unknown;
loadNewerMessages: (conversationId: string, messageId: string) => unknown; loadNewerMessages: (conversationId: string, messageId: string) => unknown;
@ -156,7 +157,7 @@ export type PropsActionsType = {
setFocus?: boolean setFocus?: boolean
) => unknown; ) => unknown;
markMessageRead: (conversationId: string, messageId: string) => unknown; markMessageRead: (conversationId: string, messageId: string) => unknown;
selectMessage: (messageId: string, conversationId: string) => unknown; targetMessage: (messageId: string, conversationId: string) => unknown;
setIsNearBottom: (conversationId: string, isNearBottom: boolean) => unknown; setIsNearBottom: (conversationId: string, isNearBottom: boolean) => unknown;
peekGroupCallForTheFirstTime: (conversationId: string) => unknown; peekGroupCallForTheFirstTime: (conversationId: string) => unknown;
peekGroupCallIfItHasMembers: (conversationId: string) => unknown; peekGroupCallIfItHasMembers: (conversationId: string) => unknown;
@ -240,12 +241,12 @@ export class Timeline extends React.Component<
} }
private scrollToBottom = (setFocus?: boolean): void => { private scrollToBottom = (setFocus?: boolean): void => {
const { selectMessage, id, items } = this.props; const { targetMessage, id, items } = this.props;
if (setFocus && items && items.length > 0) { if (setFocus && items && items.length > 0) {
const lastIndex = items.length - 1; const lastIndex = items.length - 1;
const lastMessageId = items[lastIndex]; const lastMessageId = items[lastIndex];
selectMessage(lastMessageId, id); targetMessage(lastMessageId, id);
} else { } else {
const containerEl = this.containerRef.current; const containerEl = this.containerRef.current;
if (containerEl) { if (containerEl) {
@ -266,7 +267,7 @@ export class Timeline extends React.Component<
loadNewestMessages, loadNewestMessages,
messageLoadingState, messageLoadingState,
oldestUnseenIndex, oldestUnseenIndex,
selectMessage, targetMessage,
} = this.props; } = this.props;
const { newestBottomVisibleMessageId } = this.state; const { newestBottomVisibleMessageId } = this.state;
@ -287,7 +288,7 @@ export class Timeline extends React.Component<
) { ) {
if (setFocus) { if (setFocus) {
const messageId = items[oldestUnseenIndex]; const messageId = items[oldestUnseenIndex];
selectMessage(messageId, id); targetMessage(messageId, id);
} else { } else {
this.scrollToItemIndex(oldestUnseenIndex); this.scrollToItemIndex(oldestUnseenIndex);
} }
@ -642,15 +643,15 @@ export class Timeline extends React.Component<
} }
private handleBlur = (event: React.FocusEvent): void => { private handleBlur = (event: React.FocusEvent): void => {
const { clearSelectedMessage } = this.props; const { clearTargetedMessage } = this.props;
const { currentTarget } = event; const { currentTarget } = event;
// Thanks to https://gist.github.com/pstoica/4323d3e6e37e8a23dd59 // Thanks to https://gist.github.com/pstoica/4323d3e6e37e8a23dd59
setTimeout(() => { setTimeout(() => {
// If focus moved to one of our portals, we do not clear the selected // If focus moved to one of our portals, we do not clear the targeted
// message so that focus stays inside the portal. We need to be careful // message so that focus stays inside the portal. We need to be careful
// to not create colliding keyboard shortcuts between selected messages // to not create colliding keyboard shortcuts between targeted messages
// and our portals! // and our portals!
const portals = Array.from( const portals = Array.from(
document.querySelectorAll('body > div:not(.inbox)') document.querySelectorAll('body > div:not(.inbox)')
@ -660,7 +661,7 @@ export class Timeline extends React.Component<
} }
if (!currentTarget.contains(document.activeElement)) { if (!currentTarget.contains(document.activeElement)) {
clearSelectedMessage(); clearTargetedMessage();
} }
}, 0); }, 0);
}; };
@ -668,7 +669,7 @@ export class Timeline extends React.Component<
private handleKeyDown = ( private handleKeyDown = (
event: React.KeyboardEvent<HTMLDivElement> event: React.KeyboardEvent<HTMLDivElement>
): void => { ): void => {
const { selectMessage, selectedMessageId, items, id } = this.props; const { targetMessage, targetedMessageId, items, id } = this.props;
const commandKey = get(window, 'platform') === 'darwin' && event.metaKey; const commandKey = get(window, 'platform') === 'darwin' && event.metaKey;
const controlKey = get(window, 'platform') !== 'darwin' && event.ctrlKey; const controlKey = get(window, 'platform') !== 'darwin' && event.ctrlKey;
const commandOrCtrl = commandKey || controlKey; const commandOrCtrl = commandKey || controlKey;
@ -677,21 +678,21 @@ export class Timeline extends React.Component<
return; return;
} }
if (selectedMessageId && !commandOrCtrl && event.key === 'ArrowUp') { if (targetedMessageId && !commandOrCtrl && event.key === 'ArrowUp') {
const selectedMessageIndex = items.findIndex( const targetedMessageIndex = items.findIndex(
item => item === selectedMessageId item => item === targetedMessageId
); );
if (selectedMessageIndex < 0) { if (targetedMessageIndex < 0) {
return; return;
} }
const targetIndex = selectedMessageIndex - 1; const targetIndex = targetedMessageIndex - 1;
if (targetIndex < 0) { if (targetIndex < 0) {
return; return;
} }
const messageId = items[targetIndex]; const messageId = items[targetIndex];
selectMessage(messageId, id); targetMessage(messageId, id);
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
@ -699,21 +700,21 @@ export class Timeline extends React.Component<
return; return;
} }
if (selectedMessageId && !commandOrCtrl && event.key === 'ArrowDown') { if (targetedMessageId && !commandOrCtrl && event.key === 'ArrowDown') {
const selectedMessageIndex = items.findIndex( const targetedMessageIndex = items.findIndex(
item => item === selectedMessageId item => item === targetedMessageId
); );
if (selectedMessageIndex < 0) { if (targetedMessageIndex < 0) {
return; return;
} }
const targetIndex = selectedMessageIndex + 1; const targetIndex = targetedMessageIndex + 1;
if (targetIndex >= items.length) { if (targetIndex >= items.length) {
return; return;
} }
const messageId = items[targetIndex]; const messageId = items[targetIndex];
selectMessage(messageId, id); targetMessage(messageId, id);
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
@ -724,7 +725,7 @@ export class Timeline extends React.Component<
if (commandOrCtrl && event.key === 'ArrowUp') { if (commandOrCtrl && event.key === 'ArrowUp') {
const firstMessageId = first(items); const firstMessageId = first(items);
if (firstMessageId) { if (firstMessageId) {
selectMessage(firstMessageId, id); targetMessage(firstMessageId, id);
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
} }

View file

@ -58,18 +58,19 @@ const getDefaultProps = () => ({
getPreferredBadge: () => undefined, getPreferredBadge: () => undefined,
id: 'asdf', id: 'asdf',
isNextItemCallingNotification: false, isNextItemCallingNotification: false,
isSelected: false, isTargeted: false,
interactionMode: 'keyboard' as const, interactionMode: 'keyboard' as const,
theme: ThemeType.light, theme: ThemeType.light,
selectMessage: action('selectMessage'), targetMessage: action('targetMessage'),
toggleSelectMessage: action('toggleSelectMessage'),
reactToMessage: action('reactToMessage'), reactToMessage: action('reactToMessage'),
checkForAccount: action('checkForAccount'), checkForAccount: action('checkForAccount'),
clearSelectedMessage: action('clearSelectedMessage'), clearTargetedMessage: action('clearTargetedMessage'),
setQuoteByMessageId: action('setQuoteByMessageId'), setQuoteByMessageId: action('setQuoteByMessageId'),
retryDeleteForEveryone: action('retryDeleteForEveryone'), retryDeleteForEveryone: action('retryDeleteForEveryone'),
retryMessageSend: action('retryMessageSend'), retryMessageSend: action('retryMessageSend'),
blockGroupLinkRequests: action('blockGroupLinkRequests'), blockGroupLinkRequests: action('blockGroupLinkRequests'),
deleteMessage: action('deleteMessage'), deleteMessages: action('deleteMessages'),
deleteMessageForEveryone: action('deleteMessageForEveryone'), deleteMessageForEveryone: action('deleteMessageForEveryone'),
kickOffAttachmentDownload: action('kickOffAttachmentDownload'), kickOffAttachmentDownload: action('kickOffAttachmentDownload'),
markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'), markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'),
@ -80,7 +81,7 @@ const getDefaultProps = () => ({
pushPanelForConversation: action('pushPanelForConversation'), pushPanelForConversation: action('pushPanelForConversation'),
showContactModal: action('showContactModal'), showContactModal: action('showContactModal'),
showLightbox: action('showLightbox'), showLightbox: action('showLightbox'),
toggleForwardMessageModal: action('toggleForwardMessageModal'), toggleForwardMessagesModal: action('toggleForwardMessagesModal'),
showLightboxForViewOnceMedia: action('showLightboxForViewOnceMedia'), showLightboxForViewOnceMedia: action('showLightboxForViewOnceMedia'),
doubleCheckMissingQuoteReference: action('doubleCheckMissingQuoteReference'), doubleCheckMissingQuoteReference: action('doubleCheckMissingQuoteReference'),
showExpiredIncomingTapToViewToast: action( showExpiredIncomingTapToViewToast: action(
@ -107,6 +108,8 @@ const getDefaultProps = () => ({
renderReactionPicker, renderReactionPicker,
renderAudioAttachment: () => <div>*AudioAttachment*</div>, renderAudioAttachment: () => <div>*AudioAttachment*</div>,
viewStory: action('viewStory'), viewStory: action('viewStory'),
onReplyToMessage: action('onReplyToMessage'),
}); });
export default { export default {

View file

@ -148,8 +148,8 @@ type PropsLocalType = {
item?: TimelineItemType; item?: TimelineItemType;
id: string; id: string;
isNextItemCallingNotification: boolean; isNextItemCallingNotification: boolean;
isSelected: boolean; isTargeted: boolean;
selectMessage: (messageId: string, conversationId: string) => unknown; targetMessage: (messageId: string, conversationId: string) => unknown;
shouldRenderDateHeader: boolean; shouldRenderDateHeader: boolean;
renderContact: SmartContactRendererType<FullJSXType>; renderContact: SmartContactRendererType<FullJSXType>;
renderUniversalTimerNotification: () => JSX.Element; renderUniversalTimerNotification: () => JSX.Element;
@ -186,11 +186,11 @@ export class TimelineItem extends React.PureComponent<PropsType> {
i18n, i18n,
id, id,
isNextItemCallingNotification, isNextItemCallingNotification,
isSelected, isTargeted,
item, item,
renderUniversalTimerNotification, renderUniversalTimerNotification,
returnToActiveCall, returnToActiveCall,
selectMessage, targetMessage,
shouldCollapseAbove, shouldCollapseAbove,
shouldCollapseBelow, shouldCollapseBelow,
shouldHideMetadata, shouldHideMetadata,
@ -216,8 +216,8 @@ export class TimelineItem extends React.PureComponent<PropsType> {
<TimelineMessage <TimelineMessage
{...reducedProps} {...reducedProps}
{...item.data} {...item.data}
isSelected={isSelected} isTargeted={isTargeted}
selectMessage={selectMessage} targetMessage={targetMessage}
shouldCollapseAbove={shouldCollapseAbove} shouldCollapseAbove={shouldCollapseAbove}
shouldCollapseBelow={shouldCollapseBelow} shouldCollapseBelow={shouldCollapseBelow}
shouldHideMetadata={shouldHideMetadata} shouldHideMetadata={shouldHideMetadata}
@ -346,8 +346,8 @@ export class TimelineItem extends React.PureComponent<PropsType> {
<InlineNotificationWrapper <InlineNotificationWrapper
id={id} id={id}
conversationId={conversationId} conversationId={conversationId}
isSelected={isSelected} isTargeted={isTargeted}
selectMessage={selectMessage} targetMessage={targetMessage}
> >
{notification} {notification}
</InlineNotificationWrapper> </InlineNotificationWrapper>

View file

@ -252,7 +252,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
canRetry: overrideProps.canRetry || false, canRetry: overrideProps.canRetry || false,
canRetryDeleteForEveryone: overrideProps.canRetryDeleteForEveryone || false, canRetryDeleteForEveryone: overrideProps.canRetryDeleteForEveryone || false,
checkForAccount: action('checkForAccount'), checkForAccount: action('checkForAccount'),
clearSelectedMessage: action('clearSelectedMessage'), clearTargetedMessage: action('clearSelectedMessage'),
containerElementRef: React.createRef<HTMLElement>(), containerElementRef: React.createRef<HTMLElement>(),
containerWidthBreakpoint: WidthBreakpoint.Wide, containerWidthBreakpoint: WidthBreakpoint.Wide,
conversationColor: conversationColor:
@ -265,7 +265,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
conversationType: overrideProps.conversationType || 'direct', conversationType: overrideProps.conversationType || 'direct',
contact: overrideProps.contact, contact: overrideProps.contact,
deletedForEveryone: overrideProps.deletedForEveryone, deletedForEveryone: overrideProps.deletedForEveryone,
deleteMessage: action('deleteMessage'), deleteMessages: action('deleteMessages'),
deleteMessageForEveryone: action('deleteMessageForEveryone'), deleteMessageForEveryone: action('deleteMessageForEveryone'),
// disableMenu: overrideProps.disableMenu, // disableMenu: overrideProps.disableMenu,
disableScroll: overrideProps.disableScroll, disableScroll: overrideProps.disableScroll,
@ -293,6 +293,12 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
isMessageRequestAccepted: isBoolean(overrideProps.isMessageRequestAccepted) isMessageRequestAccepted: isBoolean(overrideProps.isMessageRequestAccepted)
? overrideProps.isMessageRequestAccepted ? overrideProps.isMessageRequestAccepted
: true, : true,
isSelected: isBoolean(overrideProps.isSelected)
? overrideProps.isSelected
: false,
isSelectMode: isBoolean(overrideProps.isSelectMode)
? overrideProps.isSelectMode
: false,
isTapToView: overrideProps.isTapToView, isTapToView: overrideProps.isTapToView,
isTapToViewError: overrideProps.isTapToViewError, isTapToViewError: overrideProps.isTapToViewError,
isTapToViewExpired: overrideProps.isTapToViewExpired, isTapToViewExpired: overrideProps.isTapToViewExpired,
@ -317,7 +323,11 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
retryMessageSend: action('retryMessageSend'), retryMessageSend: action('retryMessageSend'),
retryDeleteForEveryone: action('retryDeleteForEveryone'), retryDeleteForEveryone: action('retryDeleteForEveryone'),
scrollToQuotedMessage: action('scrollToQuotedMessage'), scrollToQuotedMessage: action('scrollToQuotedMessage'),
selectMessage: action('selectMessage'), targetMessage: action('targetMessage'),
toggleSelectMessage:
overrideProps.toggleSelectMessage == null
? action('toggleSelectMessage')
: overrideProps.toggleSelectMessage,
shouldCollapseAbove: isBoolean(overrideProps.shouldCollapseAbove) shouldCollapseAbove: isBoolean(overrideProps.shouldCollapseAbove)
? overrideProps.shouldCollapseAbove ? overrideProps.shouldCollapseAbove
: false, : false,
@ -335,7 +345,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
showExpiredOutgoingTapToViewToast: action( showExpiredOutgoingTapToViewToast: action(
'showExpiredOutgoingTapToViewToast' 'showExpiredOutgoingTapToViewToast'
), ),
toggleForwardMessageModal: action('toggleForwardMessageModal'), toggleForwardMessagesModal: action('toggleForwardMessagesModal'),
showLightbox: action('showLightbox'), showLightbox: action('showLightbox'),
startConversation: action('startConversation'), startConversation: action('startConversation'),
status: overrideProps.status || 'sent', status: overrideProps.status || 'sent',
@ -2126,3 +2136,33 @@ PaymentNotification.args = {
note: 'Hello there', note: 'Hello there',
}, },
}; };
function MultiSelectMessage() {
const [selected, setSelected] = React.useState(false);
return (
<TimelineMessage
{...createProps({
text: 'Hello',
isSelected: selected,
isSelectMode: true,
toggleSelectMessage(_conversationId, _messageId, _shift, newSelected) {
setSelected(newSelected);
},
})}
/>
);
}
export function MultiSelect(): JSX.Element {
return (
<>
<MultiSelectMessage />
<MultiSelectMessage />
<MultiSelectMessage />
</>
);
}
MultiSelect.args = {
name: 'Multi Select',
};

View file

@ -37,17 +37,17 @@ export type PropsData = {
canReact: boolean; canReact: boolean;
canReply: boolean; canReply: boolean;
selectedReaction?: string; selectedReaction?: string;
isSelected?: boolean; isTargeted?: boolean;
} & Omit<MessagePropsData, 'renderingContext' | 'menu'>; } & Omit<MessagePropsData, 'renderingContext' | 'menu'>;
export type PropsActions = { export type PropsActions = {
deleteMessage: (options: { deleteMessages: (options: {
conversationId: string; conversationId: string;
messageId: string; messageIds: ReadonlyArray<string>;
}) => void; }) => void;
deleteMessageForEveryone: (id: string) => void; deleteMessageForEveryone: (id: string) => void;
pushPanelForConversation: PushPanelForConversationActionType; pushPanelForConversation: PushPanelForConversationActionType;
toggleForwardMessageModal: (id: string) => void; toggleForwardMessagesModal: (id: Array<string>) => void;
reactToMessage: ( reactToMessage: (
id: string, id: string,
{ emoji, remove }: { emoji: string; remove: boolean } { emoji, remove }: { emoji: string; remove: boolean }
@ -55,7 +55,13 @@ export type PropsActions = {
retryMessageSend: (id: string) => void; retryMessageSend: (id: string) => void;
retryDeleteForEveryone: (id: string) => void; retryDeleteForEveryone: (id: string) => void;
setQuoteByMessageId: (conversationId: string, messageId: string) => void; setQuoteByMessageId: (conversationId: string, messageId: string) => void;
} & MessagePropsActions; toggleSelectMessage: (
conversationId: string,
messageId: string,
shift: boolean,
selected: boolean
) => void;
} & Omit<MessagePropsActions, 'onToggleSelect' | 'onReplyToMessage'>;
export type Props = PropsData & export type Props = PropsData &
PropsActions & PropsActions &
@ -87,14 +93,14 @@ export function TimelineMessage(props: Props): JSX.Element {
containerElementRef, containerElementRef,
containerWidthBreakpoint, containerWidthBreakpoint,
conversationId, conversationId,
deleteMessage, deleteMessages,
deleteMessageForEveryone, deleteMessageForEveryone,
deletedForEveryone, deletedForEveryone,
direction, direction,
giftBadge, giftBadge,
i18n, i18n,
id, id,
isSelected, isTargeted,
isSticker, isSticker,
isTapToView, isTapToView,
kickOffAttachmentDownload, kickOffAttachmentDownload,
@ -110,7 +116,8 @@ export function TimelineMessage(props: Props): JSX.Element {
setQuoteByMessageId, setQuoteByMessageId,
text, text,
timestamp, timestamp,
toggleForwardMessageModal, toggleForwardMessagesModal,
toggleSelectMessage,
} = props; } = props;
const [reactionPickerRoot, setReactionPickerRoot] = useState< const [reactionPickerRoot, setReactionPickerRoot] = useState<
@ -260,14 +267,14 @@ export function TimelineMessage(props: Props): JSX.Element {
); );
useEffect(() => { useEffect(() => {
if (isSelected) { if (isTargeted) {
document.addEventListener('keydown', toggleReactionPickerKeyboard); document.addEventListener('keydown', toggleReactionPickerKeyboard);
} }
return () => { return () => {
document.removeEventListener('keydown', toggleReactionPickerKeyboard); document.removeEventListener('keydown', toggleReactionPickerKeyboard);
}; };
}, [isSelected, toggleReactionPickerKeyboard]); }, [isTargeted, toggleReactionPickerKeyboard]);
const renderMenu = useCallback(() => { const renderMenu = useCallback(() => {
return ( return (
@ -357,9 +364,9 @@ export function TimelineMessage(props: Props): JSX.Element {
actions={[ actions={[
{ {
action: () => action: () =>
deleteMessage({ deleteMessages({
conversationId, conversationId,
messageId: id, messageIds: [id],
}), }),
style: 'negative', style: 'negative',
text: i18n('delete'), text: i18n('delete'),
@ -372,24 +379,17 @@ export function TimelineMessage(props: Props): JSX.Element {
{i18n('deleteWarning')} {i18n('deleteWarning')}
</ConfirmationDialog> </ConfirmationDialog>
)} )}
<div
onDoubleClick={ev => {
if (!handleReplyToMessage) {
return;
}
ev.stopPropagation(); <Message
ev.preventDefault(); {...props}
handleReplyToMessage(); renderingContext="conversation/TimelineItem"
onContextMenu={handleContextMenu}
renderMenu={renderMenu}
onToggleSelect={(selected, shift) => {
toggleSelectMessage(conversationId, id, shift, selected);
}} }}
> onReplyToMessage={handleReplyToMessage}
<Message />
{...props}
renderingContext="conversation/TimelineItem"
onContextMenu={handleContextMenu}
renderMenu={renderMenu}
/>
</div>
<MessageContextMenu <MessageContextMenu
i18n={i18n} i18n={i18n}
@ -404,7 +404,10 @@ export function TimelineMessage(props: Props): JSX.Element {
? () => retryDeleteForEveryone(id) ? () => retryDeleteForEveryone(id)
: undefined : undefined
} }
onForward={canForward ? () => toggleForwardMessageModal(id) : undefined} onSelect={() => toggleSelectMessage(conversationId, id, false, true)}
onForward={
canForward ? () => toggleForwardMessagesModal([id]) : undefined
}
onDeleteForMe={() => setHasDeleteConfirmation(true)} onDeleteForMe={() => setHasDeleteConfirmation(true)}
onDeleteForEveryone={ onDeleteForEveryone={
canDeleteForEveryone ? () => setHasDOEConfirmation(true) : undefined canDeleteForEveryone ? () => setHasDOEConfirmation(true) : undefined
@ -589,6 +592,7 @@ type MessageContextProps = {
onDeleteForMe: () => void; onDeleteForMe: () => void;
onDeleteForEveryone: (() => void) | undefined; onDeleteForEveryone: (() => void) | undefined;
onMoreInfo: () => void; onMoreInfo: () => void;
onSelect: () => void;
}; };
const MessageContextMenu = ({ const MessageContextMenu = ({
@ -599,6 +603,7 @@ const MessageContextMenu = ({
onReplyToMessage, onReplyToMessage,
onReact, onReact,
onMoreInfo, onMoreInfo,
onSelect,
onRetryMessageSend, onRetryMessageSend,
onRetryDeleteForEveryone, onRetryDeleteForEveryone,
onForward, onForward,
@ -668,6 +673,17 @@ const MessageContextMenu = ({
> >
{i18n('moreInfo')} {i18n('moreInfo')}
</MenuItem> </MenuItem>
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__select',
}}
onClick={() => {
onSelect();
}}
>
{i18n('icu:MessageContextMenu__select')}
</MenuItem>
{onRetryMessageSend && ( {onRetryMessageSend && (
<MenuItem <MenuItem
attributes={{ attributes={{

View file

@ -183,13 +183,13 @@ export class LeftPaneArchiveHelper extends LeftPaneHelper<LeftPaneArchivePropsTy
getConversationAndMessageInDirection( getConversationAndMessageInDirection(
toFind: Readonly<ToFindType>, toFind: Readonly<ToFindType>,
selectedConversationId: undefined | string, selectedConversationId: undefined | string,
selectedMessageId: unknown targetedMessageId: unknown
): undefined | { conversationId: string } { ): undefined | { conversationId: string } {
if (this.searchHelper) { if (this.searchHelper) {
return this.searchHelper.getConversationAndMessageInDirection( return this.searchHelper.getConversationAndMessageInDirection(
toFind, toFind,
selectedConversationId, selectedConversationId,
selectedMessageId targetedMessageId
); );
} }

View file

@ -128,7 +128,7 @@ export abstract class LeftPaneHelper<T> {
abstract getConversationAndMessageInDirection( abstract getConversationAndMessageInDirection(
toFind: Readonly<ToFindType>, toFind: Readonly<ToFindType>,
selectedConversationId: undefined | string, selectedConversationId: undefined | string,
selectedMessageId: undefined | string targetedMessageId: undefined | string
): undefined | { conversationId: string; messageId?: string }; ): undefined | { conversationId: string; messageId?: string };
abstract shouldRecomputeRowHeights(old: Readonly<T>): boolean; abstract shouldRecomputeRowHeights(old: Readonly<T>): boolean;

View file

@ -267,7 +267,7 @@ export class LeftPaneInboxHelper extends LeftPaneHelper<LeftPaneInboxPropsType>
getConversationAndMessageInDirection( getConversationAndMessageInDirection(
toFind: Readonly<ToFindType>, toFind: Readonly<ToFindType>,
selectedConversationId: undefined | string, selectedConversationId: undefined | string,
_selectedMessageId: unknown _targetedMessageId: unknown
): undefined | { conversationId: string } { ): undefined | { conversationId: string } {
return getConversationInDirection( return getConversationInDirection(
[...this.pinnedConversations, ...this.conversations], [...this.pinnedConversations, ...this.conversations],

View file

@ -335,7 +335,7 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<LeftPaneSearchPropsType
getConversationAndMessageInDirection( getConversationAndMessageInDirection(
_toFind: Readonly<ToFindType>, _toFind: Readonly<ToFindType>,
_selectedConversationId: undefined | string, _selectedConversationId: undefined | string,
_selectedMessageId: unknown _targetedMessageId: unknown
): undefined | { conversationId: string } { ): undefined | { conversationId: string } {
return undefined; return undefined;
} }

View file

@ -17,10 +17,14 @@ export function useEscapeHandling(handleEscape?: () => unknown): void {
event.stopPropagation(); event.stopPropagation();
} }
}; };
document.addEventListener('keydown', handler); document.addEventListener('keydown', handler, {
capture: true,
});
return () => { return () => {
document.removeEventListener('keydown', handler); document.removeEventListener('keydown', handler, {
capture: true,
});
}; };
}, [handleEscape]); }, [handleEscape]);
} }

View file

@ -2156,9 +2156,12 @@ export class ConversationModel extends window.Backbone
return undefined; return undefined;
} }
decrementMessageCount(): void { decrementMessageCount(numberOfMessages = 1): void {
this.set({ this.set({
messageCount: Math.max((this.get('messageCount') || 0) - 1, 0), messageCount: Math.max(
(this.get('messageCount') || 0) - numberOfMessages,
0
),
}); });
window.Signal.Data.updateConversation(this.attributes); window.Signal.Data.updateConversation(this.attributes);
} }
@ -2180,10 +2183,16 @@ export class ConversationModel extends window.Backbone
return undefined; return undefined;
} }
decrementSentMessageCount(): void { decrementSentMessageCount(numberOfMessages = 1): void {
this.set({ this.set({
messageCount: Math.max((this.get('messageCount') || 0) - 1, 0), messageCount: Math.max(
sentMessageCount: Math.max((this.get('sentMessageCount') || 0) - 1, 0), (this.get('messageCount') || 0) - numberOfMessages,
0
),
sentMessageCount: Math.max(
(this.get('sentMessageCount') || 0) - numberOfMessages,
0
),
}); });
window.Signal.Data.updateConversation(this.attributes); window.Signal.Data.updateConversation(this.attributes);
} }

View file

@ -24,10 +24,10 @@ export function startInteractionMode(): void {
document.body.classList.add('keyboard-mode'); document.body.classList.add('keyboard-mode');
document.body.classList.remove('mouse-mode'); document.body.classList.remove('mouse-mode');
const clearSelectedMessage = const clearTargetedMessage =
window.reduxActions?.conversations?.clearSelectedMessage; window.reduxActions?.conversations?.clearTargetedMessage;
if (clearSelectedMessage) { if (clearTargetedMessage) {
clearSelectedMessage(); clearTargetedMessage();
} }
const userChanged = window.reduxActions?.user?.userChanged; const userChanged = window.reduxActions?.user?.userChanged;
@ -45,10 +45,10 @@ export function startInteractionMode(): void {
document.body.classList.add('mouse-mode'); document.body.classList.add('mouse-mode');
document.body.classList.remove('keyboard-mode'); document.body.classList.remove('keyboard-mode');
const clearSelectedMessage = const clearTargetedMessage =
window.reduxActions?.conversations?.clearSelectedMessage; window.reduxActions?.conversations?.clearTargetedMessage;
if (clearSelectedMessage) { if (clearTargetedMessage) {
clearSelectedMessage(); clearTargetedMessage();
} }
const userChanged = window.reduxActions?.user?.userChanged; const userChanged = window.reduxActions?.user?.userChanged;

View file

@ -69,6 +69,7 @@ import Server from './Server';
import { parseSqliteError, SqliteErrorKind } from './errors'; import { parseSqliteError, SqliteErrorKind } from './errors';
import { MINUTE } from '../util/durations'; import { MINUTE } from '../util/durations';
import { getMessageIdForLogging } from '../util/idForLogging'; import { getMessageIdForLogging } from '../util/idForLogging';
import type { MessageAttributesType } from '../model-types';
const getRealPath = pify(fs.realpath); const getRealPath = pify(fs.realpath);
@ -227,6 +228,7 @@ const dataInterface: ClientInterface = {
saveMessage, saveMessage,
saveMessages, saveMessages,
removeMessage, removeMessage,
removeMessages,
saveAttachmentDownloadJob, saveAttachmentDownloadJob,
}; };
@ -668,6 +670,28 @@ async function removeMessage(id: string): Promise<void> {
} }
} }
async function _cleanupMessages(
messages: ReadonlyArray<MessageAttributesType>
): Promise<void> {
const queue = new PQueue({ concurrency: 3, timeout: MINUTE * 30 });
drop(
queue.addAll(
messages.map(
(message: MessageAttributesType) => async () => cleanupMessage(message)
)
)
);
await queue.onIdle();
}
async function removeMessages(
messageIds: ReadonlyArray<string>
): Promise<void> {
const messages = await channels.getMessagesById(messageIds);
await _cleanupMessages(messages);
await channels.removeMessages(messageIds);
}
function handleMessageJSON( function handleMessageJSON(
messages: Array<MessageTypeUnhydrated> messages: Array<MessageTypeUnhydrated>
): Array<MessageType> { ): Array<MessageType> {
@ -733,18 +757,8 @@ async function removeAllMessagesInConversation(
const ids = messages.map(message => message.id); const ids = messages.map(message => message.id);
log.info(`removeAllMessagesInConversation/${logId}: Cleanup...`); log.info(`removeAllMessagesInConversation/${logId}: Cleanup...`);
// Note: It's very important that these models are fully hydrated because
// we need to delete all associated on-disk files along with the database delete.
const queue = new PQueue({ concurrency: 3, timeout: MINUTE * 30 });
drop(
queue.addAll(
messages.map(
(message: MessageType) => async () => cleanupMessage(message)
)
)
);
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await queue.onIdle(); await _cleanupMessages(messages);
log.info(`removeAllMessagesInConversation/${logId}: Deleting...`); log.info(`removeAllMessagesInConversation/${logId}: Deleting...`);
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop

View file

@ -18,6 +18,8 @@ import type { BadgeType } from '../badges/types';
import type { RemoveAllConfiguration } from '../types/RemoveAllConfiguration'; import type { RemoveAllConfiguration } from '../types/RemoveAllConfiguration';
import type { LoggerType } from '../types/Logging'; import type { LoggerType } from '../types/Logging';
import type { ReadStatus } from '../messages/MessageReadStatus'; import type { ReadStatus } from '../messages/MessageReadStatus';
import type { GetMessagesBetweenOptions } from './Server';
import type { MessageTimestamps } from '../state/ducks/conversations';
export type AdjacentMessagesByConversationOptionsType = Readonly<{ export type AdjacentMessagesByConversationOptionsType = Readonly<{
conversationId: string; conversationId: string;
@ -30,6 +32,14 @@ export type AdjacentMessagesByConversationOptionsType = Readonly<{
requireVisualMediaAttachments?: boolean; requireVisualMediaAttachments?: boolean;
}>; }>;
export type GetNearbyMessageFromDeletedSetOptionsType = Readonly<{
conversationId: string;
lastSelectedMessage: MessageTimestamps;
deletedMessageIds: ReadonlyArray<string>;
storyId: string | undefined;
includeStoryReplies: boolean;
}>;
export type AttachmentDownloadJobTypeType = export type AttachmentDownloadJobTypeType =
| 'long-message' | 'long-message'
| 'attachment' | 'attachment'
@ -529,7 +539,9 @@ export type DataInterface = {
sent_at: number; sent_at: number;
}) => Promise<MessageType | undefined>; }) => Promise<MessageType | undefined>;
getMessageById: (id: string) => Promise<MessageType | undefined>; getMessageById: (id: string) => Promise<MessageType | undefined>;
getMessagesById: (messageIds: Array<string>) => Promise<Array<MessageType>>; getMessagesById: (
messageIds: ReadonlyArray<string>
) => Promise<Array<MessageType>>;
_getAllMessages: () => Promise<Array<MessageType>>; _getAllMessages: () => Promise<Array<MessageType>>;
_removeAllMessages: () => Promise<void>; _removeAllMessages: () => Promise<void>;
getAllMessageIds: () => Promise<Array<string>>; getAllMessageIds: () => Promise<Array<string>>;
@ -573,7 +585,13 @@ export type DataInterface = {
obsoleteId: string, obsoleteId: string,
currentId: string currentId: string
) => Promise<void>; ) => Promise<void>;
getMessagesBetween: (
conversationId: string,
options: GetMessagesBetweenOptions
) => Promise<Array<string>>;
getNearbyMessageFromDeletedSet: (
options: GetNearbyMessageFromDeletedSetOptionsType
) => Promise<string | null>;
getUnprocessedCount: () => Promise<number>; getUnprocessedCount: () => Promise<number>;
getUnprocessedByIdsAndIncrementAttempts: ( getUnprocessedByIdsAndIncrementAttempts: (
ids: ReadonlyArray<string> ids: ReadonlyArray<string>

View file

@ -52,8 +52,17 @@ import { parseBadgeCategory } from '../badges/BadgeCategory';
import { parseBadgeImageTheme } from '../badges/BadgeImageTheme'; import { parseBadgeImageTheme } from '../badges/BadgeImageTheme';
import type { LoggerType } from '../types/Logging'; import type { LoggerType } from '../types/Logging';
import * as log from '../logging/log'; import * as log from '../logging/log';
import type { EmptyQuery, ArrayQuery, Query, JSONRows } from './util'; import type {
EmptyQuery,
ArrayQuery,
Query,
JSONRows,
QueryFragment,
} from './util';
import { import {
sqlJoin,
sqlFragment,
sql,
jsonToObject, jsonToObject,
objectToJSON, objectToJSON,
batchMultiVarQuery, batchMultiVarQuery,
@ -122,6 +131,7 @@ import type {
UninstalledStickerPackType, UninstalledStickerPackType,
UnprocessedType, UnprocessedType,
UnprocessedUpdateType, UnprocessedUpdateType,
GetNearbyMessageFromDeletedSetOptionsType,
} from './Interface'; } from './Interface';
import { SeenStatus } from '../MessageSeenStatus'; import { SeenStatus } from '../MessageSeenStatus';
@ -261,6 +271,8 @@ const dataInterface: ServerInterface = {
getCallHistoryMessageByCallId, getCallHistoryMessageByCallId,
hasGroupCallHistoryMessage, hasGroupCallHistoryMessage,
migrateConversationMessages, migrateConversationMessages,
getMessagesBetween,
getNearbyMessageFromDeletedSet,
getUnprocessedCount, getUnprocessedCount,
getUnprocessedByIdsAndIncrementAttempts, getUnprocessedByIdsAndIncrementAttempts,
@ -2098,7 +2110,7 @@ export function getMessageByIdSync(
} }
async function getMessagesById( async function getMessagesById(
messageIds: Array<string> messageIds: ReadonlyArray<string>
): Promise<Array<MessageType>> { ): Promise<Array<MessageType>> {
const db = getInstance(); const db = getInstance();
@ -2189,17 +2201,17 @@ async function getMessageBySender({
export function _storyIdPredicate( export function _storyIdPredicate(
storyId: string | undefined, storyId: string | undefined,
includeStoryReplies: boolean includeStoryReplies: boolean
): string { ): QueryFragment {
// This is unintuitive, but 'including story replies' means that we need replies to // This is unintuitive, but 'including story replies' means that we need replies to
// lots of different stories. So, we remove the storyId check with a clause that will // lots of different stories. So, we remove the storyId check with a clause that will
// always be true. We don't just return TRUE because we want to use our passed-in // always be true. We don't just return TRUE because we want to use our passed-in
// $storyId parameter. // $storyId parameter.
if (includeStoryReplies && storyId === undefined) { if (includeStoryReplies && storyId === undefined) {
return '$storyId IS NULL'; return sqlFragment`${storyId} IS NULL`;
} }
// In contrast to: replies to a specific story // In contrast to: replies to a specific story
return 'storyId IS $storyId'; return sqlFragment`storyId IS ${storyId}`;
} }
async function getUnreadByConversationAndMarkRead({ async function getUnreadByConversationAndMarkRead({
@ -2220,75 +2232,63 @@ async function getUnreadByConversationAndMarkRead({
const db = getInstance(); const db = getInstance();
return db.transaction(() => { return db.transaction(() => {
const expirationStartTimestamp = Math.min(now, readAt ?? Infinity); const expirationStartTimestamp = Math.min(now, readAt ?? Infinity);
db.prepare<Query>(
` const expirationJsonPatch = JSON.stringify({ expirationStartTimestamp });
const [updateExpirationQuery, updateExpirationParams] = sql`
UPDATE messages UPDATE messages
INDEXED BY expiring_message_by_conversation_and_received_at INDEXED BY expiring_message_by_conversation_and_received_at
SET SET
expirationStartTimestamp = $expirationStartTimestamp, expirationStartTimestamp = ${expirationStartTimestamp},
json = json_patch(json, $jsonPatch) json = json_patch(json, ${expirationJsonPatch})
WHERE WHERE
conversationId = $conversationId AND conversationId = ${conversationId} AND
(${_storyIdPredicate(storyId, includeStoryReplies)}) AND (${_storyIdPredicate(storyId, includeStoryReplies)}) AND
isStory IS 0 AND isStory IS 0 AND
type IS 'incoming' AND type IS 'incoming' AND
( (
expirationStartTimestamp IS NULL OR expirationStartTimestamp IS NULL OR
expirationStartTimestamp > $expirationStartTimestamp expirationStartTimestamp > ${expirationStartTimestamp}
) AND ) AND
expireTimer > 0 AND expireTimer > 0 AND
received_at <= $newestUnreadAt; received_at <= ${newestUnreadAt};
` `;
).run({
conversationId,
expirationStartTimestamp,
jsonPatch: JSON.stringify({ expirationStartTimestamp }),
newestUnreadAt,
storyId: storyId || null,
});
const rows = db db.prepare(updateExpirationQuery).run(updateExpirationParams);
.prepare<Query>(
` const [selectQuery, selectParams] = sql`
SELECT id, json FROM messages SELECT id, json FROM messages
WHERE WHERE
conversationId = $conversationId AND conversationId = ${conversationId} AND
seenStatus = ${SeenStatus.Unseen} AND seenStatus = ${SeenStatus.Unseen} AND
isStory = 0 AND isStory = 0 AND
(${_storyIdPredicate(storyId, includeStoryReplies)}) AND (${_storyIdPredicate(storyId, includeStoryReplies)}) AND
received_at <= $newestUnreadAt received_at <= ${newestUnreadAt}
ORDER BY received_at DESC, sent_at DESC; ORDER BY received_at DESC, sent_at DESC;
` `;
)
.all({
conversationId,
newestUnreadAt,
storyId: storyId || null,
});
db.prepare<Query>( const rows = db.prepare(selectQuery).all(selectParams);
`
UPDATE messages const statusJsonPatch = JSON.stringify({
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
});
const [updateStatusQuery, updateStatusParams] = sql`
UPDATE messages
SET SET
readStatus = ${ReadStatus.Read}, readStatus = ${ReadStatus.Read},
seenStatus = ${SeenStatus.Seen}, seenStatus = ${SeenStatus.Seen},
json = json_patch(json, $jsonPatch) json = json_patch(json, ${statusJsonPatch})
WHERE WHERE
conversationId = $conversationId AND conversationId = ${conversationId} AND
seenStatus = ${SeenStatus.Unseen} AND seenStatus = ${SeenStatus.Unseen} AND
isStory = 0 AND isStory = 0 AND
(${_storyIdPredicate(storyId, includeStoryReplies)}) AND (${_storyIdPredicate(storyId, includeStoryReplies)}) AND
received_at <= $newestUnreadAt; received_at <= ${newestUnreadAt};
` `;
).run({
conversationId, db.prepare(updateStatusQuery).run(updateStatusParams);
jsonPatch: JSON.stringify({
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Seen,
}),
newestUnreadAt,
storyId: storyId || null,
});
return rows.map(row => { return rows.map(row => {
const json = jsonToObject<MessageType>(row.json); const json = jsonToObject<MessageType>(row.json);
@ -2500,32 +2500,35 @@ function getAdjacentMessagesByConversationSync(
const timeFilter = const timeFilter =
direction === AdjacentDirection.Older direction === AdjacentDirection.Older
? ` ? sqlFragment`
(received_at = $received_at AND sent_at < $sent_at) OR (received_at = ${receivedAt} AND sent_at < ${sentAt}) OR
received_at < $received_at received_at < ${receivedAt}
` `
: ` : sqlFragment`
(received_at = $received_at AND sent_at > $sent_at) OR (received_at = ${receivedAt} AND sent_at > ${sentAt}) OR
received_at > $received_at received_at > ${receivedAt}
`; `;
const timeOrder = direction === AdjacentDirection.Older ? 'DESC' : 'ASC'; const timeOrder =
direction === AdjacentDirection.Older
? sqlFragment`DESC`
: sqlFragment`ASC`;
const requireDifferentMessage = const requireDifferentMessage =
direction === AdjacentDirection.Older || requireVisualMediaAttachments; direction === AdjacentDirection.Older || requireVisualMediaAttachments;
let query = ` let template = sqlFragment`
SELECT json FROM messages WHERE SELECT json FROM messages WHERE
conversationId = $conversationId AND conversationId = ${conversationId} AND
${ ${
requireDifferentMessage requireDifferentMessage
? '($messageId IS NULL OR id IS NOT $messageId) AND' ? sqlFragment`(${messageId} IS NULL OR id IS NOT ${messageId}) AND`
: '' : sqlFragment``
} }
${ ${
requireVisualMediaAttachments requireVisualMediaAttachments
? 'hasVisualMediaAttachments IS 1 AND' ? sqlFragment`hasVisualMediaAttachments IS 1 AND`
: '' : sqlFragment``
} }
isStory IS 0 AND isStory IS 0 AND
(${_storyIdPredicate(storyId, includeStoryReplies)}) AND (${_storyIdPredicate(storyId, includeStoryReplies)}) AND
@ -2537,9 +2540,9 @@ function getAdjacentMessagesByConversationSync(
// See `filterValidAttachments` in ts/state/ducks/lightbox.ts // See `filterValidAttachments` in ts/state/ducks/lightbox.ts
if (requireVisualMediaAttachments) { if (requireVisualMediaAttachments) {
query = ` template = sqlFragment`
SELECT json SELECT json
FROM (${query}) as messages FROM (${template}) as messages
WHERE WHERE
( (
SELECT COUNT(*) SELECT COUNT(*)
@ -2549,20 +2552,15 @@ function getAdjacentMessagesByConversationSync(
attachment.value ->> 'pending' IS NOT 1 AND attachment.value ->> 'pending' IS NOT 1 AND
attachment.value ->> 'error' IS NULL attachment.value ->> 'error' IS NULL
) > 0 ) > 0
LIMIT $limit; LIMIT ${limit};
`; `;
} else { } else {
query = `${query} LIMIT $limit`; template = sqlFragment`${template} LIMIT ${limit}`;
} }
const results = db.prepare<Query>(query).all({ const [query, params] = sql`${template}`;
conversationId,
limit, const results = db.prepare(query).all(params);
messageId: messageId || null,
received_at: receivedAt,
sent_at: sentAt,
storyId: storyId || null,
});
if (direction === AdjacentDirection.Older) { if (direction === AdjacentDirection.Older) {
results.reverse(); results.reverse();
@ -2648,21 +2646,16 @@ function getOldestMessageForConversation(
} }
): MessageMetricsType | undefined { ): MessageMetricsType | undefined {
const db = getInstance(); const db = getInstance();
const row = db const [query, params] = sql`
.prepare<Query>( SELECT received_at, sent_at, id FROM messages WHERE
` conversationId = ${conversationId} AND
SELECT received_at, sent_at, id FROM messages WHERE
conversationId = $conversationId AND
isStory IS 0 AND isStory IS 0 AND
(${_storyIdPredicate(storyId, includeStoryReplies)}) (${_storyIdPredicate(storyId, includeStoryReplies)})
ORDER BY received_at ASC, sent_at ASC ORDER BY received_at ASC, sent_at ASC
LIMIT 1; LIMIT 1;
` `;
)
.get({ const row = db.prepare(query).get(params);
conversationId,
storyId: storyId || null,
});
if (!row) { if (!row) {
return undefined; return undefined;
@ -2681,21 +2674,15 @@ function getNewestMessageForConversation(
} }
): MessageMetricsType | undefined { ): MessageMetricsType | undefined {
const db = getInstance(); const db = getInstance();
const row = db const [query, params] = sql`
.prepare<Query>( SELECT received_at, sent_at, id FROM messages WHERE
` conversationId = ${conversationId} AND
SELECT received_at, sent_at, id FROM messages WHERE
conversationId = $conversationId AND
isStory IS 0 AND isStory IS 0 AND
(${_storyIdPredicate(storyId, includeStoryReplies)}) (${_storyIdPredicate(storyId, includeStoryReplies)})
ORDER BY received_at DESC, sent_at DESC ORDER BY received_at DESC, sent_at DESC
LIMIT 1; LIMIT 1;
` `;
) const row = db.prepare(query).get(params);
.get({
conversationId,
storyId: storyId || null,
});
if (!row) { if (!row) {
return undefined; return undefined;
@ -2704,6 +2691,96 @@ function getNewestMessageForConversation(
return row; return row;
} }
export type GetMessagesBetweenOptions = Readonly<{
after: { received_at: number; sent_at: number };
before: { received_at: number; sent_at: number };
includeStoryReplies: boolean;
}>;
async function getMessagesBetween(
conversationId: string,
options: GetMessagesBetweenOptions
): Promise<Array<string>> {
const db = getInstance();
// In the future we could accept this as an option, but for now we just
// use it for the story predicate.
const storyId = undefined;
const { after, before, includeStoryReplies } = options;
const [query, params] = sql`
SELECT id
FROM messages
WHERE
conversationId = ${conversationId} AND
(${_storyIdPredicate(storyId, includeStoryReplies)}) AND
isStory IS 0 AND
(
received_at > ${after.received_at}
OR (received_at = ${after.received_at} AND sent_at > ${after.sent_at})
) AND (
received_at < ${before.received_at}
OR (received_at = ${before.received_at} AND sent_at < ${before.sent_at})
)
ORDER BY received_at ASC, sent_at ASC;
`;
const rows = db.prepare(query).all(params);
return rows.map(row => row.id);
}
/**
* Given a set of deleted message IDs, find a message in the conversation that
* is close to the set. Searching from the last selected message as a starting
* point.
*/
async function getNearbyMessageFromDeletedSet({
conversationId,
lastSelectedMessage,
deletedMessageIds,
storyId,
includeStoryReplies,
}: GetNearbyMessageFromDeletedSetOptionsType): Promise<string | null> {
const db = getInstance();
function runQuery(after: boolean) {
const dir = after ? sqlFragment`ASC` : sqlFragment`DESC`;
const compare = after ? sqlFragment`>` : sqlFragment`<`;
const { received_at, sent_at } = lastSelectedMessage;
const [query, params] = sql`
SELECT id FROM messages WHERE
conversationId = ${conversationId} AND
(${_storyIdPredicate(storyId, includeStoryReplies)}) AND
isStory IS 0 AND
id NOT IN (${sqlJoin(deletedMessageIds, ', ')}) AND
type IN ('incoming', 'outgoing')
AND (
(received_at = ${received_at} AND sent_at ${compare} ${sent_at}) OR
received_at ${compare} ${received_at}
)
ORDER BY received_at ${dir}, sent_at ${dir}
LIMIT 1
`;
return db.prepare(query).pluck().get(params);
}
const after = runQuery(true);
if (after != null) {
return after;
}
const before = runQuery(false);
if (before != null) {
return before;
}
return null;
}
function getLastConversationActivity({ function getLastConversationActivity({
conversationId, conversationId,
includeStoryReplies, includeStoryReplies,
@ -2844,22 +2921,18 @@ function getOldestUnseenMessageForConversation(
} }
): MessageMetricsType | undefined { ): MessageMetricsType | undefined {
const db = getInstance(); const db = getInstance();
const row = db
.prepare<Query>( const [query, params] = sql`
` SELECT received_at, sent_at, id FROM messages WHERE
SELECT received_at, sent_at, id FROM messages WHERE conversationId = ${conversationId} AND
conversationId = $conversationId AND seenStatus = ${SeenStatus.Unseen} AND
seenStatus = ${SeenStatus.Unseen} AND isStory IS 0 AND
isStory IS 0 AND (${_storyIdPredicate(storyId, includeStoryReplies)})
(${_storyIdPredicate(storyId, includeStoryReplies)}) ORDER BY received_at ASC, sent_at ASC
ORDER BY received_at ASC, sent_at ASC LIMIT 1;
LIMIT 1; `;
`
) const row = db.prepare(query).get(params);
.get({
conversationId,
storyId: storyId || null,
});
if (!row) { if (!row) {
return undefined; return undefined;
@ -2888,23 +2961,16 @@ function getTotalUnreadForConversationSync(
} }
): number { ): number {
const db = getInstance(); const db = getInstance();
const row = db const [query, params] = sql`
.prepare<Query>( SELECT count(1)
` FROM messages
SELECT count(1) WHERE
FROM messages conversationId = ${conversationId} AND
WHERE readStatus = ${ReadStatus.Unread} AND
conversationId = $conversationId AND isStory IS 0 AND
readStatus = ${ReadStatus.Unread} AND (${_storyIdPredicate(storyId, includeStoryReplies)})
isStory IS 0 AND `;
(${_storyIdPredicate(storyId, includeStoryReplies)}) const row = db.prepare(query).pluck().get(params);
`
)
.pluck()
.get({
conversationId,
storyId: storyId || null,
});
return row; return row;
} }
@ -2919,23 +2985,16 @@ function getTotalUnseenForConversationSync(
} }
): number { ): number {
const db = getInstance(); const db = getInstance();
const row = db const [query, params] = sql`
.prepare<Query>( SELECT count(1)
`
SELECT count(1)
FROM messages FROM messages
WHERE WHERE
conversationId = $conversationId AND conversationId = ${conversationId} AND
seenStatus = ${SeenStatus.Unseen} AND seenStatus = ${SeenStatus.Unseen} AND
isStory IS 0 AND isStory IS 0 AND
(${_storyIdPredicate(storyId, includeStoryReplies)}) (${_storyIdPredicate(storyId, includeStoryReplies)})
` `;
) const row = db.prepare(query).pluck().get(params);
.pluck()
.get({
conversationId,
storyId: storyId || null,
});
return row; return row;
} }
@ -3395,7 +3454,7 @@ async function getAllUnprocessedIds(): Promise<Array<string>> {
return db return db
.prepare<EmptyQuery>( .prepare<EmptyQuery>(
` `
SELECT id SELECT id
FROM unprocessed FROM unprocessed
ORDER BY receivedAtCounter ASC ORDER BY receivedAtCounter ASC
` `

View file

@ -35,6 +35,147 @@ export function jsonToObject<T>(json: string): T {
return JSON.parse(json); return JSON.parse(json);
} }
export type QueryTemplateParam = string | number | undefined;
export type QueryFragmentValue = QueryFragment | QueryTemplateParam;
export type QueryFragment = [
{ fragment: string },
ReadonlyArray<QueryTemplateParam>
];
/**
* You can use tagged template literals to build "fragments" of SQL queries
*
* ```ts
* const [query, params] = sql`
* SELECT * FROM examples
* WHERE groupId = ${groupId}
* ORDER BY timestamp ${asc ? sqlFragment`ASC` : sqlFragment`DESC`}
* `;
* ```
*
* SQL Fragments can contain other SQL fragments, but must be finalized with
* `sql` before being passed to `Database#prepare`.
*
* The name `sqlFragment` comes from several editors that support SQL syntax
* highlighting inside JavaScript template literals.
*/
export function sqlFragment(
strings: TemplateStringsArray,
...values: ReadonlyArray<QueryFragmentValue>
): QueryFragment {
let query = '';
const params: Array<string | number | undefined> = [];
strings.forEach((string, index) => {
const value = values[index];
query += string;
if (index < values.length) {
if (Array.isArray(value)) {
const [{ fragment }, fragmentParams] = value;
query += fragment;
params.push(...fragmentParams);
} else {
query += '?';
params.push(value);
}
}
});
return [{ fragment: query }, params];
}
/**
* Like `Array.prototype.join`, but for SQL fragments.
*/
export function sqlJoin(
items: ReadonlyArray<QueryFragmentValue>,
separator: string
): QueryFragment {
let query = '';
const params: Array<string | number | undefined> = [];
items.forEach((item, index) => {
const [{ fragment }, fragmentParams] = sqlFragment`${item}`;
query += fragment;
params.push(...fragmentParams);
if (index < items.length - 1) {
query += separator;
}
});
return [{ fragment: query }, params];
}
export type QueryTemplate = [
string,
ReadonlyArray<string | number | undefined>
];
/**
* You can use tagged template literals to build SQL queries
* that can be passed to `Database#prepare`.
*
* ```ts
* const [query, params] = sql`
* SELECT * FROM examples
* WHERE groupId = ${groupId}
* ORDER BY timestamp ASC
* `;
* db.prepare(query).all(params);
* ```
*
* SQL queries can contain other SQL fragments, but cannot contain other SQL
* queries.
*
* The name `sql` comes from several editors that support SQL syntax
* highlighting inside JavaScript template literals.
*/
export function sql(
strings: TemplateStringsArray,
...values: ReadonlyArray<QueryFragment | string | number | undefined>
): QueryTemplate {
const [{ fragment }, params] = sqlFragment(strings, ...values);
return [fragment, params];
}
type QueryPlanRow = Readonly<{
id: number;
parent: number;
details: string;
}>;
type QueryPlan = Readonly<{
query: string;
plan: ReadonlyArray<QueryPlanRow>;
}>;
/**
* Returns typed objects of the query plan for the given query.
*
*
* ```ts
* const [query, params] = sql`
* SELECT * FROM examples
* WHERE groupId = ${groupId}
* ORDER BY timestamp ASC
* `;
* log.info('Query plan', explainQueryPlan(db, [query, params]));
* db.prepare(query).all(params);
* ```
*/
export function explainQueryPlan(
db: Database,
template: QueryTemplate
): QueryPlan {
const [query, params] = template;
const plan = db.prepare(`EXPLAIN QUERY PLAN ${query}`).all(params);
return { query, plan };
}
// //
// Database helpers // Database helpers
// //

View file

@ -18,7 +18,7 @@ import type {
MessagesAddedActionType, MessagesAddedActionType,
MessageDeletedActionType, MessageDeletedActionType,
MessageChangedActionType, MessageChangedActionType,
SelectedConversationChangedActionType, TargetedConversationChangedActionType,
ConversationChangedActionType, ConversationChangedActionType,
} from './conversations'; } from './conversations';
import * as log from '../../logging/log'; import * as log from '../../logging/log';
@ -295,7 +295,7 @@ export function reducer(
| MessageDeletedActionType | MessageDeletedActionType
| MessageChangedActionType | MessageChangedActionType
| MessagesAddedActionType | MessagesAddedActionType
| SelectedConversationChangedActionType | TargetedConversationChangedActionType
> >
): AudioPlayerStateType { ): AudioPlayerStateType {
const { active } = state; const { active } = state;

View file

@ -77,12 +77,12 @@ import { enqueueReactionForSend } from '../../reactions/enqueueReactionForSend';
import { useBoundActions } from '../../hooks/useBoundActions'; import { useBoundActions } from '../../hooks/useBoundActions';
import { import {
CONVERSATION_UNLOADED, CONVERSATION_UNLOADED,
SELECTED_CONVERSATION_CHANGED, TARGETED_CONVERSATION_CHANGED,
scrollToMessage, scrollToMessage,
} from './conversations'; } from './conversations';
import type { import type {
ConversationUnloadedActionType, ConversationUnloadedActionType,
SelectedConversationChangedActionType, TargetedConversationChangedActionType,
ScrollToMessageActionType, ScrollToMessageActionType,
} from './conversations'; } from './conversations';
import { longRunningTaskWrapper } from '../../util/longRunningTaskWrapper'; import { longRunningTaskWrapper } from '../../util/longRunningTaskWrapper';
@ -204,7 +204,7 @@ type ComposerActionType =
| RemoveLinkPreviewActionType | RemoveLinkPreviewActionType
| ReplaceAttachmentsActionType | ReplaceAttachmentsActionType
| ResetComposerActionType | ResetComposerActionType
| SelectedConversationChangedActionType | TargetedConversationChangedActionType
| SetComposerDisabledStateActionType | SetComposerDisabledStateActionType
| SetFocusActionType | SetFocusActionType
| SetHighQualitySettingActionType | SetHighQualitySettingActionType
@ -1267,7 +1267,7 @@ export function reducer(
}; };
} }
if (action.type === SELECTED_CONVERSATION_CHANGED) { if (action.type === TARGETED_CONVERSATION_CHANGED) {
if (action.payload.conversationId) { if (action.payload.conversationId) {
return { return {
...state, ...state,

View file

@ -85,7 +85,7 @@ import {
ComposerStep, ComposerStep,
ConversationVerificationState, ConversationVerificationState,
OneTimeModalState, OneTimeModalState,
SelectedMessageSource, TargetedMessageSource,
} from './conversationsEnums'; } from './conversationsEnums';
import { markViewed as messageUpdaterMarkViewed } from '../../services/MessageUpdater'; import { markViewed as messageUpdaterMarkViewed } from '../../services/MessageUpdater';
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions'; import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
@ -148,6 +148,7 @@ import {
handleLeaveConversation, handleLeaveConversation,
} from './composer'; } from './composer';
import { ReceiptType } from '../../types/Receipt'; import { ReceiptType } from '../../types/Receipt';
import { sortByMessageOrder } from '../../util/maybeForwardMessages';
// State // State
@ -161,6 +162,10 @@ export type DBConversationType = ReadonlyDeep<{
export const InteractionModes = ['mouse', 'keyboard'] as const; export const InteractionModes = ['mouse', 'keyboard'] as const;
export type InteractionModeType = ReadonlyDeep<typeof InteractionModes[number]>; export type InteractionModeType = ReadonlyDeep<typeof InteractionModes[number]>;
export type MessageTimestamps = ReadonlyDeep<
Pick<MessageAttributesType, 'sent_at' | 'received_at'>
>;
// eslint-disable-next-line local-rules/type-alias-readonlydeep // eslint-disable-next-line local-rules/type-alias-readonlydeep
export type MessageType = MessageAttributesType & { export type MessageType = MessageAttributesType & {
interactionType?: InteractionModeType; interactionType?: InteractionModeType;
@ -438,11 +443,14 @@ export type ConversationsStateType = Readonly<{
conversationsByGroupId: ConversationLookupType; conversationsByGroupId: ConversationLookupType;
conversationsByUsername: ConversationLookupType; conversationsByUsername: ConversationLookupType;
selectedConversationId?: string; selectedConversationId?: string;
selectedMessage: string | undefined; targetedMessage: string | undefined;
selectedMessageCounter: number; targetedMessageCounter: number;
selectedMessageSource: SelectedMessageSource | undefined; targetedMessageSource: TargetedMessageSource | undefined;
selectedConversationPanels: ReadonlyArray<PanelRenderType>; targetedConversationPanels: ReadonlyArray<PanelRenderType>;
selectedMessageForDetails?: MessageAttributesType; targetedMessageForDetails?: MessageAttributesType;
lastSelectedMessage: MessageTimestamps | undefined;
selectedMessageIds: ReadonlyArray<string> | undefined;
showArchived: boolean; showArchived: boolean;
composer?: ComposerStateType; composer?: ComposerStateType;
@ -505,8 +513,8 @@ const CONVERSATION_STOPPED_BY_MISSING_VERIFICATION =
'conversations/CONVERSATION_STOPPED_BY_MISSING_VERIFICATION'; 'conversations/CONVERSATION_STOPPED_BY_MISSING_VERIFICATION';
const DISCARD_MESSAGES = 'conversations/DISCARD_MESSAGES'; const DISCARD_MESSAGES = 'conversations/DISCARD_MESSAGES';
const REPLACE_AVATARS = 'conversations/REPLACE_AVATARS'; const REPLACE_AVATARS = 'conversations/REPLACE_AVATARS';
export const SELECTED_CONVERSATION_CHANGED = export const TARGETED_CONVERSATION_CHANGED =
'conversations/SELECTED_CONVERSATION_CHANGED'; 'conversations/TARGETED_CONVERSATION_CHANGED';
const PUSH_PANEL = 'conversations/PUSH_PANEL'; const PUSH_PANEL = 'conversations/PUSH_PANEL';
const POP_PANEL = 'conversations/POP_PANEL'; const POP_PANEL = 'conversations/POP_PANEL';
export const MESSAGE_CHANGED = 'MESSAGE_CHANGED'; export const MESSAGE_CHANGED = 'MESSAGE_CHANGED';
@ -648,13 +656,27 @@ export type RemoveAllConversationsActionType = ReadonlyDeep<{
type: 'CONVERSATIONS_REMOVE_ALL'; type: 'CONVERSATIONS_REMOVE_ALL';
payload: null; payload: null;
}>; }>;
export type MessageSelectedActionType = ReadonlyDeep<{ export type MessageTargetedActionType = ReadonlyDeep<{
type: 'MESSAGE_SELECTED'; type: 'MESSAGE_TARGETED';
payload: { payload: {
messageId: string; messageId: string;
conversationId: string; conversationId: string;
}; };
}>; }>;
export type ToggleSelectMessagesActionType = ReadonlyDeep<{
type: 'TOGGLE_SELECT_MESSAGES';
payload: {
toggledMessageId: string;
messageIds: Array<string>;
selected: boolean;
};
}>;
export type ToggleSelectModeActionType = ReadonlyDeep<{
type: 'TOGGLE_SELECT_MODE';
payload: {
on: boolean;
};
}>;
type ConversationStoppedByMissingVerificationActionType = ReadonlyDeep<{ type ConversationStoppedByMissingVerificationActionType = ReadonlyDeep<{
type: typeof CONVERSATION_STOPPED_BY_MISSING_VERIFICATION; type: typeof CONVERSATION_STOPPED_BY_MISSING_VERIFICATION;
payload: { payload: {
@ -752,8 +774,8 @@ export type ScrollToMessageActionType = ReadonlyDeep<{
messageId: string; messageId: string;
}; };
}>; }>;
export type ClearSelectedMessageActionType = ReadonlyDeep<{ export type ClearTargetedMessageActionType = ReadonlyDeep<{
type: 'CLEAR_SELECTED_MESSAGE'; type: 'CLEAR_TARGETED_MESSAGE';
payload: null; payload: null;
}>; }>;
export type ClearUnreadMetricsActionType = ReadonlyDeep<{ export type ClearUnreadMetricsActionType = ReadonlyDeep<{
@ -762,8 +784,8 @@ export type ClearUnreadMetricsActionType = ReadonlyDeep<{
conversationId: string; conversationId: string;
}; };
}>; }>;
export type SelectedConversationChangedActionType = ReadonlyDeep<{ export type TargetedConversationChangedActionType = ReadonlyDeep<{
type: typeof SELECTED_CONVERSATION_CHANGED; type: typeof TARGETED_CONVERSATION_CHANGED;
payload: { payload: {
conversationId?: string; conversationId?: string;
messageId?: string; messageId?: string;
@ -865,7 +887,7 @@ export type ConversationActionType =
| ClearCancelledVerificationActionType | ClearCancelledVerificationActionType
| ClearGroupCreationErrorActionType | ClearGroupCreationErrorActionType
| ClearInvitedUuidsForNewlyCreatedGroupActionType | ClearInvitedUuidsForNewlyCreatedGroupActionType
| ClearSelectedMessageActionType | ClearTargetedMessageActionType
| ClearUnreadMetricsActionType | ClearUnreadMetricsActionType
| ClearVerificationDataByConversationActionType | ClearVerificationDataByConversationActionType
| CloseContactSpoofingReviewActionType | CloseContactSpoofingReviewActionType
@ -890,7 +912,7 @@ export type ConversationActionType =
| MessageDeletedActionType | MessageDeletedActionType
| MessageExpandedActionType | MessageExpandedActionType
| MessageExpiredActionType | MessageExpiredActionType
| MessageSelectedActionType | MessageTargetedActionType
| MessagesAddedActionType | MessagesAddedActionType
| MessagesResetActionType | MessagesResetActionType
| PopPanelActionType | PopPanelActionType
@ -902,7 +924,7 @@ export type ConversationActionType =
| ReviewGroupMemberNameCollisionActionType | ReviewGroupMemberNameCollisionActionType
| ReviewMessageRequestNameCollisionActionType | ReviewMessageRequestNameCollisionActionType
| ScrollToMessageActionType | ScrollToMessageActionType
| SelectedConversationChangedActionType | TargetedConversationChangedActionType
| SetComposeGroupAvatarActionType | SetComposeGroupAvatarActionType
| SetComposeGroupExpireTimerActionType | SetComposeGroupExpireTimerActionType
| SetComposeGroupNameActionType | SetComposeGroupNameActionType
@ -919,7 +941,9 @@ export type ConversationActionType =
| StartComposingActionType | StartComposingActionType
| StartSettingGroupMetadataActionType | StartSettingGroupMetadataActionType
| ToggleComposeEditingAvatarActionType | ToggleComposeEditingAvatarActionType
| ToggleConversationInChooseMembersActionType; | ToggleConversationInChooseMembersActionType
| ToggleSelectMessagesActionType
| ToggleSelectModeActionType;
// Action Creators // Action Creators
@ -938,7 +962,7 @@ export const actions = {
clearCancelledConversationVerification, clearCancelledConversationVerification,
clearGroupCreationError, clearGroupCreationError,
clearInvitedUuidsForNewlyCreatedGroup, clearInvitedUuidsForNewlyCreatedGroup,
clearSelectedMessage, clearTargetedMessage,
clearUnreadMetrics, clearUnreadMetrics,
closeContactSpoofingReview, closeContactSpoofingReview,
closeMaximumGroupSizeModal, closeMaximumGroupSizeModal,
@ -954,7 +978,7 @@ export const actions = {
createGroup, createGroup,
deleteAvatarFromDisk, deleteAvatarFromDisk,
deleteConversation, deleteConversation,
deleteMessage, deleteMessages,
deleteMessageForEveryone, deleteMessageForEveryone,
destroyMessages, destroyMessages,
discardMessages, discardMessages,
@ -1001,7 +1025,7 @@ export const actions = {
saveAttachmentFromMessage, saveAttachmentFromMessage,
saveAvatarToDisk, saveAvatarToDisk,
scrollToMessage, scrollToMessage,
selectMessage, targetMessage,
setAccessControlAddFromInviteLinkSetting, setAccessControlAddFromInviteLinkSetting,
setAccessControlAttributesSetting, setAccessControlAttributesSetting,
setAccessControlMembersSetting, setAccessControlMembersSetting,
@ -1033,6 +1057,8 @@ export const actions = {
toggleConversationInChooseMembers, toggleConversationInChooseMembers,
toggleGroupsForStorySend, toggleGroupsForStorySend,
toggleHideStories, toggleHideStories,
toggleSelectMessage,
toggleSelectMode,
unblurAvatar, unblurAvatar,
updateConversationModelSharedGroups, updateConversationModelSharedGroups,
updateGroupAttributes, updateGroupAttributes,
@ -1083,7 +1109,7 @@ function onUndoArchive(
void, void,
RootStateType, RootStateType,
unknown, unknown,
SelectedConversationChangedActionType TargetedConversationChangedActionType
> { > {
return (dispatch, getState) => { return (dispatch, getState) => {
const conversation = window.ConversationController.get(conversationId); const conversation = window.ConversationController.get(conversationId);
@ -1553,17 +1579,19 @@ function setPinned(
}; };
} }
function deleteMessage({ function deleteMessages({
conversationId, conversationId,
messageId, messageIds,
lastSelectedMessage,
}: { }: {
conversationId: string; conversationId: string;
messageId: string; messageIds: ReadonlyArray<string>;
lastSelectedMessage?: MessageTimestamps;
}): ThunkAction<void, RootStateType, unknown, NoopActionType> { }): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const message = await getMessageById(messageId); if (!messageIds || messageIds.length === 0) {
if (!message) { log.warn('deleteMessages: No message ids provided');
throw new Error(`deleteMessage: Message ${messageId} missing!`); return;
} }
const conversation = window.ConversationController.get(conversationId); const conversation = window.ConversationController.get(conversationId);
@ -1571,25 +1599,61 @@ function deleteMessage({
throw new Error('deleteMessage: No conversation found'); throw new Error('deleteMessage: No conversation found');
} }
const messageConversationId = message.get('conversationId'); let outgoingDeleted = 0;
if (conversationId !== messageConversationId) { let incomingDeleted = 0;
throw new Error(
`deleteMessage: message conversation ${messageConversationId} doesn't match provided conversation ${conversationId}` await Promise.all(
); messageIds.map(async messageId => {
const message = await getMessageById(messageId);
if (!message) {
throw new Error(`deleteMessages: Message ${messageId} missing!`);
}
const messageConversationId = message.get('conversationId');
if (conversationId !== messageConversationId) {
throw new Error(
`deleteMessages: message conversation ${messageConversationId} doesn't match provided conversation ${conversationId}`
);
}
if (isOutgoing(message.attributes)) {
outgoingDeleted += 1;
} else {
incomingDeleted += 1;
}
})
);
let nearbyMessageId: string | null = null;
if (nearbyMessageId == null && lastSelectedMessage != null) {
const foundMessageId =
await window.Signal.Data.getNearbyMessageFromDeletedSet({
conversationId,
lastSelectedMessage,
deletedMessageIds: messageIds,
includeStoryReplies: false,
storyId: undefined,
});
if (foundMessageId != null) {
nearbyMessageId = foundMessageId;
}
} }
void window.Signal.Data.removeMessage(messageId); await window.Signal.Data.removeMessages(messageIds);
if (isOutgoing(message.attributes)) {
conversation.decrementSentMessageCount(); if (outgoingDeleted > 0) {
} else { conversation.decrementSentMessageCount(outgoingDeleted);
conversation.decrementMessageCount(); }
if (incomingDeleted > 0) {
conversation.decrementMessageCount(incomingDeleted);
} }
popPanelForConversation()(dispatch, getState, undefined); popPanelForConversation()(dispatch, getState, undefined);
dispatch({ if (nearbyMessageId != null) {
type: 'NOOP', dispatch(scrollToMessage(conversationId, nearbyMessageId));
payload: null, }
});
}; };
} }
@ -2330,7 +2394,7 @@ function createGroup(
| CreateGroupPendingActionType | CreateGroupPendingActionType
| CreateGroupFulfilledActionType | CreateGroupFulfilledActionType
| CreateGroupRejectedActionType | CreateGroupRejectedActionType
| SelectedConversationChangedActionType | TargetedConversationChangedActionType
> { > {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const { composer } = getState().conversations; const { composer } = getState().conversations;
@ -2380,12 +2444,12 @@ function removeAllConversations(): RemoveAllConversationsActionType {
}; };
} }
function selectMessage( function targetMessage(
messageId: string, messageId: string,
conversationId: string conversationId: string
): MessageSelectedActionType { ): MessageTargetedActionType {
return { return {
type: 'MESSAGE_SELECTED', type: 'MESSAGE_TARGETED',
payload: { payload: {
messageId, messageId,
conversationId, conversationId,
@ -2393,6 +2457,79 @@ function selectMessage(
}; };
} }
function toggleSelectMessage(
conversationId: string,
messageId: string,
shift: boolean,
selected: boolean
): ThunkAction<void, RootStateType, unknown, ToggleSelectMessagesActionType> {
return async (dispatch, getState) => {
const state = getState();
const { conversations } = state;
let toggledMessageIds: ReadonlyArray<string>;
if (shift && conversations.lastSelectedMessage != null) {
if (conversationId !== conversations.selectedConversationId) {
throw new Error("toggleSelectMessage: conversationId doesn't match");
}
const conversation = window.ConversationController.get(conversationId);
if (conversation == null) {
throw new Error('toggleSelectMessage: conversation not found');
}
const toggledMessage = getOwn(conversations.messagesLookup, messageId);
strictAssert(
toggledMessage != null,
'toggleSelectMessage: toggled message not found'
);
// Sort the messages by their order in the conversation
const [after, before] = sortByMessageOrder(
[toggledMessage, conversations.lastSelectedMessage],
message => message
);
const betweenIds = await window.Signal.Data.getMessagesBetween(
conversationId,
{
after: {
sent_at: after.sent_at,
received_at: after.received_at,
},
before: {
sent_at: before.sent_at,
received_at: before.received_at,
},
includeStoryReplies: !isGroup(conversation.attributes),
}
);
toggledMessageIds = [messageId, ...betweenIds];
} else {
toggledMessageIds = [messageId];
}
dispatch({
type: 'TOGGLE_SELECT_MESSAGES',
payload: {
toggledMessageId: messageId,
messageIds: toggledMessageIds,
selected,
},
});
};
}
function toggleSelectMode(on: boolean): ToggleSelectModeActionType {
return {
type: 'TOGGLE_SELECT_MODE',
payload: { on },
};
}
function getProfilesForConversation(conversationId: string): NoopActionType { function getProfilesForConversation(conversationId: string): NoopActionType {
const conversation = window.ConversationController.get(conversationId); const conversation = window.ConversationController.get(conversationId);
if (!conversation) { if (!conversation) {
@ -2662,7 +2799,8 @@ function popPanelForConversation(): ThunkAction<
> { > {
return (dispatch, getState) => { return (dispatch, getState) => {
const { conversations } = getState(); const { conversations } = getState();
const { selectedConversationPanels } = conversations; const { targetedConversationPanels: selectedConversationPanels } =
conversations;
if (!selectedConversationPanels.length) { if (!selectedConversationPanels.length) {
return; return;
@ -3130,9 +3268,9 @@ function clearInvitedUuidsForNewlyCreatedGroup(): ClearInvitedUuidsForNewlyCreat
function clearGroupCreationError(): ClearGroupCreationErrorActionType { function clearGroupCreationError(): ClearGroupCreationErrorActionType {
return { type: 'CLEAR_GROUP_CREATION_ERROR' }; return { type: 'CLEAR_GROUP_CREATION_ERROR' };
} }
function clearSelectedMessage(): ClearSelectedMessageActionType { function clearTargetedMessage(): ClearTargetedMessageActionType {
return { return {
type: 'CLEAR_SELECTED_MESSAGE', type: 'CLEAR_TARGETED_MESSAGE',
payload: null, payload: null,
}; };
} }
@ -3523,7 +3661,7 @@ function showConversation({
void, void,
RootStateType, RootStateType,
unknown, unknown,
SelectedConversationChangedActionType TargetedConversationChangedActionType
> { > {
return (dispatch, getState) => { return (dispatch, getState) => {
const { conversations } = getState(); const { conversations } = getState();
@ -3543,7 +3681,7 @@ function showConversation({
} }
dispatch({ dispatch({
type: SELECTED_CONVERSATION_CHANGED, type: TARGETED_CONVERSATION_CHANGED,
payload: { payload: {
conversationId, conversationId,
messageId, messageId,
@ -3726,11 +3864,13 @@ export function getEmptyState(): ConversationsStateType {
verificationDataByConversation: {}, verificationDataByConversation: {},
messagesByConversation: {}, messagesByConversation: {},
messagesLookup: {}, messagesLookup: {},
selectedMessage: undefined, targetedMessage: undefined,
selectedMessageCounter: 0, targetedMessageCounter: 0,
selectedMessageSource: undefined, targetedMessageSource: undefined,
lastSelectedMessage: undefined,
selectedMessageIds: undefined,
showArchived: false, showArchived: false,
selectedConversationPanels: [], targetedConversationPanels: [],
}; };
} }
@ -3966,24 +4106,24 @@ function visitListsInVerificationData(
function maybeUpdateSelectedMessageForDetails( function maybeUpdateSelectedMessageForDetails(
{ {
messageId, messageId,
selectedMessageForDetails, targetedMessageForDetails,
}: { }: {
messageId: string; messageId: string;
selectedMessageForDetails: MessageAttributesType | undefined; targetedMessageForDetails: MessageAttributesType | undefined;
}, },
state: ConversationsStateType state: ConversationsStateType
): ConversationsStateType { ): ConversationsStateType {
if (!state.selectedMessageForDetails) { if (!state.targetedMessageForDetails) {
return state; return state;
} }
if (state.selectedMessageForDetails.id !== messageId) { if (state.targetedMessageForDetails.id !== messageId) {
return state; return state;
} }
return { return {
...state, ...state,
selectedMessageForDetails, targetedMessageForDetails,
}; };
} }
@ -4092,6 +4232,11 @@ export function reducer(
} }
if (action.type === DISCARD_MESSAGES) { if (action.type === DISCARD_MESSAGES) {
if (state.selectedMessageIds != null) {
log.info('Not discarding messages because we are in select mode');
return state;
}
const { conversationId } = action.payload; const { conversationId } = action.payload;
if ('numberToKeepAtBottom' in action.payload) { if ('numberToKeepAtBottom' in action.payload) {
const { numberToKeepAtBottom } = action.payload; const { numberToKeepAtBottom } = action.payload;
@ -4261,7 +4406,7 @@ export function reducer(
return { return {
...omit(state, 'contactSpoofingReview'), ...omit(state, 'contactSpoofingReview'),
selectedConversationId, selectedConversationId,
selectedConversationPanels: [], targetedConversationPanels: [],
messagesLookup: omit(state.messagesLookup, [...messageIds]), messagesLookup: omit(state.messagesLookup, [...messageIds]),
messagesByConversation: omit(state.messagesByConversation, [ messagesByConversation: omit(state.messagesByConversation, [
conversationId, conversationId,
@ -4311,7 +4456,7 @@ export function reducer(
}, },
}; };
} }
if (action.type === 'MESSAGE_SELECTED') { if (action.type === 'MESSAGE_TARGETED') {
const { messageId, conversationId } = action.payload; const { messageId, conversationId } = action.payload;
if (state.selectedConversationId !== conversationId) { if (state.selectedConversationId !== conversationId) {
@ -4320,9 +4465,44 @@ export function reducer(
return { return {
...state, ...state,
selectedMessage: messageId, targetedMessage: messageId,
selectedMessageCounter: state.selectedMessageCounter + 1, targetedMessageCounter: state.targetedMessageCounter + 1,
selectedMessageSource: SelectedMessageSource.Focus, targetedMessageSource: TargetedMessageSource.Focus,
};
}
if (action.type === 'TOGGLE_SELECT_MESSAGES') {
const { toggledMessageId, messageIds, selected } = action.payload;
let { selectedMessageIds = [] } = state;
if (selected) {
selectedMessageIds = selectedMessageIds.concat(messageIds);
} else {
selectedMessageIds = selectedMessageIds.filter(
id => !messageIds.includes(id)
);
}
const lastSelectedMessage = getOwn(state.messagesLookup, toggledMessageId);
strictAssert(lastSelectedMessage, 'Message not found in lookup');
return {
...state,
lastSelectedMessage: selected
? pick(lastSelectedMessage, 'sent_at', 'received_at')
: undefined,
selectedMessageIds,
};
}
if (action.type === 'TOGGLE_SELECT_MODE') {
const { on } = action.payload;
const { selectedMessageIds = [] } = state;
return {
...state,
lastSelectedMessage: undefined,
selectedMessageIds: on ? selectedMessageIds : undefined,
}; };
} }
@ -4521,7 +4701,7 @@ export function reducer(
// We don't keep track of messages unless their conversation is loaded... // We don't keep track of messages unless their conversation is loaded...
if (!existingConversation) { if (!existingConversation) {
return maybeUpdateSelectedMessageForDetails( return maybeUpdateSelectedMessageForDetails(
{ messageId: id, selectedMessageForDetails: data }, { messageId: id, targetedMessageForDetails: data },
state state
); );
} }
@ -4530,7 +4710,7 @@ export function reducer(
const existingMessage = getOwn(state.messagesLookup, id); const existingMessage = getOwn(state.messagesLookup, id);
if (!existingMessage) { if (!existingMessage) {
return maybeUpdateSelectedMessageForDetails( return maybeUpdateSelectedMessageForDetails(
{ messageId: id, selectedMessageForDetails: data }, { messageId: id, targetedMessageForDetails: data },
state state
); );
} }
@ -4547,7 +4727,7 @@ export function reducer(
...maybeUpdateSelectedMessageForDetails( ...maybeUpdateSelectedMessageForDetails(
{ {
messageId: id, messageId: id,
selectedMessageForDetails: data, targetedMessageForDetails: data,
}, },
state state
), ),
@ -4571,7 +4751,7 @@ export function reducer(
if (action.type === MESSAGE_EXPIRED) { if (action.type === MESSAGE_EXPIRED) {
return maybeUpdateSelectedMessageForDetails( return maybeUpdateSelectedMessageForDetails(
{ messageId: action.payload.id, selectedMessageForDetails: undefined }, { messageId: action.payload.id, targetedMessageForDetails: undefined },
state state
); );
} }
@ -4638,9 +4818,9 @@ export function reducer(
...state, ...state,
...(state.selectedConversationId === conversationId ...(state.selectedConversationId === conversationId
? { ? {
selectedMessage: scrollToMessageId, targetedMessage: scrollToMessageId,
selectedMessageCounter: state.selectedMessageCounter + 1, targetedMessageCounter: state.targetedMessageCounter + 1,
selectedMessageSource: SelectedMessageSource.Reset, targetedMessageSource: TargetedMessageSource.Reset,
} }
: {}), : {}),
messagesLookup: { messagesLookup: {
@ -4731,9 +4911,9 @@ export function reducer(
return { return {
...state, ...state,
selectedMessage: messageId, targetedMessage: messageId,
selectedMessageCounter: state.selectedMessageCounter + 1, targetedMessageCounter: state.targetedMessageCounter + 1,
selectedMessageSource: SelectedMessageSource.NavigateToMessage, targetedMessageSource: TargetedMessageSource.NavigateToMessage,
messagesByConversation: { messagesByConversation: {
...messagesByConversation, ...messagesByConversation,
[conversationId]: { [conversationId]: {
@ -4753,7 +4933,7 @@ export function reducer(
const existingConversation = messagesByConversation[conversationId]; const existingConversation = messagesByConversation[conversationId];
if (!existingConversation) { if (!existingConversation) {
return maybeUpdateSelectedMessageForDetails( return maybeUpdateSelectedMessageForDetails(
{ messageId: id, selectedMessageForDetails: undefined }, { messageId: id, targetedMessageForDetails: undefined },
state state
); );
} }
@ -4800,7 +4980,7 @@ export function reducer(
return { return {
...maybeUpdateSelectedMessageForDetails( ...maybeUpdateSelectedMessageForDetails(
{ messageId: id, selectedMessageForDetails: undefined }, { messageId: id, targetedMessageForDetails: undefined },
state state
), ),
messagesLookup: omit(messagesLookup, id), messagesLookup: omit(messagesLookup, id),
@ -5021,12 +5201,12 @@ export function reducer(
}, },
}; };
} }
if (action.type === 'CLEAR_SELECTED_MESSAGE') { if (action.type === 'CLEAR_TARGETED_MESSAGE') {
return { return {
...state, ...state,
selectedMessage: undefined, targetedMessage: undefined,
selectedMessageCounter: 0, targetedMessageCounter: 0,
selectedMessageSource: undefined, targetedMessageSource: undefined,
}; };
} }
if (action.type === 'CLEAR_UNREAD_METRICS') { if (action.type === 'CLEAR_UNREAD_METRICS') {
@ -5053,15 +5233,15 @@ export function reducer(
}, },
}; };
} }
if (action.type === SELECTED_CONVERSATION_CHANGED) { if (action.type === TARGETED_CONVERSATION_CHANGED) {
const { payload } = action; const { payload } = action;
const { conversationId, messageId, switchToAssociatedView } = payload; const { conversationId, messageId, switchToAssociatedView } = payload;
const nextState = { const nextState = {
...omit(state, 'contactSpoofingReview'), ...omit(state, 'contactSpoofingReview'),
selectedConversationId: conversationId, selectedConversationId: conversationId,
selectedMessage: messageId, targetedMessage: messageId,
selectedMessageSource: SelectedMessageSource.NavigateToMessage, targetedMessageSource: TargetedMessageSource.NavigateToMessage,
}; };
if (switchToAssociatedView && conversationId) { if (switchToAssociatedView && conversationId) {
@ -5070,7 +5250,7 @@ export function reducer(
return nextState; return nextState;
} }
return { return {
...omit(nextState, 'composer'), ...omit(nextState, 'composer', 'selectedMessageIds'),
showArchived: Boolean(conversation.isArchived), showArchived: Boolean(conversation.isArchived),
}; };
} }
@ -5094,25 +5274,25 @@ export function reducer(
if (action.payload.type === PanelType.MessageDetails) { if (action.payload.type === PanelType.MessageDetails) {
return { return {
...state, ...state,
selectedConversationPanels: [ targetedConversationPanels: [
...state.selectedConversationPanels, ...state.targetedConversationPanels,
action.payload, action.payload,
], ],
selectedMessageForDetails: action.payload.args.message, targetedMessageForDetails: action.payload.args.message,
}; };
} }
return { return {
...state, ...state,
selectedConversationPanels: [ targetedConversationPanels: [
...state.selectedConversationPanels, ...state.targetedConversationPanels,
action.payload, action.payload,
], ],
}; };
} }
if (action.type === POP_PANEL) { if (action.type === POP_PANEL) {
const { selectedConversationPanels } = state; const { targetedConversationPanels: selectedConversationPanels } = state;
const nextPanels = [...selectedConversationPanels]; const nextPanels = [...selectedConversationPanels];
const panel = nextPanels.pop(); const panel = nextPanels.pop();
@ -5123,14 +5303,14 @@ export function reducer(
if (panel.type === PanelType.MessageDetails) { if (panel.type === PanelType.MessageDetails) {
return { return {
...state, ...state,
selectedConversationPanels: nextPanels, targetedConversationPanels: nextPanels,
selectedMessageForDetails: undefined, targetedMessageForDetails: undefined,
}; };
} }
return { return {
...state, ...state,
selectedConversationPanels: nextPanels, targetedConversationPanels: nextPanels,
}; };
} }

View file

@ -24,7 +24,7 @@ export enum ConversationVerificationState {
VerificationCancelled = 'VerificationCancelled', VerificationCancelled = 'VerificationCancelled',
} }
export enum SelectedMessageSource { export enum TargetedMessageSource {
Reset = 'Reset', Reset = 'Reset',
NavigateToMessage = 'NavigateToMessage', NavigateToMessage = 'NavigateToMessage',
Focus = 'Focus', Focus = 'Focus',

View file

@ -32,6 +32,10 @@ import type { ShowToastActionType } from './toast';
export type ForwardMessagePropsType = ReadonlyDeep< export type ForwardMessagePropsType = ReadonlyDeep<
Omit<PropsForMessage, 'renderingContext' | 'menu' | 'contextMenu'> Omit<PropsForMessage, 'renderingContext' | 'menu' | 'contextMenu'>
>; >;
export type ForwardMessagesPropsType = ReadonlyDeep<{
messages: Array<ForwardMessagePropsType>;
onForward?: () => void;
}>;
export type SafetyNumberChangedBlockingDataType = ReadonlyDeep<{ export type SafetyNumberChangedBlockingDataType = ReadonlyDeep<{
promiseUuid: UUIDStringType; promiseUuid: UUIDStringType;
source?: SafetyNumberChangeSource; source?: SafetyNumberChangeSource;
@ -54,7 +58,7 @@ export type GlobalModalsStateType = ReadonlyDeep<{
description?: string; description?: string;
title?: string; title?: string;
}; };
forwardMessageProps?: ForwardMessagePropsType; forwardMessagesProps?: ForwardMessagesPropsType;
gv2MigrationProps?: MigrateToGV2PropsType; gv2MigrationProps?: MigrateToGV2PropsType;
isProfileEditorVisible: boolean; isProfileEditorVisible: boolean;
isSignalConnectionsVisible: boolean; isSignalConnectionsVisible: boolean;
@ -80,8 +84,8 @@ const HIDE_UUID_NOT_FOUND_MODAL = 'globalModals/HIDE_UUID_NOT_FOUND_MODAL';
const SHOW_UUID_NOT_FOUND_MODAL = 'globalModals/SHOW_UUID_NOT_FOUND_MODAL'; const SHOW_UUID_NOT_FOUND_MODAL = 'globalModals/SHOW_UUID_NOT_FOUND_MODAL';
const SHOW_STORIES_SETTINGS = 'globalModals/SHOW_STORIES_SETTINGS'; const SHOW_STORIES_SETTINGS = 'globalModals/SHOW_STORIES_SETTINGS';
const HIDE_STORIES_SETTINGS = 'globalModals/HIDE_STORIES_SETTINGS'; const HIDE_STORIES_SETTINGS = 'globalModals/HIDE_STORIES_SETTINGS';
const TOGGLE_FORWARD_MESSAGE_MODAL = const TOGGLE_FORWARD_MESSAGES_MODAL =
'globalModals/TOGGLE_FORWARD_MESSAGE_MODAL'; 'globalModals/TOGGLE_FORWARD_MESSAGES_MODAL';
const TOGGLE_PROFILE_EDITOR = 'globalModals/TOGGLE_PROFILE_EDITOR'; const TOGGLE_PROFILE_EDITOR = 'globalModals/TOGGLE_PROFILE_EDITOR';
export const TOGGLE_PROFILE_EDITOR_ERROR = export const TOGGLE_PROFILE_EDITOR_ERROR =
'globalModals/TOGGLE_PROFILE_EDITOR_ERROR'; 'globalModals/TOGGLE_PROFILE_EDITOR_ERROR';
@ -149,9 +153,9 @@ export type ShowUserNotFoundModalActionType = ReadonlyDeep<{
payload: UserNotFoundModalStateType; payload: UserNotFoundModalStateType;
}>; }>;
type ToggleForwardMessageModalActionType = ReadonlyDeep<{ type ToggleForwardMessagesModalActionType = ReadonlyDeep<{
type: typeof TOGGLE_FORWARD_MESSAGE_MODAL; type: typeof TOGGLE_FORWARD_MESSAGES_MODAL;
payload: ForwardMessagePropsType | undefined; payload: ForwardMessagesPropsType | undefined;
}>; }>;
type ToggleProfileEditorActionType = ReadonlyDeep<{ type ToggleProfileEditorActionType = ReadonlyDeep<{
@ -273,7 +277,7 @@ export type GlobalModalsActionType = ReadonlyDeep<
| ConfirmAuthArtCreatorPendingActionType | ConfirmAuthArtCreatorPendingActionType
| ConfirmAuthArtCreatorFulfilledActionType | ConfirmAuthArtCreatorFulfilledActionType
| ShowAuthArtCreatorActionType | ShowAuthArtCreatorActionType
| ToggleForwardMessageModalActionType | ToggleForwardMessagesModalActionType
| ToggleProfileEditorActionType | ToggleProfileEditorActionType
| ToggleProfileEditorErrorActionType | ToggleProfileEditorErrorActionType
| ToggleSafetyNumberModalActionType | ToggleSafetyNumberModalActionType
@ -294,7 +298,7 @@ export const actions = {
showStoriesSettings, showStoriesSettings,
hideBlockingSafetyNumberChangeDialog, hideBlockingSafetyNumberChangeDialog,
showBlockingSafetyNumberChangeDialog, showBlockingSafetyNumberChangeDialog,
toggleForwardMessageModal, toggleForwardMessagesModal,
toggleProfileEditor, toggleProfileEditor,
toggleProfileEditorHasError, toggleProfileEditorHasError,
toggleSafetyNumberModal, toggleSafetyNumberModal,
@ -422,37 +426,44 @@ function closeGV2MigrationDialog(): CloseGV2MigrationDialogActionType {
}; };
} }
function toggleForwardMessageModal( function toggleForwardMessagesModal(
messageId?: string messageIds?: ReadonlyArray<string>,
onForward?: () => void
): ThunkAction< ): ThunkAction<
void, void,
RootStateType, RootStateType,
unknown, unknown,
ToggleForwardMessageModalActionType ToggleForwardMessagesModalActionType
> { > {
return async (dispatch, getState) => { return async (dispatch, getState) => {
if (!messageId) { if (!messageIds) {
dispatch({ dispatch({
type: TOGGLE_FORWARD_MESSAGE_MODAL, type: TOGGLE_FORWARD_MESSAGES_MODAL,
payload: undefined, payload: undefined,
}); });
return; return;
} }
const message = await getMessageById(messageId); const messagesProps = await Promise.all(
messageIds.map(async messageId => {
const message = await getMessageById(messageId);
if (!message) { if (!message) {
throw new Error( throw new Error(
`toggleForwardMessageModal: no message found for ${messageId}` `toggleForwardMessagesModal: no message found for ${messageId}`
); );
} }
const messagePropsSelector = getMessagePropsSelector(getState()); const messagePropsSelector = getMessagePropsSelector(getState());
const messageProps = messagePropsSelector(message.attributes); const messageProps = messagePropsSelector(message.attributes);
return messageProps;
})
);
dispatch({ dispatch({
type: TOGGLE_FORWARD_MESSAGE_MODAL, type: TOGGLE_FORWARD_MESSAGES_MODAL,
payload: messageProps, payload: { messages: messagesProps, onForward },
}); });
}; };
} }
@ -737,10 +748,10 @@ export function reducer(
}; };
} }
if (action.type === TOGGLE_FORWARD_MESSAGE_MODAL) { if (action.type === TOGGLE_FORWARD_MESSAGES_MODAL) {
return { return {
...state, ...state,
forwardMessageProps: action.payload, forwardMessagesProps: action.payload,
}; };
} }

View file

@ -21,7 +21,7 @@ import type {
MessageDeletedActionType, MessageDeletedActionType,
MessageType, MessageType,
RemoveAllConversationsActionType, RemoveAllConversationsActionType,
SelectedConversationChangedActionType, TargetedConversationChangedActionType,
ShowArchivedConversationsActionType, ShowArchivedConversationsActionType,
} from './conversations'; } from './conversations';
import { getQuery, getSearchConversation } from '../selectors/search'; import { getQuery, getSearchConversation } from '../selectors/search';
@ -34,7 +34,7 @@ import {
import { strictAssert } from '../../util/assert'; import { strictAssert } from '../../util/assert';
import { import {
CONVERSATION_UNLOADED, CONVERSATION_UNLOADED,
SELECTED_CONVERSATION_CHANGED, TARGETED_CONVERSATION_CHANGED,
} from './conversations'; } from './conversations';
const { const {
@ -64,7 +64,7 @@ export type SearchStateType = ReadonlyDeep<{
messageIds: Array<string>; messageIds: Array<string>;
// We do store message data to pass through the selector // We do store message data to pass through the selector
messageLookup: MessageSearchResultLookupType; messageLookup: MessageSearchResultLookupType;
selectedMessage?: string; targetedMessage?: string;
// Loading state // Loading state
discussionsLoading: boolean; discussionsLoading: boolean;
messagesLoading: boolean; messagesLoading: boolean;
@ -120,7 +120,7 @@ export type SearchActionType = ReadonlyDeep<
| SearchInConversationActionType | SearchInConversationActionType
| MessageDeletedActionType | MessageDeletedActionType
| RemoveAllConversationsActionType | RemoveAllConversationsActionType
| SelectedConversationChangedActionType | TargetedConversationChangedActionType
| ShowArchivedConversationsActionType | ShowArchivedConversationsActionType
| ConversationUnloadedActionType | ConversationUnloadedActionType
>; >;
@ -444,7 +444,7 @@ export function reducer(
return getEmptyState(); return getEmptyState();
} }
if (action.type === SELECTED_CONVERSATION_CHANGED) { if (action.type === TARGETED_CONVERSATION_CHANGED) {
const { payload } = action; const { payload } = action;
const { conversationId, messageId } = payload; const { conversationId, messageId } = payload;
const { searchConversationId } = state; const { searchConversationId } = state;
@ -455,7 +455,7 @@ export function reducer(
return { return {
...state, ...state,
selectedMessage: messageId, targetedMessage: messageId,
}; };
} }

View file

@ -13,7 +13,7 @@ import type {
MessageChangedActionType, MessageChangedActionType,
MessageDeletedActionType, MessageDeletedActionType,
MessagesAddedActionType, MessagesAddedActionType,
SelectedConversationChangedActionType, TargetedConversationChangedActionType,
} from './conversations'; } from './conversations';
import type { NoopActionType } from './noop'; import type { NoopActionType } from './noop';
import type { StateType as RootStateType } from '../reducer'; import type { StateType as RootStateType } from '../reducer';
@ -21,7 +21,7 @@ import type { StoryViewTargetType, StoryViewType } from '../../types/Stories';
import type { SyncType } from '../../jobs/helpers/syncHelpers'; import type { SyncType } from '../../jobs/helpers/syncHelpers';
import type { UUIDStringType } from '../../types/UUID'; import type { UUIDStringType } from '../../types/UUID';
import * as log from '../../logging/log'; import * as log from '../../logging/log';
import { SELECTED_CONVERSATION_CHANGED } from './conversations'; import { TARGETED_CONVERSATION_CHANGED } from './conversations';
import { SIGNAL_ACI } from '../../types/SignalConversation'; import { SIGNAL_ACI } from '../../types/SignalConversation';
import dataInterface from '../../sql/Client'; import dataInterface from '../../sql/Client';
import { ReadStatus } from '../../messages/MessageReadStatus'; import { ReadStatus } from '../../messages/MessageReadStatus';
@ -265,7 +265,7 @@ export type StoriesActionType =
| ViewStoryActionType | ViewStoryActionType
| StoryReplyDeletedActionType | StoryReplyDeletedActionType
| RemoveAllStoriesActionType | RemoveAllStoriesActionType
| SelectedConversationChangedActionType | TargetedConversationChangedActionType
| SetAddStoryDataType | SetAddStoryDataType
| SetStorySendingType | SetStorySendingType
| SetHasAllStoriesUnmutedType; | SetHasAllStoriesUnmutedType;
@ -1781,7 +1781,7 @@ export function reducer(
}; };
} }
if (action.type === SELECTED_CONVERSATION_CHANGED) { if (action.type === TARGETED_CONVERSATION_CHANGED) {
return { return {
...state, ...state,
lastOpenedAtTimestamp: state.openedAtTimestamp || Date.now(), lastOpenedAtTimestamp: state.openedAtTimestamp || Date.now(),

View file

@ -64,6 +64,10 @@ export type ShowToastActionCreatorType = ReadonlyDeep<
) => ShowToastActionType ) => ShowToastActionType
>; >;
export type ShowToastAction = ReadonlyDeep<
(toastType: ToastType, parameters?: ReplacementValuesType) => void
>;
export const showToast: ShowToastActionCreatorType = ( export const showToast: ShowToastActionCreatorType = (
toastType, toastType,
parameters parameters

View file

@ -15,6 +15,7 @@ import type {
ConversationVerificationData, ConversationVerificationData,
MessageLookupType, MessageLookupType,
MessagesByConversationType, MessagesByConversationType,
MessageTimestamps,
PreJoinConversationType, PreJoinConversationType,
} from '../ducks/conversations'; } from '../ducks/conversations';
import type { StoriesStateType, StoryDataType } from '../ducks/stories'; import type { StoriesStateType, StoryDataType } from '../ducks/stories';
@ -151,23 +152,35 @@ export const getSelectedConversationId = createSelector(
} }
); );
type SelectedMessageType = { type TargetedMessageType = {
id: string; id: string;
counter: number; counter: number;
}; };
export const getSelectedMessage = createSelector( export const getTargetedMessage = createSelector(
getConversations, getConversations,
(state: ConversationsStateType): SelectedMessageType | undefined => { (state: ConversationsStateType): TargetedMessageType | undefined => {
if (!state.selectedMessage) { if (!state.targetedMessage) {
return undefined; return undefined;
} }
return { return {
id: state.selectedMessage, id: state.targetedMessage,
counter: state.selectedMessageCounter, counter: state.targetedMessageCounter,
}; };
} }
); );
export const getSelectedMessageIds = createSelector(
getConversations,
(state: ConversationsStateType): ReadonlyArray<string> | undefined => {
return state.selectedMessageIds;
}
);
export const getLastSelectedMessage = createSelector(
getConversations,
(state: ConversationsStateType): MessageTimestamps | undefined => {
return state.lastSelectedMessage;
}
);
export const getShowArchived = createSelector( export const getShowArchived = createSelector(
getConversations, getConversations,
@ -1095,8 +1108,8 @@ export const getHideStoryConversationIds = createSelector(
export const getTopPanel = createSelector( export const getTopPanel = createSelector(
getConversations, getConversations,
(conversations): PanelRenderType | undefined => (conversations): PanelRenderType | undefined =>
conversations.selectedConversationPanels[ conversations.targetedConversationPanels[
conversations.selectedConversationPanels.length - 1 conversations.targetedConversationPanels.length - 1
] ]
); );

View file

@ -66,7 +66,8 @@ import { getAccountSelector } from './accounts';
import { import {
getContactNameColorSelector, getContactNameColorSelector,
getConversationSelector, getConversationSelector,
getSelectedMessage, getSelectedMessageIds,
getTargetedMessage,
isMissingRequiredProfileSharing, isMissingRequiredProfileSharing,
} from './conversations'; } from './conversations';
import { import {
@ -146,8 +147,9 @@ export type GetPropsForBubbleOptions = Readonly<{
ourNumber?: string; ourNumber?: string;
ourACI?: UUIDStringType; ourACI?: UUIDStringType;
ourPNI?: UUIDStringType; ourPNI?: UUIDStringType;
selectedMessageId?: string; targetedMessageId?: string;
selectedMessageCounter?: number; targetedMessageCounter?: number;
selectedMessageIds: ReadonlyArray<string> | undefined;
regionCode?: string; regionCode?: string;
callSelector: CallSelectorType; callSelector: CallSelectorType;
activeCall?: CallStateType; activeCall?: CallStateType;
@ -550,8 +552,9 @@ export type GetPropsForMessageOptions = Pick<
| 'ourACI' | 'ourACI'
| 'ourPNI' | 'ourPNI'
| 'ourNumber' | 'ourNumber'
| 'selectedMessageId' | 'targetedMessageId'
| 'selectedMessageCounter' | 'targetedMessageCounter'
| 'selectedMessageIds'
| 'regionCode' | 'regionCode'
| 'accountSelector' | 'accountSelector'
| 'contactNameColorSelector' | 'contactNameColorSelector'
@ -645,8 +648,9 @@ export const getPropsForMessage = (
ourNumber, ourNumber,
ourACI, ourACI,
regionCode, regionCode,
selectedMessageId, targetedMessageId,
selectedMessageCounter, targetedMessageCounter,
selectedMessageIds,
contactNameColorSelector, contactNameColorSelector,
} = options; } = options;
@ -661,7 +665,9 @@ export const getPropsForMessage = (
const isMessageTapToView = isTapToView(message); const isMessageTapToView = isTapToView(message);
const isSelected = message.id === selectedMessageId; const isTargeted = message.id === targetedMessageId;
const isSelected = selectedMessageIds?.includes(message.id) ?? false;
const isSelectMode = selectedMessageIds != null;
const selectedReaction = ( const selectedReaction = (
(message.reactions || []).find(re => re.fromId === ourConversationId) || {} (message.reactions || []).find(re => re.fromId === ourConversationId) || {}
@ -713,8 +719,10 @@ export const getPropsForMessage = (
id: message.id, id: message.id,
isBlocked: conversation.isBlocked || false, isBlocked: conversation.isBlocked || false,
isMessageRequestAccepted: conversation?.acceptedMessageRequest ?? true, isMessageRequestAccepted: conversation?.acceptedMessageRequest ?? true,
isTargeted,
isTargetedCounter: isTargeted ? targetedMessageCounter : undefined,
isSelected, isSelected,
isSelectedCounter: isSelected ? selectedMessageCounter : undefined, isSelectMode,
isSticker: Boolean(sticker), isSticker: Boolean(sticker),
isTapToView: isMessageTapToView, isTapToView: isMessageTapToView,
isTapToViewError: isTapToViewError:
@ -742,7 +750,8 @@ export const getMessagePropsSelector = createSelector(
getRegionCode, getRegionCode,
getAccountSelector, getAccountSelector,
getContactNameColorSelector, getContactNameColorSelector,
getSelectedMessage, getTargetedMessage,
getSelectedMessageIds,
( (
conversationSelector, conversationSelector,
ourConversationId, ourConversationId,
@ -752,7 +761,8 @@ export const getMessagePropsSelector = createSelector(
regionCode, regionCode,
accountSelector, accountSelector,
contactNameColorSelector, contactNameColorSelector,
selectedMessage targetedMessage,
selectedMessageIds
) => ) =>
(message: MessageWithUIFieldsType) => { (message: MessageWithUIFieldsType) => {
return getPropsForMessage(message, { return getPropsForMessage(message, {
@ -764,8 +774,9 @@ export const getMessagePropsSelector = createSelector(
ourACI, ourACI,
ourPNI, ourPNI,
regionCode, regionCode,
selectedMessageCounter: selectedMessage?.counter, targetedMessageCounter: targetedMessage?.counter,
selectedMessageId: selectedMessage?.id, targetedMessageId: targetedMessage?.id,
selectedMessageIds,
}); });
} }
); );
@ -1794,10 +1805,10 @@ export function getLastChallengeError(
return challengeErrors.pop(); return challengeErrors.pop();
} }
const getSelectedMessageForDetails = ( const getTargetedMessageForDetails = (
state: StateType state: StateType
): MessageAttributesType | undefined => ): MessageAttributesType | undefined =>
state.conversations.selectedMessageForDetails; state.conversations.targetedMessageForDetails;
const OUTGOING_KEY_ERROR = 'OutgoingIdentityKeyError'; const OUTGOING_KEY_ERROR = 'OutgoingIdentityKeyError';
@ -1807,11 +1818,12 @@ export const getMessageDetails = createSelector(
getConversationSelector, getConversationSelector,
getIntl, getIntl,
getRegionCode, getRegionCode,
getSelectedMessageForDetails, getTargetedMessageForDetails,
getUserACI, getUserACI,
getUserPNI, getUserPNI,
getUserConversationId, getUserConversationId,
getUserNumber, getUserNumber,
getSelectedMessageIds,
( (
accountSelector, accountSelector,
contactNameColorSelector, contactNameColorSelector,
@ -1822,7 +1834,8 @@ export const getMessageDetails = createSelector(
ourACI, ourACI,
ourPNI, ourPNI,
ourConversationId, ourConversationId,
ourNumber ourNumber,
selectedMessageIds
): SmartMessageDetailPropsType | undefined => { ): SmartMessageDetailPropsType | undefined => {
if (!message || !ourConversationId) { if (!message || !ourConversationId) {
return; return;
@ -1957,6 +1970,7 @@ export const getMessageDetails = createSelector(
ourNumber, ourNumber,
ourPNI, ourPNI,
regionCode, regionCode,
selectedMessageIds,
}), }),
receivedAt: Number(message.received_at_ms || message.received_at), receivedAt: Number(message.received_at_ms || message.received_at),
}; };

View file

@ -41,7 +41,7 @@ export const getQuery = createSelector(
export const getSelectedMessage = createSelector( export const getSelectedMessage = createSelector(
getSearch, getSearch,
(state: SearchStateType): string | undefined => state.selectedMessage (state: SearchStateType): string | undefined => state.targetedMessage
); );
const getSearchConversationId = createSelector( const getSearchConversationId = createSelector(
@ -156,7 +156,7 @@ type CachedMessageSearchResultSelectorType = (
from: ConversationType, from: ConversationType,
to: ConversationType, to: ConversationType,
searchConversationId?: string, searchConversationId?: string,
selectedMessageId?: string targetedMessageId?: string
) => MessageSearchResultPropsDataType; ) => MessageSearchResultPropsDataType;
export const getCachedSelectorForMessageSearchResult = createSelector( export const getCachedSelectorForMessageSearchResult = createSelector(
@ -174,7 +174,7 @@ export const getCachedSelectorForMessageSearchResult = createSelector(
from: ConversationType, from: ConversationType,
to: ConversationType, to: ConversationType,
searchConversationId?: string, searchConversationId?: string,
selectedMessageId?: string targetedMessageId?: string
) => { ) => {
const bodyRanges = message.bodyRanges || []; const bodyRanges = message.bodyRanges || [];
return { return {
@ -199,7 +199,7 @@ export const getCachedSelectorForMessageSearchResult = createSelector(
body: message.body || '', body: message.body || '',
isSelected: Boolean( isSelected: Boolean(
selectedMessageId && message.id === selectedMessageId targetedMessageId && message.id === targetedMessageId
), ),
isSearchingInConversation: Boolean(searchConversationId), isSearchingInConversation: Boolean(searchConversationId),
}; };
@ -223,7 +223,7 @@ export const getMessageSearchResultSelector = createSelector(
( (
messageSearchResultSelector: CachedMessageSearchResultSelectorType, messageSearchResultSelector: CachedMessageSearchResultSelectorType,
messageSearchResultLookup: MessageSearchResultLookupType, messageSearchResultLookup: MessageSearchResultLookupType,
selectedMessageId: string | undefined, targetedMessageId: string | undefined,
conversationSelector: GetConversationByIdType, conversationSelector: GetConversationByIdType,
searchConversationId: string | undefined, searchConversationId: string | undefined,
ourConversationId: string | undefined ourConversationId: string | undefined
@ -260,7 +260,7 @@ export const getMessageSearchResultSelector = createSelector(
from, from,
to, to,
searchConversationId, searchConversationId,
selectedMessageId targetedMessageId
); );
}; };
} }

View file

@ -7,8 +7,9 @@ import type { StateType } from '../reducer';
import { import {
getContactNameColorSelector, getContactNameColorSelector,
getConversationSelector, getConversationSelector,
getSelectedMessage, getTargetedMessage,
getMessages, getMessages,
getSelectedMessageIds,
} from './conversations'; } from './conversations';
import { getAccountSelector } from './accounts'; import { getAccountSelector } from './accounts';
import { import {
@ -36,7 +37,7 @@ export const getTimelineItem = (
return undefined; return undefined;
} }
const selectedMessage = getSelectedMessage(state); const targetedMessage = getTargetedMessage(state);
const conversationSelector = getConversationSelector(state); const conversationSelector = getConversationSelector(state);
const regionCode = getRegionCode(state); const regionCode = getRegionCode(state);
const ourNumber = getUserNumber(state); const ourNumber = getUserNumber(state);
@ -47,6 +48,7 @@ export const getTimelineItem = (
const activeCall = getActiveCall(state); const activeCall = getActiveCall(state);
const accountSelector = getAccountSelector(state); const accountSelector = getAccountSelector(state);
const contactNameColorSelector = getContactNameColorSelector(state); const contactNameColorSelector = getContactNameColorSelector(state);
const selectedMessageIds = getSelectedMessageIds(state);
return getPropsForBubble(message, { return getPropsForBubble(message, {
conversationSelector, conversationSelector,
@ -55,11 +57,12 @@ export const getTimelineItem = (
ourACI, ourACI,
ourPNI, ourPNI,
regionCode, regionCode,
selectedMessageId: selectedMessage?.id, targetedMessageId: targetedMessage?.id,
selectedMessageCounter: selectedMessage?.counter, targetedMessageCounter: targetedMessage?.counter,
contactNameColorSelector, contactNameColorSelector,
callSelector, callSelector,
activeCall, activeCall,
accountSelector, accountSelector,
selectedMessageIds,
}); });
}; };

View file

@ -19,6 +19,8 @@ import { getEmojiSkinTone } from '../selectors/items';
import { import {
getConversationSelector, getConversationSelector,
getGroupAdminsSelector, getGroupAdminsSelector,
getLastSelectedMessage,
getSelectedMessageIds,
isMissingRequiredProfileSharing, isMissingRequiredProfileSharing,
} from '../selectors/conversations'; } from '../selectors/conversations';
import { getPropsForQuote } from '../selectors/message'; import { getPropsForQuote } from '../selectors/message';
@ -89,6 +91,9 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
const recentEmojis = selectRecentEmojis(state); const recentEmojis = selectRecentEmojis(state);
const selectedMessageIds = getSelectedMessageIds(state);
const lastSelectedMessage = getLastSelectedMessage(state);
return { return {
// Base // Base
conversationId: id, conversationId: id,
@ -160,6 +165,10 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
) => { ) => {
return <SmartCompositionRecordingDraft {...draftProps} />; return <SmartCompositionRecordingDraft {...draftProps} />;
}, },
// Select Mode
selectedMessageIds,
lastSelectedMessage,
}; };
}; };

View file

@ -107,7 +107,7 @@ const mapStateToProps = (state: StateType, ownProps: OwnProps) => {
isSMSOnly: isConversationSMSOnly(conversation), isSMSOnly: isConversationSMSOnly(conversation),
isSignalConversation: isSignalConversation(conversation), isSignalConversation: isSignalConversation(conversation),
i18n: getIntl(state), i18n: getIntl(state),
showBackButton: state.conversations.selectedConversationPanels.length > 0, showBackButton: state.conversations.targetedConversationPanels.length > 0,
outgoingCallButtonStyle: getOutgoingCallButtonStyle(conversation, state), outgoingCallButtonStyle: getOutgoingCallButtonStyle(conversation, state),
theme: getTheme(state), theme: getTheme(state),
}; };

View file

@ -25,6 +25,7 @@ import { SmartTimeline } from './Timeline';
import { getIntl } from '../selectors/user'; import { getIntl } from '../selectors/user';
import { import {
getSelectedConversationId, getSelectedConversationId,
getSelectedMessageIds,
getTopPanel, getTopPanel,
} from '../selectors/conversations'; } from '../selectors/conversations';
import { useComposerActions } from '../ducks/composer'; import { useComposerActions } from '../ducks/composer';
@ -40,11 +41,17 @@ export function SmartConversationView(): JSX.Element {
const topPanel = useSelector<StateType, PanelRenderType | undefined>( const topPanel = useSelector<StateType, PanelRenderType | undefined>(
getTopPanel getTopPanel
); );
const { startConversation } = useConversationsActions(); const { startConversation, toggleSelectMode } = useConversationsActions();
const selectedMessageIds = useSelector(getSelectedMessageIds);
const isSelectMode = selectedMessageIds != null;
const { processAttachments } = useComposerActions(); const { processAttachments } = useComposerActions();
const i18n = useSelector(getIntl); const i18n = useSelector(getIntl);
const isForwardModalOpen = useSelector((state: StateType) => {
return state.globalModals.forwardMessagesProps != null;
});
return ( return (
<ConversationView <ConversationView
conversationId={conversationId} conversationId={conversationId}
@ -172,6 +179,11 @@ export function SmartConversationView(): JSX.Element {
return undefined; return undefined;
}} }}
isSelectMode={isSelectMode}
isForwardModalOpen={isForwardModalOpen}
onExitSelectMode={() => {
toggleSelectMode(false);
}}
/> />
); );
} }

View file

@ -1,13 +1,15 @@
// Copyright 2021 Signal Messenger, LLC // Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React from 'react'; import React, { useState } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import type { DraftBodyRangesType } from '../../types/Util'; import type {
import type { ForwardMessagePropsType } from '../ducks/globalModals'; ForwardMessagePropsType,
ForwardMessagesPropsType,
} from '../ducks/globalModals';
import type { StateType } from '../reducer'; import type { StateType } from '../reducer';
import * as log from '../../logging/log'; import * as log from '../../logging/log';
import { ForwardMessageModal } from '../../components/ForwardMessageModal'; import { ForwardMessagesModal } from '../../components/ForwardMessagesModal';
import { LinkPreviewSourceType } from '../../types/LinkPreview'; import { LinkPreviewSourceType } from '../../types/LinkPreview';
import * as Errors from '../../types/errors'; import * as Errors from '../../types/errors';
import type { GetConversationByIdType } from '../selectors/conversations'; import type { GetConversationByIdType } from '../selectors/conversations';
@ -19,7 +21,11 @@ import { getIntl, getTheme, getRegionCode } from '../selectors/user';
import { getLinkPreview } from '../selectors/linkPreviews'; import { getLinkPreview } from '../selectors/linkPreviews';
import { getMessageById } from '../../messages/getMessageById'; import { getMessageById } from '../../messages/getMessageById';
import { getPreferredBadgeSelector } from '../selectors/badges'; import { getPreferredBadgeSelector } from '../selectors/badges';
import { maybeForwardMessage } from '../../util/maybeForwardMessage'; import type {
ForwardMessageData,
MessageForwardDraft,
} from '../../util/maybeForwardMessages';
import { maybeForwardMessages } from '../../util/maybeForwardMessages';
import { import {
maybeGrabLinkPreview, maybeGrabLinkPreview,
resetLinkPreview, resetLinkPreview,
@ -29,6 +35,7 @@ import { useLinkPreviewActions } from '../ducks/linkPreviews';
import { processBodyRanges } from '../selectors/message'; import { processBodyRanges } from '../selectors/message';
import { getTextWithMentions } from '../../util/getTextWithMentions'; import { getTextWithMentions } from '../../util/getTextWithMentions';
import { SmartCompositionTextArea } from './CompositionTextArea'; import { SmartCompositionTextArea } from './CompositionTextArea';
import { useToastActions } from '../ducks/toast';
function renderMentions( function renderMentions(
message: ForwardMessagePropsType, message: ForwardMessagePropsType,
@ -51,11 +58,11 @@ function renderMentions(
return text; return text;
} }
export function SmartForwardMessageModal(): JSX.Element | null { export function SmartForwardMessagesModal(): JSX.Element | null {
const forwardMessageProps = useSelector< const forwardMessagesProps = useSelector<
StateType, StateType,
ForwardMessagePropsType | undefined ForwardMessagesPropsType | undefined
>(state => state.globalModals.forwardMessageProps); >(state => state.globalModals.forwardMessagesProps);
const candidateConversations = useSelector(getAllComposableConversations); const candidateConversations = useSelector(getAllComposableConversations);
const getPreferredBadge = useSelector(getPreferredBadgeSelector); const getPreferredBadge = useSelector(getPreferredBadgeSelector);
const getConversation = useSelector(getConversationSelector); const getConversation = useSelector(getConversationSelector);
@ -65,70 +72,82 @@ export function SmartForwardMessageModal(): JSX.Element | null {
const theme = useSelector(getTheme); const theme = useSelector(getTheme);
const { removeLinkPreview } = useLinkPreviewActions(); const { removeLinkPreview } = useLinkPreviewActions();
const { toggleForwardMessageModal } = useGlobalModalActions(); const { toggleForwardMessagesModal } = useGlobalModalActions();
const { showToast } = useToastActions();
if (!forwardMessageProps) { const [drafts, setDrafts] = useState<ReadonlyArray<MessageForwardDraft>>(
() => {
return (
forwardMessagesProps?.messages.map((props): MessageForwardDraft => {
return {
originalMessageId: props.id,
attachments: props.attachments ?? [],
messageBody: renderMentions(props, getConversation),
isSticker: Boolean(props.isSticker),
hasContact: Boolean(props.contact),
previews: props.previews ?? [],
};
}) ?? []
);
}
);
if (!drafts.length) {
return null; return null;
} }
const { attachments = [] } = forwardMessageProps;
function closeModal() { function closeModal() {
resetLinkPreview(); resetLinkPreview();
toggleForwardMessageModal(); toggleForwardMessagesModal();
} }
const cleanedBody = renderMentions(forwardMessageProps, getConversation);
return ( return (
<ForwardMessageModal <ForwardMessagesModal
attachments={attachments} drafts={drafts}
candidateConversations={candidateConversations} candidateConversations={candidateConversations}
doForwardMessage={async ( doForwardMessages={async (conversationIds, finalDrafts) => {
conversationIds,
messageBody,
includedAttachments,
linkPreview
) => {
try { try {
const message = await getMessageById(forwardMessageProps.id); const messages = await Promise.all(
if (!message) { finalDrafts.map(async (draft): Promise<ForwardMessageData> => {
throw new Error('No message found'); const message = await getMessageById(draft.originalMessageId);
} if (message == null) {
throw new Error('No message found');
}
return {
draft,
originalMessage: message.attributes,
};
})
);
const didForwardSuccessfully = await maybeForwardMessage( const didForwardSuccessfully = await maybeForwardMessages(
message.attributes, messages,
conversationIds, conversationIds
messageBody,
includedAttachments,
linkPreview
); );
if (didForwardSuccessfully) { if (didForwardSuccessfully) {
closeModal(); closeModal();
forwardMessagesProps?.onForward?.();
} }
} catch (err) { } catch (err) {
log.warn('doForwardMessage', Errors.toLogFormat(err)); log.warn('doForwardMessage', Errors.toLogFormat(err));
} }
}} }}
linkPreviewForSource={linkPreviewForSource}
getPreferredBadge={getPreferredBadge} getPreferredBadge={getPreferredBadge}
hasContact={Boolean(forwardMessageProps.contact)}
i18n={i18n} i18n={i18n}
isSticker={Boolean(forwardMessageProps.isSticker)}
linkPreview={linkPreviewForSource(
LinkPreviewSourceType.ForwardMessageModal
)}
messageBody={cleanedBody}
onClose={closeModal} onClose={closeModal}
onEditorStateChange={( onChange={(updatedDrafts, caretLocation) => {
_conversationId: string | undefined, setDrafts(updatedDrafts);
messageText: string, const isLonelyDraft = updatedDrafts.length === 1;
_: DraftBodyRangesType, const lonelyDraft = isLonelyDraft ? updatedDrafts[0] : null;
caretLocation?: number if (lonelyDraft == null) {
) => { return;
if (!attachments.length) { }
const attachmentsLength = lonelyDraft.attachments?.length ?? 0;
if (attachmentsLength === 0) {
maybeGrabLinkPreview( maybeGrabLinkPreview(
messageText, lonelyDraft.messageBody ?? '',
LinkPreviewSourceType.ForwardMessageModal, LinkPreviewSourceType.ForwardMessageModal,
{ caretLocation } { caretLocation }
); );
@ -137,6 +156,7 @@ export function SmartForwardMessageModal(): JSX.Element | null {
regionCode={regionCode} regionCode={regionCode}
RenderCompositionTextArea={SmartCompositionTextArea} RenderCompositionTextArea={SmartCompositionTextArea}
removeLinkPreview={removeLinkPreview} removeLinkPreview={removeLinkPreview}
showToast={showToast}
theme={theme} theme={theme}
/> />
); );

View file

@ -10,7 +10,7 @@ import { ErrorModal } from '../../components/ErrorModal';
import { GlobalModalContainer } from '../../components/GlobalModalContainer'; import { GlobalModalContainer } from '../../components/GlobalModalContainer';
import { SmartAddUserToAnotherGroupModal } from './AddUserToAnotherGroupModal'; import { SmartAddUserToAnotherGroupModal } from './AddUserToAnotherGroupModal';
import { SmartContactModal } from './ContactModal'; import { SmartContactModal } from './ContactModal';
import { SmartForwardMessageModal } from './ForwardMessageModal'; import { SmartForwardMessagesModal } from './ForwardMessagesModal';
import { SmartProfileEditorModal } from './ProfileEditorModal'; import { SmartProfileEditorModal } from './ProfileEditorModal';
import { SmartSafetyNumberModal } from './SafetyNumberModal'; import { SmartSafetyNumberModal } from './SafetyNumberModal';
import { SmartSendAnywayDialog } from './SendAnywayDialog'; import { SmartSendAnywayDialog } from './SendAnywayDialog';
@ -29,8 +29,8 @@ function renderContactModal(): JSX.Element {
return <SmartContactModal />; return <SmartContactModal />;
} }
function renderForwardMessageModal(): JSX.Element { function renderForwardMessagesModal(): JSX.Element {
return <SmartForwardMessageModal />; return <SmartForwardMessagesModal />;
} }
function renderStoriesSettings(): JSX.Element { function renderStoriesSettings(): JSX.Element {
@ -56,7 +56,7 @@ export function SmartGlobalModalContainer(): JSX.Element {
addUserToAnotherGroupModalContactId, addUserToAnotherGroupModalContactId,
contactModalState, contactModalState,
errorModalProps, errorModalProps,
forwardMessageProps, forwardMessagesProps,
isProfileEditorVisible, isProfileEditorVisible,
isShortcutGuideModalVisible, isShortcutGuideModalVisible,
isSignalConnectionsVisible, isSignalConnectionsVisible,
@ -121,7 +121,7 @@ export function SmartGlobalModalContainer(): JSX.Element {
addUserToAnotherGroupModalContactId={addUserToAnotherGroupModalContactId} addUserToAnotherGroupModalContactId={addUserToAnotherGroupModalContactId}
contactModalState={contactModalState} contactModalState={contactModalState}
errorModalProps={errorModalProps} errorModalProps={errorModalProps}
forwardMessageProps={forwardMessageProps} forwardMessagesProps={forwardMessagesProps}
hasSafetyNumberChangeModal={hasSafetyNumberChangeModal} hasSafetyNumberChangeModal={hasSafetyNumberChangeModal}
hideUserNotFoundModal={hideUserNotFoundModal} hideUserNotFoundModal={hideUserNotFoundModal}
hideWhatsNewModal={hideWhatsNewModal} hideWhatsNewModal={hideWhatsNewModal}
@ -134,7 +134,7 @@ export function SmartGlobalModalContainer(): JSX.Element {
renderAddUserToAnotherGroup={renderAddUserToAnotherGroup} renderAddUserToAnotherGroup={renderAddUserToAnotherGroup}
renderContactModal={renderContactModal} renderContactModal={renderContactModal}
renderErrorModal={renderErrorModal} renderErrorModal={renderErrorModal}
renderForwardMessageModal={renderForwardMessageModal} renderForwardMessagesModal={renderForwardMessagesModal}
renderProfileEditor={renderProfileEditor} renderProfileEditor={renderProfileEditor}
renderSafetyNumber={renderSafetyNumber} renderSafetyNumber={renderSafetyNumber}
renderSendAnywayDialog={renderSendAnywayDialog} renderSendAnywayDialog={renderSendAnywayDialog}

View file

@ -40,7 +40,7 @@ export function SmartInbox(): JSX.Element {
const { hasInitialLoadCompleted } = useSelector<StateType, AppStateType>( const { hasInitialLoadCompleted } = useSelector<StateType, AppStateType>(
state => state.app state => state.app
); );
const { selectedConversationId, selectedMessage, selectedMessageSource } = const { selectedConversationId, targetedMessage, targetedMessageSource } =
useSelector<StateType, ConversationsStateType>( useSelector<StateType, ConversationsStateType>(
state => state.conversations state => state.conversations
); );
@ -67,8 +67,8 @@ export function SmartInbox(): JSX.Element {
renderMiniPlayer={renderMiniPlayer} renderMiniPlayer={renderMiniPlayer}
scrollToMessage={scrollToMessage} scrollToMessage={scrollToMessage}
selectedConversationId={selectedConversationId} selectedConversationId={selectedConversationId}
selectedMessage={selectedMessage} targetedMessage={targetedMessage}
selectedMessageSource={selectedMessageSource} targetedMessageSource={targetedMessageSource}
showConversation={showConversation} showConversation={showConversation}
showWhatsNewModal={showWhatsNewModal} showWhatsNewModal={showWhatsNewModal}
/> />

View file

@ -57,7 +57,7 @@ import {
getMaximumGroupSizeModalState, getMaximumGroupSizeModalState,
getRecommendedGroupSizeModalState, getRecommendedGroupSizeModalState,
getSelectedConversationId, getSelectedConversationId,
getSelectedMessage, getTargetedMessage,
getShowArchived, getShowArchived,
hasGroupCreationError, hasGroupCreationError,
isCreatingGroup, isCreatingGroup,
@ -230,7 +230,7 @@ const mapStateToProps = (state: StateType) => {
modeSpecificProps: getModeSpecificProps(state), modeSpecificProps: getModeSpecificProps(state),
preferredWidthFromStorage: getPreferredLeftPaneWidth(state), preferredWidthFromStorage: getPreferredLeftPaneWidth(state),
selectedConversationId: getSelectedConversationId(state), selectedConversationId: getSelectedConversationId(state),
selectedMessageId: getSelectedMessage(state)?.id, targetedMessageId: getTargetedMessage(state)?.id,
showArchived: getShowArchived(state), showArchived: getShowArchived(state),
getPreferredBadge: getPreferredBadgeSelector(state), getPreferredBadge: getPreferredBadgeSelector(state),
i18n: getIntl(state), i18n: getIntl(state),

View file

@ -34,7 +34,7 @@ export function SmartLightbox(): JSX.Element | null {
showLightboxForPrevMessage, showLightboxForPrevMessage,
setSelectedLightboxPath, setSelectedLightboxPath,
} = useLightboxActions(); } = useLightboxActions();
const { toggleForwardMessageModal } = useGlobalModalActions(); const { toggleForwardMessagesModal } = useGlobalModalActions();
const { pauseVoiceNotePlayer } = useAudioPlayerActions(); const { pauseVoiceNotePlayer } = useAudioPlayerActions();
const conversationSelector = useSelector<StateType, GetConversationByIdType>( const conversationSelector = useSelector<StateType, GetConversationByIdType>(
@ -103,7 +103,7 @@ export function SmartLightbox(): JSX.Element | null {
media={media} media={media}
saveAttachment={saveAttachment} saveAttachment={saveAttachment}
selectedIndex={selectedIndex || 0} selectedIndex={selectedIndex || 0}
toggleForwardMessageModal={toggleForwardMessageModal} toggleForwardMessagesModal={toggleForwardMessagesModal}
onMediaPlaybackStart={pauseVoiceNotePlayer} onMediaPlaybackStart={pauseVoiceNotePlayer}
onPrevAttachment={onPrevAttachment} onPrevAttachment={onPrevAttachment}
onNextAttachment={onNextAttachment} onNextAttachment={onNextAttachment}

View file

@ -32,7 +32,7 @@ export function SmartMessageDetail(): JSX.Element | null {
const theme = useSelector(getTheme); const theme = useSelector(getTheme);
const { checkForAccount } = useAccountsActions(); const { checkForAccount } = useAccountsActions();
const { const {
clearSelectedMessage, clearTargetedMessage: clearSelectedMessage,
doubleCheckMissingQuoteReference, doubleCheckMissingQuoteReference,
kickOffAttachmentDownload, kickOffAttachmentDownload,
markAttachmentAsCorrupted, markAttachmentAsCorrupted,
@ -69,7 +69,7 @@ export function SmartMessageDetail(): JSX.Element | null {
return ( return (
<MessageDetail <MessageDetail
checkForAccount={checkForAccount} checkForAccount={checkForAccount}
clearSelectedMessage={clearSelectedMessage} clearTargetedMessage={clearSelectedMessage}
contactNameColor={contactNameColor} contactNameColor={contactNameColor}
contacts={contacts} contacts={contacts}
doubleCheckMissingQuoteReference={doubleCheckMissingQuoteReference} doubleCheckMissingQuoteReference={doubleCheckMissingQuoteReference}

View file

@ -42,7 +42,7 @@ export function SmartStories(): JSX.Element | null {
showConversation, showConversation,
toggleHideStories, toggleHideStories,
} = useConversationsActions(); } = useConversationsActions();
const { showStoriesSettings, toggleForwardMessageModal } = const { showStoriesSettings, toggleForwardMessagesModal } =
useGlobalModalActions(); useGlobalModalActions();
const { showToast } = useToastActions(); const { showToast } = useToastActions();
@ -92,7 +92,9 @@ export function SmartStories(): JSX.Element | null {
maxAttachmentSizeInKb={maxAttachmentSizeInKb} maxAttachmentSizeInKb={maxAttachmentSizeInKb}
me={me} me={me}
myStories={myStories} myStories={myStories}
onForwardStory={toggleForwardMessageModal} onForwardStory={messageId => {
toggleForwardMessagesModal([messageId]);
}}
onSaveStory={story => { onSaveStory={story => {
if (story.attachment) { if (story.attachment) {
saveAttachment(story.attachment, story.timestamp); saveAttachment(story.attachment, story.timestamp);

View file

@ -24,7 +24,7 @@ import {
getConversationSelector, getConversationSelector,
getConversationsByTitleSelector, getConversationsByTitleSelector,
getInvitedContactsForNewlyCreatedGroup, getInvitedContactsForNewlyCreatedGroup,
getSelectedMessage, getTargetedMessage,
} from '../selectors/conversations'; } from '../selectors/conversations';
import { selectAudioPlayerActive } from '../selectors/audioPlayer'; import { selectAudioPlayerActive } from '../selectors/audioPlayer';
@ -227,7 +227,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
const conversation = getConversationSelector(state)(id); const conversation = getConversationSelector(state)(id);
const conversationMessages = getConversationMessagesSelector(state)(id); const conversationMessages = getConversationMessagesSelector(state)(id);
const selectedMessage = getSelectedMessage(state); const targetedMessage = getTargetedMessage(state);
const getTimestampForMessage = (messageId: string): undefined | number => const getTimestampForMessage = (messageId: string): undefined | number =>
getMessages(state)[messageId]?.timestamp; getMessages(state)[messageId]?.timestamp;
@ -247,7 +247,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
invitedContactsForNewlyCreatedGroup: invitedContactsForNewlyCreatedGroup:
getInvitedContactsForNewlyCreatedGroup(state), getInvitedContactsForNewlyCreatedGroup(state),
selectedMessageId: selectedMessage ? selectedMessage.id : undefined, targetedMessageId: targetedMessage ? targetedMessage.id : undefined,
shouldShowMiniPlayer, shouldShowMiniPlayer,
warning: getWarning(conversation, state), warning: getWarning(conversation, state),

View file

@ -17,7 +17,7 @@ import { useStoriesActions } from '../ducks/stories';
import { useCallingActions } from '../ducks/calling'; import { useCallingActions } from '../ducks/calling';
import { getPreferredBadgeSelector } from '../selectors/badges'; import { getPreferredBadgeSelector } from '../selectors/badges';
import { getIntl, getInteractionMode, getTheme } from '../selectors/user'; import { getIntl, getInteractionMode, getTheme } from '../selectors/user';
import { getSelectedMessage } from '../selectors/conversations'; import { getTargetedMessage } from '../selectors/conversations';
import { getTimelineItem } from '../selectors/timeline'; import { getTimelineItem } from '../selectors/timeline';
import { import {
areMessagesInSameGroup, areMessagesInSameGroup,
@ -71,9 +71,9 @@ export function SmartTimelineItem(props: ExternalProps): JSX.Element {
const previousItem = useProxySelector(getTimelineItem, previousMessageId); const previousItem = useProxySelector(getTimelineItem, previousMessageId);
const nextItem = useProxySelector(getTimelineItem, nextMessageId); const nextItem = useProxySelector(getTimelineItem, nextMessageId);
const selectedMessage = useSelector(getSelectedMessage); const targetedMessage = useSelector(getTargetedMessage);
const isSelected = Boolean( const isTargeted = Boolean(
selectedMessage && messageId === selectedMessage.id targetedMessage && messageId === targetedMessage.id
); );
const isNextItemCallingNotification = nextItem?.type === 'callHistory'; const isNextItemCallingNotification = nextItem?.type === 'callHistory';
@ -105,8 +105,8 @@ export function SmartTimelineItem(props: ExternalProps): JSX.Element {
const { const {
blockGroupLinkRequests, blockGroupLinkRequests,
clearSelectedMessage, clearTargetedMessage: clearSelectedMessage,
deleteMessage, deleteMessages,
deleteMessageForEveryone, deleteMessageForEveryone,
doubleCheckMissingQuoteReference, doubleCheckMissingQuoteReference,
kickOffAttachmentDownload, kickOffAttachmentDownload,
@ -117,7 +117,8 @@ export function SmartTimelineItem(props: ExternalProps): JSX.Element {
retryDeleteForEveryone, retryDeleteForEveryone,
retryMessageSend, retryMessageSend,
saveAttachment, saveAttachment,
selectMessage, targetMessage,
toggleSelectMessage,
showConversation, showConversation,
showExpiredIncomingTapToViewToast, showExpiredIncomingTapToViewToast,
showExpiredOutgoingTapToViewToast, showExpiredOutgoingTapToViewToast,
@ -129,7 +130,7 @@ export function SmartTimelineItem(props: ExternalProps): JSX.Element {
const { const {
showContactModal, showContactModal,
toggleForwardMessageModal, toggleForwardMessagesModal,
toggleSafetyNumberModal, toggleSafetyNumberModal,
} = useGlobalModalActions(); } = useGlobalModalActions();
@ -150,7 +151,7 @@ export function SmartTimelineItem(props: ExternalProps): JSX.Element {
conversationId={conversationId} conversationId={conversationId}
getPreferredBadge={getPreferredBadge} getPreferredBadge={getPreferredBadge}
isNextItemCallingNotification={isNextItemCallingNotification} isNextItemCallingNotification={isNextItemCallingNotification}
isSelected={isSelected} isTargeted={isTargeted}
renderAudioAttachment={renderAudioAttachment} renderAudioAttachment={renderAudioAttachment}
renderContact={renderContact} renderContact={renderContact}
renderEmojiPicker={renderEmojiPicker} renderEmojiPicker={renderEmojiPicker}
@ -165,8 +166,8 @@ export function SmartTimelineItem(props: ExternalProps): JSX.Element {
theme={theme} theme={theme}
blockGroupLinkRequests={blockGroupLinkRequests} blockGroupLinkRequests={blockGroupLinkRequests}
checkForAccount={checkForAccount} checkForAccount={checkForAccount}
clearSelectedMessage={clearSelectedMessage} clearTargetedMessage={clearSelectedMessage}
deleteMessage={deleteMessage} deleteMessages={deleteMessages}
deleteMessageForEveryone={deleteMessageForEveryone} deleteMessageForEveryone={deleteMessageForEveryone}
doubleCheckMissingQuoteReference={doubleCheckMissingQuoteReference} doubleCheckMissingQuoteReference={doubleCheckMissingQuoteReference}
kickOffAttachmentDownload={kickOffAttachmentDownload} kickOffAttachmentDownload={kickOffAttachmentDownload}
@ -180,7 +181,7 @@ export function SmartTimelineItem(props: ExternalProps): JSX.Element {
returnToActiveCall={returnToActiveCall} returnToActiveCall={returnToActiveCall}
saveAttachment={saveAttachment} saveAttachment={saveAttachment}
scrollToQuotedMessage={scrollToQuotedMessage} scrollToQuotedMessage={scrollToQuotedMessage}
selectMessage={selectMessage} targetMessage={targetMessage}
setQuoteByMessageId={setQuoteByMessageId} setQuoteByMessageId={setQuoteByMessageId}
showContactModal={showContactModal} showContactModal={showContactModal}
showConversation={showConversation} showConversation={showConversation}
@ -190,9 +191,10 @@ export function SmartTimelineItem(props: ExternalProps): JSX.Element {
showLightboxForViewOnceMedia={showLightboxForViewOnceMedia} showLightboxForViewOnceMedia={showLightboxForViewOnceMedia}
startCallingLobby={startCallingLobby} startCallingLobby={startCallingLobby}
startConversation={startConversation} startConversation={startConversation}
toggleForwardMessageModal={toggleForwardMessageModal} toggleForwardMessagesModal={toggleForwardMessagesModal}
toggleSafetyNumberModal={toggleSafetyNumberModal} toggleSafetyNumberModal={toggleSafetyNumberModal}
viewStory={viewStory} viewStory={viewStory}
toggleSelectMessage={toggleSelectMessage}
/> />
); );
} }

View file

@ -3,15 +3,15 @@
import { assert } from 'chai'; import { assert } from 'chai';
import type { TargetedConversationChangedActionType } from '../../../state/ducks/conversations';
import { import {
SELECTED_CONVERSATION_CHANGED, TARGETED_CONVERSATION_CHANGED,
actions as conversationsActions, actions as conversationsActions,
} from '../../../state/ducks/conversations'; } from '../../../state/ducks/conversations';
import { noopAction } from '../../../state/ducks/noop'; import { noopAction } from '../../../state/ducks/noop';
import type { StateType } from '../../../state/reducer'; import type { StateType } from '../../../state/reducer';
import { reducer as rootReducer } from '../../../state/reducer'; import { reducer as rootReducer } from '../../../state/reducer';
import type { SelectedConversationChangedActionType } from '../../../state/ducks/conversations';
import { actions, AudioPlayerContent } from '../../../state/ducks/audioPlayer'; import { actions, AudioPlayerContent } from '../../../state/ducks/audioPlayer';
import type { VoiceNoteAndConsecutiveForPlayback } from '../../../state/selectors/audioPlayer'; import type { VoiceNoteAndConsecutiveForPlayback } from '../../../state/selectors/audioPlayer';
@ -100,8 +100,8 @@ describe('both/state/ducks/audioPlayer', () => {
it('active is not changed when changing the conversation', () => { it('active is not changed when changing the conversation', () => {
const state = getInitializedState(); const state = getInitializedState();
const updated = rootReducer(state, <SelectedConversationChangedActionType>{ const updated = rootReducer(state, <TargetedConversationChangedActionType>{
type: SELECTED_CONVERSATION_CHANGED, type: TARGETED_CONVERSATION_CHANGED,
payload: { id: 'any' }, payload: { id: 'any' },
}); });

View file

@ -0,0 +1,123 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import dataInterface from '../../sql/Client';
import { UUID } from '../../types/UUID';
import type { UUIDStringType } from '../../types/UUID';
import type { MessageAttributesType } from '../../model-types';
const {
saveMessages,
_getAllMessages,
_removeAllMessages,
getMessagesBetween,
} = dataInterface;
function getUuid(): UUIDStringType {
return UUID.generate().toString();
}
describe('sql/getMessagesBetween', () => {
beforeEach(async () => {
await _removeAllMessages();
});
it('finds all messages between two in-order messages', async () => {
assert.lengthOf(await _getAllMessages(), 0);
const now = Date.now();
const conversationId = getUuid();
const ourUuid = getUuid();
function getMessage(body: string, offset: number): MessageAttributesType {
return {
id: getUuid(),
body,
type: 'outgoing',
conversationId,
sent_at: now + offset,
received_at: now + offset,
timestamp: now + offset,
};
}
const message1 = getMessage('message 1', -50);
const message2 = getMessage('message 2', -40); // after
const message3 = getMessage('message 3', -30);
const message4 = getMessage('message 4', -20); // before
const message5 = getMessage('message 5', -10);
await saveMessages([message1, message2, message3, message4, message5], {
forceSave: true,
ourUuid,
});
assert.lengthOf(await _getAllMessages(), 5);
const ids = await getMessagesBetween(conversationId, {
after: {
received_at: message2.received_at,
sent_at: message2.sent_at,
},
before: {
received_at: message4.received_at,
sent_at: message4.sent_at,
},
includeStoryReplies: false,
});
assert.lengthOf(ids, 1);
assert.deepEqual(ids, [message3.id]);
});
it('returns based on timestamps even if one message doesnt exist', async () => {
assert.lengthOf(await _getAllMessages(), 0);
const now = Date.now();
const conversationId = getUuid();
const ourUuid = getUuid();
function getMessage(body: string, offset: number): MessageAttributesType {
return {
id: getUuid(),
body,
type: 'outgoing',
conversationId,
sent_at: now + offset,
received_at: now + offset,
timestamp: now + offset,
};
}
const message1 = getMessage('message 1', -50);
const message2 = getMessage('message 2', -40); // after
const message3 = getMessage('message 3', -30);
const message4 = getMessage('message 4', -20); // before, doesn't exist
const message5 = getMessage('message 5', -10);
await saveMessages([message1, message2, message3, message5], {
forceSave: true,
ourUuid,
});
assert.lengthOf(await _getAllMessages(), 4);
const ids = await getMessagesBetween(conversationId, {
after: {
received_at: message2.received_at,
sent_at: message2.sent_at,
},
before: {
received_at: message4.received_at,
sent_at: message4.sent_at,
},
includeStoryReplies: false,
});
assert.lengthOf(ids, 1);
assert.deepEqual(ids, [message3.id]);
});
});

View file

@ -0,0 +1,131 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import dataInterface from '../../sql/Client';
import { UUID } from '../../types/UUID';
import type { UUIDStringType } from '../../types/UUID';
import type { MessageAttributesType } from '../../model-types';
const {
saveMessages,
_getAllMessages,
_removeAllMessages,
getNearbyMessageFromDeletedSet,
} = dataInterface;
function getUuid(): UUIDStringType {
return UUID.generate().toString();
}
describe('sql/getNearbyMessageFromDeletedSet', () => {
beforeEach(async () => {
await _removeAllMessages();
});
it('finds the closest message before, after, or between a set of messages', async () => {
assert.lengthOf(await _getAllMessages(), 0);
const now = Date.now();
const conversationId = getUuid();
const ourUuid = getUuid();
function getMessage(body: string, offset: number): MessageAttributesType {
return {
id: body,
body,
type: 'outgoing',
conversationId,
sent_at: now + offset,
received_at: now + offset,
timestamp: now + offset,
};
}
const message1 = getMessage('message 1', -50);
const message2 = getMessage('message 2', -40);
const message3 = getMessage('message 3', -30);
const message4 = getMessage('message 4', -20);
const message5 = getMessage('message 5', -10);
await saveMessages([message1, message2, message3, message4, message5], {
forceSave: true,
ourUuid,
});
assert.lengthOf(await _getAllMessages(), 5);
const testCases = [
{
name: '1 -> 2',
lastSelectedMessage: message1,
deletedMessageIds: [message1.id],
expectedId: message2.id,
},
{
name: '5 -> 4',
lastSelectedMessage: message5,
deletedMessageIds: [message5.id],
expectedId: message4.id,
},
{
name: '1,2 -> 3',
lastSelectedMessage: message2,
deletedMessageIds: [message1.id, message2.id],
expectedId: message3.id,
},
{
name: '4,5 -> 3',
lastSelectedMessage: message5,
deletedMessageIds: [message4.id, message5.id],
expectedId: message3.id,
},
{
name: '3,1 -> 2',
lastSelectedMessage: message1,
deletedMessageIds: [message3.id, message1.id],
expectedId: message2.id,
},
{
name: '4,2 -> 3',
lastSelectedMessage: message2,
deletedMessageIds: [message4.id, message2.id],
expectedId: message3.id,
},
{
name: '1,2,4,5 -> 3',
lastSelectedMessage: message5,
deletedMessageIds: [message1.id, message2.id, message4.id, message5.id],
expectedId: message3.id,
},
{
name: '1,2,3,4,5 -> null',
lastSelectedMessage: message5,
deletedMessageIds: [
message1.id,
message2.id,
message3.id,
message4.id,
message5.id,
],
expectedId: null,
},
];
for (const testCase of testCases) {
const { name, lastSelectedMessage, deletedMessageIds, expectedId } =
testCase;
// eslint-disable-next-line no-await-in-loop
const id = await getNearbyMessageFromDeletedSet({
conversationId,
lastSelectedMessage,
deletedMessageIds,
storyId: undefined,
includeStoryReplies: false,
});
assert.strictEqual(id, expectedId, name);
}
});
});

View file

@ -0,0 +1,53 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import type { Database } from '@signalapp/better-sqlite3';
import SQL from '@signalapp/better-sqlite3';
import { sql, sqlFragment, sqlJoin } from '../../sql/util';
describe('sql/utils/sql', () => {
let db: Database;
beforeEach(() => {
db = new SQL(':memory:');
});
afterEach(() => {
db.close();
});
it('can run different query types with nested sql syntax', async () => {
const [createQuery, createParams] = sql`
CREATE TABLE examples (
id INTEGER PRIMARY KEY,
body TEXT
);
`;
db.prepare(createQuery).run(createParams);
const [insertQuery, insertParams] = sql`
INSERT INTO examples (id, body) VALUES
(1, 'foo'),
(2, 'bar'),
(3, 'baz');
`;
db.prepare(insertQuery).run(insertParams);
const predicate = sqlFragment`body = ${'baz'}`;
const [selectQuery, selectParams] = sql`
SELECT * FROM examples WHERE
id IN (${sqlJoin([1, 2], ', ')}) OR
${predicate};
`;
const result = db.prepare(selectQuery).all(selectParams);
assert.deepEqual(result, [
{ id: 1, body: 'foo' },
{ id: 2, body: 'bar' },
{ id: 3, body: 'baz' },
]);
});
});

View file

@ -18,12 +18,12 @@ import type {
ConversationType, ConversationType,
ConversationsStateType, ConversationsStateType,
MessageType, MessageType,
SelectedConversationChangedActionType, TargetedConversationChangedActionType,
ToggleConversationInChooseMembersActionType, ToggleConversationInChooseMembersActionType,
MessageChangedActionType, MessageChangedActionType,
} from '../../../state/ducks/conversations'; } from '../../../state/ducks/conversations';
import { import {
SELECTED_CONVERSATION_CHANGED, TARGETED_CONVERSATION_CHANGED,
actions, actions,
cancelConversationVerification, cancelConversationVerification,
clearCancelledConversationVerification, clearCancelledConversationVerification,
@ -386,7 +386,7 @@ describe('both/state/ducks/conversations', () => {
const nextState = reducer(state, action); const nextState = reducer(state, action);
assert.equal(nextState.selectedConversationId, 'abc123'); assert.equal(nextState.selectedConversationId, 'abc123');
assert.isUndefined(nextState.selectedMessage); assert.isUndefined(nextState.targetedMessage);
}); });
it('selects a conversation and a message', () => { it('selects a conversation and a message', () => {
@ -402,11 +402,11 @@ describe('both/state/ducks/conversations', () => {
const nextState = reducer(state, action); const nextState = reducer(state, action);
assert.equal(nextState.selectedConversationId, 'abc123'); assert.equal(nextState.selectedConversationId, 'abc123');
assert.equal(nextState.selectedMessage, 'xyz987'); assert.equal(nextState.targetedMessage, 'xyz987');
}); });
describe('showConversation switchToAssociatedView=true', () => { describe('showConversation switchToAssociatedView=true', () => {
let action: SelectedConversationChangedActionType; let action: TargetedConversationChangedActionType;
beforeEach(() => { beforeEach(() => {
const dispatch = sinon.spy(); const dispatch = sinon.spy();
@ -766,7 +766,7 @@ describe('both/state/ducks/conversations', () => {
}); });
sinon.assert.calledWith(dispatch, { sinon.assert.calledWith(dispatch, {
type: SELECTED_CONVERSATION_CHANGED, type: TARGETED_CONVERSATION_CHANGED,
payload: { payload: {
conversationId: '9876', conversationId: '9876',
messageId: undefined, messageId: undefined,

View file

@ -15,6 +15,7 @@ import {
} from '../sql/Server'; } from '../sql/Server';
import { ReadStatus } from '../messages/MessageReadStatus'; import { ReadStatus } from '../messages/MessageReadStatus';
import { SeenStatus } from '../MessageSeenStatus'; import { SeenStatus } from '../MessageSeenStatus';
import { sql } from '../sql/util';
const OUR_UUID = generateGuid(); const OUR_UUID = generateGuid();
@ -1608,58 +1609,53 @@ describe('SQL migrations test', () => {
}); });
describe('updateToSchemaVersion52', () => { describe('updateToSchemaVersion52', () => {
const queries = [ function getQueries(
{
query: `
EXPLAIN QUERY PLAN
SELECT * FROM messages WHERE
conversationId = 'conversation' AND
readStatus = 'something' AND
isStory IS 0 AND
:story_id_predicate:
ORDER BY received_at ASC, sent_at ASC
LIMIT 1;
`,
index: 'messages_unread',
},
{
query: `
EXPLAIN QUERY PLAN
SELECT json FROM messages WHERE
conversationId = 'd8b05bb1-36b3-4478-841b-600af62321eb' AND
(NULL IS NULL OR id IS NOT NULL) AND
isStory IS 0 AND
:story_id_predicate: AND
(
(received_at = 17976931348623157 AND sent_at < NULL) OR
received_at < 17976931348623157
)
ORDER BY received_at DESC, sent_at DESC
LIMIT 10;
`,
index: 'messages_conversation',
},
];
function insertPredicate(
query: string,
storyId: string | undefined, storyId: string | undefined,
includeStoryReplies: boolean includeStoryReplies: boolean
): string { ) {
return query.replaceAll( return [
':story_id_predicate:', {
_storyIdPredicate(storyId, includeStoryReplies) template: sql`
); EXPLAIN QUERY PLAN
SELECT * FROM messages WHERE
conversationId = 'conversation' AND
readStatus = 'something' AND
isStory IS 0 AND
${_storyIdPredicate(storyId, includeStoryReplies)}
ORDER BY received_at ASC, sent_at ASC
LIMIT 1;
`,
index: 'messages_unread',
},
{
template: sql`
EXPLAIN QUERY PLAN
SELECT json FROM messages WHERE
conversationId = 'd8b05bb1-36b3-4478-841b-600af62321eb' AND
(NULL IS NULL OR id IS NOT NULL) AND
isStory IS 0 AND
${_storyIdPredicate(storyId, includeStoryReplies)} AND
(
(received_at = 17976931348623157 AND sent_at < NULL) OR
received_at < 17976931348623157
)
ORDER BY received_at DESC, sent_at DESC
LIMIT 10;
`,
index: 'messages_conversation',
},
];
} }
it('produces optimizable queries for present and absent storyId', () => { it('produces optimizable queries for present and absent storyId', () => {
updateToVersion(52); updateToVersion(52);
for (const storyId of ['123', undefined]) { for (const storyId of ['123', undefined]) {
for (const { query, index } of queries) { for (const { template, index } of getQueries(storyId, true)) {
const [query, params] = template;
const details = db const details = db
.prepare(insertPredicate(query, storyId, true)) .prepare(query)
.all({ storyId }) .all(params)
.map(({ detail }) => detail) .map(({ detail }) => detail)
.join('\n'); .join('\n');

View file

@ -7,6 +7,7 @@ export enum ToastType {
AlreadyRequestedToJoin = 'AlreadyRequestedToJoin', AlreadyRequestedToJoin = 'AlreadyRequestedToJoin',
Blocked = 'Blocked', Blocked = 'Blocked',
BlockedGroup = 'BlockedGroup', BlockedGroup = 'BlockedGroup',
CannotForwardEmptyMessage = 'CannotForwardEmptyMessage',
CannotMixMultiAndNonMultiAttachments = 'CannotMixMultiAndNonMultiAttachments', CannotMixMultiAndNonMultiAttachments = 'CannotMixMultiAndNonMultiAttachments',
CannotOpenGiftBadgeIncoming = 'CannotOpenGiftBadgeIncoming', CannotOpenGiftBadgeIncoming = 'CannotOpenGiftBadgeIncoming',
CannotOpenGiftBadgeOutgoing = 'CannotOpenGiftBadgeOutgoing', CannotOpenGiftBadgeOutgoing = 'CannotOpenGiftBadgeOutgoing',
@ -38,6 +39,7 @@ export enum ToastType {
StoryVideoUnsupported = 'StoryVideoUnsupported', StoryVideoUnsupported = 'StoryVideoUnsupported',
TapToViewExpiredIncoming = 'TapToViewExpiredIncoming', TapToViewExpiredIncoming = 'TapToViewExpiredIncoming',
TapToViewExpiredOutgoing = 'TapToViewExpiredOutgoing', TapToViewExpiredOutgoing = 'TapToViewExpiredOutgoing',
TooManyMessagesToForward = 'TooManyMessagesToForward',
UnableToLoadAttachment = 'UnableToLoadAttachment', UnableToLoadAttachment = 'UnableToLoadAttachment',
UnsupportedMultiAttachment = 'UnsupportedMultiAttachment', UnsupportedMultiAttachment = 'UnsupportedMultiAttachment',
UnsupportedOS = 'UnsupportedOS', UnsupportedOS = 'UnsupportedOS',

View file

@ -2013,7 +2013,7 @@
}, },
{ {
"rule": "React-useRef", "rule": "React-useRef",
"path": "ts/components/ForwardMessageModal.tsx", "path": "ts/components/ForwardMessagesModal.tsx",
"line": " const inputRef = useRef<null | HTMLInputElement>(null);", "line": " const inputRef = useRef<null | HTMLInputElement>(null);",
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2021-07-30T16:57:33.618Z" "updated": "2021-07-30T16:57:33.618Z"

View file

@ -1,144 +0,0 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { AttachmentType } from '../types/Attachment';
import type { LinkPreviewType } from '../types/message/LinkPreviews';
import type { MessageAttributesType } from '../model-types.d';
import * as log from '../logging/log';
import { SafetyNumberChangeSource } from '../components/SafetyNumberChangeDialog';
import { blockSendUntilConversationsAreVerified } from './blockSendUntilConversationsAreVerified';
import { getMessageIdForLogging } from './idForLogging';
import { isNotNil } from './isNotNil';
import { resetLinkPreview } from '../services/LinkPreview';
import { getRecipientsByConversation } from './getRecipientsByConversation';
export async function maybeForwardMessage(
messageAttributes: MessageAttributesType,
conversationIds: ReadonlyArray<string>,
messageBody?: string,
attachments?: ReadonlyArray<AttachmentType>,
linkPreview?: LinkPreviewType
): Promise<boolean> {
const idForLogging = getMessageIdForLogging(messageAttributes);
log.info(`maybeForwardMessage/${idForLogging}: Starting...`);
const attachmentLookup = new Set();
if (attachments) {
attachments.forEach(attachment => {
attachmentLookup.add(`${attachment.fileName}/${attachment.contentType}`);
});
}
const conversations = conversationIds
.map(id => window.ConversationController.get(id))
.filter(isNotNil);
const cannotSend = conversations.some(
conversation =>
conversation?.get('announcementsOnly') && !conversation.areWeAdmin()
);
if (cannotSend) {
throw new Error('Cannot send to group');
}
const recipientsByConversation = getRecipientsByConversation(
conversations.map(x => x.attributes)
);
// Verify that all contacts that we're forwarding
// to are verified and trusted.
// 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 canSend = await blockSendUntilConversationsAreVerified(
recipientsByConversation,
SafetyNumberChangeSource.MessageSend
);
if (!canSend) {
return false;
}
const sendMessageOptions = { dontClearDraft: true };
const baseTimestamp = Date.now();
const {
loadAttachmentData,
loadContactData,
loadPreviewData,
loadStickerData,
} = window.Signal.Migrations;
// 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, offset) => {
const timestamp = baseTimestamp + offset;
if (conversation) {
const { sticker, contact } = messageAttributes;
if (sticker) {
const stickerWithData = await loadStickerData(sticker);
const stickerNoPath = stickerWithData
? {
...stickerWithData,
data: {
...stickerWithData.data,
path: undefined,
},
}
: undefined;
void conversation.enqueueMessageForSend(
{
body: undefined,
attachments: [],
sticker: stickerNoPath,
},
{ ...sendMessageOptions, timestamp }
);
} else if (contact?.length) {
const contactWithHydratedAvatar = await loadContactData(contact);
void conversation.enqueueMessageForSend(
{
body: undefined,
attachments: [],
contact: contactWithHydratedAvatar,
},
{ ...sendMessageOptions, timestamp }
);
} else {
const preview = linkPreview
? await loadPreviewData([linkPreview])
: [];
const attachmentsWithData = await Promise.all(
(attachments || []).map(async item => ({
...(await loadAttachmentData(item)),
path: undefined,
}))
);
const attachmentsToSend = attachmentsWithData.filter(
(attachment: Partial<AttachmentType>) =>
attachmentLookup.has(
`${attachment.fileName}/${attachment.contentType}`
)
);
void conversation.enqueueMessageForSend(
{
body: messageBody || undefined,
attachments: attachmentsToSend,
preview,
},
{ ...sendMessageOptions, timestamp }
);
}
}
})
);
// Cancel any link still pending, even if it didn't make it into the message
resetLinkPreview();
return true;
}

View file

@ -0,0 +1,260 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { orderBy } from 'lodash';
import type { AttachmentType } from '../types/Attachment';
import type { LinkPreviewType } from '../types/message/LinkPreviews';
import type { MessageAttributesType, QuotedMessageType } from '../model-types';
import * as log from '../logging/log';
import { SafetyNumberChangeSource } from '../components/SafetyNumberChangeDialog';
import { blockSendUntilConversationsAreVerified } from './blockSendUntilConversationsAreVerified';
import {
getMessageIdForLogging,
getConversationIdForLogging,
} from './idForLogging';
import { isNotNil } from './isNotNil';
import { resetLinkPreview } from '../services/LinkPreview';
import { getRecipientsByConversation } from './getRecipientsByConversation';
import type { ContactWithHydratedAvatar } from '../textsecure/SendMessage';
import type { BodyRangesType } from '../types/Util';
import type { StickerWithHydratedData } from '../types/Stickers';
import { drop } from './drop';
import { toLogFormat } from '../types/errors';
export type MessageForwardDraft = Readonly<{
originalMessageId: string;
attachments?: ReadonlyArray<AttachmentType>;
previews: ReadonlyArray<LinkPreviewType>;
isSticker: boolean;
hasContact: boolean;
messageBody?: string;
}>;
export type ForwardMessageData = Readonly<{
originalMessage: MessageAttributesType;
draft: MessageForwardDraft;
}>;
export function isDraftEditable(draft: MessageForwardDraft): boolean {
if (draft.isSticker) {
return false;
}
if (draft.hasContact) {
return false;
}
return true;
}
export function isDraftForwardable(draft: MessageForwardDraft): boolean {
const messageLength = draft.messageBody?.length ?? 0;
if (messageLength > 0) {
return true;
}
if (draft.isSticker) {
return true;
}
if (draft.hasContact) {
return true;
}
const attachmentsLength = draft.attachments?.length ?? 0;
if (attachmentsLength > 0) {
return true;
}
return false;
}
export function isMessageForwardable(message: MessageAttributesType): boolean {
const { body, attachments, sticker, contact } = message;
const messageLength = body?.length ?? 0;
if (messageLength > 0) {
return true;
}
if (sticker) {
return true;
}
if (contact?.length) {
return true;
}
const attachmentsLength = attachments?.length ?? 0;
if (attachmentsLength > 0) {
return true;
}
return false;
}
export function sortByMessageOrder<T>(
items: ReadonlyArray<T>,
getMesssage: (
item: T
) => Pick<MessageAttributesType, 'sent_at' | 'received_at'>
): Array<T> {
return orderBy(
items,
[item => getMesssage(item).received_at, item => getMesssage(item).sent_at],
['ASC', 'ASC']
);
}
export async function maybeForwardMessages(
messages: Array<ForwardMessageData>,
conversationIds: ReadonlyArray<string>
): Promise<boolean> {
log.info(
`maybeForwardMessage: Attempting to forward ${messages.length} messages...`
);
const conversations = conversationIds
.map(id => window.ConversationController.get(id))
.filter(isNotNil);
const cannotSend = conversations.some(
conversation =>
conversation?.get('announcementsOnly') && !conversation.areWeAdmin()
);
if (cannotSend) {
throw new Error('Cannot send to group');
}
const recipientsByConversation = getRecipientsByConversation(
conversations.map(x => x.attributes)
);
// Verify that all contacts that we're forwarding
// to are verified and trusted.
// 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 canSend = await blockSendUntilConversationsAreVerified(
recipientsByConversation,
SafetyNumberChangeSource.MessageSend
);
if (!canSend) {
return false;
}
const sendMessageOptions = { dontClearDraft: true };
const baseTimestamp = Date.now();
const {
loadAttachmentData,
loadContactData,
loadPreviewData,
loadStickerData,
} = window.Signal.Migrations;
// load any sticker data, attachments, or link previews that we need to
// send along with the message and do the send to each conversation.
const preparedMessages = await Promise.all(
messages.map(async message => {
const { originalMessage, draft } = message;
const { sticker, contact } = originalMessage;
const { messageBody, previews, attachments } = draft;
const idForLogging = getMessageIdForLogging(originalMessage);
log.info(`maybeForwardMessage: Forwarding ${idForLogging}`);
const attachmentLookup = new Set();
if (attachments) {
attachments.forEach(attachment => {
attachmentLookup.add(
`${attachment.fileName}/${attachment.contentType}`
);
});
}
let enqueuedMessage: {
attachments: Array<AttachmentType>;
body: string | undefined;
contact?: Array<ContactWithHydratedAvatar>;
mentions?: BodyRangesType;
preview?: Array<LinkPreviewType>;
quote?: QuotedMessageType;
sticker?: StickerWithHydratedData;
};
if (sticker) {
const stickerWithData = await loadStickerData(sticker);
const stickerNoPath = stickerWithData
? {
...stickerWithData,
data: {
...stickerWithData.data,
path: undefined,
},
}
: undefined;
enqueuedMessage = {
body: undefined,
attachments: [],
sticker: stickerNoPath,
};
} else if (contact?.length) {
const contactWithHydratedAvatar = await loadContactData(contact);
enqueuedMessage = {
body: undefined,
attachments: [],
contact: contactWithHydratedAvatar,
};
} else {
const preview = await loadPreviewData([...previews]);
const attachmentsWithData = await Promise.all(
(attachments || []).map(async item => ({
...(await loadAttachmentData(item)),
path: undefined,
}))
);
const attachmentsToSend = attachmentsWithData.filter(
(attachment: Partial<AttachmentType>) =>
attachmentLookup.has(
`${attachment.fileName}/${attachment.contentType}`
)
);
enqueuedMessage = {
body: messageBody || undefined,
attachments: attachmentsToSend,
preview,
};
}
return { originalMessage, enqueuedMessage };
})
);
const sortedMessages = sortByMessageOrder(
preparedMessages,
message => message.originalMessage
);
// Actually send the messages
conversations.forEach((conversation, offset) => {
if (conversation == null) {
return;
}
const timestamp = baseTimestamp + offset;
sortedMessages.forEach(entry => {
const { enqueuedMessage, originalMessage } = entry;
drop(
conversation
.enqueueMessageForSend(enqueuedMessage, {
...sendMessageOptions,
timestamp,
})
.catch(error => {
log.error(
'maybeForwardMessage: message send error',
getConversationIdForLogging(conversation.attributes),
getMessageIdForLogging(originalMessage),
toLogFormat(error)
);
})
);
});
});
// Cancel any link still pending, even if it didn't make it into the message
resetLinkPreview();
return true;
}