diff --git a/docs/ai/architecture.md b/docs/ai/architecture.md
index 2b75ea8c..1bf1e858 100644
--- a/docs/ai/architecture.md
+++ b/docs/ai/architecture.md
@@ -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.
- `WelcomePage.tsx` — Empty state (vojo SVG + version). **Mobile is intentionally null** at the index route (`{mobile ? null : } />}`) — 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.
- - `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)
diff --git a/public/locales/en.json b/public/locales/en.json
index 34edb500..26f604f0 100644
--- a/public/locales/en.json
+++ b/public/locales/en.json
@@ -392,7 +392,6 @@
"self_row_label": "You",
"self_row_preview": "Settings & profile",
"message_me_label": "me",
- "status_e2ee": "e2ee",
"username": "Username",
"username_placeholder": "username",
"server": "Server",
diff --git a/public/locales/ru.json b/public/locales/ru.json
index 73c057cc..341dba26 100644
--- a/public/locales/ru.json
+++ b/public/locales/ru.json
@@ -394,7 +394,6 @@
"self_row_label": "Я",
"self_row_preview": "Настройки и профиль",
"message_me_label": "я",
- "status_e2ee": "e2ee",
"username": "Имя пользователя",
"username_placeholder": "username",
"server": "Сервер",
diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx
index 4f1d9c75..f3416509 100644
--- a/src/app/components/message/content/ImageContent.tsx
+++ b/src/app/components/message/content/ImageContent.tsx
@@ -101,6 +101,10 @@ export const ImageContent = as<'div', ImageContentProps>(
const handleLoad = () => {
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 = () => {
setLoad(false);
@@ -113,7 +117,17 @@ export const ImageContent = as<'div', ImageContentProps>(
};
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 '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]);
return (
diff --git a/src/app/components/message/content/VideoContent.tsx b/src/app/components/message/content/VideoContent.tsx
index b33ec272..cfcb6279 100644
--- a/src/app/components/message/content/VideoContent.tsx
+++ b/src/app/components/message/content/VideoContent.tsx
@@ -94,6 +94,9 @@ export const VideoContent = as<'div', VideoContentProps>(
const handleLoad = () => {
setLoad(true);
+ // Mirror ImageContent: a successful load conclusively clears any
+ // sticky error state from a previous failed attempt.
+ setError(false);
};
const handleError = () => {
setLoad(false);
@@ -106,7 +109,14 @@ export const VideoContent = as<'div', VideoContentProps>(
};
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]);
return (
diff --git a/src/app/hooks/useDelayedTrue.ts b/src/app/hooks/useDelayedTrue.ts
new file mode 100644
index 00000000..a9b80f9a
--- /dev/null
+++ b/src/app/hooks/useDelayedTrue.ts
@@ -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;
+}
diff --git a/src/app/hooks/useSyncHealth.ts b/src/app/hooks/useSyncHealth.ts
new file mode 100644
index 00000000..7eb08fbb
--- /dev/null
+++ b/src/app/hooks/useSyncHealth.ts
@@ -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(() => mx.getSyncState());
+ const [offline, setOffline] = useState(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 };
+}
diff --git a/src/app/pages/ConfigConfig.tsx b/src/app/pages/ConfigConfig.tsx
index 38a06167..e9a394b7 100644
--- a/src/app/pages/ConfigConfig.tsx
+++ b/src/app/pages/ConfigConfig.tsx
@@ -3,7 +3,11 @@ import React from 'react';
import { AuthSplashScreen } from './auth/AuthSplashScreen';
export function ConfigConfigLoading() {
- return ;
+ // Brief gap (~50–300ms) 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 = {
diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx
index 9973d56d..d8906c44 100644
--- a/src/app/pages/client/ClientRoot.tsx
+++ b/src/app/pages/client/ClientRoot.tsx
@@ -28,19 +28,14 @@ import { MediaConfigProvider } from '../../hooks/useMediaConfig';
import { MatrixClientProvider } from '../../hooks/useMatrixClient';
import { SpecVersions } from './SpecVersions';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
-import { useSyncState } from '../../hooks/useSyncState';
import { stopPropagation } from '../../utils/keyboard';
-import { SyncStatus } from './SyncStatus';
+import { SyncIndicator } from './SyncIndicator';
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
import { getFallbackSession } from '../../state/sessions';
import { AutoDiscovery } from './AutoDiscovery';
import { AuthSplashScreen } from '../auth/AuthSplashScreen';
import { clearSessionBridge, writeSessionBridge } from '../../utils/sessionBridge';
-function ClientRootLoading() {
- return ;
-}
-
function ClientRootOptions({ mx }: { mx?: MatrixClient }) {
const [menuAnchor, setMenuAnchor] = useState();
@@ -140,7 +135,6 @@ type ClientRootProps = {
children: ReactNode;
};
export function ClientRoot({ children }: ClientRootProps) {
- const [loading, setLoading] = useState(true);
const { baseUrl, userId } = getFallbackSession() ?? {};
const [loadState, loadMatrix] = useAsyncCallback(
@@ -179,21 +173,15 @@ export function ClientRoot({ children }: ClientRootProps) {
writeSessionBridge(mx);
}, [mx]);
- useSyncState(
- mx,
- useCallback((state) => {
- if (state === 'PREPARED') {
- setLoading(false);
- }
- }, [])
- );
+ const hasError =
+ loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error;
return (
- {mx && }
- {loading && }
- {(loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error) && (
+ {mx && !hasError && }
+ {(hasError || !mx) && }
+ {hasError ? (
- )}
- {loading || !mx ? (
-
+ ) : !mx ? (
+ // Brief gap (~300–700ms) while initClient runs IndexedDB open +
+ // crypto WASM init. Render nothing — the dark body background
+ // shows through instead of the mascot splash. SyncIndicator
+ // covers the post-mx phase visually.
+ null
) : (
diff --git a/src/app/pages/client/SpecVersions.tsx b/src/app/pages/client/SpecVersions.tsx
index 2c3f0088..7e9d5c33 100644
--- a/src/app/pages/client/SpecVersions.tsx
+++ b/src/app/pages/client/SpecVersions.tsx
@@ -1,40 +1,59 @@
-import React, { ReactNode } from 'react';
-import { Box, Dialog, config, Text, Button } from 'folds';
-import { SpecVersionsLoader } from '../../components/SpecVersionsLoader';
+import React, { ReactNode, useEffect, useRef, useState } from 'react';
+import { specVersions, SpecVersions as SpecVersionsData } from '../../cs-api';
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.5–2s 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 }) {
- return (
- }
- error={(err, retry, ignore) => (
-
-
-
-
-
- )}
- >
- {(versions) => {children}}
-
- );
+ const [versions, setVersions] = useState(DEFAULT_VERSIONS);
+ // Ref so the `online` retry can read latest state without forcing the
+ // listener-attach effect to re-run on every successful update.
+ const versionsRef = useRef(versions);
+ versionsRef.current = versions;
+
+ useEffect(() => {
+ let cancelled = false;
+ const tryFetch = () => {
+ specVersions(fetch, baseUrl)
+ .then((data) => {
+ if (!cancelled) setVersions(data);
+ })
+ .catch(() => {
+ // Silently keep defaults — the `online` retry below covers
+ // transient network failures, and a permanently unreachable
+ // server surfaces via the SyncState error path.
+ });
+ };
+
+ tryFetch();
+
+ // Retry on browser online event, but only while we're still on the
+ // empty default — once we've hydrated real versions we don't need to
+ // re-fetch on every Wi-Fi flap. Without this, a /versions failure on
+ // cold start would lock the session into degraded feature support
+ // (no authenticated media, hidden mutual-rooms / report-room UI) until
+ // the user reloads.
+ const onOnline = () => {
+ if (versionsRef.current === DEFAULT_VERSIONS) {
+ tryFetch();
+ }
+ };
+ window.addEventListener('online', onOnline);
+
+ return () => {
+ cancelled = true;
+ window.removeEventListener('online', onOnline);
+ };
+ }, [baseUrl]);
+
+ return {children};
}
diff --git a/src/app/pages/client/SyncIndicator.css.ts b/src/app/pages/client/SyncIndicator.css.ts
new file mode 100644
index 00000000..f66a0d72
--- /dev/null
+++ b/src/app/pages/client/SyncIndicator.css.ts
@@ -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))',
+ },
+]);
diff --git a/src/app/pages/client/SyncIndicator.tsx b/src/app/pages/client/SyncIndicator.tsx
new file mode 100644
index 00000000..eab302b4
--- /dev/null
+++ b/src/app/pages/client/SyncIndicator.tsx
@@ -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 (
+
+
+
+
+ );
+}
diff --git a/src/app/pages/client/SyncStatus.tsx b/src/app/pages/client/SyncStatus.tsx
deleted file mode 100644
index 9cd4b0b2..00000000
--- a/src/app/pages/client/SyncStatus.tsx
+++ /dev/null
@@ -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({
- 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 (
-
-
- Connecting...
-
-
-
- );
- }
-
- if (stateData.current === SyncState.Reconnecting) {
- return (
-
-
- Connection Lost! Reconnecting...
-
-
-
- );
- }
-
- if (stateData.current === SyncState.Error) {
- return (
-
-
- Connection Lost!
-
-
-
- );
- }
-
- return null;
-}
diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx
index 0bad07af..32438cde 100644
--- a/src/app/pages/client/direct/Direct.tsx
+++ b/src/app/pages/client/direct/Direct.tsx
@@ -4,7 +4,9 @@ import { useAtomValue } from 'jotai';
import { Box, Button, Icon, Icons, Text, color, config, toRem } from 'folds';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useNavigate } from 'react-router-dom';
+import { SyncState } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { useSyncHealth } from '../../../hooks/useSyncHealth';
import { factoryRoomIdByActivity } from '../../../utils/sort';
import { NavCategory, NavEmptyCenter, NavEmptyLayout } from '../../../components/nav';
import { getDirectCreatePath, getDirectRoomPath } from '../../pathUtils';
@@ -64,7 +66,22 @@ function DirectEmpty() {
}
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 (
vojo.chat
-
- {t('Direct.status_e2ee')}
);
}
diff --git a/src/app/pages/client/direct/RoomProvider.tsx b/src/app/pages/client/direct/RoomProvider.tsx
index 4fa0d894..ae2a9229 100644
--- a/src/app/pages/client/direct/RoomProvider.tsx
+++ b/src/app/pages/client/direct/RoomProvider.tsx
@@ -1,5 +1,6 @@
import React, { ReactNode } from 'react';
import { Room } from 'matrix-js-sdk';
+import { useAtomValue } from 'jotai';
import { Navigate, useParams } from 'react-router-dom';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import { IsOneOnOneProvider, RoomProvider } from '../../../hooks/useRoom';
@@ -9,6 +10,7 @@ import { isSpace } from '../../../utils/room';
import { useIsOneOnOneRoom } from '../../../hooks/useIsOneOnOneRoom';
import { Membership } from '../../../../types/matrix/room';
import { getDirectPath } from '../../pathUtils';
+import { allRoomsAtom } from '../../../state/room-list/roomList';
// 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`
@@ -24,6 +26,12 @@ function ResolvedRoomProvider({ room, children }: { room: Room; children: ReactN
export function DirectRouteRoomProvider({ children }: { children: ReactNode }) {
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 roomId = useSelectedRoom();