diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 40f5d659a..6bf2c6014 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -60,6 +60,7 @@ export default defineConfig({ "**/integration.spec.ts", "**/profile.spec.ts", "**/sidebar.spec.ts", + "**/sidebar-relay-card.spec.ts", "**/tokens.spec.ts", "**/persona-env-vars.spec.ts", "**/mesh-compute.spec.ts", diff --git a/desktop/public/pow/LICENSE.txt b/desktop/public/pow/LICENSE.txt new file mode 100644 index 000000000..b0d56df20 --- /dev/null +++ b/desktop/public/pow/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Emerge Tools, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/desktop/public/pow/plop.m4a b/desktop/public/pow/plop.m4a new file mode 100644 index 000000000..593f4e8b7 Binary files /dev/null and b/desktop/public/pow/plop.m4a differ diff --git a/desktop/public/pow/poof1@3x.png b/desktop/public/pow/poof1@3x.png new file mode 100644 index 000000000..48b5166c1 Binary files /dev/null and b/desktop/public/pow/poof1@3x.png differ diff --git a/desktop/public/pow/poof2@3x.png b/desktop/public/pow/poof2@3x.png new file mode 100644 index 000000000..2e1d6f269 Binary files /dev/null and b/desktop/public/pow/poof2@3x.png differ diff --git a/desktop/public/pow/poof3@3x.png b/desktop/public/pow/poof3@3x.png new file mode 100644 index 000000000..a8d303e15 Binary files /dev/null and b/desktop/public/pow/poof3@3x.png differ diff --git a/desktop/public/pow/poof4@3x.png b/desktop/public/pow/poof4@3x.png new file mode 100644 index 000000000..5ab8dc528 Binary files /dev/null and b/desktop/public/pow/poof4@3x.png differ diff --git a/desktop/public/pow/poof5@3x.png b/desktop/public/pow/poof5@3x.png new file mode 100644 index 000000000..b4d05aff9 Binary files /dev/null and b/desktop/public/pow/poof5@3x.png differ diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 85904fde0..e9c2958b4 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -81,7 +81,6 @@ import { chromeCssVarDefaults } from "@/shared/layout/chromeLayout"; import { cn } from "@/shared/lib/cn"; import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; import { useMessageDeepLinks } from "@/shared/useMessageDeepLinks"; -import { ConnectionBanner } from "@/shared/ui/ConnectionBanner"; import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar"; const LazySettingsScreen = React.lazy(async () => { @@ -879,7 +878,6 @@ export function AppShell() { className="min-h-0 min-w-0 overflow-hidden" style={chromeCssVarDefaults} > - diff --git a/desktop/src/features/onboarding/ui/OnboardingFlow.tsx b/desktop/src/features/onboarding/ui/OnboardingFlow.tsx index 7e9618f6e..dce2e7810 100644 --- a/desktop/src/features/onboarding/ui/OnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/OnboardingFlow.tsx @@ -5,6 +5,7 @@ import { profileQueryKey, useUpdateProfileMutation, } from "@/features/profile/hooks"; +import { useWorkspaces } from "@/features/workspaces/useWorkspaces"; import { relayClient } from "@/shared/api/relayClient"; import { getMyRelayMembershipLookup } from "@/shared/api/relayMembers"; import { getIdentity, importIdentity } from "@/shared/api/tauri"; @@ -146,6 +147,7 @@ export function OnboardingFlow({ onBackToWorkspaceSetup, }: OnboardingFlowProps) { const { complete, skipForNow } = actions; + const { activeWorkspace } = useWorkspaces(); const queryClient = useQueryClient(); const savedProfile = resolveSavedProfile(initialProfile); const profileUpdateMutation = useUpdateProfileMutation(); @@ -486,6 +488,7 @@ export function OnboardingFlow({ updateDisplayName: updateDisplayNameDraft, }} direction={transitionDirection} + relayUrl={activeWorkspace?.relayUrl} state={profileStepState} /> ) : currentPage === "key-import" ? ( diff --git a/desktop/src/features/onboarding/ui/ProfileStep.tsx b/desktop/src/features/onboarding/ui/ProfileStep.tsx index 024bd1e3d..efc5781c9 100644 --- a/desktop/src/features/onboarding/ui/ProfileStep.tsx +++ b/desktop/src/features/onboarding/ui/ProfileStep.tsx @@ -1,6 +1,15 @@ import * as React from "react"; +import { toast } from "sonner"; +import { + SidebarBlockAccessRefreshCompactCard, + SidebarBlockVpnOffCompactCard, + SidebarRelayConnectionCompactCard, +} from "@/features/sidebar/ui/SidebarRelayConnectionCard"; +import { useReconnectRelay } from "@/shared/api/useReconnectRelay"; import { cn } from "@/shared/lib/cn"; +import { resolveRelayConnectivityCardVariant } from "@/shared/lib/relayConnectivityCard"; +import { isRelayUnreachableError } from "@/shared/lib/relayError"; import { Button } from "@/shared/ui/button"; import { Spinner } from "@/shared/ui/spinner"; import { @@ -13,15 +22,206 @@ import type { ProfileStepActions, ProfileStepState } from "./types"; type ProfileStepProps = { actions: ProfileStepActions; direction: OnboardingTransitionDirection; + relayUrl?: string | null; transitionEffect?: OnboardingTransitionEffect; state: ProfileStepState; }; -function ErrorBanner({ message }: { message: string | null }) { +type OnboardingConnectivityAction = + | "connect-vpn" + | "reconnect-relay" + | "refresh-access"; +type OnboardingRelayCardVariant = + | "connect-vpn" + | "reconnect-relay" + | "refresh-access"; + +const ONBOARDING_CONNECTIVITY_SUCCESS_AUTO_DISMISS_MS = 2_500; + +function resolveOnboardingRelayCardVariant( + errorMessage: string, + relayUrl: string | null | undefined, +): OnboardingRelayCardVariant { + return resolveRelayConnectivityCardVariant(errorMessage, relayUrl); +} + +function OnboardingRelayConnectionErrorCard({ + isSaving, + message, + relayUrl, +}: { + isSaving: boolean; + message: string; + relayUrl?: string | null; +}) { + const { isPending: isReconnectPending, reconnect } = useReconnectRelay(); + const [dismissedErrorMessage, setDismissedErrorMessage] = React.useState< + string | null + >(null); + const [connectivityAction, setConnectivityAction] = + React.useState(null); + const [successAction, setSuccessAction] = + React.useState(null); + const connectivityActionRef = + React.useRef(null); + const successTimeoutRef = React.useRef(null); + const wasSavingRef = React.useRef(isSaving); + const cardVariant = resolveOnboardingRelayCardVariant(message, relayUrl); + const isActionPending = connectivityAction !== null || isReconnectPending; + + React.useEffect(() => { + return () => { + if (successTimeoutRef.current !== null) { + window.clearTimeout(successTimeoutRef.current); + } + }; + }, []); + + React.useEffect(() => { + if (isSaving && !wasSavingRef.current) { + if (successTimeoutRef.current !== null) { + window.clearTimeout(successTimeoutRef.current); + successTimeoutRef.current = null; + } + setDismissedErrorMessage(null); + setSuccessAction(null); + } + wasSavingRef.current = isSaving; + }, [isSaving]); + + const markSuccess = React.useCallback( + (action: OnboardingConnectivityAction) => { + setSuccessAction(action); + if (successTimeoutRef.current !== null) { + window.clearTimeout(successTimeoutRef.current); + } + successTimeoutRef.current = window.setTimeout(() => { + successTimeoutRef.current = null; + setDismissedErrorMessage(message); + }, ONBOARDING_CONNECTIVITY_SUCCESS_AUTO_DISMISS_MS); + }, + [message], + ); + + const runConnectivityAction = React.useCallback( + ( + action: OnboardingConnectivityAction, + runAction: () => Promise, + ) => { + if (connectivityActionRef.current !== null) { + return; + } + + connectivityActionRef.current = action; + setConnectivityAction(action); + setSuccessAction(null); + void Promise.resolve() + .then(runAction) + .then((didReconnect) => { + if (didReconnect !== false) { + markSuccess(action); + } + }) + .catch((error) => { + const detail = error instanceof Error ? error.message : String(error); + const label = + action === "refresh-access" + ? "Could not refresh VPN access." + : action === "connect-vpn" + ? "Could not turn on VPN." + : "Could not reconnect to the relay."; + toast.error(`${label} ${detail}`); + }) + .finally(() => { + connectivityActionRef.current = null; + setConnectivityAction(null); + }); + }, + [markSuccess], + ); + + const handleConnectWarpVpn = React.useCallback(() => { + runConnectivityAction("connect-vpn", reconnect); + }, [reconnect, runConnectivityAction]); + + const handleReconnectRelay = React.useCallback(() => { + runConnectivityAction("reconnect-relay", reconnect); + }, [reconnect, runConnectivityAction]); + + const handleRefreshWarpAccess = React.useCallback(() => { + runConnectivityAction("refresh-access", reconnect); + }, [reconnect, runConnectivityAction]); + + if (dismissedErrorMessage === message) { + return null; + } + + return ( +
+ {cardVariant === "refresh-access" ? ( + setDismissedErrorMessage(message)} + surface="secondary" + testId="onboarding-vpn-access-refresh-card" + /> + ) : cardVariant === "connect-vpn" ? ( + setDismissedErrorMessage(message)} + surface="secondary" + testId="onboarding-vpn-off-card" + /> + ) : ( + setDismissedErrorMessage(message)} + onReconnect={handleReconnectRelay} + surface="secondary" + testId="onboarding-relay-reconnect-card" + /> + )} +
+ ); +} + +function ErrorBanner({ + isSaving, + message, + relayUrl, +}: { + isSaving: boolean; + message: string | null; + relayUrl?: string | null; +}) { if (!message) { return null; } + if (isRelayUnreachableError(message)) { + return ( + + ); + } + return (

{message} @@ -32,6 +232,7 @@ function ErrorBanner({ message }: { message: string | null }) { export function ProfileStep({ actions, direction, + relayUrl, transitionEffect = "line-slide", state, }: ProfileStepProps) { @@ -117,7 +318,11 @@ export function ProfileStep({ {saveRecovery.errorMessage ? ( - + ) : null}

diff --git a/desktop/src/features/settings/SidebarUpdateCard.tsx b/desktop/src/features/settings/SidebarUpdateCard.tsx new file mode 100644 index 000000000..71ed7aa2f --- /dev/null +++ b/desktop/src/features/settings/SidebarUpdateCard.tsx @@ -0,0 +1,100 @@ +import * as React from "react"; +import { CircleArrowUp, Loader2 } from "lucide-react"; + +import { useUpdaterContext } from "./hooks/UpdaterProvider"; +import { shouldShowSidebarUpdateCard } from "./sidebarUpdateCardVisibility"; +import { SidebarCompactActionCard } from "@/shared/ui/sidebar-action-card"; + +type SidebarUpdateCardProps = { + onDismiss: () => void; +}; + +type SidebarUpdateCompactCardProps = SidebarUpdateCardProps & { + actionTestId?: string; + testId?: string; +}; + +export function SidebarUpdateCompactCard({ + actionTestId, + onDismiss, + testId = "sidebar-update-card-compact", +}: SidebarUpdateCompactCardProps) { + const { relaunch } = useUpdaterContext(); + const [isRestartPending, setIsRestartPending] = React.useState(false); + const restartPendingRef = React.useRef(false); + const restartFrameRef = React.useRef(null); + const restartTimeoutRef = React.useRef(null); + + React.useEffect(() => { + return () => { + if (restartFrameRef.current !== null) { + window.cancelAnimationFrame(restartFrameRef.current); + } + if (restartTimeoutRef.current !== null) { + window.clearTimeout(restartTimeoutRef.current); + } + restartPendingRef.current = false; + }; + }, []); + + const handleRestart = React.useCallback(() => { + if (restartPendingRef.current) { + return; + } + + restartPendingRef.current = true; + setIsRestartPending(true); + restartFrameRef.current = window.requestAnimationFrame(() => { + restartFrameRef.current = null; + restartTimeoutRef.current = window.setTimeout(() => { + restartTimeoutRef.current = null; + void relaunch() + .catch((error) => { + console.error("[SidebarUpdateCard] relaunch failed:", error); + }) + .finally(() => { + restartPendingRef.current = false; + setIsRestartPending(false); + }); + }, 0); + }); + }, [relaunch]); + + return ( +