Compare commits

...
2 Commits
Author SHA1 Message Date
jaredandClaude Opus 5 8d11a62e14 feat(composer): tap once to preview, again to send stickers/GIFs on touch (#147)
CI / Build & Quality Checks (push) Successful in 1m59s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Canceled after 4m38s
Fat-finger guard for phones. On coarse-pointer devices the first tap on a
sticker (emoji board Sticker tab) or a GIF parks it in a small bar with a
thumbnail, name, Send and Cancel; a second tap on the same item or Send
sends it; tapping a different item switches the preview; tapping empty
picker space or Cancel clears it. Mouse clicks, keyboard activation and
screen-reader activation (bare click without touch events) still send in
one step, and the emoji tab is untouched.

useRecentTouch records touches inside the picker at the document level so
it survives the Sticker tab remount, and reports false without a coarse
pointer.

Also fixes the compact composer's GIF picker opening mostly off-screen:
end-aligning the 312px popout to a button near the left edge of the
overflow row pushed it to x≈-95; it now anchors to the row itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 00:15:09 -04:00
jaredandClaude Opus 5 bf05751eca 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
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
2026-09-19 23:54:52 -04:00
9 changed files with 408 additions and 49 deletions
+36 -5
View File
@@ -4,6 +4,8 @@ import { useAtom } from 'jotai';
import { Grid, SearchBar, SearchContext, SearchContextManager } from '@giphy/react-components';
import { IGif } from '@giphy/js-types';
import { Box, color, config } from 'folds';
import { TapToSendBar } from './tap-to-send/TapToSendBar';
import { useRecentTouch } from '../hooks/useRecentTouch';
import { useElementSizeObserver } from '../hooks/useElementSizeObserver';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
@@ -120,13 +122,31 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
const sendGif = useCallback(
(gif: RecentGif) => {
setRecents((prev) => addRecentGif(prev, gif));
const { url, width, height, previewUrl } = gif;
setRecents((prev) => addRecentGif(prev, { url, width, height, previewUrl }));
onSelect(gif.url, gif.width, gif.height);
requestClose();
},
[onSelect, requestClose, setRecents],
);
// [Gitea #147] Touch: first tap parks the GIF in a preview bar, second tap
// (or Send) sends. Mouse/keyboard/screen reader: one step, as before.
const containerRef = useRef<HTMLDivElement>(null);
const { wasTouch } = useRecentTouch(containerRef);
const [pending, setPending] = useState<(RecentGif & { title?: string }) | undefined>();
const pick = useCallback(
(gif: RecentGif & { title?: string }) => {
if (wasTouch() && pending?.url !== gif.url) {
setPending(gif);
return;
}
setPending(undefined);
sendGif(gif);
},
[wasTouch, pending, sendGif],
);
const handleClick = useCallback(
(gif: IGif, e: React.SyntheticEvent) => {
e.preventDefault();
@@ -135,14 +155,15 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
gif.images.fixed_width_small_still?.url ??
gif.images.downsized_still?.url ??
gif.images.original_still?.url;
sendGif({
pick({
url: r.url,
width: Number(r.width) || 200,
height: Number(r.height) || 200,
previewUrl,
title: gif.title,
});
},
[sendGif],
[pick],
);
const showRecents = recents.length > 0 && !(term ?? '').trim();
@@ -150,7 +171,6 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
// The container is min(312px, 100vw-16); feed the Grid the live pixel width
// (minus the inner 8px padding on each side) so it doesn't overflow a phone
// narrower than 312px with a fixed 296px grid.
const containerRef = useRef<HTMLDivElement>(null);
const [gridWidth, setGridWidth] = useState(PICKER_WIDTH - 16);
useElementSizeObserver(
useCallback(() => containerRef.current, []),
@@ -180,11 +200,22 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
<SearchBar />
</div>
</Box>
{pending && (
<TapToSendBar
previewUrl={pending.previewUrl ?? pending.url}
label={pending.title || 'GIF'}
onSend={() => {
setPending(undefined);
sendGif(pending);
}}
onCancel={() => setPending(undefined)}
/>
)}
<div
style={{ overflowY: 'auto', overflowX: 'hidden', maxHeight: '340px', padding: '0 8px 8px' }}
>
{showRecents && (
<RecentGifs recents={recents} lotusTerminal={lotusTerminal} onPick={sendGif} />
<RecentGifs recents={recents} lotusTerminal={lotusTerminal} onPick={pick} />
)}
<Grid
key={searchKey}
+39 -11
View File
@@ -31,6 +31,8 @@ import { useThrottle } from '../../hooks/useThrottle';
import { addRecentEmoji } from '../../plugins/recent-emoji';
import { addRecentSticker, recentStickersAtom } from '../../state/recentStickers';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useRecentTouch } from '../../hooks/useRecentTouch';
import { TapToSendBar } from '../tap-to-send/TapToSendBar';
import { ImagePack, ImageUsage, PackImageReader } from '../../plugins/custom-emoji';
import { getEmoticonSearchStr } from '../../plugins/utils';
import {
@@ -52,7 +54,7 @@ import {
EmojiGroup,
EmojiBoardLayout,
} from './components';
import { EmojiBoardTab, EmojiType } from './types';
import { EmojiBoardTab, EmojiItemInfo, EmojiType } from './types';
import { VirtualTile } from '../virtualizer';
const RECENT_GROUP_ID = 'recent_group';
@@ -518,10 +520,27 @@ export function EmojiBoard({
});
const vItems = virtualizer.getVirtualItems();
// [Gitea #147] Touch: first tap on a sticker parks it in a preview bar,
// second tap (or the bar's Send) sends. Mouse/keyboard/screen reader: one step.
const { wasTouch } = useRecentTouch(contentScrollRef);
const [pendingSticker, setPendingSticker] = useState<EmojiItemInfo | undefined>();
const stickerUseAuthentication = useMediaAuthentication();
const sendSticker = (info: EmojiItemInfo, close: boolean) => {
onStickerSelect?.(info.data, info.shortcode, info.label);
setRecentStickers((prev) =>
addRecentSticker(prev, { url: info.data, shortcode: info.shortcode, body: info.label }),
);
setPendingSticker(undefined);
if (close) requestClose();
};
const handleGroupItemClick: MouseEventHandler = (evt) => {
const targetEl = targetFromEvent(evt.nativeEvent, 'button');
const emojiInfo = targetEl && getEmojiItemInfo(targetEl);
if (!emojiInfo) return;
if (!emojiInfo) {
if (pendingSticker) setPendingSticker(undefined);
return;
}
if (emojiInfo.type === EmojiType.Emoji) {
onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
@@ -533,14 +552,12 @@ export function EmojiBoard({
onCustomEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
}
if (emojiInfo.type === EmojiType.Sticker) {
onStickerSelect?.(emojiInfo.data, emojiInfo.shortcode, emojiInfo.label);
setRecentStickers((prev) =>
addRecentSticker(prev, {
url: emojiInfo.data,
shortcode: emojiInfo.shortcode,
body: emojiInfo.label,
}),
);
if (wasTouch() && pendingSticker?.data !== emojiInfo.data) {
setPendingSticker(emojiInfo);
return;
}
sendSticker(emojiInfo, !evt.altKey && !evt.shiftKey);
return;
}
if (!evt.altKey && !evt.shiftKey) requestClose();
};
@@ -670,7 +687,18 @@ export function EmojiBoard({
{tab === EmojiBoardTab.Sticker && groups.length === 0 && <NoStickerPacks />}
</EmojiGroupHolder>
</Box>
<Preview previewAtom={previewAtom} />
{pendingSticker && tab === EmojiBoardTab.Sticker ? (
<TapToSendBar
previewUrl={
mxcUrlToHttp(mx, pendingSticker.data, stickerUseAuthentication) ?? undefined
}
label={pendingSticker.label}
onSend={() => sendSticker(pendingSticker, true)}
onCancel={() => setPendingSticker(undefined)}
/>
) : (
<Preview previewAtom={previewAtom} />
)}
</EmojiBoardLayout>
</FocusTrap>
);
@@ -0,0 +1,75 @@
import React, { useEffect, useRef } from 'react';
import { Box, Button, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds';
type TapToSendBarProps = {
previewUrl?: string;
label: string;
onSend: () => void;
onCancel: () => void;
};
/**
* [Gitea #147] Fat-finger guard for touch screens: the first tap on a sticker
* or GIF parks it here with a preview; "Send" (or a second tap on the same
* item) sends it.
*/
export function TapToSendBar({ previewUrl, label, onSend, onCancel }: TapToSendBarProps) {
const liveRef = useRef<HTMLDivElement>(null);
useEffect(() => {
liveRef.current?.focus?.();
}, [label]);
return (
<Box
ref={liveRef}
role="status"
aria-live="polite"
tabIndex={-1}
shrink="No"
alignItems="Center"
gap="300"
style={{
margin: `0 ${config.space.S300} ${config.space.S200}`,
padding: config.space.S200,
borderRadius: config.radii.R400,
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
outline: 'none',
}}
>
{previewUrl && (
<img
src={previewUrl}
alt=""
style={{
width: toRem(40),
height: toRem(40),
objectFit: 'contain',
borderRadius: config.radii.R300,
flexShrink: 0,
}}
/>
)}
<Box grow="Yes" direction="Column" style={{ minWidth: 0 }}>
<Text size="T200" priority="300">
Tap again to send
</Text>
<Text size="T300" truncate>
{label}
</Text>
</Box>
<Button size="300" variant="Primary" fill="Solid" radii="300" onClick={onSend}>
<Text size="B300">Send</Text>
</Button>
<IconButton
size="300"
variant="SurfaceVariant"
radii="300"
onClick={onCancel}
aria-label="Cancel"
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
</Box>
);
}
+10 -2
View File
@@ -1339,12 +1339,20 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
{(gifOpen: boolean, setGifOpen) => (
<PopOut
offset={16}
alignOffset={-44}
// [Gitea #147] In the compact overflow row the GIF button sits
// near the left edge; end-aligning a 312px picker to it pushed
// most of it off-screen. Anchor to the row instead.
alignOffset={compact ? 0 : -44}
position="Top"
align="End"
anchor={
gifOpen
? (gifBtnRef.current?.getBoundingClientRect() ?? undefined)
? ((compact
? gifBtnRef.current?.closest('#composer-more-actions')
: gifBtnRef.current
)?.getBoundingClientRect() ??
gifBtnRef.current?.getBoundingClientRect() ??
undefined)
: undefined
}
content={
+136 -30
View File
@@ -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,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<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();
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}
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 (
<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) => (
<Reaction
ref={targetRef}
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}
useAuthentication={useAuthentication}
/>
)}
</TooltipProvider>
<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) => {
+23 -1
View File
@@ -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',
});
+36
View File
@@ -0,0 +1,36 @@
import { RefObject, useCallback, useEffect, useRef } from 'react';
import { useMediaQuery } from './useMediaQuery';
const RECENT_MS = 700;
/**
* [Gitea #147] Tells whether a click was produced by a finger. Records the
* last touch inside `ref`; `wasTouch()` is true for a short window after it.
* Keyboard activation and screen-reader activation (TalkBack/VoiceOver send a
* bare click, no touch events) report false, so they keep one-step behaviour.
* Always false on devices without a coarse pointer.
*/
export function useRecentTouch(ref: RefObject<HTMLElement | null>) {
const coarse = useMediaQuery('(pointer: coarse)');
const last = useRef(0);
// Listen on the document (the ref'd element may be remounted, e.g. on a tab
// switch) and only count touches that land inside it.
useEffect(() => {
if (!coarse) return undefined;
const mark = (evt: TouchEvent) => {
const el = ref.current;
if (el && evt.target instanceof Node && !el.contains(evt.target)) return;
last.current = Date.now();
};
document.addEventListener('touchstart', mark, { passive: true, capture: true });
document.addEventListener('touchend', mark, { passive: true, capture: true });
return () => {
document.removeEventListener('touchstart', mark, { capture: true });
document.removeEventListener('touchend', mark, { capture: true });
};
}, [ref, coarse]);
const wasTouch = useCallback(() => coarse && Date.now() - last.current < RECENT_MS, [coarse]);
return { coarse, wasTouch };
}
+27
View File
@@ -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);
});
});
+26
View File
@@ -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>();