/* * 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 {Trans, useLingui} from '@lingui/react/macro'; import {BellSlashIcon, EyeIcon, WarningCircleIcon} from '@phosphor-icons/react'; import {clsx} from 'clsx'; import {autorun} from 'mobx'; import {observer} from 'mobx-react-lite'; import React from 'react'; import * as MessageActionCreators from '~/actions/MessageActionCreators'; import {FLUXERBOT_ID, MessageEmbedTypes, MessageFlags, MessageStates, MessageTypes} from '~/Constants'; import {EditingMessageInput} from '~/components/channel/EditingMessageInput'; import {MessageAttachments} from '~/components/channel/MessageAttachments'; import {MessageAuthorInfo} from '~/components/channel/MessageAuthorInfo'; import {MessageAvatar} from '~/components/channel/MessageAvatar'; import {MessageUsername} from '~/components/channel/MessageUsername'; import {ReplyPreview} from '~/components/channel/ReplyPreview'; import {TimestampWithTooltip} from '~/components/channel/TimestampWithTooltip'; import {UserTag} from '~/components/channel/UserTag'; import {Tooltip} from '~/components/uikit/Tooltip'; import FocusManager from '~/lib/FocusManager'; import {SafeMarkdown} from '~/lib/markdown'; import {NodeType} from '~/lib/markdown/parser/types/enums'; import {MarkdownContext, parse} from '~/lib/markdown/renderers'; import AccessibilityStore from '~/stores/AccessibilityStore'; import GuildMemberStore from '~/stores/GuildMemberStore'; import GuildStore from '~/stores/GuildStore'; import MessageEditStore from '~/stores/MessageEditStore'; import MobileLayoutStore from '~/stores/MobileLayoutStore'; import UserSettingsStore from '~/stores/UserSettingsStore'; import UserStore from '~/stores/UserStore'; import markupStyles from '~/styles/Markup.module.css'; import styles from '~/styles/Message.module.css'; import * as DateUtils from '~/utils/DateUtils'; import {SpoilerSyncProvider} from '~/utils/SpoilerUtils'; import {useMessageViewContext} from './MessageViewContext'; const MessageStateToClassName: Record = { [MessageStates.SENT]: styles.messageSent, [MessageStates.SENDING]: styles.messageSending, [MessageStates.FAILED]: styles.messageFailed, }; export const UserMessage = observer(() => { const {t, i18n} = useLingui(); const {message, channel, handleDelete, isHovering, shouldGroup, previewContext, previewOverrides} = useMessageViewContext(); const [animateEmoji, setAnimateEmoji] = React.useState( UserSettingsStore.getAnimateEmoji() && FocusManager.isFocused(), ); const [value, setValue] = React.useState(''); const hasInitializedEditingRef = React.useRef(false); const isEditing = MessageEditStore.isEditing(message.channelId, message.id); const userAuthor = UserStore.getUser(message.author.id); const author = message.webhookId != null ? message.author : (userAuthor ?? message.author); const formattedDate = DateUtils.getRelativeDateString(message.timestamp, i18n); const messageDisplayCompact = UserSettingsStore.getMessageDisplayCompact(); const showUserAvatarsInCompactMode = AccessibilityStore.showUserAvatarsInCompactMode; const textareaRef = React.useRef(null); const {nodes: astNodes} = React.useMemo( () => parse({ content: message.content, context: MarkdownContext.STANDARD_WITH_JUMBO, }), [message.content], ); 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 guild = GuildStore.getGuild(channel.guildId ?? ''); const member = GuildMemberStore.getMember(guild?.id ?? '', author?.id ?? ''); const shouldAppearAuthorless = false; const mobileLayout = MobileLayoutStore; React.useLayoutEffect(() => { if (isEditing) { if (!hasInitializedEditingRef.current) { hasInitializedEditingRef.current = true; const persistedDraft = MessageEditStore.getEditingContent(channel.id, message.id); const initialValue = persistedDraft ?? message.content; setValue(initialValue); textareaRef.current?.focus(); textareaRef.current?.setSelectionRange(initialValue.length, initialValue.length); } else { textareaRef.current?.focus(); } } else { hasInitializedEditingRef.current = false; setValue(''); } }, [channel.id, isEditing, message.content, message.id]); React.useEffect(() => { if (!isEditing) { return; } MessageEditStore.setEditingContent(channel.id, message.id, value); }, [channel.id, isEditing, message.id, value]); React.useEffect(() => { if (animateEmoji) return; const emojiImgs = document.querySelectorAll( `img[data-message-id="${message.id}"][data-animated="true"]`, ) as NodeListOf; for (const img of emojiImgs) { const src = img.src; img.src = isHovering ? src.replace('.webp', '.gif') : src.replace('.gif', '.webp'); } }, [animateEmoji, isHovering, message.id]); React.useEffect(() => { const disposer = autorun(() => { const shouldAnimate = UserSettingsStore.animateEmoji && FocusManager.isFocused(); setAnimateEmoji(shouldAnimate); if (shouldAnimate) { const emojiImgs = document.querySelectorAll( `img[data-message-id="${message.id}"][data-animated="true"]`, ) as NodeListOf; for (const img of emojiImgs) { const src = img.src; img.src = src.replace('.webp', '.gif'); } } }); return () => disposer(); }, [message.id]); React.useEffect(() => { const disposer = autorun(() => { const shouldAnimate = UserSettingsStore.animateEmoji && FocusManager.isFocused(); setAnimateEmoji(shouldAnimate); const emojiImgs = document.querySelectorAll( `img[data-message-id="${message.id}"][data-animated="true"]`, ) as NodeListOf; for (const img of emojiImgs) { const src = img.src; if (shouldAnimate) { img.src = src.replace('.webp', '.gif'); } else { img.src = src.replace('.gif', '.webp'); } } }); return () => disposer(); }, [message.id]); const onSubmit = React.useCallback( (actualContent?: string) => { if (message.messageSnapshots) { return; } const content = (actualContent ?? value).trim(); if (!content) { handleDelete(); return; } MessageActionCreators.stopEdit(channel.id); MessageActionCreators.edit(channel.id, message.id, content); }, [channel.id, handleDelete, message.id, value, message.messageSnapshots], ); const cancelEditing = React.useCallback(() => { MessageActionCreators.stopEdit(message.channelId); }, [message.channelId]); const handleDismissSystemMessage = React.useCallback(() => { MessageActionCreators.deleteOptimistic(message.channelId, message.id); }, [message.channelId, message.id]); if (message.type === MessageTypes.CLIENT_SYSTEM && message.author.id === FLUXERBOT_ID) { return (

{formattedDate}

only you can see this message.{' '}
); } const renderMessageContent = () => { if (isEditing && !previewContext && !mobileLayout.enabled) { return ( ); } if (shouldHideContent) return null; return (
{(message.editedTimestamp || message.isEditing) && (message.isEditing ? ( {t`(edited)`} ) : ( {t`(edited)`} ))}
); }; if (messageDisplayCompact) { return ( {message.messageReference && message.messageReference.type === 0 && ( )}
{!shouldHideContent && ( {isEditing && !previewContext && !mobileLayout.enabled ? ( ) : ( {(message.editedTimestamp || message.isEditing) && (message.isEditing ? ( {t`(edited)`} ) : ( {t`(edited)`} ))} )} )}
{mobileLayout.enabled && message.state === MessageStates.FAILED && (
{t`Failed to send message. Hold for options.`}
)}
); } return ( {message.messageReference && message.messageReference.type === 0 && ( )} {!shouldGroup && ( <>
)} {shouldGroup && ( )} {(message.content || isEditing) && (!shouldHideContent || isEditing) && (
{!shouldGroup && (

{author.bot && } {formattedDate} {(message.flags & MessageFlags.SUPPRESS_NOTIFICATIONS) !== 0 && ( )}

)}
{renderMessageContent()}
)}
{((!message.content && !isEditing) || (shouldHideContent && !isEditing)) && !shouldGroup && (

{author.bot && } {formattedDate} {(message.flags & MessageFlags.SUPPRESS_NOTIFICATIONS) !== 0 && ( )}

)}
{mobileLayout.enabled && message.state === MessageStates.FAILED && (
{t`Failed to send message. Hold for options.`}
)} ); });