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
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { Badge, Box, Button, Icon, IconButton, Icons, Scroll, Text, color, config } from 'folds';
|
||||
import { EventTimeline, MatrixEvent, Room } from 'matrix-js-sdk';
|
||||
import { Page, PageContent, PageHeader } from '../../components/page';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { SequenceCard } from '../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../common-settings/styles.css';
|
||||
|
||||
// ── Policy event types ────────────────────────────────────────────────────────
|
||||
|
||||
const POLICY_USER_EVENT = 'm.policy.rule.user';
|
||||
const POLICY_ROOM_EVENT = 'm.policy.rule.room';
|
||||
const POLICY_SERVER_EVENT = 'm.policy.rule.server';
|
||||
|
||||
type PolicyRuleContent = {
|
||||
entity?: string;
|
||||
reason?: string;
|
||||
recommendation?: string;
|
||||
};
|
||||
|
||||
type PolicyEntry = {
|
||||
entity: string;
|
||||
reason: string;
|
||||
recommendation: string;
|
||||
stateKey: string;
|
||||
};
|
||||
|
||||
type PolicyTab = 'users' | 'rooms' | 'servers';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function isGlob(entity: string): boolean {
|
||||
return entity.includes('*') || entity.includes('?');
|
||||
}
|
||||
|
||||
function recommendationLabel(rec: string): string {
|
||||
if (rec === 'm.ban') return 'Ban';
|
||||
return rec;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all state events of a given type from a room's live forward state.
|
||||
* Uses the raw matrix-js-sdk `getStateEvents` API which accepts any string type.
|
||||
*/
|
||||
function getRoomPolicyEvents(room: Room, eventType: string): MatrixEvent[] {
|
||||
const state = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
|
||||
if (!state) return [];
|
||||
return state.getStateEvents(eventType);
|
||||
}
|
||||
|
||||
function extractPolicyEntries(events: MatrixEvent[]): PolicyEntry[] {
|
||||
return events
|
||||
.map((e) => {
|
||||
const content = e.getContent<PolicyRuleContent>();
|
||||
return {
|
||||
entity: content.entity ?? '',
|
||||
reason: content.reason ?? '',
|
||||
recommendation: content.recommendation ?? '',
|
||||
stateKey: e.getStateKey() ?? '',
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry.entity !== '');
|
||||
}
|
||||
|
||||
// ── Entry row ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function PolicyEntryRow({ entry }: { entry: PolicyEntry }) {
|
||||
const glob = isGlob(entry.entity);
|
||||
return (
|
||||
<Box
|
||||
direction="Column"
|
||||
gap="100"
|
||||
style={{
|
||||
padding: `${config.space.S200} ${config.space.S300}`,
|
||||
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
|
||||
}}
|
||||
>
|
||||
<Box gap="200" alignItems="Center" wrap="Wrap">
|
||||
<Text
|
||||
size="T300"
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
color: glob ? color.Warning.Main : undefined,
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{entry.entity}
|
||||
</Text>
|
||||
{glob && (
|
||||
<Badge variant="Warning" fill="Soft" radii="Pill">
|
||||
<Text size="T200">glob</Text>
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="Critical" fill="Soft" radii="Pill">
|
||||
<Text size="T200">{recommendationLabel(entry.recommendation)}</Text>
|
||||
</Badge>
|
||||
</Box>
|
||||
{entry.reason && (
|
||||
<Text size="T200" priority="300" style={{ wordBreak: 'break-word' }}>
|
||||
{entry.reason}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tab button ────────────────────────────────────────────────────────────────
|
||||
|
||||
function TabButton({
|
||||
label,
|
||||
count,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
count: number;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
onClick={onClick}
|
||||
variant={active ? 'Primary' : 'Secondary'}
|
||||
fill={active ? 'Solid' : 'Soft'}
|
||||
size="300"
|
||||
radii="300"
|
||||
>
|
||||
<Text size="B300">
|
||||
{label} ({count})
|
||||
</Text>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
type PolicyListViewerProps = {
|
||||
requestClose: () => void;
|
||||
};
|
||||
|
||||
export function PolicyListViewer({ requestClose }: PolicyListViewerProps) {
|
||||
const mx = useMatrixClient();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [roomIdInput, setRoomIdInput] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<PolicyTab>('users');
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
|
||||
const [userEntries, setUserEntries] = useState<PolicyEntry[]>([]);
|
||||
const [roomEntries, setRoomEntries] = useState<PolicyEntry[]>([]);
|
||||
const [serverEntries, setServerEntries] = useState<PolicyEntry[]>([]);
|
||||
const [loadedRoomId, setLoadedRoomId] = useState<string | undefined>();
|
||||
|
||||
const handleLoad = useCallback(() => {
|
||||
const rawInput = (inputRef.current?.value ?? roomIdInput).trim();
|
||||
if (!rawInput) {
|
||||
setError('Please enter a room ID or alias.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve alias to room ID using local cache
|
||||
let roomId = rawInput;
|
||||
if (rawInput.startsWith('#')) {
|
||||
const cachedId = mx.getRooms().find((r) => {
|
||||
const aliases = r.getAltAliases();
|
||||
const canonical = r.getCanonicalAlias();
|
||||
return aliases.includes(rawInput) || canonical === rawInput;
|
||||
})?.roomId;
|
||||
|
||||
if (cachedId) {
|
||||
roomId = cachedId;
|
||||
} else {
|
||||
setError(`Cannot resolve alias "${rawInput}". Make sure you have joined that room.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) {
|
||||
setError(`Not joined to room "${roomId}". Join the policy list room first.`);
|
||||
setUserEntries([]);
|
||||
setRoomEntries([]);
|
||||
setServerEntries([]);
|
||||
setLoadedRoomId(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
setUserEntries(extractPolicyEntries(getRoomPolicyEvents(room, POLICY_USER_EVENT)));
|
||||
setRoomEntries(extractPolicyEntries(getRoomPolicyEvents(room, POLICY_ROOM_EVENT)));
|
||||
setServerEntries(extractPolicyEntries(getRoomPolicyEvents(room, POLICY_SERVER_EVENT)));
|
||||
setLoadedRoomId(roomId);
|
||||
setError(undefined);
|
||||
}, [mx, roomIdInput]);
|
||||
|
||||
const activeEntries =
|
||||
activeTab === 'users' ? userEntries : activeTab === 'rooms' ? roomEntries : serverEntries;
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader outlined={false}>
|
||||
<Box grow="Yes" gap="200">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Text as="h2" size="H3" truncate>
|
||||
Policy Lists
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</PageHeader>
|
||||
<Box grow="Yes">
|
||||
<Scroll hideTrack visibility="Hover">
|
||||
<PageContent>
|
||||
<Box direction="Column" gap="700">
|
||||
{/* Description */}
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">About</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="200"
|
||||
>
|
||||
<Text size="T300">
|
||||
Policy lists are Matrix rooms containing ban rules managed by moderation bots
|
||||
(e.g. Draupnir). Enter a policy list room ID below to inspect its current rules.
|
||||
This is a read-only viewer — rule enforcement is handled by your moderation bot.
|
||||
</Text>
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
|
||||
{/* Room ID input */}
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Policy List Room</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="300"
|
||||
>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={roomIdInput}
|
||||
onChange={(e) => setRoomIdInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleLoad();
|
||||
}}
|
||||
placeholder="!roomId:server or #alias:server"
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
padding: `${config.space.S200} ${config.space.S300}`,
|
||||
borderRadius: config.radii.R300,
|
||||
border: `1px solid ${error ? color.Critical.Main : color.Surface.ContainerLine}`,
|
||||
background: color.Surface.Container,
|
||||
color: color.Surface.OnContainer,
|
||||
fontSize: 'inherit',
|
||||
fontFamily: 'inherit',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleLoad}
|
||||
variant="Primary"
|
||||
fill="Solid"
|
||||
size="300"
|
||||
radii="300"
|
||||
before={<Icon src={Icons.Search} size="100" />}
|
||||
>
|
||||
<Text size="B300">Load</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
{error && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
{loadedRoomId && (
|
||||
<Text size="T200" priority="300">
|
||||
Showing rules from:{' '}
|
||||
<span style={{ fontFamily: 'monospace' }}>{loadedRoomId}</span>
|
||||
</Text>
|
||||
)}
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
|
||||
{/* Rules viewer */}
|
||||
{loadedRoomId && (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Rules</Text>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="300"
|
||||
>
|
||||
{/* Tabs */}
|
||||
<Box gap="200">
|
||||
<TabButton
|
||||
label="Users"
|
||||
count={userEntries.length}
|
||||
active={activeTab === 'users'}
|
||||
onClick={() => setActiveTab('users')}
|
||||
/>
|
||||
<TabButton
|
||||
label="Rooms"
|
||||
count={roomEntries.length}
|
||||
active={activeTab === 'rooms'}
|
||||
onClick={() => setActiveTab('rooms')}
|
||||
/>
|
||||
<TabButton
|
||||
label="Servers"
|
||||
count={serverEntries.length}
|
||||
active={activeTab === 'servers'}
|
||||
onClick={() => setActiveTab('servers')}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Entry list */}
|
||||
<Box
|
||||
direction="Column"
|
||||
style={{
|
||||
border: `1px solid ${color.Surface.ContainerLine}`,
|
||||
borderRadius: config.radii.R300,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{activeEntries.length === 0 ? (
|
||||
<Box
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
style={{ padding: config.space.S500 }}
|
||||
>
|
||||
<Text size="T300" priority="300">
|
||||
No{' '}
|
||||
{activeTab === 'users'
|
||||
? 'user'
|
||||
: activeTab === 'rooms'
|
||||
? 'room'
|
||||
: 'server'}{' '}
|
||||
ban rules found.
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
activeEntries.map((entry) => (
|
||||
<PolicyEntryRow key={entry.stateKey || entry.entity} entry={entry} />
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -316,13 +316,6 @@ export function RoomActivityLog({ requestClose }: RoomActivityLogProps) {
|
||||
setEvents(getStateEvents());
|
||||
}, [getStateEvents]);
|
||||
|
||||
// Auto-paginate on mount — state events are rarely in the initial sync
|
||||
// window, so we immediately fetch backwards to populate the log.
|
||||
useEffect(() => {
|
||||
handleLoadMore();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleLoadMore = useCallback(async () => {
|
||||
if (loading || !canLoadMore) return;
|
||||
setLoading(true);
|
||||
@@ -341,6 +334,13 @@ export function RoomActivityLog({ requestClose }: RoomActivityLogProps) {
|
||||
}
|
||||
}, [loading, canLoadMore, mx, room, getStateEvents]);
|
||||
|
||||
// Auto-paginate on mount — state events are rarely in the initial sync
|
||||
// window, so we immediately fetch backwards to populate the log.
|
||||
useEffect(() => {
|
||||
handleLoadMore();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Build described entries
|
||||
const entries: Array<{ ev: MatrixEvent; desc: EventDesc }> = [];
|
||||
for (const ev of events) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1132,6 +1132,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
||||
eventId={mEventId}
|
||||
/>
|
||||
)}
|
||||
</Message>
|
||||
@@ -1241,6 +1242,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
||||
eventId={mEventId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import React, {
|
||||
MouseEventHandler,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
@@ -58,7 +59,8 @@ import { MessageLayout, MessageSpacing } from '../../../state/settings';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
||||
import * as css from './styles.css';
|
||||
import { SendingSpinClass } from '../../../styles/Animations.css';
|
||||
import { MsgAppearClass, SendingSpinClass } from '../../../styles/Animations.css';
|
||||
import { MentionHighlightPulse } from '../../../components/message/layout/layout.css';
|
||||
import { EventReaders } from '../../../components/event-readers';
|
||||
import { ReadReceiptAvatars } from '../../../components/read-receipt-avatars';
|
||||
import { useReadPositions } from '../ReadPositionsContext';
|
||||
@@ -787,6 +789,19 @@ export const Message = React.memo(
|
||||
: (readPositions.get(mEvent.getId() ?? '') ?? []);
|
||||
const isMine = mEvent.getSender() === mx.getUserId();
|
||||
const lotusTerminal = lotusTerminalProp;
|
||||
// Track whether this message should play the appear animation (own messages only)
|
||||
const isNewRef = useRef(true);
|
||||
const [playAppear, setPlayAppear] = useState(isMine && isNewRef.current);
|
||||
// Mention pulse: play once for new incoming @mention messages from others
|
||||
const myUserId = mx.getUserId() ?? '';
|
||||
const mentionContent = mEvent.getContent<{
|
||||
'm.mentions'?: { user_ids?: string[]; room?: boolean };
|
||||
}>();
|
||||
const isMentioned =
|
||||
!isMine &&
|
||||
(mentionContent['m.mentions']?.user_ids?.includes(myUserId) === true ||
|
||||
mentionContent['m.mentions']?.room === true);
|
||||
const [playMentionPulse, setPlayMentionPulse] = useState(isMentioned && isNewRef.current);
|
||||
const [hover, setHover] = useState(false);
|
||||
const { hoverProps } = useHover({ onHoverChange: setHover });
|
||||
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
|
||||
@@ -956,12 +971,24 @@ export const Message = React.memo(
|
||||
<MessageBase
|
||||
className={classNames(css.MessageBase, className, {
|
||||
[css.MessageBaseBubbleCollapsed]: messageLayout === MessageLayout.Bubble && collapse,
|
||||
[MsgAppearClass]: playAppear,
|
||||
[MentionHighlightPulse]: playMentionPulse,
|
||||
})}
|
||||
tabIndex={0}
|
||||
space={messageSpacing}
|
||||
collapse={collapse}
|
||||
highlight={highlight}
|
||||
selected={!!menuAnchor || !!emojiBoardAnchor}
|
||||
onAnimationEnd={() => {
|
||||
if (playAppear) {
|
||||
isNewRef.current = false;
|
||||
setPlayAppear(false);
|
||||
}
|
||||
if (playMentionPulse) {
|
||||
isNewRef.current = false;
|
||||
setPlayMentionPulse(false);
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
{...hoverProps}
|
||||
{...focusWithinProps}
|
||||
|
||||
@@ -17,6 +17,9 @@ import { Members } from '../common-settings/members';
|
||||
import { DeveloperTools } from '../common-settings/developer-tools';
|
||||
import { General } from './general';
|
||||
import { Permissions } from './permissions';
|
||||
import { PolicyListViewer } from '../room-settings/PolicyListViewer';
|
||||
import { usePowerLevels, readPowerLevel } from '../../hooks/usePowerLevels';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
|
||||
type SpaceSettingsMenuItem = {
|
||||
page: SpaceSettingsPage;
|
||||
@@ -24,36 +27,47 @@ type SpaceSettingsMenuItem = {
|
||||
icon: IconSrc;
|
||||
};
|
||||
|
||||
const useSpaceSettingsMenuItems = (): SpaceSettingsMenuItem[] =>
|
||||
const BASE_SPACE_MENU_ITEMS: SpaceSettingsMenuItem[] = [
|
||||
{
|
||||
page: SpaceSettingsPage.GeneralPage,
|
||||
name: 'General',
|
||||
icon: Icons.Setting,
|
||||
},
|
||||
{
|
||||
page: SpaceSettingsPage.MembersPage,
|
||||
name: 'Members',
|
||||
icon: Icons.User,
|
||||
},
|
||||
{
|
||||
page: SpaceSettingsPage.PermissionsPage,
|
||||
name: 'Permissions',
|
||||
icon: Icons.Lock,
|
||||
},
|
||||
{
|
||||
page: SpaceSettingsPage.EmojisStickersPage,
|
||||
name: 'Emojis & Stickers',
|
||||
icon: Icons.Smile,
|
||||
},
|
||||
{
|
||||
page: SpaceSettingsPage.DeveloperToolsPage,
|
||||
name: 'Developer Tools',
|
||||
icon: Icons.Terminal,
|
||||
},
|
||||
];
|
||||
|
||||
const SPACE_POLICY_LISTS_ITEM: SpaceSettingsMenuItem = {
|
||||
page: SpaceSettingsPage.PolicyListsPage,
|
||||
name: 'Policy Lists',
|
||||
icon: Icons.NoEntry,
|
||||
};
|
||||
|
||||
const useSpaceSettingsMenuItems = (canSeePolicyLists: boolean): SpaceSettingsMenuItem[] =>
|
||||
useMemo(
|
||||
() => [
|
||||
{
|
||||
page: SpaceSettingsPage.GeneralPage,
|
||||
name: 'General',
|
||||
icon: Icons.Setting,
|
||||
},
|
||||
{
|
||||
page: SpaceSettingsPage.MembersPage,
|
||||
name: 'Members',
|
||||
icon: Icons.User,
|
||||
},
|
||||
{
|
||||
page: SpaceSettingsPage.PermissionsPage,
|
||||
name: 'Permissions',
|
||||
icon: Icons.Lock,
|
||||
},
|
||||
{
|
||||
page: SpaceSettingsPage.EmojisStickersPage,
|
||||
name: 'Emojis & Stickers',
|
||||
icon: Icons.Smile,
|
||||
},
|
||||
{
|
||||
page: SpaceSettingsPage.DeveloperToolsPage,
|
||||
name: 'Developer Tools',
|
||||
icon: Icons.Terminal,
|
||||
},
|
||||
],
|
||||
[],
|
||||
() =>
|
||||
canSeePolicyLists
|
||||
? [...BASE_SPACE_MENU_ITEMS, SPACE_POLICY_LISTS_ITEM]
|
||||
: BASE_SPACE_MENU_ITEMS,
|
||||
[canSeePolicyLists],
|
||||
);
|
||||
|
||||
type SpaceSettingsProps = {
|
||||
@@ -74,12 +88,19 @@ export function SpaceSettings({ initialPage, requestClose }: SpaceSettingsProps)
|
||||
? (mxcUrlToHttp(mx, roomAvatar, useAuthentication, 96, 96, 'crop') ?? undefined)
|
||||
: undefined;
|
||||
|
||||
const powerLevels = usePowerLevels(room);
|
||||
const creators = useRoomCreators(room);
|
||||
const myUserId = mx.getSafeUserId();
|
||||
const myPL = readPowerLevel.user(powerLevels, 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<SpaceSettingsPage | undefined>(() => {
|
||||
if (initialPage) return initialPage;
|
||||
return screenSize === ScreenSize.Mobile ? undefined : SpaceSettingsPage.GeneralPage;
|
||||
});
|
||||
const menuItems = useSpaceSettingsMenuItems();
|
||||
const menuItems = useSpaceSettingsMenuItems(canSeePolicyLists);
|
||||
|
||||
const handlePageRequestClose = () => {
|
||||
if (screenSize === ScreenSize.Mobile) {
|
||||
@@ -172,6 +193,9 @@ export function SpaceSettings({ initialPage, requestClose }: SpaceSettingsProps)
|
||||
{activePage === SpaceSettingsPage.DeveloperToolsPage && (
|
||||
<DeveloperTools requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
{activePage === SpaceSettingsPage.PolicyListsPage && (
|
||||
<PolicyListViewer requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
</PageRoot>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user