/* * 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 {FloatingPortal} from '@floating-ui/react'; import {Trans} from '@lingui/react/macro'; import {PencilIcon, SmileyIcon} from '@phosphor-icons/react'; import {clsx} from 'clsx'; import {AnimatePresence, motion} from 'framer-motion'; import {observer} from 'mobx-react-lite'; import React from 'react'; import {EmojiAttributionSubtext, getEmojiAttribution} from '~/components/emojis/EmojiAttributionSubtext'; import {EmojiTooltipContent} from '~/components/uikit/EmojiTooltipContent/EmojiTooltipContent'; import FocusRing from '~/components/uikit/FocusRing/FocusRing'; import {useTooltipPortalRoot} from '~/components/uikit/Tooltip'; import {Tooltip} from '~/components/uikit/Tooltip/Tooltip'; import {useMergeRefs} from '~/hooks/useMergeRefs'; import {useReactionTooltip} from '~/hooks/useReactionTooltip'; import {type CustomStatus, getCustomStatusText, normalizeCustomStatus} from '~/lib/customStatus'; import UnicodeEmojis from '~/lib/UnicodeEmojis'; import EmojiStore from '~/stores/EmojiStore'; import GuildStore from '~/stores/GuildStore'; import MobileLayoutStore from '~/stores/MobileLayoutStore'; import PresenceStore from '~/stores/PresenceStore'; import * as AvatarUtils from '~/utils/AvatarUtils'; import {getEmojiURL, shouldUseNativeEmoji} from '~/utils/EmojiUtils'; import styles from './CustomStatusDisplay.module.css'; const useTextOverflow = ( containerRef: React.RefObject, content: string | null, checkVertical = false, ) => { const [isOverflowing, setIsOverflowing] = React.useState(false); React.useLayoutEffect(() => { const el = containerRef.current; if (!el || !content) { setIsOverflowing(false); return; } const checkOverflow = () => { if (el.scrollWidth > el.clientWidth || (checkVertical && el.scrollHeight > el.clientHeight)) { setIsOverflowing(true); return; } const range = document.createRange(); range.selectNodeContents(el); const contentWidth = range.getBoundingClientRect().width; const containerWidth = el.getBoundingClientRect().width; setIsOverflowing(Math.ceil(contentWidth) > Math.ceil(containerWidth)); }; const frameId = requestAnimationFrame(checkOverflow); const resizeObserver = new ResizeObserver(checkOverflow); resizeObserver.observe(el); return () => { cancelAnimationFrame(frameId); resizeObserver.disconnect(); }; }, [containerRef, content, checkVertical]); return isOverflowing; }; export interface EmojiPressData { id: string | null; name: string; animated: boolean; } interface CustomStatusDisplayProps { className?: string; emojiClassName?: string; customStatus?: CustomStatus | null; userId?: string; showText?: boolean; showTooltip?: boolean; allowJumboEmoji?: boolean; maxLines?: number; isEditable?: boolean; onEdit?: () => void; onEmojiPress?: (emoji: EmojiPressData) => void; constrained?: boolean; showPlaceholder?: boolean; animateOnParentHover?: boolean; alwaysAnimate?: boolean; } interface ClampedStyle extends React.CSSProperties { '--max-lines'?: number; } const sanitizeText = (text: string): string => { return text.replace(/[\r\n]+/g, ' ').trim(); }; const getTooltipEmojiUrl = (status: CustomStatus): string | null => { if (status.emojiId) { const emoji = EmojiStore.getEmojiById(status.emojiId); const isAnimated = emoji?.animated ?? status.emojiAnimated ?? false; return `${AvatarUtils.getEmojiURL({id: status.emojiId, animated: isAnimated})}?size=96&quality=lossless`; } if (status.emojiName && !shouldUseNativeEmoji) { return getEmojiURL(status.emojiName); } return null; }; interface StatusEmojiWithTooltipProps { status: CustomStatus; children: React.ReactNode; onClick?: () => void; isButton?: boolean; } const StatusEmojiWithTooltip = observer( ({status, children, onClick, isButton = false}: StatusEmojiWithTooltipProps) => { const tooltipPortalRoot = useTooltipPortalRoot(); const {targetRef, tooltipRef, state, updatePosition, handlers, tooltipHandlers} = useReactionTooltip(500); const emoji = status.emojiId ? EmojiStore.getEmojiById(status.emojiId) : null; const attribution = getEmojiAttribution({ emojiId: status.emojiId, guildId: emoji?.guildId ?? null, guild: emoji?.guildId ? GuildStore.getGuild(emoji.guildId) : null, emojiName: status.emojiName, }); const getEmojiDisplayName = (): string => { if (status.emojiId) { return `:${status.emojiName}:`; } if (status.emojiName) { return UnicodeEmojis.convertSurrogateToName(status.emojiName, true, status.emojiName); } return ''; }; const emojiName = getEmojiDisplayName(); const tooltipEmojiUrl = getTooltipEmojiUrl(status); const triggerRef = React.useRef(null); const mergedRef = useMergeRefs([targetRef, triggerRef]); const TriggerComponent = isButton ? 'button' : 'span'; const triggerProps = isButton ? {type: 'button' as const, className: styles.emojiPressable, onClick} : {className: styles.emojiTooltipTrigger}; return ( <> } {...triggerProps} {...handlers} > {children} {state.isOpen && ( { (tooltipRef as React.MutableRefObject).current = node; if (node && targetRef.current) { updatePosition(); } }} style={{ position: 'fixed', left: state.x, top: state.y, zIndex: 'var(--z-index-tooltip)', visibility: state.isReady ? 'visible' : 'hidden', }} initial={{opacity: 0, scale: 0.98}} animate={{opacity: 1, scale: 1}} exit={{opacity: 0, scale: 0.98}} transition={{ opacity: {duration: 0.1}, scale: {type: 'spring', damping: 25, stiffness: 500}, }} {...tooltipHandlers} > } /> )} > ); }, ); interface EmojiRenderResult { node: React.ReactNode; altText: string; } const renderStatusEmoji = ( status: CustomStatus, emojiClassName?: string, animateOnParentHover?: boolean, alwaysAnimate?: boolean, ): EmojiRenderResult | null => { const sizeSuffix = '?size=96&quality=lossless'; if (status.emojiId) { const emoji = EmojiStore.getEmojiById(status.emojiId); const altText = `:${status.emojiName}:`; const isAnimated = emoji?.animated ?? status.emojiAnimated ?? false; const staticUrl = `${AvatarUtils.getEmojiURL({id: status.emojiId, animated: false})}${sizeSuffix}`; const animatedUrl = isAnimated ? `${AvatarUtils.getEmojiURL({id: status.emojiId, animated: true})}${sizeSuffix}` : null; if (alwaysAnimate && animatedUrl) { return { node: ( ), altText, }; } if (animateOnParentHover && animatedUrl) { return { node: ( ), altText, }; } return { node: ( ), altText, }; } if (status.emojiName) { const altText = status.emojiName; if (!shouldUseNativeEmoji) { const twemojiUrl = getEmojiURL(status.emojiName); if (twemojiUrl) { return { node: ( ), altText, }; } } return { node: {status.emojiName}, altText, }; } return null; }; export const CustomStatusDisplay = observer( ({ className, emojiClassName, customStatus, userId, showText = true, showTooltip = true, allowJumboEmoji = false, maxLines = 1, isEditable = false, onEdit, onEmojiPress, constrained = false, showPlaceholder = false, animateOnParentHover = false, alwaysAnimate = false, }: CustomStatusDisplayProps) => { const containerRef = React.useRef(null); const status = customStatus === undefined ? (userId ? PresenceStore.getCustomStatus(userId) : null) : customStatus; const normalized = normalizeCustomStatus(status); const displayText = normalized?.text ? sanitizeText(normalized.text) : null; const isOverflowing = useTextOverflow(containerRef, displayText, maxLines > 1); if (!normalized) { if (showPlaceholder && isEditable && onEdit) { return ( Set a custom status ); } return null; } const fullText = getCustomStatusText(normalized); const hasEmoji = Boolean(normalized.emojiId || normalized.emojiName); const hasText = Boolean(normalized.text); if (!hasEmoji && !hasText) { return null; } const emojiResult = hasEmoji ? renderStatusEmoji(normalized, emojiClassName, animateOnParentHover, alwaysAnimate) : null; const isEmojiOnly = hasEmoji && !hasText; const isSingleLine = maxLines === 1 && !isEmojiOnly; const shouldClamp = maxLines > 1 && !isEmojiOnly; const clampedStyle: ClampedStyle | undefined = shouldClamp ? {'--max-lines': maxLines} : undefined; if (isEditable && onEdit) { const isDesktop = !MobileLayoutStore.enabled; const shouldShowEmojiTooltip = showTooltip && isDesktop && hasEmoji; const renderEditableEmoji = () => { if (!emojiResult) { return null; } if (shouldShowEmojiTooltip) { return ( {emojiResult.node} {emojiResult.altText} ); } return ( <> {emojiResult.node} {emojiResult.altText} > ); }; const editableContent = ( {renderEditableEmoji()} {showText && displayText && {displayText}} {isEmojiOnly && } ); if (showTooltip && fullText && isOverflowing) { return {editableContent}; } return editableContent; } const handleEmojiPress = () => { if (!onEmojiPress || !normalized) { return; } const emoji = EmojiStore.getEmojiById(normalized.emojiId ?? ''); const shouldAnimate = emoji?.animated ?? normalized.emojiAnimated ?? false; onEmojiPress({ id: normalized.emojiId, name: normalized.emojiName ?? '', animated: shouldAnimate, }); }; const renderEmojiNode = () => { if (!emojiResult) { return null; } const isDesktop = !MobileLayoutStore.enabled; const shouldShowEmojiTooltip = showTooltip && isDesktop && hasEmoji; if (onEmojiPress && hasEmoji) { if (shouldShowEmojiTooltip) { return ( {emojiResult.node} {emojiResult.altText} ); } return ( {emojiResult.node} {emojiResult.altText} ); } if (shouldShowEmojiTooltip) { return ( {emojiResult.node} {emojiResult.altText} ); } return ( {emojiResult.node} {emojiResult.altText} ); }; const content = ( {renderEmojiNode()} {showText && displayText && {displayText}} ); if (showTooltip && fullText && isOverflowing) { return {content}; } return content; }, ); CustomStatusDisplay.displayName = 'CustomStatusDisplay';