diff --git a/fluxer_app/src/features/channel/components/ChannelMessage.tsx b/fluxer_app/src/features/channel/components/ChannelMessage.tsx index 0a1347be6..5216a9ec8 100644 --- a/fluxer_app/src/features/channel/components/ChannelMessage.tsx +++ b/fluxer_app/src/features/channel/components/ChannelMessage.tsx @@ -16,8 +16,7 @@ import MessageEdit from '@app/features/messaging/state/MessageEdit'; import MessageFocus from '@app/features/messaging/state/MessageFocus'; import MessageReply from '@app/features/messaging/state/MessageReply'; import {getMessageComponent} from '@app/features/messaging/utils/MessageComponentUtils'; -import {getParserFlagsForContext} from '@app/features/messaging/utils/markdown/MarkdownParserFlags'; -import {parseAndRenderToPlaintext} from '@app/features/messaging/utils/markdown/Plaintext'; +import {renderAstToPlaintext} from '@app/features/messaging/utils/markdown/Plaintext'; import {NodeType} from '@app/features/messaging/utils/markdown/parser/Enums'; import {SystemMessageUtils} from '@app/features/messaging/utils/SystemMessageUtils'; import {subscribeWindowFocus} from '@app/features/platform/utils/WindowFocusBroadcast'; @@ -89,7 +88,6 @@ const shouldApplyGroupedLayout = (message: MessageModel, _prevMessage?: MessageM const isDisplaySystemMessage = (message: MessageModel): boolean => message.type !== MessageTypes.DEFAULT && message.type !== MessageTypes.REPLY; const isActivationKey = (key: string) => key === 'Enter' || key === ' ' || key === 'Spacebar' || key === 'Space'; -const MESSAGE_ARIA_PARSER_FLAGS = getParserFlagsForContext(MarkdownContext.STANDARD_WITHOUT_JUMBO); const MAX_ARIA_MESSAGE_TEXT_LENGTH = 220; const LONG_PRESS_DELAY = 500; const MOVEMENT_THRESHOLD = 10; @@ -335,12 +333,20 @@ export const Message: React.FC = observer((props) => { behaviorOverrides?.messageGroupSpacing ?? Accessibility.getMessageGroupSpacingValue(messageDisplayCompact); const authorName = previewOverrides?.displayName || NicknameUtils.getNickname(message.author, channel.guildId, channel.id); + const {nodes: astNodes} = useMemo( + () => + parse({ + content: message.content, + context: MarkdownContext.STANDARD_WITH_JUMBO, + }), + [message.content], + ); const messageAriaLabel = useMemo(() => { const timeLabel = DateUtils.getFormattedDateTime(message.timestamp); const systemText = message.isSystemMessage() ? SystemMessageUtils.stringify(message, i18n) : null; let text = systemText || - parseAndRenderToPlaintext(message.content, MESSAGE_ARIA_PARSER_FLAGS, { + renderAstToPlaintext(astNodes, { channelId: channel.id, preserveMarkdown: false, includeEmojiNames: true, @@ -369,6 +375,7 @@ export const Message: React.FC = observer((props) => { } return `${authorName}, ${truncateAriaLabelText(text)}, ${timeLabel}, ${i18n._(MESSAGE_DESCRIPTOR)}`; }, [ + astNodes, authorName, channel.id, i18n.locale, @@ -764,14 +771,6 @@ export const Message: React.FC = observer((props) => { {getMessageComponent(message, channel, forceUnknownMessageType)} ); - const {nodes: astNodes} = useMemo( - () => - parse({ - content: message.content, - context: MarkdownContext.STANDARD_WITH_JUMBO, - }), - [message.content], - ); const shouldHideContent = UserSettings.getRenderEmbeds() && message.embeds.length > 0 && @@ -848,10 +847,14 @@ export const Message: React.FC = observer((props) => { !mobileLayoutEnabled, [previewContext, suppressMessageActions, message.state, isEditing, mobileLayoutEnabled], ); - const shouldRenderInlineActionBar = useMemo( - () => shouldShowActionBar && (previewMode || isHovering || isKeyboardFocused || contextMenuOpen || isPopoutOpen), - [shouldShowActionBar, previewMode, isHovering, isKeyboardFocused, contextMenuOpen, isPopoutOpen], + const isActionBarForcedVisible = useMemo( + () => Boolean(previewMode || isKeyboardFocused || contextMenuOpen || isPopoutOpen), + [previewMode, isKeyboardFocused, contextMenuOpen, isPopoutOpen], ); + const isActionBarActive = isHovering || isActionBarForcedVisible; + const wasActionBarActivatedRef = useRef(false); + wasActionBarActivatedRef.current = wasActionBarActivatedRef.current || isActionBarActive; + const shouldMountActionBar = shouldShowActionBar && wasActionBarActivatedRef.current; const shouldShowBottomSheet = useMemo( () => mobileLayoutEnabled && showActionBar && !previewContext && message.state !== MessageStates.SENDING && !isEditing, @@ -894,6 +897,8 @@ export const Message: React.FC = observer((props) => { data-flx-edited={message.editedTimestamp != null ? 'true' : undefined} data-flx-compact={messageDisplayCompact ? 'true' : undefined} data-flx-grouped={shouldGroup && shouldApplyGroupedLayout(message, prevMessage) ? 'true' : undefined} + data-flx-action-bar={shouldShowActionBar ? 'true' : undefined} + data-flx-action-bar-forced={shouldShowActionBar && isActionBarForcedVisible ? 'true' : undefined} tabIndex={keyboardModeEnabled ? -1 : undefined} className={messageClasses} ref={messageRef} @@ -911,7 +916,7 @@ export const Message: React.FC = observer((props) => { data-flx="channel.message.article.alt-click" > {messageComponent} - {shouldRenderInlineActionBar && + {shouldMountActionBar && (previewMode ? ( = observer((props) => { canForwardMessage: true, shouldRenderSuppressEmbeds: true, }} - isSaved={false} developerMode={false} + isActive={isActionBarActive} onPopoutToggle={handleMessagePopoutToggle} data-flx="channel.message.message-action-bar-core" /> @@ -936,6 +941,7 @@ export const Message: React.FC = observer((props) => { message={message} handleDelete={handleDelete} sourceChannel={channel} + isActive={isActionBarActive} onPopoutToggle={handleMessagePopoutToggle} data-flx="channel.message.message-action-bar" /> diff --git a/fluxer_app/src/features/channel/components/MessageActionBar.module.css b/fluxer_app/src/features/channel/components/MessageActionBar.module.css index 1612d00a4..872071b26 100644 --- a/fluxer_app/src/features/channel/components/MessageActionBar.module.css +++ b/fluxer_app/src/features/channel/components/MessageActionBar.module.css @@ -6,10 +6,44 @@ right: 0; z-index: var(--z-index-elevated-1); padding: 0 0.875rem 0.375rem 2rem; + opacity: 0; + visibility: hidden; + pointer-events: none; +} + +:global([data-flx-action-bar]:hover) .actionBarContainer, +:global([data-flx-action-bar]) .actionBarContainer:has(:focus-visible) { + opacity: 1; + visibility: visible; + pointer-events: auto; +} + +@media (pointer: coarse) { + :global([data-flx-action-bar]:not([data-flx-action-bar-forced]):hover) .actionBarContainer { + opacity: 0; + visibility: hidden; + pointer-events: none; + } +} + +:global([data-flx-action-bar][data-flx-action-bar-forced]) .actionBarContainer, +.actionBarContainer.actionBarPinned { + opacity: 1; + visibility: visible; + pointer-events: auto; +} + +:global(html:not(.window-focused):not(.unfocused-fully-interactive)) .actionBarContainer, +:global(.window-focus-activation-guard) .actionBarContainer { + opacity: 0 !important; + visibility: hidden !important; + pointer-events: none !important; } .actionBar { composes: toolbar from '@app/features/ui/action_toolbar/ActionToolbar.module.css'; + --action-toolbar-gap: 0; + --action-toolbar-padding: 0.125rem; --message-action-bar-surface: var(--settings-surface-background, var(--form-surface-background)); --message-action-bar-border: var(--settings-border-color, var(--background-modifier-accent)); --message-action-bar-hover-background: var(--settings-hover-background, var(--surface-interactive-hover-bg)); @@ -41,6 +75,11 @@ .button { composes: item from '@app/features/ui/action_toolbar/ActionToolbar.module.css'; + --action-toolbar-item-border-width: 0; +} + +.quickReactionGroupEnd { + margin-right: 0.5625rem; } .button[hidden] { @@ -74,8 +113,8 @@ } .actionBarIcon { - width: 1.125rem; - height: 1.125rem; + width: 1.25rem; + height: 1.25rem; display: flex; align-items: center; justify-content: center; @@ -83,8 +122,8 @@ } .actionBarIcon > svg { - width: 1.125rem; - height: 1.125rem; + width: 1.25rem; + height: 1.25rem; display: block; flex: 0 0 auto; object-fit: contain; @@ -103,8 +142,8 @@ } .emojiImage { - height: 1.125rem; - width: 1.125rem; + height: 1.25rem; + width: 1.25rem; } img.emojiImage { diff --git a/fluxer_app/src/features/channel/components/MessageActionBar.tsx b/fluxer_app/src/features/channel/components/MessageActionBar.tsx index 36d2ec10e..a870f88aa 100644 --- a/fluxer_app/src/features/channel/components/MessageActionBar.tsx +++ b/fluxer_app/src/features/channel/components/MessageActionBar.tsx @@ -1,14 +1,13 @@ // 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 {useShouldAnimate} from '@app/features/app/hooks/useShouldAnimate'; import styles from '@app/features/channel/components/MessageActionBar.module.css'; +import {useShiftKey} from '@app/features/channel/components/MessageActionBarShiftKey'; import { createMessageActionHandlers, - getCopyableMessageText, isClientSystemMessage, - isEmbedsSuppressed, useMessagePermissions, } from '@app/features/channel/components/MessageActionUtils'; import {REACT_WITH_EMOJI_DESCRIPTOR} from '@app/features/channel/components/QuickReactionsRow'; @@ -18,34 +17,28 @@ import {MessageDebugModal} from '@app/features/devtools/components/debug/Message 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 {buildCustomEmojiURL} from '@app/features/expressions/utils/CustomEmojiImageUrl'; 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 {ComponentBus} 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, @@ -55,7 +48,6 @@ import { 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'; @@ -68,13 +60,12 @@ 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 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'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; const CLICK_TO_REACT_DESCRIPTOR = msg({ message: 'Click to react', @@ -92,100 +83,10 @@ 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); -}; interface MessageActionBarButtonProps { label: string; @@ -250,13 +151,14 @@ MessageActionBarButton.displayName = 'MessageActionBarButton'; interface QuickReactionButtonProps { emoji: FlatEmoji; onReact: (emoji: FlatEmoji) => void; + hidden?: boolean; } -const QuickReactionButton = observer( - React.forwardRef(({emoji, onReact}, ref) => { +export const QuickReactionButton = observer( + React.forwardRef(({emoji, onReact, hidden}, ref) => { const {i18n} = useLingui(); + const isAnimatedEmoji = emoji.animated === true; const [isHovered, setIsHovered] = useState(false); - const animatedMediaPlaybackAllowed = useAnimatedMediaPlaybackAllowed(); const {url: displayUrl} = getEmojiDisplayData(emoji); const handleClick = useCallback( (event: React.MouseEvent | React.KeyboardEvent) => { @@ -267,19 +169,13 @@ const QuickReactionButton = observer( }, [emoji, onReact], ); - const handleMouseEnter = useCallback(() => setIsHovered(true), []); - const handleMouseLeave = useCallback(() => setIsHovered(false), []); + const beginEmojiHover = useMemo(() => (isAnimatedEmoji ? () => setIsHovered(true) : undefined), [isAnimatedEmoji]); + const endEmojiHover = useMemo(() => (isAnimatedEmoji ? () => setIsHovered(false) : undefined), [isAnimatedEmoji]); const emojiNameWithColons = useMemo(() => getEmojiNameWithColons(toReactionEmoji(emoji)), [emoji]); - const shouldShowAnimated = useMemo( - () => emoji.animated && isHovered && animatedMediaPlaybackAllowed, - [animatedMediaPlaybackAllowed, emoji.animated, isHovered], - ); + const shouldShowAnimated = useShouldAnimate({kind: 'emoji', isAnimated: isAnimatedEmoji, isHovering: isHovered}); const emojiSrc = useMemo( - () => - emoji.animated && emoji.id - ? AvatarUtils.getEmojiURL({id: emoji.id, animated: shouldShowAnimated}) - : (displayUrl ?? ''), - [emoji.animated, emoji.id, displayUrl, shouldShowAnimated], + () => (emoji.id ? buildCustomEmojiURL({id: emoji.id, animated: shouldShowAnimated}) : (displayUrl ?? '')), + [emoji.id, displayUrl, shouldShowAnimated], ); const tooltipContent = useCallback( () => ( @@ -303,17 +199,19 @@ const QuickReactionButton = observer( type="button" ref={ref} aria-label={ariaLabel} + hidden={hidden} onClick={handleClick} - onMouseEnter={handleMouseEnter} - onMouseLeave={handleMouseLeave} - onFocus={handleMouseEnter} - onBlur={handleMouseLeave} + onMouseEnter={beginEmojiHover} + onMouseLeave={endEmojiHover} + onFocus={beginEmojiHover} + onBlur={endEmojiHover} className={styles.button} data-flx="channel.message-action-bar.button.click" > {emoji.name} @@ -339,19 +237,20 @@ interface MessageActionBarCoreProps { canForwardMessage: boolean; shouldRenderSuppressEmbeds: boolean; }; - isSaved: boolean; developerMode: boolean; + isActive: boolean; onPopoutToggle?: (isOpen: boolean) => void; } export const MessageActionBarCore: React.FC = observer( - ({message, handleDelete, permissions, isSaved, developerMode, onPopoutToggle}) => { + ({message, handleDelete, permissions, developerMode, isActive, 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 emojiPickerOpenRef = useRef(false); const contextMenuOpen = useContextMenuHoverState(actionBarRef); const moreMenuOpen = useContextMenuHoverState(moreOptionsButtonRef); const showMessageActionBar = Accessibility.showMessageActionBar; @@ -361,16 +260,10 @@ export const MessageActionBarCore: React.FC = observe 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 showFullActions = shouldListenForShift && shiftPressed; + const {canSendMessages, canAddReactions, canEditMessage, canDeleteMessage, canPinMessage, canForwardMessage} = + permissions; + const showsEditInTail = message.isUserMessage() && !message.messageSnapshots && canEditMessage; const supportsInteractiveActions = useMemo(() => !isClientSystemMessage(message), [message]); const handlers = useMemo( () => createMessageActionHandlers(message, {i18n, channel: permissions.channel}), @@ -388,8 +281,23 @@ export const MessageActionBarCore: React.FC = observe } requestAnimationFrame(() => emojiPickerButtonRef.current?.blur()); }, [keyboardModeEnabled]); + useEffect(() => { + if (isActive || keyboardModeEnabled) { + return; + } + const container = actionBarRef.current; + if (container == null) { + return; + } + const focused = container.ownerDocument.activeElement; + if (!(focused instanceof HTMLElement) || !container.contains(focused) || focused.matches(':focus-visible')) { + return; + } + focused.blur(); + }, [isActive, keyboardModeEnabled]); const handleEmojiPickerToggle = useCallback( (open: boolean) => { + emojiPickerOpenRef.current = open; setEmojiPickerOpen(open); onPopoutToggle?.(open); if (!open) { @@ -405,7 +313,7 @@ export const MessageActionBarCore: React.FC = observe }, []); const handleMoreMenuClose = useCallback( (closeMenu: () => void) => { - if (!keepOpenOnNextMoreMenuCloseRef.current) { + if (!keepOpenOnNextMoreMenuCloseRef.current || !emojiPickerOpenRef.current) { onPopoutToggle?.(false); } closeMenu(); @@ -413,8 +321,9 @@ export const MessageActionBarCore: React.FC = observe [onPopoutToggle], ); const handleMoreMenuClosed = useCallback(() => { - if (keepOpenOnNextMoreMenuCloseRef.current) { - keepOpenOnNextMoreMenuCloseRef.current = false; + const handedOffToReactionPicker = keepOpenOnNextMoreMenuCloseRef.current; + keepOpenOnNextMoreMenuCloseRef.current = false; + if (handedOffToReactionPicker && emojiPickerOpenRef.current) { return; } onPopoutToggle?.(false); @@ -534,6 +443,7 @@ export const MessageActionBarCore: React.FC = observe styles.actionBarContainer, messageStyles.buttons, (emojiPickerOpen || contextMenuOpen) && messageStyles.emojiPickerOpen, + (emojiPickerOpen || contextMenuOpen || moreMenuOpen) && styles.actionBarPinned, )} data-flx="channel.message-action-bar.message-action-bar-core.action-bar-container" > @@ -551,13 +461,13 @@ export const MessageActionBarCore: React.FC = observe /> ) : ( <> - {!showFullActions && - canAddReactions && + {canAddReactions && showQuickReactions && quickReactionEmojis.map((emoji) => (