Files
cinny/src/app/features/common-settings/general/RoomUpgrade.tsx
T
Lotus CIandClaude Opus 5.5 57036bb6a4
CI / Build & Quality Checks (pull_request) Successful in 4m0s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 9s
CI / Playwright smoke (e2e) (pull_request) Successful in 14m50s
fix(rooms): upgrading a room keeps its Lotus settings (#327)
POST /rooms/{id}/upgrade copies only Synapse's fixed list of state, so an
upgrade silently dropped everything Lotus keeps in room state: an
audio-only voice channel became camera/screenshare-allowed, user limits
and bitrate caps vanished, the soundboard pack was lost, and a room set
to delete messages after 7 days kept new ones forever.

RoomUpgrade now reads the client-owned state first (utils/roomUpgradeState,
unit-tested: io.lotus.*, m.room.retention, role tags, room emoji packs)
and re-creates it in the replacement room; a partial copy is reported in
the dialog instead of closing silently.

Verified headless through the UI (v10 room with voice limit, quality
policy, a soundboard pack and retention → upgrade): before, none
survived; now all four are in the new room.

Follow-up (not here): for spaces, the children's m.space.parent still
point at the old space.

Fixes #327

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 22:23:15 -04:00

311 lines
11 KiB
TypeScript

import React, { useCallback, useEffect, useState } from 'react';
import {
Button,
color,
Spinner,
Text,
Overlay,
OverlayBackdrop,
OverlayCenter,
Dialog,
Header,
config,
Box,
IconButton,
Icon,
Icons,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { MatrixError, Method } from 'matrix-js-sdk';
import { RoomTombstoneEventContent } from 'matrix-js-sdk/lib/types';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../../room-settings/styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useRoom } from '../../../hooks/useRoom';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { IRoomCreateContent, StateEvent } from '../../../../types/matrix/room';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useStateEvent } from '../../../hooks/useStateEvent';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
import { useCapabilities } from '../../../hooks/useCapabilities';
import { stopPropagation } from '../../../utils/keyboard';
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
import {
AdditionalCreatorInput,
RoomVersionSelector,
useAdditionalCreators,
} from '../../../components/create-room';
import { useAlive } from '../../../hooks/useAlive';
import { creatorsSupported } from '../../../utils/matrix';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { BreakWord } from '../../../styles/Text.css';
import { useModalStyle } from '../../../hooks/useModalStyle';
import { stateToCarry } from '../../../utils/roomUpgradeState';
function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
const mx = useMatrixClient();
const room = useRoom();
const alive = useAlive();
const creators = useRoomCreators(room);
const modalStyle = useModalStyle(480);
const capabilities = useCapabilities();
const roomVersions = capabilities['m.room_versions'];
const [selectedRoomVersion, selectRoomVersion] = useState(roomVersions?.default ?? '1');
useEffect(() => {
// capabilities load async
selectRoomVersion(roomVersions?.default ?? '1');
}, [roomVersions?.default]);
const allowAdditionalCreators = creatorsSupported(selectedRoomVersion);
const { additionalCreators, addAdditionalCreator, removeAdditionalCreator } =
useAdditionalCreators(Array.from(creators));
const [upgradeState, upgrade] = useAsyncCallback(
useCallback(
async (version: string, newAdditionalCreators?: string[]) => {
// [Gitea #327] Synapse doesn't copy Lotus room state (voice limit, call
// policy, soundboard, retention, role tags, emoji packs) into the
// replacement room: read it first, re-create it after the upgrade.
const carried = stateToCarry(
Array.from(room.currentState.events.values()).flatMap((byKey) =>
Array.from(byKey.values()),
),
);
const res = await mx.http.authedRequest<{ replacement_room?: string }>(
Method.Post,
`/rooms/${room.roomId}/upgrade`,
undefined,
{
new_version: version,
additional_creators: newAdditionalCreators,
},
);
const newRoomId = res?.replacement_room;
if (!newRoomId || carried.length === 0) return;
const results = await Promise.allSettled(
carried.map((st) =>
mx.http.authedRequest(
Method.Put,
`/rooms/${encodeURIComponent(newRoomId)}/state/${encodeURIComponent(st.type)}/${encodeURIComponent(st.stateKey)}`,
undefined,
st.content,
),
),
);
const failed = results.filter((r) => r.status === 'rejected').length;
if (failed > 0) {
throw new Error(
`Room upgraded, but ${failed} of ${carried.length} Lotus settings (voice limit, call policy, soundboard, retention…) could not be copied to the new room.`,
);
}
},
[mx, room],
),
);
const upgrading = upgradeState.status === AsyncStatus.Loading;
const handleUpgradeRoom = () => {
const version = selectedRoomVersion;
upgrade(version, allowAdditionalCreators ? additionalCreators : undefined).then(() => {
if (alive()) {
requestClose();
}
});
};
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
fallbackFocus: '#roomupgrade-dialog-1',
onDeactivate: requestClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog
id="roomupgrade-dialog-1"
role="dialog"
aria-modal="true"
aria-labelledby="roomupgrade-dialog-1-title"
tabIndex={-1}
variant="Surface"
style={modalStyle}
>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text id="roomupgrade-dialog-1-title" as="h2" size="H4">
{room.isSpaceRoom() ? 'Space Upgrade' : 'Room Upgrade'}
</Text>
</Box>
<IconButton size="300" onClick={requestClose} radii="300" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Text priority="400" style={{ color: color.Critical.Main }}>
<b>This action is irreversible!</b>
</Text>
<Box direction="Column" gap="100">
<Text size="L400">Options</Text>
<RoomVersionSelector
versions={roomVersions?.available ? Object.keys(roomVersions.available) : ['1']}
value={selectedRoomVersion}
onChange={selectRoomVersion}
disabled={upgrading}
/>
{allowAdditionalCreators && (
<SequenceCard
style={{ padding: config.space.S300 }}
variant="SurfaceVariant"
direction="Column"
gap="500"
>
<AdditionalCreatorInput
additionalCreators={additionalCreators}
onSelect={addAdditionalCreator}
onRemove={removeAdditionalCreator}
disabled={upgrading}
/>
</SequenceCard>
)}
</Box>
{upgradeState.status === AsyncStatus.Error && (
<Text className={BreakWord} style={{ color: color.Critical.Main }} size="T200">
{(upgradeState.error as MatrixError).message}
</Text>
)}
<Button
onClick={handleUpgradeRoom}
variant="Secondary"
disabled={upgrading}
before={upgrading && <Spinner size="200" variant="Secondary" fill="Solid" />}
>
<Text size="B400">{room.isSpaceRoom() ? 'Upgrade Space' : 'Upgrade Room'}</Text>
</Button>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}
type RoomUpgradeProps = {
permissions: RoomPermissionsAPI;
requestClose: () => void;
};
export function RoomUpgrade({ permissions, requestClose }: RoomUpgradeProps) {
const mx = useMatrixClient();
const room = useRoom();
const { navigateRoom, navigateSpace } = useRoomNavigate();
const createContent = useStateEvent(
room,
StateEvent.RoomCreate,
)?.getContent<IRoomCreateContent>();
const roomVersion = createContent?.room_version ?? '1';
const predecessorRoomId = createContent?.predecessor?.room_id;
const tombstoneContent = useStateEvent(
room,
StateEvent.RoomTombstone,
)?.getContent<RoomTombstoneEventContent>();
const replacementRoom = tombstoneContent?.replacement_room;
const canUpgrade = permissions.stateEvent(StateEvent.RoomTombstone, mx.getSafeUserId());
const handleOpenRoom = () => {
if (replacementRoom) {
requestClose();
if (room.isSpaceRoom()) {
navigateSpace(replacementRoom);
} else {
navigateRoom(replacementRoom);
}
}
};
const handleOpenOldRoom = () => {
if (predecessorRoomId) {
requestClose();
if (room.isSpaceRoom()) {
navigateSpace(predecessorRoomId);
} else {
navigateRoom(predecessorRoomId, createContent.predecessor?.event_id);
}
}
};
const [prompt, setPrompt] = useState(false);
return (
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title={room.isSpaceRoom() ? 'Upgrade Space' : 'Upgrade Room'}
description={
replacementRoom
? tombstoneContent.body ||
`This ${room.isSpaceRoom() ? 'space' : 'room'} has been replaced!`
: `Current version: ${roomVersion}.`
}
after={
<Box alignItems="Center" gap="200">
{predecessorRoomId && (
<Button
size="300"
variant="Secondary"
fill="Soft"
outlined
radii="300"
onClick={handleOpenOldRoom}
>
<Text size="B300">{room.isSpaceRoom() ? 'Old Space' : 'Old Room'}</Text>
</Button>
)}
{replacementRoom ? (
<Button
size="300"
variant="Success"
fill="Solid"
radii="300"
onClick={handleOpenRoom}
>
<Text size="B300">{room.isSpaceRoom() ? 'Open New Space' : 'Open New Room'}</Text>
</Button>
) : (
<Button
size="300"
variant="Secondary"
fill="Solid"
radii="300"
disabled={!canUpgrade}
onClick={() => setPrompt(true)}
>
<Text size="B300">Upgrade</Text>
</Button>
)}
</Box>
}
>
{prompt && <RoomUpgradeDialog requestClose={() => setPrompt(false)} />}
</SettingTile>
</SequenceCard>
);
}