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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user