From 1d549a9991847206f174081a3d22a5f42b5a79ad Mon Sep 17 00:00:00 2001 From: Jamie Kyle <113370520+jamiebuilds-signal@users.noreply.github.com> Date: Mon, 20 Mar 2023 15:23:53 -0700 Subject: [PATCH] Multi-select forwarding and deleting --- _locales/en/messages.json | 52 +++ images/icons/v2/check-20.svg | 4 + stylesheets/_mixins.scss | 24 ++ stylesheets/_modules.scss | 86 +++- stylesheets/components/Button.scss | 28 +- stylesheets/components/SelectModeActions.scss | 61 +++ stylesheets/manifest.scss | 1 + ts/background.ts | 24 +- ts/components/AvatarLightbox.tsx | 2 +- ts/components/Button.tsx | 3 + ts/components/CompositionArea.stories.tsx | 6 + ts/components/CompositionArea.tsx | 45 +++ ...s.tsx => ForwardMessagesModal.stories.tsx} | 126 ++++-- ...sageModal.tsx => ForwardMessagesModal.tsx} | 240 +++++++----- ts/components/GlobalModalContainer.tsx | 14 +- ts/components/Inbox.tsx | 22 +- ts/components/LeftPane.stories.tsx | 2 +- ts/components/LeftPane.tsx | 8 +- ts/components/Lightbox.stories.tsx | 2 +- ts/components/Lightbox.tsx | 6 +- ts/components/StoryViewsNRepliesModal.tsx | 6 +- ts/components/ToastManager.tsx | 19 + .../ConversationHeader.stories.tsx | 1 + .../conversation/ConversationHeader.tsx | 9 + .../conversation/ConversationView.tsx | 11 + .../InlineNotificationWrapper.tsx | 22 +- ts/components/conversation/Message.tsx | 220 +++++++---- .../conversation/MessageDetail.stories.tsx | 4 +- ts/components/conversation/MessageDetail.tsx | 8 +- ts/components/conversation/Quote.stories.tsx | 11 +- .../conversation/SelectModeActions.tsx | 122 ++++++ .../conversation/Timeline.stories.tsx | 15 +- ts/components/conversation/Timeline.tsx | 49 +-- .../conversation/TimelineItem.stories.tsx | 13 +- ts/components/conversation/TimelineItem.tsx | 16 +- .../conversation/TimelineMessage.stories.tsx | 48 ++- .../conversation/TimelineMessage.tsx | 74 ++-- .../leftPane/LeftPaneArchiveHelper.tsx | 4 +- ts/components/leftPane/LeftPaneHelper.tsx | 2 +- .../leftPane/LeftPaneInboxHelper.tsx | 2 +- .../leftPane/LeftPaneSearchHelper.tsx | 2 +- ts/hooks/useEscapeHandling.ts | 8 +- ts/models/conversations.ts | 19 +- ts/services/InteractionMode.ts | 16 +- ts/sql/Client.ts | 36 +- ts/sql/Interface.ts | 22 +- ts/sql/Server.ts | 347 ++++++++++------- ts/sql/util.ts | 141 +++++++ ts/state/ducks/audioPlayer.ts | 4 +- ts/state/ducks/composer.ts | 8 +- ts/state/ducks/conversations.ts | 368 +++++++++++++----- ts/state/ducks/conversationsEnums.ts | 2 +- ts/state/ducks/globalModals.ts | 61 +-- ts/state/ducks/search.ts | 12 +- ts/state/ducks/stories.ts | 8 +- ts/state/ducks/toast.ts | 4 + ts/state/selectors/conversations.ts | 29 +- ts/state/selectors/message.ts | 48 ++- ts/state/selectors/search.ts | 12 +- ts/state/selectors/timeline.ts | 11 +- ts/state/smart/CompositionArea.tsx | 9 + ts/state/smart/ConversationHeader.tsx | 2 +- ts/state/smart/ConversationView.tsx | 14 +- ...sageModal.tsx => ForwardMessagesModal.tsx} | 116 +++--- ts/state/smart/GlobalModalContainer.tsx | 12 +- ts/state/smart/Inbox.tsx | 6 +- ts/state/smart/LeftPane.tsx | 4 +- ts/state/smart/Lightbox.tsx | 4 +- ts/state/smart/MessageDetail.tsx | 4 +- ts/state/smart/Stories.tsx | 6 +- ts/state/smart/Timeline.tsx | 6 +- ts/state/smart/TimelineItem.tsx | 28 +- ts/test-both/state/ducks/audioPlayer_test.ts | 8 +- .../sql/getMessagesBetween_test.ts | 123 ++++++ .../getNearbyMessageFromDeletedSet_test.ts | 131 +++++++ ts/test-electron/sql/utils_test.ts | 53 +++ .../state/ducks/conversations_test.ts | 12 +- ts/test-node/sql_migrations_test.ts | 82 ++-- ts/types/Toast.tsx | 2 + ts/util/lint/exceptions.json | 2 +- ts/util/maybeForwardMessage.ts | 144 ------- ts/util/maybeForwardMessages.ts | 260 +++++++++++++ 82 files changed, 2607 insertions(+), 991 deletions(-) create mode 100644 images/icons/v2/check-20.svg create mode 100644 stylesheets/components/SelectModeActions.scss rename ts/components/{ForwardMessageModal.stories.tsx => ForwardMessagesModal.stories.tsx} (55%) rename ts/components/{ForwardMessageModal.tsx => ForwardMessagesModal.tsx} (71%) create mode 100644 ts/components/conversation/SelectModeActions.tsx rename ts/state/smart/{ForwardMessageModal.tsx => ForwardMessagesModal.tsx} (53%) create mode 100644 ts/test-electron/sql/getMessagesBetween_test.ts create mode 100644 ts/test-electron/sql/getNearbyMessageFromDeletedSet_test.ts create mode 100644 ts/test-electron/sql/utils_test.ts delete mode 100644 ts/util/maybeForwardMessage.ts create mode 100644 ts/util/maybeForwardMessages.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 37a95284dd2d..37c01d461700 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -315,6 +315,10 @@ "message": "Mark as unread", "description": "Shown in menu for conversation, and marks conversation as unread" }, + "icu:ConversationHeader__menu__selectMessages": { + "messageformat": "Select messages", + "description": "Shown in menu for conversation, allows the user to start selecting multiple messages in the conversation" + }, "moveConversationToInbox": { "message": "Unarchive", "description": "Undoes Archive Conversation action, and moves archived conversation back to the main conversation list" @@ -1173,6 +1177,10 @@ "message": "More Info", "description": "Shown on the drop-down menu for an individual message, takes you to message detail screen" }, + "icu:MessageContextMenu__select": { + "messageformat": "Select", + "description": "Shown on the drop-down menu for an individual message, opens the conversation in select mode with the current message selected" + }, "retrySend": { "message": "Retry Send", "description": "Shown on the drop-down menu for an individual message, but only if it is an outgoing message that failed to send" @@ -2371,6 +2379,14 @@ "messageformat": "Redeemed", "description": "Shown when you've redeemed the donation badge on another device" }, + "icu:messageAccessibilityLabel--outgoing": { + "messageformat": "Message sent by you", + "description": "Accessibility label for outgoing messages" + }, + "icu:messageAccessibilityLabel--incoming": { + "messageformat": "Message sent by {author}", + "description": "Accessibility label for incoming messages" + }, "icu:modal--donation--title": { "messageformat": "Thanks for your support!", "description": "The title of the outgoing donation badge detail dialog" @@ -4619,6 +4635,34 @@ "message": "Block Request", "description": "Confirmation button of dialog to block a user from requesting to join via the link again" }, + "icu:SelectModeActions--exitSelectMode": { + "messageformat": "Exit select mode", + "description": "conversation > in select mode > composition area actions > exit select mode > accessibility label" + }, + "icu:SelectModeActions--selectedMessages": { + "messageformat": "{count} selected", + "description": "conversation > in select mode > composition area actions > count of selected messsages" + }, + "icu:SelectModeActions--deleteSelectedMessages": { + "messageformat": "Delete selected messages", + "description": "conversation > in select mode > composition area actions > delete selected messsages action > accessibility label" + }, + "icu:SelectModeActions--forwardSelectedMessages": { + "messageformat": "Forward selected messages", + "description": "conversation > in select mode > composition area actions > forward selected messsages action > accessibility label" + }, + "icu:SelectModeActions__confirmDelete--title": { + "messageformat": "Delete {count, plural, one {message} other {# messages}}", + "description": "conversation > in select mode > composition area actions > delete selected messages > confirmation modal > title" + }, + "icu:SelectModeActions__confirmDelete--confirm": { + "messageformat": "Delete for me", + "description": "conversation > in select mode > composition area actions > delete selected messages > confirmation modal > button" + }, + "icu:SelectModeActions__toast--TooManyMessagesToForward": { + "messageformat": "You can only forward up to 30 messages", + "description": "conversation > in select mode > composition area actions > forward selected messages (disabled) > toast message when too many messages" + }, "AvatarInput--no-photo-label--group": { "message": "Add a group photo", "description": "The label for the avatar uploader when no group photo is selected" @@ -4759,10 +4803,18 @@ "message": "compose button", "description": "Shown in the left-pane when the inbox is empty. Describes the button that composes a new message." }, + "icu:ForwardMessageModal__title": { + "messageformat": "Forward To", + "description": "Title for the forward a message modal dialog" + }, "ForwardMessageModal--continue": { "message": "Continue", "description": "aria-label for the 'next' button in the forward a message modal dialog" }, + "icu:ForwardMessagesModal__toast--CannotForwardEmptyMessage": { + "messageformat": "Cannot forward empty or deleted messages", + "description": "Toast message shown when trying to forward an empty or deleted message" + }, "TimelineDateHeader--date-in-last-6-months": { "message": "ddd, MMM D", "description": "(deleted 01/25/2023) Moment.js format for date headers in the message timeline, for dates <6 months old. See https://momentjs.com/docs/#/displaying/format/." diff --git a/images/icons/v2/check-20.svg b/images/icons/v2/check-20.svg new file mode 100644 index 000000000000..061d3f6562eb --- /dev/null +++ b/images/icons/v2/check-20.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/stylesheets/_mixins.scss b/stylesheets/_mixins.scss index 8941b21e536d..7499e5feb268 100644 --- a/stylesheets/_mixins.scss +++ b/stylesheets/_mixins.scss @@ -728,3 +728,27 @@ background: $color-gray-80; } } + +@mixin sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} + +@mixin disabled { + &:is(:disabled, [aria-disabled='true']) { + @content; + } +} + +@mixin not-disabled { + &:not(:disabled):not([aria-disabled='true']) { + @content; + } +} diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index c4cfc42ce294..121c529103d9 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -62,7 +62,9 @@ outline: none; padding-left: 16px; padding-right: 16px; - transition: background 0.1s ease-out; + transition-property: background, translate; + transition-duration: 0.1s; + transition-timing-function: ease-out; } .module-message__quote-story-reaction-header { @@ -187,7 +189,7 @@ } } - .module-message--selected & { + .module-message--targeted & { @include mouse-mode { background-color: $color-gray-60; } @@ -290,7 +292,7 @@ display: flex; flex-direction: column; min-width: 0; - max-width: 306px; + max-width: min(306px, calc(100% - 16px - 22px)); .module-timeline--width-wide &, .module-message-detail & { @@ -347,18 +349,76 @@ $message-padding-horizontal: 12px; } } -.module-message__container--selected { +.module-message__container--targeted { @include mouse-mode { animation: module-message__highlight 1.2s cubic-bezier(0.17, 0.17, 0, 1); } } -.module-message__container--selected-lighter { +.module-message__container--targeted-lighter { @include mouse-mode { animation: module-message__highlight-lighter 1.2s cubic-bezier(0.17, 0.17, 0, 1); } } +.module-message__wrapper { + position: relative; + transition: background 0.1s ease-out; +} + +.module-message__wrapper--select-mode { + .module-message--incoming { + translate: calc(16px + 22px) 0; + } +} + +.module-message__alt-accessibility-tree { + @include sr-only; +} + +.module-message__wrapper--selected { + background: rgba($color-ultramarine, 8%); +} + +.module-message__select-checkbox { + position: absolute; + top: 50%; + left: 16px; + translate: 0 -50%; + width: 18px; + height: 18px; + border-radius: 9999px; + background: transparent; + border: 1px solid $color-gray-20; + animation: module-message__select-checkbox--fadeIn 0.2s ease-out; + transition: background 0.1s ease-out, border-color 0.1s ease-out; + + &::before { + content: ''; + display: block; + width: 20px; + height: 20px; + margin: -2px; + @include color-svg('../images/icons/v2/check-20.svg', $color-white); + opacity: 0; + transition: opacity 0.1s ease-out; + } + + .module-message__wrapper--selected & { + background: $color-ultramarine; + border-color: $color-ultramarine; + &::before { + opacity: 1; + } + } +} + +@keyframes module-message__select-checkbox--fadeIn { + from { + opacity: 0; + } +} + .module-message:focus-within { @include keyboard-mode { background: $color-selected-message-background-light; @@ -7590,6 +7650,22 @@ button.module-image__border-overlay:focus { } } + &__select::before { + @include light-theme { + @include color-svg( + '../images/icons/v2/check-circle-outline-24.svg', + $color-black + ); + } + + @include dark-theme { + @include color-svg( + '../images/icons/v2/check-circle-outline-24.svg', + $color-gray-15 + ); + } + } + &__retry-send::before { @include light-theme { @include color-svg('../images/icons/v2/send-24.svg', $color-black); diff --git a/stylesheets/components/Button.scss b/stylesheets/components/Button.scss index 96c48f2568d1..398b69b37227 100644 --- a/stylesheets/components/Button.scss +++ b/stylesheets/components/Button.scss @@ -9,11 +9,15 @@ } @mixin hover-and-active-states($background-color, $mix-color) { - &:hover:not(:disabled) { - background: mix($background-color, $mix-color, 85%); + &:hover { + @include not-disabled { + background: mix($background-color, $mix-color, 85%); + } } - &:active:not(:disabled) { - background: mix($background-color, $mix-color, 75%); + &:active { + @include not-disabled { + background: mix($background-color, $mix-color, 75%); + } } } @@ -31,7 +35,7 @@ @include focus-box-shadow($color-black, $color-ultramarine-icon); } - &:disabled { + @include disabled { cursor: not-allowed; } @@ -57,7 +61,7 @@ color: $color; background: $background-color; - &:disabled { + @include disabled { color: fade-out($color, 0.4); background: fade-out($background-color, 0.6); } @@ -79,7 +83,7 @@ color: $color; background: $background-color; - &:disabled { + @include disabled { color: $color-black-alpha-40; background: fade-out($background-color, 0.6); } @@ -102,7 +106,7 @@ color: $color; background: $background-color; - &:disabled { + @include disabled { color: $color-white-alpha-20; background: fade-out($background-color, 0.6); } @@ -126,7 +130,7 @@ color: $color; background: $background-color; - &:disabled { + @include disabled { color: fade-out($color, 0.4); background: fade-out($background-color, 0.6); } @@ -148,7 +152,7 @@ color: $color; background: $background-color; - &:disabled { + @include disabled { color: fade-out($color, 0.4); background: fade-out($background-color, 0.6); } @@ -180,7 +184,7 @@ color: $color; background: $background-color; - &:disabled { + @include disabled { color: fade-out($color, 0.4); background: fade-out($background-color, 0.6); } @@ -194,7 +198,7 @@ color: $color; background: $background-color; - &:disabled { + @include disabled { color: fade-out($color, 0.4); background: fade-out($background-color, 0.6); } diff --git a/stylesheets/components/SelectModeActions.scss b/stylesheets/components/SelectModeActions.scss new file mode 100644 index 000000000000..ff473dda49dd --- /dev/null +++ b/stylesheets/components/SelectModeActions.scss @@ -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); +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 7e6c4c884678..64be9d28577a 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -117,6 +117,7 @@ @import './components/SearchResultsLoadingFakeHeader.scss'; @import './components/SearchResultsLoadingFakeRow.scss'; @import './components/Select.scss'; +@import './components/SelectModeActions.scss'; @import './components/SendStoryModal.scss'; @import './components/SignalConnectionsModal.scss'; @import './components/Slider.scss'; diff --git a/ts/background.ts b/ts/background.ts index 8bcc3569916d..be6302bbb2a4 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -1649,15 +1649,15 @@ export async function startApp(): Promise { event.preventDefault(); event.stopPropagation(); - const { selectedMessage } = state.conversations; - if (!selectedMessage) { + const { targetedMessage } = state.conversations; + if (!targetedMessage) { return; } window.reduxActions.conversations.pushPanelForConversation({ type: PanelType.MessageDetails, args: { - messageId: selectedMessage, + messageId: targetedMessage, }, }); return; @@ -1673,14 +1673,14 @@ export async function startApp(): Promise { event.preventDefault(); event.stopPropagation(); - const { selectedMessage } = state.conversations; + const { targetedMessage } = state.conversations; const quotedMessageSelector = getQuotedMessageSelector(state); const quote = quotedMessageSelector(conversation.id); window.reduxActions.composer.setQuoteByMessageId( conversation.id, - quote ? undefined : selectedMessage + quote ? undefined : targetedMessage ); return; @@ -1696,11 +1696,11 @@ export async function startApp(): Promise { event.preventDefault(); event.stopPropagation(); - const { selectedMessage } = state.conversations; + const { targetedMessage } = state.conversations; - if (selectedMessage) { + if (targetedMessage) { window.reduxActions.conversations.saveAttachmentFromMessage( - selectedMessage + targetedMessage ); return; } @@ -1712,9 +1712,9 @@ export async function startApp(): Promise { shiftKey && (key === 'd' || key === 'D') ) { - const { selectedMessage } = state.conversations; + const { targetedMessage } = state.conversations; - if (selectedMessage) { + if (targetedMessage) { event.preventDefault(); event.stopPropagation(); @@ -1724,9 +1724,9 @@ export async function startApp(): Promise { message: window.i18n('deleteWarning'), okText: window.i18n('delete'), resolve: () => { - window.reduxActions.conversations.deleteMessage({ + window.reduxActions.conversations.deleteMessages({ conversationId: conversation.id, - messageId: selectedMessage, + messageIds: [targetedMessage], }); }, }); diff --git a/ts/components/AvatarLightbox.tsx b/ts/components/AvatarLightbox.tsx index 282c3defc14b..2f8c05da846e 100644 --- a/ts/components/AvatarLightbox.tsx +++ b/ts/components/AvatarLightbox.tsx @@ -33,7 +33,7 @@ export function AvatarLightbox({ isViewOnce media={[]} saveAttachment={noop} - toggleForwardMessageModal={noop} + toggleForwardMessagesModal={noop} onMediaPlaybackStart={noop} onNextAttachment={noop} onPrevAttachment={noop} diff --git a/ts/components/Button.tsx b/ts/components/Button.tsx index 99adbeaf2046..6b3e094cb94d 100644 --- a/ts/components/Button.tsx +++ b/ts/components/Button.tsx @@ -50,6 +50,7 @@ type PropsType = { tabIndex?: number; theme?: Theme; variant?: ButtonVariant; + 'aria-disabled'?: boolean; } & ( | { onClick: MouseEventHandler; @@ -115,6 +116,7 @@ export const Button = React.forwardRef( : ButtonSize.Medium, } = props; const ariaLabel = props['aria-label']; + const ariaDisabled = props['aria-disabled']; let onClick: undefined | MouseEventHandler; let type: 'button' | 'submit'; @@ -137,6 +139,7 @@ export const Button = React.forwardRef( const buttonElement = (