Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 f7bdd2855a fix(composer): Escape closes the emoji and sticker boards (#304)
CI / Build & Quality Checks (pull_request) Successful in 2m46s
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 15m19s
The board opens with focus in its search field, and its FocusTrap used
the shared stopPropagation helper, which ignores Escape while an input
is focused, so Escape never closed it (Chromium, Firefox, WebKit).
Always close on Escape here, still stopping the key from closing the
room or modal underneath. The GIF picker already uses the default.

Fixes #304

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 20:06:41 -04:00
5 changed files with 16 additions and 113 deletions
@@ -19,7 +19,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
import { EmojiData, IEmoji, emojiGroups, emojis, loadEmojiData } from '../../plugins/emoji';
import { useEmojiGroupLabels } from './useEmojiGroupLabels';
import { useEmojiGroupIcons } from './useEmojiGroupIcons';
import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard';
import { preventScrollWithArrowKey } from '../../utils/keyboard';
import { useRelevantImagePacks } from '../../hooks/useImagePacks';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRecentEmoji } from '../../hooks/useRecentEmoji';
@@ -612,7 +612,13 @@ export function EmojiBoard({
!editableActiveElement() && isKeyHotkey(['arrowdown', 'arrowright'], evt),
isKeyBackward: (evt: KeyboardEvent) =>
!editableActiveElement() && isKeyHotkey(['arrowup', 'arrowleft'], evt),
escapeDeactivates: stopPropagation,
// [Gitea #304] The board opens with focus in its search field, where
// the shared stopPropagation helper ignores Escape. Always close here,
// without letting the key also close the room/modal underneath.
escapeDeactivates: (evt: KeyboardEvent) => {
evt.stopPropagation();
return true;
},
}}
>
<EmojiBoardLayout
@@ -9,7 +9,6 @@ import { useServerStatus } from '../../hooks/useServerStatus';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { serverStatusAtom } from '../../state/serverStatus';
import { pickBanner, resolveStatusPage, StatusBanner } from '../../utils/kumaStatus';
import { homeserverStalledAtom, useHomeserverStall } from '../../hooks/useHomeserverStall';
const DISMISS_KEY = 'lotus-status-banner-dismissed';
const DISMISS_MAX = 50;
@@ -164,12 +163,7 @@ export function ServerStatusFeature({ mx }: { mx: MatrixClient }) {
});
}, []),
);
// [Gitea #317] A hung server keeps sync "SYNCING" for up to 110 s; the stall
// watchdog flags it sooner, and Kuma is asked at once.
const stalled = useHomeserverStall(mx);
const setStalled = useSetAtom(homeserverStalledAtom);
useEffect(() => setStalled(stalled), [setStalled, stalled]);
const syncLost = lostAt !== null || stalled;
const syncLost = lostAt !== null;
const status = useServerStatus(page, syncLost);
const setAtom = useSetAtom(serverStatusAtom);
useEffect(() => setAtom({ status, syncLost, lostAt }), [setAtom, status, syncLost, lostAt]);
-16
View File
@@ -1,16 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { SyncState } from 'matrix-js-sdk';
import { STALL_AFTER_MS, syncOverdue } from './useHomeserverStall';
test('syncOverdue: only while SYNCING and past the stall threshold (#317)', () => {
const now = 1_000_000;
assert.equal(syncOverdue(now - STALL_AFTER_MS - 1, now, SyncState.Syncing), true);
assert.equal(syncOverdue(now - 20_000, now, SyncState.Syncing), false, 'a normal long-poll');
assert.equal(
syncOverdue(now - 120_000, now, SyncState.Reconnecting),
false,
'SyncStatus already shows that',
);
assert.equal(syncOverdue(now - 120_000, now, null), false);
});
-57
View File
@@ -1,57 +0,0 @@
import { useEffect, useState } from 'react';
import { atom } from 'jotai';
import { ClientEvent, MatrixClient, SyncState } from 'matrix-js-sdk';
/**
* [Gitea #317] A hung homeserver (accepts connections, never answers) went
* unnoticed for up to 110 s: the SDK only gives up on a /sync long-poll after
* pollTimeout (30 s) + an 80 s buffer, and until then the sync state stays
* SYNCING, so no strip showed and Kuma wasn't asked. This watchdog notices a
* /sync that's overdue and confirms with a short probe of /versions.
*/
export const STALL_AFTER_MS = 45_000; // a healthy long-poll returns within ~30 s
const CHECK_EVERY_MS = 10_000;
const PROBE_TIMEOUT_MS = 5_000;
export const homeserverStalledAtom = atom(false);
/** Pure: should we probe, given the last sync response time? */
export const syncOverdue = (lastSyncAt: number, now: number, state: SyncState | null): boolean =>
state === SyncState.Syncing && now - lastSyncAt > STALL_AFTER_MS;
export function useHomeserverStall(mx: MatrixClient): boolean {
const [stalled, setStalled] = useState(false);
useEffect(() => {
let lastSyncAt = Date.now();
let probing = false;
const onSync = () => {
lastSyncAt = Date.now();
setStalled(false);
};
const check = async () => {
if (probing || !syncOverdue(lastSyncAt, Date.now(), mx.getSyncState())) return;
probing = true;
let answered = false;
try {
const res = await fetch(`${mx.getHomeserverUrl()}/_matrix/client/versions`, {
cache: 'no-store',
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
});
answered = res.ok;
} catch {
answered = false;
}
probing = false;
// Still overdue and the server didn't answer a trivial request: stalled.
if (!answered && syncOverdue(lastSyncAt, Date.now(), mx.getSyncState())) setStalled(true);
if (answered) setStalled(false);
};
mx.on(ClientEvent.Sync, onSync);
const id = setInterval(check, CHECK_EVERY_MS);
return () => {
mx.removeListener(ClientEvent.Sync, onSync);
clearInterval(id);
};
}, [mx]);
return stalled;
}
+7 -31
View File
@@ -6,7 +6,6 @@ import { useSyncState } from '../../hooks/useSyncState';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { serverStatusAtom } from '../../state/serverStatus';
import { serverConfirmedUpSince } from '../../utils/kumaStatus';
import { homeserverStalledAtom } from '../../hooks/useHomeserverStall';
type StateData = {
current: SyncState | null;
@@ -18,7 +17,6 @@ type SyncStatusProps = {
};
export function SyncStatus({ mx }: SyncStatusProps) {
const serverStatus = useAtomValue(serverStatusAtom);
const stalled = useAtomValue(homeserverStalledAtom);
const [stateData, setStateData] = useState<StateData>({
current: null,
previous: undefined,
@@ -36,35 +34,6 @@ export function SyncStatus({ mx }: SyncStatusProps) {
}, []),
);
// [Gitea #124] When Kuma confirms the homeserver is down, the status banner
// says so ("Lotus Chat's server is down…") instead of "Connection Lost!".
const serverDown = serverStatus.status?.homeserver === 'down';
// Only blame the user's connection when Kuma has checked the server since
// the drop and it was fine (a stale "up" from before proves nothing).
const serverUp = serverConfirmedUpSince(serverStatus.status, serverStatus.lostAt);
// [Gitea #317] Sync still says SYNCING, but the server has stopped answering.
if (stalled && stateData.current === SyncState.Syncing && !serverDown) {
return (
<Box direction="Column" shrink="No">
<Box
className={ContainerColor({ variant: 'Warning' })}
style={{ padding: `${config.space.S100} 0` }}
alignItems="Center"
justifyContent="Center"
role="status"
aria-live="polite"
>
<Text size="L400">
Lotus Chat&apos;s server isn&apos;t responding. Messages will send when it&apos;s back.
Still trying…
</Text>
</Box>
<Line variant="Warning" size="300" />
</Box>
);
}
if (
(stateData.current === SyncState.Prepared ||
stateData.current === SyncState.Syncing ||
@@ -88,6 +57,13 @@ export function SyncStatus({ mx }: SyncStatusProps) {
);
}
// [Gitea #124] When Kuma confirms the homeserver is down, the status banner
// says so ("Lotus Chat's server is down…") instead of "Connection Lost!".
const serverDown = serverStatus.status?.homeserver === 'down';
// Only blame the user's connection when Kuma has checked the server since
// the drop and it was fine (a stale "up" from before proves nothing).
const serverUp = serverConfirmedUpSince(serverStatus.status, serverStatus.lostAt);
if (stateData.current === SyncState.Reconnecting && !serverDown) {
return (
<Box direction="Column" shrink="No">