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",
"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": {
"message": "Unarchive",
"description": "Undoes Archive Conversation action, and moves archived conversation back to the main conversation list"
@ -1173,6 +1177,10 @@
"message": "More Info",
"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": {
"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"
@ -2371,6 +2379,14 @@
"messageformat": "Redeemed",
"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": {
"messageformat": "Thanks for your support!",
"description": "The title of the outgoing donation badge detail dialog"
@ -4619,6 +4635,34 @@
"message": "Block Request",
"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": {
"message": "Add a group photo",
"description": "The label for the avatar uploader when no group photo is selected"
@ -4759,10 +4803,18 @@
"message": "compose button",
"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": {
"message": "Continue",
"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": {
"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/."

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;
}
}
@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;
padding-left: 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 {
@ -187,7 +189,7 @@
}
}
.module-message--selected & {
.module-message--targeted & {
@include mouse-mode {
background-color: $color-gray-60;
}
@ -290,7 +292,7 @@
display: flex;
flex-direction: column;
min-width: 0;
max-width: 306px;
max-width: min(306px, calc(100% - 16px - 22px));
.module-timeline--width-wide &,
.module-message-detail & {
@ -347,18 +349,76 @@ $message-padding-horizontal: 12px;
}
}
.module-message__container--selected {
.module-message__container--targeted {
@include mouse-mode {
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 {
animation: module-message__highlight-lighter 1.2s
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 {
@include keyboard-mode {
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 {
@include light-theme {
@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) {
&:hover:not(:disabled) {
background: mix($background-color, $mix-color, 85%);
&:hover {
@include not-disabled {
background: mix($background-color, $mix-color, 85%);
}
}
&:active:not(:disabled) {
background: mix($background-color, $mix-color, 75%);
&:active {
@include not-disabled {
background: mix($background-color, $mix-color, 75%);
}
}
}
@ -31,7 +35,7 @@
@include focus-box-shadow($color-black, $color-ultramarine-icon);
}
&:disabled {
@include disabled {
cursor: not-allowed;
}
@ -57,7 +61,7 @@
color: $color;
background: $background-color;
&:disabled {
@include disabled {
color: fade-out($color, 0.4);
background: fade-out($background-color, 0.6);
}
@ -79,7 +83,7 @@
color: $color;
background: $background-color;
&:disabled {
@include disabled {
color: $color-black-alpha-40;
background: fade-out($background-color, 0.6);
}
@ -102,7 +106,7 @@
color: $color;
background: $background-color;
&:disabled {
@include disabled {
color: $color-white-alpha-20;
background: fade-out($background-color, 0.6);
}
@ -126,7 +130,7 @@
color: $color;
background: $background-color;
&:disabled {
@include disabled {
color: fade-out($color, 0.4);
background: fade-out($background-color, 0.6);
}
@ -148,7 +152,7 @@
color: $color;
background: $background-color;
&:disabled {
@include disabled {
color: fade-out($color, 0.4);
background: fade-out($background-color, 0.6);
}
@ -180,7 +184,7 @@
color: $color;
background: $background-color;
&:disabled {
@include disabled {
color: fade-out($color, 0.4);
background: fade-out($background-color, 0.6);
}
@ -194,7 +198,7 @@
color: $color;
background: $background-color;
&:disabled {
@include disabled {
color: fade-out($color, 0.4);
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/SearchResultsLoadingFakeRow.scss';
@import './components/Select.scss';
@import './components/SelectModeActions.scss';
@import './components/SendStoryModal.scss';
@import './components/SignalConnectionsModal.scss';
@import './components/Slider.scss';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -22,12 +22,7 @@ import type { Row } from './ConversationList';
import { ConversationList, RowType } from './ConversationList';
import type { ConversationType } from '../state/ducks/conversations';
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
import type { LinkPreviewType } from '../types/message/LinkPreviews';
import type {
DraftBodyRangesType,
LocalizerType,
ThemeType,
} from '../types/Util';
import type { LocalizerType, ThemeType } from '../types/Util';
import type { SmartCompositionTextAreaProps } from '../state/smart/CompositionTextArea';
import { ModalHost } from './ModalHost';
import { SearchInput } from './SearchInput';
@ -39,34 +34,40 @@ import {
asyncShouldNeverBeCalled,
} from '../util/shouldNeverBeCalled';
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 = {
attachments?: ReadonlyArray<AttachmentType>;
candidateConversations: ReadonlyArray<ConversationType>;
doForwardMessage: (
selectedContacts: Array<string>,
messageBody?: string,
attachments?: ReadonlyArray<AttachmentType>,
linkPreview?: LinkPreviewType
doForwardMessages: (
conversationIds: ReadonlyArray<string>,
drafts: ReadonlyArray<MessageForwardDraft>
) => void;
drafts: ReadonlyArray<MessageForwardDraft>;
getPreferredBadge: PreferredBadgeSelectorType;
hasContact: boolean;
i18n: LocalizerType;
isSticker: boolean;
linkPreview?: LinkPreviewType;
messageBody?: string;
linkPreviewForSource: (
source: LinkPreviewSourceType
) => LinkPreviewType | void;
onClose: () => void;
onEditorStateChange: (
conversationId: string | undefined,
messageText: string,
bodyRanges: DraftBodyRangesType,
onChange: (
updatedDrafts: ReadonlyArray<MessageForwardDraft>,
caretLocation?: number
) => unknown;
theme: ThemeType;
regionCode: string | undefined;
RenderCompositionTextArea: (
props: SmartCompositionTextAreaProps
) => JSX.Element;
showToast: ShowToastAction;
theme: ThemeType;
};
type ActionPropsType = {
@ -77,20 +78,18 @@ export type PropsType = DataPropsType & ActionPropsType;
const MAX_FORWARD = 5;
export function ForwardMessageModal({
attachments,
export function ForwardMessagesModal({
drafts,
candidateConversations,
doForwardMessage,
doForwardMessages,
linkPreviewForSource,
getPreferredBadge,
hasContact,
i18n,
isSticker,
linkPreview,
messageBody,
onClose,
onEditorStateChange,
onChange,
removeLinkPreview,
RenderCompositionTextArea,
showToast,
theme,
regionCode,
}: PropsType): JSX.Element {
@ -102,14 +101,16 @@ export function ForwardMessageModal({
const [filteredConversations, setFilteredConversations] = useState(
filterAndSortConversationsByRecent(candidateConversations, '', regionCode)
);
const [attachmentsToForward, setAttachmentsToForward] = useState<
ReadonlyArray<AttachmentType>
>(attachments || []);
const [isEditingMessage, setIsEditingMessage] = useState(false);
const [messageBodyText, setMessageBodyText] = useState(messageBody || '');
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 =
selectedContacts.length >= MAX_FORWARD;
@ -121,31 +122,29 @@ export function ForwardMessageModal({
const hasContactsSelected = Boolean(selectedContacts.length);
const canForwardMessage =
hasContactsSelected &&
(Boolean(messageBodyText) ||
isSticker ||
hasContact ||
(attachmentsToForward && attachmentsToForward.length));
const canForwardMessages =
hasContactsSelected && drafts.every(isDraftForwardable);
const forwardMessage = React.useCallback(() => {
if (!canForwardMessage) {
const forwardMessages = React.useCallback(() => {
if (!canForwardMessages) {
showToast(ToastType.CannotForwardEmptyMessage);
return;
}
doForwardMessage(
selectedContacts.map(contact => contact.id),
messageBodyText,
attachmentsToForward,
linkPreview
);
const conversationIds = selectedContacts.map(contact => contact.id);
if (lonelyDraft != null) {
const previews = lonelyLinkPreview ? [lonelyLinkPreview] : [];
doForwardMessages(conversationIds, [{ ...lonelyDraft, previews }]);
} else {
doForwardMessages(conversationIds, drafts);
}
}, [
attachmentsToForward,
canForwardMessage,
doForwardMessage,
linkPreview,
messageBodyText,
drafts,
lonelyDraft,
lonelyLinkPreview,
doForwardMessages,
selectedContacts,
canForwardMessages,
showToast,
]);
const normalizedSearchTerm = searchTerm.trim();
@ -299,52 +298,21 @@ export function ForwardMessageModal({
type="button"
/>
)}
<h1>{i18n('forwardMessage')}</h1>
<h1>{i18n('icu:ForwardMessageModal__title')}</h1>
</div>
{isEditingMessage ? (
<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={messageBodyText}
onChange={(messageText, bodyRanges, caretLocation?) => {
setMessageBodyText(messageText);
onEditorStateChange(
undefined,
messageText,
bodyRanges,
caretLocation
);
}}
onSubmit={forwardMessage}
theme={theme}
/>
</div>
{isEditingMessage && lonelyDraft != null ? (
<ForwardMessageEditor
draft={lonelyDraft}
linkPreview={lonelyLinkPreview}
onChange={messageBody => {
onChange([{ ...lonelyDraft, messageBody }]);
}}
removeLinkPreview={removeLinkPreview}
theme={theme}
i18n={i18n}
RenderCompositionTextArea={RenderCompositionTextArea}
onSubmit={forwardMessages}
/>
) : (
<div className="module-ForwardMessageModal__main-body">
<SearchInput
@ -418,12 +386,12 @@ export function ForwardMessageModal({
)}
</div>
<div>
{isEditingMessage || !isMessageEditable ? (
{isEditingMessage || !isLonelyDraftEditable ? (
<Button
aria-label={i18n('ForwardMessageModal--continue')}
className="module-ForwardMessageModal__send-button module-ForwardMessageModal__send-button--forward"
disabled={!canForwardMessage}
onClick={forwardMessage}
aria-disabled={!canForwardMessages}
onClick={forwardMessages}
/>
) : (
<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 type {
ContactModalStateType,
ForwardMessagePropsType,
UserNotFoundModalStateType,
SafetyNumberChangedBlockingDataType,
AuthorizeArtCreatorDataType,
ForwardMessagesPropsType,
} from '../state/ducks/globalModals';
import type { LocalizerType, ThemeType } from '../types/Util';
import { missingCaseError } from '../util/missingCaseError';
@ -35,8 +35,8 @@ export type PropsType = {
title?: string;
}) => JSX.Element;
// ForwardMessageModal
forwardMessageProps: ForwardMessagePropsType | undefined;
renderForwardMessageModal: () => JSX.Element;
forwardMessagesProps: ForwardMessagesPropsType | undefined;
renderForwardMessagesModal: () => JSX.Element;
// ProfileEditor
isProfileEditorVisible: boolean;
renderProfileEditor: () => JSX.Element;
@ -86,8 +86,8 @@ export function GlobalModalContainer({
errorModalProps,
renderErrorModal,
// ForwardMessageModal
forwardMessageProps,
renderForwardMessageModal,
forwardMessagesProps,
renderForwardMessagesModal,
// ProfileEditor
isProfileEditorVisible,
renderProfileEditor,
@ -147,8 +147,8 @@ export function GlobalModalContainer({
return renderContactModal();
}
if (forwardMessageProps) {
return renderForwardMessageModal();
if (forwardMessagesProps) {
return renderForwardMessagesModal();
}
if (isProfileEditorVisible) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { get } from 'lodash';
import type { LocalizerType, ReplacementValuesType } from '../types/Util';
import { SECOND } from '../util/durations';
import { Toast } from './Toast';
@ -71,6 +72,14 @@ export function ToastManager({
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) {
return (
<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) {
return <Toast onClose={hideToast}>{i18n('unableToLoadAttachment')}</Toast>;
}

View file

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

View file

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

View file

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

View file

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

View file

@ -3,7 +3,12 @@
/* 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 { createPortal } from 'react-dom';
import classNames from 'classnames';
@ -113,7 +118,7 @@ const GROUP_AVATAR_SIZE = AvatarSize.TWENTY_EIGHT;
const STICKER_SIZE = 200;
const GIF_SIZE = 300;
// Note: this needs to match the animation time
const SELECTED_TIMEOUT = 1200;
const TARGETED_TIMEOUT = 1200;
const THREE_HOURS = 3 * 60 * 60 * 1000;
const SENT_STATUSES = new Set<MessageStatusType>([
'delivered',
@ -202,8 +207,10 @@ export type PropsData = {
textDirection: TextDirection;
textAttachment?: AttachmentType;
isSticker?: boolean;
isSelected?: boolean;
isSelectedCounter?: number;
isTargeted?: boolean;
isTargetedCounter?: number;
isSelected: boolean;
isSelectMode: boolean;
direction: DirectionType;
timestamp: number;
status?: MessageStatusType;
@ -297,7 +304,7 @@ export type PropsHousekeeping = {
};
export type PropsActions = {
clearSelectedMessage: () => unknown;
clearTargetedMessage: () => unknown;
doubleCheckMissingQuoteReference: (messageId: string) => unknown;
messageExpanded: (id: string, displayLimit: number) => unknown;
checkForAccount: (phoneNumber: string) => unknown;
@ -328,11 +335,14 @@ export type PropsActions = {
conversationId: string;
sentAt: number;
}) => void;
selectMessage?: (messageId: string, conversationId: string) => unknown;
targetMessage?: (messageId: string, conversationId: string) => unknown;
showExpiredIncomingTapToViewToast: () => unknown;
showExpiredOutgoingTapToViewToast: () => unknown;
viewStory: ViewStoryActionCreatorType;
onToggleSelect: (selected: boolean, shift: boolean) => void;
onReplyToMessage: () => void;
};
export type Props = PropsData & PropsHousekeeping & PropsActions;
@ -344,8 +354,8 @@ type State = {
expired: boolean;
imageBroken: boolean;
isSelected?: boolean;
prevSelectedCounter?: number;
isTargeted?: boolean;
prevTargetedCounter?: number;
reactionViewerRoot: HTMLDivElement | null;
reactionViewerOutsideClickDestructor?: () => void;
@ -372,7 +382,7 @@ export class Message extends React.PureComponent<Props, State> {
public expiredTimeout: NodeJS.Timeout | undefined;
public selectedTimeout: NodeJS.Timeout | undefined;
public targetedTimeout: NodeJS.Timeout | undefined;
public deleteForEveryoneTimeout: NodeJS.Timeout | undefined;
@ -386,8 +396,8 @@ export class Message extends React.PureComponent<Props, State> {
expired: false,
imageBroken: false,
isSelected: props.isSelected,
prevSelectedCounter: props.isSelectedCounter,
isTargeted: props.isTargeted,
prevTargetedCounter: props.isTargetedCounter,
reactionViewerRoot: null,
@ -400,22 +410,22 @@ export class Message extends React.PureComponent<Props, State> {
}
public static getDerivedStateFromProps(props: Props, state: State): State {
if (!props.isSelected) {
if (!props.isTargeted) {
return {
...state,
isSelected: false,
prevSelectedCounter: 0,
isTargeted: false,
prevTargetedCounter: 0,
};
}
if (
props.isSelected &&
props.isSelectedCounter !== state.prevSelectedCounter
props.isTargeted &&
props.isTargetedCounter !== state.prevTargetedCounter
) {
return {
...state,
isSelected: props.isSelected,
prevSelectedCounter: props.isSelectedCounter,
isTargeted: props.isTargeted,
prevTargetedCounter: props.isTargetedCounter,
};
}
@ -428,10 +438,10 @@ export class Message extends React.PureComponent<Props, State> {
}
public handleFocus = (): void => {
const { interactionMode, isSelected } = this.props;
const { interactionMode, isTargeted } = this.props;
if (interactionMode === 'keyboard' && !isSelected) {
this.setSelected();
if (interactionMode === 'keyboard' && !isTargeted) {
this.setTargeted();
}
};
@ -445,11 +455,11 @@ export class Message extends React.PureComponent<Props, State> {
});
};
public setSelected = (): void => {
const { id, conversationId, selectMessage } = this.props;
public setTargeted = (): void => {
const { id, conversationId, targetMessage } = this.props;
if (selectMessage) {
selectMessage(id, conversationId);
if (targetMessage) {
targetMessage(id, conversationId);
}
};
@ -465,12 +475,12 @@ export class Message extends React.PureComponent<Props, State> {
const { conversationId } = this.props;
window.ConversationController?.onConvoMessageMount(conversationId);
this.startSelectedTimer();
this.startTargetedTimer();
this.startDeleteForEveryoneTimerIfApplicable();
this.startGiftBadgeInterval();
const { isSelected } = this.props;
if (isSelected) {
const { isTargeted } = this.props;
if (isTargeted) {
this.setFocus();
}
@ -493,7 +503,7 @@ export class Message extends React.PureComponent<Props, State> {
}
public override componentWillUnmount(): void {
clearTimeoutIfNecessary(this.selectedTimeout);
clearTimeoutIfNecessary(this.targetedTimeout);
clearTimeoutIfNecessary(this.expirationCheckInterval);
clearTimeoutIfNecessary(this.expiredTimeout);
clearTimeoutIfNecessary(this.deleteForEveryoneTimeout);
@ -502,12 +512,12 @@ export class Message extends React.PureComponent<Props, State> {
}
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();
if (!prevProps.isSelected && isSelected) {
if (!prevProps.isTargeted && isTargeted) {
this.setFocus();
}
@ -610,20 +620,20 @@ export class Message extends React.PureComponent<Props, State> {
return result;
}
public startSelectedTimer(): void {
const { clearSelectedMessage, interactionMode } = this.props;
const { isSelected } = this.state;
public startTargetedTimer(): void {
const { clearTargetedMessage, interactionMode } = this.props;
const { isTargeted } = this.state;
if (interactionMode === 'keyboard' || !isSelected) {
if (interactionMode === 'keyboard' || !isTargeted) {
return;
}
if (!this.selectedTimeout) {
this.selectedTimeout = setTimeout(() => {
this.selectedTimeout = undefined;
this.setState({ isSelected: false });
clearSelectedMessage();
}, SELECTED_TIMEOUT);
if (!this.targetedTimeout) {
this.targetedTimeout = setTimeout(() => {
this.targetedTimeout = undefined;
this.setState({ isTargeted: false });
clearTargetedMessage();
}, TARGETED_TIMEOUT);
}
}
@ -2450,7 +2460,7 @@ export class Message extends React.PureComponent<Props, State> {
onKeyDown,
text,
} = this.props;
const { isSelected } = this.state;
const { isTargeted } = this.state;
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
const lighterSelect =
isSelected &&
isTargeted &&
direction === 'incoming' &&
!isStickerLike &&
(text || (!isVideo(attachments) && !isImage(attachments)));
@ -2470,8 +2480,8 @@ export class Message extends React.PureComponent<Props, State> {
const containerClassnames = classNames(
'module-message__container',
isGIF(attachments) ? 'module-message__container--gif' : null,
isSelected ? 'module-message__container--selected' : null,
lighterSelect ? 'module-message__container--selected-lighter' : null,
isTargeted ? 'module-message__container--targeted' : null,
lighterSelect ? 'module-message__container--targeted-lighter' : null,
!isStickerLike ? `module-message__container--${direction}` : null,
isEmojiOnly ? 'module-message__container--emoji' : 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 {
const {
id,
attachments,
direction,
isSticker,
isSelected,
isSelectMode,
onKeyDown,
renderMenu,
shouldCollapseAbove,
shouldCollapseBelow,
timestamp,
onToggleSelect,
onReplyToMessage,
} = this.props;
const { expired, expiring, isSelected, imageBroken } = this.state;
const { expired, expiring, isTargeted, imageBroken } = this.state;
if (expired) {
return null;
@ -2546,29 +2580,85 @@ export class Message extends React.PureComponent<Props, State> {
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 (
<div
className={classNames(
'module-message',
`module-message--${direction}`,
shouldCollapseAbove && 'module-message--collapsed-above',
shouldCollapseBelow && 'module-message--collapsed-below',
isSelected ? 'module-message--selected' : null,
expiring ? 'module-message--expired' : null
'module-message__wrapper',
isSelectMode && 'module-message__wrapper--select-mode',
isSelected && 'module-message__wrapper--selected'
)}
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}
{...wrapperProps}
>
{this.renderError()}
{this.renderAvatar()}
{this.renderContainer()}
{renderMenu?.()}
{isSelectMode && (
<>
<span
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>
);
}

View file

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

View file

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

View file

@ -87,14 +87,14 @@ const defaultMessageProps: TimelineMessagesProps = {
canDeleteForEveryone: true,
canDownload: true,
checkForAccount: action('checkForAccount'),
clearSelectedMessage: action('default--clearSelectedMessage'),
clearTargetedMessage: action('default--clearTargetedMessage'),
containerElementRef: React.createRef<HTMLElement>(),
containerWidthBreakpoint: WidthBreakpoint.Wide,
conversationColor: 'crimson',
conversationId: 'conversationId',
conversationTitle: 'Conversation Title',
conversationType: 'direct', // override
deleteMessage: action('default--deleteMessage'),
deleteMessages: action('default--deleteMessages'),
deleteMessageForEveryone: action('default--deleteMessageForEveryone'),
direction: 'incoming',
showLightboxForViewOnceMedia: action('default--showLightboxForViewOnceMedia'),
@ -108,6 +108,9 @@ const defaultMessageProps: TimelineMessagesProps = {
interactionMode: 'keyboard',
isBlocked: false,
isMessageRequestAccepted: true,
isSelected: false,
isSelectMode: false,
toggleSelectMessage: action('toggleSelectMessage'),
kickOffAttachmentDownload: action('default--kickOffAttachmentDownload'),
markAttachmentAsCorrupted: action('default--markAttachmentAsCorrupted'),
messageExpanded: action('default--message-expanded'),
@ -124,7 +127,7 @@ const defaultMessageProps: TimelineMessagesProps = {
retryDeleteForEveryone: action('default--retryDeleteForEveryone'),
saveAttachment: action('saveAttachment'),
scrollToQuotedMessage: action('default--scrollToQuotedMessage'),
selectMessage: action('default--selectMessage'),
targetMessage: action('default--targetMessage'),
shouldCollapseAbove: false,
shouldCollapseBelow: false,
shouldHideMetadata: false,
@ -136,7 +139,7 @@ const defaultMessageProps: TimelineMessagesProps = {
showExpiredOutgoingTapToViewToast: action(
'showExpiredOutgoingTapToViewToast'
),
toggleForwardMessageModal: action('default--toggleForwardMessageModal'),
toggleForwardMessagesModal: action('default--toggleForwardMessagesModal'),
showLightbox: action('default--showLightbox'),
startConversation: action('default--startConversation'),
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!',
isBlocked: false,
isMessageRequestAccepted: true,
isSelected: false,
isSelectMode: false,
previews: [],
readStatus: ReadStatus.Read,
canRetryDeleteForEveryone: true,
@ -270,15 +272,16 @@ const actions = () => ({
loadNewerMessages: action('loadNewerMessages'),
loadNewestMessages: action('loadNewestMessages'),
markMessageRead: action('markMessageRead'),
selectMessage: action('selectMessage'),
clearSelectedMessage: action('clearSelectedMessage'),
toggleSelectMessage: action('toggleSelectMessage'),
targetMessage: action('targetMessage'),
clearTargetedMessage: action('clearTargetedMessage'),
updateSharedGroups: action('updateSharedGroups'),
reactToMessage: action('reactToMessage'),
setQuoteByMessageId: action('setQuoteByMessageId'),
retryDeleteForEveryone: action('retryDeleteForEveryone'),
retryMessageSend: action('retryMessageSend'),
deleteMessage: action('deleteMessage'),
deleteMessages: action('deleteMessages'),
deleteMessageForEveryone: action('deleteMessageForEveryone'),
saveAttachment: action('saveAttachment'),
pushPanelForConversation: action('pushPanelForConversation'),
@ -300,7 +303,7 @@ const actions = () => ({
showExpiredOutgoingTapToViewToast: action(
'showExpiredOutgoingTapToViewToast'
),
toggleForwardMessageModal: action('toggleForwardMessageModal'),
toggleForwardMessagesModal: action('toggleForwardMessagesModal'),
toggleSafetyNumberModal: action('toggleSafetyNumberModal'),
@ -320,6 +323,8 @@ const actions = () => ({
peekGroupCallIfItHasMembers: action('peekGroupCallIfItHasMembers'),
viewStory: action('viewStory'),
onReplyToMessage: action('onReplyToMessage'),
});
const renderItem = ({
@ -334,7 +339,7 @@ const renderItem = ({
<TimelineItem
getPreferredBadge={() => undefined}
id=""
isSelected={false}
isTargeted={false}
i18n={i18n}
interactionMode="keyboard"
isNextItemCallingNotification={false}

View file

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

View file

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

View file

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

View file

@ -252,7 +252,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
canRetry: overrideProps.canRetry || false,
canRetryDeleteForEveryone: overrideProps.canRetryDeleteForEveryone || false,
checkForAccount: action('checkForAccount'),
clearSelectedMessage: action('clearSelectedMessage'),
clearTargetedMessage: action('clearSelectedMessage'),
containerElementRef: React.createRef<HTMLElement>(),
containerWidthBreakpoint: WidthBreakpoint.Wide,
conversationColor:
@ -265,7 +265,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
conversationType: overrideProps.conversationType || 'direct',
contact: overrideProps.contact,
deletedForEveryone: overrideProps.deletedForEveryone,
deleteMessage: action('deleteMessage'),
deleteMessages: action('deleteMessages'),
deleteMessageForEveryone: action('deleteMessageForEveryone'),
// disableMenu: overrideProps.disableMenu,
disableScroll: overrideProps.disableScroll,
@ -293,6 +293,12 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
isMessageRequestAccepted: isBoolean(overrideProps.isMessageRequestAccepted)
? overrideProps.isMessageRequestAccepted
: true,
isSelected: isBoolean(overrideProps.isSelected)
? overrideProps.isSelected
: false,
isSelectMode: isBoolean(overrideProps.isSelectMode)
? overrideProps.isSelectMode
: false,
isTapToView: overrideProps.isTapToView,
isTapToViewError: overrideProps.isTapToViewError,
isTapToViewExpired: overrideProps.isTapToViewExpired,
@ -317,7 +323,11 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
retryMessageSend: action('retryMessageSend'),
retryDeleteForEveryone: action('retryDeleteForEveryone'),
scrollToQuotedMessage: action('scrollToQuotedMessage'),
selectMessage: action('selectMessage'),
targetMessage: action('targetMessage'),
toggleSelectMessage:
overrideProps.toggleSelectMessage == null
? action('toggleSelectMessage')
: overrideProps.toggleSelectMessage,
shouldCollapseAbove: isBoolean(overrideProps.shouldCollapseAbove)
? overrideProps.shouldCollapseAbove
: false,
@ -335,7 +345,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
showExpiredOutgoingTapToViewToast: action(
'showExpiredOutgoingTapToViewToast'
),
toggleForwardMessageModal: action('toggleForwardMessageModal'),
toggleForwardMessagesModal: action('toggleForwardMessagesModal'),
showLightbox: action('showLightbox'),
startConversation: action('startConversation'),
status: overrideProps.status || 'sent',
@ -2126,3 +2136,33 @@ PaymentNotification.args = {
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;
canReply: boolean;
selectedReaction?: string;
isSelected?: boolean;
isTargeted?: boolean;
} & Omit<MessagePropsData, 'renderingContext' | 'menu'>;
export type PropsActions = {
deleteMessage: (options: {
deleteMessages: (options: {
conversationId: string;
messageId: string;
messageIds: ReadonlyArray<string>;
}) => void;
deleteMessageForEveryone: (id: string) => void;
pushPanelForConversation: PushPanelForConversationActionType;
toggleForwardMessageModal: (id: string) => void;
toggleForwardMessagesModal: (id: Array<string>) => void;
reactToMessage: (
id: string,
{ emoji, remove }: { emoji: string; remove: boolean }
@ -55,7 +55,13 @@ export type PropsActions = {
retryMessageSend: (id: string) => void;
retryDeleteForEveryone: (id: 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 &
PropsActions &
@ -87,14 +93,14 @@ export function TimelineMessage(props: Props): JSX.Element {
containerElementRef,
containerWidthBreakpoint,
conversationId,
deleteMessage,
deleteMessages,
deleteMessageForEveryone,
deletedForEveryone,
direction,
giftBadge,
i18n,
id,
isSelected,
isTargeted,
isSticker,
isTapToView,
kickOffAttachmentDownload,
@ -110,7 +116,8 @@ export function TimelineMessage(props: Props): JSX.Element {
setQuoteByMessageId,
text,
timestamp,
toggleForwardMessageModal,
toggleForwardMessagesModal,
toggleSelectMessage,
} = props;
const [reactionPickerRoot, setReactionPickerRoot] = useState<
@ -260,14 +267,14 @@ export function TimelineMessage(props: Props): JSX.Element {
);
useEffect(() => {
if (isSelected) {
if (isTargeted) {
document.addEventListener('keydown', toggleReactionPickerKeyboard);
}
return () => {
document.removeEventListener('keydown', toggleReactionPickerKeyboard);
};
}, [isSelected, toggleReactionPickerKeyboard]);
}, [isTargeted, toggleReactionPickerKeyboard]);
const renderMenu = useCallback(() => {
return (
@ -357,9 +364,9 @@ export function TimelineMessage(props: Props): JSX.Element {
actions={[
{
action: () =>
deleteMessage({
deleteMessages({
conversationId,
messageId: id,
messageIds: [id],
}),
style: 'negative',
text: i18n('delete'),
@ -372,24 +379,17 @@ export function TimelineMessage(props: Props): JSX.Element {
{i18n('deleteWarning')}
</ConfirmationDialog>
)}
<div
onDoubleClick={ev => {
if (!handleReplyToMessage) {
return;
}
ev.stopPropagation();
ev.preventDefault();
handleReplyToMessage();
<Message
{...props}
renderingContext="conversation/TimelineItem"
onContextMenu={handleContextMenu}
renderMenu={renderMenu}
onToggleSelect={(selected, shift) => {
toggleSelectMessage(conversationId, id, shift, selected);
}}
>
<Message
{...props}
renderingContext="conversation/TimelineItem"
onContextMenu={handleContextMenu}
renderMenu={renderMenu}
/>
</div>
onReplyToMessage={handleReplyToMessage}
/>
<MessageContextMenu
i18n={i18n}
@ -404,7 +404,10 @@ export function TimelineMessage(props: Props): JSX.Element {
? () => retryDeleteForEveryone(id)
: undefined
}
onForward={canForward ? () => toggleForwardMessageModal(id) : undefined}
onSelect={() => toggleSelectMessage(conversationId, id, false, true)}
onForward={
canForward ? () => toggleForwardMessagesModal([id]) : undefined
}
onDeleteForMe={() => setHasDeleteConfirmation(true)}
onDeleteForEveryone={
canDeleteForEveryone ? () => setHasDOEConfirmation(true) : undefined
@ -589,6 +592,7 @@ type MessageContextProps = {
onDeleteForMe: () => void;
onDeleteForEveryone: (() => void) | undefined;
onMoreInfo: () => void;
onSelect: () => void;
};
const MessageContextMenu = ({
@ -599,6 +603,7 @@ const MessageContextMenu = ({
onReplyToMessage,
onReact,
onMoreInfo,
onSelect,
onRetryMessageSend,
onRetryDeleteForEveryone,
onForward,
@ -668,6 +673,17 @@ const MessageContextMenu = ({
>
{i18n('moreInfo')}
</MenuItem>
<MenuItem
attributes={{
className:
'module-message__context--icon module-message__context__select',
}}
onClick={() => {
onSelect();
}}
>
{i18n('icu:MessageContextMenu__select')}
</MenuItem>
{onRetryMessageSend && (
<MenuItem
attributes={{

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2156,9 +2156,12 @@ export class ConversationModel extends window.Backbone
return undefined;
}
decrementMessageCount(): void {
decrementMessageCount(numberOfMessages = 1): void {
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);
}
@ -2180,10 +2183,16 @@ export class ConversationModel extends window.Backbone
return undefined;
}
decrementSentMessageCount(): void {
decrementSentMessageCount(numberOfMessages = 1): void {
this.set({
messageCount: Math.max((this.get('messageCount') || 0) - 1, 0),
sentMessageCount: Math.max((this.get('sentMessageCount') || 0) - 1, 0),
messageCount: Math.max(
(this.get('messageCount') || 0) - numberOfMessages,
0
),
sentMessageCount: Math.max(
(this.get('sentMessageCount') || 0) - numberOfMessages,
0
),
});
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.remove('mouse-mode');
const clearSelectedMessage =
window.reduxActions?.conversations?.clearSelectedMessage;
if (clearSelectedMessage) {
clearSelectedMessage();
const clearTargetedMessage =
window.reduxActions?.conversations?.clearTargetedMessage;
if (clearTargetedMessage) {
clearTargetedMessage();
}
const userChanged = window.reduxActions?.user?.userChanged;
@ -45,10 +45,10 @@ export function startInteractionMode(): void {
document.body.classList.add('mouse-mode');
document.body.classList.remove('keyboard-mode');
const clearSelectedMessage =
window.reduxActions?.conversations?.clearSelectedMessage;
if (clearSelectedMessage) {
clearSelectedMessage();
const clearTargetedMessage =
window.reduxActions?.conversations?.clearTargetedMessage;
if (clearTargetedMessage) {
clearTargetedMessage();
}
const userChanged = window.reduxActions?.user?.userChanged;

View file

@ -69,6 +69,7 @@ import Server from './Server';
import { parseSqliteError, SqliteErrorKind } from './errors';
import { MINUTE } from '../util/durations';
import { getMessageIdForLogging } from '../util/idForLogging';
import type { MessageAttributesType } from '../model-types';
const getRealPath = pify(fs.realpath);
@ -227,6 +228,7 @@ const dataInterface: ClientInterface = {
saveMessage,
saveMessages,
removeMessage,
removeMessages,
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(
messages: Array<MessageTypeUnhydrated>
): Array<MessageType> {
@ -733,18 +757,8 @@ async function removeAllMessagesInConversation(
const ids = messages.map(message => message.id);
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
await queue.onIdle();
await _cleanupMessages(messages);
log.info(`removeAllMessagesInConversation/${logId}: Deleting...`);
// 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 { LoggerType } from '../types/Logging';
import type { ReadStatus } from '../messages/MessageReadStatus';
import type { GetMessagesBetweenOptions } from './Server';
import type { MessageTimestamps } from '../state/ducks/conversations';
export type AdjacentMessagesByConversationOptionsType = Readonly<{
conversationId: string;
@ -30,6 +32,14 @@ export type AdjacentMessagesByConversationOptionsType = Readonly<{
requireVisualMediaAttachments?: boolean;
}>;
export type GetNearbyMessageFromDeletedSetOptionsType = Readonly<{
conversationId: string;
lastSelectedMessage: MessageTimestamps;
deletedMessageIds: ReadonlyArray<string>;
storyId: string | undefined;
includeStoryReplies: boolean;
}>;
export type AttachmentDownloadJobTypeType =
| 'long-message'
| 'attachment'
@ -529,7 +539,9 @@ export type DataInterface = {
sent_at: number;
}) => 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>>;
_removeAllMessages: () => Promise<void>;
getAllMessageIds: () => Promise<Array<string>>;
@ -573,7 +585,13 @@ export type DataInterface = {
obsoleteId: string,
currentId: string
) => Promise<void>;
getMessagesBetween: (
conversationId: string,
options: GetMessagesBetweenOptions
) => Promise<Array<string>>;
getNearbyMessageFromDeletedSet: (
options: GetNearbyMessageFromDeletedSetOptionsType
) => Promise<string | null>;
getUnprocessedCount: () => Promise<number>;
getUnprocessedByIdsAndIncrementAttempts: (
ids: ReadonlyArray<string>

View file

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

View file

@ -35,6 +35,147 @@ export function jsonToObject<T>(json: string): T {
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
//

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -13,7 +13,7 @@ import type {
MessageChangedActionType,
MessageDeletedActionType,
MessagesAddedActionType,
SelectedConversationChangedActionType,
TargetedConversationChangedActionType,
} from './conversations';
import type { NoopActionType } from './noop';
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 { UUIDStringType } from '../../types/UUID';
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 dataInterface from '../../sql/Client';
import { ReadStatus } from '../../messages/MessageReadStatus';
@ -265,7 +265,7 @@ export type StoriesActionType =
| ViewStoryActionType
| StoryReplyDeletedActionType
| RemoveAllStoriesActionType
| SelectedConversationChangedActionType
| TargetedConversationChangedActionType
| SetAddStoryDataType
| SetStorySendingType
| SetHasAllStoriesUnmutedType;
@ -1781,7 +1781,7 @@ export function reducer(
};
}
if (action.type === SELECTED_CONVERSATION_CHANGED) {
if (action.type === TARGETED_CONVERSATION_CHANGED) {
return {
...state,
lastOpenedAtTimestamp: state.openedAtTimestamp || Date.now(),

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,15 +3,15 @@
import { assert } from 'chai';
import type { TargetedConversationChangedActionType } from '../../../state/ducks/conversations';
import {
SELECTED_CONVERSATION_CHANGED,
TARGETED_CONVERSATION_CHANGED,
actions as conversationsActions,
} from '../../../state/ducks/conversations';
import { noopAction } from '../../../state/ducks/noop';
import type { StateType } 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 type { VoiceNoteAndConsecutiveForPlayback } from '../../../state/selectors/audioPlayer';
@ -100,8 +100,8 @@ describe('both/state/ducks/audioPlayer', () => {
it('active is not changed when changing the conversation', () => {
const state = getInitializedState();
const updated = rootReducer(state, <SelectedConversationChangedActionType>{
type: SELECTED_CONVERSATION_CHANGED,
const updated = rootReducer(state, <TargetedConversationChangedActionType>{
type: TARGETED_CONVERSATION_CHANGED,
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,
ConversationsStateType,
MessageType,
SelectedConversationChangedActionType,
TargetedConversationChangedActionType,
ToggleConversationInChooseMembersActionType,
MessageChangedActionType,
} from '../../../state/ducks/conversations';
import {
SELECTED_CONVERSATION_CHANGED,
TARGETED_CONVERSATION_CHANGED,
actions,
cancelConversationVerification,
clearCancelledConversationVerification,
@ -386,7 +386,7 @@ describe('both/state/ducks/conversations', () => {
const nextState = reducer(state, action);
assert.equal(nextState.selectedConversationId, 'abc123');
assert.isUndefined(nextState.selectedMessage);
assert.isUndefined(nextState.targetedMessage);
});
it('selects a conversation and a message', () => {
@ -402,11 +402,11 @@ describe('both/state/ducks/conversations', () => {
const nextState = reducer(state, action);
assert.equal(nextState.selectedConversationId, 'abc123');
assert.equal(nextState.selectedMessage, 'xyz987');
assert.equal(nextState.targetedMessage, 'xyz987');
});
describe('showConversation switchToAssociatedView=true', () => {
let action: SelectedConversationChangedActionType;
let action: TargetedConversationChangedActionType;
beforeEach(() => {
const dispatch = sinon.spy();
@ -766,7 +766,7 @@ describe('both/state/ducks/conversations', () => {
});
sinon.assert.calledWith(dispatch, {
type: SELECTED_CONVERSATION_CHANGED,
type: TARGETED_CONVERSATION_CHANGED,
payload: {
conversationId: '9876',
messageId: undefined,

View file

@ -15,6 +15,7 @@ import {
} from '../sql/Server';
import { ReadStatus } from '../messages/MessageReadStatus';
import { SeenStatus } from '../MessageSeenStatus';
import { sql } from '../sql/util';
const OUR_UUID = generateGuid();
@ -1608,58 +1609,53 @@ describe('SQL migrations test', () => {
});
describe('updateToSchemaVersion52', () => {
const queries = [
{
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,
function getQueries(
storyId: string | undefined,
includeStoryReplies: boolean
): string {
return query.replaceAll(
':story_id_predicate:',
_storyIdPredicate(storyId, includeStoryReplies)
);
) {
return [
{
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', () => {
updateToVersion(52);
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
.prepare(insertPredicate(query, storyId, true))
.all({ storyId })
.prepare(query)
.all(params)
.map(({ detail }) => detail)
.join('\n');

View file

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

View file

@ -2013,7 +2013,7 @@
},
{
"rule": "React-useRef",
"path": "ts/components/ForwardMessageModal.tsx",
"path": "ts/components/ForwardMessagesModal.tsx",
"line": " const inputRef = useRef<null | HTMLInputElement>(null);",
"reasonCategory": "usageTrusted",
"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;
}