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
@@ -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>
);
}
+2
View File
@@ -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}
/>
);
}
+28 -1
View File
@@ -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>
);
}