diff --git a/docs/ai/architecture.md b/docs/ai/architecture.md
index 162da374..cceb54ff 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.
- - `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.
+ - `SyncStatus.tsx`, `SpecVersions.tsx` — Connection status
### Universal Stream routing + DM classification (post-P3c)
diff --git a/public/locales/en.json b/public/locales/en.json
index e22e2952..a875f554 100644
--- a/public/locales/en.json
+++ b/public/locales/en.json
@@ -371,6 +371,7 @@
"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 e581aac2..37d0cd14 100644
--- a/public/locales/ru.json
+++ b/public/locales/ru.json
@@ -373,6 +373,7 @@
"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 f3416509..4f1d9c75 100644
--- a/src/app/components/message/content/ImageContent.tsx
+++ b/src/app/components/message/content/ImageContent.tsx
@@ -101,10 +101,6 @@ 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);
@@ -117,17 +113,7 @@ export const ImageContent = as<'div', ImageContentProps>(
};
useEffect(() => {
- 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();
+ if (autoPlay) loadSrc();
}, [autoPlay, loadSrc]);
return (
diff --git a/src/app/components/message/content/VideoContent.tsx b/src/app/components/message/content/VideoContent.tsx
index cfcb6279..b33ec272 100644
--- a/src/app/components/message/content/VideoContent.tsx
+++ b/src/app/components/message/content/VideoContent.tsx
@@ -94,9 +94,6 @@ 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);
@@ -109,14 +106,7 @@ export const VideoContent = as<'div', VideoContentProps>(
};
useEffect(() => {
- 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();
+ if (autoPlay) loadSrc();
}, [autoPlay, loadSrc]);
return (
diff --git a/src/app/hooks/useDelayedTrue.ts b/src/app/hooks/useDelayedTrue.ts
deleted file mode 100644
index a9b80f9a..00000000
--- a/src/app/hooks/useDelayedTrue.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-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
deleted file mode 100644
index 7eb08fbb..00000000
--- a/src/app/hooks/useSyncHealth.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-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 e9a394b7..38a06167 100644
--- a/src/app/pages/ConfigConfig.tsx
+++ b/src/app/pages/ConfigConfig.tsx
@@ -3,11 +3,7 @@ import React from 'react';
import { AuthSplashScreen } from './auth/AuthSplashScreen';
export function ConfigConfigLoading() {
- // 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;
+ return ;
}
type ConfigConfigErrorProps = {
diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx
index d8906c44..9973d56d 100644
--- a/src/app/pages/client/ClientRoot.tsx
+++ b/src/app/pages/client/ClientRoot.tsx
@@ -28,14 +28,19 @@ 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 { SyncIndicator } from './SyncIndicator';
+import { SyncStatus } from './SyncStatus';
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();
@@ -135,6 +140,7 @@ type ClientRootProps = {
children: ReactNode;
};
export function ClientRoot({ children }: ClientRootProps) {
+ const [loading, setLoading] = useState(true);
const { baseUrl, userId } = getFallbackSession() ?? {};
const [loadState, loadMatrix] = useAsyncCallback(
@@ -173,15 +179,21 @@ export function ClientRoot({ children }: ClientRootProps) {
writeSessionBridge(mx);
}, [mx]);
- const hasError =
- loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error;
+ useSyncState(
+ mx,
+ useCallback((state) => {
+ if (state === 'PREPARED') {
+ setLoading(false);
+ }
+ }, [])
+ );
return (
- {mx && !hasError && }
- {(hasError || !mx) && }
- {hasError ? (
+ {mx && }
+ {loading && }
+ {(loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error) && (
- ) : !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
+ )}
+ {loading || !mx ? (
+
) : (
diff --git a/src/app/pages/client/SpecVersions.tsx b/src/app/pages/client/SpecVersions.tsx
index 7e9d5c33..2c3f0088 100644
--- a/src/app/pages/client/SpecVersions.tsx
+++ b/src/app/pages/client/SpecVersions.tsx
@@ -1,59 +1,40 @@
-import React, { ReactNode, useEffect, useRef, useState } from 'react';
-import { specVersions, SpecVersions as SpecVersionsData } from '../../cs-api';
+import React, { ReactNode } from 'react';
+import { Box, Dialog, config, Text, Button } from 'folds';
+import { SpecVersionsLoader } from '../../components/SpecVersionsLoader';
import { SpecVersionsProvider } from '../../hooks/useSpecVersions';
-
-// 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: [] };
+import { AuthSplashScreen } from '../auth/AuthSplashScreen';
export function SpecVersions({ baseUrl, children }: { baseUrl: string; children: ReactNode }) {
- 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};
+ return (
+ }
+ error={(err, retry, ignore) => (
+
+
+
+
+
+ )}
+ >
+ {(versions) => {children}}
+
+ );
}
diff --git a/src/app/pages/client/SyncIndicator.css.ts b/src/app/pages/client/SyncIndicator.css.ts
deleted file mode 100644
index f66a0d72..00000000
--- a/src/app/pages/client/SyncIndicator.css.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-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
deleted file mode 100644
index eab302b4..00000000
--- a/src/app/pages/client/SyncIndicator.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-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
new file mode 100644
index 00000000..9cd4b0b2
--- /dev/null
+++ b/src/app/pages/client/SyncStatus.tsx
@@ -0,0 +1,87 @@
+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 32438cde..0bad07af 100644
--- a/src/app/pages/client/direct/Direct.tsx
+++ b/src/app/pages/client/direct/Direct.tsx
@@ -4,9 +4,7 @@ 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';
@@ -66,22 +64,7 @@ function DirectEmpty() {
}
function DirectFooterStatus() {
- 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';
-
+ const { t } = useTranslation();
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 ae2a9229..4fa0d894 100644
--- a/src/app/pages/client/direct/RoomProvider.tsx
+++ b/src/app/pages/client/direct/RoomProvider.tsx
@@ -1,6 +1,5 @@
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';
@@ -10,7 +9,6 @@ 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`
@@ -26,12 +24,6 @@ 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();