diff --git a/src/app/features/common-settings/general/RoomUpgrade.tsx b/src/app/features/common-settings/general/RoomUpgrade.tsx index 436d6445a..c3c89a589 100644 --- a/src/app/features/common-settings/general/RoomUpgrade.tsx +++ b/src/app/features/common-settings/general/RoomUpgrade.tsx @@ -40,6 +40,7 @@ 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(); @@ -63,10 +64,41 @@ function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) { const [upgradeState, upgrade] = useAsyncCallback( useCallback( async (version: string, newAdditionalCreators?: string[]) => { - await mx.http.authedRequest(Method.Post, `/rooms/${room.roomId}/upgrade`, undefined, { - new_version: version, - additional_creators: newAdditionalCreators, - }); + // [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], ), diff --git a/src/app/utils/roomUpgradeState.test.ts b/src/app/utils/roomUpgradeState.test.ts new file mode 100644 index 000000000..02e53cb16 --- /dev/null +++ b/src/app/utils/roomUpgradeState.test.ts @@ -0,0 +1,35 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { isCarriedStateType, stateToCarry } from './roomUpgradeState'; + +const ev = (type: string, content: Record, stateKey = '') => ({ + getType: () => type, + getStateKey: () => stateKey, + getContent: () => content, +}); + +test('Lotus room state is carried; state Synapse already copies is not (#327)', () => { + assert.equal(isCarriedStateType('io.lotus.voice_limit'), true); + assert.equal(isCarriedStateType('io.lotus.room_quality'), true); + assert.equal(isCarriedStateType('io.lotus.soundboard'), true); + assert.equal(isCarriedStateType('m.room.retention'), true); + assert.equal(isCarriedStateType('in.cinny.room.power_level_tags'), true); + assert.equal(isCarriedStateType('im.ponies.room_emotes'), true); + assert.equal(isCarriedStateType('m.room.power_levels'), false); + assert.equal(isCarriedStateType('m.room.name'), false); + assert.equal(isCarriedStateType('m.room.member'), false); +}); + +test('stateToCarry keeps state keys and skips emptied events', () => { + const out = stateToCarry([ + ev('io.lotus.voice_limit', { max_users: 5 }), + ev('io.lotus.soundboard', { clips: [1] }, 'pack-a'), + ev('io.lotus.room_quality', {}), + ev('m.room.name', { name: 'x' }), + ev('m.room.retention', { max_lifetime: 604800000 }), + ]); + assert.deepEqual( + out.map((s) => `${s.type}|${s.stateKey}`), + ['io.lotus.voice_limit|', 'io.lotus.soundboard|pack-a', 'm.room.retention|'], + ); +}); diff --git a/src/app/utils/roomUpgradeState.ts b/src/app/utils/roomUpgradeState.ts new file mode 100644 index 000000000..ca6036bd4 --- /dev/null +++ b/src/app/utils/roomUpgradeState.ts @@ -0,0 +1,36 @@ +/** + * [Gitea #327] Room state Synapse does NOT copy into the replacement room on + * `POST /rooms/{id}/upgrade` (it carries only power levels, join rules, + * name/topic/avatar, history visibility, guest access, encryption, server ACL + * and space parent/child). Lotus keeps call policy, voice limits, soundboard, + * message retention, role tags and room emoji packs in state, so an upgrade + * silently dropped all of them. + */ +const CARRIED_TYPES = new Set([ + 'm.room.retention', + 'in.cinny.room.power_level_tags', + 'im.ponies.room_emotes', +]); +const CARRIED_PREFIXES = ['io.lotus.']; + +export type StateToCarry = { type: string; stateKey: string; content: Record }; + +export const isCarriedStateType = (type: string): boolean => + CARRIED_TYPES.has(type) || CARRIED_PREFIXES.some((p) => type.startsWith(p)); + +/** The state events to re-create in the replacement room (non-empty content only). */ +export const stateToCarry = ( + events: Array<{ + getType: () => string; + getStateKey: () => string | undefined; + getContent: () => Record; + }>, +): StateToCarry[] => + events + .filter((ev) => isCarriedStateType(ev.getType())) + .map((ev) => ({ + type: ev.getType(), + stateKey: ev.getStateKey() ?? '', + content: ev.getContent(), + })) + .filter((s) => s.content && Object.keys(s.content).length > 0);