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) => ( - - - - - - Unable to connect to the homeserver. The homeserver or your internet connection - may be down. - - - - - - - - )} - > - {(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();