Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57036bb6a4 |
@@ -18,10 +18,8 @@ import {
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import {
|
||||
ClientEvent,
|
||||
EventTimelineSetHandlerMap,
|
||||
EventType,
|
||||
SyncState,
|
||||
RelationType,
|
||||
Room,
|
||||
RoomEvent,
|
||||
@@ -74,7 +72,7 @@ import { useSetting } from '../state/hooks/settings';
|
||||
import { useCallPreferences } from '../state/hooks/callPreferences';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { getStateEvent, getMemberName } from '../utils/room';
|
||||
import { declinedRing, hasActiveCallMembership, isRingableRoom } from '../utils/callRing';
|
||||
import { isRingableRoom } from '../utils/callRing';
|
||||
import { StateEvent } from '../../types/matrix/room';
|
||||
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
|
||||
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
|
||||
@@ -598,50 +596,11 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
refEventId,
|
||||
};
|
||||
|
||||
// [Gitea #312] Don't ring for a call that is already over or handled: after
|
||||
// a reload the first sync replays the ring together with the caller's
|
||||
// hang-up and our own decline. Check the room STATE (not the possibly
|
||||
// stale RTC session). The caller's membership can arrive just after the
|
||||
// ring, so an absent one gets a short grace period before giving up.
|
||||
const myUserId = mx.getSafeUserId();
|
||||
if (declinedRing(room, refEventId, myUserId) || hasActiveCallMembership(room, myUserId)) {
|
||||
return;
|
||||
}
|
||||
if (hasActiveCallMembership(room, sender)) {
|
||||
setCallInfo(info);
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (Date.now() >= senderTs + lifetime) return;
|
||||
if (declinedRing(room, refEventId, myUserId) || hasActiveCallMembership(room, myUserId)) {
|
||||
return;
|
||||
}
|
||||
if (hasActiveCallMembership(room, sender)) setCallInfo(info);
|
||||
}, 4000);
|
||||
setCallInfo(info);
|
||||
},
|
||||
[mx, directs, callEmbed, setToast],
|
||||
);
|
||||
|
||||
// [Gitea #312] A catch-up sync (after a reload or reconnect) can replay a
|
||||
// call's join and leave in one batch, and the RTC session may keep the join
|
||||
// (stale "1 Live", call screen instead of chat). Once sync is live again,
|
||||
// recompute every active session from the room's current state.
|
||||
useEffect(() => {
|
||||
const onSync = (state: SyncState, prev: SyncState | null) => {
|
||||
if (state !== SyncState.Syncing || prev === SyncState.Syncing) return;
|
||||
mx.getRooms().forEach((r) => {
|
||||
mx.matrixRTC
|
||||
.getActiveRoomSession(r)
|
||||
?._onRTCSessionMemberUpdate()
|
||||
.catch(() => undefined);
|
||||
});
|
||||
};
|
||||
mx.on(ClientEvent.Sync, onSync);
|
||||
return () => {
|
||||
mx.removeListener(ClientEvent.Sync, onSync);
|
||||
};
|
||||
}, [mx]);
|
||||
|
||||
useEffect(() => {
|
||||
mx.on(RoomEvent.Timeline, handleTimelineEvent);
|
||||
return () => {
|
||||
@@ -670,10 +629,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
const { memberships } = session;
|
||||
if (memberships.some((m) => m.sender === myUserId))
|
||||
dismiss(); // answered elsewhere
|
||||
else if (memberships.length === 0)
|
||||
dismiss(); // caller gone
|
||||
// [Gitea #312] The session can be stale after a reload; the room state isn't.
|
||||
else if (!hasActiveCallMembership(room, callInfo.sender)) dismiss();
|
||||
else if (memberships.length === 0) dismiss(); // caller gone
|
||||
};
|
||||
const onTimeline: EventTimelineSetHandlerMap[RoomEvent.Timeline] = (
|
||||
event,
|
||||
|
||||
@@ -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],
|
||||
),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { Room } from 'matrix-js-sdk';
|
||||
import { declinedRing, hasActiveCallMembership, isRingableRoom } from './callRing';
|
||||
import { isRingableRoom } from './callRing';
|
||||
|
||||
const fakeRoom = (opts: { callRoom?: boolean; joinRule?: string; spaceParent?: boolean }): Room =>
|
||||
({
|
||||
@@ -27,43 +27,3 @@ test('space channels, public and restricted rooms do not ring', () => {
|
||||
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'public' }), false), false);
|
||||
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'restricted' }), false), false);
|
||||
});
|
||||
|
||||
const stateRoom = (
|
||||
members: Array<{ sender: string; content: Record<string, unknown> }>,
|
||||
timeline: unknown[] = [],
|
||||
): Room =>
|
||||
({
|
||||
getLiveTimeline: () => ({
|
||||
getState: () => ({
|
||||
getStateEvents: () =>
|
||||
members.map((m) => ({
|
||||
getSender: () => m.sender,
|
||||
getStateKey: () => `_${m.sender}_DEV_m.call`,
|
||||
getContent: () => m.content,
|
||||
})),
|
||||
}),
|
||||
getEvents: () => timeline,
|
||||
}),
|
||||
}) as unknown as Room;
|
||||
|
||||
test('hasActiveCallMembership reads the room state: joined vs left (#312)', () => {
|
||||
const room = stateRoom([
|
||||
{ sender: '@bob:x', content: { application: 'm.call', call_id: '' } },
|
||||
{ sender: '@carol:x', content: {} },
|
||||
]);
|
||||
assert.equal(hasActiveCallMembership(room, '@bob:x'), true);
|
||||
assert.equal(hasActiveCallMembership(room, '@carol:x'), false, 'left = empty content');
|
||||
assert.equal(hasActiveCallMembership(room, '@dave:x'), false);
|
||||
});
|
||||
|
||||
test('declinedRing finds our own decline of that ring only (#312)', () => {
|
||||
const decline = (sender: string, rel: string) => ({
|
||||
getType: () => 'org.matrix.msc4310.rtc.decline',
|
||||
getSender: () => sender,
|
||||
getRelation: () => ({ event_id: rel }),
|
||||
});
|
||||
const room = stateRoom([], [decline('@me:x', '$ring1'), decline('@bob:x', '$ring2')]);
|
||||
assert.equal(declinedRing(room, '$ring1', '@me:x'), true);
|
||||
assert.equal(declinedRing(room, '$ring2', '@me:x'), false, "someone else's decline");
|
||||
assert.equal(declinedRing(room, '$ring3', '@me:x'), false);
|
||||
});
|
||||
|
||||
@@ -16,37 +16,3 @@ export const isRingableRoom = (room: Room, isDirect: boolean): boolean => {
|
||||
const isSpaceChild = getStateEvents(room, StateEvent.SpaceParent).length > 0;
|
||||
return !isSpaceChild && room.getJoinRule() === JoinRule.Invite;
|
||||
};
|
||||
|
||||
/** A call-membership state event that is still "in the call" (left = empty content). */
|
||||
const isActiveMembership = (content: Record<string, unknown> | undefined): boolean =>
|
||||
!!content &&
|
||||
(typeof content.application === 'string' ||
|
||||
(Array.isArray(content.memberships) && content.memberships.length > 0));
|
||||
|
||||
/**
|
||||
* [Gitea #312] Whether `userId` is in a call in `room` according to the room's
|
||||
* current STATE. After a reload the MatrixRTC session can be stale (a join and
|
||||
* leave replayed in one catch-up batch), but the state is right.
|
||||
*/
|
||||
export const hasActiveCallMembership = (room: Room, userId: string): boolean =>
|
||||
getStateEvents(room, StateEvent.GroupCallMemberPrefix).some(
|
||||
(ev) =>
|
||||
(ev.getSender() === userId || ev.getStateKey()?.includes(userId)) &&
|
||||
isActiveMembership(ev.getContent()),
|
||||
);
|
||||
|
||||
/**
|
||||
* [Gitea #312] Whether we already handled the ring `refEventId` points at:
|
||||
* declined it (our own RTCDecline relates to it) — e.g. replayed together with
|
||||
* the ring in the first sync after a restart, before any dismiss listener.
|
||||
*/
|
||||
export const declinedRing = (room: Room, refEventId: string, myUserId: string): boolean =>
|
||||
room
|
||||
.getLiveTimeline()
|
||||
.getEvents()
|
||||
.some(
|
||||
(ev) =>
|
||||
ev.getType() === 'org.matrix.msc4310.rtc.decline' &&
|
||||
ev.getSender() === myUserId &&
|
||||
ev.getRelation()?.event_id === refEventId,
|
||||
);
|
||||
|
||||
@@ -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|'],
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user