diff --git a/.changeset/feat-revamp-connection-status-indicator.md b/.changeset/feat-revamp-connection-status-indicator.md new file mode 100644 index 0000000000..55b9546e2c --- /dev/null +++ b/.changeset/feat-revamp-connection-status-indicator.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Replaced the connection status top bar with a small styled popup and added a variant that shows sliding sync initial progress. diff --git a/.changeset/fix-banner-spam.md b/.changeset/fix-banner-spam.md new file mode 100644 index 0000000000..1702cf9dae --- /dev/null +++ b/.changeset/fix-banner-spam.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Made popups on start appear sequentially instead of bombarding all at once. diff --git a/.changeset/unify-update-popups.md b/.changeset/unify-update-popups.md new file mode 100644 index 0000000000..b827ec8b97 --- /dev/null +++ b/.changeset/unify-update-popups.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Made update popups use in-app modals instead of calling os native dialogs. diff --git a/package.json b/package.json index 50392d3ed2..9eff87f136 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,6 @@ "@tauri-apps/api": "2.11.1", "@tauri-apps/plugin-clipboard-manager": "2.3.2", "@tauri-apps/plugin-deep-link": "2.4.9", - "@tauri-apps/plugin-dialog": "~2.7.1", "@tauri-apps/plugin-http": "2.5.9", "@tauri-apps/plugin-opener": "2.5.4", "@tauri-apps/plugin-os": "2.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da7ba51340..9f8fc1757b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,9 +65,6 @@ importers: '@tauri-apps/plugin-deep-link': specifier: 2.4.9 version: 2.4.9 - '@tauri-apps/plugin-dialog': - specifier: ~2.7.1 - version: 2.7.1 '@tauri-apps/plugin-http': specifier: 2.5.9 version: 2.5.9 @@ -2924,9 +2921,6 @@ packages: '@tauri-apps/plugin-deep-link@2.4.9': resolution: {integrity: sha512-u0SKOUHnJ1wqeqXsDFq2+kASCBj9xxbG0g9XZWPy9SOmU4wXtp6b/wiYpm6oH6/5fBTQsLqnLhIvqLBRpgHJlA==} - '@tauri-apps/plugin-dialog@2.7.1': - resolution: {integrity: sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==} - '@tauri-apps/plugin-http@2.5.9': resolution: {integrity: sha512-lCiY0+vs4HvIUSvZrBs8TC3TiCB0MOPRmiUjTq4prW7SlcJE2jdLeT6KBsJrT9Tlplufl7W1pY6SFAO3gCWxDA==} @@ -7499,10 +7493,6 @@ snapshots: dependencies: '@tauri-apps/api': 2.11.1 - '@tauri-apps/plugin-dialog@2.7.1': - dependencies: - '@tauri-apps/api': 2.11.1 - '@tauri-apps/plugin-http@2.5.9': dependencies: '@tauri-apps/api': 2.11.1 diff --git a/src/app/components/SyncConnectionStatus.tsx b/src/app/components/SyncConnectionStatus.tsx index ec6ef2505d..a15e957f19 100644 --- a/src/app/components/SyncConnectionStatus.tsx +++ b/src/app/components/SyncConnectionStatus.tsx @@ -1,6 +1,6 @@ import classNames from 'classnames'; -import { Box, config, Line, Text } from 'folds'; -import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'; +import { Box, config, Text } from 'folds'; +import { AnimatePresence, motion, useReducedMotion, type Variants } from 'framer-motion'; import { SyncState } from '$types/matrix-sdk'; import { type TitlebarStatusView } from '$state/titlebarStatus'; import { ContainerColor } from '$styles/ContainerColor.css'; @@ -37,19 +37,119 @@ export function getSyncConnectionStatusView( } export function SyncConnectionStatusBanner({ status }: SyncConnectionStatusProps) { - if (!status) return null; + const shouldReduceMotion = useReducedMotion(); + + const bannerVariants: Variants = shouldReduceMotion + ? { + hidden: { opacity: 0 }, + visible: { opacity: 1, transition: { duration: 0.2 } }, + exit: { opacity: 0, transition: { duration: 0.15 } }, + } + : { + hidden: { y: -30, opacity: 0, scale: 0.95 }, + visible: { + y: 0, + opacity: 1, + scale: 1, + transition: { type: 'spring', damping: 20, stiffness: 300 }, + }, + exit: { + y: -20, + opacity: 0, + scale: 0.95, + transition: { duration: 0.15, ease: TITLEBAR_EASE_OUT }, + }, + }; return ( - - - {status.text} - - + + + {status && ( + + + {status.text} + + {status.progress !== undefined && ( +
+ )} + + + )} + ); } @@ -132,15 +232,18 @@ export function SyncConnectionStatusTitlebar({ status }: SyncConnectionStatusPro {status && ( {status.text} + {status.progress !== undefined && ( +
+ )} )} diff --git a/src/app/components/global-banner/GlobalBannerRenderer.css.ts b/src/app/components/global-banner/GlobalBannerRenderer.css.ts new file mode 100644 index 0000000000..37b9677cfc --- /dev/null +++ b/src/app/components/global-banner/GlobalBannerRenderer.css.ts @@ -0,0 +1,57 @@ +import { style } from '@vanilla-extract/css'; +import { color, config, toRem } from 'folds'; + +export const Container = style({ + position: 'fixed', + top: 'calc(env(safe-area-inset-top, 0px) + 16px)', + left: '50%', + transform: 'translateX(-50%)', + zIndex: 9998, + width: `min(calc(100vw - 32px), ${toRem(540)})`, + pointerEvents: 'none', +}); + +export const Banner = style({ + pointerEvents: 'all', + display: 'flex', + flexDirection: 'column', + gap: config.space.S300, + backgroundColor: + 'color-mix(in srgb, var(--sable-surface-var-container, #e4e4e7) 78%, transparent)', + color: color.SurfaceVariant.OnContainer, + border: `${config.borderWidth.B300} solid color-mix(in srgb, var(--sable-surface-var-container-line, #71717a) 70%, transparent)`, + borderRadius: toRem(16), + padding: config.space.S400, + boxShadow: '0 10px 32px rgba(0, 0, 0, 0.22)', + backdropFilter: 'blur(20px)', + WebkitBackdropFilter: 'blur(20px)', +}); + +export const Header = style({ + display: 'flex', + alignItems: 'flex-start', + gap: config.space.S300, +}); + +export const IconContainer = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + paddingTop: toRem(2), + color: 'var(--sable-primary-main)', +}); + +export const HeaderText = style({ + flex: 1, + minWidth: 0, + display: 'flex', + flexDirection: 'column', + gap: toRem(4), +}); + +export const Actions = style({ + display: 'flex', + gap: config.space.S200, + justifyContent: 'flex-end', + flexWrap: 'wrap', +}); diff --git a/src/app/components/global-banner/GlobalBannerRenderer.tsx b/src/app/components/global-banner/GlobalBannerRenderer.tsx new file mode 100644 index 0000000000..657e4dcc0d --- /dev/null +++ b/src/app/components/global-banner/GlobalBannerRenderer.tsx @@ -0,0 +1,73 @@ +import { useAtomValue } from 'jotai'; +import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'; +import { Box, Button, Text } from 'folds'; +import { sizedIcon } from '$components/icons/phosphor'; +import { globalBannersAtom } from '$state/globalBanners'; +import * as css from './GlobalBannerRenderer.css'; + +export function GlobalBannerRenderer() { + const banners = useAtomValue(globalBannersAtom); + const shouldReduceMotion = useReducedMotion(); + + // Pick the highest priority banner (or oldest if equal priority) + const activeBanner = banners.toSorted((a, b) => (b.priority ?? 0) - (a.priority ?? 0))[0]; + + return ( +
+ + {activeBanner && ( + +
+ {activeBanner.icon && ( +
{sizedIcon(activeBanner.icon, '400')}
+ )} +
+ {activeBanner.title} + {typeof activeBanner.description === 'string' ? ( + + {activeBanner.description} + + ) : ( + activeBanner.description + )} +
+
+ + {activeBanner.secondaryAction && ( + + )} + + +
+ )} +
+
+ ); +} diff --git a/src/app/components/telemetry-consent/TelemetryConsentBanner.css.ts b/src/app/components/telemetry-consent/TelemetryConsentBanner.css.ts deleted file mode 100644 index 70d9ba0ba6..0000000000 --- a/src/app/components/telemetry-consent/TelemetryConsentBanner.css.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { keyframes, style } from '@vanilla-extract/css'; -import { color, config, toRem } from 'folds'; - -const slideUp = keyframes({ - from: { - opacity: 0, - transform: 'translateY(100%)', - }, - to: { - opacity: 1, - transform: 'translateY(0)', - }, -}); - -const slideDown = keyframes({ - from: { - opacity: 1, - transform: 'translateY(0)', - }, - to: { - opacity: 0, - transform: 'translateY(100%)', - }, -}); - -export const Container = style({ - position: 'fixed', - bottom: 'env(safe-area-inset-bottom, 0)', - left: '50%', - transform: 'translateX(-50%)', - zIndex: 9998, - width: `min(100%, ${toRem(520)})`, - padding: config.space.S400, - pointerEvents: 'none', -}); - -export const Banner = style({ - pointerEvents: 'all', - display: 'flex', - flexDirection: 'column', - gap: config.space.S300, - backgroundColor: color.Surface.Container, - color: color.Surface.OnContainer, - border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`, - borderRadius: toRem(16), - padding: config.space.S400, - boxShadow: `0 ${toRem(8)} ${toRem(32)} rgba(0, 0, 0, 0.45), 0 ${toRem(2)} ${toRem(8)} rgba(0, 0, 0, 0.3)`, - animationName: slideUp, - animationDuration: '300ms', - animationTimingFunction: 'cubic-bezier(0.22, 0.8, 0.6, 1)', - animationFillMode: 'both', - - selectors: { - '&[data-dismissing=true]': { - animationName: slideDown, - animationDuration: '220ms', - animationTimingFunction: 'cubic-bezier(0.4, 0, 1, 1)', - animationFillMode: 'both', - }, - }, -}); - -export const Header = style({ - display: 'flex', - alignItems: 'flex-start', - gap: config.space.S300, -}); - -export const HeaderText = style({ - flex: 1, - minWidth: 0, - display: 'flex', - flexDirection: 'column', - gap: toRem(4), -}); - -export const Actions = style({ - display: 'flex', - gap: config.space.S200, - justifyContent: 'flex-end', - flexWrap: 'wrap', -}); diff --git a/src/app/components/telemetry-consent/TelemetryConsentBanner.test.tsx b/src/app/components/telemetry-consent/TelemetryConsentBanner.test.tsx index 666d9a5bb6..d6ad8194e1 100644 --- a/src/app/components/telemetry-consent/TelemetryConsentBanner.test.tsx +++ b/src/app/components/telemetry-consent/TelemetryConsentBanner.test.tsx @@ -1,10 +1,20 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; import { TelemetryConsentBanner } from './TelemetryConsentBanner'; +import { GlobalBannerRenderer } from '$components/global-banner/GlobalBannerRenderer'; const SENTRY_KEY = 'sable_sentry_enabled'; const TEST_DSN = 'https://examplePublicKey@o0.ingest.sentry.io/0'; +function renderTestBanner() { + return render( + <> + + + + ); +} + describe('TelemetryConsentBanner', () => { beforeEach(() => { localStorage.clear(); @@ -20,28 +30,28 @@ describe('TelemetryConsentBanner', () => { it('renders nothing when VITE_SENTRY_DSN is not configured', () => { vi.stubEnv('VITE_SENTRY_DSN', ''); - const { container } = render(); - expect(container).toBeEmptyDOMElement(); + const { container } = renderTestBanner(); + expect(container.querySelector('[role="region"]')).toBeNull(); }); it('renders nothing when the user has already opted in', () => { vi.stubEnv('VITE_SENTRY_DSN', TEST_DSN); localStorage.setItem(SENTRY_KEY, 'true'); - const { container } = render(); - expect(container).toBeEmptyDOMElement(); + const { container } = renderTestBanner(); + expect(container.querySelector('[role="region"]')).toBeNull(); }); it('renders nothing when the user has already opted out', () => { vi.stubEnv('VITE_SENTRY_DSN', TEST_DSN); localStorage.setItem(SENTRY_KEY, 'false'); - const { container } = render(); - expect(container).toBeEmptyDOMElement(); + const { container } = renderTestBanner(); + expect(container.querySelector('[role="region"]')).toBeNull(); }); it('renders the banner when DSN is configured and no preference is saved', () => { vi.stubEnv('VITE_SENTRY_DSN', TEST_DSN); - render(); - expect(screen.getByRole('region', { name: /crash reporting prompt/i })).toBeInTheDocument(); + renderTestBanner(); + expect(screen.getByRole('region', { name: /help improve sable/i })).toBeInTheDocument(); expect(screen.getByText(/help improve sable/i)).toBeInTheDocument(); }); @@ -49,7 +59,7 @@ describe('TelemetryConsentBanner', () => { it('has both action buttons visible', () => { vi.stubEnv('VITE_SENTRY_DSN', TEST_DSN); - render(); + renderTestBanner(); expect(screen.getByRole('button', { name: /enable/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /no thanks/i })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: /dismiss/i })).not.toBeInTheDocument(); @@ -57,7 +67,7 @@ describe('TelemetryConsentBanner', () => { it('includes a link to the privacy policy', () => { vi.stubEnv('VITE_SENTRY_DSN', TEST_DSN); - render(); + renderTestBanner(); expect(screen.getByRole('link', { name: /learn more/i })).toBeInTheDocument(); }); @@ -65,14 +75,14 @@ describe('TelemetryConsentBanner', () => { it('"Enable" saves opted-in preference to localStorage', () => { vi.stubEnv('VITE_SENTRY_DSN', TEST_DSN); - render(); + renderTestBanner(); fireEvent.click(screen.getByRole('button', { name: /enable/i })); expect(localStorage.getItem(SENTRY_KEY)).toBe('true'); }); it('"Enable" reloads the page so Sentry initialises', () => { vi.stubEnv('VITE_SENTRY_DSN', TEST_DSN); - render(); + renderTestBanner(); fireEvent.click(screen.getByRole('button', { name: /enable/i })); expect(window.location.reload).toHaveBeenCalledOnce(); }); @@ -81,14 +91,14 @@ describe('TelemetryConsentBanner', () => { it('"No thanks" saves opted-out preference to localStorage', () => { vi.stubEnv('VITE_SENTRY_DSN', TEST_DSN); - render(); + renderTestBanner(); fireEvent.click(screen.getByRole('button', { name: /no thanks/i })); expect(localStorage.getItem(SENTRY_KEY)).toBe('false'); }); it('"No thanks" does not reload the page', () => { vi.stubEnv('VITE_SENTRY_DSN', TEST_DSN); - render(); + renderTestBanner(); fireEvent.click(screen.getByRole('button', { name: /no thanks/i })); expect(window.location.reload).not.toHaveBeenCalled(); }); diff --git a/src/app/components/telemetry-consent/TelemetryConsentBanner.tsx b/src/app/components/telemetry-consent/TelemetryConsentBanner.tsx index 6bc92f0289..10a1efcf10 100644 --- a/src/app/components/telemetry-consent/TelemetryConsentBanner.tsx +++ b/src/app/components/telemetry-consent/TelemetryConsentBanner.tsx @@ -1,7 +1,7 @@ -import { useEffect, useRef, useState } from 'react'; -import { Box, Button, Text } from 'folds'; -import { Shield, sizedIcon } from '$components/icons/phosphor'; -import * as css from './TelemetryConsentBanner.css'; +import { useMemo, useState } from 'react'; +import { Text } from 'folds'; +import { Shield } from '$components/icons/phosphor'; +import { useRegisterGlobalBanner, type GlobalBanner } from '$state/globalBanners'; const SENTRY_KEY = 'sable_sentry_enabled'; @@ -10,17 +10,6 @@ export function TelemetryConsentBanner() { const [visible, setVisible] = useState( isSentryConfigured && localStorage.getItem(SENTRY_KEY) === null ); - const [dismissing, setDismissing] = useState(false); - const dismissTimerRef = useRef | null>(null); - - useEffect( - () => () => { - if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current); - }, - [] - ); - - if (!visible) return null; const handleEnable = () => { localStorage.setItem(SENTRY_KEY, 'true'); @@ -29,44 +18,43 @@ export function TelemetryConsentBanner() { const handleDecline = () => { localStorage.setItem(SENTRY_KEY, 'false'); - setDismissing(true); - dismissTimerRef.current = setTimeout(() => setVisible(false), 220); + setVisible(false); }; - return ( -
-
-
- {sizedIcon(Shield, '400')} -
- Help improve Sable - - Optionally send anonymous crash reports to help us fix bugs faster. No messages, room - names, or personal data are included.{' '} - - Learn more - - -
-
- - - - -
-
- ); + const bannerData = useMemo(() => { + if (!visible) return null; + return { + id: 'telemetry-consent', + priority: 100, // Higher priority than device verification + icon: Shield, + title: 'Help improve Sable', + description: ( + + Optionally send anonymous crash reports to help us fix bugs faster. No messages, room + names, or personal data are included.{' '} + + Learn more + + + ), + primaryAction: { + label: 'Enable', + variant: 'Primary', + onClick: handleEnable, + }, + secondaryAction: { + label: 'No thanks', + variant: 'Secondary', + onClick: handleDecline, + }, + }; + }, [visible]); + + useRegisterGlobalBanner(bannerData); + + return null; } diff --git a/src/app/components/unverified-notice/UnverifiedNoticeBanner.css.ts b/src/app/components/unverified-notice/UnverifiedNoticeBanner.css.ts deleted file mode 100644 index 868ef19d03..0000000000 --- a/src/app/components/unverified-notice/UnverifiedNoticeBanner.css.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { keyframes, style } from '@vanilla-extract/css'; -import { color, config, toRem } from 'folds'; - -const slideUp = keyframes({ - from: { - opacity: 0, - transform: 'translateY(-100%)', - }, - to: { - opacity: 1, - transform: 'translateY(0)', - }, -}); - -const slideDown = keyframes({ - from: { - opacity: 1, - transform: 'translateY(0)', - }, - to: { - opacity: 0, - transform: 'translateY(100%)', - }, -}); - -export const Container = style({ - position: 'fixed', - top: 'env(safe-area-inset-top, 0)', - left: '50%', - transform: 'translateX(-50%)', - zIndex: 9998, - width: `min(100%, ${toRem(520)})`, - padding: config.space.S400, - pointerEvents: 'none', -}); - -export const Banner = style({ - pointerEvents: 'all', - display: 'flex', - flexDirection: 'column', - gap: config.space.S300, - backgroundColor: color.Surface.Container, - color: color.Surface.OnContainer, - border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`, - borderRadius: toRem(16), - padding: config.space.S400, - boxShadow: `0 ${toRem(8)} ${toRem(32)} rgba(0, 0, 0, 0.45), 0 ${toRem(2)} ${toRem(8)} rgba(0, 0, 0, 0.3)`, - animationName: slideUp, - animationDuration: '300ms', - animationTimingFunction: 'cubic-bezier(0.22, 0.8, 0.6, 1)', - animationFillMode: 'both', - - selectors: { - '&[data-dismissing=true]': { - animationName: slideDown, - animationDuration: '220ms', - animationTimingFunction: 'cubic-bezier(0.4, 0, 1, 1)', - animationFillMode: 'both', - }, - }, -}); - -export const Header = style({ - display: 'flex', - alignItems: 'flex-start', - gap: config.space.S300, -}); - -export const HeaderText = style({ - flex: 1, - minWidth: 0, - display: 'flex', - flexDirection: 'column', - gap: toRem(4), -}); - -export const Actions = style({ - display: 'flex', - gap: config.space.S200, - justifyContent: 'flex-end', - flexWrap: 'wrap', -}); diff --git a/src/app/components/unverified-notice/UnverifiedNoticeBanner.tsx b/src/app/components/unverified-notice/UnverifiedNoticeBanner.tsx index 29632f1827..3f18c9622c 100644 --- a/src/app/components/unverified-notice/UnverifiedNoticeBanner.tsx +++ b/src/app/components/unverified-notice/UnverifiedNoticeBanner.tsx @@ -1,11 +1,10 @@ -import { Box, Button, color, Text } from 'folds'; -import { sizedIcon } from '$components/icons/phosphor'; -import * as css from './UnverifiedNoticeBanner.css'; -import { useOpenSettings } from '$features/settings'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { color, Text } from 'folds'; import { ShieldWarningIcon } from '@phosphor-icons/react'; +import { useOpenSettings } from '$features/settings'; import { useIsUnverified, useUnverifiedDevices } from '$pages/client/sidebar/UserMenuTab'; import { getLocalStorageItem, setLocalStorageItem } from '$state/utils/atomWithLocalStorage'; -import { useEffect, useState } from 'react'; +import { useRegisterGlobalBanner, type GlobalBanner } from '$state/globalBanners'; export function UnverifiedNoticeBanner() { const openSettings = useOpenSettings(); @@ -18,6 +17,7 @@ export function UnverifiedNoticeBanner() { const [isDismissedNotice, setIsDismissedNotice] = useState( getLocalStorageItem('dismissNotice', false) ); + useEffect(() => { if ( unverifiedDeviceCount && @@ -31,49 +31,54 @@ export function UnverifiedNoticeBanner() { setDismissCount(unverifiedDeviceCount); }, [unverifiedDeviceCount, dismissCount]); - if (!hasUnverified || isDismissedNotice) return null; - - const handleVerify = () => { + const handleVerify = useCallback(() => { openSettings('devices'); - }; - const handleDismiss = () => { + }, [openSettings]); + + const handleDismiss = useCallback(() => { setLocalStorageItem('dismissNotice', true); setIsDismissedNotice(true); - }; + }, []); - return ( -
-
-
- {sizedIcon(ShieldWarningIcon, '400')} -
- - {isUnverified - ? 'This Device is Unverified!' - : `You have ${unverifiedDeviceCount === 1 ? 'an' : ''} Unverified Device${unverifiedDeviceCount !== 1 ? 's' : ''}!`} - - - An unverified device is unable to read old encrypted messages and might result in - other people being unable to read your messages or to send you messages! - -
-
- - - - -
-
- ); + const bannerData = useMemo(() => { + if (!hasUnverified || isDismissedNotice) return null; + + const title = isUnverified + ? 'This Device is Unverified!' + : `You have ${unverifiedDeviceCount === 1 ? 'an' : ''} Unverified Device${unverifiedDeviceCount !== 1 ? 's' : ''}!`; + + return { + id: 'unverified-device-notice', + priority: 50, // Lower priority than Sentry consent (50 < 100) + icon: ShieldWarningIcon, + title, + description: ( + + An unverified device is unable to read old encrypted messages and might result in other + people being unable to read your messages or to send you messages! + + ), + primaryAction: { + label: 'Verify', + variant: 'Primary', + onClick: handleVerify, + }, + secondaryAction: { + label: 'Dismiss', + variant: 'Secondary', + onClick: handleDismiss, + }, + }; + }, [ + hasUnverified, + isDismissedNotice, + isUnverified, + unverifiedDeviceCount, + handleVerify, + handleDismiss, + ]); + + useRegisterGlobalBanner(bannerData); + + return null; } diff --git a/src/app/hooks/useSlidingSyncHydrating.ts b/src/app/hooks/useSlidingSyncHydrating.ts new file mode 100644 index 0000000000..9037a31e97 --- /dev/null +++ b/src/app/hooks/useSlidingSyncHydrating.ts @@ -0,0 +1,45 @@ +import { useEffect, useState } from 'react'; +import type { MatrixClient } from '$types/matrix-sdk'; +import { ClientEvent } from '$types/matrix-sdk'; +import { getSlidingSyncManager } from '$client/initMatrix'; +import type { HydrationProgress } from '$client/slidingSync'; + +export const useSlidingSyncHydrating = ( + mx: MatrixClient +): { isHydrating: boolean; progress?: HydrationProgress } => { + const [manager, setManager] = useState(() => getSlidingSyncManager(mx)); + const [state, setState] = useState<{ isHydrating: boolean; progress?: HydrationProgress }>( + () => ({ + isHydrating: manager?.isSyncingRoomData() ?? false, + progress: manager?.isSyncingRoomData() ? manager.getHydrationProgress() : undefined, + }) + ); + + useEffect(() => { + if (manager) return undefined; + + const checkForManager = () => { + const nextManager = getSlidingSyncManager(mx); + if (nextManager) setManager(nextManager); + }; + + const retryId = globalThis.setTimeout(checkForManager, 0); + mx.on(ClientEvent.Sync, checkForManager); + return () => { + globalThis.clearTimeout(retryId); + mx.removeListener(ClientEvent.Sync, checkForManager); + }; + }, [manager, mx]); + + useEffect(() => { + if (!manager) { + setState({ isHydrating: false }); + return undefined; + } + return manager.onHydrationStatusChange((isHydrating, progress) => { + setState({ isHydrating, progress }); + }); + }, [manager]); + + return state; +}; diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 0403b20f66..b39ec15123 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -73,8 +73,10 @@ import { incomingCallAtom, mutedCallRoomIdAtom } from '$state/callEmbed'; import { getInboxInvitesPath } from '../pathUtils'; import { BackgroundNotifications } from './BackgroundNotifications'; import { DesktopUpdater } from './DesktopUpdater'; +import { WebUpdater } from './WebUpdater'; import { NotificationTransportRuntimeFeature } from '$features/settings/notifications/NotificationTransportRuntimeFeature'; import { UnverifiedNoticeBanner } from '$components/unverified-notice'; +import { GlobalBannerRenderer } from '$components/global-banner/GlobalBannerRenderer'; import { getRenderableMediaUrlStats } from '$hooks/useRenderableMediaUrl'; const pushRelayLog = createDebugLogger('push-relay'); @@ -1014,12 +1016,14 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { + + diff --git a/src/app/pages/client/DesktopUpdater.tsx b/src/app/pages/client/DesktopUpdater.tsx index 88595335bb..35e54decdc 100644 --- a/src/app/pages/client/DesktopUpdater.tsx +++ b/src/app/pages/client/DesktopUpdater.tsx @@ -1,51 +1,126 @@ -import { useEffect } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useAtomValue } from 'jotai'; import { isTauri } from '@tauri-apps/api/core'; import { type as osType } from '@tauri-apps/plugin-os'; +import type { Update } from '@tauri-apps/plugin-updater'; import { autoUpdateCheckAtom } from '$state/autoUpdateCheck'; import { createLogger } from '$utils/debug'; +import { ArrowUp } from '$components/icons/phosphor'; +import { useRegisterGlobalBanner, type GlobalBanner } from '$state/globalBanners'; const log = createLogger('DesktopUpdater'); const DESKTOP_TAURI_OS = new Set(['linux', 'macos', 'windows']); const isDesktopTauri = (): boolean => isTauri() && DESKTOP_TAURI_OS.has(osType()); -async function checkForDesktopUpdate(): Promise { - const { check } = await import('@tauri-apps/plugin-updater'); - const update = await check(); - if (!update) return; - - log.log(`update ${update.version} available`); - const { ask } = await import('@tauri-apps/plugin-dialog'); - - const shouldInstall = await ask(`Sable ${update.version} is available. Install it now?`, { - title: 'Update available', - kind: 'info', - okLabel: 'Install', - cancelLabel: 'Later', - }); - if (!shouldInstall) return; - - await update.downloadAndInstall(); - - const { relaunch } = await import('@tauri-apps/plugin-process'); - const shouldRestart = await ask('Update installed. Restart Sable to finish?', { - title: 'Restart required', - kind: 'info', - okLabel: 'Restart', - cancelLabel: 'Later', - }); - if (shouldRestart) await relaunch(); -} - export function DesktopUpdater() { const autoUpdateCheck = useAtomValue(autoUpdateCheckAtom); + const [updateInfo, setUpdateInfo] = useState(null); + const [isInstalling, setIsInstalling] = useState(false); + const [isDownloaded, setIsDownloaded] = useState(false); + const [dismissed, setDismissed] = useState(false); useEffect(() => { - if (!isDesktopTauri()) return; - if (!autoUpdateCheck) return; - checkForDesktopUpdate().catch((err) => log.error('update check failed', err)); + if (!isDesktopTauri()) return undefined; + if (!autoUpdateCheck) return undefined; + + let isMounted = true; + async function checkUpdate() { + try { + const { check } = await import('@tauri-apps/plugin-updater'); + const update = await check(); + if (update && isMounted) { + log.log(`Desktop update ${update.version} available`); + setUpdateInfo(update); + } + } catch (err) { + log.error('Desktop update check failed', err); + } + } + + checkUpdate(); + return () => { + isMounted = false; + }; }, [autoUpdateCheck]); + const handleInstall = useCallback(async () => { + if (!updateInfo) return; + try { + setIsInstalling(true); + await updateInfo.downloadAndInstall(); + setIsInstalling(false); + setIsDownloaded(true); + } catch (err) { + log.error('Failed to download and install update', err); + setIsInstalling(false); + } + }, [updateInfo]); + + const handleRestart = useCallback(async () => { + try { + const { relaunch } = await import('@tauri-apps/plugin-process'); + await relaunch(); + } catch (err) { + log.error('Failed to relaunch after update', err); + } + }, []); + + const handleDismiss = useCallback(() => { + setDismissed(true); + }, []); + + const bannerData = useMemo(() => { + if (!updateInfo || dismissed) return null; + + if (isDownloaded) { + return { + id: 'desktop-update-restart', + priority: 200, + icon: ArrowUp, + title: 'Update Ready', + description: `Sable ${updateInfo.version} has been downloaded. Restart the app to finish updating.`, + primaryAction: { + label: 'Restart Now', + variant: 'Primary', + onClick: handleRestart, + }, + secondaryAction: { + label: 'Later', + variant: 'Secondary', + onClick: handleDismiss, + }, + }; + } + + return { + id: 'desktop-update-available', + priority: 200, + icon: ArrowUp, + title: 'Desktop Update Available', + description: `Sable ${updateInfo.version} is available for installation.`, + primaryAction: { + label: isInstalling ? 'Installing...' : 'Install & Update', + variant: 'Primary', + onClick: handleInstall, + }, + secondaryAction: { + label: 'Later', + variant: 'Secondary', + onClick: handleDismiss, + }, + }; + }, [ + updateInfo, + dismissed, + isDownloaded, + isInstalling, + handleInstall, + handleRestart, + handleDismiss, + ]); + + useRegisterGlobalBanner(bannerData); + return null; } diff --git a/src/app/pages/client/SyncStatus.tsx b/src/app/pages/client/SyncStatus.tsx index 446ed40ac1..3adcde76d1 100644 --- a/src/app/pages/client/SyncStatus.tsx +++ b/src/app/pages/client/SyncStatus.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useSetAtom } from 'jotai'; import * as Sentry from '@sentry/react'; import { useSyncState } from '$hooks/useSyncState'; +import { useSlidingSyncHydrating } from '$hooks/useSlidingSyncHydrating'; import { type TitlebarStatusView, titlebarStatusAtom } from '$state/titlebarStatus'; import { SyncConnectionStatusBanner } from '$components/SyncConnectionStatus'; import { hasCustomDesktopTitlebar } from '$utils/tauriTitlebar'; @@ -39,6 +40,7 @@ export function SyncStatus({ mx }: SyncStatusProps) { showConnecting: false, }); const [showDisconnected, setShowDisconnected] = useState(false); + const { isHydrating, progress } = useSlidingSyncHydrating(mx); const isDisconnected = stateData.current === SyncState.Reconnecting || stateData.current === SyncState.Error; @@ -84,15 +86,27 @@ export function SyncStatus({ mx }: SyncStatusProps) { ); const view = useMemo(() => { - if (stateData.showConnecting) return { text: 'Connecting...', variant: 'Success' }; + if (showDisconnected && stateData.current === SyncState.Error) { + return { text: 'Connection Lost!', variant: 'Critical' }; + } if (showDisconnected && stateData.current === SyncState.Reconnecting) { return { text: 'Connection Lost! Reconnecting...', variant: 'Warning' }; } - if (showDisconnected && stateData.current === SyncState.Error) { - return { text: 'Connection Lost!', variant: 'Critical' }; + if (stateData.showConnecting) return { text: 'Connecting...', variant: 'Success' }; + if ( + isHydrating && + (stateData.current === SyncState.Syncing || + stateData.current === SyncState.Prepared || + stateData.current === SyncState.Catchup) + ) { + let percentage: number | undefined = undefined; + if (progress && progress.totalRooms > 0) { + percentage = Math.min(100, Math.floor((progress.loadedRooms / progress.totalRooms) * 100)); + } + return { text: 'Syncing room data...', variant: 'Success', progress: percentage }; } return null; - }, [stateData, showDisconnected]); + }, [stateData, showDisconnected, isHydrating, progress]); // Publish to the atom that feeds the custom desktop titlebar's status pill. useEffect(() => { diff --git a/src/app/pages/client/WebUpdater.tsx b/src/app/pages/client/WebUpdater.tsx new file mode 100644 index 0000000000..810143b8d2 --- /dev/null +++ b/src/app/pages/client/WebUpdater.tsx @@ -0,0 +1,62 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { ArrowUp } from '$components/icons/phosphor'; +import { useRegisterGlobalBanner, type GlobalBanner } from '$state/globalBanners'; +import { createLogger } from '$utils/debug'; + +const log = createLogger('WebUpdater'); + +export function WebUpdater() { + const [registration, setRegistration] = useState(null); + const [dismissed, setDismissed] = useState(false); + + useEffect(() => { + const handleUpdate = (ev: Event) => { + const customEv = ev as CustomEvent<{ registration: ServiceWorkerRegistration }>; + if (customEv.detail?.registration) { + log.log('Web update available via ServiceWorker'); + setRegistration(customEv.detail.registration); + } + }; + + window.addEventListener('sable-sw-update-available', handleUpdate); + return () => window.removeEventListener('sable-sw-update-available', handleUpdate); + }, []); + + const handleRefresh = useCallback(() => { + if (registration?.waiting) { + // oxlint-disable-next-line unicorn/require-post-message-target-origin + registration.waiting.postMessage({ type: 'SKIP_WAITING_AND_CLAIM' }); + } + window.location.reload(); + }, [registration]); + + const handleDismiss = useCallback(() => { + setDismissed(true); + }, []); + + const bannerData = useMemo(() => { + if (!registration || dismissed) return null; + + return { + id: 'web-app-update', + priority: 200, // Top priority for updates + icon: ArrowUp, + title: 'Update Available', + description: 'A new version of Sable is available. Refresh to apply updates.', + primaryAction: { + label: 'Refresh', + variant: 'Primary', + onClick: handleRefresh, + }, + secondaryAction: { + label: 'Later', + variant: 'Secondary', + onClick: handleDismiss, + }, + }; + }, [registration, dismissed, handleRefresh, handleDismiss]); + + useRegisterGlobalBanner(bannerData); + + return null; +} diff --git a/src/app/state/globalBanners.ts b/src/app/state/globalBanners.ts new file mode 100644 index 0000000000..841ae5b80e --- /dev/null +++ b/src/app/state/globalBanners.ts @@ -0,0 +1,40 @@ +import { atom, useSetAtom } from 'jotai'; +import type { ComponentType, ReactNode } from 'react'; +import { useEffect } from 'react'; + +export type GlobalBannerAction = { + label: string; + onClick: () => void; + variant?: 'Primary' | 'Secondary' | 'Critical'; +}; + +export type GlobalBanner = { + id: string; + icon?: ComponentType<{ size?: string | number; className?: string }>; + title: string; + description: ReactNode; + primaryAction: GlobalBannerAction; + secondaryAction?: GlobalBannerAction; + priority?: number; // Higher priority renders first +}; + +export const globalBannersAtom = atom([]); + +export function useRegisterGlobalBanner(banner: GlobalBanner | null) { + const setBanners = useSetAtom(globalBannersAtom); + + useEffect(() => { + if (!banner) return undefined; + + setBanners((prev) => { + if (prev.some((b) => b.id === banner.id)) { + return prev.map((b) => (b.id === banner.id ? banner : b)); + } + return [...prev, banner]; + }); + + return () => { + setBanners((prev) => prev.filter((b) => b.id !== banner.id)); + }; + }, [banner, setBanners]); +} diff --git a/src/app/state/titlebarStatus.ts b/src/app/state/titlebarStatus.ts index c5b6bf5487..932655f3f3 100644 --- a/src/app/state/titlebarStatus.ts +++ b/src/app/state/titlebarStatus.ts @@ -1,9 +1,10 @@ import { atom } from 'jotai'; -export type TitlebarStatusVariant = 'Success' | 'Warning' | 'Critical'; +export type TitlebarStatusVariant = 'Success' | 'Warning' | 'Critical' | 'Primary'; export type TitlebarStatusView = { text: string; variant: TitlebarStatusVariant; + progress?: number; }; export const titlebarStatusAtom = atom(null); diff --git a/src/app/styles/edgeToEdgeInsets.test.ts b/src/app/styles/edgeToEdgeInsets.test.ts index 3aa3feb12e..c6e0a5fd5f 100644 --- a/src/app/styles/edgeToEdgeInsets.test.ts +++ b/src/app/styles/edgeToEdgeInsets.test.ts @@ -102,12 +102,11 @@ describe('android edge-to-edge inset contract', () => { 'src/app/components/notification-banner/NotificationBanner.css.ts' ); const telemetryBannerStyles = readWorkspaceFile( - 'src/app/components/telemetry-consent/TelemetryConsentBanner.css.ts' + 'src/app/components/global-banner/GlobalBannerRenderer.css.ts' ); expect(notificationBannerStyles).toContain("position: 'fixed'"); expect(notificationBannerStyles).toContain("top: 'env(safe-area-inset-top, 0)'"); expect(telemetryBannerStyles).toContain("position: 'fixed'"); - expect(telemetryBannerStyles).toContain("bottom: 'env(safe-area-inset-bottom, 0)'"); }); }); diff --git a/src/app/styles/overrides/TauriDesktop.css b/src/app/styles/overrides/TauriDesktop.css index bdcdc117d3..33a1dcd143 100644 --- a/src/app/styles/overrides/TauriDesktop.css +++ b/src/app/styles/overrides/TauriDesktop.css @@ -90,6 +90,10 @@ background: rgb(239 68 68 / 28%); } +.tauri-titlebar-status__label--primary { + background: color-mix(in srgb, var(--sable-primary-main) 16%, transparent); +} + .tauri-titlebar__control { appearance: none; -webkit-appearance: none; diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index c4a5acf0a4..d8ef816bf0 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -73,6 +73,8 @@ export type SlidingSyncDiagnostics = { lists: SlidingSyncListDiagnostics[]; }; +export type HydrationProgress = { loadedRooms: number; totalRooms: number }; + const clampPositive = (value: number | undefined, fallback: number): number => { if (typeof value !== 'number' || Number.isNaN(value) || value <= 0) return fallback; return Math.round(value); @@ -315,6 +317,10 @@ export class SlidingSyncManager { Set<(loading: boolean) => void> >(); + private readonly hydrationStatusListeners = new Set< + (isHydrating: boolean, progress?: HydrationProgress) => void + >(); + private readonly roomDataAwaitingSyncCompletion = new Set(); /** Wall-clock time recorded in attach() — used to compute true initial-sync latency. */ @@ -576,6 +582,8 @@ export class SlidingSyncManager { listeners.forEach((listener) => listener(false)) ); this.roomSubscriptionStatusListeners.clear(); + this.hydrationStatusListeners.forEach((listener) => listener(false)); + this.hydrationStatusListeners.clear(); this.disposed = true; this.slidingSync.stop(); @@ -805,6 +813,7 @@ export class SlidingSyncManager { if (!this.listsFullyLoaded) { this.listsFullyLoaded = true; this.initialListHydrationCompleted = true; + this.hydrationStatusListeners.forEach((listener) => listener(false)); this.reconcileSidebarCacheMembership(); globalThis.setTimeout(() => this.flushDeferredSubscriptions(), 0); this.applySteadyStateListRanges(); @@ -822,7 +831,12 @@ export class SlidingSyncManager { }); } } else if (expandedAny) { - this.listsFullyLoaded = false; + if (this.listsFullyLoaded) { + this.listsFullyLoaded = false; + } + this.hydrationStatusListeners.forEach((listener) => + listener(true, this.getHydrationProgress()) + ); log.log(`Sliding Sync lists expanding... for ${this.mx.getUserId()}`); } @@ -931,6 +945,39 @@ export class SlidingSyncManager { return this.activeRoomSubscriptions.has(roomId); } + public getHydrationProgress(): HydrationProgress { + let loadedRooms = 0; + let totalRooms = 0; + this.listKeys.forEach((key) => { + const existing = this.slidingSync.getListData(key); + const knownCount = existing?.joinedCount ?? 0; + const currentEnd = this.confirmedListRangeEnds.get(key) ?? -1; + + if (knownCount > 0) { + totalRooms += knownCount; + loadedRooms += Math.min(knownCount, currentEnd + 1); + } + }); + return { loadedRooms, totalRooms }; + } + + public isSyncingRoomData(): boolean { + return !this.listsFullyLoaded; + } + + public onHydrationStatusChange( + listener: (isHydrating: boolean, progress?: HydrationProgress) => void + ): () => void { + this.hydrationStatusListeners.add(listener); + listener( + this.isSyncingRoomData(), + this.isSyncingRoomData() ? this.getHydrationProgress() : undefined + ); + return () => { + this.hydrationStatusListeners.delete(listener); + }; + } + public isResponseProcessing(): boolean { return this.responseProcessing; } diff --git a/src/serviceWorkerBootstrap.ts b/src/serviceWorkerBootstrap.ts index 68b362cbbb..5b515f9bfc 100644 --- a/src/serviceWorkerBootstrap.ts +++ b/src/serviceWorkerBootstrap.ts @@ -16,14 +16,11 @@ const showUpdateAvailablePrompt = (registration: ServiceWorkerRegistration) => { return; } - // eslint-disable-next-line no-alert - if (window.confirm('A new version of the app is available. Refresh to update?')) { - if (registration.waiting) { - // oxlint-disable-next-line unicorn/require-post-message-target-origin - registration.waiting.postMessage({ type: 'SKIP_WAITING_AND_CLAIM' }); - } - window.location.reload(); - } + window.dispatchEvent( + new CustomEvent<{ registration: ServiceWorkerRegistration }>('sable-sw-update-available', { + detail: { registration }, + }) + ); }; const sendSessionToSW = () => {