/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import {useLingui} from '@lingui/react/macro'; import {clsx} from 'clsx'; import {autorun} from 'mobx'; import {observer} from 'mobx-react-lite'; import type React from 'react'; import {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators'; import * as MessageActionCreators from '~/actions/MessageActionCreators'; import * as ReadStateActionCreators from '~/actions/ReadStateActionCreators'; import {FLUXERBOT_ID, MessageEmbedTypes, MessagePreviewContext, MessageStates, MessageTypes} from '~/Constants'; import {MessageActionBar, MessageActionBarCore} from '~/components/channel/MessageActionBar'; import {MessageActionBottomSheet} from '~/components/channel/MessageActionBottomSheet'; import {MessageContextMenu} from '~/components/uikit/ContextMenu/MessageContextMenu'; import FocusRing from '~/components/uikit/FocusRing/FocusRing'; import {NodeType} from '~/lib/markdown/parser/types/enums'; import {MarkdownContext, parse} from '~/lib/markdown/renderers'; import type {ChannelRecord} from '~/records/ChannelRecord'; import type {MessageRecord} from '~/records/MessageRecord'; import AccessibilityStore from '~/stores/AccessibilityStore'; import ContextMenuStore, {isContextMenuNodeTarget} from '~/stores/ContextMenuStore'; import DeveloperOptionsStore from '~/stores/DeveloperOptionsStore'; import KeyboardModeStore from '~/stores/KeyboardModeStore'; import MessageEditStore from '~/stores/MessageEditStore'; import MessageReplyStore from '~/stores/MessageReplyStore'; import MobileLayoutStore from '~/stores/MobileLayoutStore'; import UserSettingsStore from '~/stores/UserSettingsStore'; import styles from '~/styles/Message.module.css'; import {getMessageComponent} from '~/utils/MessageComponentUtils'; import {MessageViewContextProvider} from './MessageViewContext'; const shouldApplyGroupedLayout = (message: MessageRecord, _prevMessage?: MessageRecord) => { if (message.type !== MessageTypes.DEFAULT && message.type !== MessageTypes.REPLY) { return false; } return true; }; const isActivationKey = (key: string) => key === 'Enter' || key === ' ' || key === 'Spacebar' || key === 'Space'; const handleAltClickEvent = (event: React.MouseEvent, message: MessageRecord) => { if (!event.altKey) return; ReadStateActionCreators.markAsUnread(message.channelId, message.id); }; const handleAltKeyboardEvent = (event: React.KeyboardEvent, message: MessageRecord) => { if (!event.altKey || !isActivationKey(event.key)) { return; } event.preventDefault(); ReadStateActionCreators.markAsUnread(message.channelId, message.id); }; const handleDeleteMessage = (i18n: any, bypassConfirm: boolean, message: MessageRecord) => { if (bypassConfirm) { MessageActionCreators.remove(message.channelId, message.id); return; } MessageActionCreators.showDeleteConfirmation(i18n, {message}); }; export type MessageBehaviorOverrides = Partial<{ mobileLayoutEnabled: boolean; messageGroupSpacing: number; messageDisplayCompact: boolean; prefersReducedMotion: boolean; isEditing: boolean; isReplying: boolean; isHighlight: boolean; forceUnknownMessageType: boolean; contextMenuOpen: boolean; disableContextMenu: boolean; disableContextMenuTracking: boolean; }>; interface MessageProps { channel: ChannelRecord; message: MessageRecord; prevMessage?: MessageRecord; onEdit?: (targetNode: HTMLElement) => void; previewContext?: keyof typeof MessagePreviewContext; shouldGroup?: boolean; previewOverrides?: { usernameColor?: string; displayName?: string; }; removeTopSpacing?: boolean; isJumpTarget?: boolean; previewMode?: boolean; behaviorOverrides?: MessageBehaviorOverrides; compact?: boolean; idPrefix?: string; } export const Message: React.FC = observer((props) => { const { channel, message, prevMessage, onEdit, previewContext, shouldGroup = false, previewOverrides, removeTopSpacing = false, isJumpTarget = false, previewMode, behaviorOverrides, compact, idPrefix = 'message', } = props; const {i18n} = useLingui(); const [showActionBar, setShowActionBar] = useState(false); const [isLongPressing, setIsLongPressing] = useState(false); const [contextMenuOpen, setContextMenuOpen] = useState(behaviorOverrides?.contextMenuOpen ?? false); const [isHoveringDesktop, setIsHoveringDesktop] = useState(false); const [isFocusedWithin, setIsFocusedWithin] = useState(false); const [isPopoutOpen, setIsPopoutOpen] = useState(false); const messageRef = useRef(null); const longPressTimerRef = useRef(null); const wasEditingInPreviousUpdateRef = useRef(false); const mobileLayoutEnabled = behaviorOverrides?.mobileLayoutEnabled ?? MobileLayoutStore.isEnabled(); const messageDisplayCompact = compact ?? behaviorOverrides?.messageDisplayCompact ?? UserSettingsStore.getMessageDisplayCompact(); const prefersReducedMotion = behaviorOverrides?.prefersReducedMotion ?? AccessibilityStore.useReducedMotion; const isEditing = behaviorOverrides?.isEditing ?? MessageEditStore.isEditing(message.channelId, message.id); const isReplying = behaviorOverrides?.isReplying ?? MessageReplyStore.isReplying(message.channelId, message.id); const isHighlight = behaviorOverrides?.isHighlight ?? MessageReplyStore.isHighlight(message.id); const forceUnknownMessageType = behaviorOverrides?.forceUnknownMessageType ?? DeveloperOptionsStore.forceUnknownMessageType; const messageGroupSpacing = behaviorOverrides?.messageGroupSpacing ?? AccessibilityStore.messageGroupSpacingValue; const handleContextMenuUpdate = useCallback(() => { const contextMenu = ContextMenuStore.contextMenu; const contextMenuTarget = contextMenu?.target?.target ?? null; const messageElement = messageRef.current; const isOpen = Boolean(contextMenu) && isContextMenuNodeTarget(contextMenuTarget) && Boolean(messageElement?.contains(contextMenuTarget)); setContextMenuOpen(!!isOpen); }, []); const handleAltClick = useCallback( (event: React.MouseEvent) => { handleAltClickEvent(event, message); }, [message], ); const handleAltKeyDown = useCallback( (event: React.KeyboardEvent) => { handleAltKeyboardEvent(event, message); }, [message], ); const handleDelete = useCallback( (bypassConfirm = false) => { handleDeleteMessage(i18n, bypassConfirm, message); }, [i18n, message], ); const handleContextMenu = useCallback( (event: React.MouseEvent) => { if (behaviorOverrides?.disableContextMenu) { event.preventDefault(); return; } if ( (previewContext && previewContext !== MessagePreviewContext.LIST_POPOUT) || message.state === MessageStates.SENDING || isEditing ) { return; } event.preventDefault(); if (mobileLayoutEnabled) { return; } let linkUrl: string | undefined; const target = event.target as HTMLElement; const anchor = target.closest('a'); if (anchor?.href) { linkUrl = anchor.href; } ContextMenuActionCreators.openFromEvent(event, (props) => ( )); }, [previewContext, message, isEditing, mobileLayoutEnabled, handleDelete, behaviorOverrides?.disableContextMenu], ); const LONG_PRESS_DELAY = 500; const MOVEMENT_THRESHOLD = 10; const SWIPE_VELOCITY_THRESHOLD = 0.4; const HIGHLIGHT_DELAY = 100; const touchStartPos = useRef<{x: number; y: number} | null>(null); const velocitySamples = useRef>([]); const highlightTimerRef = useRef(null); const clearLongPressState = useCallback(() => { if (longPressTimerRef.current) { clearTimeout(longPressTimerRef.current); longPressTimerRef.current = null; } if (highlightTimerRef.current) { clearTimeout(highlightTimerRef.current); highlightTimerRef.current = null; } touchStartPos.current = null; velocitySamples.current = []; setIsLongPressing(false); }, []); const calculateVelocity = useCallback((): number => { const samples = velocitySamples.current; if (samples.length < 2) return 0; const now = performance.now(); const recentSamples = samples.filter((s) => now - s.timestamp < 100); if (recentSamples.length < 2) return 0; const first = recentSamples[0]; const last = recentSamples[recentSamples.length - 1]; const dt = last.timestamp - first.timestamp; if (dt === 0) return 0; const dx = last.x - first.x; const dy = last.y - first.y; return Math.sqrt(dx * dx + dy * dy) / dt; }, []); const handleLongPressStart = useCallback( (event: React.TouchEvent) => { if (!mobileLayoutEnabled || previewContext) { return; } const touch = event.touches[0]; if (!touch) return; touchStartPos.current = {x: touch.clientX, y: touch.clientY}; velocitySamples.current = [{x: touch.clientX, y: touch.clientY, timestamp: performance.now()}]; highlightTimerRef.current = setTimeout(() => { if (touchStartPos.current) { setIsLongPressing(true); } highlightTimerRef.current = null; }, HIGHLIGHT_DELAY); longPressTimerRef.current = setTimeout(() => { if (touchStartPos.current) { setShowActionBar(true); setIsLongPressing(false); } clearLongPressState(); }, LONG_PRESS_DELAY); }, [mobileLayoutEnabled, previewContext, clearLongPressState], ); const handleLongPressEnd = useCallback(() => { clearLongPressState(); }, [clearLongPressState]); const handleLongPressMove = useCallback( (event: React.TouchEvent) => { if (!touchStartPos.current) return; const touch = event.touches[0]; if (!touch) return; velocitySamples.current.push({x: touch.clientX, y: touch.clientY, timestamp: performance.now()}); if (velocitySamples.current.length > 10) { velocitySamples.current = velocitySamples.current.slice(-10); } const deltaX = Math.abs(touch.clientX - touchStartPos.current.x); const deltaY = Math.abs(touch.clientY - touchStartPos.current.y); if (deltaX > MOVEMENT_THRESHOLD || deltaY > MOVEMENT_THRESHOLD) { clearLongPressState(); return; } const velocity = calculateVelocity(); if (velocity > SWIPE_VELOCITY_THRESHOLD) { clearLongPressState(); } }, [clearLongPressState, calculateVelocity], ); useEffect(() => { if (behaviorOverrides?.disableContextMenuTracking) { return; } const disposer = autorun(() => { handleContextMenuUpdate(); }); return () => { disposer(); }; }, [handleContextMenuUpdate, behaviorOverrides?.disableContextMenuTracking]); useEffect(() => { if (!behaviorOverrides?.disableContextMenuTracking) { return; } if (behaviorOverrides.contextMenuOpen !== undefined) { setContextMenuOpen(behaviorOverrides.contextMenuOpen); } }, [behaviorOverrides?.contextMenuOpen, behaviorOverrides?.disableContextMenuTracking]); const keyboardModeEnabled = KeyboardModeStore.keyboardModeEnabled; const handleFocusWithin = useCallback(() => { if (!keyboardModeEnabled) { return; } setIsFocusedWithin(true); }, [keyboardModeEnabled]); const handleBlurWithin = useCallback(() => { setIsFocusedWithin(false); }, []); useEffect(() => { if (mobileLayoutEnabled || !messageRef.current) return; const element = messageRef.current; const syncHoverState = () => { setIsHoveringDesktop(element.matches(':hover')); }; const handleMouseEnter = () => { setIsHoveringDesktop(true); }; const handleMouseLeave = () => { setIsHoveringDesktop(false); }; element.addEventListener('mouseenter', handleMouseEnter); element.addEventListener('mouseleave', handleMouseLeave); window.addEventListener('focus', syncHoverState); const rafId = requestAnimationFrame(syncHoverState); return () => { cancelAnimationFrame(rafId); element.removeEventListener('mouseenter', handleMouseEnter); element.removeEventListener('mouseleave', handleMouseLeave); window.removeEventListener('focus', syncHoverState); }; }, [mobileLayoutEnabled, keyboardModeEnabled]); useLayoutEffect(() => { const wasEditing = wasEditingInPreviousUpdateRef.current; const justStartedEditing = !wasEditing && isEditing; if (justStartedEditing && onEdit && messageRef.current) { onEdit(messageRef.current); } wasEditingInPreviousUpdateRef.current = isEditing; }, [isEditing, onEdit]); useEffect(() => { if (!mobileLayoutEnabled) return; const handleScroll = () => { if (touchStartPos.current) { clearLongPressState(); } }; window.addEventListener('scroll', handleScroll, {capture: true, passive: true}); return () => { window.removeEventListener('scroll', handleScroll, {capture: true}); if (longPressTimerRef.current) { clearTimeout(longPressTimerRef.current); } if (highlightTimerRef.current) { clearTimeout(highlightTimerRef.current); } }; }, [mobileLayoutEnabled, clearLongPressState]); const isHovering = mobileLayoutEnabled ? false : isHoveringDesktop; useEffect(() => { if (!keyboardModeEnabled) { setIsFocusedWithin(false); return; } const activeElement = messageRef.current?.ownerDocument?.activeElement ?? document.activeElement; if (messageRef.current && activeElement && messageRef.current.contains(activeElement)) { setIsFocusedWithin(true); } }, [keyboardModeEnabled]); const actionBarHoverState = previewMode ? true : isHovering || (keyboardModeEnabled && isFocusedWithin) || isPopoutOpen; const messageContextValue = useMemo( () => ({ channel, message, handleDelete, shouldGroup, isHovering, previewContext, previewOverrides, onPopoutToggle: setIsPopoutOpen, }), [channel, message, handleDelete, shouldGroup, isHovering, previewContext, previewOverrides, setIsPopoutOpen], ); const messageComponent = ( {getMessageComponent(channel, message, forceUnknownMessageType)} ); const {nodes: astNodes} = parse({ content: message.content, context: MarkdownContext.STANDARD_WITH_JUMBO, }); const shouldHideContent = UserSettingsStore.getRenderEmbeds() && message.embeds.length > 0 && message.embeds.every((embed) => embed.type === MessageEmbedTypes.IMAGE || embed.type === MessageEmbedTypes.GIFV) && astNodes.length === 1 && astNodes[0].type === NodeType.Link && !message.suppressEmbeds; const shouldDisableHoverBackground = prefersReducedMotion && !isEditing; const isKeyboardFocused = keyboardModeEnabled && isFocusedWithin; const shouldApplySpacing = !shouldGroup && !removeTopSpacing && previewContext !== MessagePreviewContext.LIST_POPOUT; const messageClasses = clsx( messageDisplayCompact ? styles.messageCompact : styles.message, shouldDisableHoverBackground && styles.messageNoHover, isEditing && styles.messageEditing, !messageDisplayCompact && shouldGroup && shouldApplyGroupedLayout(message, prevMessage) && styles.messageGrouped, !previewContext && message.isMentioned() && styles.messageMentioned, !previewContext && (isReplying || isHighlight || isJumpTarget) && (isReplying ? styles.messageReplying : styles.messageHighlight), message.type === MessageTypes.CLIENT_SYSTEM && message.author.id === FLUXERBOT_ID && styles.messageClientSystem, isLongPressing && styles.messageLongPress, !previewContext && (contextMenuOpen || isPopoutOpen) && styles.contextMenuActive, previewContext && styles.messagePreview, MobileLayoutStore.isEnabled() && styles.mobileLayout, !messageDisplayCompact && (!message.content || shouldHideContent) && !isEditing && message.isUserMessage() && styles.messageNoText, isKeyboardFocused && styles.keyboardFocused, isKeyboardFocused && 'keyboard-focus-active', shouldApplySpacing && previewContext && styles.messagePreviewSpacing, ); const shouldShowActionBar = !previewContext && message.state !== MessageStates.SENDING && !isEditing && !MobileLayoutStore.isEnabled(); const shouldShowBottomSheet = MobileLayoutStore.isEnabled() && showActionBar && !previewContext && message.state !== MessageStates.SENDING && !isEditing; return ( <>
{messageComponent} {shouldShowActionBar && (previewMode ? ( ) : ( ))}
{shouldShowBottomSheet && ( setShowActionBar(false)} message={message} handleDelete={handleDelete} /> )} ); });