Files
fx-test/fluxer_app/src/components/common/CustomStatusDisplay/CustomStatusDisplay.tsx
2026-01-02 19:27:51 +00:00

526 lines
15 KiB
TypeScript

/*
* 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 <https://www.gnu.org/licenses/>.
*/
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<HTMLElement | null>,
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<HTMLElement>(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 (
<>
<TriggerComponent
ref={mergedRef as React.Ref<HTMLButtonElement & HTMLSpanElement>}
{...triggerProps}
{...handlers}
>
{children}
</TriggerComponent>
{state.isOpen && (
<FloatingPortal root={tooltipPortalRoot}>
<AnimatePresence>
<motion.div
ref={(node) => {
(tooltipRef as React.MutableRefObject<HTMLDivElement | null>).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}
>
<EmojiTooltipContent
emojiUrl={tooltipEmojiUrl}
emoji={shouldUseNativeEmoji && status.emojiName && !status.emojiId ? status.emojiName : undefined}
emojiAlt={status.emojiName ?? undefined}
primaryContent={emojiName}
subtext={
<EmojiAttributionSubtext
attribution={attribution}
classes={{
container: styles.emojiTooltipSubtext,
guildRow: styles.emojiTooltipGuildRow,
guildIcon: styles.emojiTooltipGuildIcon,
guildName: styles.emojiTooltipGuildName,
verifiedIcon: styles.emojiTooltipVerifiedIcon,
}}
/>
}
/>
</motion.div>
</AnimatePresence>
</FloatingPortal>
)}
</>
);
},
);
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: (
<img
src={animatedUrl}
alt={status.emojiName ?? undefined}
draggable={false}
className={clsx(styles.statusEmoji, emojiClassName)}
/>
),
altText,
};
}
if (animateOnParentHover && animatedUrl) {
return {
node: (
<span className={styles.statusEmojiWrapper}>
<img
src={staticUrl}
alt={status.emojiName ?? undefined}
draggable={false}
className={clsx(styles.statusEmoji, styles.staticEmoji, emojiClassName)}
/>
<img
src={animatedUrl}
alt={status.emojiName ?? undefined}
draggable={false}
className={clsx(styles.statusEmoji, styles.animatedEmoji, emojiClassName)}
/>
</span>
),
altText,
};
}
return {
node: (
<img
src={staticUrl}
alt={status.emojiName ?? undefined}
draggable={false}
className={clsx(styles.statusEmoji, emojiClassName)}
/>
),
altText,
};
}
if (status.emojiName) {
const altText = status.emojiName;
if (!shouldUseNativeEmoji) {
const twemojiUrl = getEmojiURL(status.emojiName);
if (twemojiUrl) {
return {
node: (
<img
src={twemojiUrl}
alt={status.emojiName}
draggable={false}
className={clsx(styles.statusEmoji, emojiClassName)}
/>
),
altText,
};
}
}
return {
node: <span className={clsx(styles.statusEmoji, styles.nativeEmoji, emojiClassName)}>{status.emojiName}</span>,
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<HTMLDivElement>(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 (
<FocusRing offset={-2}>
<button type="button" className={styles.placeholder} onClick={onEdit}>
<SmileyIcon size={14} weight="regular" className={styles.placeholderIcon} />
<span className={styles.placeholderText}>
<Trans>Set a custom status</Trans>
</span>
</button>
</FocusRing>
);
}
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 (
<StatusEmojiWithTooltip status={normalized}>
{emojiResult.node}
<span className={styles.hiddenVisually}>{emojiResult.altText}</span>
</StatusEmojiWithTooltip>
);
}
return (
<>
{emojiResult.node}
<span className={styles.hiddenVisually}>{emojiResult.altText}</span>
</>
);
};
const editableContent = (
<FocusRing offset={-2}>
<button
type="button"
className={clsx(styles.editableWrapper, {
[styles.editableTextHover]: hasText,
[styles.editableEmojiOnly]: isEmojiOnly,
})}
onClick={onEdit}
>
<div
ref={containerRef}
className={clsx(styles.content, className, {
[styles.jumbo]: allowJumboEmoji && isEmojiOnly,
[styles.singleLine]: isSingleLine,
[styles.clamped]: shouldClamp,
})}
style={clampedStyle}
>
{renderEditableEmoji()}
{showText && displayText && <span className={styles.truncatedText}>{displayText}</span>}
</div>
{isEmojiOnly && <PencilIcon size={12} weight="bold" className={styles.editPencilIcon} />}
</button>
</FocusRing>
);
if (showTooltip && fullText && isOverflowing) {
return <Tooltip text={fullText}>{editableContent}</Tooltip>;
}
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 (
<StatusEmojiWithTooltip status={normalized} onClick={handleEmojiPress} isButton>
{emojiResult.node}
<span className={styles.hiddenVisually}>{emojiResult.altText}</span>
</StatusEmojiWithTooltip>
);
}
return (
<button type="button" className={styles.emojiPressable} onClick={handleEmojiPress}>
{emojiResult.node}
<span className={styles.hiddenVisually}>{emojiResult.altText}</span>
</button>
);
}
if (shouldShowEmojiTooltip) {
return (
<StatusEmojiWithTooltip status={normalized}>
{emojiResult.node}
<span className={styles.hiddenVisually}>{emojiResult.altText}</span>
</StatusEmojiWithTooltip>
);
}
return (
<span className={styles.emojiTooltipTrigger}>
{emojiResult.node}
<span className={styles.hiddenVisually}>{emojiResult.altText}</span>
</span>
);
};
const content = (
<div
ref={containerRef}
className={clsx(styles.content, className, {
[styles.jumbo]: allowJumboEmoji && isEmojiOnly,
[styles.singleLine]: isSingleLine,
[styles.clamped]: shouldClamp,
[styles.constrained]: constrained,
})}
style={clampedStyle}
>
{renderEmojiNode()}
{showText && displayText && <span className={styles.truncatedText}>{displayText}</span>}
</div>
);
if (showTooltip && fullText && isOverflowing) {
return <Tooltip text={fullText}>{content}</Tooltip>;
}
return content;
},
);
CustomStatusDisplay.displayName = 'CustomStatusDisplay';