From ff01e6cacc5f6fc434525c3cc0c6eecde3c306f9 Mon Sep 17 00:00:00 2001 From: "v.lagerev" Date: Sun, 3 May 2026 20:15:26 +0300 Subject: [PATCH] Revert "feat(connection): replace 30s 'Connecting...' banner with bottom-edge sync indicator and homeserver footer dot, drop mascot loading splash" This reverts commit 541f9181d4b15a588e9d9a68bdc605f0d2ad5bb6. --- docs/ai/architecture.md | 2 +- public/locales/en.json | 1 + public/locales/ru.json | 1 + .../message/content/ImageContent.tsx | 16 +--- .../message/content/VideoContent.tsx | 12 +-- src/app/hooks/useDelayedTrue.ts | 34 ------- src/app/hooks/useSyncHealth.ts | 80 ---------------- src/app/pages/ConfigConfig.tsx | 6 +- src/app/pages/client/ClientRoot.tsx | 33 ++++--- src/app/pages/client/SpecVersions.tsx | 91 ++++++++----------- src/app/pages/client/SyncIndicator.css.ts | 79 ---------------- src/app/pages/client/SyncIndicator.tsx | 78 ---------------- src/app/pages/client/SyncStatus.tsx | 87 ++++++++++++++++++ src/app/pages/client/direct/Direct.tsx | 24 +---- src/app/pages/client/direct/RoomProvider.tsx | 8 -- 15 files changed, 154 insertions(+), 398 deletions(-) delete mode 100644 src/app/hooks/useDelayedTrue.ts delete mode 100644 src/app/hooks/useSyncHealth.ts delete mode 100644 src/app/pages/client/SyncIndicator.css.ts delete mode 100644 src/app/pages/client/SyncIndicator.tsx create mode 100644 src/app/pages/client/SyncStatus.tsx 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) => ( + + + + + + Unable to connect to the homeserver. The homeserver or your internet connection + may be down. + + + + + + + + )} + > + {(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();