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:
@@ -46,6 +46,7 @@ type RenderMessageContentProps = {
|
||||
htmlReactParserOptions: HTMLReactParserOptions;
|
||||
linkifyOpts: Opts;
|
||||
outlineAttachment?: boolean;
|
||||
eventId?: string;
|
||||
};
|
||||
export function RenderMessageContent({
|
||||
displayName,
|
||||
@@ -60,6 +61,7 @@ export function RenderMessageContent({
|
||||
htmlReactParserOptions,
|
||||
linkifyOpts,
|
||||
outlineAttachment,
|
||||
eventId,
|
||||
}: RenderMessageContentProps) {
|
||||
const renderUrlsPreview = (urls: string[]) => {
|
||||
const filteredUrls = urls.filter((url) => !testMatrixTo(url));
|
||||
@@ -147,6 +149,7 @@ export function RenderMessageContent({
|
||||
/>
|
||||
)}
|
||||
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
|
||||
eventId={eventId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -167,6 +170,7 @@ export function RenderMessageContent({
|
||||
/>
|
||||
)}
|
||||
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
|
||||
eventId={eventId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -186,6 +190,7 @@ export function RenderMessageContent({
|
||||
/>
|
||||
)}
|
||||
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
|
||||
eventId={eventId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -675,3 +675,68 @@ export const PortraitSideLayout = style([
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GIF card (Giphy / Tenor)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const GifThumbnailWrapper = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
maxHeight: toRem(200),
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
backgroundColor: color.Surface.Container,
|
||||
cursor: 'pointer',
|
||||
|
||||
':hover': {
|
||||
filter: 'brightness(0.9)',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export const GifThumbnailImg = style([
|
||||
DefaultReset,
|
||||
{
|
||||
width: '100%',
|
||||
maxHeight: toRem(200),
|
||||
objectFit: 'cover',
|
||||
objectPosition: 'center',
|
||||
display: 'block',
|
||||
},
|
||||
]);
|
||||
|
||||
export const GifBadge = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'absolute',
|
||||
top: config.space.S100,
|
||||
right: config.space.S100,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
paddingLeft: config.space.S100,
|
||||
paddingRight: config.space.S100,
|
||||
paddingTop: '2px',
|
||||
paddingBottom: '2px',
|
||||
borderRadius: config.radii.R300,
|
||||
fontSize: toRem(11),
|
||||
fontWeight: 700,
|
||||
lineHeight: '1.4',
|
||||
letterSpacing: '0.05em',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.65)',
|
||||
color: '#ffffff',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
]);
|
||||
|
||||
export const BadgeGiphy = style({
|
||||
backgroundColor: '#00c0c0',
|
||||
color: '#ffffff',
|
||||
});
|
||||
|
||||
export const BadgeTenor = style({
|
||||
backgroundColor: '#0078d4',
|
||||
color: '#ffffff',
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user