feat(connection): replace 30s 'Connecting...' banner with bottom-edge sync indicator and homeserver footer dot, drop mascot loading splash

This commit is contained in:
heaven 2026-05-03 18:21:00 +03:00
parent 8f49124043
commit a1ff5db724
15 changed files with 398 additions and 154 deletions

View file

@ -54,7 +54,7 @@ Router in `Router.tsx`. Each top-level tab (`/direct/`, `/space/...`, `/explore/
- `sidebar/` — Tab components (`DirectTab`, `SpaceTabs`, `ExploreTab`, `CreateTab`, `SearchTab`, `UnverifiedTab`, `InboxTab`, `SettingsTab`). The legacy `HomeTab` was removed in P3c. - `sidebar/` — Tab components (`DirectTab`, `SpaceTabs`, `ExploreTab`, `CreateTab`, `SearchTab`, `UnverifiedTab`, `InboxTab`, `SettingsTab`). The legacy `HomeTab` was removed in P3c.
- `WelcomePage.tsx` — Empty state (vojo SVG + version). **Mobile is intentionally null** at the index route (`{mobile ? null : <Route index element={<WelcomePage />} />}`) — by design, not a bug. - `WelcomePage.tsx` — Empty state (vojo SVG + version). **Mobile is intentionally null** at the index route (`{mobile ? null : <Route index element={<WelcomePage />} />}`) — by design, not a bug.
- `SidebarNav.tsx` — global 66px icon-rail (DirectTab, SpaceTabs, …, sticky bottom: SearchTab, UnverifiedTab, InboxTab, SettingsTab). Earmarked for removal in a follow-up `sidebar_cleanup` plan once the new Direct/Channels surfaces are self-sufficient. - `SidebarNav.tsx` — global 66px icon-rail (DirectTab, SpaceTabs, …, sticky bottom: SearchTab, UnverifiedTab, InboxTab, SettingsTab). Earmarked for removal in a follow-up `sidebar_cleanup` plan once the new Direct/Channels surfaces are self-sufficient.
- `SyncStatus.tsx`, `SpecVersions.tsx` — Connection status - `SyncIndicator.tsx`, `SpecVersions.tsx` — Connection status. `SyncIndicator` is the bottom-edge glow line (green animated while syncing, frozen red on `SyncState.Error`); replaced the upstream Cinny `SyncStatus` top banner whose `previous !== Syncing` clear-condition could hold "Connecting..." for the full 30s long-poll. Render is no longer gated on `Prepared``MatrixClientProvider` mounts as soon as `mx` is created, the indicator carries the loading affordance.
### Universal Stream routing + DM classification (post-P3c) ### Universal Stream routing + DM classification (post-P3c)

View file

@ -392,7 +392,6 @@
"self_row_label": "You", "self_row_label": "You",
"self_row_preview": "Settings & profile", "self_row_preview": "Settings & profile",
"message_me_label": "me", "message_me_label": "me",
"status_e2ee": "e2ee",
"username": "Username", "username": "Username",
"username_placeholder": "username", "username_placeholder": "username",
"server": "Server", "server": "Server",

View file

@ -394,7 +394,6 @@
"self_row_label": "Я", "self_row_label": "Я",
"self_row_preview": "Настройки и профиль", "self_row_preview": "Настройки и профиль",
"message_me_label": "я", "message_me_label": "я",
"status_e2ee": "e2ee",
"username": "Имя пользователя", "username": "Имя пользователя",
"username_placeholder": "username", "username_placeholder": "username",
"server": "Сервер", "server": "Сервер",

View file

@ -101,6 +101,10 @@ export const ImageContent = as<'div', ImageContentProps>(
const handleLoad = () => { const handleLoad = () => {
setLoad(true); setLoad(true);
// A successful load is the source of truth — clear any sticky error
// from a previous attempt that the useEffect-driven reset might miss
// in edge browser-event orderings (e.g. delayed onload after onerror).
setError(false);
}; };
const handleError = () => { const handleError = () => {
setLoad(false); setLoad(false);
@ -113,7 +117,17 @@ export const ImageContent = as<'div', ImageContentProps>(
}; };
useEffect(() => { useEffect(() => {
if (autoPlay) loadSrc(); if (!autoPlay) return;
// Clear any sticky local state from a previous attempt before kicking
// off a fresh load. `loadSrc` identity changes whenever its inner
// callback's deps change — notably when `useMediaAuthentication` flips
// false→true after `/_matrix/client/versions` hydrates in the
// background, an unauthenticated URL was tried and the <img>'s onError
// set `error=true`, leaving the "Failed to load" overlay stuck above
// a subsequent successful authenticated load.
setError(false);
setLoad(false);
loadSrc();
}, [autoPlay, loadSrc]); }, [autoPlay, loadSrc]);
return ( return (

View file

@ -94,6 +94,9 @@ export const VideoContent = as<'div', VideoContentProps>(
const handleLoad = () => { const handleLoad = () => {
setLoad(true); setLoad(true);
// Mirror ImageContent: a successful load conclusively clears any
// sticky error state from a previous failed attempt.
setError(false);
}; };
const handleError = () => { const handleError = () => {
setLoad(false); setLoad(false);
@ -106,7 +109,14 @@ export const VideoContent = as<'div', VideoContentProps>(
}; };
useEffect(() => { useEffect(() => {
if (autoPlay) loadSrc(); if (!autoPlay) return;
// Same reasoning as ImageContent: clear local sticky state before a
// fresh `loadSrc()` so `error=true` from an earlier unauthenticated
// attempt doesn't outlive a successful retry once
// `useMediaAuthentication` flips post-/versions hydration.
setError(false);
setLoad(false);
loadSrc();
}, [autoPlay, loadSrc]); }, [autoPlay, loadSrc]);
return ( return (

View file

@ -0,0 +1,34 @@
import { useEffect, useState } from 'react';
/**
* Returns `true` once `condition` has been continuously `true` for at least
* `delayMs`. Goes back to `false` immediately when `condition` flips false
* the rising edge is delayed, the falling edge is not.
*
* Single primitive serving two roles in this codebase:
*
* - **Anti-flicker debounce.** Suppress a UI element that should only show
* when its trigger is sustained: e.g. don't render the green sync bar
* if the underlying state will resolve to "hidden" within ~150ms.
*
* - **Stuck/timeout watchdog.** Trigger an alarm after a condition has
* been continuously true for longer than expected: e.g. force-show the
* error bar if the sync state has been "in progress" for too long
* without a transition. Threshold is the caller's choice.
*
* Same mechanic, opposite intents naming is at the call site.
*/
export function useDelayedTrue(condition: boolean, delayMs: number): boolean {
const [delayed, setDelayed] = useState(false);
useEffect(() => {
if (!condition) {
setDelayed(false);
return undefined;
}
const timer = setTimeout(() => setDelayed(true), delayMs);
return () => clearTimeout(timer);
}, [condition, delayMs]);
return delayed;
}

View file

@ -0,0 +1,80 @@
import { useEffect, useState } from 'react';
import { ClientEvent, MatrixClient, SyncState } from 'matrix-js-sdk';
import { useDelayedTrue } from './useDelayedTrue';
// Two stuck-detection thresholds for two semantically different signals.
//
// FAST path: a "known-bad" signal — the SDK explicitly says it's retrying
// (Reconnecting) or the browser says the device is offline. The connection
// is confirmed broken; 15s is enough for users to see that state escalate
// from "trying" (green) to "broken" (red).
//
// SLOW path: an "ambiguous" signal — the SDK is in null/Prepared/Catchup
// without any explicit fail-signal. Could be a legitimate slow first sync
// (cold login on a server with many rooms can run 30s+ on Edge/3G) OR a
// silently-stalled fetch that will never resolve (carrier whitelist, DPI
// middlebox holding the connection open). 60s skews toward avoiding false
// red on slow-but-healthy sync, while still eventually surfacing genuine
// silent stalls before the user gives up.
const FAST_STUCK_TIMEOUT_MS = 15_000;
const SLOW_STUCK_TIMEOUT_MS = 60_000;
export type SyncHealth = {
syncState: SyncState | null;
stuck: boolean;
offline: boolean;
};
const readNavigatorOffline = (): boolean =>
typeof navigator !== 'undefined' && navigator.onLine === false;
/**
* Single source of truth for the connection-status surface (bottom indicator
* bar + footer dot). Combines three signals:
* - matrix-js-sdk SyncState (the canonical state machine)
* - a two-tier stuck-watchdog for the SDK's silent-stall failure modes
* - browser `navigator.onLine` for instant offline feedback
*/
export function useSyncHealth(mx: MatrixClient): SyncHealth {
const [syncState, setSyncState] = useState<SyncState | null>(() => mx.getSyncState());
const [offline, setOffline] = useState<boolean>(readNavigatorOffline);
useEffect(() => {
setSyncState(mx.getSyncState());
const handler = (state: SyncState) => {
setSyncState(state);
};
mx.on(ClientEvent.Sync, handler);
return () => {
mx.removeListener(ClientEvent.Sync, handler);
};
}, [mx]);
useEffect(() => {
const onOffline = () => setOffline(true);
const onOnline = () => setOffline(false);
window.addEventListener('offline', onOffline);
window.addEventListener('online', onOnline);
return () => {
window.removeEventListener('offline', onOffline);
window.removeEventListener('online', onOnline);
};
}, []);
// Known-bad: SDK or browser explicitly says the connection is failing.
const knownBad = offline || syncState === SyncState.Reconnecting;
// Ambiguous: SDK in initial / recovery progress without an explicit fail
// signal. Could be slow-but-healthy or silently-stalled — we can't tell
// until the SLOW timeout elapses.
const ambiguousProgress =
!knownBad &&
syncState !== SyncState.Error &&
syncState !== SyncState.Syncing &&
syncState !== SyncState.Stopped;
const stuckFromKnownBad = useDelayedTrue(knownBad, FAST_STUCK_TIMEOUT_MS);
const stuckFromAmbiguous = useDelayedTrue(ambiguousProgress, SLOW_STUCK_TIMEOUT_MS);
const stuck = stuckFromKnownBad || stuckFromAmbiguous;
return { syncState, stuck, offline };
}

View file

@ -3,7 +3,11 @@ import React from 'react';
import { AuthSplashScreen } from './auth/AuthSplashScreen'; import { AuthSplashScreen } from './auth/AuthSplashScreen';
export function ConfigConfigLoading() { export function ConfigConfigLoading() {
return <AuthSplashScreen />; // Brief gap (~50300ms) while /config.json is fetched. Render nothing so
// the body background shows through instead of flashing the mascot splash.
// Error path below keeps the mascot wrapper since errors are rare and the
// retry dialog needs anchoring.
return null;
} }
type ConfigConfigErrorProps = { type ConfigConfigErrorProps = {

View file

@ -28,19 +28,14 @@ import { MediaConfigProvider } from '../../hooks/useMediaConfig';
import { MatrixClientProvider } from '../../hooks/useMatrixClient'; import { MatrixClientProvider } from '../../hooks/useMatrixClient';
import { SpecVersions } from './SpecVersions'; import { SpecVersions } from './SpecVersions';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useSyncState } from '../../hooks/useSyncState';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { SyncStatus } from './SyncStatus'; import { SyncIndicator } from './SyncIndicator';
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata'; import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
import { getFallbackSession } from '../../state/sessions'; import { getFallbackSession } from '../../state/sessions';
import { AutoDiscovery } from './AutoDiscovery'; import { AutoDiscovery } from './AutoDiscovery';
import { AuthSplashScreen } from '../auth/AuthSplashScreen'; import { AuthSplashScreen } from '../auth/AuthSplashScreen';
import { clearSessionBridge, writeSessionBridge } from '../../utils/sessionBridge'; import { clearSessionBridge, writeSessionBridge } from '../../utils/sessionBridge';
function ClientRootLoading() {
return <AuthSplashScreen />;
}
function ClientRootOptions({ mx }: { mx?: MatrixClient }) { function ClientRootOptions({ mx }: { mx?: MatrixClient }) {
const [menuAnchor, setMenuAnchor] = useState<RectCords>(); const [menuAnchor, setMenuAnchor] = useState<RectCords>();
@ -140,7 +135,6 @@ type ClientRootProps = {
children: ReactNode; children: ReactNode;
}; };
export function ClientRoot({ children }: ClientRootProps) { export function ClientRoot({ children }: ClientRootProps) {
const [loading, setLoading] = useState(true);
const { baseUrl, userId } = getFallbackSession() ?? {}; const { baseUrl, userId } = getFallbackSession() ?? {};
const [loadState, loadMatrix] = useAsyncCallback<MatrixClient, Error, []>( const [loadState, loadMatrix] = useAsyncCallback<MatrixClient, Error, []>(
@ -179,21 +173,15 @@ export function ClientRoot({ children }: ClientRootProps) {
writeSessionBridge(mx); writeSessionBridge(mx);
}, [mx]); }, [mx]);
useSyncState( const hasError =
mx, loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error;
useCallback((state) => {
if (state === 'PREPARED') {
setLoading(false);
}
}, [])
);
return ( return (
<AutoDiscovery userId={userId!} baseUrl={baseUrl!}> <AutoDiscovery userId={userId!} baseUrl={baseUrl!}>
<SpecVersions baseUrl={baseUrl!}> <SpecVersions baseUrl={baseUrl!}>
{mx && <SyncStatus mx={mx} />} {mx && !hasError && <SyncIndicator mx={mx} />}
{loading && <ClientRootOptions mx={mx} />} {(hasError || !mx) && <ClientRootOptions mx={mx} />}
{(loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error) && ( {hasError ? (
<AuthSplashScreen> <AuthSplashScreen>
<Box <Box
direction="Column" direction="Column"
@ -219,9 +207,12 @@ export function ClientRoot({ children }: ClientRootProps) {
</Dialog> </Dialog>
</Box> </Box>
</AuthSplashScreen> </AuthSplashScreen>
)} ) : !mx ? (
{loading || !mx ? ( // Brief gap (~300700ms) while initClient runs IndexedDB open +
<ClientRootLoading /> // crypto WASM init. Render nothing — the dark body background
// shows through instead of the mascot splash. SyncIndicator
// covers the post-mx phase visually.
null
) : ( ) : (
<MatrixClientProvider value={mx}> <MatrixClientProvider value={mx}>
<ServerConfigsLoader> <ServerConfigsLoader>

View file

@ -1,40 +1,59 @@
import React, { ReactNode } from 'react'; import React, { ReactNode, useEffect, useRef, useState } from 'react';
import { Box, Dialog, config, Text, Button } from 'folds'; import { specVersions, SpecVersions as SpecVersionsData } from '../../cs-api';
import { SpecVersionsLoader } from '../../components/SpecVersionsLoader';
import { SpecVersionsProvider } from '../../hooks/useSpecVersions'; import { SpecVersionsProvider } from '../../hooks/useSpecVersions';
import { AuthSplashScreen } from '../auth/AuthSplashScreen';
// Render children immediately with empty versions and hydrate the real value
// in the background. The previous implementation blocked the entire post-login
// tree on a `/_matrix/client/versions` round-trip (0.52s on flaky networks),
// which the user perceived as a lingering splash. Consumers
// (`useMediaAuthentication`, `useMutualRooms`, `useReportRoomSupported`)
// degrade gracefully on `versions: []` — feature-gated UI stays hidden until
// the response lands, then re-renders. If the server is genuinely unreachable
// the SyncState error path surfaces it via the bottom indicator and footer
// dot, no fullscreen blocker needed.
const DEFAULT_VERSIONS: SpecVersionsData = { versions: [] };
export function SpecVersions({ baseUrl, children }: { baseUrl: string; children: ReactNode }) { export function SpecVersions({ baseUrl, children }: { baseUrl: string; children: ReactNode }) {
return ( const [versions, setVersions] = useState<SpecVersionsData>(DEFAULT_VERSIONS);
<SpecVersionsLoader // Ref so the `online` retry can read latest state without forcing the
baseUrl={baseUrl} // listener-attach effect to re-run on every successful update.
fallback={() => <AuthSplashScreen />} const versionsRef = useRef(versions);
error={(err, retry, ignore) => ( versionsRef.current = versions;
<AuthSplashScreen>
<Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="400"> useEffect(() => {
<Dialog> let cancelled = false;
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}> const tryFetch = () => {
<Text> specVersions(fetch, baseUrl)
Unable to connect to the homeserver. The homeserver or your internet connection .then((data) => {
may be down. if (!cancelled) setVersions(data);
</Text> })
<Button variant="Critical" onClick={retry}> .catch(() => {
<Text as="span" size="B400"> // Silently keep defaults — the `online` retry below covers
Retry // transient network failures, and a permanently unreachable
</Text> // server surfaces via the SyncState error path.
</Button> });
<Button variant="Critical" onClick={ignore} fill="Soft"> };
<Text as="span" size="B400">
Continue tryFetch();
</Text>
</Button> // Retry on browser online event, but only while we're still on the
</Box> // empty default — once we've hydrated real versions we don't need to
</Dialog> // re-fetch on every Wi-Fi flap. Without this, a /versions failure on
</Box> // cold start would lock the session into degraded feature support
</AuthSplashScreen> // (no authenticated media, hidden mutual-rooms / report-room UI) until
)} // the user reloads.
> const onOnline = () => {
{(versions) => <SpecVersionsProvider value={versions}>{children}</SpecVersionsProvider>} if (versionsRef.current === DEFAULT_VERSIONS) {
</SpecVersionsLoader> tryFetch();
); }
};
window.addEventListener('online', onOnline);
return () => {
cancelled = true;
window.removeEventListener('online', onOnline);
};
}, [baseUrl]);
return <SpecVersionsProvider value={versions}>{children}</SpecVersionsProvider>;
} }

View file

@ -0,0 +1,79 @@
import { keyframes, style } from '@vanilla-extract/css';
import { config } from 'folds';
// One cycle = a single left→right sweep. The bar is full-viewport-wide and
// the radial gradient fades to transparent at both edges, so when the
// keyframe loops back from `translateX(100%)` to `translateX(-100%)` the bar
// is fully off-screen on the right and reappears off-screen on the left —
// the snap is invisible to the user.
const slide = keyframes({
'0%': { transform: 'translateX(-100%)' },
'100%': { transform: 'translateX(100%)' },
});
// Container height accommodates the largest drop-shadow halo we render here
// (the error bar's 12px) — `overflow: hidden` would otherwise clip the glow.
// The visible bar(s) sit flush with the bottom; everything above is a
// transparent pass-through zone (pointer-events: none).
export const root = style({
position: 'fixed',
left: 'env(safe-area-inset-left, 0px)',
right: 'env(safe-area-inset-right, 0px)',
bottom: 'env(safe-area-inset-bottom, 0px)',
height: '28px',
pointerEvents: 'none',
// Z400 sits above app chrome and below folds modals/popouts (which are at
// Max=9999). Connection state should be visible on the page but not on top
// of dialogs.
zIndex: config.zIndex.Z400,
overflow: 'hidden',
});
const barBase = style({
position: 'absolute',
bottom: 0,
height: '2px',
width: '100%',
left: 0,
// Both progress and error variants are always mounted; visibility is
// driven by opacity from the React side. The fade carries the green→red
// and progress→hidden transitions so the slide animation doesn't snap-cut.
transition: 'opacity 250ms ease',
});
export const barProgress = style([
barBase,
{
background:
'radial-gradient(ellipse 50% 100% at center, #5BE3C5 0%, rgba(91, 227, 197, 0.45) 35%, rgba(91, 227, 197, 0) 70%)',
filter: 'drop-shadow(0 0 6px rgba(91, 227, 197, 0.8))',
// `linear` keeps the sweep speed constant edge-to-edge — `ease-in-out`
// would slow at the extremes and read as the bar getting stuck.
animation: `${slide} 1.8s linear infinite`,
willChange: 'transform',
// Under prefers-reduced-motion the bar stays static at translateX(0) —
// the radial gradient is naturally centred so the user still sees the
// bright spot, just not moving. Visibility is controlled by the
// component's inline `opacity` style (don't override it here).
'@media': {
'(prefers-reduced-motion: reduce)': {
animation: 'none',
},
},
},
]);
// Error variant inherits barBase geometry (full viewport width, 2px tall —
// same thickness as the green progress bar). The gradient stops are pushed
// outward (50% / 90% vs green's 35% / 70%) so the visible coloured span
// extends closer to the viewport edges and reads as slightly longer than
// the green sweep at rest. Brightness comes from a more saturated #FF3030
// + denser mid-fade alpha, and a wider drop-shadow halo (12px vs 6px).
export const barError = style([
barBase,
{
background:
'radial-gradient(ellipse 50% 100% at center, #FF3030 0%, rgba(255, 48, 48, 0.7) 50%, rgba(255, 48, 48, 0) 90%)',
filter: 'drop-shadow(0 0 12px rgba(255, 48, 48, 0.95))',
},
]);

View file

@ -0,0 +1,78 @@
import React from 'react';
import { useAtomValue } from 'jotai';
import { MatrixClient, SyncState } from 'matrix-js-sdk';
import { useSyncHealth } from '../../hooks/useSyncHealth';
import { useDelayedTrue } from '../../hooks/useDelayedTrue';
import { incomingCallsAtom } from '../../state/incomingCalls';
import * as css from './SyncIndicator.css';
// Wait this long before fading the green bar in. Suppresses transient flashes
// when recovery is fast: e.g. Wi-Fi comes back, visual cycles error→progress→
// hidden in tens of milliseconds, and we'd otherwise show a meaningless green
// blink at whatever position the slide animation happened to be at. 150ms is
// short enough to feel "immediate" for sustained progress (initial sync,
// long-lasting reconnect) and long enough to swallow real-world recovery
// transients.
const PROGRESS_FADE_IN_DELAY_MS = 150;
type SyncIndicatorProps = {
mx: MatrixClient;
};
type Visual = 'hidden' | 'progress' | 'error';
const computeVisual = (
syncState: SyncState | null,
stuck: boolean,
offline: boolean
): Visual => {
if (stuck || syncState === SyncState.Error) return 'error';
if (offline) return 'progress';
if (syncState === SyncState.Syncing || syncState === SyncState.Stopped) return 'hidden';
// null / Prepared / Catchup / Reconnecting → trying to reach the server.
return 'progress';
};
export function SyncIndicator({ mx }: SyncIndicatorProps) {
const { syncState, stuck, offline } = useSyncHealth(mx);
// Hide while there's an incoming call ringing — IncomingCallStrip is a
// normal-flow block at the bottom of the layout column, our `position:
// fixed; zIndex: Z400` bar otherwise paints over its Decline/Answer
// controls. Connection state can wait until the user dismisses the call.
const incomingCalls = useAtomValue(incomingCallsAtom);
const hasIncomingCall = incomingCalls.size > 0;
const visual: Visual = hasIncomingCall
? 'hidden'
: computeVisual(syncState, stuck, offline);
// Anti-flicker debounce: the green layer only becomes visible if the
// progress visual is sustained — fast error→hidden transitions never
// surface as a green flash.
const showProgress = useDelayedTrue(visual === 'progress', PROGRESS_FADE_IN_DELAY_MS);
const showError = visual === 'error';
// Both bar layers are always mounted. Visibility is driven by opacity with
// a 250ms transition (set on barBase in the .css.ts), so progress↔error
// and progress↔hidden cross-fade smoothly. The container is
// `pointer-events: none`, so always-mounted layers are free.
//
// `animationPlayState: 'paused'` on the green layer when invisible: the
// slide keyframe runs `infinite`, and `opacity: 0` does NOT pause CSS
// animations in any major browser — the compositor still moves the
// transform every frame. Steady-state Syncing keeps the bar invisible
// for >99% of a session, so pausing avoids continuous battery drain on
// mobile. Resuming preserves the last animation position, so the bar
// picks up where it stopped instead of jumping.
return (
<div className={css.root} aria-hidden>
<div
className={css.barProgress}
style={{
opacity: showProgress ? 1 : 0,
animationPlayState: showProgress ? 'running' : 'paused',
}}
/>
<div className={css.barError} style={{ opacity: showError ? 1 : 0 }} />
</div>
);
}

View file

@ -1,87 +0,0 @@
import { MatrixClient, SyncState } from 'matrix-js-sdk';
import React, { useCallback, useState } from 'react';
import { Box, config, Line, Text } from 'folds';
import { useSyncState } from '../../hooks/useSyncState';
import { ContainerColor } from '../../styles/ContainerColor.css';
type StateData = {
current: SyncState | null;
previous: SyncState | null | undefined;
};
type SyncStatusProps = {
mx: MatrixClient;
};
export function SyncStatus({ mx }: SyncStatusProps) {
const [stateData, setStateData] = useState<StateData>({
current: null,
previous: undefined,
});
useSyncState(
mx,
useCallback((current, previous) => {
setStateData((s) => {
if (s.current === current && s.previous === previous) {
return s;
}
return { current, previous };
});
}, [])
);
if (
(stateData.current === SyncState.Prepared ||
stateData.current === SyncState.Syncing ||
stateData.current === SyncState.Catchup) &&
stateData.previous !== SyncState.Syncing
) {
return (
<Box direction="Column" shrink="No">
<Box
className={ContainerColor({ variant: 'Success' })}
style={{ padding: `${config.space.S100} 0` }}
alignItems="Center"
justifyContent="Center"
>
<Text size="L400">Connecting...</Text>
</Box>
<Line variant="Success" size="300" />
</Box>
);
}
if (stateData.current === SyncState.Reconnecting) {
return (
<Box direction="Column" shrink="No">
<Box
className={ContainerColor({ variant: 'Warning' })}
style={{ padding: `${config.space.S100} 0` }}
alignItems="Center"
justifyContent="Center"
>
<Text size="L400">Connection Lost! Reconnecting...</Text>
</Box>
<Line variant="Warning" size="300" />
</Box>
);
}
if (stateData.current === SyncState.Error) {
return (
<Box direction="Column" shrink="No">
<Box
className={ContainerColor({ variant: 'Critical' })}
style={{ padding: `${config.space.S100} 0` }}
alignItems="Center"
justifyContent="Center"
>
<Text size="L400">Connection Lost!</Text>
</Box>
<Line variant="Critical" size="300" />
</Box>
);
}
return null;
}

View file

@ -4,7 +4,9 @@ import { useAtomValue } from 'jotai';
import { Box, Button, Icon, Icons, Text, color, config, toRem } from 'folds'; import { Box, Button, Icon, Icons, Text, color, config, toRem } from 'folds';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { SyncState } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useSyncHealth } from '../../../hooks/useSyncHealth';
import { factoryRoomIdByActivity } from '../../../utils/sort'; import { factoryRoomIdByActivity } from '../../../utils/sort';
import { NavCategory, NavEmptyCenter, NavEmptyLayout } from '../../../components/nav'; import { NavCategory, NavEmptyCenter, NavEmptyLayout } from '../../../components/nav';
import { getDirectCreatePath, getDirectRoomPath } from '../../pathUtils'; import { getDirectCreatePath, getDirectRoomPath } from '../../pathUtils';
@ -64,7 +66,22 @@ function DirectEmpty() {
} }
function DirectFooterStatus() { function DirectFooterStatus() {
const { t } = useTranslation(); const mx = useMatrixClient();
const { syncState, stuck, offline } = useSyncHealth(mx);
// Aligned with SyncIndicator: red only on confirmed broken (`stuck` from
// the watchdog or `Error` from the SDK). Bare `offline` from the browser
// shows transitional gray here — the bar simultaneously shows the green
// "trying" state, and after 15s the fast watchdog escalates `stuck` so
// both surfaces flip to red together. `Syncing` only paints green when
// the browser also confirms we're online; otherwise stay transitional.
const dotBackground =
stuck || syncState === SyncState.Error
? color.Critical.Main
: syncState === SyncState.Syncing && !offline
? color.Success.Main
: 'currentColor';
return ( return (
<Box <Box
alignItems="Center" alignItems="Center"
@ -83,14 +100,13 @@ function DirectFooterStatus() {
width: toRem(6), width: toRem(6),
height: toRem(6), height: toRem(6),
borderRadius: '50%', borderRadius: '50%',
background: color.Success.Main, background: dotBackground,
display: 'inline-block', display: 'inline-block',
transition: 'background 200ms ease',
}} }}
aria-hidden aria-hidden
/> />
<span>vojo.chat</span> <span>vojo.chat</span>
<Box grow="Yes" />
<span>{t('Direct.status_e2ee')}</span>
</Box> </Box>
); );
} }

View file

@ -1,5 +1,6 @@
import React, { ReactNode } from 'react'; import React, { ReactNode } from 'react';
import { Room } from 'matrix-js-sdk'; import { Room } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai';
import { Navigate, useParams } from 'react-router-dom'; import { Navigate, useParams } from 'react-router-dom';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import { IsOneOnOneProvider, RoomProvider } from '../../../hooks/useRoom'; import { IsOneOnOneProvider, RoomProvider } from '../../../hooks/useRoom';
@ -9,6 +10,7 @@ import { isSpace } from '../../../utils/room';
import { useIsOneOnOneRoom } from '../../../hooks/useIsOneOnOneRoom'; import { useIsOneOnOneRoom } from '../../../hooks/useIsOneOnOneRoom';
import { Membership } from '../../../../types/matrix/room'; import { Membership } from '../../../../types/matrix/room';
import { getDirectPath } from '../../pathUtils'; import { getDirectPath } from '../../pathUtils';
import { allRoomsAtom } from '../../../state/room-list/roomList';
// Inner provider hosts the reactive 1:1 subscription. Hooks can't run inside // Inner provider hosts the reactive 1:1 subscription. Hooks can't run inside
// the early-return path of the parent, so we split the component once `room` // the early-return path of the parent, so we split the component once `room`
@ -24,6 +26,12 @@ function ResolvedRoomProvider({ room, children }: { room: Room; children: ReactN
export function DirectRouteRoomProvider({ children }: { children: ReactNode }) { export function DirectRouteRoomProvider({ children }: { children: ReactNode }) {
const mx = useMatrixClient(); const mx = useMatrixClient();
// Subscribe to allRoomsAtom so the lookup re-runs when /sync populates the
// store. Cold-start deep links (push tap, bookmark) hit this provider before
// first sync — without the subscription `mx.getRoom(roomId)` returns null
// forever and the user is stuck on JoinBeforeNavigate. Mirror of the same
// pattern in `home/RoomProvider.tsx` and `space/RoomProvider.tsx`.
useAtomValue(allRoomsAtom);
const { roomIdOrAlias, eventId } = useParams(); const { roomIdOrAlias, eventId } = useParams();
const roomId = useSelectedRoom(); const roomId = useSelectedRoom();