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 (
-
+ <>
+ >
);
}
diff --git a/src/app/features/common-settings/developer-tools/DevelopTools.tsx b/src/app/features/common-settings/developer-tools/DevelopTools.tsx
index 29b6aa5..9982e5f 100644
--- a/src/app/features/common-settings/developer-tools/DevelopTools.tsx
+++ b/src/app/features/common-settings/developer-tools/DevelopTools.tsx
@@ -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();
@@ -49,6 +62,72 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
const [expandAccountData, setExpandAccountData] = useState(false);
const [accountDataType, setAccountDataType] = useState();
+ 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 ;
}
+ if (showRoomInfo) {
+ return (
+ }>
+
+ setShowRoomInfo(false),
+ escapeDeactivates: stopPropagation,
+ }}
+ >
+
+
+
+
+ {isSpace(room) ? 'Space' : 'Room'} Information
+
+
+
+ setShowRoomInfo(false)} variant="Surface" size="300">
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
return (
@@ -144,6 +279,23 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
}
/>
+ 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'}.
+ >
+ )}
+
+
+
+ ) : (
+
+ )
+ }
+ >
+
+ {isSyncing
+ ? 'Syncing...'
+ : `Sync to ${syncableChildren.length} ${
+ syncableChildren.length === 1 ? 'Child' : 'Children'
+ }${nonSyncableChildren.length > 0 ? ` (${nonSyncableChildren.length} skipped)` : ''}`}
+
+
+ {syncSuccess && (
+
+ Successfully synced permissions to {syncState.data.completed} of{' '}
+ {syncState.data.total} children
+
+ )}
+ {syncError && (
+
+ Error syncing permissions: {syncState.error.message}
+
+ )}
+
+
+
+ )}