From 6cd66e042362116265b0ef7c6697fd23c9d757ab Mon Sep 17 00:00:00 2001 From: Ryan Miller Date: Mon, 23 Feb 2026 15:49:09 +1100 Subject: [PATCH 01/21] fix: tab indexes and reply to failed messages --- ts/components/SessionSearchInput.tsx | 6 +++++- .../conversation/SessionEmojiReactBarPopover.tsx | 3 +++ ts/components/dialog/KeyboardShortcutsModal.tsx | 1 + .../dialog/UpdateConversationDetailsDialog.tsx | 2 +- ts/hooks/useMessageInteractions.ts | 12 +++++++++++- 5 files changed, 21 insertions(+), 3 deletions(-) diff --git a/ts/components/SessionSearchInput.tsx b/ts/components/SessionSearchInput.tsx index 092becf62..afc631471 100644 --- a/ts/components/SessionSearchInput.tsx +++ b/ts/components/SessionSearchInput.tsx @@ -133,7 +133,11 @@ export const SessionSearchInput = ({ searchType }: { searchType: SearchType }) = iconColor="var(--text-secondary-color)" iconSize={iconSize} unicode={LUCIDE_ICONS_UNICODE.X} - tabIndex={0} + // NOTE: we dont want the clear button in the tab index list + // as the Escape key already does this action for keyboard + // users and we want the next tab after the search input to + // be the first result + tabIndex={-1} onClick={() => { setCurrentSearchTerm(''); dispatch(searchActions.clearSearch()); diff --git a/ts/components/conversation/SessionEmojiReactBarPopover.tsx b/ts/components/conversation/SessionEmojiReactBarPopover.tsx index c76756aad..0ec097f44 100644 --- a/ts/components/conversation/SessionEmojiReactBarPopover.tsx +++ b/ts/components/conversation/SessionEmojiReactBarPopover.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import useClickAway from 'react-use/lib/useClickAway'; +import useKey from 'react-use/lib/useKey'; import { useTriggerPosition, type PopoverTriggerPosition } from '../SessionTooltip'; import { SessionPopoverContent } from '../SessionPopover'; import { MessageReactBar } from './message/message-content/MessageReactBar'; @@ -62,6 +63,8 @@ export function SessionEmojiReactBarPopover({ } }, [focusedMessageId, messageId, onClickAwayFromReactionBar]); + useKey('Escape', onClickAwayFromReactionBar); + return ( <> diff --git a/ts/components/dialog/UpdateConversationDetailsDialog.tsx b/ts/components/dialog/UpdateConversationDetailsDialog.tsx index c107df049..dd888a964 100644 --- a/ts/components/dialog/UpdateConversationDetailsDialog.tsx +++ b/ts/components/dialog/UpdateConversationDetailsDialog.tsx @@ -376,7 +376,7 @@ export function UpdateConversationDetailsDialog(props: WithConvoId) { errorDataTestId="error-message" providedError={errorStringDescription} autoFocus={false} - tabIndex={1} + tabIndex={0} required={false} singleLine={false} allowEscapeKeyPassthrough={true} diff --git a/ts/hooks/useMessageInteractions.ts b/ts/hooks/useMessageInteractions.ts index af0e75b34..4fbaf665b 100644 --- a/ts/hooks/useMessageInteractions.ts +++ b/ts/hooks/useMessageInteractions.ts @@ -13,6 +13,7 @@ import { Reactions } from '../util/reactions'; import { useMessageAttachments, useMessageBody, + useMessageDirection, useMessageSender, useMessageServerTimestamp, useMessageStatus, @@ -67,6 +68,12 @@ function useCopyText(messageId?: string) { function useReply(messageId?: string) { const isSelectedBlocked = useSelectedIsBlocked(); + const direction = useMessageDirection(messageId); + const status = useMessageStatus(messageId); + + const isOutgoing = direction === 'outgoing'; + const isSent = status === 'sent' || status === 'read'; // a read message should be replyable + return () => { if (!messageId) { return; @@ -75,7 +82,10 @@ function useReply(messageId?: string) { pushUnblockToSend(); return; } - void replyToMessage(messageId); + if (isSent || !isOutgoing) { + void replyToMessage(messageId); + } + // NOTE: we dont want to reply to failed to send messages }; } From 790928d416ec06bc69db3833a16c4d0b8f1e885a Mon Sep 17 00:00:00 2001 From: Ryan Miller Date: Tue, 24 Feb 2026 12:06:09 +1100 Subject: [PATCH 02/21] fix: add main screen focus state --- ts/state/focus.ts | 5 +++++ ts/util/keyboardShortcuts.ts | 27 ++++++++++++++++----------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/ts/state/focus.ts b/ts/state/focus.ts index d85181598..d90a97561 100644 --- a/ts/state/focus.ts +++ b/ts/state/focus.ts @@ -8,6 +8,7 @@ import { useTopModalId } from './selectors/modal'; export type FocusScope = | 'global' + | 'mainScreen' | 'conversationList' | 'message' | 'compositionBoxInput' @@ -43,6 +44,10 @@ export function useIsInScope({ scope, scopeId }: ScopeArgs) { return true; } + if (scope === 'mainScreen') { + return !modalId; + } + // if we've got a modal shown, and it is the one the scope is far, return `true` if (modalId && modalId === scope) { return true; diff --git a/ts/util/keyboardShortcuts.ts b/ts/util/keyboardShortcuts.ts index 56de193a2..48f5c9c2d 100644 --- a/ts/util/keyboardShortcuts.ts +++ b/ts/util/keyboardShortcuts.ts @@ -71,7 +71,7 @@ const baseConversationNavigation = { name: 'Navigate to Conversation', withCtrl: true, keys: ['1-9'], - scope: 'conversationList', + scope: 'mainScreen', } satisfies KbdShortcutOptions; type ConversationNavKeys = `conversationNavigation${1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9}`; @@ -95,38 +95,43 @@ export const KbdShortcut = { zoomIn: { name: tr('appearanceZoomIn'), scope: 'global', withCtrl: true, keys: ['+'] }, zoomOut: { name: tr('appearanceZoomOut'), scope: 'global', withCtrl: true, keys: ['-'] }, userSettingsModal: { name: 'User Settings', scope: 'global', withCtrl: true, keys: [','] }, - newConversation: { name: tr('conversationsNew'), scope: 'global', withCtrl: true, keys: ['n'] }, + newConversation: { + name: tr('conversationsNew'), + scope: 'mainScreen', + withCtrl: true, + keys: ['n'], + }, newMessage: { name: tr('messageNew', { count: 1 }), - scope: 'global', + scope: 'mainScreen', withCtrl: true, withShift: true, keys: ['m'], }, createGroup: { name: tr('groupCreate'), - scope: 'global', + scope: 'mainScreen', withCtrl: true, withShift: true, keys: ['g'], }, joinCommunity: { name: tr('communityJoin'), - scope: 'global', + scope: 'mainScreen', withCtrl: true, withShift: true, keys: ['c'], }, openNoteToSelf: { name: tr('noteToSelfOpen'), - scope: 'global', + scope: 'mainScreen', withCtrl: true, withShift: true, keys: ['n'], }, conversationListSearch: { name: tr('search'), - scope: 'conversationList', + scope: 'mainScreen', withCtrl: true, keys: ['f'], }, @@ -137,24 +142,24 @@ export const KbdShortcut = { // Conversation Shortcuts conversationFocusTextArea: { name: tr('focusTextArea'), - scope: 'conversationList', + scope: 'mainScreen', keys: ['Escape'], }, conversationUploadAttachment: { name: tr('attachmentsAdd'), - scope: 'conversationList', + scope: 'mainScreen', withCtrl: true, keys: ['u'], }, conversationToggleEmojiPicker: { name: tr('toggleEmojiPicker'), - scope: 'conversationList', + scope: 'mainScreen', withCtrl: true, keys: ['e'], }, conversationSettingsModal: { name: tr('conversationSettings'), - scope: 'global', + scope: 'mainScreen', withCtrl: true, keys: ['.'], }, From 0483e3b36d07eeae4e4e753f1c2d860ca22e50cc Mon Sep 17 00:00:00 2001 From: Ryan Miller Date: Tue, 24 Feb 2026 12:06:29 +1100 Subject: [PATCH 03/21] feat: add keyboard navigation to file attachments --- .../message/message-content/MessageAttachment.tsx | 4 ++-- .../message/message-content/MessageGenericAttachment.tsx | 7 +++++-- .../message/message-content/MessageHighlighter.tsx | 8 ++++++-- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/ts/components/conversation/message/message-content/MessageAttachment.tsx b/ts/components/conversation/message/message-content/MessageAttachment.tsx index 31705bc61..88b92fb6d 100644 --- a/ts/components/conversation/message/message-content/MessageAttachment.tsx +++ b/ts/components/conversation/message/message-content/MessageAttachment.tsx @@ -1,5 +1,5 @@ import { clone } from 'lodash'; -import { useCallback } from 'react'; +import { useCallback, type MouseEvent, type KeyboardEvent } from 'react'; import { useSelector } from 'react-redux'; import styled from 'styled-components'; import { getAppDispatch } from '../../../../state/dispatch'; @@ -82,7 +82,7 @@ export const MessageAttachment = (props: Props) => { ); const onClickOnGenericAttachment = useCallback( - (e: any) => { + (e: MouseEvent | KeyboardEvent) => { e.stopPropagation(); e.preventDefault(); if (!attachmentProps?.attachments?.length || attachmentProps?.attachments[0]?.pending) { diff --git a/ts/components/conversation/message/message-content/MessageGenericAttachment.tsx b/ts/components/conversation/message/message-content/MessageGenericAttachment.tsx index 73f526625..7d93469ab 100644 --- a/ts/components/conversation/message/message-content/MessageGenericAttachment.tsx +++ b/ts/components/conversation/message/message-content/MessageGenericAttachment.tsx @@ -1,6 +1,7 @@ import styled from 'styled-components'; import clsx from 'clsx'; +import type { MouseEvent, KeyboardEvent } from 'react'; import { PropsForAttachment } from '../../../../state/ducks/conversations'; import { AttachmentTypeWithPath } from '../../../../types/Attachment'; import { Spinner } from '../../../loading'; @@ -9,6 +10,7 @@ import { LucideIcon } from '../../../icon/LucideIcon'; import { LUCIDE_ICONS_UNICODE } from '../../../icon/lucide'; import { MessageHighlighter } from './MessageHighlighter'; import { getShortenedFilename } from './quote/QuoteText'; +import { createButtonOnKeyDownForClickEventHandler } from '../../../../util/keyboardShortcuts'; const StyledGenericAttachmentContainer = styled.div<{ selected: boolean; @@ -30,14 +32,15 @@ export function MessageGenericAttachment({ selected: boolean; highlight: boolean; direction?: MessageModelType; - onClick?: (e: any) => void; + onClick?: (e: MouseEvent | KeyboardEvent) => void; }) { const { fileName, fileSize } = attachment; const shortenedFilename = getShortenedFilename(fileName); + const onKeyDown = onClick ? createButtonOnKeyDownForClickEventHandler(onClick) : undefined; return ( - + ) => void; + onKeyDown?: (event: KeyboardEvent) => void; + tabIndex?: number; }) { - const { children, $highlight, onClick } = props; + const { children, $highlight, onClick, onKeyDown, tabIndex } = props; return ( Date: Tue, 24 Feb 2026 16:43:11 +1100 Subject: [PATCH 04/21] fix: emoji reaction list rendering and emoji reaction bar focus --- ts/components/SessionSearchInput.tsx | 6 +- .../conversation/SessionEmojiPanelPopover.tsx | 6 +- .../SessionEmojiReactBarPopover.tsx | 27 +-- .../conversation/SessionMessagesList.tsx | 5 +- .../MessageContentWithStatus.tsx | 55 +++--- .../message-content/MessageContextMenu.tsx | 72 ++++---- .../message-content/MessageReactBar.tsx | 52 ++++-- .../message-content/MessageReactions.tsx | 78 ++++---- .../message-item/GenericReadableMessage.tsx | 6 + .../message/reactions/Reaction.tsx | 6 +- ts/components/dialog/ReactListModal.tsx | 2 +- .../DeleteMessage/DeleteMessageMenuItem.tsx | 20 ++- ts/hooks/useMessageInteractions.ts | 170 +++++++++--------- 13 files changed, 272 insertions(+), 233 deletions(-) diff --git a/ts/components/SessionSearchInput.tsx b/ts/components/SessionSearchInput.tsx index afc631471..c345efe95 100644 --- a/ts/components/SessionSearchInput.tsx +++ b/ts/components/SessionSearchInput.tsx @@ -133,10 +133,10 @@ export const SessionSearchInput = ({ searchType }: { searchType: SearchType }) = iconColor="var(--text-secondary-color)" iconSize={iconSize} unicode={LUCIDE_ICONS_UNICODE.X} - // NOTE: we dont want the clear button in the tab index list - // as the Escape key already does this action for keyboard + // NOTE: we don't want this clear button in the tab index list + // as the Escape key already does the clear action for keyboard // users and we want the next tab after the search input to - // be the first result + // be the first search result tabIndex={-1} onClick={() => { setCurrentSearchTerm(''); diff --git a/ts/components/conversation/SessionEmojiPanelPopover.tsx b/ts/components/conversation/SessionEmojiPanelPopover.tsx index aee1a8066..700a96158 100644 --- a/ts/components/conversation/SessionEmojiPanelPopover.tsx +++ b/ts/components/conversation/SessionEmojiPanelPopover.tsx @@ -11,14 +11,14 @@ const EMOJI_PANEL_HEIGHT_PX = 435; export function SessionEmojiPanelPopover({ triggerPos, emojiPanelRef, - onEmojiClicked, + onEmojiClick, open, onClose, }: { triggerPos: PopoverTriggerPosition | null; open: boolean; emojiPanelRef: RefObject; - onEmojiClicked: (emoji: FixedBaseEmoji) => void; + onEmojiClick: (emoji: FixedBaseEmoji) => Promise; onClose: () => void; }) { const _open = open && !!triggerPos; @@ -36,7 +36,7 @@ export function SessionEmojiPanelPopover({ void onEmojiClick(emoji)} onClose={onClose} isModal={true} /> diff --git a/ts/components/conversation/SessionEmojiReactBarPopover.tsx b/ts/components/conversation/SessionEmojiReactBarPopover.tsx index 0ec097f44..f17c6f917 100644 --- a/ts/components/conversation/SessionEmojiReactBarPopover.tsx +++ b/ts/components/conversation/SessionEmojiReactBarPopover.tsx @@ -1,33 +1,34 @@ import { useEffect, useRef, useState } from 'react'; import useClickAway from 'react-use/lib/useClickAway'; -import useKey from 'react-use/lib/useKey'; import { useTriggerPosition, type PopoverTriggerPosition } from '../SessionTooltip'; import { SessionPopoverContent } from '../SessionPopover'; import { MessageReactBar } from './message/message-content/MessageReactBar'; import { THEME_GLOBALS } from '../../themes/globals'; import { SessionEmojiPanelPopover } from './SessionEmojiPanelPopover'; import { closeContextMenus } from '../../util/contextMenu'; -import { useMessageInteractions } from '../../hooks/useMessageInteractions'; import { useFocusedMessageId } from '../../state/selectors/conversations'; +import { useMessageReact } from '../../hooks/useMessageInteractions'; export function SessionEmojiReactBarPopover({ messageId, open, triggerPos, onClickAwayFromReactionBar, + autoFocusFirstEmoji, }: { messageId: string; // this can be null as we want the emoji panel to stay when the reaction bar closes triggerPos: PopoverTriggerPosition | null; open: boolean; onClickAwayFromReactionBar: () => void; + autoFocusFirstEmoji?: boolean; }) { const emojiPanelTriggerRef = useRef(null); const emojiPanelTriggerPos = useTriggerPosition(emojiPanelTriggerRef); const emojiPanelRef = useRef(null); const emojiReactionBarRef = useRef(null); const [showEmojiPanel, setShowEmojiPanel] = useState(false); - const { reactToMessage } = useMessageInteractions(messageId); + const reactToMessage = useMessageReact(messageId); const focusedMessageId = useFocusedMessageId(); const closeEmojiPanel = () => { @@ -42,7 +43,13 @@ export function SessionEmojiReactBarPopover({ const onEmojiClick = async (args: any) => { const emoji = args.native ?? args; closeEmojiPanel(); - await reactToMessage(emoji); + if (reactToMessage) { + await reactToMessage(emoji); + } else { + window.log.warn( + `[SessionEmojiReactBarPopover] reactToMessage undefined for message ${messageId}` + ); + } }; useClickAway(emojiPanelRef, () => { @@ -63,15 +70,12 @@ export function SessionEmojiReactBarPopover({ } }, [focusedMessageId, messageId, onClickAwayFromReactionBar]); - useKey('Escape', onClickAwayFromReactionBar); - return ( <> @@ -90,10 +94,11 @@ export function SessionEmojiReactBarPopover({ {open ? ( ) : null} diff --git a/ts/components/conversation/SessionMessagesList.tsx b/ts/components/conversation/SessionMessagesList.tsx index 1ac41785d..a57b2ac8d 100644 --- a/ts/components/conversation/SessionMessagesList.tsx +++ b/ts/components/conversation/SessionMessagesList.tsx @@ -25,7 +25,7 @@ import { InteractionNotification } from './message/message-item/InteractionNotif import type { WithMessageId } from '../../session/types/with'; import { useKeyboardShortcut } from '../../hooks/useKeyboardShortcut'; import { KbdShortcut } from '../../util/keyboardShortcuts'; -import { useMessageInteractions } from '../../hooks/useMessageInteractions'; +import { useMessageCopyText, useMessageReply } from '../../hooks/useMessageInteractions'; function isNotTextboxEvent(e: KeyboardEvent) { return (e?.target as any)?.type === undefined; @@ -61,7 +61,8 @@ export const SessionMessagesList = (props: { const oldTopMessageId = useSelector(getOldTopMessageId); const oldBottomMessageId = useSelector(getOldBottomMessageId); const focusedMessageId = useFocusedMessageId(); - const { reply, copyText } = useMessageInteractions(focusedMessageId); + const reply = useMessageReply(focusedMessageId ?? undefined); + const copyText = useMessageCopyText(focusedMessageId ?? undefined); useKeyboardShortcut({ shortcut: KbdShortcut.messageToggleReply, handler: reply, scopeId: 'all' }); useKeyboardShortcut({ shortcut: KbdShortcut.messageCopyText, handler: copyText, scopeId: 'all' }); diff --git a/ts/components/conversation/message/message-content/MessageContentWithStatus.tsx b/ts/components/conversation/message/message-content/MessageContentWithStatus.tsx index ea6f48265..6d925723b 100644 --- a/ts/components/conversation/message/message-content/MessageContentWithStatus.tsx +++ b/ts/components/conversation/message/message-content/MessageContentWithStatus.tsx @@ -17,13 +17,11 @@ import { MessageContent } from './MessageContent'; import { MessageContextMenu } from './MessageContextMenu'; import { MessageReactions } from './MessageReactions'; import { MessageStatus } from './MessageStatus'; -import { - useIsMessageSelectionMode, - useSelectedIsLegacyGroup, -} from '../../../../state/selectors/selectedConversation'; +import { useIsMessageSelectionMode } from '../../../../state/selectors/selectedConversation'; import { SessionEmojiReactBarPopover } from '../../SessionEmojiReactBarPopover'; import { PopoverTriggerPosition } from '../../../SessionTooltip'; -import { useMessageInteractions } from '../../../../hooks/useMessageInteractions'; +import { trimWhitespace } from '../../../../session/utils/String'; +import { useMessageReact, useMessageReply } from '../../../../hooks/useMessageInteractions'; export type MessageContentWithStatusSelectorProps = { isGroup: boolean } & Pick< MessageRenderingProps, @@ -37,6 +35,7 @@ type Props = { convoReactionsEnabled: boolean; triggerPosition: PopoverTriggerPosition | null; setTriggerPosition: Dispatch; + autoFocusReactionBarFirstEmoji?: boolean; }; const StyledMessageContentContainer = styled.div<{ $isIncoming: boolean; $isDetailView: boolean }>` @@ -65,51 +64,45 @@ export const MessageContentWithStatuses = (props: Props) => { convoReactionsEnabled, triggerPosition, setTriggerPosition, + autoFocusReactionBarFirstEmoji, } = props; const dispatch = getAppDispatch(); const contentProps = useSelector((state: StateType) => getMessageContentWithStatusesSelectorProps(state, messageId) ); - const { reactToMessage, reply } = useMessageInteractions(messageId); + const reply = useMessageReply(messageId); + const reactToMessage = useMessageReact(messageId); const hideAvatar = useHideAvatarInMsgList(messageId); const isDetailView = useIsDetailMessageView(); const multiSelectMode = useIsMessageSelectionMode(); - const isLegacyGroup = useSelectedIsLegacyGroup(); const status = useMessageStatus(props.messageId); const isSent = status === 'sent' || status === 'read'; // a read message should be reactable const onClickOnMessageOuterContainer = useCallback( (event: MouseEvent) => { - if (multiSelectMode && props?.messageId) { + if (multiSelectMode && messageId) { event.preventDefault(); event.stopPropagation(); - dispatch(toggleSelectedMessageId(props?.messageId)); + dispatch(toggleSelectedMessageId(messageId)); } }, - [dispatch, props?.messageId, multiSelectMode] + [dispatch, messageId, multiSelectMode] ); - const onDoubleClickReplyToMessage = (e: MouseEvent) => { - if (isLegacyGroup) { - return; - } - const currentSelection = window.getSelection(); - const currentSelectionString = currentSelection?.toString() || undefined; + const onDoubleClickReplyToMessage = reply + ? (e: MouseEvent) => { + const currentSelection = window.getSelection(); + const currentSelectionString = currentSelection?.toString() || undefined; - if ((e.target as any).localName !== 'em-emoji-picker') { - if ( - !currentSelectionString || - currentSelectionString.length === 0 || - !/\s/.test(currentSelectionString) - ) { - // if multiple word are selected, consider that this double click was actually NOT used to reply to - // but to select - void reply(); - currentSelection?.empty(); - e.preventDefault(); + if ( + (!currentSelectionString || trimWhitespace(currentSelectionString).length === 0) && + (e.target as any).localName !== 'em-emoji-picker' + ) { + e.preventDefault(); + void reply(); + } } - } - }; + : undefined; if (!contentProps) { return null; @@ -166,6 +159,7 @@ export const MessageContentWithStatuses = (props: Props) => { open={!!triggerPosition} triggerPos={triggerPosition} onClickAwayFromReactionBar={closeReactionBar} + autoFocusFirstEmoji={autoFocusReactionBarFirstEmoji} /> ) : null} {enableContextMenu ? ( @@ -179,8 +173,7 @@ export const MessageContentWithStatuses = (props: Props) => { {!isDetailView && enableReactions ? ( void reactToMessage(emoji) : undefined} onPopupClick={handlePopupClick} noAvatar={hideAvatar} /> diff --git a/ts/components/conversation/message/message-content/MessageContextMenu.tsx b/ts/components/conversation/message/message-content/MessageContextMenu.tsx index 1a4f4d540..11377a25d 100644 --- a/ts/components/conversation/message/message-content/MessageContextMenu.tsx +++ b/ts/components/conversation/message/message-content/MessageContextMenu.tsx @@ -10,14 +10,7 @@ import { Data } from '../../../../data/data'; import { MessageRenderingProps } from '../../../../models/messageType'; import { openRightPanel, showMessageInfoView } from '../../../../state/ducks/conversations'; -import { - useMessageAttachments, - useMessageDirection, - useMessageIsDeletable, - useMessageSender, - useMessageSenderIsAdmin, - useMessageStatus, -} from '../../../../state/selectors'; +import { useMessageSender, useMessageSenderIsAdmin } from '../../../../state/selectors'; import { useSelectedConversationKey, useSelectedIsLegacyGroup, @@ -39,7 +32,12 @@ import { showContextMenu } from '../../../../util/contextMenu'; import { clampNumber } from '../../../../util/maths'; import { PopoverTriggerPosition } from '../../../SessionTooltip'; import { LUCIDE_ICONS_UNICODE } from '../../../icon/lucide'; -import { useMessageInteractions } from '../../../../hooks/useMessageInteractions'; +import { + useMessageCopyText, + useMessageReply, + useMessageSaveAttachement, + useMessageSelect, +} from '../../../../hooks/useMessageInteractions'; export type MessageContextMenuSelectorProps = Pick< MessageRenderingProps, @@ -205,10 +203,9 @@ export const showMessageInfoOverlay = async ({ }; function SaveAttachmentMenuItem({ messageId }: { messageId: string }) { - const attachments = useMessageAttachments(messageId); - const { saveAttachment } = useMessageInteractions(messageId); + const saveAttachment = useMessageSaveAttachement(messageId); - return attachments?.length && attachments.every(m => !m.pending && m.path) ? ( + return saveAttachment ? ( {tr('copy')} - ); + ) : null; +} + +function MessageReplyMenuItem({ messageId }: { messageId: string }) { + const reply = useMessageReply(messageId); + + return reply ? ( + + {tr('reply')} + + ) : null; +} + +function MessageSelectMenuItem({ messageId }: { messageId: string }) { + const select = useMessageSelect(messageId); + + return select ? ( + + + + ) : null; } export const MessageContextMenu = (props: Props) => { const { messageId, contextMenuId, setTriggerPosition } = props; - const { reply, select } = useMessageInteractions(messageId); - const isLegacyGroup = useSelectedIsLegacyGroup(); const convoId = useSelectedConversationKey(); - const direction = useMessageDirection(messageId); - const status = useMessageStatus(messageId); - const isDeletable = useMessageIsDeletable(messageId); const sender = useMessageSender(messageId); - - const isOutgoing = direction === 'outgoing'; - const isSent = status === 'sent' || status === 'read'; // a read message should be replyable - const contextMenuRef = useRef(null); const onShow: MenuOnShowCallback = (_, { x, y }) => { @@ -315,22 +323,10 @@ export const MessageContextMenu = (props: Props) => { > - {(isSent || !isOutgoing) && ( - - {tr('reply')} - - )} + - {isDeletable ? ( - - - - ) : null} + diff --git a/ts/components/conversation/message/message-content/MessageReactBar.tsx b/ts/components/conversation/message/message-content/MessageReactBar.tsx index df610c0e2..d81708d06 100644 --- a/ts/components/conversation/message/message-content/MessageReactBar.tsx +++ b/ts/components/conversation/message/message-content/MessageReactBar.tsx @@ -1,16 +1,22 @@ import styled from 'styled-components'; -import { RefObject } from 'react'; +import { type RefObject, type KeyboardEvent, useRef } from 'react'; +import useMount from 'react-use/lib/useMount'; import { nativeEmojiData } from '../../../../util/emoji'; import { getRecentReactions } from '../../../../util/storage'; import { LUCIDE_ICONS_UNICODE } from '../../../icon/lucide'; import { SessionLucideIconButton } from '../../../icon/SessionIconButton'; -import { createButtonOnKeyDownForClickEventHandler } from '../../../../util/keyboardShortcuts'; +import { + createButtonOnKeyDownForClickEventHandler, + isEscapeKey, +} from '../../../../util/keyboardShortcuts'; type Props = { ref?: RefObject; - action: (...args: Array) => void; - additionalAction: (...args: Array) => void; emojiPanelTriggerRef: RefObject; + autoFocusFirstEmoji?: boolean; + onEmojiClick: (emoji: string) => Promise; + onPlusButtonClick: () => void; + closeReactionBar: () => void; }; const StyledMessageReactBar = styled.div` @@ -51,21 +57,43 @@ const StyledContainer = styled.div` left: -1px; `; -export const MessageReactBar = ({ ref, action, additionalAction, emojiPanelTriggerRef }: Props) => { +export const MessageReactBar = ({ + ref, + onEmojiClick, + onPlusButtonClick, + emojiPanelTriggerRef, + closeReactionBar, + autoFocusFirstEmoji, +}: Props) => { const recentReactions = getRecentReactions(); + const firstEmojiRef = useRef(null); + + useMount(() => { + // NOTE: this allows the fist emoji to be focused when the + // reaction bar appears if auto focus is enabled + if (autoFocusFirstEmoji) { + firstEmojiRef?.current?.focus(); + } + }); return ( - {recentReactions.map(emoji => { - const onClick = () => action(emoji); - const onKeyDown = createButtonOnKeyDownForClickEventHandler(onClick); - const ariaLabel = nativeEmojiData?.ariaLabels - ? nativeEmojiData.ariaLabels[emoji] - : undefined; + {recentReactions.map((emoji, i) => { + const onClick = () => void onEmojiClick(emoji); + const onKeyDownButtonClickHandler = createButtonOnKeyDownForClickEventHandler(onClick); + const onKeyDown = (e: KeyboardEvent) => { + if (isEscapeKey(e)) { + closeReactionBar(); + } else { + onKeyDownButtonClickHandler(e); + } + }; + const ariaLabel = nativeEmojiData?.ariaLabels?.[emoji]; return ( ` + gap: var(--margins-sm) 8px; + margin: 0 4px var(--margins-sm); ${props => (props.$fullWidth ? '' : 'max-width: 640px;')} `; @@ -30,8 +32,6 @@ const StyledReactionOverflow = styled.button` align-items: center; border: none; - margin-right: 4px; - margin-bottom: var(--margins-sm); span { background-color: var(--message-bubble-incoming-background-color); @@ -116,7 +116,7 @@ const ExpandedReactions = (props: ExpandReactionsProps) => { const onKeyDown = createButtonOnKeyDownForClickEventHandler(handleExpand); return ( - + void; - onPopupClick?: (emoji: string) => void; inModal?: boolean; - onSelected?: (emoji: string) => boolean; noAvatar: boolean; + onEmojiClick?: (emoji: string) => void; + onPopupClick?: (emoji: string) => void; + onSelected?: (emoji: string) => boolean; }; -export const MessageReactions = (props: Props) => { +export const MessageReactions = ({ + messageId, + hasReactLimit = true, + onPopupClick, + inModal = false, + onSelected, + noAvatar, + onEmojiClick, +}: Props) => { const isDetailView = useIsDetailMessageView(); - - const { - messageId, - hasReactLimit = true, - onClick, - onPopupClick, - inModal = false, - onSelected, - noAvatar, - } = props; - - const [isExpanded, setIsExpanded] = useState(false); - const handleExpand = () => { - setIsExpanded(!isExpanded); - }; - - const msgProps = useMessageReactsPropsById(messageId); - const inGroup = useSelectedIsGroupOrCommunity(); + const msgProps = useMessageReactsPropsById(messageId); + const [isExpanded, setIsExpanded] = useState(false); if (!msgProps) { return null; } - const reactionsProps = { - messageId, - reactions: msgProps.sortedReacts ?? [], - inModal, - inGroup, - onClick: !isDetailView ? onClick : undefined, - onSelected, - handlePopupClick: onPopupClick, + const reactions = msgProps.sortedReacts ?? []; + const onClick = !isDetailView && onEmojiClick ? onEmojiClick : undefined; + const handleExpand = () => { + setIsExpanded(expanded => !expanded); }; - const ExtendedReactions = isExpanded ? ExpandedReactions : CompressedReactions; + const ReactionsComp = + !hasReactLimit || reactions.length <= REACT_LIMIT + ? Reactions + : isExpanded + ? ExpandedReactions + : CompressedReactions; return ( { $alignItems={inModal ? 'flex-start' : 'center'} $noAvatar={noAvatar} > - {reactionsProps.reactions.length ? ( - !hasReactLimit || reactionsProps.reactions.length <= REACT_LIMIT ? ( - - ) : ( - - ) + {reactions.length ? ( + ) : null} ); diff --git a/ts/components/conversation/message/message-item/GenericReadableMessage.tsx b/ts/components/conversation/message/message-item/GenericReadableMessage.tsx index d8abc09d0..04b3059df 100644 --- a/ts/components/conversation/message/message-item/GenericReadableMessage.tsx +++ b/ts/components/conversation/message/message-item/GenericReadableMessage.tsx @@ -95,6 +95,8 @@ export const GenericReadableMessage = (props: Props) => { const ref = useRef(null); const pointerDownRef = useRef(false); const [triggerPosition, setTriggerPosition] = useState(null); + const [autoFocusReactionBarFirstEmoji, setAutoFocusReactionBarFirstEmoji] = + useState(false); const isInFocusScope = useIsInScope({ scope: 'message', scopeId: messageId }); const { focusedMessageId } = useFocusScope(); const isAnotherMessageFocused = focusedMessageId && !isInFocusScope; @@ -150,6 +152,7 @@ export const GenericReadableMessage = (props: Props) => { } const overrideTriggerPosition = getMessageContainerTriggerPosition(); if (overrideTriggerPosition) { + setAutoFocusReactionBarFirstEmoji(true); handleContextMenu(e, overrideTriggerPosition); } } @@ -161,6 +164,7 @@ export const GenericReadableMessage = (props: Props) => { const onBlur = () => { dispatch(setFocusedMessageId(null)); + setAutoFocusReactionBarFirstEmoji(false); }; const toggleEmojiReactionBarWithKeyboard = () => { @@ -169,6 +173,7 @@ export const GenericReadableMessage = (props: Props) => { } else { const pos = getMessageContainerTriggerPosition(); if (pos) { + setAutoFocusReactionBarFirstEmoji(true); setTriggerPosition(pos); } } @@ -219,6 +224,7 @@ export const GenericReadableMessage = (props: Props) => { convoReactionsEnabled={convoReactionsEnabled} triggerPosition={triggerPosition} setTriggerPosition={setTriggerPosition} + autoFocusReactionBarFirstEmoji={autoFocusReactionBarFirstEmoji} /> ); diff --git a/ts/components/conversation/message/reactions/Reaction.tsx b/ts/components/conversation/message/reactions/Reaction.tsx index 9abe6aee4..6f284bb45 100644 --- a/ts/components/conversation/message/reactions/Reaction.tsx +++ b/ts/components/conversation/message/reactions/Reaction.tsx @@ -31,7 +31,6 @@ const StyledReaction = styled.button<{ border-radius: var(--border-radius-message-box); box-sizing: border-box; padding: 0 7px; - margin: 0 4px var(--margins-sm); height: ${EMOJI_REACTION_HEIGHT}px; min-width: ${props => (props.$showCount ? 2 * EMOJI_REACTION_HEIGHT : EMOJI_REACTION_HEIGHT)}px; @@ -42,6 +41,11 @@ const StyledReaction = styled.button<{ ${props => !props.onClick && 'cursor: not-allowed;'} ${focusVisibleOutline()} + // box-shadow needs to be re-added in focus-visible for the selected state to show while focused + &:focus-visible { + box-shadow: 0 0 0 1px + ${props => (props.selected ? 'var(--primary-color)' : 'var(--transparent-color)')}; + } `; const StyledReactionContainer = styled.div<{ diff --git a/ts/components/dialog/ReactListModal.tsx b/ts/components/dialog/ReactListModal.tsx index 7a8d50087..32b6712a2 100644 --- a/ts/components/dialog/ReactListModal.tsx +++ b/ts/components/dialog/ReactListModal.tsx @@ -314,7 +314,7 @@ export const ReactListModal = (props: Props) => { hasReactLimit={false} inModal={true} onSelected={handleSelectedReaction} - onClick={handleReactionClick} + onEmojiClick={handleReactionClick} noAvatar={true} /> diff --git a/ts/components/menu/items/DeleteMessage/DeleteMessageMenuItem.tsx b/ts/components/menu/items/DeleteMessage/DeleteMessageMenuItem.tsx index c89b921d4..acefc6e49 100644 --- a/ts/components/menu/items/DeleteMessage/DeleteMessageMenuItem.tsx +++ b/ts/components/menu/items/DeleteMessage/DeleteMessageMenuItem.tsx @@ -16,7 +16,7 @@ import { LUCIDE_ICONS_UNICODE } from '../../../icon/lucide'; import { DURATION } from '../../../../session/constants'; import { formatAbbreviatedExpireDoubleTimer } from '../../../../util/i18n/formatting/expirationTimer'; import { useMessageExpirationPropsById } from '../../../../hooks/useParamSelector'; -import { useMessageInteractions } from '../../../../hooks/useMessageInteractions'; +import { useMessageDelete } from '../../../../hooks/useMessageInteractions'; const StyledDeleteItemContent = styled.span` display: flex; @@ -100,12 +100,20 @@ export const DeleteItem = ({ messageId }: { messageId: string }) => { const isDeletable = useMessageIsDeletable(messageId); const isDeletableForEveryone = useMessageIsDeletableForEveryone(messageId); - const { deleteFromConvo } = useMessageInteractions(messageId); - const onClick = () => { - deleteFromConvo(isPublic, convoId); - }; + const deleteFromConvo = useMessageDelete(messageId); - if (!convoId || (isPublic && !isDeletableForEveryone) || (!isPublic && !isDeletable)) { + const onClick = deleteFromConvo + ? () => { + deleteFromConvo(isPublic, convoId); + } + : undefined; + + if ( + !onClick || + !convoId || + (isPublic && !isDeletableForEveryone) || + (!isPublic && !isDeletable) + ) { return null; } diff --git a/ts/hooks/useMessageInteractions.ts b/ts/hooks/useMessageInteractions.ts index 4fbaf665b..09fa302ef 100644 --- a/ts/hooks/useMessageInteractions.ts +++ b/ts/hooks/useMessageInteractions.ts @@ -14,6 +14,7 @@ import { useMessageAttachments, useMessageBody, useMessageDirection, + useMessageIsDeletable, useMessageSender, useMessageServerTimestamp, useMessageStatus, @@ -22,114 +23,113 @@ import { import { saveAttachmentToDisk } from '../util/attachment/attachmentsUtil'; import { deleteMessagesForX } from '../interactions/conversations/unsendingInteractions'; -function useSaveAttachemnt(messageId?: string) { +export function useMessageSaveAttachement(messageId?: string) { const convoId = useSelectedConversationKey(); const attachments = useMessageAttachments(messageId); const timestamp = useMessageTimestamp(messageId); const serverTimestamp = useMessageServerTimestamp(messageId); const sender = useMessageSender(messageId); - return (e: ItemParams) => { - // this is quite dirty but considering that we want the context menu of the message to show on click on the attachment - // and the context menu save attachment item to save the right attachment I did not find a better way for now. - // Note: If you change this, also make sure to update the `handleContextMenu()` in GenericReadableMessage.tsx - const targetAttachmentIndex = isNumber(e?.props?.dataAttachmentIndex) - ? e.props.dataAttachmentIndex - : 0; - e.event.stopPropagation(); - if (!attachments?.length || !convoId || !sender) { - return; - } - - if (targetAttachmentIndex > attachments.length) { - return; - } - const messageTimestamp = timestamp || serverTimestamp || 0; - void saveAttachmentToDisk({ - attachment: attachments[targetAttachmentIndex], - messageTimestamp, - messageSender: sender, - conversationId: convoId, - index: targetAttachmentIndex, - }); - }; + const cannotSave = + !messageId || + !convoId || + !sender || + !attachments?.length || + !attachments.every(m => !m.pending && m.path); + + return cannotSave + ? null + : (e: ItemParams) => { + // this is quite dirty but considering that we want the context menu of the message to show on click on the attachment + // and the context menu save attachment item to save the right attachment I did not find a better way for now. + // Note: If you change this, also make sure to update the `handleContextMenu()` in GenericReadableMessage.tsx + const targetAttachmentIndex = isNumber(e?.props?.dataAttachmentIndex) + ? e.props.dataAttachmentIndex + : 0; + e.event.stopPropagation(); + if (targetAttachmentIndex > attachments.length) { + return; + } + const messageTimestamp = timestamp || serverTimestamp || 0; + void saveAttachmentToDisk({ + attachment: attachments[targetAttachmentIndex], + messageTimestamp, + messageSender: sender, + conversationId: convoId, + index: targetAttachmentIndex, + }); + }; } -function useCopyText(messageId?: string) { +export function useMessageCopyText(messageId?: string) { const text = useMessageBody(messageId); - return () => { - const selection = window.getSelection(); - const selectedText = selection?.toString().trim(); - // Note: we want to allow to copy through the "Copy" menu item the currently selected text, if any. - MessageInteraction.copyBodyToClipboard(selectedText || text); - }; + const cannotCopy = !messageId; + + return cannotCopy + ? null + : () => { + const selection = window.getSelection(); + const selectedText = selection?.toString().trim(); + // NOTE: the copy action should copy selected text (if any) then fallback to the whole message + MessageInteraction.copyBodyToClipboard(selectedText || text); + }; } -function useReply(messageId?: string) { +export function useMessageReply(messageId?: string) { const isSelectedBlocked = useSelectedIsBlocked(); const direction = useMessageDirection(messageId); const status = useMessageStatus(messageId); const isOutgoing = direction === 'outgoing'; - const isSent = status === 'sent' || status === 'read'; // a read message should be replyable - - return () => { - if (!messageId) { - return; - } - if (isSelectedBlocked) { - pushUnblockToSend(); - return; - } - if (isSent || !isOutgoing) { - void replyToMessage(messageId); - } - // NOTE: we dont want to reply to failed to send messages - }; + const isSendingOrError = status === 'sending' || status === 'error'; + + // NOTE: we don't want to allow replying to outgoing messages that failed to send or messages currently sending + const cannotReply = !messageId || (isOutgoing && isSendingOrError); + + return cannotReply + ? null + : () => { + if (isSelectedBlocked) { + pushUnblockToSend(); + return; + } + void replyToMessage(messageId); + }; } -function useDelete(messageId?: string) { +export function useMessageDelete(messageId?: string) { const messageStatus = useMessageStatus(messageId); - return (isPublic: boolean, convoId?: string) => { - if (convoId && messageId) { - const enforceDeleteServerSide = isPublic && messageStatus !== 'error'; - void deleteMessagesForX([messageId], convoId, enforceDeleteServerSide); - } - }; + const cannotDelete = !messageId; + + return cannotDelete + ? null + : (isPublic: boolean, convoId?: string) => { + if (convoId) { + const enforceDeleteServerSide = isPublic && messageStatus !== 'error'; + void deleteMessagesForX([messageId], convoId, enforceDeleteServerSide); + } + }; } -export function useMessageInteractions(messageId?: string | null) { +export function useMessageSelect(messageId?: string) { const dispatch = getAppDispatch(); + const isDeletable = useMessageIsDeletable(messageId); + const cannotSelect = !messageId || !isDeletable; + + return cannotSelect + ? null + : () => { + dispatch(toggleSelectedMessageId(messageId)); + }; +} - const copyText = useCopyText(messageId ?? undefined); - const saveAttachment = useSaveAttachemnt(messageId ?? undefined); - - const reply = useReply(messageId ?? undefined); - const deleteFromConvo = useDelete(messageId ?? undefined); - - const select = () => { - if (!messageId) { - return; - } - - dispatch(toggleSelectedMessageId(messageId)); - }; - - const reactToMessage = async (emoji: string) => { - if (!messageId) { - return; - } - - await Reactions.sendMessageReaction(messageId, emoji); - }; +export function useMessageReact(messageId?: string) { + const cannotReact = !messageId; - return { - copyText, - saveAttachment, - reply, - select, - reactToMessage, - deleteFromConvo, - }; + return cannotReact + ? null + : async (emoji: string) => { + await Reactions.sendMessageReaction(messageId, emoji); + }; } From 8a87b9c2bab5c2d6e025eba1ae5a7ab00b7f90a2 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Fri, 20 Feb 2026 17:04:39 +1100 Subject: [PATCH 05/21] chore: made all of the message types selectable --- ts/components/SessionToastContainer.tsx | 3 +- ts/components/SessionTooltip.tsx | 6 + ts/components/basic/SessionRadioGroup.tsx | 2 + .../conversation/SessionConversation.tsx | 4 +- .../SessionEmojiReactBarPopover.tsx | 6 +- .../conversation/SessionMessagesList.tsx | 35 +- .../conversation/TimerNotification.tsx | 16 +- .../header/ConversationHeader.tsx | 6 +- .../ConversationHeaderSelectionOverlay.tsx | 29 +- .../message-content/ClickToTrustSender.tsx | 6 +- .../MessageContentWithStatus.tsx | 104 ++-- .../message-content/MessageContextMenu.tsx | 67 +-- .../message-item/CommunityInvitation.tsx | 19 +- .../DataExtractionNotification.tsx | 10 +- .../message-item/ExpirableReadableMessage.tsx | 306 ++++++++-- .../message-item/GenericReadableMessage.tsx | 104 ++-- .../message-item/GroupUpdateMessage.tsx | 16 +- .../message-item/InteractionNotification.tsx | 19 +- .../message/message-item/Message.tsx | 11 - .../message-item/MessageRequestResponse.tsx | 21 +- .../message/message-item/ReadableMessage.tsx | 219 ------- .../notification-bubble/CallNotification.tsx | 18 +- .../message-info/OverlayMessageInfo.tsx | 22 +- ts/components/dialog/SessionConfirm.tsx | 53 +- .../dialog/debug/hooks/useReleaseChannel.tsx | 5 +- ts/components/dialog/shared/ModalWarning.tsx | 25 + .../pages/PrivacySettingsPage.tsx | 10 +- .../overlay/OverlayMessageRequest.tsx | 4 +- .../DeleteMessage/DeleteMessageMenuItem.tsx | 29 +- .../SelectMessage/SelectMessageMenuItem.tsx | 22 + .../useClearAllMessages.ts | 4 +- .../useDeclineMessageRequest.ts | 8 +- .../useDeleteMessagesCb.tsx | 428 +++++++++++++ .../menuAndSettingsHooks/useHideNoteToSelf.ts | 7 +- .../useShowDeletePrivateContact.ts | 7 +- .../useShowDeletePrivateConversation.ts | 7 +- .../useShowLeaveCommunity.ts | 5 +- .../menuAndSettingsHooks/useShowLeaveGroup.ts | 6 +- .../menuAndSettingsHooks/useShowNoteToSelf.ts | 5 +- ts/hooks/useMessageInteractions.ts | 175 +++--- ts/interactions/conversationInteractions.ts | 5 +- .../deleteMessagesFromSwarmOnly.ts | 47 ++ .../deleteMessagesLocallyOnly.ts | 37 ++ .../conversations/unsendingInteractions.ts | 561 +----------------- ts/interactions/messageInteractions.ts | 4 +- ts/models/message.ts | 97 +-- ts/react.d.ts | 6 +- ts/receiver/contentMessage.ts | 2 +- ts/receiver/groupv2/handleGroupV2Message.ts | 39 +- ts/session/types/with.ts | 8 +- .../jobs/GroupPendingRemovalsJob.ts | 19 +- ts/state/ducks/conversations.ts | 90 ++- ts/state/ducks/modalDialog.tsx | 2 +- ts/state/selectors/conversations.ts | 98 +-- ts/state/selectors/messages.ts | 82 ++- ts/types/isStringArray.ts | 3 + ts/util/accountManager.ts | 7 +- 57 files changed, 1492 insertions(+), 1464 deletions(-) delete mode 100644 ts/components/conversation/message/message-item/Message.tsx delete mode 100644 ts/components/conversation/message/message-item/ReadableMessage.tsx create mode 100644 ts/components/dialog/shared/ModalWarning.tsx create mode 100644 ts/components/menu/items/SelectMessage/SelectMessageMenuItem.tsx create mode 100644 ts/components/menuAndSettingsHooks/useDeleteMessagesCb.tsx create mode 100644 ts/interactions/conversations/deleteMessagesFromSwarmOnly.ts create mode 100644 ts/interactions/conversations/deleteMessagesLocallyOnly.ts create mode 100644 ts/types/isStringArray.ts diff --git a/ts/components/SessionToastContainer.tsx b/ts/components/SessionToastContainer.tsx index a6ff25343..5f6794c54 100644 --- a/ts/components/SessionToastContainer.tsx +++ b/ts/components/SessionToastContainer.tsx @@ -1,5 +1,6 @@ import { Slide, ToastContainer, ToastContainerProps } from 'react-toastify'; import styled from 'styled-components'; +import { isTestIntegration } from '../shared/env_vars'; // NOTE: https://styled-components.com/docs/faqs#how-can-i-override-styles-with-higher-specificity const StyledToastContainer = styled(ToastContainer)` @@ -40,7 +41,7 @@ export const SessionToastContainer = () => { return ( ; +}; + export const defaultTriggerPos: PopoverTriggerPosition = { x: 0, y: 0, width: 0, height: 0 }; export function getTriggerPositionFromBoundingClientRect(rect: DOMRect): PopoverTriggerPosition { diff --git a/ts/components/basic/SessionRadioGroup.tsx b/ts/components/basic/SessionRadioGroup.tsx index e9c50212c..1d118e7fc 100644 --- a/ts/components/basic/SessionRadioGroup.tsx +++ b/ts/components/basic/SessionRadioGroup.tsx @@ -10,6 +10,7 @@ export type SessionRadioItems = Array<{ label: string; inputDataTestId: SessionDataTestId; labelDataTestId: SessionDataTestId; + disabled?: boolean; }>; interface Props { @@ -50,6 +51,7 @@ export const SessionRadioGroup = (props: Props) => { label={item.label} active={itemIsActive} value={item.value} + disabled={item.disabled} inputDataTestId={item.inputDataTestId} labelDataTestId={item.labelDataTestId} inputName={group} diff --git a/ts/components/conversation/SessionConversation.tsx b/ts/components/conversation/SessionConversation.tsx index db45be927..f167f05cf 100644 --- a/ts/components/conversation/SessionConversation.tsx +++ b/ts/components/conversation/SessionConversation.tsx @@ -226,10 +226,10 @@ export class SessionConversation extends Component { if (msg.body.replace(/\s/g, '').includes(recoveryPhrase.replace(/\s/g, ''))) { window.inboxStore?.dispatch( updateConfirmModal({ - title: tr('warning'), + title: { token: 'warning' }, i18nMessage: { token: 'recoveryPasswordWarningSendDescription' }, okTheme: SessionButtonColor.Danger, - okText: tr('send'), + okText: { token: 'send' }, onClickOk: () => { void sendAndScroll(); }, diff --git a/ts/components/conversation/SessionEmojiReactBarPopover.tsx b/ts/components/conversation/SessionEmojiReactBarPopover.tsx index deb7f8f7c..f9d8bac5a 100644 --- a/ts/components/conversation/SessionEmojiReactBarPopover.tsx +++ b/ts/components/conversation/SessionEmojiReactBarPopover.tsx @@ -6,8 +6,8 @@ import { MessageReactBar } from './message/message-content/MessageReactBar'; import { THEME_GLOBALS } from '../../themes/globals'; import { SessionEmojiPanelPopover } from './SessionEmojiPanelPopover'; import { closeContextMenus } from '../../util/contextMenu'; -import { useMessageInteractions } from '../../hooks/useMessageInteractions'; import { useFocusedMessageId } from '../../state/selectors/conversations'; +import { useReactToMessage } from '../../hooks/useMessageInteractions'; export function SessionEmojiReactBarPopover({ messageId, @@ -25,7 +25,7 @@ export function SessionEmojiReactBarPopover({ const emojiPanelRef = useRef(null); const emojiReactionBarRef = useRef(null); const [showEmojiPanel, setShowEmojiPanel] = useState(false); - const { reactToMessage } = useMessageInteractions(messageId); + const reactToMessage = useReactToMessage(messageId); const focusedMessageId = useFocusedMessageId(); const closeEmojiPanel = () => { @@ -41,7 +41,7 @@ export function SessionEmojiReactBarPopover({ const onEmojiClick = async (args: any) => { const emoji = args.native ?? args; closeEmojiPanel(); - await reactToMessage(emoji); + await reactToMessage?.(emoji); }; useClickAway(emojiPanelRef, () => { diff --git a/ts/components/conversation/SessionMessagesList.tsx b/ts/components/conversation/SessionMessagesList.tsx index 1ac41785d..b60504a50 100644 --- a/ts/components/conversation/SessionMessagesList.tsx +++ b/ts/components/conversation/SessionMessagesList.tsx @@ -1,4 +1,4 @@ -import { useLayoutEffect, useState, type FC } from 'react'; +import { useLayoutEffect, useState } from 'react'; import { useSelector } from 'react-redux'; import useKey from 'react-use/lib/useKey'; @@ -7,25 +7,16 @@ import { getOldTopMessageId, getSortedMessagesTypesOfSelectedConversation, useFocusedMessageId, - type MessagePropsType, } from '../../state/selectors/conversations'; import { useSelectedConversationKey } from '../../state/selectors/selectedConversation'; import { MessageDateBreak } from './message/message-item/DateBreak'; -import { CommunityInvitation } from './message/message-item/CommunityInvitation'; -import { GroupUpdateMessage } from './message/message-item/GroupUpdateMessage'; -import { Message } from './message/message-item/Message'; -import { MessageRequestResponse } from './message/message-item/MessageRequestResponse'; -import { CallNotification } from './message/message-item/notification-bubble/CallNotification'; import { IsDetailMessageViewContext } from '../../contexts/isDetailViewContext'; import { SessionLastSeenIndicator } from './SessionLastSeenIndicator'; -import { TimerNotification } from './TimerNotification'; -import { DataExtractionNotification } from './message/message-item/DataExtractionNotification'; -import { InteractionNotification } from './message/message-item/InteractionNotification'; -import type { WithMessageId } from '../../session/types/with'; import { useKeyboardShortcut } from '../../hooks/useKeyboardShortcut'; import { KbdShortcut } from '../../util/keyboardShortcuts'; -import { useMessageInteractions } from '../../hooks/useMessageInteractions'; +import { GenericReadableMessage } from './message/message-item/GenericReadableMessage'; +import { useCopyText, useReply } from '../../hooks/useMessageInteractions'; function isNotTextboxEvent(e: KeyboardEvent) { return (e?.target as any)?.type === undefined; @@ -33,17 +24,6 @@ function isNotTextboxEvent(e: KeyboardEvent) { let previousRenderedConvo: string | undefined; -const componentForMessageType: Record> = { - 'group-notification': GroupUpdateMessage, - 'group-invitation': CommunityInvitation, - 'message-request-response': MessageRequestResponse, - 'data-extraction': DataExtractionNotification, - 'timer-notification': TimerNotification, - 'call-notification': CallNotification, - 'interaction-notification': InteractionNotification, - 'regular-message': Message, -}; - export const SessionMessagesList = (props: { scrollAfterLoadMore: ( messageIdToScrollTo: string, @@ -60,8 +40,9 @@ export const SessionMessagesList = (props: { const [didScroll, setDidScroll] = useState(false); const oldTopMessageId = useSelector(getOldTopMessageId); const oldBottomMessageId = useSelector(getOldBottomMessageId); - const focusedMessageId = useFocusedMessageId(); - const { reply, copyText } = useMessageInteractions(focusedMessageId); + const focusedMessageId = useFocusedMessageId() ?? undefined; + const reply = useReply(focusedMessageId); + const copyText = useCopyText(focusedMessageId); useKeyboardShortcut({ shortcut: KbdShortcut.messageToggleReply, handler: reply, scopeId: 'all' }); useKeyboardShortcut({ shortcut: KbdShortcut.messageCopyText, handler: copyText, scopeId: 'all' }); @@ -113,8 +94,6 @@ export const SessionMessagesList = (props: { .map(messageProps => { const { messageId } = messageProps; - const ComponentToRender = componentForMessageType[messageProps.message.messageType]; - const unreadIndicator = messageProps.showUnreadIndicator ? ( , + , ]; }) // TODO: check if we reverse this upstream, we might be reversing twice diff --git a/ts/components/conversation/TimerNotification.tsx b/ts/components/conversation/TimerNotification.tsx index cbbc1ec4f..fee804272 100644 --- a/ts/components/conversation/TimerNotification.tsx +++ b/ts/components/conversation/TimerNotification.tsx @@ -24,7 +24,7 @@ import { Localizer } from '../basic/Localizer'; import { SessionButtonColor } from '../basic/SessionButton'; import { SessionIcon } from '../icon'; import { getTimerNotificationStr } from '../../models/timerNotifications'; -import type { WithMessageId } from '../../session/types/with'; +import type { WithContextMenuId, WithMessageId } from '../../session/types/with'; import { useMessageAuthor, useMessageAuthorIsUs, @@ -34,6 +34,7 @@ import { useMessageExpirationUpdateTimespanText, } from '../../state/selectors'; import { tr, type TrArgs } from '../../localization/localeTools'; +import type { WithPopoverPosition, WithSetPopoverPosition } from '../SessionTooltip'; const FollowSettingButton = styled.button` color: var(--primary-color); @@ -65,13 +66,11 @@ function useFollowSettingsButtonClick({ messageId }: WithMessageId) { disappearing_messages_type: localizedMode, }; - const okText = tr('confirm'); - dispatch( updateConfirmModal({ - title: tr('disappearingMessagesFollowSetting'), + title: { token: 'disappearingMessagesFollowSetting' }, i18nMessage, - okText, + okText: { token: 'confirm' }, okTheme: SessionButtonColor.Danger, onClickOk: async () => { if (!selectedConvoKey) { @@ -149,7 +148,9 @@ const FollowSettingsButton = ({ messageId }: WithMessageId) => { ); }; -export const TimerNotification = (props: WithMessageId) => { +export const TimerNotification = ( + props: WithMessageId & WithPopoverPosition & WithSetPopoverPosition & WithContextMenuId +) => { const { messageId } = props; const timespanSeconds = useMessageExpirationUpdateTimespanSeconds(messageId); const expirationMode = useMessageExpirationUpdateMode(messageId); @@ -177,8 +178,9 @@ export const TimerNotification = (props: WithMessageId) => { return ( diff --git a/ts/components/conversation/header/ConversationHeader.tsx b/ts/components/conversation/header/ConversationHeader.tsx index 75005c525..2ad96d8d0 100644 --- a/ts/components/conversation/header/ConversationHeader.tsx +++ b/ts/components/conversation/header/ConversationHeader.tsx @@ -111,10 +111,10 @@ function useShowRecreateModal() { (name: string, members: Array) => { dispatch( updateConfirmModal({ - title: tr('recreateGroup'), + title: { token: 'recreateGroup' }, i18nMessage: { token: 'legacyGroupChatHistory' }, - okText: tr('theContinue'), - cancelText: tr('cancel'), + okText: { token: 'theContinue' }, + cancelText: { token: 'cancel' }, okTheme: SessionButtonColor.Danger, onClickOk: () => { openCreateGroup(name, members); diff --git a/ts/components/conversation/header/ConversationHeaderSelectionOverlay.tsx b/ts/components/conversation/header/ConversationHeaderSelectionOverlay.tsx index b5a695f6a..9df2b1ddd 100644 --- a/ts/components/conversation/header/ConversationHeaderSelectionOverlay.tsx +++ b/ts/components/conversation/header/ConversationHeaderSelectionOverlay.tsx @@ -3,13 +3,9 @@ import { useSelector } from 'react-redux'; import useKey from 'react-use/lib/useKey'; import { getAppDispatch } from '../../../state/dispatch'; -import { deleteMessagesForX } from '../../../interactions/conversations/unsendingInteractions'; import { resetSelectedMessageIds } from '../../../state/ducks/conversations'; import { getSelectedMessageIds } from '../../../state/selectors/conversations'; -import { - useSelectedConversationKey, - useSelectedIsPublic, -} from '../../../state/selectors/selectedConversation'; +import { useSelectedConversationKey } from '../../../state/selectors/selectedConversation'; import { SessionButton, SessionButtonColor, @@ -21,11 +17,12 @@ import { tr } from '../../../localization/localeTools'; import { SessionLucideIconButton } from '../../icon/SessionIconButton'; import { LUCIDE_ICONS_UNICODE } from '../../icon/lucide'; import { isBackspace, isDeleteKey, isEscapeKey } from '../../../util/keyboardShortcuts'; +import { useDeleteMessagesCb } from '../../menuAndSettingsHooks/useDeleteMessagesCb'; export const SelectionOverlay = () => { const selectedMessageIds = useSelector(getSelectedMessageIds); const selectedConversationKey = useSelectedConversationKey(); - const isPublic = useSelectedIsPublic(); + const deleteMessagesCb = useDeleteMessagesCb(selectedConversationKey); const dispatch = getAppDispatch(); const ref = useRef(null); @@ -51,8 +48,8 @@ export const SelectionOverlay = () => { return true; case 'Backspace': case 'Delete': - if (selectionMode && selectedConversationKey) { - void deleteMessagesForX(selectedMessageIds, selectedConversationKey, isPublic); + if (selectionMode) { + void deleteMessagesCb?.(selectedMessageIds); } return true; default: @@ -61,12 +58,6 @@ export const SelectionOverlay = () => { } ); - // `enforceDeleteServerSide` should check for message statuses too, but when we have multiple selected, - // some might be sent and some in an error state. We default to trying to delete all of them server side first, - // which might fail. If that fails, the user will need to do a delete for all the ones sent already, and a manual delete - // for each ones which is in an error state. - const enforceDeleteServerSide = isPublic; - return ( ref.current} @@ -96,14 +87,8 @@ export const SelectionOverlay = () => { buttonShape={SessionButtonShape.Square} buttonType={SessionButtonType.Solid} text={tr('delete')} - onClick={async () => { - if (selectedConversationKey) { - await deleteMessagesForX( - selectedMessageIds, - selectedConversationKey, - enforceDeleteServerSide - ); - } + onClick={() => { + void deleteMessagesCb?.(selectedMessageIds); }} /> diff --git a/ts/components/conversation/message/message-content/ClickToTrustSender.tsx b/ts/components/conversation/message/message-content/ClickToTrustSender.tsx index a99ffc370..dc7081f9c 100644 --- a/ts/components/conversation/message/message-content/ClickToTrustSender.tsx +++ b/ts/components/conversation/message/message-content/ClickToTrustSender.tsx @@ -44,7 +44,7 @@ export const ClickToTrustSender = (props: { messageId: string }) => { const convo = ConvoHub.use().get(sender); window.inboxStore?.dispatch( updateConfirmModal({ - title: tr('attachmentsAutoDownloadModalTitle'), + title: { token: 'attachmentsAutoDownloadModalTitle' }, i18nMessage: { token: 'attachmentsAutoDownloadModalDescription', conversation_name: convo.getNicknameOrRealUsernameOrPlaceholder(), @@ -114,8 +114,8 @@ export const ClickToTrustSender = (props: { messageId: string }) => { onClickClose: () => { window.inboxStore?.dispatch(updateConfirmModal(null)); }, - okText: tr('yes'), - cancelText: tr('no'), + okText: { token: 'yes' }, + cancelText: { token: 'no' }, }) ); }; diff --git a/ts/components/conversation/message/message-content/MessageContentWithStatus.tsx b/ts/components/conversation/message/message-content/MessageContentWithStatus.tsx index ea6f48265..1f1c80daa 100644 --- a/ts/components/conversation/message/message-content/MessageContentWithStatus.tsx +++ b/ts/components/conversation/message/message-content/MessageContentWithStatus.tsx @@ -1,43 +1,29 @@ -import { SessionDataTestId, MouseEvent, useCallback, Dispatch } from 'react'; -import { useSelector } from 'react-redux'; +import { MouseEvent, useMemo } from 'react'; import { clsx } from 'clsx'; import styled from 'styled-components'; import { getAppDispatch } from '../../../../state/dispatch'; import { useIsDetailMessageView } from '../../../../contexts/isDetailViewContext'; -import { MessageRenderingProps } from '../../../../models/messageType'; -import { toggleSelectedMessageId } from '../../../../state/ducks/conversations'; import { updateReactListModal } from '../../../../state/ducks/modalDialog'; -import { StateType } from '../../../../state/reducer'; -import { useHideAvatarInMsgList, useMessageStatus } from '../../../../state/selectors'; -import { getMessageContentWithStatusesSelectorProps } from '../../../../state/selectors/conversations'; +import { + useHideAvatarInMsgList, + useMessageDirection, + useMessageStatus, +} from '../../../../state/selectors'; import { Flex } from '../../../basic/Flex'; import { ExpirableReadableMessage } from '../message-item/ExpirableReadableMessage'; import { MessageAuthorText } from './MessageAuthorText'; import { MessageContent } from './MessageContent'; -import { MessageContextMenu } from './MessageContextMenu'; import { MessageReactions } from './MessageReactions'; import { MessageStatus } from './MessageStatus'; import { - useIsMessageSelectionMode, + useSelectedConversationKey, useSelectedIsLegacyGroup, } from '../../../../state/selectors/selectedConversation'; import { SessionEmojiReactBarPopover } from '../../SessionEmojiReactBarPopover'; -import { PopoverTriggerPosition } from '../../../SessionTooltip'; -import { useMessageInteractions } from '../../../../hooks/useMessageInteractions'; - -export type MessageContentWithStatusSelectorProps = { isGroup: boolean } & Pick< - MessageRenderingProps, - 'conversationType' | 'direction' | 'isDeleted' ->; - -type Props = { - messageId: string; - ctxMenuID: string; - dataTestId: SessionDataTestId; - convoReactionsEnabled: boolean; - triggerPosition: PopoverTriggerPosition | null; - setTriggerPosition: Dispatch; -}; +import { type WithPopoverPosition, type WithSetPopoverPosition } from '../../../SessionTooltip'; +import { useReactToMessage, useReply } from '../../../../hooks/useMessageInteractions'; +import { ConvoHub } from '../../../../session/conversations'; +import type { WithContextMenuId, WithMessageId } from '../../../../session/types/with'; const StyledMessageContentContainer = styled.div<{ $isIncoming: boolean; $isDetailView: boolean }>` display: flex; @@ -57,40 +43,24 @@ const StyledMessageWithAuthor = styled.div` gap: var(--margins-xs); `; -export const MessageContentWithStatuses = (props: Props) => { - const { - messageId, - ctxMenuID, - dataTestId, - convoReactionsEnabled, - triggerPosition, - setTriggerPosition, - } = props; +export const MessageContentWithStatuses = ( + props: WithMessageId & WithContextMenuId & WithPopoverPosition & WithSetPopoverPosition +) => { + const { messageId, contextMenuId, triggerPosition, setTriggerPosition } = props; const dispatch = getAppDispatch(); - const contentProps = useSelector((state: StateType) => - getMessageContentWithStatusesSelectorProps(state, messageId) - ); - const { reactToMessage, reply } = useMessageInteractions(messageId); + const _direction = useMessageDirection(messageId); + const reactToMessage = useReactToMessage(messageId); + const reply = useReply(messageId); const hideAvatar = useHideAvatarInMsgList(messageId); const isDetailView = useIsDetailMessageView(); - const multiSelectMode = useIsMessageSelectionMode(); const isLegacyGroup = useSelectedIsLegacyGroup(); - const status = useMessageStatus(props.messageId); - const isSent = status === 'sent' || status === 'read'; // a read message should be reactable - const onClickOnMessageOuterContainer = useCallback( - (event: MouseEvent) => { - if (multiSelectMode && props?.messageId) { - event.preventDefault(); - event.stopPropagation(); - dispatch(toggleSelectedMessageId(props?.messageId)); - } - }, - [dispatch, props?.messageId, multiSelectMode] - ); + const status = useMessageStatus(props.messageId); + const convoId = useSelectedConversationKey(); + const isSent = status === 'sent' || status === 'read'; // a read message can be reacted to const onDoubleClickReplyToMessage = (e: MouseEvent) => { - if (isLegacyGroup) { + if (isLegacyGroup || !reply) { return; } const currentSelection = window.getSelection(); @@ -111,17 +81,25 @@ export const MessageContentWithStatuses = (props: Props) => { } }; - if (!contentProps) { + const convoReactionsEnabled = useMemo(() => { + if (convoId) { + const conversationModel = ConvoHub.use().get(convoId); + if (conversationModel) { + return conversationModel.hasReactions(); + } + } + return true; + }, [convoId]); + + if (!messageId) { return null; } - const { direction: _direction, isDeleted } = contentProps; // NOTE we want messages on the left in the message detail view regardless of direction const direction = isDetailView ? 'incoming' : _direction; const isIncoming = direction === 'incoming'; - const enableReactions = convoReactionsEnabled && !isDeleted && (isSent || isIncoming); - const enableContextMenu = !isDeleted; + const enableReactions = convoReactionsEnabled && (isSent || isIncoming); const handlePopupClick = (emoji: string) => { dispatch( @@ -142,9 +120,10 @@ export const MessageContentWithStatuses = (props: Props) => { messageId={messageId} className={clsx('module-message', `module-message--${direction}`)} role={'button'} - onClick={onClickOnMessageOuterContainer} onDoubleClickCapture={onDoubleClickReplyToMessage} - dataTestId={dataTestId} + dataTestId="message-content" + contextMenuId={contextMenuId} + setTriggerPosition={setTriggerPosition} > { onClickAwayFromReactionBar={closeReactionBar} /> ) : null} - {enableContextMenu ? ( - - ) : null} - {!isDetailView && enableReactions ? ( + {!isDetailView && enableReactions && !!reactToMessage ? ( ; -type Props = { - messageId: string; - contextMenuId: string; - setTriggerPosition: Dispatch; -}; +type Props = WithMessageId & WithContextMenuId & WithSetPopoverPosition; const CONTEXTIFY_MENU_WIDTH_PX = 200; const SCREEN_RIGHT_MARGIN_PX = 104; @@ -206,7 +197,11 @@ export const showMessageInfoOverlay = async ({ function SaveAttachmentMenuItem({ messageId }: { messageId: string }) { const attachments = useMessageAttachments(messageId); - const { saveAttachment } = useMessageInteractions(messageId); + const saveAttachment = useSaveAttachment(messageId); + + if (!saveAttachment) { + return null; + } return attachments?.length && attachments.every(m => !m.pending && m.path) ? ( @@ -248,14 +247,15 @@ function CopyBodyMenuItem({ messageId }: { messageId: string }) { export const MessageContextMenu = (props: Props) => { const { messageId, contextMenuId, setTriggerPosition } = props; - const { reply, select } = useMessageInteractions(messageId); + const reply = useReply(messageId); const isLegacyGroup = useSelectedIsLegacyGroup(); const convoId = useSelectedConversationKey(); const direction = useMessageDirection(messageId); const status = useMessageStatus(messageId); - const isDeletable = useMessageIsDeletable(messageId); + const isDeleted = useMessageIsDeleted(messageId); const sender = useMessageSender(messageId); + const isControlMessage = useMessageIsControlMessage(messageId); const isOutgoing = direction === 'outgoing'; const isSent = status === 'sent' || status === 'read'; // a read message should be replyable @@ -288,21 +288,24 @@ export const MessageContextMenu = (props: Props) => { return null; } - if (isLegacyGroup) { + if (isDeleted || isControlMessage) { return ( - - - - + + ); } + if (isLegacyGroup) { + // legacy groups are deprecated + return null; + } + return ( @@ -315,22 +318,14 @@ export const MessageContextMenu = (props: Props) => { > - {(isSent || !isOutgoing) && ( + {(isSent || !isOutgoing) && !!reply && ( {tr('reply')} )} - {isDeletable ? ( - - - - ) : null} + diff --git a/ts/components/conversation/message/message-item/CommunityInvitation.tsx b/ts/components/conversation/message/message-item/CommunityInvitation.tsx index bd0750cf3..0b098e0e7 100644 --- a/ts/components/conversation/message/message-item/CommunityInvitation.tsx +++ b/ts/components/conversation/message/message-item/CommunityInvitation.tsx @@ -10,10 +10,11 @@ import { useMessageCommunityInvitationCommunityName, useMessageDirection, } from '../../../../state/selectors'; -import type { WithMessageId } from '../../../../session/types/with'; +import type { WithContextMenuId, WithMessageId } from '../../../../session/types/with'; import { SessionLucideIconButton } from '../../../icon/SessionIconButton'; import { LUCIDE_ICONS_UNICODE } from '../../../icon/lucide'; import { tr } from '../../../../localization/localeTools'; +import type { WithPopoverPosition, WithSetPopoverPosition } from '../../../SessionTooltip'; const StyledCommunityInvitation = styled.div` background-color: var(--message-bubble-incoming-background-color); @@ -77,12 +78,14 @@ const StyledIconContainer = styled.div` border-radius: 100%; `; -export const CommunityInvitation = ({ messageId }: WithMessageId) => { - const messageDirection = useMessageDirection(messageId); +export const CommunityInvitation = ( + props: WithMessageId & WithPopoverPosition & WithSetPopoverPosition & WithContextMenuId +) => { + const messageDirection = useMessageDirection(props.messageId); const classes = ['group-invitation']; - const fullUrl = useMessageCommunityInvitationFullUrl(messageId); - const communityName = useMessageCommunityInvitationCommunityName(messageId); + const fullUrl = useMessageCommunityInvitationFullUrl(props.messageId); + const communityName = useMessageCommunityInvitationCommunityName(props.messageId); const hostname = useMemo(() => { try { @@ -104,8 +107,10 @@ export const CommunityInvitation = ({ messageId }: WithMessageId) => { return ( { +export const DataExtractionNotification = ( + props: WithMessageId & WithPopoverPosition & WithSetPopoverPosition & WithContextMenuId +) => { const { messageId } = props; const author = useMessageAuthor(messageId); const authorName = useConversationUsernameWithFallback(true, author); @@ -21,9 +24,10 @@ export const DataExtractionNotification = (props: WithMessageId) => { return ( ` - display: flex; - justify-content: flex-end; // ${props => (props.$isIncoming ? 'flex-start' : 'flex-end')}; - align-items: ${props => (props.$isIncoming ? 'flex-start' : 'flex-end')}; - width: 100%; - flex-direction: column; -`; - -export interface ExpirableReadableMessageProps extends Omit { +export type ReadableMessageProps = { + children: ReactNode; messageId: string; + className?: string; + isUnread: boolean; + onClick?: MouseEventHandler; + onDoubleClickCapture?: MouseEventHandler; + dataTestId: SessionDataTestId; + role?: AriaRole; + onContextMenu?: (e: MouseEvent) => void; isControlMessage?: boolean; +}; + +const debouncedTriggerLoadMoreTop = debounce( + (selectedConversationKey: string, oldestMessageId: string) => { + (window.inboxStore?.dispatch as any)( + fetchTopMessagesForConversation({ + conversationKey: selectedConversationKey, + oldTopMessageId: oldestMessageId, + }) + ); + }, + 100 +); + +const debouncedTriggerLoadMoreBottom = debounce( + (selectedConversationKey: string, youngestMessageId: string) => { + (window.inboxStore?.dispatch as any)( + fetchBottomMessagesForConversation({ + conversationKey: selectedConversationKey, + oldBottomMessageId: youngestMessageId, + }) + ); + }, + 100 +); + +async function markReadFromMessageId({ + conversationId, + messageId, + isUnread, +}: WithMessageId & WithConvoId & { isUnread: boolean }) { + // isUnread comes from the redux store in memory, so pretty fast and allows us to not fetch from the DB too often + if (!isUnread) { + return; + } + const found = await Data.getMessageById(messageId); + + if (!found) { + return; + } + + if (found.isUnread()) { + ConvoHub.use() + .get(conversationId) + ?.markConversationRead({ + newestUnreadDate: found.get('sent_at') || found.get('serverTimestamp') || Date.now(), + fromConfigMessage: false, + }); + } } +const ReadableMessage = ( + props: ReadableMessageProps & { alignItems: 'flex-start' | 'flex-end' | 'center' } +) => { + const { + messageId, + onContextMenu, + className, + isUnread, + onClick, + onDoubleClickCapture, + role, + dataTestId, + alignItems, + } = props; + + const isAppFocused = useSelector(getIsAppFocused); + const dispatch = getAppDispatch(); + + const selectedConversationKey = useSelectedConversationKey(); + const mostRecentMessageId = useSelector(getMostRecentMessageId); + const oldestMessageId = useSelector(getOldestMessageId); + const youngestMessageId = useSelector(getYoungestMessageId); + const fetchingMoreInProgress = useSelector(areMoreMessagesBeingFetched); + const conversationHasUnread = useHasUnread(selectedConversationKey); + const scrollButtonVisible = useSelector(getShowScrollButton); + + const [didScroll, setDidScroll] = useState(false); + const quotedMessageToAnimate = useSelector(getQuotedMessageToAnimate); + + const scrollToLoadedMessage = useScrollToLoadedMessage(); + + // if this unread-indicator is rendered, + // we want to scroll here only if the conversation was not opened to a specific message + // eslint-disable-next-line react-hooks/exhaustive-deps + useLayoutEffect(() => { + if ( + props.messageId === youngestMessageId && + !quotedMessageToAnimate && + !scrollButtonVisible && + !didScroll && + !conversationHasUnread + ) { + scrollToLoadedMessage(props.messageId, 'go-to-bottom'); + setDidScroll(true); + } else if (quotedMessageToAnimate) { + setDidScroll(true); + } + }); + + const onVisible = useCallback( + async (inView: boolean, _: IntersectionObserverEntry) => { + if (!selectedConversationKey) { + return; + } + // we are the most recent message + if (mostRecentMessageId === messageId) { + // make sure the app is focused, because we mark message as read here + if (inView === true && isAppFocused) { + dispatch(showScrollToBottomButton(false)); + // TODO this is pretty expensive and should instead use values from the redux store + await markReadFromMessageId({ + messageId, + conversationId: selectedConversationKey, + isUnread, + }); + + dispatch(markConversationFullyRead(selectedConversationKey)); + } else if (inView === false) { + dispatch(showScrollToBottomButton(true)); + } + } + + if (inView && isAppFocused && oldestMessageId === messageId && !fetchingMoreInProgress) { + debouncedTriggerLoadMoreTop(selectedConversationKey, oldestMessageId); + } + + if (inView && isAppFocused && youngestMessageId === messageId && !fetchingMoreInProgress) { + debouncedTriggerLoadMoreBottom(selectedConversationKey, youngestMessageId); + } + + // this part is just handling the marking of the message as read if needed + if (inView) { + // TODO this is pretty expensive and should instead use values from the redux store + await markReadFromMessageId({ + messageId, + conversationId: selectedConversationKey, + isUnread, + }); + } + }, + [ + dispatch, + selectedConversationKey, + mostRecentMessageId, + oldestMessageId, + fetchingMoreInProgress, + isAppFocused, + messageId, + youngestMessageId, + isUnread, + ] + ); + return ( + + {props.children} + + ); +}; + +export type ExpirableReadableMessageProps = Omit & + WithMessageId & + WithContextMenuId & + WithSetPopoverPosition; + function ExpireTimerControlMessage({ expirationTimestamp, expirationDurationMs, - isControlMessage, }: { expirationDurationMs: number | null | undefined; expirationTimestamp: number | null | undefined; - isControlMessage: boolean | undefined; }) { - if (!isControlMessage) { - return null; - } return ( { const selected = useMessageExpirationPropsByIdInternal(props.messageId); const isDetailView = useIsDetailMessageViewInternal(); + const selectViaClick = useSelectMessageViaClickInternal(props.messageId); + + const { onDoubleClickCapture, role, dataTestId, contextMenuId, setTriggerPosition, messageId } = + props; - const { isControlMessage, onClick, onDoubleClickCapture, role, dataTestId } = props; + const messageType = useMessageTypeInternal(messageId); const { isExpired } = useIsExpired({ convoId: selected?.convoId, @@ -122,35 +339,50 @@ export const ExpirableReadableMessage = (props: ExpirableReadableMessageProps) = return null; } - const { - messageId, - direction: _direction, - isUnread, - expirationDurationMs, - expirationTimestamp, - } = selected; + const { direction: _direction, isUnread, expirationDurationMs, expirationTimestamp } = selected; // NOTE we want messages on the left in the message detail view regardless of direction const direction = isDetailView ? 'incoming' : _direction; const isIncoming = direction === 'incoming'; + /** + * If the message can expire, it will show the expiration timer if it is expiring. + * Note, the only two message types that cannot expire are + * - 'interaction-notification' + * - 'message-request-response' + */ + const canExpire = + messageType !== 'interaction-notification' && messageType !== 'message-request-response'; + const isControlMessage = + messageType !== 'regular-message' && messageType !== 'community-invitation'; + + const alignItems = isControlMessage ? 'center' : isIncoming ? 'flex-start' : 'flex-end'; + return ( - - + ) : null} + {props.children} - + ); }; diff --git a/ts/components/conversation/message/message-item/GenericReadableMessage.tsx b/ts/components/conversation/message/message-item/GenericReadableMessage.tsx index 5d4be039e..2ba29a09b 100644 --- a/ts/components/conversation/message/message-item/GenericReadableMessage.tsx +++ b/ts/components/conversation/message/message-item/GenericReadableMessage.tsx @@ -1,43 +1,42 @@ -import { type MouseEvent, type KeyboardEvent, useCallback, useRef, useMemo, useState } from 'react'; +import { type MouseEvent, type KeyboardEvent, useCallback, useRef, useState } from 'react'; import clsx from 'clsx'; -import { useSelector } from 'react-redux'; import styled, { keyframes } from 'styled-components'; import { useIsDetailMessageView } from '../../../../contexts/isDetailViewContext'; import { MessageRenderingProps } from '../../../../models/messageType'; -import { ConvoHub } from '../../../../session/conversations'; -import { StateType } from '../../../../state/reducer'; -import { useMessageSelected } from '../../../../state/selectors'; -import { getGenericReadableMessageSelectorProps } from '../../../../state/selectors/conversations'; +import { + useMessageDirection, + useMessageSelected, + useMessageType, +} from '../../../../state/selectors'; import { MessageContentWithStatuses } from '../message-content/MessageContentWithStatus'; import { StyledMessageReactionsContainer } from '../message-content/MessageReactions'; import { useIsMessageSelectionMode, + useSelectedConversationKey, useSelectedIsBlocked, + useSelectedIsKickedFromGroup, } from '../../../../state/selectors/selectedConversation'; import { isButtonClickKey, KbdShortcut } from '../../../../util/keyboardShortcuts'; import { showMessageContextMenu } from '../message-content/MessageContextMenu'; import { getAppDispatch } from '../../../../state/dispatch'; -import { setFocusedMessageId } from '../../../../state/ducks/conversations'; +import { setFocusedMessageId, type UIMessageType } from '../../../../state/ducks/conversations'; import { PopoverTriggerPosition } from '../../../SessionTooltip'; import { useKeyboardShortcut } from '../../../../hooks/useKeyboardShortcut'; +import type { WithMessageId } from '../../../../session/types/with'; +import { CommunityInvitation } from './CommunityInvitation'; +import { DataExtractionNotification } from './DataExtractionNotification'; +import { TimerNotification } from '../../TimerNotification'; +import { GroupUpdateMessage } from './GroupUpdateMessage'; +import { CallNotification } from './notification-bubble/CallNotification'; +import { InteractionNotification } from './InteractionNotification'; +import { MessageRequestResponse } from './MessageRequestResponse'; export type GenericReadableMessageSelectorProps = Pick< MessageRenderingProps, - | 'direction' - | 'conversationType' - | 'receivedAt' - | 'isUnread' - | 'convoId' - | 'isDeleted' - | 'isKickedFromGroup' + 'direction' | 'convoId' | 'isKickedFromGroup' >; -type Props = { - messageId: string; - ctxMenuID: string; -}; - const highlightedMessageAnimation = keyframes` 1% { background-color: var(--primary-color); } `; @@ -65,20 +64,44 @@ const StyledReadableMessage = styled.div<{ } `; -export const GenericReadableMessage = (props: Props) => { +function getMessageComponent(messageType: UIMessageType) { + switch (messageType) { + case 'community-invitation': + return CommunityInvitation; + case 'data-extraction-notification': + return DataExtractionNotification; + case 'timer-update-notification': + return TimerNotification; + case 'group-update-notification': + return GroupUpdateMessage; + case 'call-notification': + return CallNotification; + case 'interaction-notification': + return InteractionNotification; + case 'message-request-response': + return MessageRequestResponse; + case 'regular-message': + return MessageContentWithStatuses; + default: + return null; + } +} + +export const GenericReadableMessage = ({ messageId }: WithMessageId) => { const isDetailView = useIsDetailMessageView(); const dispatch = getAppDispatch(); - const { ctxMenuID, messageId } = props; + const ctxMenuID = `ctx-menu-message-${messageId}`; - const msgProps = useSelector((state: StateType) => - getGenericReadableMessageSelectorProps(state, props.messageId) - ); - const isMessageSelected = useMessageSelected(props.messageId); + const isMessageSelected = useMessageSelected(messageId); const selectedIsBlocked = useSelectedIsBlocked(); const multiSelectMode = useIsMessageSelectionMode(); + const convoId = useSelectedConversationKey(); + const direction = useMessageDirection(messageId); + const isKickedFromGroup = useSelectedIsKickedFromGroup(); + const ref = useRef(null); const pointerDownRef = useRef(false); const keyboardFocusedRef = useRef(false); @@ -96,7 +119,7 @@ export const GenericReadableMessage = (props: Props) => { y: rect.top, height: rect.height, width: rect.width, - offsetX: msgProps?.direction === 'incoming' ? -halfWidth : halfWidth, + offsetX: direction === 'incoming' ? -halfWidth : halfWidth, }; }; @@ -105,7 +128,7 @@ export const GenericReadableMessage = (props: Props) => { e: MouseEvent | KeyboardEvent, overridePosition?: { x: number; y: number } ) => { - if (!selectedIsBlocked && !multiSelectMode && !msgProps?.isKickedFromGroup) { + if (!selectedIsBlocked && !multiSelectMode && !isKickedFromGroup) { showMessageContextMenu({ id: ctxMenuID, event: e, @@ -113,19 +136,9 @@ export const GenericReadableMessage = (props: Props) => { }); } }, - [selectedIsBlocked, ctxMenuID, multiSelectMode, msgProps?.isKickedFromGroup] + [selectedIsBlocked, ctxMenuID, multiSelectMode, isKickedFromGroup] ); - const convoReactionsEnabled = useMemo(() => { - if (msgProps?.convoId) { - const conversationModel = ConvoHub.use().get(msgProps?.convoId); - if (conversationModel) { - return conversationModel.hasReactions(); - } - } - return true; - }, [msgProps?.convoId]); - const onKeyDown = (e: KeyboardEvent) => { if (isButtonClickKey(e)) { if (e.target instanceof HTMLElement && e.target.tagName === 'BUTTON') { @@ -165,11 +178,18 @@ export const GenericReadableMessage = (props: Props) => { scopeId: messageId, }); - if (!msgProps) { + const messageType = useMessageType(messageId); + + if (!convoId || !messageId || !messageType) { return null; } const selected = isMessageSelected || false; + const CmpToRender = getMessageComponent(messageType); + + if (!CmpToRender) { + throw new Error(`Couldn't find a component for message type ${messageType}`); + } return ( { } }} > - diff --git a/ts/components/conversation/message/message-item/GroupUpdateMessage.tsx b/ts/components/conversation/message/message-item/GroupUpdateMessage.tsx index f41a0efed..31ace9638 100644 --- a/ts/components/conversation/message/message-item/GroupUpdateMessage.tsx +++ b/ts/components/conversation/message/message-item/GroupUpdateMessage.tsx @@ -13,11 +13,12 @@ import { import { Localizer } from '../../../basic/Localizer'; import { ExpirableReadableMessage } from './ExpirableReadableMessage'; import { NotificationBubble } from './notification-bubble/NotificationBubble'; -import type { WithMessageId } from '../../../../session/types/with'; +import type { WithContextMenuId, WithMessageId } from '../../../../session/types/with'; import { useMessageGroupUpdateChange } from '../../../../state/selectors'; import { assertUnreachable } from '../../../../types/sqlSharedTypes'; import { LUCIDE_ICONS_UNICODE } from '../../../icon/lucide'; import type { TrArgs } from '../../../../localization/localeTools'; +import type { WithPopoverPosition, WithSetPopoverPosition } from '../../../SessionTooltip'; // This component is used to display group updates in the conversation view. @@ -66,8 +67,10 @@ function useChangeItem(change?: PropsForGroupUpdateType): TrArgs | null { // NOTE: [react-compiler] this convinces the compiler the hook is static const useMessageGroupUpdateChangeInternal = useMessageGroupUpdateChange; -export const GroupUpdateMessage = ({ messageId }: WithMessageId) => { - const groupChange = useMessageGroupUpdateChangeInternal(messageId); +export const GroupUpdateMessage = ( + props: WithMessageId & WithPopoverPosition & WithSetPopoverPosition & WithContextMenuId +) => { + const groupChange = useMessageGroupUpdateChangeInternal(props.messageId); const changeProps = useChangeItem(groupChange); @@ -77,10 +80,11 @@ export const GroupUpdateMessage = ({ messageId }: WithMessageId) => { return ( diff --git a/ts/components/conversation/message/message-item/InteractionNotification.tsx b/ts/components/conversation/message/message-item/InteractionNotification.tsx index b920d97d6..c2000ea16 100644 --- a/ts/components/conversation/message/message-item/InteractionNotification.tsx +++ b/ts/components/conversation/message/message-item/InteractionNotification.tsx @@ -2,7 +2,6 @@ import { isEmpty } from 'lodash'; import styled from 'styled-components'; import { assertUnreachable } from '../../../../types/sqlSharedTypes'; import { Flex } from '../../../basic/Flex'; -import { ReadableMessage } from './ReadableMessage'; import { ConversationInteractionStatus, ConversationInteractionType, @@ -12,23 +11,26 @@ import { useSelectedIsPrivate, useSelectedIsPublic, } from '../../../../state/selectors/selectedConversation'; -import { useMessageInteractionNotification, useMessageIsUnread } from '../../../../state/selectors'; -import type { WithMessageId } from '../../../../session/types/with'; +import { useMessageInteractionNotification } from '../../../../state/selectors'; +import type { WithContextMenuId, WithMessageId } from '../../../../session/types/with'; import { tr } from '../../../../localization/localeTools'; import { useConversationUsernameWithFallback } from '../../../../hooks/useParamSelector'; +import { ExpirableReadableMessage } from './ExpirableReadableMessage'; +import type { WithPopoverPosition, WithSetPopoverPosition } from '../../../SessionTooltip'; const StyledFailText = styled.div` color: var(--danger-color); `; -export const InteractionNotification = (props: WithMessageId) => { +export const InteractionNotification = ( + props: WithMessageId & WithPopoverPosition & WithSetPopoverPosition & WithContextMenuId +) => { const { messageId } = props; const convoId = useSelectedConversationKey(); const displayName = useConversationUsernameWithFallback(true, convoId); const isGroup = !useSelectedIsPrivate(); const isCommunity = useSelectedIsPublic(); - const isUnread = useMessageIsUnread(messageId) || false; const interactionNotification = useMessageInteractionNotification(messageId); if (!convoId || !messageId || !interactionNotification) { @@ -72,9 +74,10 @@ export const InteractionNotification = (props: WithMessageId) => { } return ( - @@ -89,6 +92,6 @@ export const InteractionNotification = (props: WithMessageId) => { > {text} - + ); }; diff --git a/ts/components/conversation/message/message-item/Message.tsx b/ts/components/conversation/message/message-item/Message.tsx deleted file mode 100644 index 9fc4f75c7..000000000 --- a/ts/components/conversation/message/message-item/Message.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { GenericReadableMessage } from './GenericReadableMessage'; - -type Props = { - messageId: string; -}; - -export const Message = (props: Props) => { - const ctxMenuID = `ctx-menu-message-${props.messageId}`; - - return ; -}; diff --git a/ts/components/conversation/message/message-item/MessageRequestResponse.tsx b/ts/components/conversation/message/message-item/MessageRequestResponse.tsx index 845e9dc3c..38c8c2720 100644 --- a/ts/components/conversation/message/message-item/MessageRequestResponse.tsx +++ b/ts/components/conversation/message/message-item/MessageRequestResponse.tsx @@ -1,16 +1,18 @@ import { useConversationUsernameWithFallback } from '../../../../hooks/useParamSelector'; -import type { WithMessageId } from '../../../../session/types/with'; -import { useMessageAuthorIsUs, useMessageIsUnread } from '../../../../state/selectors'; +import type { WithContextMenuId, WithMessageId } from '../../../../session/types/with'; +import { useMessageAuthorIsUs } from '../../../../state/selectors'; import { useSelectedConversationKey } from '../../../../state/selectors/selectedConversation'; import { Flex } from '../../../basic/Flex'; import { Localizer } from '../../../basic/Localizer'; import { SpacerSM, TextWithChildren } from '../../../basic/Text'; -import { ReadableMessage } from './ReadableMessage'; +import type { WithPopoverPosition, WithSetPopoverPosition } from '../../../SessionTooltip'; +import { ExpirableReadableMessage } from './ExpirableReadableMessage'; -// Note: this should not respond to the disappearing message conversation setting so we use the ReadableMessage directly -export const MessageRequestResponse = ({ messageId }: WithMessageId) => { +export const MessageRequestResponse = ({ + messageId, + ...props +}: WithMessageId & WithPopoverPosition & WithSetPopoverPosition & WithContextMenuId) => { const conversationId = useSelectedConversationKey(); - const isUnread = useMessageIsUnread(messageId) || false; const isUs = useMessageAuthorIsUs(messageId); const name = useConversationUsernameWithFallback(true, conversationId); @@ -20,9 +22,10 @@ export const MessageRequestResponse = ({ messageId }: WithMessageId) => { } return ( - @@ -43,6 +46,6 @@ export const MessageRequestResponse = ({ messageId }: WithMessageId) => { )} - + ); }; diff --git a/ts/components/conversation/message/message-item/ReadableMessage.tsx b/ts/components/conversation/message/message-item/ReadableMessage.tsx deleted file mode 100644 index 7fd56bcd1..000000000 --- a/ts/components/conversation/message/message-item/ReadableMessage.tsx +++ /dev/null @@ -1,219 +0,0 @@ -import { debounce, noop } from 'lodash'; -import { - SessionDataTestId, - AriaRole, - MouseEvent, - MouseEventHandler, - ReactNode, - useCallback, - useLayoutEffect, - useState, -} from 'react'; -import { InView } from 'react-intersection-observer'; -import { useSelector } from 'react-redux'; -import { getAppDispatch } from '../../../../state/dispatch'; -import { useScrollToLoadedMessage } from '../../../../contexts/ScrollToLoadedMessage'; -import { Data } from '../../../../data/data'; -import { useHasUnread } from '../../../../hooks/useParamSelector'; -import { ConvoHub } from '../../../../session/conversations'; -import { - fetchBottomMessagesForConversation, - fetchTopMessagesForConversation, - markConversationFullyRead, - showScrollToBottomButton, -} from '../../../../state/ducks/conversations'; -import { - areMoreMessagesBeingFetched, - getMostRecentMessageId, - getOldestMessageId, - getQuotedMessageToAnimate, - getShowScrollButton, - getYoungestMessageId, -} from '../../../../state/selectors/conversations'; -import { getIsAppFocused } from '../../../../state/selectors/section'; -import { useSelectedConversationKey } from '../../../../state/selectors/selectedConversation'; -import type { WithConvoId, WithMessageId } from '../../../../session/types/with'; - -export type ReadableMessageProps = { - children: ReactNode; - messageId: string; - className?: string; - isUnread: boolean; - onClick?: MouseEventHandler; - onDoubleClickCapture?: MouseEventHandler; - dataTestId: SessionDataTestId; - role?: AriaRole; - onContextMenu?: (e: MouseEvent) => void; - isControlMessage?: boolean; -}; - -const debouncedTriggerLoadMoreTop = debounce( - (selectedConversationKey: string, oldestMessageId: string) => { - (window.inboxStore?.dispatch as any)( - fetchTopMessagesForConversation({ - conversationKey: selectedConversationKey, - oldTopMessageId: oldestMessageId, - }) - ); - }, - 100 -); - -const debouncedTriggerLoadMoreBottom = debounce( - (selectedConversationKey: string, youngestMessageId: string) => { - (window.inboxStore?.dispatch as any)( - fetchBottomMessagesForConversation({ - conversationKey: selectedConversationKey, - oldBottomMessageId: youngestMessageId, - }) - ); - }, - 100 -); - -async function markReadFromMessageId({ - conversationId, - messageId, - isUnread, -}: WithMessageId & WithConvoId & { isUnread: boolean }) { - // isUnread comes from the redux store in memory, so pretty fast and allows us to not fetch from the DB too often - if (!isUnread) { - return; - } - const found = await Data.getMessageById(messageId); - - if (!found) { - return; - } - - if (found.isUnread()) { - ConvoHub.use() - .get(conversationId) - ?.markConversationRead({ - newestUnreadDate: found.get('sent_at') || found.get('serverTimestamp') || Date.now(), - fromConfigMessage: false, - }); - } -} - -export const ReadableMessage = (props: ReadableMessageProps) => { - const { - messageId, - onContextMenu, - className, - isUnread, - onClick, - onDoubleClickCapture, - role, - dataTestId, - } = props; - - const isAppFocused = useSelector(getIsAppFocused); - const dispatch = getAppDispatch(); - - const selectedConversationKey = useSelectedConversationKey(); - const mostRecentMessageId = useSelector(getMostRecentMessageId); - const oldestMessageId = useSelector(getOldestMessageId); - const youngestMessageId = useSelector(getYoungestMessageId); - const fetchingMoreInProgress = useSelector(areMoreMessagesBeingFetched); - const conversationHasUnread = useHasUnread(selectedConversationKey); - const scrollButtonVisible = useSelector(getShowScrollButton); - - const [didScroll, setDidScroll] = useState(false); - const quotedMessageToAnimate = useSelector(getQuotedMessageToAnimate); - - const scrollToLoadedMessage = useScrollToLoadedMessage(); - - // if this unread-indicator is rendered, - // we want to scroll here only if the conversation was not opened to a specific message - // eslint-disable-next-line react-hooks/exhaustive-deps - useLayoutEffect(() => { - if ( - props.messageId === youngestMessageId && - !quotedMessageToAnimate && - !scrollButtonVisible && - !didScroll && - !conversationHasUnread - ) { - scrollToLoadedMessage(props.messageId, 'go-to-bottom'); - setDidScroll(true); - } else if (quotedMessageToAnimate) { - setDidScroll(true); - } - }); - - const onVisible = useCallback( - async (inView: boolean, _: IntersectionObserverEntry) => { - if (!selectedConversationKey) { - return; - } - // we are the most recent message - if (mostRecentMessageId === messageId) { - // make sure the app is focused, because we mark message as read here - if (inView === true && isAppFocused) { - dispatch(showScrollToBottomButton(false)); - // TODO this is pretty expensive and should instead use values from the redux store - await markReadFromMessageId({ - messageId, - conversationId: selectedConversationKey, - isUnread, - }); - - dispatch(markConversationFullyRead(selectedConversationKey)); - } else if (inView === false) { - dispatch(showScrollToBottomButton(true)); - } - } - - if (inView && isAppFocused && oldestMessageId === messageId && !fetchingMoreInProgress) { - debouncedTriggerLoadMoreTop(selectedConversationKey, oldestMessageId); - } - - if (inView && isAppFocused && youngestMessageId === messageId && !fetchingMoreInProgress) { - debouncedTriggerLoadMoreBottom(selectedConversationKey, youngestMessageId); - } - - // this part is just handling the marking of the message as read if needed - if (inView) { - // TODO this is pretty expensive and should instead use values from the redux store - await markReadFromMessageId({ - messageId, - conversationId: selectedConversationKey, - isUnread, - }); - } - }, - [ - dispatch, - selectedConversationKey, - mostRecentMessageId, - oldestMessageId, - fetchingMoreInProgress, - isAppFocused, - messageId, - youngestMessageId, - isUnread, - ] - ); - - return ( - - {props.children} - - ); -}; diff --git a/ts/components/conversation/message/message-item/notification-bubble/CallNotification.tsx b/ts/components/conversation/message/message-item/notification-bubble/CallNotification.tsx index 847ea4109..d476f1a51 100644 --- a/ts/components/conversation/message/message-item/notification-bubble/CallNotification.tsx +++ b/ts/components/conversation/message/message-item/notification-bubble/CallNotification.tsx @@ -5,9 +5,10 @@ import { ExpirableReadableMessage } from '../ExpirableReadableMessage'; import { NotificationBubble } from './NotificationBubble'; import { Localizer } from '../../../../basic/Localizer'; import { MergedLocalizerTokens } from '../../../../../localization/localeTools'; -import type { WithMessageId } from '../../../../../session/types/with'; +import type { WithContextMenuId, WithMessageId } from '../../../../../session/types/with'; import { useMessageCallNotificationType } from '../../../../../state/selectors'; import { LUCIDE_ICONS_UNICODE } from '../../../../icon/lucide'; +import type { WithPopoverPosition, WithSetPopoverPosition } from '../../../../SessionTooltip'; type StyleType = Record< CallNotificationType, @@ -32,10 +33,10 @@ const style = { }, } satisfies StyleType; -export const CallNotification = (props: WithMessageId) => { - const { messageId } = props; - - const notificationType = useMessageCallNotificationType(messageId); +export const CallNotification = ( + props: WithMessageId & WithPopoverPosition & WithSetPopoverPosition & WithContextMenuId +) => { + const notificationType = useMessageCallNotificationType(props.messageId); const name = useSelectedNicknameOrProfileNameOrShortenedPubkey(); @@ -47,10 +48,11 @@ export const CallNotification = (props: WithMessageId) => { return ( {notificationTextKey === 'callsInProgress' ? ( diff --git a/ts/components/conversation/right-panel/overlay/message-info/OverlayMessageInfo.tsx b/ts/components/conversation/right-panel/overlay/message-info/OverlayMessageInfo.tsx index 4b83c6944..a26ea65c5 100644 --- a/ts/components/conversation/right-panel/overlay/message-info/OverlayMessageInfo.tsx +++ b/ts/components/conversation/right-panel/overlay/message-info/OverlayMessageInfo.tsx @@ -17,12 +17,10 @@ import { replyToMessage, resendMessage, } from '../../../../../interactions/conversationInteractions'; -import { deleteMessagesById } from '../../../../../interactions/conversations/unsendingInteractions'; import { useMessageAttachments, useMessageBody, useMessageDirection, - useMessageIsDeletable, useMessageQuote, useMessageSender, useMessageServerTimestamp, @@ -43,7 +41,6 @@ import { GoogleChrome } from '../../../../../util'; import { saveAttachmentToDisk } from '../../../../../util/attachment/attachmentsUtil'; import { SpacerLG, SpacerMD, SpacerXL } from '../../../../basic/Text'; import { PanelButtonGroup, PanelIconButton } from '../../../../buttons'; -import { Message } from '../../../message/message-item/Message'; import { AttachmentInfo, MessageInfo } from './components'; import { AttachmentCarousel } from './components/AttachmentCarousel'; import { ToastUtils } from '../../../../../session/utils'; @@ -55,6 +52,8 @@ import { tr } from '../../../../../localization/localeTools'; import { AppDispatch } from '../../../../../state/createStore'; import { useKeyboardShortcut } from '../../../../../hooks/useKeyboardShortcut'; import { KbdShortcut } from '../../../../../util/keyboardShortcuts'; +import { useDeleteMessagesCb } from '../../../../menuAndSettingsHooks/useDeleteMessagesCb'; +import { GenericReadableMessage } from '../../../message/message-item/GenericReadableMessage'; // NOTE we override the default max-widths when in the detail isDetailView const StyledMessageBody = styled.div` @@ -87,7 +86,7 @@ const MessageBody = ({ return ( - + ); @@ -253,7 +252,6 @@ function useMessageId() { // NOTE: [react-compiler] this has to live here for the hook to be identified as static function useMessageDetailsInternal(messageId?: string) { const rightOverlayMode = useRightOverlayMode(); - const isDeletable = useMessageIsDeletable(messageId); const direction = useMessageDirection(messageId); const timestamp = useMessageTimestamp(messageId); const serverTimestamp = useMessageServerTimestamp(messageId); @@ -267,7 +265,6 @@ function useMessageDetailsInternal(messageId?: string) { return { rightOverlayMode, - isDeletable, direction, timestamp, serverTimestamp, @@ -288,17 +285,20 @@ function useClosePanelIfMessageDeleted(sender?: string) { }, [sender, dispatch]); } -const useKeyboardShortcutLocal = useKeyboardShortcut; +const useKeyboardShortcutInternal = useKeyboardShortcut; +const useDeleteMessagesCbInternal = useDeleteMessagesCb; +const useSelectedConversationKeyInternal = useSelectedConversationKey; export const OverlayMessageInfo = () => { const dispatch = getAppDispatch(); const messageId = useMessageId(); const messageInfo = useMessageInfo(messageId); + const selectedConversationKey = useSelectedConversationKeyInternal(); + const deleteMessagesCb = useDeleteMessagesCbInternal(selectedConversationKey); const { rightOverlayMode, - isDeletable, direction, timestamp, serverTimestamp, @@ -310,7 +310,7 @@ export const OverlayMessageInfo = () => { const closePanelCb = () => closePanel(dispatch); - useKeyboardShortcutLocal({ + useKeyboardShortcutInternal({ shortcut: KbdShortcut.closeRightPanel, handler: closePanelCb, }); @@ -430,14 +430,14 @@ export const OverlayMessageInfo = () => { /> )} {/* Deleting messages sends a "delete message" message so it must be disabled for message requests. */} - {isDeletable && !isLegacyGroup && !isIncomingMessageRequest && ( + {!isLegacyGroup && !isIncomingMessageRequest && deleteMessagesCb && ( } color={'var(--danger-color)'} dataTestId="delete-from-details" onClick={() => { - void deleteMessagesById([messageId], convoId); + void deleteMessagesCb?.(messageId); }} /> )} diff --git a/ts/components/dialog/SessionConfirm.tsx b/ts/components/dialog/SessionConfirm.tsx index efd016453..576478468 100644 --- a/ts/components/dialog/SessionConfirm.tsx +++ b/ts/components/dialog/SessionConfirm.tsx @@ -13,30 +13,35 @@ import { SessionButton, SessionButtonColor, SessionButtonType } from '../basic/S import { SessionRadioGroup, SessionRadioItems } from '../basic/SessionRadioGroup'; import { SessionSpinner } from '../loading'; import { ModalDescription } from './shared/ModalDescriptionContainer'; -import { tr, type TrArgs } from '../../localization/localeTools'; +import { messageArgsToArgsOnly, tr, type TrArgs } from '../../localization/localeTools'; import { ModalFlexContainer } from './shared/ModalFlexContainer'; +import { ModalWarning } from './shared/ModalWarning'; export type SessionConfirmDialogProps = { i18nMessage?: TrArgs; - title?: string; + title?: TrArgs; + /** + * Warning message to display in the modal + */ + warningMessage?: TrArgs; radioOptions?: SessionRadioItems; onClose?: any; /** - * function to run on ok click. Closes modal after execution by default - * sometimes the callback might need arguments when using radioOptions + * Callback to run on ok click. Closes modal after execution by default + * If we have radioOptions rendered, the args will be the value of the selected radio option */ - onClickOk?: (...args: Array) => Promise | void; + onClickOk?: (chosenOptionValue?: string) => Promise | unknown; - onClickClose?: () => any; + onClickClose?: () => Promise | unknown; /** - * function to run on close click. Closes modal after execution by default + * Callback to run on close click. Closes modal after execution by default */ - onClickCancel?: () => any; + onClickCancel?: () => Promise | unknown; - okText: string; - cancelText?: string; + okText: TrArgs; + cancelText?: TrArgs; hideCancel?: boolean; okTheme?: SessionButtonColor; /** @@ -70,8 +75,9 @@ export const SessionConfirm = (props: SessionConfirmDialogProps) => { radioOptions?.length ? radioOptions[0].value : '' ); - const okText = props.okText; - const cancelText = props.cancelText || tr('cancel'); + const cancelText = props.cancelText + ? tr(props.cancelText.token, messageArgsToArgsOnly(props.cancelText)) + : tr('cancel'); const onClickOkHandler = async () => { if (onClickOk) { @@ -105,20 +111,29 @@ export const SessionConfirm = (props: SessionConfirmDialogProps) => { * Performs specified on close action then removes the modal. */ const onClickCancelHandler = () => { - onClickCancel?.(); - onClickClose?.(); + void onClickCancel?.(); + void onClickClose?.(); window.inboxStore?.dispatch(updateConfirmModal(null)); }; return ( : null} - onClose={onClickClose} + headerChildren={ + title ? ( + + ) : null + } + onClose={() => { + void onClickClose?.(); + }} buttonChildren={ { {i18nMessage ? ( ) : null} + + {props.warningMessage ? ( + + ) : null} {radioOptions && chosenOption !== '' ? ( { try { await Storage.put('releaseChannel', channel); diff --git a/ts/components/dialog/shared/ModalWarning.tsx b/ts/components/dialog/shared/ModalWarning.tsx new file mode 100644 index 000000000..758301c8e --- /dev/null +++ b/ts/components/dialog/shared/ModalWarning.tsx @@ -0,0 +1,25 @@ +import type { SessionDataTestId } from 'react'; +import styled from 'styled-components'; +import type { CSSProperties } from 'styled-components'; +import { Localizer, type WithAsTag, type WithClassName } from '../../basic/Localizer'; +import type { TrArgs } from '../../../localization/localeTools'; + +const StyledModalWarningContainer = styled.div` + padding: 0 var(--margins-md); // no margins top&bottom here, as it depends on if actions are displayed or not + max-width: 500px; + line-height: 1.2; + text-align: center; + font-size: var(--font-size-md); +`; + +export function ModalWarning(props: { + localizerProps: TrArgs & WithAsTag & WithClassName; + dataTestId: SessionDataTestId; + style?: CSSProperties; +}) { + return ( + + + + ); +} diff --git a/ts/components/dialog/user-settings/pages/PrivacySettingsPage.tsx b/ts/components/dialog/user-settings/pages/PrivacySettingsPage.tsx index 127b7ef58..96241c71b 100644 --- a/ts/components/dialog/user-settings/pages/PrivacySettingsPage.tsx +++ b/ts/components/dialog/user-settings/pages/PrivacySettingsPage.tsx @@ -39,10 +39,10 @@ const toggleCallMediaPermissions = async (triggerUIUpdate: () => void) => { if (!currentValue) { window.inboxStore?.dispatch( updateConfirmModal({ - title: tr('callsVoiceAndVideoBeta'), + title: { token: 'callsVoiceAndVideoBeta' }, i18nMessage: { token: 'callsVoiceAndVideoModalDescription' }, okTheme: SessionButtonColor.Danger, - okText: tr('theContinue'), + okText: { token: 'theContinue' }, onClickOk: async () => { await window.toggleCallMediaPermissionsTo(true); triggerUIUpdate(); @@ -54,7 +54,7 @@ const toggleCallMediaPermissions = async (triggerUIUpdate: () => void) => { triggerUIUpdate(); onClose(); }, - onClickClose: onClose, + onClickClose: onClose ? void onClose() : undefined, }) ); } else { @@ -67,10 +67,10 @@ async function toggleLinkPreviews(isToggleOn: boolean, forceUpdate: () => void) if (!isToggleOn) { window.inboxStore?.dispatch( updateConfirmModal({ - title: tr('linkPreviewsSend'), + title: { token: 'linkPreviewsSend' }, i18nMessage: { token: 'linkPreviewsSendModalDescription' }, okTheme: SessionButtonColor.Danger, - okText: tr('theContinue'), + okText: { token: 'theContinue' }, onClickOk: async () => { const newValue = !isToggleOn; await window.setSettingValue(SettingsKey.settingsLinkPreview, newValue); diff --git a/ts/components/leftpane/overlay/OverlayMessageRequest.tsx b/ts/components/leftpane/overlay/OverlayMessageRequest.tsx index 9bef64d0e..3cd5d559d 100644 --- a/ts/components/leftpane/overlay/OverlayMessageRequest.tsx +++ b/ts/components/leftpane/overlay/OverlayMessageRequest.tsx @@ -70,10 +70,10 @@ export const OverlayMessageRequest = () => { function handleClearAllRequestsClick() { dispatch( updateConfirmModal({ - title: tr('clearAll'), + title: { token: 'clearAll' }, i18nMessage: { token: 'messageRequestsClearAllExplanation' }, okTheme: SessionButtonColor.Danger, - okText: tr('clear'), + okText: { token: 'clear' }, onClickOk: async () => { window?.log?.info('Blocking all message requests'); if (!hasRequests) { diff --git a/ts/components/menu/items/DeleteMessage/DeleteMessageMenuItem.tsx b/ts/components/menu/items/DeleteMessage/DeleteMessageMenuItem.tsx index c89b921d4..3455fb51d 100644 --- a/ts/components/menu/items/DeleteMessage/DeleteMessageMenuItem.tsx +++ b/ts/components/menu/items/DeleteMessage/DeleteMessageMenuItem.tsx @@ -2,21 +2,14 @@ import styled from 'styled-components'; import { isEmpty } from 'lodash'; import useUpdate from 'react-use/lib/useUpdate'; import useInterval from 'react-use/lib/useInterval'; -import { - useMessageIsDeletable, - useMessageIsDeletableForEveryone, -} from '../../../../state/selectors'; -import { - useSelectedConversationKey, - useSelectedIsPublic, -} from '../../../../state/selectors/selectedConversation'; +import { useSelectedConversationKey } from '../../../../state/selectors/selectedConversation'; import { MenuItem } from '../MenuItem'; import { tr } from '../../../../localization/localeTools'; import { LUCIDE_ICONS_UNICODE } from '../../../icon/lucide'; import { DURATION } from '../../../../session/constants'; import { formatAbbreviatedExpireDoubleTimer } from '../../../../util/i18n/formatting/expirationTimer'; import { useMessageExpirationPropsById } from '../../../../hooks/useParamSelector'; -import { useMessageInteractions } from '../../../../hooks/useMessageInteractions'; +import { useDeleteMessagesCb } from '../../../menuAndSettingsHooks/useDeleteMessagesCb'; const StyledDeleteItemContent = styled.span` display: flex; @@ -95,22 +88,20 @@ const ExpiresInItem = ({ messageId }: { messageId: string }) => { export const DeleteItem = ({ messageId }: { messageId: string }) => { const convoId = useSelectedConversationKey(); - const isPublic = useSelectedIsPublic(); - const isDeletable = useMessageIsDeletable(messageId); - const isDeletableForEveryone = useMessageIsDeletableForEveryone(messageId); + const deleteMessagesCb = useDeleteMessagesCb(convoId); + console.warn('fixme allow ot delete a message to a sogs that failed to send'); - const { deleteFromConvo } = useMessageInteractions(messageId); - const onClick = () => { - deleteFromConvo(isPublic, convoId); - }; - - if (!convoId || (isPublic && !isDeletableForEveryone) || (!isPublic && !isDeletable)) { + if (!deleteMessagesCb || !messageId) { return null; } return ( - + void deleteMessagesCb(messageId)} + iconType={LUCIDE_ICONS_UNICODE.TRASH2} + isDangerAction={true} + > {tr('delete')} diff --git a/ts/components/menu/items/SelectMessage/SelectMessageMenuItem.tsx b/ts/components/menu/items/SelectMessage/SelectMessageMenuItem.tsx new file mode 100644 index 000000000..fb9d54955 --- /dev/null +++ b/ts/components/menu/items/SelectMessage/SelectMessageMenuItem.tsx @@ -0,0 +1,22 @@ +import { MenuItem } from '../MenuItem'; +import { LUCIDE_ICONS_UNICODE } from '../../../icon/lucide'; +import { Localizer } from '../../../basic/Localizer'; +import { useSelectMessageViaMenuCb } from '../../../../hooks/useMessageInteractions'; + +export function SelectMessageMenuItem({ messageId }: { messageId: string }) { + const selectViaMenu = useSelectMessageViaMenuCb(messageId); + + if (!selectViaMenu) { + return null; + } + + return ( + + + + ); +} diff --git a/ts/components/menuAndSettingsHooks/useClearAllMessages.ts b/ts/components/menuAndSettingsHooks/useClearAllMessages.ts index b52246411..4db3ce1f4 100644 --- a/ts/components/menuAndSettingsHooks/useClearAllMessages.ts +++ b/ts/components/menuAndSettingsHooks/useClearAllMessages.ts @@ -95,12 +95,12 @@ export function useClearAllMessagesCb({ conversationId }: { conversationId: stri const cb = () => dispatch( updateConfirmModal({ - title: tr('clearMessages'), + title: { token: 'clearMessages' }, i18nMessage, onClickOk, okTheme: SessionButtonColor.Danger, onClickClose, - okText: tr('clear'), + okText: { token: 'clear' }, radioOptions: isGroupV2AndAdmin ? [ { diff --git a/ts/components/menuAndSettingsHooks/useDeclineMessageRequest.ts b/ts/components/menuAndSettingsHooks/useDeclineMessageRequest.ts index 8432402a0..b026d17fc 100644 --- a/ts/components/menuAndSettingsHooks/useDeclineMessageRequest.ts +++ b/ts/components/menuAndSettingsHooks/useDeclineMessageRequest.ts @@ -1,5 +1,5 @@ import { declineConversationWithoutConfirm } from '../../interactions/conversationInteractions'; -import { tr, type TrArgs } from '../../localization'; +import { type TrArgs } from '../../localization'; import { updateConfirmModal } from '../../state/ducks/modalDialog'; import { SessionButtonColor } from '../basic/SessionButton'; import { getAppDispatch } from '../../state/dispatch'; @@ -52,9 +52,9 @@ export const useDeclineMessageRequest = ({ dispatch( updateConfirmModal({ - okText: alsoBlock ? tr('block') : tr('delete'), - cancelText: tr('cancel'), - title: alsoBlock ? tr('block') : tr('delete'), + okText: alsoBlock ? { token: 'block' } : { token: 'delete' }, + cancelText: { token: 'cancel' }, + title: alsoBlock ? { token: 'block' } : { token: 'delete' }, i18nMessage, okTheme: SessionButtonColor.Danger, onClickOk: async () => { diff --git a/ts/components/menuAndSettingsHooks/useDeleteMessagesCb.tsx b/ts/components/menuAndSettingsHooks/useDeleteMessagesCb.tsx new file mode 100644 index 000000000..c021a5fa9 --- /dev/null +++ b/ts/components/menuAndSettingsHooks/useDeleteMessagesCb.tsx @@ -0,0 +1,428 @@ +import type { PubkeyType, GroupPubkeyType } from 'libsession_util_nodejs'; +import { compact, isArray } from 'lodash'; +import { useDispatch } from 'react-redux'; +import { updateConfirmModal } from '../../state/ducks/modalDialog'; +import { useIsMe, useIsPublic, useWeAreAdmin } from '../../hooks/useParamSelector'; +import { SessionButtonColor } from '../basic/SessionButton'; +import { closeRightPanel, resetSelectedMessageIds } from '../../state/ducks/conversations'; +import { tr, type TrArgs } from '../../localization/localeTools'; +import { useWeAreCommunityAdminOrModerator } from '../../state/selectors/conversations'; +import type { ConversationModel } from '../../models/conversation'; +import type { MessageModel } from '../../models/message'; +import { PubKey } from '../../session/types'; +import { ToastUtils } from '../../session/utils'; +import { UserGroupsWrapperActions } from '../../webworker/workers/browser/libsession_worker_interface'; +import { Data } from '../../data/data'; +import { MessageQueue } from '../../session/sending'; +import { + deleteMessagesFromSwarmAndCompletelyLocally, + deleteMessagesFromSwarmAndMarkAsDeletedLocally, +} from '../../interactions/conversations/unsendingInteractions'; +import { deleteSogsMessageByServerIds } from '../../session/apis/open_group_api/sogsv3/sogsV3DeleteMessages'; +import { SnodeNamespaces } from '../../session/apis/snode_api/namespaces'; +import type { SessionRadioItems } from '../basic/SessionRadioGroup'; +import { getSodiumRenderer } from '../../session/crypto'; +import { GroupUpdateDeleteMemberContentMessage } from '../../session/messages/outgoing/controlMessage/group_v2/to_group/GroupUpdateDeleteMemberContentMessage'; +import { UnsendMessage } from '../../session/messages/outgoing/controlMessage/UnsendMessage'; +import { NetworkTime } from '../../util/NetworkTime'; +import { deleteMessagesLocallyOnly } from '../../interactions/conversations/deleteMessagesLocallyOnly'; +import { sectionActions } from '../../state/ducks/section'; +import { ConvoHub } from '../../session/conversations'; +import { uuidV4 } from '../../util/uuid'; +import { isUsAnySogsFromCache } from '../../session/apis/open_group_api/sogsv3/knownBlindedkeys'; + +const deleteMessageDeviceOnly = 'deleteMessageDeviceOnly'; +const deleteMessageDevicesAll = 'deleteMessageDevicesAll'; +const deleteMessageEveryone = 'deleteMessageEveryone'; + +type MessageDeletionType = + | typeof deleteMessageDeviceOnly + | typeof deleteMessageDevicesAll + | typeof deleteMessageEveryone; + +/** + * Offer to delete for everyone or not, based on what is currently selected + * and our role in the corresponding conversation. + */ +export function useDeleteMessagesCb(conversationId: string | undefined) { + const dispatch = useDispatch(); + + const isMe = useIsMe(conversationId); + const isPublic = useIsPublic(conversationId); + const weAreAdminOrModCommunity = useWeAreCommunityAdminOrModerator(conversationId); + const weAreAdminGroup = useWeAreAdmin(conversationId); + + const closeDialog = () => dispatch(updateConfirmModal(null)); + + if (!conversationId) { + return null; + } + + return async (messageIds: string | Array | undefined) => { + const count = isArray(messageIds) ? messageIds.length : messageIds ? 1 : 0; + const convo = ConvoHub.use().get(conversationId); + if (!convo || !messageIds || (!isArray(messageIds) && !messageIds.length)) { + return; + } + const messageIdsArr = isArray(messageIds) ? messageIds : [messageIds]; + + const canDeleteAllForEveryoneAsAdmin = + (isPublic && weAreAdminOrModCommunity) || (!isPublic && weAreAdminGroup); + + const msgModels = await Data.getMessagesById(messageIdsArr); + const senders = compact(msgModels.map(m => m.getSource())); + + const anyAreMarkAsDeleted = msgModels.some(m => m.get('isDeleted')); + const anyAreControlMessages = msgModels.some(m => m.isControlMessage()); + + const canDeleteAllForEveryoneAsMe = senders.every(isUsAnySogsFromCache); + const canDeleteAllForEveryone = + (canDeleteAllForEveryoneAsMe || canDeleteAllForEveryoneAsAdmin) && + !anyAreControlMessages && + !anyAreMarkAsDeleted; + + // Note: the isMe case has no radio buttons, so we just show the description below + const i18nMessage: TrArgs | undefined = isMe + ? { token: 'deleteMessageDescriptionDevice', count } + : undefined; + + const canDeleteFromAllDevices = isMe && !anyAreControlMessages && !anyAreMarkAsDeleted; + + const radioOptions: SessionRadioItems | undefined = [ + { + label: tr(deleteMessageDeviceOnly), + value: deleteMessageDeviceOnly, + inputDataTestId: `input-${deleteMessageDeviceOnly}` as const, + labelDataTestId: `label-${deleteMessageDeviceOnly}` as const, + disabled: false, // we can always delete message locally + }, + isMe + ? { + label: tr(deleteMessageDevicesAll), + value: deleteMessageDevicesAll, + inputDataTestId: `input-${deleteMessageDevicesAll}` as const, + labelDataTestId: `label-${deleteMessageDevicesAll}` as const, + disabled: !canDeleteFromAllDevices, + } + : { + label: tr(deleteMessageEveryone), + value: deleteMessageEveryone, + inputDataTestId: `input-${deleteMessageEveryone}` as const, + labelDataTestId: `label-${deleteMessageEveryone}` as const, + disabled: !canDeleteAllForEveryone, + }, + ]; + + dispatch( + updateConfirmModal({ + title: { token: 'deleteMessage', count }, + radioOptions, + i18nMessage, + + okText: { token: 'delete' }, + + okTheme: SessionButtonColor.Danger, + onClickOk: async args => { + if ( + args !== deleteMessageEveryone && + args !== deleteMessageDevicesAll && + args !== deleteMessageDeviceOnly + ) { + throw new Error('doDeleteSelectedMessages: invalid args onClickOk'); + } + + await doDeleteSelectedMessages({ + selectedMessages: msgModels, + conversation: convo, + deletionType: args, + }); + dispatch(updateConfirmModal(null)); + dispatch(closeRightPanel()); + dispatch(sectionActions.resetRightOverlayMode()); + }, + onClickClose: closeDialog, + }) + ); + }; +} + +/** + * Delete the messages from the conversation. + * Also deletes messages from the swarm/sogs if needed, sends unsend requests for syncing etc... + * + * Note: this function does not check if the user is allowed to delete the messages. + * The call will just fail if the user is not allowed to delete the messages, silently. + * So make sure to check the user permissions before calling this function and to display only valid actions for the user's permissions. + * + */ +const doDeleteSelectedMessages = async ({ + conversation, + selectedMessages, + deletionType, +}: { + selectedMessages: Array; + conversation: ConversationModel; + deletionType: MessageDeletionType; +}) => { + // legacy groups are read only + if (conversation.isClosedGroup() && PubKey.is05Pubkey(conversation.id)) { + window.log.info( + 'doDeleteSelectedMessages: legacy groups are read only. Remove the conversation to remove a message' + ); + return; + } + + console.warn('selectedMessages', selectedMessages); + console.warn('deletionType', deletionType); + + if (deletionType === deleteMessageDeviceOnly) { + // Mark those messages as deleted only locally + await deleteMessagesLocallyOnly({ + conversation, + messages: selectedMessages, + deletionType: 'markDeleted', + }); + ToastUtils.pushDeleted(selectedMessages.length); + + return; + } + + // device only was handled above, so this isPublic can only mean delete for everyone in a community + if (conversation.isOpenGroupV2()) { + await doDeleteSelectedMessagesInSOGS(selectedMessages, conversation); + return; + } + + if (deletionType === deleteMessageDevicesAll) { + // Delete those messages locally, from our swarm and from our other devices, but not for anyone else in the conversation + await unsendMessageJustForThisUserAllDevices(conversation, selectedMessages); + return; + } + + console.warn('FIXME: this is all done but untested'); + // Note: a groupv2 member can delete messages for everyone if they are the admin, or if that message is theirs. + + if (deletionType !== deleteMessageEveryone) { + throw new Error('doDeleteSelectedMessages: invalid deletionType'); + } + + if (conversation.isPrivate()) { + // Note: we cannot delete for everyone a message in non 05-private chat + if (!PubKey.is05Pubkey(conversation.id)) { + throw new Error('unsendMessagesForEveryone1o1AndLegacy requires a 05 key'); + } + // private chats: we want to delete those messages completely (not just marked as deleted) + await unsendMessagesForEveryone1o1(conversation, conversation.id, selectedMessages); + await deleteMessagesFromSwarmAndCompletelyLocally(conversation, selectedMessages); + ToastUtils.pushDeleted(selectedMessages.length); + window.inboxStore?.dispatch(resetSelectedMessageIds()); + + return; + } + + if (!conversation.isClosedGroupV2() || !PubKey.is03Pubkey(conversation.id)) { + // considering the above, the only valid case here is 03 groupv2 + throw new Error('doDeleteSelectedMessages: invalid conversation type'); + } + + await unsendMessagesForEveryoneGroupV2({ + groupPk: conversation.id, + msgsToDelete: selectedMessages, + allMessagesFrom: [], // currently we cannot remove all the messages from a specific pubkey but we do already handle them on the receiving side + }); + + // 03 groups: mark as deleted + await deleteMessagesFromSwarmAndMarkAsDeletedLocally(conversation, selectedMessages); + + window.inboxStore?.dispatch(resetSelectedMessageIds()); + ToastUtils.pushDeleted(selectedMessages.length); +}; + +/** + * Send an UnsendMessage synced message so our devices removes those messages locally, + * and send an unsend request on our swarm so this message is effectively removed from it. + * Then, deletes completely the messages locally. + * + * Show a toast on error/success and reset the selection + */ +async function unsendMessageJustForThisUserAllDevices( + conversation: ConversationModel, + msgsToDelete: Array +) { + window?.log?.info('Deleting messages just for this user'); + + const unsendMsgObjects = getUnsendMessagesObjects1o1OrLegacyGroups(msgsToDelete); + + // sending to our other devices all the messages separately for now + await Promise.all( + unsendMsgObjects.map(unsendObject => + MessageQueue.use() + .sendSyncMessage({ namespace: SnodeNamespaces.Default, message: unsendObject }) + .catch(window?.log?.error) + ) + ); + await deleteMessagesFromSwarmAndCompletelyLocally(conversation, msgsToDelete); + + // Update view and trigger update + window.inboxStore?.dispatch(resetSelectedMessageIds()); + ToastUtils.pushDeleted(unsendMsgObjects.length); +} + +/** + * Attempt to delete the messages from the SOGS. + * Note: this function does not check if the user is allowed to delete the messages. + * The call will just fail if the user is not allowed to delete the messages, silently, so make + * sure to check the user permissions before calling this function and to display only valid actions for the user's permissions. + */ +async function doDeleteSelectedMessagesInSOGS( + selectedMessages: Array, + conversation: ConversationModel +) { + const toDeleteLocallyIds = await deleteOpenGroupMessages(selectedMessages, conversation); + if (toDeleteLocallyIds.length === 0) { + // Failed to delete those messages from the sogs. + ToastUtils.pushToastError('errorGeneric', tr('errorGeneric')); + return; + } + + await deleteMessagesLocallyOnly({ + conversation, + messages: selectedMessages, + deletionType: 'complete', + }); + + // successful deletion + ToastUtils.pushDeleted(toDeleteLocallyIds.length); + window.inboxStore?.dispatch(resetSelectedMessageIds()); +} + +/** + * + * @param messages the list of MessageModel to delete + * @param convo the conversation to delete from (only v2 opengroups are supported) + */ +async function deleteOpenGroupMessages( + messages: Array, + convo: ConversationModel +): Promise> { + if (!convo.isOpenGroupV2()) { + throw new Error('cannot delete public message on a non public groups'); + } + + const roomInfos = convo.toOpenGroupV2(); + // on v2 servers we can only remove a single message per request.. + // so logic here is to delete each messages and get which one where not removed + const validServerIdsToRemove = compact( + messages.map(msg => { + return msg.get('serverId'); + }) + ); + + const validMessageModelsToRemove = compact( + messages.map(msg => { + const serverId = msg.get('serverId'); + if (serverId) { + return msg; + } + return undefined; + }) + ); + + let allMessagesAreDeleted: boolean = false; + if (validServerIdsToRemove.length) { + allMessagesAreDeleted = await deleteSogsMessageByServerIds(validServerIdsToRemove, roomInfos); + } + // remove only the messages we managed to remove on the server + if (allMessagesAreDeleted) { + window?.log?.info('Removed all those serverIds messages successfully'); + return validMessageModelsToRemove.map(m => m.id); + } + window?.log?.info( + 'failed to remove all those serverIds message. not removing them locally neither' + ); + return []; +} + +async function unsendMessagesForEveryone1o1( + conversation: ConversationModel, + destination: PubkeyType, + msgsToDelete: Array +) { + const unsendMsgObjects = getUnsendMessagesObjects1o1OrLegacyGroups(msgsToDelete); + + if (!conversation.isPrivate()) { + throw new Error('unsendMessagesForEveryone1o1 only works with private conversations'); + } + + // sending to recipient all the messages separately for now + console.warn('fixme sending to recipient all the messages separately for'); + await Promise.all( + unsendMsgObjects.map(unsendObject => + MessageQueue.use() + .sendToPubKey(new PubKey(destination), unsendObject, SnodeNamespaces.Default) + .catch(window?.log?.error) + ) + ); + await Promise.all( + unsendMsgObjects.map(unsendObject => + MessageQueue.use() + .sendSyncMessage({ namespace: SnodeNamespaces.Default, message: unsendObject }) + .catch(window?.log?.error) + ) + ); +} + +async function unsendMessagesForEveryoneGroupV2({ + allMessagesFrom, + groupPk, + msgsToDelete, +}: { + groupPk: GroupPubkeyType; + msgsToDelete: Array; + allMessagesFrom: Array; +}) { + const messageHashesToUnsend = compact(msgsToDelete.map(m => m.getMessageHash())); + const group = await UserGroupsWrapperActions.getGroup(groupPk); + + if (!messageHashesToUnsend.length && !allMessagesFrom.length) { + window.log.info('unsendMessagesForEveryoneGroupV2: no hashes nor author to remove'); + return; + } + + await MessageQueue.use().sendToGroupV2NonDurably({ + message: new GroupUpdateDeleteMemberContentMessage({ + createAtNetworkTimestamp: NetworkTime.now(), + expirationType: 'unknown', // GroupUpdateDeleteMemberContentMessage is not displayed so not expiring. + expireTimer: 0, + groupPk, + memberSessionIds: allMessagesFrom, + messageHashes: messageHashesToUnsend, + sodium: await getSodiumRenderer(), + secretKey: group?.secretKey || undefined, + dbMessageIdentifier: uuidV4(), + }), + }); +} + +function getUnsendMessagesObjects1o1OrLegacyGroups(messages: Array) { + return compact( + messages.map((message, index) => { + const author = message.get('source'); + + // call getPropsForMessage here so we get the received_at or sent_at timestamp in timestamp + const referencedMessageTimestamp = message.getPropsForMessage().timestamp; + if (!referencedMessageTimestamp) { + window?.log?.error('cannot find timestamp - aborting unsend request'); + return undefined; + } + + return new UnsendMessage({ + // this isn't pretty, but we need a unique timestamp for Android to not drop the message as a duplicate + createAtNetworkTimestamp: NetworkTime.now() + index, + referencedMessageTimestamp, + author, + dbMessageIdentifier: uuidV4(), + }); + }) + ); +} diff --git a/ts/components/menuAndSettingsHooks/useHideNoteToSelf.ts b/ts/components/menuAndSettingsHooks/useHideNoteToSelf.ts index c105611c4..433564834 100644 --- a/ts/components/menuAndSettingsHooks/useHideNoteToSelf.ts +++ b/ts/components/menuAndSettingsHooks/useHideNoteToSelf.ts @@ -1,5 +1,4 @@ import { useIsHidden, useIsMe } from '../../hooks/useParamSelector'; -import { tr } from '../../localization/localeTools'; import { ConvoHub } from '../../session/conversations'; import { getAppDispatch } from '../../state/dispatch'; import { updateConfirmModal } from '../../state/ducks/modalDialog'; @@ -20,8 +19,6 @@ export function useHideNoteToSelfCb({ conversationId }: { conversationId: string return null; } - const menuItemText = tr('noteToSelfHide'); - const onClickClose = () => { dispatch(updateConfirmModal(null)); }; @@ -29,7 +26,7 @@ export function useHideNoteToSelfCb({ conversationId }: { conversationId: string const showConfirmationModal = () => { dispatch( updateConfirmModal({ - title: menuItemText, + title: { token: 'noteToSelfHide' }, i18nMessage: { token: 'noteToSelfHideDescription' }, onClickClose, okTheme: SessionButtonColor.Danger, @@ -41,7 +38,7 @@ export function useHideNoteToSelfCb({ conversationId }: { conversationId: string }); // Note: We don't want to close the modal for the hide NTS action. }, - okText: tr('hide'), + okText: { token: 'hide' }, }) ); }; diff --git a/ts/components/menuAndSettingsHooks/useShowDeletePrivateContact.ts b/ts/components/menuAndSettingsHooks/useShowDeletePrivateContact.ts index d4e8eb94f..012116fee 100644 --- a/ts/components/menuAndSettingsHooks/useShowDeletePrivateContact.ts +++ b/ts/components/menuAndSettingsHooks/useShowDeletePrivateContact.ts @@ -5,7 +5,6 @@ import { useIsMe, useIsPrivate, } from '../../hooks/useParamSelector'; -import { tr } from '../../localization/localeTools'; import { ConvoHub } from '../../session/conversations'; import { updateConfirmModal, updateConversationSettingsModal } from '../../state/ducks/modalDialog'; import { SessionButtonColor } from '../basic/SessionButton'; @@ -30,8 +29,6 @@ export function useShowDeletePrivateContactCb({ conversationId }: { conversation return null; } - const menuItemText = tr('contactDelete'); - const onClickClose = () => { dispatch(updateConfirmModal(null)); }; @@ -39,7 +36,7 @@ export function useShowDeletePrivateContactCb({ conversationId }: { conversation const showConfirmationModal = () => { dispatch( updateConfirmModal({ - title: menuItemText, + title: { token: 'contactDelete' }, i18nMessage: { token: 'deleteContactDescription', name }, onClickClose, okTheme: SessionButtonColor.Danger, @@ -51,7 +48,7 @@ export function useShowDeletePrivateContactCb({ conversationId }: { conversation }); dispatch(updateConversationSettingsModal(null)); }, - okText: tr('delete'), + okText: { token: 'delete' }, }) ); }; diff --git a/ts/components/menuAndSettingsHooks/useShowDeletePrivateConversation.ts b/ts/components/menuAndSettingsHooks/useShowDeletePrivateConversation.ts index a641c66ee..38c776352 100644 --- a/ts/components/menuAndSettingsHooks/useShowDeletePrivateConversation.ts +++ b/ts/components/menuAndSettingsHooks/useShowDeletePrivateConversation.ts @@ -5,7 +5,6 @@ import { useIsMe, useConversationUsernameWithFallback, } from '../../hooks/useParamSelector'; -import { tr } from '../../localization/localeTools'; import { ConvoHub } from '../../session/conversations'; import { updateConfirmModal, updateConversationSettingsModal } from '../../state/ducks/modalDialog'; import { SessionButtonColor } from '../basic/SessionButton'; @@ -30,8 +29,6 @@ export function useShowDeletePrivateConversationCb({ conversationId }: { convers return null; } - const menuItemText = tr('conversationsDelete'); - const onClickClose = () => { dispatch(updateConfirmModal(null)); }; @@ -39,7 +36,7 @@ export function useShowDeletePrivateConversationCb({ conversationId }: { convers const showConfirmationModal = () => { dispatch( updateConfirmModal({ - title: menuItemText, + title: { token: 'conversationsDelete' }, i18nMessage: { token: 'deleteConversationDescription', name }, onClickClose, okTheme: SessionButtonColor.Danger, @@ -51,7 +48,7 @@ export function useShowDeletePrivateConversationCb({ conversationId }: { convers }); dispatch(updateConversationSettingsModal(null)); }, - okText: tr('delete'), + okText: { token: 'delete' }, }) ); }; diff --git a/ts/components/menuAndSettingsHooks/useShowLeaveCommunity.ts b/ts/components/menuAndSettingsHooks/useShowLeaveCommunity.ts index 61fa28e17..6c8f751d3 100644 --- a/ts/components/menuAndSettingsHooks/useShowLeaveCommunity.ts +++ b/ts/components/menuAndSettingsHooks/useShowLeaveCommunity.ts @@ -1,6 +1,5 @@ import { getAppDispatch } from '../../state/dispatch'; import { useConversationUsernameWithFallback, useIsPublic } from '../../hooks/useParamSelector'; -import { tr } from '../../localization/localeTools'; import { ConvoHub } from '../../session/conversations'; import { updateConfirmModal, updateConversationSettingsModal } from '../../state/ducks/modalDialog'; import { SessionButtonColor } from '../basic/SessionButton'; @@ -28,10 +27,10 @@ export function useShowLeaveCommunityCb(conversationId?: string) { dispatch( updateConfirmModal({ - title: tr('communityLeave'), + title: { token: 'communityLeave' }, i18nMessage: { token: 'groupLeaveDescription', group_name: username ?? '' }, onClickOk, - okText: tr('leave'), + okText: { token: 'leave' }, okTheme: SessionButtonColor.Danger, onClickClose, conversationId, diff --git a/ts/components/menuAndSettingsHooks/useShowLeaveGroup.ts b/ts/components/menuAndSettingsHooks/useShowLeaveGroup.ts index fb14e70c9..dfea5e0bb 100644 --- a/ts/components/menuAndSettingsHooks/useShowLeaveGroup.ts +++ b/ts/components/menuAndSettingsHooks/useShowLeaveGroup.ts @@ -168,10 +168,10 @@ function dispatchDeleteOrLeave({ onClickClose: () => void; groupName: string; }) { - const title = + const title: TrArgs = deleteType === 'delete' || deleteType === 'delete-local-only' - ? tr('groupDelete') - : tr('groupLeave'); + ? { token: 'groupDelete' } + : { token: 'groupLeave' }; const i18nMessage: TrArgs = { token: deleteType === 'delete' diff --git a/ts/components/menuAndSettingsHooks/useShowNoteToSelf.ts b/ts/components/menuAndSettingsHooks/useShowNoteToSelf.ts index 8451d4a0f..7a1d6be48 100644 --- a/ts/components/menuAndSettingsHooks/useShowNoteToSelf.ts +++ b/ts/components/menuAndSettingsHooks/useShowNoteToSelf.ts @@ -3,7 +3,6 @@ import { useIsHidden, useIsMe } from '../../hooks/useParamSelector'; import { ConvoHub } from '../../session/conversations'; import { updateConfirmModal } from '../../state/ducks/modalDialog'; import { SessionButtonColor } from '../basic/SessionButton'; -import { tr } from '../../localization/localeTools'; function useShowNoteToSelf({ conversationId }: { conversationId: string }) { const isMe = useIsMe(conversationId); @@ -23,7 +22,7 @@ export function useShowNoteToSelfCb({ conversationId }: { conversationId: string const showConfirmationModal = () => { dispatch( updateConfirmModal({ - title: tr('showNoteToSelf'), + title: { token: 'showNoteToSelf' }, i18nMessage: { token: 'showNoteToSelfDescription' }, onClickClose, closeTheme: SessionButtonColor.TextPrimary, @@ -32,7 +31,7 @@ export function useShowNoteToSelfCb({ conversationId }: { conversationId: string await convo.unhideIfNeeded(true); // Note: We don't want to close the modal for the show NTS action. }, - okText: tr('show'), + okText: { token: 'show' }, }) ); }; diff --git a/ts/hooks/useMessageInteractions.ts b/ts/hooks/useMessageInteractions.ts index af0e75b34..ce0c9403f 100644 --- a/ts/hooks/useMessageInteractions.ts +++ b/ts/hooks/useMessageInteractions.ts @@ -1,3 +1,4 @@ +import { type MouseEvent } from 'react'; import { ItemParams } from 'react-contexify'; import { isNumber } from 'lodash'; import { MessageInteraction } from '../interactions'; @@ -6,120 +7,140 @@ import { pushUnblockToSend } from '../session/utils/Toast'; import { getAppDispatch } from '../state/dispatch'; import { toggleSelectedMessageId } from '../state/ducks/conversations'; import { + useIsMessageSelectionMode, useSelectedConversationKey, useSelectedIsBlocked, } from '../state/selectors/selectedConversation'; -import { Reactions } from '../util/reactions'; import { useMessageAttachments, useMessageBody, + useMessageDirection, useMessageSender, useMessageServerTimestamp, useMessageStatus, useMessageTimestamp, } from '../state/selectors'; import { saveAttachmentToDisk } from '../util/attachment/attachmentsUtil'; -import { deleteMessagesForX } from '../interactions/conversations/unsendingInteractions'; +import { Reactions } from '../util/reactions'; -function useSaveAttachemnt(messageId?: string) { +export function useSaveAttachment(messageId?: string) { const convoId = useSelectedConversationKey(); const attachments = useMessageAttachments(messageId); const timestamp = useMessageTimestamp(messageId); const serverTimestamp = useMessageServerTimestamp(messageId); const sender = useMessageSender(messageId); - return (e: ItemParams) => { - // this is quite dirty but considering that we want the context menu of the message to show on click on the attachment - // and the context menu save attachment item to save the right attachment I did not find a better way for now. - // Note: If you change this, also make sure to update the `handleContextMenu()` in GenericReadableMessage.tsx - const targetAttachmentIndex = isNumber(e?.props?.dataAttachmentIndex) - ? e.props.dataAttachmentIndex - : 0; - e.event.stopPropagation(); - if (!attachments?.length || !convoId || !sender) { - return; - } - - if (targetAttachmentIndex > attachments.length) { - return; - } - const messageTimestamp = timestamp || serverTimestamp || 0; - void saveAttachmentToDisk({ - attachment: attachments[targetAttachmentIndex], - messageTimestamp, - messageSender: sender, - conversationId: convoId, - index: targetAttachmentIndex, - }); - }; + const cannotSave = + !messageId || + !convoId || + !sender || + !attachments?.length || + !attachments.every(m => !m.pending && m.path); + + return cannotSave + ? null + : (e: ItemParams) => { + // this is quite dirty but considering that we want the context menu of the message to show on click on the attachment + // and the context menu save attachment item to save the right attachment I did not find a better way for now. + // Note: If you change this, also make sure to update the `handleContextMenu()` in GenericReadableMessage.tsx + const targetAttachmentIndex = isNumber(e?.props?.dataAttachmentIndex) + ? e.props.dataAttachmentIndex + : 0; + e.event.stopPropagation(); + if (targetAttachmentIndex > attachments.length) { + return; + } + const messageTimestamp = timestamp || serverTimestamp || 0; + void saveAttachmentToDisk({ + attachment: attachments[targetAttachmentIndex], + messageTimestamp, + messageSender: sender, + conversationId: convoId, + index: targetAttachmentIndex, + }); + }; } -function useCopyText(messageId?: string) { +export function useCopyText(messageId?: string) { const text = useMessageBody(messageId); - return () => { - const selection = window.getSelection(); - const selectedText = selection?.toString().trim(); - // Note: we want to allow to copy through the "Copy" menu item the currently selected text, if any. - MessageInteraction.copyBodyToClipboard(selectedText || text); - }; + const cannotCopy = !messageId; + + return cannotCopy + ? null + : () => { + const selection = window.getSelection(); + const selectedText = selection?.toString().trim(); + // Note: we want to allow to copy through the "Copy" menu item the currently selected text, if any. + MessageInteraction.copyBodyToClipboard(selectedText || text); + }; } -function useReply(messageId?: string) { +export function useReply(messageId?: string) { const isSelectedBlocked = useSelectedIsBlocked(); - return () => { - if (!messageId) { - return; - } - if (isSelectedBlocked) { - pushUnblockToSend(); - return; - } - void replyToMessage(messageId); - }; -} + const direction = useMessageDirection(messageId); + const status = useMessageStatus(messageId); -function useDelete(messageId?: string) { - const messageStatus = useMessageStatus(messageId); - return (isPublic: boolean, convoId?: string) => { - if (convoId && messageId) { - const enforceDeleteServerSide = isPublic && messageStatus !== 'error'; - void deleteMessagesForX([messageId], convoId, enforceDeleteServerSide); - } - }; + const isOutgoing = direction === 'outgoing'; + const isSendingOrError = status === 'sending' || status === 'error'; + + // NOTE: we dont want to allow to reply to outgoing messages that failed to send or is sending + const cannotReply = !messageId || (isOutgoing && isSendingOrError); + + return cannotReply + ? null + : () => { + if (isSelectedBlocked) { + pushUnblockToSend(); + return; + } + void replyToMessage(messageId); + }; } -export function useMessageInteractions(messageId?: string | null) { - const dispatch = getAppDispatch(); +export function useReactToMessage(messageId?: string) { + const cannotReact = !messageId; - const copyText = useCopyText(messageId ?? undefined); - const saveAttachment = useSaveAttachemnt(messageId ?? undefined); + return cannotReact + ? null + : async (emoji: string) => { + await Reactions.sendMessageReaction(messageId, emoji); + }; +} - const reply = useReply(messageId ?? undefined); - const deleteFromConvo = useDelete(messageId ?? undefined); +/** + * Cb to invoke when a manual click to "select" in the msg context menu is done. + * i.e. starts the Multi selection mode + * @see `useSelectMessageViaClick` + */ +export function useSelectMessageViaMenuCb(messageId?: string | null) { + const dispatch = getAppDispatch(); + const multiSelectMode = useIsMessageSelectionMode(); - const select = () => { - if (!messageId) { - return; - } + if (!messageId || multiSelectMode) { + return null; + } + return () => { dispatch(toggleSelectedMessageId(messageId)); }; +} - const reactToMessage = async (emoji: string) => { - if (!messageId) { - return; - } +/** + * Cb to invite when we are in multi select mode and a message is clicked + */ +export function useSelectMessageViaClick(messageId?: string | null) { + const dispatch = getAppDispatch(); + const multiSelectMode = useIsMessageSelectionMode(); - await Reactions.sendMessageReaction(messageId, emoji); - }; + // we can only select via click on msg once multi select mode is already on + if (!messageId || !multiSelectMode) { + return null; + } - return { - copyText, - saveAttachment, - reply, - select, - reactToMessage, - deleteFromConvo, + return (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + dispatch(toggleSelectedMessageId(messageId)); }; } diff --git a/ts/interactions/conversationInteractions.ts b/ts/interactions/conversationInteractions.ts index 0511e3af9..9f06c22ca 100644 --- a/ts/interactions/conversationInteractions.ts +++ b/ts/interactions/conversationInteractions.ts @@ -40,7 +40,6 @@ import { MessageSender } from '../session/sending'; import { StoreGroupRequestFactory } from '../session/apis/snode_api/factories/StoreGroupRequestFactory'; import { DURATION } from '../session/constants'; import { GroupInvite } from '../session/utils/job_runners/jobs/GroupInviteJob'; -import { tr } from '../localization/localeTools'; export async function copyPublicKeyByConvoId(convoId: string) { if (OpenGroupUtils.isOpenGroupV2(convoId)) { @@ -334,7 +333,7 @@ export async function showLinkSharingConfirmationModalDialog(link: string) { if (!alreadyDisplayedPopup) { window.inboxStore?.dispatch( updateConfirmModal({ - title: tr('linkPreviewsEnable'), + title: { token: 'linkPreviewsEnable' }, i18nMessage: { token: 'linkPreviewsFirstDescription' }, okTheme: SessionButtonColor.Danger, onClickOk: async () => { @@ -343,7 +342,7 @@ export async function showLinkSharingConfirmationModalDialog(link: string) { onClickClose: async () => { await Storage.put(SettingsKey.hasLinkPreviewPopupBeenDisplayed, true); }, - okText: tr('enable'), + okText: { token: 'enable' }, }) ); } diff --git a/ts/interactions/conversations/deleteMessagesFromSwarmOnly.ts b/ts/interactions/conversations/deleteMessagesFromSwarmOnly.ts new file mode 100644 index 000000000..fcfec5185 --- /dev/null +++ b/ts/interactions/conversations/deleteMessagesFromSwarmOnly.ts @@ -0,0 +1,47 @@ +import type { PubkeyType, GroupPubkeyType } from 'libsession_util_nodejs'; +import { compact, isEmpty } from 'lodash'; +import type { MessageModel } from '../../models/message'; +import { SnodeAPI } from '../../session/apis/snode_api/SNodeAPI'; +import { PubKey } from '../../session/types'; +import { ed25519Str } from '../../session/utils/String'; +import { isStringArray } from '../../types/isStringArray'; + +/** + * Do a single request to the swarm with all the message hashes to delete from the swarm. + * Does not delete anything locally. + * Should only be used when we are deleting a + * + * Returns true if no errors happened, false in an error happened + */ +export async function deleteMessagesFromSwarmOnly( + messages: Array | Array, + pubkey: PubkeyType | GroupPubkeyType +) { + const deletionMessageHashes = isStringArray(messages) + ? messages + : compact(messages.map(m => m.getMessageHash())); + + try { + if (isEmpty(messages)) { + return false; + } + + if (!deletionMessageHashes.length) { + window.log?.warn( + 'deleteMessagesFromSwarmOnly: We do not have hashes for some of those messages' + ); + return false; + } + const hashesAsSet = new Set(deletionMessageHashes); + if (PubKey.is03Pubkey(pubkey)) { + return await SnodeAPI.networkDeleteMessagesForGroup(hashesAsSet, pubkey); + } + return await SnodeAPI.networkDeleteMessageOurSwarm(hashesAsSet, pubkey); + } catch (e) { + window.log?.error( + `deleteMessagesFromSwarmOnly: Error deleting message from swarm of ${ed25519Str(pubkey)}, hashes: ${deletionMessageHashes}`, + e + ); + return false; + } +} diff --git a/ts/interactions/conversations/deleteMessagesLocallyOnly.ts b/ts/interactions/conversations/deleteMessagesLocallyOnly.ts new file mode 100644 index 000000000..ada1f0fb3 --- /dev/null +++ b/ts/interactions/conversations/deleteMessagesLocallyOnly.ts @@ -0,0 +1,37 @@ +/* eslint-disable no-await-in-loop */ +import type { ConversationModel } from '../../models/conversation'; +import type { MessageModel } from '../../models/message'; +import type { WithLocalMessageDeletionType } from '../../session/types/with'; + +/** + * Deletes a message completely or mark it as deleted. Does not interact with the swarm at all + * @param message Message to delete + * @param deletionType 'complete' means completely delete the item from the database, markDeleted means empty the message content but keep an entry + */ +export async function deleteMessagesLocallyOnly({ + conversation, + messages, + deletionType, +}: WithLocalMessageDeletionType & { + conversation: ConversationModel; + messages: Array; +}) { + for (let index = 0; index < messages.length; index++) { + const message = messages[index]; + // a control message or a message deleted is forcefully removed from the DB + if (message.isControlMessage() || message.get('isDeleted')) { + await conversation.removeMessage(message.id); + + continue; + } + if (deletionType === 'complete') { + // remove the message from the database + await conversation.removeMessage(message.id); + } else { + // just mark the message as deleted but still show in conversation + await message.markAsDeleted(deletionType === 'marDeletedLocally'); + } + } + + conversation.updateLastMessage(); +} diff --git a/ts/interactions/conversations/unsendingInteractions.ts b/ts/interactions/conversations/unsendingInteractions.ts index b737a42dd..6bd246ec0 100644 --- a/ts/interactions/conversations/unsendingInteractions.ts +++ b/ts/interactions/conversations/unsendingInteractions.ts @@ -1,216 +1,11 @@ -import { GroupPubkeyType, PubkeyType } from 'libsession_util_nodejs'; -import { compact, isEmpty } from 'lodash'; -import { SessionButtonColor } from '../../components/basic/SessionButton'; -import { Data } from '../../data/data'; import { ConversationModel } from '../../models/conversation'; import { MessageModel } from '../../models/message'; -import { deleteSogsMessageByServerIds } from '../../session/apis/open_group_api/sogsv3/sogsV3DeleteMessages'; -import { SnodeAPI } from '../../session/apis/snode_api/SNodeAPI'; -import { SnodeNamespaces } from '../../session/apis/snode_api/namespaces'; -import { ConvoHub } from '../../session/conversations'; -import { getSodiumRenderer } from '../../session/crypto'; -import { UnsendMessage } from '../../session/messages/outgoing/controlMessage/UnsendMessage'; -import { GroupUpdateDeleteMemberContentMessage } from '../../session/messages/outgoing/controlMessage/group_v2/to_group/GroupUpdateDeleteMemberContentMessage'; import { PubKey } from '../../session/types'; -import { ToastUtils, UserUtils } from '../../session/utils'; -import { closeRightPanel, resetSelectedMessageIds } from '../../state/ducks/conversations'; -import { updateConfirmModal } from '../../state/ducks/modalDialog'; +import { UserUtils } from '../../session/utils'; import { ed25519Str } from '../../session/utils/String'; -import { UserGroupsWrapperActions } from '../../webworker/workers/browser/libsession_worker_interface'; -import { NetworkTime } from '../../util/NetworkTime'; -import { MessageQueue } from '../../session/sending'; -import { WithLocalMessageDeletionType } from '../../session/types/with'; -import { tr, type TrArgs } from '../../localization/localeTools'; -import { uuidV4 } from '../../util/uuid'; - -async function unsendMessagesForEveryone1o1AndLegacy( - conversation: ConversationModel, - destination: PubkeyType, - msgsToDelete: Array -) { - const unsendMsgObjects = getUnsendMessagesObjects1o1OrLegacyGroups(msgsToDelete); - - if (conversation.isClosedGroupV2()) { - throw new Error('unsendMessagesForEveryone1o1AndLegacy not compatible with group v2'); - } - - if (conversation.isPrivate()) { - // sending to recipient all the messages separately for now - await Promise.all( - unsendMsgObjects.map(unsendObject => - MessageQueue.use() - .sendToPubKey(new PubKey(destination), unsendObject, SnodeNamespaces.Default) - .catch(window?.log?.error) - ) - ); - await Promise.all( - unsendMsgObjects.map(unsendObject => - MessageQueue.use() - .sendSyncMessage({ namespace: SnodeNamespaces.Default, message: unsendObject }) - .catch(window?.log?.error) - ) - ); - return; - } - if (conversation.isClosedGroup()) { - // legacy groups are readonly - } -} - -export async function unsendMessagesForEveryoneGroupV2({ - allMessagesFrom, - groupPk, - msgsToDelete, -}: { - groupPk: GroupPubkeyType; - msgsToDelete: Array; - allMessagesFrom: Array; -}) { - const messageHashesToUnsend = getMessageHashes(msgsToDelete); - const group = await UserGroupsWrapperActions.getGroup(groupPk); - - if (!messageHashesToUnsend.length && !allMessagesFrom.length) { - window.log.info('unsendMessagesForEveryoneGroupV2: no hashes nor author to remove'); - return; - } - - await MessageQueue.use().sendToGroupV2NonDurably({ - message: new GroupUpdateDeleteMemberContentMessage({ - createAtNetworkTimestamp: NetworkTime.now(), - expirationType: 'unknown', // GroupUpdateDeleteMemberContentMessage is not displayed so not expiring. - expireTimer: 0, - groupPk, - memberSessionIds: allMessagesFrom, - messageHashes: messageHashesToUnsend, - sodium: await getSodiumRenderer(), - secretKey: group?.secretKey || undefined, - dbMessageIdentifier: uuidV4(), - }), - }); -} - -/** - * Deletes messages for everyone in a 1-1 or everyone in a closed group conversation. - */ -async function unsendMessagesForEveryone( - conversation: ConversationModel, - msgsToDelete: Array, - { deletionType }: WithLocalMessageDeletionType -) { - window?.log?.info('Deleting messages for all users in this conversation'); - const destinationId = conversation.id; - if (!destinationId) { - return; - } - if (conversation.isOpenGroupV2()) { - throw new Error( - 'Cannot unsend a message for an opengroup v2. This has to be a deleteMessage api call' - ); - } - - if ( - conversation.isPrivate() || - (conversation.isClosedGroup() && !conversation.isClosedGroupV2()) - ) { - if (!PubKey.is05Pubkey(conversation.id)) { - throw new Error('unsendMessagesForEveryone1o1AndLegacy requires a 05 key'); - } - await unsendMessagesForEveryone1o1AndLegacy(conversation, conversation.id, msgsToDelete); - } else if (conversation.isClosedGroupV2()) { - if (!PubKey.is03Pubkey(destinationId)) { - throw new Error('invalid conversation id (03) for unsendMessageForEveryone'); - } - await unsendMessagesForEveryoneGroupV2({ - groupPk: destinationId, - msgsToDelete, - allMessagesFrom: [], // currently we cannot remove all the messages from a specific pubkey but we do already handle them on the receiving side - }); - } - if (deletionType === 'complete') { - await deleteMessagesFromSwarmAndCompletelyLocally(conversation, msgsToDelete); - } else { - await deleteMessagesFromSwarmAndMarkAsDeletedLocally(conversation, msgsToDelete); - } - - window.inboxStore?.dispatch(resetSelectedMessageIds()); - ToastUtils.pushDeleted(msgsToDelete.length); -} - -function getUnsendMessagesObjects1o1OrLegacyGroups(messages: Array) { - // #region building request - return compact( - messages.map((message, index) => { - const author = message.get('source'); - - // call getPropsForMessage here so we get the received_at or sent_at timestamp in timestamp - const referencedMessageTimestamp = message.getPropsForMessage().timestamp; - if (!referencedMessageTimestamp) { - window?.log?.error('cannot find timestamp - aborting unsend request'); - return undefined; - } - - return new UnsendMessage({ - // this isn't pretty, but we need a unique timestamp for Android to not drop the message as a duplicate - createAtNetworkTimestamp: NetworkTime.now() + index, - referencedMessageTimestamp, - author, - dbMessageIdentifier: uuidV4(), - }); - }) - ); - // #endregion -} - -function getMessageHashes(messages: Array) { - return compact( - messages.map(message => { - return message.get('messageHash'); - }) - ); -} - -function isStringArray(value: unknown): value is Array { - return Array.isArray(value) && value.every(val => typeof val === 'string'); -} - -/** - * Do a single request to the swarm with all the message hashes to delete from the swarm. - * - * It does not delete anything locally. - * - * Returns true if no errors happened, false in an error happened - */ -export async function deleteMessagesFromSwarmOnly( - messages: Array | Array, - pubkey: PubkeyType | GroupPubkeyType -) { - const deletionMessageHashes = isStringArray(messages) ? messages : getMessageHashes(messages); - - try { - if (isEmpty(messages)) { - return false; - } - - if (!deletionMessageHashes.length) { - window.log?.warn( - 'deleteMessagesFromSwarmOnly: We do not have hashes for some of those messages' - ); - return false; - } - const hashesAsSet = new Set(deletionMessageHashes); - if (PubKey.is03Pubkey(pubkey)) { - return await SnodeAPI.networkDeleteMessagesForGroup(hashesAsSet, pubkey); - } - return await SnodeAPI.networkDeleteMessageOurSwarm(hashesAsSet, pubkey); - } catch (e) { - window.log?.error( - `deleteMessagesFromSwarmOnly: Error deleting message from swarm of ${ed25519Str(pubkey)}, hashes: ${deletionMessageHashes}`, - e - ); - return false; - } -} +import { deleteMessagesLocallyOnly } from './deleteMessagesLocallyOnly'; +import { deleteMessagesFromSwarmOnly } from './deleteMessagesFromSwarmOnly'; /** * Delete the messages from the swarm with an unsend request and if it worked, delete those messages locally. @@ -231,10 +26,10 @@ export async function deleteMessagesFromSwarmAndCompletelyLocally( ); return; } - // LEGACY GROUPS -- we cannot delete on the swarm (just unsend which is done separately) + // LEGACY GROUPS are deprecated if (conversation.isClosedGroup() && PubKey.is05Pubkey(pubkey)) { - window.log.info('Cannot delete message from a closed group swarm, so we just complete delete.'); - await deleteMessagesLocallyOnly({ conversation, messages, deletionType: 'complete' }); + window.log.info('legacy groups are deprecated.'); + return; } window.log.info( @@ -253,19 +48,16 @@ export async function deleteMessagesFromSwarmAndCompletelyLocally( } /** - * Delete the messages from the swarm with an unsend request and if it worked, mark those messages locally as deleted but do not remove them. + * Delete the messages from the swarm with an unsend request and mark those messages locally as deleted but do not remove them. * If an error happened, we still mark the message locally as deleted. */ export async function deleteMessagesFromSwarmAndMarkAsDeletedLocally( conversation: ConversationModel, messages: Array ) { - // legacy groups cannot delete messages on the swarm (just "unsend") + // legacy groups are deprecated if (conversation.isClosedGroup() && PubKey.is05Pubkey(conversation.id)) { - window.log.info( - 'Cannot delete messages from a legacy closed group swarm, so we just markDeleted.' - ); - await deleteMessagesLocallyOnly({ conversation, messages, deletionType: 'markDeleted' }); + window.log.info('legacy groups are deprecated. Not deleting anything'); return; } @@ -284,338 +76,3 @@ export async function deleteMessagesFromSwarmAndMarkAsDeletedLocally( } await deleteMessagesLocallyOnly({ conversation, messages, deletionType: 'markDeleted' }); } - -/** - * Deletes a message completely or mark it as deleted only. Does not interact with the swarm at all - * @param message Message to delete - * @param deletionType 'complete' means completely delete the item from the database, markDeleted means empty the message content but keep an entry - */ -async function deleteMessagesLocallyOnly({ - conversation, - messages, - deletionType, -}: WithLocalMessageDeletionType & { - conversation: ConversationModel; - messages: Array; -}) { - for (let index = 0; index < messages.length; index++) { - const message = messages[index]; - if (deletionType === 'complete') { - // remove the message from the database - // eslint-disable-next-line no-await-in-loop - await conversation.removeMessage(message.id); - } else { - // just mark the message as deleted but still show in conversation - // eslint-disable-next-line no-await-in-loop - await message.markAsDeleted(); - } - } - - conversation.updateLastMessage(); -} - -/** - * Send an UnsendMessage synced message so our devices removes those messages locally, - * and send an unsend request on our swarm so this message is effectively removed. - * - * Show a toast on error/success and reset the selection - */ -async function unsendMessageJustForThisUser( - conversation: ConversationModel, - msgsToDelete: Array -) { - window?.log?.warn('Deleting messages just for this user'); - - const unsendMsgObjects = getUnsendMessagesObjects1o1OrLegacyGroups(msgsToDelete); - - // sending to our other devices all the messages separately for now - await Promise.all( - unsendMsgObjects.map(unsendObject => - MessageQueue.use() - .sendSyncMessage({ namespace: SnodeNamespaces.Default, message: unsendObject }) - .catch(window?.log?.error) - ) - ); - await deleteMessagesFromSwarmAndCompletelyLocally(conversation, msgsToDelete); - - // Update view and trigger update - window.inboxStore?.dispatch(resetSelectedMessageIds()); - ToastUtils.pushDeleted(unsendMsgObjects.length); -} - -const doDeleteSelectedMessagesInSOGS = async ( - selectedMessages: Array, - conversation: ConversationModel, - isAllOurs: boolean -) => { - const ourDevicePubkey = UserUtils.getOurPubKeyStrFromCache(); - if (!ourDevicePubkey) { - return; - } - // #region open group v2 deletion - // Get our Moderator status - const isAdmin = conversation.weAreAdminUnblinded(); - const isModerator = conversation.isModerator(ourDevicePubkey); - - if (!isAllOurs && !(isAdmin || isModerator)) { - ToastUtils.pushMessageDeleteForbidden(); - window.inboxStore?.dispatch(resetSelectedMessageIds()); - return; - } - - const toDeleteLocallyIds = await deleteOpenGroupMessages(selectedMessages, conversation); - if (toDeleteLocallyIds.length === 0) { - // Message failed to delete from server, show error? - return; - } - await Promise.all( - toDeleteLocallyIds.map(async id => { - const msgToDeleteLocally = await Data.getMessageById(id); - if (msgToDeleteLocally) { - return deleteMessagesLocallyOnly({ - conversation, - messages: [msgToDeleteLocally], - deletionType: 'complete', - }); - } - return null; - }) - ); - // successful deletion - ToastUtils.pushDeleted(toDeleteLocallyIds.length); - window.inboxStore?.dispatch(resetSelectedMessageIds()); - // #endregion -}; - -/** - * Effectively delete the messages from a conversation. - * This call is to be called by the user on a confirmation dialog for instance. - * - * It does what needs to be done on a user action to delete messages for each conversation type - */ -const doDeleteSelectedMessages = async ({ - conversation, - selectedMessages, - deleteForEveryone, -}: { - selectedMessages: Array; - conversation: ConversationModel; - deleteForEveryone: boolean; -}) => { - const ourDevicePubkey = UserUtils.getOurPubKeyStrFromCache(); - if (!ourDevicePubkey) { - return; - } - - const areAllOurs = selectedMessages.every(message => message.getSource() === ourDevicePubkey); - if (conversation.isOpenGroupV2()) { - await doDeleteSelectedMessagesInSOGS(selectedMessages, conversation, areAllOurs); - return; - } - - // Note: a groupv2 member can delete messages for everyone if they are the admin, or if that message is theirs. - - if (deleteForEveryone) { - if (conversation.isClosedGroupV2()) { - const convoId = conversation.id; - if (!PubKey.is03Pubkey(convoId)) { - throw new Error('unsend request for groupv2 but not a 03 key is impossible possible'); - } - // only lookup adminKey if we need to - if (!areAllOurs) { - const group = await UserGroupsWrapperActions.getGroup(convoId); - const weHaveAdminKey = !isEmpty(group?.secretKey); - if (!weHaveAdminKey) { - ToastUtils.pushMessageDeleteForbidden(); - window.inboxStore?.dispatch(resetSelectedMessageIds()); - return; - } - } - // if they are all ours, of not but we are an admin, we can move forward - await unsendMessagesForEveryone(conversation, selectedMessages, { - deletionType: 'markDeleted', // 03 groups: mark as deleted - }); - return; - } - - if (!areAllOurs) { - ToastUtils.pushMessageDeleteForbidden(); - window.inboxStore?.dispatch(resetSelectedMessageIds()); - return; - } - await unsendMessagesForEveryone(conversation, selectedMessages, { deletionType: 'complete' }); // not 03 group: delete completely - return; - } - - // delete just for me in a groupv2 only means delete locally (not even synced to our other devices) - if (conversation.isClosedGroupV2()) { - await deleteMessagesLocallyOnly({ - conversation, - messages: selectedMessages, - deletionType: 'markDeleted', - }); - ToastUtils.pushDeleted(selectedMessages.length); - - return; - } - - // delete just for me in a legacy closed group only means delete locally - if (conversation.isClosedGroup()) { - await deleteMessagesFromSwarmAndMarkAsDeletedLocally(conversation, selectedMessages); - - // Update view and trigger update - window.inboxStore?.dispatch(resetSelectedMessageIds()); - ToastUtils.pushDeleted(selectedMessages.length); - return; - } - // otherwise, delete that message locally, from our swarm and from our other devices - await unsendMessageJustForThisUser(conversation, selectedMessages); -}; - -/** - * Either delete for everyone or not, based on the props - */ -export async function deleteMessagesForX( - messageIds: Array, - conversationId: string, - /** should only be enforced for messages successfully sent on communities */ - enforceDeleteServerSide: boolean -) { - if (conversationId) { - if (enforceDeleteServerSide) { - await deleteMessagesByIdForEveryone(messageIds, conversationId); - } else { - await deleteMessagesById(messageIds, conversationId); - } - } -} - -export async function deleteMessagesByIdForEveryone( - messageIds: Array, - conversationId: string -) { - const conversation = ConvoHub.use().getOrThrow(conversationId); - const isMe = conversation.isMe(); - const selectedMessages = compact( - await Promise.all(messageIds.map(m => Data.getMessageById(m, false))) - ); - - const closeDialog = () => window.inboxStore?.dispatch(updateConfirmModal(null)); - - window.inboxStore?.dispatch( - updateConfirmModal({ - title: isMe ? tr('deleteMessageDevicesAll') : tr('clearMessagesForEveryone'), - i18nMessage: { token: 'deleteMessageConfirm', count: selectedMessages.length }, - okText: isMe ? tr('deleteMessageDevicesAll') : tr('clearMessagesForEveryone'), - okTheme: SessionButtonColor.Danger, - onClickOk: async () => { - await doDeleteSelectedMessages({ selectedMessages, conversation, deleteForEveryone: true }); - - // explicitly close modal for this case. - closeDialog(); - }, - onClickCancel: closeDialog, - onClickClose: closeDialog, - }) - ); -} - -export async function deleteMessagesById(messageIds: Array, conversationId: string) { - const conversation = ConvoHub.use().getOrThrow(conversationId); - const selectedMessages = compact( - await Promise.all(messageIds.map(m => Data.getMessageById(m, false))) - ); - - const isMe = conversation.isMe(); - const count = messageIds.length; - - const closeDialog = () => window.inboxStore?.dispatch(updateConfirmModal(null)); - const clearMessagesForEveryone = 'clearMessagesForEveryone'; - - // Note: the isMe case has no radio buttons, so we just show the description below - const i18nMessage: TrArgs | undefined = isMe - ? { token: 'deleteMessageDescriptionDevice', count } - : undefined; - - window.inboxStore?.dispatch( - updateConfirmModal({ - title: tr('deleteMessage', { count: selectedMessages.length }), - radioOptions: !isMe - ? [ - { - label: tr('clearMessagesForMe'), - value: 'clearMessagesForMe' as const, - inputDataTestId: 'input-deleteJustForMe' as const, - labelDataTestId: 'label-deleteJustForMe' as const, - }, - { - label: tr('clearMessagesForEveryone'), - value: clearMessagesForEveryone, - inputDataTestId: 'input-deleteForEveryone' as const, - labelDataTestId: 'label-deleteForEveryone' as const, - }, - ] - : undefined, - i18nMessage, - okText: tr('delete'), - okTheme: SessionButtonColor.Danger, - onClickOk: async args => { - await doDeleteSelectedMessages({ - selectedMessages, - conversation, - deleteForEveryone: args === clearMessagesForEveryone, - }); - window.inboxStore?.dispatch(updateConfirmModal(null)); - window.inboxStore?.dispatch(closeRightPanel()); - }, - onClickClose: closeDialog, - }) - ); -} - -/** - * - * @param messages the list of MessageModel to delete - * @param convo the conversation to delete from (only v2 opengroups are supported) - */ -async function deleteOpenGroupMessages( - messages: Array, - convo: ConversationModel -): Promise> { - if (!convo.isOpenGroupV2()) { - throw new Error('cannot delete public message on a non public groups'); - } - - const roomInfos = convo.toOpenGroupV2(); - // on v2 servers we can only remove a single message per request.. - // so logic here is to delete each messages and get which one where not removed - const validServerIdsToRemove = compact( - messages.map(msg => { - return msg.get('serverId'); - }) - ); - - const validMessageModelsToRemove = compact( - messages.map(msg => { - const serverId = msg.get('serverId'); - if (serverId) { - return msg; - } - return undefined; - }) - ); - - let allMessagesAreDeleted: boolean = false; - if (validServerIdsToRemove.length) { - allMessagesAreDeleted = await deleteSogsMessageByServerIds(validServerIdsToRemove, roomInfos); - } - // remove only the messages we managed to remove on the server - if (allMessagesAreDeleted) { - window?.log?.info('Removed all those serverIds messages successfully'); - return validMessageModelsToRemove.map(m => m.id); - } - window?.log?.info( - 'failed to remove all those serverIds message. not removing them locally neither' - ); - return []; -} diff --git a/ts/interactions/messageInteractions.ts b/ts/interactions/messageInteractions.ts index 7f8589b06..d1238fabf 100644 --- a/ts/interactions/messageInteractions.ts +++ b/ts/interactions/messageInteractions.ts @@ -20,7 +20,7 @@ const acceptOpenGroupInvitationV2 = (completeUrl: string, roomName?: string) => window.inboxStore?.dispatch( updateConfirmModal({ - title: tr('communityJoin'), + title: { token: 'communityJoin' }, i18nMessage: { token: 'communityJoinDescription', community_name: roomName || tr('unknown'), @@ -30,7 +30,7 @@ const acceptOpenGroupInvitationV2 = (completeUrl: string, roomName?: string) => }, onClickClose, - okText: tr('join'), + okText: { token: 'join' }, }) ); // this function does not throw, and will showToasts if anything happens diff --git a/ts/models/message.ts b/ts/models/message.ts index bc9d51eb4..bd059f0aa 100644 --- a/ts/models/message.ts +++ b/ts/models/message.ts @@ -84,7 +84,7 @@ import { Storage } from '../util/storage'; import { ConversationModel } from './conversation'; import { READ_MESSAGE_STATE } from './conversationAttributes'; import { ConversationInteractionStatus, ConversationInteractionType } from '../interactions/types'; -import { LastMessageStatusType, type PropsForCallNotification } from '../state/ducks/types'; +import { LastMessageStatusType } from '../state/ducks/types'; import { getGroupDisplayPictureChangeStr, getGroupNameChangeStr, @@ -139,39 +139,62 @@ export class MessageModel extends Model { const isMessageResponse = this.isMessageRequestResponse(); const callNotificationType = this.get('callNotificationType'); const interactionNotification = this.getInteractionNotification(); + const propsForMessage = this.getPropsForMessage(); + + const byType = propsForDataExtractionNotification + ? ({ + messageType: 'data-extraction-notification', + propsForDataExtractionNotification, + isControlMessage: true, + } as const) + : propsForCommunityInvitation + ? ({ + messageType: 'community-invitation', + propsForCommunityInvitation, + isControlMessage: false, + } as const) + : propsForGroupUpdateMessage + ? ({ + messageType: 'group-update-notification', + propsForGroupUpdateMessage, + isControlMessage: true, + } as const) + : propsForTimerNotification + ? ({ + messageType: 'timer-update-notification', + propsForTimerNotification, + isControlMessage: true, + } as const) + : callNotificationType + ? ({ + messageType: 'call-notification', + propsForCallNotification: { + messageId: this.id, + notificationType: callNotificationType, + }, + isControlMessage: true, + } as const) + : interactionNotification + ? ({ + messageType: 'interaction-notification', + propsForInteractionNotification: { notificationType: interactionNotification }, + isControlMessage: true, + } as const) + : isMessageResponse + ? ({ + messageType: 'message-request-response', + propsForMessageRequestResponse: {}, + isControlMessage: true, + } as const) + : ({ + messageType: 'regular-message', + isControlMessage: false, + } as const); const messageProps: MessageModelPropsWithoutConvoProps = { - propsForMessage: this.getPropsForMessage(), + propsForMessage, + ...byType, }; - if (propsForDataExtractionNotification) { - messageProps.propsForDataExtractionNotification = propsForDataExtractionNotification; - } - if (isMessageResponse) { - messageProps.propsForMessageRequestResponse = {}; - } - if (propsForCommunityInvitation) { - messageProps.propsForCommunityInvitation = propsForCommunityInvitation; - } - if (propsForGroupUpdateMessage) { - messageProps.propsForGroupUpdateMessage = propsForGroupUpdateMessage; - } - if (propsForTimerNotification) { - messageProps.propsForTimerNotification = propsForTimerNotification; - } - - if (callNotificationType) { - const propsForCallNotification: PropsForCallNotification = { - messageId: this.id, - notificationType: callNotificationType, - }; - messageProps.propsForCallNotification = propsForCallNotification; - } - - if (interactionNotification) { - messageProps.propsForInteractionNotification = { - notificationType: interactionNotification, - }; - } return messageProps; } @@ -189,7 +212,9 @@ export class MessageModel extends Model { this.isExpirationTimerUpdate() || this.isDataExtractionNotification() || this.isMessageRequestResponse() || - this.isGroupUpdate() + this.isGroupUpdate() || + this.isCallNotification() || + this.isInteractionNotification() ); } @@ -890,10 +915,12 @@ export class MessageModel extends Model { * Marks the message as deleted to show the author has deleted this message for everyone. * Sets isDeleted property to true. Set message body text to deletion placeholder for conversation list items. */ - public async markAsDeleted() { + public async markAsDeleted(deletedLocallyOnly: boolean) { this.set({ isDeleted: true, - body: tr('deleteMessageDeletedGlobally'), + body: deletedLocallyOnly + ? tr('deleteMessageDeletedLocally') + : tr('deleteMessageDeletedGlobally'), quote: undefined, groupInvitation: undefined, dataExtractionNotification: undefined, @@ -908,6 +935,8 @@ export class MessageModel extends Model { interactionNotification: undefined, reaction: undefined, messageRequestResponse: undefined, + errors: undefined, + unread: undefined, }); // we can ignore the result of that markMessageReadNoCommit as it would only be used // to refresh the expiry of it(but it is already marked as "deleted", so we don't care) diff --git a/ts/react.d.ts b/ts/react.d.ts index 694147edd..cea9b43ae 100644 --- a/ts/react.d.ts +++ b/ts/react.d.ts @@ -220,8 +220,9 @@ declare module 'react' { type InputLabels = | 'device_and_network' | 'device_only' - | 'deleteForEveryone' - | 'deleteJustForMe' + | 'deleteMessageEveryone' + | 'deleteMessageDevicesAll' + | 'deleteMessageDeviceOnly' | 'enterForSend' | 'enterForNewLine' | 'message' @@ -423,6 +424,7 @@ declare module 'react' { | 'msg-link-preview-title' | 'modal-heading' | 'modal-description' + | 'modal-warning' | 'error-message' | 'group-not-updated-30-days-banner' | 'delete-from-details' diff --git a/ts/receiver/contentMessage.ts b/ts/receiver/contentMessage.ts index 7e865340e..40a6f04ed 100644 --- a/ts/receiver/contentMessage.ts +++ b/ts/receiver/contentMessage.ts @@ -491,7 +491,7 @@ async function handleUnsendMessage( return; } if (messageToDelete.getSource() === UserUtils.getOurPubKeyStrFromCache()) { - // a message we sent is completely removed when we get a unsend request + // a message we sent is completely removed when we get a unsend request for it void deleteMessagesFromSwarmAndCompletelyLocally(conversation, [messageToDelete]); } else { void deleteMessagesFromSwarmAndMarkAsDeletedLocally(conversation, [messageToDelete]); diff --git a/ts/receiver/groupv2/handleGroupV2Message.ts b/ts/receiver/groupv2/handleGroupV2Message.ts index c4dce91d5..f4ffe4e34 100644 --- a/ts/receiver/groupv2/handleGroupV2Message.ts +++ b/ts/receiver/groupv2/handleGroupV2Message.ts @@ -2,7 +2,6 @@ import { GroupPubkeyType, PubkeyType, WithGroupPubkey } from 'libsession_util_no import { isEmpty, isFinite, isNumber } from 'lodash'; import { Data } from '../../data/data'; import { deleteAllMessagesByConvoIdNoConfirmation } from '../../interactions/conversationInteractions'; -import { deleteMessagesFromSwarmOnly } from '../../interactions/conversations/unsendingInteractions'; import { CONVERSATION_PRIORITIES, ConversationTypeEnum } from '../../models/types'; import { HexString } from '../../node/hexStrings'; import { SignalService } from '../../protobuf'; @@ -27,6 +26,8 @@ import { UserGroupsWrapperActions, } from '../../webworker/workers/browser/libsession_worker_interface'; import { sendInviteResponseToGroup } from '../../session/sending/group/GroupInviteResponse'; +import { deleteMessagesFromSwarmOnly } from '../../interactions/conversations/deleteMessagesFromSwarmOnly'; +import { deleteMessagesLocallyOnly } from '../../interactions/conversations/deleteMessagesLocallyOnly'; type WithSignatureTimestamp = { signatureTimestamp: number }; type WithAuthor = { author: PubkeyType }; @@ -445,19 +446,11 @@ async function handleGroupUpdateDeleteMemberContentMessage({ // processing the handleGroupUpdateDeleteMemberContentMessage itself // (we are running on the receiving pipeline here) // so network calls are not allowed. - for (let index = 0; index < messageModels.length; index++) { - const messageModel = messageModels[index]; - try { - // eslint-disable-next-line no-await-in-loop - await messageModel.markAsDeleted(); - } catch (e) { - window.log.warn( - `handleGroupUpdateDeleteMemberContentMessage markAsDeleted non-admin of ${messageModel.getMessageHash()} failed with`, - e.message - ); - } - } - convo.updateLastMessage(); + await deleteMessagesLocallyOnly({ + conversation: convo, + messages: messageModels, + deletionType: 'complete', + }); return; } @@ -496,19 +489,11 @@ async function handleGroupUpdateDeleteMemberContentMessage({ // (we are running on the receiving pipeline here) // so network calls are not allowed. const mergedModels = modelsByHashes.concat(modelsBySenders); - for (let index = 0; index < mergedModels.length; index++) { - const messageModel = mergedModels[index]; - try { - // eslint-disable-next-line no-await-in-loop - await messageModel.markAsDeleted(); - } catch (e) { - window.log.warn( - `handleGroupDeleteMemberContentMessage markAsDeleted non-admin of ${messageModel.getMessageHash()} failed with`, - e.message - ); - } - } - convo.updateLastMessage(); + await deleteMessagesLocallyOnly({ + conversation: convo, + messages: mergedModels, + deletionType: 'complete', + }); } async function handleGroupUpdateInviteResponseMessage({ diff --git a/ts/session/types/with.ts b/ts/session/types/with.ts index 2801a7d63..75266f877 100644 --- a/ts/session/types/with.ts +++ b/ts/session/types/with.ts @@ -1,3 +1,4 @@ +import type { SessionDataTestId } from 'react'; import { PubkeyType } from 'libsession_util_nodejs'; import { Snode } from '../../data/types'; @@ -22,8 +23,11 @@ export type WithGetNow = { getNow: () => number }; export type WithConvoId = { conversationId: string }; export type WithMessageId = { messageId: string }; +export type WithContextMenuId = { contextMenuId: string }; -export type WithLocalMessageDeletionType = { deletionType: 'complete' | 'markDeleted' }; +export type WithLocalMessageDeletionType = { + deletionType: 'complete' | 'markDeleted' | 'marDeletedLocally'; +}; export type ShortenOrExtend = 'extend' | 'shorten' | ''; export type WithShortenOrExtend = { shortenOrExtend: ShortenOrExtend }; export type WithMessagesHashes = { messagesHashes: Array }; @@ -37,3 +41,5 @@ export type WithGuardNode = { guardNode: Snode }; export type WithSymmetricKey = { symmetricKey: ArrayBuffer }; export type WithReason = { reason: string }; + +export type WithDataTestId = { dataTestId: SessionDataTestId }; diff --git a/ts/session/utils/job_runners/jobs/GroupPendingRemovalsJob.ts b/ts/session/utils/job_runners/jobs/GroupPendingRemovalsJob.ts index 1aa850ca3..67f34a648 100644 --- a/ts/session/utils/job_runners/jobs/GroupPendingRemovalsJob.ts +++ b/ts/session/utils/job_runners/jobs/GroupPendingRemovalsJob.ts @@ -4,7 +4,7 @@ import { compact, isEmpty, isNumber } from 'lodash'; import AbortController from 'abort-controller'; import { StringUtils } from '../..'; import { Data } from '../../../../data/data'; -import { deleteMessagesFromSwarmOnly } from '../../../../interactions/conversations/unsendingInteractions'; +import { deleteMessagesFromSwarmAndMarkAsDeletedLocally } from '../../../../interactions/conversations/unsendingInteractions'; import { MetaGroupWrapperActions, MultiEncryptWrapperActions, @@ -227,20 +227,9 @@ class GroupPendingRemovalsJob extends PersistedJob m.getMessageHash())); - if (messageHashes.length) { - await deleteMessagesFromSwarmOnly(messageHashes, groupPk); - } - for (let index = 0; index < models.length; index++) { - const messageModel = models[index]; - try { - // eslint-disable-next-line no-await-in-loop - await messageModel.markAsDeleted(); - } catch (e) { - window.log.warn( - `GroupPendingRemoval markAsDeleted of ${messageModel.getMessageHash()} failed with`, - e.message - ); - } + const convo = models?.[0].getConversation(); + if (convo && messageHashes.length) { + await deleteMessagesFromSwarmAndMarkAsDeletedLocally(convo, models); } } } catch (e) { diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 89e3cd74b..6f8fb695e 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -37,16 +37,73 @@ import { handleTriggeredCTAs } from '../../components/dialog/SessionCTA'; import { getFeatureFlag } from './types/releasedFeaturesReduxTypes'; import type { Quote } from '../../session/messages/outgoing/visibleMessage/VisibleMessage'; +export type UIMessageType = + | 'community-invitation' + | 'data-extraction-notification' + | 'timer-update-notification' + | 'group-update-notification' + | 'call-notification' + | 'interaction-notification' + | 'message-request-response' + | 'regular-message'; + +type MessageTypeIsControlMessage = Extract< + T, + | 'data-extraction-notification' + | 'timer-update-notification' + | 'group-update-notification' + | 'call-notification' + | 'interaction-notification' + | 'message-request-response' +>; + +type WithMessageTypeDetails = { + messageType: T; + isControlMessage: T extends MessageTypeIsControlMessage ? true : false; +}; + +type WithCommunityInvitation = WithMessageTypeDetails<'community-invitation'> & { + propsForCommunityInvitation: PropsForCommunityInvitation; +}; + +type WithDataExtractionNotification = WithMessageTypeDetails<'data-extraction-notification'> & { + propsForDataExtractionNotification: PropsForDataExtractionNotification; +}; + +type WithExpirationTimerUpdate = WithMessageTypeDetails<'timer-update-notification'> & { + propsForTimerNotification: PropsForExpirationTimer; +}; + +type WithGroupUpdateNotification = WithMessageTypeDetails<'group-update-notification'> & { + propsForGroupUpdateMessage: PropsForGroupUpdate; +}; + +type WithCallNotification = WithMessageTypeDetails<'call-notification'> & { + propsForCallNotification: PropsForCallNotification; +}; + +type WithInteractionNotification = WithMessageTypeDetails<'interaction-notification'> & { + propsForInteractionNotification: PropsForInteractionNotification; +}; + +type WithMessageRequestResponse = WithMessageTypeDetails<'message-request-response'> & { + propsForMessageRequestResponse: PropsForMessageRequestResponse; +}; + +type WithRegularMessage = WithMessageTypeDetails<'regular-message'>; + export type MessageModelPropsWithoutConvoProps = { propsForMessage: PropsForMessageWithoutConvoProps; - propsForCommunityInvitation?: PropsForCommunityInvitation; - propsForTimerNotification?: PropsForExpirationTimer; - propsForDataExtractionNotification?: PropsForDataExtractionNotification; - propsForGroupUpdateMessage?: PropsForGroupUpdate; - propsForCallNotification?: PropsForCallNotification; - propsForMessageRequestResponse?: PropsForMessageRequestResponse; - propsForInteractionNotification?: PropsForInteractionNotification; -}; +} & ( + | WithCallNotification + | WithCommunityInvitation + | WithDataExtractionNotification + | WithExpirationTimerUpdate + | WithMessageRequestResponse + | WithInteractionNotification + | WithRegularMessage + | WithGroupUpdateNotification +); export type MessageModelPropsWithConvoProps = SortedMessageModelProps & { propsForMessage: PropsForMessageWithConvoProps; @@ -180,8 +237,6 @@ export type PropsForMessageWithConvoProps = PropsForMessageWithoutConvoProps & { isKickedFromGroup: boolean; weAreAdmin: boolean; isSenderAdmin: boolean; - isDeletable: boolean; - isDeletableForEveryone: boolean; isBlocked: boolean; isDeleted?: boolean; }; @@ -663,20 +718,6 @@ const conversationsSlice = createSlice({ removeMessageInfoId(state: ConversationsStateType) { return { ...state, messageInfoId: undefined }; }, - addMessageIdToSelection(state: ConversationsStateType, action: PayloadAction) { - if (state.selectedMessageIds.some(id => id === action.payload)) { - return state; - } - return { ...state, selectedMessageIds: [...state.selectedMessageIds, action.payload] }; - }, - removeMessageIdFromSelection(state: ConversationsStateType, action: PayloadAction) { - const index = state.selectedMessageIds.findIndex(id => id === action.payload); - - if (index === -1) { - return state; - } - return { ...state, selectedMessageIds: state.selectedMessageIds.splice(index, 1) }; - }, toggleSelectedMessageId(state: ConversationsStateType, action: PayloadAction) { const index = state.selectedMessageIds.findIndex(id => id === action.payload); @@ -1120,7 +1161,6 @@ export const { openRightPanel, closeRightPanel, removeMessageInfoId, - addMessageIdToSelection, resetSelectedMessageIds, setFocusedMessageId, setIsCompositionTextAreaFocused, diff --git a/ts/state/ducks/modalDialog.tsx b/ts/state/ducks/modalDialog.tsx index 5cec27cf7..be26418ec 100644 --- a/ts/state/ducks/modalDialog.tsx +++ b/ts/state/ducks/modalDialog.tsx @@ -250,7 +250,7 @@ function pushModal( } function popModal(state: ModalState, modalId: ModalId) { - state[modalId] = null; + state[modalId] = null as never; // just to make tsc happy state.modalStack = state.modalStack.filter(m => m !== modalId); return state; diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 4d5388e02..94866c5a8 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -19,8 +19,6 @@ import { StateType } from '../reducer'; import { ReplyingToMessageProps } from '../../components/conversation/composition/CompositionBox'; import { MessageAttachmentSelectorProps } from '../../components/conversation/message/message-content/MessageAttachment'; import { MessageContentSelectorProps } from '../../components/conversation/message/message-content/MessageContent'; -import { MessageContentWithStatusSelectorProps } from '../../components/conversation/message/message-content/MessageContentWithStatus'; -import { GenericReadableMessageSelectorProps } from '../../components/conversation/message/message-item/GenericReadableMessage'; import { hasValidIncomingRequestValues } from '../../models/conversation'; import { isOpenOrClosedGroup } from '../../models/conversationAttributes'; import { ConvoHub } from '../../session/conversations'; @@ -36,7 +34,7 @@ import { isUsAnySogsFromCache } from '../../session/apis/open_group_api/sogsv3/k import { PubKey } from '../../session/types'; import { UserGroupsWrapperActions } from '../../webworker/workers/browser/libsession_worker_interface'; import { getSelectedConversationKey } from './selectedConversation'; -import { getModeratorsOutsideRedux, useModerators } from './sogsRoomInfo'; +import { useModerators } from './sogsRoomInfo'; import type { SessionSuggestionDataItem } from '../../components/conversation/composition/types'; import { useIsPublic, useWeAreAdmin } from '../../hooks/useParamSelector'; import { tr } from '../../localization/localeTools'; @@ -127,16 +125,6 @@ export const getFirstUnreadMessageId = (state: StateType): string | null => { return state.conversations.firstUnreadMessageId; }; -export type MessagePropsType = - | 'group-notification' - | 'group-invitation' - | 'data-extraction' - | 'message-request-response' - | 'timer-notification' - | 'regular-message' - | 'call-notification' - | 'interaction-notification'; - export const getSortedMessagesTypesOfSelectedConversation = createSelector( getSortedMessagesOfSelectedConversation, getFirstUnreadMessageId, @@ -166,27 +154,8 @@ export const getSortedMessagesTypesOfSelectedConversation = createSelector( messageId: msg.propsForMessage.id, }; - const messageType: MessagePropsType = msg.propsForDataExtractionNotification - ? ('data-extraction' as const) - : msg.propsForMessageRequestResponse - ? ('message-request-response' as const) - : msg.propsForCommunityInvitation - ? ('group-invitation' as const) - : msg.propsForGroupUpdateMessage - ? ('group-notification' as const) - : msg.propsForTimerNotification - ? ('timer-notification' as const) - : msg.propsForCallNotification - ? ('call-notification' as const) - : msg.propsForInteractionNotification - ? ('interaction-notification' as const) - : ('regular-message' as const); - return { ...common, - message: { - messageType, - }, }; }); } @@ -745,21 +714,6 @@ export const getMessagePropsByMessageId = createSelector( const groupAdmins = (isGroup && selectedConvo.groupAdmins) || []; const weAreAdmin = groupAdmins.includes(ourPubkey) || false; - const weAreModerator = - (isPublic && getModeratorsOutsideRedux(selectedConvo.id).includes(ourPubkey)) || false; - // A message is deletable if - // either we sent it, - // or the convo is not a public one (in this case, we will only be able to delete for us) - // or the convo is public and we are an admin or moderator - const isDeletable = - sender === ourPubkey || !isPublic || (isPublic && (weAreAdmin || weAreModerator)); - - // A message is deletable for everyone if - // either we sent it no matter what the conversation type, - // or the convo is public and we are an admin or moderator - const isDeletableForEveryone = - sender === ourPubkey || (isPublic && (weAreAdmin || weAreModerator)) || false; - const isSenderAdmin = groupAdmins.includes(sender); const messageProps: MessageModelPropsWithConvoProps = { @@ -769,8 +723,6 @@ export const getMessagePropsByMessageId = createSelector( isBlocked: !!selectedConvo.isBlocked, isPublic: !!isPublic, isSenderAdmin, - isDeletable, - isDeletableForEveryone, weAreAdmin, conversationType: selectedConvo.type, sender, @@ -941,9 +893,7 @@ export const getIsMessageSelected = createSelector( return false; } - const { id } = props.propsForMessage; - - return selectedIds.includes(id); + return selectedIds.includes(props.propsForMessage.id); } ); @@ -969,50 +919,6 @@ export const getMessageContentSelectorProps = createSelector( return msgProps; } ); - -export const getMessageContentWithStatusesSelectorProps = createSelector( - getMessagePropsByMessageId, - (props): MessageContentWithStatusSelectorProps | undefined => { - if (!props || isEmpty(props)) { - return undefined; - } - - const isGroup = - props.propsForMessage.conversationType !== 'private' && !props.propsForMessage.isPublic; - - const msgProps: MessageContentWithStatusSelectorProps = { - ...pick(props.propsForMessage, ['conversationType', 'direction', 'isDeleted']), - isGroup, - }; - - return msgProps; - } -); - -export const getGenericReadableMessageSelectorProps = createSelector( - getMessagePropsByMessageId, - (props): GenericReadableMessageSelectorProps | undefined => { - if (!props || isEmpty(props)) { - return undefined; - } - - const msgProps: GenericReadableMessageSelectorProps = pick(props.propsForMessage, [ - 'convoId', - 'direction', - 'conversationType', - 'expirationDurationMs', - 'expirationTimestamp', - 'isExpired', - 'isUnread', - 'receivedAt', - 'isDeleted', - 'isKickedFromGroup', - ]); - - return msgProps; - } -); - export const getOldTopMessageId = (state: StateType): string | null => state.conversations.oldTopMessageId || null; diff --git a/ts/state/selectors/messages.ts b/ts/state/selectors/messages.ts index 700626352..66a3835e0 100644 --- a/ts/state/selectors/messages.ts +++ b/ts/state/selectors/messages.ts @@ -116,10 +116,6 @@ export const useMessageSenderIsAdmin = (messageId: string | undefined): boolean return useMessagePropsByMessageId(messageId)?.propsForMessage.isSenderAdmin || false; }; -export const useMessageIsDeletable = (messageId: string | undefined): boolean => { - return useMessagePropsByMessageId(messageId)?.propsForMessage.isDeletable || false; -}; - export const useMessageStatus = ( messageId: string | undefined ): LastMessageStatusType | undefined => { @@ -130,8 +126,8 @@ export function useMessageSender(messageId: string | undefined) { return useMessagePropsByMessageId(messageId)?.propsForMessage.sender; } -export function useMessageIsDeletableForEveryone(messageId: string | undefined) { - return useMessagePropsByMessageId(messageId)?.propsForMessage.isDeletableForEveryone; +export function useMessageIsControlMessage(messageId: string | undefined) { + return useMessagePropsByMessageId(messageId)?.isControlMessage; } export function useMessageServerTimestamp(messageId: string | undefined) { @@ -178,6 +174,10 @@ export const useMessageServerId = (messageId: string | undefined) => { return useMessagePropsByMessageId(messageId)?.propsForMessage.serverId; }; +export function useMessageType(messageId: string | undefined) { + return useMessagePropsByMessageId(messageId)?.messageType; +} + export const useMessageText = (messageId: string | undefined): string | undefined => { return useMessagePropsByMessageId(messageId)?.propsForMessage.text; }; @@ -206,18 +206,29 @@ export function useMessageSentWithProFeatures(messageId?: string) { * ================================================== */ +function useCommunityInvitationProps(messageId: string | undefined) { + const props = useMessagePropsByMessageId(messageId); + if (!props) { + return null; + } + if (props.messageType !== 'community-invitation') { + throw new Error('useCommunityInvitationProps: messageType is not community-invitation'); + } + return props?.propsForCommunityInvitation; +} + /** * Return the full url needed to join a community through a community invitation message */ export function useMessageCommunityInvitationFullUrl(messageId: string) { - return useMessagePropsByMessageId(messageId)?.propsForCommunityInvitation?.fullUrl; + return useCommunityInvitationProps(messageId)?.fullUrl; } /** * Return the community display name to have a guess of what a community is about */ export function useMessageCommunityInvitationCommunityName(messageId: string) { - return useMessagePropsByMessageId(messageId)?.propsForCommunityInvitation?.serverName; + return useCommunityInvitationProps(messageId)?.serverName; } /** @@ -230,7 +241,14 @@ export function useMessageCommunityInvitationCommunityName(messageId: string) { * Return the call notification type linked to the specified message */ export function useMessageCallNotificationType(messageId: string) { - return useMessagePropsByMessageId(messageId)?.propsForCallNotification?.notificationType; + const props = useMessagePropsByMessageId(messageId); + if (!props) { + return null; + } + if (props.messageType !== 'call-notification') { + throw new Error('useCommunityInvitationProps: messageType is not call-notification'); + } + return props.propsForCallNotification?.notificationType; } /** @@ -243,7 +261,16 @@ export function useMessageCallNotificationType(messageId: string) { * Return the data extraction type linked to the specified message */ export function useMessageDataExtractionType(messageId: string) { - return useMessagePropsByMessageId(messageId)?.propsForDataExtractionNotification?.type; + const props = useMessagePropsByMessageId(messageId); + if (!props) { + return null; + } + if (props.messageType !== 'data-extraction-notification') { + throw new Error( + 'useMessageDataExtractionType: messageType is not data-extraction-notification' + ); + } + return props?.propsForDataExtractionNotification?.type; } /** @@ -256,7 +283,14 @@ export function useMessageDataExtractionType(messageId: string) { * Return the interaction notification type linked to the specified message */ export function useMessageInteractionNotification(messageId: string) { - return useMessagePropsByMessageId(messageId)?.propsForInteractionNotification?.notificationType; + const props = useMessagePropsByMessageId(messageId); + if (!props) { + return null; + } + if (props.messageType !== 'interaction-notification') { + throw new Error('useMessageDataExtractionType: messageType is not interaction-notification'); + } + return props?.propsForInteractionNotification?.notificationType; } /** @@ -265,11 +299,22 @@ export function useMessageInteractionNotification(messageId: string) { * ================================================ */ +function useExpirationTimerUpdateProps(messageId: string | undefined) { + const props = useMessagePropsByMessageId(messageId); + if (!props) { + return null; + } + if (props.messageType !== 'timer-update-notification') { + throw new Error('useExpirationTimerUpdateProps: messageType is not timer-update-notification'); + } + return props?.propsForTimerNotification; +} + /** * Return the expiration update mode linked to the specified message */ export function useMessageExpirationUpdateMode(messageId: string) { - return useMessagePropsByMessageId(messageId)?.propsForTimerNotification?.expirationMode || 'off'; + return useExpirationTimerUpdateProps(messageId)?.expirationMode || 'off'; } /** @@ -284,14 +329,14 @@ export function useMessageExpirationUpdateDisabled(messageId: string) { * Return the timespan in seconds to which this expiration timer update is set */ export function useMessageExpirationUpdateTimespanSeconds(messageId: string) { - return useMessagePropsByMessageId(messageId)?.propsForTimerNotification?.timespanSeconds; + return useExpirationTimerUpdateProps(messageId)?.timespanSeconds; } /** * Return the timespan in text (localised) built from the field timespanSeconds */ export function useMessageExpirationUpdateTimespanText(messageId: string) { - return useMessagePropsByMessageId(messageId)?.propsForTimerNotification?.timespanText || ''; + return useExpirationTimerUpdateProps(messageId)?.timespanText || ''; } /** @@ -304,5 +349,12 @@ export function useMessageExpirationUpdateTimespanText(messageId: string) { * Return the group change corresponding to this message's group update */ export function useMessageGroupUpdateChange(messageId: string) { - return useMessagePropsByMessageId(messageId)?.propsForGroupUpdateMessage?.change; + const props = useMessagePropsByMessageId(messageId); + if (!props) { + return undefined; + } + if (props.messageType !== 'group-update-notification') { + throw new Error('useExpirationTimerUpdateProps: messageType is not group-update-notification'); + } + return props?.propsForGroupUpdateMessage?.change; } diff --git a/ts/types/isStringArray.ts b/ts/types/isStringArray.ts new file mode 100644 index 000000000..03d9b60e6 --- /dev/null +++ b/ts/types/isStringArray.ts @@ -0,0 +1,3 @@ +export function isStringArray(value: unknown): value is Array { + return Array.isArray(value) && value.every(val => typeof val === 'string'); +} diff --git a/ts/util/accountManager.ts b/ts/util/accountManager.ts index 9ef384936..dd97017ef 100644 --- a/ts/util/accountManager.ts +++ b/ts/util/accountManager.ts @@ -22,7 +22,6 @@ import { Registration } from './registration'; import { Storage, saveRecoveryPhrase, setLocalPubKey, setSignInByLinking } from './storage'; import { PromiseUtils } from '../session/utils'; import { SnodeAPI } from '../session/apis/snode_api/SNodeAPI'; -import { tr } from '../localization/localeTools'; import { SessionDisplayNameOnlyPrivate } from '../models/profile'; import { UserConfigWrapperActions } from '../webworker/workers/browser/libsession/libsession_worker_userconfig_interface'; @@ -357,11 +356,11 @@ export async function deleteEverythingAndNetworkData() { // open a new confirm dialog to ask user what to do window?.inboxStore?.dispatch( updateConfirmModal({ - title: tr('clearDataAll'), + title: { token: 'clearDataAll' }, i18nMessage: { token: 'clearDataErrorDescriptionGeneric' }, okTheme: SessionButtonColor.Danger, - okText: tr('clearDevice'), - cancelText: tr('cancel'), + okText: { token: 'clearDevice' }, + cancelText: { token: 'cancel' }, onClickOk: async () => { await deleteDbLocally(); window.restart(); From a813a6ef9cb38b127bc8a53b44e130cbeaa02894 Mon Sep 17 00:00:00 2001 From: Ryan Miller Date: Thu, 26 Feb 2026 15:41:22 +1100 Subject: [PATCH 06/21] fix: emoji panel focus trap --- ts/components/SessionFocusTrap.tsx | 87 +++++++++++---- ts/components/SessionTooltip.tsx | 11 +- .../conversation/SessionEmojiPanel.tsx | 68 ++++++------ .../conversation/SessionEmojiPanelPopover.tsx | 28 +++-- .../SessionEmojiReactBarPopover.tsx | 103 +++++++----------- .../composition/CompositionBox.tsx | 15 +-- .../MessageContentWithStatus.tsx | 45 +++++--- .../message-content/MessageContextMenu.tsx | 9 +- .../message-content/MessageReactBar.tsx | 41 ++----- .../message-item/GenericReadableMessage.tsx | 37 ++++--- ts/components/leftpane/ActionsPanel.tsx | 6 + ts/hooks/useMessageInteractions.ts | 2 +- ts/react.d.ts | 3 +- ts/util/logger/renderer_process_logging.ts | 17 ++- 14 files changed, 254 insertions(+), 218 deletions(-) diff --git a/ts/components/SessionFocusTrap.tsx b/ts/components/SessionFocusTrap.tsx index d2e01f9a1..3c79deeb2 100644 --- a/ts/components/SessionFocusTrap.tsx +++ b/ts/components/SessionFocusTrap.tsx @@ -1,34 +1,83 @@ -import { FocusTrap } from 'focus-trap-react'; -import { ReactNode } from 'react'; +import { FocusTrap, type FocusTrapProps } from 'focus-trap-react'; +import { type ReactNode, useEffect, useState } from 'react'; import type { CSSProperties } from 'styled-components'; +import { windowErrorFilters } from '../util/logger/renderer_process_logging'; + +const focusTrapErrorSource = 'focus-trap'; + +type SessionFocusTrapProps = FocusTrapProps['focusTrapOptions'] & { + children: ReactNode; + active?: boolean; + containerDivStyle?: CSSProperties; + /** Suppress errors thrown from inside the focus trap, preventing logging or global error emission */ + suppressErrors?: boolean; + /** Allows the focus trap to exist without detectable tabbable elements. This is required if the children + * are within a Shadow DOM. Internally sets suppressErrors to true. */ + allowNoTabbableNodes?: boolean; +}; -/** - * Focus trap which activates on mount. - */ export function SessionFocusTrap({ children, + active = true, allowOutsideClick = true, - returnFocusOnDeactivate, - initialFocus, containerDivStyle, -}: { - children: ReactNode; - allowOutsideClick?: boolean; - returnFocusOnDeactivate?: boolean; - initialFocus: () => HTMLElement | null; - containerDivStyle?: CSSProperties; -}) { + suppressErrors, + allowNoTabbableNodes, + onPostActivate, + onDeactivate, + ...rest +}: SessionFocusTrapProps) { + const defaultTabIndex = allowNoTabbableNodes ? 0 : -1; + const _suppressErrors = suppressErrors || allowNoTabbableNodes; + /** + * NOTE: the tab index tricks the focus trap into thinking it has + * tabbable children by setting a tab index on the empty div child. When + * the trap activates it will see the div in the tab list and render without + * error, then remove that div from the tab index list. Then when the trap + * deactivates the state is reset. + */ + const [tabIndex, setTabIndex] = useState<0 | 1 | -1>(defaultTabIndex); + + const _onPostActivate = () => { + if (allowNoTabbableNodes) { + setTabIndex(-1); + } + onPostActivate?.(); + }; + + const _onDeactivate = () => { + if (allowNoTabbableNodes) { + setTabIndex(defaultTabIndex); + } + onDeactivate?.(); + }; + + useEffect(() => { + if (!active || !_suppressErrors) { + return; + } + windowErrorFilters.add(focusTrapErrorSource); + // eslint-disable-next-line consistent-return -- This return is the destructor + return () => { + windowErrorFilters.remove(focusTrapErrorSource); + }; + }, [_suppressErrors, active]); + return ( - {/* Note: not too sure why, but without this div, the focus trap doesn't work */} -
{children}
+ {/* Note: without this div, the focus trap doesn't work */} +
+ {allowNoTabbableNodes ?
: null} + {children} +
); } diff --git a/ts/components/SessionTooltip.tsx b/ts/components/SessionTooltip.tsx index 35750bc3c..dfdcf54d3 100644 --- a/ts/components/SessionTooltip.tsx +++ b/ts/components/SessionTooltip.tsx @@ -67,15 +67,20 @@ export const getTriggerPositionFromId = (id: string): PopoverTriggerPosition => }; // Returns null if the ref is null -export const useTriggerPosition = ( - ref: RefObject -): PopoverTriggerPosition | null => { +export const getTriggerPosition = (ref: RefObject) => { if (!ref.current) { return null; } return getTriggerPositionFromBoundingClientRect(ref.current.getBoundingClientRect()); }; +// Returns null if the ref is null +export const useTriggerPosition = ( + ref: RefObject +): PopoverTriggerPosition | null => { + return getTriggerPosition(ref); +}; + export const SessionTooltip = ({ children, content, diff --git a/ts/components/conversation/SessionEmojiPanel.tsx b/ts/components/conversation/SessionEmojiPanel.tsx index d2bae1a35..a73314a0b 100644 --- a/ts/components/conversation/SessionEmojiPanel.tsx +++ b/ts/components/conversation/SessionEmojiPanel.tsx @@ -1,7 +1,6 @@ import Picker from '@emoji-mart/react'; -import { forwardRef } from 'react'; +import { type RefObject } from 'react'; import styled from 'styled-components'; -import clsx from 'clsx'; import { usePrimaryColor } from '../../state/selectors/primaryColor'; import { useIsDarkTheme, useTheme } from '../../state/theme/selectors/theme'; @@ -9,6 +8,7 @@ import { COLORS, THEMES, ThemeStateType, type ColorsType } from '../../themes/co import { FixedBaseEmoji } from '../../types/Reaction'; import { i18nEmojiData } from '../../util/emoji'; import { hexColorToRGB } from '../../util/hexColorToRGB'; +import { SessionFocusTrap } from '../SessionFocusTrap'; export const StyledEmojiPanel = styled.div<{ $isModal: boolean; @@ -19,20 +19,11 @@ export const StyledEmojiPanel = styled.div<{ }>` ${props => (!props.$isModal ? 'padding: var(--margins-lg);' : '')} z-index: 5; - opacity: 0; - visibility: hidden; - // this disables the slide-in animation when showing the emoji picker from a right click on a message - /* transition: var(--default-duration); */ button:focus { outline: none; } - &.show { - opacity: 1; - visibility: visible; - } - em-emoji-picker { ${props => props.$panelBackgroundRGB && `background-color: rgb(${props.$panelBackgroundRGB})`}; border: var(--default-borders); @@ -75,21 +66,18 @@ export const StyledEmojiPanel = styled.div<{ `; type Props = { + ref: RefObject; onEmojiClicked: (emoji: FixedBaseEmoji) => void; - show: boolean; isModal?: boolean; onClose?: () => void; + show: boolean; }; -const pickerProps = { - title: '', - showPreview: true, - autoFocus: true, - skinTonePosition: 'preview', +export const SessionEmojiPanel = (props: Props) => { + return props.show ? : null; }; -export const SessionEmojiPanel = forwardRef((props: Props, ref) => { - const { onEmojiClicked, show, isModal = false, onClose } = props; +const EmojiPanel = ({ ref, onEmojiClicked, isModal = false, onClose }: Props) => { const _primaryColor = usePrimaryColor(); const theme = useTheme(); const isDarkTheme = useIsDarkTheme(); @@ -125,22 +113,30 @@ export const SessionEmojiPanel = forwardRef((props: Props ); return ( - - - + + + + ); -}); +}; diff --git a/ts/components/conversation/SessionEmojiPanelPopover.tsx b/ts/components/conversation/SessionEmojiPanelPopover.tsx index 700a96158..43f01e4c2 100644 --- a/ts/components/conversation/SessionEmojiPanelPopover.tsx +++ b/ts/components/conversation/SessionEmojiPanelPopover.tsx @@ -9,22 +9,22 @@ const EMOJI_PANEL_WIDTH_PX = 354; const EMOJI_PANEL_HEIGHT_PX = 435; export function SessionEmojiPanelPopover({ - triggerPos, emojiPanelRef, + triggerPosition, onEmojiClick, open, onClose, }: { - triggerPos: PopoverTriggerPosition | null; - open: boolean; emojiPanelRef: RefObject; - onEmojiClick: (emoji: FixedBaseEmoji) => Promise; + triggerPosition: PopoverTriggerPosition | null; + open: boolean; + onEmojiClick: (emoji: FixedBaseEmoji) => void; onClose: () => void; }) { - const _open = open && !!triggerPos; + const _open = open && !!triggerPosition; return ( - {_open ? ( - void onEmojiClick(emoji)} - onClose={onClose} - isModal={true} - /> - ) : null} + ); } diff --git a/ts/components/conversation/SessionEmojiReactBarPopover.tsx b/ts/components/conversation/SessionEmojiReactBarPopover.tsx index f17c6f917..262ee71a4 100644 --- a/ts/components/conversation/SessionEmojiReactBarPopover.tsx +++ b/ts/components/conversation/SessionEmojiReactBarPopover.tsx @@ -1,50 +1,54 @@ -import { useEffect, useRef, useState } from 'react'; -import useClickAway from 'react-use/lib/useClickAway'; -import { useTriggerPosition, type PopoverTriggerPosition } from '../SessionTooltip'; +import { type RefObject, useRef, useState } from 'react'; +import { getTriggerPosition, type PopoverTriggerPosition } from '../SessionTooltip'; import { SessionPopoverContent } from '../SessionPopover'; import { MessageReactBar } from './message/message-content/MessageReactBar'; import { THEME_GLOBALS } from '../../themes/globals'; import { SessionEmojiPanelPopover } from './SessionEmojiPanelPopover'; import { closeContextMenus } from '../../util/contextMenu'; -import { useFocusedMessageId } from '../../state/selectors/conversations'; import { useMessageReact } from '../../hooks/useMessageInteractions'; export function SessionEmojiReactBarPopover({ messageId, - open, triggerPos, - onClickAwayFromReactionBar, - autoFocusFirstEmoji, + reactBarFirstEmojiRef, }: { messageId: string; // this can be null as we want the emoji panel to stay when the reaction bar closes triggerPos: PopoverTriggerPosition | null; - open: boolean; - onClickAwayFromReactionBar: () => void; - autoFocusFirstEmoji?: boolean; + reactBarFirstEmojiRef?: RefObject; }) { const emojiPanelTriggerRef = useRef(null); - const emojiPanelTriggerPos = useTriggerPosition(emojiPanelTriggerRef); const emojiPanelRef = useRef(null); const emojiReactionBarRef = useRef(null); - const [showEmojiPanel, setShowEmojiPanel] = useState(false); + const [emojiPanelTriggerPos, setEmojiPanelTriggerPos] = useState( + null + ); const reactToMessage = useMessageReact(messageId); - const focusedMessageId = useFocusedMessageId(); + + const barOpen = !!triggerPos; + const panelOpen = !!emojiPanelTriggerPos; const closeEmojiPanel = () => { - setShowEmojiPanel(false); + setEmojiPanelTriggerPos(null); }; const openEmojiPanel = () => { closeContextMenus(); - setShowEmojiPanel(true); + const pos = getTriggerPosition(emojiPanelTriggerRef); + if (pos) { + setEmojiPanelTriggerPos(pos); + } else { + window.log.warn( + `[SessionEmojiReactBarPopover] getTriggerPosition for the emojiPanelTriggerRef returned null for message ${messageId}` + ); + } }; - const onEmojiClick = async (args: any) => { + const onEmojiClick = (args: any) => { const emoji = args.native ?? args; closeEmojiPanel(); if (reactToMessage) { - await reactToMessage(emoji); + void reactToMessage(emoji); } else { window.log.warn( `[SessionEmojiReactBarPopover] reactToMessage undefined for message ${messageId}` @@ -52,57 +56,34 @@ export function SessionEmojiReactBarPopover({ } }; - useClickAway(emojiPanelRef, () => { - if (showEmojiPanel) { - closeEmojiPanel(); - } - }); - - useClickAway(emojiReactionBarRef, () => { - if (open) { - onClickAwayFromReactionBar(); - } - }); - - useEffect(() => { - if (focusedMessageId && messageId && focusedMessageId !== messageId) { - onClickAwayFromReactionBar(); - } - }, [focusedMessageId, messageId, onClickAwayFromReactionBar]); - return ( <> - {triggerPos ? ( - - {open ? ( - - ) : null} - - ) : null} + + + ); } diff --git a/ts/components/conversation/composition/CompositionBox.tsx b/ts/components/conversation/composition/CompositionBox.tsx index 618c1ca41..78edbbea3 100644 --- a/ts/components/conversation/composition/CompositionBox.tsx +++ b/ts/components/conversation/composition/CompositionBox.tsx @@ -297,17 +297,6 @@ class CompositionBoxInner extends Component { ); } - private handleClick(e: any) { - if ( - (this.emojiPanel?.current && this.emojiPanel.current.contains(e.target)) || - (this.emojiPanelButton?.current && this.emojiPanelButton.current.contains(e.target)) - ) { - return; - } - - this.hideEmojiPanel(); - } - private handlePaste(e: ClipboardEvent) { if (!e.clipboardData) { return; @@ -342,7 +331,6 @@ class CompositionBoxInner extends Component { } private showEmojiPanel() { - document.addEventListener('mousedown', this.handleClick, false); this.setState({ lastSelectedLength: window.getSelection()?.toString().length ?? 0 }); closeContextMenus(); @@ -352,7 +340,6 @@ class CompositionBoxInner extends Component { } private hideEmojiPanel() { - document.removeEventListener('mousedown', this.handleClick, false); this.setState({ lastSelectedLength: 0 }); this.setState({ @@ -454,9 +441,9 @@ class CompositionBoxInner extends Component { ) : null} diff --git a/ts/components/conversation/message/message-content/MessageContentWithStatus.tsx b/ts/components/conversation/message/message-content/MessageContentWithStatus.tsx index 6d925723b..429a6e7a4 100644 --- a/ts/components/conversation/message/message-content/MessageContentWithStatus.tsx +++ b/ts/components/conversation/message/message-content/MessageContentWithStatus.tsx @@ -1,4 +1,4 @@ -import { SessionDataTestId, MouseEvent, useCallback, Dispatch } from 'react'; +import { SessionDataTestId, MouseEvent, useCallback, Dispatch, useRef } from 'react'; import { useSelector } from 'react-redux'; import { clsx } from 'clsx'; import styled from 'styled-components'; @@ -22,6 +22,7 @@ import { SessionEmojiReactBarPopover } from '../../SessionEmojiReactBarPopover'; import { PopoverTriggerPosition } from '../../../SessionTooltip'; import { trimWhitespace } from '../../../../session/utils/String'; import { useMessageReact, useMessageReply } from '../../../../hooks/useMessageInteractions'; +import { SessionFocusTrap } from '../../../SessionFocusTrap'; export type MessageContentWithStatusSelectorProps = { isGroup: boolean } & Pick< MessageRenderingProps, @@ -64,7 +65,6 @@ export const MessageContentWithStatuses = (props: Props) => { convoReactionsEnabled, triggerPosition, setTriggerPosition, - autoFocusReactionBarFirstEmoji, } = props; const dispatch = getAppDispatch(); const contentProps = useSelector((state: StateType) => @@ -78,6 +78,8 @@ export const MessageContentWithStatuses = (props: Props) => { const status = useMessageStatus(props.messageId); const isSent = status === 'sent' || status === 'read'; // a read message should be reactable + const reactBarFirstEmojiRef = useRef(null); + const onClickOnMessageOuterContainer = useCallback( (event: MouseEvent) => { if (multiSelectMode && messageId) { @@ -129,6 +131,8 @@ export const MessageContentWithStatuses = (props: Props) => { setTriggerPosition(null); }; + const active = !!triggerPosition && !!reactBarFirstEmojiRef.current; + return ( { - {enableReactions ? ( - - ) : null} - {enableContextMenu ? ( - - ) : null} + reactBarFirstEmojiRef.current ?? false} + onDeactivate={closeReactionBar} + clickOutsideDeactivates={true} + > + {enableReactions ? ( + + ) : null} + {enableContextMenu ? ( + + ) : null} + {!isDetailView && enableReactions ? ( {tr('reply')} diff --git a/ts/components/conversation/message/message-content/MessageReactBar.tsx b/ts/components/conversation/message/message-content/MessageReactBar.tsx index d81708d06..255641986 100644 --- a/ts/components/conversation/message/message-content/MessageReactBar.tsx +++ b/ts/components/conversation/message/message-content/MessageReactBar.tsx @@ -1,22 +1,17 @@ import styled from 'styled-components'; -import { type RefObject, type KeyboardEvent, useRef } from 'react'; -import useMount from 'react-use/lib/useMount'; +import { type RefObject } from 'react'; import { nativeEmojiData } from '../../../../util/emoji'; import { getRecentReactions } from '../../../../util/storage'; import { LUCIDE_ICONS_UNICODE } from '../../../icon/lucide'; import { SessionLucideIconButton } from '../../../icon/SessionIconButton'; -import { - createButtonOnKeyDownForClickEventHandler, - isEscapeKey, -} from '../../../../util/keyboardShortcuts'; +import { createButtonOnKeyDownForClickEventHandler } from '../../../../util/keyboardShortcuts'; type Props = { ref?: RefObject; emojiPanelTriggerRef: RefObject; - autoFocusFirstEmoji?: boolean; - onEmojiClick: (emoji: string) => Promise; + firstEmojiRef?: RefObject; + onEmojiClick: (emoji: string) => void; onPlusButtonClick: () => void; - closeReactionBar: () => void; }; const StyledMessageReactBar = styled.div` @@ -59,41 +54,24 @@ const StyledContainer = styled.div` export const MessageReactBar = ({ ref, + emojiPanelTriggerRef, + firstEmojiRef, onEmojiClick, onPlusButtonClick, - emojiPanelTriggerRef, - closeReactionBar, - autoFocusFirstEmoji, }: Props) => { const recentReactions = getRecentReactions(); - const firstEmojiRef = useRef(null); - - useMount(() => { - // NOTE: this allows the fist emoji to be focused when the - // reaction bar appears if auto focus is enabled - if (autoFocusFirstEmoji) { - firstEmojiRef?.current?.focus(); - } - }); return ( {recentReactions.map((emoji, i) => { - const onClick = () => void onEmojiClick(emoji); - const onKeyDownButtonClickHandler = createButtonOnKeyDownForClickEventHandler(onClick); - const onKeyDown = (e: KeyboardEvent) => { - if (isEscapeKey(e)) { - closeReactionBar(); - } else { - onKeyDownButtonClickHandler(e); - } - }; + const onClick = () => onEmojiClick(emoji); + const onKeyDown = createButtonOnKeyDownForClickEventHandler(onClick); const ariaLabel = nativeEmojiData?.ariaLabels?.[emoji]; return ( ` display: flex; align-items: center; @@ -76,6 +78,11 @@ const StyledReadableMessage = styled.div<{ background-color: var(--conversation-tab-background-selected-color); }` : ''} + + ${props => + props.$forceFocusedMessageBackground + ? 'background-color: var(--conversation-tab-background-selected-color);' + : ''} `; export const GenericReadableMessage = (props: Props) => { @@ -95,12 +102,22 @@ export const GenericReadableMessage = (props: Props) => { const ref = useRef(null); const pointerDownRef = useRef(false); const [triggerPosition, setTriggerPosition] = useState(null); - const [autoFocusReactionBarFirstEmoji, setAutoFocusReactionBarFirstEmoji] = - useState(false); const isInFocusScope = useIsInScope({ scope: 'message', scopeId: messageId }); const { focusedMessageId } = useFocusScope(); const isAnotherMessageFocused = focusedMessageId && !isInFocusScope; + const focusMessageId = () => { + dispatch(setFocusedMessageId(messageId)); + }; + + const onFocus = () => { + focusMessageId(); + }; + + const onBlur = () => { + dispatch(setFocusedMessageId(null)); + }; + const getMessageContainerTriggerPosition = (): PopoverTriggerPosition | null => { if (!ref.current) { return null; @@ -152,28 +169,18 @@ export const GenericReadableMessage = (props: Props) => { } const overrideTriggerPosition = getMessageContainerTriggerPosition(); if (overrideTriggerPosition) { - setAutoFocusReactionBarFirstEmoji(true); handleContextMenu(e, overrideTriggerPosition); } } }; - const onFocus = () => { - dispatch(setFocusedMessageId(messageId)); - }; - - const onBlur = () => { - dispatch(setFocusedMessageId(null)); - setAutoFocusReactionBarFirstEmoji(false); - }; - const toggleEmojiReactionBarWithKeyboard = () => { if (triggerPosition) { + closeContextMenus(); setTriggerPosition(null); } else { const pos = getMessageContainerTriggerPosition(); if (pos) { - setAutoFocusReactionBarFirstEmoji(true); setTriggerPosition(pos); } } @@ -207,6 +214,9 @@ export const GenericReadableMessage = (props: Props) => { key={`readable-message-${messageId}`} onKeyDown={onKeyDown} $focusedKeyboard={!pointerDownRef.current} + $forceFocusedMessageBackground={ + /** FIXME: sss */ !!triggerPosition && !pointerDownRef.current + } tabIndex={0} onPointerDown={() => { pointerDownRef.current = true; @@ -224,7 +234,6 @@ export const GenericReadableMessage = (props: Props) => { convoReactionsEnabled={convoReactionsEnabled} triggerPosition={triggerPosition} setTriggerPosition={setTriggerPosition} - autoFocusReactionBarFirstEmoji={autoFocusReactionBarFirstEmoji} /> ); diff --git a/ts/components/leftpane/ActionsPanel.tsx b/ts/components/leftpane/ActionsPanel.tsx index dfe86d97d..2aaa95e7f 100644 --- a/ts/components/leftpane/ActionsPanel.tsx +++ b/ts/components/leftpane/ActionsPanel.tsx @@ -180,6 +180,12 @@ function useDebugFocusScope() { const debugFocusScope = getFeatureFlagMemo('debugFocusScope'); const focusScope = useFocusScope(); + useMount(() => { + document.addEventListener('focusin', event => { + window.log.warn('Element focused:', event.target); + }); + }); + useEffect(() => { if (debugFocusScope) { window.log.debug(`[debugFocusScope] focus scope changed to`, focusScope); diff --git a/ts/hooks/useMessageInteractions.ts b/ts/hooks/useMessageInteractions.ts index 09fa302ef..ad0d03473 100644 --- a/ts/hooks/useMessageInteractions.ts +++ b/ts/hooks/useMessageInteractions.ts @@ -23,7 +23,7 @@ import { import { saveAttachmentToDisk } from '../util/attachment/attachmentsUtil'; import { deleteMessagesForX } from '../interactions/conversations/unsendingInteractions'; -export function useMessageSaveAttachement(messageId?: string) { +export function useMessageSaveAttachment(messageId?: string) { const convoId = useSelectedConversationKey(); const attachments = useMessageAttachments(messageId); const timestamp = useMessageTimestamp(messageId); diff --git a/ts/react.d.ts b/ts/react.d.ts index 694147edd..a85e2c850 100644 --- a/ts/react.d.ts +++ b/ts/react.d.ts @@ -215,7 +215,8 @@ declare module 'react' { | `${ConfirmButtons}-confirm` | `${CancelButtons}-cancel` | `clear-${ClearButtons}` - | `${SetButton}-set`; + | `${SetButton}-set` + | `reaction-emoji-panel`; type InputLabels = | 'device_and_network' diff --git a/ts/util/logger/renderer_process_logging.ts b/ts/util/logger/renderer_process_logging.ts index a4ddc84bd..a4ccc9e91 100644 --- a/ts/util/logger/renderer_process_logging.ts +++ b/ts/util/logger/renderer_process_logging.ts @@ -111,7 +111,20 @@ function toLocation( return `(@ ${source})`; } +const filters = new Set(); + +export const windowErrorFilters = { + add: (filter: string) => filters.add(filter), + remove: (filter: string) => filters.delete(filter), + shouldSuppress: (source: string | undefined) => + source ? [...filters].some(f => source.includes(f)) : false, +}; + window.onerror = (event, source, line, column, error) => { + if (windowErrorFilters.shouldSuppress(source)) { + return true; + } + const errorInfo = Errors.toString(error); if (errorInfo.startsWith('Error: write EPIPE')) { /** @@ -123,10 +136,10 @@ window.onerror = (event, source, line, column, error) => { * Note: this is not graceful at all, but app.quit() doesn't work. */ ipc.send('force-exit'); - return; + return false; } - log.error(`Top-level unhandled error: ${errorInfo}`, toLocation(event, source, line, column)); + return false; }; window.addEventListener('unhandledrejection', rejectionEvent => { From b110f97620a71a40fbca16c00dc3a0bf6947b36f Mon Sep 17 00:00:00 2001 From: Ryan Miller Date: Thu, 26 Feb 2026 15:46:22 +1100 Subject: [PATCH 07/21] chore: remove event focusin log --- ts/components/leftpane/ActionsPanel.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/ts/components/leftpane/ActionsPanel.tsx b/ts/components/leftpane/ActionsPanel.tsx index 2aaa95e7f..dfe86d97d 100644 --- a/ts/components/leftpane/ActionsPanel.tsx +++ b/ts/components/leftpane/ActionsPanel.tsx @@ -180,12 +180,6 @@ function useDebugFocusScope() { const debugFocusScope = getFeatureFlagMemo('debugFocusScope'); const focusScope = useFocusScope(); - useMount(() => { - document.addEventListener('focusin', event => { - window.log.warn('Element focused:', event.target); - }); - }); - useEffect(() => { if (debugFocusScope) { window.log.debug(`[debugFocusScope] focus scope changed to`, focusScope); From 41aa2c6a926aec460881703bb14f332ec98284ef Mon Sep 17 00:00:00 2001 From: Ryan Miller Date: Thu, 26 Feb 2026 16:30:52 +1100 Subject: [PATCH 08/21] fix: only show reply in message info when available --- .../overlay/message-info/OverlayMessageInfo.tsx | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/ts/components/conversation/right-panel/overlay/message-info/OverlayMessageInfo.tsx b/ts/components/conversation/right-panel/overlay/message-info/OverlayMessageInfo.tsx index 4b83c6944..9a30a2869 100644 --- a/ts/components/conversation/right-panel/overlay/message-info/OverlayMessageInfo.tsx +++ b/ts/components/conversation/right-panel/overlay/message-info/OverlayMessageInfo.tsx @@ -13,10 +13,7 @@ import { Header, HeaderTitle, StyledScrollContainer } from '../components'; import { IsDetailMessageViewContext } from '../../../../../contexts/isDetailViewContext'; import { Data } from '../../../../../data/data'; import { useRightOverlayMode } from '../../../../../hooks/useUI'; -import { - replyToMessage, - resendMessage, -} from '../../../../../interactions/conversationInteractions'; +import { resendMessage } from '../../../../../interactions/conversationInteractions'; import { deleteMessagesById } from '../../../../../interactions/conversations/unsendingInteractions'; import { useMessageAttachments, @@ -55,6 +52,7 @@ import { tr } from '../../../../../localization/localeTools'; import { AppDispatch } from '../../../../../state/createStore'; import { useKeyboardShortcut } from '../../../../../hooks/useKeyboardShortcut'; import { KbdShortcut } from '../../../../../util/keyboardShortcuts'; +import { useMessageReply } from '../../../../../hooks/useMessageInteractions'; // NOTE we override the default max-widths when in the detail isDetailView const StyledMessageBody = styled.div` @@ -226,7 +224,8 @@ function closePanel(dispatch: AppDispatch) { function ReplyToMessageButton({ messageId }: WithMessageIdOpt) { const dispatch = getAppDispatch(); - if (!messageId) { + const replyToMessage = useMessageReply(messageId); + if (!messageId || !replyToMessage) { return null; } return ( @@ -234,12 +233,8 @@ function ReplyToMessageButton({ messageId }: WithMessageIdOpt) { text={{ token: 'reply' }} iconElement={} onClick={() => { - // eslint-disable-next-line more/no-then - void replyToMessage(messageId).then(foundIt => { - if (foundIt) { - closePanel(dispatch); - } - }); + replyToMessage(); + closePanel(dispatch); }} dataTestId="reply-to-msg-from-details" /> From 68e9739bcbf5c127306d8b7963f74fd70cc68a79 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Fri, 27 Feb 2026 10:56:45 +1100 Subject: [PATCH 09/21] chore: refactor message architecture as all are selectable --- ts/components/calling/IncomingCallDialog.tsx | 2 - .../MessageContentWithStatus.tsx | 7 +- .../message-content/MessageContextMenu.tsx | 12 +- .../message/message-content/MessageText.tsx | 6 +- ts/components/dialog/SessionConfirm.tsx | 27 +- ts/components/dialog/shared/ModalWarning.tsx | 3 +- .../DeleteMessage/DeleteMessageMenuItem.tsx | 1 - .../useClearAllMessages.ts | 38 +-- .../useDeleteMessagesCb.tsx | 230 ++++++++++++------ .../deleteMessagesLocallyOnly.ts | 17 +- .../conversations/unsendingInteractions.ts | 89 +++---- ts/models/conversation.ts | 3 - ts/models/message.ts | 46 +++- ts/node/migration/sessionMigrations.ts | 12 - ts/receiver/contentMessage.ts | 2 - ts/session/apis/snode_api/SNodeAPI.ts | 21 +- ts/session/types/with.ts | 2 +- ts/session/utils/Toast.tsx | 4 + ts/state/ducks/networkData.ts | 3 - ts/state/ducks/releasedFeatures.tsx | 3 - ts/state/onboarding/selectors/registration.ts | 4 - ts/state/selectors/messages.ts | 16 ++ ts/state/selectors/networkModal.ts | 3 - 23 files changed, 326 insertions(+), 225 deletions(-) diff --git a/ts/components/calling/IncomingCallDialog.tsx b/ts/components/calling/IncomingCallDialog.tsx index 0c980233b..222f43a51 100644 --- a/ts/components/calling/IncomingCallDialog.tsx +++ b/ts/components/calling/IncomingCallDialog.tsx @@ -60,7 +60,6 @@ export const IncomingCallDialog = () => { }; }, [incomingCallFromPubkey]); - // #region input handlers const handleAcceptIncomingCall = async () => { if (incomingCallFromPubkey) { await CallManager.USER_acceptIncomingCallRequest(incomingCallFromPubkey); @@ -77,7 +76,6 @@ export const IncomingCallDialog = () => { if (!hasIncomingCall || !incomingCallFromPubkey) { return null; } - // #endregion if (hasIncomingCall) { return ( diff --git a/ts/components/conversation/message/message-content/MessageContentWithStatus.tsx b/ts/components/conversation/message/message-content/MessageContentWithStatus.tsx index 1f1c80daa..3181c52e9 100644 --- a/ts/components/conversation/message/message-content/MessageContentWithStatus.tsx +++ b/ts/components/conversation/message/message-content/MessageContentWithStatus.tsx @@ -7,7 +7,7 @@ import { updateReactListModal } from '../../../../state/ducks/modalDialog'; import { useHideAvatarInMsgList, useMessageDirection, - useMessageStatus, + useMessageIsOnline, } from '../../../../state/selectors'; import { Flex } from '../../../basic/Flex'; import { ExpirableReadableMessage } from '../message-item/ExpirableReadableMessage'; @@ -55,9 +55,8 @@ export const MessageContentWithStatuses = ( const isDetailView = useIsDetailMessageView(); const isLegacyGroup = useSelectedIsLegacyGroup(); - const status = useMessageStatus(props.messageId); const convoId = useSelectedConversationKey(); - const isSent = status === 'sent' || status === 'read'; // a read message can be reacted to + const msgIsOnline = useMessageIsOnline(messageId); const onDoubleClickReplyToMessage = (e: MouseEvent) => { if (isLegacyGroup || !reply) { @@ -99,7 +98,7 @@ export const MessageContentWithStatuses = ( const direction = isDetailView ? 'incoming' : _direction; const isIncoming = direction === 'incoming'; - const enableReactions = convoReactionsEnabled && (isSent || isIncoming); + const enableReactions = convoReactionsEnabled && msgIsOnline; const handlePopupClick = (emoji: string) => { dispatch( diff --git a/ts/components/conversation/message/message-content/MessageContextMenu.tsx b/ts/components/conversation/message/message-content/MessageContextMenu.tsx index 31562d55d..431cfd256 100644 --- a/ts/components/conversation/message/message-content/MessageContextMenu.tsx +++ b/ts/components/conversation/message/message-content/MessageContextMenu.tsx @@ -12,12 +12,11 @@ import { MessageRenderingProps } from '../../../../models/messageType'; import { openRightPanel, showMessageInfoView } from '../../../../state/ducks/conversations'; import { useMessageAttachments, - useMessageDirection, useMessageIsControlMessage, useMessageIsDeleted, useMessageSender, useMessageSenderIsAdmin, - useMessageStatus, + useMessageIsOnline, } from '../../../../state/selectors'; import { useSelectedConversationKey, @@ -251,14 +250,11 @@ export const MessageContextMenu = (props: Props) => { const isLegacyGroup = useSelectedIsLegacyGroup(); const convoId = useSelectedConversationKey(); - const direction = useMessageDirection(messageId); - const status = useMessageStatus(messageId); const isDeleted = useMessageIsDeleted(messageId); const sender = useMessageSender(messageId); const isControlMessage = useMessageIsControlMessage(messageId); - - const isOutgoing = direction === 'outgoing'; - const isSent = status === 'sent' || status === 'read'; // a read message should be replyable + // we should be able to reply to a sent or read message + const msgIsOnline = useMessageIsOnline(messageId); const contextMenuRef = useRef(null); @@ -318,7 +314,7 @@ export const MessageContextMenu = (props: Props) => { > - {(isSent || !isOutgoing) && !!reply && ( + {msgIsOnline && !!reply && ( {tr('reply')} diff --git a/ts/components/conversation/message/message-content/MessageText.tsx b/ts/components/conversation/message/message-content/MessageText.tsx index adf4721d6..2dda26ab1 100644 --- a/ts/components/conversation/message/message-content/MessageText.tsx +++ b/ts/components/conversation/message/message-content/MessageText.tsx @@ -15,7 +15,6 @@ import { import type { WithMessageId } from '../../../../session/types/with'; import { LucideIcon } from '../../../icon/LucideIcon'; import { LUCIDE_ICONS_UNICODE } from '../../../icon/lucide'; -import { tr } from '../../../../localization/localeTools'; import { MessageBubble } from './MessageBubble'; type Props = WithMessageId; @@ -38,7 +37,10 @@ export const MessageText = ({ messageId }: Props) => { const text = useMessageText(messageId); const isOpenOrClosedGroup = useSelectedIsGroupOrCommunity(); const isPublic = useSelectedIsPublic(); - const contents = isDeleted ? tr('deleteMessageDeletedGlobally') : text?.trim(); + // Note the body is overridden with `deleteMessageDeletedLocally` or `deleteMessageDeletedGlobally` + // depending on the `isDeleted` value, when the message is marked as deleted + + const contents = text?.trim(); if (!contents) { return null; diff --git a/ts/components/dialog/SessionConfirm.tsx b/ts/components/dialog/SessionConfirm.tsx index 576478468..32984af96 100644 --- a/ts/components/dialog/SessionConfirm.tsx +++ b/ts/components/dialog/SessionConfirm.tsx @@ -17,6 +17,8 @@ import { messageArgsToArgsOnly, tr, type TrArgs } from '../../localization/local import { ModalFlexContainer } from './shared/ModalFlexContainer'; import { ModalWarning } from './shared/ModalWarning'; +export type RadioOptions = { items: SessionRadioItems; defaultSelectedValue: string | undefined }; + export type SessionConfirmDialogProps = { i18nMessage?: TrArgs; title?: TrArgs; @@ -24,7 +26,7 @@ export type SessionConfirmDialogProps = { * Warning message to display in the modal */ warningMessage?: TrArgs; - radioOptions?: SessionRadioItems; + radioOptions?: RadioOptions; onClose?: any; /** @@ -71,8 +73,21 @@ export const SessionConfirm = (props: SessionConfirmDialogProps) => { const lastMessage = useLastMessage(conversationId); const [isLoading, setIsLoading] = useState(false); - const [chosenOption, setChosenOption] = useState( - radioOptions?.length ? radioOptions[0].value : '' + + const defaultSelectedOption = radioOptions?.defaultSelectedValue + ? radioOptions?.items.find(item => item.value === radioOptions?.defaultSelectedValue) + : undefined; + + const defaultSelectedIsDisabled = defaultSelectedOption?.disabled ?? false; + const firstItemNotDisabled = radioOptions?.items.find(item => !item.disabled); + + const [chosenOption, setChosenOption] = useState( + // If a defaultSelected is provided and it is not disabled, use that. + // Otherwise use the first item that is not disabled. + // + defaultSelectedOption && !defaultSelectedIsDisabled + ? defaultSelectedOption.value + : firstItemNotDisabled?.value ); const cancelText = props.cancelText @@ -163,11 +178,11 @@ export const SessionConfirm = (props: SessionConfirmDialogProps) => { {props.warningMessage ? ( ) : null} - {radioOptions && chosenOption !== '' ? ( + {!!radioOptions?.items.length && chosenOption !== '' ? ( { if (value) { setChosenOption(value); diff --git a/ts/components/dialog/shared/ModalWarning.tsx b/ts/components/dialog/shared/ModalWarning.tsx index 758301c8e..240f5776f 100644 --- a/ts/components/dialog/shared/ModalWarning.tsx +++ b/ts/components/dialog/shared/ModalWarning.tsx @@ -9,7 +9,8 @@ const StyledModalWarningContainer = styled.div` max-width: 500px; line-height: 1.2; text-align: center; - font-size: var(--font-size-md); + font-size: var(--font-size-sm); + color: var(--warning-color); `; export function ModalWarning(props: { diff --git a/ts/components/menu/items/DeleteMessage/DeleteMessageMenuItem.tsx b/ts/components/menu/items/DeleteMessage/DeleteMessageMenuItem.tsx index 3455fb51d..796ec1117 100644 --- a/ts/components/menu/items/DeleteMessage/DeleteMessageMenuItem.tsx +++ b/ts/components/menu/items/DeleteMessage/DeleteMessageMenuItem.tsx @@ -90,7 +90,6 @@ export const DeleteItem = ({ messageId }: { messageId: string }) => { const convoId = useSelectedConversationKey(); const deleteMessagesCb = useDeleteMessagesCb(convoId); - console.warn('fixme allow ot delete a message to a sogs that failed to send'); if (!deleteMessagesCb || !messageId) { return null; diff --git a/ts/components/menuAndSettingsHooks/useClearAllMessages.ts b/ts/components/menuAndSettingsHooks/useClearAllMessages.ts index 4db3ce1f4..10c208ec3 100644 --- a/ts/components/menuAndSettingsHooks/useClearAllMessages.ts +++ b/ts/components/menuAndSettingsHooks/useClearAllMessages.ts @@ -15,6 +15,7 @@ import { } from '../../hooks/useParamSelector'; import { ToastUtils } from '../../session/utils'; import { groupInfoActions } from '../../state/ducks/metaGroups'; +import type { RadioOptions } from '../dialog/SessionConfirm'; export function useClearAllMessagesCb({ conversationId }: { conversationId: string }) { const dispatch = getAppDispatch(); @@ -92,6 +93,26 @@ export function useClearAllMessagesCb({ conversationId }: { conversationId: stri throw new Error('useClearAllMessagesCb: invalid case'); } + const radioOptions: RadioOptions | undefined = isGroupV2AndAdmin + ? { + items: [ + { + value: 'clearOnThisDevice', + label: tr('clearOnThisDevice'), + inputDataTestId: 'clear-device-radio-option', + labelDataTestId: 'clear-device-radio-option-label', + }, + { + value: clearMessagesForEveryone, + label: tr(clearMessagesForEveryone), + inputDataTestId: 'clear-everyone-radio-option', + labelDataTestId: 'clear-everyone-radio-option-label', + }, + ] as const, + defaultSelectedValue: 'clearOnThisDevice', + } + : undefined; + const cb = () => dispatch( updateConfirmModal({ @@ -101,22 +122,7 @@ export function useClearAllMessagesCb({ conversationId }: { conversationId: stri okTheme: SessionButtonColor.Danger, onClickClose, okText: { token: 'clear' }, - radioOptions: isGroupV2AndAdmin - ? [ - { - value: 'clearOnThisDevice', - label: tr('clearOnThisDevice'), - inputDataTestId: 'clear-device-radio-option', - labelDataTestId: 'clear-device-radio-option-label', - }, - { - value: clearMessagesForEveryone, - label: tr(clearMessagesForEveryone), - inputDataTestId: 'clear-everyone-radio-option', - labelDataTestId: 'clear-everyone-radio-option-label', - }, - ] - : undefined, + radioOptions, }) ); diff --git a/ts/components/menuAndSettingsHooks/useDeleteMessagesCb.tsx b/ts/components/menuAndSettingsHooks/useDeleteMessagesCb.tsx index c021a5fa9..55bdb7f77 100644 --- a/ts/components/menuAndSettingsHooks/useDeleteMessagesCb.tsx +++ b/ts/components/menuAndSettingsHooks/useDeleteMessagesCb.tsx @@ -20,7 +20,6 @@ import { } from '../../interactions/conversations/unsendingInteractions'; import { deleteSogsMessageByServerIds } from '../../session/apis/open_group_api/sogsv3/sogsV3DeleteMessages'; import { SnodeNamespaces } from '../../session/apis/snode_api/namespaces'; -import type { SessionRadioItems } from '../basic/SessionRadioGroup'; import { getSodiumRenderer } from '../../session/crypto'; import { GroupUpdateDeleteMemberContentMessage } from '../../session/messages/outgoing/controlMessage/group_v2/to_group/GroupUpdateDeleteMemberContentMessage'; import { UnsendMessage } from '../../session/messages/outgoing/controlMessage/UnsendMessage'; @@ -30,14 +29,15 @@ import { sectionActions } from '../../state/ducks/section'; import { ConvoHub } from '../../session/conversations'; import { uuidV4 } from '../../util/uuid'; import { isUsAnySogsFromCache } from '../../session/apis/open_group_api/sogsv3/knownBlindedkeys'; +import type { RadioOptions } from '../dialog/SessionConfirm'; const deleteMessageDeviceOnly = 'deleteMessageDeviceOnly'; -const deleteMessageDevicesAll = 'deleteMessageDevicesAll'; +const deleteMessageAllMyDevices = 'deleteMessageDevicesAll'; const deleteMessageEveryone = 'deleteMessageEveryone'; type MessageDeletionType = | typeof deleteMessageDeviceOnly - | typeof deleteMessageDevicesAll + | typeof deleteMessageAllMyDevices | typeof deleteMessageEveryone; /** @@ -83,35 +83,48 @@ export function useDeleteMessagesCb(conversationId: string | undefined) { // Note: the isMe case has no radio buttons, so we just show the description below const i18nMessage: TrArgs | undefined = isMe - ? { token: 'deleteMessageDescriptionDevice', count } + ? { token: 'deleteMessageConfirm', count } : undefined; const canDeleteFromAllDevices = isMe && !anyAreControlMessages && !anyAreMarkAsDeleted; - const radioOptions: SessionRadioItems | undefined = [ - { - label: tr(deleteMessageDeviceOnly), - value: deleteMessageDeviceOnly, - inputDataTestId: `input-${deleteMessageDeviceOnly}` as const, - labelDataTestId: `label-${deleteMessageDeviceOnly}` as const, - disabled: false, // we can always delete message locally - }, - isMe - ? { - label: tr(deleteMessageDevicesAll), - value: deleteMessageDevicesAll, - inputDataTestId: `input-${deleteMessageDevicesAll}` as const, - labelDataTestId: `label-${deleteMessageDevicesAll}` as const, - disabled: !canDeleteFromAllDevices, - } - : { - label: tr(deleteMessageEveryone), - value: deleteMessageEveryone, - inputDataTestId: `input-${deleteMessageEveryone}` as const, - labelDataTestId: `label-${deleteMessageEveryone}` as const, - disabled: !canDeleteAllForEveryone, - }, - ]; + const warningMessage: TrArgs | undefined = + isMe && !canDeleteFromAllDevices + ? { token: 'deleteMessageNoteToSelfWarning', count } + : !isMe && !canDeleteAllForEveryone + ? { + token: 'deleteMessageWarning', + count, + } + : undefined; + + const radioOptions: RadioOptions | undefined = { + items: [ + { + label: tr(deleteMessageDeviceOnly), + value: deleteMessageDeviceOnly, + inputDataTestId: `input-${deleteMessageDeviceOnly}` as const, + labelDataTestId: `label-${deleteMessageDeviceOnly}` as const, + disabled: false, // we can always delete messages locally + }, + isMe + ? { + label: tr(deleteMessageAllMyDevices), + value: deleteMessageAllMyDevices, + inputDataTestId: `input-${deleteMessageAllMyDevices}` as const, + labelDataTestId: `label-${deleteMessageAllMyDevices}` as const, + disabled: !canDeleteFromAllDevices, + } + : { + label: tr(deleteMessageEveryone), + value: deleteMessageEveryone, + inputDataTestId: `input-${deleteMessageEveryone}` as const, + labelDataTestId: `label-${deleteMessageEveryone}` as const, + disabled: !canDeleteAllForEveryone, + }, + ], + defaultSelectedValue: !isMe && canDeleteAllForEveryone ? deleteMessageEveryone : undefined, + }; dispatch( updateConfirmModal({ @@ -120,12 +133,13 @@ export function useDeleteMessagesCb(conversationId: string | undefined) { i18nMessage, okText: { token: 'delete' }, + warningMessage, okTheme: SessionButtonColor.Danger, onClickOk: async args => { if ( args !== deleteMessageEveryone && - args !== deleteMessageDevicesAll && + args !== deleteMessageAllMyDevices && args !== deleteMessageDeviceOnly ) { throw new Error('doDeleteSelectedMessages: invalid args onClickOk'); @@ -151,11 +165,12 @@ export function useDeleteMessagesCb(conversationId: string | undefined) { * Also deletes messages from the swarm/sogs if needed, sends unsend requests for syncing etc... * * Note: this function does not check if the user is allowed to delete the messages. - * The call will just fail if the user is not allowed to delete the messages, silently. + * The call will just fail if the user is not allowed to delete the messages. * So make sure to check the user permissions before calling this function and to display only valid actions for the user's permissions. * + * Returns true if the modal should be closed (i.e. messages were deleted as expected) */ -const doDeleteSelectedMessages = async ({ +async function doDeleteSelectedMessages({ conversation, selectedMessages, deletionType, @@ -163,61 +178,89 @@ const doDeleteSelectedMessages = async ({ selectedMessages: Array; conversation: ConversationModel; deletionType: MessageDeletionType; -}) => { +}) { // legacy groups are read only if (conversation.isClosedGroup() && PubKey.is05Pubkey(conversation.id)) { window.log.info( - 'doDeleteSelectedMessages: legacy groups are read only. Remove the conversation to remove a message' + 'doDeleteSelectedMessages: legacy groups are read only. Only removing those messages locally' ); - return; + await deleteMessagesLocallyOnly({ + conversation, + messages: selectedMessages, + deletionType: 'complete', + }); + return true; } - console.warn('selectedMessages', selectedMessages); - console.warn('deletionType', deletionType); - if (deletionType === deleteMessageDeviceOnly) { - // Mark those messages as deleted only locally + // Delete on device only is an easy case. + // `deleteMessagesLocallyOnly` will forcefully remove + // - control messages or + // - already marked as deleted messages await deleteMessagesLocallyOnly({ conversation, messages: selectedMessages, - deletionType: 'markDeleted', + deletionType: 'markDeletedThisDevice', }); + // this can never fail ToastUtils.pushDeleted(selectedMessages.length); + window.inboxStore?.dispatch(resetSelectedMessageIds()); - return; + return true; } - // device only was handled above, so this isPublic can only mean delete for everyone in a community - if (conversation.isOpenGroupV2()) { - await doDeleteSelectedMessagesInSOGS(selectedMessages, conversation); - return; + if (deletionType === deleteMessageAllMyDevices) { + if (!conversation.isMe()) { + throw new Error( + ' doDeleteSelectedMessages: invalid deletionType: "deleteMessageAllMyDevices" for a different conversation than ours' + ); + } + // Delete those messages locally, from our swarm and from our other devices, but not for anyone else in the conversation + const deletedFromOurSwarm = await unsendMessageJustForThisUserAllDevices( + conversation, + selectedMessages + ); + return deletedFromOurSwarm; } - if (deletionType === deleteMessageDevicesAll) { - // Delete those messages locally, from our swarm and from our other devices, but not for anyone else in the conversation - await unsendMessageJustForThisUserAllDevices(conversation, selectedMessages); - return; + // device only was handled above, so this isOpenGroupV2 can only mean delete for everyone in a community + if (conversation.isOpenGroupV2()) { + // this shows a toast on success or failure + const deletedFromSogs = await doDeleteSelectedMessagesInSOGS(selectedMessages, conversation); + return deletedFromSogs; } - console.warn('FIXME: this is all done but untested'); // Note: a groupv2 member can delete messages for everyone if they are the admin, or if that message is theirs. - if (deletionType !== deleteMessageEveryone) { - throw new Error('doDeleteSelectedMessages: invalid deletionType'); + throw new Error(`doDeleteSelectedMessages: invalid deletionType: "${deletionType}"`); } if (conversation.isPrivate()) { // Note: we cannot delete for everyone a message in non 05-private chat if (!PubKey.is05Pubkey(conversation.id)) { - throw new Error('unsendMessagesForEveryone1o1AndLegacy requires a 05 key'); + throw new Error('unsendMessagesForEveryone1o1 requires a 05 key'); } - // private chats: we want to delete those messages completely (not just marked as deleted) - await unsendMessagesForEveryone1o1(conversation, conversation.id, selectedMessages); - await deleteMessagesFromSwarmAndCompletelyLocally(conversation, selectedMessages); + + // build the unsendMsgObjects before we delete the hash from those messages + const unsendMsgObjects = getUnsendMessagesObjects1o1(conversation, selectedMessages); + + // private chats: we want to mark those messages as deleted + const deletedFromSwarmAndLocally = await deleteMessagesFromSwarmAndMarkAsDeletedLocally( + conversation, + selectedMessages + ); + if (!deletedFromSwarmAndLocally) { + window.log.warn( + 'unsendMessagesForEveryone1o1: failed to delete from swarm and locally. Not sending unsend requests' + ); + ToastUtils.pushGenericError(); + return false; + } + await unsendMessagesForEveryone1o1(conversation, unsendMsgObjects); ToastUtils.pushDeleted(selectedMessages.length); window.inboxStore?.dispatch(resetSelectedMessageIds()); - return; + return true; } if (!conversation.isClosedGroupV2() || !PubKey.is03Pubkey(conversation.id)) { @@ -225,35 +268,60 @@ const doDeleteSelectedMessages = async ({ throw new Error('doDeleteSelectedMessages: invalid conversation type'); } - await unsendMessagesForEveryoneGroupV2({ + const groupv2UnsendSent = await unsendMessagesForEveryoneGroupV2({ groupPk: conversation.id, msgsToDelete: selectedMessages, allMessagesFrom: [], // currently we cannot remove all the messages from a specific pubkey but we do already handle them on the receiving side }); + if (!groupv2UnsendSent) { + window.log.warn( + 'unsendMessagesForEveryoneGroupV2: failed to send our groupv2 unsend for everyone' + ); + ToastUtils.pushGenericError(); + return false; + } // 03 groups: mark as deleted await deleteMessagesFromSwarmAndMarkAsDeletedLocally(conversation, selectedMessages); window.inboxStore?.dispatch(resetSelectedMessageIds()); ToastUtils.pushDeleted(selectedMessages.length); -}; + + return true; +} /** - * Send an UnsendMessage synced message so our devices removes those messages locally, - * and send an unsend request on our swarm so this message is effectively removed from it. + * Delete those message hashes from our swarm. + * On success, send an UnsendMessage synced message so our devices removes those messages locally, * Then, deletes completely the messages locally. * - * Show a toast on error/success and reset the selection + * Shows a toast on error/success and reset the selection */ async function unsendMessageJustForThisUserAllDevices( conversation: ConversationModel, msgsToDelete: Array ) { + // we can only delete the messages on the swarm when they've been sent + const msgsToDeleteFromSwarm = msgsToDelete.filter(m => m.getMessageHash()); window?.log?.info('Deleting messages just for this user'); - const unsendMsgObjects = getUnsendMessagesObjects1o1OrLegacyGroups(msgsToDelete); + // get the unsendMsgObjects before we delete the hash from those messages + const unsendMsgObjects = getUnsendMessagesObjects1o1(conversation, msgsToDeleteFromSwarm); - // sending to our other devices all the messages separately for now + const deletedFromSwarm = await deleteMessagesFromSwarmAndCompletelyLocally( + conversation, + msgsToDelete + ); + // we want to locally only when we've manage to delete them from the swarm first + if (!deletedFromSwarm) { + window.log.warn( + 'unsendMessageJustForThisUserAllDevices: failed to delete from swarm. Not sending unsend requests' + ); + ToastUtils.pushGenericError(); + return false; + } + + // deleting from the swarm worked, sending to our other devices all the messages separately for now await Promise.all( unsendMsgObjects.map(unsendObject => MessageQueue.use() @@ -261,11 +329,10 @@ async function unsendMessageJustForThisUserAllDevices( .catch(window?.log?.error) ) ); - await deleteMessagesFromSwarmAndCompletelyLocally(conversation, msgsToDelete); - // Update view and trigger update window.inboxStore?.dispatch(resetSelectedMessageIds()); ToastUtils.pushDeleted(unsendMsgObjects.length); + return true; } /** @@ -273,6 +340,8 @@ async function unsendMessageJustForThisUserAllDevices( * Note: this function does not check if the user is allowed to delete the messages. * The call will just fail if the user is not allowed to delete the messages, silently, so make * sure to check the user permissions before calling this function and to display only valid actions for the user's permissions. + * + * Returns true if those messages could be removed from the SOGS and were removed locally */ async function doDeleteSelectedMessagesInSOGS( selectedMessages: Array, @@ -281,8 +350,8 @@ async function doDeleteSelectedMessagesInSOGS( const toDeleteLocallyIds = await deleteOpenGroupMessages(selectedMessages, conversation); if (toDeleteLocallyIds.length === 0) { // Failed to delete those messages from the sogs. - ToastUtils.pushToastError('errorGeneric', tr('errorGeneric')); - return; + ToastUtils.pushGenericError(); + return false; } await deleteMessagesLocallyOnly({ @@ -294,6 +363,7 @@ async function doDeleteSelectedMessagesInSOGS( // successful deletion ToastUtils.pushDeleted(toDeleteLocallyIds.length); window.inboxStore?.dispatch(resetSelectedMessageIds()); + return true; } /** @@ -345,21 +415,20 @@ async function deleteOpenGroupMessages( async function unsendMessagesForEveryone1o1( conversation: ConversationModel, - destination: PubkeyType, - msgsToDelete: Array + unsendMsgObjects: Array ) { - const unsendMsgObjects = getUnsendMessagesObjects1o1OrLegacyGroups(msgsToDelete); - if (!conversation.isPrivate()) { throw new Error('unsendMessagesForEveryone1o1 only works with private conversations'); } + if (unsendMsgObjects.length === 0) { + return; + } // sending to recipient all the messages separately for now - console.warn('fixme sending to recipient all the messages separately for'); await Promise.all( unsendMsgObjects.map(unsendObject => MessageQueue.use() - .sendToPubKey(new PubKey(destination), unsendObject, SnodeNamespaces.Default) + .sendToPubKey(new PubKey(conversation.id), unsendObject, SnodeNamespaces.Default) .catch(window?.log?.error) ) ); @@ -386,10 +455,10 @@ async function unsendMessagesForEveryoneGroupV2({ if (!messageHashesToUnsend.length && !allMessagesFrom.length) { window.log.info('unsendMessagesForEveryoneGroupV2: no hashes nor author to remove'); - return; + return true; } - await MessageQueue.use().sendToGroupV2NonDurably({ + const storedAt = await MessageQueue.use().sendToGroupV2NonDurably({ message: new GroupUpdateDeleteMemberContentMessage({ createAtNetworkTimestamp: NetworkTime.now(), expirationType: 'unknown', // GroupUpdateDeleteMemberContentMessage is not displayed so not expiring. @@ -402,9 +471,18 @@ async function unsendMessagesForEveryoneGroupV2({ dbMessageIdentifier: uuidV4(), }), }); + return !!storedAt; } -function getUnsendMessagesObjects1o1OrLegacyGroups(messages: Array) { +function getUnsendMessagesObjects1o1( + conversation: ConversationModel, + messages: Array +) { + if (!conversation.isPrivate()) { + throw new Error( + 'getUnsendMessagesObjects1o1: cannot send messages to a non-private conversation' + ); + } return compact( messages.map((message, index) => { const author = message.get('source'); diff --git a/ts/interactions/conversations/deleteMessagesLocallyOnly.ts b/ts/interactions/conversations/deleteMessagesLocallyOnly.ts index ada1f0fb3..a983e0dab 100644 --- a/ts/interactions/conversations/deleteMessagesLocallyOnly.ts +++ b/ts/interactions/conversations/deleteMessagesLocallyOnly.ts @@ -4,9 +4,8 @@ import type { MessageModel } from '../../models/message'; import type { WithLocalMessageDeletionType } from '../../session/types/with'; /** - * Deletes a message completely or mark it as deleted. Does not interact with the swarm at all - * @param message Message to delete - * @param deletionType 'complete' means completely delete the item from the database, markDeleted means empty the message content but keep an entry + * Deletes a message completely or mark it as deleted. Does not interact with the swarm at all. + * Note: no matter the `deletionType`, a control message or a "mark as deleted" message are always removed entirely from the database. */ export async function deleteMessagesLocallyOnly({ conversation, @@ -19,19 +18,11 @@ export async function deleteMessagesLocallyOnly({ for (let index = 0; index < messages.length; index++) { const message = messages[index]; // a control message or a message deleted is forcefully removed from the DB - if (message.isControlMessage() || message.get('isDeleted')) { - await conversation.removeMessage(message.id); - - continue; - } - if (deletionType === 'complete') { - // remove the message from the database + if (deletionType === 'complete' || message.isControlMessage() || message.get('isDeleted')) { await conversation.removeMessage(message.id); } else { // just mark the message as deleted but still show in conversation - await message.markAsDeleted(deletionType === 'marDeletedLocally'); + await message.markAsDeleted(deletionType === 'markDeletedThisDevice'); } } - - conversation.updateLastMessage(); } diff --git a/ts/interactions/conversations/unsendingInteractions.ts b/ts/interactions/conversations/unsendingInteractions.ts index 6bd246ec0..29d2fd337 100644 --- a/ts/interactions/conversations/unsendingInteractions.ts +++ b/ts/interactions/conversations/unsendingInteractions.ts @@ -8,71 +8,78 @@ import { deleteMessagesLocallyOnly } from './deleteMessagesLocallyOnly'; import { deleteMessagesFromSwarmOnly } from './deleteMessagesFromSwarmOnly'; /** - * Delete the messages from the swarm with an unsend request and if it worked, delete those messages locally. - * If an error happened, we just return false, Toast an error, and do not remove the messages locally at all. + * Delete the messages (with a valid hash) from the swarm and completely delete the messages locally. + * Only delete locally if the delete from swarm was successful. + * + * Returns true if the delete from swarm was successful, false otherwise. */ export async function deleteMessagesFromSwarmAndCompletelyLocally( conversation: ConversationModel, messages: Array ) { - // If this is a private chat, we can only delete messages on our own swarm, so use our "side" of the conversation - const pubkey = conversation.isPrivate() ? UserUtils.getOurPubKeyStrFromCache() : conversation.id; - if (!PubKey.is03Pubkey(pubkey) && !PubKey.is05Pubkey(pubkey)) { - throw new Error('deleteMessagesFromSwarmAndCompletelyLocally needs a 03 or 05 pk'); - } - if (PubKey.is05Pubkey(pubkey) && pubkey !== UserUtils.getOurPubKeyStrFromCache()) { - window.log.warn( - 'deleteMessagesFromSwarmAndCompletelyLocally with 05 pk can only delete for ourself' - ); - return; - } - // LEGACY GROUPS are deprecated - if (conversation.isClosedGroup() && PubKey.is05Pubkey(pubkey)) { - window.log.info('legacy groups are deprecated.'); - - return; - } - window.log.info( - 'Deleting from swarm of ', - ed25519Str(pubkey), - ' hashes: ', - messages.map(m => m.get('messageHash')) - ); - const deletedFromSwarm = await deleteMessagesFromSwarmOnly(messages, pubkey); - if (!deletedFromSwarm) { - window.log.warn( - 'deleteMessagesFromSwarmAndCompletelyLocally: some messages failed to be deleted. Maybe they were already deleted?' - ); - } - await deleteMessagesLocallyOnly({ conversation, messages, deletionType: 'complete' }); + return deleteMessagesFromSwarmShared(conversation, messages, 'complete'); } /** - * Delete the messages from the swarm with an unsend request and mark those messages locally as deleted but do not remove them. - * If an error happened, we still mark the message locally as deleted. + * Delete the messages (with a valid hash) from the swarm. + * Only mark as deleted if the delete from swarm was successful. + * + * Returns true if the delete from swarm was successful, false otherwise. + * */ export async function deleteMessagesFromSwarmAndMarkAsDeletedLocally( conversation: ConversationModel, messages: Array +) { + return deleteMessagesFromSwarmShared(conversation, messages, 'markDeleted'); +} + +async function deleteMessagesFromSwarmShared( + conversation: ConversationModel, + messages: Array, + deletionType: 'complete' | 'markDeleted' ) { // legacy groups are deprecated if (conversation.isClosedGroup() && PubKey.is05Pubkey(conversation.id)) { - window.log.info('legacy groups are deprecated. Not deleting anything'); - - return; + throw new Error('legacy groups are deprecated. Not deleting anything'); } + if (conversation.isPrivateAndBlinded()) { + throw new Error( + `deleteMessagesFromSwarmShared ${deletionType} does not support blinded conversations` + ); + } + + // Legacy groups are handled above + // We are deleting from a swarm, so this is not a sogs. + // This means that the target here can only be the 03 group pubkey, or our own swarm pubkey. - // we can only delete messages on the swarm when they are on our own swarm, or it is a groupv2 that we are the admin off + // If this is a private chat, we can only delete messages on our own swarm, so use our "side" of the conversation const pubkeyToDeleteFrom = PubKey.is03Pubkey(conversation.id) ? conversation.id : UserUtils.getOurPubKeyStrFromCache(); + if (!PubKey.is03Pubkey(pubkeyToDeleteFrom) && !PubKey.is05Pubkey(pubkeyToDeleteFrom)) { + throw new Error(`deleteMessagesFromSwarmShared ${deletionType} needs a 03 or 05 pk`); + } + if ( + PubKey.is05Pubkey(pubkeyToDeleteFrom) && + pubkeyToDeleteFrom !== UserUtils.getOurPubKeyStrFromCache() + ) { + throw new Error( + `deleteMessagesFromSwarmShared ${deletionType} with 05 pk can only delete for ourself` + ); + } + + window.log.info( + `deleteMessagesFromSwarmShared ${deletionType}: Deleting from swarm of ${ed25519Str(pubkeyToDeleteFrom)}, hashes: ${messages.map(m => m.getMessageHash())}` + ); - // if this is a groupv2 and we don't have the admin key, it will fail and return false. const deletedFromSwarm = await deleteMessagesFromSwarmOnly(messages, pubkeyToDeleteFrom); if (!deletedFromSwarm) { window.log.warn( - 'deleteMessagesFromSwarmAndMarkAsDeletedLocally: some messages failed to be deleted but still removing the messages content... ' + `deleteMessagesFromSwarmShared ${deletionType}: some messages failed to be deleted from swarm of ${ed25519Str(pubkeyToDeleteFrom)}. Maybe they were already deleted?` ); + } else { + await deleteMessagesLocallyOnly({ conversation, messages, deletionType }); } - await deleteMessagesLocallyOnly({ conversation, messages, deletionType: 'markDeleted' }); + return deletedFromSwarm; } diff --git a/ts/models/conversation.ts b/ts/models/conversation.ts index 98f71b7f6..6f19fbcaf 100644 --- a/ts/models/conversation.ts +++ b/ts/models/conversation.ts @@ -3029,7 +3029,6 @@ export class ConversationModel extends Model { return success; } - // #region Start of getters public getExpirationMode() { return this.get('expirationMode'); } @@ -3041,8 +3040,6 @@ export class ConversationModel extends Model { public getIsExpired03Group() { return PubKey.is03Pubkey(this.id) && !!this.get('isExpired03Group'); } - - // #endregion } export const Convo = { commitConversationAndRefreshWrapper }; diff --git a/ts/models/message.ts b/ts/models/message.ts index bd059f0aa..c1f0b0f05 100644 --- a/ts/models/message.ts +++ b/ts/models/message.ts @@ -109,6 +109,7 @@ import { privateSet, privateSetKey } from './modelFriends'; import { getFeatureFlag } from '../state/ducks/types/releasedFeaturesReduxTypes'; import type { OutgoingProMessageDetails } from '../types/message/OutgoingProMessageDetails'; import { longOrNumberToBigInt } from '../types/Bigint'; +import { toSqliteBoolean } from '../node/database_utility'; // tslint:disable: cyclomatic-complexity @@ -587,15 +588,6 @@ export class MessageModel extends Model { return undefined; } - // some incoming legacy group updates are outgoing, but when synced to our other devices have just the received_at field set. - // when that is the case, we don't want to render the spinning 'sending' state - if ( - (this.isExpirationTimerUpdate() || this.isDataExtractionNotification()) && - this.get('received_at') - ) { - return undefined; - } - if ( this.isDataExtractionNotification() || this.isCallNotification() || @@ -618,6 +610,36 @@ export class MessageModel extends Model { return 'sending'; } + /** + * Returns true if + * - the message is an incoming message (i.e. we've fetched it from the server/swarm) + * - the message is outgoing and marked as sent (i.e. we've sent it to the server/swarm and its status is "sent" or "read") + * + * @see useMessageIsOnline() that uses the same logic, redux side + */ + public isOnline() { + const status = this.getMessagePropStatus(); + const isIncoming = this.isIncoming(); + + if (isIncoming) { + return true; + } + switch (status) { + case 'sent': + case 'read': + // once a message is read by the recipient, the status is "read" so we display the "eye" status icon. + // but such a message was still sent in the first place + return true; + case 'sending': + case 'error': + case undefined: + return false; + default: + assertUnreachable(status, `wasSent: invalid msg status "${status}"`); + return false; // to make tsc happy + } + } + public getPropsForMessage(): PropsForMessageWithoutConvoProps { const sender = this.getSource(); const expirationType = this.getExpirationType(); @@ -936,7 +958,7 @@ export class MessageModel extends Model { reaction: undefined, messageRequestResponse: undefined, errors: undefined, - unread: undefined, + unread: toSqliteBoolean(false), }); // we can ignore the result of that markMessageReadNoCommit as it would only be used // to refresh the expiry of it(but it is already marked as "deleted", so we don't care) @@ -946,6 +968,7 @@ export class MessageModel extends Model { // getNextExpiringMessage is used on app start to clean already expired messages which should have been removed already, but are not await this.setToExpire(); await this.getConversation()?.refreshInMemoryDetails(); + this.getConversation()?.updateLastMessage(); } // One caller today: event handler for the 'Retry Send' entry on right click of a failed send message @@ -1598,7 +1621,6 @@ export class MessageModel extends Model { return forcedArrayUpdate; } - // #region Start of getters public getExpirationType() { return this.get('expirationType'); } @@ -1626,8 +1648,6 @@ export class MessageModel extends Model { public getExpirationTimerUpdate() { return this.get('expirationTimerUpdate'); } - - // #endregion } const throttledAllMessagesDispatch = debounce( diff --git a/ts/node/migration/sessionMigrations.ts b/ts/node/migration/sessionMigrations.ts index fc272ed97..447b1bf03 100644 --- a/ts/node/migration/sessionMigrations.ts +++ b/ts/node/migration/sessionMigrations.ts @@ -1645,7 +1645,6 @@ function updateToSessionSchemaVersion34(currentVersion: number, db: Database) { console.log(`updateToSessionSchemaVersion${targetVersion}: starting...`); db.transaction(() => { try { - // #region v34 Disappearing Messages Database Model Changes // Conversation changes db.prepare( `ALTER TABLE ${CONVERSATIONS_TABLE} ADD COLUMN expirationMode TEXT DEFAULT "off";` @@ -1658,8 +1657,6 @@ function updateToSessionSchemaVersion34(currentVersion: number, db: Database) { db.prepare(`ALTER TABLE ${MESSAGES_TABLE} ADD COLUMN flags INTEGER;`).run(); db.prepare(`UPDATE ${MESSAGES_TABLE} SET flags = json_extract(json, '$.flags');`); - // #endregion - const loggedInUser = getLoggedInUserConvoDuringMigration(db); if (!loggedInUser || !loggedInUser.ourKeys) { @@ -1668,7 +1665,6 @@ function updateToSessionSchemaVersion34(currentVersion: number, db: Database) { const { privateEd25519, publicKeyHex } = loggedInUser.ourKeys; - // #region v34 Disappearing Messages Note to Self const noteToSelfInfo = db .prepare( `UPDATE ${CONVERSATIONS_TABLE} SET @@ -1717,9 +1713,6 @@ function updateToSessionSchemaVersion34(currentVersion: number, db: Database) { } } - // #endregion - - // #region v34 Disappearing Messages Private Conversations const privateConversationsInfo = db .prepare( `UPDATE ${CONVERSATIONS_TABLE} SET @@ -1793,9 +1786,6 @@ function updateToSessionSchemaVersion34(currentVersion: number, db: Database) { } } - // #endregion - - // #region v34 Disappearing Messages Groups const groupConversationsInfo = db .prepare( `UPDATE ${CONVERSATIONS_TABLE} SET @@ -1866,8 +1856,6 @@ function updateToSessionSchemaVersion34(currentVersion: number, db: Database) { } } } - - // #endregion } catch (e) { console.error( `Failed to migrate to disappearing messages v2. Might just not have a logged in user yet? `, diff --git a/ts/receiver/contentMessage.ts b/ts/receiver/contentMessage.ts index 40a6f04ed..c52c615a7 100644 --- a/ts/receiver/contentMessage.ts +++ b/ts/receiver/contentMessage.ts @@ -481,9 +481,7 @@ async function handleUnsendMessage( ]) )?.[0]; const messageHash = messageToDelete?.get('messageHash'); - // #endregion - // #region executing deletion if (messageHash && messageToDelete) { window.log.info('handleUnsendMessage: got a request to delete ', messageHash); const conversation = ConvoHub.use().get(messageToDelete.get('conversationId')); diff --git a/ts/session/apis/snode_api/SNodeAPI.ts b/ts/session/apis/snode_api/SNodeAPI.ts index 9b22f9bbd..3f94e151f 100644 --- a/ts/session/apis/snode_api/SNodeAPI.ts +++ b/ts/session/apis/snode_api/SNodeAPI.ts @@ -21,7 +21,7 @@ import { ReduxOnionSelectors } from '../../../state/selectors/onions'; export const ERROR_CODE_NO_CONNECT = 'ENETUNREACH: No network connection.'; // TODO we should merge those two functions together as they are almost exactly the same -const forceNetworkDeletion = async (): Promise | null> => { +async function forceNetworkDeletion(): Promise | null> { const sodium = await getSodiumRenderer(); const usPk = UserUtils.getOurPubKeyStrFromCache(); @@ -154,18 +154,18 @@ const forceNetworkDeletion = async (): Promise | null> => { window?.log?.warn(`failed to ${request.method} everything on network:`, e); return null; } -}; +} const getMinTimeout = () => 500; /** * Delete the specified message hashes from our own swarm only. - * Note: legacy groups does not support this + * Note: legacy groups are not supported */ -const networkDeleteMessageOurSwarm = async ( +async function networkDeleteMessageOurSwarm( messagesHashes: Set, pubkey: PubkeyType -): Promise => { +): Promise { const sodium = await getSodiumRenderer(); if (!PubKey.is05Pubkey(pubkey) || pubkey !== UserUtils.getOurPubKeyStrFromCache()) { throw new Error('networkDeleteMessageOurSwarm with 05 pk can only for our own swarm'); @@ -272,7 +272,6 @@ const networkDeleteMessageOurSwarm = async ( return null; }) ); - return isEmpty(results); } catch (e) { throw new Error( @@ -283,7 +282,7 @@ const networkDeleteMessageOurSwarm = async ( } }, { - retries: 5, + retries: 2, minTimeout: SnodeAPI.getMinTimeout(), onFailedAttempt: e => { window?.log?.warn( @@ -301,7 +300,7 @@ const networkDeleteMessageOurSwarm = async ( ); return false; } -}; +} /** * Delete the specified message hashes from the 03-group's swarm. @@ -311,10 +310,10 @@ const networkDeleteMessageOurSwarm = async ( * - if one of the hashes was already not found in the swarm, * - if the request failed too many times */ -const networkDeleteMessagesForGroup = async ( +async function networkDeleteMessagesForGroup( messagesHashes: Set, groupPk: GroupPubkeyType -): Promise => { +): Promise { if (!PubKey.is03Pubkey(groupPk)) { throw new Error('networkDeleteMessagesForGroup with 05 pk can only delete for ourself'); } @@ -379,7 +378,7 @@ const networkDeleteMessagesForGroup = async ( window?.log?.warn(`networkDeleteMessagesForGroup: failed to delete messages on network:`, e); return false; } -}; +} export const SnodeAPI = { getMinTimeout, diff --git a/ts/session/types/with.ts b/ts/session/types/with.ts index 75266f877..f803e2047 100644 --- a/ts/session/types/with.ts +++ b/ts/session/types/with.ts @@ -26,7 +26,7 @@ export type WithMessageId = { messageId: string }; export type WithContextMenuId = { contextMenuId: string }; export type WithLocalMessageDeletionType = { - deletionType: 'complete' | 'markDeleted' | 'marDeletedLocally'; + deletionType: 'complete' | 'markDeleted' | 'markDeletedThisDevice'; }; export type ShortenOrExtend = 'extend' | 'shorten' | ''; export type WithShortenOrExtend = { shortenOrExtend: ShortenOrExtend }; diff --git a/ts/session/utils/Toast.tsx b/ts/session/utils/Toast.tsx index 4210d5caa..5fb19478d 100644 --- a/ts/session/utils/Toast.tsx +++ b/ts/session/utils/Toast.tsx @@ -264,3 +264,7 @@ export function pushNoMediaUntilApproved() { export function pushRateLimitHitReactions() { pushToastInfo('reactRateLimit', tStripped('emojiReactsCoolDown')); } + +export function pushGenericError() { + pushToastError('errorGeneric', tStripped('errorGeneric')); +} diff --git a/ts/state/ducks/networkData.ts b/ts/state/ducks/networkData.ts index 92c904478..4cf94bba1 100644 --- a/ts/state/ducks/networkData.ts +++ b/ts/state/ducks/networkData.ts @@ -34,7 +34,6 @@ export const initialNetworkDataState: NetworkDataState = { }, }; -// #region - Async thunks const fetchInfoFromSeshServer = createAsyncThunk( 'networkData/fetchInfoFromSeshServer', async (_, payloadCreator): Promise => { @@ -140,8 +139,6 @@ const refreshInfoFromSeshServer = createAsyncThunk( } ); -// #endregion - export const networkDataSlice = createSlice({ name: 'networkData', initialState: initialNetworkDataState, diff --git a/ts/state/ducks/releasedFeatures.tsx b/ts/state/ducks/releasedFeatures.tsx index 2b929dc7f..b63cb7c38 100644 --- a/ts/state/ducks/releasedFeatures.tsx +++ b/ts/state/ducks/releasedFeatures.tsx @@ -10,8 +10,6 @@ export const initialReleasedFeaturesState = { refreshedAt: Date.now(), }; -// #region - Async Thunks - // NOTE as features are released in production they will be removed from this list const resetExperiments = createAsyncThunk( 'releasedFeatures/resetExperiments', @@ -25,7 +23,6 @@ const resetExperiments = createAsyncThunk( ToastUtils.pushToastInfo('releasedFeatures/resetExperiments', 'Reset experiments!'); } ); -// #endregion const releasedFeaturesSlice = createSlice({ name: 'releasedFeatures', diff --git a/ts/state/onboarding/selectors/registration.ts b/ts/state/onboarding/selectors/registration.ts index ba22b5191..2f67e1bfb 100644 --- a/ts/state/onboarding/selectors/registration.ts +++ b/ts/state/onboarding/selectors/registration.ts @@ -9,7 +9,6 @@ import { } from '../ducks/registration'; import { OnboardingStoreState } from '../store'; -// #region Getters const getRegistration = (state: OnboardingStoreState): OnboardingState => { return state.registration; }; @@ -63,9 +62,7 @@ const getDisplayNameError = createSelector( getRegistration, (state: OnboardingState): string | undefined => state.displayNameError ); -// #endregion -// #region Hooks export const useOnboardStep = () => { return useSelector(getOnboardingStep); }; @@ -105,4 +102,3 @@ export const useDisplayName = () => { export const useDisplayNameError = () => { return useSelector(getDisplayNameError); }; -// #endregion diff --git a/ts/state/selectors/messages.ts b/ts/state/selectors/messages.ts index 66a3835e0..1d5dbfd16 100644 --- a/ts/state/selectors/messages.ts +++ b/ts/state/selectors/messages.ts @@ -122,6 +122,22 @@ export const useMessageStatus = ( return useMessagePropsByMessageId(messageId)?.propsForMessage.status; }; +/** + * Returns true if + * - the message is incoming (i.e. we've fetched it from the server/swarm) + * - the message was sent or already read by the recipient, false otherwise. + * + * @see MessageModel.isOnline() + */ +export const useMessageIsOnline = (messageId: string | undefined): boolean => { + const status = useMessageStatus(messageId); + const direction = useMessageDirection(messageId); + if (direction === 'incoming') { + return true; + } + return status === 'sent' || status === 'read'; +}; + export function useMessageSender(messageId: string | undefined) { return useMessagePropsByMessageId(messageId)?.propsForMessage.sender; } diff --git a/ts/state/selectors/networkModal.ts b/ts/state/selectors/networkModal.ts index 571ce8709..66451a75a 100644 --- a/ts/state/selectors/networkModal.ts +++ b/ts/state/selectors/networkModal.ts @@ -7,7 +7,6 @@ const getNetworkModal = (state: StateType): NetworkModalState => { return state.networkModal; }; -// #region - Hooks export const useInfoLoading = (): boolean => { return useSelector((state: StateType) => getNetworkModal(state).infoLoading); }; @@ -23,5 +22,3 @@ export const useLastRefreshedTimestamp = (): number => { export const useErrorMessage = (): TrArgs | null => { return useSelector((state: StateType) => getNetworkModal(state).errorMessage); }; - -// #endregion From de81b32238d647dda36c70e61414a2a01730341d Mon Sep 17 00:00:00 2001 From: Ryan Miller Date: Fri, 27 Feb 2026 11:12:20 +1100 Subject: [PATCH 10/21] chore: move message interaction logic to outer message container --- .../MessageContentWithStatus.tsx | 37 +---------- .../message-item/GenericReadableMessage.tsx | 66 ++++++++++++++----- 2 files changed, 50 insertions(+), 53 deletions(-) diff --git a/ts/components/conversation/message/message-content/MessageContentWithStatus.tsx b/ts/components/conversation/message/message-content/MessageContentWithStatus.tsx index 429a6e7a4..d2b2a8fec 100644 --- a/ts/components/conversation/message/message-content/MessageContentWithStatus.tsx +++ b/ts/components/conversation/message/message-content/MessageContentWithStatus.tsx @@ -1,11 +1,10 @@ -import { SessionDataTestId, MouseEvent, useCallback, Dispatch, useRef } from 'react'; +import { SessionDataTestId, Dispatch, useRef } from 'react'; import { useSelector } from 'react-redux'; import { clsx } from 'clsx'; import styled from 'styled-components'; import { getAppDispatch } from '../../../../state/dispatch'; import { useIsDetailMessageView } from '../../../../contexts/isDetailViewContext'; import { MessageRenderingProps } from '../../../../models/messageType'; -import { toggleSelectedMessageId } from '../../../../state/ducks/conversations'; import { updateReactListModal } from '../../../../state/ducks/modalDialog'; import { StateType } from '../../../../state/reducer'; import { useHideAvatarInMsgList, useMessageStatus } from '../../../../state/selectors'; @@ -17,11 +16,9 @@ import { MessageContent } from './MessageContent'; import { MessageContextMenu } from './MessageContextMenu'; import { MessageReactions } from './MessageReactions'; import { MessageStatus } from './MessageStatus'; -import { useIsMessageSelectionMode } from '../../../../state/selectors/selectedConversation'; import { SessionEmojiReactBarPopover } from '../../SessionEmojiReactBarPopover'; import { PopoverTriggerPosition } from '../../../SessionTooltip'; -import { trimWhitespace } from '../../../../session/utils/String'; -import { useMessageReact, useMessageReply } from '../../../../hooks/useMessageInteractions'; +import { useMessageReact } from '../../../../hooks/useMessageInteractions'; import { SessionFocusTrap } from '../../../SessionFocusTrap'; export type MessageContentWithStatusSelectorProps = { isGroup: boolean } & Pick< @@ -70,42 +67,14 @@ export const MessageContentWithStatuses = (props: Props) => { const contentProps = useSelector((state: StateType) => getMessageContentWithStatusesSelectorProps(state, messageId) ); - const reply = useMessageReply(messageId); const reactToMessage = useMessageReact(messageId); const hideAvatar = useHideAvatarInMsgList(messageId); const isDetailView = useIsDetailMessageView(); - const multiSelectMode = useIsMessageSelectionMode(); const status = useMessageStatus(props.messageId); const isSent = status === 'sent' || status === 'read'; // a read message should be reactable const reactBarFirstEmojiRef = useRef(null); - const onClickOnMessageOuterContainer = useCallback( - (event: MouseEvent) => { - if (multiSelectMode && messageId) { - event.preventDefault(); - event.stopPropagation(); - dispatch(toggleSelectedMessageId(messageId)); - } - }, - [dispatch, messageId, multiSelectMode] - ); - - const onDoubleClickReplyToMessage = reply - ? (e: MouseEvent) => { - const currentSelection = window.getSelection(); - const currentSelectionString = currentSelection?.toString() || undefined; - - if ( - (!currentSelectionString || trimWhitespace(currentSelectionString).length === 0) && - (e.target as any).localName !== 'em-emoji-picker' - ) { - e.preventDefault(); - void reply(); - } - } - : undefined; - if (!contentProps) { return null; } @@ -139,8 +108,6 @@ export const MessageContentWithStatuses = (props: Props) => { messageId={messageId} className={clsx('module-message', `module-message--${direction}`)} role={'button'} - onClick={onClickOnMessageOuterContainer} - onDoubleClickCapture={onDoubleClickReplyToMessage} dataTestId={dataTestId} > ` display: flex; align-items: center; @@ -78,11 +82,6 @@ const StyledReadableMessage = styled.div<{ background-color: var(--conversation-tab-background-selected-color); }` : ''} - - ${props => - props.$forceFocusedMessageBackground - ? 'background-color: var(--conversation-tab-background-selected-color);' - : ''} `; export const GenericReadableMessage = (props: Props) => { @@ -96,7 +95,6 @@ export const GenericReadableMessage = (props: Props) => { ); const isMessageSelected = useMessageSelected(props.messageId); const selectedIsBlocked = useSelectedIsBlocked(); - const multiSelectMode = useIsMessageSelectionMode(); const ref = useRef(null); @@ -106,16 +104,25 @@ export const GenericReadableMessage = (props: Props) => { const { focusedMessageId } = useFocusScope(); const isAnotherMessageFocused = focusedMessageId && !isInFocusScope; + const reply = useMessageReply(messageId); const focusMessageId = () => { dispatch(setFocusedMessageId(messageId)); }; const onFocus = () => { + window.log.warn('potato onFocus'); focusMessageId(); }; const onBlur = () => { + window.log.warn('potato onBlur'); dispatch(setFocusedMessageId(null)); + pointerDownRef.current = false; + }; + + const onPointerDown = () => { + pointerDownRef.current = true; + window.log.warn('potato onPointerDown'); }; const getMessageContainerTriggerPosition = (): PopoverTriggerPosition | null => { @@ -186,6 +193,35 @@ export const GenericReadableMessage = (props: Props) => { } }; + const onClick = useCallback( + (event: MouseEvent) => { + if (multiSelectMode && messageId) { + event.preventDefault(); + event.stopPropagation(); + dispatch(toggleSelectedMessageId(messageId)); + } + }, + [dispatch, messageId, multiSelectMode] + ); + + const onDoubleClickCapture = reply + ? (e: MouseEvent) => { + if (multiSelectMode) { + return; + } + const currentSelection = window.getSelection(); + const currentSelectionString = currentSelection?.toString() || undefined; + + if ( + (!currentSelectionString || trimWhitespace(currentSelectionString).length === 0) && + (e.target as any).localName !== 'em-emoji-picker' + ) { + e.preventDefault(); + void reply(); + } + } + : undefined; + useKeyboardShortcut({ shortcut: KbdShortcut.messageToggleReactionBar, handler: toggleEmojiReactionBarWithKeyboard, @@ -214,18 +250,12 @@ export const GenericReadableMessage = (props: Props) => { key={`readable-message-${messageId}`} onKeyDown={onKeyDown} $focusedKeyboard={!pointerDownRef.current} - $forceFocusedMessageBackground={ - /** FIXME: sss */ !!triggerPosition && !pointerDownRef.current - } tabIndex={0} - onPointerDown={() => { - pointerDownRef.current = true; - }} - onFocus={() => { - onFocus(); - pointerDownRef.current = false; - }} + onPointerDown={onPointerDown} + onFocus={onFocus} onBlur={onBlur} + onClick={onClick} + onDoubleClickCapture={onDoubleClickCapture} > Date: Fri, 27 Feb 2026 14:19:30 +1100 Subject: [PATCH 11/21] fix: move popovers to interactable component --- .../SessionEmojiReactBarPopover.tsx | 12 +- .../conversation/TimerNotification.tsx | 6 +- .../MessageContentWithStatus.tsx | 15 +-- .../message-content/MessageContextMenu.tsx | 26 +++-- .../message-item/CommunityInvitation.tsx | 6 +- .../DataExtractionNotification.tsx | 6 +- .../message-item/ExpirableReadableMessage.tsx | 107 ++++++++++-------- .../message-item/GenericReadableMessage.tsx | 3 +- .../message-item/GroupUpdateMessage.tsx | 6 +- .../message-item/InteractionNotification.tsx | 13 +-- .../message-item/MessageRequestResponse.tsx | 8 +- .../notification-bubble/CallNotification.tsx | 6 +- .../conversation/right-panel/RightPanel.tsx | 49 ++++---- .../message-info/OverlayMessageInfo.tsx | 9 +- ts/hooks/useMessageInteractions.ts | 4 +- 15 files changed, 137 insertions(+), 139 deletions(-) diff --git a/ts/components/conversation/SessionEmojiReactBarPopover.tsx b/ts/components/conversation/SessionEmojiReactBarPopover.tsx index 262ee71a4..8e35092c6 100644 --- a/ts/components/conversation/SessionEmojiReactBarPopover.tsx +++ b/ts/components/conversation/SessionEmojiReactBarPopover.tsx @@ -1,5 +1,10 @@ import { type RefObject, useRef, useState } from 'react'; -import { getTriggerPosition, type PopoverTriggerPosition } from '../SessionTooltip'; +import { + getTriggerPosition, + WithPopoverPosition, + WithSetPopoverPosition, + type PopoverTriggerPosition, +} from '../SessionTooltip'; import { SessionPopoverContent } from '../SessionPopover'; import { MessageReactBar } from './message/message-content/MessageReactBar'; import { THEME_GLOBALS } from '../../themes/globals'; @@ -7,6 +12,11 @@ import { SessionEmojiPanelPopover } from './SessionEmojiPanelPopover'; import { closeContextMenus } from '../../util/contextMenu'; import { useMessageReact } from '../../hooks/useMessageInteractions'; +export type ReactionBarOptions = WithPopoverPosition & WithSetPopoverPosition; +export type WithReactionBarOptions = { + reactionBarOptions?: ReactionBarOptions; +}; + export function SessionEmojiReactBarPopover({ messageId, triggerPos, diff --git a/ts/components/conversation/TimerNotification.tsx b/ts/components/conversation/TimerNotification.tsx index fee804272..2029d5073 100644 --- a/ts/components/conversation/TimerNotification.tsx +++ b/ts/components/conversation/TimerNotification.tsx @@ -34,7 +34,6 @@ import { useMessageExpirationUpdateTimespanText, } from '../../state/selectors'; import { tr, type TrArgs } from '../../localization/localeTools'; -import type { WithPopoverPosition, WithSetPopoverPosition } from '../SessionTooltip'; const FollowSettingButton = styled.button` color: var(--primary-color); @@ -148,9 +147,7 @@ const FollowSettingsButton = ({ messageId }: WithMessageId) => { ); }; -export const TimerNotification = ( - props: WithMessageId & WithPopoverPosition & WithSetPopoverPosition & WithContextMenuId -) => { +export const TimerNotification = (props: WithMessageId & WithContextMenuId) => { const { messageId } = props; const timespanSeconds = useMessageExpirationUpdateTimespanSeconds(messageId); const expirationMode = useMessageExpirationUpdateMode(messageId); @@ -179,7 +176,6 @@ export const TimerNotification = ( return ( ; -type Props = WithMessageId & - WithContextMenuId & - WithPopoverPosition & - WithSetPopoverPosition & { - autoFocusReactionBarFirstEmoji?: boolean; - }; +type Props = WithMessageId & WithContextMenuId & WithReactionBarOptions; const StyledMessageContentContainer = styled.div<{ $isIncoming: boolean; $isDetailView: boolean }>` display: flex; @@ -56,7 +51,7 @@ const StyledMessageWithAuthor = styled.div` `; export const MessageContentWithStatuses = (props: Props) => { - const { messageId, contextMenuId, triggerPosition, setTriggerPosition } = props; + const { messageId, contextMenuId, reactionBarOptions } = props; const dispatch = getAppDispatch(); const reactToMessage = useMessageReact(messageId); const reply = useMessageReply(messageId); @@ -129,9 +124,7 @@ export const MessageContentWithStatuses = (props: Props) => { onDoubleClickCapture={onDoubleClickReplyToMessage} dataTestId="message-content" contextMenuId={contextMenuId} - setTriggerPosition={setTriggerPosition} - triggerPosition={triggerPosition} - enableReactions={enableReactions} + reactionBarOptions={enableReactions ? reactionBarOptions : undefined} > ; -type Props = WithMessageId & WithContextMenuId & WithSetPopoverPosition; +type Props = WithMessageId & WithContextMenuId & WithReactionBarOptions; const CONTEXTIFY_MENU_WIDTH_PX = 200; const SCREEN_RIGHT_MARGIN_PX = 104; @@ -250,7 +250,7 @@ function MessageReplyMenuItem({ messageId }: { messageId: string }) { } export const MessageContextMenu = (props: Props) => { - const { messageId, contextMenuId, setTriggerPosition } = props; + const { messageId, contextMenuId, reactionBarOptions } = props; const contextMenuRef = useRef(null); const isLegacyGroup = useSelectedIsLegacyGroup(); @@ -267,18 +267,20 @@ export const MessageContextMenu = (props: Props) => { // it does not include changes to prevent the menu from overflowing the window. This temporary // fix resolves this by mirroring the y-offset adjustment. const yClamped = clampNumber(y, 0, window.innerHeight - triggerHeight); - setTriggerPosition({ - x, - // Changes the x-anchor from the center to the far left - offsetX: -triggerWidth / 2, - y: yClamped, - height: triggerHeight, - width: triggerWidth, - }); + if (reactionBarOptions) { + reactionBarOptions.setTriggerPosition({ + x, + // Changes the x-anchor from the center to the far left + offsetX: -triggerWidth / 2, + y: yClamped, + height: triggerHeight, + width: triggerWidth, + }); + } }; const onHide: MenuOnHideCallback = () => { - setTriggerPosition(null); + reactionBarOptions?.setTriggerPosition(null); }; if (!convoId) { diff --git a/ts/components/conversation/message/message-item/CommunityInvitation.tsx b/ts/components/conversation/message/message-item/CommunityInvitation.tsx index 0b098e0e7..d6857683b 100644 --- a/ts/components/conversation/message/message-item/CommunityInvitation.tsx +++ b/ts/components/conversation/message/message-item/CommunityInvitation.tsx @@ -14,7 +14,6 @@ import type { WithContextMenuId, WithMessageId } from '../../../../session/types import { SessionLucideIconButton } from '../../../icon/SessionIconButton'; import { LUCIDE_ICONS_UNICODE } from '../../../icon/lucide'; import { tr } from '../../../../localization/localeTools'; -import type { WithPopoverPosition, WithSetPopoverPosition } from '../../../SessionTooltip'; const StyledCommunityInvitation = styled.div` background-color: var(--message-bubble-incoming-background-color); @@ -78,9 +77,7 @@ const StyledIconContainer = styled.div` border-radius: 100%; `; -export const CommunityInvitation = ( - props: WithMessageId & WithPopoverPosition & WithSetPopoverPosition & WithContextMenuId -) => { +export const CommunityInvitation = (props: WithMessageId & WithContextMenuId) => { const messageDirection = useMessageDirection(props.messageId); const classes = ['group-invitation']; @@ -109,7 +106,6 @@ export const CommunityInvitation = ( diff --git a/ts/components/conversation/message/message-item/DataExtractionNotification.tsx b/ts/components/conversation/message/message-item/DataExtractionNotification.tsx index 2fc4f0962..acc270b96 100644 --- a/ts/components/conversation/message/message-item/DataExtractionNotification.tsx +++ b/ts/components/conversation/message/message-item/DataExtractionNotification.tsx @@ -6,11 +6,8 @@ import { useConversationUsernameWithFallback } from '../../../../hooks/useParamS import type { WithContextMenuId, WithMessageId } from '../../../../session/types/with'; import { SignalService } from '../../../../protobuf'; import { LUCIDE_ICONS_UNICODE } from '../../../icon/lucide'; -import type { WithPopoverPosition, WithSetPopoverPosition } from '../../../SessionTooltip'; -export const DataExtractionNotification = ( - props: WithMessageId & WithPopoverPosition & WithSetPopoverPosition & WithContextMenuId -) => { +export const DataExtractionNotification = (props: WithMessageId & WithContextMenuId) => { const { messageId } = props; const author = useMessageAuthor(messageId); const authorName = useConversationUsernameWithFallback(true, author); @@ -25,7 +22,6 @@ export const DataExtractionNotification = ( diff --git a/ts/components/conversation/message/message-item/ExpirableReadableMessage.tsx b/ts/components/conversation/message/message-item/ExpirableReadableMessage.tsx index 1216c603d..09270ada3 100644 --- a/ts/components/conversation/message/message-item/ExpirableReadableMessage.tsx +++ b/ts/components/conversation/message/message-item/ExpirableReadableMessage.tsx @@ -31,7 +31,6 @@ import { ExpireTimer } from '../../ExpireTimer'; import { Data } from '../../../../data/data'; import { ConvoHub } from '../../../../session/conversations'; import { MessageContextMenu } from '../message-content/MessageContextMenu'; -import type { WithPopoverPosition, WithSetPopoverPosition } from '../../../SessionTooltip'; import type { WithContextMenuId, WithConvoId, WithMessageId } from '../../../../session/types/with'; import { useScrollToLoadedMessage } from '../../../../contexts/ScrollToLoadedMessage'; import { @@ -46,7 +45,10 @@ import { getIsAppFocused } from '../../../../state/selectors/section'; import { useSelectedConversationKey } from '../../../../state/selectors/selectedConversation'; import { useMessageType } from '../../../../state/selectors'; import { useSelectMessageViaClick } from '../../../../hooks/useMessageInteractions'; -import { SessionEmojiReactBarPopover } from '../../SessionEmojiReactBarPopover'; +import { + SessionEmojiReactBarPopover, + type WithReactionBarOptions, +} from '../../SessionEmojiReactBarPopover'; import { SessionFocusTrap } from '../../../SessionFocusTrap'; const EXPIRATION_CHECK_MINIMUM = 2000; @@ -296,10 +298,7 @@ const ReadableMessage = ( export type ExpirableReadableMessageProps = Omit & WithMessageId & WithContextMenuId & - WithSetPopoverPosition & - WithPopoverPosition & { - enableReactions?: boolean; - }; + WithReactionBarOptions; function ExpireTimerControlMessage({ expirationTimestamp, @@ -322,30 +321,62 @@ const useIsDetailMessageViewInternal = useIsDetailMessageView; const useSelectMessageViaClickInternal = useSelectMessageViaClick; const useMessageTypeInternal = useMessageType; -export const ExpirableReadableMessage = (props: ExpirableReadableMessageProps) => { - const selected = useMessageExpirationPropsByIdInternal(props.messageId); +function SessionMessageInteractables({ + messageId, + contextMenuId, + reactionBarOptions, +}: WithMessageId & WithContextMenuId & WithReactionBarOptions) { + const reactionBarFirstEmojiRef = useRef(null); const isDetailView = useIsDetailMessageViewInternal(); - const selectViaClick = useSelectMessageViaClickInternal(props.messageId); - const { - onDoubleClickCapture, - role, - dataTestId, - contextMenuId, - triggerPosition, - setTriggerPosition, - messageId, - enableReactions, - } = props; + if (isDetailView) { + return null; + } - const messageType = useMessageTypeInternal(messageId); + const closeReactionBar = reactionBarOptions + ? () => { + reactionBarOptions.setTriggerPosition(null); + } + : undefined; - const reactBarFirstEmojiRef = useRef(null); - const closeReactionBar = () => { - setTriggerPosition(null); - }; + const reactionBarFocusTrapActive = + !!reactionBarOptions?.triggerPosition && !!reactionBarFirstEmojiRef.current; + return ( + reactionBarFirstEmojiRef.current ?? false} + onDeactivate={closeReactionBar} + clickOutsideDeactivates={true} + > + {reactionBarOptions ? ( + + ) : null} + + + ); +} - const active = !!triggerPosition && !!reactBarFirstEmojiRef.current; +export const ExpirableReadableMessage = ({ + onDoubleClickCapture, + role, + dataTestId, + contextMenuId, + messageId, + reactionBarOptions, + children, +}: ExpirableReadableMessageProps) => { + const selected = useMessageExpirationPropsByIdInternal(messageId); + const isDetailView = useIsDetailMessageViewInternal(); + const selectViaClick = useSelectMessageViaClickInternal(messageId); + const messageType = useMessageTypeInternal(messageId); const { isExpired } = useIsExpired({ convoId: selected?.convoId, @@ -398,26 +429,12 @@ export const ExpirableReadableMessage = (props: ExpirableReadableMessageProps) = expirationTimestamp={expirationTimestamp} /> ) : null} - reactBarFirstEmojiRef.current ?? false} - onDeactivate={closeReactionBar} - clickOutsideDeactivates={true} - > - {enableReactions ? ( - - ) : null} - - - {props.children} + + {children} ); }; diff --git a/ts/components/conversation/message/message-item/GenericReadableMessage.tsx b/ts/components/conversation/message/message-item/GenericReadableMessage.tsx index 80e409b58..8df437d8f 100644 --- a/ts/components/conversation/message/message-item/GenericReadableMessage.tsx +++ b/ts/components/conversation/message/message-item/GenericReadableMessage.tsx @@ -280,8 +280,7 @@ export const GenericReadableMessage = ({ messageId }: WithMessageId) => { ); diff --git a/ts/components/conversation/message/message-item/GroupUpdateMessage.tsx b/ts/components/conversation/message/message-item/GroupUpdateMessage.tsx index 31ace9638..1eefeb3ed 100644 --- a/ts/components/conversation/message/message-item/GroupUpdateMessage.tsx +++ b/ts/components/conversation/message/message-item/GroupUpdateMessage.tsx @@ -18,7 +18,6 @@ import { useMessageGroupUpdateChange } from '../../../../state/selectors'; import { assertUnreachable } from '../../../../types/sqlSharedTypes'; import { LUCIDE_ICONS_UNICODE } from '../../../icon/lucide'; import type { TrArgs } from '../../../../localization/localeTools'; -import type { WithPopoverPosition, WithSetPopoverPosition } from '../../../SessionTooltip'; // This component is used to display group updates in the conversation view. @@ -67,9 +66,7 @@ function useChangeItem(change?: PropsForGroupUpdateType): TrArgs | null { // NOTE: [react-compiler] this convinces the compiler the hook is static const useMessageGroupUpdateChangeInternal = useMessageGroupUpdateChange; -export const GroupUpdateMessage = ( - props: WithMessageId & WithPopoverPosition & WithSetPopoverPosition & WithContextMenuId -) => { +export const GroupUpdateMessage = (props: WithMessageId & WithContextMenuId) => { const groupChange = useMessageGroupUpdateChangeInternal(props.messageId); const changeProps = useChangeItem(groupChange); @@ -82,7 +79,6 @@ export const GroupUpdateMessage = ( diff --git a/ts/components/conversation/message/message-item/InteractionNotification.tsx b/ts/components/conversation/message/message-item/InteractionNotification.tsx index c2000ea16..96e9377be 100644 --- a/ts/components/conversation/message/message-item/InteractionNotification.tsx +++ b/ts/components/conversation/message/message-item/InteractionNotification.tsx @@ -16,17 +16,15 @@ import type { WithContextMenuId, WithMessageId } from '../../../../session/types import { tr } from '../../../../localization/localeTools'; import { useConversationUsernameWithFallback } from '../../../../hooks/useParamSelector'; import { ExpirableReadableMessage } from './ExpirableReadableMessage'; -import type { WithPopoverPosition, WithSetPopoverPosition } from '../../../SessionTooltip'; const StyledFailText = styled.div` color: var(--danger-color); `; -export const InteractionNotification = ( - props: WithMessageId & WithPopoverPosition & WithSetPopoverPosition & WithContextMenuId -) => { - const { messageId } = props; - +export const InteractionNotification = ({ + messageId, + contextMenuId, +}: WithMessageId & WithContextMenuId) => { const convoId = useSelectedConversationKey(); const displayName = useConversationUsernameWithFallback(true, convoId); const isGroup = !useSelectedIsPrivate(); @@ -76,8 +74,7 @@ export const InteractionNotification = ( return ( diff --git a/ts/components/conversation/message/message-item/MessageRequestResponse.tsx b/ts/components/conversation/message/message-item/MessageRequestResponse.tsx index 38c8c2720..55d0e0f42 100644 --- a/ts/components/conversation/message/message-item/MessageRequestResponse.tsx +++ b/ts/components/conversation/message/message-item/MessageRequestResponse.tsx @@ -5,13 +5,12 @@ import { useSelectedConversationKey } from '../../../../state/selectors/selected import { Flex } from '../../../basic/Flex'; import { Localizer } from '../../../basic/Localizer'; import { SpacerSM, TextWithChildren } from '../../../basic/Text'; -import type { WithPopoverPosition, WithSetPopoverPosition } from '../../../SessionTooltip'; import { ExpirableReadableMessage } from './ExpirableReadableMessage'; export const MessageRequestResponse = ({ messageId, - ...props -}: WithMessageId & WithPopoverPosition & WithSetPopoverPosition & WithContextMenuId) => { + contextMenuId, +}: WithMessageId & WithContextMenuId) => { const conversationId = useSelectedConversationKey(); const isUs = useMessageAuthorIsUs(messageId); @@ -24,8 +23,7 @@ export const MessageRequestResponse = ({ return ( diff --git a/ts/components/conversation/message/message-item/notification-bubble/CallNotification.tsx b/ts/components/conversation/message/message-item/notification-bubble/CallNotification.tsx index d476f1a51..5afb64551 100644 --- a/ts/components/conversation/message/message-item/notification-bubble/CallNotification.tsx +++ b/ts/components/conversation/message/message-item/notification-bubble/CallNotification.tsx @@ -8,7 +8,6 @@ import { MergedLocalizerTokens } from '../../../../../localization/localeTools'; import type { WithContextMenuId, WithMessageId } from '../../../../../session/types/with'; import { useMessageCallNotificationType } from '../../../../../state/selectors'; import { LUCIDE_ICONS_UNICODE } from '../../../../icon/lucide'; -import type { WithPopoverPosition, WithSetPopoverPosition } from '../../../../SessionTooltip'; type StyleType = Record< CallNotificationType, @@ -33,9 +32,7 @@ const style = { }, } satisfies StyleType; -export const CallNotification = ( - props: WithMessageId & WithPopoverPosition & WithSetPopoverPosition & WithContextMenuId -) => { +export const CallNotification = (props: WithMessageId & WithContextMenuId) => { const notificationType = useMessageCallNotificationType(props.messageId); const name = useSelectedNicknameOrProfileNameOrShortenedPubkey(); @@ -50,7 +47,6 @@ export const CallNotification = ( diff --git a/ts/components/conversation/right-panel/RightPanel.tsx b/ts/components/conversation/right-panel/RightPanel.tsx index 1e69e2e34..537f8760a 100644 --- a/ts/components/conversation/right-panel/RightPanel.tsx +++ b/ts/components/conversation/right-panel/RightPanel.tsx @@ -9,6 +9,7 @@ import { THEME_GLOBALS } from '../../../themes/globals'; import { sectionActions } from '../../../state/ducks/section'; import { getAppDispatch } from '../../../state/dispatch'; import { removeMessageInfoId } from '../../../state/ducks/conversations'; +import { IsDetailMessageViewContext } from '../../../contexts/isDetailViewContext'; const StyledRightPanelContainer = styled(motion.div)` position: absolute; @@ -50,30 +51,32 @@ export const RightPanel = ({ open }: { open: boolean }) => { return ( - {open && ( - - + - - - - )} + + + + + + ) : null} ); }; diff --git a/ts/components/conversation/right-panel/overlay/message-info/OverlayMessageInfo.tsx b/ts/components/conversation/right-panel/overlay/message-info/OverlayMessageInfo.tsx index 5133a5240..886edb728 100644 --- a/ts/components/conversation/right-panel/overlay/message-info/OverlayMessageInfo.tsx +++ b/ts/components/conversation/right-panel/overlay/message-info/OverlayMessageInfo.tsx @@ -10,7 +10,6 @@ import { getMessageInfoId } from '../../../../../state/selectors/conversations'; import { Flex } from '../../../../basic/Flex'; import { Header, HeaderTitle, StyledScrollContainer } from '../components'; -import { IsDetailMessageViewContext } from '../../../../../contexts/isDetailViewContext'; import { Data } from '../../../../../data/data'; import { useRightOverlayMode } from '../../../../../hooks/useUI'; import { @@ -82,11 +81,9 @@ const MessageBody = ({ } return ( - - - - - + + + ); }; diff --git a/ts/hooks/useMessageInteractions.ts b/ts/hooks/useMessageInteractions.ts index e6cebf119..a5be9ac76 100644 --- a/ts/hooks/useMessageInteractions.ts +++ b/ts/hooks/useMessageInteractions.ts @@ -14,6 +14,7 @@ import { import { useMessageAttachments, useMessageBody, + useMessageIsControlMessage, useMessageIsOnline, useMessageSender, useMessageServerTimestamp, @@ -77,9 +78,10 @@ export function useMessageCopyText(messageId?: string) { export function useMessageReply(messageId?: string) { const isSelectedBlocked = useSelectedIsBlocked(); + const isControlMessage = useMessageIsControlMessage(messageId); const msgIsOnline = useMessageIsOnline(messageId); - const cannotReply = !messageId || !msgIsOnline; + const cannotReply = !messageId || !msgIsOnline || isControlMessage; return cannotReply ? null From 1729fb06c0b468c8e1bf4ce2512ec4c475e3fbb0 Mon Sep 17 00:00:00 2001 From: Ryan Miller Date: Fri, 27 Feb 2026 14:43:55 +1100 Subject: [PATCH 12/21] fix: split message component into interactable parent --- .../conversation/SessionMessagesList.tsx | 4 +- .../MessageContentWithStatus.tsx | 35 +-- .../GenericReadableInteractableMessage.tsx | 203 +++++++++++++++ .../message-item/GenericReadableMessage.tsx | 232 +++--------------- 4 files changed, 244 insertions(+), 230 deletions(-) create mode 100644 ts/components/conversation/message/message-item/GenericReadableInteractableMessage.tsx diff --git a/ts/components/conversation/SessionMessagesList.tsx b/ts/components/conversation/SessionMessagesList.tsx index 986304551..dc8fedc64 100644 --- a/ts/components/conversation/SessionMessagesList.tsx +++ b/ts/components/conversation/SessionMessagesList.tsx @@ -16,7 +16,7 @@ import { SessionLastSeenIndicator } from './SessionLastSeenIndicator'; import { useKeyboardShortcut } from '../../hooks/useKeyboardShortcut'; import { KbdShortcut } from '../../util/keyboardShortcuts'; import { useMessageCopyText, useMessageReply } from '../../hooks/useMessageInteractions'; -import { GenericReadableMessage } from './message/message-item/GenericReadableMessage'; +import { GenericReadableInteractableMessage } from './message/message-item/GenericReadableInteractableMessage'; function isNotTextboxEvent(e: KeyboardEvent) { return (e?.target as any)?.type === undefined; @@ -115,7 +115,7 @@ export const SessionMessagesList = (props: { return [ dateBreak, unreadIndicator, - , + , ]; }) // TODO: check if we reverse this upstream, we might be reversing twice diff --git a/ts/components/conversation/message/message-content/MessageContentWithStatus.tsx b/ts/components/conversation/message/message-content/MessageContentWithStatus.tsx index 68cc1eb7c..235fe7ba6 100644 --- a/ts/components/conversation/message/message-content/MessageContentWithStatus.tsx +++ b/ts/components/conversation/message/message-content/MessageContentWithStatus.tsx @@ -1,4 +1,4 @@ -import { type MouseEvent, useMemo } from 'react'; +import { useMemo } from 'react'; import { clsx } from 'clsx'; import styled from 'styled-components'; import { getAppDispatch } from '../../../../state/dispatch'; @@ -16,11 +16,8 @@ import { MessageAuthorText } from './MessageAuthorText'; import { MessageContent } from './MessageContent'; import { MessageReactions } from './MessageReactions'; import { MessageStatus } from './MessageStatus'; -import { useMessageReact, useMessageReply } from '../../../../hooks/useMessageInteractions'; -import { - useSelectedConversationKey, - useSelectedIsLegacyGroup, -} from '../../../../state/selectors/selectedConversation'; +import { useMessageReact } from '../../../../hooks/useMessageInteractions'; +import { useSelectedConversationKey } from '../../../../state/selectors/selectedConversation'; import { ConvoHub } from '../../../../session/conversations'; import type { WithContextMenuId, WithMessageId } from '../../../../session/types/with'; import { WithReactionBarOptions } from '../../SessionEmojiReactBarPopover'; @@ -54,38 +51,14 @@ export const MessageContentWithStatuses = (props: Props) => { const { messageId, contextMenuId, reactionBarOptions } = props; const dispatch = getAppDispatch(); const reactToMessage = useMessageReact(messageId); - const reply = useMessageReply(messageId); const hideAvatar = useHideAvatarInMsgList(messageId); const isDetailView = useIsDetailMessageView(); const _direction = useMessageDirection(messageId); - const isLegacyGroup = useSelectedIsLegacyGroup(); const convoId = useSelectedConversationKey(); const msgIsOnline = useMessageIsOnline(messageId); - const onDoubleClickReplyToMessage = (e: MouseEvent) => { - if (isLegacyGroup || !reply) { - return; - } - const currentSelection = window.getSelection(); - const currentSelectionString = currentSelection?.toString() || undefined; - - if ((e.target as any).localName !== 'em-emoji-picker') { - if ( - !currentSelectionString || - currentSelectionString.length === 0 || - !/\s/.test(currentSelectionString) - ) { - // if multiple word are selected, consider that this double click was actually NOT used to reply to - // but to select - void reply(); - currentSelection?.empty(); - e.preventDefault(); - } - } - }; - const convoReactionsEnabled = useMemo(() => { if (convoId) { const conversationModel = ConvoHub.use().get(convoId); @@ -120,8 +93,6 @@ export const MessageContentWithStatuses = (props: Props) => { (null); + const pointerDownRef = useRef(false); + const [triggerPosition, setTriggerPosition] = useState(null); + const isInFocusScope = useIsInScope({ scope: 'message', scopeId: messageId }); + const { focusedMessageId } = useFocusScope(); + const isAnotherMessageFocused = focusedMessageId && !isInFocusScope; + + const reply = useMessageReply(messageId); + const focusMessageId = () => { + dispatch(setFocusedMessageId(messageId)); + }; + + const onFocus = () => { + focusMessageId(); + }; + + const onBlur = () => { + dispatch(setFocusedMessageId(null)); + pointerDownRef.current = false; + }; + + const onPointerDown = () => { + pointerDownRef.current = true; + }; + + const getMessageContainerTriggerPosition = (): PopoverTriggerPosition | null => { + if (!ref.current) { + return null; + } + const rect = ref.current.getBoundingClientRect(); + const halfWidth = rect.width / 2; + return { + x: rect.left, + // NOTE: y needs to be clamped to the parent otherwise it can overflow the container + y: rect.top, + height: rect.height, + width: rect.width, + offsetX: direction === 'incoming' ? -halfWidth : halfWidth, + }; + }; + + const handleContextMenu = useCallback( + ( + e: MouseEvent | KeyboardEvent, + overridePosition?: { x: number; y: number } + ) => { + if (!selectedIsBlocked && !multiSelectMode && !isKickedFromGroup) { + showMessageContextMenu({ + id: ctxMenuID, + event: e, + triggerPosition: overridePosition, + }); + } + }, + [selectedIsBlocked, ctxMenuID, multiSelectMode, isKickedFromGroup] + ); + + const onKeyDown = (e: KeyboardEvent) => { + if (isButtonClickKey(e)) { + if (e.target instanceof HTMLElement && e.target.tagName === 'BUTTON') { + // If the target is a button, we don't want to open the context menu as this is + // handled by the button itself + return; + } + const overrideTriggerPosition = getMessageContainerTriggerPosition(); + if (overrideTriggerPosition) { + handleContextMenu(e, overrideTriggerPosition); + } + } + }; + + const toggleEmojiReactionBarWithKeyboard = () => { + if (triggerPosition) { + closeContextMenus(); + setTriggerPosition(null); + } else { + const pos = getMessageContainerTriggerPosition(); + if (pos) { + setTriggerPosition(pos); + } + } + }; + + const onClick = useCallback( + (event: MouseEvent) => { + if (multiSelectMode && messageId) { + event.preventDefault(); + event.stopPropagation(); + dispatch(toggleSelectedMessageId(messageId)); + } + }, + [dispatch, messageId, multiSelectMode] + ); + + const onDoubleClickCapture = reply + ? (e: MouseEvent) => { + if (multiSelectMode) { + return; + } + const currentSelection = window.getSelection(); + const currentSelectionString = currentSelection?.toString() || undefined; + + if ( + (!currentSelectionString || trimWhitespace(currentSelectionString).length === 0) && + (e.target as any).localName !== 'em-emoji-picker' + ) { + e.preventDefault(); + void reply(); + } + } + : undefined; + + useKeyboardShortcut({ + shortcut: KbdShortcut.messageToggleReactionBar, + handler: toggleEmojiReactionBarWithKeyboard, + scopeId: messageId, + }); + + const messageType = useMessageType(messageId); + + useEffect(() => { + if (isAnotherMessageFocused) { + setTriggerPosition(null); + } + }, [isAnotherMessageFocused]); + + if (!convoId || !messageId || !messageType) { + return null; + } + + const selected = isMessageSelected || false; + + return ( + + ); +} diff --git a/ts/components/conversation/message/message-item/GenericReadableMessage.tsx b/ts/components/conversation/message/message-item/GenericReadableMessage.tsx index 8df437d8f..2c80f1d62 100644 --- a/ts/components/conversation/message/message-item/GenericReadableMessage.tsx +++ b/ts/components/conversation/message/message-item/GenericReadableMessage.tsx @@ -1,40 +1,12 @@ -import { - type MouseEvent, - type KeyboardEvent, - useCallback, - useRef, - useState, - useEffect, -} from 'react'; +import type { HTMLProps } from 'react'; import clsx from 'clsx'; - import styled, { keyframes } from 'styled-components'; -import { useIsDetailMessageView } from '../../../../contexts/isDetailViewContext'; import { MessageRenderingProps } from '../../../../models/messageType'; -import { - useMessageDirection, - useMessageSelected, - useMessageType, -} from '../../../../state/selectors'; +import { useMessageType } from '../../../../state/selectors'; import { MessageContentWithStatuses } from '../message-content/MessageContentWithStatus'; import { StyledMessageReactionsContainer } from '../message-content/MessageReactions'; -import { - useIsMessageSelectionMode, - useSelectedConversationKey, - useSelectedIsBlocked, - useSelectedIsKickedFromGroup, -} from '../../../../state/selectors/selectedConversation'; -import { isButtonClickKey, KbdShortcut } from '../../../../util/keyboardShortcuts'; -import { showMessageContextMenu } from '../message-content/MessageContextMenu'; -import { getAppDispatch } from '../../../../state/dispatch'; -import { - setFocusedMessageId, - toggleSelectedMessageId, - type UIMessageType, -} from '../../../../state/ducks/conversations'; -import { PopoverTriggerPosition } from '../../../SessionTooltip'; -import { useKeyboardShortcut } from '../../../../hooks/useKeyboardShortcut'; -import type { WithMessageId } from '../../../../session/types/with'; +import { type UIMessageType } from '../../../../state/ducks/conversations'; +import type { WithContextMenuId, WithMessageId } from '../../../../session/types/with'; import { CommunityInvitation } from './CommunityInvitation'; import { DataExtractionNotification } from './DataExtractionNotification'; import { TimerNotification } from '../../TimerNotification'; @@ -42,10 +14,8 @@ import { GroupUpdateMessage } from './GroupUpdateMessage'; import { CallNotification } from './notification-bubble/CallNotification'; import { InteractionNotification } from './InteractionNotification'; import { MessageRequestResponse } from './MessageRequestResponse'; -import { useFocusScope, useIsInScope } from '../../../../state/focus'; -import { closeContextMenus } from '../../../../util/contextMenu'; -import { trimWhitespace } from '../../../../session/utils/String'; -import { useMessageReply } from '../../../../hooks/useMessageInteractions'; +import { WithReactionBarOptions } from '../../SessionEmojiReactBarPopover'; +import { useIsDetailMessageView } from '../../../../contexts/isDetailViewContext'; export type GenericReadableMessageSelectorProps = Pick< MessageRenderingProps, @@ -56,11 +26,14 @@ const highlightedMessageAnimation = keyframes` 1% { background-color: var(--primary-color); } `; -const StyledReadableMessage = styled.div<{ - selected: boolean; - $isDetailView: boolean; - $focusedKeyboard: boolean; -}>` +type StyledReadableMessageProps = { + selected?: boolean; + // TODO: remove this, we can add styles to the message list + $isDetailView?: boolean; + $focusedKeyboard?: boolean; +}; + +const StyledReadableMessage = styled.div` display: flex; align-items: center; width: 100%; @@ -106,154 +79,29 @@ function getMessageComponent(messageType: UIMessageType) { } } -export const GenericReadableMessage = ({ messageId }: WithMessageId) => { - const isDetailView = useIsDetailMessageView(); - const dispatch = getAppDispatch(); - - const ctxMenuID = `ctx-menu-message-${messageId}`; - - const isMessageSelected = useMessageSelected(messageId); - const selectedIsBlocked = useSelectedIsBlocked(); - const multiSelectMode = useIsMessageSelectionMode(); - - const convoId = useSelectedConversationKey(); - const direction = useMessageDirection(messageId); - const isKickedFromGroup = useSelectedIsKickedFromGroup(); - - const ref = useRef(null); - const pointerDownRef = useRef(false); - const [triggerPosition, setTriggerPosition] = useState(null); - const isInFocusScope = useIsInScope({ scope: 'message', scopeId: messageId }); - const { focusedMessageId } = useFocusScope(); - const isAnotherMessageFocused = focusedMessageId && !isInFocusScope; - - const reply = useMessageReply(messageId); - const focusMessageId = () => { - dispatch(setFocusedMessageId(messageId)); - }; - - const onFocus = () => { - window.log.warn('potato onFocus'); - focusMessageId(); - }; - - const onBlur = () => { - window.log.warn('potato onBlur'); - dispatch(setFocusedMessageId(null)); - pointerDownRef.current = false; - }; - - const onPointerDown = () => { - pointerDownRef.current = true; - window.log.warn('potato onPointerDown'); - }; - - const getMessageContainerTriggerPosition = (): PopoverTriggerPosition | null => { - if (!ref.current) { - return null; - } - const rect = ref.current.getBoundingClientRect(); - const halfWidth = rect.width / 2; - return { - x: rect.left, - // NOTE: y needs to be clamped to the parent otherwise it can overflow the container - y: rect.top, - height: rect.height, - width: rect.width, - offsetX: direction === 'incoming' ? -halfWidth : halfWidth, - }; - }; - - const handleContextMenu = useCallback( - ( - e: MouseEvent | KeyboardEvent, - overridePosition?: { x: number; y: number } - ) => { - if (!selectedIsBlocked && !multiSelectMode && !isKickedFromGroup) { - showMessageContextMenu({ - id: ctxMenuID, - event: e, - triggerPosition: overridePosition, - }); - } - }, - [selectedIsBlocked, ctxMenuID, multiSelectMode, isKickedFromGroup] - ); - - const onKeyDown = (e: KeyboardEvent) => { - if (isButtonClickKey(e)) { - if (e.target instanceof HTMLElement && e.target.tagName === 'BUTTON') { - // If the target is a button, we don't want to open the context menu as this is - // handled by the button itself - return; - } - const overrideTriggerPosition = getMessageContainerTriggerPosition(); - if (overrideTriggerPosition) { - handleContextMenu(e, overrideTriggerPosition); - } - } - }; - - const toggleEmojiReactionBarWithKeyboard = () => { - if (triggerPosition) { - closeContextMenus(); - setTriggerPosition(null); - } else { - const pos = getMessageContainerTriggerPosition(); - if (pos) { - setTriggerPosition(pos); - } - } - }; - - const onClick = useCallback( - (event: MouseEvent) => { - if (multiSelectMode && messageId) { - event.preventDefault(); - event.stopPropagation(); - dispatch(toggleSelectedMessageId(messageId)); - } - }, - [dispatch, messageId, multiSelectMode] - ); - - const onDoubleClickCapture = reply - ? (e: MouseEvent) => { - if (multiSelectMode) { - return; - } - const currentSelection = window.getSelection(); - const currentSelectionString = currentSelection?.toString() || undefined; - - if ( - (!currentSelectionString || trimWhitespace(currentSelectionString).length === 0) && - (e.target as any).localName !== 'em-emoji-picker' - ) { - e.preventDefault(); - void reply(); - } - } - : undefined; - - useKeyboardShortcut({ - shortcut: KbdShortcut.messageToggleReactionBar, - handler: toggleEmojiReactionBarWithKeyboard, - scopeId: messageId, - }); +type GenericReadableMessageProps = Partial< + HTMLProps & + Omit & + WithMessageId & + WithContextMenuId & + WithReactionBarOptions +>; +export const GenericReadableMessage = ({ + ref, + messageId, + selected, + contextMenuId, + reactionBarOptions, + ...rest +}: GenericReadableMessageProps) => { const messageType = useMessageType(messageId); + const isDetailView = useIsDetailMessageView(); - useEffect(() => { - if (isAnotherMessageFocused) { - setTriggerPosition(null); - } - }, [isAnotherMessageFocused]); - - if (!convoId || !messageId || !messageType) { + if (!messageId || !messageType) { return null; } - const selected = isMessageSelected || false; const CmpToRender = getMessageComponent(messageType); if (!CmpToRender) { @@ -263,24 +111,16 @@ export const GenericReadableMessage = ({ messageId }: WithMessageId) => { return ( ); From d02b0d7a16f0b37ca3e2e9e81b6be5e5250ff5ac Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Fri, 27 Feb 2026 16:26:23 +1100 Subject: [PATCH 13/21] fix: track message deleted state as int instead of bool --- .../media-gallery/types/Message.ts | 8 - .../message-content/MessageContent.tsx | 57 ++++--- .../message/message-content/MessageText.tsx | 19 ++- .../useDeleteMessagesCb.tsx | 159 +++++++++++++----- .../deleteMessagesFromSwarmOnly.ts | 68 ++++++-- .../deleteMessagesLocallyOnly.ts | 2 +- .../conversations/unsendingInteractions.ts | 8 +- ts/models/message.ts | 24 ++- ts/models/messageType.ts | 21 ++- ts/node/migration/sessionMigrations.ts | 29 ++++ ts/receiver/groupv2/handleGroupV2Message.ts | 6 +- ts/session/utils/Toast.tsx | 4 + ts/state/ducks/conversations.ts | 10 +- ts/state/selectors/conversations.ts | 34 +--- ts/state/selectors/messages.ts | 7 +- 15 files changed, 293 insertions(+), 163 deletions(-) delete mode 100644 ts/components/conversation/media-gallery/types/Message.ts diff --git a/ts/components/conversation/media-gallery/types/Message.ts b/ts/components/conversation/media-gallery/types/Message.ts deleted file mode 100644 index 954987d2e..000000000 --- a/ts/components/conversation/media-gallery/types/Message.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Attachment } from '../../../../types/Attachment'; - -export type Message = { - id: string; - attachments: Array; - received_at: number; - sent_at: number; -}; diff --git a/ts/components/conversation/message/message-content/MessageContent.tsx b/ts/components/conversation/message/message-content/MessageContent.tsx index 2a376b555..30af64226 100644 --- a/ts/components/conversation/message/message-content/MessageContent.tsx +++ b/ts/components/conversation/message/message-content/MessageContent.tsx @@ -9,14 +9,18 @@ import { useScrollToLoadedMessage } from '../../../../contexts/ScrollToLoadedMes import { useIsDetailMessageView } from '../../../../contexts/isDetailViewContext'; import { IsMessageVisibleContext } from '../../../../contexts/isMessageVisibleContext'; import { MessageModelType, MessageRenderingProps } from '../../../../models/messageType'; -import { StateType } from '../../../../state/reducer'; import { useHideAvatarInMsgList, + useMessageDirection, useMessageIsDeleted, + useMessageLinkPreview, + useMessageQuote, useMessageSelected, + useMessageServerTimestamp, + useMessageText, + useMessageTimestamp, } from '../../../../state/selectors'; import { - getMessageContentSelectorProps, getQuotedMessageToAnimate, getShouldHighlightMessage, } from '../../../../state/selectors/conversations'; @@ -70,10 +74,15 @@ export const MessageContent = (props: Props) => { const [highlight, setHighlight] = useState(false); const [didScroll, setDidScroll] = useState(false); - const contentProps = useSelector((state: StateType) => - getMessageContentSelectorProps(state, props.messageId) - ); + + const direction = useMessageDirection(props.messageId); + + const previews = useMessageLinkPreview(props.messageId); + const quote = useMessageQuote(props.messageId); + const text = useMessageText(props.messageId); const isDeleted = useMessageIsDeleted(props.messageId); + const serverTimestamp = useMessageServerTimestamp(props.messageId); + const timestamp = useMessageTimestamp(props.messageId); const [isMessageVisible, setMessageIsVisible] = useState(false); const scrollToLoadedMessage = useScrollToLoadedMessage(); @@ -128,15 +137,14 @@ export const MessageContent = (props: Props) => { shouldHighlightMessage, ]); - const toolTipTitle = useFormatFullDate(contentProps?.serverTimestamp || contentProps?.timestamp); + const toolTipTitle = useFormatFullDate(serverTimestamp || timestamp); - if (!contentProps) { + if (!props.messageId || !direction) { return null; } - const { direction, text, previews, quote } = contentProps; - - const hasContentBeforeAttachment = !isEmpty(previews) || !isEmpty(quote) || !isEmpty(text); + const hasContentBeforeAttachment = + !isEmpty(previews) || !isEmpty(quote) || !isEmpty(text) || !!isDeleted; return ( { $highlight={highlight} $selected={selected} > - {!isDeleted && ( - <> - - - - )} + + + )} - {!isDeleted ? ( - - ) : null} + diff --git a/ts/components/conversation/message/message-content/MessageText.tsx b/ts/components/conversation/message/message-content/MessageText.tsx index 2dda26ab1..cb675ebd5 100644 --- a/ts/components/conversation/message/message-content/MessageText.tsx +++ b/ts/components/conversation/message/message-content/MessageText.tsx @@ -16,6 +16,8 @@ import type { WithMessageId } from '../../../../session/types/with'; import { LucideIcon } from '../../../icon/LucideIcon'; import { LUCIDE_ICONS_UNICODE } from '../../../icon/lucide'; import { MessageBubble } from './MessageBubble'; +import { MessageDeletedType } from '../../../../models/messageType'; +import { tr } from '../../../../localization'; type Props = WithMessageId; @@ -37,15 +39,8 @@ export const MessageText = ({ messageId }: Props) => { const text = useMessageText(messageId); const isOpenOrClosedGroup = useSelectedIsGroupOrCommunity(); const isPublic = useSelectedIsPublic(); - // Note the body is overridden with `deleteMessageDeletedLocally` or `deleteMessageDeletedGlobally` - // depending on the `isDeleted` value, when the message is marked as deleted - const contents = text?.trim(); - if (!contents) { - return null; - } - const iconColor = direction === 'incoming' ? 'var(--message-bubble-incoming-text-color)' @@ -60,11 +55,19 @@ export const MessageText = ({ messageId }: Props) => { iconColor={iconColor} style={{ padding: '0 var(--margins-xs)' }} /> - {contents} + {isDeleted === MessageDeletedType.deletedGlobally + ? tr('deleteMessageDeletedGlobally') + : isDeleted === MessageDeletedType.deletedLocally + ? tr('deleteMessageDeletedLocally') + : null} ); } + if (!contents) { + return null; + } + return ( diff --git a/ts/components/menuAndSettingsHooks/useDeleteMessagesCb.tsx b/ts/components/menuAndSettingsHooks/useDeleteMessagesCb.tsx index 55bdb7f77..811dc10f4 100644 --- a/ts/components/menuAndSettingsHooks/useDeleteMessagesCb.tsx +++ b/ts/components/menuAndSettingsHooks/useDeleteMessagesCb.tsx @@ -1,8 +1,13 @@ import type { PubkeyType, GroupPubkeyType } from 'libsession_util_nodejs'; -import { compact, isArray } from 'lodash'; +import { compact, isArray, isEmpty } from 'lodash'; import { useDispatch } from 'react-redux'; import { updateConfirmModal } from '../../state/ducks/modalDialog'; -import { useIsMe, useIsPublic, useWeAreAdmin } from '../../hooks/useParamSelector'; +import { + useIsLegacyGroup, + useIsMe, + useIsPublic, + useWeAreAdmin, +} from '../../hooks/useParamSelector'; import { SessionButtonColor } from '../basic/SessionButton'; import { closeRightPanel, resetSelectedMessageIds } from '../../state/ducks/conversations'; import { tr, type TrArgs } from '../../localization/localeTools'; @@ -14,10 +19,7 @@ import { ToastUtils } from '../../session/utils'; import { UserGroupsWrapperActions } from '../../webworker/workers/browser/libsession_worker_interface'; import { Data } from '../../data/data'; import { MessageQueue } from '../../session/sending'; -import { - deleteMessagesFromSwarmAndCompletelyLocally, - deleteMessagesFromSwarmAndMarkAsDeletedLocally, -} from '../../interactions/conversations/unsendingInteractions'; + import { deleteSogsMessageByServerIds } from '../../session/apis/open_group_api/sogsv3/sogsV3DeleteMessages'; import { SnodeNamespaces } from '../../session/apis/snode_api/namespaces'; import { getSodiumRenderer } from '../../session/crypto'; @@ -30,6 +32,7 @@ import { ConvoHub } from '../../session/conversations'; import { uuidV4 } from '../../util/uuid'; import { isUsAnySogsFromCache } from '../../session/apis/open_group_api/sogsv3/knownBlindedkeys'; import type { RadioOptions } from '../dialog/SessionConfirm'; +import { deleteMessagesFromSwarmOnly } from '../../interactions/conversations/deleteMessagesFromSwarmOnly'; const deleteMessageDeviceOnly = 'deleteMessageDeviceOnly'; const deleteMessageAllMyDevices = 'deleteMessageDevicesAll'; @@ -47,10 +50,11 @@ type MessageDeletionType = export function useDeleteMessagesCb(conversationId: string | undefined) { const dispatch = useDispatch(); - const isMe = useIsMe(conversationId); + const isNts = useIsMe(conversationId); const isPublic = useIsPublic(conversationId); const weAreAdminOrModCommunity = useWeAreCommunityAdminOrModerator(conversationId); const weAreAdminGroup = useWeAreAdmin(conversationId); + const isLegacyGroup = useIsLegacyGroup(conversationId); const closeDialog = () => dispatch(updateConfirmModal(null)); @@ -66,32 +70,43 @@ export function useDeleteMessagesCb(conversationId: string | undefined) { } const messageIdsArr = isArray(messageIds) ? messageIds : [messageIds]; + // legacy groups are read only, we can only delete locally. const canDeleteAllForEveryoneAsAdmin = - (isPublic && weAreAdminOrModCommunity) || (!isPublic && weAreAdminGroup); + !isLegacyGroup && ((isPublic && weAreAdminOrModCommunity) || (!isPublic && weAreAdminGroup)); const msgModels = await Data.getMessagesById(messageIdsArr); const senders = compact(msgModels.map(m => m.getSource())); - const anyAreMarkAsDeleted = msgModels.some(m => m.get('isDeleted')); + const anyAreMarkAsDeleted = msgModels.some(m => m.isMarkedAsDeleted()); const anyAreControlMessages = msgModels.some(m => m.isControlMessage()); + const anyAreSending = msgModels.some(m => m.getMessagePropStatus() === 'sending'); + const anyAreErrors = msgModels.some(m => m.getMessagePropStatus() === 'error'); + + // We can never delete for everyone if one of the message is + // - a control message + // - a message marked as deleted + // - a message that is sending + // - a message that failed to be sent. + // In this case, the only option is to delete locally + const sharedCannotDeleteForEveryone = + anyAreControlMessages || anyAreMarkAsDeleted || anyAreSending || anyAreErrors; const canDeleteAllForEveryoneAsMe = senders.every(isUsAnySogsFromCache); const canDeleteAllForEveryone = (canDeleteAllForEveryoneAsMe || canDeleteAllForEveryoneAsAdmin) && - !anyAreControlMessages && - !anyAreMarkAsDeleted; + !sharedCannotDeleteForEveryone; + + const canDeleteFromAllDevices = isNts && !sharedCannotDeleteForEveryone; // Note: the isMe case has no radio buttons, so we just show the description below - const i18nMessage: TrArgs | undefined = isMe + const i18nMessage: TrArgs | undefined = isNts ? { token: 'deleteMessageConfirm', count } : undefined; - const canDeleteFromAllDevices = isMe && !anyAreControlMessages && !anyAreMarkAsDeleted; - const warningMessage: TrArgs | undefined = - isMe && !canDeleteFromAllDevices + isNts && !canDeleteFromAllDevices ? { token: 'deleteMessageNoteToSelfWarning', count } - : !isMe && !canDeleteAllForEveryone + : !isNts && !canDeleteAllForEveryone ? { token: 'deleteMessageWarning', count, @@ -107,7 +122,7 @@ export function useDeleteMessagesCb(conversationId: string | undefined) { labelDataTestId: `label-${deleteMessageDeviceOnly}` as const, disabled: false, // we can always delete messages locally }, - isMe + isNts ? { label: tr(deleteMessageAllMyDevices), value: deleteMessageAllMyDevices, @@ -123,7 +138,7 @@ export function useDeleteMessagesCb(conversationId: string | undefined) { disabled: !canDeleteAllForEveryone, }, ], - defaultSelectedValue: !isMe && canDeleteAllForEveryone ? deleteMessageEveryone : undefined, + defaultSelectedValue: !isNts && canDeleteAllForEveryone ? deleteMessageEveryone : undefined, }; dispatch( @@ -175,10 +190,15 @@ async function doDeleteSelectedMessages({ selectedMessages, deletionType, }: { - selectedMessages: Array; conversation: ConversationModel; + selectedMessages: Array; deletionType: MessageDeletionType; }) { + if (selectedMessages.length === 0) { + window.log.info('doDeleteSelectedMessages: no messages selected'); + return true; + } + // legacy groups are read only if (conversation.isClosedGroup() && PubKey.is05Pubkey(conversation.id)) { window.log.info( @@ -230,13 +250,18 @@ async function doDeleteSelectedMessages({ return deletedFromSogs; } - // Note: a groupv2 member can delete messages for everyone if they are the admin, or if that message is theirs. + // sanity check that this is the last available option if (deletionType !== deleteMessageEveryone) { throw new Error(`doDeleteSelectedMessages: invalid deletionType: "${deletionType}"`); } if (conversation.isPrivate()) { - // Note: we cannot delete for everyone a message in non 05-private chat + if (conversation.isMe()) { + throw new Error( + 'the NTS case should have been deleteMessageDeviceOnly or deleteMessageAllMyDevices' + ); + } + // Note: we cannot delete for everyone a message in a non 05-private chat if (!PubKey.is05Pubkey(conversation.id)) { throw new Error('unsendMessagesForEveryone1o1 requires a 05 key'); } @@ -244,19 +269,24 @@ async function doDeleteSelectedMessages({ // build the unsendMsgObjects before we delete the hash from those messages const unsendMsgObjects = getUnsendMessagesObjects1o1(conversation, selectedMessages); - // private chats: we want to mark those messages as deleted - const deletedFromSwarmAndLocally = await deleteMessagesFromSwarmAndMarkAsDeletedLocally( - conversation, - selectedMessages - ); - if (!deletedFromSwarmAndLocally) { + // Note: not calling deleteMessagesFromSwarmAndMarkAsDeletedLocally here as + // we've got some custom logic going on + const deletedFromSwarm = await deleteMessagesFromSwarmOnly(conversation, selectedMessages); + if (!deletedFromSwarm) { window.log.warn( - 'unsendMessagesForEveryone1o1: failed to delete from swarm and locally. Not sending unsend requests' + 'unsendMessagesForEveryone1o1: failed to delete from swarm. Not sending unsend requests' ); - ToastUtils.pushGenericError(); + ToastUtils.pushFailedToDelete(selectedMessages.length); return false; } + await deleteMessagesLocallyOnly({ + conversation, + messages: selectedMessages, + deletionType: 'markDeleted', + }); + await unsendMessagesForEveryone1o1(conversation, unsendMsgObjects); + ToastUtils.pushDeleted(selectedMessages.length); window.inboxStore?.dispatch(resetSelectedMessageIds()); @@ -268,22 +298,52 @@ async function doDeleteSelectedMessages({ throw new Error('doDeleteSelectedMessages: invalid conversation type'); } - const groupv2UnsendSent = await unsendMessagesForEveryoneGroupV2({ + const weAreAdmin = await hasGroupAdminKey(conversation.id); + // 03 groups: mark as deleted + if (weAreAdmin) { + // when we are an admin, we first delete the messages from the swarm + // Note: not calling deleteMessagesFromSwarmAndMarkAsDeletedLocally here as + // we've got some custom logic going on + const deletedFromGroupSwarm = await deleteMessagesFromSwarmOnly(conversation, selectedMessages); + if (!deletedFromGroupSwarm) { + window.log.warn( + 'unsendMessagesForEveryone1o1: failed to delete from group swarm. Not sending unsend requests' + ); + ToastUtils.pushFailedToDelete(selectedMessages.length); + + return false; + } + + if (!deletedFromGroupSwarm) { + window.log.warn( + 'unsendMessagesForEveryoneGroupV2: failed to delete messages on group swarm:' + ); + return false; + } + } + + // Here, either we've removed those messages from the swarm as an admin, + // or we want to request the admin to delete them for us. + // Those messages have to be ours in this case. + + const groupV2UnsendSent = await unsendMessagesForEveryoneGroupV2({ groupPk: conversation.id, msgsToDelete: selectedMessages, allMessagesFrom: [], // currently we cannot remove all the messages from a specific pubkey but we do already handle them on the receiving side }); - if (!groupv2UnsendSent) { + if (!groupV2UnsendSent) { window.log.warn( 'unsendMessagesForEveryoneGroupV2: failed to send our groupv2 unsend for everyone' ); - ToastUtils.pushGenericError(); + ToastUtils.pushFailedToDelete(selectedMessages.length); return false; } - // 03 groups: mark as deleted - await deleteMessagesFromSwarmAndMarkAsDeletedLocally(conversation, selectedMessages); - + await deleteMessagesLocallyOnly({ + conversation, + messages: selectedMessages, + deletionType: 'markDeleted', + }); window.inboxStore?.dispatch(resetSelectedMessageIds()); ToastUtils.pushDeleted(selectedMessages.length); @@ -292,7 +352,7 @@ async function doDeleteSelectedMessages({ /** * Delete those message hashes from our swarm. - * On success, send an UnsendMessage synced message so our devices removes those messages locally, + * On success, send an UnsendMessage synced message so our devices removes those already fetched messages. * Then, deletes completely the messages locally. * * Shows a toast on error/success and reset the selection @@ -307,19 +367,25 @@ async function unsendMessageJustForThisUserAllDevices( // get the unsendMsgObjects before we delete the hash from those messages const unsendMsgObjects = getUnsendMessagesObjects1o1(conversation, msgsToDeleteFromSwarm); + console.warn('unsendMsgObjects', unsendMsgObjects); + + // Note: not calling deleteMessagesFromSwarmAndCompletelyLocally here as + // we've got some custom logic going on + const deletedFromSwarm = await deleteMessagesFromSwarmOnly(conversation, msgsToDelete); - const deletedFromSwarm = await deleteMessagesFromSwarmAndCompletelyLocally( - conversation, - msgsToDelete - ); // we want to locally only when we've manage to delete them from the swarm first if (!deletedFromSwarm) { window.log.warn( 'unsendMessageJustForThisUserAllDevices: failed to delete from swarm. Not sending unsend requests' ); - ToastUtils.pushGenericError(); + ToastUtils.pushFailedToDelete(msgsToDelete.length); return false; } + await deleteMessagesLocallyOnly({ + conversation, + messages: msgsToDelete, + deletionType: 'complete', + }); // deleting from the swarm worked, sending to our other devices all the messages separately for now await Promise.all( @@ -338,10 +404,10 @@ async function unsendMessageJustForThisUserAllDevices( /** * Attempt to delete the messages from the SOGS. * Note: this function does not check if the user is allowed to delete the messages. - * The call will just fail if the user is not allowed to delete the messages, silently, so make - * sure to check the user permissions before calling this function and to display only valid actions for the user's permissions. + * The call will just fail if the user is not allowed to delete the messages. + * So make sure to check the user permissions before calling this function and to display only valid actions for the user's permissions. * - * Returns true if those messages could be removed from the SOGS and were removed locally + * Returns true if those messages could be removed from the SOGS and were removed locally. */ async function doDeleteSelectedMessagesInSOGS( selectedMessages: Array, @@ -441,6 +507,11 @@ async function unsendMessagesForEveryone1o1( ); } +async function hasGroupAdminKey(groupPk: GroupPubkeyType) { + const group = await UserGroupsWrapperActions.getGroup(groupPk); + return !!isEmpty(group?.secretKey); +} + async function unsendMessagesForEveryoneGroupV2({ allMessagesFrom, groupPk, diff --git a/ts/interactions/conversations/deleteMessagesFromSwarmOnly.ts b/ts/interactions/conversations/deleteMessagesFromSwarmOnly.ts index fcfec5185..4548e6156 100644 --- a/ts/interactions/conversations/deleteMessagesFromSwarmOnly.ts +++ b/ts/interactions/conversations/deleteMessagesFromSwarmOnly.ts @@ -1,10 +1,11 @@ -import type { PubkeyType, GroupPubkeyType } from 'libsession_util_nodejs'; -import { compact, isEmpty } from 'lodash'; +import { compact } from 'lodash'; import type { MessageModel } from '../../models/message'; import { SnodeAPI } from '../../session/apis/snode_api/SNodeAPI'; import { PubKey } from '../../session/types'; import { ed25519Str } from '../../session/utils/String'; import { isStringArray } from '../../types/isStringArray'; +import type { ConversationModel } from '../../models/conversation'; +import { UserUtils } from '../../session/utils'; /** * Do a single request to the swarm with all the message hashes to delete from the swarm. @@ -14,34 +15,67 @@ import { isStringArray } from '../../types/isStringArray'; * Returns true if no errors happened, false in an error happened */ export async function deleteMessagesFromSwarmOnly( - messages: Array | Array, - pubkey: PubkeyType | GroupPubkeyType + conversation: ConversationModel, + messages: Array | Array ) { - const deletionMessageHashes = isStringArray(messages) + const us = UserUtils.getOurPubKeyStrFromCache(); + + // legacy groups are deprecated + if (conversation.isClosedGroup() && PubKey.is05Pubkey(conversation.id)) { + throw new Error('legacy groups are deprecated. Not deleting anything'); + } + if (conversation.isPrivateAndBlinded()) { + throw new Error(`deleteMessagesFromSwarmOnly does not support blinded conversations`); + } + + const pubkeyToDeleteFrom = PubKey.is03Pubkey(conversation.id) ? conversation.id : us; + if (!PubKey.is03Pubkey(pubkeyToDeleteFrom) && !PubKey.is05Pubkey(pubkeyToDeleteFrom)) { + throw new Error(`deleteMessagesFromSwarmOnly needs a 03 or 05 pk`); + } + if (PubKey.is05Pubkey(pubkeyToDeleteFrom) && pubkeyToDeleteFrom !== us) { + throw new Error(`deleteMessagesFromSwarmOnly with 05 pk can only delete for ourself`); + } + + const hashesToDelete = isStringArray(messages) ? messages : compact(messages.map(m => m.getMessageHash())); try { - if (isEmpty(messages)) { - return false; + // Legacy groups are handled above + // We are deleting from a swarm, so this is not a sogs. + // This means that the target here can only be the 03 group pubkey, or our own swarm pubkey. + + // If this is a private chat, we can only delete messages on our own swarm only, so use our "side" of the conversation + if (!hashesToDelete.length) { + window.log?.warn('deleteMessagesFromSwarmOnly: no hashes to delete'); + return true; } - if (!deletionMessageHashes.length) { - window.log?.warn( - 'deleteMessagesFromSwarmOnly: We do not have hashes for some of those messages' - ); - return false; + window.log.debug( + `deleteMessagesFromSwarmOnly: Deleting from swarm of ${ed25519Str(pubkeyToDeleteFrom)}, hashes: ${hashesToDelete}` + ); + + const hashesAsSet = new Set(hashesToDelete); + if (PubKey.is03Pubkey(pubkeyToDeleteFrom)) { + return await SnodeAPI.networkDeleteMessagesForGroup(hashesAsSet, pubkeyToDeleteFrom); } - const hashesAsSet = new Set(deletionMessageHashes); - if (PubKey.is03Pubkey(pubkey)) { - return await SnodeAPI.networkDeleteMessagesForGroup(hashesAsSet, pubkey); + const deletedFromSwarm = await SnodeAPI.networkDeleteMessageOurSwarm( + hashesAsSet, + pubkeyToDeleteFrom + ); + if (!deletedFromSwarm) { + window.log.warn( + `deleteMessagesFromSwarmOnly: some messages failed to be deleted from swarm of ${ed25519Str(pubkeyToDeleteFrom)}. Maybe they were already deleted?` + ); } - return await SnodeAPI.networkDeleteMessageOurSwarm(hashesAsSet, pubkey); + + return deletedFromSwarm; } catch (e) { window.log?.error( - `deleteMessagesFromSwarmOnly: Error deleting message from swarm of ${ed25519Str(pubkey)}, hashes: ${deletionMessageHashes}`, + `deleteMessagesFromSwarmOnly: Error deleting message from swarm of ${ed25519Str(pubkeyToDeleteFrom)}, hashesToDelete length: ${hashesToDelete.length}`, e ); + window.log.debug('deleteMessagesFromSwarmOnly: hashesToDelete', hashesToDelete); return false; } } diff --git a/ts/interactions/conversations/deleteMessagesLocallyOnly.ts b/ts/interactions/conversations/deleteMessagesLocallyOnly.ts index a983e0dab..9ffac8f70 100644 --- a/ts/interactions/conversations/deleteMessagesLocallyOnly.ts +++ b/ts/interactions/conversations/deleteMessagesLocallyOnly.ts @@ -18,7 +18,7 @@ export async function deleteMessagesLocallyOnly({ for (let index = 0; index < messages.length; index++) { const message = messages[index]; // a control message or a message deleted is forcefully removed from the DB - if (deletionType === 'complete' || message.isControlMessage() || message.get('isDeleted')) { + if (deletionType === 'complete' || message.isControlMessage() || message.isMarkedAsDeleted()) { await conversation.removeMessage(message.id); } else { // just mark the message as deleted but still show in conversation diff --git a/ts/interactions/conversations/unsendingInteractions.ts b/ts/interactions/conversations/unsendingInteractions.ts index 29d2fd337..481168d98 100644 --- a/ts/interactions/conversations/unsendingInteractions.ts +++ b/ts/interactions/conversations/unsendingInteractions.ts @@ -6,6 +6,7 @@ import { ed25519Str } from '../../session/utils/String'; import { deleteMessagesLocallyOnly } from './deleteMessagesLocallyOnly'; import { deleteMessagesFromSwarmOnly } from './deleteMessagesFromSwarmOnly'; +import { ConvoHub } from '../../session/conversations'; /** * Delete the messages (with a valid hash) from the swarm and completely delete the messages locally. @@ -73,7 +74,12 @@ async function deleteMessagesFromSwarmShared( `deleteMessagesFromSwarmShared ${deletionType}: Deleting from swarm of ${ed25519Str(pubkeyToDeleteFrom)}, hashes: ${messages.map(m => m.getMessageHash())}` ); - const deletedFromSwarm = await deleteMessagesFromSwarmOnly(messages, pubkeyToDeleteFrom); + const convo = ConvoHub.use().get(conversation.id); + if (!convo) { + throw new Error(`deleteMessagesFromSwarmShared ${deletionType} convo not found`); + } + + const deletedFromSwarm = await deleteMessagesFromSwarmOnly(convo, messages); if (!deletedFromSwarm) { window.log.warn( `deleteMessagesFromSwarmShared ${deletionType}: some messages failed to be deleted from swarm of ${ed25519Str(pubkeyToDeleteFrom)}. Maybe they were already deleted?` diff --git a/ts/models/message.ts b/ts/models/message.ts index c1f0b0f05..59dc75244 100644 --- a/ts/models/message.ts +++ b/ts/models/message.ts @@ -27,6 +27,7 @@ import { import { MessageAttributes, MessageAttributesOptionals, + MessageDeletedType, MessageGroupUpdate, fillMessageAttributesWithDefaults, type DataExtractionNotificationMsg, @@ -219,6 +220,13 @@ export class MessageModel extends Model { ); } + /** + * Returns true if the message is marked as deleted either globally or locally. + */ + public isMarkedAsDeleted() { + return !!this.get('isDeleted'); + } + public isIncoming() { return this.get('type') === 'incoming' || this.get('direction') === 'incoming'; } @@ -668,8 +676,9 @@ export class MessageModel extends Model { if (body) { props.text = body; } - if (this.get('isDeleted')) { - props.isDeleted = !!this.get('isDeleted'); + const isDeleted = this.get('isDeleted'); + if (isDeleted) { + props.isDeleted = isDeleted; } if (this.getMessageHash()) { @@ -934,15 +943,14 @@ export class MessageModel extends Model { } /** - * Marks the message as deleted to show the author has deleted this message for everyone. - * Sets isDeleted property to true. Set message body text to deletion placeholder for conversation list items. + * Marks the message as deleted locally or globally. */ public async markAsDeleted(deletedLocallyOnly: boolean) { this.set({ - isDeleted: true, - body: deletedLocallyOnly - ? tr('deleteMessageDeletedLocally') - : tr('deleteMessageDeletedGlobally'), + isDeleted: deletedLocallyOnly + ? MessageDeletedType.deletedLocally + : MessageDeletedType.deletedGlobally, + body: '', quote: undefined, groupInvitation: undefined, dataExtractionNotification: undefined, diff --git a/ts/models/messageType.ts b/ts/models/messageType.ts index 39ba63ff8..91b27ac1b 100644 --- a/ts/models/messageType.ts +++ b/ts/models/messageType.ts @@ -97,7 +97,7 @@ type SharedMessageAttributes = { /** * This field is used for unsending messages and used in sending unsend message requests. */ - isDeleted?: boolean; + isDeleted?: MessageDeletedType; callNotificationType?: CallNotificationType; @@ -173,17 +173,26 @@ export type MessageAttributes = SharedMessageAttributes & NotSharedMessageAttrib export type MessageAttributesOptionals = SharedMessageAttributes & Partial; -export interface MessageRequestResponseMsg { - source: string; - isApproved: boolean; -} - export enum MessageDirection { outgoing = 'outgoing', incoming = 'incoming', any = '%', } +/** + * The types of deletion of a message are: + * - 0: the message is not deleted + * - 1: the message is deleted globally + * - 2: the message is deleted locally + * + * @see `MessageDeletedType` + */ +export enum MessageDeletedType { + notDeleted = 0, + deletedGlobally = 1, + deletedLocally = 2, +} + export type DataExtractionNotificationMsg = { type: SignalService.DataExtractionNotification.Type; }; diff --git a/ts/node/migration/sessionMigrations.ts b/ts/node/migration/sessionMigrations.ts index 447b1bf03..17687346a 100644 --- a/ts/node/migration/sessionMigrations.ts +++ b/ts/node/migration/sessionMigrations.ts @@ -39,6 +39,7 @@ import { hasDebugEnvVariable, } from './utils'; import { CONVERSATION_PRIORITIES } from '../../models/types'; +import { MessageDeletedType } from '../../models/messageType'; // eslint:disable: quotemark one-variable-per-declaration no-unused-expression @@ -127,6 +128,7 @@ const LOKI_SCHEMA_VERSIONS: Array<(currentVersion: number, db: Database) => void updateToSessionSchemaVersion51, updateToSessionSchemaVersion52, updateToSessionSchemaVersion53, + updateToSessionSchemaVersion54, ]; function updateToSessionSchemaVersion1(currentVersion: number, db: Database) { @@ -2367,6 +2369,33 @@ async function updateToSessionSchemaVersion53(currentVersion: number, db: Databa console.log(`updateToSessionSchemaVersion${targetVersion}: success!`); } +async function updateToSessionSchemaVersion54(currentVersion: number, db: Database) { + const targetVersion = 54; + if (currentVersion >= targetVersion) { + return; + } + console.log(`updateToSessionSchemaVersion${targetVersion}: starting...`); + + db.transaction(() => { + db.exec(`DROP INDEX IF EXISTS messages_isDeleted;`); + + db.exec(`ALTER TABLE ${MESSAGES_TABLE} RENAME COLUMN isDeleted TO isDeleted_old;`); + db.exec(`ALTER TABLE ${MESSAGES_TABLE} ADD COLUMN isDeleted INTEGER;`); + db.exec( + `UPDATE ${MESSAGES_TABLE} SET isDeleted = CASE WHEN isDeleted_old = ${toSqliteBoolean(true)} THEN ${MessageDeletedType.deletedGlobally} ELSE NULL END;` + ); + db.exec(`ALTER TABLE ${MESSAGES_TABLE} DROP COLUMN isDeleted_old;`); + + db.exec( + `CREATE INDEX messages_isDeleted ON ${MESSAGES_TABLE} (isDeleted) WHERE isDeleted IS NOT NULL;` + ); + + writeSessionSchemaVersion(targetVersion, db); + })(); + + console.log(`updateToSessionSchemaVersion${targetVersion}: success!`); +} + export function printTableColumns(table: string, db: Database) { console.info(db.pragma(`table_info('${table}');`)); } diff --git a/ts/receiver/groupv2/handleGroupV2Message.ts b/ts/receiver/groupv2/handleGroupV2Message.ts index f4ffe4e34..af94e962e 100644 --- a/ts/receiver/groupv2/handleGroupV2Message.ts +++ b/ts/receiver/groupv2/handleGroupV2Message.ts @@ -653,10 +653,8 @@ async function handle1o1GroupUpdateMessage( }); } if (details.messageHash && !isEmpty(details.messageHash)) { - const deleted = await deleteMessagesFromSwarmOnly( - [details.messageHash], - UserUtils.getOurPubKeyStrFromCache() - ); + const convo = ConvoHub.use().get(UserUtils.getOurPubKeyStrFromCache()); + const deleted = await deleteMessagesFromSwarmOnly(convo, [details.messageHash]); if (!deleted) { window.log.warn( `failed to delete invite/promote while processing it in handle1o1GroupUpdateMessage. hash:${details.messageHash}` diff --git a/ts/session/utils/Toast.tsx b/ts/session/utils/Toast.tsx index 5fb19478d..41381a048 100644 --- a/ts/session/utils/Toast.tsx +++ b/ts/session/utils/Toast.tsx @@ -175,6 +175,10 @@ export function pushDeleted(count: number) { pushToastSuccess('deleted', tStripped('deleteMessageDeleted', { count })); } +export function pushFailedToDelete(count: number) { + pushToastSuccess('failedToDelete', tStripped('deleteMessageFailed', { count })); +} + export function pushCannotRemoveGroupAdmin() { pushToastWarning('adminCannotBeRemoved', tStripped('adminCannotBeRemoved')); } diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 6f8fb695e..3e026b269 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -6,7 +6,11 @@ import { ReplyingToMessageProps } from '../../components/conversation/compositio import { Data } from '../../data/data'; import { ConversationNotificationSettingType } from '../../models/conversationAttributes'; -import { MessageModelType, PropsForDataExtractionNotification } from '../../models/messageType'; +import { + MessageModelType, + PropsForDataExtractionNotification, + type MessageDeletedType, +} from '../../models/messageType'; import { ConvoHub } from '../../session/conversations'; import { DisappearingMessages } from '../../session/disappearing_messages'; import { @@ -215,7 +219,7 @@ export type PropsForMessageWithoutConvoProps = { previews?: Array; quote?: Quote; messageHash?: string; - isDeleted?: boolean; + isDeleted?: MessageDeletedType; isUnread?: boolean; expirationType?: DisappearingMessageType; expirationDurationMs?: number; @@ -238,7 +242,7 @@ export type PropsForMessageWithConvoProps = PropsForMessageWithoutConvoProps & { weAreAdmin: boolean; isSenderAdmin: boolean; isBlocked: boolean; - isDeleted?: boolean; + isDeleted?: MessageDeletedType; }; /** diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 94866c5a8..232edd214 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -18,7 +18,6 @@ import { StateType } from '../reducer'; import { ReplyingToMessageProps } from '../../components/conversation/composition/CompositionBox'; import { MessageAttachmentSelectorProps } from '../../components/conversation/message/message-content/MessageAttachment'; -import { MessageContentSelectorProps } from '../../components/conversation/message/message-content/MessageContent'; import { hasValidIncomingRequestValues } from '../../models/conversation'; import { isOpenOrClosedGroup } from '../../models/conversationAttributes'; import { ConvoHub } from '../../session/conversations'; @@ -129,7 +128,6 @@ export const getSortedMessagesTypesOfSelectedConversation = createSelector( getSortedMessagesOfSelectedConversation, getFirstUnreadMessageId, (sortedMessages, firstUnreadId) => { - const maxMessagesBetweenTwoDateBreaks = 5; // we want to show the date break if there is a large jump in time // remember that messages are sorted from the most recent to the oldest return sortedMessages.map((msg, index) => { @@ -144,19 +142,13 @@ export const getSortedMessagesTypesOfSelectedConversation = createSelector( sortedMessages[index + 1].propsForMessage.timestamp; const showDateBreak = - messageTimestamp - previousMessageTimestamp > maxMessagesBetweenTwoDateBreaks * 60 * 1000 - ? messageTimestamp - : undefined; + messageTimestamp - previousMessageTimestamp > 5 * 60 * 1000 ? messageTimestamp : undefined; - const common = { + return { showUnreadIndicator: isFirstUnread, showDateBreak, messageId: msg.propsForMessage.id, }; - - return { - ...common, - }; }); } ); @@ -897,28 +889,6 @@ export const getIsMessageSelected = createSelector( } ); -export const getMessageContentSelectorProps = createSelector( - getMessagePropsByMessageId, - (props): MessageContentSelectorProps | undefined => { - if (!props || isEmpty(props)) { - return undefined; - } - - const msgProps: MessageContentSelectorProps = { - ...pick(props.propsForMessage, [ - 'direction', - 'serverTimestamp', - 'text', - 'timestamp', - 'previews', - 'quote', - 'attachments', - ]), - }; - - return msgProps; - } -); export const getOldTopMessageId = (state: StateType): string | null => state.conversations.oldTopMessageId || null; diff --git a/ts/state/selectors/messages.ts b/ts/state/selectors/messages.ts index 1d5dbfd16..7ce7bfdd8 100644 --- a/ts/state/selectors/messages.ts +++ b/ts/state/selectors/messages.ts @@ -73,9 +73,9 @@ export const useAuthorAvatarPath = (messageId: string): string | null => { return senderProps.avatarPath || null; }; -export const useMessageIsDeleted = (messageId: string): boolean => { +export const useMessageIsDeleted = (messageId: string) => { const props = useMessagePropsByMessageId(messageId); - return !!props?.propsForMessage.isDeleted || false; + return props?.propsForMessage.isDeleted; }; export const useFirstMessageOfSeries = (messageId: string | undefined): boolean => { @@ -101,8 +101,7 @@ export const useMessageDirection = ( }; export const useMessageLinkPreview = (messageId: string | undefined): Array | undefined => { - const previews = useMessagePropsByMessageId(messageId)?.propsForMessage.previews; - return previews; + return useMessagePropsByMessageId(messageId)?.propsForMessage.previews; }; export const useMessageAttachments = ( From b410b4217d533b412a47369b89821d432050361e Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Mon, 2 Mar 2026 16:56:57 +1100 Subject: [PATCH 14/21] fix: standardise message deletion logic --- ts/components/NoticeBanner.tsx | 4 +- ts/components/SessionToastContainer.tsx | 2 +- .../SessionQuotedMessageComposition.tsx | 11 ++- .../conversation/SessionStagedLinkPreview.tsx | 4 +- .../message-content/ClickToTrustSender.tsx | 7 +- .../message/message-content/MessageQuote.tsx | 2 + .../message/message-content/quote/Quote.tsx | 5 +- .../message-content/quote/QuoteText.tsx | 16 ++++- .../useDeleteMessagesCb.tsx | 41 ++++++----- ts/data/data.ts | 43 +++++++++--- .../deleteMessagesLocallyOnly.ts | 28 -------- .../conversations/unsendingInteractions.ts | 68 ++++++++----------- ts/models/conversation.ts | 8 ++- ts/models/message.ts | 44 ++++++++++-- ts/node/migration/sessionMigrations.ts | 2 +- ts/node/sql.ts | 25 +++++-- ts/receiver/contentMessage.ts | 30 +++++--- ts/receiver/groupv2/handleGroupV2Message.ts | 12 ++-- ts/session/apis/snode_api/SNodeAPI.ts | 1 + ts/session/types/with.ts | 10 ++- .../jobs/GroupPendingRemovalsJob.ts | 10 ++- ts/state/ducks/conversations.ts | 67 ++++++++++++++---- ts/state/selectors/conversations.ts | 5 +- ts/util/reactions.ts | 6 ++ 24 files changed, 295 insertions(+), 156 deletions(-) delete mode 100644 ts/interactions/conversations/deleteMessagesLocallyOnly.ts diff --git a/ts/components/NoticeBanner.tsx b/ts/components/NoticeBanner.tsx index 52fc09868..cb519593b 100644 --- a/ts/components/NoticeBanner.tsx +++ b/ts/components/NoticeBanner.tsx @@ -19,7 +19,7 @@ const StyledNoticeBanner = styled(Flex)<{ isClickable: boolean }>` } `; -const StyledText = styled.span` +const StyledBannerText = styled.span` margin-right: var(--margins-sm); `; @@ -48,7 +48,7 @@ export const NoticeBanner = (props: NoticeBannerProps) => { onBannerClick(); }} > - {text} + {text} {unicode ? ( ) : null} diff --git a/ts/components/SessionToastContainer.tsx b/ts/components/SessionToastContainer.tsx index 5f6794c54..49134b08d 100644 --- a/ts/components/SessionToastContainer.tsx +++ b/ts/components/SessionToastContainer.tsx @@ -41,7 +41,7 @@ export const SessionToastContainer = () => { return ( { return null; } - const contact = findAndFormatContact(author); - const { hasAttachments, firstImageLikeAttachment } = checkHasAttachments(attachments); const isImage = Boolean( firstImageLikeAttachment && @@ -152,20 +149,20 @@ export const SessionQuotedMessageComposition = () => { ) : null} )} - {subtitleText && {subtitleText}} - + ) : null} {!isLoading && data?.title ? ( - {data.title} + {data.title} ) : null} { onClickOk: async () => { convo.setIsTrustedForAttachmentDownload(true); await convo.commit(); - const messagesInConvo = await Data.getLastMessagesByConversation(convo.id, 100, false); + const messagesInConvo = await Data.getLastMessagesByConversation({ + conversationId: convo.id, + limit: 100, + skipTimerInit: true, + skipMarkedAsDeleted: true, + }); await Promise.all( messagesInConvo.map(async message => { diff --git a/ts/components/conversation/message/message-content/MessageQuote.tsx b/ts/components/conversation/message/message-content/MessageQuote.tsx index 0917c5431..0801859b6 100644 --- a/ts/components/conversation/message/message-content/MessageQuote.tsx +++ b/ts/components/conversation/message/message-content/MessageQuote.tsx @@ -67,6 +67,7 @@ export const MessageQuote = (props: Props) => { isIncoming={direction === 'incoming'} author={quoteProps.author} isFromMe={false} + isDeleted={undefined} /> ); } @@ -81,6 +82,7 @@ export const MessageQuote = (props: Props) => { author={quoteProps.author} referencedMessageNotFound={false} isFromMe={Boolean(quoteProps.isFromMe)} + isDeleted={quoteProps.isDeleted} /> ); }; diff --git a/ts/components/conversation/message/message-content/quote/Quote.tsx b/ts/components/conversation/message/message-content/quote/Quote.tsx index 8d41734a9..ccd36baf1 100644 --- a/ts/components/conversation/message/message-content/quote/Quote.tsx +++ b/ts/components/conversation/message/message-content/quote/Quote.tsx @@ -7,6 +7,7 @@ import * as MIME from '../../../../../types/MIME'; import { QuoteAuthor } from './QuoteAuthor'; import { QuoteIconContainer } from './QuoteIconContainer'; import { QuoteText } from './QuoteText'; +import type { MessageDeletedType } from '../../../../../models/messageType'; const StyledQuoteContainer = styled.div` min-width: 300px; // if the quoted content is small it doesn't look very good so we set a minimum @@ -51,6 +52,7 @@ export type QuoteProps = { referencedMessageNotFound: boolean; text?: string; attachment?: QuotedAttachmentType; + isDeleted?: MessageDeletedType; onClick?: (e: MouseEvent) => void; }; @@ -71,7 +73,7 @@ export interface QuotedAttachmentType { export const Quote = (props: QuoteProps) => { const isSelectionMode = useIsMessageSelectionMode(); - const { isIncoming, attachment, text, referencedMessageNotFound, onClick } = props; + const { isIncoming, attachment, text, referencedMessageNotFound, onClick, isDeleted } = props; const [imageBroken, setImageBroken] = useState(false); const handleImageErrorBound = () => { @@ -102,6 +104,7 @@ export const Quote = (props: QuoteProps) => { text={text} attachment={attachment} referencedMessageNotFound={referencedMessageNotFound} + isDeleted={isDeleted} /> diff --git a/ts/components/conversation/message/message-content/quote/QuoteText.tsx b/ts/components/conversation/message/message-content/quote/QuoteText.tsx index 272558d64..590ee8f09 100644 --- a/ts/components/conversation/message/message-content/quote/QuoteText.tsx +++ b/ts/components/conversation/message/message-content/quote/QuoteText.tsx @@ -10,6 +10,7 @@ import { GoogleChrome } from '../../../../../util'; import { MessageBody } from '../MessageBody'; import { QuoteProps } from './Quote'; import { tr } from '../../../../../localization/localeTools'; +import { MessageDeletedType } from '../../../../../models/messageType'; const StyledQuoteText = styled.div<{ $isIncoming: boolean }>` display: -webkit-box; @@ -81,9 +82,12 @@ export function getShortenedFilename(fileName: string) { } export const QuoteText = ( - props: Pick + props: Pick< + QuoteProps, + 'text' | 'attachment' | 'isIncoming' | 'referencedMessageNotFound' | 'isDeleted' + > ) => { - const { text, attachment, isIncoming, referencedMessageNotFound } = props; + const { text, attachment, isIncoming, referencedMessageNotFound, isDeleted } = props; const isGroup = useSelectedIsGroupOrCommunity(); const isPublic = useSelectedIsPublic(); @@ -96,11 +100,17 @@ export const QuoteText = ( return
{typeLabel}
; } } + const textOrFallbacks = + isDeleted === MessageDeletedType.deletedGlobally + ? tr('deleteMessageDeletedGlobally') + : isDeleted === MessageDeletedType.deletedLocally + ? tr('deleteMessageDeletedLocally') + : text || tr('messageErrorOriginal'); return ( > { - const messages = await channels.getLastMessagesByConversation(conversationId, limit); +async function getLastMessagesByConversation({ + conversationId, + limit, + skipTimerInit, + skipMarkedAsDeleted, +}: { + conversationId: string; + limit: number; + skipTimerInit: boolean; + skipMarkedAsDeleted: boolean; +}): Promise> { + const messages = await channels.getLastMessagesByConversation({ + conversationId, + limit, + skipMarkedAsDeleted, + }); + if (skipTimerInit) { // eslint-disable-next-line no-restricted-syntax for (const message of messages) { @@ -476,12 +487,21 @@ async function getLastMessagesByConversation( } async function getLastMessageIdInConversation(conversationId: string) { - const models = await getLastMessagesByConversation(conversationId, 1, true); + const models = await getLastMessagesByConversation({ + conversationId, + limit: 1, + skipTimerInit: true, + skipMarkedAsDeleted: false, + }); return models?.[0]?.id || null; } async function getLastMessageInConversation(conversationId: string) { - const messages = await channels.getLastMessagesByConversation(conversationId, 1); + const messages = await channels.getLastMessagesByConversation({ + conversationId, + limit: 1, + skipTimerInit: true, + }); // eslint-disable-next-line no-restricted-syntax for (const message of messages) { message.skipTimerInit = true; @@ -537,7 +557,12 @@ async function removeAllMessagesInConversation(conversationId: string): Promise< // Yes, we really want the await in the loop. We're deleting 500 at a // time so we don't use too much memory. // eslint-disable-next-line no-await-in-loop - messages = await getLastMessagesByConversation(conversationId, 1000, false); + messages = await getLastMessagesByConversation({ + conversationId, + limit: 1000, + skipTimerInit: false, + skipMarkedAsDeleted: false, + }); if (!messages.length) { return; } diff --git a/ts/interactions/conversations/deleteMessagesLocallyOnly.ts b/ts/interactions/conversations/deleteMessagesLocallyOnly.ts deleted file mode 100644 index 9ffac8f70..000000000 --- a/ts/interactions/conversations/deleteMessagesLocallyOnly.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* eslint-disable no-await-in-loop */ -import type { ConversationModel } from '../../models/conversation'; -import type { MessageModel } from '../../models/message'; -import type { WithLocalMessageDeletionType } from '../../session/types/with'; - -/** - * Deletes a message completely or mark it as deleted. Does not interact with the swarm at all. - * Note: no matter the `deletionType`, a control message or a "mark as deleted" message are always removed entirely from the database. - */ -export async function deleteMessagesLocallyOnly({ - conversation, - messages, - deletionType, -}: WithLocalMessageDeletionType & { - conversation: ConversationModel; - messages: Array; -}) { - for (let index = 0; index < messages.length; index++) { - const message = messages[index]; - // a control message or a message deleted is forcefully removed from the DB - if (deletionType === 'complete' || message.isControlMessage() || message.isMarkedAsDeleted()) { - await conversation.removeMessage(message.id); - } else { - // just mark the message as deleted but still show in conversation - await message.markAsDeleted(deletionType === 'markDeletedThisDevice'); - } - } -} diff --git a/ts/interactions/conversations/unsendingInteractions.ts b/ts/interactions/conversations/unsendingInteractions.ts index 481168d98..e81ab80ff 100644 --- a/ts/interactions/conversations/unsendingInteractions.ts +++ b/ts/interactions/conversations/unsendingInteractions.ts @@ -4,49 +4,28 @@ import { PubKey } from '../../session/types'; import { UserUtils } from '../../session/utils'; import { ed25519Str } from '../../session/utils/String'; -import { deleteMessagesLocallyOnly } from './deleteMessagesLocallyOnly'; +import { deleteOrMarkAsDeletedMessages } from './deleteOrMarkAsDeletedMessages'; import { deleteMessagesFromSwarmOnly } from './deleteMessagesFromSwarmOnly'; import { ConvoHub } from '../../session/conversations'; +import type { WithActionContext, WithLocalMessageDeletionType } from '../../session/types/with'; -/** - * Delete the messages (with a valid hash) from the swarm and completely delete the messages locally. - * Only delete locally if the delete from swarm was successful. - * - * Returns true if the delete from swarm was successful, false otherwise. - */ -export async function deleteMessagesFromSwarmAndCompletelyLocally( - conversation: ConversationModel, - messages: Array -) { - return deleteMessagesFromSwarmShared(conversation, messages, 'complete'); -} - -/** - * Delete the messages (with a valid hash) from the swarm. - * Only mark as deleted if the delete from swarm was successful. - * - * Returns true if the delete from swarm was successful, false otherwise. - * - */ -export async function deleteMessagesFromSwarmAndMarkAsDeletedLocally( - conversation: ConversationModel, - messages: Array -) { - return deleteMessagesFromSwarmShared(conversation, messages, 'markDeleted'); -} - -async function deleteMessagesFromSwarmShared( - conversation: ConversationModel, - messages: Array, - deletionType: 'complete' | 'markDeleted' -) { +export async function deleteMessagesFromSwarmAndDeleteOrMarkAsDeleted({ + conversation, + deletionType, + messages, + actionContextIsUI, +}: WithLocalMessageDeletionType & + WithActionContext & { + conversation: ConversationModel; + messages: Array; + }) { // legacy groups are deprecated if (conversation.isClosedGroup() && PubKey.is05Pubkey(conversation.id)) { throw new Error('legacy groups are deprecated. Not deleting anything'); } if (conversation.isPrivateAndBlinded()) { throw new Error( - `deleteMessagesFromSwarmShared ${deletionType} does not support blinded conversations` + `deleteMessagesFromSwarmAndDeleteOrMarkAsDeleted ${deletionType} does not support blinded conversations` ); } @@ -59,33 +38,42 @@ async function deleteMessagesFromSwarmShared( ? conversation.id : UserUtils.getOurPubKeyStrFromCache(); if (!PubKey.is03Pubkey(pubkeyToDeleteFrom) && !PubKey.is05Pubkey(pubkeyToDeleteFrom)) { - throw new Error(`deleteMessagesFromSwarmShared ${deletionType} needs a 03 or 05 pk`); + throw new Error( + `deleteMessagesFromSwarmAndDeleteOrMarkAsDeleted ${deletionType} needs a 03 or 05 pk` + ); } if ( PubKey.is05Pubkey(pubkeyToDeleteFrom) && pubkeyToDeleteFrom !== UserUtils.getOurPubKeyStrFromCache() ) { throw new Error( - `deleteMessagesFromSwarmShared ${deletionType} with 05 pk can only delete for ourself` + `deleteMessagesFromSwarmAndDeleteOrMarkAsDeleted ${deletionType} with 05 pk can only delete for ourself` ); } window.log.info( - `deleteMessagesFromSwarmShared ${deletionType}: Deleting from swarm of ${ed25519Str(pubkeyToDeleteFrom)}, hashes: ${messages.map(m => m.getMessageHash())}` + `deleteMessagesFromSwarmAndDeleteOrMarkAsDeleted ${deletionType}: Deleting from swarm of ${ed25519Str(pubkeyToDeleteFrom)}, hashes: ${messages.map(m => m.getMessageHash())}` ); const convo = ConvoHub.use().get(conversation.id); if (!convo) { - throw new Error(`deleteMessagesFromSwarmShared ${deletionType} convo not found`); + throw new Error( + `deleteMessagesFromSwarmAndDeleteOrMarkAsDeleted ${deletionType} convo not found` + ); } const deletedFromSwarm = await deleteMessagesFromSwarmOnly(convo, messages); if (!deletedFromSwarm) { window.log.warn( - `deleteMessagesFromSwarmShared ${deletionType}: some messages failed to be deleted from swarm of ${ed25519Str(pubkeyToDeleteFrom)}. Maybe they were already deleted?` + `deleteMessagesFromSwarmAndDeleteOrMarkAsDeleted ${deletionType}: some messages failed to be deleted from swarm of ${ed25519Str(pubkeyToDeleteFrom)}. Maybe they were already deleted?` ); } else { - await deleteMessagesLocallyOnly({ conversation, messages, deletionType }); + await deleteOrMarkAsDeletedMessages({ + conversation, + messages, + deletionType, + actionContextIsUI, + }); } return deletedFromSwarm; } diff --git a/ts/models/conversation.ts b/ts/models/conversation.ts index 6f19fbcaf..fb8343523 100644 --- a/ts/models/conversation.ts +++ b/ts/models/conversation.ts @@ -2606,7 +2606,13 @@ export class ConversationModel extends Model { if (!this.id || !this.getActiveAt() || this.isHidden()) { return; } - const messages = await Data.getLastMessagesByConversation(this.id, 1, true); + const messages = await Data.getLastMessagesByConversation({ + conversationId: this.id, + limit: 1, + skipTimerInit: true, + // we want to render the text from the last non-marked as deleted message + skipMarkedAsDeleted: true, + }); const existingLastMessageAttribute = this.get('lastMessage'); const existingLastMessageStatus = this.get('lastMessageStatus'); if (!messages || !messages.length) { diff --git a/ts/models/message.ts b/ts/models/message.ts index 59dc75244..6d079c717 100644 --- a/ts/models/message.ts +++ b/ts/models/message.ts @@ -111,6 +111,7 @@ import { getFeatureFlag } from '../state/ducks/types/releasedFeaturesReduxTypes' import type { OutgoingProMessageDetails } from '../types/message/OutgoingProMessageDetails'; import { longOrNumberToBigInt } from '../types/Bigint'; import { toSqliteBoolean } from '../node/database_utility'; +import type { WithLocalMessageDeletionType } from '../session/types/with'; // tslint:disable: cyclomatic-complexity @@ -420,6 +421,14 @@ export class MessageModel extends Model { return tStrippedWithObj(i18nProps); } + if (this.isMessageRequestResponse()) { + if (this.get('direction') === 'incoming') { + return tStripped('messageRequestsAccepted'); + } + return tStripped('messageRequestYouHaveAccepted', { + name: ConvoHub.use().getNicknameOrRealUsernameOrPlaceholder(this.get('conversationId')), + }); + } const body = this.get('body'); if (body) { let bodyMentionsMappedToNames = body; @@ -945,11 +954,33 @@ export class MessageModel extends Model { /** * Marks the message as deleted locally or globally. */ - public async markAsDeleted(deletedLocallyOnly: boolean) { + public async markAsDeleted( + requestedDeleteType: Extract< + WithLocalMessageDeletionType['deletionType'], + 'markDeletedGlobally' | 'markDeletedThisDevice' + > + ) { + const isDeletedType = this.get('isDeleted'); + const requestedDeleteLocallyOnly = requestedDeleteType === 'markDeletedThisDevice'; + const requestedDeleteGlobally = requestedDeleteType === 'markDeletedGlobally'; + + // if the msg is already marked as deleted of the correct type, do nothing + if (isDeletedType === MessageDeletedType.deletedLocally && requestedDeleteLocallyOnly) { + return; + } + if (isDeletedType === MessageDeletedType.deletedGlobally && requestedDeleteGlobally) { + return; + } + // if we want to mark as deleted a globally deleted message, do nothing + if (isDeletedType === MessageDeletedType.deletedGlobally && !requestedDeleteGlobally) { + return; + } + this.set({ - isDeleted: deletedLocallyOnly - ? MessageDeletedType.deletedLocally - : MessageDeletedType.deletedGlobally, + isDeleted: + requestedDeleteType === 'markDeletedThisDevice' + ? MessageDeletedType.deletedLocally + : MessageDeletedType.deletedGlobally, body: '', quote: undefined, groupInvitation: undefined, @@ -968,6 +999,11 @@ export class MessageModel extends Model { errors: undefined, unread: toSqliteBoolean(false), }); + // Only overwrite the messageHash when we are deleting globally. + // This is because a locally deleted message should be able to be marked as deleted globally + if (requestedDeleteGlobally) { + this.set({ messageHash: undefined }); + } // we can ignore the result of that markMessageReadNoCommit as it would only be used // to refresh the expiry of it(but it is already marked as "deleted", so we don't care) this.markMessageReadNoCommit(Date.now()); diff --git a/ts/node/migration/sessionMigrations.ts b/ts/node/migration/sessionMigrations.ts index 17687346a..29d3fcab3 100644 --- a/ts/node/migration/sessionMigrations.ts +++ b/ts/node/migration/sessionMigrations.ts @@ -2387,7 +2387,7 @@ async function updateToSessionSchemaVersion54(currentVersion: number, db: Databa db.exec(`ALTER TABLE ${MESSAGES_TABLE} DROP COLUMN isDeleted_old;`); db.exec( - `CREATE INDEX messages_isDeleted ON ${MESSAGES_TABLE} (isDeleted) WHERE isDeleted IS NOT NULL;` + `CREATE INDEX messages_isDeleted_conversationId ON ${MESSAGES_TABLE} (conversationId, isDeleted) WHERE isDeleted IS NOT NULL;` ); writeSessionSchemaVersion(targetVersion, db); diff --git a/ts/node/sql.ts b/ts/node/sql.ts index dad16a51e..cd94be85c 100644 --- a/ts/node/sql.ts +++ b/ts/node/sql.ts @@ -77,7 +77,7 @@ import { FindAllMessageHashesInConversationMatchingAuthorTypeArgs, FindAllMessageHashesInConversationTypeArgs, } from '../data/sharedDataTypes'; -import { MessageAttributes } from '../models/messageType'; +import { MessageAttributes, MessageDeletedType } from '../models/messageType'; import { SignalService } from '../protobuf'; import { DURATION } from '../session/constants'; import { createDeleter, getAttachmentsPath } from '../shared/attachments/shared_attachments'; @@ -1021,6 +1021,7 @@ function saveMessages(dataArray: Array): Array { messageHash, errors, expirationTimerUpdate, + isDeleted, } = data; // Check if this message mentions us @@ -1059,6 +1060,7 @@ function saveMessages(dataArray: Array): Array { errors: errors ?? null, mentionsUs: toSqliteBoolean(mentionsUs), + isDeleted: isDeleted ? (isDeleted as number) : null, } satisfies SQLInsertable; devAssertValidSQLPayload(MESSAGES_TABLE, payload); return payload; @@ -1089,6 +1091,7 @@ function saveMessages(dataArray: Array): Array { flags, messageHash, errors, + isDeleted, ${MessageColumns.mentionsUs} ) VALUES ( $id, @@ -1113,6 +1116,7 @@ function saveMessages(dataArray: Array): Array { $flags, $messageHash, $errors, + $isDeleted, $mentionsUs )` ); @@ -1750,15 +1754,28 @@ function getMessagesByConversation( }; } -function getLastMessagesByConversation(conversationId: string, limit: number) { +function getLastMessagesByConversation({ + conversationId, + limit, + skipMarkedAsDeleted, +}: { + conversationId: string; + limit: number; + skipMarkedAsDeleted: boolean; +}) { if (!isNumber(limit)) { throw new Error('limit must be a number'); } + const andNotDeleted = skipMarkedAsDeleted + ? `AND (isDeleted is NULL OR isDeleted == ${MessageDeletedType.notDeleted})` + : ''; + const sql = `SELECT json FROM ${MESSAGES_TABLE} WHERE - conversationId = $conversationId + conversationId = $conversationId ${andNotDeleted} ${orderByClauseDESC} LIMIT $limit;`; + const params = { conversationId, limit }; const rows = analyzeQuery(assertGlobalInstance(), sql, params).all(); @@ -2264,7 +2281,7 @@ function removeKnownAttachments(allAttachments: Array) { ORDER BY id ASC LIMIT $chunkSize;`; const params = { id, chunkSize }; - const rows = analyzeQuery(assertGlobalInstance(), sql, params, true).all(); + const rows = analyzeQuery(assertGlobalInstance(), sql, params).all(); const messages = parseJsonRows(rows); diff --git a/ts/receiver/contentMessage.ts b/ts/receiver/contentMessage.ts index c52c615a7..e81713411 100644 --- a/ts/receiver/contentMessage.ts +++ b/ts/receiver/contentMessage.ts @@ -8,10 +8,6 @@ import { PubKey } from '../session/types'; import { Data } from '../data/data'; import { SettingsKey } from '../data/settings-key'; -import { - deleteMessagesFromSwarmAndCompletelyLocally, - deleteMessagesFromSwarmAndMarkAsDeletedLocally, -} from '../interactions/conversations/unsendingInteractions'; import { findCachedBlindedMatchOrLookupOnAllServers } from '../session/apis/open_group_api/sogsv3/knownBlindedkeys'; import { ConvoHub } from '../session/conversations'; import { getSodiumRenderer } from '../session/crypto'; @@ -35,6 +31,7 @@ import { CONVERSATION_PRIORITIES, ConversationTypeEnum } from '../models/types'; import { shouldProcessContentMessage } from './common'; import { longOrNumberToNumber } from '../types/long/longOrNumberToNumber'; import { buildPrivateProfileChangeFromMsgRequestResponse } from '../models/profile'; +import { deleteOrMarkAsDeletedMessages } from '../interactions/conversations/deleteOrMarkAsDeletedMessages'; async function shouldDropIncomingPrivateMessage( envelope: BaseDecodedEnvelope, @@ -484,15 +481,28 @@ async function handleUnsendMessage( if (messageHash && messageToDelete) { window.log.info('handleUnsendMessage: got a request to delete ', messageHash); - const conversation = ConvoHub.use().get(messageToDelete.get('conversationId')); + const conversation = messageToDelete.getConversation(); if (!conversation) { return; } - if (messageToDelete.getSource() === UserUtils.getOurPubKeyStrFromCache()) { - // a message we sent is completely removed when we get a unsend request for it - void deleteMessagesFromSwarmAndCompletelyLocally(conversation, [messageToDelete]); - } else { - void deleteMessagesFromSwarmAndMarkAsDeletedLocally(conversation, [messageToDelete]); + const messages = [messageToDelete]; + + if (conversation.isMe()) { + // an unsend request in our NTS conversation is removing the corresponding message completely + await deleteOrMarkAsDeletedMessages({ + conversation, + messages, + deletionType: 'complete', + actionContextIsUI: false, + }); + } else if (conversation.isPrivate() && !conversation.isPrivateAndBlinded()) { + // in a 1o1 conversation (not NTS), processing an unsend request is marking the message as deleted globally + await deleteOrMarkAsDeletedMessages({ + conversation, + messages, + deletionType: 'markDeletedGlobally', + actionContextIsUI: false, + }); } } else { window.log.info( diff --git a/ts/receiver/groupv2/handleGroupV2Message.ts b/ts/receiver/groupv2/handleGroupV2Message.ts index af94e962e..6a915c15e 100644 --- a/ts/receiver/groupv2/handleGroupV2Message.ts +++ b/ts/receiver/groupv2/handleGroupV2Message.ts @@ -27,7 +27,7 @@ import { } from '../../webworker/workers/browser/libsession_worker_interface'; import { sendInviteResponseToGroup } from '../../session/sending/group/GroupInviteResponse'; import { deleteMessagesFromSwarmOnly } from '../../interactions/conversations/deleteMessagesFromSwarmOnly'; -import { deleteMessagesLocallyOnly } from '../../interactions/conversations/deleteMessagesLocallyOnly'; +import { deleteOrMarkAsDeletedMessages } from '../../interactions/conversations/deleteOrMarkAsDeletedMessages'; type WithSignatureTimestamp = { signatureTimestamp: number }; type WithAuthor = { author: PubkeyType }; @@ -446,10 +446,11 @@ async function handleGroupUpdateDeleteMemberContentMessage({ // processing the handleGroupUpdateDeleteMemberContentMessage itself // (we are running on the receiving pipeline here) // so network calls are not allowed. - await deleteMessagesLocallyOnly({ + await deleteOrMarkAsDeletedMessages({ conversation: convo, messages: messageModels, - deletionType: 'complete', + deletionType: 'markDeletedGlobally', + actionContextIsUI: false, }); return; @@ -489,10 +490,11 @@ async function handleGroupUpdateDeleteMemberContentMessage({ // (we are running on the receiving pipeline here) // so network calls are not allowed. const mergedModels = modelsByHashes.concat(modelsBySenders); - await deleteMessagesLocallyOnly({ + await deleteOrMarkAsDeletedMessages({ conversation: convo, messages: mergedModels, - deletionType: 'complete', + deletionType: 'markDeletedGlobally', + actionContextIsUI: false, }); } diff --git a/ts/session/apis/snode_api/SNodeAPI.ts b/ts/session/apis/snode_api/SNodeAPI.ts index 3f94e151f..3a1c3e935 100644 --- a/ts/session/apis/snode_api/SNodeAPI.ts +++ b/ts/session/apis/snode_api/SNodeAPI.ts @@ -272,6 +272,7 @@ async function networkDeleteMessageOurSwarm( return null; }) ); + return isEmpty(results); } catch (e) { throw new Error( diff --git a/ts/session/types/with.ts b/ts/session/types/with.ts index f803e2047..9a79c439a 100644 --- a/ts/session/types/with.ts +++ b/ts/session/types/with.ts @@ -26,8 +26,16 @@ export type WithMessageId = { messageId: string }; export type WithContextMenuId = { contextMenuId: string }; export type WithLocalMessageDeletionType = { - deletionType: 'complete' | 'markDeleted' | 'markDeletedThisDevice'; + deletionType: 'complete' | 'markDeletedGlobally' | 'markDeletedThisDevice'; }; + +export type WithActionContext = { + /** + * A bunch of actions have different meaning when done via the UI or not (i.e. the local user doing the action). + */ + actionContextIsUI: boolean; +}; + export type ShortenOrExtend = 'extend' | 'shorten' | ''; export type WithShortenOrExtend = { shortenOrExtend: ShortenOrExtend }; export type WithMessagesHashes = { messagesHashes: Array }; diff --git a/ts/session/utils/job_runners/jobs/GroupPendingRemovalsJob.ts b/ts/session/utils/job_runners/jobs/GroupPendingRemovalsJob.ts index 67f34a648..3ac87461e 100644 --- a/ts/session/utils/job_runners/jobs/GroupPendingRemovalsJob.ts +++ b/ts/session/utils/job_runners/jobs/GroupPendingRemovalsJob.ts @@ -4,7 +4,7 @@ import { compact, isEmpty, isNumber } from 'lodash'; import AbortController from 'abort-controller'; import { StringUtils } from '../..'; import { Data } from '../../../../data/data'; -import { deleteMessagesFromSwarmAndMarkAsDeletedLocally } from '../../../../interactions/conversations/unsendingInteractions'; +import { deleteMessagesFromSwarmAndDeleteOrMarkAsDeleted } from '../../../../interactions/conversations/unsendingInteractions'; import { MetaGroupWrapperActions, MultiEncryptWrapperActions, @@ -229,7 +229,13 @@ class GroupPendingRemovalsJob extends PersistedJob m.propsForMessage.id === changedOrAddedMessageProps.propsForMessage.id ); @@ -615,6 +621,50 @@ function handleMessagesChangedOrAdded( return stateCopy; } +function removeQuotedMessageProps( + quotedMessageProps: Array, + msgChangedProps: MessageModelPropsWithoutConvoProps +) { + // Check if the message is quoted somewhere, and if so, remove it from the quotes + const { timestamp, sender } = msgChangedProps.propsForMessage; + if (timestamp && sender) { + const { foundAt, foundProps } = lookupQuoteInStore({ + timestamp, + quotedMessagesInStore: quotedMessageProps, + }); + if (foundAt >= 0) { + window.log.debug(`Deleting quote ${JSON.stringify(foundProps)}`); + + const editedQuotedMessages = [...quotedMessageProps]; + editedQuotedMessages.splice(foundAt, 1); + return editedQuotedMessages; + } + } + return quotedMessageProps; +} + +function updateQuotedMessageProps( + quotedMessageProps: Array, + msgChangedProps: MessageModelPropsWithoutConvoProps +) { + // Check if the message is quoted somewhere, and if so, update the quoted message props + const { timestamp, sender } = msgChangedProps.propsForMessage; + if (timestamp && sender) { + const { foundAt } = lookupQuoteInStore({ + timestamp, + quotedMessagesInStore: quotedMessageProps, + }); + if (foundAt >= 0) { + window.log.debug(`Updating quote found at ${foundAt}`); + + const editedQuotedMessages = [...quotedMessageProps]; + editedQuotedMessages[foundAt] = msgChangedProps; + return editedQuotedMessages; + } + } + return quotedMessageProps; +} + function handleMessageExpiredOrDeleted( state: ConversationsStateType, payload: WithConvoId & (WithMessageId | WithMessageHash) @@ -638,26 +688,13 @@ function handleMessageExpiredOrDeleted( if (messageInStoreIndex >= 0) { const msgToRemove = state.messages[messageInStoreIndex]; const extractedMessageId = msgToRemove.propsForMessage.id; - const msgRemovedProps = state.messages[messageInStoreIndex].propsForMessage; + const msgRemovedProps = state.messages[messageInStoreIndex]; // we cannot edit the array directly, so slice the first part, and slice the second part, // keeping the index removed out const editedMessages = [...state.messages]; editedMessages.splice(messageInStoreIndex, 1); - const editedQuotedMessages = [...state.quotedMessages]; - - // Check if the message is quoted somewhere, and if so, remove it from the quotes - const { timestamp, sender } = msgRemovedProps; - if (timestamp && sender) { - const { foundAt, foundProps } = lookupQuoteInStore({ - timestamp, - quotedMessagesInStore: state.quotedMessages, - }); - if (foundAt >= 0) { - window.log.debug(`Deleting quote ${JSON.stringify(foundProps)}`); - editedQuotedMessages.splice(foundAt, 1); - } - } + const editedQuotedMessages = removeQuotedMessageProps(state.quotedMessages, msgRemovedProps); return { ...state, diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 232edd214..42f7f09bb 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -782,7 +782,7 @@ type QuotePropsFound = QuotePropsAlwaysThere & { referencedMessageNotFound: false; id: string; convoId: string; -} & Pick; +} & Pick; export const getMessageQuoteProps = createSelector( getConversationLookup, @@ -829,7 +829,7 @@ export const getMessageQuoteProps = createSelector( } const sourceMsgProps = foundProps.propsForMessage; - if (!sourceMsgProps || sourceMsgProps.isDeleted) { + if (!sourceMsgProps) { return quoteNotFoundWithDetails(author, timestamp); } @@ -850,6 +850,7 @@ export const getMessageQuoteProps = createSelector( referencedMessageNotFound: false, convoId: convo.id, timestamp: toNumber(timestamp), + isDeleted: sourceMsgProps.isDeleted, }; } ); diff --git a/ts/util/reactions.ts b/ts/util/reactions.ts index 638f2d3d3..243324b88 100644 --- a/ts/util/reactions.ts +++ b/ts/util/reactions.ts @@ -160,6 +160,12 @@ const handleMessageReaction = async ({ if (!originalMessage) { return undefined; } + if (originalMessage.isMarkedAsDeleted()) { + window.log.debug( + `Received a reaction on a marked-as-deleted message. Ignoring it.${originalMessage.idForLogging()}` + ); + return undefined; + } const reacts: ReactionList = originalMessage.get('reacts') ?? {}; reacts[reaction.emoji] = reacts[reaction.emoji] || { count: null, senders: [] }; From 1463f68800bd0438b962588046fa03004573b64e Mon Sep 17 00:00:00 2001 From: Ryan Miller Date: Tue, 3 Mar 2026 11:05:16 +1100 Subject: [PATCH 15/21] feat: move interactables to message list and flatten messages --- ts/components/OutgoingLightBox.tsx | 1 + ts/components/SessionFocusTrap.tsx | 19 +++ ts/components/SessionWrapperModal.tsx | 6 +- .../conversation/SessionEmojiPanel.tsx | 3 +- .../conversation/SessionEmojiPanelPopover.tsx | 2 +- .../SessionEmojiReactBarPopover.tsx | 104 ++++--------- .../SessionMessageInteractables.tsx | 138 ++++++++++++++++++ .../conversation/SessionMessagesList.tsx | 138 +++++++++++++++++- .../SessionMessagesListContainer.tsx | 95 ++---------- .../conversation/TimerNotification.tsx | 8 +- .../ConversationHeaderSelectionOverlay.tsx | 1 + .../MessageContentWithStatus.tsx | 64 +------- .../message-content/MessageContextMenu.tsx | 117 +++++++-------- .../message-content/MessageReactions.tsx | 2 +- .../message/message-content/MessageStatus.tsx | 2 +- .../message-item/CommunityInvitation.tsx | 138 +++++++----------- .../DataExtractionNotification.tsx | 6 +- .../message-item/ExpirableReadableMessage.tsx | 63 +------- .../GenericReadableInteractableMessage.tsx | 96 +++++++----- .../message-item/GenericReadableMessage.tsx | 46 +++--- .../message-item/GroupUpdateMessage.tsx | 11 +- .../message-item/InteractionNotification.tsx | 8 +- .../message-item/MessageRequestResponse.tsx | 8 +- .../notification-bubble/CallNotification.tsx | 11 +- ts/components/lightbox/Lightbox.tsx | 1 + ts/hooks/useMessageInteractions.ts | 3 +- ts/state/ducks/conversations.ts | 18 +++ ts/state/ducks/types/defaultFeatureFlags.ts | 1 + .../ducks/types/releasedFeaturesReduxTypes.ts | 1 + ts/state/focus.ts | 4 +- ts/state/selectors/conversations.ts | 15 ++ ts/state/selectors/messages.ts | 13 +- 32 files changed, 629 insertions(+), 514 deletions(-) create mode 100644 ts/components/conversation/SessionMessageInteractables.tsx diff --git a/ts/components/OutgoingLightBox.tsx b/ts/components/OutgoingLightBox.tsx index 313a8e7d2..172dd73fe 100644 --- a/ts/components/OutgoingLightBox.tsx +++ b/ts/components/OutgoingLightBox.tsx @@ -106,6 +106,7 @@ export const OutgoingLightBox = (props: NonNullable) => return ( ref.current} allowOutsideClick={true} returnFocusOnDeactivate={false} diff --git a/ts/components/SessionFocusTrap.tsx b/ts/components/SessionFocusTrap.tsx index 3c79deeb2..9b754852a 100644 --- a/ts/components/SessionFocusTrap.tsx +++ b/ts/components/SessionFocusTrap.tsx @@ -2,10 +2,13 @@ import { FocusTrap, type FocusTrapProps } from 'focus-trap-react'; import { type ReactNode, useEffect, useState } from 'react'; import type { CSSProperties } from 'styled-components'; import { windowErrorFilters } from '../util/logger/renderer_process_logging'; +import { getFeatureFlagMemo } from '../state/ducks/types/releasedFeaturesReduxTypes'; const focusTrapErrorSource = 'focus-trap'; type SessionFocusTrapProps = FocusTrapProps['focusTrapOptions'] & { + /** id used for debugging */ + focusTrapId: string; children: ReactNode; active?: boolean; containerDivStyle?: CSSProperties; @@ -17,16 +20,20 @@ type SessionFocusTrapProps = FocusTrapProps['focusTrapOptions'] & { }; export function SessionFocusTrap({ + focusTrapId, children, active = true, allowOutsideClick = true, containerDivStyle, suppressErrors, allowNoTabbableNodes, + onActivate, onPostActivate, onDeactivate, + onPostDeactivate, ...rest }: SessionFocusTrapProps) { + const debugFocusTrap = getFeatureFlagMemo('debugFocusTrap'); const defaultTabIndex = allowNoTabbableNodes ? 0 : -1; const _suppressErrors = suppressErrors || allowNoTabbableNodes; /** @@ -38,6 +45,13 @@ export function SessionFocusTrap({ */ const [tabIndex, setTabIndex] = useState<0 | 1 | -1>(defaultTabIndex); + const _onActivate = () => { + if (debugFocusTrap) { + window.log.debug(`[SessionFocusTrap] onActivate - ${focusTrapId}`); + } + onActivate?.(); + }; + const _onPostActivate = () => { if (allowNoTabbableNodes) { setTabIndex(-1); @@ -46,6 +60,9 @@ export function SessionFocusTrap({ }; const _onDeactivate = () => { + if (debugFocusTrap) { + window.log.debug(`[SessionFocusTrap] onDeactivate - ${focusTrapId}`); + } if (allowNoTabbableNodes) { setTabIndex(defaultTabIndex); } @@ -69,8 +86,10 @@ export function SessionFocusTrap({ focusTrapOptions={{ ...rest, allowOutsideClick, + onActivate: _onActivate, onPostActivate: _onPostActivate, onDeactivate: _onDeactivate, + onPostDeactivate, }} > {/* Note: without this div, the focus trap doesn't work */} diff --git a/ts/components/SessionWrapperModal.tsx b/ts/components/SessionWrapperModal.tsx index 940e6ffe4..d0a12fa93 100644 --- a/ts/components/SessionWrapperModal.tsx +++ b/ts/components/SessionWrapperModal.tsx @@ -452,7 +452,11 @@ export const SessionWrapperModal = (props: SessionWrapperModalType & { onClose?: props.headerChildren && moveHeaderIntoScrollableBody ? props.headerChildren : null; return ( - modalRef.current}> + modalRef.current} + > diff --git a/ts/components/conversation/SessionEmojiPanel.tsx b/ts/components/conversation/SessionEmojiPanel.tsx index a73314a0b..8523a025d 100644 --- a/ts/components/conversation/SessionEmojiPanel.tsx +++ b/ts/components/conversation/SessionEmojiPanel.tsx @@ -66,7 +66,7 @@ export const StyledEmojiPanel = styled.div<{ `; type Props = { - ref: RefObject; + ref?: RefObject; onEmojiClicked: (emoji: FixedBaseEmoji) => void; isModal?: boolean; onClose?: () => void; @@ -114,6 +114,7 @@ const EmojiPanel = ({ ref, onEmojiClicked, isModal = false, onClose }: Props) => return ( ; + emojiPanelRef?: RefObject; triggerPosition: PopoverTriggerPosition | null; open: boolean; onEmojiClick: (emoji: FixedBaseEmoji) => void; diff --git a/ts/components/conversation/SessionEmojiReactBarPopover.tsx b/ts/components/conversation/SessionEmojiReactBarPopover.tsx index 8e35092c6..244765749 100644 --- a/ts/components/conversation/SessionEmojiReactBarPopover.tsx +++ b/ts/components/conversation/SessionEmojiReactBarPopover.tsx @@ -1,62 +1,31 @@ -import { type RefObject, useRef, useState } from 'react'; -import { - getTriggerPosition, - WithPopoverPosition, - WithSetPopoverPosition, - type PopoverTriggerPosition, -} from '../SessionTooltip'; +import { type RefObject } from 'react'; import { SessionPopoverContent } from '../SessionPopover'; import { MessageReactBar } from './message/message-content/MessageReactBar'; import { THEME_GLOBALS } from '../../themes/globals'; -import { SessionEmojiPanelPopover } from './SessionEmojiPanelPopover'; -import { closeContextMenus } from '../../util/contextMenu'; import { useMessageReact } from '../../hooks/useMessageInteractions'; +import { PopoverTriggerPosition } from '../SessionTooltip'; -export type ReactionBarOptions = WithPopoverPosition & WithSetPopoverPosition; -export type WithReactionBarOptions = { - reactionBarOptions?: ReactionBarOptions; +type SessionEmojiReactBarPopoverProps = { + emojiPanelTriggerRef: RefObject; + reactBarFirstEmojiRef?: RefObject; + triggerPosition: PopoverTriggerPosition | null; + messageId: string | undefined; + onPlusButtonClick: () => void; + onAfterEmojiClick: () => void; }; export function SessionEmojiReactBarPopover({ - messageId, - triggerPos, reactBarFirstEmojiRef, -}: { - messageId: string; - // this can be null as we want the emoji panel to stay when the reaction bar closes - triggerPos: PopoverTriggerPosition | null; - reactBarFirstEmojiRef?: RefObject; -}) { - const emojiPanelTriggerRef = useRef(null); - const emojiPanelRef = useRef(null); - const emojiReactionBarRef = useRef(null); - const [emojiPanelTriggerPos, setEmojiPanelTriggerPos] = useState( - null - ); + emojiPanelTriggerRef, + onPlusButtonClick, + onAfterEmojiClick, + triggerPosition, + messageId, +}: SessionEmojiReactBarPopoverProps) { const reactToMessage = useMessageReact(messageId); - const barOpen = !!triggerPos; - const panelOpen = !!emojiPanelTriggerPos; - - const closeEmojiPanel = () => { - setEmojiPanelTriggerPos(null); - }; - - const openEmojiPanel = () => { - closeContextMenus(); - const pos = getTriggerPosition(emojiPanelTriggerRef); - if (pos) { - setEmojiPanelTriggerPos(pos); - } else { - window.log.warn( - `[SessionEmojiReactBarPopover] getTriggerPosition for the emojiPanelTriggerRef returned null for message ${messageId}` - ); - } - }; - const onEmojiClick = (args: any) => { const emoji = args.native ?? args; - closeEmojiPanel(); if (reactToMessage) { void reactToMessage(emoji); } else { @@ -64,36 +33,27 @@ export function SessionEmojiReactBarPopover({ `[SessionEmojiReactBarPopover] reactToMessage undefined for message ${messageId}` ); } + onAfterEmojiClick?.(); }; return ( - <> - + - - - - + ); } diff --git a/ts/components/conversation/SessionMessageInteractables.tsx b/ts/components/conversation/SessionMessageInteractables.tsx new file mode 100644 index 000000000..5961cd95a --- /dev/null +++ b/ts/components/conversation/SessionMessageInteractables.tsx @@ -0,0 +1,138 @@ +import { useRef, useState } from 'react'; +import type { MenuOnHideCallback } from 'react-contexify'; +import type { WithContextMenuId } from '../../session/types/with'; +import { SessionEmojiReactBarPopover } from './SessionEmojiReactBarPopover'; +import { SessionFocusTrap } from '../SessionFocusTrap'; +import { MessageContextMenu } from './message/message-content/MessageContextMenu'; +import { getTriggerPosition, type PopoverTriggerPosition } from '../SessionTooltip'; +import { SessionEmojiPanelPopover } from './SessionEmojiPanelPopover'; +import { useMessageReact } from '../../hooks/useMessageInteractions'; +import { closeContextMenus } from '../../util/contextMenu'; +import { useMessageIsControlMessage } from '../../state/selectors'; +import { + useReactionBarTriggerPosition, + useInteractableMessageId, +} from '../../state/selectors/conversations'; +import { setReactionBarTriggerPosition } from '../../state/ducks/conversations'; +import { getAppDispatch } from '../../state/dispatch'; + +export type MessageInteractableOptions = { + messageId?: string; + reactionBarTriggerPosition: PopoverTriggerPosition | null; + debugChangeReason: string; +}; + +type SessionMessageInteractablesProps = WithContextMenuId & { convoReactionsEnabled?: boolean }; + +export function SessionMessageInteractables({ + contextMenuId, + convoReactionsEnabled, +}: SessionMessageInteractablesProps) { + const reactionBarFirstEmojiRef = useRef(null); + const emojiPanelTriggerRef = useRef(null); + const messageId = useInteractableMessageId() ?? undefined; + const reactionBarTriggerPosition = useReactionBarTriggerPosition(); + const [emojiPanelTriggerPos, setEmojiPanelTriggerPos] = useState( + null + ); + const reactToMessageEmojiPanel = useMessageReact(messageId); + const isControlMessage = useMessageIsControlMessage(messageId); + const dispatch = getAppDispatch(); + + /** + * The reaction bar can be hidden by the following: + * - Deactivation of the focus trap + * - Hiding the message context menu + * - Clicking a context menu item + * - Reaction keyboard shortcut + * */ + const showReactionBar = + convoReactionsEnabled && !isControlMessage && !!reactionBarTriggerPosition; + + const closeReactionBar = () => { + dispatch(setReactionBarTriggerPosition(null)); + }; + + const onMessageContextMenuHide: MenuOnHideCallback = fromVisible => { + if (fromVisible && !!reactionBarTriggerPosition) { + closeReactionBar(); + } + }; + + /** + * NOTE: for some reason onMessageContextMenuHide doesn't work properly when a context menu + * item is clicked, this captures any context menu item click and forces the reaction bar to + * close + */ + const messageContextMenuOnClickCapture = () => { + closeReactionBar(); + }; + + const closeEmojiPanel = () => { + setEmojiPanelTriggerPos(null); + }; + + const openEmojiPanel = () => { + if (!messageId) { + window.log.warn(`[SessionEmojiReactBarPopover] openEmojiPanel has no messageId`); + return; + } + closeContextMenus(); + closeReactionBar(); + const pos = getTriggerPosition(emojiPanelTriggerRef); + if (pos) { + setEmojiPanelTriggerPos(pos); + } else { + window.log.warn( + `[SessionEmojiReactBarPopover] getTriggerPosition for the emojiPanelTriggerRef returned null for message ${messageId}` + ); + } + }; + + const onEmojiPanelEmojiClick = (args: any) => { + const emoji = args.native ?? args; + closeEmojiPanel(); + if (reactToMessageEmojiPanel) { + void reactToMessageEmojiPanel(emoji); + } else { + window.log.warn( + `[SessionMessageInteractables] reactToMessage undefined for message ${messageId}` + ); + } + }; + + return ( + <> + reactionBarFirstEmojiRef.current ?? false} + onDeactivate={closeReactionBar} + clickOutsideDeactivates={true} + > + {showReactionBar ? ( + + ) : null} + + + + + ); +} diff --git a/ts/components/conversation/SessionMessagesList.tsx b/ts/components/conversation/SessionMessagesList.tsx index dc8fedc64..9ad9f3a13 100644 --- a/ts/components/conversation/SessionMessagesList.tsx +++ b/ts/components/conversation/SessionMessagesList.tsx @@ -1,7 +1,8 @@ -import { useLayoutEffect, useState } from 'react'; +import { RefObject, useLayoutEffect, useMemo, useState } from 'react'; import { useSelector } from 'react-redux'; import useKey from 'react-use/lib/useKey'; +import styled from 'styled-components'; import { getOldBottomMessageId, getOldTopMessageId, @@ -17,6 +18,54 @@ import { useKeyboardShortcut } from '../../hooks/useKeyboardShortcut'; import { KbdShortcut } from '../../util/keyboardShortcuts'; import { useMessageCopyText, useMessageReply } from '../../hooks/useMessageInteractions'; import { GenericReadableInteractableMessage } from './message/message-item/GenericReadableInteractableMessage'; +import { StyledMessageBubble } from './message/message-content/MessageBubble'; +import { StyledMentionAnother } from './AddMentions'; +import { MessagesContainerRefContext } from '../../contexts/MessagesContainerRefContext'; +import { + ScrollToLoadedMessageContext, + ScrollToLoadedReasons, +} from '../../contexts/ScrollToLoadedMessage'; +import { TypingBubble } from './TypingBubble'; +import { ReduxConversationType } from '../../state/ducks/conversations'; +import { SessionScrollButton } from '../SessionScrollButton'; +import { ConvoHub } from '../../session/conversations'; +import { SessionMessageInteractables } from './SessionMessageInteractables'; + +const StyledMessagesContainer = styled.div` + display: flex; + gap: var(--margins-sm); + flex-direction: column; + justify-items: end; + position: relative; + overflow-x: hidden; + scrollbar-width: 4px; + padding-top: var(--margins-sm); + padding-bottom: var(--margins-xl); + padding-left: var(--margins-lg); + padding-right: var(--margins-lg); + + .session-icon-button { + display: flex; + justify-content: center; + align-items: center; + height: 40px; + width: 40px; + border-radius: 50%; + } + + ${StyledMessageBubble} { + user-select: text; + } + + ${StyledMentionAnother} { + user-select: all; + } +`; + +// NOTE Must always match the padding of the StyledReadableMessage +const StyledTypingBubbleContainer = styled.div` + padding: var(--margins-xs) var(--margins-lg) 0; +`; function isNotTextboxEvent(e: KeyboardEvent) { return (e?.target as any)?.type === undefined; @@ -24,7 +73,7 @@ function isNotTextboxEvent(e: KeyboardEvent) { let previousRenderedConvo: string | undefined; -export const SessionMessagesList = (props: { +export const SessionMessagesListInner = (props: { scrollAfterLoadMore: ( messageIdToScrollTo: string, type: 'load-more-top' | 'load-more-bottom' @@ -33,6 +82,7 @@ export const SessionMessagesList = (props: { onPageDownPressed: () => void; onHomePressed: () => void; onEndPressed: () => void; + convoReactionsEnabled?: boolean; }) => { const messagesProps = useSelector(getSortedMessagesTypesOfSelectedConversation); const convoKey = useSelectedConversationKey(); @@ -115,7 +165,11 @@ export const SessionMessagesList = (props: { return [ dateBreak, unreadIndicator, - , + , ]; }) // TODO: check if we reverse this upstream, we might be reversing twice @@ -123,3 +177,81 @@ export const SessionMessagesList = (props: { ); }; + +export const messageContainerDomID = 'messages-container'; +export const messageContextMenuID = 'message-context-menu'; +type SessionMessagesListProps = { + messageContainerRef: RefObject; + conversation: ReduxConversationType; + scrollToLoadedMessage: (loadedMessageToScrollTo: string, reason: ScrollToLoadedReasons) => void; + scrollToMessage: (messageId: string, reason: ScrollToLoadedReasons) => void; + scrollToNow: () => Promise; + handleScroll: () => void; + onPageUpPressed: () => void; + onPageDownPressed: () => void; + onHomePressed: () => void; + onEndPressed: () => void; +}; + +export function SessionMessagesList({ + messageContainerRef, + conversation, + scrollToLoadedMessage, + scrollToMessage, + scrollToNow, + handleScroll, + onEndPressed, + onHomePressed, + onPageDownPressed, + onPageUpPressed, +}: SessionMessagesListProps) { + const convoReactionsEnabled = useMemo(() => { + if (conversation.id) { + const conversationModel = ConvoHub.use().get(conversation.id); + if (conversationModel) { + return conversationModel.hasReactions(); + } + } + return true; + }, [conversation.id]); + + return ( + + + + { + scrollToMessage(messageIdToScrollTo, type); + }} + onPageDownPressed={onPageDownPressed} + onPageUpPressed={onPageUpPressed} + onHomePressed={onHomePressed} + onEndPressed={onEndPressed} + convoReactionsEnabled={convoReactionsEnabled} + /> + + + + + + + + + ); +} diff --git a/ts/components/conversation/SessionMessagesListContainer.tsx b/ts/components/conversation/SessionMessagesListContainer.tsx index 824fa5c5c..8fa83644a 100644 --- a/ts/components/conversation/SessionMessagesListContainer.tsx +++ b/ts/components/conversation/SessionMessagesListContainer.tsx @@ -2,7 +2,6 @@ import { connect } from 'react-redux'; import autoBind from 'auto-bind'; import { Component, RefObject } from 'react'; -import styled from 'styled-components'; import { ReduxConversationType, SortedMessageModelProps, @@ -10,12 +9,8 @@ import { resetOldBottomMessageId, resetOldTopMessageId, } from '../../state/ducks/conversations'; -import { SessionScrollButton } from '../SessionScrollButton'; -import { - ScrollToLoadedMessageContext, - ScrollToLoadedReasons, -} from '../../contexts/ScrollToLoadedMessage'; +import { ScrollToLoadedReasons } from '../../contexts/ScrollToLoadedMessage'; import { StateType } from '../../state/reducer'; import { getQuotedMessageToAnimate, @@ -23,17 +18,12 @@ import { getSortedMessagesOfSelectedConversation, } from '../../state/selectors/conversations'; import { getSelectedConversationKey } from '../../state/selectors/selectedConversation'; -import { SessionMessagesList } from './SessionMessagesList'; -import { TypingBubble } from './TypingBubble'; -import { StyledMessageBubble } from './message/message-content/MessageBubble'; -import { StyledMentionAnother } from './AddMentions'; -import { MessagesContainerRefContext } from '../../contexts/MessagesContainerRefContext'; +import { messageContainerDomID, SessionMessagesList } from './SessionMessagesList'; import { closeContextMenus } from '../../util/contextMenu'; export type SessionMessageListProps = { messageContainerRef: RefObject; }; -export const messageContainerDomID = 'messages-container'; type Props = SessionMessageListProps & { conversationKey?: string; @@ -44,40 +34,6 @@ type Props = SessionMessageListProps & { scrollToNow: () => Promise; }; -const StyledMessagesContainer = styled.div` - display: flex; - gap: var(--margins-sm); - flex-direction: column; - justify-items: end; - position: relative; - overflow-x: hidden; - scrollbar-width: 4px; - padding-top: var(--margins-sm); - padding-bottom: var(--margins-xl); - - .session-icon-button { - display: flex; - justify-content: center; - align-items: center; - height: 40px; - width: 40px; - border-radius: 50%; - } - - ${StyledMessageBubble} { - user-select: text; - } - - ${StyledMentionAnother} { - user-select: all; - } -`; - -// NOTE Must always match the padding of the StyledReadableMessage -const StyledTypingBubbleContainer = styled.div` - padding: var(--margins-xs) var(--margins-lg) 0; -`; - class SessionMessagesListContainerInner extends Component { private timeoutResetQuotedScroll: NodeJS.Timeout | null = null; @@ -117,41 +73,18 @@ class SessionMessagesListContainerInner extends Component { } return ( - - - - { - this.scrollToMessage(messageIdToScrollTo, type); - }} - onPageDownPressed={this.scrollPgDown} - onPageUpPressed={this.scrollPgUp} - onHomePressed={this.scrollTop} - onEndPressed={this.scrollEnd} - /> - - - - - - - + ); } diff --git a/ts/components/conversation/TimerNotification.tsx b/ts/components/conversation/TimerNotification.tsx index 2029d5073..feb4f99f5 100644 --- a/ts/components/conversation/TimerNotification.tsx +++ b/ts/components/conversation/TimerNotification.tsx @@ -24,7 +24,7 @@ import { Localizer } from '../basic/Localizer'; import { SessionButtonColor } from '../basic/SessionButton'; import { SessionIcon } from '../icon'; import { getTimerNotificationStr } from '../../models/timerNotifications'; -import type { WithContextMenuId, WithMessageId } from '../../session/types/with'; +import type { WithMessageId } from '../../session/types/with'; import { useMessageAuthor, useMessageAuthorIsUs, @@ -147,8 +147,7 @@ const FollowSettingsButton = ({ messageId }: WithMessageId) => { ); }; -export const TimerNotification = (props: WithMessageId & WithContextMenuId) => { - const { messageId } = props; +export const TimerNotification = ({ messageId }: WithMessageId) => { const timespanSeconds = useMessageExpirationUpdateTimespanSeconds(messageId); const expirationMode = useMessageExpirationUpdateMode(messageId); const disabled = useMessageExpirationUpdateDisabled(messageId); @@ -175,7 +174,6 @@ export const TimerNotification = (props: WithMessageId & WithContextMenuId) => { return ( { - +
); diff --git a/ts/components/conversation/header/ConversationHeaderSelectionOverlay.tsx b/ts/components/conversation/header/ConversationHeaderSelectionOverlay.tsx index 9df2b1ddd..bd342f2e9 100644 --- a/ts/components/conversation/header/ConversationHeaderSelectionOverlay.tsx +++ b/ts/components/conversation/header/ConversationHeaderSelectionOverlay.tsx @@ -60,6 +60,7 @@ export const SelectionOverlay = () => { return ( ref.current} containerDivStyle={{ position: 'absolute', diff --git a/ts/components/conversation/message/message-content/MessageContentWithStatus.tsx b/ts/components/conversation/message/message-content/MessageContentWithStatus.tsx index 235fe7ba6..6f1ea8163 100644 --- a/ts/components/conversation/message/message-content/MessageContentWithStatus.tsx +++ b/ts/components/conversation/message/message-content/MessageContentWithStatus.tsx @@ -1,41 +1,25 @@ -import { useMemo } from 'react'; import { clsx } from 'clsx'; import styled from 'styled-components'; -import { getAppDispatch } from '../../../../state/dispatch'; import { useIsDetailMessageView } from '../../../../contexts/isDetailViewContext'; import { MessageRenderingProps } from '../../../../models/messageType'; -import { updateReactListModal } from '../../../../state/ducks/modalDialog'; -import { - useHideAvatarInMsgList, - useMessageDirection, - useMessageIsOnline, -} from '../../../../state/selectors'; +import { useMessageDirection } from '../../../../state/selectors'; import { Flex } from '../../../basic/Flex'; import { ExpirableReadableMessage } from '../message-item/ExpirableReadableMessage'; import { MessageAuthorText } from './MessageAuthorText'; import { MessageContent } from './MessageContent'; -import { MessageReactions } from './MessageReactions'; -import { MessageStatus } from './MessageStatus'; -import { useMessageReact } from '../../../../hooks/useMessageInteractions'; -import { useSelectedConversationKey } from '../../../../state/selectors/selectedConversation'; -import { ConvoHub } from '../../../../session/conversations'; -import type { WithContextMenuId, WithMessageId } from '../../../../session/types/with'; -import { WithReactionBarOptions } from '../../SessionEmojiReactBarPopover'; +import type { WithMessageId } from '../../../../session/types/with'; export type MessageContentWithStatusSelectorProps = { isGroup: boolean } & Pick< MessageRenderingProps, 'conversationType' | 'direction' | 'isDeleted' >; -type Props = WithMessageId & WithContextMenuId & WithReactionBarOptions; - const StyledMessageContentContainer = styled.div<{ $isIncoming: boolean; $isDetailView: boolean }>` display: flex; flex-direction: column; justify-content: flex-start; align-items: ${props => (props.$isIncoming ? 'flex-start' : 'flex-end')}; - padding-left: ${props => (props.$isDetailView || props.$isIncoming ? 0 : '25%')}; - padding-right: ${props => (props.$isDetailView || !props.$isIncoming ? 0 : '25%')}; + width: 100%; `; @@ -47,28 +31,10 @@ const StyledMessageWithAuthor = styled.div` gap: var(--margins-xs); `; -export const MessageContentWithStatuses = (props: Props) => { - const { messageId, contextMenuId, reactionBarOptions } = props; - const dispatch = getAppDispatch(); - const reactToMessage = useMessageReact(messageId); - const hideAvatar = useHideAvatarInMsgList(messageId); +export const MessageContentWithStatuses = ({ messageId }: WithMessageId) => { const isDetailView = useIsDetailMessageView(); - const _direction = useMessageDirection(messageId); - const convoId = useSelectedConversationKey(); - const msgIsOnline = useMessageIsOnline(messageId); - - const convoReactionsEnabled = useMemo(() => { - if (convoId) { - const conversationModel = ConvoHub.use().get(convoId); - if (conversationModel) { - return conversationModel.hasReactions(); - } - } - return true; - }, [convoId]); - if (!messageId) { return null; } @@ -77,25 +43,12 @@ export const MessageContentWithStatuses = (props: Props) => { const direction = isDetailView ? 'incoming' : _direction; const isIncoming = direction === 'incoming'; - const enableReactions = convoReactionsEnabled && msgIsOnline; - - const handlePopupClick = (emoji: string) => { - dispatch( - updateReactListModal({ - reaction: emoji, - messageId, - }) - ); - }; - return ( { {!isDetailView && } - - {!isDetailView && enableReactions ? ( - void reactToMessage(emoji) : undefined} - onPopupClick={handlePopupClick} - noAvatar={hideAvatar} - /> - ) : null} ); }; diff --git a/ts/components/conversation/message/message-content/MessageContextMenu.tsx b/ts/components/conversation/message/message-content/MessageContextMenu.tsx index de0f0df95..8a2772caa 100644 --- a/ts/components/conversation/message/message-content/MessageContextMenu.tsx +++ b/ts/components/conversation/message/message-content/MessageContextMenu.tsx @@ -2,7 +2,7 @@ import { Dispatch, type KeyboardEvent, type MouseEvent, useRef } from 'react'; import { isNil, isNumber, isString } from 'lodash'; -import { MenuOnHideCallback, MenuOnShowCallback } from 'react-contexify'; +import { MenuOnHideCallback, type MenuOnShowCallback } from 'react-contexify'; import styled from 'styled-components'; import { toNumber } from 'lodash/fp'; import { useSelector } from 'react-redux'; @@ -10,7 +10,11 @@ import { getAppDispatch } from '../../../../state/dispatch'; import { Data } from '../../../../data/data'; import { MessageRenderingProps } from '../../../../models/messageType'; -import { openRightPanel, showMessageInfoView } from '../../../../state/ducks/conversations'; +import { + openRightPanel, + setReactionBarTriggerPosition, + showMessageInfoView, +} from '../../../../state/ducks/conversations'; import { useMessageIsControlMessage, useMessageIsDeleted, @@ -44,29 +48,29 @@ import { } from '../../../../hooks/useMessageInteractions'; import { SelectMessageMenuItem } from '../../../menu/items/SelectMessage/SelectMessageMenuItem'; import { DeleteItem } from '../../../menu/items/DeleteMessage/DeleteMessageMenuItem'; -import { WithReactionBarOptions } from '../../SessionEmojiReactBarPopover'; +import { messageContextMenuID } from '../../SessionMessagesList'; export type MessageContextMenuSelectorProps = Pick< MessageRenderingProps, 'sender' | 'direction' | 'status' | 'isSenderAdmin' | 'text' | 'serverTimestamp' | 'timestamp' >; -type Props = WithMessageId & WithContextMenuId & WithReactionBarOptions; +type Props = WithContextMenuId & + Partial & { + onShow?: MenuOnShowCallback; + onHide?: MenuOnHideCallback; + onClickCapture?: (e: MouseEvent) => void; + }; const CONTEXTIFY_MENU_WIDTH_PX = 200; const SCREEN_RIGHT_MARGIN_PX = 104; export type ShowMessageContextMenuParams = { - id: string; event: MouseEvent | KeyboardEvent; triggerPosition?: { x: number; y: number }; }; -export function showMessageContextMenu({ - id, - event, - triggerPosition, -}: ShowMessageContextMenuParams) { +export function showMessageContextMenu({ event, triggerPosition }: ShowMessageContextMenuParams) { // this is quite dirty but considering that we want the context menu of the message to show on click on the attachment // and the context menu save attachment item to save the right attachment I did not find a better way for now. // NOTE: If you change this, also make sure to update the `saveAttachment()` @@ -99,7 +103,7 @@ export function showMessageContextMenu({ const position = { x: clampNumber(_triggerPosition.x, 0, MAX_TRIGGER_X), y: _triggerPosition.y }; showContextMenu({ - id, + id: messageContextMenuID, event, position, props: { @@ -249,9 +253,14 @@ function MessageReplyMenuItem({ messageId }: { messageId: string }) { ) : null; } -export const MessageContextMenu = (props: Props) => { - const { messageId, contextMenuId, reactionBarOptions } = props; - +export const MessageContextMenu = ({ + messageId, + contextMenuId, + onShow, + onHide, + onClickCapture, +}: Props) => { + const dispatch = getAppDispatch(); const contextMenuRef = useRef(null); const isLegacyGroup = useSelectedIsLegacyGroup(); const convoId = useSelectedConversationKey(); @@ -259,7 +268,7 @@ export const MessageContextMenu = (props: Props) => { const sender = useMessageSender(messageId); const isControlMessage = useMessageIsControlMessage(messageId); - const onShow: MenuOnShowCallback = (_, { x, y }) => { + const _onShow: MenuOnShowCallback = (_, { x, y }) => { const triggerHeight = contextMenuRef.current?.clientHeight ?? 0; const triggerWidth = contextMenuRef.current?.clientWidth ?? 0; @@ -267,41 +276,24 @@ export const MessageContextMenu = (props: Props) => { // it does not include changes to prevent the menu from overflowing the window. This temporary // fix resolves this by mirroring the y-offset adjustment. const yClamped = clampNumber(y, 0, window.innerHeight - triggerHeight); - if (reactionBarOptions) { - reactionBarOptions.setTriggerPosition({ - x, - // Changes the x-anchor from the center to the far left - offsetX: -triggerWidth / 2, - y: yClamped, - height: triggerHeight, - width: triggerWidth, - }); - } - }; - const onHide: MenuOnHideCallback = () => { - reactionBarOptions?.setTriggerPosition(null); + const pos = { + x, + // Changes the x-anchor from the center to the far left + offsetX: -triggerWidth / 2, + y: yClamped, + height: triggerHeight, + width: triggerWidth, + }; + dispatch(setReactionBarTriggerPosition(pos)); + onShow?.(_, { x, y }); }; - if (!convoId) { - return null; - } - - if (isDeleted || isControlMessage) { - return ( - - - - - - - - - ); - } + const _onHide: MenuOnHideCallback = fromVisible => { + onHide?.(fromVisible); + }; - if (isLegacyGroup) { - // legacy groups are deprecated + if (!convoId || isLegacyGroup) { return null; } @@ -311,19 +303,30 @@ export const MessageContextMenu = (props: Props) => { - - - - - - - - - + {!messageId ? null : isDeleted || isControlMessage ? ( + <> + + + + ) : ( + <> + + + + + + + + + + + )} diff --git a/ts/components/conversation/message/message-content/MessageReactions.tsx b/ts/components/conversation/message/message-content/MessageReactions.tsx index 4cc34df1c..0ea134c9b 100644 --- a/ts/components/conversation/message/message-content/MessageReactions.tsx +++ b/ts/components/conversation/message/message-content/MessageReactions.tsx @@ -166,7 +166,7 @@ export const MessageReactions = ({ const reactions = msgProps.sortedReacts ?? []; const onClick = !isDetailView && onEmojiClick ? onEmojiClick : undefined; const handleExpand = () => { - setIsExpanded(expanded => !expanded); + setIsExpanded(!isExpanded); }; const ReactionsComp = diff --git a/ts/components/conversation/message/message-content/MessageStatus.tsx b/ts/components/conversation/message/message-content/MessageStatus.tsx index 54cfe48b0..cafacf42b 100644 --- a/ts/components/conversation/message/message-content/MessageStatus.tsx +++ b/ts/components/conversation/message/message-content/MessageStatus.tsx @@ -190,7 +190,7 @@ const MessageStatusSent = ({ dataTestId, messageId }: Omit - + ); }; diff --git a/ts/components/conversation/message/message-item/CommunityInvitation.tsx b/ts/components/conversation/message/message-item/CommunityInvitation.tsx index d6857683b..101f7fad9 100644 --- a/ts/components/conversation/message/message-item/CommunityInvitation.tsx +++ b/ts/components/conversation/message/message-item/CommunityInvitation.tsx @@ -1,75 +1,55 @@ import styled from 'styled-components'; - import { useMemo } from 'react'; -import clsx from 'clsx'; - import { acceptOpenGroupInvitation } from '../../../../interactions/messageInteractions'; import { ExpirableReadableMessage } from './ExpirableReadableMessage'; import { useMessageCommunityInvitationFullUrl, useMessageCommunityInvitationCommunityName, - useMessageDirection, + useMessageDirectionIncoming, } from '../../../../state/selectors'; -import type { WithContextMenuId, WithMessageId } from '../../../../session/types/with'; +import type { WithMessageId } from '../../../../session/types/with'; import { SessionLucideIconButton } from '../../../icon/SessionIconButton'; import { LUCIDE_ICONS_UNICODE } from '../../../icon/lucide'; import { tr } from '../../../../localization/localeTools'; -const StyledCommunityInvitation = styled.div` - background-color: var(--message-bubble-incoming-background-color); - - &.invitation-outgoing { - background-color: var(--message-bubble-outgoing-background-color); - align-self: flex-end; - - .contents { - .group-details { - color: var(--message-bubble-outgoing-text-color); - } - .session-icon-button { - background-color: var(--transparent-color); - } - } - } - - display: inline-block; - padding: 4px; - margin: var(--margins-xs) calc(var(--margins-lg) + var(--margins-md)) 0 var(--margins-lg); - +const StyledCommunityInvitation = styled.div<{ $isIncoming: boolean }>` + background-color: ${props => + props.$isIncoming + ? 'var(--message-bubble-incoming-background-color)' + : 'var(--message-bubble-outgoing-background-color)'}; + color: ${props => + props.$isIncoming + ? 'var(--message-bubble-incoming-text-color)' + : 'var(--message-bubble-outgoing-text-color)'}; + + padding: var(--margins-sm); border-radius: var(--border-radius-message-box); + cursor: pointer; +`; - align-self: flex-start; - - box-shadow: none; - - .contents { - display: flex; - align-items: center; - margin: 6px; - - .invite-group-avatar { - height: 48px; - width: 48px; - } +const StyledCommunityContentsContainer = styled.div` + display: flex; + align-items: center; +`; - .group-details { - display: inline-flex; - flex-direction: column; - color: var(--message-bubble-incoming-text-color); +const StyledCommunityDetailsContainer = styled.div` + display: inline-flex; + flex-direction: column; + padding: 0px var(--margins-sm); + line-height: var(--font-line-height); +`; - padding: 0px 12px; - .group-name { - font-weight: bold; - font-size: 18px; - } - } +const StyledCommunityName = styled.div` + font-weight: bold; + font-size: var(--font-size-lg); +`; - .session-icon-button { - background-color: var(--primary-color); - } - } +const StyledCommunityType = styled.div` + font-size: var(--font-size-sm); +`; - cursor: pointer; +const StyledCommunityUrl = styled.div` + font-size: var(--font-size-xs); `; const StyledIconContainer = styled.div` @@ -77,12 +57,11 @@ const StyledIconContainer = styled.div` border-radius: 100%; `; -export const CommunityInvitation = (props: WithMessageId & WithContextMenuId) => { - const messageDirection = useMessageDirection(props.messageId); - const classes = ['group-invitation']; +export const CommunityInvitation = ({ messageId }: WithMessageId) => { + const isIncoming = useMessageDirectionIncoming(messageId); - const fullUrl = useMessageCommunityInvitationFullUrl(props.messageId); - const communityName = useMessageCommunityInvitationCommunityName(props.messageId); + const fullUrl = useMessageCommunityInvitationFullUrl(messageId); + const communityName = useMessageCommunityInvitationCommunityName(messageId); const hostname = useMemo(() => { try { @@ -94,51 +73,38 @@ export const CommunityInvitation = (props: WithMessageId & WithContextMenuId) => } }, [fullUrl]); - if (messageDirection === 'outgoing') { - classes.push('invitation-outgoing'); - } - if (!fullUrl || !hostname) { return null; } return ( { acceptOpenGroupInvitation(fullUrl, communityName); }} > -
+ - - {communityName} - {tr('communityInvitation')} - {hostname} - -
+ + {communityName} + {tr('communityInvitation')} + {hostname} + +
); diff --git a/ts/components/conversation/message/message-item/DataExtractionNotification.tsx b/ts/components/conversation/message/message-item/DataExtractionNotification.tsx index acc270b96..f55484823 100644 --- a/ts/components/conversation/message/message-item/DataExtractionNotification.tsx +++ b/ts/components/conversation/message/message-item/DataExtractionNotification.tsx @@ -3,12 +3,11 @@ import { NotificationBubble } from './notification-bubble/NotificationBubble'; import { Localizer } from '../../../basic/Localizer'; import { useMessageAuthor, useMessageDataExtractionType } from '../../../../state/selectors'; import { useConversationUsernameWithFallback } from '../../../../hooks/useParamSelector'; -import type { WithContextMenuId, WithMessageId } from '../../../../session/types/with'; +import type { WithMessageId } from '../../../../session/types/with'; import { SignalService } from '../../../../protobuf'; import { LUCIDE_ICONS_UNICODE } from '../../../icon/lucide'; -export const DataExtractionNotification = (props: WithMessageId & WithContextMenuId) => { - const { messageId } = props; +export const DataExtractionNotification = ({ messageId }: WithMessageId) => { const author = useMessageAuthor(messageId); const authorName = useConversationUsernameWithFallback(true, author); @@ -21,7 +20,6 @@ export const DataExtractionNotification = (props: WithMessageId & WithContextMen return ( diff --git a/ts/components/conversation/message/message-item/ExpirableReadableMessage.tsx b/ts/components/conversation/message/message-item/ExpirableReadableMessage.tsx index 09270ada3..a44f7fa00 100644 --- a/ts/components/conversation/message/message-item/ExpirableReadableMessage.tsx +++ b/ts/components/conversation/message/message-item/ExpirableReadableMessage.tsx @@ -4,7 +4,6 @@ import { useSelector } from 'react-redux'; import { useCallback, useLayoutEffect, - useRef, useState, type AriaRole, type MouseEvent, @@ -30,8 +29,7 @@ import { getIncrement } from '../../../../util/timer'; import { ExpireTimer } from '../../ExpireTimer'; import { Data } from '../../../../data/data'; import { ConvoHub } from '../../../../session/conversations'; -import { MessageContextMenu } from '../message-content/MessageContextMenu'; -import type { WithContextMenuId, WithConvoId, WithMessageId } from '../../../../session/types/with'; +import type { WithConvoId, WithMessageId } from '../../../../session/types/with'; import { useScrollToLoadedMessage } from '../../../../contexts/ScrollToLoadedMessage'; import { getMostRecentMessageId, @@ -45,11 +43,6 @@ import { getIsAppFocused } from '../../../../state/selectors/section'; import { useSelectedConversationKey } from '../../../../state/selectors/selectedConversation'; import { useMessageType } from '../../../../state/selectors'; import { useSelectMessageViaClick } from '../../../../hooks/useMessageInteractions'; -import { - SessionEmojiReactBarPopover, - type WithReactionBarOptions, -} from '../../SessionEmojiReactBarPopover'; -import { SessionFocusTrap } from '../../../SessionFocusTrap'; const EXPIRATION_CHECK_MINIMUM = 2000; @@ -296,9 +289,7 @@ const ReadableMessage = ( }; export type ExpirableReadableMessageProps = Omit & - WithMessageId & - WithContextMenuId & - WithReactionBarOptions; + WithMessageId; function ExpireTimerControlMessage({ expirationTimestamp, @@ -321,56 +312,11 @@ const useIsDetailMessageViewInternal = useIsDetailMessageView; const useSelectMessageViaClickInternal = useSelectMessageViaClick; const useMessageTypeInternal = useMessageType; -function SessionMessageInteractables({ - messageId, - contextMenuId, - reactionBarOptions, -}: WithMessageId & WithContextMenuId & WithReactionBarOptions) { - const reactionBarFirstEmojiRef = useRef(null); - const isDetailView = useIsDetailMessageViewInternal(); - - if (isDetailView) { - return null; - } - - const closeReactionBar = reactionBarOptions - ? () => { - reactionBarOptions.setTriggerPosition(null); - } - : undefined; - - const reactionBarFocusTrapActive = - !!reactionBarOptions?.triggerPosition && !!reactionBarFirstEmojiRef.current; - return ( - reactionBarFirstEmojiRef.current ?? false} - onDeactivate={closeReactionBar} - clickOutsideDeactivates={true} - > - {reactionBarOptions ? ( - - ) : null} - - - ); -} - export const ExpirableReadableMessage = ({ onDoubleClickCapture, role, dataTestId, - contextMenuId, messageId, - reactionBarOptions, children, }: ExpirableReadableMessageProps) => { const selected = useMessageExpirationPropsByIdInternal(messageId); @@ -429,11 +375,6 @@ export const ExpirableReadableMessage = ({ expirationTimestamp={expirationTimestamp} /> ) : null} - {children} ); diff --git a/ts/components/conversation/message/message-item/GenericReadableInteractableMessage.tsx b/ts/components/conversation/message/message-item/GenericReadableInteractableMessage.tsx index e4154b381..5f1ba9d9f 100644 --- a/ts/components/conversation/message/message-item/GenericReadableInteractableMessage.tsx +++ b/ts/components/conversation/message/message-item/GenericReadableInteractableMessage.tsx @@ -1,14 +1,10 @@ -import { - type MouseEvent, - type KeyboardEvent, - useCallback, - useRef, - useState, - useEffect, -} from 'react'; +import { type MouseEvent, type KeyboardEvent, useCallback, useRef } from 'react'; import clsx from 'clsx'; import { + useHideAvatarInMsgList, useMessageDirection, + useMessageIsControlMessage, + useMessageIsOnline, useMessageSelected, useMessageType, } from '../../../../state/selectors'; @@ -23,36 +19,52 @@ import { showMessageContextMenu } from '../message-content/MessageContextMenu'; import { getAppDispatch } from '../../../../state/dispatch'; import { setFocusedMessageId, + setInteractableMessageId, + setReactionBarTriggerPosition, toggleSelectedMessageId, } from '../../../../state/ducks/conversations'; import { PopoverTriggerPosition } from '../../../SessionTooltip'; import { useKeyboardShortcut } from '../../../../hooks/useKeyboardShortcut'; import type { WithMessageId } from '../../../../session/types/with'; -import { useFocusScope, useIsInScope } from '../../../../state/focus'; import { closeContextMenus } from '../../../../util/contextMenu'; import { trimWhitespace } from '../../../../session/utils/String'; -import { useMessageReply } from '../../../../hooks/useMessageInteractions'; +import { useMessageReact, useMessageReply } from '../../../../hooks/useMessageInteractions'; import { GenericReadableMessage } from './GenericReadableMessage'; -export function GenericReadableInteractableMessage({ messageId }: WithMessageId) { +import { updateReactListModal } from '../../../../state/ducks/modalDialog'; +import { MessageReactions } from '../message-content/MessageReactions'; +import { MessageStatus } from '../message-content/MessageStatus'; +import { + useInteractableMessageId, + useReactionBarTriggerPosition, +} from '../../../../state/selectors/conversations'; + +type GenericReadableInteractableMessageProps = WithMessageId & { + convoReactionsEnabled?: boolean; +}; + +export function GenericReadableInteractableMessage({ + messageId, + convoReactionsEnabled, +}: GenericReadableInteractableMessageProps) { const dispatch = getAppDispatch(); - const ctxMenuID = `ctx-menu-message-${messageId}`; - const isMessageSelected = useMessageSelected(messageId); + const isControlMessage = useMessageIsControlMessage(messageId); const selectedIsBlocked = useSelectedIsBlocked(); const multiSelectMode = useIsMessageSelectionMode(); - const convoId = useSelectedConversationKey(); + const reactToMessage = useMessageReact(messageId); + const hideAvatar = useHideAvatarInMsgList(messageId); const direction = useMessageDirection(messageId); const isKickedFromGroup = useSelectedIsKickedFromGroup(); + const messageType = useMessageType(messageId); + const msgIsOnline = useMessageIsOnline(messageId); + const interactableMessageId = useInteractableMessageId(); + const reactionBarTriggerPosition = useReactionBarTriggerPosition(); const ref = useRef(null); const pointerDownRef = useRef(false); - const [triggerPosition, setTriggerPosition] = useState(null); - const isInFocusScope = useIsInScope({ scope: 'message', scopeId: messageId }); - const { focusedMessageId } = useFocusScope(); - const isAnotherMessageFocused = focusedMessageId && !isInFocusScope; const reply = useMessageReply(messageId); const focusMessageId = () => { @@ -64,7 +76,9 @@ export function GenericReadableInteractableMessage({ messageId }: WithMessageId) }; const onBlur = () => { - dispatch(setFocusedMessageId(null)); + if (!reactionBarTriggerPosition) { + dispatch(setFocusedMessageId(null)); + } pointerDownRef.current = false; }; @@ -94,14 +108,14 @@ export function GenericReadableInteractableMessage({ messageId }: WithMessageId) overridePosition?: { x: number; y: number } ) => { if (!selectedIsBlocked && !multiSelectMode && !isKickedFromGroup) { + dispatch(setInteractableMessageId(messageId)); showMessageContextMenu({ - id: ctxMenuID, event: e, triggerPosition: overridePosition, }); } }, - [selectedIsBlocked, ctxMenuID, multiSelectMode, isKickedFromGroup] + [dispatch, selectedIsBlocked, multiSelectMode, isKickedFromGroup, messageId] ); const onKeyDown = (e: KeyboardEvent) => { @@ -119,13 +133,14 @@ export function GenericReadableInteractableMessage({ messageId }: WithMessageId) }; const toggleEmojiReactionBarWithKeyboard = () => { - if (triggerPosition) { + if (interactableMessageId === messageId && !!reactionBarTriggerPosition) { closeContextMenus(); - setTriggerPosition(null); + dispatch(setReactionBarTriggerPosition(null)); } else { const pos = getMessageContainerTriggerPosition(); if (pos) { - setTriggerPosition(pos); + dispatch(setInteractableMessageId(messageId)); + dispatch(setReactionBarTriggerPosition(pos)); } } }; @@ -165,14 +180,16 @@ export function GenericReadableInteractableMessage({ messageId }: WithMessageId) scopeId: messageId, }); - const messageType = useMessageType(messageId); - - useEffect(() => { - if (isAnotherMessageFocused) { - setTriggerPosition(null); - } - }, [isAnotherMessageFocused]); + const enableReactions = convoReactionsEnabled && msgIsOnline; + const handlePopupClick = (emoji: string) => { + dispatch( + updateReactListModal({ + reaction: emoji, + messageId, + }) + ); + }; if (!convoId || !messageId || !messageType) { return null; } @@ -183,13 +200,15 @@ export function GenericReadableInteractableMessage({ messageId }: WithMessageId) + > + {enableReactions ? ( + void reactToMessage(emoji) : undefined} + onPopupClick={handlePopupClick} + noAvatar={hideAvatar} + /> + ) : null} + {isControlMessage ? null : } + ); } diff --git a/ts/components/conversation/message/message-item/GenericReadableMessage.tsx b/ts/components/conversation/message/message-item/GenericReadableMessage.tsx index 2c80f1d62..60bef33e2 100644 --- a/ts/components/conversation/message/message-item/GenericReadableMessage.tsx +++ b/ts/components/conversation/message/message-item/GenericReadableMessage.tsx @@ -2,11 +2,15 @@ import type { HTMLProps } from 'react'; import clsx from 'clsx'; import styled, { keyframes } from 'styled-components'; import { MessageRenderingProps } from '../../../../models/messageType'; -import { useMessageType } from '../../../../state/selectors'; +import { + useMessageDirectionIncoming, + useMessageIsControlMessage, + useMessageType, +} from '../../../../state/selectors'; import { MessageContentWithStatuses } from '../message-content/MessageContentWithStatus'; import { StyledMessageReactionsContainer } from '../message-content/MessageReactions'; import { type UIMessageType } from '../../../../state/ducks/conversations'; -import type { WithContextMenuId, WithMessageId } from '../../../../session/types/with'; +import type { WithMessageId } from '../../../../session/types/with'; import { CommunityInvitation } from './CommunityInvitation'; import { DataExtractionNotification } from './DataExtractionNotification'; import { TimerNotification } from '../../TimerNotification'; @@ -14,7 +18,6 @@ import { GroupUpdateMessage } from './GroupUpdateMessage'; import { CallNotification } from './notification-bubble/CallNotification'; import { InteractionNotification } from './InteractionNotification'; import { MessageRequestResponse } from './MessageRequestResponse'; -import { WithReactionBarOptions } from '../../SessionEmojiReactBarPopover'; import { useIsDetailMessageView } from '../../../../contexts/isDetailViewContext'; export type GenericReadableMessageSelectorProps = Pick< @@ -31,14 +34,21 @@ type StyledReadableMessageProps = { // TODO: remove this, we can add styles to the message list $isDetailView?: boolean; $focusedKeyboard?: boolean; + $forceFocusStyle?: boolean; + $isIncoming?: boolean; + $isControlMessage?: boolean; }; const StyledReadableMessage = styled.div` display: flex; - align-items: center; + flex-direction: column; + align-items: ${props => (props.$isIncoming ? 'flex-start' : 'flex-end')}; width: 100%; letter-spacing: 0.03rem; - padding: ${props => (props.$isDetailView ? '0' : 'var(--margins-xs) var(--margins-lg) 0')}; + padding-left: ${props => + props.$isDetailView || props.$isIncoming || props.$isControlMessage ? 0 : '25%'}; + padding-right: ${props => + props.$isDetailView || !props.$isIncoming || props.$isControlMessage ? 0 : '25%'}; &.message-highlighted { animation: ${highlightedMessageAnimation} var(--duration-message-highlight) ease-in-out; @@ -54,6 +64,11 @@ const StyledReadableMessage = styled.div` background-color: var(--conversation-tab-background-selected-color); }` : ''} + + ${props => + props.$forceFocusStyle + ? 'background-color: var(--conversation-tab-background-selected-color);' + : ''} `; function getMessageComponent(messageType: UIMessageType) { @@ -80,23 +95,20 @@ function getMessageComponent(messageType: UIMessageType) { } type GenericReadableMessageProps = Partial< - HTMLProps & - Omit & - WithMessageId & - WithContextMenuId & - WithReactionBarOptions + HTMLProps & Omit & WithMessageId >; export const GenericReadableMessage = ({ ref, messageId, selected, - contextMenuId, - reactionBarOptions, + children, ...rest }: GenericReadableMessageProps) => { const messageType = useMessageType(messageId); const isDetailView = useIsDetailMessageView(); + const isControlMessage = useMessageIsControlMessage(messageId); + const isIncoming = useMessageDirectionIncoming(messageId, isDetailView); if (!messageId || !messageType) { return null; @@ -113,15 +125,13 @@ export const GenericReadableMessage = ({ ref={ref} className={clsx(selected ? 'message-selected' : undefined)} selected={selected} + $isIncoming={isIncoming} + $isControlMessage={isControlMessage} {...rest} $isDetailView={isDetailView} > - + + {children} ); }; diff --git a/ts/components/conversation/message/message-item/GroupUpdateMessage.tsx b/ts/components/conversation/message/message-item/GroupUpdateMessage.tsx index 1eefeb3ed..dd08547e1 100644 --- a/ts/components/conversation/message/message-item/GroupUpdateMessage.tsx +++ b/ts/components/conversation/message/message-item/GroupUpdateMessage.tsx @@ -13,7 +13,7 @@ import { import { Localizer } from '../../../basic/Localizer'; import { ExpirableReadableMessage } from './ExpirableReadableMessage'; import { NotificationBubble } from './notification-bubble/NotificationBubble'; -import type { WithContextMenuId, WithMessageId } from '../../../../session/types/with'; +import type { WithMessageId } from '../../../../session/types/with'; import { useMessageGroupUpdateChange } from '../../../../state/selectors'; import { assertUnreachable } from '../../../../types/sqlSharedTypes'; import { LUCIDE_ICONS_UNICODE } from '../../../icon/lucide'; @@ -66,8 +66,8 @@ function useChangeItem(change?: PropsForGroupUpdateType): TrArgs | null { // NOTE: [react-compiler] this convinces the compiler the hook is static const useMessageGroupUpdateChangeInternal = useMessageGroupUpdateChange; -export const GroupUpdateMessage = (props: WithMessageId & WithContextMenuId) => { - const groupChange = useMessageGroupUpdateChangeInternal(props.messageId); +export const GroupUpdateMessage = ({ messageId }: WithMessageId) => { + const groupChange = useMessageGroupUpdateChangeInternal(messageId); const changeProps = useChangeItem(groupChange); @@ -77,9 +77,8 @@ export const GroupUpdateMessage = (props: WithMessageId & WithContextMenuId) => return ( diff --git a/ts/components/conversation/message/message-item/InteractionNotification.tsx b/ts/components/conversation/message/message-item/InteractionNotification.tsx index 96e9377be..0176f88c5 100644 --- a/ts/components/conversation/message/message-item/InteractionNotification.tsx +++ b/ts/components/conversation/message/message-item/InteractionNotification.tsx @@ -12,7 +12,7 @@ import { useSelectedIsPublic, } from '../../../../state/selectors/selectedConversation'; import { useMessageInteractionNotification } from '../../../../state/selectors'; -import type { WithContextMenuId, WithMessageId } from '../../../../session/types/with'; +import type { WithMessageId } from '../../../../session/types/with'; import { tr } from '../../../../localization/localeTools'; import { useConversationUsernameWithFallback } from '../../../../hooks/useParamSelector'; import { ExpirableReadableMessage } from './ExpirableReadableMessage'; @@ -21,10 +21,7 @@ const StyledFailText = styled.div` color: var(--danger-color); `; -export const InteractionNotification = ({ - messageId, - contextMenuId, -}: WithMessageId & WithContextMenuId) => { +export const InteractionNotification = ({ messageId }: WithMessageId) => { const convoId = useSelectedConversationKey(); const displayName = useConversationUsernameWithFallback(true, convoId); const isGroup = !useSelectedIsPrivate(); @@ -74,7 +71,6 @@ export const InteractionNotification = ({ return ( diff --git a/ts/components/conversation/message/message-item/MessageRequestResponse.tsx b/ts/components/conversation/message/message-item/MessageRequestResponse.tsx index 55d0e0f42..1a2cbf825 100644 --- a/ts/components/conversation/message/message-item/MessageRequestResponse.tsx +++ b/ts/components/conversation/message/message-item/MessageRequestResponse.tsx @@ -1,5 +1,5 @@ import { useConversationUsernameWithFallback } from '../../../../hooks/useParamSelector'; -import type { WithContextMenuId, WithMessageId } from '../../../../session/types/with'; +import type { WithMessageId } from '../../../../session/types/with'; import { useMessageAuthorIsUs } from '../../../../state/selectors'; import { useSelectedConversationKey } from '../../../../state/selectors/selectedConversation'; import { Flex } from '../../../basic/Flex'; @@ -7,10 +7,7 @@ import { Localizer } from '../../../basic/Localizer'; import { SpacerSM, TextWithChildren } from '../../../basic/Text'; import { ExpirableReadableMessage } from './ExpirableReadableMessage'; -export const MessageRequestResponse = ({ - messageId, - contextMenuId, -}: WithMessageId & WithContextMenuId) => { +export const MessageRequestResponse = ({ messageId }: WithMessageId) => { const conversationId = useSelectedConversationKey(); const isUs = useMessageAuthorIsUs(messageId); @@ -23,7 +20,6 @@ export const MessageRequestResponse = ({ return ( diff --git a/ts/components/conversation/message/message-item/notification-bubble/CallNotification.tsx b/ts/components/conversation/message/message-item/notification-bubble/CallNotification.tsx index 5afb64551..9b42dfbcf 100644 --- a/ts/components/conversation/message/message-item/notification-bubble/CallNotification.tsx +++ b/ts/components/conversation/message/message-item/notification-bubble/CallNotification.tsx @@ -5,7 +5,7 @@ import { ExpirableReadableMessage } from '../ExpirableReadableMessage'; import { NotificationBubble } from './NotificationBubble'; import { Localizer } from '../../../../basic/Localizer'; import { MergedLocalizerTokens } from '../../../../../localization/localeTools'; -import type { WithContextMenuId, WithMessageId } from '../../../../../session/types/with'; +import type { WithMessageId } from '../../../../../session/types/with'; import { useMessageCallNotificationType } from '../../../../../state/selectors'; import { LUCIDE_ICONS_UNICODE } from '../../../../icon/lucide'; @@ -32,8 +32,8 @@ const style = { }, } satisfies StyleType; -export const CallNotification = (props: WithMessageId & WithContextMenuId) => { - const notificationType = useMessageCallNotificationType(props.messageId); +export const CallNotification = ({ messageId }: WithMessageId) => { + const notificationType = useMessageCallNotificationType(messageId); const name = useSelectedNicknameOrProfileNameOrShortenedPubkey(); @@ -45,9 +45,8 @@ export const CallNotification = (props: WithMessageId & WithContextMenuId) => { return ( diff --git a/ts/components/lightbox/Lightbox.tsx b/ts/components/lightbox/Lightbox.tsx index 142abc39c..5087928b6 100644 --- a/ts/components/lightbox/Lightbox.tsx +++ b/ts/components/lightbox/Lightbox.tsx @@ -282,6 +282,7 @@ export const Lightbox = (props: Props) => { return ( { return closeButtonRef.current; }} diff --git a/ts/hooks/useMessageInteractions.ts b/ts/hooks/useMessageInteractions.ts index a5be9ac76..baa41c538 100644 --- a/ts/hooks/useMessageInteractions.ts +++ b/ts/hooks/useMessageInteractions.ts @@ -95,7 +95,8 @@ export function useMessageReply(messageId?: string) { } export function useMessageReact(messageId?: string) { - const cannotReact = !messageId; + const isControlMessage = useMessageIsControlMessage(messageId); + const cannotReact = !messageId || isControlMessage; return cannotReact ? null diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 6f8fb695e..17c7db545 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -36,6 +36,7 @@ import type { ProMessageFeature } from '../../models/proMessageFeature'; import { handleTriggeredCTAs } from '../../components/dialog/SessionCTA'; import { getFeatureFlag } from './types/releasedFeaturesReduxTypes'; import type { Quote } from '../../session/messages/outgoing/visibleMessage/VisibleMessage'; +import { PopoverTriggerPosition } from '../../components/SessionTooltip'; export type UIMessageType = | 'community-invitation' @@ -360,6 +361,8 @@ export type ConversationsStateType = { nextMessageToPlayId?: string; mentionMembers: Array; focusedMessageId: string | null; + interactableMessageId: string | null; + reactionBarTriggerPosition: PopoverTriggerPosition | null; isCompositionTextAreaFocused: boolean; }; @@ -547,6 +550,8 @@ export function getEmptyConversationState(): ConversationsStateType { shouldHighlightMessage: false, mostRecentMessageId: null, focusedMessageId: null, + interactableMessageId: null, + reactionBarTriggerPosition: null, isCompositionTextAreaFocused: false, }; } @@ -735,6 +740,15 @@ const conversationsSlice = createSlice({ setFocusedMessageId(state: ConversationsStateType, action: PayloadAction) { return { ...state, focusedMessageId: action.payload }; }, + setInteractableMessageId(state: ConversationsStateType, action: PayloadAction) { + return { ...state, interactableMessageId: action.payload }; + }, + setReactionBarTriggerPosition( + state: ConversationsStateType, + action: PayloadAction + ) { + return { ...state, reactionBarTriggerPosition: action.payload }; + }, setIsCompositionTextAreaFocused(state: ConversationsStateType, action: PayloadAction) { return { ...state, isCompositionTextAreaFocused: action.payload }; }, @@ -898,6 +912,8 @@ const conversationsSlice = createSlice({ oldBottomMessageId: null, mentionMembers: [], focusedMessageId: null, + interactableMessageId: null, + reactionBarTriggerPosition: null, isCompositionTextAreaFocused: false, }; }, @@ -1172,6 +1188,8 @@ export const { updateMentionsMembers, resetConversationExternal, markConversationInitialLoadingInProgress, + setReactionBarTriggerPosition, + setInteractableMessageId, } = actions; async function unmarkAsForcedUnread(convoId: string) { diff --git a/ts/state/ducks/types/defaultFeatureFlags.ts b/ts/state/ducks/types/defaultFeatureFlags.ts index 6cffe955d..7fe2072dd 100644 --- a/ts/state/ducks/types/defaultFeatureFlags.ts +++ b/ts/state/ducks/types/defaultFeatureFlags.ts @@ -50,6 +50,7 @@ export const defaultBooleanFeatureFlags = { debugForceSeedNodeFailure: !isEmpty(process.env.SESSION_DEBUG_FORCE_SEED_NODE_FAILURE), debugKeyboardShortcuts: !isEmpty(process.env.SESSION_DEBUG_KEYBOARD_SHORTCUTS), debugFocusScope: !isEmpty(process.env.SESSION_DEBUG_FOCUS_SCOPE), + debugFocusTrap: !isEmpty(process.env.SESSION_DEBUG_FOCUS_TRAP), } satisfies SessionBooleanFeatureFlags; function getMockNetworkPageNodeCount() { diff --git a/ts/state/ducks/types/releasedFeaturesReduxTypes.ts b/ts/state/ducks/types/releasedFeaturesReduxTypes.ts index 03c8d911f..714bba788 100644 --- a/ts/state/ducks/types/releasedFeaturesReduxTypes.ts +++ b/ts/state/ducks/types/releasedFeaturesReduxTypes.ts @@ -45,6 +45,7 @@ export type SessionDebugBooleanFeatureFlags = { debugOnlineState: boolean; debugKeyboardShortcuts: boolean; debugFocusScope: boolean; + debugFocusTrap: boolean; }; export type SessionBooleanFeatureFlags = SessionBaseBooleanFeatureFlags & diff --git a/ts/state/focus.ts b/ts/state/focus.ts index d90a97561..bb12db11c 100644 --- a/ts/state/focus.ts +++ b/ts/state/focus.ts @@ -36,7 +36,7 @@ export function useFocusScope() { }; } -export function useIsInScope({ scope, scopeId }: ScopeArgs) { +export function useIsInScope({ scope, scopeId }: ScopeArgs): boolean { const { modalId, focusedMessageId, isCompositionTextAreaFocused, isRightPanelShowing } = useFocusScope(); @@ -74,7 +74,7 @@ export function useIsInScope({ scope, scopeId }: ScopeArgs) { if (scopeId === 'all') { return !!focusedMessageId; } - return scopeId && scopeId === focusedMessageId; + return !!scopeId && scopeId === focusedMessageId; } if (scope === 'compositionBoxInput') { diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 94866c5a8..4e84bb987 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -39,6 +39,7 @@ import type { SessionSuggestionDataItem } from '../../components/conversation/co import { useIsPublic, useWeAreAdmin } from '../../hooks/useParamSelector'; import { tr } from '../../localization/localeTools'; import type { QuoteProps } from '../../components/conversation/message/message-content/quote/Quote'; +import { PopoverTriggerPosition } from '../../components/SessionTooltip'; export const getConversations = (state: StateType): ConversationsStateType => state.conversations; @@ -505,6 +506,12 @@ export const getIsMessageSelectionMode = (state: StateType): boolean => export const getFocusedMessageId = (state: StateType): string | null => state.conversations.focusedMessageId; +export const getInteractableMessageId = (state: StateType): string | null => + state.conversations.interactableMessageId; + +export const getReactionBarTriggerPosition = (state: StateType): PopoverTriggerPosition | null => + state.conversations.reactionBarTriggerPosition; + export const getIsCompositionTextAreaFocused = (state: StateType): boolean => state.conversations.isCompositionTextAreaFocused; @@ -533,6 +540,14 @@ export function useFocusedMessageId() { return useSelector(getFocusedMessageId); } +export function useInteractableMessageId() { + return useSelector(getInteractableMessageId); +} + +export function useReactionBarTriggerPosition() { + return useSelector(getReactionBarTriggerPosition); +} + export function useIsCompositionTextAreaFocused() { return useSelector(getIsCompositionTextAreaFocused); } diff --git a/ts/state/selectors/messages.ts b/ts/state/selectors/messages.ts index 1d5dbfd16..1d0c41fbd 100644 --- a/ts/state/selectors/messages.ts +++ b/ts/state/selectors/messages.ts @@ -73,7 +73,7 @@ export const useAuthorAvatarPath = (messageId: string): string | null => { return senderProps.avatarPath || null; }; -export const useMessageIsDeleted = (messageId: string): boolean => { +export const useMessageIsDeleted = (messageId?: string): boolean => { const props = useMessagePropsByMessageId(messageId); return !!props?.propsForMessage.isDeleted || false; }; @@ -100,6 +100,17 @@ export const useMessageDirection = ( return useMessagePropsByMessageId(messageId)?.propsForMessage.direction; }; +export const useMessageDirectionIncoming = ( + messageId: string | undefined, + isDetailView?: boolean +) => { + const _direction = useMessageDirection(messageId); + + // NOTE we want messages on the left in the message detail view regardless of direction + const direction = isDetailView ? 'incoming' : _direction; + return direction === 'incoming'; +}; + export const useMessageLinkPreview = (messageId: string | undefined): Array | undefined => { const previews = useMessagePropsByMessageId(messageId)?.propsForMessage.previews; return previews; From 9fa5724ae96bf59111634466d780a1cf4d41e466 Mon Sep 17 00:00:00 2001 From: Ryan Miller Date: Tue, 3 Mar 2026 14:02:47 +1100 Subject: [PATCH 16/21] fix: multi select mode event propagation and message list padding --- .../SessionMessageInteractables.tsx | 32 ++++++++++--- .../conversation/SessionMessagesList.tsx | 2 - .../MessageContentWithStatus.tsx | 47 +++++++------------ .../message-item/ExpirableReadableMessage.tsx | 26 +--------- .../GenericReadableInteractableMessage.tsx | 34 +++++++------- .../message-item/GenericReadableMessage.tsx | 19 ++++++-- 6 files changed, 75 insertions(+), 85 deletions(-) diff --git a/ts/components/conversation/SessionMessageInteractables.tsx b/ts/components/conversation/SessionMessageInteractables.tsx index 5961cd95a..78488d66e 100644 --- a/ts/components/conversation/SessionMessageInteractables.tsx +++ b/ts/components/conversation/SessionMessageInteractables.tsx @@ -1,5 +1,5 @@ import { useRef, useState } from 'react'; -import type { MenuOnHideCallback } from 'react-contexify'; +import type { MenuOnHideCallback, MenuOnShowCallback } from 'react-contexify'; import type { WithContextMenuId } from '../../session/types/with'; import { SessionEmojiReactBarPopover } from './SessionEmojiReactBarPopover'; import { SessionFocusTrap } from '../SessionFocusTrap'; @@ -39,6 +39,8 @@ export function SessionMessageInteractables({ const isControlMessage = useMessageIsControlMessage(messageId); const dispatch = getAppDispatch(); + const [messageContextMenuVisible, setMessageContextMenuVisible] = useState(false); + /** * The reaction bar can be hidden by the following: * - Deactivation of the focus trap @@ -49,13 +51,29 @@ export function SessionMessageInteractables({ const showReactionBar = convoReactionsEnabled && !isControlMessage && !!reactionBarTriggerPosition; + const activateFocusTrap = showReactionBar || messageContextMenuVisible; + const closeReactionBar = () => { dispatch(setReactionBarTriggerPosition(null)); }; + const closeReactionBarAndContextMenu = () => { + closeReactionBar(); + closeContextMenus(); + }; + + const onMessageContextMenuShow: MenuOnShowCallback = fromHidden => { + if (fromHidden) { + setMessageContextMenuVisible(true); + } + }; + const onMessageContextMenuHide: MenuOnHideCallback = fromVisible => { - if (fromVisible && !!reactionBarTriggerPosition) { - closeReactionBar(); + if (fromVisible) { + setMessageContextMenuVisible(false); + if (reactionBarTriggerPosition) { + closeReactionBar(); + } } }; @@ -77,8 +95,7 @@ export function SessionMessageInteractables({ window.log.warn(`[SessionEmojiReactBarPopover] openEmojiPanel has no messageId`); return; } - closeContextMenus(); - closeReactionBar(); + closeReactionBarAndContextMenu(); const pos = getTriggerPosition(emojiPanelTriggerRef); if (pos) { setEmojiPanelTriggerPos(pos); @@ -105,7 +122,7 @@ export function SessionMessageInteractables({ <> reactionBarFirstEmojiRef.current ?? false} onDeactivate={closeReactionBar} clickOutsideDeactivates={true} @@ -116,13 +133,14 @@ export function SessionMessageInteractables({ emojiPanelTriggerRef={emojiPanelTriggerRef} onPlusButtonClick={openEmojiPanel} messageId={messageId} - onAfterEmojiClick={closeReactionBar} + onAfterEmojiClick={closeReactionBarAndContextMenu} triggerPosition={reactionBarTriggerPosition} /> ) : null} diff --git a/ts/components/conversation/SessionMessagesList.tsx b/ts/components/conversation/SessionMessagesList.tsx index 9ad9f3a13..df15d93e1 100644 --- a/ts/components/conversation/SessionMessagesList.tsx +++ b/ts/components/conversation/SessionMessagesList.tsx @@ -41,8 +41,6 @@ const StyledMessagesContainer = styled.div` scrollbar-width: 4px; padding-top: var(--margins-sm); padding-bottom: var(--margins-xl); - padding-left: var(--margins-lg); - padding-right: var(--margins-lg); .session-icon-button { display: flex; diff --git a/ts/components/conversation/message/message-content/MessageContentWithStatus.tsx b/ts/components/conversation/message/message-content/MessageContentWithStatus.tsx index 6f1ea8163..2b7dcaeff 100644 --- a/ts/components/conversation/message/message-content/MessageContentWithStatus.tsx +++ b/ts/components/conversation/message/message-content/MessageContentWithStatus.tsx @@ -14,15 +14,6 @@ export type MessageContentWithStatusSelectorProps = { isGroup: boolean } & Pick< 'conversationType' | 'direction' | 'isDeleted' >; -const StyledMessageContentContainer = styled.div<{ $isIncoming: boolean; $isDetailView: boolean }>` - display: flex; - flex-direction: column; - justify-content: flex-start; - align-items: ${props => (props.$isIncoming ? 'flex-start' : 'flex-end')}; - - width: 100%; -`; - const StyledMessageWithAuthor = styled.div` max-width: 100%; display: flex; @@ -44,26 +35,24 @@ export const MessageContentWithStatuses = ({ messageId }: WithMessageId) => { const isIncoming = direction === 'incoming'; return ( - - + - - - {!isDetailView && } - - - - - + + {!isDetailView && } + + + + ); }; diff --git a/ts/components/conversation/message/message-item/ExpirableReadableMessage.tsx b/ts/components/conversation/message/message-item/ExpirableReadableMessage.tsx index a44f7fa00..7b8f89af9 100644 --- a/ts/components/conversation/message/message-item/ExpirableReadableMessage.tsx +++ b/ts/components/conversation/message/message-item/ExpirableReadableMessage.tsx @@ -6,8 +6,6 @@ import { useLayoutEffect, useState, type AriaRole, - type MouseEvent, - type MouseEventHandler, type ReactNode, type SessionDataTestId, } from 'react'; @@ -42,7 +40,6 @@ import { import { getIsAppFocused } from '../../../../state/selectors/section'; import { useSelectedConversationKey } from '../../../../state/selectors/selectedConversation'; import { useMessageType } from '../../../../state/selectors'; -import { useSelectMessageViaClick } from '../../../../hooks/useMessageInteractions'; const EXPIRATION_CHECK_MINIMUM = 2000; @@ -100,11 +97,8 @@ export type ReadableMessageProps = { messageId: string; className?: string; isUnread: boolean; - onClick?: MouseEventHandler; - onDoubleClickCapture?: MouseEventHandler; dataTestId: SessionDataTestId; role?: AriaRole; - onContextMenu?: (e: MouseEvent) => void; isControlMessage?: boolean; }; @@ -160,17 +154,7 @@ async function markReadFromMessageId({ const ReadableMessage = ( props: ReadableMessageProps & { alignItems: 'flex-start' | 'flex-end' | 'center' } ) => { - const { - messageId, - onContextMenu, - className, - isUnread, - onClick, - onDoubleClickCapture, - role, - dataTestId, - alignItems, - } = props; + const { messageId, className, isUnread, role, dataTestId, alignItems } = props; const isAppFocused = useSelector(getIsAppFocused); const dispatch = getAppDispatch(); @@ -262,7 +246,6 @@ const ReadableMessage = ( return ( { const selected = useMessageExpirationPropsByIdInternal(messageId); const isDetailView = useIsDetailMessageViewInternal(); - const selectViaClick = useSelectMessageViaClickInternal(messageId); const messageType = useMessageTypeInternal(messageId); const { isExpired } = useIsExpired({ @@ -361,8 +339,6 @@ export const ExpirableReadableMessage = ({ messageId={messageId} isUnread={!!isUnread} alignItems={alignItems} - onClick={selectViaClick ?? undefined} - onDoubleClickCapture={onDoubleClickCapture} role={role} key={`readable-message-${messageId}`} dataTestId={dataTestId} diff --git a/ts/components/conversation/message/message-item/GenericReadableInteractableMessage.tsx b/ts/components/conversation/message/message-item/GenericReadableInteractableMessage.tsx index 5f1ba9d9f..c4877beac 100644 --- a/ts/components/conversation/message/message-item/GenericReadableInteractableMessage.tsx +++ b/ts/components/conversation/message/message-item/GenericReadableInteractableMessage.tsx @@ -38,6 +38,7 @@ import { useInteractableMessageId, useReactionBarTriggerPosition, } from '../../../../state/selectors/conversations'; +import { useIsInScope } from '../../../../state/focus'; type GenericReadableInteractableMessageProps = WithMessageId & { convoReactionsEnabled?: boolean; @@ -62,17 +63,15 @@ export function GenericReadableInteractableMessage({ const msgIsOnline = useMessageIsOnline(messageId); const interactableMessageId = useInteractableMessageId(); const reactionBarTriggerPosition = useReactionBarTriggerPosition(); + const isFocused = useIsInScope({ scope: 'message', scopeId: messageId }); const ref = useRef(null); const pointerDownRef = useRef(false); const reply = useMessageReply(messageId); - const focusMessageId = () => { - dispatch(setFocusedMessageId(messageId)); - }; const onFocus = () => { - focusMessageId(); + dispatch(setFocusedMessageId(messageId)); }; const onBlur = () => { @@ -108,6 +107,7 @@ export function GenericReadableInteractableMessage({ overridePosition?: { x: number; y: number } ) => { if (!selectedIsBlocked && !multiSelectMode && !isKickedFromGroup) { + dispatch(setFocusedMessageId(messageId)); dispatch(setInteractableMessageId(messageId)); showMessageContextMenu({ event: e, @@ -145,16 +145,15 @@ export function GenericReadableInteractableMessage({ } }; - const onClick = useCallback( - (event: MouseEvent) => { - if (multiSelectMode && messageId) { - event.preventDefault(); - event.stopPropagation(); - dispatch(toggleSelectedMessageId(messageId)); - } - }, - [dispatch, messageId, multiSelectMode] - ); + // NOTE: we need to capture all clicks to the element otherwise clicked children + // will also trigger when in multiSelectMode + const onClickCapture = (event: MouseEvent) => { + if (multiSelectMode) { + event.preventDefault(); + event.stopPropagation(); + dispatch(toggleSelectedMessageId(messageId)); + } + }; const onDoubleClickCapture = reply ? (e: MouseEvent) => { @@ -190,6 +189,7 @@ export function GenericReadableInteractableMessage({ }) ); }; + if (!convoId || !messageId || !messageType) { return null; } @@ -206,15 +206,13 @@ export function GenericReadableInteractableMessage({ className={clsx(selected ? 'message-selected' : undefined)} tabIndex={0} $focusedKeyboard={!pointerDownRef.current} - /** TODO: this was a bit buggy but it would be nice to get working - * $forceFocusStyle={!pointerDownRef.current && isFocused && !!reactionBarTriggerPosition} - */ + $forceFocusStyle={!pointerDownRef.current && isFocused && !!reactionBarTriggerPosition} onContextMenu={handleContextMenu} onKeyDown={onKeyDown} onPointerDown={onPointerDown} onFocus={onFocus} onBlur={onBlur} - onClick={onClick} + onClickCapture={onClickCapture} onDoubleClickCapture={onDoubleClickCapture} > {enableReactions ? ( diff --git a/ts/components/conversation/message/message-item/GenericReadableMessage.tsx b/ts/components/conversation/message/message-item/GenericReadableMessage.tsx index 60bef33e2..dee3338ca 100644 --- a/ts/components/conversation/message/message-item/GenericReadableMessage.tsx +++ b/ts/components/conversation/message/message-item/GenericReadableMessage.tsx @@ -31,7 +31,6 @@ const highlightedMessageAnimation = keyframes` type StyledReadableMessageProps = { selected?: boolean; - // TODO: remove this, we can add styles to the message list $isDetailView?: boolean; $focusedKeyboard?: boolean; $forceFocusStyle?: boolean; @@ -39,7 +38,7 @@ type StyledReadableMessageProps = { $isControlMessage?: boolean; }; -const StyledReadableMessage = styled.div` +export const StyledReadableMessage = styled.div` display: flex; flex-direction: column; align-items: ${props => (props.$isIncoming ? 'flex-start' : 'flex-end')}; @@ -71,6 +70,16 @@ const StyledReadableMessage = styled.div` : ''} `; +const StyledMessageContentContainer = styled.div<{ $isIncoming: boolean }>` + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: ${props => (props.$isIncoming ? 'flex-start' : 'flex-end')}; + padding-left: var(--margins-lg); + padding-right: var(--margins-lg); + width: 100%; +`; + function getMessageComponent(messageType: UIMessageType) { switch (messageType) { case 'community-invitation': @@ -130,8 +139,10 @@ export const GenericReadableMessage = ({ {...rest} $isDetailView={isDetailView} > - - {children} + + + {children} + ); }; From 3dca2c6c721f85d0070e6a0888c2059d50babba4 Mon Sep 17 00:00:00 2001 From: Ryan Miller Date: Tue, 3 Mar 2026 14:10:28 +1100 Subject: [PATCH 17/21] fix: message info view padding --- .../message/message-item/GenericReadableMessage.tsx | 8 ++++---- .../overlay/message-info/components/MessageInfo.tsx | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ts/components/conversation/message/message-item/GenericReadableMessage.tsx b/ts/components/conversation/message/message-item/GenericReadableMessage.tsx index dee3338ca..484d5232d 100644 --- a/ts/components/conversation/message/message-item/GenericReadableMessage.tsx +++ b/ts/components/conversation/message/message-item/GenericReadableMessage.tsx @@ -70,13 +70,13 @@ export const StyledReadableMessage = styled.div` : ''} `; -const StyledMessageContentContainer = styled.div<{ $isIncoming: boolean }>` +const StyledMessageContentContainer = styled.div<{ $isIncoming: boolean; $isDetailView?: boolean }>` display: flex; flex-direction: column; justify-content: flex-start; align-items: ${props => (props.$isIncoming ? 'flex-start' : 'flex-end')}; - padding-left: var(--margins-lg); - padding-right: var(--margins-lg); + padding-left: ${props => (props.$isDetailView ? '0' : 'var(--margins-lg)')}; + padding-right: ${props => (props.$isDetailView ? '0' : 'var(--margins-lg)')}; width: 100%; `; @@ -139,7 +139,7 @@ export const GenericReadableMessage = ({ {...rest} $isDetailView={isDetailView} > - + {children} diff --git a/ts/components/conversation/right-panel/overlay/message-info/components/MessageInfo.tsx b/ts/components/conversation/right-panel/overlay/message-info/components/MessageInfo.tsx index 393e5a4ad..1328b1423 100644 --- a/ts/components/conversation/right-panel/overlay/message-info/components/MessageInfo.tsx +++ b/ts/components/conversation/right-panel/overlay/message-info/components/MessageInfo.tsx @@ -80,7 +80,7 @@ export const LabelWithInfo = (props: LabelWithInfoProps) => { ) : null} From bee80e018cfd3235b85f3a05bd2c6d96e33a2bc6 Mon Sep 17 00:00:00 2001 From: Ryan Miller Date: Tue, 3 Mar 2026 14:15:56 +1100 Subject: [PATCH 18/21] fix: conversation header button spacing --- ts/components/conversation/header/ConversationHeader.tsx | 2 +- ts/components/conversation/header/ConversationHeaderItems.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ts/components/conversation/header/ConversationHeader.tsx b/ts/components/conversation/header/ConversationHeader.tsx index 2ad96d8d0..9cc20e1ba 100644 --- a/ts/components/conversation/header/ConversationHeader.tsx +++ b/ts/components/conversation/header/ConversationHeader.tsx @@ -37,7 +37,7 @@ const StyledConversationHeader = styled.div` align-items: center; height: var(--main-view-header-height); position: relative; - padding: 0px var(--margins-lg) 0px var(--margins-sm); + padding: 0px var(--margins-sm); background: var(--background-primary-color); `; diff --git a/ts/components/conversation/header/ConversationHeaderItems.tsx b/ts/components/conversation/header/ConversationHeaderItems.tsx index 83c6634d7..41a4d4548 100644 --- a/ts/components/conversation/header/ConversationHeaderItems.tsx +++ b/ts/components/conversation/header/ConversationHeaderItems.tsx @@ -95,7 +95,7 @@ export const CallButton = () => { void callRecipient(selectedConvoKey, canCall); }} dataTestId="call-button" - margin="0 var(--margins-sm) 0 0" + padding="var(--margins-md)" disabled={isBlocked || !canCall} /> ); From 25a92a02dc14ad56d16c9190c16e769c8b48a246 Mon Sep 17 00:00:00 2001 From: Ryan Miller Date: Tue, 3 Mar 2026 14:40:23 +1100 Subject: [PATCH 19/21] chore: unify message padding for typing indicator --- ts/components/conversation/SessionMessagesList.tsx | 5 +++-- .../message/message-item/GenericReadableMessage.tsx | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/ts/components/conversation/SessionMessagesList.tsx b/ts/components/conversation/SessionMessagesList.tsx index df15d93e1..f7171da68 100644 --- a/ts/components/conversation/SessionMessagesList.tsx +++ b/ts/components/conversation/SessionMessagesList.tsx @@ -31,6 +31,8 @@ import { SessionScrollButton } from '../SessionScrollButton'; import { ConvoHub } from '../../session/conversations'; import { SessionMessageInteractables } from './SessionMessageInteractables'; +export const MESSAGE_LIST_MESSAGE_PADDING_PX = 'var(--margins-lg)' as const; + const StyledMessagesContainer = styled.div` display: flex; gap: var(--margins-sm); @@ -60,9 +62,8 @@ const StyledMessagesContainer = styled.div` } `; -// NOTE Must always match the padding of the StyledReadableMessage const StyledTypingBubbleContainer = styled.div` - padding: var(--margins-xs) var(--margins-lg) 0; + padding: var(--margins-xs) ${MESSAGE_LIST_MESSAGE_PADDING_PX} 0; `; function isNotTextboxEvent(e: KeyboardEvent) { diff --git a/ts/components/conversation/message/message-item/GenericReadableMessage.tsx b/ts/components/conversation/message/message-item/GenericReadableMessage.tsx index 484d5232d..48e61b6fe 100644 --- a/ts/components/conversation/message/message-item/GenericReadableMessage.tsx +++ b/ts/components/conversation/message/message-item/GenericReadableMessage.tsx @@ -19,6 +19,7 @@ import { CallNotification } from './notification-bubble/CallNotification'; import { InteractionNotification } from './InteractionNotification'; import { MessageRequestResponse } from './MessageRequestResponse'; import { useIsDetailMessageView } from '../../../../contexts/isDetailViewContext'; +import { MESSAGE_LIST_MESSAGE_PADDING_PX } from '../../SessionMessagesList'; export type GenericReadableMessageSelectorProps = Pick< MessageRenderingProps, @@ -75,8 +76,8 @@ const StyledMessageContentContainer = styled.div<{ $isIncoming: boolean; $isDeta flex-direction: column; justify-content: flex-start; align-items: ${props => (props.$isIncoming ? 'flex-start' : 'flex-end')}; - padding-left: ${props => (props.$isDetailView ? '0' : 'var(--margins-lg)')}; - padding-right: ${props => (props.$isDetailView ? '0' : 'var(--margins-lg)')}; + padding-left: ${props => (props.$isDetailView ? '0' : MESSAGE_LIST_MESSAGE_PADDING_PX)}; + padding-right: ${props => (props.$isDetailView ? '0' : MESSAGE_LIST_MESSAGE_PADDING_PX)}; width: 100%; `; From 83e8f1583af170c5731df7f3d6727e56a7569b47 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Tue, 3 Mar 2026 17:04:44 +1100 Subject: [PATCH 20/21] fix: clean up mark as deleted logic & quote --- .../composition/CompositionBox.tsx | 16 +++++++-- .../deleteOrMarkAsDeletedMessages.ts | 36 +++++++++++++++++++ ts/models/conversation.ts | 6 ++-- 3 files changed, 52 insertions(+), 6 deletions(-) create mode 100644 ts/interactions/conversations/deleteOrMarkAsDeletedMessages.ts diff --git a/ts/components/conversation/composition/CompositionBox.tsx b/ts/components/conversation/composition/CompositionBox.tsx index 618c1ca41..e23028153 100644 --- a/ts/components/conversation/composition/CompositionBox.tsx +++ b/ts/components/conversation/composition/CompositionBox.tsx @@ -67,9 +67,16 @@ import { isEnterKey, isEscapeKey } from '../../../util/keyboardShortcuts'; export interface ReplyingToMessageProps { convoId: string; - id: string; // this is the quoted message timestamp + + /** + * this is the local id of that message (i.e the uuid that we generate to identify it) + */ + id: string; author: string; - timestamp: number; + /** + * This is the quoted message timestamp, i.e. what we will send as reference of the message we are quoting + */ + referencedMessageSentAt: number; text?: string; attachments?: Array; } @@ -673,7 +680,10 @@ class CompositionBoxInner extends Component { body: text.trim(), attachments: attachments || [], quote: quotedMessageProps - ? { author: quotedMessageProps.author, timestamp: quotedMessageProps.timestamp } + ? { + author: quotedMessageProps.author, + timestamp: quotedMessageProps.referencedMessageSentAt, + } : undefined, preview: previews, groupInvitation: undefined, diff --git a/ts/interactions/conversations/deleteOrMarkAsDeletedMessages.ts b/ts/interactions/conversations/deleteOrMarkAsDeletedMessages.ts new file mode 100644 index 000000000..de645be5a --- /dev/null +++ b/ts/interactions/conversations/deleteOrMarkAsDeletedMessages.ts @@ -0,0 +1,36 @@ +/* eslint-disable no-await-in-loop */ +import type { ConversationModel } from '../../models/conversation'; +import type { MessageModel } from '../../models/message'; +import type { WithActionContext, WithLocalMessageDeletionType } from '../../session/types/with'; + +/** + * Deletes a message completely or mark it as deleted. + * Does not interact with the swarm at all. + + */ +export async function deleteOrMarkAsDeletedMessages({ + conversation, + messages, + deletionType, + actionContextIsUI, +}: WithLocalMessageDeletionType & + WithActionContext & { + conversation: ConversationModel; + messages: Array; + }) { + for (let index = 0; index < messages.length; index++) { + const message = messages[index]; + // - a control message is forcefully removed from the DB, no matter the requested deletion type + // - an already marked as deleted message is forcefully removed from the DB only when the action is done via the UI + if ( + deletionType === 'complete' || + message.isControlMessage() || + (message.isMarkedAsDeleted() && actionContextIsUI) + ) { + await conversation.removeMessage(message.id); + } else { + // just mark the message as deleted but still show in conversation + await message.markAsDeleted(deletionType); + } + } +} diff --git a/ts/models/conversation.ts b/ts/models/conversation.ts index fb8343523..2eabf49c0 100644 --- a/ts/models/conversation.ts +++ b/ts/models/conversation.ts @@ -653,12 +653,12 @@ export class ConversationModel extends Model { return { author: msgSource, - id: `${quotedMessage.get('sent_at')}` || '', - // NOTE we send the entire body to be consistent with the other platforms + // NOTE we don't send this anymore. But we need this for the reply in the composition box text: body, attachments: quotedAttachments, - timestamp: quotedMessage.get('sent_at') || 0, + referencedMessageSentAt: quotedMessage.get('sent_at') || 0, convoId: this.id, + id: quotedMessage.id, }; } From 9b94899e9f397aa5ad6e903e3d2578b88a44e798 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Tue, 3 Mar 2026 17:10:41 +1100 Subject: [PATCH 21/21] fix: useMessageIsDeleted is not returning a bool anymore --- ts/state/selectors/messages.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ts/state/selectors/messages.ts b/ts/state/selectors/messages.ts index bd5c4a6d1..c1858ea7e 100644 --- a/ts/state/selectors/messages.ts +++ b/ts/state/selectors/messages.ts @@ -73,9 +73,9 @@ export const useAuthorAvatarPath = (messageId: string): string | null => { return senderProps.avatarPath || null; }; -export const useMessageIsDeleted = (messageId?: string): boolean => { +export const useMessageIsDeleted = (messageId?: string) => { const props = useMessagePropsByMessageId(messageId); - return !!props?.propsForMessage.isDeleted; + return props?.propsForMessage.isDeleted; }; export const useFirstMessageOfSeries = (messageId: string | undefined): boolean => {