perf(app): show the message action bar from css not remounting (#1896)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:41:24 +02:00
1 parent c54d7bcc88
commit 3a6bc4bc7b
4 files changed
+217 -211

No files matched your search

@@ -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<MessageProps> = 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<MessageProps> = 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<MessageProps> = observer((props) => {
{getMessageComponent(message, channel, forceUnknownMessageType)}
</MessageViewContextProvider>
);
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<MessageProps> = 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<MessageProps> = 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<MessageProps> = observer((props) => {
data-flx="channel.message.article.alt-click"
>
{messageComponent}
{shouldRenderInlineActionBar &&
{shouldMountActionBar &&
(previewMode ? (
<MessageActionBarCore
message={message}
@@ -926,8 +931,8 @@ export const Message: React.FC<MessageProps> = 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<MessageProps> = observer((props) => {
message={message}
handleDelete={handleDelete}
sourceChannel={channel}
isActive={isActionBarActive}
onPopoutToggle={handleMessagePopoutToggle}
data-flx="channel.message.message-action-bar"
/>
@@ -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 {
@@ -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<HTMLButtonElement, QuickReactionButtonProps>(({emoji, onReact}, ref) => {
export const QuickReactionButton = observer(
React.forwardRef<HTMLButtonElement, QuickReactionButtonProps>(({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"
>
<img
src={emojiSrc}
alt={emoji.name}
aria-hidden={true}
className={styles.emojiImage}
data-flx="channel.message-action-bar.emoji-image"
/>
@@ -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<MessageActionBarCoreProps> = observer(
({message, handleDelete, permissions, isSaved, developerMode, onPopoutToggle}) => {
({message, handleDelete, permissions, developerMode, isActive, onPopoutToggle}) => {
const {i18n} = useLingui();
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false);
const moreOptionsButtonRef = useRef<HTMLButtonElement>(null);
const emojiPickerButtonRef = useRef<HTMLButtonElement>(null);
const actionBarRef = useRef<HTMLDivElement>(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<MessageActionBarCoreProps> = 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<MessageActionBarCoreProps> = 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<MessageActionBarCoreProps> = 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<MessageActionBarCoreProps> = 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<MessageActionBarCoreProps> = 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<MessageActionBarCoreProps> = observe
/>
) : (
<>
{!showFullActions &&
canAddReactions &&
{canAddReactions &&
showQuickReactions &&
quickReactionEmojis.map((emoji) => (
<QuickReactionButton
key={emoji.name}
key={emoji.id ?? emoji.uniqueName}
emoji={emoji}
hidden={showFullActions}
onReact={handlers.handleEmojiSelect}
data-flx="channel.message-action-bar.message-action-bar-core.quick-reaction-button"
/>
@@ -601,36 +511,6 @@ export const MessageActionBarCore: React.FC<MessageActionBarCoreProps> = observe
data-flx="channel.message-action-bar.message-action-bar-core.message-action-bar-button.copy-message-link"
/>
)}
{getCopyableMessageText(message, i18n) && (
<MessageActionBarButton
icon={
<CopyMessageTextIcon
size={20}
data-flx="channel.message-action-bar.message-action-bar-core.copy-message-text-icon"
/>
}
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 && (
<MessageActionBarButton
icon={
<SuppressEmbedsIcon
size={20}
data-flx="channel.message-action-bar.message-action-bar-core.suppress-embeds-icon"
/>
}
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"
/>
)}
<MessageActionBarButton
icon={
<MarkAsUnreadIcon
@@ -642,20 +522,6 @@ export const MessageActionBarCore: React.FC<MessageActionBarCoreProps> = observe
onClick={handlers.handleMarkAsUnread}
data-flx="channel.message-action-bar.message-action-bar-core.message-action-bar-button.mark-as-unread"
/>
{message.isUserMessage() && supportsInteractiveActions && (
<MessageActionBarButton
icon={
<BookmarkIcon
size={20}
filled={isSaved}
data-flx="channel.message-action-bar.message-action-bar-core.bookmark-icon"
/>
}
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 && (
<MessageActionBarButton
icon={
@@ -666,6 +532,19 @@ export const MessageActionBarCore: React.FC<MessageActionBarCoreProps> = observe
data-flx="channel.message-action-bar.message-action-bar-core.message-action-bar-button.pin-message"
/>
)}
{showsEditInTail && supportsInteractiveActions && canSendMessages && (
<MessageActionBarButton
icon={
<ReplyIcon
size={20}
data-flx="channel.message-action-bar.message-action-bar-core.reply-icon--2"
/>
}
label={i18n._(REPLY_DESCRIPTOR)}
onClick={handlers.handleReply}
data-flx="channel.message-action-bar.message-action-bar-core.message-action-bar-button.reply--2"
/>
)}
</>
)}
{canAddReactions && (
@@ -701,7 +580,7 @@ export const MessageActionBarCore: React.FC<MessageActionBarCoreProps> = observe
/>
</Popout>
)}
{message.isUserMessage() && !message.messageSnapshots && canEditMessage && (
{showsEditInTail && (
<MessageActionBarButton
icon={
<EditMessageIcon
@@ -714,7 +593,7 @@ export const MessageActionBarCore: React.FC<MessageActionBarCoreProps> = observe
data-flx="channel.message-action-bar.message-action-bar-core.message-action-bar-button.edit-message"
/>
)}
{message.isUserMessage() && supportsInteractiveActions && canSendMessages && (
{message.isUserMessage() && supportsInteractiveActions && canSendMessages && !showsEditInTail && (
<MessageActionBarButton
icon={
<ReplyIcon size={20} data-flx="channel.message-action-bar.message-action-bar-core.reply-icon" />
@@ -754,7 +633,7 @@ export const MessageActionBarCore: React.FC<MessageActionBarCoreProps> = observe
<MoreIcon size={20} data-flx="channel.message-action-bar.message-action-bar-core.more-icon--2" />
}
label={i18n._(MORE_DESCRIPTOR)}
hidden={showFullActions}
hidden={showFullActions && canDeleteMessage}
onPointerDownCapture={handleMoreOptionsPointerDown}
onClick={openMoreOptionsMenu}
isActive={moreMenuOpen}
@@ -791,14 +670,15 @@ export const MessageActionBar = observer(
message,
handleDelete,
sourceChannel,
isActive,
onPopoutToggle,
}: {
message: Message;
handleDelete: (bypassConfirm?: boolean) => void;
sourceChannel?: Channel | null;
isActive: boolean;
onPopoutToggle?: (isOpen: boolean) => void;
}) => {
const isSaved = SavedMessages.isSaved(message.id);
const developerMode = UserSettings.developerMode;
const permissions = useMessagePermissions(message, sourceChannel);
if (!permissions) {
@@ -809,8 +689,8 @@ export const MessageActionBar = observer(
message={message}
handleDelete={handleDelete}
permissions={permissions}
isSaved={isSaved}
developerMode={developerMode}
isActive={isActive}
onPopoutToggle={onPopoutToggle}
data-flx="channel.message-action-bar.message-action-bar-core"
/>
@@ -0,0 +1,81 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {useCallback, useSyncExternalStore} from 'react';
export 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 clearShiftPressed = () => {
setShiftPressed(false);
};
const handleVisibilityChange = () => {
if (document.visibilityState !== 'visible') {
setShiftPressed(false);
}
};
const handlePointerModifierChange = (event: MouseEvent) => {
setShiftPressed(event.getModifierState?.('Shift') ?? event.shiftKey);
};
const supportsPointerModifierEvents = (): boolean => 'PointerEvent' in window;
const registerListeners = () => {
window.addEventListener('keydown', handleKeyDown, true);
window.addEventListener('keyup', handleKeyUp, true);
window.addEventListener('blur', clearShiftPressed);
document.addEventListener('visibilitychange', handleVisibilityChange);
if (supportsPointerModifierEvents()) {
window.addEventListener('pointermove', handlePointerModifierChange, true);
window.addEventListener('pointerdown', handlePointerModifierChange, true);
window.addEventListener('pointerup', handlePointerModifierChange, true);
return;
}
window.addEventListener('mousemove', handlePointerModifierChange, true);
window.addEventListener('mousedown', handlePointerModifierChange, true);
window.addEventListener('mouseup', handlePointerModifierChange, true);
};
registerListeners();
return {
subscribe: (listener: () => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
getSnapshot: () => isShiftPressed,
getServerSnapshot: () => false,
};
})();
export 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);
};