feat(messages): collapse reactions to one row with a +N chip (#138)
CI / Build & Quality Checks (push) Successful in 1m36s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 10s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
CI / Build & Quality Checks (push) Successful in 1m36s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 10s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -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<number | undefined>();
|
||||
const [rowHeight, setRowHeight] = useState<number | undefined>();
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const moreRef = useRef<HTMLButtonElement | null>(null);
|
||||
const chipRefs = useRef(new Map<string, HTMLElement>());
|
||||
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<HTMLButtonElement> = (evt) => {
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
@@ -56,29 +107,32 @@ 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<HTMLDivElement | null>).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();
|
||||
|
||||
return (
|
||||
<TooltipProvider
|
||||
key={key}
|
||||
position="Top"
|
||||
tooltip={
|
||||
<Tooltip style={{ maxWidth: toRem(200) }}>
|
||||
<Text className={css.ReactionsTooltipText} size="T300">
|
||||
<ReactionTooltipMsg room={room} reaction={key} events={rEvents} />
|
||||
</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(targetRef) => (
|
||||
const hidden = collapsed && index >= (limit ?? 0);
|
||||
const chip = (targetRef?: React.RefCallback<HTMLElement>) => (
|
||||
<Reaction
|
||||
ref={targetRef}
|
||||
ref={(el: HTMLElement | null) => {
|
||||
targetRef?.(el);
|
||||
if (el) chipRefs.current.set(key, el);
|
||||
else chipRefs.current.delete(key);
|
||||
}}
|
||||
data-reaction-key={key}
|
||||
aria-pressed={isPressed}
|
||||
key={key}
|
||||
@@ -88,12 +142,64 @@ export const Reactions = as<'div', ReactionsProps>(
|
||||
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 && (
|
||||
<button
|
||||
key="more"
|
||||
ref={moreRef}
|
||||
type="button"
|
||||
className={css.ReactionsMore}
|
||||
onClick={() => setExpanded(true)}
|
||||
aria-expanded={false}
|
||||
aria-label={`Show ${hiddenCount} more reactions`}
|
||||
aria-hidden={!collapsed || undefined}
|
||||
tabIndex={collapsed ? undefined : -1}
|
||||
style={collapsed ? undefined : { ...hiddenStyle, position: 'absolute' }}
|
||||
>
|
||||
<Text as="span" size="T300" dir="ltr">
|
||||
{`+${collapsed ? hiddenCount : reactions.length - 1}`}
|
||||
</Text>
|
||||
</button>
|
||||
);
|
||||
if (hidden) return <React.Fragment key={key}>{chip()}</React.Fragment>;
|
||||
|
||||
return (
|
||||
<React.Fragment key={key}>
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
tooltip={
|
||||
<Tooltip style={{ maxWidth: toRem(200) }}>
|
||||
<Text className={css.ReactionsTooltipText} size="T300">
|
||||
<ReactionTooltipMsg room={room} reaction={key} events={rEvents} />
|
||||
</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(targetRef) => chip(targetRef)}
|
||||
</TooltipProvider>
|
||||
{moreChip}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{expanded && limit !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.ReactionsMore}
|
||||
onClick={() => setExpanded(false)}
|
||||
aria-expanded
|
||||
aria-label="Show fewer reactions"
|
||||
>
|
||||
<Text as="span" size="T300">
|
||||
less
|
||||
</Text>
|
||||
</button>
|
||||
)}
|
||||
{reactions.length > 0 && (
|
||||
<Overlay
|
||||
onContextMenu={(evt: any) => {
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string>();
|
||||
Reference in New Issue
Block a user