feat: enhance update notification layout and add room info display in developer tools

This commit is contained in:
2026-02-24 21:49:03 +11:00
parent 495747af84
commit 58966aec19
5 changed files with 359 additions and 51 deletions

View File

@@ -616,8 +616,10 @@ export function TitleBar({ mx, children }: TitleBarProps) {
))}
{children}
</Box>
<UpdateNotification />
<WindowControls />
<Box alignItems="Center" gap="100" style={{ height: '100%', flexShrink: 0 }}>
<UpdateNotification />
<WindowControls />
</Box>
</Box>
);
}

View File

@@ -6,15 +6,17 @@ export const CheckButton = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: config.space.S200,
padding: 0,
borderRadius: config.radii.R300,
cursor: 'pointer',
color: color.Secondary.Main,
backgroundColor: 'transparent',
transition: 'background-color 0.15s',
minWidth: toRem(32),
minHeight: toRem(32),
height: '32px',
width: '32px',
opacity: 0.7,
WebkitAppRegion: 'no-drag',
flexShrink: 0,
':hover': {
backgroundColor: color.Surface.ContainerHover,
@@ -36,14 +38,16 @@ export const UpdateButton = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: config.space.S200,
padding: 0,
borderRadius: config.radii.R300,
cursor: 'pointer',
color: color.Success.Main,
backgroundColor: 'transparent',
transition: 'background-color 0.15s',
minWidth: toRem(32),
minHeight: toRem(32),
height: '32px',
width: '32px',
WebkitAppRegion: 'no-drag',
flexShrink: 0,
':hover': {
backgroundColor: color.Surface.ContainerHover,

View File

@@ -118,42 +118,38 @@ export function UpdateNotification() {
// Show check button if no update status
if (!updateAvailable && !updateReady && !checking) {
return (
<Box gap="100" alignItems="Center">
<button
className={css.CheckButton}
onClick={handleCheckForUpdates}
aria-label="Check for updates"
title="Check for updates"
type="button"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M8 2V10M8 10L5 7M8 10L11 7"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3 14H13"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
</Box>
<button
className={css.CheckButton}
onClick={handleCheckForUpdates}
aria-label="Check for updates"
title="Check for updates"
type="button"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M8 2V10M8 10L5 7M8 10L11 7"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3 14H13"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
);
}
// Show checking state
if (checking) {
return (
<Box gap="100" alignItems="Center">
<button className={css.UpdateButton} disabled type="button">
<Spinner variant="Secondary" size="50" />
</button>
</Box>
<button className={css.UpdateButton} disabled type="button">
<Spinner variant="Secondary" size="50" />
</button>
);
}
@@ -163,7 +159,7 @@ export function UpdateNotification() {
}
return (
<Box gap="100" alignItems="Center">
<>
<button
className={css.UpdateButton}
onClick={handleMenuToggle}
@@ -172,14 +168,7 @@ export function UpdateNotification() {
type="button"
>
{downloading ? (
<Box alignItems="Center" gap="100">
<Spinner variant="Secondary" size="50" />
{downloadProgress > 0 && (
<Text size="T200" className={css.ProgressText}>
{downloadProgress}%
</Text>
)}
</Box>
<Spinner variant="Secondary" size="50" />
) : (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
@@ -246,6 +235,6 @@ export function UpdateNotification() {
>
{null}
</PopOut>
</Box>
</>
);
}

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useState } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import {
Box,
Text,
@@ -11,7 +11,14 @@ import {
MenuItem,
config,
color,
TextArea,
Dialog,
Overlay,
OverlayBackdrop,
OverlayCenter,
Modal,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
@@ -30,6 +37,10 @@ import {
AccountDataSubmitCallback,
} from '../../../components/AccountDataEditor';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { stopPropagation } from '../../../utils/keyboard';
import { usePowerLevels } from '../../../hooks/usePowerLevels';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { isSpace } from '../../../utils/room';
type DeveloperToolsProps = {
requestClose: () => void;
@@ -41,6 +52,8 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
const roomState = useRoomState(room);
const accountData = useRoomAccountData(room);
const powerLevels = usePowerLevels(room);
const creators = useRoomCreators(room);
const [expandState, setExpandState] = useState(false);
const [expandStateType, setExpandStateType] = useState<string>();
@@ -49,6 +62,72 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
const [expandAccountData, setExpandAccountData] = useState(false);
const [accountDataType, setAccountDataType] = useState<string | null>();
const [showRoomInfo, setShowRoomInfo] = useState(false);
const roomInfoJson = useMemo(() => {
const liveTimeline = room.getLiveTimeline();
const liveEvents = liveTimeline.getEvents();
const oldestEvent = liveEvents.length > 0 ? liveEvents[0] : null;
const newestEvent = liveEvents.length > 0 ? liveEvents[liveEvents.length - 1] : null;
// Get power level tags
const powerLevelTagsEvent = room.currentState.getStateEvents('in.cinny.room.power_level_tags', '');
const powerLevelTags = powerLevelTagsEvent?.getContent();
const info: any = {
roomId: room.roomId,
name: room.name,
canonicalAlias: room.canonicalAlias,
altAliases: room.getAltAliases(),
isSpace: isSpace(room),
joinRule: room.getJoinRule(),
guestAccess: room.getGuestAccess(),
historyVisibility: room.getHistoryVisibility(),
myUserId: mx.getUserId(),
myMembership: room.getMyMembership(),
creator: Array.from(creators),
memberCount: room.getJoinedMemberCount(),
invitedMemberCount: room.getInvitedMemberCount(),
powerLevels,
powerLevelTags: powerLevelTags || null,
tags: room.tags,
notificationCounts: {
total: room.getRoomUnreadNotificationCount('total'),
highlight: room.getRoomUnreadNotificationCount('highlight'),
},
timeline: {
oldestEventTs: oldestEvent?.getTs(),
newestEventTs: newestEvent?.getTs(),
liveEventsCount: liveEvents.length,
timelineSetSize: room.getTimelineSets().length,
},
encryption: room.hasEncryptionStateEvent(),
state: {} as any,
accountData: {} as any,
};
// Add all state events
roomState.forEach((stateKeyToEvents, eventType) => {
info.state[eventType] = {};
stateKeyToEvents.forEach((event, stateKey) => {
info.state[eventType][stateKey || '_default'] = {
content: event.getContent(),
sender: event.getSender(),
stateKey: event.getStateKey(),
eventId: event.getId(),
timestamp: event.getTs(),
unsigned: event.getUnsigned(),
};
});
});
// Add account data
accountData.forEach((content, type) => {
info.accountData[type] = content;
});
return JSON.stringify(info, null, 2);
}, [room, mx, roomState, accountData, powerLevels, creators]);
const handleClose = useCallback(() => {
setOpenStateEvent(undefined);
@@ -82,6 +161,62 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
return <StateEventEditor {...openStateEvent} requestClose={handleClose} />;
}
if (showRoomInfo) {
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setShowRoomInfo(false),
escapeDeactivates: stopPropagation,
}}
>
<Modal size="500" variant="Surface">
<Box direction="Column" gap="400" style={{ height: '80vh' }}>
<Box justifyContent="SpaceBetween" alignItems="Center" gap="200">
<Text size="H4" truncate>
{isSpace(room) ? 'Space' : 'Room'} Information
</Text>
<Box gap="200">
<Button
onClick={() => copyToClipboard(roomInfoJson)}
variant="Secondary"
fill="Soft"
size="300"
radii="300"
>
<Text size="B300">Copy All</Text>
</Button>
<IconButton onClick={() => setShowRoomInfo(false)} variant="Surface" size="300">
<Icon src={Icons.Cross} size="200" />
</IconButton>
</Box>
</Box>
<Box grow="Yes" style={{ minHeight: 0 }}>
<Scroll hideTrack visibility="Hover" size="300">
<TextArea
style={{
minHeight: '100%',
fontFamily: 'monospace',
fontSize: '12px',
resize: 'none',
}}
defaultValue={roomInfoJson}
readOnly
autoComplete="off"
onFocus={(e) => e.target.select()}
/>
</Scroll>
</Box>
</Box>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}
return (
<Page>
<PageHeader outlined={false}>
@@ -144,6 +279,23 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
</Button>
}
/>
<SettingTile
title={`View ${isSpace(room) ? 'Space' : 'Room'} Information`}
description="Display comprehensive information about this room/space including state, members, power levels, and more."
after={
<Button
onClick={() => setShowRoomInfo(true)}
variant="Primary"
fill="Soft"
size="300"
radii="300"
outlined
before={<Icon src={Icons.Info} size="50" />}
>
<Text size="B300">View Info</Text>
</Button>
}
/>
</SequenceCard>
)}
</Box>

View File

@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
import React, { useCallback, useMemo, useState } from 'react';
import { Box, Button, Icon, IconButton, Icons, Scroll, Spinner, Text, color } from 'folds';
import { useAtomValue } from 'jotai';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { useRoom } from '../../../hooks/useRoom';
import { usePowerLevels } from '../../../hooks/usePowerLevels';
@@ -9,6 +10,10 @@ import { usePermissionGroups } from './usePermissionItems';
import { PermissionGroups, Powers, PowersEditor } from '../../common-settings/permissions';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { useRecursiveChildScopeFactory, useRecursiveChildSpaceScopeFactory, useSpaceChildren } from '../../../state/hooks/roomList';
type PermissionsProps = {
requestClose: () => void;
@@ -18,6 +23,7 @@ export function Permissions({ requestClose }: PermissionsProps) {
const room = useRoom();
const powerLevels = usePowerLevels(room);
const creators = useRoomCreators(room);
const roomToParents = useAtomValue(roomToParentsAtom);
const permissions = useRoomPermissions(creators, powerLevels);
@@ -27,14 +33,110 @@ export function Permissions({ requestClose }: PermissionsProps) {
const [powerEditor, setPowerEditor] = useState(false);
// Get all child spaces recursively
const childSpaces = useSpaceChildren(
allRoomsAtom,
room.roomId,
useRecursiveChildSpaceScopeFactory(mx, roomToParents)
);
// Get all child rooms recursively
const childRooms = useSpaceChildren(
allRoomsAtom,
room.roomId,
useRecursiveChildScopeFactory(mx, roomToParents)
);
// Combine both spaces and rooms for syncing
const allChildren = [...childSpaces, ...childRooms];
// Calculate which children we can sync to
const { syncableChildren, nonSyncableChildren } = useMemo(() => {
const syncable: string[] = [];
const nonSyncable: { roomId: string; name: string }[] = [];
allChildren.forEach((childId) => {
const childRoom = mx.getRoom(childId);
if (!childRoom) {
nonSyncable.push({ roomId: childId, name: childId });
return;
}
// Check if we have permission to edit power levels in the child
const childPowerLevels = childRoom.currentState.getStateEvents(StateEvent.RoomPowerLevels, '');
const childPowerLevelsContent = childPowerLevels?.getContent() || {};
const childCreators = new Set<string>();
const creatorId = childRoom.currentState.getStateEvents('m.room.create', '')?.getContent()?.creator;
if (creatorId) childCreators.add(creatorId);
const myUserId = mx.getSafeUserId();
const myPower = childPowerLevelsContent.users?.[myUserId] ?? childPowerLevelsContent.users_default ?? 0;
// Check the specific power level required for m.room.power_levels events
const requiredPower = childPowerLevelsContent.events?.[StateEvent.RoomPowerLevels] ??
childPowerLevelsContent.state_default ?? 50;
if (myPower >= requiredPower || childCreators.has(myUserId)) {
syncable.push(childId);
} else {
nonSyncable.push({ roomId: childId, name: childRoom.name || childId });
}
});
return { syncableChildren: syncable, nonSyncableChildren: nonSyncable };
}, [allChildren, mx]);
const [syncState, syncPermissions] = useAsyncCallback(
useCallback(async () => {
// Get the power level tags from this space
const powerLevelTagsEvent = room.currentState.getStateEvents('in.cinny.room.power_level_tags', '');
const powerLevelTags = powerLevelTagsEvent?.getContent();
let completed = 0;
const total = syncableChildren.length;
for (const childId of syncableChildren) {
try {
// Copy the space's power levels to the child
await mx.sendStateEvent(childId, StateEvent.RoomPowerLevels as any, powerLevels);
// Also copy power level tags if they exist
if (powerLevelTags) {
await mx.sendStateEvent(childId, 'in.cinny.room.power_level_tags' as any, powerLevelTags);
}
completed += 1;
// Add a small delay to avoid rate limiting
if (completed < total) {
await new Promise(resolve => setTimeout(resolve, 100));
}
} catch (error) {
console.error(`Failed to sync permissions to ${childId}:`, error);
}
}
return { completed, total };
}, [mx, syncableChildren, powerLevels, room])
);
const handleEditPowers = () => {
setPowerEditor(true);
};
const handleSyncPermissions = () => {
syncPermissions();
};
if (canEditPowers && powerEditor) {
return <PowersEditor powerLevels={powerLevels} requestClose={() => setPowerEditor(false)} />;
}
const hasSyncableChildren = allChildren.length > 0;
const isSyncing = syncState.status === AsyncStatus.Loading;
const syncSuccess = syncState.status === AsyncStatus.Success;
const syncError = syncState.status === AsyncStatus.Error;
return (
<Page>
<PageHeader outlined={false}>
@@ -55,6 +157,65 @@ export function Permissions({ requestClose }: PermissionsProps) {
<Scroll hideTrack visibility="Hover">
<PageContent>
<Box direction="Column" gap="700">
{canEditPermissions && hasSyncableChildren && (
<Box direction="Column" gap="100">
<Text size="L400">Sync Permissions</Text>
<Box direction="Column" gap="300" style={{ padding: 'var(--sp-300)' }}>
<Text size="T300">
Sync these permissions and power level tags to all child subspaces and rooms.
{nonSyncableChildren.length > 0 ? (
<> This will sync to {syncableChildren.length} child{' '}
{syncableChildren.length === 1 ? 'space/room' : 'spaces/rooms'} where you have permission.
<Text as="span" style={{ color: color.Warning.Main }}>
{' '}You don't have permission to edit {nonSyncableChildren.length} other{' '}
{nonSyncableChildren.length === 1 ? 'space/room' : 'spaces/rooms'}
{nonSyncableChildren.length <= 5 && (
<>: {nonSyncableChildren.map(c => c.name).join(', ')}</>
)}.
</Text>
</>
) : (
<> This will overwrite the power levels and custom role tags in {syncableChildren.length} child{' '}
{syncableChildren.length === 1 ? 'space/room' : 'spaces/rooms'}.
</>
)}
</Text>
<Box gap="200" alignItems="Center">
<Button
variant="Critical"
onClick={handleSyncPermissions}
disabled={isSyncing}
before={
isSyncing ? (
<Spinner variant="Critical" fill="Solid" size="50" />
) : (
<Icon src={Icons.Download} size="50" />
)
}
>
<Text size="B400">
{isSyncing
? 'Syncing...'
: `Sync to ${syncableChildren.length} ${
syncableChildren.length === 1 ? 'Child' : 'Children'
}${nonSyncableChildren.length > 0 ? ` (${nonSyncableChildren.length} skipped)` : ''}`}
</Text>
</Button>
{syncSuccess && (
<Text size="T300" style={{ color: color.Success.Main }}>
Successfully synced permissions to {syncState.data.completed} of{' '}
{syncState.data.total} children
</Text>
)}
{syncError && (
<Text size="T300" style={{ color: color.Critical.Main }}>
Error syncing permissions: {syncState.error.message}
</Text>
)}
</Box>
</Box>
</Box>
)}
<Powers
powerLevels={powerLevels}
onEdit={canEditPowers ? handleEditPowers : undefined}