Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 a6c7333ea3 fix(rooms): members can start and join calls in new private rooms (#328)
CI / Build & Quality Checks (pull_request) Successful in 2m58s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Waiting to run
CI / Secret scan (gitleaks) (pull_request) Successful in 13s
createRoom granted org.matrix.msc3401.call.member at user level only for
voice rooms. Every other room fell back to Synapse's state_default (50),
so in a private group room (where Lotus shows the call button and rings)
only admins could start a call; members saw "1 Live · Join" and then
"You don't have permission to join!". All new non-space rooms now get
call.member: 0 (spaces keep their own override).

Verified headless through Create Room → Private: before, no call.member
entry (state_default 50); now 0. Existing rooms are unchanged; an admin
can set it in room permissions.

Fixes #328

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 18:09:33 -04:00
jared 3878a85339 Merge pull request 'fix(messages): the delivery indicator updates when a send completes (#306)' (#374) from fix/delivery-status-live into lotus
CI / Build & Quality Checks (push) Successful in 4m16s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 12s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Successful in 14m49s
2026-10-17 17:51:44 -04:00
jared 9edb76835c Merge pull request 'fix(rooms): upgrading a room keeps its Lotus settings (#327)' (#373) from fix/room-upgrade-lotus-state into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-17 17:51:41 -04:00
jared 1784d40ba1 Merge pull request 'fix(notifications): quiet hours start and end while the app stays open (#325)' (#372) from fix/quiet-hours-tick into lotus
CI / Build & Quality Checks (push) Canceled after 1s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-17 17:51:40 -04:00
jared 76ef8a52c7 Merge pull request 'fix(logout): no draft or search opt-in left behind after logout (#319, #296)' (#371) from fix/logout-wipe-leftovers into lotus
CI / Build & Quality Checks (push) Canceled after 6s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-17 17:51:33 -04:00
jared a3ef2b4098 Merge pull request 'fix(fonts): the app uses Inter again (#320)' (#370) from fix/inter-font-name into lotus
CI / Build & Quality Checks (push) Canceled after 3s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-17 17:51:26 -04:00
jared 0c5cf4109a Merge pull request 'fix(retention): a tiny or brand-new retention value can't wipe a room (#298)' (#367) from fix/retention-floor-grace into lotus
CI / Build & Quality Checks (push) Successful in 3m55s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 9s
CI / Trigger Desktop Build (push) Successful in 7s
CI / Playwright smoke (e2e) (push) Successful in 14m5s
2026-10-16 22:48:18 -04:00
jared 39f74d51e6 Merge pull request 'fix(startup): Firefox no longer hangs on "Heating up" after login (#303)' (#366) from fix/firefox-persist-hang into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-16 22:36:52 -04:00
Lotus CIandClaude Opus 5.5 36e697613b fix(messages): the delivery indicator updates when a send completes (#306)
CI / Build & Quality Checks (pull_request) Successful in 3m47s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 13s
CI / Playwright smoke (e2e) (pull_request) Successful in 14m53s
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 22:25:41 -04:00
jared 2d31312a73 Merge pull request 'fix(calls): a finished call no longer rings again after a reload (#312)' (#365) from fix/phantom-ring-after-reload into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-16 22:24:05 -04:00
Lotus CIandClaude Opus 5.5 57036bb6a4 fix(rooms): upgrading a room keeps its Lotus settings (#327)
CI / Build & Quality Checks (pull_request) Successful in 4m0s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 9s
CI / Playwright smoke (e2e) (pull_request) Successful in 14m50s
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 22:23:15 -04:00
jared 29f6a35bfd Merge pull request 'fix(rooms): "Rename for me" names in search results, inbox and threads (#336)' (#364) from fix/local-room-names-rest into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-16 22:12:55 -04:00
Lotus CIandClaude Opus 5.5 78691f1fff fix(logout): no draft or search opt-in left behind after logout (#319, #296)
CI / Build & Quality Checks (pull_request) Successful in 3m48s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 9s
CI / Playwright smoke (e2e) (pull_request) Successful in 15m26s
- #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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 22:12:19 -04:00
Lotus CIandClaude Opus 5.5 37372b215a fix(fonts): the app uses Inter again (#320)
CI / Build & Quality Checks (pull_request) Successful in 3m49s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 13s
CI / Playwright smoke (e2e) (pull_request) Successful in 14m34s
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 22:01:05 -04:00
Lotus CIandClaude Opus 5.5 8b2322f751 fix(retention): a tiny or brand-new retention value can't wipe a room (#298)
CI / Build & Quality Checks (pull_request) Successful in 2m3s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 27s
CI / Playwright smoke (e2e) (pull_request) Successful in 11m33s
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 21:37:16 -04:00
Lotus CIandClaude Opus 5.5 02950499ea fix(startup): Firefox no longer hangs on "Heating up" after login (#303)
CI / Build & Quality Checks (pull_request) Successful in 1m50s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 14s
CI / Playwright smoke (e2e) (pull_request) Successful in 12m48s
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 21:31:55 -04:00
Lotus CIandClaude Opus 5.5 05d7e87d26 fix(calls): a finished call no longer rings again after a reload (#312)
CI / Build & Quality Checks (pull_request) Successful in 1m39s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Playwright smoke (e2e) (pull_request) Successful in 10m58s
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 21:28:33 -04:00
Lotus CIandClaude Opus 5.5 bbb2034080 fix(rooms): "Rename for me" names in search results, inbox and threads (#336)
CI / Build & Quality Checks (pull_request) Successful in 1m50s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 6s
CI / Playwright smoke (e2e) (pull_request) Successful in 15m5s
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 21:19:47 -04:00
23 changed files with 401 additions and 32 deletions
+47 -3
View File
@@ -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,
+5 -2
View File
@@ -139,8 +139,11 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
data.allowFederation,
data.additionalCreators,
),
power_level_content_override:
data.type === RoomType.Call ? createVoiceRoomPowerLevelsOverride() : undefined,
// [Gitea #328] Every non-space room gets call.member at user level, not just
// voice rooms: the call button is shown in private group rooms too, and with
// Synapse's state_default (50) only admins could start or join a call there
// ("You don't have permission to join!").
power_level_content_override: createVoiceRoomPowerLevelsOverride(),
initial_state: initialState,
};
@@ -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],
),
@@ -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<HTMLDivElement>(null);
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [localSelected, setLocalSelected] = useState(selectedRooms);
const getRoomNameStr: SearchItemStrGetter<string> = 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
}
>
<Text truncate size="T300">
{room.name}
{localNames[room.roomId] ?? room.name}
</Text>
</MenuItem>
</VirtualTile>
@@ -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={<Icon size="50" src={Icons.Cross} />}
>
<Text size="T200">{room.name}</Text>
<Text size="T200">{localNames[room.roomId] ?? room.name}</Text>
</Chip>
);
})}
@@ -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({
/>
</Avatar>
<Text size="H4" truncate>
{room.name}
{localNames[room.roomId] ?? room.name}
</Text>
</Box>
</Header>
+3
View File
@@ -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<HTMLDivElement, RoomInputProps>(
// 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);
+5 -2
View File
@@ -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<RetentionContent>().max_lifetime;
// [Gitea #298] Same floor as everywhere else: a tiny value must not blank the room.
const retentionMs = effectiveRetentionMs(
retentionEvent?.getContent<RetentionContent>().max_lifetime,
);
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
+16 -1
View File
@@ -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;
+3 -1
View File
@@ -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
</Text>
<Text size="T200" truncate style={{ opacity: 0.65 }}>
{room.name}
{localNames[room.roomId] ?? room.name}
{startedBy && root && ` · started by ${startedBy}, ${format(root.getTs())}`}
</Text>
</Box>
@@ -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
</Text>
<Text size="T200" truncate style={{ opacity: 0.65 }}>
{room.name}
{localNames[room.roomId] ?? room.name}
</Text>
</Box>
{totalUnread > 0 && (
+1 -1
View File
@@ -48,7 +48,7 @@ import {
const EMOJI_FONTS = 'var(--font-flags), var(--font-emoji)';
const FONT_MAP: Record<string, string> = {
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`,
};
+9 -1
View File
@@ -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()
+3 -1
View File
@@ -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({
/>
</Avatar>
<Text size="H4" truncate>
{room.name}
{localNames[room.roomId] ?? room.name}
</Text>
</Box>
<Box shrink="No">
+11 -1
View File
@@ -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');
});
+28
View File
@@ -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);
};
+41 -1
View File
@@ -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<string, unknown> }>,
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);
});
+34
View File
@@ -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<string, unknown> | 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,
);
+27 -1
View File
@@ -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);
});
+27 -2
View File
@@ -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<RetentionContent>()?.max_lifetime;
return typeof ms === 'number' && ms > 0 ? ms : undefined;
return effectiveRetentionMs(event?.getContent<RetentionContent>()?.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;
+35
View File
@@ -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<string, unknown>, 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|'],
);
});
+36
View File
@@ -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<string, unknown> };
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<string, unknown>;
}>,
): 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);
+14 -3
View File
@@ -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<boolean> => {
try {
if (!navigator.storage?.persist) return false;
@@ -39,7 +42,15 @@ export const requestPersistentStorage = async (): Promise<boolean> => {
export const initClient = async (session: Session): Promise<MatrixClient> => {
// 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<boolean>((resolve) => {
setTimeout(() => resolve(false), PERSIST_WAIT_MS);
}),
]);
const indexedDBStore = new IndexedDBStore({
indexedDB: globalThis.indexedDB,
+3 -2
View File
@@ -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 {