CI / Secret scan (gitleaks) (push) Successful in 52s
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
The reaction button's aria-label used getShortcodeFor(), which returns undefined until the lazily loaded emoji data arrives. The same button read "🎉 reaction, 1 person" on first render and "tada reaction, 2 people" after any later re-render. It now always uses the emoji itself (screen readers speak it by its proper name, e.g. "party popper"). Custom (mxc) emoji were labelled just "custom emoji"; they now use the shortcode carried on the reaction event (":lotus_blob: reaction"). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
248 lines
9.4 KiB
TypeScript
248 lines
9.4 KiB
TypeScript
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<boolean | string>(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<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();
|
|
const key = evt.currentTarget.getAttribute('data-reaction-key');
|
|
if (!key) setViewer(true);
|
|
else setViewer(key);
|
|
};
|
|
|
|
return (
|
|
<Box
|
|
className={classNames(css.ReactionsContainer, className)}
|
|
gap="200"
|
|
wrap="Wrap"
|
|
{...props}
|
|
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], 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<HTMLElement>) => (
|
|
<Reaction
|
|
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}
|
|
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 && (
|
|
<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: React.MouseEvent) => {
|
|
evt.stopPropagation();
|
|
}}
|
|
open={!!viewer}
|
|
backdrop={<OverlayBackdrop />}
|
|
>
|
|
<OverlayCenter>
|
|
<FocusTrap
|
|
focusTrapOptions={{
|
|
// #185 P5: move focus into the viewer so it is announced (it opened
|
|
// silently with focus left on the reaction behind it).
|
|
initialFocus: '[data-reaction-viewer]',
|
|
onDeactivate: () => setViewer(false),
|
|
clickOutsideDeactivates: true,
|
|
escapeDeactivates: stopPropagation,
|
|
}}
|
|
>
|
|
<Modal
|
|
variant="Surface"
|
|
size="300"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="Reactions"
|
|
data-reaction-viewer=""
|
|
tabIndex={-1}
|
|
style={{ outline: 'none' }}
|
|
>
|
|
<ReactionViewer
|
|
room={room}
|
|
initialKey={typeof viewer === 'string' ? viewer : undefined}
|
|
relations={relations}
|
|
requestClose={() => setViewer(false)}
|
|
/>
|
|
</Modal>
|
|
</FocusTrap>
|
|
</OverlayCenter>
|
|
</Overlay>
|
|
)}
|
|
</Box>
|
|
);
|
|
},
|
|
);
|