diff --git a/ts/components/NoticeBanner.tsx b/ts/components/NoticeBanner.tsx index 52fc09868a..cb519593b6 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/OutgoingLightBox.tsx b/ts/components/OutgoingLightBox.tsx index 313a8e7d2d..172dd73feb 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 d2e01f9a12..9b754852a1 100644 --- a/ts/components/SessionFocusTrap.tsx +++ b/ts/components/SessionFocusTrap.tsx @@ -1,34 +1,102 @@ -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'; +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; + /** 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({ + focusTrapId, children, + active = true, allowOutsideClick = true, - returnFocusOnDeactivate, - initialFocus, containerDivStyle, -}: { - children: ReactNode; - allowOutsideClick?: boolean; - returnFocusOnDeactivate?: boolean; - initialFocus: () => HTMLElement | null; - containerDivStyle?: CSSProperties; -}) { + suppressErrors, + allowNoTabbableNodes, + onActivate, + onPostActivate, + onDeactivate, + onPostDeactivate, + ...rest +}: SessionFocusTrapProps) { + const debugFocusTrap = getFeatureFlagMemo('debugFocusTrap'); + 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 _onActivate = () => { + if (debugFocusTrap) { + window.log.debug(`[SessionFocusTrap] onActivate - ${focusTrapId}`); + } + onActivate?.(); + }; + + const _onPostActivate = () => { + if (allowNoTabbableNodes) { + setTabIndex(-1); + } + onPostActivate?.(); + }; + + const _onDeactivate = () => { + if (debugFocusTrap) { + window.log.debug(`[SessionFocusTrap] onDeactivate - ${focusTrapId}`); + } + 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/SessionSearchInput.tsx b/ts/components/SessionSearchInput.tsx index 092becf62d..c345efe950 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 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 search result + tabIndex={-1} onClick={() => { setCurrentSearchTerm(''); dispatch(searchActions.clearSearch()); diff --git a/ts/components/SessionToastContainer.tsx b/ts/components/SessionToastContainer.tsx index a6ff253432..49134b08d1 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 { @@ -67,15 +73,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/SessionWrapperModal.tsx b/ts/components/SessionWrapperModal.tsx index 940e6ffe46..d0a12fa93b 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/basic/SessionRadioGroup.tsx b/ts/components/basic/SessionRadioGroup.tsx index e9c50212c9..1d118e7fca 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/calling/IncomingCallDialog.tsx b/ts/components/calling/IncomingCallDialog.tsx index 0c980233be..222f43a513 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/SessionConversation.tsx b/ts/components/conversation/SessionConversation.tsx index db45be9278..f167f05cff 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/SessionEmojiPanel.tsx b/ts/components/conversation/SessionEmojiPanel.tsx index d2bae1a35c..8523a025d3 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,31 @@ export const SessionEmojiPanel = forwardRef((props: Props ); return ( - - - + + + + ); -}); +}; diff --git a/ts/components/conversation/SessionEmojiPanelPopover.tsx b/ts/components/conversation/SessionEmojiPanelPopover.tsx index aee1a80665..ab6282aa38 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, - onEmojiClicked, + triggerPosition, + onEmojiClick, open, onClose, }: { - triggerPos: PopoverTriggerPosition | null; + emojiPanelRef?: RefObject; + triggerPosition: PopoverTriggerPosition | null; open: boolean; - emojiPanelRef: RefObject; - onEmojiClicked: (emoji: FixedBaseEmoji) => void; + onEmojiClick: (emoji: FixedBaseEmoji) => void; onClose: () => void; }) { - const _open = open && !!triggerPos; + const _open = open && !!triggerPosition; return ( - {_open ? ( - - ) : null} + ); } diff --git a/ts/components/conversation/SessionEmojiReactBarPopover.tsx b/ts/components/conversation/SessionEmojiReactBarPopover.tsx index c76756aada..244765749e 100644 --- a/ts/components/conversation/SessionEmojiReactBarPopover.tsx +++ b/ts/components/conversation/SessionEmojiReactBarPopover.tsx @@ -1,100 +1,59 @@ -import { useEffect, useRef, useState } from 'react'; -import useClickAway from 'react-use/lib/useClickAway'; -import { useTriggerPosition, 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 { useMessageInteractions } from '../../hooks/useMessageInteractions'; -import { useFocusedMessageId } from '../../state/selectors/conversations'; +import { useMessageReact } from '../../hooks/useMessageInteractions'; +import { PopoverTriggerPosition } from '../SessionTooltip'; + +type SessionEmojiReactBarPopoverProps = { + emojiPanelTriggerRef: RefObject; + reactBarFirstEmojiRef?: RefObject; + triggerPosition: PopoverTriggerPosition | null; + messageId: string | undefined; + onPlusButtonClick: () => void; + onAfterEmojiClick: () => void; +}; export function SessionEmojiReactBarPopover({ + reactBarFirstEmojiRef, + emojiPanelTriggerRef, + onPlusButtonClick, + onAfterEmojiClick, + triggerPosition, messageId, - open, - triggerPos, - onClickAwayFromReactionBar, -}: { - 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; -}) { - 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 focusedMessageId = useFocusedMessageId(); +}: SessionEmojiReactBarPopoverProps) { + const reactToMessage = useMessageReact(messageId); - const closeEmojiPanel = () => { - setShowEmojiPanel(false); - }; - - const openEmojiPanel = () => { - closeContextMenus(); - setShowEmojiPanel(true); - }; - - const onEmojiClick = async (args: any) => { + const onEmojiClick = (args: any) => { const emoji = args.native ?? args; - closeEmojiPanel(); - await reactToMessage(emoji); - }; - - useClickAway(emojiPanelRef, () => { - if (showEmojiPanel) { - closeEmojiPanel(); + if (reactToMessage) { + void reactToMessage(emoji); + } else { + window.log.warn( + `[SessionEmojiReactBarPopover] reactToMessage undefined for message ${messageId}` + ); } - }); - - useClickAway(emojiReactionBarRef, () => { - if (open) { - onClickAwayFromReactionBar(); - } - }); - - useEffect(() => { - if (focusedMessageId && messageId && focusedMessageId !== messageId) { - onClickAwayFromReactionBar(); - } - }, [focusedMessageId, messageId, onClickAwayFromReactionBar]); + onAfterEmojiClick?.(); + }; return ( - <> - + - {triggerPos ? ( - - {open ? ( - - ) : null} - - ) : null} - > + ); } diff --git a/ts/components/conversation/SessionMessageInteractables.tsx b/ts/components/conversation/SessionMessageInteractables.tsx new file mode 100644 index 0000000000..78488d66e4 --- /dev/null +++ b/ts/components/conversation/SessionMessageInteractables.tsx @@ -0,0 +1,156 @@ +import { useRef, useState } from 'react'; +import type { MenuOnHideCallback, MenuOnShowCallback } 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(); + + const [messageContextMenuVisible, setMessageContextMenuVisible] = useState(false); + + /** + * 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 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) { + setMessageContextMenuVisible(false); + if (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; + } + closeReactionBarAndContextMenu(); + 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 1ac41785dc..f7171da68d 100644 --- a/ts/components/conversation/SessionMessagesList.tsx +++ b/ts/components/conversation/SessionMessagesList.tsx @@ -1,31 +1,70 @@ -import { useLayoutEffect, useState, type FC } 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, 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 { 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'; + +export const MESSAGE_LIST_MESSAGE_PADDING_PX = 'var(--margins-lg)' as const; + +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; + } +`; + +const StyledTypingBubbleContainer = styled.div` + padding: var(--margins-xs) ${MESSAGE_LIST_MESSAGE_PADDING_PX} 0; +`; function isNotTextboxEvent(e: KeyboardEvent) { return (e?.target as any)?.type === undefined; @@ -33,18 +72,7 @@ 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: { +export const SessionMessagesListInner = (props: { scrollAfterLoadMore: ( messageIdToScrollTo: string, type: 'load-more-top' | 'load-more-bottom' @@ -53,6 +81,7 @@ export const SessionMessagesList = (props: { onPageDownPressed: () => void; onHomePressed: () => void; onEndPressed: () => void; + convoReactionsEnabled?: boolean; }) => { const messagesProps = useSelector(getSortedMessagesTypesOfSelectedConversation); const convoKey = useSelectedConversationKey(); @@ -60,8 +89,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 = useMessageReply(focusedMessageId); + const copyText = useMessageCopyText(focusedMessageId); useKeyboardShortcut({ shortcut: KbdShortcut.messageToggleReply, handler: reply, scopeId: 'all' }); useKeyboardShortcut({ shortcut: KbdShortcut.messageCopyText, handler: copyText, scopeId: 'all' }); @@ -113,8 +143,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 @@ -144,3 +176,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 824fa5c5c4..8fa83644a1 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/SessionQuotedMessageComposition.tsx b/ts/components/conversation/SessionQuotedMessageComposition.tsx index f3a2493d1b..f8b050c0f2 100644 --- a/ts/components/conversation/SessionQuotedMessageComposition.tsx +++ b/ts/components/conversation/SessionQuotedMessageComposition.tsx @@ -9,7 +9,6 @@ import { AUDIO_MP3 } from '../../types/MIME'; import { Flex } from '../basic/Flex'; import { Image } from './Image'; -import { findAndFormatContact } from '../../models/message'; import { getAbsoluteAttachmentPath } from '../../types/MessageAttachment'; import { GoogleChrome } from '../../util'; import { SessionLucideIconButton } from '../icon/SessionIconButton'; @@ -48,7 +47,7 @@ const StyledImage = styled.div` } `; -const StyledText = styled(Flex)` +const StyledQuotedText = styled(Flex)` margin: 0 var(--margins-sm) 0 var(--margins-sm); min-width: 0; p { @@ -88,8 +87,6 @@ export const SessionQuotedMessageComposition = () => { 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} { if (!selectedConvoKey) { @@ -149,8 +147,7 @@ const FollowSettingsButton = ({ messageId }: WithMessageId) => { ); }; -export const TimerNotification = (props: WithMessageId) => { - const { messageId } = props; +export const TimerNotification = ({ messageId }: WithMessageId) => { const timespanSeconds = useMessageExpirationUpdateTimespanSeconds(messageId); const expirationMode = useMessageExpirationUpdateMode(messageId); const disabled = useMessageExpirationUpdateDisabled(messageId); @@ -178,7 +175,6 @@ export const TimerNotification = (props: WithMessageId) => { return ( @@ -206,7 +202,7 @@ export const TimerNotification = (props: WithMessageId) => { - + ); diff --git a/ts/components/conversation/composition/CompositionBox.tsx b/ts/components/conversation/composition/CompositionBox.tsx index 618c1ca416..672852e309 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; } @@ -297,17 +304,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 +338,6 @@ class CompositionBoxInner extends Component { } private showEmojiPanel() { - document.addEventListener('mousedown', this.handleClick, false); this.setState({ lastSelectedLength: window.getSelection()?.toString().length ?? 0 }); closeContextMenus(); @@ -352,7 +347,6 @@ class CompositionBoxInner extends Component { } private hideEmojiPanel() { - document.removeEventListener('mousedown', this.handleClick, false); this.setState({ lastSelectedLength: 0 }); this.setState({ @@ -454,9 +448,9 @@ class CompositionBoxInner extends Component { ) : null} @@ -673,7 +667,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/components/conversation/header/ConversationHeader.tsx b/ts/components/conversation/header/ConversationHeader.tsx index 75005c5256..9cc20e1ba3 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); `; @@ -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/ConversationHeaderItems.tsx b/ts/components/conversation/header/ConversationHeaderItems.tsx index 83c6634d72..41a4d45483 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} /> ); diff --git a/ts/components/conversation/header/ConversationHeaderSelectionOverlay.tsx b/ts/components/conversation/header/ConversationHeaderSelectionOverlay.tsx index b5a695f6a5..bd342f2e92 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,14 +58,9 @@ 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} containerDivStyle={{ position: 'absolute', @@ -96,14 +88,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/media-gallery/types/Message.ts b/ts/components/conversation/media-gallery/types/Message.ts deleted file mode 100644 index 954987d2e7..0000000000 --- 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/ClickToTrustSender.tsx b/ts/components/conversation/message/message-content/ClickToTrustSender.tsx index a99ffc370a..470cc24f9e 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(), @@ -53,7 +53,12 @@ export const ClickToTrustSender = (props: { messageId: string }) => { 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 => { @@ -114,8 +119,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/MessageAttachment.tsx b/ts/components/conversation/message/message-content/MessageAttachment.tsx index 31705bc61f..88b92fb6d2 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/MessageContent.tsx b/ts/components/conversation/message/message-content/MessageContent.tsx index 2a376b5556..30af642265 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/MessageContentWithStatus.tsx b/ts/components/conversation/message/message-content/MessageContentWithStatus.tsx index ea6f482655..2b7dcaeff3 100644 --- a/ts/components/conversation/message/message-content/MessageContentWithStatus.tsx +++ b/ts/components/conversation/message/message-content/MessageContentWithStatus.tsx @@ -1,54 +1,19 @@ -import { SessionDataTestId, MouseEvent, useCallback, Dispatch } 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'; -import { getMessageContentWithStatusesSelectorProps } from '../../../../state/selectors/conversations'; +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 { MessageContextMenu } from './MessageContextMenu'; -import { MessageReactions } from './MessageReactions'; -import { MessageStatus } from './MessageStatus'; -import { - useIsMessageSelectionMode, - useSelectedIsLegacyGroup, -} from '../../../../state/selectors/selectedConversation'; -import { SessionEmojiReactBarPopover } from '../../SessionEmojiReactBarPopover'; -import { PopoverTriggerPosition } from '../../../SessionTooltip'; -import { useMessageInteractions } from '../../../../hooks/useMessageInteractions'; +import type { WithMessageId } from '../../../../session/types/with'; 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; -}; - -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%; -`; - const StyledMessageWithAuthor = styled.div` max-width: 100%; display: flex; @@ -57,134 +22,37 @@ const StyledMessageWithAuthor = styled.div` gap: var(--margins-xs); `; -export const MessageContentWithStatuses = (props: Props) => { - const { - messageId, - ctxMenuID, - dataTestId, - convoReactionsEnabled, - triggerPosition, - setTriggerPosition, - } = props; - const dispatch = getAppDispatch(); - const contentProps = useSelector((state: StateType) => - getMessageContentWithStatusesSelectorProps(state, messageId) - ); - const { reactToMessage, reply } = useMessageInteractions(messageId); - const hideAvatar = useHideAvatarInMsgList(messageId); +export const MessageContentWithStatuses = ({ messageId }: WithMessageId) => { 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 _direction = useMessageDirection(messageId); - const onClickOnMessageOuterContainer = useCallback( - (event: MouseEvent) => { - if (multiSelectMode && props?.messageId) { - event.preventDefault(); - event.stopPropagation(); - dispatch(toggleSelectedMessageId(props?.messageId)); - } - }, - [dispatch, props?.messageId, multiSelectMode] - ); - - const onDoubleClickReplyToMessage = (e: MouseEvent) => { - if (isLegacyGroup) { - 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(); - } - } - }; - - if (!contentProps) { + 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 handlePopupClick = (emoji: string) => { - dispatch( - updateReactListModal({ - reaction: emoji, - messageId, - }) - ); - }; - - const closeReactionBar = () => { - setTriggerPosition(null); - }; - return ( - - + - - - {!isDetailView && } - - - - - {enableReactions ? ( - - ) : null} - {enableContextMenu ? ( - - ) : null} - - {!isDetailView && enableReactions ? ( - - ) : null} - + + {!isDetailView && } + + + + ); }; diff --git a/ts/components/conversation/message/message-content/MessageContextMenu.tsx b/ts/components/conversation/message/message-content/MessageContextMenu.tsx index 1a4f4d540c..8a2772caa3 100644 --- a/ts/components/conversation/message/message-content/MessageContextMenu.tsx +++ b/ts/components/conversation/message/message-content/MessageContextMenu.tsx @@ -2,23 +2,27 @@ 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'; import { getAppDispatch } from '../../../../state/dispatch'; import { Data } from '../../../../data/data'; import { MessageRenderingProps } from '../../../../models/messageType'; -import { openRightPanel, showMessageInfoView } from '../../../../state/ducks/conversations'; import { - useMessageAttachments, - useMessageDirection, - useMessageIsDeletable, + openRightPanel, + setReactionBarTriggerPosition, + showMessageInfoView, +} from '../../../../state/ducks/conversations'; +import { + useMessageIsControlMessage, + useMessageIsDeleted, useMessageSender, useMessageSenderIsAdmin, - useMessageStatus, } from '../../../../state/selectors'; import { + getSelectedCanWrite, useSelectedConversationKey, useSelectedIsLegacyGroup, } from '../../../../state/selectors/selectedConversation'; @@ -26,8 +30,7 @@ import { SessionContextMenuContainer } from '../../../SessionContextMenuContaine import { CopyAccountIdMenuItem } from '../../../menu/items/CopyAccountId/CopyAccountIdMenuItem'; import { Localizer } from '../../../basic/Localizer'; import { Menu, MenuItem } from '../../../menu/items/MenuItem'; -import { WithMessageId } from '../../../../session/types/with'; -import { DeleteItem } from '../../../menu/items/DeleteMessage/DeleteMessageMenuItem'; +import { WithMessageId, type WithContextMenuId } from '../../../../session/types/with'; import { RetryItem } from '../../../menu/items/RetrySend/RetrySendMenuItem'; import { useBanUserCb } from '../../../menuAndSettingsHooks/useBanUser'; import { useUnbanUserCb } from '../../../menuAndSettingsHooks/useUnbanUser'; @@ -37,42 +40,37 @@ import { useRemoveSenderFromCommunityAdmin } from '../../../menuAndSettingsHooks import { useAddSenderAsCommunityAdmin } from '../../../menuAndSettingsHooks/useAddSenderAsCommunityAdmin'; 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, + useMessageSaveAttachment, +} from '../../../../hooks/useMessageInteractions'; +import { SelectMessageMenuItem } from '../../../menu/items/SelectMessage/SelectMessageMenuItem'; +import { DeleteItem } from '../../../menu/items/DeleteMessage/DeleteMessageMenuItem'; +import { messageContextMenuID } from '../../SessionMessagesList'; export type MessageContextMenuSelectorProps = Pick< MessageRenderingProps, - | 'sender' - | 'direction' - | 'status' - | 'isDeletable' - | 'isSenderAdmin' - | 'text' - | 'serverTimestamp' - | 'timestamp' + 'sender' | 'direction' | 'status' | 'isSenderAdmin' | 'text' | 'serverTimestamp' | 'timestamp' >; -type Props = { - messageId: string; - contextMenuId: string; - setTriggerPosition: Dispatch; -}; +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()` @@ -105,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: { @@ -205,10 +203,9 @@ export const showMessageInfoOverlay = async ({ }; function SaveAttachmentMenuItem({ messageId }: { messageId: string }) { - const attachments = useMessageAttachments(messageId); - const { saveAttachment } = useMessageInteractions(messageId); + const saveAttachment = useMessageSaveAttachment(messageId); - return attachments?.length && attachments.every(m => !m.pending && m.path) ? ( + return saveAttachment ? ( {tr('copy')} - ); + ) : null; } -export const MessageContextMenu = (props: Props) => { - const { messageId, contextMenuId, setTriggerPosition } = props; +function MessageReplyMenuItem({ messageId }: { messageId: string }) { + const reply = useMessageReply(messageId); + const canWrite = useSelector(getSelectedCanWrite); - const { reply, select } = useMessageInteractions(messageId); + return reply && canWrite ? ( + + {tr('reply')} + + ) : null; +} +export const MessageContextMenu = ({ + messageId, + contextMenuId, + onShow, + onHide, + onClickCapture, +}: Props) => { + const dispatch = getAppDispatch(); + const contextMenuRef = useRef(null); 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 - - const contextMenuRef = useRef(null); - - const onShow: MenuOnShowCallback = (_, { x, y }) => { + const _onShow: MenuOnShowCallback = (_, { x, y }) => { const triggerHeight = contextMenuRef.current?.clientHeight ?? 0; const triggerWidth = contextMenuRef.current?.clientWidth ?? 0; @@ -270,70 +276,57 @@ 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({ + + 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 }); }; - const onHide: MenuOnHideCallback = () => { - setTriggerPosition(null); + const _onHide: MenuOnHideCallback = fromVisible => { + onHide?.(fromVisible); }; - if (!convoId) { + if (!convoId || isLegacyGroup) { return null; } - if (isLegacyGroup) { - return ( - - - - - - - - - - - ); - } - return ( - - - {(isSent || !isOutgoing) && ( - - {tr('reply')} - + {!messageId ? null : isDeleted || isControlMessage ? ( + <> + + + > + ) : ( + <> + + + + + + + + + + > )} - - - {isDeletable ? ( - - - - ) : null} - - - diff --git a/ts/components/conversation/message/message-content/MessageGenericAttachment.tsx b/ts/components/conversation/message/message-content/MessageGenericAttachment.tsx index 73f526625e..7d93469abc 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 ( { 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/MessageReactBar.tsx b/ts/components/conversation/message/message-content/MessageReactBar.tsx index df610c0e2f..2556419868 100644 --- a/ts/components/conversation/message/message-content/MessageReactBar.tsx +++ b/ts/components/conversation/message/message-content/MessageReactBar.tsx @@ -1,5 +1,5 @@ import styled from 'styled-components'; -import { RefObject } from 'react'; +import { type RefObject } from 'react'; import { nativeEmojiData } from '../../../../util/emoji'; import { getRecentReactions } from '../../../../util/storage'; import { LUCIDE_ICONS_UNICODE } from '../../../icon/lucide'; @@ -8,9 +8,10 @@ import { createButtonOnKeyDownForClickEventHandler } from '../../../../util/keyb type Props = { ref?: RefObject; - action: (...args: Array) => void; - additionalAction: (...args: Array) => void; emojiPanelTriggerRef: RefObject; + firstEmojiRef?: RefObject; + onEmojiClick: (emoji: string) => void; + onPlusButtonClick: () => void; }; const StyledMessageReactBar = styled.div` @@ -51,21 +52,26 @@ const StyledContainer = styled.div` left: -1px; `; -export const MessageReactBar = ({ ref, action, additionalAction, emojiPanelTriggerRef }: Props) => { +export const MessageReactBar = ({ + ref, + emojiPanelTriggerRef, + firstEmojiRef, + onEmojiClick, + onPlusButtonClick, +}: Props) => { const recentReactions = getRecentReactions(); return ( - {recentReactions.map(emoji => { - const onClick = () => action(emoji); + {recentReactions.map((emoji, i) => { + const onClick = () => onEmojiClick(emoji); const onKeyDown = createButtonOnKeyDownForClickEventHandler(onClick); - const ariaLabel = nativeEmojiData?.ariaLabels - ? nativeEmojiData.ariaLabels[emoji] - : undefined; + 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(!isExpanded); }; - 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-content/MessageStatus.tsx b/ts/components/conversation/message/message-content/MessageStatus.tsx index 54cfe48b07..cafacf42ba 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-content/MessageText.tsx b/ts/components/conversation/message/message-content/MessageText.tsx index adf4721d69..cb675ebd58 100644 --- a/ts/components/conversation/message/message-content/MessageText.tsx +++ b/ts/components/conversation/message/message-content/MessageText.tsx @@ -15,8 +15,9 @@ 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'; +import { MessageDeletedType } from '../../../../models/messageType'; +import { tr } from '../../../../localization'; type Props = WithMessageId; @@ -38,11 +39,7 @@ export const MessageText = ({ messageId }: Props) => { const text = useMessageText(messageId); const isOpenOrClosedGroup = useSelectedIsGroupOrCommunity(); const isPublic = useSelectedIsPublic(); - const contents = isDeleted ? tr('deleteMessageDeletedGlobally') : text?.trim(); - - if (!contents) { - return null; - } + const contents = text?.trim(); const iconColor = direction === 'incoming' @@ -58,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/conversation/message/message-content/quote/Quote.tsx b/ts/components/conversation/message/message-content/quote/Quote.tsx index 8d41734a97..ccd36baf1b 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 326f75446b..3bc65cdad4 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'; import { collapseString } from '../../../../../shared/string_utils'; const StyledQuoteText = styled.div<{ $isIncoming: boolean }>` @@ -79,9 +80,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(); @@ -94,11 +98,17 @@ export const QuoteText = ( return {typeLabel}; } } + const textOrFallbacks = + isDeleted === MessageDeletedType.deletedGlobally + ? tr('deleteMessageDeletedGlobally') + : isDeleted === MessageDeletedType.deletedLocally + ? tr('deleteMessageDeletedLocally') + : text || tr('messageErrorOriginal'); return ( ` + 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` @@ -78,8 +58,7 @@ const StyledIconContainer = styled.div` `; export const CommunityInvitation = ({ messageId }: WithMessageId) => { - const messageDirection = useMessageDirection(messageId); - const classes = ['group-invitation']; + const isIncoming = useMessageDirectionIncoming(messageId); const fullUrl = useMessageCommunityInvitationFullUrl(messageId); const communityName = useMessageCommunityInvitationCommunityName(messageId); @@ -94,10 +73,6 @@ export const CommunityInvitation = ({ messageId }: WithMessageId) => { } }, [fullUrl]); - if (messageDirection === 'outgoing') { - classes.push('invitation-outgoing'); - } - if (!fullUrl || !hostname) { return null; } @@ -109,35 +84,27 @@ export const CommunityInvitation = ({ messageId }: WithMessageId) => { dataTestId="control-message" > { 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 66a85ea3a8..f554848232 100644 --- a/ts/components/conversation/message/message-item/DataExtractionNotification.tsx +++ b/ts/components/conversation/message/message-item/DataExtractionNotification.tsx @@ -7,8 +7,7 @@ import type { WithMessageId } from '../../../../session/types/with'; import { SignalService } from '../../../../protobuf'; import { LUCIDE_ICONS_UNICODE } from '../../../icon/lucide'; -export const DataExtractionNotification = (props: WithMessageId) => { - const { messageId } = props; +export const DataExtractionNotification = ({ messageId }: WithMessageId) => { const author = useMessageAuthor(messageId); const authorName = useConversationUsernameWithFallback(true, author); @@ -23,7 +22,6 @@ export const DataExtractionNotification = (props: WithMessageId) => { messageId={messageId} dataTestId="data-extraction-notification" key={`readable-message-${messageId}`} - isControlMessage={true} > ` - 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; + dataTestId: SessionDataTestId; + role?: AriaRole; 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, className, isUnread, 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; + 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); +export const ExpirableReadableMessage = ({ + role, + dataTestId, + messageId, + children, +}: ExpirableReadableMessageProps) => { + const selected = useMessageExpirationPropsByIdInternal(messageId); const isDetailView = useIsDetailMessageViewInternal(); - - const { isControlMessage, onClick, onDoubleClickCapture, role, dataTestId } = props; + const messageType = useMessageTypeInternal(messageId); const { isExpired } = useIsExpired({ convoId: selected?.convoId, @@ -122,35 +315,43 @@ 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 ( - - - {props.children} - + {/* This is the expire timer for control messages only (centered).The one for regular + messages is rendered as part of MessageStatusContainer */} + {canExpire && isControlMessage ? ( + + ) : null} + {children} + ); }; diff --git a/ts/components/conversation/message/message-item/GenericReadableInteractableMessage.tsx b/ts/components/conversation/message/message-item/GenericReadableInteractableMessage.tsx new file mode 100644 index 0000000000..c4877beac0 --- /dev/null +++ b/ts/components/conversation/message/message-item/GenericReadableInteractableMessage.tsx @@ -0,0 +1,229 @@ +import { type MouseEvent, type KeyboardEvent, useCallback, useRef } from 'react'; +import clsx from 'clsx'; +import { + useHideAvatarInMsgList, + useMessageDirection, + useMessageIsControlMessage, + useMessageIsOnline, + useMessageSelected, + useMessageType, +} from '../../../../state/selectors'; +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, + setInteractableMessageId, + setReactionBarTriggerPosition, + toggleSelectedMessageId, +} from '../../../../state/ducks/conversations'; +import { PopoverTriggerPosition } from '../../../SessionTooltip'; +import { useKeyboardShortcut } from '../../../../hooks/useKeyboardShortcut'; +import type { WithMessageId } from '../../../../session/types/with'; +import { closeContextMenus } from '../../../../util/contextMenu'; +import { trimWhitespace } from '../../../../session/utils/String'; +import { useMessageReact, useMessageReply } from '../../../../hooks/useMessageInteractions'; +import { GenericReadableMessage } from './GenericReadableMessage'; + +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'; +import { useIsInScope } from '../../../../state/focus'; + +type GenericReadableInteractableMessageProps = WithMessageId & { + convoReactionsEnabled?: boolean; +}; + +export function GenericReadableInteractableMessage({ + messageId, + convoReactionsEnabled, +}: GenericReadableInteractableMessageProps) { + const dispatch = getAppDispatch(); + + 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 isFocused = useIsInScope({ scope: 'message', scopeId: messageId }); + + const ref = useRef(null); + const pointerDownRef = useRef(false); + + const reply = useMessageReply(messageId); + + const onFocus = () => { + dispatch(setFocusedMessageId(messageId)); + }; + + const onBlur = () => { + if (!reactionBarTriggerPosition) { + 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) { + dispatch(setFocusedMessageId(messageId)); + dispatch(setInteractableMessageId(messageId)); + showMessageContextMenu({ + event: e, + triggerPosition: overridePosition, + }); + } + }, + [dispatch, selectedIsBlocked, multiSelectMode, isKickedFromGroup, messageId] + ); + + 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 (interactableMessageId === messageId && !!reactionBarTriggerPosition) { + closeContextMenus(); + dispatch(setReactionBarTriggerPosition(null)); + } else { + const pos = getMessageContainerTriggerPosition(); + if (pos) { + dispatch(setInteractableMessageId(messageId)); + dispatch(setReactionBarTriggerPosition(pos)); + } + } + }; + + // 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) => { + 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 enableReactions = convoReactionsEnabled && msgIsOnline; + + const handlePopupClick = (emoji: string) => { + dispatch( + updateReactListModal({ + reaction: emoji, + messageId, + }) + ); + }; + + if (!convoId || !messageId || !messageType) { + return null; + } + + const selected = isMessageSelected || false; + + return ( + + {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 d8abc09d09..48e61b6fe9 100644 --- a/ts/components/conversation/message/message-item/GenericReadableMessage.tsx +++ b/ts/components/conversation/message/message-item/GenericReadableMessage.tsx @@ -1,66 +1,54 @@ -import { - type MouseEvent, - type KeyboardEvent, - useCallback, - useRef, - useMemo, - useState, - useEffect, -} from 'react'; +import type { HTMLProps } 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 { + useMessageDirectionIncoming, + useMessageIsControlMessage, + useMessageType, +} from '../../../../state/selectors'; import { MessageContentWithStatuses } from '../message-content/MessageContentWithStatus'; import { StyledMessageReactionsContainer } from '../message-content/MessageReactions'; -import { - useIsMessageSelectionMode, - useSelectedIsBlocked, -} 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 { PopoverTriggerPosition } from '../../../SessionTooltip'; -import { useKeyboardShortcut } from '../../../../hooks/useKeyboardShortcut'; -import { useFocusScope, useIsInScope } from '../../../../state/focus'; +import { type UIMessageType } from '../../../../state/ducks/conversations'; +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'; +import { useIsDetailMessageView } from '../../../../contexts/isDetailViewContext'; +import { MESSAGE_LIST_MESSAGE_PADDING_PX } from '../../SessionMessagesList'; 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); } `; -const StyledReadableMessage = styled.div<{ - selected: boolean; - $isDetailView: boolean; - $focusedKeyboard: boolean; -}>` +type StyledReadableMessageProps = { + selected?: boolean; + $isDetailView?: boolean; + $focusedKeyboard?: boolean; + $forceFocusStyle?: boolean; + $isIncoming?: boolean; + $isControlMessage?: boolean; +}; + +export 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; @@ -76,150 +64,86 @@ const StyledReadableMessage = styled.div<{ background-color: var(--conversation-tab-background-selected-color); }` : ''} -`; -export const GenericReadableMessage = (props: Props) => { - const isDetailView = useIsDetailMessageView(); - const dispatch = getAppDispatch(); - - const { ctxMenuID, messageId } = props; + ${props => + props.$forceFocusStyle + ? 'background-color: var(--conversation-tab-background-selected-color);' + : ''} +`; - const msgProps = useSelector((state: StateType) => - getGenericReadableMessageSelectorProps(state, props.messageId) - ); - const isMessageSelected = useMessageSelected(props.messageId); - const selectedIsBlocked = useSelectedIsBlocked(); +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 ? '0' : MESSAGE_LIST_MESSAGE_PADDING_PX)}; + padding-right: ${props => (props.$isDetailView ? '0' : MESSAGE_LIST_MESSAGE_PADDING_PX)}; + width: 100%; +`; - const multiSelectMode = useIsMessageSelectionMode(); +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; + } +} - 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; +type GenericReadableMessageProps = Partial< + HTMLProps & Omit & WithMessageId +>; - 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: msgProps?.direction === 'incoming' ? -halfWidth : halfWidth, - }; - }; - - const handleContextMenu = useCallback( - ( - e: MouseEvent | KeyboardEvent, - overridePosition?: { x: number; y: number } - ) => { - if (!selectedIsBlocked && !multiSelectMode && !msgProps?.isKickedFromGroup) { - showMessageContextMenu({ - id: ctxMenuID, - event: e, - triggerPosition: overridePosition, - }); - } - }, - [selectedIsBlocked, ctxMenuID, multiSelectMode, msgProps?.isKickedFromGroup] - ); +export const GenericReadableMessage = ({ + ref, + messageId, + selected, + children, + ...rest +}: GenericReadableMessageProps) => { + const messageType = useMessageType(messageId); + const isDetailView = useIsDetailMessageView(); + const isControlMessage = useMessageIsControlMessage(messageId); + const isIncoming = useMessageDirectionIncoming(messageId, isDetailView); - 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') { - // 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 onFocus = () => { - dispatch(setFocusedMessageId(messageId)); - }; - - const onBlur = () => { - dispatch(setFocusedMessageId(null)); - }; - - const toggleEmojiReactionBarWithKeyboard = () => { - if (triggerPosition) { - setTriggerPosition(null); - } else { - const pos = getMessageContainerTriggerPosition(); - if (pos) { - setTriggerPosition(pos); - } - } - }; - - useKeyboardShortcut({ - shortcut: KbdShortcut.messageToggleReactionBar, - handler: toggleEmojiReactionBarWithKeyboard, - scopeId: messageId, - }); - - useEffect(() => { - if (isAnotherMessageFocused) { - setTriggerPosition(null); - } - }, [isAnotherMessageFocused]); - - if (!msgProps) { + if (!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 ( { - pointerDownRef.current = true; - }} - onFocus={() => { - onFocus(); - pointerDownRef.current = false; - }} - onBlur={onBlur} > - + + + {children} + ); }; diff --git a/ts/components/conversation/message/message-item/GroupUpdateMessage.tsx b/ts/components/conversation/message/message-item/GroupUpdateMessage.tsx index f41a0efedc..dd08547e1f 100644 --- a/ts/components/conversation/message/message-item/GroupUpdateMessage.tsx +++ b/ts/components/conversation/message/message-item/GroupUpdateMessage.tsx @@ -80,7 +80,6 @@ export const GroupUpdateMessage = ({ messageId }: WithMessageId) => { messageId={messageId} key={`readable-message-${messageId}`} dataTestId="group-update-message" - isControlMessage={true} > diff --git a/ts/components/conversation/message/message-item/InteractionNotification.tsx b/ts/components/conversation/message/message-item/InteractionNotification.tsx index b920d97d67..0176f88c52 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,21 @@ import { useSelectedIsPrivate, useSelectedIsPublic, } from '../../../../state/selectors/selectedConversation'; -import { useMessageInteractionNotification, useMessageIsUnread } from '../../../../state/selectors'; +import { useMessageInteractionNotification } from '../../../../state/selectors'; import type { WithMessageId } from '../../../../session/types/with'; import { tr } from '../../../../localization/localeTools'; import { useConversationUsernameWithFallback } from '../../../../hooks/useParamSelector'; +import { ExpirableReadableMessage } from './ExpirableReadableMessage'; const StyledFailText = styled.div` color: var(--danger-color); `; -export const InteractionNotification = (props: WithMessageId) => { - const { messageId } = props; - +export const InteractionNotification = ({ messageId }: WithMessageId) => { 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 +69,8 @@ export const InteractionNotification = (props: WithMessageId) => { } return ( - @@ -89,6 +85,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 9fc4f75c7b..0000000000 --- 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 845e9dc3c7..1a2cbf8258 100644 --- a/ts/components/conversation/message/message-item/MessageRequestResponse.tsx +++ b/ts/components/conversation/message/message-item/MessageRequestResponse.tsx @@ -1,16 +1,14 @@ import { useConversationUsernameWithFallback } from '../../../../hooks/useParamSelector'; import type { WithMessageId } from '../../../../session/types/with'; -import { useMessageAuthorIsUs, useMessageIsUnread } from '../../../../state/selectors'; +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 { 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) => { const conversationId = useSelectedConversationKey(); - const isUnread = useMessageIsUnread(messageId) || false; const isUs = useMessageAuthorIsUs(messageId); const name = useConversationUsernameWithFallback(true, conversationId); @@ -20,9 +18,8 @@ export const MessageRequestResponse = ({ messageId }: WithMessageId) => { } return ( - @@ -43,6 +40,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 7fd56bcd1e..0000000000 --- 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 847ea41093..9b42dfbcfe 100644 --- a/ts/components/conversation/message/message-item/notification-bubble/CallNotification.tsx +++ b/ts/components/conversation/message/message-item/notification-bubble/CallNotification.tsx @@ -32,9 +32,7 @@ const style = { }, } satisfies StyleType; -export const CallNotification = (props: WithMessageId) => { - const { messageId } = props; - +export const CallNotification = ({ messageId }: WithMessageId) => { const notificationType = useMessageCallNotificationType(messageId); const name = useSelectedNicknameOrProfileNameOrShortenedPubkey(); @@ -50,7 +48,6 @@ export const CallNotification = (props: WithMessageId) => { messageId={messageId} key={`readable-message-${messageId}`} dataTestId={`call-notification-${notificationType}`} - isControlMessage={true} > {notificationTextKey === 'callsInProgress' ? ( diff --git a/ts/components/conversation/message/reactions/Reaction.tsx b/ts/components/conversation/message/reactions/Reaction.tsx index 9abe6aee4a..6f284bb45e 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/conversation/right-panel/RightPanel.tsx b/ts/components/conversation/right-panel/RightPanel.tsx index 1e69e2e345..537f8760a6 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 4b83c69441..886edb728a 100644 --- a/ts/components/conversation/right-panel/overlay/message-info/OverlayMessageInfo.tsx +++ b/ts/components/conversation/right-panel/overlay/message-info/OverlayMessageInfo.tsx @@ -10,19 +10,12 @@ 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 { - replyToMessage, - resendMessage, -} from '../../../../../interactions/conversationInteractions'; -import { deleteMessagesById } from '../../../../../interactions/conversations/unsendingInteractions'; import { useMessageAttachments, useMessageBody, useMessageDirection, - useMessageIsDeletable, useMessageQuote, useMessageSender, useMessageServerTimestamp, @@ -43,7 +36,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 +47,10 @@ 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'; +import { useDeleteMessagesCb } from '../../../../menuAndSettingsHooks/useDeleteMessagesCb'; +import { GenericReadableMessage } from '../../../message/message-item/GenericReadableMessage'; +import { resendMessage } from '../../../../../interactions/conversationInteractions'; // NOTE we override the default max-widths when in the detail isDetailView const StyledMessageBody = styled.div` @@ -85,11 +81,9 @@ const MessageBody = ({ } return ( - - - - - + + + ); }; @@ -226,7 +220,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 +229,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" /> @@ -253,7 +244,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 +257,6 @@ function useMessageDetailsInternal(messageId?: string) { return { rightOverlayMode, - isDeletable, direction, timestamp, serverTimestamp, @@ -288,17 +277,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 +302,7 @@ export const OverlayMessageInfo = () => { const closePanelCb = () => closePanel(dispatch); - useKeyboardShortcutLocal({ + useKeyboardShortcutInternal({ shortcut: KbdShortcut.closeRightPanel, handler: closePanelCb, }); @@ -430,14 +422,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/conversation/right-panel/overlay/message-info/components/MessageInfo.tsx b/ts/components/conversation/right-panel/overlay/message-info/components/MessageInfo.tsx index 393e5a4ad5..1328b14235 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} diff --git a/ts/components/dialog/KeyboardShortcutsModal.tsx b/ts/components/dialog/KeyboardShortcutsModal.tsx index c54c678929..453aa4717e 100644 --- a/ts/components/dialog/KeyboardShortcutsModal.tsx +++ b/ts/components/dialog/KeyboardShortcutsModal.tsx @@ -167,6 +167,7 @@ export function KeyboardShortcutsModal() { $alignItems="flex-start" $padding="var(--margins-sm) 0 var(--margins-xl)" width="100%" + tabIndex={0} > diff --git a/ts/components/dialog/ReactListModal.tsx b/ts/components/dialog/ReactListModal.tsx index 7a8d500875..32b6712a24 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/dialog/SessionConfirm.tsx b/ts/components/dialog/SessionConfirm.tsx index efd016453b..32984af96d 100644 --- a/ts/components/dialog/SessionConfirm.tsx +++ b/ts/components/dialog/SessionConfirm.tsx @@ -13,30 +13,37 @@ 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 RadioOptions = { items: SessionRadioItems; defaultSelectedValue: string | undefined }; export type SessionConfirmDialogProps = { i18nMessage?: TrArgs; - title?: string; - radioOptions?: SessionRadioItems; + title?: TrArgs; + /** + * Warning message to display in the modal + */ + warningMessage?: TrArgs; + radioOptions?: RadioOptions; 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; /** @@ -66,12 +73,26 @@ 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 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 +126,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} - {radioOptions && chosenOption !== '' ? ( + + {props.warningMessage ? ( + + ) : null} + {!!radioOptions?.items.length && chosenOption !== '' ? ( { if (value) { setChosenOption(value); diff --git a/ts/components/dialog/UpdateConversationDetailsDialog.tsx b/ts/components/dialog/UpdateConversationDetailsDialog.tsx index c107df0491..dd888a964f 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/components/dialog/debug/hooks/useReleaseChannel.tsx b/ts/components/dialog/debug/hooks/useReleaseChannel.tsx index 0f57bdb00e..a19fdcdee1 100644 --- a/ts/components/dialog/debug/hooks/useReleaseChannel.tsx +++ b/ts/components/dialog/debug/hooks/useReleaseChannel.tsx @@ -1,6 +1,5 @@ import useUpdate from 'react-use/lib/useUpdate'; import { getAppDispatch } from '../../../../state/dispatch'; -import { tr } from '../../../../localization/localeTools'; import { Storage } from '../../../../util/storage'; import { updateConfirmModal } from '../../../../state/ducks/modalDialog'; import { SessionButtonColor } from '../../../basic/SessionButton'; @@ -25,10 +24,10 @@ export const useReleaseChannel = (): { ); dispatch( updateConfirmModal({ - title: tr('warning'), + title: { token: 'warning' }, i18nMessage: { token: 'settingsRestartDescription' }, okTheme: SessionButtonColor.Danger, - okText: tr('restart'), + okText: { token: 'restart' }, onClickOk: async () => { 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 0000000000..240f5776f8 --- /dev/null +++ b/ts/components/dialog/shared/ModalWarning.tsx @@ -0,0 +1,26 @@ +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-sm); + color: var(--warning-color); +`; + +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 127b7ef588..96241c71b6 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 9bef64d0ef..3cd5d559da 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/lightbox/Lightbox.tsx b/ts/components/lightbox/Lightbox.tsx index 142abc39cf..5087928b63 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/components/menu/items/DeleteMessage/DeleteMessageMenuItem.tsx b/ts/components/menu/items/DeleteMessage/DeleteMessageMenuItem.tsx index c89b921d4f..5399d590d3 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,18 @@ const ExpiresInItem = ({ messageId }: { messageId: string }) => { export const DeleteItem = ({ messageId }: { messageId: string }) => { const convoId = useSelectedConversationKey(); - const isPublic = useSelectedIsPublic(); + const deleteMessagesCb = useDeleteMessagesCb(convoId); - const isDeletable = useMessageIsDeletable(messageId); - const isDeletableForEveryone = useMessageIsDeletableForEveryone(messageId); - - 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 0000000000..fb9d54955b --- /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 b522464116..10c208ec38 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,31 +93,36 @@ 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({ - title: tr('clearMessages'), + title: { token: 'clearMessages' }, i18nMessage, onClickOk, okTheme: SessionButtonColor.Danger, onClickClose, - okText: tr('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, + okText: { token: 'clear' }, + radioOptions, }) ); diff --git a/ts/components/menuAndSettingsHooks/useDeclineMessageRequest.ts b/ts/components/menuAndSettingsHooks/useDeclineMessageRequest.ts index 8432402a0e..b026d17fcc 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 0000000000..a9e72903fb --- /dev/null +++ b/ts/components/menuAndSettingsHooks/useDeleteMessagesCb.tsx @@ -0,0 +1,584 @@ +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 { + 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'; +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 { deleteSogsMessageByServerIds } from '../../session/apis/open_group_api/sogsv3/sogsV3DeleteMessages'; +import { SnodeNamespaces } from '../../session/apis/snode_api/namespaces'; +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 { deleteOrMarkAsDeletedMessages } from '../../interactions/conversations/deleteOrMarkAsDeletedMessages'; +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'; +import { deleteMessagesFromSwarmOnly } from '../../interactions/conversations/deleteMessagesFromSwarmOnly'; + +const deleteMessageDeviceOnly = 'deleteMessageDeviceOnly'; +const deleteMessageAllMyDevices = 'deleteMessageDevicesAll'; +const deleteMessageEveryone = 'deleteMessageEveryone'; + +type MessageDeletionType = + | typeof deleteMessageDeviceOnly + | typeof deleteMessageAllMyDevices + | 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 isNts = useIsMe(conversationId); + const isPublic = useIsPublic(conversationId); + const weAreAdminOrModCommunity = useWeAreCommunityAdminOrModerator(conversationId); + const weAreAdminGroup = useWeAreAdmin(conversationId); + const isLegacyGroup = useIsLegacyGroup(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]; + + // legacy groups are read only, we can only delete locally. + const canDeleteAllForEveryoneAsAdmin = + !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.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) && + !sharedCannotDeleteForEveryone; + + const canDeleteFromAllDevices = isNts && !sharedCannotDeleteForEveryone; + + // Note: the isMe case has no radio buttons, so we just show the description below + const i18nMessage: TrArgs | undefined = isNts + ? { token: 'deleteMessageConfirm', count } + : undefined; + + const warningMessage: TrArgs | undefined = + isNts && !canDeleteFromAllDevices + ? { token: 'deleteMessageNoteToSelfWarning', count } + : !isNts && !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 + }, + isNts + ? { + 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: !isNts && canDeleteAllForEveryone ? deleteMessageEveryone : undefined, + }; + + dispatch( + updateConfirmModal({ + title: { token: 'deleteMessage', count }, + radioOptions, + i18nMessage, + + okText: { token: 'delete' }, + warningMessage, + + okTheme: SessionButtonColor.Danger, + onClickOk: async args => { + if ( + args !== deleteMessageEveryone && + args !== deleteMessageAllMyDevices && + args !== deleteMessageDeviceOnly + ) { + throw new Error('doDeleteSelectedMessages: invalid args onClickOk'); + } + + const noErrors = await doDeleteSelectedMessages({ + selectedMessages: msgModels, + conversation: convo, + deletionType: args, + }); + if (noErrors) { + 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. + * 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) + */ +async function doDeleteSelectedMessages({ + conversation, + selectedMessages, + deletionType, +}: { + 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( + 'doDeleteSelectedMessages: legacy groups are read only. Only removing those messages locally' + ); + await deleteOrMarkAsDeletedMessages({ + conversation, + messages: selectedMessages, + deletionType: 'complete', + actionContextIsUI: true, + }); + return true; + } + + if (deletionType === deleteMessageDeviceOnly) { + // Delete on device only is an easy case. + // `deleteOrMarkAsDeletedMessages` will forcefully remove + // - control messages or + // - already marked as deleted messages + await deleteOrMarkAsDeletedMessages({ + conversation, + messages: selectedMessages, + deletionType: 'markDeletedThisDevice', + actionContextIsUI: true, + }); + // this can never fail + ToastUtils.pushDeleted(selectedMessages.length); + window.inboxStore?.dispatch(resetSelectedMessageIds()); + + return true; + } + + 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; + } + + // 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; + } + + // sanity check that this is the last available option + if (deletionType !== deleteMessageEveryone) { + throw new Error(`doDeleteSelectedMessages: invalid deletionType: "${deletionType}"`); + } + + if (conversation.isPrivate()) { + 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'); + } + + // build the unsendMsgObjects before we delete the hash from those messages + const unsendMsgObjects = getUnsendMessagesObjects1o1(conversation, selectedMessages); + + // 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. Not sending unsend requests' + ); + ToastUtils.pushFailedToDelete(selectedMessages.length); + return false; + } + await deleteOrMarkAsDeletedMessages({ + conversation, + messages: selectedMessages, + deletionType: 'markDeletedGlobally', + actionContextIsUI: true, + }); + + await unsendMessagesForEveryone1o1(conversation, unsendMsgObjects); + + ToastUtils.pushDeleted(selectedMessages.length); + window.inboxStore?.dispatch(resetSelectedMessageIds()); + + return true; + } + + 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'); + } + + 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) { + window.log.warn( + 'unsendMessagesForEveryoneGroupV2: failed to send our groupv2 unsend for everyone' + ); + ToastUtils.pushFailedToDelete(selectedMessages.length); + return false; + } + await deleteOrMarkAsDeletedMessages({ + conversation, + messages: selectedMessages, + deletionType: 'markDeletedGlobally', + actionContextIsUI: true, + }); + window.inboxStore?.dispatch(resetSelectedMessageIds()); + ToastUtils.pushDeleted(selectedMessages.length); + + return true; +} + +/** + * Delete those message hashes from our swarm. + * 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 + */ +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'); + + // get the unsendMsgObjects before we delete the hash from those messages + const unsendMsgObjects = getUnsendMessagesObjects1o1(conversation, msgsToDeleteFromSwarm); + + // Note: not calling deleteMessagesFromSwarmAndCompletelyLocally here as + // we've got some custom logic going on + const deletedFromSwarm = await deleteMessagesFromSwarmOnly(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.pushFailedToDelete(msgsToDelete.length); + return false; + } + await deleteOrMarkAsDeletedMessages({ + conversation, + messages: msgsToDelete, + deletionType: 'complete', + actionContextIsUI: true, + }); + + // deleting from the swarm worked, 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) + ) + ); + // Update view and trigger update + window.inboxStore?.dispatch(resetSelectedMessageIds()); + ToastUtils.pushDeleted(unsendMsgObjects.length); + return true; +} + +/** + * 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. + * 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, + conversation: ConversationModel +) { + const toDeleteLocallyIds = await deleteOpenGroupMessages(selectedMessages, conversation); + if (toDeleteLocallyIds.length === 0) { + // Failed to delete those messages from the sogs. + ToastUtils.pushGenericError(); + return false; + } + + await deleteOrMarkAsDeletedMessages({ + conversation, + messages: selectedMessages, + deletionType: 'complete', + actionContextIsUI: true, + }); + + // successful deletion + ToastUtils.pushDeleted(toDeleteLocallyIds.length); + window.inboxStore?.dispatch(resetSelectedMessageIds()); + return true; +} + +/** + * + * @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, + unsendMsgObjects: Array +) { + 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 + await Promise.all( + unsendMsgObjects.map(unsendObject => + MessageQueue.use() + .sendToPubKey(new PubKey(conversation.id), 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 hasGroupAdminKey(groupPk: GroupPubkeyType) { + const group = await UserGroupsWrapperActions.getGroup(groupPk); + return !!group?.secretKey?.length; +} + +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 true; + } + + const storedAt = 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(), + }), + }); + return !!storedAt; +} + +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'); + + // 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 c105611c4b..4335648344 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 d4e8eb94f6..012116fee8 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 a641c66eea..38c7763523 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 61fa28e17c..6c8f751d3a 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 fb14e70c99..dfea5e0bb6 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 8451d4a0f4..7a1d6be48c 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/data/data.ts b/ts/data/data.ts index 6dd4e3d28e..acce9fbc01 100644 --- a/ts/data/data.ts +++ b/ts/data/data.ts @@ -460,12 +460,23 @@ async function getMessagesByConversation( * @param skipTimerInit see MessageModel.skipTimerInit * @returns the fetched messageModels */ -async function getLastMessagesByConversation( - conversationId: string, - limit: number, - skipTimerInit: boolean -): Promise> { - 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/hooks/useMessageInteractions.ts b/ts/hooks/useMessageInteractions.ts index af0e75b34e..baa41c5382 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,137 @@ 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, + useMessageIsControlMessage, + useMessageIsOnline, 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 useMessageSaveAttachment(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(); - return () => { - if (!messageId) { - return; - } - if (isSelectedBlocked) { - pushUnblockToSend(); - return; - } - void replyToMessage(messageId); - }; -} + const isControlMessage = useMessageIsControlMessage(messageId); + const msgIsOnline = useMessageIsOnline(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 cannotReply = !messageId || !msgIsOnline || isControlMessage; + + return cannotReply + ? null + : () => { + if (isSelectedBlocked) { + pushUnblockToSend(); + return; + } + void replyToMessage(messageId); + }; } -export function useMessageInteractions(messageId?: string | null) { - const dispatch = getAppDispatch(); +export function useMessageReact(messageId?: string) { + const isControlMessage = useMessageIsControlMessage(messageId); + const cannotReact = !messageId || isControlMessage; - 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 0511e3af9e..9f06c22cab 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 0000000000..4548e61565 --- /dev/null +++ b/ts/interactions/conversations/deleteMessagesFromSwarmOnly.ts @@ -0,0 +1,81 @@ +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. + * 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( + conversation: ConversationModel, + messages: Array | Array +) { + 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 { + // 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; + } + + 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 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 deletedFromSwarm; + } catch (e) { + window.log?.error( + `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/deleteOrMarkAsDeletedMessages.ts b/ts/interactions/conversations/deleteOrMarkAsDeletedMessages.ts new file mode 100644 index 0000000000..de645be5ae --- /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/interactions/conversations/unsendingInteractions.ts b/ts/interactions/conversations/unsendingInteractions.ts index b737a42dde..e81ab80ff6 100644 --- a/ts/interactions/conversations/unsendingInteractions.ts +++ b/ts/interactions/conversations/unsendingInteractions.ts @@ -1,621 +1,79 @@ -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); +import { deleteOrMarkAsDeletedMessages } from './deleteOrMarkAsDeletedMessages'; +import { deleteMessagesFromSwarmOnly } from './deleteMessagesFromSwarmOnly'; +import { ConvoHub } from '../../session/conversations'; +import type { WithActionContext, WithLocalMessageDeletionType } from '../../session/types/with'; - if (conversation.isClosedGroupV2()) { - throw new Error('unsendMessagesForEveryone1o1AndLegacy not compatible with group v2'); +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.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) - ) + if (conversation.isPrivateAndBlinded()) { + throw new Error( + `deleteMessagesFromSwarmAndDeleteOrMarkAsDeleted ${deletionType} does not support blinded conversations` ); - 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(), - }), - }); -} + // 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. -/** - * 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()) { + // 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( - 'Cannot unsend a message for an opengroup v2. This has to be a deleteMessage api call' + `deleteMessagesFromSwarmAndDeleteOrMarkAsDeleted ${deletionType} needs a 03 or 05 pk` ); } - if ( - conversation.isPrivate() || - (conversation.isClosedGroup() && !conversation.isClosedGroupV2()) + PubKey.is05Pubkey(pubkeyToDeleteFrom) && + pubkeyToDeleteFrom !== UserUtils.getOurPubKeyStrFromCache() ) { - 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 + throw new Error( + `deleteMessagesFromSwarmAndDeleteOrMarkAsDeleted ${deletionType} with 05 pk can only delete for ourself` ); - return false; } -} -/** - * 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. - */ -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 -- we cannot delete on the swarm (just unsend which is done separately) - 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' }); - return; - } window.log.info( - 'Deleting from swarm of ', - ed25519Str(pubkey), - ' hashes: ', - messages.map(m => m.get('messageHash')) + `deleteMessagesFromSwarmAndDeleteOrMarkAsDeleted ${deletionType}: Deleting from swarm of ${ed25519Str(pubkeyToDeleteFrom)}, hashes: ${messages.map(m => m.getMessageHash())}` ); - 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' }); -} -/** - * 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. - * 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") - if (conversation.isClosedGroup() && PubKey.is05Pubkey(conversation.id)) { - window.log.info( - 'Cannot delete messages from a legacy closed group swarm, so we just markDeleted.' + const convo = ConvoHub.use().get(conversation.id); + if (!convo) { + throw new Error( + `deleteMessagesFromSwarmAndDeleteOrMarkAsDeleted ${deletionType} convo not found` ); - await deleteMessagesLocallyOnly({ conversation, messages, deletionType: 'markDeleted' }); - - return; } - // 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 - const pubkeyToDeleteFrom = PubKey.is03Pubkey(conversation.id) - ? conversation.id - : UserUtils.getOurPubKeyStrFromCache(); - - // 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); + const deletedFromSwarm = await deleteMessagesFromSwarmOnly(convo, messages); if (!deletedFromSwarm) { window.log.warn( - 'deleteMessagesFromSwarmAndMarkAsDeletedLocally: some messages failed to be deleted but still removing the messages content... ' + `deleteMessagesFromSwarmAndDeleteOrMarkAsDeleted ${deletionType}: some messages failed to be deleted from swarm of ${ed25519Str(pubkeyToDeleteFrom)}. Maybe they were already deleted?` ); - } - 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({ + } else { + await deleteOrMarkAsDeletedMessages({ conversation, - messages: selectedMessages, - deletionType: 'markDeleted', + messages, + deletionType, + actionContextIsUI, }); - 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 []; + return deletedFromSwarm; } diff --git a/ts/interactions/messageInteractions.ts b/ts/interactions/messageInteractions.ts index 7f8589b069..d1238fabf5 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/conversation.ts b/ts/models/conversation.ts index 98f71b7f65..2eabf49c06 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, }; } @@ -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) { @@ -3029,7 +3035,6 @@ export class ConversationModel extends Model { return success; } - // #region Start of getters public getExpirationMode() { return this.get('expirationMode'); } @@ -3041,8 +3046,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 bc9d51eb4c..6d079c7173 100644 --- a/ts/models/message.ts +++ b/ts/models/message.ts @@ -27,6 +27,7 @@ import { import { MessageAttributes, MessageAttributesOptionals, + MessageDeletedType, MessageGroupUpdate, fillMessageAttributesWithDefaults, type DataExtractionNotificationMsg, @@ -84,7 +85,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, @@ -109,6 +110,8 @@ 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'; +import type { WithLocalMessageDeletionType } from '../session/types/with'; // tslint:disable: cyclomatic-complexity @@ -139,39 +142,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,10 +215,19 @@ export class MessageModel extends Model { this.isExpirationTimerUpdate() || this.isDataExtractionNotification() || this.isMessageRequestResponse() || - this.isGroupUpdate() + this.isGroupUpdate() || + this.isCallNotification() || + this.isInteractionNotification() ); } + /** + * 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'; } @@ -386,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; @@ -562,15 +605,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() || @@ -593,6 +627,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(); @@ -621,8 +685,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()) { @@ -887,13 +952,36 @@ 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() { + 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: true, - body: tr('deleteMessageDeletedGlobally'), + isDeleted: + requestedDeleteType === 'markDeletedThisDevice' + ? MessageDeletedType.deletedLocally + : MessageDeletedType.deletedGlobally, + body: '', quote: undefined, groupInvitation: undefined, dataExtractionNotification: undefined, @@ -908,7 +996,14 @@ export class MessageModel extends Model { interactionNotification: undefined, reaction: undefined, messageRequestResponse: undefined, + 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()); @@ -917,6 +1012,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 @@ -1569,7 +1665,6 @@ export class MessageModel extends Model { return forcedArrayUpdate; } - // #region Start of getters public getExpirationType() { return this.get('expirationType'); } @@ -1597,8 +1692,6 @@ export class MessageModel extends Model { public getExpirationTimerUpdate() { return this.get('expirationTimerUpdate'); } - - // #endregion } const throttledAllMessagesDispatch = debounce( diff --git a/ts/models/messageType.ts b/ts/models/messageType.ts index 39ba63ff8d..91b27ac1bb 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 fc272ed97b..29d3fcab35 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) { @@ -1645,7 +1647,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 +1659,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 +1667,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 +1715,6 @@ function updateToSessionSchemaVersion34(currentVersion: number, db: Database) { } } - // #endregion - - // #region v34 Disappearing Messages Private Conversations const privateConversationsInfo = db .prepare( `UPDATE ${CONVERSATIONS_TABLE} SET @@ -1793,9 +1788,6 @@ function updateToSessionSchemaVersion34(currentVersion: number, db: Database) { } } - // #endregion - - // #region v34 Disappearing Messages Groups const groupConversationsInfo = db .prepare( `UPDATE ${CONVERSATIONS_TABLE} SET @@ -1866,8 +1858,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? `, @@ -2379,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_conversationId ON ${MESSAGES_TABLE} (conversationId, 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/node/sql.ts b/ts/node/sql.ts index dad16a51e5..cd94be85cb 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/react.d.ts b/ts/react.d.ts index fb46ec82f8..7ec1c6925a 100644 --- a/ts/react.d.ts +++ b/ts/react.d.ts @@ -215,13 +215,15 @@ declare module 'react' { | `${ConfirmButtons}-confirm` | `${CancelButtons}-cancel` | `clear-${ClearButtons}` - | `${SetButton}-set`; + | `${SetButton}-set` + | `reaction-emoji-panel`; type InputLabels = | 'device_and_network' | 'device_only' - | 'deleteForEveryone' - | 'deleteJustForMe' + | 'deleteMessageEveryone' + | 'deleteMessageDevicesAll' + | 'deleteMessageDeviceOnly' | 'enterForSend' | 'enterForNewLine' | 'message' @@ -434,6 +436,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 7e865340e1..e817134119 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, @@ -481,20 +478,31 @@ 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')); + 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 - 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 c4dce91d5c..6a915c15e9 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 { deleteOrMarkAsDeletedMessages } from '../../interactions/conversations/deleteOrMarkAsDeletedMessages'; type WithSignatureTimestamp = { signatureTimestamp: number }; type WithAuthor = { author: PubkeyType }; @@ -445,19 +446,12 @@ 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 deleteOrMarkAsDeletedMessages({ + conversation: convo, + messages: messageModels, + deletionType: 'markDeletedGlobally', + actionContextIsUI: false, + }); return; } @@ -496,19 +490,12 @@ 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 deleteOrMarkAsDeletedMessages({ + conversation: convo, + messages: mergedModels, + deletionType: 'markDeletedGlobally', + actionContextIsUI: false, + }); } async function handleGroupUpdateInviteResponseMessage({ @@ -668,10 +655,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/apis/snode_api/SNodeAPI.ts b/ts/session/apis/snode_api/SNodeAPI.ts index 9b22f9bbd9..3a1c3e9354 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'); @@ -283,7 +283,7 @@ const networkDeleteMessageOurSwarm = async ( } }, { - retries: 5, + retries: 2, minTimeout: SnodeAPI.getMinTimeout(), onFailedAttempt: e => { window?.log?.warn( @@ -301,7 +301,7 @@ const networkDeleteMessageOurSwarm = async ( ); return false; } -}; +} /** * Delete the specified message hashes from the 03-group's swarm. @@ -311,10 +311,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 +379,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 2801a7d634..9a79c439a0 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,19 @@ 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' | '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 WithLocalMessageDeletionType = { deletionType: 'complete' | 'markDeleted' }; export type ShortenOrExtend = 'extend' | 'shorten' | ''; export type WithShortenOrExtend = { shortenOrExtend: ShortenOrExtend }; export type WithMessagesHashes = { messagesHashes: Array }; @@ -37,3 +49,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/Toast.tsx b/ts/session/utils/Toast.tsx index 4210d5caac..41381a048d 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')); } @@ -264,3 +268,7 @@ export function pushNoMediaUntilApproved() { export function pushRateLimitHitReactions() { pushToastInfo('reactRateLimit', tStripped('emojiReactsCoolDown')); } + +export function pushGenericError() { + pushToastError('errorGeneric', tStripped('errorGeneric')); +} diff --git a/ts/session/utils/job_runners/jobs/GroupPendingRemovalsJob.ts b/ts/session/utils/job_runners/jobs/GroupPendingRemovalsJob.ts index 1aa850ca37..3ac87461ef 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 { deleteMessagesFromSwarmAndDeleteOrMarkAsDeleted } from '../../../../interactions/conversations/unsendingInteractions'; import { MetaGroupWrapperActions, MultiEncryptWrapperActions, @@ -227,20 +227,15 @@ 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 deleteMessagesFromSwarmAndDeleteOrMarkAsDeleted({ + conversation: convo, + messages: models, + deletionType: 'markDeletedGlobally', + // this job is only used for non visible messages + actionContextIsUI: false, + }); } } } catch (e) { diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 89e3cd74b6..4feca6907a 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 { @@ -36,17 +40,75 @@ 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' + | '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; @@ -158,7 +220,7 @@ export type PropsForMessageWithoutConvoProps = { previews?: Array; quote?: Quote; messageHash?: string; - isDeleted?: boolean; + isDeleted?: MessageDeletedType; isUnread?: boolean; expirationType?: DisappearingMessageType; expirationDurationMs?: number; @@ -180,10 +242,8 @@ export type PropsForMessageWithConvoProps = PropsForMessageWithoutConvoProps & { isKickedFromGroup: boolean; weAreAdmin: boolean; isSenderAdmin: boolean; - isDeletable: boolean; - isDeletableForEveryone: boolean; isBlocked: boolean; - isDeleted?: boolean; + isDeleted?: MessageDeletedType; }; /** @@ -305,6 +365,8 @@ export type ConversationsStateType = { nextMessageToPlayId?: string; mentionMembers: Array; focusedMessageId: string | null; + interactableMessageId: string | null; + reactionBarTriggerPosition: PopoverTriggerPosition | null; isCompositionTextAreaFocused: boolean; }; @@ -492,6 +554,8 @@ export function getEmptyConversationState(): ConversationsStateType { shouldHighlightMessage: false, mostRecentMessageId: null, focusedMessageId: null, + interactableMessageId: null, + reactionBarTriggerPosition: null, isCompositionTextAreaFocused: false, }; } @@ -504,6 +568,12 @@ function handleMessageChangedOrAdded( return state; } + const editedQuotedMessages = updateQuotedMessageProps( + state.quotedMessages, + changedOrAddedMessageProps + ); + state.quotedMessages = editedQuotedMessages; + const messageInStoreIndex = state.messages.findIndex( m => m.propsForMessage.id === changedOrAddedMessageProps.propsForMessage.id ); @@ -556,6 +626,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) @@ -579,26 +693,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, @@ -663,20 +764,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); @@ -694,6 +781,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 }; }, @@ -857,6 +953,8 @@ const conversationsSlice = createSlice({ oldBottomMessageId: null, mentionMembers: [], focusedMessageId: null, + interactableMessageId: null, + reactionBarTriggerPosition: null, isCompositionTextAreaFocused: false, }; }, @@ -1120,7 +1218,6 @@ export const { openRightPanel, closeRightPanel, removeMessageInfoId, - addMessageIdToSelection, resetSelectedMessageIds, setFocusedMessageId, setIsCompositionTextAreaFocused, @@ -1132,6 +1229,8 @@ export const { updateMentionsMembers, resetConversationExternal, markConversationInitialLoadingInProgress, + setReactionBarTriggerPosition, + setInteractableMessageId, } = actions; async function unmarkAsForcedUnread(convoId: string) { diff --git a/ts/state/ducks/modalDialog.tsx b/ts/state/ducks/modalDialog.tsx index 5cec27cf7c..be26418ec4 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/ducks/networkData.ts b/ts/state/ducks/networkData.ts index 92c904478b..4cf94bba16 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 2b929dc7ff..b63cb7c382 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/ducks/types/defaultFeatureFlags.ts b/ts/state/ducks/types/defaultFeatureFlags.ts index 6cffe955d5..7fe2072dd2 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 03c8d911fb..714bba7888 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 d85181598d..bb12db11c2 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' @@ -35,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(); @@ -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; @@ -69,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/onboarding/selectors/registration.ts b/ts/state/onboarding/selectors/registration.ts index ba22b51919..2f67e1bfb4 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/conversations.ts b/ts/state/selectors/conversations.ts index 4d5388e023..b29cb80bdf 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -18,9 +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 { 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,11 +33,12 @@ 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'; import type { QuoteProps } from '../../components/conversation/message/message-content/quote/Quote'; +import { PopoverTriggerPosition } from '../../components/SessionTooltip'; export const getConversations = (state: StateType): ConversationsStateType => state.conversations; @@ -127,21 +125,10 @@ 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, (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) => { @@ -156,38 +143,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, }; - - 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, - }, - }; }); } ); @@ -536,6 +498,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; @@ -564,6 +532,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); } @@ -745,21 +721,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 +730,6 @@ export const getMessagePropsByMessageId = createSelector( isBlocked: !!selectedConvo.isBlocked, isPublic: !!isPublic, isSenderAdmin, - isDeletable, - isDeletableForEveryone, weAreAdmin, conversationType: selectedConvo.type, sender, @@ -838,7 +797,7 @@ type QuotePropsFound = QuotePropsAlwaysThere & { referencedMessageNotFound: false; id: string; convoId: string; -} & Pick; +} & Pick; export const getMessageQuoteProps = createSelector( getConversationLookup, @@ -885,7 +844,7 @@ export const getMessageQuoteProps = createSelector( } const sourceMsgProps = foundProps.propsForMessage; - if (!sourceMsgProps || sourceMsgProps.isDeleted) { + if (!sourceMsgProps) { return quoteNotFoundWithDetails(author, timestamp); } @@ -906,6 +865,7 @@ export const getMessageQuoteProps = createSelector( referencedMessageNotFound: false, convoId: convo.id, timestamp: toNumber(timestamp), + isDeleted: sourceMsgProps.isDeleted, }; } ); @@ -941,75 +901,7 @@ export const getIsMessageSelected = createSelector( return false; } - const { id } = props.propsForMessage; - - return selectedIds.includes(id); - } -); - -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 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; + return selectedIds.includes(props.propsForMessage.id); } ); diff --git a/ts/state/selectors/messages.ts b/ts/state/selectors/messages.ts index 7006263523..c1858ea7e3 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 => { @@ -100,9 +100,19 @@ 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; + return useMessagePropsByMessageId(messageId)?.propsForMessage.previews; }; export const useMessageAttachments = ( @@ -116,22 +126,34 @@ 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 => { 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; } -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 +200,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 +232,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 +267,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 +287,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 +309,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 +325,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 +355,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 +375,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/state/selectors/networkModal.ts b/ts/state/selectors/networkModal.ts index 571ce87093..66451a75a9 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 diff --git a/ts/types/isStringArray.ts b/ts/types/isStringArray.ts new file mode 100644 index 0000000000..03d9b60e68 --- /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 9ef384936f..dd97017efe 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(); diff --git a/ts/util/keyboardShortcuts.ts b/ts/util/keyboardShortcuts.ts index 56de193a22..48f5c9c2d5 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: ['.'], }, diff --git a/ts/util/logger/renderer_process_logging.ts b/ts/util/logger/renderer_process_logging.ts index a4ddc84bda..a4ccc9e919 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 => { diff --git a/ts/util/reactions.ts b/ts/util/reactions.ts index 638f2d3d3b..243324b888 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: [] };