/* * 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 * as MessageActionCreators from '@app/actions/MessageActionCreators'; import {EditingMessageInput} from '@app/components/channel/EditingMessageInput'; import {MessageAttachments} from '@app/components/channel/MessageAttachments'; import {MessageAuthorInfo} from '@app/components/channel/MessageAuthorInfo'; import {MessageAvatar} from '@app/components/channel/MessageAvatar'; import {MessageUsername} from '@app/components/channel/MessageUsername'; import {useMessageViewContext} from '@app/components/channel/MessageViewContext'; import {ReplyPreview} from '@app/components/channel/ReplyPreview'; import {TimestampWithTooltip} from '@app/components/channel/TimestampWithTooltip'; import {UserTag} from '@app/components/channel/UserTag'; import {Tooltip} from '@app/components/uikit/tooltip/Tooltip'; import FocusManager from '@app/lib/FocusManager'; import {SafeMarkdown} from '@app/lib/markdown'; import {parse} from '@app/lib/markdown/renderers'; import {MarkdownContext} from '@app/lib/markdown/renderers/RendererTypes'; import AccessibilityStore from '@app/stores/AccessibilityStore'; import EmojiStore from '@app/stores/EmojiStore'; import GuildMemberStore from '@app/stores/GuildMemberStore'; import GuildStore from '@app/stores/GuildStore'; import MessageEditStore from '@app/stores/MessageEditStore'; import MobileLayoutStore from '@app/stores/MobileLayoutStore'; import UserSettingsStore from '@app/stores/UserSettingsStore'; import UserStore from '@app/stores/UserStore'; import markupStyles from '@app/styles/Markup.module.css'; import styles from '@app/styles/Message.module.css'; import {createSystemMessage} from '@app/utils/CommandUtils'; import * as DateUtils from '@app/utils/DateUtils'; import {checkEmojiAvailability} from '@app/utils/ExpressionPermissionUtils'; import {SpoilerSyncProvider} from '@app/utils/SpoilerUtils'; import {FLUXERBOT_ID} from '@fluxer/constants/src/AppConstants'; import {MessageEmbedTypes, MessageFlags, MessageStates, MessageTypes} from '@fluxer/constants/src/ChannelConstants'; import {NodeType} from '@fluxer/markdown_parser/src/types/Enums'; 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 {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; const MessageStateToClassName: Record = { [MessageStates.SENT]: styles.messageSent, [MessageStates.SENDING]: styles.messageSending, [MessageStates.FAILED]: styles.messageFailed, }; const CUSTOM_EMOJI_MARKDOWN_PATTERN = //g; export const UserMessage = observer(() => { const {t, i18n} = useLingui(); const { message, channel, handleDelete, isHovering, shouldGroup, messageDisplayCompact, previewContext, previewOverrides, } = useMessageViewContext(); const [animateEmoji, setAnimateEmoji] = useState(UserSettingsStore.getAnimateEmoji() && FocusManager.isFocused()); const [value, setValue] = useState(''); const hasInitializedEditingRef = useRef(false); const textareaRef = useRef(null); 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 showUserAvatarsInCompactMode = AccessibilityStore.showUserAvatarsInCompactMode; const {nodes: astNodes} = 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; const checkCustomEmojiAvailability = useCallback( (content: string): boolean => { CUSTOM_EMOJI_MARKDOWN_PATTERN.lastIndex = 0; let match: RegExpExecArray | null = null; while ((match = CUSTOM_EMOJI_MARKDOWN_PATTERN.exec(content))) { const emojiId = match[1]; const emoji = EmojiStore.getEmojiById(emojiId); if (!emoji) { continue; } const availability = checkEmojiAvailability(i18n, emoji, channel); if (availability.canUse) { continue; } if (availability.lockReason) { const errorMessage = createSystemMessage(channel.id, availability.lockReason); MessageActionCreators.createOptimistic(channel.id, errorMessage.toJSON()); } return true; } return false; }, [channel, i18n], ); const onSubmit = useCallback( (actualContent?: string) => { if (message.messageSnapshots) { return; } const content = (actualContent ?? value).trim(); if (!content) { handleDelete(); return; } if (checkCustomEmojiAvailability(content)) { return; } MessageActionCreators.edit(channel.id, message.id, content).then((result) => { if (result) { MessageEditStore.clearDraftContent(message.id); MessageActionCreators.stopEdit(channel.id); } }); }, [channel.id, handleDelete, message.id, value, message.messageSnapshots, checkCustomEmojiAvailability], ); const cancelEditing = useCallback(() => { MessageActionCreators.stopEdit(message.channelId); }, [message.channelId]); const handleDismissSystemMessage = useCallback(() => { MessageActionCreators.deleteOptimistic(message.channelId, message.id); }, [message.channelId, message.id]); const shouldShowEditingInput = isEditing && !previewContext && !mobileLayout.enabled; const renderMessageContent = useCallback(() => { if (shouldShowEditingInput) { return ( ); } if (shouldHideContent) return null; return ( {(message.editedTimestamp || message.isEditing) && (message.isEditing ? ( {t`(edited)`} ) : ( {t`(edited)`} ))} ); }, [ shouldShowEditingInput, shouldHideContent, message.content, message.id, message.channelId, message.editedTimestamp, message.isEditing, channel, cancelEditing, onSubmit, value, t, ]); useLayoutEffect(() => { if (isEditing) { if (!hasInitializedEditingRef.current) { hasInitializedEditingRef.current = true; const persistedDraft = MessageEditStore.getEditingContent(channel.id, message.id) ?? MessageEditStore.getDraftContent(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]); useEffect(() => { if (!isEditing) { return; } MessageEditStore.setEditingContent(channel.id, message.id, value); }, [channel.id, isEditing, message.id, value]); 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 url = new URL(img.src, window.location.origin); url.searchParams.set('animated', isHovering.toString()); img.src = url.toString(); } }, [animateEmoji, isHovering, message.id]); 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 url = new URL(img.src, window.location.origin); url.searchParams.set('animated', shouldAnimate.toString()); img.src = url.toString(); } }); return () => disposer(); }, [message.id]); if (message.type === MessageTypes.CLIENT_SYSTEM && message.author.id === FLUXERBOT_ID) { return ( {' \u2014 '} {formattedDate} only you can see this message.{' '} dismiss ); } 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 && ( )} {(message.content || isEditing) && (!shouldHideContent || isEditing) && ( {!shouldGroup && ( {author.bot && } {' \u2014 '} {formattedDate} {(message.flags & MessageFlags.SUPPRESS_NOTIFICATIONS) !== 0 && ( )} )} {renderMessageContent()} )} {shouldGroup && ( )} {!shouldGroup && ( <> > )} {((!message.content && !isEditing) || (shouldHideContent && !isEditing)) && !shouldGroup && ( {author.bot && } {' \u2014 '} {formattedDate} {(message.flags & MessageFlags.SUPPRESS_NOTIFICATIONS) !== 0 && ( )} )} {mobileLayout.enabled && message.state === MessageStates.FAILED && ( {t`Failed to send message. Hold for options.`} )} ); });