feat: enhance update notification layout and add room info display in developer tools
This commit is contained in:
@@ -616,8 +616,10 @@ export function TitleBar({ mx, children }: TitleBarProps) {
|
||||
))}
|
||||
{children}
|
||||
</Box>
|
||||
<Box alignItems="Center" gap="100" style={{ height: '100%', flexShrink: 0 }}>
|
||||
<UpdateNotification />
|
||||
<WindowControls />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -118,7 +118,6 @@ 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}
|
||||
@@ -142,18 +141,15 @@ export function UpdateNotification() {
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
@@ -246,6 +235,6 @@ export function UpdateNotification() {
|
||||
>
|
||||
{null}
|
||||
</PopOut>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user