feat(rooms): Disappearing Messages (MSC1763 m.room.retention)
B1 of the Matrix protocol-gaps roadmap, gate-green (688 tests): - StateEvent.RoomRetention + a shared utils/retention.ts (presets, isExpired, getRoomRetentionMs) with tests. - RoomRetention settings control (PL-gated preset buttons Off/1d/1w/1m) in Room Settings → General → Message Retention. - Timeline hides events past the room's max_lifetime (gated behind Show Hidden Events, like redactions) — messages visually disappear, losslessly. - Opt-in setting enforceRetentionLocally (default OFF) + a headless RetentionSweeper that permanently redacts the user's OWN expired messages (own-only, loaded-timeline scope, dedupe + retry). Nothing auto-deletes unless the user opts in. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { isExpired, RETENTION_PRESETS, RETENTION_MIN_MS } 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
|
||||
// (0) must never expire anything.
|
||||
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
|
||||
test('isExpired: an event older than the window is expired', () => {
|
||||
const now = 10 * HOUR;
|
||||
assert.equal(isExpired(now - 2 * HOUR, HOUR, now), true);
|
||||
});
|
||||
|
||||
test('isExpired: an event within the window is NOT expired', () => {
|
||||
const now = 10 * HOUR;
|
||||
assert.equal(isExpired(now - HOUR / 2, HOUR, now), false);
|
||||
});
|
||||
|
||||
test('isExpired: exactly at the boundary is NOT expired (strict >)', () => {
|
||||
const now = 10 * HOUR;
|
||||
assert.equal(isExpired(now - HOUR, HOUR, now), false);
|
||||
});
|
||||
|
||||
test('isExpired: a disabled policy (0 / negative) never expires', () => {
|
||||
const now = 10 * HOUR;
|
||||
assert.equal(isExpired(now - 100 * HOUR, 0, now), false);
|
||||
assert.equal(isExpired(0, -1, now), false);
|
||||
});
|
||||
|
||||
test('presets: Off is 0 and the rest are strictly increasing, all >= the floor', () => {
|
||||
assert.equal(RETENTION_PRESETS[0].ms, 0);
|
||||
const nonZero = RETENTION_PRESETS.slice(1).map((p) => p.ms);
|
||||
for (let i = 1; i < nonZero.length; i += 1) {
|
||||
assert.ok(nonZero[i] > nonZero[i - 1], 'presets increase');
|
||||
}
|
||||
assert.ok(
|
||||
nonZero.every((ms) => ms >= RETENTION_MIN_MS),
|
||||
'all presets above the floor',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { StateEvent } from '../../types/matrix/room';
|
||||
|
||||
// MSC1763 — per-room message retention (`m.room.retention`). `max_lifetime` is a
|
||||
// duration in milliseconds after which a message is considered expired.
|
||||
export type RetentionContent = {
|
||||
max_lifetime?: number;
|
||||
};
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Floor to avoid foot-guns (an admin fat-fingering a tiny value nuking a room).
|
||||
export const RETENTION_MIN_MS = 10 * 60 * 1000;
|
||||
|
||||
export type RetentionPreset = { label: string; ms: number };
|
||||
export const RETENTION_PRESETS: RetentionPreset[] = [
|
||||
{ label: 'Off', ms: 0 },
|
||||
{ label: '1 Day', ms: DAY_MS },
|
||||
{ label: '1 Week', ms: 7 * DAY_MS },
|
||||
{ label: '1 Month', ms: 30 * DAY_MS },
|
||||
];
|
||||
|
||||
/** 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;
|
||||
};
|
||||
|
||||
/** 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;
|
||||
Reference in New Issue
Block a user