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
+130 -1
View File
@@ -216,6 +216,30 @@ function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) {
);
}
// localStorage key for timed mute timers
const MUTE_TIMERS_KEY = 'io.lotus.mute_timers';
type MuteTimerEntry = { roomId: string; unmuteAt: number };
function loadMuteTimers(): MuteTimerEntry[] {
try {
return JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]');
} catch {
return [];
}
}
function saveMuteTimers(timers: MuteTimerEntry[]): void {
localStorage.setItem(MUTE_TIMERS_KEY, JSON.stringify(timers));
}
function scheduleMuteTimer(roomId: string, durationMs: number, onUnmute: () => void): void {
const unmuteAt = Date.now() + durationMs;
const existing = loadMuteTimers().filter((e) => e.roomId !== roomId);
saveMuteTimers([...existing, { roomId, unmuteAt }]);
setTimeout(onUnmute, durationMs);
}
type RoomNavItemMenuProps = {
room: Room;
requestClose: () => void;
@@ -236,6 +260,7 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
const space = useSpaceOptionally();
const [invitePrompt, setInvitePrompt] = useState(false);
const [copiedLink, setCopiedLink] = useState(false);
const isServerNotice = room.getType() === 'm.server_notice';
const isFavorite = !!room.tags?.['m.favourite'];
@@ -254,6 +279,41 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
requestClose();
};
const handleCopyRoomLink = () => {
const roomAlias = room.getCanonicalAlias() ?? room.roomId;
const link = `https://matrix.to/#/${encodeURIComponent(roomAlias)}`;
navigator.clipboard.writeText(link).catch(() => {});
setCopiedLink(true);
setTimeout(() => setCopiedLink(false), 1500);
};
const handleMuteFor = useCallback(
async (durationMs: number | null) => {
const { setRoomNotificationPreference } =
await import('../../hooks/useRoomsNotificationPreferences');
const prevMode = notificationMode ?? RoomNotificationMode.Unset;
await setRoomNotificationPreference(
mx,
room.roomId,
RoomNotificationMode.Mute,
prevMode,
).catch(() => {});
if (durationMs !== null) {
scheduleMuteTimer(room.roomId, durationMs, () => {
setRoomNotificationPreference(
mx,
room.roomId,
RoomNotificationMode.Unset,
RoomNotificationMode.Mute,
).catch(() => {});
saveMuteTimers(loadMuteTimers().filter((e) => e.roomId !== room.roomId));
});
}
requestClose();
},
[mx, room.roomId, notificationMode, requestClose],
);
const handleInvite = () => {
setInvitePrompt(true);
};
@@ -263,8 +323,10 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
requestClose();
};
const isMuted = notificationMode === RoomNotificationMode.Mute;
return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
<Menu ref={ref} style={{ maxWidth: toRem(180), width: '100vw' }}>
{invitePrompt && room && (
<InviteUserPrompt
room={room}
@@ -286,6 +348,16 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
Mark as Read
</Text>
</MenuItem>
<MenuItem
onClick={handleCopyRoomLink}
size="300"
after={<Icon size="100" src={Icons.Link} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{copiedLink ? 'Copied!' : 'Copy Link'}
</Text>
</MenuItem>
<RoomNotificationModeSwitcher roomId={room.roomId} value={notificationMode}>
{(handleOpen, opened, changing) => (
<MenuItem
@@ -308,6 +380,63 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
)}
</RoomNotificationModeSwitcher>
</Box>
{!isMuted && (
<>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
size="300"
after={<Icon size="100" src={Icons.BellMute} />}
radii="300"
onClick={() => handleMuteFor(15 * 60 * 1000)}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mute for 15m
</Text>
</MenuItem>
<MenuItem
size="300"
after={<Icon size="100" src={Icons.BellMute} />}
radii="300"
onClick={() => handleMuteFor(60 * 60 * 1000)}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mute for 1h
</Text>
</MenuItem>
<MenuItem
size="300"
after={<Icon size="100" src={Icons.BellMute} />}
radii="300"
onClick={() => handleMuteFor(8 * 60 * 60 * 1000)}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mute for 8h
</Text>
</MenuItem>
<MenuItem
size="300"
after={<Icon size="100" src={Icons.BellMute} />}
radii="300"
onClick={() => handleMuteFor(24 * 60 * 60 * 1000)}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mute for 24h
</Text>
</MenuItem>
<MenuItem
size="300"
after={<Icon size="100" src={Icons.BellMute} />}
radii="300"
onClick={() => handleMuteFor(null)}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Mute indefinitely
</Text>
</MenuItem>
</Box>
</>
)}
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem