diff --git a/CHANGELOG.md b/CHANGELOG.md index a716d95f1b..ee716339cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - Prefer light theme for the help and webxdc loading pages - Helper method to easily use confirmation dialogs #3601 - Refactor using new `useConfirmationDialog` hook #3602 +- Add multi selecting messages for forwarding and deleting #3602 diff --git a/scss/manifest.scss b/scss/manifest.scss index 3620754b73..d65111c5cf 100644 --- a/scss/manifest.scss +++ b/scss/manifest.scss @@ -64,6 +64,7 @@ @import 'message/_status-icon'; @import 'message/_message-calls'; @import 'message/_message-markdown'; +@import 'message/_selected-messages-action.scss'; // misc @import 'misc/_avatar.scss'; diff --git a/scss/message/_selected-messages-action.scss b/scss/message/_selected-messages-action.scss new file mode 100644 index 0000000000..3a42f5b5d9 --- /dev/null +++ b/scss/message/_selected-messages-action.scss @@ -0,0 +1,16 @@ +.selected-messages-action { + display: flex; + flex-direction: row; + background-color: var(--navBarBackground); + justify-content: flex-start; + align-items: center; + & > p { + width: 100%; + text-align: right; + margin: 8px; + } + & > button { + margin: 4px; + margin-bottom: 6px !important; + } +} diff --git a/scss/misc/_delta-buttons.scss b/scss/misc/_delta-buttons.scss index 5206ab933b..d5f7f299d7 100644 --- a/scss/misc/_delta-buttons.scss +++ b/scss/misc/_delta-buttons.scss @@ -16,7 +16,7 @@ button.delta-button-round { border-color: rgba(206, 217, 224, 0.5); background-image: none; cursor: not-allowed; - color: rgba(0, 0, 0, 0.2); + color: var(--globalText); &:hover { background-color: rgba(206, 217, 224, 0.5); } @@ -35,6 +35,56 @@ button.delta-button-round { } } +button.delta-button { + color: var(--colorNone); + background-color: rgba(0, 0, 0, 0); + padding: 0 2px; + margin-bottom: 0px; + letter-spacing: 0px; + font-size: initial; + font-weight: 'initial'; + text-align: center; + text-transform: uppercase; + padding: 5px; + border-style: solid; + border-color: transparent; + border-radius: 2px; + &:hover { + cursor: pointer; + background-color: #f1f1f1; + } + + &.no-padding { + padding: 0px; + } + + &.bold { + letter-spacing: 2px; + font-weight: bold; + } + + &.light-bold { + letter-spacing: 1px; + font-weight: 100; + } + + &.primary { + color: var(--colorPrimary); + } + + &.danger { + color: var(--colorDanger); + } + + &:disabled { + color: grey; + cursor: default; + &:hover { + cursor: default; + background-color: none; + } + } +} button.delta-button-tab { padding: 8px; margin: 5px; diff --git a/src/renderer/components/attachment/messageAttachment.tsx b/src/renderer/components/attachment/messageAttachment.tsx index 3802c94236..700a96cb07 100644 --- a/src/renderer/components/attachment/messageAttachment.tsx +++ b/src/renderer/components/attachment/messageAttachment.tsx @@ -27,6 +27,7 @@ type AttachmentProps = { conversationType: ConversationType message: Type.Message hasQuote: boolean + isSelectMode: boolean } export default function Attachment({ @@ -34,6 +35,7 @@ export default function Attachment({ conversationType, message, hasQuote, + isSelectMode, }: AttachmentProps) { const tx = useTranslationFunction() const { openDialog } = useDialog() @@ -42,6 +44,7 @@ export default function Attachment({ } const direction = getDirection(message) const onClickAttachment = (ev: any) => { + if (isSelectMode) return if (message.viewType === 'Sticker') return ev.stopPropagation() if (isDisplayableByFullscreenMedia(message.fileMime)) { diff --git a/src/renderer/components/composer/EmojiAndStickerPicker.tsx b/src/renderer/components/composer/EmojiAndStickerPicker.tsx index c3caac06da..d9f163ef34 100644 --- a/src/renderer/components/composer/EmojiAndStickerPicker.tsx +++ b/src/renderer/components/composer/EmojiAndStickerPicker.tsx @@ -16,6 +16,7 @@ import { useThemeCssVar } from '../../ThemeManager' import type { EmojiData } from 'emoji-mart/index' import useTranslationFunction from '../../hooks/useTranslationFunction' +import Button from '../ui/Button' const DisplayedStickerPack = ({ stickerPackName, @@ -89,24 +90,18 @@ export const StickerPicker = ({ ))}
- +
) : (

{tx('add_stickers_instructions')}

- +
)} diff --git a/src/renderer/components/dialogs/About.tsx b/src/renderer/components/dialogs/About.tsx index 14033d58fc..3c30ee5972 100644 --- a/src/renderer/components/dialogs/About.tsx +++ b/src/renderer/components/dialogs/About.tsx @@ -11,6 +11,7 @@ import { DialogBody, DialogContent, DialogWithHeader } from '../Dialog' import useTranslationFunction from '../../hooks/useTranslationFunction' import type { DialogProps } from '../../contexts/DialogContext' +import Button from '../ui/Button' const log = getLogger('renderer/dialogs/About') @@ -56,9 +57,9 @@ export function DCInfo(_props: any) {
- +
) diff --git a/src/renderer/components/dialogs/ConfirmSendingFiles.tsx b/src/renderer/components/dialogs/ConfirmSendingFiles.tsx index 99cc955d83..56d184303c 100644 --- a/src/renderer/components/dialogs/ConfirmSendingFiles.tsx +++ b/src/renderer/components/dialogs/ConfirmSendingFiles.tsx @@ -8,6 +8,7 @@ import { } from './DeltaDialog' import type { DialogProps } from '../../contexts/DialogContext' +import Button from '../ui/Button' type Props = { onClick: (isConfirmed: boolean) => void @@ -56,12 +57,12 @@ export default function ConfirmSendingFiles({ -

+ + diff --git a/src/renderer/components/dialogs/DeltaDialog.tsx b/src/renderer/components/dialogs/DeltaDialog.tsx index f2f91c5a62..e3d730245e 100644 --- a/src/renderer/components/dialogs/DeltaDialog.tsx +++ b/src/renderer/components/dialogs/DeltaDialog.tsx @@ -3,6 +3,7 @@ import { Dialog, Classes, RadioGroup, Radio } from '@blueprintjs/core' import classNames from 'classnames' import type { DialogProps } from '../../contexts/DialogContext' +import Button from '../ui/Button' export const DeltaDialogBase = React.memo< React.PropsWithChildren< @@ -311,18 +312,18 @@ export function SmallSelectDialog({ -

{ onCancel && onCancel() onClose() }} > {tx('cancel')} -

-

+ + @@ -368,25 +369,19 @@ export function DeltaDialogOkCancelFooter({ return ( -

- {cancelLabel} -

-

+ + + ) @@ -398,9 +393,9 @@ export function DeltaDialogCloseFooter({ onClose }: { onClose: () => any }) { return ( -

+ ) diff --git a/src/renderer/components/dialogs/EditProfileDialog/ProfileImageSelector.tsx b/src/renderer/components/dialogs/EditProfileDialog/ProfileImageSelector.tsx index 6bafc1f51b..1f38887043 100644 --- a/src/renderer/components/dialogs/EditProfileDialog/ProfileImageSelector.tsx +++ b/src/renderer/components/dialogs/EditProfileDialog/ProfileImageSelector.tsx @@ -3,6 +3,7 @@ import React from 'react' import { runtime } from '../../../runtime' import { avatarInitial } from '../../Avatar' import useTranslationFunction from '../../../hooks/useTranslationFunction' +import Button from '../../ui/Button' export default function ProfileImageSelector({ displayName, @@ -48,21 +49,21 @@ export default function ProfileImageSelector({ {initial} )} <> - - + ) diff --git a/src/renderer/components/dialogs/EditVideochatInstanceDialog.tsx b/src/renderer/components/dialogs/EditVideochatInstanceDialog.tsx index 89f77a3e78..0a341a5e8b 100644 --- a/src/renderer/components/dialogs/EditVideochatInstanceDialog.tsx +++ b/src/renderer/components/dialogs/EditVideochatInstanceDialog.tsx @@ -2,8 +2,8 @@ import React, { useState } from 'react' import { DeltaInput } from '../Login-Styles' import { SettingsStoreState } from '../../stores/settings' -import RadioGroup from '../RadioGroup' -import Radio from '../Radio' +import RadioGroup from '../ui/RadioGroup' +import Radio from '../ui/Radio' import { VIDEO_CHAT_INSTANCE_AUTISTICI, VIDEO_CHAT_INSTANCE_SYSTEMLI, diff --git a/src/renderer/components/dialogs/ForwardMessage/index.tsx b/src/renderer/components/dialogs/ForwardMessage/index.tsx index 0b3cea9920..3d07e8b07a 100644 --- a/src/renderer/components/dialogs/ForwardMessage/index.tsx +++ b/src/renderer/components/dialogs/ForwardMessage/index.tsx @@ -22,14 +22,15 @@ import styles from './styles.module.scss' const LIST_FLAGS = C.DC_GCL_FOR_FORWARDING | C.DC_GCL_NO_SPECIALS export default function ForwardMessage(props: { - message: T.Message + message: T.Message | number[] onClose: DialogProps['onClose'] + onForward?: () => void }) { const accountId = selectedAccountId() const tx = useTranslationFunction() const { openDialog } = useDialog() - const { message, onClose } = props + const { message, onClose, onForward } = props const { chatListIds, queryStr, setQueryStr } = useChatList(LIST_FLAGS) const { isChatLoaded, loadChats, chatCache } = useLogicVirtualChatList( chatListIds, @@ -38,6 +39,7 @@ export default function ForwardMessage(props: { const onChatClick = async (chatId: number) => { const chat = await BackendRemote.rpc.getFullChatById(accountId, chatId) + const isMany = Array.isArray(message) onClose() if (!chat.isSelfTalk) { selectChat(chat.id) @@ -48,10 +50,30 @@ export default function ForwardMessage(props: { chat ) if (!yes) { - selectChat(message.chatId) + if (isMany) { + const message_ = await BackendRemote.rpc.getMessage( + accountId, + message[0] + ) + selectChat(message_.chatId) + } else { + selectChat(message.chatId) + } } } else { - await forwardMessage(accountId, message.id, chat.id) + if (isMany) { + const messageObjects: T.Message[] = await Promise.all( + message.map(id => BackendRemote.rpc.getMessage(accountId, id)) + ) + messageObjects.sort((msgA, msgB) => msgA.timestamp - msgB.timestamp) + const messageIds = messageObjects.map(msg => msg.id) + for (const messageId of messageIds) { + await forwardMessage(accountId, messageId, chat.id) + } + } else { + await forwardMessage(accountId, message.id, chat.id) + } + onForward && onForward() } } const onSearchChange = (e: React.ChangeEvent) => diff --git a/src/renderer/components/dialogs/ViewGroup.tsx b/src/renderer/components/dialogs/ViewGroup.tsx index e3187c7597..0fdb652501 100644 --- a/src/renderer/components/dialogs/ViewGroup.tsx +++ b/src/renderer/components/dialogs/ViewGroup.tsx @@ -37,6 +37,7 @@ import useTranslationFunction from '../../hooks/useTranslationFunction' import useConfirmationDialog from '../../hooks/useConfirmationDialog' import type { DialogProps } from '../../contexts/DialogContext' +import Button from '../ui/Button' const log = getLogger('renderer/ViewGroup') @@ -528,21 +529,21 @@ export function GroupImageSelector({ {initial} )} <> - - + ) diff --git a/src/renderer/components/dialogs/ViewProfile/index.tsx b/src/renderer/components/dialogs/ViewProfile/index.tsx index 7e7d572d7c..b88b2d921e 100644 --- a/src/renderer/components/dialogs/ViewProfile/index.tsx +++ b/src/renderer/components/dialogs/ViewProfile/index.tsx @@ -28,6 +28,7 @@ import { MessagesDisplayContext } from '../../../contexts/MessagesDisplayContext import styles from './styles.module.scss' import type { DialogProps } from '../../../contexts/DialogContext' +import Button from '../../ui/Button' const log = getLogger('renderer/dialogs/ViewProfile') @@ -275,14 +276,15 @@ export function ViewProfileInner({ }} > {!isDeviceChat && ( - + + + )} {statusText != '' && ( diff --git a/src/renderer/components/dialogs/ForwardMessage.tsx b/src/renderer/components/dialogs/_ForwardMessage.tsx similarity index 84% rename from src/renderer/components/dialogs/ForwardMessage.tsx rename to src/renderer/components/dialogs/_ForwardMessage.tsx index b8cf058469..384dee8ee5 100644 --- a/src/renderer/components/dialogs/ForwardMessage.tsx +++ b/src/renderer/components/dialogs/_ForwardMessage.tsx @@ -19,15 +19,21 @@ import useDialog from '../../hooks/useDialog' import type { DialogProps } from '../../contexts/DialogContext' -export default function ForwardMessage(props: { - message: T.Message +type ForwardMessageProps = { + messages: T.Message | number[] onClose: DialogProps['onClose'] -}) { + onForward?: () => void +} + +export default function ForwardMessage({ + messages, + onClose, + onForward, +}: ForwardMessageProps) { const accountId = selectedAccountId() const tx = useTranslationFunction() const { openDialog } = useDialog() - const { message, onClose } = props const listFlags = C.DC_GCL_FOR_FORWARDING | C.DC_GCL_NO_SPECIALS const { chatListIds, queryStr, setQueryStr } = useChatList(listFlags) const { isChatLoaded, loadChats, chatCache } = useLogicVirtualChatList( @@ -37,20 +43,36 @@ export default function ForwardMessage(props: { const onChatClick = async (chatId: number) => { const chat = await BackendRemote.rpc.getFullChatById(accountId, chatId) + const isMany = Array.isArray(messages) onClose() if (!chat.isSelfTalk) { selectChat(chat.id) const yes = await confirmForwardMessage( openDialog, accountId, - message, + messages, chat ) if (!yes) { - selectChat(message.chatId) + if (isMany) { + const message_ = await BackendRemote.rpc.getMessage( + accountId, + messages[0] + ) + selectChat(message_.chatId) + } else { + selectChat(messages.chatId) + } } } else { - await forwardMessage(accountId, message.id, chat.id) + if (isMany) { + for (const messageId of messages) { + await forwardMessage(accountId, messageId, chat.id) + } + } else { + await forwardMessage(accountId, messages.id, chat.id) + } + onForward && onForward() } } const onSearchChange = (e: React.ChangeEvent) => diff --git a/src/renderer/components/message/Message.tsx b/src/renderer/components/message/Message.tsx index a91ac308c6..3488be660e 100644 --- a/src/renderer/components/message/Message.tsx +++ b/src/renderer/components/message/Message.tsx @@ -1,4 +1,4 @@ -import React, { useContext } from 'react' +import React, { useContext, useMemo } from 'react' import reactStringReplace from 'react-string-replace' import classNames from 'classnames' import { C, T } from '@deltachat/jsonrpc-client' @@ -41,6 +41,9 @@ import { ScreenContext } from '../../contexts/ScreenContext' import useDialog from '../../hooks/useDialog' import EnterAutocryptSetupMessage from '../dialogs/EnterAutocryptSetupMessage' import { OpenDialog } from '../../contexts/DialogContext' +import SelectModeOverlay from './SelectModeOverlay' +import Button from '../ui/Button' +import useSelectedMessages from '../../hooks/useSelectedMessages' const Avatar = ( contact: Type.Contact, @@ -137,12 +140,14 @@ function buildContextMenu( conversationType, openDialog, chat, + selectMessage, }: { message: Type.Message | null text?: string conversationType: ConversationType openDialog: OpenDialog chat: T.FullChat + selectMessage: () => void }, clickTarget: HTMLAnchorElement | null ): (false | ContextMenuItem)[] { @@ -282,15 +287,22 @@ function buildContextMenu( label: tx('delete_message_desktop'), action: confirmDeleteMessage.bind(null, openDialog, message), }, + // Select + { + label: tx('select'), + action: selectMessage, + }, ] } -export default function Message(props: { +type MessageProps = { message: Type.Message conversationType: ConversationType -}) { - const { message, conversationType } = props - const { id, viewType, text, hasLocation, isSetupmessage, hasHtml } = message +} + +export default function Message({ message, conversationType }: MessageProps) { + const { id, viewType, text, hasLocation, isSetupmessage, hasHtml, chatId } = + message const direction = getDirection(message) const status = mapCoreMsgStatus2String(message.state) const tx = useTranslationFunction() @@ -299,11 +311,17 @@ export default function Message(props: { const screenContext = useContext(ScreenContext) const { openDialog } = useDialog() const { openContextMenu } = screenContext + const { selectedMessages, selectMessage } = useSelectedMessages(chatId) + const isSelectMode = useMemo( + () => selectedMessages.length !== 0, + [selectedMessages] + ) const showMenu: ( event: React.MouseEvent ) => Promise = async event => { event.preventDefault() // prevent default runtime context menu from opening + if (isSelectMode) return const chat = await BackendRemote.rpc.getFullChatById( accountId, @@ -319,6 +337,7 @@ export default function Message(props: { conversationType, openDialog, chat, + selectMessage: selectMessage.bind(null, message.id), }, target ) @@ -494,6 +513,9 @@ export default function Message(props: { )} id={message.id.toString()} > + {isSelectMode && ( + + )} {showAuthor && direction === 'incoming' && Avatar(message.sender, onContactClick)} @@ -538,6 +560,7 @@ export default function Message(props: { conversationType={conversationType} message={message} hasQuote={message.quote !== null} + isSelectMode={isSelectMode} /> )} {message.viewType === 'Webxdc' && ( @@ -684,12 +707,9 @@ function WebxdcMessageContent({ message }: { message: Type.Message }) { (only works in saved messages) )} - + ) } diff --git a/src/renderer/components/message/MessageList.tsx b/src/renderer/components/message/MessageList.tsx index 9f99b570dd..c3b53fe961 100644 --- a/src/renderer/components/message/MessageList.tsx +++ b/src/renderer/components/message/MessageList.tsx @@ -92,13 +92,15 @@ function useUnreadCount( return freshMessageCounter } +type MessageListProps = { + chatStore: ChatStoreStateWithChatSet + refComposer: todo +} + export default function MessageList({ chatStore, refComposer, -}: { - chatStore: ChatStoreStateWithChatSet - refComposer: todo -}) { +}: MessageListProps) { const accountId = selectedAccountId() const { store: { diff --git a/src/renderer/components/message/MessageListAndComposer.tsx b/src/renderer/components/message/MessageListAndComposer.tsx index 75f25cef82..4662fe1f70 100644 --- a/src/renderer/components/message/MessageListAndComposer.tsx +++ b/src/renderer/components/message/MessageListAndComposer.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useEffect, useCallback } from 'react' +import React, { useRef, useEffect, useCallback, useReducer } from 'react' import { join, parse } from 'path' import { Viewtype } from '@deltachat/jsonrpc-client/dist/generated/types' @@ -15,6 +15,7 @@ import { sendMessage } from '../helpers/ChatMethods' import useDialog from '../../hooks/useDialog' import ConfirmSendingFiles from '../dialogs/ConfirmSendingFiles' import useIsChatDisabled from '../composer/useIsChatDisabled' +import SelectedMessagesAction from './SelectedMessagesAction' const log = getLogger('renderer/MessageListAndComposer') @@ -230,6 +231,7 @@ export default function MessageListAndComposer({ onDrop={onDrop.bind({ props: { chat: chatStore } })} onDragOver={onDragOver} > +

diff --git a/src/renderer/components/message/MessageWrapper.tsx b/src/renderer/components/message/MessageWrapper.tsx index ef2dd43574..86d66d98b6 100644 --- a/src/renderer/components/message/MessageWrapper.tsx +++ b/src/renderer/components/message/MessageWrapper.tsx @@ -16,6 +16,7 @@ type RenderMessageProps = { export function MessageWrapper(props: RenderMessageProps) { const state = props.message.state + const { key2, unreadMessageInViewIntersectionObserver } = props const shouldInViewObserve = state === C.DC_STATE_IN_FRESH || state === C.DC_STATE_IN_NOTICED @@ -25,44 +26,40 @@ export function MessageWrapper(props: RenderMessageProps) { if (!shouldInViewObserve) return log.debug( - `MessageWrapper: key: ${props.key2} We should observe this message if in view` + `MessageWrapper: key: ${key2} We should observe this message if in view` ) - const messageBottomElement = document.querySelector('#bottom-' + props.key2) + const messageBottomElement = document.querySelector('#bottom-' + key2) if (!messageBottomElement) { log.error( - `MessageWrapper: key: ${props.key2} couldn't find dom element. Returning` + `MessageWrapper: key: ${key2} couldn't find dom element. Returning` ) return } if ( - !props.unreadMessageInViewIntersectionObserver.current || - !props.unreadMessageInViewIntersectionObserver.current.observe + !unreadMessageInViewIntersectionObserver.current || + !unreadMessageInViewIntersectionObserver.current.observe ) { log.error( - `MessageWrapper: key: ${props.key2} unreadMessageInViewIntersectionObserver is null. Returning` + `MessageWrapper: key: ${key2} unreadMessageInViewIntersectionObserver is null. Returning` ) return } - props.unreadMessageInViewIntersectionObserver.current.observe( + unreadMessageInViewIntersectionObserver.current.observe( messageBottomElement ) - log.debug(`MessageWrapper: key: ${props.key2} Successfully observing ;)`) + log.debug(`MessageWrapper: key: ${key2} Successfully observing ;)`) return () => - props.unreadMessageInViewIntersectionObserver.current?.unobserve( + unreadMessageInViewIntersectionObserver.current?.unobserve( messageBottomElement ) - }, [ - props.key2, - props.unreadMessageInViewIntersectionObserver, - shouldInViewObserve, - ]) + }, [key2, unreadMessageInViewIntersectionObserver, shouldInViewObserve]) return ( -
  • +
  • -
    +
  • ) } diff --git a/src/renderer/components/message/SelectModeOverlay/SelectModeOverlay.scss b/src/renderer/components/message/SelectModeOverlay/SelectModeOverlay.scss new file mode 100644 index 0000000000..26c05acb25 --- /dev/null +++ b/src/renderer/components/message/SelectModeOverlay/SelectModeOverlay.scss @@ -0,0 +1,12 @@ +.select-mode-overlay { + z-index: 1; + width: 100%; + height: 100%; + position: absolute; + border-radius: 16px; + background-color: transparent; + cursor: grab; + &.selected { + background-color: var(--messageHightlightColor); + } +} diff --git a/src/renderer/components/message/SelectModeOverlay/SelectModeOverlay.tsx b/src/renderer/components/message/SelectModeOverlay/SelectModeOverlay.tsx new file mode 100644 index 0000000000..4516d551c0 --- /dev/null +++ b/src/renderer/components/message/SelectModeOverlay/SelectModeOverlay.tsx @@ -0,0 +1,33 @@ +import React, { useMemo } from 'react' +import classNames from 'classnames' +import useSelectedMessages from '../../hooks/useSelectedMessages' +import { MessageId, ChatId } from '../../contexts/SelectedMessagesContext' +import './SelectModeOverlay.scss' + +type SelectModeOverlayProps = { + messageId: MessageId + chatId: ChatId +} + +export default function SelectModeOverlay({ + messageId, + chatId, +}: SelectModeOverlayProps) { + const { selectedMessages, selectMessage, unselectMessage } = + useSelectedMessages(chatId) + const isSelected = useMemo( + () => selectedMessages.includes(messageId), + [selectedMessages] + ) + + return ( +
    + ) +} diff --git a/src/renderer/components/message/SelectModeOverlay/index.ts b/src/renderer/components/message/SelectModeOverlay/index.ts new file mode 100644 index 0000000000..ea191cf072 --- /dev/null +++ b/src/renderer/components/message/SelectModeOverlay/index.ts @@ -0,0 +1,3 @@ +import SelectModeOverlay from './SelectModeOverlay' + +export default SelectModeOverlay diff --git a/src/renderer/components/message/SelectedMessagesAction.tsx b/src/renderer/components/message/SelectedMessagesAction.tsx new file mode 100644 index 0000000000..f209bc4693 --- /dev/null +++ b/src/renderer/components/message/SelectedMessagesAction.tsx @@ -0,0 +1,43 @@ +import React from 'react' + +import useTranslationFunction from '../../hooks/useTranslationFunction' +import { openForwardDialog, confirmDeleteMessage } from './messageFunctions' +import useDialog from '../../hooks/useDialog' +import useSelectedMessages from '../../hooks/useSelectedMessages' +import { ChatId } from '../../contexts/SelectedMessagesContext' + +export default function SelectedMessagesAction({ chatId }: { chatId: ChatId }) { + const tx = useTranslationFunction() + const { openDialog } = useDialog() + const { selectedMessages, resetSelected } = useSelectedMessages(chatId) + if (selectedMessages.length === 0) return null + + return ( +
    + + + +

    + {tx('n_selected', [selectedMessages.length.toLocaleString()], { + quantity: selectedMessages.length, + })} +

    +
    + ) +} diff --git a/src/renderer/components/message/messageFunctions.ts b/src/renderer/components/message/messageFunctions.ts index 095ffb62c7..b38cf4745c 100644 --- a/src/renderer/components/message/messageFunctions.ts +++ b/src/renderer/components/message/messageFunctions.ts @@ -47,9 +47,10 @@ export function openAttachmentInShell(msg: Type.Message) { export function openForwardDialog( openDialog: OpenDialog, - message: Type.Message + message: Type.Message | number[], + onForward?: () => void ) { - openDialog(ForwardMessage, { message }) + openDialog(ForwardMessage, { message, onForward }) } export function confirmDialog( @@ -73,7 +74,7 @@ export function confirmDialog( export async function confirmForwardMessage( openDialog: OpenDialog, accountId: number, - message: Type.Message, + messages: Type.Message | number[], chat: Type.FullChat ) { const tx = window.static_translate @@ -82,22 +83,41 @@ export async function confirmForwardMessage( tx('ask_forward', [chat.name]), tx('forward') ) + const isMany = Array.isArray(messages) if (yes) { - await forwardMessage(accountId, message.id, chat.id) + if (isMany) { + for (const messageId of messages) + await forwardMessage(accountId, messageId, chat.id) + } else { + await forwardMessage(accountId, messages.id, chat.id) + } } return yes } export function confirmDeleteMessage( openDialog: OpenDialog, - msg: Type.Message + messages: Type.Message | number[], + onDelete?: () => void ) { const tx = window.static_translate openDialog(ConfirmationDialog, { message: tx('ask_delete_message'), confirmLabel: tx('delete'), - cb: (yes: boolean) => yes && deleteMessage(msg.id), + cb: (yes: boolean) => { + if (yes) { + const isMany = Array.isArray(messages) + if (isMany) { + for (const messageId of messages) { + deleteMessage(messageId) + } + } else { + deleteMessage(messages.id) + } + onDelete && onDelete() + } + }, }) } diff --git a/src/renderer/components/screens/WelcomeScreen.tsx b/src/renderer/components/screens/WelcomeScreen.tsx index c660880e7c..52cbd2dcb7 100644 --- a/src/renderer/components/screens/WelcomeScreen.tsx +++ b/src/renderer/components/screens/WelcomeScreen.tsx @@ -19,6 +19,7 @@ import useTranslationFunction from '../../hooks/useTranslationFunction' import useDialog from '../../hooks/useDialog' import ImportQrCode from '../dialogs/ImportQrCode' import AlertDialog from '../dialogs/AlertDialog' +import Button from '../ui/Button' const log = getLogger('renderer/components/AccountsScreen') @@ -105,12 +106,9 @@ const ImportButton = function ImportButton() { } return ( - + ) } @@ -190,25 +188,19 @@ export default function WelcomeScreen({

    {tx('welcome_chat_over_email')}

    - - + - + +
    diff --git a/src/renderer/components/ui/Button.tsx b/src/renderer/components/ui/Button.tsx new file mode 100644 index 0000000000..393886cafc --- /dev/null +++ b/src/renderer/components/ui/Button.tsx @@ -0,0 +1,41 @@ +import React from 'react' +import classNames from 'classnames' + +type ButtonProps = React.PropsWithChildren<{ + type?: 'secondary' | 'primary' | 'danger' + onClick: any + round?: boolean + id?: string + 'aria-label'?: string + disabled?: boolean + className?: any +}> + +export default function Button({ + children, + className, + disabled, + type, + round, + onClick, + id, + ...props +}: ButtonProps) { + return ( + + ) +} diff --git a/src/renderer/components/Radio.tsx b/src/renderer/components/ui/Radio.tsx similarity index 100% rename from src/renderer/components/Radio.tsx rename to src/renderer/components/ui/Radio.tsx diff --git a/src/renderer/components/RadioGroup.tsx b/src/renderer/components/ui/RadioGroup.tsx similarity index 100% rename from src/renderer/components/RadioGroup.tsx rename to src/renderer/components/ui/RadioGroup.tsx diff --git a/src/renderer/contexts/SelectedMessagesContext.tsx b/src/renderer/contexts/SelectedMessagesContext.tsx new file mode 100644 index 0000000000..9423c14c48 --- /dev/null +++ b/src/renderer/contexts/SelectedMessagesContext.tsx @@ -0,0 +1,15 @@ +import { createContext } from 'react' + +export type MessageId = number +export type ChatId = number + +export type SelectedMessagesValue = { + selectedMessages: Record + selectMessage: (chatId: ChatId, msgId: MessageId) => void + unselectMessage: (chatId: ChatId, msgId: MessageId) => void + resetSelected: (chatId: ChatId) => void +} + +const SelectedMessagesContext = createContext(null) + +export default SelectedMessagesContext diff --git a/src/renderer/hooks/useSelectedMessages.tsx b/src/renderer/hooks/useSelectedMessages.tsx new file mode 100644 index 0000000000..a55b193329 --- /dev/null +++ b/src/renderer/hooks/useSelectedMessages.tsx @@ -0,0 +1,74 @@ +import { useReducer, useCallback } from 'react' +import { + MessageId, + ChatId, +} from '../contexts/SelectedMessagesContext' + +export type SelectedMessagesPerChatValue = { + selectedMessages: MessageId[] + selectMessage: (msgId: MessageId) => void + unselectMessage: (msgId: MessageId) => void + resetSelected: () => void +} + + +export default function useSelectedMessages( + chatId: MessageId +): SelectedMessagesPerChatValue { + type MessageAction = + | { + type_: 'select' | 'unselect' + messageId: MessageId + } + | { type_: 'reset'; messageId?: never } + const [selectedMessagesValue, _dispatch] = useReducer( + ( + selectedMessagesPerChat: Record, + action: MessageAction + ) => { + const selectedMessages = selectedMessagesPerChat[chatId] || [] + switch (action.type_) { + case 'select': + selectedMessagesPerChat[chatId] = [ + ...selectedMessages, + action.messageId, + ] + break + case 'unselect': + selectedMessagesPerChat[chatId] = selectedMessages.filter( + id => id !== action.messageId + ) + break + case 'reset': + selectedMessagesPerChat[chatId] = [] + break + } + return selectedMessagesPerChat + }, + {} + ) + + const selectMessage = useCallback<(id: MessageId) => void>( + (id: MessageId) => _dispatch({ messageId: id, type_: 'select' }), + [_dispatch, chatId] + ) + + const unselectMessage = useCallback<(id: MessageId) => void>( + (id: MessageId) => _dispatch({ messageId: id, type_: 'unselect' }), + [_dispatch, chatId] + ) + + const resetSelected = useCallback<() => void>( + () => _dispatch({ type_: 'reset' }), + [_dispatch, chatId] + ) + + const value: SelectedMessagesPerChatValue = { + selectedMessages: selectedMessagesValue[chatId] || [], + selectMessage, + unselectMessage, + resetSelected, + } + + return value +}