diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index 1019be8d5..953249de3 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, isFromFilteredTimelineSet } 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'; @@ -597,11 +599,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 () => { @@ -630,7 +671,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/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/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/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/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 49cd21fc1..5a2e70f08 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -122,7 +122,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'; @@ -498,7 +498,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/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; 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/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); 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/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 0dfdfc829..bff141e6a 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -69,7 +69,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'; @@ -912,6 +917,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/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} 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); }; 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, + ); 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; 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); 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, 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 {