Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8fd0a1112 |
@@ -9,6 +9,7 @@ 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;
|
||||
@@ -163,7 +164,12 @@ export function ServerStatusFeature({ mx }: { mx: MatrixClient }) {
|
||||
});
|
||||
}, []),
|
||||
);
|
||||
const syncLost = lostAt !== null;
|
||||
// [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 status = useServerStatus(page, syncLost);
|
||||
const setAtom = useSetAtom(serverStatusAtom);
|
||||
useEffect(() => setAtom({ status, syncLost, lostAt }), [setAtom, status, syncLost, lostAt]);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
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;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ 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;
|
||||
@@ -17,6 +18,7 @@ type SyncStatusProps = {
|
||||
};
|
||||
export function SyncStatus({ mx }: SyncStatusProps) {
|
||||
const serverStatus = useAtomValue(serverStatusAtom);
|
||||
const stalled = useAtomValue(homeserverStalledAtom);
|
||||
const [stateData, setStateData] = useState<StateData>({
|
||||
current: null,
|
||||
previous: undefined,
|
||||
@@ -34,6 +36,35 @@ 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's server isn't responding. Messages will send when it's back.
|
||||
Still trying…
|
||||
</Text>
|
||||
</Box>
|
||||
<Line variant="Warning" size="300" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
(stateData.current === SyncState.Prepared ||
|
||||
stateData.current === SyncState.Syncing ||
|
||||
@@ -57,13 +88,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);
|
||||
|
||||
if (stateData.current === SyncState.Reconnecting && !serverDown) {
|
||||
return (
|
||||
<Box direction="Column" shrink="No">
|
||||
|
||||
+134
-185
@@ -255,8 +255,6 @@ function HomeCustomSection({
|
||||
return (localName ?? matrixName).toLowerCase().includes(query);
|
||||
});
|
||||
}, [mx, section.rooms, closed, roomsWithUnreadSet, selectedRoomId, filterQuery]);
|
||||
// [Gitea #289] While filtering, a section with no matches is hidden.
|
||||
if (filterQuery.trim() && items.length === 0) return null;
|
||||
// Sections are user-curated and small, so they render plainly — no
|
||||
// virtualizer per section (a late-mounted one never measured its scroller).
|
||||
return (
|
||||
@@ -445,26 +443,6 @@ export function Home() {
|
||||
});
|
||||
}, [mx, sortedRooms, filterQuery]);
|
||||
|
||||
// [Gitea #289] While filtering, hide empty sections; with no match at all,
|
||||
// say so instead of leaving bare headers.
|
||||
const filtering = filterQuery.trim().length > 0;
|
||||
const customSectionMatch = useMemo(() => {
|
||||
if (!filtering) return true;
|
||||
const query = filterQuery.toLowerCase();
|
||||
const localNames = getLocalRoomNamesContent(mx);
|
||||
return customSections.some((section) =>
|
||||
section.rooms.some((rId) =>
|
||||
(localNames.rooms[rId] ?? mx.getRoom(rId)?.name ?? '').toLowerCase().includes(query),
|
||||
),
|
||||
);
|
||||
}, [mx, customSections, filtering, filterQuery]);
|
||||
const noFilterMatch =
|
||||
filtering &&
|
||||
!customSectionMatch &&
|
||||
filteredFavoriteRooms.length === 0 &&
|
||||
filteredRooms.length === 0 &&
|
||||
filteredLowPriorityRooms.length === 0;
|
||||
|
||||
const favVirtualizer = useVirtualizer({
|
||||
count: filteredFavoriteRooms.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
@@ -602,7 +580,7 @@ export function Home() {
|
||||
/>
|
||||
</Box>
|
||||
</NavCategory>
|
||||
{favoriteRooms.length > 0 && (!filtering || filteredFavoriteRooms.length > 0) && (
|
||||
{favoriteRooms.length > 0 && (
|
||||
<NavCategory>
|
||||
<NavCategoryHeader>
|
||||
<RoomNavCategoryButton
|
||||
@@ -651,171 +629,142 @@ export function Home() {
|
||||
notificationPreferences={notificationPreferences}
|
||||
/>
|
||||
))}
|
||||
{noFilterMatch && (
|
||||
<NavCategory>
|
||||
<Box
|
||||
direction="Column"
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
style={{ padding: config.space.S400 }}
|
||||
<NavCategory>
|
||||
<NavCategoryHeader>
|
||||
<RoomNavCategoryButton
|
||||
closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
|
||||
data-category-id={DEFAULT_CATEGORY_ID}
|
||||
onClick={handleCategoryClick}
|
||||
>
|
||||
<Text size="T300" priority="300" align="Center">
|
||||
No rooms match “{filterQuery.trim()}”
|
||||
</Text>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
onClick={() => setFilterQuery('')}
|
||||
>
|
||||
<Text size="B300">Clear filter</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</NavCategory>
|
||||
)}
|
||||
{(!filtering || filteredRooms.length > 0) && (
|
||||
<NavCategory>
|
||||
<NavCategoryHeader>
|
||||
<RoomNavCategoryButton
|
||||
closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
|
||||
data-category-id={DEFAULT_CATEGORY_ID}
|
||||
onClick={handleCategoryClick}
|
||||
>
|
||||
Rooms
|
||||
</RoomNavCategoryButton>
|
||||
{!closedCategories.has(DEFAULT_CATEGORY_ID) && (
|
||||
<>
|
||||
<IconButton
|
||||
aria-expanded={!!sortMenuAnchor}
|
||||
aria-haspopup="menu"
|
||||
variant="Background"
|
||||
fill="None"
|
||||
size="300"
|
||||
radii="300"
|
||||
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const cords = evt.currentTarget.getBoundingClientRect();
|
||||
setSortMenuAnchor((current) => (current ? undefined : cords));
|
||||
}}
|
||||
aria-label="Sort rooms"
|
||||
>
|
||||
<Icon src={Icons.Sort} size="100" />
|
||||
</IconButton>
|
||||
<PopOut
|
||||
anchor={sortMenuAnchor}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
offset={6}
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setSortMenuAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu style={{ maxWidth: toRem(180), width: '100vw' }}>
|
||||
<Box
|
||||
direction="Column"
|
||||
gap="100"
|
||||
style={{ padding: config.space.S100 }}
|
||||
Rooms
|
||||
</RoomNavCategoryButton>
|
||||
{!closedCategories.has(DEFAULT_CATEGORY_ID) && (
|
||||
<>
|
||||
<IconButton
|
||||
aria-expanded={!!sortMenuAnchor}
|
||||
aria-haspopup="menu"
|
||||
variant="Background"
|
||||
fill="None"
|
||||
size="300"
|
||||
radii="300"
|
||||
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const cords = evt.currentTarget.getBoundingClientRect();
|
||||
setSortMenuAnchor((current) => (current ? undefined : cords));
|
||||
}}
|
||||
aria-label="Sort rooms"
|
||||
>
|
||||
<Icon src={Icons.Sort} size="100" />
|
||||
</IconButton>
|
||||
<PopOut
|
||||
anchor={sortMenuAnchor}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
offset={6}
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setSortMenuAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu style={{ maxWidth: toRem(180), width: '100vw' }}>
|
||||
<Box
|
||||
direction="Column"
|
||||
gap="100"
|
||||
style={{ padding: config.space.S100 }}
|
||||
>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setHomeRoomSort('recent');
|
||||
setSortMenuAnchor(undefined);
|
||||
}}
|
||||
size="300"
|
||||
after={
|
||||
homeRoomSort === 'recent' ? (
|
||||
<Icon size="100" src={Icons.Check} />
|
||||
) : undefined
|
||||
}
|
||||
radii="300"
|
||||
>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setHomeRoomSort('recent');
|
||||
setSortMenuAnchor(undefined);
|
||||
}}
|
||||
size="300"
|
||||
after={
|
||||
homeRoomSort === 'recent' ? (
|
||||
<Icon size="100" src={Icons.Check} />
|
||||
) : undefined
|
||||
}
|
||||
radii="300"
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Recent Activity
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setHomeRoomSort('alpha');
|
||||
setSortMenuAnchor(undefined);
|
||||
}}
|
||||
size="300"
|
||||
after={
|
||||
homeRoomSort === 'alpha' ? (
|
||||
<Icon size="100" src={Icons.Check} />
|
||||
) : undefined
|
||||
}
|
||||
radii="300"
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
A → Z
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setHomeRoomSort('unread');
|
||||
setSortMenuAnchor(undefined);
|
||||
}}
|
||||
size="300"
|
||||
after={
|
||||
homeRoomSort === 'unread' ? (
|
||||
<Icon size="100" src={Icons.Check} />
|
||||
) : undefined
|
||||
}
|
||||
radii="300"
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Unread First
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</Box>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</NavCategoryHeader>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: virtualizer.getTotalSize(),
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((vItem) => {
|
||||
const roomId = filteredRooms[vItem.index];
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return null;
|
||||
const selected = selectedRoomId === roomId;
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Recent Activity
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setHomeRoomSort('alpha');
|
||||
setSortMenuAnchor(undefined);
|
||||
}}
|
||||
size="300"
|
||||
after={
|
||||
homeRoomSort === 'alpha' ? (
|
||||
<Icon size="100" src={Icons.Check} />
|
||||
) : undefined
|
||||
}
|
||||
radii="300"
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
A → Z
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setHomeRoomSort('unread');
|
||||
setSortMenuAnchor(undefined);
|
||||
}}
|
||||
size="300"
|
||||
after={
|
||||
homeRoomSort === 'unread' ? (
|
||||
<Icon size="100" src={Icons.Check} />
|
||||
) : undefined
|
||||
}
|
||||
radii="300"
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
Unread First
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</Box>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</NavCategoryHeader>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: virtualizer.getTotalSize(),
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((vItem) => {
|
||||
const roomId = filteredRooms[vItem.index];
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return null;
|
||||
const selected = selectedRoomId === roomId;
|
||||
|
||||
return (
|
||||
<VirtualTile
|
||||
virtualItem={vItem}
|
||||
key={roomId}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<RoomNavItem
|
||||
room={room}
|
||||
selected={selected}
|
||||
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
|
||||
notificationMode={getRoomNotificationMode(
|
||||
notificationPreferences,
|
||||
room.roomId,
|
||||
)}
|
||||
/>
|
||||
</VirtualTile>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</NavCategory>
|
||||
)}
|
||||
{lowPriorityRooms.length > 0 && (!filtering || filteredLowPriorityRooms.length > 0) && (
|
||||
return (
|
||||
<VirtualTile virtualItem={vItem} key={roomId} ref={virtualizer.measureElement}>
|
||||
<RoomNavItem
|
||||
room={room}
|
||||
selected={selected}
|
||||
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
|
||||
notificationMode={getRoomNotificationMode(
|
||||
notificationPreferences,
|
||||
room.roomId,
|
||||
)}
|
||||
/>
|
||||
</VirtualTile>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</NavCategory>
|
||||
{lowPriorityRooms.length > 0 && (
|
||||
<NavCategory>
|
||||
<NavCategoryHeader>
|
||||
<RoomNavCategoryButton
|
||||
|
||||
Reference in New Issue
Block a user