// SPDX-License-Identifier: AGPL-3.0-or-later import Accessibility from '@app/features/accessibility/state/Accessibility'; import {useAnimatedMediaPlaybackAllowed} from '@app/features/app/hooks/useAnimatedMediaPlayback'; import {useContextMenuHoverState} from '@app/features/app/hooks/useContextMenuHoverState'; import styles from '@app/features/channel/components/MessageActionBar.module.css'; import { createMessageActionHandlers, getCopyableMessageText, isClientSystemMessage, isEmbedsSuppressed, useMessagePermissions, } from '@app/features/channel/components/MessageActionUtils'; import {REACT_WITH_EMOJI_DESCRIPTOR} from '@app/features/channel/components/QuickReactionsRow'; import type {Channel} from '@app/features/channel/models/Channel'; import {useQuickReactionEmojis} from '@app/features/channel/state/QuickReactionStore'; import {MessageDebugModal} from '@app/features/devtools/components/debug/MessageDebugModal'; import * as EmojiPickerCommands from '@app/features/emoji/commands/EmojiPickerCommands'; import {EmojiPickerPopout} from '@app/features/emoji/components/popouts/EmojiPickerPopout'; import type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes'; import {getEmojiDisplayData} from '@app/features/expressions/utils/SkinToneUtils'; import { ADD_REACTION_DESCRIPTOR, BOOKMARK_MESSAGE_DESCRIPTOR, COPY_MESSAGE_ID_DESCRIPTOR, COPY_MESSAGE_LINK_DESCRIPTOR, COPY_TEXT_DESCRIPTOR, DELETE_MESSAGE_DESCRIPTOR, EDIT_MESSAGE_DESCRIPTOR, MARK_AS_UNREAD_DESCRIPTOR, PIN_MESSAGE_DESCRIPTOR, REMOVE_BOOKMARK_DESCRIPTOR, REPLY_DESCRIPTOR, SUPPRESS_EMBEDS_DESCRIPTOR, TRY_AGAIN_DESCRIPTOR, UNPIN_MESSAGE_DESCRIPTOR, } from '@app/features/i18n/utils/CommonMessageDescriptors'; import type {Message} from '@app/features/messaging/models/MessagingMessage'; import SavedMessages from '@app/features/messaging/state/SavedMessages'; import {getEmojiNameWithColons, toReactionEmoji} from '@app/features/messaging/utils/ReactionUtils'; import {ComponentDispatch} from '@app/features/platform/utils/ComponentBus'; import messageStyles from '@app/features/theme/styles/Message.module.css'; import { AddReactionIcon, BookmarkIcon, CopyIdIcon, CopyLinkIcon, CopyMessageTextIcon, DebugMessageIcon, DeleteIcon, EditMessageIcon, ForwardIcon, MarkAsUnreadIcon, MoreIcon, PinIcon, ReplyIcon, RetryIcon, SuppressEmbedsIcon, } from '@app/features/ui/action_menu/ContextMenuIcons'; import {MessageContextMenu} from '@app/features/ui/action_menu/MessageContextMenu'; import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands'; import * as ModalCommands from '@app/features/ui/commands/ModalCommands'; import {modal} from '@app/features/ui/commands/ModalCommands'; import * as PopoutCommands from '@app/features/ui/commands/PopoutCommands'; import FocusRing from '@app/features/ui/focus_ring/FocusRing'; import {Popout} from '@app/features/ui/popover/PopoverPopout'; import ContextMenu from '@app/features/ui/state/ContextMenu'; import KeyboardMode from '@app/features/ui/state/KeyboardMode'; import {Tooltip} from '@app/features/ui/tooltip/Tooltip'; import {canUseWindowFocusedActivationClick} from '@app/features/ui/utils/WindowFocusInteractionGuard'; import UserSettings from '@app/features/user/state/UserSettings'; import * as AvatarUtils from '@app/features/user/utils/AvatarUtils'; import {MessageStates} from '@fluxer/constants/src/ChannelConstants'; import {msg} from '@lingui/core/macro'; import {useLingui} from '@lingui/react/macro'; import {clsx} from 'clsx'; import {observer} from 'mobx-react-lite'; import React, {useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore} from 'react'; const CLICK_TO_REACT_DESCRIPTOR = msg({ message: 'Click to react', comment: 'Tooltip on the add-reaction button in the inline message hover action bar.', }); const MESSAGE_DEBUG_DESCRIPTOR = msg({ message: 'Message debug', comment: 'Title of the developer-mode message debug modal opened from the message action bar.', }); const MORE_DESCRIPTOR = msg({ message: 'More', comment: 'Tooltip on the overflow button in the inline message hover action bar. Opens the full action menu.', }); const DEBUG_MESSAGE_DESCRIPTOR = msg({ message: 'Debug message', comment: 'Developer-mode item in the message action bar overflow menu. Opens the message debug modal.', }); const UNSUPPRESS_EMBEDS_DESCRIPTOR = msg({ message: 'Unsuppress embeds', comment: 'Item in the message action bar overflow menu. Re-shows previously hidden link previews and embeds.', }); const FORWARD_DESCRIPTOR = msg({ message: 'Forward', comment: 'Tooltip on the forward button in the inline message hover action bar.', }); const shiftKeyManager = (() => { let isShiftPressed = false; const listeners = new Set<() => void>(); const notify = () => { listeners.forEach((listener) => listener()); }; const setShiftPressed = (pressed: boolean) => { if (isShiftPressed !== pressed) { isShiftPressed = pressed; notify(); } }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Shift') { setShiftPressed(true); } }; const handleKeyUp = (event: KeyboardEvent) => { if (event.key === 'Shift') { setShiftPressed(false); } }; const handleWindowBlur = () => { setShiftPressed(false); }; const handlePointerModifierChange = (event: MouseEvent) => { setShiftPressed(event.getModifierState?.('Shift') ?? event.shiftKey); }; const supportsPointerModifierEvents = (): boolean => 'PointerEvent' in window; const addPointerModifierListeners = () => { if (supportsPointerModifierEvents()) { window.addEventListener('pointerover', handlePointerModifierChange, true); window.addEventListener('pointerdown', handlePointerModifierChange, true); window.addEventListener('pointerup', handlePointerModifierChange, true); return; } window.addEventListener('mouseover', handlePointerModifierChange, true); window.addEventListener('mousedown', handlePointerModifierChange, true); window.addEventListener('mouseup', handlePointerModifierChange, true); }; const removePointerModifierListeners = () => { if (supportsPointerModifierEvents()) { window.removeEventListener('pointerover', handlePointerModifierChange, true); window.removeEventListener('pointerdown', handlePointerModifierChange, true); window.removeEventListener('pointerup', handlePointerModifierChange, true); return; } window.removeEventListener('mouseover', handlePointerModifierChange, true); window.removeEventListener('mousedown', handlePointerModifierChange, true); window.removeEventListener('mouseup', handlePointerModifierChange, true); }; window.addEventListener('keydown', handleKeyDown); window.addEventListener('keyup', handleKeyUp); window.addEventListener('blur', handleWindowBlur); return { subscribe: (listener: () => void) => { if (listeners.size === 0) { addPointerModifierListeners(); } listeners.add(listener); return () => { listeners.delete(listener); if (listeners.size === 0) { removePointerModifierListeners(); } }; }, getSnapshot: () => isShiftPressed, getServerSnapshot: () => false, }; })(); const useShiftKey = (enabled: boolean) => { const subscribe = useCallback( (listener: () => void) => { if (!enabled) { return () => undefined; } return shiftKeyManager.subscribe(listener); }, [enabled], ); const getSnapshot = useCallback(() => { return enabled ? shiftKeyManager.getSnapshot() : false; }, [enabled]); return useSyncExternalStore(subscribe, getSnapshot, shiftKeyManager.getServerSnapshot); }; const suppressBlockedActivationClick = (event: React.SyntheticEvent): boolean => { if (canUseWindowFocusedActivationClick()) { return false; } event.preventDefault(); event.stopPropagation(); return true; }; interface MessageActionBarButtonProps { label: string; icon: React.ReactNode; onClick?: (event: React.MouseEvent | React.KeyboardEvent) => void; onPointerDownCapture?: (event: React.PointerEvent) => void; danger?: boolean; isActive?: boolean; hidden?: boolean; dataAction?: string; } const MessageActionBarButton = React.forwardRef( ({label, icon, onClick, onPointerDownCapture, danger, isActive, hidden, dataAction}, ref) => { const handleClick = useCallback( (event: React.MouseEvent | React.KeyboardEvent) => { if (suppressBlockedActivationClick(event)) { return; } event.preventDefault(); event.stopPropagation(); onClick?.(event); }, [onClick], ); const handlePointerDownCapture = useCallback( (event: React.PointerEvent) => { if (suppressBlockedActivationClick(event)) { return; } onPointerDownCapture?.(event); }, [onPointerDownCapture], ); const buttonClassName = useMemo( () => clsx(styles.button, danger && styles.danger, isActive && styles.active), [danger, isActive], ); return ( ); }, ); MessageActionBarButton.displayName = 'MessageActionBarButton'; interface QuickReactionButtonProps { emoji: FlatEmoji; onReact: (emoji: FlatEmoji) => void; } const QuickReactionButton = observer( React.forwardRef(({emoji, onReact}, ref) => { const {i18n} = useLingui(); const [isHovered, setIsHovered] = useState(false); const animatedMediaPlaybackAllowed = useAnimatedMediaPlaybackAllowed(); const {url: displayUrl} = getEmojiDisplayData(emoji); const handleClick = useCallback( (event: React.MouseEvent | React.KeyboardEvent) => { if (suppressBlockedActivationClick(event)) { return; } event.preventDefault(); event.stopPropagation(); EmojiPickerCommands.trackEmojiUsage(emoji); onReact(emoji); }, [emoji, onReact], ); const handleMouseEnter = useCallback(() => setIsHovered(true), []); const handleMouseLeave = useCallback(() => setIsHovered(false), []); const emojiNameWithColons = useMemo(() => getEmojiNameWithColons(toReactionEmoji(emoji)), [emoji]); const shouldShowAnimated = useMemo( () => emoji.animated && isHovered && animatedMediaPlaybackAllowed, [animatedMediaPlaybackAllowed, emoji.animated, isHovered], ); const emojiSrc = useMemo( () => emoji.animated && emoji.id ? AvatarUtils.getEmojiURL({id: emoji.id, animated: shouldShowAnimated}) : (displayUrl ?? ''), [emoji.animated, emoji.id, displayUrl, shouldShowAnimated], ); const tooltipContent = useCallback( () => (
{emojiNameWithColons} {i18n._(CLICK_TO_REACT_DESCRIPTOR)}
), [emojiNameWithColons, i18n], ); const ariaLabel = useMemo( () => i18n._(REACT_WITH_EMOJI_DESCRIPTOR, {emojiShortcode: emojiNameWithColons}), [emojiNameWithColons, i18n.locale], ); return ( ); }), ); QuickReactionButton.displayName = 'QuickReactionButton'; interface MessageActionBarCoreProps { message: Message; handleDelete: (bypassConfirm?: boolean) => void; permissions: { channel: Channel; canSendMessages: boolean; canAddReactions: boolean; canEditMessage: boolean; canDeleteMessage: boolean; canPinMessage: boolean; canForwardMessage: boolean; shouldRenderSuppressEmbeds: boolean; }; isSaved: boolean; developerMode: boolean; onPopoutToggle?: (isOpen: boolean) => void; } export const MessageActionBarCore: React.FC = observer( ({message, handleDelete, permissions, isSaved, developerMode, onPopoutToggle}) => { const {i18n} = useLingui(); const [emojiPickerOpen, setEmojiPickerOpen] = useState(false); const moreOptionsButtonRef = useRef(null); const emojiPickerButtonRef = useRef(null); const actionBarRef = useRef(null); const keepOpenOnNextMoreMenuCloseRef = useRef(false); const contextMenuOpen = useContextMenuHoverState(actionBarRef); const moreMenuOpen = useContextMenuHoverState(moreOptionsButtonRef); const showMessageActionBar = Accessibility.showMessageActionBar; const showQuickReactions = Accessibility.showMessageActionBarQuickReactions; const showShiftExpand = Accessibility.showMessageActionBarShiftExpand; const onlyMoreButton = Accessibility.showMessageActionBarOnlyMoreButton; const keyboardModeEnabled = KeyboardMode.keyboardModeEnabled; const shouldListenForShift = showShiftExpand && showMessageActionBar && !onlyMoreButton && !keyboardModeEnabled; const shiftPressed = useShiftKey(shouldListenForShift); const showFullActions = showShiftExpand && shiftPressed; const { canSendMessages, canAddReactions, canEditMessage, canDeleteMessage, canPinMessage, canForwardMessage, shouldRenderSuppressEmbeds, } = permissions; const supportsInteractiveActions = useMemo(() => !isClientSystemMessage(message), [message]); const handlers = useMemo( () => createMessageActionHandlers(message, {i18n, channel: permissions.channel}), [message, i18n.locale, permissions.channel], ); const channel = permissions.channel; const quickReactionEmojis = useQuickReactionEmojis( channel, 3, canAddReactions && showQuickReactions && message.state === MessageStates.SENT, ); const blurEmojiPickerTrigger = useCallback(() => { if (keyboardModeEnabled) { return; } requestAnimationFrame(() => emojiPickerButtonRef.current?.blur()); }, [keyboardModeEnabled]); const handleEmojiPickerToggle = useCallback( (open: boolean) => { setEmojiPickerOpen(open); onPopoutToggle?.(open); if (!open) { blurEmojiPickerTrigger(); } }, [onPopoutToggle, blurEmojiPickerTrigger], ); const handleEmojiPickerOpen = useCallback(() => handleEmojiPickerToggle(true), [handleEmojiPickerToggle]); const handleEmojiPickerClose = useCallback(() => handleEmojiPickerToggle(false), [handleEmojiPickerToggle]); const handleMoreMenuOpenReactionPicker = useCallback(() => { keepOpenOnNextMoreMenuCloseRef.current = true; }, []); const handleMoreMenuClose = useCallback( (closeMenu: () => void) => { if (!keepOpenOnNextMoreMenuCloseRef.current) { onPopoutToggle?.(false); } closeMenu(); }, [onPopoutToggle], ); const handleMoreMenuClosed = useCallback(() => { if (keepOpenOnNextMoreMenuCloseRef.current) { keepOpenOnNextMoreMenuCloseRef.current = false; return; } onPopoutToggle?.(false); }, [onPopoutToggle]); useEffect(() => { return () => { if (emojiPickerOpen) { onPopoutToggle?.(false); } }; }, [emojiPickerOpen, onPopoutToggle]); const handleDebugClick = useCallback(() => { ModalCommands.push( modal(() => ( )), ); }, [message, i18n]); useEffect(() => { const unsubscribe = ComponentDispatch.subscribe('EMOJI_PICKER_OPEN', (payload?: unknown) => { const data = (payload ?? {}) as {messageId?: string}; if (data.messageId === message.id && emojiPickerButtonRef.current) { PopoutCommands.open({ key: `emoji_picker-${message.id}`, position: 'left-start', render: ({onClose}) => ( ), target: emojiPickerButtonRef.current, shouldAutoUpdate: false, animationType: 'none', onOpen: () => handleEmojiPickerToggle(true), onClose: () => handleEmojiPickerToggle(false), }); } }); return () => unsubscribe(); }, [message.id, message.channelId, handlers.handleEmojiSelect, handleEmojiPickerToggle]); const handleMoreOptionsPointerDown = useCallback( (event: React.PointerEvent) => { const contextMenu = ContextMenu.contextMenu; const isOpen = !!contextMenu && contextMenu.target.target === moreOptionsButtonRef.current; if (isOpen) { event.stopPropagation(); event.preventDefault(); ContextMenuCommands.close(); onPopoutToggle?.(false); } }, [onPopoutToggle], ); useEffect(() => { if (!showFullActions || !moreMenuOpen) { return; } const contextMenu = ContextMenu.contextMenu; if (contextMenu?.target.target === moreOptionsButtonRef.current) { ContextMenuCommands.close(); } }, [moreMenuOpen, showFullActions]); const openMoreOptionsMenu = useCallback( (event: React.MouseEvent | React.KeyboardEvent) => { if (!showMessageActionBar || showFullActions) { return; } const contextMenu = ContextMenu.contextMenu; const isOpen = !!contextMenu && contextMenu.target.target === event.currentTarget; if (isOpen) { return; } onPopoutToggle?.(true); ContextMenuCommands.openFromElementLeftStart( event, (props) => ( { handleMoreMenuClose(props.onClose); }} onOpenReactionPicker={handleMoreMenuOpenReactionPicker} onDelete={handleDelete} data-flx="channel.message-action-bar.open-more-options-menu.message-context-menu" /> ), { config: { onClose: handleMoreMenuClosed, }, }, ); }, [ channel, handleDelete, handleMoreMenuClose, handleMoreMenuClosed, handleMoreMenuOpenReactionPicker, message, showFullActions, showMessageActionBar, ], ); return (
{message.state === MessageStates.SENT && (onlyMoreButton ? ( } label={i18n._(MORE_DESCRIPTOR)} onPointerDownCapture={handleMoreOptionsPointerDown} onClick={openMoreOptionsMenu} isActive={moreMenuOpen} data-flx="channel.message-action-bar.message-action-bar-core.message-action-bar-button.open-more-options-menu" /> ) : ( <> {!showFullActions && canAddReactions && showQuickReactions && quickReactionEmojis.map((emoji) => ( ))} {showFullActions && ( <> {developerMode && ( } label={i18n._(DEBUG_MESSAGE_DESCRIPTOR)} onClick={handleDebugClick} data-flx="channel.message-action-bar.message-action-bar-core.message-action-bar-button.debug-click" /> )} } label={i18n._(COPY_MESSAGE_ID_DESCRIPTOR)} onClick={handlers.handleCopyMessageId} data-flx="channel.message-action-bar.message-action-bar-core.message-action-bar-button.copy-message-id" /> {supportsInteractiveActions && ( } label={i18n._(COPY_MESSAGE_LINK_DESCRIPTOR)} onClick={handlers.handleCopyMessageLink} data-flx="channel.message-action-bar.message-action-bar-core.message-action-bar-button.copy-message-link" /> )} {getCopyableMessageText(message, i18n) && ( } label={i18n._(COPY_TEXT_DESCRIPTOR)} onClick={handlers.handleCopyMessage} data-flx="channel.message-action-bar.message-action-bar-core.message-action-bar-button.copy-message" /> )} {shouldRenderSuppressEmbeds && ( } label={ isEmbedsSuppressed(message) ? i18n._(UNSUPPRESS_EMBEDS_DESCRIPTOR) : i18n._(SUPPRESS_EMBEDS_DESCRIPTOR) } onClick={handlers.handleToggleSuppressEmbeds} data-flx="channel.message-action-bar.message-action-bar-core.message-action-bar-button.toggle-suppress-embeds" /> )} } label={i18n._(MARK_AS_UNREAD_DESCRIPTOR)} onClick={handlers.handleMarkAsUnread} data-flx="channel.message-action-bar.message-action-bar-core.message-action-bar-button.mark-as-unread" /> {message.isUserMessage() && supportsInteractiveActions && ( } label={isSaved ? i18n._(REMOVE_BOOKMARK_DESCRIPTOR) : i18n._(BOOKMARK_MESSAGE_DESCRIPTOR)} onClick={handlers.handleSaveMessage(isSaved)} data-flx="channel.message-action-bar.message-action-bar-core.message-action-bar-button.save-message" /> )} {message.isUserMessage() && canPinMessage && ( } label={message.pinned ? i18n._(UNPIN_MESSAGE_DESCRIPTOR) : i18n._(PIN_MESSAGE_DESCRIPTOR)} onClick={handlers.handlePinMessage} data-flx="channel.message-action-bar.message-action-bar-core.message-action-bar-button.pin-message" /> )} )} {canAddReactions && ( ( )} position="left-start" uniqueId={`emoji_picker-actionbar-${message.id}`} shouldAutoUpdate={false} animationType="none" shouldOpenOnClick={() => canUseWindowFocusedActivationClick()} onOpen={handleEmojiPickerOpen} onClose={handleEmojiPickerClose} data-flx="channel.message-action-bar.message-action-bar-core.popout" > } label={i18n._(ADD_REACTION_DESCRIPTOR)} isActive={emojiPickerOpen} dataAction="message-add-reaction-button" data-flx="channel.message-action-bar.message-action-bar-core.message-action-bar-button" /> )} {message.isUserMessage() && !message.messageSnapshots && canEditMessage && ( } label={i18n._(EDIT_MESSAGE_DESCRIPTOR)} onClick={handlers.handleEditMessage} data-flx="channel.message-action-bar.message-action-bar-core.message-action-bar-button.edit-message" /> )} {message.isUserMessage() && supportsInteractiveActions && canSendMessages && ( } label={i18n._(REPLY_DESCRIPTOR)} onClick={handlers.handleReply} data-flx="channel.message-action-bar.message-action-bar-core.message-action-bar-button.reply" /> )} {message.isUserMessage() && supportsInteractiveActions && canForwardMessage && ( } label={i18n._(FORWARD_DESCRIPTOR)} onClick={handlers.handleForward} data-flx="channel.message-action-bar.message-action-bar-core.message-action-bar-button.forward" /> )} {showFullActions && canDeleteMessage && ( } label={i18n._(DELETE_MESSAGE_DESCRIPTOR)} onClick={(event) => handleDelete(event.shiftKey)} data-flx="channel.message-action-bar.message-action-bar-core.message-action-bar-button.delete" /> )} } label={i18n._(MORE_DESCRIPTOR)} hidden={showFullActions} onPointerDownCapture={handleMoreOptionsPointerDown} onClick={openMoreOptionsMenu} isActive={moreMenuOpen} data-flx="channel.message-action-bar.message-action-bar-core.message-action-bar-button.open-more-options-menu--2" /> ))} {message.state === MessageStates.FAILED && ( <> } label={i18n._(TRY_AGAIN_DESCRIPTOR)} onClick={handlers.handleRetryMessage} data-flx="channel.message-action-bar.message-action-bar-core.message-action-bar-button.retry-message" /> } label={i18n._(DELETE_MESSAGE_DESCRIPTOR)} onClick={handlers.handleFailedMessageDelete} data-flx="channel.message-action-bar.message-action-bar-core.message-action-bar-button.failed-message-delete" /> )}
); }, ); export const MessageActionBar = observer( ({ message, handleDelete, sourceChannel, onPopoutToggle, }: { message: Message; handleDelete: (bypassConfirm?: boolean) => void; sourceChannel?: Channel | null; onPopoutToggle?: (isOpen: boolean) => void; }) => { const isSaved = SavedMessages.isSaved(message.id); const developerMode = UserSettings.developerMode; const permissions = useMessagePermissions(message, sourceChannel); if (!permissions) { return null; } return ( ); }, );