From bf05751eca05236851fc9d297c8b65a65b733fdf Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 19 Sep 2026 23:54:52 -0400 Subject: [PATCH] feat(messages): collapse reactions to one row with a +N chip (#138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Many distinct reactions used to wrap into a tall stack (16 reactions = 4 rows at phone width). Now only the first row is shown, ending in a "+N" chip; tapping it expands inline and a "less" chip collapses again. Expanded state is remembered per message for the session. Nothing changes when the reactions already fit on one row. Overflowing chips stay in the DOM (invisible, aria-hidden, untabbable, clipped by max-height) so the container keeps its natural width — which keeps shrink-to-fit bubble layout stable — and each chip stays measurable. utils/reactionOverflow.ts holds the unit-tested fit calculation; a ResizeObserver re-fits on width changes. "+N" is forced LTR for RTL UIs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/app/features/room/message/Reactions.tsx | 166 ++++++++++++++++---- src/app/features/room/message/styles.css.ts | 24 ++- src/app/utils/reactionOverflow.test.ts | 27 ++++ src/app/utils/reactionOverflow.ts | 26 +++ 4 files changed, 212 insertions(+), 31 deletions(-) create mode 100644 src/app/utils/reactionOverflow.test.ts create mode 100644 src/app/utils/reactionOverflow.ts diff --git a/src/app/features/room/message/Reactions.tsx b/src/app/features/room/message/Reactions.tsx index 26e06b6ee..baf8cb44e 100644 --- a/src/app/features/room/message/Reactions.tsx +++ b/src/app/features/room/message/Reactions.tsx @@ -1,4 +1,11 @@ -import React, { MouseEventHandler, useCallback, useState } from 'react'; +import React, { + MouseEventHandler, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; import { Box, Modal, @@ -23,6 +30,7 @@ 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; @@ -42,6 +50,49 @@ export const Reactions = as<'div', ReactionsProps>( 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(); @@ -56,44 +107,99 @@ export const Reactions = as<'div', ReactionsProps>( gap="200" wrap="Wrap" {...props} - ref={ref} + style={{ + ...props.style, + ...(collapsed && rowHeight + ? // 2px breathing room so row-1 focus outlines are not clipped. + { maxHeight: rowHeight + 4, overflow: 'hidden', padding: 2, margin: -2 } + : {}), + }} + ref={(el) => { + containerRef.current = el; + if (typeof ref === 'function') ref(el); + else if (ref) (ref as React.MutableRefObject).current = el; + }} > - {reactions.map(([key, events]) => { + {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} + 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) => ( - onReactionToggle(mEventId, key) : undefined} - onContextMenu={handleViewReaction} - aria-disabled={!canSendReaction} - useAuthentication={useAuthentication} - /> - )} - + + + + + + + } + > + {(targetRef) => chip(targetRef)} + + {moreChip} + ); })} + {expanded && limit !== undefined && ( + + )} {reactions.length > 0 && ( { diff --git a/src/app/features/room/message/styles.css.ts b/src/app/features/room/message/styles.css.ts index 4be501bdc..7dacf14d0 100644 --- a/src/app/features/room/message/styles.css.ts +++ b/src/app/features/room/message/styles.css.ts @@ -1,5 +1,5 @@ import { style } from '@vanilla-extract/css'; -import { DefaultReset, config, toRem } from 'folds'; +import { DefaultReset, FocusOutline, color, config, toRem } from 'folds'; export const MessageBase = style({ position: 'relative', @@ -45,6 +45,7 @@ export const MessageMenuItemText = style({ }); export const ReactionsContainer = style({ + position: 'relative', selectors: { '&:empty': { display: 'none', @@ -52,6 +53,27 @@ export const ReactionsContainer = style({ }, }); +/** [Gitea #138] "+N" / "less" chip at the end of a collapsed reaction row. */ +export const ReactionsMore = style([ + FocusOutline, + { + display: 'inline-flex', + alignItems: 'center', + flexShrink: 0, + padding: `${toRem(2)} ${config.space.S200}`, + color: color.SurfaceVariant.OnContainer, + backgroundColor: 'transparent', + border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`, + borderRadius: config.radii.R300, + cursor: 'pointer', + selectors: { + '&:hover, &:focus-visible': { + backgroundColor: color.SurfaceVariant.ContainerHover, + }, + }, + }, +]); + export const ReactionsTooltipText = style({ wordBreak: 'break-word', }); diff --git a/src/app/utils/reactionOverflow.test.ts b/src/app/utils/reactionOverflow.test.ts new file mode 100644 index 000000000..2585edbb8 --- /dev/null +++ b/src/app/utils/reactionOverflow.test.ts @@ -0,0 +1,27 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { fitReactionRow } from './reactionOverflow'; + +describe('fitReactionRow', () => { + it('returns undefined when everything fits', () => { + assert.equal(fitReactionRow([50, 50, 50], 40, 8, 166), undefined); + assert.equal(fitReactionRow([], 40, 8, 100), undefined); + }); + + it('leaves room for the +N chip', () => { + // 5 chips of 50 + 4 gaps = 282 > 200. 3 chips + gap + more = 150+16+8+40 = 214 > 200 → 2. + assert.equal(fitReactionRow([50, 50, 50, 50, 50], 40, 8, 200), 2); + // Wider row: 3 chips (166) + 8 + 40 = 214 ≤ 220 → 3. + assert.equal(fitReactionRow([50, 50, 50, 50, 50], 40, 8, 220), 3); + }); + + it('never hides every chip', () => { + assert.equal(fitReactionRow([90, 90], 40, 8, 100), 1); + assert.equal(fitReactionRow([200, 200], 40, 8, 100), 1); + }); + + it('handles uneven chip widths', () => { + // 30+8+80+8+30 = 156 > 150. 2 chips: 30+8+80 = 118, +8+40 = 166 > 150 → 1. + assert.equal(fitReactionRow([30, 80, 30], 40, 8, 150), 1); + }); +}); diff --git a/src/app/utils/reactionOverflow.ts b/src/app/utils/reactionOverflow.ts new file mode 100644 index 000000000..ae34f15a0 --- /dev/null +++ b/src/app/utils/reactionOverflow.ts @@ -0,0 +1,26 @@ +/** + * [Gitea #138] How many reaction chips fit on one row next to a "+N" chip. + * + * Returns `undefined` when every chip fits on the first row (nothing to + * collapse). Otherwise returns the number of leading chips to show; the + * caller renders a "+N" chip after them. Always at least 1 chip. + */ +export function fitReactionRow( + chipWidths: number[], + moreChipWidth: number, + gap: number, + rowWidth: number, +): number | undefined { + const rowLength = (count: number, extra: number) => { + let total = extra; + for (let i = 0; i < count; i += 1) total += chipWidths[i] + (i > 0 || extra > 0 ? gap : 0); + return total; + }; + if (rowLength(chipWidths.length, 0) <= rowWidth) return undefined; + let count = chipWidths.length - 1; + while (count > 1 && rowLength(count, 0) + gap + moreChipWidth > rowWidth) count -= 1; + return count; +} + +/** Session-only memory of which messages have their reactions expanded. */ +export const expandedReactionMessages = new Set();