Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 57036bb6a4 fix(rooms): upgrading a room keeps its Lotus settings (#327)
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
2026-10-16 22:23:15 -04:00
jared 400ee76b63 Merge pull request 'fix(calls): no "Missed call" in voice rooms or space channels' (#363) from fix/missed-call-voice-rooms into lotus
CI / Build & Quality Checks (push) Successful in 1m44s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 13s
CI / Trigger Desktop Build (push) Successful in 3s
CI / Playwright smoke (e2e) (push) Successful in 10m58s
2026-10-16 21:06:57 -04:00
jared 47c1e435b7 Merge pull request 'perf(backgrounds): animated chat backgrounds cost ~5% CPU instead of 50 to 70% (#337)' (#361) from fix/animated-bg-compositor into lotus
CI / Build & Quality Checks (push) Successful in 1m42s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 11s
CI / Trigger Desktop Build (push) Canceled after 8s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-16 20:52:34 -04:00
Lotus CIandClaude Opus 5.5 4650796120 fix(calls): no "Missed call" in voice rooms or space channels
CI / Build & Quality Checks (pull_request) Successful in 1m39s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Playwright smoke (e2e) (pull_request) Successful in 10m48s
Joining a voice channel alone sends a call notification (m.rtc.notification)
to the room, and the timeline rendered every unanswered one as "Missed call
from X", so everyone in the channel saw a missed call from someone who had
just joined it. Ringing already excluded these rooms; the missed-call line
didn't.

utils/callRing.ts (unit-tested) holds the one rule for both: only DMs and
invite-only groups outside any space ring or show "Missed call"; voice
rooms, space channels, restricted and public rooms don't.

Verified headless with the same two notifications from Bob: the Voice
Lounge chat showed 2 "Missed call" lines before, 0 after; a small
invite-only group still shows its missed call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 20:29:50 -04:00
7 changed files with 161 additions and 12 deletions
+3 -8
View File
@@ -20,7 +20,6 @@ import {
import {
EventTimelineSetHandlerMap,
EventType,
JoinRule,
RelationType,
Room,
RoomEvent,
@@ -72,7 +71,8 @@ import { useReducedMotion } from '../hooks/useReducedMotion';
import { useSetting } from '../state/hooks/settings';
import { useCallPreferences } from '../state/hooks/callPreferences';
import { settingsAtom } from '../state/settings';
import { getStateEvent, getStateEvents, getMemberName } from '../utils/room';
import { getStateEvent, getMemberName } from '../utils/room';
import { isRingableRoom } from '../utils/callRing';
import { StateEvent } from '../../types/matrix/room';
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
@@ -581,12 +581,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
// Only ring in rooms where the call button is visible: DMs or invite-only rooms
// with no space parent. Persistent voice rooms (call rooms), space channels,
// restricted rooms, and public rooms must never trigger ringing.
if (room.isCallRoom()) return;
const isDirect = directs.has(room.roomId);
const isSpaceChild = getStateEvents(room, StateEvent.SpaceParent).length > 0;
const joinRule = room.getJoinRule();
const isPrivateInviteGroup = !isSpaceChild && joinRule === JoinRule.Invite;
if (!isDirect && !isPrivateInviteGroup) return;
if (!isRingableRoom(room, directs.has(room.roomId))) return;
const info: IncomingCallInfo = {
room,
@@ -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],
),
+4
View File
@@ -138,6 +138,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
import { useIsDirectRoom } from '../../hooks/useRoom';
import { isRingableRoom } from '../../utils/callRing';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { useSpaceOptionally } from '../../hooks/useSpace';
import { useRoomCreators } from '../../hooks/useRoomCreators';
@@ -2030,6 +2031,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
[EventType.RTCNotification]: (mEventId, mEvent, item) => {
const senderId = mEvent.getSender() ?? '';
if (!senderId || senderId === mx.getUserId()) return null;
// Same rule as ringing: no "Missed call" in voice rooms or space
// channels, where starting a call is just joining the channel.
if (!isRingableRoom(room, direct)) return null;
const content = mEvent.getContent();
const mention =
content['m.mentions']?.room ||
+29
View File
@@ -0,0 +1,29 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { Room } from 'matrix-js-sdk';
import { isRingableRoom } from './callRing';
const fakeRoom = (opts: { callRoom?: boolean; joinRule?: string; spaceParent?: boolean }): Room =>
({
isCallRoom: () => !!opts.callRoom,
getJoinRule: () => opts.joinRule ?? 'invite',
getLiveTimeline: () => ({
getState: () => ({ getStateEvents: () => (opts.spaceParent ? [{}] : []) }),
}),
}) as unknown as Room;
test('voice rooms never ring or show "missed call", even as DMs', () => {
assert.equal(isRingableRoom(fakeRoom({ callRoom: true }), false), false);
assert.equal(isRingableRoom(fakeRoom({ callRoom: true }), true), false);
});
test('DMs and invite-only groups outside a space ring', () => {
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'public' }), true), true);
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'invite' }), false), true);
});
test('space channels, public and restricted rooms do not ring', () => {
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'invite', spaceParent: true }), false), false);
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'public' }), false), false);
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'restricted' }), false), false);
});
+18
View File
@@ -0,0 +1,18 @@
import { JoinRule, Room } from 'matrix-js-sdk';
import { StateEvent } from '../../types/matrix/room';
import { getStateEvents } from './room';
/**
* Whether a call notification in this room may ring — and, by the same rule,
* whether an unanswered one may show as "Missed call" (Gitea #162 follow-up).
* Only DMs and invite-only groups outside any space ring, i.e. rooms where the
* call button is shown. Persistent voice rooms, space channels, restricted and
* public rooms never do: joining a voice channel alone is normal and isn't a
* "call" anyone missed.
*/
export const isRingableRoom = (room: Room, isDirect: boolean): boolean => {
if (room.isCallRoom()) return false;
if (isDirect) return true;
const isSpaceChild = getStateEvents(room, StateEvent.SpaceParent).length > 0;
return !isSpaceChild && room.getJoinRule() === JoinRule.Invite;
};
+35
View File
@@ -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<string, unknown>, 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|'],
);
});
+36
View File
@@ -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<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);