import React, { MouseEventHandler, useCallback, useEffect, useLayoutEffect, useRef, useState, } from 'react'; import { Box, Modal, Overlay, OverlayBackdrop, OverlayCenter, Text, Tooltip, TooltipProvider, as, toRem, } from 'folds'; import classNames from 'classnames'; import { Room } from 'matrix-js-sdk'; import { type Relations } from 'matrix-js-sdk/lib/models/relations'; import FocusTrap from 'focus-trap-react'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { eventWithShortcode, factoryEventSentBy } from '../../../utils/matrix'; import { Reaction, ReactionTooltipMsg } from '../../../components/message'; import { useRelations } from '../../../hooks/useRelations'; import * as css from './styles.css'; import { ReactionViewer } from '../reaction-viewer'; import { stopPropagation } from '../../../utils/keyboard'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { expandedReactionMessages, fitReactionRow } from '../../../utils/reactionOverflow'; export type ReactionsProps = { room: Room; mEventId: string; canSendReaction?: boolean; relations: Relations; onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void; }; export const Reactions = as<'div', ReactionsProps>( ({ className, room, relations, mEventId, canSendReaction, onReactionToggle, ...props }, ref) => { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const [viewer, setViewer] = useState(false); const myUserId = mx.getUserId(); const reactions = useRelations( relations, useCallback((rel) => [...(rel.getSortedAnnotationsByKey() ?? [])], []), ); // [Gitea #138] Collapse to one row + "+N" when the chips would wrap. // Overflowing chips stay in the DOM (invisible, clipped by max-height) so // the container keeps its natural width and every chip stays measurable. const [expanded, setExpandedState] = useState(() => expandedReactionMessages.has(mEventId)); const setExpanded = (next: boolean) => { if (next) expandedReactionMessages.add(mEventId); else expandedReactionMessages.delete(mEventId); setExpandedState(next); }; const [limit, setLimit] = useState(); const [rowHeight, setRowHeight] = useState(); const containerRef = useRef(null); const moreRef = useRef(null); const chipRefs = useRef(new Map()); const keys = reactions.map(([key]) => key).join('\u0000'); const measure = useCallback(() => { const container = containerRef.current; if (!container) return; const rowWidth = container.getBoundingClientRect().width; const gap = parseFloat(getComputedStyle(container).columnGap) || 8; const chips = keys ? keys.split('\u0000').map((k) => chipRefs.current.get(k)) : []; const widths = chips.map((el) => el?.getBoundingClientRect().width ?? 0); const moreWidth = moreRef.current?.getBoundingClientRect().width ?? 48; setLimit(fitReactionRow(widths, moreWidth, gap, rowWidth)); setRowHeight(Math.max(0, ...chips.map((el) => el?.getBoundingClientRect().height ?? 0))); }, [keys]); useLayoutEffect(() => { measure(); }, [measure]); useEffect(() => { const container = containerRef.current; if (!container || typeof ResizeObserver === 'undefined') return undefined; const ro = new ResizeObserver(() => measure()); ro.observe(container); return () => ro.disconnect(); }, [measure]); const collapsed = !expanded && limit !== undefined; const hiddenCount = collapsed ? reactions.length - (limit ?? 0) : 0; const hiddenStyle: React.CSSProperties = { visibility: 'hidden', pointerEvents: 'none' }; const handleViewReaction: MouseEventHandler = (evt) => { evt.stopPropagation(); evt.preventDefault(); const key = evt.currentTarget.getAttribute('data-reaction-key'); if (!key) setViewer(true); else setViewer(key); }; return ( { containerRef.current = el; if (typeof ref === 'function') ref(el); else if (ref) (ref as React.MutableRefObject).current = el; }} > {reactions.map(([key, events], index) => { const rEvents = Array.from(events); if (rEvents.length === 0 || typeof key !== 'string') return null; const myREvent = myUserId ? rEvents.find(factoryEventSentBy(myUserId)) : undefined; const isPressed = !!myREvent?.getRelation(); const hidden = collapsed && index >= (limit ?? 0); const chip = (targetRef?: React.RefCallback) => ( { targetRef?.(el); if (el) chipRefs.current.set(key, el); else chipRefs.current.delete(key); }} data-reaction-key={key} aria-pressed={isPressed} key={key} mx={mx} reaction={key} shortcode={rEvents.find(eventWithShortcode)?.getContent().shortcode} count={events.size} onClick={canSendReaction ? () => onReactionToggle(mEventId, key) : undefined} onContextMenu={handleViewReaction} aria-disabled={!canSendReaction} aria-hidden={hidden || undefined} tabIndex={hidden ? -1 : undefined} style={hidden ? hiddenStyle : undefined} useAuthentication={useAuthentication} /> ); // The "+N" chip sits right after the last visible chip so it lands on row 1. const moreChip = index === (limit ?? 0) - 1 && ( ); if (hidden) return {chip()}; return ( } > {(targetRef) => chip(targetRef)} {moreChip} ); })} {expanded && limit !== undefined && ( )} {reactions.length > 0 && ( { evt.stopPropagation(); }} open={!!viewer} backdrop={} > setViewer(false), clickOutsideDeactivates: true, escapeDeactivates: stopPropagation, }} > setViewer(false)} /> )} ); }, );