From 82e52e1bc74a0ecd75d2d6ded67e246bde278da4 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 3 Jul 2026 11:23:14 -0400 Subject: [PATCH] feat(rooms): Disappearing Messages (MSC1763 m.room.retention) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- LOTUS_TESTING.md | 2 + .../common-settings/general/RoomRetention.tsx | 80 +++++++++++++++++++ .../features/common-settings/general/index.ts | 1 + .../room-settings/general/General.tsx | 5 ++ src/app/features/room/RoomTimeline.tsx | 12 +++ src/app/features/settings/general/General.tsx | 17 ++++ src/app/pages/client/ClientNonUIFeatures.tsx | 58 ++++++++++++++ src/app/state/settings.ts | 4 + src/app/utils/retention.test.ts | 42 ++++++++++ src/app/utils/retention.ts | 32 ++++++++ src/types/matrix/room.ts | 2 + 11 files changed, 255 insertions(+) create mode 100644 src/app/features/common-settings/general/RoomRetention.tsx create mode 100644 src/app/utils/retention.test.ts create mode 100644 src/app/utils/retention.ts diff --git a/LOTUS_TESTING.md b/LOTUS_TESTING.md index b126b3a40..dfa0441d5 100644 --- a/LOTUS_TESTING.md +++ b/LOTUS_TESTING.md @@ -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. diff --git a/src/app/features/common-settings/general/RoomRetention.tsx b/src/app/features/common-settings/general/RoomRetention.tsx new file mode 100644 index 000000000..753a47b33 --- /dev/null +++ b/src/app/features/common-settings/general/RoomRetention.tsx @@ -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().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 ( + + + + {RETENTION_PRESETS.map((preset) => { + const active = currentMs === preset.ms; + return ( + + ); + })} + {submitting && } + + {submitState.status === AsyncStatus.Error && ( + + {(submitState.error as MatrixError).message} + + )} + + + ); +} diff --git a/src/app/features/common-settings/general/index.ts b/src/app/features/common-settings/general/index.ts index 883ad3881..98f76cbc7 100644 --- a/src/app/features/common-settings/general/index.ts +++ b/src/app/features/common-settings/general/index.ts @@ -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'; diff --git a/src/app/features/room-settings/general/General.tsx b/src/app/features/room-settings/general/General.tsx index 8b8c7cd57..1422edb2c 100644 --- a/src/app/features/room-settings/general/General.tsx +++ b/src/app/features/room-settings/general/General.tsx @@ -12,6 +12,7 @@ import { RoomPublishedAddresses, RoomPublish, RoomQuality, + RoomRetention, RoomShareInvite, RoomUpgrade, RoomVoiceLimit, @@ -56,6 +57,10 @@ export function General({ requestClose }: GeneralProps) { + + Message Retention + + Voice diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index e5341769e..f21768cf2 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -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().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(); diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 2f6e127c5..a547ba2d1 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -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 ( @@ -2348,6 +2352,19 @@ function Messages() { } /> + + + } + /> + ); } diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 7d72433ca..2e3171d44 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -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>(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) { + diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 43c3db323..e57c47491 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -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, diff --git a/src/app/utils/retention.test.ts b/src/app/utils/retention.test.ts new file mode 100644 index 000000000..1155eb7df --- /dev/null +++ b/src/app/utils/retention.test.ts @@ -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', + ); +}); diff --git a/src/app/utils/retention.ts b/src/app/utils/retention.ts new file mode 100644 index 000000000..4573dcc12 --- /dev/null +++ b/src/app/utils/retention.ts @@ -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()?.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; diff --git a/src/types/matrix/room.ts b/src/types/matrix/room.ts index 3bfd41246..ab2b1d8e5 100644 --- a/src/types/matrix/room.ts +++ b/src/types/matrix/room.ts @@ -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',