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
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:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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