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",
|
"message": "Mark as unread",
|
||||||
"description": "Shown in menu for conversation, and marks conversation as unread"
|
"description": "Shown in menu for conversation, and marks conversation as unread"
|
||||||
},
|
},
|
||||||
|
"icu:ConversationHeader__menu__selectMessages": {
|
||||||
|
"messageformat": "Select messages",
|
||||||
|
"description": "Shown in menu for conversation, allows the user to start selecting multiple messages in the conversation"
|
||||||
|
},
|
||||||
"moveConversationToInbox": {
|
"moveConversationToInbox": {
|
||||||
"message": "Unarchive",
|
"message": "Unarchive",
|
||||||
"description": "Undoes Archive Conversation action, and moves archived conversation back to the main conversation list"
|
"description": "Undoes Archive Conversation action, and moves archived conversation back to the main conversation list"
|
||||||
|
@ -1173,6 +1177,10 @@
|
||||||
"message": "More Info",
|
"message": "More Info",
|
||||||
"description": "Shown on the drop-down menu for an individual message, takes you to message detail screen"
|
"description": "Shown on the drop-down menu for an individual message, takes you to message detail screen"
|
||||||
},
|
},
|
||||||
|
"icu:MessageContextMenu__select": {
|
||||||
|
"messageformat": "Select",
|
||||||
|
"description": "Shown on the drop-down menu for an individual message, opens the conversation in select mode with the current message selected"
|
||||||
|
},
|
||||||
"retrySend": {
|
"retrySend": {
|
||||||
"message": "Retry Send",
|
"message": "Retry Send",
|
||||||
"description": "Shown on the drop-down menu for an individual message, but only if it is an outgoing message that failed to send"
|
"description": "Shown on the drop-down menu for an individual message, but only if it is an outgoing message that failed to send"
|
||||||
|
@ -2371,6 +2379,14 @@
|
||||||
"messageformat": "Redeemed",
|
"messageformat": "Redeemed",
|
||||||
"description": "Shown when you've redeemed the donation badge on another device"
|
"description": "Shown when you've redeemed the donation badge on another device"
|
||||||
},
|
},
|
||||||
|
"icu:messageAccessibilityLabel--outgoing": {
|
||||||
|
"messageformat": "Message sent by you",
|
||||||
|
"description": "Accessibility label for outgoing messages"
|
||||||
|
},
|
||||||
|
"icu:messageAccessibilityLabel--incoming": {
|
||||||
|
"messageformat": "Message sent by {author}",
|
||||||
|
"description": "Accessibility label for incoming messages"
|
||||||
|
},
|
||||||
"icu:modal--donation--title": {
|
"icu:modal--donation--title": {
|
||||||
"messageformat": "Thanks for your support!",
|
"messageformat": "Thanks for your support!",
|
||||||
"description": "The title of the outgoing donation badge detail dialog"
|
"description": "The title of the outgoing donation badge detail dialog"
|
||||||
|
@ -4619,6 +4635,34 @@
|
||||||
"message": "Block Request",
|
"message": "Block Request",
|
||||||
"description": "Confirmation button of dialog to block a user from requesting to join via the link again"
|
"description": "Confirmation button of dialog to block a user from requesting to join via the link again"
|
||||||
},
|
},
|
||||||
|
"icu:SelectModeActions--exitSelectMode": {
|
||||||
|
"messageformat": "Exit select mode",
|
||||||
|
"description": "conversation > in select mode > composition area actions > exit select mode > accessibility label"
|
||||||
|
},
|
||||||
|
"icu:SelectModeActions--selectedMessages": {
|
||||||
|
"messageformat": "{count} selected",
|
||||||
|
"description": "conversation > in select mode > composition area actions > count of selected messsages"
|
||||||
|
},
|
||||||
|
"icu:SelectModeActions--deleteSelectedMessages": {
|
||||||
|
"messageformat": "Delete selected messages",
|
||||||
|
"description": "conversation > in select mode > composition area actions > delete selected messsages action > accessibility label"
|
||||||
|
},
|
||||||
|
"icu:SelectModeActions--forwardSelectedMessages": {
|
||||||
|
"messageformat": "Forward selected messages",
|
||||||
|
"description": "conversation > in select mode > composition area actions > forward selected messsages action > accessibility label"
|
||||||
|
},
|
||||||
|
"icu:SelectModeActions__confirmDelete--title": {
|
||||||
|
"messageformat": "Delete {count, plural, one {message} other {# messages}}",
|
||||||
|
"description": "conversation > in select mode > composition area actions > delete selected messages > confirmation modal > title"
|
||||||
|
},
|
||||||
|
"icu:SelectModeActions__confirmDelete--confirm": {
|
||||||
|
"messageformat": "Delete for me",
|
||||||
|
"description": "conversation > in select mode > composition area actions > delete selected messages > confirmation modal > button"
|
||||||
|
},
|
||||||
|
"icu:SelectModeActions__toast--TooManyMessagesToForward": {
|
||||||
|
"messageformat": "You can only forward up to 30 messages",
|
||||||
|
"description": "conversation > in select mode > composition area actions > forward selected messages (disabled) > toast message when too many messages"
|
||||||
|
},
|
||||||
"AvatarInput--no-photo-label--group": {
|
"AvatarInput--no-photo-label--group": {
|
||||||
"message": "Add a group photo",
|
"message": "Add a group photo",
|
||||||
"description": "The label for the avatar uploader when no group photo is selected"
|
"description": "The label for the avatar uploader when no group photo is selected"
|
||||||
|
@ -4759,10 +4803,18 @@
|
||||||
"message": "compose button",
|
"message": "compose button",
|
||||||
"description": "Shown in the left-pane when the inbox is empty. Describes the button that composes a new message."
|
"description": "Shown in the left-pane when the inbox is empty. Describes the button that composes a new message."
|
||||||
},
|
},
|
||||||
|
"icu:ForwardMessageModal__title": {
|
||||||
|
"messageformat": "Forward To",
|
||||||
|
"description": "Title for the forward a message modal dialog"
|
||||||
|
},
|
||||||
"ForwardMessageModal--continue": {
|
"ForwardMessageModal--continue": {
|
||||||
"message": "Continue",
|
"message": "Continue",
|
||||||
"description": "aria-label for the 'next' button in the forward a message modal dialog"
|
"description": "aria-label for the 'next' button in the forward a message modal dialog"
|
||||||
},
|
},
|
||||||
|
"icu:ForwardMessagesModal__toast--CannotForwardEmptyMessage": {
|
||||||
|
"messageformat": "Cannot forward empty or deleted messages",
|
||||||
|
"description": "Toast message shown when trying to forward an empty or deleted message"
|
||||||
|
},
|
||||||
"TimelineDateHeader--date-in-last-6-months": {
|
"TimelineDateHeader--date-in-last-6-months": {
|
||||||
"message": "ddd, MMM D",
|
"message": "ddd, MMM D",
|
||||||
"description": "(deleted 01/25/2023) Moment.js format for date headers in the message timeline, for dates <6 months old. See https://momentjs.com/docs/#/displaying/format/."
|
"description": "(deleted 01/25/2023) Moment.js format for date headers in the message timeline, for dates <6 months old. See https://momentjs.com/docs/#/displaying/format/."
|
||||||
|
|
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;
|
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;
|
outline: none;
|
||||||
padding-left: 16px;
|
padding-left: 16px;
|
||||||
padding-right: 16px;
|
padding-right: 16px;
|
||||||
transition: background 0.1s ease-out;
|
transition-property: background, translate;
|
||||||
|
transition-duration: 0.1s;
|
||||||
|
transition-timing-function: ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-message__quote-story-reaction-header {
|
.module-message__quote-story-reaction-header {
|
||||||
|
@ -187,7 +189,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-message--selected & {
|
.module-message--targeted & {
|
||||||
@include mouse-mode {
|
@include mouse-mode {
|
||||||
background-color: $color-gray-60;
|
background-color: $color-gray-60;
|
||||||
}
|
}
|
||||||
|
@ -290,7 +292,7 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 306px;
|
max-width: min(306px, calc(100% - 16px - 22px));
|
||||||
|
|
||||||
.module-timeline--width-wide &,
|
.module-timeline--width-wide &,
|
||||||
.module-message-detail & {
|
.module-message-detail & {
|
||||||
|
@ -347,18 +349,76 @@ $message-padding-horizontal: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.module-message__container--selected {
|
.module-message__container--targeted {
|
||||||
@include mouse-mode {
|
@include mouse-mode {
|
||||||
animation: module-message__highlight 1.2s cubic-bezier(0.17, 0.17, 0, 1);
|
animation: module-message__highlight 1.2s cubic-bezier(0.17, 0.17, 0, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.module-message__container--selected-lighter {
|
.module-message__container--targeted-lighter {
|
||||||
@include mouse-mode {
|
@include mouse-mode {
|
||||||
animation: module-message__highlight-lighter 1.2s
|
animation: module-message__highlight-lighter 1.2s
|
||||||
cubic-bezier(0.17, 0.17, 0, 1);
|
cubic-bezier(0.17, 0.17, 0, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.module-message__wrapper {
|
||||||
|
position: relative;
|
||||||
|
transition: background 0.1s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-message__wrapper--select-mode {
|
||||||
|
.module-message--incoming {
|
||||||
|
translate: calc(16px + 22px) 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-message__alt-accessibility-tree {
|
||||||
|
@include sr-only;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-message__wrapper--selected {
|
||||||
|
background: rgba($color-ultramarine, 8%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-message__select-checkbox {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 16px;
|
||||||
|
translate: 0 -50%;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid $color-gray-20;
|
||||||
|
animation: module-message__select-checkbox--fadeIn 0.2s ease-out;
|
||||||
|
transition: background 0.1s ease-out, border-color 0.1s ease-out;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin: -2px;
|
||||||
|
@include color-svg('../images/icons/v2/check-20.svg', $color-white);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.1s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-message__wrapper--selected & {
|
||||||
|
background: $color-ultramarine;
|
||||||
|
border-color: $color-ultramarine;
|
||||||
|
&::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes module-message__select-checkbox--fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.module-message:focus-within {
|
.module-message:focus-within {
|
||||||
@include keyboard-mode {
|
@include keyboard-mode {
|
||||||
background: $color-selected-message-background-light;
|
background: $color-selected-message-background-light;
|
||||||
|
@ -7590,6 +7650,22 @@ button.module-image__border-overlay:focus {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__select::before {
|
||||||
|
@include light-theme {
|
||||||
|
@include color-svg(
|
||||||
|
'../images/icons/v2/check-circle-outline-24.svg',
|
||||||
|
$color-black
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@include dark-theme {
|
||||||
|
@include color-svg(
|
||||||
|
'../images/icons/v2/check-circle-outline-24.svg',
|
||||||
|
$color-gray-15
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__retry-send::before {
|
&__retry-send::before {
|
||||||
@include light-theme {
|
@include light-theme {
|
||||||
@include color-svg('../images/icons/v2/send-24.svg', $color-black);
|
@include color-svg('../images/icons/v2/send-24.svg', $color-black);
|
||||||
|
|
|
@ -9,11 +9,15 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
@mixin hover-and-active-states($background-color, $mix-color) {
|
@mixin hover-and-active-states($background-color, $mix-color) {
|
||||||
&:hover:not(:disabled) {
|
&:hover {
|
||||||
background: mix($background-color, $mix-color, 85%);
|
@include not-disabled {
|
||||||
|
background: mix($background-color, $mix-color, 85%);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
&:active:not(:disabled) {
|
&:active {
|
||||||
background: mix($background-color, $mix-color, 75%);
|
@include not-disabled {
|
||||||
|
background: mix($background-color, $mix-color, 75%);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -31,7 +35,7 @@
|
||||||
@include focus-box-shadow($color-black, $color-ultramarine-icon);
|
@include focus-box-shadow($color-black, $color-ultramarine-icon);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
@include disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -57,7 +61,7 @@
|
||||||
color: $color;
|
color: $color;
|
||||||
background: $background-color;
|
background: $background-color;
|
||||||
|
|
||||||
&:disabled {
|
@include disabled {
|
||||||
color: fade-out($color, 0.4);
|
color: fade-out($color, 0.4);
|
||||||
background: fade-out($background-color, 0.6);
|
background: fade-out($background-color, 0.6);
|
||||||
}
|
}
|
||||||
|
@ -79,7 +83,7 @@
|
||||||
color: $color;
|
color: $color;
|
||||||
background: $background-color;
|
background: $background-color;
|
||||||
|
|
||||||
&:disabled {
|
@include disabled {
|
||||||
color: $color-black-alpha-40;
|
color: $color-black-alpha-40;
|
||||||
background: fade-out($background-color, 0.6);
|
background: fade-out($background-color, 0.6);
|
||||||
}
|
}
|
||||||
|
@ -102,7 +106,7 @@
|
||||||
color: $color;
|
color: $color;
|
||||||
background: $background-color;
|
background: $background-color;
|
||||||
|
|
||||||
&:disabled {
|
@include disabled {
|
||||||
color: $color-white-alpha-20;
|
color: $color-white-alpha-20;
|
||||||
background: fade-out($background-color, 0.6);
|
background: fade-out($background-color, 0.6);
|
||||||
}
|
}
|
||||||
|
@ -126,7 +130,7 @@
|
||||||
color: $color;
|
color: $color;
|
||||||
background: $background-color;
|
background: $background-color;
|
||||||
|
|
||||||
&:disabled {
|
@include disabled {
|
||||||
color: fade-out($color, 0.4);
|
color: fade-out($color, 0.4);
|
||||||
background: fade-out($background-color, 0.6);
|
background: fade-out($background-color, 0.6);
|
||||||
}
|
}
|
||||||
|
@ -148,7 +152,7 @@
|
||||||
color: $color;
|
color: $color;
|
||||||
background: $background-color;
|
background: $background-color;
|
||||||
|
|
||||||
&:disabled {
|
@include disabled {
|
||||||
color: fade-out($color, 0.4);
|
color: fade-out($color, 0.4);
|
||||||
background: fade-out($background-color, 0.6);
|
background: fade-out($background-color, 0.6);
|
||||||
}
|
}
|
||||||
|
@ -180,7 +184,7 @@
|
||||||
color: $color;
|
color: $color;
|
||||||
background: $background-color;
|
background: $background-color;
|
||||||
|
|
||||||
&:disabled {
|
@include disabled {
|
||||||
color: fade-out($color, 0.4);
|
color: fade-out($color, 0.4);
|
||||||
background: fade-out($background-color, 0.6);
|
background: fade-out($background-color, 0.6);
|
||||||
}
|
}
|
||||||
|
@ -194,7 +198,7 @@
|
||||||
color: $color;
|
color: $color;
|
||||||
background: $background-color;
|
background: $background-color;
|
||||||
|
|
||||||
&:disabled {
|
@include disabled {
|
||||||
color: fade-out($color, 0.4);
|
color: fade-out($color, 0.4);
|
||||||
background: fade-out($background-color, 0.6);
|
background: fade-out($background-color, 0.6);
|
||||||
}
|
}
|
||||||
|
|
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/SearchResultsLoadingFakeHeader.scss';
|
||||||
@import './components/SearchResultsLoadingFakeRow.scss';
|
@import './components/SearchResultsLoadingFakeRow.scss';
|
||||||
@import './components/Select.scss';
|
@import './components/Select.scss';
|
||||||
|
@import './components/SelectModeActions.scss';
|
||||||
@import './components/SendStoryModal.scss';
|
@import './components/SendStoryModal.scss';
|
||||||
@import './components/SignalConnectionsModal.scss';
|
@import './components/SignalConnectionsModal.scss';
|
||||||
@import './components/Slider.scss';
|
@import './components/Slider.scss';
|
||||||
|
|
|
@ -1649,15 +1649,15 @@ export async function startApp(): Promise<void> {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
const { selectedMessage } = state.conversations;
|
const { targetedMessage } = state.conversations;
|
||||||
if (!selectedMessage) {
|
if (!targetedMessage) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.reduxActions.conversations.pushPanelForConversation({
|
window.reduxActions.conversations.pushPanelForConversation({
|
||||||
type: PanelType.MessageDetails,
|
type: PanelType.MessageDetails,
|
||||||
args: {
|
args: {
|
||||||
messageId: selectedMessage,
|
messageId: targetedMessage,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
@ -1673,14 +1673,14 @@ export async function startApp(): Promise<void> {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
const { selectedMessage } = state.conversations;
|
const { targetedMessage } = state.conversations;
|
||||||
|
|
||||||
const quotedMessageSelector = getQuotedMessageSelector(state);
|
const quotedMessageSelector = getQuotedMessageSelector(state);
|
||||||
const quote = quotedMessageSelector(conversation.id);
|
const quote = quotedMessageSelector(conversation.id);
|
||||||
|
|
||||||
window.reduxActions.composer.setQuoteByMessageId(
|
window.reduxActions.composer.setQuoteByMessageId(
|
||||||
conversation.id,
|
conversation.id,
|
||||||
quote ? undefined : selectedMessage
|
quote ? undefined : targetedMessage
|
||||||
);
|
);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
@ -1696,11 +1696,11 @@ export async function startApp(): Promise<void> {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
const { selectedMessage } = state.conversations;
|
const { targetedMessage } = state.conversations;
|
||||||
|
|
||||||
if (selectedMessage) {
|
if (targetedMessage) {
|
||||||
window.reduxActions.conversations.saveAttachmentFromMessage(
|
window.reduxActions.conversations.saveAttachmentFromMessage(
|
||||||
selectedMessage
|
targetedMessage
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -1712,9 +1712,9 @@ export async function startApp(): Promise<void> {
|
||||||
shiftKey &&
|
shiftKey &&
|
||||||
(key === 'd' || key === 'D')
|
(key === 'd' || key === 'D')
|
||||||
) {
|
) {
|
||||||
const { selectedMessage } = state.conversations;
|
const { targetedMessage } = state.conversations;
|
||||||
|
|
||||||
if (selectedMessage) {
|
if (targetedMessage) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
|
@ -1724,9 +1724,9 @@ export async function startApp(): Promise<void> {
|
||||||
message: window.i18n('deleteWarning'),
|
message: window.i18n('deleteWarning'),
|
||||||
okText: window.i18n('delete'),
|
okText: window.i18n('delete'),
|
||||||
resolve: () => {
|
resolve: () => {
|
||||||
window.reduxActions.conversations.deleteMessage({
|
window.reduxActions.conversations.deleteMessages({
|
||||||
conversationId: conversation.id,
|
conversationId: conversation.id,
|
||||||
messageId: selectedMessage,
|
messageIds: [targetedMessage],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -33,7 +33,7 @@ export function AvatarLightbox({
|
||||||
isViewOnce
|
isViewOnce
|
||||||
media={[]}
|
media={[]}
|
||||||
saveAttachment={noop}
|
saveAttachment={noop}
|
||||||
toggleForwardMessageModal={noop}
|
toggleForwardMessagesModal={noop}
|
||||||
onMediaPlaybackStart={noop}
|
onMediaPlaybackStart={noop}
|
||||||
onNextAttachment={noop}
|
onNextAttachment={noop}
|
||||||
onPrevAttachment={noop}
|
onPrevAttachment={noop}
|
||||||
|
|
|
@ -50,6 +50,7 @@ type PropsType = {
|
||||||
tabIndex?: number;
|
tabIndex?: number;
|
||||||
theme?: Theme;
|
theme?: Theme;
|
||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
|
'aria-disabled'?: boolean;
|
||||||
} & (
|
} & (
|
||||||
| {
|
| {
|
||||||
onClick: MouseEventHandler<HTMLButtonElement>;
|
onClick: MouseEventHandler<HTMLButtonElement>;
|
||||||
|
@ -115,6 +116,7 @@ export const Button = React.forwardRef<HTMLButtonElement, PropsType>(
|
||||||
: ButtonSize.Medium,
|
: ButtonSize.Medium,
|
||||||
} = props;
|
} = props;
|
||||||
const ariaLabel = props['aria-label'];
|
const ariaLabel = props['aria-label'];
|
||||||
|
const ariaDisabled = props['aria-disabled'];
|
||||||
|
|
||||||
let onClick: undefined | MouseEventHandler<HTMLButtonElement>;
|
let onClick: undefined | MouseEventHandler<HTMLButtonElement>;
|
||||||
let type: 'button' | 'submit';
|
let type: 'button' | 'submit';
|
||||||
|
@ -137,6 +139,7 @@ export const Button = React.forwardRef<HTMLButtonElement, PropsType>(
|
||||||
const buttonElement = (
|
const buttonElement = (
|
||||||
<button
|
<button
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
|
aria-disabled={ariaDisabled}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'module-Button',
|
'module-Button',
|
||||||
sizeClassName,
|
sizeClassName,
|
||||||
|
|
|
@ -44,6 +44,7 @@ const useProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
theme: React.useContext(StorybookThemeContext),
|
theme: React.useContext(StorybookThemeContext),
|
||||||
setComposerFocus: action('setComposerFocus'),
|
setComposerFocus: action('setComposerFocus'),
|
||||||
setQuoteByMessageId: action('setQuoteByMessageId'),
|
setQuoteByMessageId: action('setQuoteByMessageId'),
|
||||||
|
showToast: action('showToast'),
|
||||||
|
|
||||||
// AttachmentList
|
// AttachmentList
|
||||||
draftAttachments: overrideProps.draftAttachments || [],
|
draftAttachments: overrideProps.draftAttachments || [],
|
||||||
|
@ -128,6 +129,11 @@ const useProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
isFetchingUUID: overrideProps.isFetchingUUID || false,
|
isFetchingUUID: overrideProps.isFetchingUUID || false,
|
||||||
renderSmartCompositionRecording: _ => <div>RECORDING</div>,
|
renderSmartCompositionRecording: _ => <div>RECORDING</div>,
|
||||||
renderSmartCompositionRecordingDraft: _ => <div>RECORDING DRAFT</div>,
|
renderSmartCompositionRecordingDraft: _ => <div>RECORDING DRAFT</div>,
|
||||||
|
// Select mode
|
||||||
|
selectedMessageIds: undefined,
|
||||||
|
lastSelectedMessage: undefined,
|
||||||
|
toggleSelectMode: action('toggleSelectMode'),
|
||||||
|
toggleForwardMessagesModal: action('toggleForwardMessagesModal'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export function Default(): JSX.Element {
|
export function Default(): JSX.Element {
|
||||||
|
|
|
@ -42,6 +42,7 @@ import { AudioCapture } from './conversation/AudioCapture';
|
||||||
import { CompositionUpload } from './CompositionUpload';
|
import { CompositionUpload } from './CompositionUpload';
|
||||||
import type {
|
import type {
|
||||||
ConversationType,
|
ConversationType,
|
||||||
|
MessageTimestamps,
|
||||||
PushPanelForConversationActionType,
|
PushPanelForConversationActionType,
|
||||||
ShowConversationType,
|
ShowConversationType,
|
||||||
} from '../state/ducks/conversations';
|
} from '../state/ducks/conversations';
|
||||||
|
@ -65,6 +66,8 @@ import { PanelType } from '../types/Panels';
|
||||||
import type { SmartCompositionRecordingDraftProps } from '../state/smart/CompositionRecordingDraft';
|
import type { SmartCompositionRecordingDraftProps } from '../state/smart/CompositionRecordingDraft';
|
||||||
import { useEscapeHandling } from '../hooks/useEscapeHandling';
|
import { useEscapeHandling } from '../hooks/useEscapeHandling';
|
||||||
import type { SmartCompositionRecordingProps } from '../state/smart/CompositionRecording';
|
import type { SmartCompositionRecordingProps } from '../state/smart/CompositionRecording';
|
||||||
|
import SelectModeActions from './conversation/SelectModeActions';
|
||||||
|
import type { ShowToastAction } from '../state/ducks/toast';
|
||||||
|
|
||||||
export type OwnProps = Readonly<{
|
export type OwnProps = Readonly<{
|
||||||
acceptedMessageRequest?: boolean;
|
acceptedMessageRequest?: boolean;
|
||||||
|
@ -105,6 +108,7 @@ export type OwnProps = Readonly<{
|
||||||
messageRequestsEnabled?: boolean;
|
messageRequestsEnabled?: boolean;
|
||||||
onClearAttachments(conversationId: string): unknown;
|
onClearAttachments(conversationId: string): unknown;
|
||||||
onCloseLinkPreview(conversationId: string): unknown;
|
onCloseLinkPreview(conversationId: string): unknown;
|
||||||
|
showToast: ShowToastAction;
|
||||||
processAttachments: (options: {
|
processAttachments: (options: {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
files: ReadonlyArray<File>;
|
files: ReadonlyArray<File>;
|
||||||
|
@ -146,6 +150,13 @@ export type OwnProps = Readonly<{
|
||||||
renderSmartCompositionRecordingDraft: (
|
renderSmartCompositionRecordingDraft: (
|
||||||
props: SmartCompositionRecordingDraftProps
|
props: SmartCompositionRecordingDraftProps
|
||||||
) => JSX.Element | null;
|
) => JSX.Element | null;
|
||||||
|
selectedMessageIds: ReadonlyArray<string> | undefined;
|
||||||
|
lastSelectedMessage: MessageTimestamps | undefined;
|
||||||
|
toggleSelectMode: (on: boolean) => void;
|
||||||
|
toggleForwardMessagesModal: (
|
||||||
|
messageIds: ReadonlyArray<string>,
|
||||||
|
onForward: () => void
|
||||||
|
) => void;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type Props = Pick<
|
export type Props = Pick<
|
||||||
|
@ -192,6 +203,7 @@ export function CompositionArea({
|
||||||
isDisabled,
|
isDisabled,
|
||||||
isSignalConversation,
|
isSignalConversation,
|
||||||
messageCompositionId,
|
messageCompositionId,
|
||||||
|
showToast,
|
||||||
pushPanelForConversation,
|
pushPanelForConversation,
|
||||||
processAttachments,
|
processAttachments,
|
||||||
removeAttachment,
|
removeAttachment,
|
||||||
|
@ -272,6 +284,11 @@ export function CompositionArea({
|
||||||
isFetchingUUID,
|
isFetchingUUID,
|
||||||
renderSmartCompositionRecording,
|
renderSmartCompositionRecording,
|
||||||
renderSmartCompositionRecordingDraft,
|
renderSmartCompositionRecordingDraft,
|
||||||
|
// Selected messages
|
||||||
|
selectedMessageIds,
|
||||||
|
lastSelectedMessage,
|
||||||
|
toggleSelectMode,
|
||||||
|
toggleForwardMessagesModal,
|
||||||
}: Props): JSX.Element | null {
|
}: Props): JSX.Element | null {
|
||||||
const [dirty, setDirty] = useState(false);
|
const [dirty, setDirty] = useState(false);
|
||||||
const [large, setLarge] = useState(false);
|
const [large, setLarge] = useState(false);
|
||||||
|
@ -529,6 +546,34 @@ export function CompositionArea({
|
||||||
return <div />;
|
return <div />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (selectedMessageIds != null) {
|
||||||
|
return (
|
||||||
|
<SelectModeActions
|
||||||
|
i18n={i18n}
|
||||||
|
selectedMessageIds={selectedMessageIds}
|
||||||
|
onExitSelectMode={() => {
|
||||||
|
toggleSelectMode(false);
|
||||||
|
}}
|
||||||
|
onDeleteMessages={() => {
|
||||||
|
window.reduxActions.conversations.deleteMessages({
|
||||||
|
conversationId,
|
||||||
|
lastSelectedMessage,
|
||||||
|
messageIds: selectedMessageIds,
|
||||||
|
});
|
||||||
|
toggleSelectMode(false);
|
||||||
|
}}
|
||||||
|
onForwardMessages={() => {
|
||||||
|
if (selectedMessageIds.length > 0) {
|
||||||
|
toggleForwardMessagesModal(selectedMessageIds, () => {
|
||||||
|
toggleSelectMode(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
showToast={showToast}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isBlocked ||
|
isBlocked ||
|
||||||
areWePending ||
|
areWePending ||
|
||||||
|
|
|
@ -8,13 +8,14 @@ import { text } from '@storybook/addon-knobs';
|
||||||
|
|
||||||
import enMessages from '../../_locales/en/messages.json';
|
import enMessages from '../../_locales/en/messages.json';
|
||||||
import type { AttachmentType } from '../types/Attachment';
|
import type { AttachmentType } from '../types/Attachment';
|
||||||
import type { PropsType } from './ForwardMessageModal';
|
import type { PropsType } from './ForwardMessagesModal';
|
||||||
import { ForwardMessageModal } from './ForwardMessageModal';
|
import { ForwardMessagesModal } from './ForwardMessagesModal';
|
||||||
import { IMAGE_JPEG, VIDEO_MP4, stringToMIMEType } from '../types/MIME';
|
import { IMAGE_JPEG, VIDEO_MP4, stringToMIMEType } from '../types/MIME';
|
||||||
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
|
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
|
||||||
import { setupI18n } from '../util/setupI18n';
|
import { setupI18n } from '../util/setupI18n';
|
||||||
import { StorybookThemeContext } from '../../.storybook/StorybookThemeContext';
|
import { StorybookThemeContext } from '../../.storybook/StorybookThemeContext';
|
||||||
import { CompositionTextArea } from './CompositionTextArea';
|
import { CompositionTextArea } from './CompositionTextArea';
|
||||||
|
import type { MessageForwardDraft } from '../util/maybeForwardMessages';
|
||||||
|
|
||||||
const createAttachment = (
|
const createAttachment = (
|
||||||
props: Partial<AttachmentType> = {}
|
props: Partial<AttachmentType> = {}
|
||||||
|
@ -45,17 +46,14 @@ const candidateConversations = Array.from(Array(100), () =>
|
||||||
);
|
);
|
||||||
|
|
||||||
const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||||
attachments: overrideProps.attachments,
|
drafts: overrideProps.drafts ?? [],
|
||||||
candidateConversations,
|
candidateConversations,
|
||||||
doForwardMessage: action('doForwardMessage'),
|
doForwardMessages: action('doForwardMessages'),
|
||||||
getPreferredBadge: () => undefined,
|
getPreferredBadge: () => undefined,
|
||||||
i18n,
|
i18n,
|
||||||
hasContact: Boolean(overrideProps.hasContact),
|
linkPreviewForSource: () => undefined,
|
||||||
isSticker: Boolean(overrideProps.isSticker),
|
|
||||||
linkPreview: overrideProps.linkPreview,
|
|
||||||
messageBody: text('messageBody', overrideProps.messageBody || ''),
|
|
||||||
onClose: action('onClose'),
|
onClose: action('onClose'),
|
||||||
onEditorStateChange: action('onEditorStateChange'),
|
onChange: action('onChange'),
|
||||||
removeLinkPreview: action('removeLinkPreview'),
|
removeLinkPreview: action('removeLinkPreview'),
|
||||||
RenderCompositionTextArea: props => (
|
RenderCompositionTextArea: props => (
|
||||||
<CompositionTextArea
|
<CompositionTextArea
|
||||||
|
@ -68,16 +66,36 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||||
getPreferredBadge={() => undefined}
|
getPreferredBadge={() => undefined}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
showToast: action('showToast'),
|
||||||
theme: React.useContext(StorybookThemeContext),
|
theme: React.useContext(StorybookThemeContext),
|
||||||
regionCode: 'US',
|
regionCode: 'US',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function getMessageForwardDraft(
|
||||||
|
overrideProps: Partial<MessageForwardDraft>
|
||||||
|
): MessageForwardDraft {
|
||||||
|
return {
|
||||||
|
attachments: overrideProps.attachments,
|
||||||
|
hasContact: Boolean(overrideProps.hasContact),
|
||||||
|
isSticker: Boolean(overrideProps.isSticker),
|
||||||
|
messageBody: text('messageBody', overrideProps.messageBody || ''),
|
||||||
|
originalMessageId: '123',
|
||||||
|
previews: overrideProps.previews ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function Modal(): JSX.Element {
|
export function Modal(): JSX.Element {
|
||||||
return <ForwardMessageModal {...useProps()} />;
|
return <ForwardMessagesModal {...useProps()} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WithText(): JSX.Element {
|
export function WithText(): JSX.Element {
|
||||||
return <ForwardMessageModal {...useProps({ messageBody: 'sup' })} />;
|
return (
|
||||||
|
<ForwardMessagesModal
|
||||||
|
{...useProps({
|
||||||
|
drafts: [getMessageForwardDraft({ messageBody: 'sup' })],
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
WithText.story = {
|
WithText.story = {
|
||||||
|
@ -85,7 +103,13 @@ WithText.story = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ASticker(): JSX.Element {
|
export function ASticker(): JSX.Element {
|
||||||
return <ForwardMessageModal {...useProps({ isSticker: true })} />;
|
return (
|
||||||
|
<ForwardMessagesModal
|
||||||
|
{...useProps({
|
||||||
|
drafts: [getMessageForwardDraft({ isSticker: true })],
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ASticker.story = {
|
ASticker.story = {
|
||||||
|
@ -93,7 +117,13 @@ ASticker.story = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export function WithAContact(): JSX.Element {
|
export function WithAContact(): JSX.Element {
|
||||||
return <ForwardMessageModal {...useProps({ hasContact: true })} />;
|
return (
|
||||||
|
<ForwardMessagesModal
|
||||||
|
{...useProps({
|
||||||
|
drafts: [getMessageForwardDraft({ hasContact: true })],
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
WithAContact.story = {
|
WithAContact.story = {
|
||||||
|
@ -102,21 +132,27 @@ WithAContact.story = {
|
||||||
|
|
||||||
export function LinkPreview(): JSX.Element {
|
export function LinkPreview(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<ForwardMessageModal
|
<ForwardMessagesModal
|
||||||
{...useProps({
|
{...useProps({
|
||||||
linkPreview: {
|
drafts: [
|
||||||
description: LONG_DESCRIPTION,
|
getMessageForwardDraft({
|
||||||
date: Date.now(),
|
messageBody: 'signal.org',
|
||||||
domain: 'https://www.signal.org',
|
previews: [
|
||||||
url: 'signal.org',
|
{
|
||||||
image: createAttachment({
|
description: LONG_DESCRIPTION,
|
||||||
url: '/fixtures/kitten-4-112-112.jpg',
|
date: Date.now(),
|
||||||
contentType: IMAGE_JPEG,
|
domain: 'https://www.signal.org',
|
||||||
|
url: 'signal.org',
|
||||||
|
image: createAttachment({
|
||||||
|
url: '/fixtures/kitten-4-112-112.jpg',
|
||||||
|
contentType: IMAGE_JPEG,
|
||||||
|
}),
|
||||||
|
isStickerPack: false,
|
||||||
|
title: LONG_TITLE,
|
||||||
|
},
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
isStickerPack: false,
|
],
|
||||||
title: LONG_TITLE,
|
|
||||||
},
|
|
||||||
messageBody: 'signal.org',
|
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -128,25 +164,29 @@ LinkPreview.story = {
|
||||||
|
|
||||||
export function MediaAttachments(): JSX.Element {
|
export function MediaAttachments(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<ForwardMessageModal
|
<ForwardMessagesModal
|
||||||
{...useProps({
|
{...useProps({
|
||||||
attachments: [
|
drafts: [
|
||||||
createAttachment({
|
getMessageForwardDraft({
|
||||||
pending: true,
|
messageBody: 'cats',
|
||||||
}),
|
attachments: [
|
||||||
createAttachment({
|
createAttachment({
|
||||||
contentType: IMAGE_JPEG,
|
pending: true,
|
||||||
fileName: 'tina-rolf-269345-unsplash.jpg',
|
}),
|
||||||
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
|
createAttachment({
|
||||||
}),
|
contentType: IMAGE_JPEG,
|
||||||
createAttachment({
|
fileName: 'tina-rolf-269345-unsplash.jpg',
|
||||||
contentType: VIDEO_MP4,
|
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
|
||||||
fileName: 'pixabay-Soap-Bubble-7141.mp4',
|
}),
|
||||||
url: '/fixtures/pixabay-Soap-Bubble-7141.mp4',
|
createAttachment({
|
||||||
screenshotPath: '/fixtures/kitten-4-112-112.jpg',
|
contentType: VIDEO_MP4,
|
||||||
|
fileName: 'pixabay-Soap-Bubble-7141.mp4',
|
||||||
|
url: '/fixtures/pixabay-Soap-Bubble-7141.mp4',
|
||||||
|
screenshotPath: '/fixtures/kitten-4-112-112.jpg',
|
||||||
|
}),
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
messageBody: 'cats',
|
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -158,7 +198,7 @@ MediaAttachments.story = {
|
||||||
|
|
||||||
export function AnnouncementOnlyGroupsNonAdmin(): JSX.Element {
|
export function AnnouncementOnlyGroupsNonAdmin(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<ForwardMessageModal
|
<ForwardMessagesModal
|
||||||
{...useProps()}
|
{...useProps()}
|
||||||
candidateConversations={[
|
candidateConversations={[
|
||||||
getDefaultConversation({
|
getDefaultConversation({
|
|
@ -22,12 +22,7 @@ import type { Row } from './ConversationList';
|
||||||
import { ConversationList, RowType } from './ConversationList';
|
import { ConversationList, RowType } from './ConversationList';
|
||||||
import type { ConversationType } from '../state/ducks/conversations';
|
import type { ConversationType } from '../state/ducks/conversations';
|
||||||
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
||||||
import type { LinkPreviewType } from '../types/message/LinkPreviews';
|
import type { LocalizerType, ThemeType } from '../types/Util';
|
||||||
import type {
|
|
||||||
DraftBodyRangesType,
|
|
||||||
LocalizerType,
|
|
||||||
ThemeType,
|
|
||||||
} from '../types/Util';
|
|
||||||
import type { SmartCompositionTextAreaProps } from '../state/smart/CompositionTextArea';
|
import type { SmartCompositionTextAreaProps } from '../state/smart/CompositionTextArea';
|
||||||
import { ModalHost } from './ModalHost';
|
import { ModalHost } from './ModalHost';
|
||||||
import { SearchInput } from './SearchInput';
|
import { SearchInput } from './SearchInput';
|
||||||
|
@ -39,34 +34,40 @@ import {
|
||||||
asyncShouldNeverBeCalled,
|
asyncShouldNeverBeCalled,
|
||||||
} from '../util/shouldNeverBeCalled';
|
} from '../util/shouldNeverBeCalled';
|
||||||
import { Emojify } from './conversation/Emojify';
|
import { Emojify } from './conversation/Emojify';
|
||||||
|
import type { MessageForwardDraft } from '../util/maybeForwardMessages';
|
||||||
|
import {
|
||||||
|
isDraftEditable,
|
||||||
|
isDraftForwardable,
|
||||||
|
} from '../util/maybeForwardMessages';
|
||||||
|
import type { LinkPreviewType } from '../types/message/LinkPreviews';
|
||||||
|
import { LinkPreviewSourceType } from '../types/LinkPreview';
|
||||||
|
import { ToastType } from '../types/Toast';
|
||||||
|
import type { ShowToastAction } from '../state/ducks/toast';
|
||||||
|
|
||||||
export type DataPropsType = {
|
export type DataPropsType = {
|
||||||
attachments?: ReadonlyArray<AttachmentType>;
|
|
||||||
candidateConversations: ReadonlyArray<ConversationType>;
|
candidateConversations: ReadonlyArray<ConversationType>;
|
||||||
doForwardMessage: (
|
doForwardMessages: (
|
||||||
selectedContacts: Array<string>,
|
conversationIds: ReadonlyArray<string>,
|
||||||
messageBody?: string,
|
drafts: ReadonlyArray<MessageForwardDraft>
|
||||||
attachments?: ReadonlyArray<AttachmentType>,
|
|
||||||
linkPreview?: LinkPreviewType
|
|
||||||
) => void;
|
) => void;
|
||||||
|
drafts: ReadonlyArray<MessageForwardDraft>;
|
||||||
getPreferredBadge: PreferredBadgeSelectorType;
|
getPreferredBadge: PreferredBadgeSelectorType;
|
||||||
hasContact: boolean;
|
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
isSticker: boolean;
|
|
||||||
linkPreview?: LinkPreviewType;
|
linkPreviewForSource: (
|
||||||
messageBody?: string;
|
source: LinkPreviewSourceType
|
||||||
|
) => LinkPreviewType | void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onEditorStateChange: (
|
onChange: (
|
||||||
conversationId: string | undefined,
|
updatedDrafts: ReadonlyArray<MessageForwardDraft>,
|
||||||
messageText: string,
|
|
||||||
bodyRanges: DraftBodyRangesType,
|
|
||||||
caretLocation?: number
|
caretLocation?: number
|
||||||
) => unknown;
|
) => unknown;
|
||||||
theme: ThemeType;
|
|
||||||
regionCode: string | undefined;
|
regionCode: string | undefined;
|
||||||
RenderCompositionTextArea: (
|
RenderCompositionTextArea: (
|
||||||
props: SmartCompositionTextAreaProps
|
props: SmartCompositionTextAreaProps
|
||||||
) => JSX.Element;
|
) => JSX.Element;
|
||||||
|
showToast: ShowToastAction;
|
||||||
|
theme: ThemeType;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ActionPropsType = {
|
type ActionPropsType = {
|
||||||
|
@ -77,20 +78,18 @@ export type PropsType = DataPropsType & ActionPropsType;
|
||||||
|
|
||||||
const MAX_FORWARD = 5;
|
const MAX_FORWARD = 5;
|
||||||
|
|
||||||
export function ForwardMessageModal({
|
export function ForwardMessagesModal({
|
||||||
attachments,
|
drafts,
|
||||||
candidateConversations,
|
candidateConversations,
|
||||||
doForwardMessage,
|
doForwardMessages,
|
||||||
|
linkPreviewForSource,
|
||||||
getPreferredBadge,
|
getPreferredBadge,
|
||||||
hasContact,
|
|
||||||
i18n,
|
i18n,
|
||||||
isSticker,
|
|
||||||
linkPreview,
|
|
||||||
messageBody,
|
|
||||||
onClose,
|
onClose,
|
||||||
onEditorStateChange,
|
onChange,
|
||||||
removeLinkPreview,
|
removeLinkPreview,
|
||||||
RenderCompositionTextArea,
|
RenderCompositionTextArea,
|
||||||
|
showToast,
|
||||||
theme,
|
theme,
|
||||||
regionCode,
|
regionCode,
|
||||||
}: PropsType): JSX.Element {
|
}: PropsType): JSX.Element {
|
||||||
|
@ -102,14 +101,16 @@ export function ForwardMessageModal({
|
||||||
const [filteredConversations, setFilteredConversations] = useState(
|
const [filteredConversations, setFilteredConversations] = useState(
|
||||||
filterAndSortConversationsByRecent(candidateConversations, '', regionCode)
|
filterAndSortConversationsByRecent(candidateConversations, '', regionCode)
|
||||||
);
|
);
|
||||||
const [attachmentsToForward, setAttachmentsToForward] = useState<
|
|
||||||
ReadonlyArray<AttachmentType>
|
|
||||||
>(attachments || []);
|
|
||||||
const [isEditingMessage, setIsEditingMessage] = useState(false);
|
const [isEditingMessage, setIsEditingMessage] = useState(false);
|
||||||
const [messageBodyText, setMessageBodyText] = useState(messageBody || '');
|
|
||||||
const [cannotMessage, setCannotMessage] = useState(false);
|
const [cannotMessage, setCannotMessage] = useState(false);
|
||||||
|
|
||||||
const isMessageEditable = !isSticker && !hasContact;
|
const isLonelyDraft = drafts.length === 1;
|
||||||
|
const lonelyDraft = isLonelyDraft ? drafts[0] : null;
|
||||||
|
const isLonelyDraftEditable =
|
||||||
|
lonelyDraft != null && isDraftEditable(lonelyDraft);
|
||||||
|
const lonelyLinkPreview = isLonelyDraft
|
||||||
|
? linkPreviewForSource(LinkPreviewSourceType.ForwardMessageModal)
|
||||||
|
: null;
|
||||||
|
|
||||||
const hasSelectedMaximumNumberOfContacts =
|
const hasSelectedMaximumNumberOfContacts =
|
||||||
selectedContacts.length >= MAX_FORWARD;
|
selectedContacts.length >= MAX_FORWARD;
|
||||||
|
@ -121,31 +122,29 @@ export function ForwardMessageModal({
|
||||||
|
|
||||||
const hasContactsSelected = Boolean(selectedContacts.length);
|
const hasContactsSelected = Boolean(selectedContacts.length);
|
||||||
|
|
||||||
const canForwardMessage =
|
const canForwardMessages =
|
||||||
hasContactsSelected &&
|
hasContactsSelected && drafts.every(isDraftForwardable);
|
||||||
(Boolean(messageBodyText) ||
|
|
||||||
isSticker ||
|
|
||||||
hasContact ||
|
|
||||||
(attachmentsToForward && attachmentsToForward.length));
|
|
||||||
|
|
||||||
const forwardMessage = React.useCallback(() => {
|
const forwardMessages = React.useCallback(() => {
|
||||||
if (!canForwardMessage) {
|
if (!canForwardMessages) {
|
||||||
|
showToast(ToastType.CannotForwardEmptyMessage);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const conversationIds = selectedContacts.map(contact => contact.id);
|
||||||
doForwardMessage(
|
if (lonelyDraft != null) {
|
||||||
selectedContacts.map(contact => contact.id),
|
const previews = lonelyLinkPreview ? [lonelyLinkPreview] : [];
|
||||||
messageBodyText,
|
doForwardMessages(conversationIds, [{ ...lonelyDraft, previews }]);
|
||||||
attachmentsToForward,
|
} else {
|
||||||
linkPreview
|
doForwardMessages(conversationIds, drafts);
|
||||||
);
|
}
|
||||||
}, [
|
}, [
|
||||||
attachmentsToForward,
|
drafts,
|
||||||
canForwardMessage,
|
lonelyDraft,
|
||||||
doForwardMessage,
|
lonelyLinkPreview,
|
||||||
linkPreview,
|
doForwardMessages,
|
||||||
messageBodyText,
|
|
||||||
selectedContacts,
|
selectedContacts,
|
||||||
|
canForwardMessages,
|
||||||
|
showToast,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const normalizedSearchTerm = searchTerm.trim();
|
const normalizedSearchTerm = searchTerm.trim();
|
||||||
|
@ -299,52 +298,21 @@ export function ForwardMessageModal({
|
||||||
type="button"
|
type="button"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<h1>{i18n('forwardMessage')}</h1>
|
<h1>{i18n('icu:ForwardMessageModal__title')}</h1>
|
||||||
</div>
|
</div>
|
||||||
{isEditingMessage ? (
|
{isEditingMessage && lonelyDraft != null ? (
|
||||||
<div className="module-ForwardMessageModal__main-body">
|
<ForwardMessageEditor
|
||||||
{linkPreview ? (
|
draft={lonelyDraft}
|
||||||
<div className="module-ForwardMessageModal--link-preview">
|
linkPreview={lonelyLinkPreview}
|
||||||
<StagedLinkPreview
|
onChange={messageBody => {
|
||||||
date={linkPreview.date}
|
onChange([{ ...lonelyDraft, messageBody }]);
|
||||||
description={linkPreview.description || ''}
|
}}
|
||||||
domain={linkPreview.url}
|
removeLinkPreview={removeLinkPreview}
|
||||||
i18n={i18n}
|
theme={theme}
|
||||||
image={linkPreview.image}
|
i18n={i18n}
|
||||||
onClose={() => removeLinkPreview()}
|
RenderCompositionTextArea={RenderCompositionTextArea}
|
||||||
title={linkPreview.title}
|
onSubmit={forwardMessages}
|
||||||
url={linkPreview.url}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{attachmentsToForward && attachmentsToForward.length ? (
|
|
||||||
<AttachmentList
|
|
||||||
attachments={attachmentsToForward}
|
|
||||||
i18n={i18n}
|
|
||||||
onCloseAttachment={(attachment: AttachmentType) => {
|
|
||||||
const newAttachments = attachmentsToForward.filter(
|
|
||||||
currentAttachment => currentAttachment !== attachment
|
|
||||||
);
|
|
||||||
setAttachmentsToForward(newAttachments);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<RenderCompositionTextArea
|
|
||||||
draftText={messageBodyText}
|
|
||||||
onChange={(messageText, bodyRanges, caretLocation?) => {
|
|
||||||
setMessageBodyText(messageText);
|
|
||||||
onEditorStateChange(
|
|
||||||
undefined,
|
|
||||||
messageText,
|
|
||||||
bodyRanges,
|
|
||||||
caretLocation
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
onSubmit={forwardMessage}
|
|
||||||
theme={theme}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="module-ForwardMessageModal__main-body">
|
<div className="module-ForwardMessageModal__main-body">
|
||||||
<SearchInput
|
<SearchInput
|
||||||
|
@ -418,12 +386,12 @@ export function ForwardMessageModal({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{isEditingMessage || !isMessageEditable ? (
|
{isEditingMessage || !isLonelyDraftEditable ? (
|
||||||
<Button
|
<Button
|
||||||
aria-label={i18n('ForwardMessageModal--continue')}
|
aria-label={i18n('ForwardMessageModal--continue')}
|
||||||
className="module-ForwardMessageModal__send-button module-ForwardMessageModal__send-button--forward"
|
className="module-ForwardMessageModal__send-button module-ForwardMessageModal__send-button--forward"
|
||||||
disabled={!canForwardMessage}
|
aria-disabled={!canForwardMessages}
|
||||||
onClick={forwardMessage}
|
onClick={forwardMessages}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
|
@ -440,3 +408,71 @@ export function ForwardMessageModal({
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ForwardMessageEditorProps = Readonly<{
|
||||||
|
draft: MessageForwardDraft;
|
||||||
|
linkPreview: LinkPreviewType | null | void;
|
||||||
|
removeLinkPreview(): void;
|
||||||
|
RenderCompositionTextArea: (
|
||||||
|
props: SmartCompositionTextAreaProps
|
||||||
|
) => JSX.Element;
|
||||||
|
onChange: (messageText: string, caretLocation?: number) => unknown;
|
||||||
|
onSubmit: () => unknown;
|
||||||
|
theme: ThemeType;
|
||||||
|
i18n: LocalizerType;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
function ForwardMessageEditor({
|
||||||
|
draft,
|
||||||
|
linkPreview,
|
||||||
|
i18n,
|
||||||
|
RenderCompositionTextArea,
|
||||||
|
removeLinkPreview,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
theme,
|
||||||
|
}: ForwardMessageEditorProps): JSX.Element {
|
||||||
|
const [attachmentsToForward, setAttachmentsToForward] = useState<
|
||||||
|
ReadonlyArray<AttachmentType>
|
||||||
|
>(draft.attachments ?? []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="module-ForwardMessageModal__main-body">
|
||||||
|
{linkPreview ? (
|
||||||
|
<div className="module-ForwardMessageModal--link-preview">
|
||||||
|
<StagedLinkPreview
|
||||||
|
date={linkPreview.date}
|
||||||
|
description={linkPreview.description ?? ''}
|
||||||
|
domain={linkPreview.url}
|
||||||
|
i18n={i18n}
|
||||||
|
image={linkPreview.image}
|
||||||
|
onClose={removeLinkPreview}
|
||||||
|
title={linkPreview.title}
|
||||||
|
url={linkPreview.url}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{attachmentsToForward && attachmentsToForward.length ? (
|
||||||
|
<AttachmentList
|
||||||
|
attachments={attachmentsToForward}
|
||||||
|
i18n={i18n}
|
||||||
|
onCloseAttachment={(attachment: AttachmentType) => {
|
||||||
|
const newAttachments = attachmentsToForward.filter(
|
||||||
|
currentAttachment => currentAttachment !== attachment
|
||||||
|
);
|
||||||
|
setAttachmentsToForward(newAttachments);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<RenderCompositionTextArea
|
||||||
|
draftText={draft.messageBody ?? ''}
|
||||||
|
onChange={(messageText, _bodyRanges, caretLocation) => {
|
||||||
|
onChange(messageText, caretLocation);
|
||||||
|
}}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
theme={theme}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -4,10 +4,10 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type {
|
import type {
|
||||||
ContactModalStateType,
|
ContactModalStateType,
|
||||||
ForwardMessagePropsType,
|
|
||||||
UserNotFoundModalStateType,
|
UserNotFoundModalStateType,
|
||||||
SafetyNumberChangedBlockingDataType,
|
SafetyNumberChangedBlockingDataType,
|
||||||
AuthorizeArtCreatorDataType,
|
AuthorizeArtCreatorDataType,
|
||||||
|
ForwardMessagesPropsType,
|
||||||
} from '../state/ducks/globalModals';
|
} from '../state/ducks/globalModals';
|
||||||
import type { LocalizerType, ThemeType } from '../types/Util';
|
import type { LocalizerType, ThemeType } from '../types/Util';
|
||||||
import { missingCaseError } from '../util/missingCaseError';
|
import { missingCaseError } from '../util/missingCaseError';
|
||||||
|
@ -35,8 +35,8 @@ export type PropsType = {
|
||||||
title?: string;
|
title?: string;
|
||||||
}) => JSX.Element;
|
}) => JSX.Element;
|
||||||
// ForwardMessageModal
|
// ForwardMessageModal
|
||||||
forwardMessageProps: ForwardMessagePropsType | undefined;
|
forwardMessagesProps: ForwardMessagesPropsType | undefined;
|
||||||
renderForwardMessageModal: () => JSX.Element;
|
renderForwardMessagesModal: () => JSX.Element;
|
||||||
// ProfileEditor
|
// ProfileEditor
|
||||||
isProfileEditorVisible: boolean;
|
isProfileEditorVisible: boolean;
|
||||||
renderProfileEditor: () => JSX.Element;
|
renderProfileEditor: () => JSX.Element;
|
||||||
|
@ -86,8 +86,8 @@ export function GlobalModalContainer({
|
||||||
errorModalProps,
|
errorModalProps,
|
||||||
renderErrorModal,
|
renderErrorModal,
|
||||||
// ForwardMessageModal
|
// ForwardMessageModal
|
||||||
forwardMessageProps,
|
forwardMessagesProps,
|
||||||
renderForwardMessageModal,
|
renderForwardMessagesModal,
|
||||||
// ProfileEditor
|
// ProfileEditor
|
||||||
isProfileEditorVisible,
|
isProfileEditorVisible,
|
||||||
renderProfileEditor,
|
renderProfileEditor,
|
||||||
|
@ -147,8 +147,8 @@ export function GlobalModalContainer({
|
||||||
return renderContactModal();
|
return renderContactModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (forwardMessageProps) {
|
if (forwardMessagesProps) {
|
||||||
return renderForwardMessageModal();
|
return renderForwardMessagesModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isProfileEditorVisible) {
|
if (isProfileEditorVisible) {
|
||||||
|
|
|
@ -13,7 +13,7 @@ import { ToastStickerPackInstallFailed } from './ToastStickerPackInstallFailed';
|
||||||
import { WhatsNewLink } from './WhatsNewLink';
|
import { WhatsNewLink } from './WhatsNewLink';
|
||||||
import { showToast } from '../util/showToast';
|
import { showToast } from '../util/showToast';
|
||||||
import { strictAssert } from '../util/assert';
|
import { strictAssert } from '../util/assert';
|
||||||
import { SelectedMessageSource } from '../state/ducks/conversationsEnums';
|
import { TargetedMessageSource } from '../state/ducks/conversationsEnums';
|
||||||
import { usePrevious } from '../hooks/usePrevious';
|
import { usePrevious } from '../hooks/usePrevious';
|
||||||
|
|
||||||
export type PropsType = {
|
export type PropsType = {
|
||||||
|
@ -28,8 +28,8 @@ export type PropsType = {
|
||||||
renderMiniPlayer: (options: { shouldFlow: boolean }) => JSX.Element;
|
renderMiniPlayer: (options: { shouldFlow: boolean }) => JSX.Element;
|
||||||
scrollToMessage: (conversationId: string, messageId: string) => unknown;
|
scrollToMessage: (conversationId: string, messageId: string) => unknown;
|
||||||
selectedConversationId?: string;
|
selectedConversationId?: string;
|
||||||
selectedMessage?: string;
|
targetedMessage?: string;
|
||||||
selectedMessageSource?: SelectedMessageSource;
|
targetedMessageSource?: TargetedMessageSource;
|
||||||
showConversation: ShowConversationType;
|
showConversation: ShowConversationType;
|
||||||
showWhatsNewModal: () => unknown;
|
showWhatsNewModal: () => unknown;
|
||||||
};
|
};
|
||||||
|
@ -46,8 +46,8 @@ export function Inbox({
|
||||||
renderMiniPlayer,
|
renderMiniPlayer,
|
||||||
scrollToMessage,
|
scrollToMessage,
|
||||||
selectedConversationId,
|
selectedConversationId,
|
||||||
selectedMessage,
|
targetedMessage,
|
||||||
selectedMessageSource,
|
targetedMessageSource,
|
||||||
showConversation,
|
showConversation,
|
||||||
showWhatsNewModal,
|
showWhatsNewModal,
|
||||||
}: PropsType): JSX.Element {
|
}: PropsType): JSX.Element {
|
||||||
|
@ -67,14 +67,14 @@ export function Inbox({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedConversationId) {
|
if (selectedConversationId) {
|
||||||
onConversationOpened(selectedConversationId, selectedMessage);
|
onConversationOpened(selectedConversationId, targetedMessage);
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (
|
||||||
selectedConversationId &&
|
selectedConversationId &&
|
||||||
selectedMessage &&
|
targetedMessage &&
|
||||||
selectedMessageSource !== SelectedMessageSource.Focus
|
targetedMessageSource !== TargetedMessageSource.Focus
|
||||||
) {
|
) {
|
||||||
scrollToMessage(selectedConversationId, selectedMessage);
|
scrollToMessage(selectedConversationId, targetedMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selectedConversationId) {
|
if (!selectedConversationId) {
|
||||||
|
@ -93,8 +93,8 @@ export function Inbox({
|
||||||
prevConversationId,
|
prevConversationId,
|
||||||
scrollToMessage,
|
scrollToMessage,
|
||||||
selectedConversationId,
|
selectedConversationId,
|
||||||
selectedMessage,
|
targetedMessage,
|
||||||
selectedMessageSource,
|
targetedMessageSource,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
@ -242,7 +242,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
selectedConversationId: undefined,
|
selectedConversationId: undefined,
|
||||||
selectedMessageId: undefined,
|
targetedMessageId: undefined,
|
||||||
savePreferredLeftPaneWidth: action('savePreferredLeftPaneWidth'),
|
savePreferredLeftPaneWidth: action('savePreferredLeftPaneWidth'),
|
||||||
searchInConversation: action('searchInConversation'),
|
searchInConversation: action('searchInConversation'),
|
||||||
setComposeSearchTerm: action('setComposeSearchTerm'),
|
setComposeSearchTerm: action('setComposeSearchTerm'),
|
||||||
|
|
|
@ -96,7 +96,7 @@ export type PropsType = {
|
||||||
isMacOS: boolean;
|
isMacOS: boolean;
|
||||||
preferredWidthFromStorage: number;
|
preferredWidthFromStorage: number;
|
||||||
selectedConversationId: undefined | string;
|
selectedConversationId: undefined | string;
|
||||||
selectedMessageId: undefined | string;
|
targetedMessageId: undefined | string;
|
||||||
regionCode: string | undefined;
|
regionCode: string | undefined;
|
||||||
challengeStatus: 'idle' | 'required' | 'pending';
|
challengeStatus: 'idle' | 'required' | 'pending';
|
||||||
setChallengeStatus: (status: 'idle') => void;
|
setChallengeStatus: (status: 'idle') => void;
|
||||||
|
@ -185,7 +185,7 @@ export function LeftPane({
|
||||||
savePreferredLeftPaneWidth,
|
savePreferredLeftPaneWidth,
|
||||||
searchInConversation,
|
searchInConversation,
|
||||||
selectedConversationId,
|
selectedConversationId,
|
||||||
selectedMessageId,
|
targetedMessageId,
|
||||||
setChallengeStatus,
|
setChallengeStatus,
|
||||||
setComposeGroupAvatar,
|
setComposeGroupAvatar,
|
||||||
setComposeGroupExpireTimer,
|
setComposeGroupExpireTimer,
|
||||||
|
@ -372,7 +372,7 @@ export function LeftPane({
|
||||||
conversationToOpen = helper.getConversationAndMessageInDirection(
|
conversationToOpen = helper.getConversationAndMessageInDirection(
|
||||||
toFind,
|
toFind,
|
||||||
selectedConversationId,
|
selectedConversationId,
|
||||||
selectedMessageId
|
targetedMessageId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -404,7 +404,7 @@ export function LeftPane({
|
||||||
isMacOS,
|
isMacOS,
|
||||||
searchInConversation,
|
searchInConversation,
|
||||||
selectedConversationId,
|
selectedConversationId,
|
||||||
selectedMessageId,
|
targetedMessageId,
|
||||||
showChooseGroupMembers,
|
showChooseGroupMembers,
|
||||||
showConversation,
|
showConversation,
|
||||||
showInbox,
|
showInbox,
|
||||||
|
|
|
@ -68,7 +68,7 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => {
|
||||||
media,
|
media,
|
||||||
saveAttachment: action('saveAttachment'),
|
saveAttachment: action('saveAttachment'),
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
toggleForwardMessageModal: action('toggleForwardMessageModal'),
|
toggleForwardMessagesModal: action('toggleForwardMessagesModal'),
|
||||||
onMediaPlaybackStart: noop,
|
onMediaPlaybackStart: noop,
|
||||||
onPrevAttachment: () => {
|
onPrevAttachment: () => {
|
||||||
setSelectedIndex(Math.max(0, selectedIndex - 1));
|
setSelectedIndex(Math.max(0, selectedIndex - 1));
|
||||||
|
|
|
@ -34,7 +34,7 @@ export type PropsType = {
|
||||||
media: ReadonlyArray<ReadonlyDeep<MediaItemType>>;
|
media: ReadonlyArray<ReadonlyDeep<MediaItemType>>;
|
||||||
saveAttachment: SaveAttachmentActionCreatorType;
|
saveAttachment: SaveAttachmentActionCreatorType;
|
||||||
selectedIndex: number;
|
selectedIndex: number;
|
||||||
toggleForwardMessageModal: (messageId: string) => unknown;
|
toggleForwardMessagesModal: (messageIds: ReadonlyArray<string>) => unknown;
|
||||||
onMediaPlaybackStart: () => void;
|
onMediaPlaybackStart: () => void;
|
||||||
onNextAttachment: () => void;
|
onNextAttachment: () => void;
|
||||||
onPrevAttachment: () => void;
|
onPrevAttachment: () => void;
|
||||||
|
@ -77,7 +77,7 @@ export function Lightbox({
|
||||||
isViewOnce = false,
|
isViewOnce = false,
|
||||||
saveAttachment,
|
saveAttachment,
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
toggleForwardMessageModal,
|
toggleForwardMessagesModal,
|
||||||
onMediaPlaybackStart,
|
onMediaPlaybackStart,
|
||||||
onNextAttachment,
|
onNextAttachment,
|
||||||
onPrevAttachment,
|
onPrevAttachment,
|
||||||
|
@ -186,7 +186,7 @@ export function Lightbox({
|
||||||
|
|
||||||
closeLightbox();
|
closeLightbox();
|
||||||
const mediaItem = media[selectedIndex];
|
const mediaItem = media[selectedIndex];
|
||||||
toggleForwardMessageModal(mediaItem.message.id);
|
toggleForwardMessagesModal([mediaItem.message.id]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onKeyDown = useCallback(
|
const onKeyDown = useCallback(
|
||||||
|
|
|
@ -43,11 +43,15 @@ import { ConfirmationDialog } from './ConfirmationDialog';
|
||||||
const MESSAGE_DEFAULT_PROPS = {
|
const MESSAGE_DEFAULT_PROPS = {
|
||||||
canDeleteForEveryone: false,
|
canDeleteForEveryone: false,
|
||||||
checkForAccount: shouldNeverBeCalled,
|
checkForAccount: shouldNeverBeCalled,
|
||||||
clearSelectedMessage: shouldNeverBeCalled,
|
clearTargetedMessage: shouldNeverBeCalled,
|
||||||
containerWidthBreakpoint: WidthBreakpoint.Medium,
|
containerWidthBreakpoint: WidthBreakpoint.Medium,
|
||||||
doubleCheckMissingQuoteReference: shouldNeverBeCalled,
|
doubleCheckMissingQuoteReference: shouldNeverBeCalled,
|
||||||
isBlocked: false,
|
isBlocked: false,
|
||||||
isMessageRequestAccepted: true,
|
isMessageRequestAccepted: true,
|
||||||
|
isSelected: false,
|
||||||
|
isSelectMode: false,
|
||||||
|
onToggleSelect: shouldNeverBeCalled,
|
||||||
|
onReplyToMessage: shouldNeverBeCalled,
|
||||||
kickOffAttachmentDownload: shouldNeverBeCalled,
|
kickOffAttachmentDownload: shouldNeverBeCalled,
|
||||||
markAttachmentAsCorrupted: shouldNeverBeCalled,
|
markAttachmentAsCorrupted: shouldNeverBeCalled,
|
||||||
messageExpanded: shouldNeverBeCalled,
|
messageExpanded: shouldNeverBeCalled,
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { get } from 'lodash';
|
||||||
import type { LocalizerType, ReplacementValuesType } from '../types/Util';
|
import type { LocalizerType, ReplacementValuesType } from '../types/Util';
|
||||||
import { SECOND } from '../util/durations';
|
import { SECOND } from '../util/durations';
|
||||||
import { Toast } from './Toast';
|
import { Toast } from './Toast';
|
||||||
|
@ -71,6 +72,14 @@ export function ToastManager({
|
||||||
return <Toast onClose={hideToast}>{i18n('unblockGroupToSend')}</Toast>;
|
return <Toast onClose={hideToast}>{i18n('unblockGroupToSend')}</Toast>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (toastType === ToastType.CannotForwardEmptyMessage) {
|
||||||
|
return (
|
||||||
|
<Toast onClose={hideToast}>
|
||||||
|
{i18n('icu:ForwardMessagesModal__toast--CannotForwardEmptyMessage')}
|
||||||
|
</Toast>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (toastType === ToastType.CannotMixMultiAndNonMultiAttachments) {
|
if (toastType === ToastType.CannotMixMultiAndNonMultiAttachments) {
|
||||||
return (
|
return (
|
||||||
<Toast onClose={hideToast}>
|
<Toast onClose={hideToast}>
|
||||||
|
@ -302,6 +311,16 @@ export function ToastManager({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (toastType === ToastType.TooManyMessagesToForward) {
|
||||||
|
return (
|
||||||
|
<Toast onClose={hideToast}>
|
||||||
|
{i18n('icu:SelectModeActions__toast--TooManyMessagesToForward', {
|
||||||
|
count: get(toast.parameters, 'count'),
|
||||||
|
})}
|
||||||
|
</Toast>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (toastType === ToastType.UnableToLoadAttachment) {
|
if (toastType === ToastType.UnableToLoadAttachment) {
|
||||||
return <Toast onClose={hideToast}>{i18n('unableToLoadAttachment')}</Toast>;
|
return <Toast onClose={hideToast}>{i18n('unableToLoadAttachment')}</Toast>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -48,6 +48,7 @@ const commonProps = {
|
||||||
|
|
||||||
onArchive: action('onArchive'),
|
onArchive: action('onArchive'),
|
||||||
onMarkUnread: action('onMarkUnread'),
|
onMarkUnread: action('onMarkUnread'),
|
||||||
|
toggleSelectMode: action('toggleSelectMode'),
|
||||||
onMoveToInbox: action('onMoveToInbox'),
|
onMoveToInbox: action('onMoveToInbox'),
|
||||||
pushPanelForConversation: action('pushPanelForConversation'),
|
pushPanelForConversation: action('pushPanelForConversation'),
|
||||||
popPanelForConversation: action('popPanelForConversation'),
|
popPanelForConversation: action('popPanelForConversation'),
|
||||||
|
|
|
@ -88,6 +88,7 @@ export type PropsActionsType = {
|
||||||
destroyMessages: (conversationId: string) => void;
|
destroyMessages: (conversationId: string) => void;
|
||||||
onArchive: (conversationId: string) => void;
|
onArchive: (conversationId: string) => void;
|
||||||
onMarkUnread: (conversationId: string) => void;
|
onMarkUnread: (conversationId: string) => void;
|
||||||
|
toggleSelectMode: (on: boolean) => void;
|
||||||
onMoveToInbox: (conversationId: string) => void;
|
onMoveToInbox: (conversationId: string) => void;
|
||||||
onOutgoingAudioCallInConversation: (conversationId: string) => void;
|
onOutgoingAudioCallInConversation: (conversationId: string) => void;
|
||||||
onOutgoingVideoCallInConversation: (conversationId: string) => void;
|
onOutgoingVideoCallInConversation: (conversationId: string) => void;
|
||||||
|
@ -350,6 +351,7 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
|
||||||
muteExpiresAt,
|
muteExpiresAt,
|
||||||
onArchive,
|
onArchive,
|
||||||
onMarkUnread,
|
onMarkUnread,
|
||||||
|
toggleSelectMode,
|
||||||
onMoveToInbox,
|
onMoveToInbox,
|
||||||
pushPanelForConversation,
|
pushPanelForConversation,
|
||||||
setDisappearingMessages,
|
setDisappearingMessages,
|
||||||
|
@ -505,6 +507,13 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
|
||||||
{i18n('markUnread')}
|
{i18n('markUnread')}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
toggleSelectMode(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{i18n('icu:ConversationHeader__menu__selectMessages')}
|
||||||
|
</MenuItem>
|
||||||
{isArchived ? (
|
{isArchived ? (
|
||||||
<MenuItem onClick={() => onMoveToInbox(id)}>
|
<MenuItem onClick={() => onMoveToInbox(id)}>
|
||||||
{i18n('moveConversationToInbox')}
|
{i18n('moveConversationToInbox')}
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { useEscapeHandling } from '../../hooks/useEscapeHandling';
|
||||||
|
|
||||||
export type PropsType = {
|
export type PropsType = {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
|
@ -13,6 +14,9 @@ export type PropsType = {
|
||||||
renderConversationHeader: () => JSX.Element;
|
renderConversationHeader: () => JSX.Element;
|
||||||
renderTimeline: () => JSX.Element;
|
renderTimeline: () => JSX.Element;
|
||||||
renderPanel: () => JSX.Element | undefined;
|
renderPanel: () => JSX.Element | undefined;
|
||||||
|
isSelectMode: boolean;
|
||||||
|
isForwardModalOpen: boolean;
|
||||||
|
onExitSelectMode: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ConversationView({
|
export function ConversationView({
|
||||||
|
@ -22,6 +26,9 @@ export function ConversationView({
|
||||||
renderConversationHeader,
|
renderConversationHeader,
|
||||||
renderTimeline,
|
renderTimeline,
|
||||||
renderPanel,
|
renderPanel,
|
||||||
|
isSelectMode,
|
||||||
|
isForwardModalOpen,
|
||||||
|
onExitSelectMode,
|
||||||
}: PropsType): JSX.Element {
|
}: PropsType): JSX.Element {
|
||||||
const onDrop = React.useCallback(
|
const onDrop = React.useCallback(
|
||||||
(event: React.DragEvent<HTMLDivElement>) => {
|
(event: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
@ -80,6 +87,10 @@ export function ConversationView({
|
||||||
[conversationId, processAttachments]
|
[conversationId, processAttachments]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEscapeHandling(
|
||||||
|
isSelectMode && !isForwardModalOpen ? onExitSelectMode : undefined
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ConversationView" onDrop={onDrop} onPaste={onPaste}>
|
<div className="ConversationView" onDrop={onDrop} onPaste={onPaste}>
|
||||||
<div className="ConversationView__header">
|
<div className="ConversationView__header">
|
||||||
|
|
|
@ -7,8 +7,8 @@ import { getInteractionMode } from '../../services/InteractionMode';
|
||||||
export type PropsType = {
|
export type PropsType = {
|
||||||
id: string;
|
id: string;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
isSelected: boolean;
|
isTargeted: boolean;
|
||||||
selectMessage?: (messageId: string, conversationId: string) => unknown;
|
targetMessage?: (messageId: string, conversationId: string) => unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class InlineNotificationWrapper extends React.Component<PropsType> {
|
export class InlineNotificationWrapper extends React.Component<PropsType> {
|
||||||
|
@ -24,29 +24,29 @@ export class InlineNotificationWrapper extends React.Component<PropsType> {
|
||||||
|
|
||||||
public handleFocus = (): void => {
|
public handleFocus = (): void => {
|
||||||
if (getInteractionMode() === 'keyboard') {
|
if (getInteractionMode() === 'keyboard') {
|
||||||
this.setSelected();
|
this.setTargeted();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
public setSelected = (): void => {
|
public setTargeted = (): void => {
|
||||||
const { id, conversationId, selectMessage } = this.props;
|
const { id, conversationId, targetMessage } = this.props;
|
||||||
|
|
||||||
if (selectMessage) {
|
if (targetMessage) {
|
||||||
selectMessage(id, conversationId);
|
targetMessage(id, conversationId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
public override componentDidMount(): void {
|
public override componentDidMount(): void {
|
||||||
const { isSelected } = this.props;
|
const { isTargeted } = this.props;
|
||||||
if (isSelected) {
|
if (isTargeted) {
|
||||||
this.setFocus();
|
this.setFocus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public override componentDidUpdate(prevProps: PropsType): void {
|
public override componentDidUpdate(prevProps: PropsType): void {
|
||||||
const { isSelected } = this.props;
|
const { isTargeted } = this.props;
|
||||||
|
|
||||||
if (!prevProps.isSelected && isSelected) {
|
if (!prevProps.isTargeted && isTargeted) {
|
||||||
this.setFocus();
|
this.setFocus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,12 @@
|
||||||
|
|
||||||
/* eslint-disable react/jsx-pascal-case */
|
/* eslint-disable react/jsx-pascal-case */
|
||||||
|
|
||||||
import type { ReactNode, RefObject } from 'react';
|
import type {
|
||||||
|
DetailedHTMLProps,
|
||||||
|
HTMLAttributes,
|
||||||
|
ReactNode,
|
||||||
|
RefObject,
|
||||||
|
} from 'react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
@ -113,7 +118,7 @@ const GROUP_AVATAR_SIZE = AvatarSize.TWENTY_EIGHT;
|
||||||
const STICKER_SIZE = 200;
|
const STICKER_SIZE = 200;
|
||||||
const GIF_SIZE = 300;
|
const GIF_SIZE = 300;
|
||||||
// Note: this needs to match the animation time
|
// Note: this needs to match the animation time
|
||||||
const SELECTED_TIMEOUT = 1200;
|
const TARGETED_TIMEOUT = 1200;
|
||||||
const THREE_HOURS = 3 * 60 * 60 * 1000;
|
const THREE_HOURS = 3 * 60 * 60 * 1000;
|
||||||
const SENT_STATUSES = new Set<MessageStatusType>([
|
const SENT_STATUSES = new Set<MessageStatusType>([
|
||||||
'delivered',
|
'delivered',
|
||||||
|
@ -202,8 +207,10 @@ export type PropsData = {
|
||||||
textDirection: TextDirection;
|
textDirection: TextDirection;
|
||||||
textAttachment?: AttachmentType;
|
textAttachment?: AttachmentType;
|
||||||
isSticker?: boolean;
|
isSticker?: boolean;
|
||||||
isSelected?: boolean;
|
isTargeted?: boolean;
|
||||||
isSelectedCounter?: number;
|
isTargetedCounter?: number;
|
||||||
|
isSelected: boolean;
|
||||||
|
isSelectMode: boolean;
|
||||||
direction: DirectionType;
|
direction: DirectionType;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
status?: MessageStatusType;
|
status?: MessageStatusType;
|
||||||
|
@ -297,7 +304,7 @@ export type PropsHousekeeping = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PropsActions = {
|
export type PropsActions = {
|
||||||
clearSelectedMessage: () => unknown;
|
clearTargetedMessage: () => unknown;
|
||||||
doubleCheckMissingQuoteReference: (messageId: string) => unknown;
|
doubleCheckMissingQuoteReference: (messageId: string) => unknown;
|
||||||
messageExpanded: (id: string, displayLimit: number) => unknown;
|
messageExpanded: (id: string, displayLimit: number) => unknown;
|
||||||
checkForAccount: (phoneNumber: string) => unknown;
|
checkForAccount: (phoneNumber: string) => unknown;
|
||||||
|
@ -328,11 +335,14 @@ export type PropsActions = {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
sentAt: number;
|
sentAt: number;
|
||||||
}) => void;
|
}) => void;
|
||||||
selectMessage?: (messageId: string, conversationId: string) => unknown;
|
targetMessage?: (messageId: string, conversationId: string) => unknown;
|
||||||
|
|
||||||
showExpiredIncomingTapToViewToast: () => unknown;
|
showExpiredIncomingTapToViewToast: () => unknown;
|
||||||
showExpiredOutgoingTapToViewToast: () => unknown;
|
showExpiredOutgoingTapToViewToast: () => unknown;
|
||||||
viewStory: ViewStoryActionCreatorType;
|
viewStory: ViewStoryActionCreatorType;
|
||||||
|
|
||||||
|
onToggleSelect: (selected: boolean, shift: boolean) => void;
|
||||||
|
onReplyToMessage: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Props = PropsData & PropsHousekeeping & PropsActions;
|
export type Props = PropsData & PropsHousekeeping & PropsActions;
|
||||||
|
@ -344,8 +354,8 @@ type State = {
|
||||||
expired: boolean;
|
expired: boolean;
|
||||||
imageBroken: boolean;
|
imageBroken: boolean;
|
||||||
|
|
||||||
isSelected?: boolean;
|
isTargeted?: boolean;
|
||||||
prevSelectedCounter?: number;
|
prevTargetedCounter?: number;
|
||||||
|
|
||||||
reactionViewerRoot: HTMLDivElement | null;
|
reactionViewerRoot: HTMLDivElement | null;
|
||||||
reactionViewerOutsideClickDestructor?: () => void;
|
reactionViewerOutsideClickDestructor?: () => void;
|
||||||
|
@ -372,7 +382,7 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
|
|
||||||
public expiredTimeout: NodeJS.Timeout | undefined;
|
public expiredTimeout: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
public selectedTimeout: NodeJS.Timeout | undefined;
|
public targetedTimeout: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
public deleteForEveryoneTimeout: NodeJS.Timeout | undefined;
|
public deleteForEveryoneTimeout: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
|
@ -386,8 +396,8 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
expired: false,
|
expired: false,
|
||||||
imageBroken: false,
|
imageBroken: false,
|
||||||
|
|
||||||
isSelected: props.isSelected,
|
isTargeted: props.isTargeted,
|
||||||
prevSelectedCounter: props.isSelectedCounter,
|
prevTargetedCounter: props.isTargetedCounter,
|
||||||
|
|
||||||
reactionViewerRoot: null,
|
reactionViewerRoot: null,
|
||||||
|
|
||||||
|
@ -400,22 +410,22 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static getDerivedStateFromProps(props: Props, state: State): State {
|
public static getDerivedStateFromProps(props: Props, state: State): State {
|
||||||
if (!props.isSelected) {
|
if (!props.isTargeted) {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
isSelected: false,
|
isTargeted: false,
|
||||||
prevSelectedCounter: 0,
|
prevTargetedCounter: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
props.isSelected &&
|
props.isTargeted &&
|
||||||
props.isSelectedCounter !== state.prevSelectedCounter
|
props.isTargetedCounter !== state.prevTargetedCounter
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
isSelected: props.isSelected,
|
isTargeted: props.isTargeted,
|
||||||
prevSelectedCounter: props.isSelectedCounter,
|
prevTargetedCounter: props.isTargetedCounter,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -428,10 +438,10 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
}
|
}
|
||||||
|
|
||||||
public handleFocus = (): void => {
|
public handleFocus = (): void => {
|
||||||
const { interactionMode, isSelected } = this.props;
|
const { interactionMode, isTargeted } = this.props;
|
||||||
|
|
||||||
if (interactionMode === 'keyboard' && !isSelected) {
|
if (interactionMode === 'keyboard' && !isTargeted) {
|
||||||
this.setSelected();
|
this.setTargeted();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -445,11 +455,11 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
public setSelected = (): void => {
|
public setTargeted = (): void => {
|
||||||
const { id, conversationId, selectMessage } = this.props;
|
const { id, conversationId, targetMessage } = this.props;
|
||||||
|
|
||||||
if (selectMessage) {
|
if (targetMessage) {
|
||||||
selectMessage(id, conversationId);
|
targetMessage(id, conversationId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -465,12 +475,12 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
const { conversationId } = this.props;
|
const { conversationId } = this.props;
|
||||||
window.ConversationController?.onConvoMessageMount(conversationId);
|
window.ConversationController?.onConvoMessageMount(conversationId);
|
||||||
|
|
||||||
this.startSelectedTimer();
|
this.startTargetedTimer();
|
||||||
this.startDeleteForEveryoneTimerIfApplicable();
|
this.startDeleteForEveryoneTimerIfApplicable();
|
||||||
this.startGiftBadgeInterval();
|
this.startGiftBadgeInterval();
|
||||||
|
|
||||||
const { isSelected } = this.props;
|
const { isTargeted } = this.props;
|
||||||
if (isSelected) {
|
if (isTargeted) {
|
||||||
this.setFocus();
|
this.setFocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -493,7 +503,7 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
}
|
}
|
||||||
|
|
||||||
public override componentWillUnmount(): void {
|
public override componentWillUnmount(): void {
|
||||||
clearTimeoutIfNecessary(this.selectedTimeout);
|
clearTimeoutIfNecessary(this.targetedTimeout);
|
||||||
clearTimeoutIfNecessary(this.expirationCheckInterval);
|
clearTimeoutIfNecessary(this.expirationCheckInterval);
|
||||||
clearTimeoutIfNecessary(this.expiredTimeout);
|
clearTimeoutIfNecessary(this.expiredTimeout);
|
||||||
clearTimeoutIfNecessary(this.deleteForEveryoneTimeout);
|
clearTimeoutIfNecessary(this.deleteForEveryoneTimeout);
|
||||||
|
@ -502,12 +512,12 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
}
|
}
|
||||||
|
|
||||||
public override componentDidUpdate(prevProps: Readonly<Props>): void {
|
public override componentDidUpdate(prevProps: Readonly<Props>): void {
|
||||||
const { isSelected, status, timestamp } = this.props;
|
const { isTargeted, status, timestamp } = this.props;
|
||||||
|
|
||||||
this.startSelectedTimer();
|
this.startTargetedTimer();
|
||||||
this.startDeleteForEveryoneTimerIfApplicable();
|
this.startDeleteForEveryoneTimerIfApplicable();
|
||||||
|
|
||||||
if (!prevProps.isSelected && isSelected) {
|
if (!prevProps.isTargeted && isTargeted) {
|
||||||
this.setFocus();
|
this.setFocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -610,20 +620,20 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public startSelectedTimer(): void {
|
public startTargetedTimer(): void {
|
||||||
const { clearSelectedMessage, interactionMode } = this.props;
|
const { clearTargetedMessage, interactionMode } = this.props;
|
||||||
const { isSelected } = this.state;
|
const { isTargeted } = this.state;
|
||||||
|
|
||||||
if (interactionMode === 'keyboard' || !isSelected) {
|
if (interactionMode === 'keyboard' || !isTargeted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.selectedTimeout) {
|
if (!this.targetedTimeout) {
|
||||||
this.selectedTimeout = setTimeout(() => {
|
this.targetedTimeout = setTimeout(() => {
|
||||||
this.selectedTimeout = undefined;
|
this.targetedTimeout = undefined;
|
||||||
this.setState({ isSelected: false });
|
this.setState({ isTargeted: false });
|
||||||
clearSelectedMessage();
|
clearTargetedMessage();
|
||||||
}, SELECTED_TIMEOUT);
|
}, TARGETED_TIMEOUT);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2450,7 +2460,7 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
text,
|
text,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const { isSelected } = this.state;
|
const { isTargeted } = this.state;
|
||||||
|
|
||||||
const isAttachmentPending = this.isAttachmentPending();
|
const isAttachmentPending = this.isAttachmentPending();
|
||||||
|
|
||||||
|
@ -2462,7 +2472,7 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
|
|
||||||
// If it's a mostly-normal gray incoming text box, we don't want to darken it as much
|
// If it's a mostly-normal gray incoming text box, we don't want to darken it as much
|
||||||
const lighterSelect =
|
const lighterSelect =
|
||||||
isSelected &&
|
isTargeted &&
|
||||||
direction === 'incoming' &&
|
direction === 'incoming' &&
|
||||||
!isStickerLike &&
|
!isStickerLike &&
|
||||||
(text || (!isVideo(attachments) && !isImage(attachments)));
|
(text || (!isVideo(attachments) && !isImage(attachments)));
|
||||||
|
@ -2470,8 +2480,8 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
const containerClassnames = classNames(
|
const containerClassnames = classNames(
|
||||||
'module-message__container',
|
'module-message__container',
|
||||||
isGIF(attachments) ? 'module-message__container--gif' : null,
|
isGIF(attachments) ? 'module-message__container--gif' : null,
|
||||||
isSelected ? 'module-message__container--selected' : null,
|
isTargeted ? 'module-message__container--targeted' : null,
|
||||||
lighterSelect ? 'module-message__container--selected-lighter' : null,
|
lighterSelect ? 'module-message__container--targeted-lighter' : null,
|
||||||
!isStickerLike ? `module-message__container--${direction}` : null,
|
!isStickerLike ? `module-message__container--${direction}` : null,
|
||||||
isEmojiOnly ? 'module-message__container--emoji' : null,
|
isEmojiOnly ? 'module-message__container--emoji' : null,
|
||||||
isTapToView ? 'module-message__container--with-tap-to-view' : null,
|
isTapToView ? 'module-message__container--with-tap-to-view' : null,
|
||||||
|
@ -2525,18 +2535,42 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderAltAccessibilityTree(): JSX.Element {
|
||||||
|
const { id, i18n, author } = this.props;
|
||||||
|
return (
|
||||||
|
<span className="module-message__alt-accessibility-tree">
|
||||||
|
<span id={`message-accessibility-label:${id}`}>
|
||||||
|
{author.isMe
|
||||||
|
? i18n('icu:messageAccessibilityLabel--outgoing', {})
|
||||||
|
: i18n('icu:messageAccessibilityLabel--incoming', {
|
||||||
|
author: author.title,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span id={`message-accessibility-description:${id}`}>
|
||||||
|
{this.renderText()}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public override render(): JSX.Element | null {
|
public override render(): JSX.Element | null {
|
||||||
const {
|
const {
|
||||||
|
id,
|
||||||
attachments,
|
attachments,
|
||||||
direction,
|
direction,
|
||||||
isSticker,
|
isSticker,
|
||||||
|
isSelected,
|
||||||
|
isSelectMode,
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
renderMenu,
|
renderMenu,
|
||||||
shouldCollapseAbove,
|
shouldCollapseAbove,
|
||||||
shouldCollapseBelow,
|
shouldCollapseBelow,
|
||||||
timestamp,
|
timestamp,
|
||||||
|
onToggleSelect,
|
||||||
|
onReplyToMessage,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const { expired, expiring, isSelected, imageBroken } = this.state;
|
const { expired, expiring, isTargeted, imageBroken } = this.state;
|
||||||
|
|
||||||
if (expired) {
|
if (expired) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -2546,29 +2580,85 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let wrapperProps: DetailedHTMLProps<
|
||||||
|
HTMLAttributes<HTMLDivElement>,
|
||||||
|
HTMLDivElement
|
||||||
|
>;
|
||||||
|
|
||||||
|
if (isSelectMode) {
|
||||||
|
wrapperProps = {
|
||||||
|
role: 'checkbox',
|
||||||
|
'aria-checked': isSelected,
|
||||||
|
'aria-labelledby': `message-accessibility-label:${id}`,
|
||||||
|
'aria-describedby': `message-accessibility-description:${id}`,
|
||||||
|
tabIndex: 0,
|
||||||
|
onClick: event => {
|
||||||
|
event.preventDefault();
|
||||||
|
onToggleSelect(!isSelected, event.shiftKey);
|
||||||
|
},
|
||||||
|
onKeyDown: event => {
|
||||||
|
if (event.code === 'Space') {
|
||||||
|
event.preventDefault();
|
||||||
|
onToggleSelect(!isSelected, event.shiftKey);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
wrapperProps = {
|
||||||
|
onDoubleClick: event => {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
if (!isSelectMode) {
|
||||||
|
onReplyToMessage();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'module-message',
|
'module-message__wrapper',
|
||||||
`module-message--${direction}`,
|
isSelectMode && 'module-message__wrapper--select-mode',
|
||||||
shouldCollapseAbove && 'module-message--collapsed-above',
|
isSelected && 'module-message__wrapper--selected'
|
||||||
shouldCollapseBelow && 'module-message--collapsed-below',
|
|
||||||
isSelected ? 'module-message--selected' : null,
|
|
||||||
expiring ? 'module-message--expired' : null
|
|
||||||
)}
|
)}
|
||||||
data-testid={timestamp}
|
{...wrapperProps}
|
||||||
tabIndex={0}
|
|
||||||
// We need to have a role because screenreaders need to be able to focus here to
|
|
||||||
// read the message, but we can't be a button; that would break inner buttons.
|
|
||||||
role="row"
|
|
||||||
onKeyDown={onKeyDown}
|
|
||||||
onFocus={this.handleFocus}
|
|
||||||
ref={this.focusRef}
|
|
||||||
>
|
>
|
||||||
{this.renderError()}
|
{isSelectMode && (
|
||||||
{this.renderAvatar()}
|
<>
|
||||||
{this.renderContainer()}
|
<span
|
||||||
{renderMenu?.()}
|
role="presentation"
|
||||||
|
className="module-message__select-checkbox"
|
||||||
|
/>
|
||||||
|
{this.renderAltAccessibilityTree()}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'module-message',
|
||||||
|
`module-message--${direction}`,
|
||||||
|
shouldCollapseAbove && 'module-message--collapsed-above',
|
||||||
|
shouldCollapseBelow && 'module-message--collapsed-below',
|
||||||
|
isTargeted ? 'module-message--targeted' : null,
|
||||||
|
expiring ? 'module-message--expired' : null
|
||||||
|
)}
|
||||||
|
data-testid={timestamp}
|
||||||
|
tabIndex={0}
|
||||||
|
// We need to have a role because screenreaders need to be able to focus here to
|
||||||
|
// read the message, but we can't be a button; that would break inner buttons.
|
||||||
|
role="row"
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
onFocus={this.handleFocus}
|
||||||
|
ref={this.focusRef}
|
||||||
|
// @ts-expect-error -- React/TS doesn't know about inert
|
||||||
|
// eslint-disable-next-line react/no-unknown-property
|
||||||
|
inert={isSelectMode ? '' : undefined}
|
||||||
|
>
|
||||||
|
{this.renderError()}
|
||||||
|
{this.renderAvatar()}
|
||||||
|
{this.renderContainer()}
|
||||||
|
{renderMenu?.()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -40,6 +40,8 @@ const defaultMessage: MessageDataPropsType = {
|
||||||
renderMenu: undefined,
|
renderMenu: undefined,
|
||||||
isBlocked: false,
|
isBlocked: false,
|
||||||
isMessageRequestAccepted: true,
|
isMessageRequestAccepted: true,
|
||||||
|
isSelected: false,
|
||||||
|
isSelectMode: false,
|
||||||
previews: [],
|
previews: [],
|
||||||
readStatus: ReadStatus.Read,
|
readStatus: ReadStatus.Read,
|
||||||
status: 'sent',
|
status: 'sent',
|
||||||
|
@ -72,7 +74,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
toggleSafetyNumberModal: action('toggleSafetyNumberModal'),
|
toggleSafetyNumberModal: action('toggleSafetyNumberModal'),
|
||||||
|
|
||||||
checkForAccount: action('checkForAccount'),
|
checkForAccount: action('checkForAccount'),
|
||||||
clearSelectedMessage: action('clearSelectedMessage'),
|
clearTargetedMessage: action('clearTargetedMessage'),
|
||||||
showLightboxForViewOnceMedia: action('showLightboxForViewOnceMedia'),
|
showLightboxForViewOnceMedia: action('showLightboxForViewOnceMedia'),
|
||||||
doubleCheckMissingQuoteReference: action('doubleCheckMissingQuoteReference'),
|
doubleCheckMissingQuoteReference: action('doubleCheckMissingQuoteReference'),
|
||||||
kickOffAttachmentDownload: action('kickOffAttachmentDownload'),
|
kickOffAttachmentDownload: action('kickOffAttachmentDownload'),
|
||||||
|
|
|
@ -75,7 +75,7 @@ export type PropsSmartActions = Pick<MessagePropsType, 'renderAudioAttachment'>;
|
||||||
export type PropsReduxActions = Pick<
|
export type PropsReduxActions = Pick<
|
||||||
MessagePropsType,
|
MessagePropsType,
|
||||||
| 'checkForAccount'
|
| 'checkForAccount'
|
||||||
| 'clearSelectedMessage'
|
| 'clearTargetedMessage'
|
||||||
| 'doubleCheckMissingQuoteReference'
|
| 'doubleCheckMissingQuoteReference'
|
||||||
| 'kickOffAttachmentDownload'
|
| 'kickOffAttachmentDownload'
|
||||||
| 'markAttachmentAsCorrupted'
|
| 'markAttachmentAsCorrupted'
|
||||||
|
@ -268,7 +268,7 @@ export class MessageDetail extends React.Component<Props> {
|
||||||
sentAt,
|
sentAt,
|
||||||
|
|
||||||
checkForAccount,
|
checkForAccount,
|
||||||
clearSelectedMessage,
|
clearTargetedMessage,
|
||||||
contactNameColor,
|
contactNameColor,
|
||||||
showLightboxForViewOnceMedia,
|
showLightboxForViewOnceMedia,
|
||||||
doubleCheckMissingQuoteReference,
|
doubleCheckMissingQuoteReference,
|
||||||
|
@ -306,7 +306,7 @@ export class MessageDetail extends React.Component<Props> {
|
||||||
{...message}
|
{...message}
|
||||||
renderingContext="conversation/MessageDetail"
|
renderingContext="conversation/MessageDetail"
|
||||||
checkForAccount={checkForAccount}
|
checkForAccount={checkForAccount}
|
||||||
clearSelectedMessage={clearSelectedMessage}
|
clearTargetedMessage={clearTargetedMessage}
|
||||||
contactNameColor={contactNameColor}
|
contactNameColor={contactNameColor}
|
||||||
containerElementRef={this.messageContainerRef}
|
containerElementRef={this.messageContainerRef}
|
||||||
containerWidthBreakpoint={WidthBreakpoint.Wide}
|
containerWidthBreakpoint={WidthBreakpoint.Wide}
|
||||||
|
@ -343,6 +343,8 @@ export class MessageDetail extends React.Component<Props> {
|
||||||
startConversation={startConversation}
|
startConversation={startConversation}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
viewStory={viewStory}
|
viewStory={viewStory}
|
||||||
|
onToggleSelect={noop}
|
||||||
|
onReplyToMessage={noop}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<table className="module-message-detail__info">
|
<table className="module-message-detail__info">
|
||||||
|
|
|
@ -87,14 +87,14 @@ const defaultMessageProps: TimelineMessagesProps = {
|
||||||
canDeleteForEveryone: true,
|
canDeleteForEveryone: true,
|
||||||
canDownload: true,
|
canDownload: true,
|
||||||
checkForAccount: action('checkForAccount'),
|
checkForAccount: action('checkForAccount'),
|
||||||
clearSelectedMessage: action('default--clearSelectedMessage'),
|
clearTargetedMessage: action('default--clearTargetedMessage'),
|
||||||
containerElementRef: React.createRef<HTMLElement>(),
|
containerElementRef: React.createRef<HTMLElement>(),
|
||||||
containerWidthBreakpoint: WidthBreakpoint.Wide,
|
containerWidthBreakpoint: WidthBreakpoint.Wide,
|
||||||
conversationColor: 'crimson',
|
conversationColor: 'crimson',
|
||||||
conversationId: 'conversationId',
|
conversationId: 'conversationId',
|
||||||
conversationTitle: 'Conversation Title',
|
conversationTitle: 'Conversation Title',
|
||||||
conversationType: 'direct', // override
|
conversationType: 'direct', // override
|
||||||
deleteMessage: action('default--deleteMessage'),
|
deleteMessages: action('default--deleteMessages'),
|
||||||
deleteMessageForEveryone: action('default--deleteMessageForEveryone'),
|
deleteMessageForEveryone: action('default--deleteMessageForEveryone'),
|
||||||
direction: 'incoming',
|
direction: 'incoming',
|
||||||
showLightboxForViewOnceMedia: action('default--showLightboxForViewOnceMedia'),
|
showLightboxForViewOnceMedia: action('default--showLightboxForViewOnceMedia'),
|
||||||
|
@ -108,6 +108,9 @@ const defaultMessageProps: TimelineMessagesProps = {
|
||||||
interactionMode: 'keyboard',
|
interactionMode: 'keyboard',
|
||||||
isBlocked: false,
|
isBlocked: false,
|
||||||
isMessageRequestAccepted: true,
|
isMessageRequestAccepted: true,
|
||||||
|
isSelected: false,
|
||||||
|
isSelectMode: false,
|
||||||
|
toggleSelectMessage: action('toggleSelectMessage'),
|
||||||
kickOffAttachmentDownload: action('default--kickOffAttachmentDownload'),
|
kickOffAttachmentDownload: action('default--kickOffAttachmentDownload'),
|
||||||
markAttachmentAsCorrupted: action('default--markAttachmentAsCorrupted'),
|
markAttachmentAsCorrupted: action('default--markAttachmentAsCorrupted'),
|
||||||
messageExpanded: action('default--message-expanded'),
|
messageExpanded: action('default--message-expanded'),
|
||||||
|
@ -124,7 +127,7 @@ const defaultMessageProps: TimelineMessagesProps = {
|
||||||
retryDeleteForEveryone: action('default--retryDeleteForEveryone'),
|
retryDeleteForEveryone: action('default--retryDeleteForEveryone'),
|
||||||
saveAttachment: action('saveAttachment'),
|
saveAttachment: action('saveAttachment'),
|
||||||
scrollToQuotedMessage: action('default--scrollToQuotedMessage'),
|
scrollToQuotedMessage: action('default--scrollToQuotedMessage'),
|
||||||
selectMessage: action('default--selectMessage'),
|
targetMessage: action('default--targetMessage'),
|
||||||
shouldCollapseAbove: false,
|
shouldCollapseAbove: false,
|
||||||
shouldCollapseBelow: false,
|
shouldCollapseBelow: false,
|
||||||
shouldHideMetadata: false,
|
shouldHideMetadata: false,
|
||||||
|
@ -136,7 +139,7 @@ const defaultMessageProps: TimelineMessagesProps = {
|
||||||
showExpiredOutgoingTapToViewToast: action(
|
showExpiredOutgoingTapToViewToast: action(
|
||||||
'showExpiredOutgoingTapToViewToast'
|
'showExpiredOutgoingTapToViewToast'
|
||||||
),
|
),
|
||||||
toggleForwardMessageModal: action('default--toggleForwardMessageModal'),
|
toggleForwardMessagesModal: action('default--toggleForwardMessagesModal'),
|
||||||
showLightbox: action('default--showLightbox'),
|
showLightbox: action('default--showLightbox'),
|
||||||
startConversation: action('default--startConversation'),
|
startConversation: action('default--startConversation'),
|
||||||
status: 'sent',
|
status: 'sent',
|
||||||
|
|
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!',
|
text: 'Hello there from the new world!',
|
||||||
isBlocked: false,
|
isBlocked: false,
|
||||||
isMessageRequestAccepted: true,
|
isMessageRequestAccepted: true,
|
||||||
|
isSelected: false,
|
||||||
|
isSelectMode: false,
|
||||||
previews: [],
|
previews: [],
|
||||||
readStatus: ReadStatus.Read,
|
readStatus: ReadStatus.Read,
|
||||||
canRetryDeleteForEveryone: true,
|
canRetryDeleteForEveryone: true,
|
||||||
|
@ -270,15 +272,16 @@ const actions = () => ({
|
||||||
loadNewerMessages: action('loadNewerMessages'),
|
loadNewerMessages: action('loadNewerMessages'),
|
||||||
loadNewestMessages: action('loadNewestMessages'),
|
loadNewestMessages: action('loadNewestMessages'),
|
||||||
markMessageRead: action('markMessageRead'),
|
markMessageRead: action('markMessageRead'),
|
||||||
selectMessage: action('selectMessage'),
|
toggleSelectMessage: action('toggleSelectMessage'),
|
||||||
clearSelectedMessage: action('clearSelectedMessage'),
|
targetMessage: action('targetMessage'),
|
||||||
|
clearTargetedMessage: action('clearTargetedMessage'),
|
||||||
updateSharedGroups: action('updateSharedGroups'),
|
updateSharedGroups: action('updateSharedGroups'),
|
||||||
|
|
||||||
reactToMessage: action('reactToMessage'),
|
reactToMessage: action('reactToMessage'),
|
||||||
setQuoteByMessageId: action('setQuoteByMessageId'),
|
setQuoteByMessageId: action('setQuoteByMessageId'),
|
||||||
retryDeleteForEveryone: action('retryDeleteForEveryone'),
|
retryDeleteForEveryone: action('retryDeleteForEveryone'),
|
||||||
retryMessageSend: action('retryMessageSend'),
|
retryMessageSend: action('retryMessageSend'),
|
||||||
deleteMessage: action('deleteMessage'),
|
deleteMessages: action('deleteMessages'),
|
||||||
deleteMessageForEveryone: action('deleteMessageForEveryone'),
|
deleteMessageForEveryone: action('deleteMessageForEveryone'),
|
||||||
saveAttachment: action('saveAttachment'),
|
saveAttachment: action('saveAttachment'),
|
||||||
pushPanelForConversation: action('pushPanelForConversation'),
|
pushPanelForConversation: action('pushPanelForConversation'),
|
||||||
|
@ -300,7 +303,7 @@ const actions = () => ({
|
||||||
showExpiredOutgoingTapToViewToast: action(
|
showExpiredOutgoingTapToViewToast: action(
|
||||||
'showExpiredOutgoingTapToViewToast'
|
'showExpiredOutgoingTapToViewToast'
|
||||||
),
|
),
|
||||||
toggleForwardMessageModal: action('toggleForwardMessageModal'),
|
toggleForwardMessagesModal: action('toggleForwardMessagesModal'),
|
||||||
|
|
||||||
toggleSafetyNumberModal: action('toggleSafetyNumberModal'),
|
toggleSafetyNumberModal: action('toggleSafetyNumberModal'),
|
||||||
|
|
||||||
|
@ -320,6 +323,8 @@ const actions = () => ({
|
||||||
peekGroupCallIfItHasMembers: action('peekGroupCallIfItHasMembers'),
|
peekGroupCallIfItHasMembers: action('peekGroupCallIfItHasMembers'),
|
||||||
|
|
||||||
viewStory: action('viewStory'),
|
viewStory: action('viewStory'),
|
||||||
|
|
||||||
|
onReplyToMessage: action('onReplyToMessage'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderItem = ({
|
const renderItem = ({
|
||||||
|
@ -334,7 +339,7 @@ const renderItem = ({
|
||||||
<TimelineItem
|
<TimelineItem
|
||||||
getPreferredBadge={() => undefined}
|
getPreferredBadge={() => undefined}
|
||||||
id=""
|
id=""
|
||||||
isSelected={false}
|
isTargeted={false}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
interactionMode="keyboard"
|
interactionMode="keyboard"
|
||||||
isNextItemCallingNotification={false}
|
isNextItemCallingNotification={false}
|
||||||
|
|
|
@ -100,6 +100,7 @@ type PropsHousekeepingType = {
|
||||||
isSomeoneTyping: boolean;
|
isSomeoneTyping: boolean;
|
||||||
unreadCount?: number;
|
unreadCount?: number;
|
||||||
|
|
||||||
|
targetedMessageId?: string;
|
||||||
invitedContactsForNewlyCreatedGroup: Array<ConversationType>;
|
invitedContactsForNewlyCreatedGroup: Array<ConversationType>;
|
||||||
selectedMessageId?: string;
|
selectedMessageId?: string;
|
||||||
shouldShowMiniPlayer: boolean;
|
shouldShowMiniPlayer: boolean;
|
||||||
|
@ -146,7 +147,7 @@ export type PropsActionsType = {
|
||||||
groupNameCollisions: ReadonlyDeep<GroupNameCollisionsWithIdsByTitle>
|
groupNameCollisions: ReadonlyDeep<GroupNameCollisionsWithIdsByTitle>
|
||||||
) => void;
|
) => void;
|
||||||
clearInvitedUuidsForNewlyCreatedGroup: () => void;
|
clearInvitedUuidsForNewlyCreatedGroup: () => void;
|
||||||
clearSelectedMessage: () => unknown;
|
clearTargetedMessage: () => unknown;
|
||||||
closeContactSpoofingReview: () => void;
|
closeContactSpoofingReview: () => void;
|
||||||
loadOlderMessages: (conversationId: string, messageId: string) => unknown;
|
loadOlderMessages: (conversationId: string, messageId: string) => unknown;
|
||||||
loadNewerMessages: (conversationId: string, messageId: string) => unknown;
|
loadNewerMessages: (conversationId: string, messageId: string) => unknown;
|
||||||
|
@ -156,7 +157,7 @@ export type PropsActionsType = {
|
||||||
setFocus?: boolean
|
setFocus?: boolean
|
||||||
) => unknown;
|
) => unknown;
|
||||||
markMessageRead: (conversationId: string, messageId: string) => unknown;
|
markMessageRead: (conversationId: string, messageId: string) => unknown;
|
||||||
selectMessage: (messageId: string, conversationId: string) => unknown;
|
targetMessage: (messageId: string, conversationId: string) => unknown;
|
||||||
setIsNearBottom: (conversationId: string, isNearBottom: boolean) => unknown;
|
setIsNearBottom: (conversationId: string, isNearBottom: boolean) => unknown;
|
||||||
peekGroupCallForTheFirstTime: (conversationId: string) => unknown;
|
peekGroupCallForTheFirstTime: (conversationId: string) => unknown;
|
||||||
peekGroupCallIfItHasMembers: (conversationId: string) => unknown;
|
peekGroupCallIfItHasMembers: (conversationId: string) => unknown;
|
||||||
|
@ -240,12 +241,12 @@ export class Timeline extends React.Component<
|
||||||
}
|
}
|
||||||
|
|
||||||
private scrollToBottom = (setFocus?: boolean): void => {
|
private scrollToBottom = (setFocus?: boolean): void => {
|
||||||
const { selectMessage, id, items } = this.props;
|
const { targetMessage, id, items } = this.props;
|
||||||
|
|
||||||
if (setFocus && items && items.length > 0) {
|
if (setFocus && items && items.length > 0) {
|
||||||
const lastIndex = items.length - 1;
|
const lastIndex = items.length - 1;
|
||||||
const lastMessageId = items[lastIndex];
|
const lastMessageId = items[lastIndex];
|
||||||
selectMessage(lastMessageId, id);
|
targetMessage(lastMessageId, id);
|
||||||
} else {
|
} else {
|
||||||
const containerEl = this.containerRef.current;
|
const containerEl = this.containerRef.current;
|
||||||
if (containerEl) {
|
if (containerEl) {
|
||||||
|
@ -266,7 +267,7 @@ export class Timeline extends React.Component<
|
||||||
loadNewestMessages,
|
loadNewestMessages,
|
||||||
messageLoadingState,
|
messageLoadingState,
|
||||||
oldestUnseenIndex,
|
oldestUnseenIndex,
|
||||||
selectMessage,
|
targetMessage,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const { newestBottomVisibleMessageId } = this.state;
|
const { newestBottomVisibleMessageId } = this.state;
|
||||||
|
|
||||||
|
@ -287,7 +288,7 @@ export class Timeline extends React.Component<
|
||||||
) {
|
) {
|
||||||
if (setFocus) {
|
if (setFocus) {
|
||||||
const messageId = items[oldestUnseenIndex];
|
const messageId = items[oldestUnseenIndex];
|
||||||
selectMessage(messageId, id);
|
targetMessage(messageId, id);
|
||||||
} else {
|
} else {
|
||||||
this.scrollToItemIndex(oldestUnseenIndex);
|
this.scrollToItemIndex(oldestUnseenIndex);
|
||||||
}
|
}
|
||||||
|
@ -642,15 +643,15 @@ export class Timeline extends React.Component<
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleBlur = (event: React.FocusEvent): void => {
|
private handleBlur = (event: React.FocusEvent): void => {
|
||||||
const { clearSelectedMessage } = this.props;
|
const { clearTargetedMessage } = this.props;
|
||||||
|
|
||||||
const { currentTarget } = event;
|
const { currentTarget } = event;
|
||||||
|
|
||||||
// Thanks to https://gist.github.com/pstoica/4323d3e6e37e8a23dd59
|
// Thanks to https://gist.github.com/pstoica/4323d3e6e37e8a23dd59
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// If focus moved to one of our portals, we do not clear the selected
|
// If focus moved to one of our portals, we do not clear the targeted
|
||||||
// message so that focus stays inside the portal. We need to be careful
|
// message so that focus stays inside the portal. We need to be careful
|
||||||
// to not create colliding keyboard shortcuts between selected messages
|
// to not create colliding keyboard shortcuts between targeted messages
|
||||||
// and our portals!
|
// and our portals!
|
||||||
const portals = Array.from(
|
const portals = Array.from(
|
||||||
document.querySelectorAll('body > div:not(.inbox)')
|
document.querySelectorAll('body > div:not(.inbox)')
|
||||||
|
@ -660,7 +661,7 @@ export class Timeline extends React.Component<
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentTarget.contains(document.activeElement)) {
|
if (!currentTarget.contains(document.activeElement)) {
|
||||||
clearSelectedMessage();
|
clearTargetedMessage();
|
||||||
}
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
};
|
};
|
||||||
|
@ -668,7 +669,7 @@ export class Timeline extends React.Component<
|
||||||
private handleKeyDown = (
|
private handleKeyDown = (
|
||||||
event: React.KeyboardEvent<HTMLDivElement>
|
event: React.KeyboardEvent<HTMLDivElement>
|
||||||
): void => {
|
): void => {
|
||||||
const { selectMessage, selectedMessageId, items, id } = this.props;
|
const { targetMessage, targetedMessageId, items, id } = this.props;
|
||||||
const commandKey = get(window, 'platform') === 'darwin' && event.metaKey;
|
const commandKey = get(window, 'platform') === 'darwin' && event.metaKey;
|
||||||
const controlKey = get(window, 'platform') !== 'darwin' && event.ctrlKey;
|
const controlKey = get(window, 'platform') !== 'darwin' && event.ctrlKey;
|
||||||
const commandOrCtrl = commandKey || controlKey;
|
const commandOrCtrl = commandKey || controlKey;
|
||||||
|
@ -677,21 +678,21 @@ export class Timeline extends React.Component<
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedMessageId && !commandOrCtrl && event.key === 'ArrowUp') {
|
if (targetedMessageId && !commandOrCtrl && event.key === 'ArrowUp') {
|
||||||
const selectedMessageIndex = items.findIndex(
|
const targetedMessageIndex = items.findIndex(
|
||||||
item => item === selectedMessageId
|
item => item === targetedMessageId
|
||||||
);
|
);
|
||||||
if (selectedMessageIndex < 0) {
|
if (targetedMessageIndex < 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetIndex = selectedMessageIndex - 1;
|
const targetIndex = targetedMessageIndex - 1;
|
||||||
if (targetIndex < 0) {
|
if (targetIndex < 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const messageId = items[targetIndex];
|
const messageId = items[targetIndex];
|
||||||
selectMessage(messageId, id);
|
targetMessage(messageId, id);
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
@ -699,21 +700,21 @@ export class Timeline extends React.Component<
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedMessageId && !commandOrCtrl && event.key === 'ArrowDown') {
|
if (targetedMessageId && !commandOrCtrl && event.key === 'ArrowDown') {
|
||||||
const selectedMessageIndex = items.findIndex(
|
const targetedMessageIndex = items.findIndex(
|
||||||
item => item === selectedMessageId
|
item => item === targetedMessageId
|
||||||
);
|
);
|
||||||
if (selectedMessageIndex < 0) {
|
if (targetedMessageIndex < 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetIndex = selectedMessageIndex + 1;
|
const targetIndex = targetedMessageIndex + 1;
|
||||||
if (targetIndex >= items.length) {
|
if (targetIndex >= items.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const messageId = items[targetIndex];
|
const messageId = items[targetIndex];
|
||||||
selectMessage(messageId, id);
|
targetMessage(messageId, id);
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
@ -724,7 +725,7 @@ export class Timeline extends React.Component<
|
||||||
if (commandOrCtrl && event.key === 'ArrowUp') {
|
if (commandOrCtrl && event.key === 'ArrowUp') {
|
||||||
const firstMessageId = first(items);
|
const firstMessageId = first(items);
|
||||||
if (firstMessageId) {
|
if (firstMessageId) {
|
||||||
selectMessage(firstMessageId, id);
|
targetMessage(firstMessageId, id);
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
}
|
}
|
||||||
|
|
|
@ -58,18 +58,19 @@ const getDefaultProps = () => ({
|
||||||
getPreferredBadge: () => undefined,
|
getPreferredBadge: () => undefined,
|
||||||
id: 'asdf',
|
id: 'asdf',
|
||||||
isNextItemCallingNotification: false,
|
isNextItemCallingNotification: false,
|
||||||
isSelected: false,
|
isTargeted: false,
|
||||||
interactionMode: 'keyboard' as const,
|
interactionMode: 'keyboard' as const,
|
||||||
theme: ThemeType.light,
|
theme: ThemeType.light,
|
||||||
selectMessage: action('selectMessage'),
|
targetMessage: action('targetMessage'),
|
||||||
|
toggleSelectMessage: action('toggleSelectMessage'),
|
||||||
reactToMessage: action('reactToMessage'),
|
reactToMessage: action('reactToMessage'),
|
||||||
checkForAccount: action('checkForAccount'),
|
checkForAccount: action('checkForAccount'),
|
||||||
clearSelectedMessage: action('clearSelectedMessage'),
|
clearTargetedMessage: action('clearTargetedMessage'),
|
||||||
setQuoteByMessageId: action('setQuoteByMessageId'),
|
setQuoteByMessageId: action('setQuoteByMessageId'),
|
||||||
retryDeleteForEveryone: action('retryDeleteForEveryone'),
|
retryDeleteForEveryone: action('retryDeleteForEveryone'),
|
||||||
retryMessageSend: action('retryMessageSend'),
|
retryMessageSend: action('retryMessageSend'),
|
||||||
blockGroupLinkRequests: action('blockGroupLinkRequests'),
|
blockGroupLinkRequests: action('blockGroupLinkRequests'),
|
||||||
deleteMessage: action('deleteMessage'),
|
deleteMessages: action('deleteMessages'),
|
||||||
deleteMessageForEveryone: action('deleteMessageForEveryone'),
|
deleteMessageForEveryone: action('deleteMessageForEveryone'),
|
||||||
kickOffAttachmentDownload: action('kickOffAttachmentDownload'),
|
kickOffAttachmentDownload: action('kickOffAttachmentDownload'),
|
||||||
markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'),
|
markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'),
|
||||||
|
@ -80,7 +81,7 @@ const getDefaultProps = () => ({
|
||||||
pushPanelForConversation: action('pushPanelForConversation'),
|
pushPanelForConversation: action('pushPanelForConversation'),
|
||||||
showContactModal: action('showContactModal'),
|
showContactModal: action('showContactModal'),
|
||||||
showLightbox: action('showLightbox'),
|
showLightbox: action('showLightbox'),
|
||||||
toggleForwardMessageModal: action('toggleForwardMessageModal'),
|
toggleForwardMessagesModal: action('toggleForwardMessagesModal'),
|
||||||
showLightboxForViewOnceMedia: action('showLightboxForViewOnceMedia'),
|
showLightboxForViewOnceMedia: action('showLightboxForViewOnceMedia'),
|
||||||
doubleCheckMissingQuoteReference: action('doubleCheckMissingQuoteReference'),
|
doubleCheckMissingQuoteReference: action('doubleCheckMissingQuoteReference'),
|
||||||
showExpiredIncomingTapToViewToast: action(
|
showExpiredIncomingTapToViewToast: action(
|
||||||
|
@ -107,6 +108,8 @@ const getDefaultProps = () => ({
|
||||||
renderReactionPicker,
|
renderReactionPicker,
|
||||||
renderAudioAttachment: () => <div>*AudioAttachment*</div>,
|
renderAudioAttachment: () => <div>*AudioAttachment*</div>,
|
||||||
viewStory: action('viewStory'),
|
viewStory: action('viewStory'),
|
||||||
|
|
||||||
|
onReplyToMessage: action('onReplyToMessage'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|
|
@ -148,8 +148,8 @@ type PropsLocalType = {
|
||||||
item?: TimelineItemType;
|
item?: TimelineItemType;
|
||||||
id: string;
|
id: string;
|
||||||
isNextItemCallingNotification: boolean;
|
isNextItemCallingNotification: boolean;
|
||||||
isSelected: boolean;
|
isTargeted: boolean;
|
||||||
selectMessage: (messageId: string, conversationId: string) => unknown;
|
targetMessage: (messageId: string, conversationId: string) => unknown;
|
||||||
shouldRenderDateHeader: boolean;
|
shouldRenderDateHeader: boolean;
|
||||||
renderContact: SmartContactRendererType<FullJSXType>;
|
renderContact: SmartContactRendererType<FullJSXType>;
|
||||||
renderUniversalTimerNotification: () => JSX.Element;
|
renderUniversalTimerNotification: () => JSX.Element;
|
||||||
|
@ -186,11 +186,11 @@ export class TimelineItem extends React.PureComponent<PropsType> {
|
||||||
i18n,
|
i18n,
|
||||||
id,
|
id,
|
||||||
isNextItemCallingNotification,
|
isNextItemCallingNotification,
|
||||||
isSelected,
|
isTargeted,
|
||||||
item,
|
item,
|
||||||
renderUniversalTimerNotification,
|
renderUniversalTimerNotification,
|
||||||
returnToActiveCall,
|
returnToActiveCall,
|
||||||
selectMessage,
|
targetMessage,
|
||||||
shouldCollapseAbove,
|
shouldCollapseAbove,
|
||||||
shouldCollapseBelow,
|
shouldCollapseBelow,
|
||||||
shouldHideMetadata,
|
shouldHideMetadata,
|
||||||
|
@ -216,8 +216,8 @@ export class TimelineItem extends React.PureComponent<PropsType> {
|
||||||
<TimelineMessage
|
<TimelineMessage
|
||||||
{...reducedProps}
|
{...reducedProps}
|
||||||
{...item.data}
|
{...item.data}
|
||||||
isSelected={isSelected}
|
isTargeted={isTargeted}
|
||||||
selectMessage={selectMessage}
|
targetMessage={targetMessage}
|
||||||
shouldCollapseAbove={shouldCollapseAbove}
|
shouldCollapseAbove={shouldCollapseAbove}
|
||||||
shouldCollapseBelow={shouldCollapseBelow}
|
shouldCollapseBelow={shouldCollapseBelow}
|
||||||
shouldHideMetadata={shouldHideMetadata}
|
shouldHideMetadata={shouldHideMetadata}
|
||||||
|
@ -346,8 +346,8 @@ export class TimelineItem extends React.PureComponent<PropsType> {
|
||||||
<InlineNotificationWrapper
|
<InlineNotificationWrapper
|
||||||
id={id}
|
id={id}
|
||||||
conversationId={conversationId}
|
conversationId={conversationId}
|
||||||
isSelected={isSelected}
|
isTargeted={isTargeted}
|
||||||
selectMessage={selectMessage}
|
targetMessage={targetMessage}
|
||||||
>
|
>
|
||||||
{notification}
|
{notification}
|
||||||
</InlineNotificationWrapper>
|
</InlineNotificationWrapper>
|
||||||
|
|
|
@ -252,7 +252,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
canRetry: overrideProps.canRetry || false,
|
canRetry: overrideProps.canRetry || false,
|
||||||
canRetryDeleteForEveryone: overrideProps.canRetryDeleteForEveryone || false,
|
canRetryDeleteForEveryone: overrideProps.canRetryDeleteForEveryone || false,
|
||||||
checkForAccount: action('checkForAccount'),
|
checkForAccount: action('checkForAccount'),
|
||||||
clearSelectedMessage: action('clearSelectedMessage'),
|
clearTargetedMessage: action('clearSelectedMessage'),
|
||||||
containerElementRef: React.createRef<HTMLElement>(),
|
containerElementRef: React.createRef<HTMLElement>(),
|
||||||
containerWidthBreakpoint: WidthBreakpoint.Wide,
|
containerWidthBreakpoint: WidthBreakpoint.Wide,
|
||||||
conversationColor:
|
conversationColor:
|
||||||
|
@ -265,7 +265,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
conversationType: overrideProps.conversationType || 'direct',
|
conversationType: overrideProps.conversationType || 'direct',
|
||||||
contact: overrideProps.contact,
|
contact: overrideProps.contact,
|
||||||
deletedForEveryone: overrideProps.deletedForEveryone,
|
deletedForEveryone: overrideProps.deletedForEveryone,
|
||||||
deleteMessage: action('deleteMessage'),
|
deleteMessages: action('deleteMessages'),
|
||||||
deleteMessageForEveryone: action('deleteMessageForEveryone'),
|
deleteMessageForEveryone: action('deleteMessageForEveryone'),
|
||||||
// disableMenu: overrideProps.disableMenu,
|
// disableMenu: overrideProps.disableMenu,
|
||||||
disableScroll: overrideProps.disableScroll,
|
disableScroll: overrideProps.disableScroll,
|
||||||
|
@ -293,6 +293,12 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
isMessageRequestAccepted: isBoolean(overrideProps.isMessageRequestAccepted)
|
isMessageRequestAccepted: isBoolean(overrideProps.isMessageRequestAccepted)
|
||||||
? overrideProps.isMessageRequestAccepted
|
? overrideProps.isMessageRequestAccepted
|
||||||
: true,
|
: true,
|
||||||
|
isSelected: isBoolean(overrideProps.isSelected)
|
||||||
|
? overrideProps.isSelected
|
||||||
|
: false,
|
||||||
|
isSelectMode: isBoolean(overrideProps.isSelectMode)
|
||||||
|
? overrideProps.isSelectMode
|
||||||
|
: false,
|
||||||
isTapToView: overrideProps.isTapToView,
|
isTapToView: overrideProps.isTapToView,
|
||||||
isTapToViewError: overrideProps.isTapToViewError,
|
isTapToViewError: overrideProps.isTapToViewError,
|
||||||
isTapToViewExpired: overrideProps.isTapToViewExpired,
|
isTapToViewExpired: overrideProps.isTapToViewExpired,
|
||||||
|
@ -317,7 +323,11 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
retryMessageSend: action('retryMessageSend'),
|
retryMessageSend: action('retryMessageSend'),
|
||||||
retryDeleteForEveryone: action('retryDeleteForEveryone'),
|
retryDeleteForEveryone: action('retryDeleteForEveryone'),
|
||||||
scrollToQuotedMessage: action('scrollToQuotedMessage'),
|
scrollToQuotedMessage: action('scrollToQuotedMessage'),
|
||||||
selectMessage: action('selectMessage'),
|
targetMessage: action('targetMessage'),
|
||||||
|
toggleSelectMessage:
|
||||||
|
overrideProps.toggleSelectMessage == null
|
||||||
|
? action('toggleSelectMessage')
|
||||||
|
: overrideProps.toggleSelectMessage,
|
||||||
shouldCollapseAbove: isBoolean(overrideProps.shouldCollapseAbove)
|
shouldCollapseAbove: isBoolean(overrideProps.shouldCollapseAbove)
|
||||||
? overrideProps.shouldCollapseAbove
|
? overrideProps.shouldCollapseAbove
|
||||||
: false,
|
: false,
|
||||||
|
@ -335,7 +345,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
showExpiredOutgoingTapToViewToast: action(
|
showExpiredOutgoingTapToViewToast: action(
|
||||||
'showExpiredOutgoingTapToViewToast'
|
'showExpiredOutgoingTapToViewToast'
|
||||||
),
|
),
|
||||||
toggleForwardMessageModal: action('toggleForwardMessageModal'),
|
toggleForwardMessagesModal: action('toggleForwardMessagesModal'),
|
||||||
showLightbox: action('showLightbox'),
|
showLightbox: action('showLightbox'),
|
||||||
startConversation: action('startConversation'),
|
startConversation: action('startConversation'),
|
||||||
status: overrideProps.status || 'sent',
|
status: overrideProps.status || 'sent',
|
||||||
|
@ -2126,3 +2136,33 @@ PaymentNotification.args = {
|
||||||
note: 'Hello there',
|
note: 'Hello there',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function MultiSelectMessage() {
|
||||||
|
const [selected, setSelected] = React.useState(false);
|
||||||
|
return (
|
||||||
|
<TimelineMessage
|
||||||
|
{...createProps({
|
||||||
|
text: 'Hello',
|
||||||
|
isSelected: selected,
|
||||||
|
isSelectMode: true,
|
||||||
|
toggleSelectMessage(_conversationId, _messageId, _shift, newSelected) {
|
||||||
|
setSelected(newSelected);
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MultiSelect(): JSX.Element {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MultiSelectMessage />
|
||||||
|
<MultiSelectMessage />
|
||||||
|
<MultiSelectMessage />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
MultiSelect.args = {
|
||||||
|
name: 'Multi Select',
|
||||||
|
};
|
||||||
|
|
|
@ -37,17 +37,17 @@ export type PropsData = {
|
||||||
canReact: boolean;
|
canReact: boolean;
|
||||||
canReply: boolean;
|
canReply: boolean;
|
||||||
selectedReaction?: string;
|
selectedReaction?: string;
|
||||||
isSelected?: boolean;
|
isTargeted?: boolean;
|
||||||
} & Omit<MessagePropsData, 'renderingContext' | 'menu'>;
|
} & Omit<MessagePropsData, 'renderingContext' | 'menu'>;
|
||||||
|
|
||||||
export type PropsActions = {
|
export type PropsActions = {
|
||||||
deleteMessage: (options: {
|
deleteMessages: (options: {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
messageId: string;
|
messageIds: ReadonlyArray<string>;
|
||||||
}) => void;
|
}) => void;
|
||||||
deleteMessageForEveryone: (id: string) => void;
|
deleteMessageForEveryone: (id: string) => void;
|
||||||
pushPanelForConversation: PushPanelForConversationActionType;
|
pushPanelForConversation: PushPanelForConversationActionType;
|
||||||
toggleForwardMessageModal: (id: string) => void;
|
toggleForwardMessagesModal: (id: Array<string>) => void;
|
||||||
reactToMessage: (
|
reactToMessage: (
|
||||||
id: string,
|
id: string,
|
||||||
{ emoji, remove }: { emoji: string; remove: boolean }
|
{ emoji, remove }: { emoji: string; remove: boolean }
|
||||||
|
@ -55,7 +55,13 @@ export type PropsActions = {
|
||||||
retryMessageSend: (id: string) => void;
|
retryMessageSend: (id: string) => void;
|
||||||
retryDeleteForEveryone: (id: string) => void;
|
retryDeleteForEveryone: (id: string) => void;
|
||||||
setQuoteByMessageId: (conversationId: string, messageId: string) => void;
|
setQuoteByMessageId: (conversationId: string, messageId: string) => void;
|
||||||
} & MessagePropsActions;
|
toggleSelectMessage: (
|
||||||
|
conversationId: string,
|
||||||
|
messageId: string,
|
||||||
|
shift: boolean,
|
||||||
|
selected: boolean
|
||||||
|
) => void;
|
||||||
|
} & Omit<MessagePropsActions, 'onToggleSelect' | 'onReplyToMessage'>;
|
||||||
|
|
||||||
export type Props = PropsData &
|
export type Props = PropsData &
|
||||||
PropsActions &
|
PropsActions &
|
||||||
|
@ -87,14 +93,14 @@ export function TimelineMessage(props: Props): JSX.Element {
|
||||||
containerElementRef,
|
containerElementRef,
|
||||||
containerWidthBreakpoint,
|
containerWidthBreakpoint,
|
||||||
conversationId,
|
conversationId,
|
||||||
deleteMessage,
|
deleteMessages,
|
||||||
deleteMessageForEveryone,
|
deleteMessageForEveryone,
|
||||||
deletedForEveryone,
|
deletedForEveryone,
|
||||||
direction,
|
direction,
|
||||||
giftBadge,
|
giftBadge,
|
||||||
i18n,
|
i18n,
|
||||||
id,
|
id,
|
||||||
isSelected,
|
isTargeted,
|
||||||
isSticker,
|
isSticker,
|
||||||
isTapToView,
|
isTapToView,
|
||||||
kickOffAttachmentDownload,
|
kickOffAttachmentDownload,
|
||||||
|
@ -110,7 +116,8 @@ export function TimelineMessage(props: Props): JSX.Element {
|
||||||
setQuoteByMessageId,
|
setQuoteByMessageId,
|
||||||
text,
|
text,
|
||||||
timestamp,
|
timestamp,
|
||||||
toggleForwardMessageModal,
|
toggleForwardMessagesModal,
|
||||||
|
toggleSelectMessage,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const [reactionPickerRoot, setReactionPickerRoot] = useState<
|
const [reactionPickerRoot, setReactionPickerRoot] = useState<
|
||||||
|
@ -260,14 +267,14 @@ export function TimelineMessage(props: Props): JSX.Element {
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSelected) {
|
if (isTargeted) {
|
||||||
document.addEventListener('keydown', toggleReactionPickerKeyboard);
|
document.addEventListener('keydown', toggleReactionPickerKeyboard);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('keydown', toggleReactionPickerKeyboard);
|
document.removeEventListener('keydown', toggleReactionPickerKeyboard);
|
||||||
};
|
};
|
||||||
}, [isSelected, toggleReactionPickerKeyboard]);
|
}, [isTargeted, toggleReactionPickerKeyboard]);
|
||||||
|
|
||||||
const renderMenu = useCallback(() => {
|
const renderMenu = useCallback(() => {
|
||||||
return (
|
return (
|
||||||
|
@ -357,9 +364,9 @@ export function TimelineMessage(props: Props): JSX.Element {
|
||||||
actions={[
|
actions={[
|
||||||
{
|
{
|
||||||
action: () =>
|
action: () =>
|
||||||
deleteMessage({
|
deleteMessages({
|
||||||
conversationId,
|
conversationId,
|
||||||
messageId: id,
|
messageIds: [id],
|
||||||
}),
|
}),
|
||||||
style: 'negative',
|
style: 'negative',
|
||||||
text: i18n('delete'),
|
text: i18n('delete'),
|
||||||
|
@ -372,24 +379,17 @@ export function TimelineMessage(props: Props): JSX.Element {
|
||||||
{i18n('deleteWarning')}
|
{i18n('deleteWarning')}
|
||||||
</ConfirmationDialog>
|
</ConfirmationDialog>
|
||||||
)}
|
)}
|
||||||
<div
|
|
||||||
onDoubleClick={ev => {
|
|
||||||
if (!handleReplyToMessage) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ev.stopPropagation();
|
<Message
|
||||||
ev.preventDefault();
|
{...props}
|
||||||
handleReplyToMessage();
|
renderingContext="conversation/TimelineItem"
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
|
renderMenu={renderMenu}
|
||||||
|
onToggleSelect={(selected, shift) => {
|
||||||
|
toggleSelectMessage(conversationId, id, shift, selected);
|
||||||
}}
|
}}
|
||||||
>
|
onReplyToMessage={handleReplyToMessage}
|
||||||
<Message
|
/>
|
||||||
{...props}
|
|
||||||
renderingContext="conversation/TimelineItem"
|
|
||||||
onContextMenu={handleContextMenu}
|
|
||||||
renderMenu={renderMenu}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MessageContextMenu
|
<MessageContextMenu
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
|
@ -404,7 +404,10 @@ export function TimelineMessage(props: Props): JSX.Element {
|
||||||
? () => retryDeleteForEveryone(id)
|
? () => retryDeleteForEveryone(id)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onForward={canForward ? () => toggleForwardMessageModal(id) : undefined}
|
onSelect={() => toggleSelectMessage(conversationId, id, false, true)}
|
||||||
|
onForward={
|
||||||
|
canForward ? () => toggleForwardMessagesModal([id]) : undefined
|
||||||
|
}
|
||||||
onDeleteForMe={() => setHasDeleteConfirmation(true)}
|
onDeleteForMe={() => setHasDeleteConfirmation(true)}
|
||||||
onDeleteForEveryone={
|
onDeleteForEveryone={
|
||||||
canDeleteForEveryone ? () => setHasDOEConfirmation(true) : undefined
|
canDeleteForEveryone ? () => setHasDOEConfirmation(true) : undefined
|
||||||
|
@ -589,6 +592,7 @@ type MessageContextProps = {
|
||||||
onDeleteForMe: () => void;
|
onDeleteForMe: () => void;
|
||||||
onDeleteForEveryone: (() => void) | undefined;
|
onDeleteForEveryone: (() => void) | undefined;
|
||||||
onMoreInfo: () => void;
|
onMoreInfo: () => void;
|
||||||
|
onSelect: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MessageContextMenu = ({
|
const MessageContextMenu = ({
|
||||||
|
@ -599,6 +603,7 @@ const MessageContextMenu = ({
|
||||||
onReplyToMessage,
|
onReplyToMessage,
|
||||||
onReact,
|
onReact,
|
||||||
onMoreInfo,
|
onMoreInfo,
|
||||||
|
onSelect,
|
||||||
onRetryMessageSend,
|
onRetryMessageSend,
|
||||||
onRetryDeleteForEveryone,
|
onRetryDeleteForEveryone,
|
||||||
onForward,
|
onForward,
|
||||||
|
@ -668,6 +673,17 @@ const MessageContextMenu = ({
|
||||||
>
|
>
|
||||||
{i18n('moreInfo')}
|
{i18n('moreInfo')}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
attributes={{
|
||||||
|
className:
|
||||||
|
'module-message__context--icon module-message__context__select',
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
onSelect();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{i18n('icu:MessageContextMenu__select')}
|
||||||
|
</MenuItem>
|
||||||
{onRetryMessageSend && (
|
{onRetryMessageSend && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
attributes={{
|
attributes={{
|
||||||
|
|
|
@ -183,13 +183,13 @@ export class LeftPaneArchiveHelper extends LeftPaneHelper<LeftPaneArchivePropsTy
|
||||||
getConversationAndMessageInDirection(
|
getConversationAndMessageInDirection(
|
||||||
toFind: Readonly<ToFindType>,
|
toFind: Readonly<ToFindType>,
|
||||||
selectedConversationId: undefined | string,
|
selectedConversationId: undefined | string,
|
||||||
selectedMessageId: unknown
|
targetedMessageId: unknown
|
||||||
): undefined | { conversationId: string } {
|
): undefined | { conversationId: string } {
|
||||||
if (this.searchHelper) {
|
if (this.searchHelper) {
|
||||||
return this.searchHelper.getConversationAndMessageInDirection(
|
return this.searchHelper.getConversationAndMessageInDirection(
|
||||||
toFind,
|
toFind,
|
||||||
selectedConversationId,
|
selectedConversationId,
|
||||||
selectedMessageId
|
targetedMessageId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -128,7 +128,7 @@ export abstract class LeftPaneHelper<T> {
|
||||||
abstract getConversationAndMessageInDirection(
|
abstract getConversationAndMessageInDirection(
|
||||||
toFind: Readonly<ToFindType>,
|
toFind: Readonly<ToFindType>,
|
||||||
selectedConversationId: undefined | string,
|
selectedConversationId: undefined | string,
|
||||||
selectedMessageId: undefined | string
|
targetedMessageId: undefined | string
|
||||||
): undefined | { conversationId: string; messageId?: string };
|
): undefined | { conversationId: string; messageId?: string };
|
||||||
|
|
||||||
abstract shouldRecomputeRowHeights(old: Readonly<T>): boolean;
|
abstract shouldRecomputeRowHeights(old: Readonly<T>): boolean;
|
||||||
|
|
|
@ -267,7 +267,7 @@ export class LeftPaneInboxHelper extends LeftPaneHelper<LeftPaneInboxPropsType>
|
||||||
getConversationAndMessageInDirection(
|
getConversationAndMessageInDirection(
|
||||||
toFind: Readonly<ToFindType>,
|
toFind: Readonly<ToFindType>,
|
||||||
selectedConversationId: undefined | string,
|
selectedConversationId: undefined | string,
|
||||||
_selectedMessageId: unknown
|
_targetedMessageId: unknown
|
||||||
): undefined | { conversationId: string } {
|
): undefined | { conversationId: string } {
|
||||||
return getConversationInDirection(
|
return getConversationInDirection(
|
||||||
[...this.pinnedConversations, ...this.conversations],
|
[...this.pinnedConversations, ...this.conversations],
|
||||||
|
|
|
@ -335,7 +335,7 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<LeftPaneSearchPropsType
|
||||||
getConversationAndMessageInDirection(
|
getConversationAndMessageInDirection(
|
||||||
_toFind: Readonly<ToFindType>,
|
_toFind: Readonly<ToFindType>,
|
||||||
_selectedConversationId: undefined | string,
|
_selectedConversationId: undefined | string,
|
||||||
_selectedMessageId: unknown
|
_targetedMessageId: unknown
|
||||||
): undefined | { conversationId: string } {
|
): undefined | { conversationId: string } {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,10 +17,14 @@ export function useEscapeHandling(handleEscape?: () => unknown): void {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener('keydown', handler);
|
document.addEventListener('keydown', handler, {
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('keydown', handler);
|
document.removeEventListener('keydown', handler, {
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
}, [handleEscape]);
|
}, [handleEscape]);
|
||||||
}
|
}
|
||||||
|
|
|
@ -2156,9 +2156,12 @@ export class ConversationModel extends window.Backbone
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
decrementMessageCount(): void {
|
decrementMessageCount(numberOfMessages = 1): void {
|
||||||
this.set({
|
this.set({
|
||||||
messageCount: Math.max((this.get('messageCount') || 0) - 1, 0),
|
messageCount: Math.max(
|
||||||
|
(this.get('messageCount') || 0) - numberOfMessages,
|
||||||
|
0
|
||||||
|
),
|
||||||
});
|
});
|
||||||
window.Signal.Data.updateConversation(this.attributes);
|
window.Signal.Data.updateConversation(this.attributes);
|
||||||
}
|
}
|
||||||
|
@ -2180,10 +2183,16 @@ export class ConversationModel extends window.Backbone
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
decrementSentMessageCount(): void {
|
decrementSentMessageCount(numberOfMessages = 1): void {
|
||||||
this.set({
|
this.set({
|
||||||
messageCount: Math.max((this.get('messageCount') || 0) - 1, 0),
|
messageCount: Math.max(
|
||||||
sentMessageCount: Math.max((this.get('sentMessageCount') || 0) - 1, 0),
|
(this.get('messageCount') || 0) - numberOfMessages,
|
||||||
|
0
|
||||||
|
),
|
||||||
|
sentMessageCount: Math.max(
|
||||||
|
(this.get('sentMessageCount') || 0) - numberOfMessages,
|
||||||
|
0
|
||||||
|
),
|
||||||
});
|
});
|
||||||
window.Signal.Data.updateConversation(this.attributes);
|
window.Signal.Data.updateConversation(this.attributes);
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,10 +24,10 @@ export function startInteractionMode(): void {
|
||||||
document.body.classList.add('keyboard-mode');
|
document.body.classList.add('keyboard-mode');
|
||||||
document.body.classList.remove('mouse-mode');
|
document.body.classList.remove('mouse-mode');
|
||||||
|
|
||||||
const clearSelectedMessage =
|
const clearTargetedMessage =
|
||||||
window.reduxActions?.conversations?.clearSelectedMessage;
|
window.reduxActions?.conversations?.clearTargetedMessage;
|
||||||
if (clearSelectedMessage) {
|
if (clearTargetedMessage) {
|
||||||
clearSelectedMessage();
|
clearTargetedMessage();
|
||||||
}
|
}
|
||||||
|
|
||||||
const userChanged = window.reduxActions?.user?.userChanged;
|
const userChanged = window.reduxActions?.user?.userChanged;
|
||||||
|
@ -45,10 +45,10 @@ export function startInteractionMode(): void {
|
||||||
document.body.classList.add('mouse-mode');
|
document.body.classList.add('mouse-mode');
|
||||||
document.body.classList.remove('keyboard-mode');
|
document.body.classList.remove('keyboard-mode');
|
||||||
|
|
||||||
const clearSelectedMessage =
|
const clearTargetedMessage =
|
||||||
window.reduxActions?.conversations?.clearSelectedMessage;
|
window.reduxActions?.conversations?.clearTargetedMessage;
|
||||||
if (clearSelectedMessage) {
|
if (clearTargetedMessage) {
|
||||||
clearSelectedMessage();
|
clearTargetedMessage();
|
||||||
}
|
}
|
||||||
|
|
||||||
const userChanged = window.reduxActions?.user?.userChanged;
|
const userChanged = window.reduxActions?.user?.userChanged;
|
||||||
|
|
|
@ -69,6 +69,7 @@ import Server from './Server';
|
||||||
import { parseSqliteError, SqliteErrorKind } from './errors';
|
import { parseSqliteError, SqliteErrorKind } from './errors';
|
||||||
import { MINUTE } from '../util/durations';
|
import { MINUTE } from '../util/durations';
|
||||||
import { getMessageIdForLogging } from '../util/idForLogging';
|
import { getMessageIdForLogging } from '../util/idForLogging';
|
||||||
|
import type { MessageAttributesType } from '../model-types';
|
||||||
|
|
||||||
const getRealPath = pify(fs.realpath);
|
const getRealPath = pify(fs.realpath);
|
||||||
|
|
||||||
|
@ -227,6 +228,7 @@ const dataInterface: ClientInterface = {
|
||||||
saveMessage,
|
saveMessage,
|
||||||
saveMessages,
|
saveMessages,
|
||||||
removeMessage,
|
removeMessage,
|
||||||
|
removeMessages,
|
||||||
saveAttachmentDownloadJob,
|
saveAttachmentDownloadJob,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -668,6 +670,28 @@ async function removeMessage(id: string): Promise<void> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function _cleanupMessages(
|
||||||
|
messages: ReadonlyArray<MessageAttributesType>
|
||||||
|
): Promise<void> {
|
||||||
|
const queue = new PQueue({ concurrency: 3, timeout: MINUTE * 30 });
|
||||||
|
drop(
|
||||||
|
queue.addAll(
|
||||||
|
messages.map(
|
||||||
|
(message: MessageAttributesType) => async () => cleanupMessage(message)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await queue.onIdle();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeMessages(
|
||||||
|
messageIds: ReadonlyArray<string>
|
||||||
|
): Promise<void> {
|
||||||
|
const messages = await channels.getMessagesById(messageIds);
|
||||||
|
await _cleanupMessages(messages);
|
||||||
|
await channels.removeMessages(messageIds);
|
||||||
|
}
|
||||||
|
|
||||||
function handleMessageJSON(
|
function handleMessageJSON(
|
||||||
messages: Array<MessageTypeUnhydrated>
|
messages: Array<MessageTypeUnhydrated>
|
||||||
): Array<MessageType> {
|
): Array<MessageType> {
|
||||||
|
@ -733,18 +757,8 @@ async function removeAllMessagesInConversation(
|
||||||
const ids = messages.map(message => message.id);
|
const ids = messages.map(message => message.id);
|
||||||
|
|
||||||
log.info(`removeAllMessagesInConversation/${logId}: Cleanup...`);
|
log.info(`removeAllMessagesInConversation/${logId}: Cleanup...`);
|
||||||
// Note: It's very important that these models are fully hydrated because
|
|
||||||
// we need to delete all associated on-disk files along with the database delete.
|
|
||||||
const queue = new PQueue({ concurrency: 3, timeout: MINUTE * 30 });
|
|
||||||
drop(
|
|
||||||
queue.addAll(
|
|
||||||
messages.map(
|
|
||||||
(message: MessageType) => async () => cleanupMessage(message)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
await queue.onIdle();
|
await _cleanupMessages(messages);
|
||||||
|
|
||||||
log.info(`removeAllMessagesInConversation/${logId}: Deleting...`);
|
log.info(`removeAllMessagesInConversation/${logId}: Deleting...`);
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
|
|
@ -18,6 +18,8 @@ import type { BadgeType } from '../badges/types';
|
||||||
import type { RemoveAllConfiguration } from '../types/RemoveAllConfiguration';
|
import type { RemoveAllConfiguration } from '../types/RemoveAllConfiguration';
|
||||||
import type { LoggerType } from '../types/Logging';
|
import type { LoggerType } from '../types/Logging';
|
||||||
import type { ReadStatus } from '../messages/MessageReadStatus';
|
import type { ReadStatus } from '../messages/MessageReadStatus';
|
||||||
|
import type { GetMessagesBetweenOptions } from './Server';
|
||||||
|
import type { MessageTimestamps } from '../state/ducks/conversations';
|
||||||
|
|
||||||
export type AdjacentMessagesByConversationOptionsType = Readonly<{
|
export type AdjacentMessagesByConversationOptionsType = Readonly<{
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
|
@ -30,6 +32,14 @@ export type AdjacentMessagesByConversationOptionsType = Readonly<{
|
||||||
requireVisualMediaAttachments?: boolean;
|
requireVisualMediaAttachments?: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
export type GetNearbyMessageFromDeletedSetOptionsType = Readonly<{
|
||||||
|
conversationId: string;
|
||||||
|
lastSelectedMessage: MessageTimestamps;
|
||||||
|
deletedMessageIds: ReadonlyArray<string>;
|
||||||
|
storyId: string | undefined;
|
||||||
|
includeStoryReplies: boolean;
|
||||||
|
}>;
|
||||||
|
|
||||||
export type AttachmentDownloadJobTypeType =
|
export type AttachmentDownloadJobTypeType =
|
||||||
| 'long-message'
|
| 'long-message'
|
||||||
| 'attachment'
|
| 'attachment'
|
||||||
|
@ -529,7 +539,9 @@ export type DataInterface = {
|
||||||
sent_at: number;
|
sent_at: number;
|
||||||
}) => Promise<MessageType | undefined>;
|
}) => Promise<MessageType | undefined>;
|
||||||
getMessageById: (id: string) => Promise<MessageType | undefined>;
|
getMessageById: (id: string) => Promise<MessageType | undefined>;
|
||||||
getMessagesById: (messageIds: Array<string>) => Promise<Array<MessageType>>;
|
getMessagesById: (
|
||||||
|
messageIds: ReadonlyArray<string>
|
||||||
|
) => Promise<Array<MessageType>>;
|
||||||
_getAllMessages: () => Promise<Array<MessageType>>;
|
_getAllMessages: () => Promise<Array<MessageType>>;
|
||||||
_removeAllMessages: () => Promise<void>;
|
_removeAllMessages: () => Promise<void>;
|
||||||
getAllMessageIds: () => Promise<Array<string>>;
|
getAllMessageIds: () => Promise<Array<string>>;
|
||||||
|
@ -573,7 +585,13 @@ export type DataInterface = {
|
||||||
obsoleteId: string,
|
obsoleteId: string,
|
||||||
currentId: string
|
currentId: string
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
getMessagesBetween: (
|
||||||
|
conversationId: string,
|
||||||
|
options: GetMessagesBetweenOptions
|
||||||
|
) => Promise<Array<string>>;
|
||||||
|
getNearbyMessageFromDeletedSet: (
|
||||||
|
options: GetNearbyMessageFromDeletedSetOptionsType
|
||||||
|
) => Promise<string | null>;
|
||||||
getUnprocessedCount: () => Promise<number>;
|
getUnprocessedCount: () => Promise<number>;
|
||||||
getUnprocessedByIdsAndIncrementAttempts: (
|
getUnprocessedByIdsAndIncrementAttempts: (
|
||||||
ids: ReadonlyArray<string>
|
ids: ReadonlyArray<string>
|
||||||
|
|
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 { parseBadgeImageTheme } from '../badges/BadgeImageTheme';
|
||||||
import type { LoggerType } from '../types/Logging';
|
import type { LoggerType } from '../types/Logging';
|
||||||
import * as log from '../logging/log';
|
import * as log from '../logging/log';
|
||||||
import type { EmptyQuery, ArrayQuery, Query, JSONRows } from './util';
|
import type {
|
||||||
|
EmptyQuery,
|
||||||
|
ArrayQuery,
|
||||||
|
Query,
|
||||||
|
JSONRows,
|
||||||
|
QueryFragment,
|
||||||
|
} from './util';
|
||||||
import {
|
import {
|
||||||
|
sqlJoin,
|
||||||
|
sqlFragment,
|
||||||
|
sql,
|
||||||
jsonToObject,
|
jsonToObject,
|
||||||
objectToJSON,
|
objectToJSON,
|
||||||
batchMultiVarQuery,
|
batchMultiVarQuery,
|
||||||
|
@ -122,6 +131,7 @@ import type {
|
||||||
UninstalledStickerPackType,
|
UninstalledStickerPackType,
|
||||||
UnprocessedType,
|
UnprocessedType,
|
||||||
UnprocessedUpdateType,
|
UnprocessedUpdateType,
|
||||||
|
GetNearbyMessageFromDeletedSetOptionsType,
|
||||||
} from './Interface';
|
} from './Interface';
|
||||||
import { SeenStatus } from '../MessageSeenStatus';
|
import { SeenStatus } from '../MessageSeenStatus';
|
||||||
|
|
||||||
|
@ -261,6 +271,8 @@ const dataInterface: ServerInterface = {
|
||||||
getCallHistoryMessageByCallId,
|
getCallHistoryMessageByCallId,
|
||||||
hasGroupCallHistoryMessage,
|
hasGroupCallHistoryMessage,
|
||||||
migrateConversationMessages,
|
migrateConversationMessages,
|
||||||
|
getMessagesBetween,
|
||||||
|
getNearbyMessageFromDeletedSet,
|
||||||
|
|
||||||
getUnprocessedCount,
|
getUnprocessedCount,
|
||||||
getUnprocessedByIdsAndIncrementAttempts,
|
getUnprocessedByIdsAndIncrementAttempts,
|
||||||
|
@ -2098,7 +2110,7 @@ export function getMessageByIdSync(
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getMessagesById(
|
async function getMessagesById(
|
||||||
messageIds: Array<string>
|
messageIds: ReadonlyArray<string>
|
||||||
): Promise<Array<MessageType>> {
|
): Promise<Array<MessageType>> {
|
||||||
const db = getInstance();
|
const db = getInstance();
|
||||||
|
|
||||||
|
@ -2189,17 +2201,17 @@ async function getMessageBySender({
|
||||||
export function _storyIdPredicate(
|
export function _storyIdPredicate(
|
||||||
storyId: string | undefined,
|
storyId: string | undefined,
|
||||||
includeStoryReplies: boolean
|
includeStoryReplies: boolean
|
||||||
): string {
|
): QueryFragment {
|
||||||
// This is unintuitive, but 'including story replies' means that we need replies to
|
// This is unintuitive, but 'including story replies' means that we need replies to
|
||||||
// lots of different stories. So, we remove the storyId check with a clause that will
|
// lots of different stories. So, we remove the storyId check with a clause that will
|
||||||
// always be true. We don't just return TRUE because we want to use our passed-in
|
// always be true. We don't just return TRUE because we want to use our passed-in
|
||||||
// $storyId parameter.
|
// $storyId parameter.
|
||||||
if (includeStoryReplies && storyId === undefined) {
|
if (includeStoryReplies && storyId === undefined) {
|
||||||
return '$storyId IS NULL';
|
return sqlFragment`${storyId} IS NULL`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// In contrast to: replies to a specific story
|
// In contrast to: replies to a specific story
|
||||||
return 'storyId IS $storyId';
|
return sqlFragment`storyId IS ${storyId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getUnreadByConversationAndMarkRead({
|
async function getUnreadByConversationAndMarkRead({
|
||||||
|
@ -2220,75 +2232,63 @@ async function getUnreadByConversationAndMarkRead({
|
||||||
const db = getInstance();
|
const db = getInstance();
|
||||||
return db.transaction(() => {
|
return db.transaction(() => {
|
||||||
const expirationStartTimestamp = Math.min(now, readAt ?? Infinity);
|
const expirationStartTimestamp = Math.min(now, readAt ?? Infinity);
|
||||||
db.prepare<Query>(
|
|
||||||
`
|
const expirationJsonPatch = JSON.stringify({ expirationStartTimestamp });
|
||||||
|
|
||||||
|
const [updateExpirationQuery, updateExpirationParams] = sql`
|
||||||
UPDATE messages
|
UPDATE messages
|
||||||
INDEXED BY expiring_message_by_conversation_and_received_at
|
INDEXED BY expiring_message_by_conversation_and_received_at
|
||||||
SET
|
SET
|
||||||
expirationStartTimestamp = $expirationStartTimestamp,
|
expirationStartTimestamp = ${expirationStartTimestamp},
|
||||||
json = json_patch(json, $jsonPatch)
|
json = json_patch(json, ${expirationJsonPatch})
|
||||||
WHERE
|
WHERE
|
||||||
conversationId = $conversationId AND
|
conversationId = ${conversationId} AND
|
||||||
(${_storyIdPredicate(storyId, includeStoryReplies)}) AND
|
(${_storyIdPredicate(storyId, includeStoryReplies)}) AND
|
||||||
isStory IS 0 AND
|
isStory IS 0 AND
|
||||||
type IS 'incoming' AND
|
type IS 'incoming' AND
|
||||||
(
|
(
|
||||||
expirationStartTimestamp IS NULL OR
|
expirationStartTimestamp IS NULL OR
|
||||||
expirationStartTimestamp > $expirationStartTimestamp
|
expirationStartTimestamp > ${expirationStartTimestamp}
|
||||||
) AND
|
) AND
|
||||||
expireTimer > 0 AND
|
expireTimer > 0 AND
|
||||||
received_at <= $newestUnreadAt;
|
received_at <= ${newestUnreadAt};
|
||||||
`
|
`;
|
||||||
).run({
|
|
||||||
conversationId,
|
|
||||||
expirationStartTimestamp,
|
|
||||||
jsonPatch: JSON.stringify({ expirationStartTimestamp }),
|
|
||||||
newestUnreadAt,
|
|
||||||
storyId: storyId || null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const rows = db
|
db.prepare(updateExpirationQuery).run(updateExpirationParams);
|
||||||
.prepare<Query>(
|
|
||||||
`
|
const [selectQuery, selectParams] = sql`
|
||||||
SELECT id, json FROM messages
|
SELECT id, json FROM messages
|
||||||
WHERE
|
WHERE
|
||||||
conversationId = $conversationId AND
|
conversationId = ${conversationId} AND
|
||||||
seenStatus = ${SeenStatus.Unseen} AND
|
seenStatus = ${SeenStatus.Unseen} AND
|
||||||
isStory = 0 AND
|
isStory = 0 AND
|
||||||
(${_storyIdPredicate(storyId, includeStoryReplies)}) AND
|
(${_storyIdPredicate(storyId, includeStoryReplies)}) AND
|
||||||
received_at <= $newestUnreadAt
|
received_at <= ${newestUnreadAt}
|
||||||
ORDER BY received_at DESC, sent_at DESC;
|
ORDER BY received_at DESC, sent_at DESC;
|
||||||
`
|
`;
|
||||||
)
|
|
||||||
.all({
|
|
||||||
conversationId,
|
|
||||||
newestUnreadAt,
|
|
||||||
storyId: storyId || null,
|
|
||||||
});
|
|
||||||
|
|
||||||
db.prepare<Query>(
|
const rows = db.prepare(selectQuery).all(selectParams);
|
||||||
`
|
|
||||||
UPDATE messages
|
const statusJsonPatch = JSON.stringify({
|
||||||
|
readStatus: ReadStatus.Read,
|
||||||
|
seenStatus: SeenStatus.Seen,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [updateStatusQuery, updateStatusParams] = sql`
|
||||||
|
UPDATE messages
|
||||||
SET
|
SET
|
||||||
readStatus = ${ReadStatus.Read},
|
readStatus = ${ReadStatus.Read},
|
||||||
seenStatus = ${SeenStatus.Seen},
|
seenStatus = ${SeenStatus.Seen},
|
||||||
json = json_patch(json, $jsonPatch)
|
json = json_patch(json, ${statusJsonPatch})
|
||||||
WHERE
|
WHERE
|
||||||
conversationId = $conversationId AND
|
conversationId = ${conversationId} AND
|
||||||
seenStatus = ${SeenStatus.Unseen} AND
|
seenStatus = ${SeenStatus.Unseen} AND
|
||||||
isStory = 0 AND
|
isStory = 0 AND
|
||||||
(${_storyIdPredicate(storyId, includeStoryReplies)}) AND
|
(${_storyIdPredicate(storyId, includeStoryReplies)}) AND
|
||||||
received_at <= $newestUnreadAt;
|
received_at <= ${newestUnreadAt};
|
||||||
`
|
`;
|
||||||
).run({
|
|
||||||
conversationId,
|
db.prepare(updateStatusQuery).run(updateStatusParams);
|
||||||
jsonPatch: JSON.stringify({
|
|
||||||
readStatus: ReadStatus.Read,
|
|
||||||
seenStatus: SeenStatus.Seen,
|
|
||||||
}),
|
|
||||||
newestUnreadAt,
|
|
||||||
storyId: storyId || null,
|
|
||||||
});
|
|
||||||
|
|
||||||
return rows.map(row => {
|
return rows.map(row => {
|
||||||
const json = jsonToObject<MessageType>(row.json);
|
const json = jsonToObject<MessageType>(row.json);
|
||||||
|
@ -2500,32 +2500,35 @@ function getAdjacentMessagesByConversationSync(
|
||||||
|
|
||||||
const timeFilter =
|
const timeFilter =
|
||||||
direction === AdjacentDirection.Older
|
direction === AdjacentDirection.Older
|
||||||
? `
|
? sqlFragment`
|
||||||
(received_at = $received_at AND sent_at < $sent_at) OR
|
(received_at = ${receivedAt} AND sent_at < ${sentAt}) OR
|
||||||
received_at < $received_at
|
received_at < ${receivedAt}
|
||||||
`
|
`
|
||||||
: `
|
: sqlFragment`
|
||||||
(received_at = $received_at AND sent_at > $sent_at) OR
|
(received_at = ${receivedAt} AND sent_at > ${sentAt}) OR
|
||||||
received_at > $received_at
|
received_at > ${receivedAt}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const timeOrder = direction === AdjacentDirection.Older ? 'DESC' : 'ASC';
|
const timeOrder =
|
||||||
|
direction === AdjacentDirection.Older
|
||||||
|
? sqlFragment`DESC`
|
||||||
|
: sqlFragment`ASC`;
|
||||||
|
|
||||||
const requireDifferentMessage =
|
const requireDifferentMessage =
|
||||||
direction === AdjacentDirection.Older || requireVisualMediaAttachments;
|
direction === AdjacentDirection.Older || requireVisualMediaAttachments;
|
||||||
|
|
||||||
let query = `
|
let template = sqlFragment`
|
||||||
SELECT json FROM messages WHERE
|
SELECT json FROM messages WHERE
|
||||||
conversationId = $conversationId AND
|
conversationId = ${conversationId} AND
|
||||||
${
|
${
|
||||||
requireDifferentMessage
|
requireDifferentMessage
|
||||||
? '($messageId IS NULL OR id IS NOT $messageId) AND'
|
? sqlFragment`(${messageId} IS NULL OR id IS NOT ${messageId}) AND`
|
||||||
: ''
|
: sqlFragment``
|
||||||
}
|
}
|
||||||
${
|
${
|
||||||
requireVisualMediaAttachments
|
requireVisualMediaAttachments
|
||||||
? 'hasVisualMediaAttachments IS 1 AND'
|
? sqlFragment`hasVisualMediaAttachments IS 1 AND`
|
||||||
: ''
|
: sqlFragment``
|
||||||
}
|
}
|
||||||
isStory IS 0 AND
|
isStory IS 0 AND
|
||||||
(${_storyIdPredicate(storyId, includeStoryReplies)}) AND
|
(${_storyIdPredicate(storyId, includeStoryReplies)}) AND
|
||||||
|
@ -2537,9 +2540,9 @@ function getAdjacentMessagesByConversationSync(
|
||||||
|
|
||||||
// See `filterValidAttachments` in ts/state/ducks/lightbox.ts
|
// See `filterValidAttachments` in ts/state/ducks/lightbox.ts
|
||||||
if (requireVisualMediaAttachments) {
|
if (requireVisualMediaAttachments) {
|
||||||
query = `
|
template = sqlFragment`
|
||||||
SELECT json
|
SELECT json
|
||||||
FROM (${query}) as messages
|
FROM (${template}) as messages
|
||||||
WHERE
|
WHERE
|
||||||
(
|
(
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
|
@ -2549,20 +2552,15 @@ function getAdjacentMessagesByConversationSync(
|
||||||
attachment.value ->> 'pending' IS NOT 1 AND
|
attachment.value ->> 'pending' IS NOT 1 AND
|
||||||
attachment.value ->> 'error' IS NULL
|
attachment.value ->> 'error' IS NULL
|
||||||
) > 0
|
) > 0
|
||||||
LIMIT $limit;
|
LIMIT ${limit};
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
query = `${query} LIMIT $limit`;
|
template = sqlFragment`${template} LIMIT ${limit}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = db.prepare<Query>(query).all({
|
const [query, params] = sql`${template}`;
|
||||||
conversationId,
|
|
||||||
limit,
|
const results = db.prepare(query).all(params);
|
||||||
messageId: messageId || null,
|
|
||||||
received_at: receivedAt,
|
|
||||||
sent_at: sentAt,
|
|
||||||
storyId: storyId || null,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (direction === AdjacentDirection.Older) {
|
if (direction === AdjacentDirection.Older) {
|
||||||
results.reverse();
|
results.reverse();
|
||||||
|
@ -2648,21 +2646,16 @@ function getOldestMessageForConversation(
|
||||||
}
|
}
|
||||||
): MessageMetricsType | undefined {
|
): MessageMetricsType | undefined {
|
||||||
const db = getInstance();
|
const db = getInstance();
|
||||||
const row = db
|
const [query, params] = sql`
|
||||||
.prepare<Query>(
|
SELECT received_at, sent_at, id FROM messages WHERE
|
||||||
`
|
conversationId = ${conversationId} AND
|
||||||
SELECT received_at, sent_at, id FROM messages WHERE
|
|
||||||
conversationId = $conversationId AND
|
|
||||||
isStory IS 0 AND
|
isStory IS 0 AND
|
||||||
(${_storyIdPredicate(storyId, includeStoryReplies)})
|
(${_storyIdPredicate(storyId, includeStoryReplies)})
|
||||||
ORDER BY received_at ASC, sent_at ASC
|
ORDER BY received_at ASC, sent_at ASC
|
||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
`
|
`;
|
||||||
)
|
|
||||||
.get({
|
const row = db.prepare(query).get(params);
|
||||||
conversationId,
|
|
||||||
storyId: storyId || null,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!row) {
|
if (!row) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
@ -2681,21 +2674,15 @@ function getNewestMessageForConversation(
|
||||||
}
|
}
|
||||||
): MessageMetricsType | undefined {
|
): MessageMetricsType | undefined {
|
||||||
const db = getInstance();
|
const db = getInstance();
|
||||||
const row = db
|
const [query, params] = sql`
|
||||||
.prepare<Query>(
|
SELECT received_at, sent_at, id FROM messages WHERE
|
||||||
`
|
conversationId = ${conversationId} AND
|
||||||
SELECT received_at, sent_at, id FROM messages WHERE
|
|
||||||
conversationId = $conversationId AND
|
|
||||||
isStory IS 0 AND
|
isStory IS 0 AND
|
||||||
(${_storyIdPredicate(storyId, includeStoryReplies)})
|
(${_storyIdPredicate(storyId, includeStoryReplies)})
|
||||||
ORDER BY received_at DESC, sent_at DESC
|
ORDER BY received_at DESC, sent_at DESC
|
||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
`
|
`;
|
||||||
)
|
const row = db.prepare(query).get(params);
|
||||||
.get({
|
|
||||||
conversationId,
|
|
||||||
storyId: storyId || null,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!row) {
|
if (!row) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
@ -2704,6 +2691,96 @@ function getNewestMessageForConversation(
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type GetMessagesBetweenOptions = Readonly<{
|
||||||
|
after: { received_at: number; sent_at: number };
|
||||||
|
before: { received_at: number; sent_at: number };
|
||||||
|
includeStoryReplies: boolean;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
async function getMessagesBetween(
|
||||||
|
conversationId: string,
|
||||||
|
options: GetMessagesBetweenOptions
|
||||||
|
): Promise<Array<string>> {
|
||||||
|
const db = getInstance();
|
||||||
|
|
||||||
|
// In the future we could accept this as an option, but for now we just
|
||||||
|
// use it for the story predicate.
|
||||||
|
const storyId = undefined;
|
||||||
|
|
||||||
|
const { after, before, includeStoryReplies } = options;
|
||||||
|
|
||||||
|
const [query, params] = sql`
|
||||||
|
SELECT id
|
||||||
|
FROM messages
|
||||||
|
WHERE
|
||||||
|
conversationId = ${conversationId} AND
|
||||||
|
(${_storyIdPredicate(storyId, includeStoryReplies)}) AND
|
||||||
|
isStory IS 0 AND
|
||||||
|
(
|
||||||
|
received_at > ${after.received_at}
|
||||||
|
OR (received_at = ${after.received_at} AND sent_at > ${after.sent_at})
|
||||||
|
) AND (
|
||||||
|
received_at < ${before.received_at}
|
||||||
|
OR (received_at = ${before.received_at} AND sent_at < ${before.sent_at})
|
||||||
|
)
|
||||||
|
ORDER BY received_at ASC, sent_at ASC;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const rows = db.prepare(query).all(params);
|
||||||
|
|
||||||
|
return rows.map(row => row.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a set of deleted message IDs, find a message in the conversation that
|
||||||
|
* is close to the set. Searching from the last selected message as a starting
|
||||||
|
* point.
|
||||||
|
*/
|
||||||
|
async function getNearbyMessageFromDeletedSet({
|
||||||
|
conversationId,
|
||||||
|
lastSelectedMessage,
|
||||||
|
deletedMessageIds,
|
||||||
|
storyId,
|
||||||
|
includeStoryReplies,
|
||||||
|
}: GetNearbyMessageFromDeletedSetOptionsType): Promise<string | null> {
|
||||||
|
const db = getInstance();
|
||||||
|
|
||||||
|
function runQuery(after: boolean) {
|
||||||
|
const dir = after ? sqlFragment`ASC` : sqlFragment`DESC`;
|
||||||
|
const compare = after ? sqlFragment`>` : sqlFragment`<`;
|
||||||
|
const { received_at, sent_at } = lastSelectedMessage;
|
||||||
|
|
||||||
|
const [query, params] = sql`
|
||||||
|
SELECT id FROM messages WHERE
|
||||||
|
conversationId = ${conversationId} AND
|
||||||
|
(${_storyIdPredicate(storyId, includeStoryReplies)}) AND
|
||||||
|
isStory IS 0 AND
|
||||||
|
id NOT IN (${sqlJoin(deletedMessageIds, ', ')}) AND
|
||||||
|
type IN ('incoming', 'outgoing')
|
||||||
|
AND (
|
||||||
|
(received_at = ${received_at} AND sent_at ${compare} ${sent_at}) OR
|
||||||
|
received_at ${compare} ${received_at}
|
||||||
|
)
|
||||||
|
ORDER BY received_at ${dir}, sent_at ${dir}
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
|
||||||
|
return db.prepare(query).pluck().get(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
const after = runQuery(true);
|
||||||
|
if (after != null) {
|
||||||
|
return after;
|
||||||
|
}
|
||||||
|
|
||||||
|
const before = runQuery(false);
|
||||||
|
if (before != null) {
|
||||||
|
return before;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function getLastConversationActivity({
|
function getLastConversationActivity({
|
||||||
conversationId,
|
conversationId,
|
||||||
includeStoryReplies,
|
includeStoryReplies,
|
||||||
|
@ -2844,22 +2921,18 @@ function getOldestUnseenMessageForConversation(
|
||||||
}
|
}
|
||||||
): MessageMetricsType | undefined {
|
): MessageMetricsType | undefined {
|
||||||
const db = getInstance();
|
const db = getInstance();
|
||||||
const row = db
|
|
||||||
.prepare<Query>(
|
const [query, params] = sql`
|
||||||
`
|
SELECT received_at, sent_at, id FROM messages WHERE
|
||||||
SELECT received_at, sent_at, id FROM messages WHERE
|
conversationId = ${conversationId} AND
|
||||||
conversationId = $conversationId AND
|
seenStatus = ${SeenStatus.Unseen} AND
|
||||||
seenStatus = ${SeenStatus.Unseen} AND
|
isStory IS 0 AND
|
||||||
isStory IS 0 AND
|
(${_storyIdPredicate(storyId, includeStoryReplies)})
|
||||||
(${_storyIdPredicate(storyId, includeStoryReplies)})
|
ORDER BY received_at ASC, sent_at ASC
|
||||||
ORDER BY received_at ASC, sent_at ASC
|
LIMIT 1;
|
||||||
LIMIT 1;
|
`;
|
||||||
`
|
|
||||||
)
|
const row = db.prepare(query).get(params);
|
||||||
.get({
|
|
||||||
conversationId,
|
|
||||||
storyId: storyId || null,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!row) {
|
if (!row) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
@ -2888,23 +2961,16 @@ function getTotalUnreadForConversationSync(
|
||||||
}
|
}
|
||||||
): number {
|
): number {
|
||||||
const db = getInstance();
|
const db = getInstance();
|
||||||
const row = db
|
const [query, params] = sql`
|
||||||
.prepare<Query>(
|
SELECT count(1)
|
||||||
`
|
FROM messages
|
||||||
SELECT count(1)
|
WHERE
|
||||||
FROM messages
|
conversationId = ${conversationId} AND
|
||||||
WHERE
|
readStatus = ${ReadStatus.Unread} AND
|
||||||
conversationId = $conversationId AND
|
isStory IS 0 AND
|
||||||
readStatus = ${ReadStatus.Unread} AND
|
(${_storyIdPredicate(storyId, includeStoryReplies)})
|
||||||
isStory IS 0 AND
|
`;
|
||||||
(${_storyIdPredicate(storyId, includeStoryReplies)})
|
const row = db.prepare(query).pluck().get(params);
|
||||||
`
|
|
||||||
)
|
|
||||||
.pluck()
|
|
||||||
.get({
|
|
||||||
conversationId,
|
|
||||||
storyId: storyId || null,
|
|
||||||
});
|
|
||||||
|
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
@ -2919,23 +2985,16 @@ function getTotalUnseenForConversationSync(
|
||||||
}
|
}
|
||||||
): number {
|
): number {
|
||||||
const db = getInstance();
|
const db = getInstance();
|
||||||
const row = db
|
const [query, params] = sql`
|
||||||
.prepare<Query>(
|
SELECT count(1)
|
||||||
`
|
|
||||||
SELECT count(1)
|
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE
|
WHERE
|
||||||
conversationId = $conversationId AND
|
conversationId = ${conversationId} AND
|
||||||
seenStatus = ${SeenStatus.Unseen} AND
|
seenStatus = ${SeenStatus.Unseen} AND
|
||||||
isStory IS 0 AND
|
isStory IS 0 AND
|
||||||
(${_storyIdPredicate(storyId, includeStoryReplies)})
|
(${_storyIdPredicate(storyId, includeStoryReplies)})
|
||||||
`
|
`;
|
||||||
)
|
const row = db.prepare(query).pluck().get(params);
|
||||||
.pluck()
|
|
||||||
.get({
|
|
||||||
conversationId,
|
|
||||||
storyId: storyId || null,
|
|
||||||
});
|
|
||||||
|
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
@ -3395,7 +3454,7 @@ async function getAllUnprocessedIds(): Promise<Array<string>> {
|
||||||
return db
|
return db
|
||||||
.prepare<EmptyQuery>(
|
.prepare<EmptyQuery>(
|
||||||
`
|
`
|
||||||
SELECT id
|
SELECT id
|
||||||
FROM unprocessed
|
FROM unprocessed
|
||||||
ORDER BY receivedAtCounter ASC
|
ORDER BY receivedAtCounter ASC
|
||||||
`
|
`
|
||||||
|
|
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);
|
return JSON.parse(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type QueryTemplateParam = string | number | undefined;
|
||||||
|
export type QueryFragmentValue = QueryFragment | QueryTemplateParam;
|
||||||
|
|
||||||
|
export type QueryFragment = [
|
||||||
|
{ fragment: string },
|
||||||
|
ReadonlyArray<QueryTemplateParam>
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* You can use tagged template literals to build "fragments" of SQL queries
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* const [query, params] = sql`
|
||||||
|
* SELECT * FROM examples
|
||||||
|
* WHERE groupId = ${groupId}
|
||||||
|
* ORDER BY timestamp ${asc ? sqlFragment`ASC` : sqlFragment`DESC`}
|
||||||
|
* `;
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* SQL Fragments can contain other SQL fragments, but must be finalized with
|
||||||
|
* `sql` before being passed to `Database#prepare`.
|
||||||
|
*
|
||||||
|
* The name `sqlFragment` comes from several editors that support SQL syntax
|
||||||
|
* highlighting inside JavaScript template literals.
|
||||||
|
*/
|
||||||
|
export function sqlFragment(
|
||||||
|
strings: TemplateStringsArray,
|
||||||
|
...values: ReadonlyArray<QueryFragmentValue>
|
||||||
|
): QueryFragment {
|
||||||
|
let query = '';
|
||||||
|
const params: Array<string | number | undefined> = [];
|
||||||
|
|
||||||
|
strings.forEach((string, index) => {
|
||||||
|
const value = values[index];
|
||||||
|
|
||||||
|
query += string;
|
||||||
|
|
||||||
|
if (index < values.length) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const [{ fragment }, fragmentParams] = value;
|
||||||
|
query += fragment;
|
||||||
|
params.push(...fragmentParams);
|
||||||
|
} else {
|
||||||
|
query += '?';
|
||||||
|
params.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return [{ fragment: query }, params];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like `Array.prototype.join`, but for SQL fragments.
|
||||||
|
*/
|
||||||
|
export function sqlJoin(
|
||||||
|
items: ReadonlyArray<QueryFragmentValue>,
|
||||||
|
separator: string
|
||||||
|
): QueryFragment {
|
||||||
|
let query = '';
|
||||||
|
const params: Array<string | number | undefined> = [];
|
||||||
|
|
||||||
|
items.forEach((item, index) => {
|
||||||
|
const [{ fragment }, fragmentParams] = sqlFragment`${item}`;
|
||||||
|
query += fragment;
|
||||||
|
params.push(...fragmentParams);
|
||||||
|
|
||||||
|
if (index < items.length - 1) {
|
||||||
|
query += separator;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return [{ fragment: query }, params];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QueryTemplate = [
|
||||||
|
string,
|
||||||
|
ReadonlyArray<string | number | undefined>
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* You can use tagged template literals to build SQL queries
|
||||||
|
* that can be passed to `Database#prepare`.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* const [query, params] = sql`
|
||||||
|
* SELECT * FROM examples
|
||||||
|
* WHERE groupId = ${groupId}
|
||||||
|
* ORDER BY timestamp ASC
|
||||||
|
* `;
|
||||||
|
* db.prepare(query).all(params);
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* SQL queries can contain other SQL fragments, but cannot contain other SQL
|
||||||
|
* queries.
|
||||||
|
*
|
||||||
|
* The name `sql` comes from several editors that support SQL syntax
|
||||||
|
* highlighting inside JavaScript template literals.
|
||||||
|
*/
|
||||||
|
export function sql(
|
||||||
|
strings: TemplateStringsArray,
|
||||||
|
...values: ReadonlyArray<QueryFragment | string | number | undefined>
|
||||||
|
): QueryTemplate {
|
||||||
|
const [{ fragment }, params] = sqlFragment(strings, ...values);
|
||||||
|
return [fragment, params];
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueryPlanRow = Readonly<{
|
||||||
|
id: number;
|
||||||
|
parent: number;
|
||||||
|
details: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
type QueryPlan = Readonly<{
|
||||||
|
query: string;
|
||||||
|
plan: ReadonlyArray<QueryPlanRow>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns typed objects of the query plan for the given query.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* const [query, params] = sql`
|
||||||
|
* SELECT * FROM examples
|
||||||
|
* WHERE groupId = ${groupId}
|
||||||
|
* ORDER BY timestamp ASC
|
||||||
|
* `;
|
||||||
|
* log.info('Query plan', explainQueryPlan(db, [query, params]));
|
||||||
|
* db.prepare(query).all(params);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function explainQueryPlan(
|
||||||
|
db: Database,
|
||||||
|
template: QueryTemplate
|
||||||
|
): QueryPlan {
|
||||||
|
const [query, params] = template;
|
||||||
|
const plan = db.prepare(`EXPLAIN QUERY PLAN ${query}`).all(params);
|
||||||
|
return { query, plan };
|
||||||
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// Database helpers
|
// Database helpers
|
||||||
//
|
//
|
||||||
|
|
|
@ -18,7 +18,7 @@ import type {
|
||||||
MessagesAddedActionType,
|
MessagesAddedActionType,
|
||||||
MessageDeletedActionType,
|
MessageDeletedActionType,
|
||||||
MessageChangedActionType,
|
MessageChangedActionType,
|
||||||
SelectedConversationChangedActionType,
|
TargetedConversationChangedActionType,
|
||||||
ConversationChangedActionType,
|
ConversationChangedActionType,
|
||||||
} from './conversations';
|
} from './conversations';
|
||||||
import * as log from '../../logging/log';
|
import * as log from '../../logging/log';
|
||||||
|
@ -295,7 +295,7 @@ export function reducer(
|
||||||
| MessageDeletedActionType
|
| MessageDeletedActionType
|
||||||
| MessageChangedActionType
|
| MessageChangedActionType
|
||||||
| MessagesAddedActionType
|
| MessagesAddedActionType
|
||||||
| SelectedConversationChangedActionType
|
| TargetedConversationChangedActionType
|
||||||
>
|
>
|
||||||
): AudioPlayerStateType {
|
): AudioPlayerStateType {
|
||||||
const { active } = state;
|
const { active } = state;
|
||||||
|
|
|
@ -77,12 +77,12 @@ import { enqueueReactionForSend } from '../../reactions/enqueueReactionForSend';
|
||||||
import { useBoundActions } from '../../hooks/useBoundActions';
|
import { useBoundActions } from '../../hooks/useBoundActions';
|
||||||
import {
|
import {
|
||||||
CONVERSATION_UNLOADED,
|
CONVERSATION_UNLOADED,
|
||||||
SELECTED_CONVERSATION_CHANGED,
|
TARGETED_CONVERSATION_CHANGED,
|
||||||
scrollToMessage,
|
scrollToMessage,
|
||||||
} from './conversations';
|
} from './conversations';
|
||||||
import type {
|
import type {
|
||||||
ConversationUnloadedActionType,
|
ConversationUnloadedActionType,
|
||||||
SelectedConversationChangedActionType,
|
TargetedConversationChangedActionType,
|
||||||
ScrollToMessageActionType,
|
ScrollToMessageActionType,
|
||||||
} from './conversations';
|
} from './conversations';
|
||||||
import { longRunningTaskWrapper } from '../../util/longRunningTaskWrapper';
|
import { longRunningTaskWrapper } from '../../util/longRunningTaskWrapper';
|
||||||
|
@ -204,7 +204,7 @@ type ComposerActionType =
|
||||||
| RemoveLinkPreviewActionType
|
| RemoveLinkPreviewActionType
|
||||||
| ReplaceAttachmentsActionType
|
| ReplaceAttachmentsActionType
|
||||||
| ResetComposerActionType
|
| ResetComposerActionType
|
||||||
| SelectedConversationChangedActionType
|
| TargetedConversationChangedActionType
|
||||||
| SetComposerDisabledStateActionType
|
| SetComposerDisabledStateActionType
|
||||||
| SetFocusActionType
|
| SetFocusActionType
|
||||||
| SetHighQualitySettingActionType
|
| SetHighQualitySettingActionType
|
||||||
|
@ -1267,7 +1267,7 @@ export function reducer(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action.type === SELECTED_CONVERSATION_CHANGED) {
|
if (action.type === TARGETED_CONVERSATION_CHANGED) {
|
||||||
if (action.payload.conversationId) {
|
if (action.payload.conversationId) {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
|
|
@ -85,7 +85,7 @@ import {
|
||||||
ComposerStep,
|
ComposerStep,
|
||||||
ConversationVerificationState,
|
ConversationVerificationState,
|
||||||
OneTimeModalState,
|
OneTimeModalState,
|
||||||
SelectedMessageSource,
|
TargetedMessageSource,
|
||||||
} from './conversationsEnums';
|
} from './conversationsEnums';
|
||||||
import { markViewed as messageUpdaterMarkViewed } from '../../services/MessageUpdater';
|
import { markViewed as messageUpdaterMarkViewed } from '../../services/MessageUpdater';
|
||||||
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
|
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
|
||||||
|
@ -148,6 +148,7 @@ import {
|
||||||
handleLeaveConversation,
|
handleLeaveConversation,
|
||||||
} from './composer';
|
} from './composer';
|
||||||
import { ReceiptType } from '../../types/Receipt';
|
import { ReceiptType } from '../../types/Receipt';
|
||||||
|
import { sortByMessageOrder } from '../../util/maybeForwardMessages';
|
||||||
|
|
||||||
// State
|
// State
|
||||||
|
|
||||||
|
@ -161,6 +162,10 @@ export type DBConversationType = ReadonlyDeep<{
|
||||||
export const InteractionModes = ['mouse', 'keyboard'] as const;
|
export const InteractionModes = ['mouse', 'keyboard'] as const;
|
||||||
export type InteractionModeType = ReadonlyDeep<typeof InteractionModes[number]>;
|
export type InteractionModeType = ReadonlyDeep<typeof InteractionModes[number]>;
|
||||||
|
|
||||||
|
export type MessageTimestamps = ReadonlyDeep<
|
||||||
|
Pick<MessageAttributesType, 'sent_at' | 'received_at'>
|
||||||
|
>;
|
||||||
|
|
||||||
// eslint-disable-next-line local-rules/type-alias-readonlydeep
|
// eslint-disable-next-line local-rules/type-alias-readonlydeep
|
||||||
export type MessageType = MessageAttributesType & {
|
export type MessageType = MessageAttributesType & {
|
||||||
interactionType?: InteractionModeType;
|
interactionType?: InteractionModeType;
|
||||||
|
@ -438,11 +443,14 @@ export type ConversationsStateType = Readonly<{
|
||||||
conversationsByGroupId: ConversationLookupType;
|
conversationsByGroupId: ConversationLookupType;
|
||||||
conversationsByUsername: ConversationLookupType;
|
conversationsByUsername: ConversationLookupType;
|
||||||
selectedConversationId?: string;
|
selectedConversationId?: string;
|
||||||
selectedMessage: string | undefined;
|
targetedMessage: string | undefined;
|
||||||
selectedMessageCounter: number;
|
targetedMessageCounter: number;
|
||||||
selectedMessageSource: SelectedMessageSource | undefined;
|
targetedMessageSource: TargetedMessageSource | undefined;
|
||||||
selectedConversationPanels: ReadonlyArray<PanelRenderType>;
|
targetedConversationPanels: ReadonlyArray<PanelRenderType>;
|
||||||
selectedMessageForDetails?: MessageAttributesType;
|
targetedMessageForDetails?: MessageAttributesType;
|
||||||
|
|
||||||
|
lastSelectedMessage: MessageTimestamps | undefined;
|
||||||
|
selectedMessageIds: ReadonlyArray<string> | undefined;
|
||||||
|
|
||||||
showArchived: boolean;
|
showArchived: boolean;
|
||||||
composer?: ComposerStateType;
|
composer?: ComposerStateType;
|
||||||
|
@ -505,8 +513,8 @@ const CONVERSATION_STOPPED_BY_MISSING_VERIFICATION =
|
||||||
'conversations/CONVERSATION_STOPPED_BY_MISSING_VERIFICATION';
|
'conversations/CONVERSATION_STOPPED_BY_MISSING_VERIFICATION';
|
||||||
const DISCARD_MESSAGES = 'conversations/DISCARD_MESSAGES';
|
const DISCARD_MESSAGES = 'conversations/DISCARD_MESSAGES';
|
||||||
const REPLACE_AVATARS = 'conversations/REPLACE_AVATARS';
|
const REPLACE_AVATARS = 'conversations/REPLACE_AVATARS';
|
||||||
export const SELECTED_CONVERSATION_CHANGED =
|
export const TARGETED_CONVERSATION_CHANGED =
|
||||||
'conversations/SELECTED_CONVERSATION_CHANGED';
|
'conversations/TARGETED_CONVERSATION_CHANGED';
|
||||||
const PUSH_PANEL = 'conversations/PUSH_PANEL';
|
const PUSH_PANEL = 'conversations/PUSH_PANEL';
|
||||||
const POP_PANEL = 'conversations/POP_PANEL';
|
const POP_PANEL = 'conversations/POP_PANEL';
|
||||||
export const MESSAGE_CHANGED = 'MESSAGE_CHANGED';
|
export const MESSAGE_CHANGED = 'MESSAGE_CHANGED';
|
||||||
|
@ -648,13 +656,27 @@ export type RemoveAllConversationsActionType = ReadonlyDeep<{
|
||||||
type: 'CONVERSATIONS_REMOVE_ALL';
|
type: 'CONVERSATIONS_REMOVE_ALL';
|
||||||
payload: null;
|
payload: null;
|
||||||
}>;
|
}>;
|
||||||
export type MessageSelectedActionType = ReadonlyDeep<{
|
export type MessageTargetedActionType = ReadonlyDeep<{
|
||||||
type: 'MESSAGE_SELECTED';
|
type: 'MESSAGE_TARGETED';
|
||||||
payload: {
|
payload: {
|
||||||
messageId: string;
|
messageId: string;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
|
export type ToggleSelectMessagesActionType = ReadonlyDeep<{
|
||||||
|
type: 'TOGGLE_SELECT_MESSAGES';
|
||||||
|
payload: {
|
||||||
|
toggledMessageId: string;
|
||||||
|
messageIds: Array<string>;
|
||||||
|
selected: boolean;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
export type ToggleSelectModeActionType = ReadonlyDeep<{
|
||||||
|
type: 'TOGGLE_SELECT_MODE';
|
||||||
|
payload: {
|
||||||
|
on: boolean;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
type ConversationStoppedByMissingVerificationActionType = ReadonlyDeep<{
|
type ConversationStoppedByMissingVerificationActionType = ReadonlyDeep<{
|
||||||
type: typeof CONVERSATION_STOPPED_BY_MISSING_VERIFICATION;
|
type: typeof CONVERSATION_STOPPED_BY_MISSING_VERIFICATION;
|
||||||
payload: {
|
payload: {
|
||||||
|
@ -752,8 +774,8 @@ export type ScrollToMessageActionType = ReadonlyDeep<{
|
||||||
messageId: string;
|
messageId: string;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
export type ClearSelectedMessageActionType = ReadonlyDeep<{
|
export type ClearTargetedMessageActionType = ReadonlyDeep<{
|
||||||
type: 'CLEAR_SELECTED_MESSAGE';
|
type: 'CLEAR_TARGETED_MESSAGE';
|
||||||
payload: null;
|
payload: null;
|
||||||
}>;
|
}>;
|
||||||
export type ClearUnreadMetricsActionType = ReadonlyDeep<{
|
export type ClearUnreadMetricsActionType = ReadonlyDeep<{
|
||||||
|
@ -762,8 +784,8 @@ export type ClearUnreadMetricsActionType = ReadonlyDeep<{
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
export type SelectedConversationChangedActionType = ReadonlyDeep<{
|
export type TargetedConversationChangedActionType = ReadonlyDeep<{
|
||||||
type: typeof SELECTED_CONVERSATION_CHANGED;
|
type: typeof TARGETED_CONVERSATION_CHANGED;
|
||||||
payload: {
|
payload: {
|
||||||
conversationId?: string;
|
conversationId?: string;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
|
@ -865,7 +887,7 @@ export type ConversationActionType =
|
||||||
| ClearCancelledVerificationActionType
|
| ClearCancelledVerificationActionType
|
||||||
| ClearGroupCreationErrorActionType
|
| ClearGroupCreationErrorActionType
|
||||||
| ClearInvitedUuidsForNewlyCreatedGroupActionType
|
| ClearInvitedUuidsForNewlyCreatedGroupActionType
|
||||||
| ClearSelectedMessageActionType
|
| ClearTargetedMessageActionType
|
||||||
| ClearUnreadMetricsActionType
|
| ClearUnreadMetricsActionType
|
||||||
| ClearVerificationDataByConversationActionType
|
| ClearVerificationDataByConversationActionType
|
||||||
| CloseContactSpoofingReviewActionType
|
| CloseContactSpoofingReviewActionType
|
||||||
|
@ -890,7 +912,7 @@ export type ConversationActionType =
|
||||||
| MessageDeletedActionType
|
| MessageDeletedActionType
|
||||||
| MessageExpandedActionType
|
| MessageExpandedActionType
|
||||||
| MessageExpiredActionType
|
| MessageExpiredActionType
|
||||||
| MessageSelectedActionType
|
| MessageTargetedActionType
|
||||||
| MessagesAddedActionType
|
| MessagesAddedActionType
|
||||||
| MessagesResetActionType
|
| MessagesResetActionType
|
||||||
| PopPanelActionType
|
| PopPanelActionType
|
||||||
|
@ -902,7 +924,7 @@ export type ConversationActionType =
|
||||||
| ReviewGroupMemberNameCollisionActionType
|
| ReviewGroupMemberNameCollisionActionType
|
||||||
| ReviewMessageRequestNameCollisionActionType
|
| ReviewMessageRequestNameCollisionActionType
|
||||||
| ScrollToMessageActionType
|
| ScrollToMessageActionType
|
||||||
| SelectedConversationChangedActionType
|
| TargetedConversationChangedActionType
|
||||||
| SetComposeGroupAvatarActionType
|
| SetComposeGroupAvatarActionType
|
||||||
| SetComposeGroupExpireTimerActionType
|
| SetComposeGroupExpireTimerActionType
|
||||||
| SetComposeGroupNameActionType
|
| SetComposeGroupNameActionType
|
||||||
|
@ -919,7 +941,9 @@ export type ConversationActionType =
|
||||||
| StartComposingActionType
|
| StartComposingActionType
|
||||||
| StartSettingGroupMetadataActionType
|
| StartSettingGroupMetadataActionType
|
||||||
| ToggleComposeEditingAvatarActionType
|
| ToggleComposeEditingAvatarActionType
|
||||||
| ToggleConversationInChooseMembersActionType;
|
| ToggleConversationInChooseMembersActionType
|
||||||
|
| ToggleSelectMessagesActionType
|
||||||
|
| ToggleSelectModeActionType;
|
||||||
|
|
||||||
// Action Creators
|
// Action Creators
|
||||||
|
|
||||||
|
@ -938,7 +962,7 @@ export const actions = {
|
||||||
clearCancelledConversationVerification,
|
clearCancelledConversationVerification,
|
||||||
clearGroupCreationError,
|
clearGroupCreationError,
|
||||||
clearInvitedUuidsForNewlyCreatedGroup,
|
clearInvitedUuidsForNewlyCreatedGroup,
|
||||||
clearSelectedMessage,
|
clearTargetedMessage,
|
||||||
clearUnreadMetrics,
|
clearUnreadMetrics,
|
||||||
closeContactSpoofingReview,
|
closeContactSpoofingReview,
|
||||||
closeMaximumGroupSizeModal,
|
closeMaximumGroupSizeModal,
|
||||||
|
@ -954,7 +978,7 @@ export const actions = {
|
||||||
createGroup,
|
createGroup,
|
||||||
deleteAvatarFromDisk,
|
deleteAvatarFromDisk,
|
||||||
deleteConversation,
|
deleteConversation,
|
||||||
deleteMessage,
|
deleteMessages,
|
||||||
deleteMessageForEveryone,
|
deleteMessageForEveryone,
|
||||||
destroyMessages,
|
destroyMessages,
|
||||||
discardMessages,
|
discardMessages,
|
||||||
|
@ -1001,7 +1025,7 @@ export const actions = {
|
||||||
saveAttachmentFromMessage,
|
saveAttachmentFromMessage,
|
||||||
saveAvatarToDisk,
|
saveAvatarToDisk,
|
||||||
scrollToMessage,
|
scrollToMessage,
|
||||||
selectMessage,
|
targetMessage,
|
||||||
setAccessControlAddFromInviteLinkSetting,
|
setAccessControlAddFromInviteLinkSetting,
|
||||||
setAccessControlAttributesSetting,
|
setAccessControlAttributesSetting,
|
||||||
setAccessControlMembersSetting,
|
setAccessControlMembersSetting,
|
||||||
|
@ -1033,6 +1057,8 @@ export const actions = {
|
||||||
toggleConversationInChooseMembers,
|
toggleConversationInChooseMembers,
|
||||||
toggleGroupsForStorySend,
|
toggleGroupsForStorySend,
|
||||||
toggleHideStories,
|
toggleHideStories,
|
||||||
|
toggleSelectMessage,
|
||||||
|
toggleSelectMode,
|
||||||
unblurAvatar,
|
unblurAvatar,
|
||||||
updateConversationModelSharedGroups,
|
updateConversationModelSharedGroups,
|
||||||
updateGroupAttributes,
|
updateGroupAttributes,
|
||||||
|
@ -1083,7 +1109,7 @@ function onUndoArchive(
|
||||||
void,
|
void,
|
||||||
RootStateType,
|
RootStateType,
|
||||||
unknown,
|
unknown,
|
||||||
SelectedConversationChangedActionType
|
TargetedConversationChangedActionType
|
||||||
> {
|
> {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
const conversation = window.ConversationController.get(conversationId);
|
const conversation = window.ConversationController.get(conversationId);
|
||||||
|
@ -1553,17 +1579,19 @@ function setPinned(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteMessage({
|
function deleteMessages({
|
||||||
conversationId,
|
conversationId,
|
||||||
messageId,
|
messageIds,
|
||||||
|
lastSelectedMessage,
|
||||||
}: {
|
}: {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
messageId: string;
|
messageIds: ReadonlyArray<string>;
|
||||||
|
lastSelectedMessage?: MessageTimestamps;
|
||||||
}): ThunkAction<void, RootStateType, unknown, NoopActionType> {
|
}): ThunkAction<void, RootStateType, unknown, NoopActionType> {
|
||||||
return async (dispatch, getState) => {
|
return async (dispatch, getState) => {
|
||||||
const message = await getMessageById(messageId);
|
if (!messageIds || messageIds.length === 0) {
|
||||||
if (!message) {
|
log.warn('deleteMessages: No message ids provided');
|
||||||
throw new Error(`deleteMessage: Message ${messageId} missing!`);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const conversation = window.ConversationController.get(conversationId);
|
const conversation = window.ConversationController.get(conversationId);
|
||||||
|
@ -1571,25 +1599,61 @@ function deleteMessage({
|
||||||
throw new Error('deleteMessage: No conversation found');
|
throw new Error('deleteMessage: No conversation found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const messageConversationId = message.get('conversationId');
|
let outgoingDeleted = 0;
|
||||||
if (conversationId !== messageConversationId) {
|
let incomingDeleted = 0;
|
||||||
throw new Error(
|
|
||||||
`deleteMessage: message conversation ${messageConversationId} doesn't match provided conversation ${conversationId}`
|
await Promise.all(
|
||||||
);
|
messageIds.map(async messageId => {
|
||||||
|
const message = await getMessageById(messageId);
|
||||||
|
if (!message) {
|
||||||
|
throw new Error(`deleteMessages: Message ${messageId} missing!`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageConversationId = message.get('conversationId');
|
||||||
|
if (conversationId !== messageConversationId) {
|
||||||
|
throw new Error(
|
||||||
|
`deleteMessages: message conversation ${messageConversationId} doesn't match provided conversation ${conversationId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOutgoing(message.attributes)) {
|
||||||
|
outgoingDeleted += 1;
|
||||||
|
} else {
|
||||||
|
incomingDeleted += 1;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
let nearbyMessageId: string | null = null;
|
||||||
|
|
||||||
|
if (nearbyMessageId == null && lastSelectedMessage != null) {
|
||||||
|
const foundMessageId =
|
||||||
|
await window.Signal.Data.getNearbyMessageFromDeletedSet({
|
||||||
|
conversationId,
|
||||||
|
lastSelectedMessage,
|
||||||
|
deletedMessageIds: messageIds,
|
||||||
|
includeStoryReplies: false,
|
||||||
|
storyId: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (foundMessageId != null) {
|
||||||
|
nearbyMessageId = foundMessageId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void window.Signal.Data.removeMessage(messageId);
|
await window.Signal.Data.removeMessages(messageIds);
|
||||||
if (isOutgoing(message.attributes)) {
|
|
||||||
conversation.decrementSentMessageCount();
|
if (outgoingDeleted > 0) {
|
||||||
} else {
|
conversation.decrementSentMessageCount(outgoingDeleted);
|
||||||
conversation.decrementMessageCount();
|
}
|
||||||
|
if (incomingDeleted > 0) {
|
||||||
|
conversation.decrementMessageCount(incomingDeleted);
|
||||||
}
|
}
|
||||||
popPanelForConversation()(dispatch, getState, undefined);
|
popPanelForConversation()(dispatch, getState, undefined);
|
||||||
|
|
||||||
dispatch({
|
if (nearbyMessageId != null) {
|
||||||
type: 'NOOP',
|
dispatch(scrollToMessage(conversationId, nearbyMessageId));
|
||||||
payload: null,
|
}
|
||||||
});
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2330,7 +2394,7 @@ function createGroup(
|
||||||
| CreateGroupPendingActionType
|
| CreateGroupPendingActionType
|
||||||
| CreateGroupFulfilledActionType
|
| CreateGroupFulfilledActionType
|
||||||
| CreateGroupRejectedActionType
|
| CreateGroupRejectedActionType
|
||||||
| SelectedConversationChangedActionType
|
| TargetedConversationChangedActionType
|
||||||
> {
|
> {
|
||||||
return async (dispatch, getState) => {
|
return async (dispatch, getState) => {
|
||||||
const { composer } = getState().conversations;
|
const { composer } = getState().conversations;
|
||||||
|
@ -2380,12 +2444,12 @@ function removeAllConversations(): RemoveAllConversationsActionType {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectMessage(
|
function targetMessage(
|
||||||
messageId: string,
|
messageId: string,
|
||||||
conversationId: string
|
conversationId: string
|
||||||
): MessageSelectedActionType {
|
): MessageTargetedActionType {
|
||||||
return {
|
return {
|
||||||
type: 'MESSAGE_SELECTED',
|
type: 'MESSAGE_TARGETED',
|
||||||
payload: {
|
payload: {
|
||||||
messageId,
|
messageId,
|
||||||
conversationId,
|
conversationId,
|
||||||
|
@ -2393,6 +2457,79 @@ function selectMessage(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleSelectMessage(
|
||||||
|
conversationId: string,
|
||||||
|
messageId: string,
|
||||||
|
shift: boolean,
|
||||||
|
selected: boolean
|
||||||
|
): ThunkAction<void, RootStateType, unknown, ToggleSelectMessagesActionType> {
|
||||||
|
return async (dispatch, getState) => {
|
||||||
|
const state = getState();
|
||||||
|
const { conversations } = state;
|
||||||
|
|
||||||
|
let toggledMessageIds: ReadonlyArray<string>;
|
||||||
|
if (shift && conversations.lastSelectedMessage != null) {
|
||||||
|
if (conversationId !== conversations.selectedConversationId) {
|
||||||
|
throw new Error("toggleSelectMessage: conversationId doesn't match");
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversation = window.ConversationController.get(conversationId);
|
||||||
|
|
||||||
|
if (conversation == null) {
|
||||||
|
throw new Error('toggleSelectMessage: conversation not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggledMessage = getOwn(conversations.messagesLookup, messageId);
|
||||||
|
|
||||||
|
strictAssert(
|
||||||
|
toggledMessage != null,
|
||||||
|
'toggleSelectMessage: toggled message not found'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sort the messages by their order in the conversation
|
||||||
|
const [after, before] = sortByMessageOrder(
|
||||||
|
[toggledMessage, conversations.lastSelectedMessage],
|
||||||
|
message => message
|
||||||
|
);
|
||||||
|
|
||||||
|
const betweenIds = await window.Signal.Data.getMessagesBetween(
|
||||||
|
conversationId,
|
||||||
|
{
|
||||||
|
after: {
|
||||||
|
sent_at: after.sent_at,
|
||||||
|
received_at: after.received_at,
|
||||||
|
},
|
||||||
|
before: {
|
||||||
|
sent_at: before.sent_at,
|
||||||
|
received_at: before.received_at,
|
||||||
|
},
|
||||||
|
includeStoryReplies: !isGroup(conversation.attributes),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
toggledMessageIds = [messageId, ...betweenIds];
|
||||||
|
} else {
|
||||||
|
toggledMessageIds = [messageId];
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: 'TOGGLE_SELECT_MESSAGES',
|
||||||
|
payload: {
|
||||||
|
toggledMessageId: messageId,
|
||||||
|
messageIds: toggledMessageIds,
|
||||||
|
selected,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelectMode(on: boolean): ToggleSelectModeActionType {
|
||||||
|
return {
|
||||||
|
type: 'TOGGLE_SELECT_MODE',
|
||||||
|
payload: { on },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function getProfilesForConversation(conversationId: string): NoopActionType {
|
function getProfilesForConversation(conversationId: string): NoopActionType {
|
||||||
const conversation = window.ConversationController.get(conversationId);
|
const conversation = window.ConversationController.get(conversationId);
|
||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
|
@ -2662,7 +2799,8 @@ function popPanelForConversation(): ThunkAction<
|
||||||
> {
|
> {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
const { conversations } = getState();
|
const { conversations } = getState();
|
||||||
const { selectedConversationPanels } = conversations;
|
const { targetedConversationPanels: selectedConversationPanels } =
|
||||||
|
conversations;
|
||||||
|
|
||||||
if (!selectedConversationPanels.length) {
|
if (!selectedConversationPanels.length) {
|
||||||
return;
|
return;
|
||||||
|
@ -3130,9 +3268,9 @@ function clearInvitedUuidsForNewlyCreatedGroup(): ClearInvitedUuidsForNewlyCreat
|
||||||
function clearGroupCreationError(): ClearGroupCreationErrorActionType {
|
function clearGroupCreationError(): ClearGroupCreationErrorActionType {
|
||||||
return { type: 'CLEAR_GROUP_CREATION_ERROR' };
|
return { type: 'CLEAR_GROUP_CREATION_ERROR' };
|
||||||
}
|
}
|
||||||
function clearSelectedMessage(): ClearSelectedMessageActionType {
|
function clearTargetedMessage(): ClearTargetedMessageActionType {
|
||||||
return {
|
return {
|
||||||
type: 'CLEAR_SELECTED_MESSAGE',
|
type: 'CLEAR_TARGETED_MESSAGE',
|
||||||
payload: null,
|
payload: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -3523,7 +3661,7 @@ function showConversation({
|
||||||
void,
|
void,
|
||||||
RootStateType,
|
RootStateType,
|
||||||
unknown,
|
unknown,
|
||||||
SelectedConversationChangedActionType
|
TargetedConversationChangedActionType
|
||||||
> {
|
> {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
const { conversations } = getState();
|
const { conversations } = getState();
|
||||||
|
@ -3543,7 +3681,7 @@ function showConversation({
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: SELECTED_CONVERSATION_CHANGED,
|
type: TARGETED_CONVERSATION_CHANGED,
|
||||||
payload: {
|
payload: {
|
||||||
conversationId,
|
conversationId,
|
||||||
messageId,
|
messageId,
|
||||||
|
@ -3726,11 +3864,13 @@ export function getEmptyState(): ConversationsStateType {
|
||||||
verificationDataByConversation: {},
|
verificationDataByConversation: {},
|
||||||
messagesByConversation: {},
|
messagesByConversation: {},
|
||||||
messagesLookup: {},
|
messagesLookup: {},
|
||||||
selectedMessage: undefined,
|
targetedMessage: undefined,
|
||||||
selectedMessageCounter: 0,
|
targetedMessageCounter: 0,
|
||||||
selectedMessageSource: undefined,
|
targetedMessageSource: undefined,
|
||||||
|
lastSelectedMessage: undefined,
|
||||||
|
selectedMessageIds: undefined,
|
||||||
showArchived: false,
|
showArchived: false,
|
||||||
selectedConversationPanels: [],
|
targetedConversationPanels: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3966,24 +4106,24 @@ function visitListsInVerificationData(
|
||||||
function maybeUpdateSelectedMessageForDetails(
|
function maybeUpdateSelectedMessageForDetails(
|
||||||
{
|
{
|
||||||
messageId,
|
messageId,
|
||||||
selectedMessageForDetails,
|
targetedMessageForDetails,
|
||||||
}: {
|
}: {
|
||||||
messageId: string;
|
messageId: string;
|
||||||
selectedMessageForDetails: MessageAttributesType | undefined;
|
targetedMessageForDetails: MessageAttributesType | undefined;
|
||||||
},
|
},
|
||||||
state: ConversationsStateType
|
state: ConversationsStateType
|
||||||
): ConversationsStateType {
|
): ConversationsStateType {
|
||||||
if (!state.selectedMessageForDetails) {
|
if (!state.targetedMessageForDetails) {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.selectedMessageForDetails.id !== messageId) {
|
if (state.targetedMessageForDetails.id !== messageId) {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
selectedMessageForDetails,
|
targetedMessageForDetails,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4092,6 +4232,11 @@ export function reducer(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action.type === DISCARD_MESSAGES) {
|
if (action.type === DISCARD_MESSAGES) {
|
||||||
|
if (state.selectedMessageIds != null) {
|
||||||
|
log.info('Not discarding messages because we are in select mode');
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
const { conversationId } = action.payload;
|
const { conversationId } = action.payload;
|
||||||
if ('numberToKeepAtBottom' in action.payload) {
|
if ('numberToKeepAtBottom' in action.payload) {
|
||||||
const { numberToKeepAtBottom } = action.payload;
|
const { numberToKeepAtBottom } = action.payload;
|
||||||
|
@ -4261,7 +4406,7 @@ export function reducer(
|
||||||
return {
|
return {
|
||||||
...omit(state, 'contactSpoofingReview'),
|
...omit(state, 'contactSpoofingReview'),
|
||||||
selectedConversationId,
|
selectedConversationId,
|
||||||
selectedConversationPanels: [],
|
targetedConversationPanels: [],
|
||||||
messagesLookup: omit(state.messagesLookup, [...messageIds]),
|
messagesLookup: omit(state.messagesLookup, [...messageIds]),
|
||||||
messagesByConversation: omit(state.messagesByConversation, [
|
messagesByConversation: omit(state.messagesByConversation, [
|
||||||
conversationId,
|
conversationId,
|
||||||
|
@ -4311,7 +4456,7 @@ export function reducer(
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (action.type === 'MESSAGE_SELECTED') {
|
if (action.type === 'MESSAGE_TARGETED') {
|
||||||
const { messageId, conversationId } = action.payload;
|
const { messageId, conversationId } = action.payload;
|
||||||
|
|
||||||
if (state.selectedConversationId !== conversationId) {
|
if (state.selectedConversationId !== conversationId) {
|
||||||
|
@ -4320,9 +4465,44 @@ export function reducer(
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
selectedMessage: messageId,
|
targetedMessage: messageId,
|
||||||
selectedMessageCounter: state.selectedMessageCounter + 1,
|
targetedMessageCounter: state.targetedMessageCounter + 1,
|
||||||
selectedMessageSource: SelectedMessageSource.Focus,
|
targetedMessageSource: TargetedMessageSource.Focus,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.type === 'TOGGLE_SELECT_MESSAGES') {
|
||||||
|
const { toggledMessageId, messageIds, selected } = action.payload;
|
||||||
|
let { selectedMessageIds = [] } = state;
|
||||||
|
|
||||||
|
if (selected) {
|
||||||
|
selectedMessageIds = selectedMessageIds.concat(messageIds);
|
||||||
|
} else {
|
||||||
|
selectedMessageIds = selectedMessageIds.filter(
|
||||||
|
id => !messageIds.includes(id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastSelectedMessage = getOwn(state.messagesLookup, toggledMessageId);
|
||||||
|
|
||||||
|
strictAssert(lastSelectedMessage, 'Message not found in lookup');
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
lastSelectedMessage: selected
|
||||||
|
? pick(lastSelectedMessage, 'sent_at', 'received_at')
|
||||||
|
: undefined,
|
||||||
|
selectedMessageIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.type === 'TOGGLE_SELECT_MODE') {
|
||||||
|
const { on } = action.payload;
|
||||||
|
const { selectedMessageIds = [] } = state;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
lastSelectedMessage: undefined,
|
||||||
|
selectedMessageIds: on ? selectedMessageIds : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4521,7 +4701,7 @@ export function reducer(
|
||||||
// We don't keep track of messages unless their conversation is loaded...
|
// We don't keep track of messages unless their conversation is loaded...
|
||||||
if (!existingConversation) {
|
if (!existingConversation) {
|
||||||
return maybeUpdateSelectedMessageForDetails(
|
return maybeUpdateSelectedMessageForDetails(
|
||||||
{ messageId: id, selectedMessageForDetails: data },
|
{ messageId: id, targetedMessageForDetails: data },
|
||||||
state
|
state
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -4530,7 +4710,7 @@ export function reducer(
|
||||||
const existingMessage = getOwn(state.messagesLookup, id);
|
const existingMessage = getOwn(state.messagesLookup, id);
|
||||||
if (!existingMessage) {
|
if (!existingMessage) {
|
||||||
return maybeUpdateSelectedMessageForDetails(
|
return maybeUpdateSelectedMessageForDetails(
|
||||||
{ messageId: id, selectedMessageForDetails: data },
|
{ messageId: id, targetedMessageForDetails: data },
|
||||||
state
|
state
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -4547,7 +4727,7 @@ export function reducer(
|
||||||
...maybeUpdateSelectedMessageForDetails(
|
...maybeUpdateSelectedMessageForDetails(
|
||||||
{
|
{
|
||||||
messageId: id,
|
messageId: id,
|
||||||
selectedMessageForDetails: data,
|
targetedMessageForDetails: data,
|
||||||
},
|
},
|
||||||
state
|
state
|
||||||
),
|
),
|
||||||
|
@ -4571,7 +4751,7 @@ export function reducer(
|
||||||
|
|
||||||
if (action.type === MESSAGE_EXPIRED) {
|
if (action.type === MESSAGE_EXPIRED) {
|
||||||
return maybeUpdateSelectedMessageForDetails(
|
return maybeUpdateSelectedMessageForDetails(
|
||||||
{ messageId: action.payload.id, selectedMessageForDetails: undefined },
|
{ messageId: action.payload.id, targetedMessageForDetails: undefined },
|
||||||
state
|
state
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -4638,9 +4818,9 @@ export function reducer(
|
||||||
...state,
|
...state,
|
||||||
...(state.selectedConversationId === conversationId
|
...(state.selectedConversationId === conversationId
|
||||||
? {
|
? {
|
||||||
selectedMessage: scrollToMessageId,
|
targetedMessage: scrollToMessageId,
|
||||||
selectedMessageCounter: state.selectedMessageCounter + 1,
|
targetedMessageCounter: state.targetedMessageCounter + 1,
|
||||||
selectedMessageSource: SelectedMessageSource.Reset,
|
targetedMessageSource: TargetedMessageSource.Reset,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
messagesLookup: {
|
messagesLookup: {
|
||||||
|
@ -4731,9 +4911,9 @@ export function reducer(
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
selectedMessage: messageId,
|
targetedMessage: messageId,
|
||||||
selectedMessageCounter: state.selectedMessageCounter + 1,
|
targetedMessageCounter: state.targetedMessageCounter + 1,
|
||||||
selectedMessageSource: SelectedMessageSource.NavigateToMessage,
|
targetedMessageSource: TargetedMessageSource.NavigateToMessage,
|
||||||
messagesByConversation: {
|
messagesByConversation: {
|
||||||
...messagesByConversation,
|
...messagesByConversation,
|
||||||
[conversationId]: {
|
[conversationId]: {
|
||||||
|
@ -4753,7 +4933,7 @@ export function reducer(
|
||||||
const existingConversation = messagesByConversation[conversationId];
|
const existingConversation = messagesByConversation[conversationId];
|
||||||
if (!existingConversation) {
|
if (!existingConversation) {
|
||||||
return maybeUpdateSelectedMessageForDetails(
|
return maybeUpdateSelectedMessageForDetails(
|
||||||
{ messageId: id, selectedMessageForDetails: undefined },
|
{ messageId: id, targetedMessageForDetails: undefined },
|
||||||
state
|
state
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -4800,7 +4980,7 @@ export function reducer(
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...maybeUpdateSelectedMessageForDetails(
|
...maybeUpdateSelectedMessageForDetails(
|
||||||
{ messageId: id, selectedMessageForDetails: undefined },
|
{ messageId: id, targetedMessageForDetails: undefined },
|
||||||
state
|
state
|
||||||
),
|
),
|
||||||
messagesLookup: omit(messagesLookup, id),
|
messagesLookup: omit(messagesLookup, id),
|
||||||
|
@ -5021,12 +5201,12 @@ export function reducer(
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (action.type === 'CLEAR_SELECTED_MESSAGE') {
|
if (action.type === 'CLEAR_TARGETED_MESSAGE') {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
selectedMessage: undefined,
|
targetedMessage: undefined,
|
||||||
selectedMessageCounter: 0,
|
targetedMessageCounter: 0,
|
||||||
selectedMessageSource: undefined,
|
targetedMessageSource: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (action.type === 'CLEAR_UNREAD_METRICS') {
|
if (action.type === 'CLEAR_UNREAD_METRICS') {
|
||||||
|
@ -5053,15 +5233,15 @@ export function reducer(
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (action.type === SELECTED_CONVERSATION_CHANGED) {
|
if (action.type === TARGETED_CONVERSATION_CHANGED) {
|
||||||
const { payload } = action;
|
const { payload } = action;
|
||||||
const { conversationId, messageId, switchToAssociatedView } = payload;
|
const { conversationId, messageId, switchToAssociatedView } = payload;
|
||||||
|
|
||||||
const nextState = {
|
const nextState = {
|
||||||
...omit(state, 'contactSpoofingReview'),
|
...omit(state, 'contactSpoofingReview'),
|
||||||
selectedConversationId: conversationId,
|
selectedConversationId: conversationId,
|
||||||
selectedMessage: messageId,
|
targetedMessage: messageId,
|
||||||
selectedMessageSource: SelectedMessageSource.NavigateToMessage,
|
targetedMessageSource: TargetedMessageSource.NavigateToMessage,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (switchToAssociatedView && conversationId) {
|
if (switchToAssociatedView && conversationId) {
|
||||||
|
@ -5070,7 +5250,7 @@ export function reducer(
|
||||||
return nextState;
|
return nextState;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...omit(nextState, 'composer'),
|
...omit(nextState, 'composer', 'selectedMessageIds'),
|
||||||
showArchived: Boolean(conversation.isArchived),
|
showArchived: Boolean(conversation.isArchived),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -5094,25 +5274,25 @@ export function reducer(
|
||||||
if (action.payload.type === PanelType.MessageDetails) {
|
if (action.payload.type === PanelType.MessageDetails) {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
selectedConversationPanels: [
|
targetedConversationPanels: [
|
||||||
...state.selectedConversationPanels,
|
...state.targetedConversationPanels,
|
||||||
action.payload,
|
action.payload,
|
||||||
],
|
],
|
||||||
selectedMessageForDetails: action.payload.args.message,
|
targetedMessageForDetails: action.payload.args.message,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
selectedConversationPanels: [
|
targetedConversationPanels: [
|
||||||
...state.selectedConversationPanels,
|
...state.targetedConversationPanels,
|
||||||
action.payload,
|
action.payload,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action.type === POP_PANEL) {
|
if (action.type === POP_PANEL) {
|
||||||
const { selectedConversationPanels } = state;
|
const { targetedConversationPanels: selectedConversationPanels } = state;
|
||||||
const nextPanels = [...selectedConversationPanels];
|
const nextPanels = [...selectedConversationPanels];
|
||||||
const panel = nextPanels.pop();
|
const panel = nextPanels.pop();
|
||||||
|
|
||||||
|
@ -5123,14 +5303,14 @@ export function reducer(
|
||||||
if (panel.type === PanelType.MessageDetails) {
|
if (panel.type === PanelType.MessageDetails) {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
selectedConversationPanels: nextPanels,
|
targetedConversationPanels: nextPanels,
|
||||||
selectedMessageForDetails: undefined,
|
targetedMessageForDetails: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
selectedConversationPanels: nextPanels,
|
targetedConversationPanels: nextPanels,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -24,7 +24,7 @@ export enum ConversationVerificationState {
|
||||||
VerificationCancelled = 'VerificationCancelled',
|
VerificationCancelled = 'VerificationCancelled',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum SelectedMessageSource {
|
export enum TargetedMessageSource {
|
||||||
Reset = 'Reset',
|
Reset = 'Reset',
|
||||||
NavigateToMessage = 'NavigateToMessage',
|
NavigateToMessage = 'NavigateToMessage',
|
||||||
Focus = 'Focus',
|
Focus = 'Focus',
|
||||||
|
|
|
@ -32,6 +32,10 @@ import type { ShowToastActionType } from './toast';
|
||||||
export type ForwardMessagePropsType = ReadonlyDeep<
|
export type ForwardMessagePropsType = ReadonlyDeep<
|
||||||
Omit<PropsForMessage, 'renderingContext' | 'menu' | 'contextMenu'>
|
Omit<PropsForMessage, 'renderingContext' | 'menu' | 'contextMenu'>
|
||||||
>;
|
>;
|
||||||
|
export type ForwardMessagesPropsType = ReadonlyDeep<{
|
||||||
|
messages: Array<ForwardMessagePropsType>;
|
||||||
|
onForward?: () => void;
|
||||||
|
}>;
|
||||||
export type SafetyNumberChangedBlockingDataType = ReadonlyDeep<{
|
export type SafetyNumberChangedBlockingDataType = ReadonlyDeep<{
|
||||||
promiseUuid: UUIDStringType;
|
promiseUuid: UUIDStringType;
|
||||||
source?: SafetyNumberChangeSource;
|
source?: SafetyNumberChangeSource;
|
||||||
|
@ -54,7 +58,7 @@ export type GlobalModalsStateType = ReadonlyDeep<{
|
||||||
description?: string;
|
description?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
};
|
};
|
||||||
forwardMessageProps?: ForwardMessagePropsType;
|
forwardMessagesProps?: ForwardMessagesPropsType;
|
||||||
gv2MigrationProps?: MigrateToGV2PropsType;
|
gv2MigrationProps?: MigrateToGV2PropsType;
|
||||||
isProfileEditorVisible: boolean;
|
isProfileEditorVisible: boolean;
|
||||||
isSignalConnectionsVisible: boolean;
|
isSignalConnectionsVisible: boolean;
|
||||||
|
@ -80,8 +84,8 @@ const HIDE_UUID_NOT_FOUND_MODAL = 'globalModals/HIDE_UUID_NOT_FOUND_MODAL';
|
||||||
const SHOW_UUID_NOT_FOUND_MODAL = 'globalModals/SHOW_UUID_NOT_FOUND_MODAL';
|
const SHOW_UUID_NOT_FOUND_MODAL = 'globalModals/SHOW_UUID_NOT_FOUND_MODAL';
|
||||||
const SHOW_STORIES_SETTINGS = 'globalModals/SHOW_STORIES_SETTINGS';
|
const SHOW_STORIES_SETTINGS = 'globalModals/SHOW_STORIES_SETTINGS';
|
||||||
const HIDE_STORIES_SETTINGS = 'globalModals/HIDE_STORIES_SETTINGS';
|
const HIDE_STORIES_SETTINGS = 'globalModals/HIDE_STORIES_SETTINGS';
|
||||||
const TOGGLE_FORWARD_MESSAGE_MODAL =
|
const TOGGLE_FORWARD_MESSAGES_MODAL =
|
||||||
'globalModals/TOGGLE_FORWARD_MESSAGE_MODAL';
|
'globalModals/TOGGLE_FORWARD_MESSAGES_MODAL';
|
||||||
const TOGGLE_PROFILE_EDITOR = 'globalModals/TOGGLE_PROFILE_EDITOR';
|
const TOGGLE_PROFILE_EDITOR = 'globalModals/TOGGLE_PROFILE_EDITOR';
|
||||||
export const TOGGLE_PROFILE_EDITOR_ERROR =
|
export const TOGGLE_PROFILE_EDITOR_ERROR =
|
||||||
'globalModals/TOGGLE_PROFILE_EDITOR_ERROR';
|
'globalModals/TOGGLE_PROFILE_EDITOR_ERROR';
|
||||||
|
@ -149,9 +153,9 @@ export type ShowUserNotFoundModalActionType = ReadonlyDeep<{
|
||||||
payload: UserNotFoundModalStateType;
|
payload: UserNotFoundModalStateType;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
type ToggleForwardMessageModalActionType = ReadonlyDeep<{
|
type ToggleForwardMessagesModalActionType = ReadonlyDeep<{
|
||||||
type: typeof TOGGLE_FORWARD_MESSAGE_MODAL;
|
type: typeof TOGGLE_FORWARD_MESSAGES_MODAL;
|
||||||
payload: ForwardMessagePropsType | undefined;
|
payload: ForwardMessagesPropsType | undefined;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
type ToggleProfileEditorActionType = ReadonlyDeep<{
|
type ToggleProfileEditorActionType = ReadonlyDeep<{
|
||||||
|
@ -273,7 +277,7 @@ export type GlobalModalsActionType = ReadonlyDeep<
|
||||||
| ConfirmAuthArtCreatorPendingActionType
|
| ConfirmAuthArtCreatorPendingActionType
|
||||||
| ConfirmAuthArtCreatorFulfilledActionType
|
| ConfirmAuthArtCreatorFulfilledActionType
|
||||||
| ShowAuthArtCreatorActionType
|
| ShowAuthArtCreatorActionType
|
||||||
| ToggleForwardMessageModalActionType
|
| ToggleForwardMessagesModalActionType
|
||||||
| ToggleProfileEditorActionType
|
| ToggleProfileEditorActionType
|
||||||
| ToggleProfileEditorErrorActionType
|
| ToggleProfileEditorErrorActionType
|
||||||
| ToggleSafetyNumberModalActionType
|
| ToggleSafetyNumberModalActionType
|
||||||
|
@ -294,7 +298,7 @@ export const actions = {
|
||||||
showStoriesSettings,
|
showStoriesSettings,
|
||||||
hideBlockingSafetyNumberChangeDialog,
|
hideBlockingSafetyNumberChangeDialog,
|
||||||
showBlockingSafetyNumberChangeDialog,
|
showBlockingSafetyNumberChangeDialog,
|
||||||
toggleForwardMessageModal,
|
toggleForwardMessagesModal,
|
||||||
toggleProfileEditor,
|
toggleProfileEditor,
|
||||||
toggleProfileEditorHasError,
|
toggleProfileEditorHasError,
|
||||||
toggleSafetyNumberModal,
|
toggleSafetyNumberModal,
|
||||||
|
@ -422,37 +426,44 @@ function closeGV2MigrationDialog(): CloseGV2MigrationDialogActionType {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleForwardMessageModal(
|
function toggleForwardMessagesModal(
|
||||||
messageId?: string
|
messageIds?: ReadonlyArray<string>,
|
||||||
|
onForward?: () => void
|
||||||
): ThunkAction<
|
): ThunkAction<
|
||||||
void,
|
void,
|
||||||
RootStateType,
|
RootStateType,
|
||||||
unknown,
|
unknown,
|
||||||
ToggleForwardMessageModalActionType
|
ToggleForwardMessagesModalActionType
|
||||||
> {
|
> {
|
||||||
return async (dispatch, getState) => {
|
return async (dispatch, getState) => {
|
||||||
if (!messageId) {
|
if (!messageIds) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: TOGGLE_FORWARD_MESSAGE_MODAL,
|
type: TOGGLE_FORWARD_MESSAGES_MODAL,
|
||||||
payload: undefined,
|
payload: undefined,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = await getMessageById(messageId);
|
const messagesProps = await Promise.all(
|
||||||
|
messageIds.map(async messageId => {
|
||||||
|
const message = await getMessageById(messageId);
|
||||||
|
|
||||||
if (!message) {
|
if (!message) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`toggleForwardMessageModal: no message found for ${messageId}`
|
`toggleForwardMessagesModal: no message found for ${messageId}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const messagePropsSelector = getMessagePropsSelector(getState());
|
const messagePropsSelector = getMessagePropsSelector(getState());
|
||||||
const messageProps = messagePropsSelector(message.attributes);
|
const messageProps = messagePropsSelector(message.attributes);
|
||||||
|
|
||||||
|
return messageProps;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: TOGGLE_FORWARD_MESSAGE_MODAL,
|
type: TOGGLE_FORWARD_MESSAGES_MODAL,
|
||||||
payload: messageProps,
|
payload: { messages: messagesProps, onForward },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -737,10 +748,10 @@ export function reducer(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action.type === TOGGLE_FORWARD_MESSAGE_MODAL) {
|
if (action.type === TOGGLE_FORWARD_MESSAGES_MODAL) {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
forwardMessageProps: action.payload,
|
forwardMessagesProps: action.payload,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -21,7 +21,7 @@ import type {
|
||||||
MessageDeletedActionType,
|
MessageDeletedActionType,
|
||||||
MessageType,
|
MessageType,
|
||||||
RemoveAllConversationsActionType,
|
RemoveAllConversationsActionType,
|
||||||
SelectedConversationChangedActionType,
|
TargetedConversationChangedActionType,
|
||||||
ShowArchivedConversationsActionType,
|
ShowArchivedConversationsActionType,
|
||||||
} from './conversations';
|
} from './conversations';
|
||||||
import { getQuery, getSearchConversation } from '../selectors/search';
|
import { getQuery, getSearchConversation } from '../selectors/search';
|
||||||
|
@ -34,7 +34,7 @@ import {
|
||||||
import { strictAssert } from '../../util/assert';
|
import { strictAssert } from '../../util/assert';
|
||||||
import {
|
import {
|
||||||
CONVERSATION_UNLOADED,
|
CONVERSATION_UNLOADED,
|
||||||
SELECTED_CONVERSATION_CHANGED,
|
TARGETED_CONVERSATION_CHANGED,
|
||||||
} from './conversations';
|
} from './conversations';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
@ -64,7 +64,7 @@ export type SearchStateType = ReadonlyDeep<{
|
||||||
messageIds: Array<string>;
|
messageIds: Array<string>;
|
||||||
// We do store message data to pass through the selector
|
// We do store message data to pass through the selector
|
||||||
messageLookup: MessageSearchResultLookupType;
|
messageLookup: MessageSearchResultLookupType;
|
||||||
selectedMessage?: string;
|
targetedMessage?: string;
|
||||||
// Loading state
|
// Loading state
|
||||||
discussionsLoading: boolean;
|
discussionsLoading: boolean;
|
||||||
messagesLoading: boolean;
|
messagesLoading: boolean;
|
||||||
|
@ -120,7 +120,7 @@ export type SearchActionType = ReadonlyDeep<
|
||||||
| SearchInConversationActionType
|
| SearchInConversationActionType
|
||||||
| MessageDeletedActionType
|
| MessageDeletedActionType
|
||||||
| RemoveAllConversationsActionType
|
| RemoveAllConversationsActionType
|
||||||
| SelectedConversationChangedActionType
|
| TargetedConversationChangedActionType
|
||||||
| ShowArchivedConversationsActionType
|
| ShowArchivedConversationsActionType
|
||||||
| ConversationUnloadedActionType
|
| ConversationUnloadedActionType
|
||||||
>;
|
>;
|
||||||
|
@ -444,7 +444,7 @@ export function reducer(
|
||||||
return getEmptyState();
|
return getEmptyState();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action.type === SELECTED_CONVERSATION_CHANGED) {
|
if (action.type === TARGETED_CONVERSATION_CHANGED) {
|
||||||
const { payload } = action;
|
const { payload } = action;
|
||||||
const { conversationId, messageId } = payload;
|
const { conversationId, messageId } = payload;
|
||||||
const { searchConversationId } = state;
|
const { searchConversationId } = state;
|
||||||
|
@ -455,7 +455,7 @@ export function reducer(
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
selectedMessage: messageId,
|
targetedMessage: messageId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,7 @@ import type {
|
||||||
MessageChangedActionType,
|
MessageChangedActionType,
|
||||||
MessageDeletedActionType,
|
MessageDeletedActionType,
|
||||||
MessagesAddedActionType,
|
MessagesAddedActionType,
|
||||||
SelectedConversationChangedActionType,
|
TargetedConversationChangedActionType,
|
||||||
} from './conversations';
|
} from './conversations';
|
||||||
import type { NoopActionType } from './noop';
|
import type { NoopActionType } from './noop';
|
||||||
import type { StateType as RootStateType } from '../reducer';
|
import type { StateType as RootStateType } from '../reducer';
|
||||||
|
@ -21,7 +21,7 @@ import type { StoryViewTargetType, StoryViewType } from '../../types/Stories';
|
||||||
import type { SyncType } from '../../jobs/helpers/syncHelpers';
|
import type { SyncType } from '../../jobs/helpers/syncHelpers';
|
||||||
import type { UUIDStringType } from '../../types/UUID';
|
import type { UUIDStringType } from '../../types/UUID';
|
||||||
import * as log from '../../logging/log';
|
import * as log from '../../logging/log';
|
||||||
import { SELECTED_CONVERSATION_CHANGED } from './conversations';
|
import { TARGETED_CONVERSATION_CHANGED } from './conversations';
|
||||||
import { SIGNAL_ACI } from '../../types/SignalConversation';
|
import { SIGNAL_ACI } from '../../types/SignalConversation';
|
||||||
import dataInterface from '../../sql/Client';
|
import dataInterface from '../../sql/Client';
|
||||||
import { ReadStatus } from '../../messages/MessageReadStatus';
|
import { ReadStatus } from '../../messages/MessageReadStatus';
|
||||||
|
@ -265,7 +265,7 @@ export type StoriesActionType =
|
||||||
| ViewStoryActionType
|
| ViewStoryActionType
|
||||||
| StoryReplyDeletedActionType
|
| StoryReplyDeletedActionType
|
||||||
| RemoveAllStoriesActionType
|
| RemoveAllStoriesActionType
|
||||||
| SelectedConversationChangedActionType
|
| TargetedConversationChangedActionType
|
||||||
| SetAddStoryDataType
|
| SetAddStoryDataType
|
||||||
| SetStorySendingType
|
| SetStorySendingType
|
||||||
| SetHasAllStoriesUnmutedType;
|
| SetHasAllStoriesUnmutedType;
|
||||||
|
@ -1781,7 +1781,7 @@ export function reducer(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action.type === SELECTED_CONVERSATION_CHANGED) {
|
if (action.type === TARGETED_CONVERSATION_CHANGED) {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
lastOpenedAtTimestamp: state.openedAtTimestamp || Date.now(),
|
lastOpenedAtTimestamp: state.openedAtTimestamp || Date.now(),
|
||||||
|
|
|
@ -64,6 +64,10 @@ export type ShowToastActionCreatorType = ReadonlyDeep<
|
||||||
) => ShowToastActionType
|
) => ShowToastActionType
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
export type ShowToastAction = ReadonlyDeep<
|
||||||
|
(toastType: ToastType, parameters?: ReplacementValuesType) => void
|
||||||
|
>;
|
||||||
|
|
||||||
export const showToast: ShowToastActionCreatorType = (
|
export const showToast: ShowToastActionCreatorType = (
|
||||||
toastType,
|
toastType,
|
||||||
parameters
|
parameters
|
||||||
|
|
|
@ -15,6 +15,7 @@ import type {
|
||||||
ConversationVerificationData,
|
ConversationVerificationData,
|
||||||
MessageLookupType,
|
MessageLookupType,
|
||||||
MessagesByConversationType,
|
MessagesByConversationType,
|
||||||
|
MessageTimestamps,
|
||||||
PreJoinConversationType,
|
PreJoinConversationType,
|
||||||
} from '../ducks/conversations';
|
} from '../ducks/conversations';
|
||||||
import type { StoriesStateType, StoryDataType } from '../ducks/stories';
|
import type { StoriesStateType, StoryDataType } from '../ducks/stories';
|
||||||
|
@ -151,23 +152,35 @@ export const getSelectedConversationId = createSelector(
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
type SelectedMessageType = {
|
type TargetedMessageType = {
|
||||||
id: string;
|
id: string;
|
||||||
counter: number;
|
counter: number;
|
||||||
};
|
};
|
||||||
export const getSelectedMessage = createSelector(
|
export const getTargetedMessage = createSelector(
|
||||||
getConversations,
|
getConversations,
|
||||||
(state: ConversationsStateType): SelectedMessageType | undefined => {
|
(state: ConversationsStateType): TargetedMessageType | undefined => {
|
||||||
if (!state.selectedMessage) {
|
if (!state.targetedMessage) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: state.selectedMessage,
|
id: state.targetedMessage,
|
||||||
counter: state.selectedMessageCounter,
|
counter: state.targetedMessageCounter,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
export const getSelectedMessageIds = createSelector(
|
||||||
|
getConversations,
|
||||||
|
(state: ConversationsStateType): ReadonlyArray<string> | undefined => {
|
||||||
|
return state.selectedMessageIds;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
export const getLastSelectedMessage = createSelector(
|
||||||
|
getConversations,
|
||||||
|
(state: ConversationsStateType): MessageTimestamps | undefined => {
|
||||||
|
return state.lastSelectedMessage;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export const getShowArchived = createSelector(
|
export const getShowArchived = createSelector(
|
||||||
getConversations,
|
getConversations,
|
||||||
|
@ -1095,8 +1108,8 @@ export const getHideStoryConversationIds = createSelector(
|
||||||
export const getTopPanel = createSelector(
|
export const getTopPanel = createSelector(
|
||||||
getConversations,
|
getConversations,
|
||||||
(conversations): PanelRenderType | undefined =>
|
(conversations): PanelRenderType | undefined =>
|
||||||
conversations.selectedConversationPanels[
|
conversations.targetedConversationPanels[
|
||||||
conversations.selectedConversationPanels.length - 1
|
conversations.targetedConversationPanels.length - 1
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -66,7 +66,8 @@ import { getAccountSelector } from './accounts';
|
||||||
import {
|
import {
|
||||||
getContactNameColorSelector,
|
getContactNameColorSelector,
|
||||||
getConversationSelector,
|
getConversationSelector,
|
||||||
getSelectedMessage,
|
getSelectedMessageIds,
|
||||||
|
getTargetedMessage,
|
||||||
isMissingRequiredProfileSharing,
|
isMissingRequiredProfileSharing,
|
||||||
} from './conversations';
|
} from './conversations';
|
||||||
import {
|
import {
|
||||||
|
@ -146,8 +147,9 @@ export type GetPropsForBubbleOptions = Readonly<{
|
||||||
ourNumber?: string;
|
ourNumber?: string;
|
||||||
ourACI?: UUIDStringType;
|
ourACI?: UUIDStringType;
|
||||||
ourPNI?: UUIDStringType;
|
ourPNI?: UUIDStringType;
|
||||||
selectedMessageId?: string;
|
targetedMessageId?: string;
|
||||||
selectedMessageCounter?: number;
|
targetedMessageCounter?: number;
|
||||||
|
selectedMessageIds: ReadonlyArray<string> | undefined;
|
||||||
regionCode?: string;
|
regionCode?: string;
|
||||||
callSelector: CallSelectorType;
|
callSelector: CallSelectorType;
|
||||||
activeCall?: CallStateType;
|
activeCall?: CallStateType;
|
||||||
|
@ -550,8 +552,9 @@ export type GetPropsForMessageOptions = Pick<
|
||||||
| 'ourACI'
|
| 'ourACI'
|
||||||
| 'ourPNI'
|
| 'ourPNI'
|
||||||
| 'ourNumber'
|
| 'ourNumber'
|
||||||
| 'selectedMessageId'
|
| 'targetedMessageId'
|
||||||
| 'selectedMessageCounter'
|
| 'targetedMessageCounter'
|
||||||
|
| 'selectedMessageIds'
|
||||||
| 'regionCode'
|
| 'regionCode'
|
||||||
| 'accountSelector'
|
| 'accountSelector'
|
||||||
| 'contactNameColorSelector'
|
| 'contactNameColorSelector'
|
||||||
|
@ -645,8 +648,9 @@ export const getPropsForMessage = (
|
||||||
ourNumber,
|
ourNumber,
|
||||||
ourACI,
|
ourACI,
|
||||||
regionCode,
|
regionCode,
|
||||||
selectedMessageId,
|
targetedMessageId,
|
||||||
selectedMessageCounter,
|
targetedMessageCounter,
|
||||||
|
selectedMessageIds,
|
||||||
contactNameColorSelector,
|
contactNameColorSelector,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
|
@ -661,7 +665,9 @@ export const getPropsForMessage = (
|
||||||
|
|
||||||
const isMessageTapToView = isTapToView(message);
|
const isMessageTapToView = isTapToView(message);
|
||||||
|
|
||||||
const isSelected = message.id === selectedMessageId;
|
const isTargeted = message.id === targetedMessageId;
|
||||||
|
const isSelected = selectedMessageIds?.includes(message.id) ?? false;
|
||||||
|
const isSelectMode = selectedMessageIds != null;
|
||||||
|
|
||||||
const selectedReaction = (
|
const selectedReaction = (
|
||||||
(message.reactions || []).find(re => re.fromId === ourConversationId) || {}
|
(message.reactions || []).find(re => re.fromId === ourConversationId) || {}
|
||||||
|
@ -713,8 +719,10 @@ export const getPropsForMessage = (
|
||||||
id: message.id,
|
id: message.id,
|
||||||
isBlocked: conversation.isBlocked || false,
|
isBlocked: conversation.isBlocked || false,
|
||||||
isMessageRequestAccepted: conversation?.acceptedMessageRequest ?? true,
|
isMessageRequestAccepted: conversation?.acceptedMessageRequest ?? true,
|
||||||
|
isTargeted,
|
||||||
|
isTargetedCounter: isTargeted ? targetedMessageCounter : undefined,
|
||||||
isSelected,
|
isSelected,
|
||||||
isSelectedCounter: isSelected ? selectedMessageCounter : undefined,
|
isSelectMode,
|
||||||
isSticker: Boolean(sticker),
|
isSticker: Boolean(sticker),
|
||||||
isTapToView: isMessageTapToView,
|
isTapToView: isMessageTapToView,
|
||||||
isTapToViewError:
|
isTapToViewError:
|
||||||
|
@ -742,7 +750,8 @@ export const getMessagePropsSelector = createSelector(
|
||||||
getRegionCode,
|
getRegionCode,
|
||||||
getAccountSelector,
|
getAccountSelector,
|
||||||
getContactNameColorSelector,
|
getContactNameColorSelector,
|
||||||
getSelectedMessage,
|
getTargetedMessage,
|
||||||
|
getSelectedMessageIds,
|
||||||
(
|
(
|
||||||
conversationSelector,
|
conversationSelector,
|
||||||
ourConversationId,
|
ourConversationId,
|
||||||
|
@ -752,7 +761,8 @@ export const getMessagePropsSelector = createSelector(
|
||||||
regionCode,
|
regionCode,
|
||||||
accountSelector,
|
accountSelector,
|
||||||
contactNameColorSelector,
|
contactNameColorSelector,
|
||||||
selectedMessage
|
targetedMessage,
|
||||||
|
selectedMessageIds
|
||||||
) =>
|
) =>
|
||||||
(message: MessageWithUIFieldsType) => {
|
(message: MessageWithUIFieldsType) => {
|
||||||
return getPropsForMessage(message, {
|
return getPropsForMessage(message, {
|
||||||
|
@ -764,8 +774,9 @@ export const getMessagePropsSelector = createSelector(
|
||||||
ourACI,
|
ourACI,
|
||||||
ourPNI,
|
ourPNI,
|
||||||
regionCode,
|
regionCode,
|
||||||
selectedMessageCounter: selectedMessage?.counter,
|
targetedMessageCounter: targetedMessage?.counter,
|
||||||
selectedMessageId: selectedMessage?.id,
|
targetedMessageId: targetedMessage?.id,
|
||||||
|
selectedMessageIds,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -1794,10 +1805,10 @@ export function getLastChallengeError(
|
||||||
return challengeErrors.pop();
|
return challengeErrors.pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
const getSelectedMessageForDetails = (
|
const getTargetedMessageForDetails = (
|
||||||
state: StateType
|
state: StateType
|
||||||
): MessageAttributesType | undefined =>
|
): MessageAttributesType | undefined =>
|
||||||
state.conversations.selectedMessageForDetails;
|
state.conversations.targetedMessageForDetails;
|
||||||
|
|
||||||
const OUTGOING_KEY_ERROR = 'OutgoingIdentityKeyError';
|
const OUTGOING_KEY_ERROR = 'OutgoingIdentityKeyError';
|
||||||
|
|
||||||
|
@ -1807,11 +1818,12 @@ export const getMessageDetails = createSelector(
|
||||||
getConversationSelector,
|
getConversationSelector,
|
||||||
getIntl,
|
getIntl,
|
||||||
getRegionCode,
|
getRegionCode,
|
||||||
getSelectedMessageForDetails,
|
getTargetedMessageForDetails,
|
||||||
getUserACI,
|
getUserACI,
|
||||||
getUserPNI,
|
getUserPNI,
|
||||||
getUserConversationId,
|
getUserConversationId,
|
||||||
getUserNumber,
|
getUserNumber,
|
||||||
|
getSelectedMessageIds,
|
||||||
(
|
(
|
||||||
accountSelector,
|
accountSelector,
|
||||||
contactNameColorSelector,
|
contactNameColorSelector,
|
||||||
|
@ -1822,7 +1834,8 @@ export const getMessageDetails = createSelector(
|
||||||
ourACI,
|
ourACI,
|
||||||
ourPNI,
|
ourPNI,
|
||||||
ourConversationId,
|
ourConversationId,
|
||||||
ourNumber
|
ourNumber,
|
||||||
|
selectedMessageIds
|
||||||
): SmartMessageDetailPropsType | undefined => {
|
): SmartMessageDetailPropsType | undefined => {
|
||||||
if (!message || !ourConversationId) {
|
if (!message || !ourConversationId) {
|
||||||
return;
|
return;
|
||||||
|
@ -1957,6 +1970,7 @@ export const getMessageDetails = createSelector(
|
||||||
ourNumber,
|
ourNumber,
|
||||||
ourPNI,
|
ourPNI,
|
||||||
regionCode,
|
regionCode,
|
||||||
|
selectedMessageIds,
|
||||||
}),
|
}),
|
||||||
receivedAt: Number(message.received_at_ms || message.received_at),
|
receivedAt: Number(message.received_at_ms || message.received_at),
|
||||||
};
|
};
|
||||||
|
|
|
@ -41,7 +41,7 @@ export const getQuery = createSelector(
|
||||||
|
|
||||||
export const getSelectedMessage = createSelector(
|
export const getSelectedMessage = createSelector(
|
||||||
getSearch,
|
getSearch,
|
||||||
(state: SearchStateType): string | undefined => state.selectedMessage
|
(state: SearchStateType): string | undefined => state.targetedMessage
|
||||||
);
|
);
|
||||||
|
|
||||||
const getSearchConversationId = createSelector(
|
const getSearchConversationId = createSelector(
|
||||||
|
@ -156,7 +156,7 @@ type CachedMessageSearchResultSelectorType = (
|
||||||
from: ConversationType,
|
from: ConversationType,
|
||||||
to: ConversationType,
|
to: ConversationType,
|
||||||
searchConversationId?: string,
|
searchConversationId?: string,
|
||||||
selectedMessageId?: string
|
targetedMessageId?: string
|
||||||
) => MessageSearchResultPropsDataType;
|
) => MessageSearchResultPropsDataType;
|
||||||
|
|
||||||
export const getCachedSelectorForMessageSearchResult = createSelector(
|
export const getCachedSelectorForMessageSearchResult = createSelector(
|
||||||
|
@ -174,7 +174,7 @@ export const getCachedSelectorForMessageSearchResult = createSelector(
|
||||||
from: ConversationType,
|
from: ConversationType,
|
||||||
to: ConversationType,
|
to: ConversationType,
|
||||||
searchConversationId?: string,
|
searchConversationId?: string,
|
||||||
selectedMessageId?: string
|
targetedMessageId?: string
|
||||||
) => {
|
) => {
|
||||||
const bodyRanges = message.bodyRanges || [];
|
const bodyRanges = message.bodyRanges || [];
|
||||||
return {
|
return {
|
||||||
|
@ -199,7 +199,7 @@ export const getCachedSelectorForMessageSearchResult = createSelector(
|
||||||
body: message.body || '',
|
body: message.body || '',
|
||||||
|
|
||||||
isSelected: Boolean(
|
isSelected: Boolean(
|
||||||
selectedMessageId && message.id === selectedMessageId
|
targetedMessageId && message.id === targetedMessageId
|
||||||
),
|
),
|
||||||
isSearchingInConversation: Boolean(searchConversationId),
|
isSearchingInConversation: Boolean(searchConversationId),
|
||||||
};
|
};
|
||||||
|
@ -223,7 +223,7 @@ export const getMessageSearchResultSelector = createSelector(
|
||||||
(
|
(
|
||||||
messageSearchResultSelector: CachedMessageSearchResultSelectorType,
|
messageSearchResultSelector: CachedMessageSearchResultSelectorType,
|
||||||
messageSearchResultLookup: MessageSearchResultLookupType,
|
messageSearchResultLookup: MessageSearchResultLookupType,
|
||||||
selectedMessageId: string | undefined,
|
targetedMessageId: string | undefined,
|
||||||
conversationSelector: GetConversationByIdType,
|
conversationSelector: GetConversationByIdType,
|
||||||
searchConversationId: string | undefined,
|
searchConversationId: string | undefined,
|
||||||
ourConversationId: string | undefined
|
ourConversationId: string | undefined
|
||||||
|
@ -260,7 +260,7 @@ export const getMessageSearchResultSelector = createSelector(
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
searchConversationId,
|
searchConversationId,
|
||||||
selectedMessageId
|
targetedMessageId
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,8 +7,9 @@ import type { StateType } from '../reducer';
|
||||||
import {
|
import {
|
||||||
getContactNameColorSelector,
|
getContactNameColorSelector,
|
||||||
getConversationSelector,
|
getConversationSelector,
|
||||||
getSelectedMessage,
|
getTargetedMessage,
|
||||||
getMessages,
|
getMessages,
|
||||||
|
getSelectedMessageIds,
|
||||||
} from './conversations';
|
} from './conversations';
|
||||||
import { getAccountSelector } from './accounts';
|
import { getAccountSelector } from './accounts';
|
||||||
import {
|
import {
|
||||||
|
@ -36,7 +37,7 @@ export const getTimelineItem = (
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedMessage = getSelectedMessage(state);
|
const targetedMessage = getTargetedMessage(state);
|
||||||
const conversationSelector = getConversationSelector(state);
|
const conversationSelector = getConversationSelector(state);
|
||||||
const regionCode = getRegionCode(state);
|
const regionCode = getRegionCode(state);
|
||||||
const ourNumber = getUserNumber(state);
|
const ourNumber = getUserNumber(state);
|
||||||
|
@ -47,6 +48,7 @@ export const getTimelineItem = (
|
||||||
const activeCall = getActiveCall(state);
|
const activeCall = getActiveCall(state);
|
||||||
const accountSelector = getAccountSelector(state);
|
const accountSelector = getAccountSelector(state);
|
||||||
const contactNameColorSelector = getContactNameColorSelector(state);
|
const contactNameColorSelector = getContactNameColorSelector(state);
|
||||||
|
const selectedMessageIds = getSelectedMessageIds(state);
|
||||||
|
|
||||||
return getPropsForBubble(message, {
|
return getPropsForBubble(message, {
|
||||||
conversationSelector,
|
conversationSelector,
|
||||||
|
@ -55,11 +57,12 @@ export const getTimelineItem = (
|
||||||
ourACI,
|
ourACI,
|
||||||
ourPNI,
|
ourPNI,
|
||||||
regionCode,
|
regionCode,
|
||||||
selectedMessageId: selectedMessage?.id,
|
targetedMessageId: targetedMessage?.id,
|
||||||
selectedMessageCounter: selectedMessage?.counter,
|
targetedMessageCounter: targetedMessage?.counter,
|
||||||
contactNameColorSelector,
|
contactNameColorSelector,
|
||||||
callSelector,
|
callSelector,
|
||||||
activeCall,
|
activeCall,
|
||||||
accountSelector,
|
accountSelector,
|
||||||
|
selectedMessageIds,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
@ -19,6 +19,8 @@ import { getEmojiSkinTone } from '../selectors/items';
|
||||||
import {
|
import {
|
||||||
getConversationSelector,
|
getConversationSelector,
|
||||||
getGroupAdminsSelector,
|
getGroupAdminsSelector,
|
||||||
|
getLastSelectedMessage,
|
||||||
|
getSelectedMessageIds,
|
||||||
isMissingRequiredProfileSharing,
|
isMissingRequiredProfileSharing,
|
||||||
} from '../selectors/conversations';
|
} from '../selectors/conversations';
|
||||||
import { getPropsForQuote } from '../selectors/message';
|
import { getPropsForQuote } from '../selectors/message';
|
||||||
|
@ -89,6 +91,9 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
|
||||||
|
|
||||||
const recentEmojis = selectRecentEmojis(state);
|
const recentEmojis = selectRecentEmojis(state);
|
||||||
|
|
||||||
|
const selectedMessageIds = getSelectedMessageIds(state);
|
||||||
|
const lastSelectedMessage = getLastSelectedMessage(state);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Base
|
// Base
|
||||||
conversationId: id,
|
conversationId: id,
|
||||||
|
@ -160,6 +165,10 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
|
||||||
) => {
|
) => {
|
||||||
return <SmartCompositionRecordingDraft {...draftProps} />;
|
return <SmartCompositionRecordingDraft {...draftProps} />;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Select Mode
|
||||||
|
selectedMessageIds,
|
||||||
|
lastSelectedMessage,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -107,7 +107,7 @@ const mapStateToProps = (state: StateType, ownProps: OwnProps) => {
|
||||||
isSMSOnly: isConversationSMSOnly(conversation),
|
isSMSOnly: isConversationSMSOnly(conversation),
|
||||||
isSignalConversation: isSignalConversation(conversation),
|
isSignalConversation: isSignalConversation(conversation),
|
||||||
i18n: getIntl(state),
|
i18n: getIntl(state),
|
||||||
showBackButton: state.conversations.selectedConversationPanels.length > 0,
|
showBackButton: state.conversations.targetedConversationPanels.length > 0,
|
||||||
outgoingCallButtonStyle: getOutgoingCallButtonStyle(conversation, state),
|
outgoingCallButtonStyle: getOutgoingCallButtonStyle(conversation, state),
|
||||||
theme: getTheme(state),
|
theme: getTheme(state),
|
||||||
};
|
};
|
||||||
|
|
|
@ -25,6 +25,7 @@ import { SmartTimeline } from './Timeline';
|
||||||
import { getIntl } from '../selectors/user';
|
import { getIntl } from '../selectors/user';
|
||||||
import {
|
import {
|
||||||
getSelectedConversationId,
|
getSelectedConversationId,
|
||||||
|
getSelectedMessageIds,
|
||||||
getTopPanel,
|
getTopPanel,
|
||||||
} from '../selectors/conversations';
|
} from '../selectors/conversations';
|
||||||
import { useComposerActions } from '../ducks/composer';
|
import { useComposerActions } from '../ducks/composer';
|
||||||
|
@ -40,11 +41,17 @@ export function SmartConversationView(): JSX.Element {
|
||||||
const topPanel = useSelector<StateType, PanelRenderType | undefined>(
|
const topPanel = useSelector<StateType, PanelRenderType | undefined>(
|
||||||
getTopPanel
|
getTopPanel
|
||||||
);
|
);
|
||||||
const { startConversation } = useConversationsActions();
|
const { startConversation, toggleSelectMode } = useConversationsActions();
|
||||||
|
const selectedMessageIds = useSelector(getSelectedMessageIds);
|
||||||
|
const isSelectMode = selectedMessageIds != null;
|
||||||
|
|
||||||
const { processAttachments } = useComposerActions();
|
const { processAttachments } = useComposerActions();
|
||||||
const i18n = useSelector(getIntl);
|
const i18n = useSelector(getIntl);
|
||||||
|
|
||||||
|
const isForwardModalOpen = useSelector((state: StateType) => {
|
||||||
|
return state.globalModals.forwardMessagesProps != null;
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConversationView
|
<ConversationView
|
||||||
conversationId={conversationId}
|
conversationId={conversationId}
|
||||||
|
@ -172,6 +179,11 @@ export function SmartConversationView(): JSX.Element {
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}}
|
}}
|
||||||
|
isSelectMode={isSelectMode}
|
||||||
|
isForwardModalOpen={isForwardModalOpen}
|
||||||
|
onExitSelectMode={() => {
|
||||||
|
toggleSelectMode(false);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,15 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import type { DraftBodyRangesType } from '../../types/Util';
|
import type {
|
||||||
import type { ForwardMessagePropsType } from '../ducks/globalModals';
|
ForwardMessagePropsType,
|
||||||
|
ForwardMessagesPropsType,
|
||||||
|
} from '../ducks/globalModals';
|
||||||
import type { StateType } from '../reducer';
|
import type { StateType } from '../reducer';
|
||||||
import * as log from '../../logging/log';
|
import * as log from '../../logging/log';
|
||||||
import { ForwardMessageModal } from '../../components/ForwardMessageModal';
|
import { ForwardMessagesModal } from '../../components/ForwardMessagesModal';
|
||||||
import { LinkPreviewSourceType } from '../../types/LinkPreview';
|
import { LinkPreviewSourceType } from '../../types/LinkPreview';
|
||||||
import * as Errors from '../../types/errors';
|
import * as Errors from '../../types/errors';
|
||||||
import type { GetConversationByIdType } from '../selectors/conversations';
|
import type { GetConversationByIdType } from '../selectors/conversations';
|
||||||
|
@ -19,7 +21,11 @@ import { getIntl, getTheme, getRegionCode } from '../selectors/user';
|
||||||
import { getLinkPreview } from '../selectors/linkPreviews';
|
import { getLinkPreview } from '../selectors/linkPreviews';
|
||||||
import { getMessageById } from '../../messages/getMessageById';
|
import { getMessageById } from '../../messages/getMessageById';
|
||||||
import { getPreferredBadgeSelector } from '../selectors/badges';
|
import { getPreferredBadgeSelector } from '../selectors/badges';
|
||||||
import { maybeForwardMessage } from '../../util/maybeForwardMessage';
|
import type {
|
||||||
|
ForwardMessageData,
|
||||||
|
MessageForwardDraft,
|
||||||
|
} from '../../util/maybeForwardMessages';
|
||||||
|
import { maybeForwardMessages } from '../../util/maybeForwardMessages';
|
||||||
import {
|
import {
|
||||||
maybeGrabLinkPreview,
|
maybeGrabLinkPreview,
|
||||||
resetLinkPreview,
|
resetLinkPreview,
|
||||||
|
@ -29,6 +35,7 @@ import { useLinkPreviewActions } from '../ducks/linkPreviews';
|
||||||
import { processBodyRanges } from '../selectors/message';
|
import { processBodyRanges } from '../selectors/message';
|
||||||
import { getTextWithMentions } from '../../util/getTextWithMentions';
|
import { getTextWithMentions } from '../../util/getTextWithMentions';
|
||||||
import { SmartCompositionTextArea } from './CompositionTextArea';
|
import { SmartCompositionTextArea } from './CompositionTextArea';
|
||||||
|
import { useToastActions } from '../ducks/toast';
|
||||||
|
|
||||||
function renderMentions(
|
function renderMentions(
|
||||||
message: ForwardMessagePropsType,
|
message: ForwardMessagePropsType,
|
||||||
|
@ -51,11 +58,11 @@ function renderMentions(
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SmartForwardMessageModal(): JSX.Element | null {
|
export function SmartForwardMessagesModal(): JSX.Element | null {
|
||||||
const forwardMessageProps = useSelector<
|
const forwardMessagesProps = useSelector<
|
||||||
StateType,
|
StateType,
|
||||||
ForwardMessagePropsType | undefined
|
ForwardMessagesPropsType | undefined
|
||||||
>(state => state.globalModals.forwardMessageProps);
|
>(state => state.globalModals.forwardMessagesProps);
|
||||||
const candidateConversations = useSelector(getAllComposableConversations);
|
const candidateConversations = useSelector(getAllComposableConversations);
|
||||||
const getPreferredBadge = useSelector(getPreferredBadgeSelector);
|
const getPreferredBadge = useSelector(getPreferredBadgeSelector);
|
||||||
const getConversation = useSelector(getConversationSelector);
|
const getConversation = useSelector(getConversationSelector);
|
||||||
|
@ -65,70 +72,82 @@ export function SmartForwardMessageModal(): JSX.Element | null {
|
||||||
const theme = useSelector(getTheme);
|
const theme = useSelector(getTheme);
|
||||||
|
|
||||||
const { removeLinkPreview } = useLinkPreviewActions();
|
const { removeLinkPreview } = useLinkPreviewActions();
|
||||||
const { toggleForwardMessageModal } = useGlobalModalActions();
|
const { toggleForwardMessagesModal } = useGlobalModalActions();
|
||||||
|
const { showToast } = useToastActions();
|
||||||
|
|
||||||
if (!forwardMessageProps) {
|
const [drafts, setDrafts] = useState<ReadonlyArray<MessageForwardDraft>>(
|
||||||
|
() => {
|
||||||
|
return (
|
||||||
|
forwardMessagesProps?.messages.map((props): MessageForwardDraft => {
|
||||||
|
return {
|
||||||
|
originalMessageId: props.id,
|
||||||
|
attachments: props.attachments ?? [],
|
||||||
|
messageBody: renderMentions(props, getConversation),
|
||||||
|
isSticker: Boolean(props.isSticker),
|
||||||
|
hasContact: Boolean(props.contact),
|
||||||
|
previews: props.previews ?? [],
|
||||||
|
};
|
||||||
|
}) ?? []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!drafts.length) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { attachments = [] } = forwardMessageProps;
|
|
||||||
|
|
||||||
function closeModal() {
|
function closeModal() {
|
||||||
resetLinkPreview();
|
resetLinkPreview();
|
||||||
toggleForwardMessageModal();
|
toggleForwardMessagesModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanedBody = renderMentions(forwardMessageProps, getConversation);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ForwardMessageModal
|
<ForwardMessagesModal
|
||||||
attachments={attachments}
|
drafts={drafts}
|
||||||
candidateConversations={candidateConversations}
|
candidateConversations={candidateConversations}
|
||||||
doForwardMessage={async (
|
doForwardMessages={async (conversationIds, finalDrafts) => {
|
||||||
conversationIds,
|
|
||||||
messageBody,
|
|
||||||
includedAttachments,
|
|
||||||
linkPreview
|
|
||||||
) => {
|
|
||||||
try {
|
try {
|
||||||
const message = await getMessageById(forwardMessageProps.id);
|
const messages = await Promise.all(
|
||||||
if (!message) {
|
finalDrafts.map(async (draft): Promise<ForwardMessageData> => {
|
||||||
throw new Error('No message found');
|
const message = await getMessageById(draft.originalMessageId);
|
||||||
}
|
if (message == null) {
|
||||||
|
throw new Error('No message found');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
draft,
|
||||||
|
originalMessage: message.attributes,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const didForwardSuccessfully = await maybeForwardMessage(
|
const didForwardSuccessfully = await maybeForwardMessages(
|
||||||
message.attributes,
|
messages,
|
||||||
conversationIds,
|
conversationIds
|
||||||
messageBody,
|
|
||||||
includedAttachments,
|
|
||||||
linkPreview
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (didForwardSuccessfully) {
|
if (didForwardSuccessfully) {
|
||||||
closeModal();
|
closeModal();
|
||||||
|
forwardMessagesProps?.onForward?.();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.warn('doForwardMessage', Errors.toLogFormat(err));
|
log.warn('doForwardMessage', Errors.toLogFormat(err));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
linkPreviewForSource={linkPreviewForSource}
|
||||||
getPreferredBadge={getPreferredBadge}
|
getPreferredBadge={getPreferredBadge}
|
||||||
hasContact={Boolean(forwardMessageProps.contact)}
|
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
isSticker={Boolean(forwardMessageProps.isSticker)}
|
|
||||||
linkPreview={linkPreviewForSource(
|
|
||||||
LinkPreviewSourceType.ForwardMessageModal
|
|
||||||
)}
|
|
||||||
messageBody={cleanedBody}
|
|
||||||
onClose={closeModal}
|
onClose={closeModal}
|
||||||
onEditorStateChange={(
|
onChange={(updatedDrafts, caretLocation) => {
|
||||||
_conversationId: string | undefined,
|
setDrafts(updatedDrafts);
|
||||||
messageText: string,
|
const isLonelyDraft = updatedDrafts.length === 1;
|
||||||
_: DraftBodyRangesType,
|
const lonelyDraft = isLonelyDraft ? updatedDrafts[0] : null;
|
||||||
caretLocation?: number
|
if (lonelyDraft == null) {
|
||||||
) => {
|
return;
|
||||||
if (!attachments.length) {
|
}
|
||||||
|
const attachmentsLength = lonelyDraft.attachments?.length ?? 0;
|
||||||
|
if (attachmentsLength === 0) {
|
||||||
maybeGrabLinkPreview(
|
maybeGrabLinkPreview(
|
||||||
messageText,
|
lonelyDraft.messageBody ?? '',
|
||||||
LinkPreviewSourceType.ForwardMessageModal,
|
LinkPreviewSourceType.ForwardMessageModal,
|
||||||
{ caretLocation }
|
{ caretLocation }
|
||||||
);
|
);
|
||||||
|
@ -137,6 +156,7 @@ export function SmartForwardMessageModal(): JSX.Element | null {
|
||||||
regionCode={regionCode}
|
regionCode={regionCode}
|
||||||
RenderCompositionTextArea={SmartCompositionTextArea}
|
RenderCompositionTextArea={SmartCompositionTextArea}
|
||||||
removeLinkPreview={removeLinkPreview}
|
removeLinkPreview={removeLinkPreview}
|
||||||
|
showToast={showToast}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
|
@ -10,7 +10,7 @@ import { ErrorModal } from '../../components/ErrorModal';
|
||||||
import { GlobalModalContainer } from '../../components/GlobalModalContainer';
|
import { GlobalModalContainer } from '../../components/GlobalModalContainer';
|
||||||
import { SmartAddUserToAnotherGroupModal } from './AddUserToAnotherGroupModal';
|
import { SmartAddUserToAnotherGroupModal } from './AddUserToAnotherGroupModal';
|
||||||
import { SmartContactModal } from './ContactModal';
|
import { SmartContactModal } from './ContactModal';
|
||||||
import { SmartForwardMessageModal } from './ForwardMessageModal';
|
import { SmartForwardMessagesModal } from './ForwardMessagesModal';
|
||||||
import { SmartProfileEditorModal } from './ProfileEditorModal';
|
import { SmartProfileEditorModal } from './ProfileEditorModal';
|
||||||
import { SmartSafetyNumberModal } from './SafetyNumberModal';
|
import { SmartSafetyNumberModal } from './SafetyNumberModal';
|
||||||
import { SmartSendAnywayDialog } from './SendAnywayDialog';
|
import { SmartSendAnywayDialog } from './SendAnywayDialog';
|
||||||
|
@ -29,8 +29,8 @@ function renderContactModal(): JSX.Element {
|
||||||
return <SmartContactModal />;
|
return <SmartContactModal />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderForwardMessageModal(): JSX.Element {
|
function renderForwardMessagesModal(): JSX.Element {
|
||||||
return <SmartForwardMessageModal />;
|
return <SmartForwardMessagesModal />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderStoriesSettings(): JSX.Element {
|
function renderStoriesSettings(): JSX.Element {
|
||||||
|
@ -56,7 +56,7 @@ export function SmartGlobalModalContainer(): JSX.Element {
|
||||||
addUserToAnotherGroupModalContactId,
|
addUserToAnotherGroupModalContactId,
|
||||||
contactModalState,
|
contactModalState,
|
||||||
errorModalProps,
|
errorModalProps,
|
||||||
forwardMessageProps,
|
forwardMessagesProps,
|
||||||
isProfileEditorVisible,
|
isProfileEditorVisible,
|
||||||
isShortcutGuideModalVisible,
|
isShortcutGuideModalVisible,
|
||||||
isSignalConnectionsVisible,
|
isSignalConnectionsVisible,
|
||||||
|
@ -121,7 +121,7 @@ export function SmartGlobalModalContainer(): JSX.Element {
|
||||||
addUserToAnotherGroupModalContactId={addUserToAnotherGroupModalContactId}
|
addUserToAnotherGroupModalContactId={addUserToAnotherGroupModalContactId}
|
||||||
contactModalState={contactModalState}
|
contactModalState={contactModalState}
|
||||||
errorModalProps={errorModalProps}
|
errorModalProps={errorModalProps}
|
||||||
forwardMessageProps={forwardMessageProps}
|
forwardMessagesProps={forwardMessagesProps}
|
||||||
hasSafetyNumberChangeModal={hasSafetyNumberChangeModal}
|
hasSafetyNumberChangeModal={hasSafetyNumberChangeModal}
|
||||||
hideUserNotFoundModal={hideUserNotFoundModal}
|
hideUserNotFoundModal={hideUserNotFoundModal}
|
||||||
hideWhatsNewModal={hideWhatsNewModal}
|
hideWhatsNewModal={hideWhatsNewModal}
|
||||||
|
@ -134,7 +134,7 @@ export function SmartGlobalModalContainer(): JSX.Element {
|
||||||
renderAddUserToAnotherGroup={renderAddUserToAnotherGroup}
|
renderAddUserToAnotherGroup={renderAddUserToAnotherGroup}
|
||||||
renderContactModal={renderContactModal}
|
renderContactModal={renderContactModal}
|
||||||
renderErrorModal={renderErrorModal}
|
renderErrorModal={renderErrorModal}
|
||||||
renderForwardMessageModal={renderForwardMessageModal}
|
renderForwardMessagesModal={renderForwardMessagesModal}
|
||||||
renderProfileEditor={renderProfileEditor}
|
renderProfileEditor={renderProfileEditor}
|
||||||
renderSafetyNumber={renderSafetyNumber}
|
renderSafetyNumber={renderSafetyNumber}
|
||||||
renderSendAnywayDialog={renderSendAnywayDialog}
|
renderSendAnywayDialog={renderSendAnywayDialog}
|
||||||
|
|
|
@ -40,7 +40,7 @@ export function SmartInbox(): JSX.Element {
|
||||||
const { hasInitialLoadCompleted } = useSelector<StateType, AppStateType>(
|
const { hasInitialLoadCompleted } = useSelector<StateType, AppStateType>(
|
||||||
state => state.app
|
state => state.app
|
||||||
);
|
);
|
||||||
const { selectedConversationId, selectedMessage, selectedMessageSource } =
|
const { selectedConversationId, targetedMessage, targetedMessageSource } =
|
||||||
useSelector<StateType, ConversationsStateType>(
|
useSelector<StateType, ConversationsStateType>(
|
||||||
state => state.conversations
|
state => state.conversations
|
||||||
);
|
);
|
||||||
|
@ -67,8 +67,8 @@ export function SmartInbox(): JSX.Element {
|
||||||
renderMiniPlayer={renderMiniPlayer}
|
renderMiniPlayer={renderMiniPlayer}
|
||||||
scrollToMessage={scrollToMessage}
|
scrollToMessage={scrollToMessage}
|
||||||
selectedConversationId={selectedConversationId}
|
selectedConversationId={selectedConversationId}
|
||||||
selectedMessage={selectedMessage}
|
targetedMessage={targetedMessage}
|
||||||
selectedMessageSource={selectedMessageSource}
|
targetedMessageSource={targetedMessageSource}
|
||||||
showConversation={showConversation}
|
showConversation={showConversation}
|
||||||
showWhatsNewModal={showWhatsNewModal}
|
showWhatsNewModal={showWhatsNewModal}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -57,7 +57,7 @@ import {
|
||||||
getMaximumGroupSizeModalState,
|
getMaximumGroupSizeModalState,
|
||||||
getRecommendedGroupSizeModalState,
|
getRecommendedGroupSizeModalState,
|
||||||
getSelectedConversationId,
|
getSelectedConversationId,
|
||||||
getSelectedMessage,
|
getTargetedMessage,
|
||||||
getShowArchived,
|
getShowArchived,
|
||||||
hasGroupCreationError,
|
hasGroupCreationError,
|
||||||
isCreatingGroup,
|
isCreatingGroup,
|
||||||
|
@ -230,7 +230,7 @@ const mapStateToProps = (state: StateType) => {
|
||||||
modeSpecificProps: getModeSpecificProps(state),
|
modeSpecificProps: getModeSpecificProps(state),
|
||||||
preferredWidthFromStorage: getPreferredLeftPaneWidth(state),
|
preferredWidthFromStorage: getPreferredLeftPaneWidth(state),
|
||||||
selectedConversationId: getSelectedConversationId(state),
|
selectedConversationId: getSelectedConversationId(state),
|
||||||
selectedMessageId: getSelectedMessage(state)?.id,
|
targetedMessageId: getTargetedMessage(state)?.id,
|
||||||
showArchived: getShowArchived(state),
|
showArchived: getShowArchived(state),
|
||||||
getPreferredBadge: getPreferredBadgeSelector(state),
|
getPreferredBadge: getPreferredBadgeSelector(state),
|
||||||
i18n: getIntl(state),
|
i18n: getIntl(state),
|
||||||
|
|
|
@ -34,7 +34,7 @@ export function SmartLightbox(): JSX.Element | null {
|
||||||
showLightboxForPrevMessage,
|
showLightboxForPrevMessage,
|
||||||
setSelectedLightboxPath,
|
setSelectedLightboxPath,
|
||||||
} = useLightboxActions();
|
} = useLightboxActions();
|
||||||
const { toggleForwardMessageModal } = useGlobalModalActions();
|
const { toggleForwardMessagesModal } = useGlobalModalActions();
|
||||||
const { pauseVoiceNotePlayer } = useAudioPlayerActions();
|
const { pauseVoiceNotePlayer } = useAudioPlayerActions();
|
||||||
|
|
||||||
const conversationSelector = useSelector<StateType, GetConversationByIdType>(
|
const conversationSelector = useSelector<StateType, GetConversationByIdType>(
|
||||||
|
@ -103,7 +103,7 @@ export function SmartLightbox(): JSX.Element | null {
|
||||||
media={media}
|
media={media}
|
||||||
saveAttachment={saveAttachment}
|
saveAttachment={saveAttachment}
|
||||||
selectedIndex={selectedIndex || 0}
|
selectedIndex={selectedIndex || 0}
|
||||||
toggleForwardMessageModal={toggleForwardMessageModal}
|
toggleForwardMessagesModal={toggleForwardMessagesModal}
|
||||||
onMediaPlaybackStart={pauseVoiceNotePlayer}
|
onMediaPlaybackStart={pauseVoiceNotePlayer}
|
||||||
onPrevAttachment={onPrevAttachment}
|
onPrevAttachment={onPrevAttachment}
|
||||||
onNextAttachment={onNextAttachment}
|
onNextAttachment={onNextAttachment}
|
||||||
|
|
|
@ -32,7 +32,7 @@ export function SmartMessageDetail(): JSX.Element | null {
|
||||||
const theme = useSelector(getTheme);
|
const theme = useSelector(getTheme);
|
||||||
const { checkForAccount } = useAccountsActions();
|
const { checkForAccount } = useAccountsActions();
|
||||||
const {
|
const {
|
||||||
clearSelectedMessage,
|
clearTargetedMessage: clearSelectedMessage,
|
||||||
doubleCheckMissingQuoteReference,
|
doubleCheckMissingQuoteReference,
|
||||||
kickOffAttachmentDownload,
|
kickOffAttachmentDownload,
|
||||||
markAttachmentAsCorrupted,
|
markAttachmentAsCorrupted,
|
||||||
|
@ -69,7 +69,7 @@ export function SmartMessageDetail(): JSX.Element | null {
|
||||||
return (
|
return (
|
||||||
<MessageDetail
|
<MessageDetail
|
||||||
checkForAccount={checkForAccount}
|
checkForAccount={checkForAccount}
|
||||||
clearSelectedMessage={clearSelectedMessage}
|
clearTargetedMessage={clearSelectedMessage}
|
||||||
contactNameColor={contactNameColor}
|
contactNameColor={contactNameColor}
|
||||||
contacts={contacts}
|
contacts={contacts}
|
||||||
doubleCheckMissingQuoteReference={doubleCheckMissingQuoteReference}
|
doubleCheckMissingQuoteReference={doubleCheckMissingQuoteReference}
|
||||||
|
|
|
@ -42,7 +42,7 @@ export function SmartStories(): JSX.Element | null {
|
||||||
showConversation,
|
showConversation,
|
||||||
toggleHideStories,
|
toggleHideStories,
|
||||||
} = useConversationsActions();
|
} = useConversationsActions();
|
||||||
const { showStoriesSettings, toggleForwardMessageModal } =
|
const { showStoriesSettings, toggleForwardMessagesModal } =
|
||||||
useGlobalModalActions();
|
useGlobalModalActions();
|
||||||
const { showToast } = useToastActions();
|
const { showToast } = useToastActions();
|
||||||
|
|
||||||
|
@ -92,7 +92,9 @@ export function SmartStories(): JSX.Element | null {
|
||||||
maxAttachmentSizeInKb={maxAttachmentSizeInKb}
|
maxAttachmentSizeInKb={maxAttachmentSizeInKb}
|
||||||
me={me}
|
me={me}
|
||||||
myStories={myStories}
|
myStories={myStories}
|
||||||
onForwardStory={toggleForwardMessageModal}
|
onForwardStory={messageId => {
|
||||||
|
toggleForwardMessagesModal([messageId]);
|
||||||
|
}}
|
||||||
onSaveStory={story => {
|
onSaveStory={story => {
|
||||||
if (story.attachment) {
|
if (story.attachment) {
|
||||||
saveAttachment(story.attachment, story.timestamp);
|
saveAttachment(story.attachment, story.timestamp);
|
||||||
|
|
|
@ -24,7 +24,7 @@ import {
|
||||||
getConversationSelector,
|
getConversationSelector,
|
||||||
getConversationsByTitleSelector,
|
getConversationsByTitleSelector,
|
||||||
getInvitedContactsForNewlyCreatedGroup,
|
getInvitedContactsForNewlyCreatedGroup,
|
||||||
getSelectedMessage,
|
getTargetedMessage,
|
||||||
} from '../selectors/conversations';
|
} from '../selectors/conversations';
|
||||||
import { selectAudioPlayerActive } from '../selectors/audioPlayer';
|
import { selectAudioPlayerActive } from '../selectors/audioPlayer';
|
||||||
|
|
||||||
|
@ -227,7 +227,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
|
||||||
const conversation = getConversationSelector(state)(id);
|
const conversation = getConversationSelector(state)(id);
|
||||||
|
|
||||||
const conversationMessages = getConversationMessagesSelector(state)(id);
|
const conversationMessages = getConversationMessagesSelector(state)(id);
|
||||||
const selectedMessage = getSelectedMessage(state);
|
const targetedMessage = getTargetedMessage(state);
|
||||||
|
|
||||||
const getTimestampForMessage = (messageId: string): undefined | number =>
|
const getTimestampForMessage = (messageId: string): undefined | number =>
|
||||||
getMessages(state)[messageId]?.timestamp;
|
getMessages(state)[messageId]?.timestamp;
|
||||||
|
@ -247,7 +247,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
|
||||||
|
|
||||||
invitedContactsForNewlyCreatedGroup:
|
invitedContactsForNewlyCreatedGroup:
|
||||||
getInvitedContactsForNewlyCreatedGroup(state),
|
getInvitedContactsForNewlyCreatedGroup(state),
|
||||||
selectedMessageId: selectedMessage ? selectedMessage.id : undefined,
|
targetedMessageId: targetedMessage ? targetedMessage.id : undefined,
|
||||||
shouldShowMiniPlayer,
|
shouldShowMiniPlayer,
|
||||||
|
|
||||||
warning: getWarning(conversation, state),
|
warning: getWarning(conversation, state),
|
||||||
|
|
|
@ -17,7 +17,7 @@ import { useStoriesActions } from '../ducks/stories';
|
||||||
import { useCallingActions } from '../ducks/calling';
|
import { useCallingActions } from '../ducks/calling';
|
||||||
import { getPreferredBadgeSelector } from '../selectors/badges';
|
import { getPreferredBadgeSelector } from '../selectors/badges';
|
||||||
import { getIntl, getInteractionMode, getTheme } from '../selectors/user';
|
import { getIntl, getInteractionMode, getTheme } from '../selectors/user';
|
||||||
import { getSelectedMessage } from '../selectors/conversations';
|
import { getTargetedMessage } from '../selectors/conversations';
|
||||||
import { getTimelineItem } from '../selectors/timeline';
|
import { getTimelineItem } from '../selectors/timeline';
|
||||||
import {
|
import {
|
||||||
areMessagesInSameGroup,
|
areMessagesInSameGroup,
|
||||||
|
@ -71,9 +71,9 @@ export function SmartTimelineItem(props: ExternalProps): JSX.Element {
|
||||||
const previousItem = useProxySelector(getTimelineItem, previousMessageId);
|
const previousItem = useProxySelector(getTimelineItem, previousMessageId);
|
||||||
const nextItem = useProxySelector(getTimelineItem, nextMessageId);
|
const nextItem = useProxySelector(getTimelineItem, nextMessageId);
|
||||||
|
|
||||||
const selectedMessage = useSelector(getSelectedMessage);
|
const targetedMessage = useSelector(getTargetedMessage);
|
||||||
const isSelected = Boolean(
|
const isTargeted = Boolean(
|
||||||
selectedMessage && messageId === selectedMessage.id
|
targetedMessage && messageId === targetedMessage.id
|
||||||
);
|
);
|
||||||
|
|
||||||
const isNextItemCallingNotification = nextItem?.type === 'callHistory';
|
const isNextItemCallingNotification = nextItem?.type === 'callHistory';
|
||||||
|
@ -105,8 +105,8 @@ export function SmartTimelineItem(props: ExternalProps): JSX.Element {
|
||||||
|
|
||||||
const {
|
const {
|
||||||
blockGroupLinkRequests,
|
blockGroupLinkRequests,
|
||||||
clearSelectedMessage,
|
clearTargetedMessage: clearSelectedMessage,
|
||||||
deleteMessage,
|
deleteMessages,
|
||||||
deleteMessageForEveryone,
|
deleteMessageForEveryone,
|
||||||
doubleCheckMissingQuoteReference,
|
doubleCheckMissingQuoteReference,
|
||||||
kickOffAttachmentDownload,
|
kickOffAttachmentDownload,
|
||||||
|
@ -117,7 +117,8 @@ export function SmartTimelineItem(props: ExternalProps): JSX.Element {
|
||||||
retryDeleteForEveryone,
|
retryDeleteForEveryone,
|
||||||
retryMessageSend,
|
retryMessageSend,
|
||||||
saveAttachment,
|
saveAttachment,
|
||||||
selectMessage,
|
targetMessage,
|
||||||
|
toggleSelectMessage,
|
||||||
showConversation,
|
showConversation,
|
||||||
showExpiredIncomingTapToViewToast,
|
showExpiredIncomingTapToViewToast,
|
||||||
showExpiredOutgoingTapToViewToast,
|
showExpiredOutgoingTapToViewToast,
|
||||||
|
@ -129,7 +130,7 @@ export function SmartTimelineItem(props: ExternalProps): JSX.Element {
|
||||||
|
|
||||||
const {
|
const {
|
||||||
showContactModal,
|
showContactModal,
|
||||||
toggleForwardMessageModal,
|
toggleForwardMessagesModal,
|
||||||
toggleSafetyNumberModal,
|
toggleSafetyNumberModal,
|
||||||
} = useGlobalModalActions();
|
} = useGlobalModalActions();
|
||||||
|
|
||||||
|
@ -150,7 +151,7 @@ export function SmartTimelineItem(props: ExternalProps): JSX.Element {
|
||||||
conversationId={conversationId}
|
conversationId={conversationId}
|
||||||
getPreferredBadge={getPreferredBadge}
|
getPreferredBadge={getPreferredBadge}
|
||||||
isNextItemCallingNotification={isNextItemCallingNotification}
|
isNextItemCallingNotification={isNextItemCallingNotification}
|
||||||
isSelected={isSelected}
|
isTargeted={isTargeted}
|
||||||
renderAudioAttachment={renderAudioAttachment}
|
renderAudioAttachment={renderAudioAttachment}
|
||||||
renderContact={renderContact}
|
renderContact={renderContact}
|
||||||
renderEmojiPicker={renderEmojiPicker}
|
renderEmojiPicker={renderEmojiPicker}
|
||||||
|
@ -165,8 +166,8 @@ export function SmartTimelineItem(props: ExternalProps): JSX.Element {
|
||||||
theme={theme}
|
theme={theme}
|
||||||
blockGroupLinkRequests={blockGroupLinkRequests}
|
blockGroupLinkRequests={blockGroupLinkRequests}
|
||||||
checkForAccount={checkForAccount}
|
checkForAccount={checkForAccount}
|
||||||
clearSelectedMessage={clearSelectedMessage}
|
clearTargetedMessage={clearSelectedMessage}
|
||||||
deleteMessage={deleteMessage}
|
deleteMessages={deleteMessages}
|
||||||
deleteMessageForEveryone={deleteMessageForEveryone}
|
deleteMessageForEveryone={deleteMessageForEveryone}
|
||||||
doubleCheckMissingQuoteReference={doubleCheckMissingQuoteReference}
|
doubleCheckMissingQuoteReference={doubleCheckMissingQuoteReference}
|
||||||
kickOffAttachmentDownload={kickOffAttachmentDownload}
|
kickOffAttachmentDownload={kickOffAttachmentDownload}
|
||||||
|
@ -180,7 +181,7 @@ export function SmartTimelineItem(props: ExternalProps): JSX.Element {
|
||||||
returnToActiveCall={returnToActiveCall}
|
returnToActiveCall={returnToActiveCall}
|
||||||
saveAttachment={saveAttachment}
|
saveAttachment={saveAttachment}
|
||||||
scrollToQuotedMessage={scrollToQuotedMessage}
|
scrollToQuotedMessage={scrollToQuotedMessage}
|
||||||
selectMessage={selectMessage}
|
targetMessage={targetMessage}
|
||||||
setQuoteByMessageId={setQuoteByMessageId}
|
setQuoteByMessageId={setQuoteByMessageId}
|
||||||
showContactModal={showContactModal}
|
showContactModal={showContactModal}
|
||||||
showConversation={showConversation}
|
showConversation={showConversation}
|
||||||
|
@ -190,9 +191,10 @@ export function SmartTimelineItem(props: ExternalProps): JSX.Element {
|
||||||
showLightboxForViewOnceMedia={showLightboxForViewOnceMedia}
|
showLightboxForViewOnceMedia={showLightboxForViewOnceMedia}
|
||||||
startCallingLobby={startCallingLobby}
|
startCallingLobby={startCallingLobby}
|
||||||
startConversation={startConversation}
|
startConversation={startConversation}
|
||||||
toggleForwardMessageModal={toggleForwardMessageModal}
|
toggleForwardMessagesModal={toggleForwardMessagesModal}
|
||||||
toggleSafetyNumberModal={toggleSafetyNumberModal}
|
toggleSafetyNumberModal={toggleSafetyNumberModal}
|
||||||
viewStory={viewStory}
|
viewStory={viewStory}
|
||||||
|
toggleSelectMessage={toggleSelectMessage}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,15 +3,15 @@
|
||||||
|
|
||||||
import { assert } from 'chai';
|
import { assert } from 'chai';
|
||||||
|
|
||||||
|
import type { TargetedConversationChangedActionType } from '../../../state/ducks/conversations';
|
||||||
import {
|
import {
|
||||||
SELECTED_CONVERSATION_CHANGED,
|
TARGETED_CONVERSATION_CHANGED,
|
||||||
actions as conversationsActions,
|
actions as conversationsActions,
|
||||||
} from '../../../state/ducks/conversations';
|
} from '../../../state/ducks/conversations';
|
||||||
import { noopAction } from '../../../state/ducks/noop';
|
import { noopAction } from '../../../state/ducks/noop';
|
||||||
|
|
||||||
import type { StateType } from '../../../state/reducer';
|
import type { StateType } from '../../../state/reducer';
|
||||||
import { reducer as rootReducer } from '../../../state/reducer';
|
import { reducer as rootReducer } from '../../../state/reducer';
|
||||||
import type { SelectedConversationChangedActionType } from '../../../state/ducks/conversations';
|
|
||||||
import { actions, AudioPlayerContent } from '../../../state/ducks/audioPlayer';
|
import { actions, AudioPlayerContent } from '../../../state/ducks/audioPlayer';
|
||||||
import type { VoiceNoteAndConsecutiveForPlayback } from '../../../state/selectors/audioPlayer';
|
import type { VoiceNoteAndConsecutiveForPlayback } from '../../../state/selectors/audioPlayer';
|
||||||
|
|
||||||
|
@ -100,8 +100,8 @@ describe('both/state/ducks/audioPlayer', () => {
|
||||||
it('active is not changed when changing the conversation', () => {
|
it('active is not changed when changing the conversation', () => {
|
||||||
const state = getInitializedState();
|
const state = getInitializedState();
|
||||||
|
|
||||||
const updated = rootReducer(state, <SelectedConversationChangedActionType>{
|
const updated = rootReducer(state, <TargetedConversationChangedActionType>{
|
||||||
type: SELECTED_CONVERSATION_CHANGED,
|
type: TARGETED_CONVERSATION_CHANGED,
|
||||||
payload: { id: 'any' },
|
payload: { id: 'any' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
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,
|
ConversationType,
|
||||||
ConversationsStateType,
|
ConversationsStateType,
|
||||||
MessageType,
|
MessageType,
|
||||||
SelectedConversationChangedActionType,
|
TargetedConversationChangedActionType,
|
||||||
ToggleConversationInChooseMembersActionType,
|
ToggleConversationInChooseMembersActionType,
|
||||||
MessageChangedActionType,
|
MessageChangedActionType,
|
||||||
} from '../../../state/ducks/conversations';
|
} from '../../../state/ducks/conversations';
|
||||||
import {
|
import {
|
||||||
SELECTED_CONVERSATION_CHANGED,
|
TARGETED_CONVERSATION_CHANGED,
|
||||||
actions,
|
actions,
|
||||||
cancelConversationVerification,
|
cancelConversationVerification,
|
||||||
clearCancelledConversationVerification,
|
clearCancelledConversationVerification,
|
||||||
|
@ -386,7 +386,7 @@ describe('both/state/ducks/conversations', () => {
|
||||||
const nextState = reducer(state, action);
|
const nextState = reducer(state, action);
|
||||||
|
|
||||||
assert.equal(nextState.selectedConversationId, 'abc123');
|
assert.equal(nextState.selectedConversationId, 'abc123');
|
||||||
assert.isUndefined(nextState.selectedMessage);
|
assert.isUndefined(nextState.targetedMessage);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('selects a conversation and a message', () => {
|
it('selects a conversation and a message', () => {
|
||||||
|
@ -402,11 +402,11 @@ describe('both/state/ducks/conversations', () => {
|
||||||
const nextState = reducer(state, action);
|
const nextState = reducer(state, action);
|
||||||
|
|
||||||
assert.equal(nextState.selectedConversationId, 'abc123');
|
assert.equal(nextState.selectedConversationId, 'abc123');
|
||||||
assert.equal(nextState.selectedMessage, 'xyz987');
|
assert.equal(nextState.targetedMessage, 'xyz987');
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('showConversation switchToAssociatedView=true', () => {
|
describe('showConversation switchToAssociatedView=true', () => {
|
||||||
let action: SelectedConversationChangedActionType;
|
let action: TargetedConversationChangedActionType;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const dispatch = sinon.spy();
|
const dispatch = sinon.spy();
|
||||||
|
@ -766,7 +766,7 @@ describe('both/state/ducks/conversations', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
sinon.assert.calledWith(dispatch, {
|
sinon.assert.calledWith(dispatch, {
|
||||||
type: SELECTED_CONVERSATION_CHANGED,
|
type: TARGETED_CONVERSATION_CHANGED,
|
||||||
payload: {
|
payload: {
|
||||||
conversationId: '9876',
|
conversationId: '9876',
|
||||||
messageId: undefined,
|
messageId: undefined,
|
||||||
|
|
|
@ -15,6 +15,7 @@ import {
|
||||||
} from '../sql/Server';
|
} from '../sql/Server';
|
||||||
import { ReadStatus } from '../messages/MessageReadStatus';
|
import { ReadStatus } from '../messages/MessageReadStatus';
|
||||||
import { SeenStatus } from '../MessageSeenStatus';
|
import { SeenStatus } from '../MessageSeenStatus';
|
||||||
|
import { sql } from '../sql/util';
|
||||||
|
|
||||||
const OUR_UUID = generateGuid();
|
const OUR_UUID = generateGuid();
|
||||||
|
|
||||||
|
@ -1608,58 +1609,53 @@ describe('SQL migrations test', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('updateToSchemaVersion52', () => {
|
describe('updateToSchemaVersion52', () => {
|
||||||
const queries = [
|
function getQueries(
|
||||||
{
|
|
||||||
query: `
|
|
||||||
EXPLAIN QUERY PLAN
|
|
||||||
SELECT * FROM messages WHERE
|
|
||||||
conversationId = 'conversation' AND
|
|
||||||
readStatus = 'something' AND
|
|
||||||
isStory IS 0 AND
|
|
||||||
:story_id_predicate:
|
|
||||||
ORDER BY received_at ASC, sent_at ASC
|
|
||||||
LIMIT 1;
|
|
||||||
`,
|
|
||||||
index: 'messages_unread',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
query: `
|
|
||||||
EXPLAIN QUERY PLAN
|
|
||||||
SELECT json FROM messages WHERE
|
|
||||||
conversationId = 'd8b05bb1-36b3-4478-841b-600af62321eb' AND
|
|
||||||
(NULL IS NULL OR id IS NOT NULL) AND
|
|
||||||
isStory IS 0 AND
|
|
||||||
:story_id_predicate: AND
|
|
||||||
(
|
|
||||||
(received_at = 17976931348623157 AND sent_at < NULL) OR
|
|
||||||
received_at < 17976931348623157
|
|
||||||
)
|
|
||||||
ORDER BY received_at DESC, sent_at DESC
|
|
||||||
LIMIT 10;
|
|
||||||
`,
|
|
||||||
index: 'messages_conversation',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function insertPredicate(
|
|
||||||
query: string,
|
|
||||||
storyId: string | undefined,
|
storyId: string | undefined,
|
||||||
includeStoryReplies: boolean
|
includeStoryReplies: boolean
|
||||||
): string {
|
) {
|
||||||
return query.replaceAll(
|
return [
|
||||||
':story_id_predicate:',
|
{
|
||||||
_storyIdPredicate(storyId, includeStoryReplies)
|
template: sql`
|
||||||
);
|
EXPLAIN QUERY PLAN
|
||||||
|
SELECT * FROM messages WHERE
|
||||||
|
conversationId = 'conversation' AND
|
||||||
|
readStatus = 'something' AND
|
||||||
|
isStory IS 0 AND
|
||||||
|
${_storyIdPredicate(storyId, includeStoryReplies)}
|
||||||
|
ORDER BY received_at ASC, sent_at ASC
|
||||||
|
LIMIT 1;
|
||||||
|
`,
|
||||||
|
index: 'messages_unread',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
template: sql`
|
||||||
|
EXPLAIN QUERY PLAN
|
||||||
|
SELECT json FROM messages WHERE
|
||||||
|
conversationId = 'd8b05bb1-36b3-4478-841b-600af62321eb' AND
|
||||||
|
(NULL IS NULL OR id IS NOT NULL) AND
|
||||||
|
isStory IS 0 AND
|
||||||
|
${_storyIdPredicate(storyId, includeStoryReplies)} AND
|
||||||
|
(
|
||||||
|
(received_at = 17976931348623157 AND sent_at < NULL) OR
|
||||||
|
received_at < 17976931348623157
|
||||||
|
)
|
||||||
|
ORDER BY received_at DESC, sent_at DESC
|
||||||
|
LIMIT 10;
|
||||||
|
`,
|
||||||
|
index: 'messages_conversation',
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
it('produces optimizable queries for present and absent storyId', () => {
|
it('produces optimizable queries for present and absent storyId', () => {
|
||||||
updateToVersion(52);
|
updateToVersion(52);
|
||||||
|
|
||||||
for (const storyId of ['123', undefined]) {
|
for (const storyId of ['123', undefined]) {
|
||||||
for (const { query, index } of queries) {
|
for (const { template, index } of getQueries(storyId, true)) {
|
||||||
|
const [query, params] = template;
|
||||||
const details = db
|
const details = db
|
||||||
.prepare(insertPredicate(query, storyId, true))
|
.prepare(query)
|
||||||
.all({ storyId })
|
.all(params)
|
||||||
.map(({ detail }) => detail)
|
.map(({ detail }) => detail)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,7 @@ export enum ToastType {
|
||||||
AlreadyRequestedToJoin = 'AlreadyRequestedToJoin',
|
AlreadyRequestedToJoin = 'AlreadyRequestedToJoin',
|
||||||
Blocked = 'Blocked',
|
Blocked = 'Blocked',
|
||||||
BlockedGroup = 'BlockedGroup',
|
BlockedGroup = 'BlockedGroup',
|
||||||
|
CannotForwardEmptyMessage = 'CannotForwardEmptyMessage',
|
||||||
CannotMixMultiAndNonMultiAttachments = 'CannotMixMultiAndNonMultiAttachments',
|
CannotMixMultiAndNonMultiAttachments = 'CannotMixMultiAndNonMultiAttachments',
|
||||||
CannotOpenGiftBadgeIncoming = 'CannotOpenGiftBadgeIncoming',
|
CannotOpenGiftBadgeIncoming = 'CannotOpenGiftBadgeIncoming',
|
||||||
CannotOpenGiftBadgeOutgoing = 'CannotOpenGiftBadgeOutgoing',
|
CannotOpenGiftBadgeOutgoing = 'CannotOpenGiftBadgeOutgoing',
|
||||||
|
@ -38,6 +39,7 @@ export enum ToastType {
|
||||||
StoryVideoUnsupported = 'StoryVideoUnsupported',
|
StoryVideoUnsupported = 'StoryVideoUnsupported',
|
||||||
TapToViewExpiredIncoming = 'TapToViewExpiredIncoming',
|
TapToViewExpiredIncoming = 'TapToViewExpiredIncoming',
|
||||||
TapToViewExpiredOutgoing = 'TapToViewExpiredOutgoing',
|
TapToViewExpiredOutgoing = 'TapToViewExpiredOutgoing',
|
||||||
|
TooManyMessagesToForward = 'TooManyMessagesToForward',
|
||||||
UnableToLoadAttachment = 'UnableToLoadAttachment',
|
UnableToLoadAttachment = 'UnableToLoadAttachment',
|
||||||
UnsupportedMultiAttachment = 'UnsupportedMultiAttachment',
|
UnsupportedMultiAttachment = 'UnsupportedMultiAttachment',
|
||||||
UnsupportedOS = 'UnsupportedOS',
|
UnsupportedOS = 'UnsupportedOS',
|
||||||
|
|
|
@ -2013,7 +2013,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/ForwardMessageModal.tsx",
|
"path": "ts/components/ForwardMessagesModal.tsx",
|
||||||
"line": " const inputRef = useRef<null | HTMLInputElement>(null);",
|
"line": " const inputRef = useRef<null | HTMLInputElement>(null);",
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2021-07-30T16:57:33.618Z"
|
"updated": "2021-07-30T16:57:33.618Z"
|
||||||
|
|
|
@ -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…
Add table
Add a link
Reference in a new issue