From bbb203408013eaef9212fd3e6068cf8f04ad6346 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Fri, 16 Oct 2026 21:19:47 -0400 Subject: [PATCH 1/9] fix(rooms): "Rename for me" names in search results, inbox and threads (#336) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #359: the personal room name now also shows in message-search result groups and the search room filter (whose room search matches it too), Inbox → Notifications, the threads list and the thread panel header. Verified headless with general renamed to "Zebra Lounge": a global message search and Inbox → Notifications both show "Zebra Lounge". Fixes #336 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/features/message-search/SearchFilters.tsx | 15 +++++++++++---- .../features/message-search/SearchResultGroup.tsx | 4 +++- src/app/features/room/thread/ThreadPanel.tsx | 4 +++- src/app/features/room/thread/ThreadsListPanel.tsx | 4 +++- src/app/pages/client/inbox/Notifications.tsx | 4 +++- 5 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/app/features/message-search/SearchFilters.tsx b/src/app/features/message-search/SearchFilters.tsx index 1ea4c25d2..3232f3cac 100644 --- a/src/app/features/message-search/SearchFilters.tsx +++ b/src/app/features/message-search/SearchFilters.tsx @@ -43,6 +43,7 @@ import { DebounceOptions, useDebounce } from '../../hooks/useDebounce'; import { VirtualTile } from '../../components/virtualizer'; import { stopPropagation } from '../../utils/keyboard'; import { MsgTypeFilter } from './useMessageSearch'; +import { useLocalRoomNames } from '../../hooks/useRoomMeta'; const MSG_TYPE_FILTER_OPTIONS: { msgType: MsgTypeFilter; label: string; icon: IconSrc }[] = [ { msgType: 'm.image', label: 'Images', icon: Icons.Photo }, @@ -137,14 +138,19 @@ type SelectRoomButtonProps = { onChange: (rooms?: string[]) => void; }; function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButtonProps) { + const localNames = useLocalRoomNames(); const mx = useMatrixClient(); const scrollRef = useRef(null); const [menuAnchor, setMenuAnchor] = useState(); const [localSelected, setLocalSelected] = useState(selectedRooms); const getRoomNameStr: SearchItemStrGetter = useCallback( - (rId) => mx.getRoom(rId)?.name ?? rId, - [mx], + (rId) => { + const name = mx.getRoom(rId)?.name ?? rId; + // [Gitea #336] match the personal "Rename for me" name too + return localNames[rId] ? [localNames[rId], name] : name; + }, + [mx, localNames], ); const [searchResult, _searchRoom, resetSearch] = useAsyncSearch( @@ -292,7 +298,7 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto } > - {room.name} + {localNames[room.roomId] ?? room.name} @@ -701,6 +707,7 @@ export function SearchFilters({ pinnedOnly, onTogglePinnedOnly, }: SearchFiltersProps) { + const localNames = useLocalRoomNames(); const mx = useMatrixClient(); return ( @@ -748,7 +755,7 @@ export function SearchFilters({ } after={} > - {room.name} + {localNames[room.roomId] ?? room.name} ); })} diff --git a/src/app/features/message-search/SearchResultGroup.tsx b/src/app/features/message-search/SearchResultGroup.tsx index ae9721549..da91e7e0d 100644 --- a/src/app/features/message-search/SearchResultGroup.tsx +++ b/src/app/features/message-search/SearchResultGroup.tsx @@ -50,6 +50,7 @@ import { } from '../../hooks/useMemberPowerTag'; import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; +import { useLocalRoomNames } from '../../hooks/useRoomMeta'; type SearchResultGroupProps = { room: Room; @@ -73,6 +74,7 @@ export function SearchResultGroup({ hour24Clock, dateFormatString, }: SearchResultGroupProps) { + const localNames = useLocalRoomNames(); const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const highlightRegex = useMemo(() => makeHighlightRegex(highlights), [highlights]); @@ -212,7 +214,7 @@ export function SearchResultGroup({ /> - {room.name} + {localNames[room.roomId] ?? room.name} diff --git a/src/app/features/room/thread/ThreadPanel.tsx b/src/app/features/room/thread/ThreadPanel.tsx index a00560cf8..97e30de66 100644 --- a/src/app/features/room/thread/ThreadPanel.tsx +++ b/src/app/features/room/thread/ThreadPanel.tsx @@ -32,6 +32,7 @@ import { useThreadNotificationMode } from '../../../hooks/useThreadNotifications import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter'; import { getMemberName } from '../../../utils/room'; import { ThreadNotificationMode } from '../../../utils/threadNotifications'; +import { useLocalRoomNames } from '../../../hooks/useRoomMeta'; type ThreadPanelHeaderProps = { room: Room; @@ -39,6 +40,7 @@ type ThreadPanelHeaderProps = { requestClose: () => void; }; function ThreadPanelHeader({ room, threadId, requestClose }: ThreadPanelHeaderProps) { + const localNames = useLocalRoomNames(); const mode = useThreadNotificationMode(room.roomId, threadId); const { format } = useTimestampFormatter(); // [Gitea #165] Who started the thread and when — the first thing you want @@ -55,7 +57,7 @@ function ThreadPanelHeader({ room, threadId, requestClose }: ThreadPanelHeaderPr Thread - {room.name} + {localNames[room.roomId] ?? room.name} {startedBy && root && ` · started by ${startedBy}, ${format(root.getTs())}`} diff --git a/src/app/features/room/thread/ThreadsListPanel.tsx b/src/app/features/room/thread/ThreadsListPanel.tsx index 262ede7d4..97ddb2d39 100644 --- a/src/app/features/room/thread/ThreadsListPanel.tsx +++ b/src/app/features/room/thread/ThreadsListPanel.tsx @@ -48,6 +48,7 @@ import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter'; import { formatRelativeAge } from '../../../utils/formatTimestamp'; import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; +import { useLocalRoomNames } from '../../../hooks/useRoomMeta'; // Persisted across panel opens (the panel unmounts on close). getOnInit reads // localStorage synchronously so the chosen filter/sort apply on first render. @@ -235,6 +236,7 @@ export type ThreadsListPanelProps = { onOpenThread: (threadId: string) => void; }; export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPanelProps) { + const localNames = useLocalRoomNames(); const { navigateRoom } = useRoomNavigate(); const screenSize = useScreenSizeContext(); const handleJump = useCallback( @@ -361,7 +363,7 @@ export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPan Threads - {room.name} + {localNames[room.roomId] ?? room.name} {totalUnread > 0 && ( diff --git a/src/app/pages/client/inbox/Notifications.tsx b/src/app/pages/client/inbox/Notifications.tsx index 02f12dcd4..7049a7042 100644 --- a/src/app/pages/client/inbox/Notifications.tsx +++ b/src/app/pages/client/inbox/Notifications.tsx @@ -98,6 +98,7 @@ import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag'; import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { PresenceRingAvatar } from '../../../components/presence'; import { AvatarDecoration } from '../../../components/avatar-decoration/AvatarDecoration'; +import { useLocalRoomNames } from '../../../hooks/useRoomMeta'; type RoomNotificationsGroup = { roomId: string; @@ -223,6 +224,7 @@ function RoomNotificationsGroupComp({ hour24Clock, dateFormatString, }: RoomNotificationsGroupProps) { + const localNames = useLocalRoomNames(); const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const unread = useRoomUnread(room.roomId, roomToUnreadAtom); @@ -428,7 +430,7 @@ function RoomNotificationsGroupComp({ /> - {room.name} + {localNames[room.roomId] ?? room.name} From 05d7e87d2622c66ca63ad2fef72385e0a50c9441 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Fri, 16 Oct 2026 21:28:33 -0400 Subject: [PATCH 2/9] fix(calls): a finished call no longer rings again after a reload (#312) After a reload/restart within a ring's lifetime, the catch-up sync replays the ring together with the caller's hang-up and our own decline, and the MatrixRTC session can keep the stale join. The client rang again for up to 90 s and showed "1 Live" (call screen instead of chat) for minutes. - Before ringing, check the room STATE (utils/callRing.ts, unit-tested): no ring if we already declined that ring or are in the call; if the caller has no active membership yet, re-check after 4 s (a real call's membership can land just after its ring) and ring only if they joined. - The "caller gone" dismissal also consults room state, not only the possibly stale session. - When sync becomes live (startup or reconnect), recompute every active RTC session from the current state, clearing a stale "Live". Verified headless (Bob's membership + ring in an invite-only room): live ring rings; hang-up stops it; reload within the window: before rang again + "1 Live", now no ring and no "Live"; a ring that arrives just before the membership still rings. Fixes #312 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/components/CallEmbedProvider.tsx | 50 ++++++++++++++++++++++-- src/app/utils/callRing.test.ts | 42 +++++++++++++++++++- src/app/utils/callRing.ts | 34 ++++++++++++++++ 3 files changed, 122 insertions(+), 4 deletions(-) diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index 0660d27e8..17e054da5 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -18,8 +18,10 @@ import { toRem, } from 'folds'; import { + ClientEvent, EventTimelineSetHandlerMap, EventType, + SyncState, RelationType, Room, RoomEvent, @@ -72,7 +74,7 @@ import { useSetting } from '../state/hooks/settings'; import { useCallPreferences } from '../state/hooks/callPreferences'; import { settingsAtom } from '../state/settings'; import { getStateEvent, getMemberName } from '../utils/room'; -import { isRingableRoom } from '../utils/callRing'; +import { declinedRing, hasActiveCallMembership, isRingableRoom } from '../utils/callRing'; import { StateEvent } from '../../types/matrix/room'; import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels'; import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators'; @@ -596,11 +598,50 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps) refEventId, }; - setCallInfo(info); + // [Gitea #312] Don't ring for a call that is already over or handled: after + // a reload the first sync replays the ring together with the caller's + // hang-up and our own decline. Check the room STATE (not the possibly + // stale RTC session). The caller's membership can arrive just after the + // ring, so an absent one gets a short grace period before giving up. + const myUserId = mx.getSafeUserId(); + if (declinedRing(room, refEventId, myUserId) || hasActiveCallMembership(room, myUserId)) { + return; + } + if (hasActiveCallMembership(room, sender)) { + setCallInfo(info); + return; + } + setTimeout(() => { + if (Date.now() >= senderTs + lifetime) return; + if (declinedRing(room, refEventId, myUserId) || hasActiveCallMembership(room, myUserId)) { + return; + } + if (hasActiveCallMembership(room, sender)) setCallInfo(info); + }, 4000); }, [mx, directs, callEmbed, setToast], ); + // [Gitea #312] A catch-up sync (after a reload or reconnect) can replay a + // call's join and leave in one batch, and the RTC session may keep the join + // (stale "1 Live", call screen instead of chat). Once sync is live again, + // recompute every active session from the room's current state. + useEffect(() => { + const onSync = (state: SyncState, prev: SyncState | null) => { + if (state !== SyncState.Syncing || prev === SyncState.Syncing) return; + mx.getRooms().forEach((r) => { + mx.matrixRTC + .getActiveRoomSession(r) + ?._onRTCSessionMemberUpdate() + .catch(() => undefined); + }); + }; + mx.on(ClientEvent.Sync, onSync); + return () => { + mx.removeListener(ClientEvent.Sync, onSync); + }; + }, [mx]); + useEffect(() => { mx.on(RoomEvent.Timeline, handleTimelineEvent); return () => { @@ -629,7 +670,10 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps) const { memberships } = session; if (memberships.some((m) => m.sender === myUserId)) dismiss(); // answered elsewhere - else if (memberships.length === 0) dismiss(); // caller gone + else if (memberships.length === 0) + dismiss(); // caller gone + // [Gitea #312] The session can be stale after a reload; the room state isn't. + else if (!hasActiveCallMembership(room, callInfo.sender)) dismiss(); }; const onTimeline: EventTimelineSetHandlerMap[RoomEvent.Timeline] = ( event, diff --git a/src/app/utils/callRing.test.ts b/src/app/utils/callRing.test.ts index 22a5c0c1d..ac96ae3ee 100644 --- a/src/app/utils/callRing.test.ts +++ b/src/app/utils/callRing.test.ts @@ -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 }>, + 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); +}); diff --git a/src/app/utils/callRing.ts b/src/app/utils/callRing.ts index c705a3df8..adbdd5e42 100644 --- a/src/app/utils/callRing.ts +++ b/src/app/utils/callRing.ts @@ -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 | 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, + ); From 02950499eac0307d25edcc7b53029779f9c499c9 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Fri, 16 Oct 2026 21:31:55 -0400 Subject: [PATCH 3/9] fix(startup): Firefox no longer hangs on "Heating up" after login (#303) initClient awaited navigator.storage.persist() before creating the client. Chromium and WebKit answer at once, but Firefox shows a permission prompt and leaves the promise pending until it's answered, so the app sat on "Heating up" (no sync, no crypto, no IndexedDB) for every Firefox user who didn't notice the prompt. The request is still made (KE-1 only needs that), but startup waits at most 1.5 s for the answer. The comment claiming persist() never prompts is corrected. Verified in Playwright Firefox with the prompt left unanswered (persist() still pending): before, still "Heating up" after 20 s; now the room list after 12 s. Fixes #303 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/client/initMatrix.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index ca33ebd39..605d0f300 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -20,9 +20,12 @@ export const IDB_VERSION_CONFLICT = 'IDB_VERSION_CONFLICT'; * session. When that happens the device "resurrects" with a blank key store and the * client re-uploads a one-time key at an id Synapse already holds → a permanent * `400 M_UNKNOWN: … already exists` upload-conflict storm (and, downstream, - * undecryptable to-device/media keys). `persist()` grants based on engagement / PWA - * install and shows no prompt; denial/absence is non-fatal. + * undecryptable to-device/media keys). Chromium/WebKit answer `persist()` at once + * (engagement / PWA install); Firefox shows a permission prompt and the promise + * stays pending until it's answered. Denial/absence is non-fatal. */ +const PERSIST_WAIT_MS = 1500; + export const requestPersistentStorage = async (): Promise => { try { if (!navigator.storage?.persist) return false; @@ -39,7 +42,15 @@ export const requestPersistentStorage = async (): Promise => { export const initClient = async (session: Session): Promise => { // Protect the crypto store from eviction before we create and write to it. - await requestPersistentStorage(); + // [Gitea #303] Never block startup on the answer: Firefox prompts and leaves + // the promise pending until the user responds, which froze the app on + // "Heating up". KE-1 only needs the request made; give it 1.5 s at most. + await Promise.race([ + requestPersistentStorage(), + new Promise((resolve) => { + setTimeout(() => resolve(false), PERSIST_WAIT_MS); + }), + ]); const indexedDBStore = new IndexedDBStore({ indexedDB: globalThis.indexedDB, From 8b2322f751a0ae1bcb839bbb4b9dfee5510a12d6 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Fri, 16 Oct 2026 21:37:16 -0400 Subject: [PATCH 4/9] fix(retention): a tiny or brand-new retention value can't wipe a room (#298) m.room.retention can be set to anything by anyone with state power. A fat-fingered `max_lifetime: 1000` blanked the timeline for every Lotus user and, for members with "Enforce Message Retention" on, the sweeper redacted all their messages within a minute. The RETENTION_MIN_MS floor existed but was never applied. - effectiveRetentionMs(): anything under 10 min counts as off; used by getRoomRetentionMs (sweeper) and the timeline's hiding. - The sweeper never deletes under a policy less than 24 h old, so a mistake or a malicious change can be reverted before anything is lost. (Local hiding of expired messages still applies at once.) Verified headless (the issue's repro, enforcement on): after max_lifetime 1000, all 4 messages stay visible and none are redacted after 45 s (before: timeline blank, all 4 redacted). Unit tests for the floor and the grace window. Fixes #298 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/features/room/RoomTimeline.tsx | 7 +++-- src/app/pages/client/ClientNonUIFeatures.tsx | 10 ++++++- src/app/utils/retention.test.ts | 28 ++++++++++++++++++- src/app/utils/retention.ts | 29 ++++++++++++++++++-- 4 files changed, 68 insertions(+), 6 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index fd69479c4..2500feda6 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -121,7 +121,7 @@ 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 { RetentionContent, effectiveRetentionMs, isExpired } from '../../utils/retention'; import { useKeyDown } from '../../hooks/useKeyDown'; import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange'; import { RenderMessageContent } from '../../components/RenderMessageContent'; @@ -494,7 +494,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli // 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; + // [Gitea #298] Same floor as everywhere else: a tiny value must not blank the room. + const retentionMs = effectiveRetentionMs( + retentionEvent?.getContent().max_lifetime, + ); const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools'); const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal'); diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 84e3e9f8f..6bf40cbe2 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -68,7 +68,12 @@ import { setPresenceWithRetry } from '../../utils/presenceWrite'; import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate'; import { dismissToastAtom, toastQueueAtom } from '../../state/toast'; import { useReminders } from '../../hooks/useReminders'; -import { getRoomRetentionMs, isExpired } from '../../utils/retention'; +import { + getRoomRetentionMs, + getRoomRetentionSetAt, + isExpired, + retentionDeletesAllowed, +} from '../../utils/retention'; import { useTauriUpdateProgress, useTauriUpdater } from '../../hooks/useTauriUpdater'; import { settingsRequestAtom } from '../../state/settingsRequest'; import { isNetworkUpdateError } from '../../utils/updateErrors'; @@ -910,6 +915,9 @@ function RetentionSweeper() { mx.getRooms().forEach((room) => { const maxLifetime = getRoomRetentionMs(room); if (!maxLifetime) return; + // [Gitea #298] Never delete under a policy that is less than a day old. + const setAt = getRoomRetentionSetAt(room); + if (setAt === undefined || !retentionDeletesAllowed(setAt, now)) return; room .getLiveTimeline() .getEvents() diff --git a/src/app/utils/retention.test.ts b/src/app/utils/retention.test.ts index 1155eb7df..aeac666df 100644 --- a/src/app/utils/retention.test.ts +++ b/src/app/utils/retention.test.ts @@ -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); +}); diff --git a/src/app/utils/retention.ts b/src/app/utils/retention.ts index 4573dcc12..6c7dcd68c 100644 --- a/src/app/utils/retention.ts +++ b/src/app/utils/retention.ts @@ -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()?.max_lifetime; - return typeof ms === 'number' && ms > 0 ? ms : undefined; + return effectiveRetentionMs(event?.getContent()?.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; From 37372b215a6049ed118b0269c5419a611653b2ab Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Fri, 16 Oct 2026 22:01:05 -0400 Subject: [PATCH 5/9] fix(fonts): the app uses Inter again (#320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since the switch to @fontsource-variable/inter 5 (May), the package registers the family 'Inter Variable', but the app still asked for the 4.x name 'InterVariable', so every user got their platform's default sans-serif (Arial / Helvetica / DejaVu) and Settings → Appearance → font "Inter" did nothing. --font-secondary (index.css) and the App font map now ask for 'Inter Variable' first, keeping 'InterVariable' as a fallback for a locally installed old version. Verified headless: before, 0 Inter faces loaded and document.fonts.check('16px "Inter Variable"') false; now the face loads and the check is true. Fixes #320 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/pages/App.tsx | 2 +- src/index.css | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/app/pages/App.tsx b/src/app/pages/App.tsx index 75fb86b7e..9c9488996 100644 --- a/src/app/pages/App.tsx +++ b/src/app/pages/App.tsx @@ -48,7 +48,7 @@ import { const EMOJI_FONTS = 'var(--font-flags), var(--font-emoji)'; const FONT_MAP: Record = { system: `system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', ${EMOJI_FONTS}, sans-serif`, - inter: `'InterVariable', ${EMOJI_FONTS}, sans-serif`, + inter: `'Inter Variable', 'InterVariable', ${EMOJI_FONTS}, sans-serif`, 'jetbrains-mono': `'JetBrains Mono', ${EMOJI_FONTS}, monospace`, 'fira-code': `'Fira Code', ${EMOJI_FONTS}, monospace`, }; diff --git a/src/index.css b/src/index.css index db038d112..481bf12f6 100644 --- a/src/index.css +++ b/src/index.css @@ -42,7 +42,8 @@ --font-emoji: 'Twemoji_DISABLED'; --font-flags: 'TwemojiFlags_DISABLED'; - --font-secondary: 'InterVariable', var(--font-flags), var(--font-emoji), sans-serif; + --font-secondary: + 'Inter Variable', 'InterVariable', var(--font-flags), var(--font-emoji), sans-serif; } .dark-theme, @@ -66,7 +67,7 @@ --mx-uc-7: hsl(243, 100%, 80%); --mx-uc-8: hsl(94, 100%, 80%); - --font-secondary: 'InterVariable', var(--font-emoji), sans-serif; + --font-secondary: 'Inter Variable', 'InterVariable', var(--font-emoji), sans-serif; } html { From 78691f1fffa9a3409642d492713b99892e07aeb4 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Fri, 16 Oct 2026 22:12:19 -0400 Subject: [PATCH 6/9] fix(logout): no draft or search opt-in left behind after logout (#319, #296) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #319: logout wiped the drafts, then reloaded, and the reload's pagehide made RoomInput flush the open composer's (decrypted) draft straight back to localStorage. clearPlaintextCaches now sets a module flag and persistDraft stops writing once it's set (resets with the new page). - #296: the device-wide encrypted-search opt-in (searchCacheEnabled) survived logout, so the next account started indexing decrypted text without opting in. It's now cleared, along with the SDK's per-user filter key. Verified headless with a throwaway account (search opt-in on, "SECRET half-typed reply" in the composer, Settings → Log out): before, the draft and searchCacheEnabled=true remained; now neither does. Unit test added. Fixes #319 Fixes #296 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/features/room/RoomInput.tsx | 3 +++ src/app/state/plaintextCaches.test.ts | 12 +++++++++++- src/app/state/plaintextCaches.ts | 28 +++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index c6102272b..d1c3613c7 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -160,6 +160,7 @@ import { scheduledMessagesAtom } from '../../state/scheduledMessages'; import { createErrorToast, toastQueueAtom } from '../../state/toast'; import { getThreadDraftKey } from '../../state/room/thread'; import { stripTrackingParamsInHtml, stripTrackingParamsInText } from '../../utils/urlTracking'; +import { plaintextCachesWiped } from '../../state/plaintextCaches'; const GifPicker = React.lazy(() => import('../../components/GifPicker').then((m) => ({ default: m.GifPicker })), @@ -562,6 +563,8 @@ export const RoomInput = forwardRef( // Persist the current editor state to the draft atom + localStorage; an empty // editor clears both so the draft indicators never claim a draft that isn't there. const persistDraft = useCallback(() => { + // [Gitea #319] Logout already wiped the drafts; don't write this one back. + if (plaintextCachesWiped()) return; if (!isEmptyEditor(editor)) { const parsedDraft = JSON.parse(JSON.stringify(editor.children)); setMsgDraft(parsedDraft); diff --git a/src/app/state/plaintextCaches.test.ts b/src/app/state/plaintextCaches.test.ts index 8e72c6392..869ed6bda 100644 --- a/src/app/state/plaintextCaches.test.ts +++ b/src/app/state/plaintextCaches.test.ts @@ -26,7 +26,7 @@ const api = { : Object.getOwnPropertyDescriptor(target, key), }); -const { clearPlaintextCaches } = await import('./plaintextCaches'); +const { clearPlaintextCaches, plaintextCachesWiped } = await import('./plaintextCaches'); test('clearPlaintextCaches removes every plaintext/PII localStorage key', () => { store.clear(); @@ -94,3 +94,13 @@ test('clearPlaintextCaches removes the call-volume list and the settings-sync st assert.equal(store.has('settings-sync-meta:@me:server'), false); assert.equal(store.has('settings'), true, 'device preferences are kept'); }); + +test('clearPlaintextCaches clears the encrypted-search opt-in and flags the wipe (#296, #319)', () => { + store.clear(); + store.set('searchCacheEnabled', 'true'); + store.set('mxjssdk_memory_filter_FILTER_SYNC_@me:server', '0'); + clearPlaintextCaches('@me:server'); + assert.equal(store.has('searchCacheEnabled'), false); + assert.equal(store.has('mxjssdk_memory_filter_FILTER_SYNC_@me:server'), false); + assert.equal(plaintextCachesWiped(), true, 'draft writes stop until the page reloads'); +}); diff --git a/src/app/state/plaintextCaches.ts b/src/app/state/plaintextCaches.ts index cf9b510fd..6e66bc864 100644 --- a/src/app/state/plaintextCaches.ts +++ b/src/app/state/plaintextCaches.ts @@ -112,7 +112,34 @@ const clearCallVolumesAndSyncMeta = (userId?: string): void => { }); }; +// [Gitea #319] Set once a logout has wiped the caches. Logout then reloads the +// page, and the reload's `pagehide` made RoomInput flush the open composer's +// draft straight back to localStorage; draft writes check this and stop. +// Module state, so it resets with the new page. +let wiped = false; +export const plaintextCachesWiped = (): boolean => wiped; + +/** + * [Gitea #296] Device-wide flags/keys that carry the previous account's choice + * or id: the encrypted-search opt-in (the next account would otherwise start + * indexing decrypted text without opting in) and the SDK's per-user filter key. + */ +const clearAccountScopedFlags = (userId?: string): void => { + const keys = [ + 'searchCacheEnabled', + ...(userId ? [`mxjssdk_memory_filter_FILTER_SYNC_${userId}`] : []), + ]; + keys.forEach((key) => { + try { + localStorage.removeItem(key); + } catch { + // best-effort + } + }); +}; + export const clearPlaintextCaches = (userId?: string): void => { + wiped = true; clearTranslationCache(); clearScheduledMessages(); clearRecentSearches(); @@ -124,5 +151,6 @@ export const clearPlaintextCaches = (userId?: string): void => { clearCallSession(); clearStatusMessage(); clearCallVolumesAndSyncMeta(userId); + clearAccountScopedFlags(userId); if (userId) clearNavToActivePathStore(userId); }; From c6419044cb9ee495bbf4c90df08e5d4f29a019f4 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Fri, 16 Oct 2026 22:19:24 -0400 Subject: [PATCH 7/9] fix(notifications): quiet hours start and end while the app stays open (#325) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useNotificationsQuiet evaluated the quiet-hours window (and the "Pause notifications" expiry) only at render time, and the long-lived message/invite notifiers rarely re-render, so someone leaving Lotus open overnight with quiet hours 22:00–07:00 got every sound all night (and a 30-minute pause could outlast itself). The hook now re-evaluates every 30 s. Verified headless (notifications granted, window unfocused): quiet hours set to start ~75 s after the app opened; a DM before the window notified; one inside the window, with the app left alone: before it still notified, now it's silent. Fixes #325 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/hooks/useNotificationsQuiet.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/app/hooks/useNotificationsQuiet.ts b/src/app/hooks/useNotificationsQuiet.ts index 93b33ec08..d66fe89b8 100644 --- a/src/app/hooks/useNotificationsQuiet.ts +++ b/src/app/hooks/useNotificationsQuiet.ts @@ -1,3 +1,4 @@ +import { useEffect, useState } from 'react'; import { useAtomValue } from 'jotai'; import { focusAssistActiveAtom } from '../state/focusAssist'; import { manualDndAtom } from '../state/manualDnd'; @@ -6,6 +7,8 @@ import { isWithinTimeWindow } from '../utils/timeWindow'; import { useSetting } from '../state/hooks/settings'; import { settingsAtom } from '../state/settings'; +const QUIET_TICK_MS = 30_000; + export type NotificationsQuietInputs = { focusAssistActive: boolean; manualDnd: boolean; @@ -40,6 +43,15 @@ export function isNotificationsQuiet({ // Live-atoms/settings-backed version of `isNotificationsQuiet` for use in // components. export function useNotificationsQuiet(): boolean { + // [Gitea #325] The quiet-hours window and the snooze expiry depend on the + // clock, but the long-lived notifiers using this hook rarely re-render, so a + // window starting (or a 30-minute pause ending) while the app stayed open was + // never noticed. Re-evaluate on a tick, like the night-light overlay. + const [, setTick] = useState(0); + useEffect(() => { + const id = setInterval(() => setTick((t) => t + 1), QUIET_TICK_MS); + return () => clearInterval(id); + }, []); const focusAssistActive = useAtomValue(focusAssistActiveAtom); const manualDnd = useAtomValue(manualDndAtom); const snoozeUntil = useAtomValue(notificationSnoozeUntilAtom); From 57036bb6a46388fd9c1d53e43cba2f5d30af6af0 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Fri, 16 Oct 2026 22:23:15 -0400 Subject: [PATCH 8/9] fix(rooms): upgrading a room keeps its Lotus settings (#327) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /rooms/{id}/upgrade copies only Synapse's fixed list of state, so an upgrade silently dropped everything Lotus keeps in room state: an audio-only voice channel became camera/screenshare-allowed, user limits and bitrate caps vanished, the soundboard pack was lost, and a room set to delete messages after 7 days kept new ones forever. RoomUpgrade now reads the client-owned state first (utils/roomUpgradeState, unit-tested: io.lotus.*, m.room.retention, role tags, room emoji packs) and re-creates it in the replacement room; a partial copy is reported in the dialog instead of closing silently. Verified headless through the UI (v10 room with voice limit, quality policy, a soundboard pack and retention → upgrade): before, none survived; now all four are in the new room. Follow-up (not here): for spaces, the children's m.space.parent still point at the old space. Fixes #327 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- .../common-settings/general/RoomUpgrade.tsx | 40 +++++++++++++++++-- src/app/utils/roomUpgradeState.test.ts | 35 ++++++++++++++++ src/app/utils/roomUpgradeState.ts | 36 +++++++++++++++++ 3 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 src/app/utils/roomUpgradeState.test.ts create mode 100644 src/app/utils/roomUpgradeState.ts diff --git a/src/app/features/common-settings/general/RoomUpgrade.tsx b/src/app/features/common-settings/general/RoomUpgrade.tsx index 436d6445a..c3c89a589 100644 --- a/src/app/features/common-settings/general/RoomUpgrade.tsx +++ b/src/app/features/common-settings/general/RoomUpgrade.tsx @@ -40,6 +40,7 @@ import { creatorsSupported } from '../../../utils/matrix'; import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { BreakWord } from '../../../styles/Text.css'; import { useModalStyle } from '../../../hooks/useModalStyle'; +import { stateToCarry } from '../../../utils/roomUpgradeState'; function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) { const mx = useMatrixClient(); @@ -63,10 +64,41 @@ function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) { const [upgradeState, upgrade] = useAsyncCallback( useCallback( async (version: string, newAdditionalCreators?: string[]) => { - await mx.http.authedRequest(Method.Post, `/rooms/${room.roomId}/upgrade`, undefined, { - new_version: version, - additional_creators: newAdditionalCreators, - }); + // [Gitea #327] Synapse doesn't copy Lotus room state (voice limit, call + // policy, soundboard, retention, role tags, emoji packs) into the + // replacement room: read it first, re-create it after the upgrade. + const carried = stateToCarry( + Array.from(room.currentState.events.values()).flatMap((byKey) => + Array.from(byKey.values()), + ), + ); + const res = await mx.http.authedRequest<{ replacement_room?: string }>( + Method.Post, + `/rooms/${room.roomId}/upgrade`, + undefined, + { + new_version: version, + additional_creators: newAdditionalCreators, + }, + ); + const newRoomId = res?.replacement_room; + if (!newRoomId || carried.length === 0) return; + const results = await Promise.allSettled( + carried.map((st) => + mx.http.authedRequest( + Method.Put, + `/rooms/${encodeURIComponent(newRoomId)}/state/${encodeURIComponent(st.type)}/${encodeURIComponent(st.stateKey)}`, + undefined, + st.content, + ), + ), + ); + const failed = results.filter((r) => r.status === 'rejected').length; + if (failed > 0) { + throw new Error( + `Room upgraded, but ${failed} of ${carried.length} Lotus settings (voice limit, call policy, soundboard, retention…) could not be copied to the new room.`, + ); + } }, [mx, room], ), diff --git a/src/app/utils/roomUpgradeState.test.ts b/src/app/utils/roomUpgradeState.test.ts new file mode 100644 index 000000000..02e53cb16 --- /dev/null +++ b/src/app/utils/roomUpgradeState.test.ts @@ -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, 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|'], + ); +}); diff --git a/src/app/utils/roomUpgradeState.ts b/src/app/utils/roomUpgradeState.ts new file mode 100644 index 000000000..ca6036bd4 --- /dev/null +++ b/src/app/utils/roomUpgradeState.ts @@ -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 }; + +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; + }>, +): 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); From 36e697613b28647bcf76a2f2d323e67be7db67be Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Fri, 16 Oct 2026 22:25:41 -0400 Subject: [PATCH 9/9] fix(messages): the delivery indicator updates when a send completes (#306) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeliveryStatus showed whatever mEvent.status was at the last timeline re-render, and a local echo's status changes (SENDING → SENT → confirmed, NOT_SENT → retried) only emit event-level events, so the last message in a quiet room kept "Sending..." (or a stale "Failed to send. Click to retry" after an outbox retry) indefinitely. It now re-renders on MatrixEventEvent.Status / LocalEventIdReplaced. Not browser-verified in this change (checks: tsc, unit tests, eslint). Fixes #306 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/features/room/message/Message.tsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index ec7f52490..9fc1c83d4 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -31,12 +31,13 @@ import React, { MouseEventHandler, ReactNode, useCallback, + useEffect, useRef, useState, } from 'react'; import FocusTrap from 'focus-trap-react'; import { useHover, useFocusWithin } from 'react-aria'; -import { MatrixEvent, Room, EventStatus } from 'matrix-js-sdk'; +import { MatrixEvent, MatrixEventEvent, Room, EventStatus } from 'matrix-js-sdk'; import { Relations } from 'matrix-js-sdk/lib/models/relations'; import classNames from 'classnames'; import { useAtom, useAtomValue } from 'jotai'; @@ -114,6 +115,20 @@ function DeliveryStatus({ }) { const mx = useMatrixClient(); const offline = useAtomValue(sendOfflineAtom); + // [Gitea #306] A local echo's status changes (SENDING → SENT → confirmed, + // NOT_SENT → retried) don't re-render the timeline, so the last message in a + // quiet room kept "Sending..." (or a stale "Failed to send") indefinitely. + // Re-render on the event's own status updates. + const [, setStatusTick] = useState(0); + useEffect(() => { + const bump = () => setStatusTick((t) => t + 1); + mEvent.on(MatrixEventEvent.Status, bump); + mEvent.on(MatrixEventEvent.LocalEventIdReplaced, bump); + return () => { + mEvent.off(MatrixEventEvent.Status, bump); + mEvent.off(MatrixEventEvent.LocalEventIdReplaced, bump); + }; + }, [mEvent]); const { status } = mEvent; if (status === null) return null; // confirmed by server — read receipts take over let iconSrc: IconSrc;