From 58966aec195ba92e015a9b8f90744eaa09e95450 Mon Sep 17 00:00:00 2001 From: Litruv Date: Tue, 24 Feb 2026 21:49:03 +1100 Subject: [PATCH] feat: enhance update notification layout and add room info display in developer tools --- src/app/components/title-bar/TitleBar.tsx | 6 +- .../UpdateNotification.css.ts | 16 +- .../UpdateNotification.tsx | 69 +++----- .../developer-tools/DevelopTools.tsx | 154 +++++++++++++++- .../permissions/Permissions.tsx | 165 +++++++++++++++++- 5 files changed, 359 insertions(+), 51 deletions(-) diff --git a/src/app/components/title-bar/TitleBar.tsx b/src/app/components/title-bar/TitleBar.tsx index 6ef6667..3c8c017 100644 --- a/src/app/components/title-bar/TitleBar.tsx +++ b/src/app/components/title-bar/TitleBar.tsx @@ -616,8 +616,10 @@ export function TitleBar({ mx, children }: TitleBarProps) { ))} {children} - - + + + + ); } diff --git a/src/app/components/update-notification/UpdateNotification.css.ts b/src/app/components/update-notification/UpdateNotification.css.ts index 4bfb62e..7a0af37 100644 --- a/src/app/components/update-notification/UpdateNotification.css.ts +++ b/src/app/components/update-notification/UpdateNotification.css.ts @@ -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, diff --git a/src/app/components/update-notification/UpdateNotification.tsx b/src/app/components/update-notification/UpdateNotification.tsx index 42c8a45..6070568 100644 --- a/src/app/components/update-notification/UpdateNotification.tsx +++ b/src/app/components/update-notification/UpdateNotification.tsx @@ -118,42 +118,38 @@ export function UpdateNotification() { // Show check button if no update status if (!updateAvailable && !updateReady && !checking) { return ( - - - + ); } // Show checking state if (checking) { return ( - - - + ); } @@ -163,7 +159,7 @@ export function UpdateNotification() { } return ( - + <> } /> + setShowRoomInfo(true)} + variant="Primary" + fill="Soft" + size="300" + radii="300" + outlined + before={} + > + View Info + + } + /> )} diff --git a/src/app/features/space-settings/permissions/Permissions.tsx b/src/app/features/space-settings/permissions/Permissions.tsx index 7572a71..131bb3a 100644 --- a/src/app/features/space-settings/permissions/Permissions.tsx +++ b/src/app/features/space-settings/permissions/Permissions.tsx @@ -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(); + 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 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 ( @@ -55,6 +157,65 @@ export function Permissions({ requestClose }: PermissionsProps) { + {canEditPermissions && hasSyncableChildren && ( + + Sync Permissions + + + 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. + + {' '}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(', ')} + )}. + + + ) : ( + <> This will overwrite the power levels and custom role tags in {syncableChildren.length} child{' '} + {syncableChildren.length === 1 ? 'space/room' : 'spaces/rooms'}. + + )} + + + + {syncSuccess && ( + + Successfully synced permissions to {syncState.data.completed} of{' '} + {syncState.data.total} children + + )} + {syncError && ( + + Error syncing permissions: {syncState.error.message} + + )} + + + + )}