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:
@@ -675,6 +675,8 @@ Run the axe DevTools extension (or Lighthouse → Accessibility) on a room view,
|
||||
|
||||
## Outstanding verification backlog
|
||||
|
||||
**Disappearing Messages (MSC1763 `m.room.retention`, 2026-07):** In Room Settings → General → **Message Retention**, an admin picks Off / 1 Day / 1 Week / 1 Month (non-admins see the buttons disabled). After setting e.g. 1 Day, messages older than a day **vanish from the timeline** for everyone in Lotus (toggle Settings → General → **Show Hidden Events** to reveal them again). Setting back to **Off** restores them. Separately, each user can enable Settings → General → **Enforce Message Retention** (default OFF) → their OWN expired messages then get **permanently redacted** within ~30 s (verify: OTHER people's messages are NEVER redacted by this; only your own). Note true server-side purge also needs Synapse `retention:` configured.
|
||||
|
||||
**Mark as Unread + Low Priority (MSC2867 / m.lowpriority, 2026-07):** Right-click a room in the sidebar → **Mark as Unread** puts a dot on the row (bold name) even with no new messages; opening/reading the room clears it, and it syncs to another device. **Mark as Read** on a marked room clears it too. Right-click → **Add to Low Priority** moves the room into a collapsed "Low Priority" category at the bottom of the room list (and removes it from Favorites if it was there, and vice-versa); **Remove from Low Priority** returns it to Rooms.
|
||||
|
||||
**Windows rich toast (D6, 2026-07 — desktop/Windows build only):** get a message notification while the desktop app is backgrounded → the toast is attributed to **Lotus Chat** (not "PowerShell"/generic) and shows an inline **reply box + Send**; typing a reply + Send **posts it to that room**; clicking the toast body **opens the room**. Previously these silently fell back to a plain toast (no reply/click). If it still falls back, check that a `Lotus Chat.lnk` exists in the Start-Menu Programs folder.
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { Box, Button, color, Spinner, Text } from 'folds';
|
||||
import { MatrixError } from 'matrix-js-sdk';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../../room-settings/styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useRoom } from '../../../hooks/useRoom';
|
||||
import { StateEvent } from '../../../../types/matrix/room';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { useStateEvent } from '../../../hooks/useStateEvent';
|
||||
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
|
||||
import { RetentionContent, RETENTION_PRESETS } from '../../../utils/retention';
|
||||
|
||||
type RoomRetentionProps = {
|
||||
permissions: RoomPermissionsAPI;
|
||||
};
|
||||
export function RoomRetention({ permissions }: RoomRetentionProps) {
|
||||
const mx = useMatrixClient();
|
||||
const room = useRoom();
|
||||
|
||||
const canEdit = permissions.stateEvent(StateEvent.RoomRetention, mx.getSafeUserId());
|
||||
|
||||
const event = useStateEvent(room, StateEvent.RoomRetention);
|
||||
const currentMs = event?.getContent<RetentionContent>().max_lifetime ?? 0;
|
||||
|
||||
const [submitState, submit] = useAsyncCallback(
|
||||
useCallback(
|
||||
async (ms: number) => {
|
||||
const content: RetentionContent = ms > 0 ? { max_lifetime: ms } : {};
|
||||
// Lotus custom-state convention: cast the type key (RoomRetention isn't a
|
||||
// typed key in the SDK's StateEvents map).
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await mx.sendStateEvent(room.roomId, StateEvent.RoomRetention as any, content);
|
||||
},
|
||||
[mx, room.roomId],
|
||||
),
|
||||
);
|
||||
const submitting = submitState.status === AsyncStatus.Loading;
|
||||
|
||||
return (
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Message Retention"
|
||||
description="Messages older than this window disappear from the timeline. Each member can opt in to permanently delete their own expired messages in Settings → General; full server-side deletion also requires homeserver retention to be configured."
|
||||
>
|
||||
<Box gap="200" alignItems="Center" style={{ flexWrap: 'wrap' }}>
|
||||
{RETENTION_PRESETS.map((preset) => {
|
||||
const active = currentMs === preset.ms;
|
||||
return (
|
||||
<Button
|
||||
key={preset.label}
|
||||
type="button"
|
||||
size="300"
|
||||
variant={active ? 'Primary' : 'Secondary'}
|
||||
fill={active ? 'Solid' : 'Soft'}
|
||||
radii="300"
|
||||
disabled={!canEdit || submitting}
|
||||
onClick={() => submit(preset.ms)}
|
||||
>
|
||||
<Text size="B300">{preset.label}</Text>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{submitting && <Spinner size="100" variant="Secondary" />}
|
||||
</Box>
|
||||
{submitState.status === AsyncStatus.Error && (
|
||||
<Text style={{ color: color.Critical.Main }} size="T200">
|
||||
{(submitState.error as MatrixError).message}
|
||||
</Text>
|
||||
)}
|
||||
</SettingTile>
|
||||
</SequenceCard>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export * from './RoomJoinRules';
|
||||
export * from './RoomProfile';
|
||||
export * from './RoomPublish';
|
||||
export * from './RoomQuality';
|
||||
export * from './RoomRetention';
|
||||
export * from './RoomShareInvite';
|
||||
export * from './RoomUpgrade';
|
||||
export * from './RoomVoiceLimit';
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
RoomPublishedAddresses,
|
||||
RoomPublish,
|
||||
RoomQuality,
|
||||
RoomRetention,
|
||||
RoomShareInvite,
|
||||
RoomUpgrade,
|
||||
RoomVoiceLimit,
|
||||
@@ -56,6 +57,10 @@ export function General({ requestClose }: GeneralProps) {
|
||||
<RoomEncryption permissions={permissions} />
|
||||
<RoomPublish permissions={permissions} />
|
||||
</Box>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Message Retention</Text>
|
||||
<RoomRetention permissions={permissions} />
|
||||
</Box>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Voice</Text>
|
||||
<RoomVoiceLimit permissions={permissions} />
|
||||
|
||||
@@ -109,6 +109,8 @@ import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
|
||||
import { ThreadSummary } from './thread/ThreadSummary';
|
||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { RetentionContent, isExpired } from '../../utils/retention';
|
||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
|
||||
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
||||
@@ -468,6 +470,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
|
||||
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
|
||||
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
|
||||
// MSC1763 retention: messages older than this window are hidden from the
|
||||
// timeline (unless "show hidden events" is on). Reactive so a policy change
|
||||
// re-renders. `undefined` = no policy.
|
||||
const retentionEvent = useStateEvent(room, StateEvent.RoomRetention);
|
||||
const retentionMs = retentionEvent?.getContent<RetentionContent>().max_lifetime;
|
||||
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||
|
||||
@@ -2043,6 +2050,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
if (eventSender && ignoredUsersSet.has(eventSender)) {
|
||||
return null;
|
||||
}
|
||||
// MSC1763: hide messages past the room's retention window (disappearing
|
||||
// messages). Power users can still inspect via "show hidden events".
|
||||
if (retentionMs && !showHiddenEvents && isExpired(mEvent.getTs(), retentionMs, Date.now())) {
|
||||
return null;
|
||||
}
|
||||
if (mEvent.isRedacted() && !showHiddenEvents) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||
const t = mEvent.getType();
|
||||
|
||||
@@ -2251,6 +2251,10 @@ function Messages() {
|
||||
const [urlPreview, setUrlPreview] = useSetting(settingsAtom, 'urlPreview');
|
||||
const [encUrlPreview, setEncUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
|
||||
const [showHiddenEvents, setShowHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
|
||||
const [enforceRetentionLocally, setEnforceRetentionLocally] = useSetting(
|
||||
settingsAtom,
|
||||
'enforceRetentionLocally',
|
||||
);
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
@@ -2348,6 +2352,19 @@ function Messages() {
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Enforce Message Retention"
|
||||
description="Permanently delete your own messages once a room's retention window (Room Settings → Message Retention) has passed. Off by default; only affects your own messages."
|
||||
after={
|
||||
<Switch
|
||||
variant="Primary"
|
||||
value={enforceRetentionLocally}
|
||||
onChange={setEnforceRetentionLocally}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/accou
|
||||
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
|
||||
import { toastQueueAtom } from '../../state/toast';
|
||||
import { useReminders } from '../../hooks/useReminders';
|
||||
import { getRoomRetentionMs, isExpired } from '../../utils/retention';
|
||||
import { useTauriUpdater } from '../../hooks/useTauriUpdater';
|
||||
import { TauriDesktopFeatures } from '../../components/TauriDesktopFeatures';
|
||||
import { KeyboardShortcutsDialog, useKeyboardShortcutsTrigger } from '../../features/shortcuts';
|
||||
@@ -687,6 +688,62 @@ function ReminderMonitor() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// MSC1763: opt-in local enforcement of room retention. When enabled, permanently
|
||||
// redacts the user's OWN messages once a room's retention window passes. Own-only
|
||||
// (no redact PL needed); scoped to loaded live-timeline events; dedupes in-flight
|
||||
// redactions and retries on the next tick. Default-off, so nothing auto-deletes
|
||||
// unless the user turns it on.
|
||||
function RetentionSweeper() {
|
||||
const mx = useMatrixClient();
|
||||
const [enforceRetentionLocally] = useSetting(settingsAtom, 'enforceRetentionLocally');
|
||||
const enabledRef = useRef(enforceRetentionLocally);
|
||||
enabledRef.current = enforceRetentionLocally;
|
||||
const redactingRef = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
const check = () => {
|
||||
if (!enabledRef.current) return;
|
||||
const myId = mx.getUserId();
|
||||
if (!myId) return;
|
||||
const now = Date.now();
|
||||
mx.getRooms().forEach((room) => {
|
||||
const maxLifetime = getRoomRetentionMs(room);
|
||||
if (!maxLifetime) return;
|
||||
room
|
||||
.getLiveTimeline()
|
||||
.getEvents()
|
||||
.forEach((ev) => {
|
||||
const evId = ev.getId();
|
||||
if (!evId || ev.getSender() !== myId) return;
|
||||
if (ev.isState() || ev.isRedacted() || ev.isSending()) return;
|
||||
const t = ev.getType();
|
||||
// Only actual messages — never our membership/topic/reactions.
|
||||
if (t !== 'm.room.message' && t !== 'm.room.encrypted' && t !== 'm.sticker') return;
|
||||
if (!isExpired(ev.getTs(), maxLifetime, now)) return;
|
||||
if (redactingRef.current.has(evId)) return;
|
||||
redactingRef.current.add(evId);
|
||||
mx.redactEvent(room.roomId, evId, undefined, { reason: 'expired' }).catch(() => {
|
||||
redactingRef.current.delete(evId);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
check();
|
||||
const interval = setInterval(check, 30_000);
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === 'visible') check();
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
document.removeEventListener('visibilitychange', onVisible);
|
||||
};
|
||||
}, [mx]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const TAURI_UPDATE_CHECK_INTERVAL = 12 * 60 * 60_000; // 12 hours
|
||||
const TAURI_UPDATE_LAST_CHECK_KEY = 'lotus.tauriUpdateLastCheck';
|
||||
|
||||
@@ -773,6 +830,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
||||
<InviteNotifications />
|
||||
<MessageNotifications />
|
||||
<ReminderMonitor />
|
||||
<RetentionSweeper />
|
||||
<TauriUpdateFeature />
|
||||
<TauriDesktopFeatures />
|
||||
<LotusDenoiseFeature />
|
||||
|
||||
@@ -183,6 +183,9 @@ export interface Settings {
|
||||
urlPreview: boolean;
|
||||
encUrlPreview: boolean;
|
||||
showHiddenEvents: boolean;
|
||||
// [MSC1763] Opt-in: permanently redact your OWN messages once a room's
|
||||
// retention window passes (default off — nothing auto-deletes by surprise).
|
||||
enforceRetentionLocally: boolean;
|
||||
legacyUsernameColor: boolean;
|
||||
|
||||
showNotifications: boolean;
|
||||
@@ -288,6 +291,7 @@ const defaultSettings: Settings = {
|
||||
urlPreview: true,
|
||||
encUrlPreview: true,
|
||||
showHiddenEvents: false,
|
||||
enforceRetentionLocally: false,
|
||||
legacyUsernameColor: false,
|
||||
|
||||
showNotifications: true,
|
||||
|
||||
@@ -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;
|
||||
@@ -29,6 +29,8 @@ export enum StateEvent {
|
||||
RoomPinnedEvents = 'm.room.pinned_events',
|
||||
RoomEncryption = 'm.room.encryption',
|
||||
RoomHistoryVisibility = 'm.room.history_visibility',
|
||||
// [MSC1763] Per-room message retention policy (disappearing messages).
|
||||
RoomRetention = 'm.room.retention',
|
||||
RoomGuestAccess = 'm.room.guest_access',
|
||||
RoomServerAcl = 'm.room.server_acl',
|
||||
RoomTombstone = 'm.room.tombstone',
|
||||
|
||||
Reference in New Issue
Block a user