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
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
37 lines
1.3 KiB
TypeScript
37 lines
1.3 KiB
TypeScript
/**
|
|
* [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<string, unknown> };
|
|
|
|
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<string, unknown>;
|
|
}>,
|
|
): 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);
|