feat(connection): replace 30s 'Connecting...' banner with bottom-edge sync indicator and homeserver footer dot, drop mascot loading splash
This commit is contained in:
parent
ead1290ac4
commit
541f9181d4
15 changed files with 398 additions and 154 deletions
|
|
@ -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 : <Route index element={<WelcomePage />} />}`) — by design, not a bug.
|
||||
- `SidebarNav.tsx` — global 66px icon-rail (DirectTab, SpaceTabs, …, sticky bottom: SearchTab, UnverifiedTab, InboxTab, SettingsTab). Earmarked for removal in a follow-up `sidebar_cleanup` plan once the new Direct/Channels surfaces are self-sufficient.
|
||||
- `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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -394,7 +394,6 @@
|
|||
"self_row_label": "Я",
|
||||
"self_row_preview": "Настройки и профиль",
|
||||
"message_me_label": "я",
|
||||
"status_e2ee": "e2ee",
|
||||
"username": "Имя пользователя",
|
||||
"username_placeholder": "username",
|
||||
"server": "Сервер",
|
||||
|
|
|
|||
|
|
@ -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 <img>'s onError
|
||||
// set `error=true`, leaving the "Failed to load" overlay stuck above
|
||||
// a subsequent successful authenticated load.
|
||||
setError(false);
|
||||
setLoad(false);
|
||||
loadSrc();
|
||||
}, [autoPlay, loadSrc]);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
34
src/app/hooks/useDelayedTrue.ts
Normal file
34
src/app/hooks/useDelayedTrue.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Returns `true` once `condition` has been continuously `true` for at least
|
||||
* `delayMs`. Goes back to `false` immediately when `condition` flips false —
|
||||
* the rising edge is delayed, the falling edge is not.
|
||||
*
|
||||
* Single primitive serving two roles in this codebase:
|
||||
*
|
||||
* - **Anti-flicker debounce.** Suppress a UI element that should only show
|
||||
* when its trigger is sustained: e.g. don't render the green sync bar
|
||||
* if the underlying state will resolve to "hidden" within ~150ms.
|
||||
*
|
||||
* - **Stuck/timeout watchdog.** Trigger an alarm after a condition has
|
||||
* been continuously true for longer than expected: e.g. force-show the
|
||||
* error bar if the sync state has been "in progress" for too long
|
||||
* without a transition. Threshold is the caller's choice.
|
||||
*
|
||||
* Same mechanic, opposite intents — naming is at the call site.
|
||||
*/
|
||||
export function useDelayedTrue(condition: boolean, delayMs: number): boolean {
|
||||
const [delayed, setDelayed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!condition) {
|
||||
setDelayed(false);
|
||||
return undefined;
|
||||
}
|
||||
const timer = setTimeout(() => setDelayed(true), delayMs);
|
||||
return () => clearTimeout(timer);
|
||||
}, [condition, delayMs]);
|
||||
|
||||
return delayed;
|
||||
}
|
||||
80
src/app/hooks/useSyncHealth.ts
Normal file
80
src/app/hooks/useSyncHealth.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { ClientEvent, MatrixClient, SyncState } from 'matrix-js-sdk';
|
||||
import { useDelayedTrue } from './useDelayedTrue';
|
||||
|
||||
// Two stuck-detection thresholds for two semantically different signals.
|
||||
//
|
||||
// FAST path: a "known-bad" signal — the SDK explicitly says it's retrying
|
||||
// (Reconnecting) or the browser says the device is offline. The connection
|
||||
// is confirmed broken; 15s is enough for users to see that state escalate
|
||||
// from "trying" (green) to "broken" (red).
|
||||
//
|
||||
// SLOW path: an "ambiguous" signal — the SDK is in null/Prepared/Catchup
|
||||
// without any explicit fail-signal. Could be a legitimate slow first sync
|
||||
// (cold login on a server with many rooms can run 30s+ on Edge/3G) OR a
|
||||
// silently-stalled fetch that will never resolve (carrier whitelist, DPI
|
||||
// middlebox holding the connection open). 60s skews toward avoiding false
|
||||
// red on slow-but-healthy sync, while still eventually surfacing genuine
|
||||
// silent stalls before the user gives up.
|
||||
const FAST_STUCK_TIMEOUT_MS = 15_000;
|
||||
const SLOW_STUCK_TIMEOUT_MS = 60_000;
|
||||
|
||||
export type SyncHealth = {
|
||||
syncState: SyncState | null;
|
||||
stuck: boolean;
|
||||
offline: boolean;
|
||||
};
|
||||
|
||||
const readNavigatorOffline = (): boolean =>
|
||||
typeof navigator !== 'undefined' && navigator.onLine === false;
|
||||
|
||||
/**
|
||||
* Single source of truth for the connection-status surface (bottom indicator
|
||||
* bar + footer dot). Combines three signals:
|
||||
* - matrix-js-sdk SyncState (the canonical state machine)
|
||||
* - a two-tier stuck-watchdog for the SDK's silent-stall failure modes
|
||||
* - browser `navigator.onLine` for instant offline feedback
|
||||
*/
|
||||
export function useSyncHealth(mx: MatrixClient): SyncHealth {
|
||||
const [syncState, setSyncState] = useState<SyncState | null>(() => mx.getSyncState());
|
||||
const [offline, setOffline] = useState<boolean>(readNavigatorOffline);
|
||||
|
||||
useEffect(() => {
|
||||
setSyncState(mx.getSyncState());
|
||||
const handler = (state: SyncState) => {
|
||||
setSyncState(state);
|
||||
};
|
||||
mx.on(ClientEvent.Sync, handler);
|
||||
return () => {
|
||||
mx.removeListener(ClientEvent.Sync, handler);
|
||||
};
|
||||
}, [mx]);
|
||||
|
||||
useEffect(() => {
|
||||
const onOffline = () => setOffline(true);
|
||||
const onOnline = () => setOffline(false);
|
||||
window.addEventListener('offline', onOffline);
|
||||
window.addEventListener('online', onOnline);
|
||||
return () => {
|
||||
window.removeEventListener('offline', onOffline);
|
||||
window.removeEventListener('online', onOnline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Known-bad: SDK or browser explicitly says the connection is failing.
|
||||
const knownBad = offline || syncState === SyncState.Reconnecting;
|
||||
// Ambiguous: SDK in initial / recovery progress without an explicit fail
|
||||
// signal. Could be slow-but-healthy or silently-stalled — we can't tell
|
||||
// until the SLOW timeout elapses.
|
||||
const ambiguousProgress =
|
||||
!knownBad &&
|
||||
syncState !== SyncState.Error &&
|
||||
syncState !== SyncState.Syncing &&
|
||||
syncState !== SyncState.Stopped;
|
||||
|
||||
const stuckFromKnownBad = useDelayedTrue(knownBad, FAST_STUCK_TIMEOUT_MS);
|
||||
const stuckFromAmbiguous = useDelayedTrue(ambiguousProgress, SLOW_STUCK_TIMEOUT_MS);
|
||||
const stuck = stuckFromKnownBad || stuckFromAmbiguous;
|
||||
|
||||
return { syncState, stuck, offline };
|
||||
}
|
||||
|
|
@ -3,7 +3,11 @@ import React from 'react';
|
|||
import { AuthSplashScreen } from './auth/AuthSplashScreen';
|
||||
|
||||
export function ConfigConfigLoading() {
|
||||
return <AuthSplashScreen />;
|
||||
// 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 = {
|
||||
|
|
|
|||
|
|
@ -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 <AuthSplashScreen />;
|
||||
}
|
||||
|
||||
function ClientRootOptions({ mx }: { mx?: MatrixClient }) {
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
|
||||
|
|
@ -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<MatrixClient, Error, []>(
|
||||
|
|
@ -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 (
|
||||
<AutoDiscovery userId={userId!} baseUrl={baseUrl!}>
|
||||
<SpecVersions baseUrl={baseUrl!}>
|
||||
{mx && <SyncStatus mx={mx} />}
|
||||
{loading && <ClientRootOptions mx={mx} />}
|
||||
{(loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error) && (
|
||||
{mx && !hasError && <SyncIndicator mx={mx} />}
|
||||
{(hasError || !mx) && <ClientRootOptions mx={mx} />}
|
||||
{hasError ? (
|
||||
<AuthSplashScreen>
|
||||
<Box
|
||||
direction="Column"
|
||||
|
|
@ -219,9 +207,12 @@ export function ClientRoot({ children }: ClientRootProps) {
|
|||
</Dialog>
|
||||
</Box>
|
||||
</AuthSplashScreen>
|
||||
)}
|
||||
{loading || !mx ? (
|
||||
<ClientRootLoading />
|
||||
) : !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
|
||||
) : (
|
||||
<MatrixClientProvider value={mx}>
|
||||
<ServerConfigsLoader>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<SpecVersionsLoader
|
||||
baseUrl={baseUrl}
|
||||
fallback={() => <AuthSplashScreen />}
|
||||
error={(err, retry, ignore) => (
|
||||
<AuthSplashScreen>
|
||||
<Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="400">
|
||||
<Dialog>
|
||||
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
|
||||
<Text>
|
||||
Unable to connect to the homeserver. The homeserver or your internet connection
|
||||
may be down.
|
||||
</Text>
|
||||
<Button variant="Critical" onClick={retry}>
|
||||
<Text as="span" size="B400">
|
||||
Retry
|
||||
</Text>
|
||||
</Button>
|
||||
<Button variant="Critical" onClick={ignore} fill="Soft">
|
||||
<Text as="span" size="B400">
|
||||
Continue
|
||||
</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Dialog>
|
||||
</Box>
|
||||
</AuthSplashScreen>
|
||||
)}
|
||||
>
|
||||
{(versions) => <SpecVersionsProvider value={versions}>{children}</SpecVersionsProvider>}
|
||||
</SpecVersionsLoader>
|
||||
);
|
||||
const [versions, setVersions] = useState<SpecVersionsData>(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 <SpecVersionsProvider value={versions}>{children}</SpecVersionsProvider>;
|
||||
}
|
||||
|
|
|
|||
79
src/app/pages/client/SyncIndicator.css.ts
Normal file
79
src/app/pages/client/SyncIndicator.css.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { keyframes, style } from '@vanilla-extract/css';
|
||||
import { config } from 'folds';
|
||||
|
||||
// One cycle = a single left→right sweep. The bar is full-viewport-wide and
|
||||
// the radial gradient fades to transparent at both edges, so when the
|
||||
// keyframe loops back from `translateX(100%)` to `translateX(-100%)` the bar
|
||||
// is fully off-screen on the right and reappears off-screen on the left —
|
||||
// the snap is invisible to the user.
|
||||
const slide = keyframes({
|
||||
'0%': { transform: 'translateX(-100%)' },
|
||||
'100%': { transform: 'translateX(100%)' },
|
||||
});
|
||||
|
||||
// Container height accommodates the largest drop-shadow halo we render here
|
||||
// (the error bar's 12px) — `overflow: hidden` would otherwise clip the glow.
|
||||
// The visible bar(s) sit flush with the bottom; everything above is a
|
||||
// transparent pass-through zone (pointer-events: none).
|
||||
export const root = style({
|
||||
position: 'fixed',
|
||||
left: 'env(safe-area-inset-left, 0px)',
|
||||
right: 'env(safe-area-inset-right, 0px)',
|
||||
bottom: 'env(safe-area-inset-bottom, 0px)',
|
||||
height: '28px',
|
||||
pointerEvents: 'none',
|
||||
// Z400 sits above app chrome and below folds modals/popouts (which are at
|
||||
// Max=9999). Connection state should be visible on the page but not on top
|
||||
// of dialogs.
|
||||
zIndex: config.zIndex.Z400,
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
const barBase = style({
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
height: '2px',
|
||||
width: '100%',
|
||||
left: 0,
|
||||
// Both progress and error variants are always mounted; visibility is
|
||||
// driven by opacity from the React side. The fade carries the green→red
|
||||
// and progress→hidden transitions so the slide animation doesn't snap-cut.
|
||||
transition: 'opacity 250ms ease',
|
||||
});
|
||||
|
||||
export const barProgress = style([
|
||||
barBase,
|
||||
{
|
||||
background:
|
||||
'radial-gradient(ellipse 50% 100% at center, #5BE3C5 0%, rgba(91, 227, 197, 0.45) 35%, rgba(91, 227, 197, 0) 70%)',
|
||||
filter: 'drop-shadow(0 0 6px rgba(91, 227, 197, 0.8))',
|
||||
// `linear` keeps the sweep speed constant edge-to-edge — `ease-in-out`
|
||||
// would slow at the extremes and read as the bar getting stuck.
|
||||
animation: `${slide} 1.8s linear infinite`,
|
||||
willChange: 'transform',
|
||||
// Under prefers-reduced-motion the bar stays static at translateX(0) —
|
||||
// the radial gradient is naturally centred so the user still sees the
|
||||
// bright spot, just not moving. Visibility is controlled by the
|
||||
// component's inline `opacity` style (don't override it here).
|
||||
'@media': {
|
||||
'(prefers-reduced-motion: reduce)': {
|
||||
animation: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// Error variant inherits barBase geometry (full viewport width, 2px tall —
|
||||
// same thickness as the green progress bar). The gradient stops are pushed
|
||||
// outward (50% / 90% vs green's 35% / 70%) so the visible coloured span
|
||||
// extends closer to the viewport edges and reads as slightly longer than
|
||||
// the green sweep at rest. Brightness comes from a more saturated #FF3030
|
||||
// + denser mid-fade alpha, and a wider drop-shadow halo (12px vs 6px).
|
||||
export const barError = style([
|
||||
barBase,
|
||||
{
|
||||
background:
|
||||
'radial-gradient(ellipse 50% 100% at center, #FF3030 0%, rgba(255, 48, 48, 0.7) 50%, rgba(255, 48, 48, 0) 90%)',
|
||||
filter: 'drop-shadow(0 0 12px rgba(255, 48, 48, 0.95))',
|
||||
},
|
||||
]);
|
||||
78
src/app/pages/client/SyncIndicator.tsx
Normal file
78
src/app/pages/client/SyncIndicator.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import React from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { MatrixClient, SyncState } from 'matrix-js-sdk';
|
||||
import { useSyncHealth } from '../../hooks/useSyncHealth';
|
||||
import { useDelayedTrue } from '../../hooks/useDelayedTrue';
|
||||
import { incomingCallsAtom } from '../../state/incomingCalls';
|
||||
import * as css from './SyncIndicator.css';
|
||||
|
||||
// Wait this long before fading the green bar in. Suppresses transient flashes
|
||||
// when recovery is fast: e.g. Wi-Fi comes back, visual cycles error→progress→
|
||||
// hidden in tens of milliseconds, and we'd otherwise show a meaningless green
|
||||
// blink at whatever position the slide animation happened to be at. 150ms is
|
||||
// short enough to feel "immediate" for sustained progress (initial sync,
|
||||
// long-lasting reconnect) and long enough to swallow real-world recovery
|
||||
// transients.
|
||||
const PROGRESS_FADE_IN_DELAY_MS = 150;
|
||||
|
||||
type SyncIndicatorProps = {
|
||||
mx: MatrixClient;
|
||||
};
|
||||
|
||||
type Visual = 'hidden' | 'progress' | 'error';
|
||||
|
||||
const computeVisual = (
|
||||
syncState: SyncState | null,
|
||||
stuck: boolean,
|
||||
offline: boolean
|
||||
): Visual => {
|
||||
if (stuck || syncState === SyncState.Error) return 'error';
|
||||
if (offline) return 'progress';
|
||||
if (syncState === SyncState.Syncing || syncState === SyncState.Stopped) return 'hidden';
|
||||
// null / Prepared / Catchup / Reconnecting → trying to reach the server.
|
||||
return 'progress';
|
||||
};
|
||||
|
||||
export function SyncIndicator({ mx }: SyncIndicatorProps) {
|
||||
const { syncState, stuck, offline } = useSyncHealth(mx);
|
||||
// Hide while there's an incoming call ringing — IncomingCallStrip is a
|
||||
// normal-flow block at the bottom of the layout column, our `position:
|
||||
// fixed; zIndex: Z400` bar otherwise paints over its Decline/Answer
|
||||
// controls. Connection state can wait until the user dismisses the call.
|
||||
const incomingCalls = useAtomValue(incomingCallsAtom);
|
||||
const hasIncomingCall = incomingCalls.size > 0;
|
||||
const visual: Visual = hasIncomingCall
|
||||
? 'hidden'
|
||||
: computeVisual(syncState, stuck, offline);
|
||||
|
||||
// Anti-flicker debounce: the green layer only becomes visible if the
|
||||
// progress visual is sustained — fast error→hidden transitions never
|
||||
// surface as a green flash.
|
||||
const showProgress = useDelayedTrue(visual === 'progress', PROGRESS_FADE_IN_DELAY_MS);
|
||||
const showError = visual === 'error';
|
||||
|
||||
// Both bar layers are always mounted. Visibility is driven by opacity with
|
||||
// a 250ms transition (set on barBase in the .css.ts), so progress↔error
|
||||
// and progress↔hidden cross-fade smoothly. The container is
|
||||
// `pointer-events: none`, so always-mounted layers are free.
|
||||
//
|
||||
// `animationPlayState: 'paused'` on the green layer when invisible: the
|
||||
// slide keyframe runs `infinite`, and `opacity: 0` does NOT pause CSS
|
||||
// animations in any major browser — the compositor still moves the
|
||||
// transform every frame. Steady-state Syncing keeps the bar invisible
|
||||
// for >99% of a session, so pausing avoids continuous battery drain on
|
||||
// mobile. Resuming preserves the last animation position, so the bar
|
||||
// picks up where it stopped instead of jumping.
|
||||
return (
|
||||
<div className={css.root} aria-hidden>
|
||||
<div
|
||||
className={css.barProgress}
|
||||
style={{
|
||||
opacity: showProgress ? 1 : 0,
|
||||
animationPlayState: showProgress ? 'running' : 'paused',
|
||||
}}
|
||||
/>
|
||||
<div className={css.barError} style={{ opacity: showError ? 1 : 0 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
import { MatrixClient, SyncState } from 'matrix-js-sdk';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Box, config, Line, Text } from 'folds';
|
||||
import { useSyncState } from '../../hooks/useSyncState';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
|
||||
type StateData = {
|
||||
current: SyncState | null;
|
||||
previous: SyncState | null | undefined;
|
||||
};
|
||||
|
||||
type SyncStatusProps = {
|
||||
mx: MatrixClient;
|
||||
};
|
||||
export function SyncStatus({ mx }: SyncStatusProps) {
|
||||
const [stateData, setStateData] = useState<StateData>({
|
||||
current: null,
|
||||
previous: undefined,
|
||||
});
|
||||
|
||||
useSyncState(
|
||||
mx,
|
||||
useCallback((current, previous) => {
|
||||
setStateData((s) => {
|
||||
if (s.current === current && s.previous === previous) {
|
||||
return s;
|
||||
}
|
||||
return { current, previous };
|
||||
});
|
||||
}, [])
|
||||
);
|
||||
|
||||
if (
|
||||
(stateData.current === SyncState.Prepared ||
|
||||
stateData.current === SyncState.Syncing ||
|
||||
stateData.current === SyncState.Catchup) &&
|
||||
stateData.previous !== SyncState.Syncing
|
||||
) {
|
||||
return (
|
||||
<Box direction="Column" shrink="No">
|
||||
<Box
|
||||
className={ContainerColor({ variant: 'Success' })}
|
||||
style={{ padding: `${config.space.S100} 0` }}
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
>
|
||||
<Text size="L400">Connecting...</Text>
|
||||
</Box>
|
||||
<Line variant="Success" size="300" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (stateData.current === SyncState.Reconnecting) {
|
||||
return (
|
||||
<Box direction="Column" shrink="No">
|
||||
<Box
|
||||
className={ContainerColor({ variant: 'Warning' })}
|
||||
style={{ padding: `${config.space.S100} 0` }}
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
>
|
||||
<Text size="L400">Connection Lost! Reconnecting...</Text>
|
||||
</Box>
|
||||
<Line variant="Warning" size="300" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (stateData.current === SyncState.Error) {
|
||||
return (
|
||||
<Box direction="Column" shrink="No">
|
||||
<Box
|
||||
className={ContainerColor({ variant: 'Critical' })}
|
||||
style={{ padding: `${config.space.S100} 0` }}
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
>
|
||||
<Text size="L400">Connection Lost!</Text>
|
||||
</Box>
|
||||
<Line variant="Critical" size="300" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<Box
|
||||
alignItems="Center"
|
||||
|
|
@ -83,14 +100,13 @@ function DirectFooterStatus() {
|
|||
width: toRem(6),
|
||||
height: toRem(6),
|
||||
borderRadius: '50%',
|
||||
background: color.Success.Main,
|
||||
background: dotBackground,
|
||||
display: 'inline-block',
|
||||
transition: 'background 200ms ease',
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
<span>vojo.chat</span>
|
||||
<Box grow="Yes" />
|
||||
<span>{t('Direct.status_e2ee')}</span>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue