Compare commits
2
Commits
2bdb2eb4cb
...
8d11a62e14
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d11a62e14 | ||
|
|
bf05751eca |
@@ -4,6 +4,8 @@ import { useAtom } from 'jotai';
|
|||||||
import { Grid, SearchBar, SearchContext, SearchContextManager } from '@giphy/react-components';
|
import { Grid, SearchBar, SearchContext, SearchContextManager } from '@giphy/react-components';
|
||||||
import { IGif } from '@giphy/js-types';
|
import { IGif } from '@giphy/js-types';
|
||||||
import { Box, color, config } from 'folds';
|
import { Box, color, config } from 'folds';
|
||||||
|
import { TapToSendBar } from './tap-to-send/TapToSendBar';
|
||||||
|
import { useRecentTouch } from '../hooks/useRecentTouch';
|
||||||
import { useElementSizeObserver } from '../hooks/useElementSizeObserver';
|
import { useElementSizeObserver } from '../hooks/useElementSizeObserver';
|
||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
@@ -120,13 +122,31 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
|
|||||||
|
|
||||||
const sendGif = useCallback(
|
const sendGif = useCallback(
|
||||||
(gif: RecentGif) => {
|
(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);
|
onSelect(gif.url, gif.width, gif.height);
|
||||||
requestClose();
|
requestClose();
|
||||||
},
|
},
|
||||||
[onSelect, requestClose, setRecents],
|
[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(
|
const handleClick = useCallback(
|
||||||
(gif: IGif, e: React.SyntheticEvent) => {
|
(gif: IGif, e: React.SyntheticEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -135,14 +155,15 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
|
|||||||
gif.images.fixed_width_small_still?.url ??
|
gif.images.fixed_width_small_still?.url ??
|
||||||
gif.images.downsized_still?.url ??
|
gif.images.downsized_still?.url ??
|
||||||
gif.images.original_still?.url;
|
gif.images.original_still?.url;
|
||||||
sendGif({
|
pick({
|
||||||
url: r.url,
|
url: r.url,
|
||||||
width: Number(r.width) || 200,
|
width: Number(r.width) || 200,
|
||||||
height: Number(r.height) || 200,
|
height: Number(r.height) || 200,
|
||||||
previewUrl,
|
previewUrl,
|
||||||
|
title: gif.title,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[sendGif],
|
[pick],
|
||||||
);
|
);
|
||||||
|
|
||||||
const showRecents = recents.length > 0 && !(term ?? '').trim();
|
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
|
// 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
|
// (minus the inner 8px padding on each side) so it doesn't overflow a phone
|
||||||
// narrower than 312px with a fixed 296px grid.
|
// narrower than 312px with a fixed 296px grid.
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [gridWidth, setGridWidth] = useState(PICKER_WIDTH - 16);
|
const [gridWidth, setGridWidth] = useState(PICKER_WIDTH - 16);
|
||||||
useElementSizeObserver(
|
useElementSizeObserver(
|
||||||
useCallback(() => containerRef.current, []),
|
useCallback(() => containerRef.current, []),
|
||||||
@@ -180,11 +200,22 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
|
|||||||
<SearchBar />
|
<SearchBar />
|
||||||
</div>
|
</div>
|
||||||
</Box>
|
</Box>
|
||||||
|
{pending && (
|
||||||
|
<TapToSendBar
|
||||||
|
previewUrl={pending.previewUrl ?? pending.url}
|
||||||
|
label={pending.title || 'GIF'}
|
||||||
|
onSend={() => {
|
||||||
|
setPending(undefined);
|
||||||
|
sendGif(pending);
|
||||||
|
}}
|
||||||
|
onCancel={() => setPending(undefined)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
style={{ overflowY: 'auto', overflowX: 'hidden', maxHeight: '340px', padding: '0 8px 8px' }}
|
style={{ overflowY: 'auto', overflowX: 'hidden', maxHeight: '340px', padding: '0 8px 8px' }}
|
||||||
>
|
>
|
||||||
{showRecents && (
|
{showRecents && (
|
||||||
<RecentGifs recents={recents} lotusTerminal={lotusTerminal} onPick={sendGif} />
|
<RecentGifs recents={recents} lotusTerminal={lotusTerminal} onPick={pick} />
|
||||||
)}
|
)}
|
||||||
<Grid
|
<Grid
|
||||||
key={searchKey}
|
key={searchKey}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ import { useThrottle } from '../../hooks/useThrottle';
|
|||||||
import { addRecentEmoji } from '../../plugins/recent-emoji';
|
import { addRecentEmoji } from '../../plugins/recent-emoji';
|
||||||
import { addRecentSticker, recentStickersAtom } from '../../state/recentStickers';
|
import { addRecentSticker, recentStickersAtom } from '../../state/recentStickers';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
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 { ImagePack, ImageUsage, PackImageReader } from '../../plugins/custom-emoji';
|
||||||
import { getEmoticonSearchStr } from '../../plugins/utils';
|
import { getEmoticonSearchStr } from '../../plugins/utils';
|
||||||
import {
|
import {
|
||||||
@@ -52,7 +54,7 @@ import {
|
|||||||
EmojiGroup,
|
EmojiGroup,
|
||||||
EmojiBoardLayout,
|
EmojiBoardLayout,
|
||||||
} from './components';
|
} from './components';
|
||||||
import { EmojiBoardTab, EmojiType } from './types';
|
import { EmojiBoardTab, EmojiItemInfo, EmojiType } from './types';
|
||||||
import { VirtualTile } from '../virtualizer';
|
import { VirtualTile } from '../virtualizer';
|
||||||
|
|
||||||
const RECENT_GROUP_ID = 'recent_group';
|
const RECENT_GROUP_ID = 'recent_group';
|
||||||
@@ -518,10 +520,27 @@ export function EmojiBoard({
|
|||||||
});
|
});
|
||||||
const vItems = virtualizer.getVirtualItems();
|
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 handleGroupItemClick: MouseEventHandler = (evt) => {
|
||||||
const targetEl = targetFromEvent(evt.nativeEvent, 'button');
|
const targetEl = targetFromEvent(evt.nativeEvent, 'button');
|
||||||
const emojiInfo = targetEl && getEmojiItemInfo(targetEl);
|
const emojiInfo = targetEl && getEmojiItemInfo(targetEl);
|
||||||
if (!emojiInfo) return;
|
if (!emojiInfo) {
|
||||||
|
if (pendingSticker) setPendingSticker(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (emojiInfo.type === EmojiType.Emoji) {
|
if (emojiInfo.type === EmojiType.Emoji) {
|
||||||
onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
|
onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
|
||||||
@@ -533,14 +552,12 @@ export function EmojiBoard({
|
|||||||
onCustomEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
|
onCustomEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
|
||||||
}
|
}
|
||||||
if (emojiInfo.type === EmojiType.Sticker) {
|
if (emojiInfo.type === EmojiType.Sticker) {
|
||||||
onStickerSelect?.(emojiInfo.data, emojiInfo.shortcode, emojiInfo.label);
|
if (wasTouch() && pendingSticker?.data !== emojiInfo.data) {
|
||||||
setRecentStickers((prev) =>
|
setPendingSticker(emojiInfo);
|
||||||
addRecentSticker(prev, {
|
return;
|
||||||
url: emojiInfo.data,
|
}
|
||||||
shortcode: emojiInfo.shortcode,
|
sendSticker(emojiInfo, !evt.altKey && !evt.shiftKey);
|
||||||
body: emojiInfo.label,
|
return;
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (!evt.altKey && !evt.shiftKey) requestClose();
|
if (!evt.altKey && !evt.shiftKey) requestClose();
|
||||||
};
|
};
|
||||||
@@ -670,7 +687,18 @@ export function EmojiBoard({
|
|||||||
{tab === EmojiBoardTab.Sticker && groups.length === 0 && <NoStickerPacks />}
|
{tab === EmojiBoardTab.Sticker && groups.length === 0 && <NoStickerPacks />}
|
||||||
</EmojiGroupHolder>
|
</EmojiGroupHolder>
|
||||||
</Box>
|
</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>
|
</EmojiBoardLayout>
|
||||||
</FocusTrap>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1339,12 +1339,20 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
{(gifOpen: boolean, setGifOpen) => (
|
{(gifOpen: boolean, setGifOpen) => (
|
||||||
<PopOut
|
<PopOut
|
||||||
offset={16}
|
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"
|
position="Top"
|
||||||
align="End"
|
align="End"
|
||||||
anchor={
|
anchor={
|
||||||
gifOpen
|
gifOpen
|
||||||
? (gifBtnRef.current?.getBoundingClientRect() ?? undefined)
|
? ((compact
|
||||||
|
? gifBtnRef.current?.closest('#composer-more-actions')
|
||||||
|
: gifBtnRef.current
|
||||||
|
)?.getBoundingClientRect() ??
|
||||||
|
gifBtnRef.current?.getBoundingClientRect() ??
|
||||||
|
undefined)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
content={
|
content={
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import React, { MouseEventHandler, useCallback, useState } from 'react';
|
import React, {
|
||||||
|
MouseEventHandler,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Modal,
|
Modal,
|
||||||
@@ -23,6 +30,7 @@ import * as css from './styles.css';
|
|||||||
import { ReactionViewer } from '../reaction-viewer';
|
import { ReactionViewer } from '../reaction-viewer';
|
||||||
import { stopPropagation } from '../../../utils/keyboard';
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
|
import { expandedReactionMessages, fitReactionRow } from '../../../utils/reactionOverflow';
|
||||||
|
|
||||||
export type ReactionsProps = {
|
export type ReactionsProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -42,6 +50,49 @@ export const Reactions = as<'div', ReactionsProps>(
|
|||||||
useCallback((rel) => [...(rel.getSortedAnnotationsByKey() ?? [])], []),
|
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) => {
|
const handleViewReaction: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
evt.stopPropagation();
|
evt.stopPropagation();
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
@@ -56,44 +107,99 @@ export const Reactions = as<'div', ReactionsProps>(
|
|||||||
gap="200"
|
gap="200"
|
||||||
wrap="Wrap"
|
wrap="Wrap"
|
||||||
{...props}
|
{...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);
|
const rEvents = Array.from(events);
|
||||||
if (rEvents.length === 0 || typeof key !== 'string') return null;
|
if (rEvents.length === 0 || typeof key !== 'string') return null;
|
||||||
const myREvent = myUserId ? rEvents.find(factoryEventSentBy(myUserId)) : undefined;
|
const myREvent = myUserId ? rEvents.find(factoryEventSentBy(myUserId)) : undefined;
|
||||||
const isPressed = !!myREvent?.getRelation();
|
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 (
|
return (
|
||||||
<TooltipProvider
|
<React.Fragment key={key}>
|
||||||
key={key}
|
<TooltipProvider
|
||||||
position="Top"
|
position="Top"
|
||||||
tooltip={
|
tooltip={
|
||||||
<Tooltip style={{ maxWidth: toRem(200) }}>
|
<Tooltip style={{ maxWidth: toRem(200) }}>
|
||||||
<Text className={css.ReactionsTooltipText} size="T300">
|
<Text className={css.ReactionsTooltipText} size="T300">
|
||||||
<ReactionTooltipMsg room={room} reaction={key} events={rEvents} />
|
<ReactionTooltipMsg room={room} reaction={key} events={rEvents} />
|
||||||
</Text>
|
</Text>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(targetRef) => (
|
{(targetRef) => chip(targetRef)}
|
||||||
<Reaction
|
</TooltipProvider>
|
||||||
ref={targetRef}
|
{moreChip}
|
||||||
data-reaction-key={key}
|
</React.Fragment>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{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 && (
|
{reactions.length > 0 && (
|
||||||
<Overlay
|
<Overlay
|
||||||
onContextMenu={(evt: any) => {
|
onContextMenu={(evt: any) => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
import { DefaultReset, config, toRem } from 'folds';
|
import { DefaultReset, FocusOutline, color, config, toRem } from 'folds';
|
||||||
|
|
||||||
export const MessageBase = style({
|
export const MessageBase = style({
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
@@ -45,6 +45,7 @@ export const MessageMenuItemText = style({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const ReactionsContainer = style({
|
export const ReactionsContainer = style({
|
||||||
|
position: 'relative',
|
||||||
selectors: {
|
selectors: {
|
||||||
'&:empty': {
|
'&:empty': {
|
||||||
display: 'none',
|
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({
|
export const ReactionsTooltipText = style({
|
||||||
wordBreak: 'break-word',
|
wordBreak: 'break-word',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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