feat: GIF previews, room context menu, policy lists, mention pulse, collapsible messages, send animation, D&D fix

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 fbdd0e7083
commit 657ca3a5ca
16 changed files with 979 additions and 88 deletions
+125 -36
View File
@@ -1,5 +1,5 @@
import React, { CSSProperties, ReactNode } from 'react';
import { Box, Chip, Icon, Icons, Text, toRem } from 'folds';
import React, { CSSProperties, ReactNode, useEffect, useRef, useState } from 'react';
import { Box, Button, Chip, Icon, Icons, Text, color, toRem } from 'folds';
import { IContent } from 'matrix-js-sdk';
import { JUMBO_EMOJI_REG, URL_REG } from '../../utils/regex';
import { trimReplyFromBody } from '../../utils/room';
@@ -31,6 +31,83 @@ import { parseGeoUri, scaleYDimension } from '../../utils/common';
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
import { FileHeader, FileDownloadButton } from './FileHeader';
const COLLAPSE_MAX_HEIGHT = 320; // px ≈ 20 lines
type CollapsibleBodyProps = {
eventId?: string;
children: ReactNode;
};
function CollapsibleBody({ eventId, children }: CollapsibleBodyProps) {
const bodyRef = useRef<HTMLDivElement>(null);
const [needsCollapse, setNeedsCollapse] = useState(false);
const [collapsed, setCollapsed] = useState(true);
// Reset collapsed state when the event changes (new message)
useEffect(() => {
setCollapsed(true);
setNeedsCollapse(false);
}, [eventId]);
useEffect(() => {
const el = bodyRef.current;
if (!el) return undefined;
const observer = new ResizeObserver(() => {
setNeedsCollapse(el.scrollHeight > COLLAPSE_MAX_HEIGHT);
});
observer.observe(el);
return () => observer.disconnect();
}, []);
const prefersReducedMotion =
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
return (
<div>
<div
ref={bodyRef}
style={{
position: 'relative',
...(needsCollapse && collapsed
? {
maxHeight: `${COLLAPSE_MAX_HEIGHT}px`,
overflow: 'hidden',
transition: prefersReducedMotion ? undefined : 'max-height 0.2s ease',
}
: {
transition: prefersReducedMotion ? undefined : 'max-height 0.2s ease',
}),
}}
>
{children}
{needsCollapse && collapsed && (
<div
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: '3rem',
background: `linear-gradient(transparent, ${color.Surface.Container})`,
pointerEvents: 'none',
}}
/>
)}
</div>
{needsCollapse && (
<Button
size="300"
variant="Secondary"
fill="None"
style={{ marginTop: '4px' }}
onClick={() => setCollapsed((c) => !c)}
>
<Text size="B300">{collapsed ? 'Read more ↓' : 'Show less ↑'}</Text>
</Button>
)}
</div>
);
}
export function MBadEncrypted() {
return (
<Text>
@@ -85,6 +162,7 @@ type MTextProps = {
renderBody: (props: RenderBodyProps) => ReactNode;
renderUrlsPreview?: (urls: string[]) => ReactNode;
style?: CSSProperties;
eventId?: string;
};
export function MText({
edited,
@@ -93,6 +171,7 @@ export function MText({
renderBody,
renderUrlsPreview,
style,
eventId,
}: MTextProps) {
const { body, formatted_body: customBody } = content;
@@ -103,17 +182,19 @@ export function MText({
return (
<>
<MessageTextBody
preWrap={typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
style={style}
>
{renderBody({
body: trimmedBody,
customBody: typeof customBody === 'string' ? customBody : undefined,
})}
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
</MessageTextBody>
<CollapsibleBody eventId={eventId}>
<MessageTextBody
preWrap={typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
style={style}
>
{renderBody({
body: trimmedBody,
customBody: typeof customBody === 'string' ? customBody : undefined,
})}
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
</MessageTextBody>
</CollapsibleBody>
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
</>
);
@@ -126,6 +207,7 @@ type MEmoteProps = {
content: Record<string, unknown>;
renderBody: (props: RenderBodyProps) => ReactNode;
renderUrlsPreview?: (urls: string[]) => ReactNode;
eventId?: string;
};
export function MEmote({
displayName,
@@ -134,6 +216,7 @@ export function MEmote({
content,
renderBody,
renderUrlsPreview,
eventId,
}: MEmoteProps) {
const { body, formatted_body: customBody } = content;
@@ -144,18 +227,20 @@ export function MEmote({
return (
<>
<MessageTextBody
emote
preWrap={typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
>
<b>{`${displayName} `}</b>
{renderBody({
body: trimmedBody,
customBody: typeof customBody === 'string' ? customBody : undefined,
})}
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
</MessageTextBody>
<CollapsibleBody eventId={eventId}>
<MessageTextBody
emote
preWrap={typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
>
<b>{`${displayName} `}</b>
{renderBody({
body: trimmedBody,
customBody: typeof customBody === 'string' ? customBody : undefined,
})}
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
</MessageTextBody>
</CollapsibleBody>
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
</>
);
@@ -167,6 +252,7 @@ type MNoticeProps = {
content: Record<string, unknown>;
renderBody: (props: RenderBodyProps) => ReactNode;
renderUrlsPreview?: (urls: string[]) => ReactNode;
eventId?: string;
};
export function MNotice({
edited,
@@ -174,6 +260,7 @@ export function MNotice({
content,
renderBody,
renderUrlsPreview,
eventId,
}: MNoticeProps) {
const { body, formatted_body: customBody } = content;
@@ -184,17 +271,19 @@ export function MNotice({
return (
<>
<MessageTextBody
notice
preWrap={typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
>
{renderBody({
body: trimmedBody,
customBody: typeof customBody === 'string' ? customBody : undefined,
})}
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
</MessageTextBody>
<CollapsibleBody eventId={eventId}>
<MessageTextBody
notice
preWrap={typeof customBody !== 'string'}
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
>
{renderBody({
body: trimmedBody,
customBody: typeof customBody === 'string' ? customBody : undefined,
})}
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
</MessageTextBody>
</CollapsibleBody>
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
</>
);
@@ -108,6 +108,26 @@ export const MessageBase = recipe({
export type MessageBaseVariants = RecipeVariants<typeof MessageBase>;
// ── Mention pulse animation ───────────────────────────────────────────────────
const mentionPulseKeyframes = keyframes({
'0%': { transform: 'scale(1)', boxShadow: 'none' },
'30%': { transform: 'scale(1.003)', boxShadow: `0 0 8px ${color.Warning.Main}` },
'100%': { transform: 'scale(1)', boxShadow: 'none' },
});
/**
* Applied only to new incoming @mention messages.
* Respects `prefers-reduced-motion`: no animation when motion is reduced.
*/
export const MentionHighlightPulse = style({
'@media': {
'(prefers-reduced-motion: no-preference)': {
animation: `${mentionPulseKeyframes} 0.6s ease-out`,
},
},
});
export const CompactHeader = style([
DefaultReset,
StickySection,