Merge remote-tracking branch 'origin/lotus' into fix/filtered-timeline-double-count
CI / Secret scan (gitleaks) (pull_request) Successful in 29s
CI / Build & Quality Checks (pull_request) Failing after 11m33s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Skipped

# Conflicts:
#	src/app/components/CallEmbedProvider.tsx
This commit is contained in:
Lotus CI
2026-10-17 17:52:35 -04:00
23 changed files with 408 additions and 30 deletions
+41 -1
View File
@@ -1,7 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { Room } from 'matrix-js-sdk';
import { isRingableRoom } from './callRing';
import { declinedRing, hasActiveCallMembership, isRingableRoom } from './callRing';
const fakeRoom = (opts: { callRoom?: boolean; joinRule?: string; spaceParent?: boolean }): Room =>
({
@@ -27,3 +27,43 @@ 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);
});
+34
View File
@@ -16,3 +16,37 @@ 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,
);
+27 -1
View File
@@ -1,6 +1,13 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { isExpired, RETENTION_PRESETS, RETENTION_MIN_MS } from './retention';
import {
effectiveRetentionMs,
isExpired,
RETENTION_DELETE_GRACE_MS,
RETENTION_MIN_MS,
RETENTION_PRESETS,
retentionDeletesAllowed,
} from './retention';
// MSC1763 retention: `isExpired` decides whether a message is past the room's
// retention window. It must be strict (> window, not >=) and a disabled policy
@@ -40,3 +47,22 @@ test('presets: Off is 0 and the rest are strictly increasing, all >= the floor',
'all presets above the floor',
);
});
test('effectiveRetentionMs: tiny or invalid windows count as off (#298)', () => {
assert.equal(effectiveRetentionMs(1000), undefined, '1 s is a typo, not a policy');
assert.equal(effectiveRetentionMs(RETENTION_MIN_MS - 1), undefined);
assert.equal(effectiveRetentionMs(RETENTION_MIN_MS), RETENTION_MIN_MS);
assert.equal(effectiveRetentionMs(86_400_000), 86_400_000);
assert.equal(effectiveRetentionMs(0), undefined);
assert.equal(effectiveRetentionMs(-5), undefined);
assert.equal(effectiveRetentionMs('86400000'), undefined);
assert.equal(effectiveRetentionMs(Number.NaN), undefined);
});
test('retentionDeletesAllowed: no deletions in the first 24 h of a policy (#298)', () => {
const now = 1_000 * HOUR;
assert.equal(retentionDeletesAllowed(now - 2 * HOUR, now), false);
assert.equal(retentionDeletesAllowed(now - RETENTION_DELETE_GRACE_MS + 1, now), false);
assert.equal(retentionDeletesAllowed(now - RETENTION_DELETE_GRACE_MS, now), true);
assert.equal(retentionDeletesAllowed(now - 72 * HOUR, now), true);
});
+27 -2
View File
@@ -20,13 +20,38 @@ export const RETENTION_PRESETS: RetentionPreset[] = [
{ label: '1 Month', ms: 30 * DAY_MS },
];
/**
* [Gitea #298] A usable retention window, or `undefined`. Anything under
* RETENTION_MIN_MS is treated as off: a fat-fingered `max_lifetime: 1000` (set
* from any client by anyone with state power) used to blank every timeline and
* get opted-in users' messages redacted within a minute.
*/
export const effectiveRetentionMs = (maxLifetime: unknown): number | undefined =>
typeof maxLifetime === 'number' && Number.isFinite(maxLifetime) && maxLifetime >= RETENTION_MIN_MS
? maxLifetime
: undefined;
/** The room's active retention window in ms, or `undefined` when unset/disabled. */
export const getRoomRetentionMs = (room: Room): number | undefined => {
const event = room.currentState.getStateEvents(StateEvent.RoomRetention, '');
const ms = event?.getContent<RetentionContent>()?.max_lifetime;
return typeof ms === 'number' && ms > 0 ? ms : undefined;
return effectiveRetentionMs(event?.getContent<RetentionContent>()?.max_lifetime);
};
/**
* [Gitea #298] Deletion grace: the auto-delete sweeper ignores a retention
* policy for this long after it was set, so a mistaken or malicious change can
* be reverted before anything is permanently redacted. (Hiding expired
* messages locally is not destructive and applies at once.)
*/
export const RETENTION_DELETE_GRACE_MS = DAY_MS;
export const retentionDeletesAllowed = (policySetAtMs: number, nowMs: number): boolean =>
nowMs - policySetAtMs >= RETENTION_DELETE_GRACE_MS;
/** When the room's current retention policy was set (its state event's ts). */
export const getRoomRetentionSetAt = (room: Room): number | undefined =>
room.currentState.getStateEvents(StateEvent.RoomRetention, '')?.getTs();
/** True when an event at `tsMs` has passed the `maxLifetimeMs` retention window. */
export const isExpired = (tsMs: number, maxLifetimeMs: number, nowMs: number): boolean =>
maxLifetimeMs > 0 && nowMs - tsMs > maxLifetimeMs;
+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);