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
@@ -21,6 +21,7 @@ import { ExportRoomHistory } from './ExportRoomHistory';
import { RoomActivityLog } from './RoomActivityLog';
import { RoomServerACL } from './RoomServerACL';
import { RoomInsights } from './RoomInsights';
import { PolicyListViewer } from './PolicyListViewer';
import { usePowerLevels, readPowerLevel } from '../../hooks/usePowerLevels';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { StateEvent } from '../../../types/matrix/room';
@@ -80,11 +81,22 @@ const SERVER_ACL_MENU_ITEM: RoomSettingsMenuItem = {
icon: Icons.Shield,
};
function useRoomSettingsMenuItems(canSeeServerACL: boolean): RoomSettingsMenuItem[] {
return useMemo(
() => (canSeeServerACL ? [...BASE_MENU_ITEMS, SERVER_ACL_MENU_ITEM] : BASE_MENU_ITEMS),
[canSeeServerACL],
);
const POLICY_LISTS_MENU_ITEM: RoomSettingsMenuItem = {
page: RoomSettingsPage.PolicyListsPage,
name: 'Policy Lists',
icon: Icons.NoEntry,
};
function useRoomSettingsMenuItems(
canSeeServerACL: boolean,
canSeePolicyLists: boolean,
): RoomSettingsMenuItem[] {
return useMemo(() => {
const items = [...BASE_MENU_ITEMS];
if (canSeeServerACL) items.push(SERVER_ACL_MENU_ITEM);
if (canSeePolicyLists) items.push(POLICY_LISTS_MENU_ITEM);
return items;
}, [canSeeServerACL, canSeePolicyLists]);
}
type RoomSettingsProps = {
@@ -116,13 +128,15 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) {
const requiredPL = readPowerLevel.state(powerLevels, StateEvent.RoomServerAcl);
// Show the menu item if user meets the power level OR is a room creator.
const canSeeServerACL = myPL >= requiredPL || creators.has(myUserId);
// Show Policy Lists to admins (power level 50+) or creators.
const canSeePolicyLists = myPL >= 50 || creators.has(myUserId);
const screenSize = useScreenSizeContext();
const [activePage, setActivePage] = useState<RoomSettingsPage | undefined>(() => {
if (initialPage) return initialPage;
return screenSize === ScreenSize.Mobile ? undefined : RoomSettingsPage.GeneralPage;
});
const menuItems = useRoomSettingsMenuItems(canSeeServerACL);
const menuItems = useRoomSettingsMenuItems(canSeeServerACL, canSeePolicyLists);
const handlePageRequestClose = () => {
if (screenSize === ScreenSize.Mobile) {
@@ -227,6 +241,9 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) {
{activePage === RoomSettingsPage.InsightsPage && (
<RoomInsights requestClose={handlePageRequestClose} />
)}
{activePage === RoomSettingsPage.PolicyListsPage && (
<PolicyListViewer requestClose={handlePageRequestClose} />
)}
</PageRoot>
);
}