Multi-select forwarding and deleting
This commit is contained in:
parent
d986356eea
commit
1d549a9991
82 changed files with 2607 additions and 991 deletions
|
@ -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/."
|
||||
|
|
4
images/icons/v2/check-20.svg
Normal file
4
images/icons/v2/check-20.svg
Normal 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 |
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
61
stylesheets/components/SelectModeActions.scss
Normal file
61
stylesheets/components/SelectModeActions.scss
Normal 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);
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
|
@ -33,7 +33,7 @@ export function AvatarLightbox({
|
|||
isViewOnce
|
||||
media={[]}
|
||||
saveAttachment={noop}
|
||||
toggleForwardMessageModal={noop}
|
||||
toggleForwardMessagesModal={noop}
|
||||
onMediaPlaybackStart={noop}
|
||||
onNextAttachment={noop}
|
||||
onPrevAttachment={noop}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 ||
|
||||
|
|
|
@ -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({
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -242,7 +242,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
|
|||
/>
|
||||
),
|
||||
selectedConversationId: undefined,
|
||||
selectedMessageId: undefined,
|
||||
targetedMessageId: undefined,
|
||||
savePreferredLeftPaneWidth: action('savePreferredLeftPaneWidth'),
|
||||
searchInConversation: action('searchInConversation'),
|
||||
setComposeSearchTerm: action('setComposeSearchTerm'),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -48,6 +48,7 @@ const commonProps = {
|
|||
|
||||
onArchive: action('onArchive'),
|
||||
onMarkUnread: action('onMarkUnread'),
|
||||
toggleSelectMode: action('toggleSelectMode'),
|
||||
onMoveToInbox: action('onMoveToInbox'),
|
||||
pushPanelForConversation: action('pushPanelForConversation'),
|
||||
popPanelForConversation: action('popPanelForConversation'),
|
||||
|
|
|
@ -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')}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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',
|
||||
|
|
122
ts/components/conversation/SelectModeActions.tsx
Normal file
122
ts/components/conversation/SelectModeActions.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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',
|
||||
};
|
||||
|
|
|
@ -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={{
|
||||
|
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
347
ts/sql/Server.ts
347
ts/sql/Server.ts
|
@ -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
|
||||
`
|
||||
|
|
141
ts/sql/util.ts
141
ts/sql/util.ts
|
@ -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
|
||||
//
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -24,7 +24,7 @@ export enum ConversationVerificationState {
|
|||
VerificationCancelled = 'VerificationCancelled',
|
||||
}
|
||||
|
||||
export enum SelectedMessageSource {
|
||||
export enum TargetedMessageSource {
|
||||
Reset = 'Reset',
|
||||
NavigateToMessage = 'NavigateToMessage',
|
||||
Focus = 'Focus',
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -64,6 +64,10 @@ export type ShowToastActionCreatorType = ReadonlyDeep<
|
|||
) => ShowToastActionType
|
||||
>;
|
||||
|
||||
export type ShowToastAction = ReadonlyDeep<
|
||||
(toastType: ToastType, parameters?: ReplacementValuesType) => void
|
||||
>;
|
||||
|
||||
export const showToast: ShowToastActionCreatorType = (
|
||||
toastType,
|
||||
parameters
|
||||
|
|
|
@ -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
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
|
|
|
@ -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
|
||||
);
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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' },
|
||||
});
|
||||
|
||||
|
|
123
ts/test-electron/sql/getMessagesBetween_test.ts
Normal file
123
ts/test-electron/sql/getMessagesBetween_test.ts
Normal 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]);
|
||||
});
|
||||
});
|
131
ts/test-electron/sql/getNearbyMessageFromDeletedSet_test.ts
Normal file
131
ts/test-electron/sql/getNearbyMessageFromDeletedSet_test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
53
ts/test-electron/sql/utils_test.ts
Normal file
53
ts/test-electron/sql/utils_test.ts
Normal 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' },
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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;
|
||||
}
|
260
ts/util/maybeForwardMessages.ts
Normal file
260
ts/util/maybeForwardMessages.ts
Normal 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;
|
||||
}
|
Loading…
Reference in a new issue