feat: GIF previews, room context menu, policy lists, mention pulse, collapsible messages, send animation, D&D fix
CI / Build & Quality Checks (push) Successful in 10m21s

P3-5: Giphy/Tenor URL preview cards — full-width thumbnail from og:image
mxc URL, GIF badge overlay, site badge + title footer; GifCard shared by
both; BadgeGiphy (teal) and BadgeTenor (blue) CSS classes

P3-9: Policy list viewer — read-only panel in Room Settings + Space
Settings (admin/50+ PL only); enter room ID or alias; tabs for Users /
Rooms / Servers; glob pattern warning color; Ban badge; entity + reason

P5-8: Mention highlight pulse — 0.6s scale+glow keyframe on incoming
@mention messages; prefers-reduced-motion aware; only fires on new
incoming messages (isNewRef), not on history load; onAnimationEnd cleanup

P5-19: Collapsible long messages — ResizeObserver clamps text bodies
>320px with gradient fade + "Read more ↓" / "Show less ↑" button; resets
on eventId change; skips images/video/audio/file; smooth CSS transition

P5-23: Message send animation — own messages fade+scale in (0.97→1,
0.4→1 opacity, 150ms ease-out); prefers-reduced-motion aware; one-shot
via isNewRef + onAnimationEnd clear

P5-26: Room context menu — Copy Link (matrix.to URL, 1.5s Copied!
feedback), Mute with duration (15m/1h/8h/24h/indefinite, localStorage
timer key io.lotus.mute_timers), Mark as read; Icons.Link + Icons.BellMute

BUG D&D: dragCounter ref replaces fragile dragState machine — enter
increments, leave decrements (hides at 0), drop resets to 0; fixes
spurious dragleave from child element boundary crossings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 15:51:18 -04:00
parent 5de7f3523c
commit 94fa0d96c2
16 changed files with 979 additions and 88 deletions
@@ -39,6 +39,8 @@ type CardVariant =
| 'npm'
| 'stackoverflow'
| 'imdb'
| 'giphy'
| 'tenor'
| 'generic';
function getYouTubeVideoId(url: string): string | null {
@@ -300,6 +302,34 @@ function isImdb(url: string): boolean {
}
}
function isGiphy(url: string): boolean {
try {
const { hostname, pathname } = new URL(url);
const h = hostname.replace(/^www\./, '');
if (h === 'gph.is') return true;
if (h === 'giphy.com' || h === 'media.giphy.com') {
return (
pathname.startsWith('/gifs/') ||
pathname.startsWith('/clips/') ||
pathname.startsWith('/media/')
);
}
return false;
} catch {
return false;
}
}
function isTenor(url: string): boolean {
try {
const { hostname, pathname } = new URL(url);
const h = hostname.replace(/^www\./, '');
return h === 'tenor.com' && pathname.startsWith('/view/');
} catch {
return false;
}
}
function getCardVariant(url: string): CardVariant {
// Shorts must be detected before generic youtube
if (isYouTubeShorts(url)) return 'youtube-shorts';
@@ -317,6 +347,8 @@ function getCardVariant(url: string): CardVariant {
if (isNpm(url)) return 'npm';
if (isStackOverflow(url)) return 'stackoverflow';
if (isImdb(url)) return 'imdb';
if (isGiphy(url)) return 'giphy';
if (isTenor(url)) return 'tenor';
return 'generic';
}
@@ -1518,6 +1550,87 @@ function ImdbCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
);
}
// ---------------------------------------------------------------------------
// Card: GIF (Giphy / Tenor)
// ---------------------------------------------------------------------------
function GifCard({
url,
prev,
mx,
useAuthentication,
siteBadgeLabel,
siteBadgeClass,
}: {
url: string;
prev: IPreviewUrlResponse;
mx: ReturnType<typeof useMatrixClient>;
useAuthentication: boolean;
siteBadgeLabel: string;
siteBadgeClass: string;
}) {
const title = (prev['og:title'] as string | undefined) ?? '';
const mxcImage = prev['og:image'] as string | undefined;
const thumbSrc = mxcImage
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 400, 200, 'scale', false)
: null;
// If there's no image, fall back to a generic-style layout
if (!thumbSrc) {
return (
<>
<UrlPreviewContent>
<Text
style={linkStyles}
truncate
as="a"
href={url}
target="_blank"
rel="noreferrer"
size="T200"
priority="300"
>
<SiteBadge label={siteBadgeLabel} colorClass={siteBadgeClass} />
</Text>
{title && (
<Text truncate priority="400">
<b>{title}</b>
</Text>
)}
</UrlPreviewContent>
</>
);
}
return (
<Box direction="Column" style={{ width: '100%' }}>
{/* GIF thumbnail — full width */}
<a
href={url}
target="_blank"
rel="noreferrer"
className={previewCss.GifThumbnailWrapper}
aria-label={`View GIF on ${siteBadgeLabel}: ${title}`}
>
<img className={previewCss.GifThumbnailImg} src={thumbSrc} alt={title} loading="lazy" />
<span className={previewCss.GifBadge}>GIF</span>
</a>
{/* Footer row */}
<UrlPreviewContent>
<Box alignItems="Center" gap="100" wrap="Wrap">
<SiteBadge label={siteBadgeLabel} colorClass={siteBadgeClass} />
{title && (
<Text truncate priority="400" style={{ fontWeight: 600 }}>
{title}
</Text>
)}
</Box>
</UrlPreviewContent>
</Box>
);
}
function GenericCard({
url,
prev,
@@ -1656,6 +1769,28 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
return <StackOverflowCard url={url} prev={prev} />;
case 'imdb':
return <ImdbCard url={url} prev={prev} />;
case 'giphy':
return (
<GifCard
url={url}
prev={prev}
mx={mx}
useAuthentication={useAuthentication}
siteBadgeLabel="Giphy"
siteBadgeClass={previewCss.BadgeGiphy}
/>
);
case 'tenor':
return (
<GifCard
url={url}
prev={prev}
mx={mx}
useAuthentication={useAuthentication}
siteBadgeLabel="Tenor"
siteBadgeClass={previewCss.BadgeTenor}
/>
);
default: {
// Generic fallback — skip empty cards
if (!prev['og:title'] && !prev['og:description']) return null;