mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
Polish sidebar update and relay cards (#1009)
This commit is contained in:
@@ -62,6 +62,7 @@ export default defineConfig({
|
|||||||
"**/integration.spec.ts",
|
"**/integration.spec.ts",
|
||||||
"**/profile.spec.ts",
|
"**/profile.spec.ts",
|
||||||
"**/sidebar.spec.ts",
|
"**/sidebar.spec.ts",
|
||||||
|
"**/sidebar-relay-card.spec.ts",
|
||||||
"**/tokens.spec.ts",
|
"**/tokens.spec.ts",
|
||||||
"**/persona-env-vars.spec.ts",
|
"**/persona-env-vars.spec.ts",
|
||||||
"**/mesh-compute.spec.ts",
|
"**/mesh-compute.spec.ts",
|
||||||
|
|||||||
21
desktop/public/pow/LICENSE.txt
Normal file
21
desktop/public/pow/LICENSE.txt
Normal file
@@ -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.
|
||||||
BIN
desktop/public/pow/plop.m4a
Normal file
BIN
desktop/public/pow/plop.m4a
Normal file
Binary file not shown.
BIN
desktop/public/pow/poof1@3x.png
Normal file
BIN
desktop/public/pow/poof1@3x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
BIN
desktop/public/pow/poof2@3x.png
Normal file
BIN
desktop/public/pow/poof2@3x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
BIN
desktop/public/pow/poof3@3x.png
Normal file
BIN
desktop/public/pow/poof3@3x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
BIN
desktop/public/pow/poof4@3x.png
Normal file
BIN
desktop/public/pow/poof4@3x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
BIN
desktop/public/pow/poof5@3x.png
Normal file
BIN
desktop/public/pow/poof5@3x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.8 KiB |
@@ -219,6 +219,10 @@ export function AppShell() {
|
|||||||
const channelsQuery = useChannelsQuery();
|
const channelsQuery = useChannelsQuery();
|
||||||
const { refetch: refetchChannels } = channelsQuery;
|
const { refetch: refetchChannels } = channelsQuery;
|
||||||
const channels = channelsQuery.data ?? [];
|
const channels = channelsQuery.data ?? [];
|
||||||
|
const channelsErrorMessage =
|
||||||
|
channelsQuery.error instanceof Error
|
||||||
|
? channelsQuery.error.message
|
||||||
|
: undefined;
|
||||||
const memberChannels = React.useMemo(
|
const memberChannels = React.useMemo(
|
||||||
() => channels.filter((channel) => channel.isMember),
|
() => channels.filter((channel) => channel.isMember),
|
||||||
[channels],
|
[channels],
|
||||||
@@ -775,11 +779,7 @@ export function AppShell() {
|
|||||||
activeWorkspace={workspacesHook.activeWorkspace}
|
activeWorkspace={workspacesHook.activeWorkspace}
|
||||||
channels={sidebarChannels}
|
channels={sidebarChannels}
|
||||||
currentPubkey={identityQuery.data?.pubkey}
|
currentPubkey={identityQuery.data?.pubkey}
|
||||||
errorMessage={
|
errorMessage={channelsErrorMessage}
|
||||||
channelsQuery.error instanceof Error
|
|
||||||
? channelsQuery.error.message
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
fallbackDisplayName={identityQuery.data?.displayName}
|
fallbackDisplayName={identityQuery.data?.displayName}
|
||||||
homeBadgeCount={homeBadgeCount}
|
homeBadgeCount={homeBadgeCount}
|
||||||
isAddWorkspaceOpen={isAddWorkspaceOpen}
|
isAddWorkspaceOpen={isAddWorkspaceOpen}
|
||||||
@@ -911,7 +911,9 @@ export function AppShell() {
|
|||||||
className="min-h-0 min-w-0 overflow-hidden"
|
className="min-h-0 min-w-0 overflow-hidden"
|
||||||
style={chromeCssVarDefaults}
|
style={chromeCssVarDefaults}
|
||||||
>
|
>
|
||||||
<ConnectionBanner />
|
<ConnectionBanner
|
||||||
|
errorMessage={channelsErrorMessage}
|
||||||
|
/>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</MainInsetProvider>
|
</MainInsetProvider>
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { SidebarRelayConnectionCompactCard } from "@/features/sidebar/ui/SidebarRelayConnectionCard";
|
||||||
|
import { useReconnectRelay } from "@/shared/api/useReconnectRelay";
|
||||||
import { cn } from "@/shared/lib/cn";
|
import { cn } from "@/shared/lib/cn";
|
||||||
|
import { isRelayUnreachableError } from "@/shared/lib/relayError";
|
||||||
import { Button } from "@/shared/ui/button";
|
import { Button } from "@/shared/ui/button";
|
||||||
import { Spinner } from "@/shared/ui/spinner";
|
import { Spinner } from "@/shared/ui/spinner";
|
||||||
import {
|
import {
|
||||||
@@ -17,11 +21,131 @@ type ProfileStepProps = {
|
|||||||
state: ProfileStepState;
|
state: ProfileStepState;
|
||||||
};
|
};
|
||||||
|
|
||||||
function ErrorBanner({ message }: { message: string | null }) {
|
const ONBOARDING_CONNECTIVITY_SUCCESS_AUTO_DISMISS_MS = 2_500;
|
||||||
|
|
||||||
|
function OnboardingRelayConnectionErrorCard({
|
||||||
|
isSaving,
|
||||||
|
message,
|
||||||
|
}: {
|
||||||
|
isSaving: boolean;
|
||||||
|
message: string;
|
||||||
|
}) {
|
||||||
|
const { isPending: isReconnectPending, reconnect } = useReconnectRelay();
|
||||||
|
const [dismissedErrorMessage, setDismissedErrorMessage] = React.useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
const [isReconnectActionPending, setIsReconnectActionPending] =
|
||||||
|
React.useState(false);
|
||||||
|
const [hasSuccess, setHasSuccess] = React.useState(false);
|
||||||
|
const reconnectActionPendingRef = React.useRef(false);
|
||||||
|
const successTimeoutRef = React.useRef<number | null>(null);
|
||||||
|
const wasSavingRef = React.useRef(isSaving);
|
||||||
|
const isActionPending = isReconnectActionPending || 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);
|
||||||
|
setHasSuccess(false);
|
||||||
|
}
|
||||||
|
wasSavingRef.current = isSaving;
|
||||||
|
}, [isSaving]);
|
||||||
|
|
||||||
|
const markSuccess = React.useCallback(() => {
|
||||||
|
setHasSuccess(true);
|
||||||
|
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(
|
||||||
|
(runAction: () => Promise<boolean | undefined>) => {
|
||||||
|
if (reconnectActionPendingRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnectActionPendingRef.current = true;
|
||||||
|
setIsReconnectActionPending(true);
|
||||||
|
setHasSuccess(false);
|
||||||
|
void Promise.resolve()
|
||||||
|
.then(runAction)
|
||||||
|
.then((didReconnect) => {
|
||||||
|
if (didReconnect !== false) {
|
||||||
|
markSuccess();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
toast.error(`Could not reconnect to the relay. ${detail}`);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
reconnectActionPendingRef.current = false;
|
||||||
|
setIsReconnectActionPending(false);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[markSuccess],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleReconnectRelay = React.useCallback(() => {
|
||||||
|
runConnectivityAction(reconnect);
|
||||||
|
}, [reconnect, runConnectivityAction]);
|
||||||
|
|
||||||
|
if (dismissedErrorMessage === message) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 left-4 z-50 w-[calc(100vw-2rem)] text-left sm:bottom-6 sm:left-6 sm:w-[22rem]">
|
||||||
|
<SidebarRelayConnectionCompactCard
|
||||||
|
actionTestId="onboarding-reconnect-relay"
|
||||||
|
isActionDisabled={isActionPending}
|
||||||
|
isConnected={hasSuccess}
|
||||||
|
isReconnectPending={isActionPending}
|
||||||
|
onDismiss={() => setDismissedErrorMessage(message)}
|
||||||
|
onReconnect={handleReconnectRelay}
|
||||||
|
surface="secondary"
|
||||||
|
testId="onboarding-relay-reconnect-card"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorBanner({
|
||||||
|
isSaving,
|
||||||
|
message,
|
||||||
|
}: {
|
||||||
|
isSaving: boolean;
|
||||||
|
message: string | null;
|
||||||
|
}) {
|
||||||
if (!message) {
|
if (!message) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isRelayUnreachableError(message)) {
|
||||||
|
return (
|
||||||
|
<OnboardingRelayConnectionErrorCard
|
||||||
|
isSaving={isSaving}
|
||||||
|
key={message}
|
||||||
|
message={message}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<p className="mt-4 rounded-md border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
<p className="mt-4 rounded-md border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||||
{message}
|
{message}
|
||||||
@@ -117,7 +241,7 @@ export function ProfileStep({
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
{saveRecovery.errorMessage ? (
|
{saveRecovery.errorMessage ? (
|
||||||
<ErrorBanner message={saveRecovery.errorMessage} />
|
<ErrorBanner isSaving={isSaving} message={saveRecovery.errorMessage} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="mt-12 flex w-full max-w-[500px] flex-col gap-3">
|
<div className="mt-12 flex w-full max-w-[500px] flex-col gap-3">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { ChevronRight, RefreshCw, Smile } from "lucide-react";
|
import { ChevronRight, Smile } from "lucide-react";
|
||||||
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||||
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
|
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
|
||||||
@@ -28,8 +28,6 @@ interface ProfilePopoverProps {
|
|||||||
onSetUserStatus: (text: string, emoji: string) => void;
|
onSetUserStatus: (text: string, emoji: string) => void;
|
||||||
onClearUserStatus: () => void;
|
onClearUserStatus: () => void;
|
||||||
onOpenSettings: (section?: "profile" | "appearance") => void;
|
onOpenSettings: (section?: "profile" | "appearance") => void;
|
||||||
onReconnect?: () => void;
|
|
||||||
isReconnectPending?: boolean;
|
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
// Optional outer container whose clicks should NOT close the popover.
|
// Optional outer container whose clicks should NOT close the popover.
|
||||||
// Used when auxiliary triggers (avatar, status text) live alongside the
|
// Used when auxiliary triggers (avatar, status text) live alongside the
|
||||||
@@ -68,8 +66,6 @@ export function ProfilePopover({
|
|||||||
onSetUserStatus,
|
onSetUserStatus,
|
||||||
onClearUserStatus,
|
onClearUserStatus,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
onReconnect,
|
|
||||||
isReconnectPending,
|
|
||||||
children,
|
children,
|
||||||
triggerContainerRef,
|
triggerContainerRef,
|
||||||
workspaceSwitcherSlot,
|
workspaceSwitcherSlot,
|
||||||
@@ -275,27 +271,6 @@ export function ProfilePopover({
|
|||||||
</kbd>
|
</kbd>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{onReconnect ? (
|
|
||||||
<button
|
|
||||||
className={MENU_ITEM_CLASS}
|
|
||||||
data-testid="profile-popover-reconnect"
|
|
||||||
disabled={isReconnectPending}
|
|
||||||
onClick={() => {
|
|
||||||
closePopover();
|
|
||||||
onReconnect();
|
|
||||||
}}
|
|
||||||
role="menuitem"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<RefreshCw
|
|
||||||
className={`h-4 w-4 shrink-0 text-muted-foreground${isReconnectPending ? " animate-spin" : ""}`}
|
|
||||||
/>
|
|
||||||
<span className="flex-1">
|
|
||||||
{isReconnectPending ? "Reconnecting…" : "Reconnect to relay"}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{workspaceSwitcherSlot ? (
|
{workspaceSwitcherSlot ? (
|
||||||
<>
|
<>
|
||||||
<hr className="my-1 h-px border-0 bg-border" />
|
<hr className="my-1 h-px border-0 bg-border" />
|
||||||
|
|||||||
101
desktop/src/features/settings/SidebarUpdateCard.tsx
Normal file
101
desktop/src/features/settings/SidebarUpdateCard.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { CircleArrowUp } from "lucide-react";
|
||||||
|
|
||||||
|
import { useUpdaterContext } from "./hooks/UpdaterProvider";
|
||||||
|
import { shouldShowSidebarUpdateCard } from "./sidebarUpdateCardVisibility";
|
||||||
|
import { SidebarCompactActionCard } from "@/shared/ui/sidebar-action-card";
|
||||||
|
import { Spinner } from "@/shared/ui/spinner";
|
||||||
|
|
||||||
|
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<number | null>(null);
|
||||||
|
const restartTimeoutRef = React.useRef<number | null>(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 (
|
||||||
|
<SidebarCompactActionCard
|
||||||
|
actionAriaLabel="Restart now to apply update"
|
||||||
|
actionDisabled={isRestartPending}
|
||||||
|
actionTestId={actionTestId}
|
||||||
|
description={isRestartPending ? "Restarting" : "Click to restart"}
|
||||||
|
dismissLabel="Dismiss update notification"
|
||||||
|
icon={
|
||||||
|
isRestartPending ? (
|
||||||
|
<Spinner aria-hidden="true" className="h-5 w-5 border-2" />
|
||||||
|
) : (
|
||||||
|
<CircleArrowUp aria-hidden="true" className="h-5 w-5" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
iconKey={isRestartPending ? "pending" : "idle"}
|
||||||
|
onAction={handleRestart}
|
||||||
|
onDismiss={onDismiss}
|
||||||
|
testId={testId}
|
||||||
|
title="Ready to update!"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarUpdateCard({ onDismiss }: SidebarUpdateCardProps) {
|
||||||
|
const { status } = useUpdaterContext();
|
||||||
|
|
||||||
|
if (!shouldShowSidebarUpdateCard(status)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarUpdateCompactCard
|
||||||
|
actionTestId="sidebar-update-restart"
|
||||||
|
onDismiss={onDismiss}
|
||||||
|
testId="sidebar-update-card"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useState, useRef, useCallback, useEffect } from "react";
|
import { useState, useRef, useCallback, useEffect } from "react";
|
||||||
import { check, type Update } from "@tauri-apps/plugin-updater";
|
import { check, type Update } from "@tauri-apps/plugin-updater";
|
||||||
import { relaunch } from "@tauri-apps/plugin-process";
|
import { relaunch } from "@tauri-apps/plugin-process";
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
export type UpdateStatus =
|
export type UpdateStatus =
|
||||||
| { state: "idle" }
|
| { state: "idle" }
|
||||||
@@ -87,10 +86,6 @@ export function useUpdater() {
|
|||||||
|
|
||||||
updateRef.current = null;
|
updateRef.current = null;
|
||||||
setStatus({ state: "ready" });
|
setStatus({ state: "ready" });
|
||||||
toast("Update ready", {
|
|
||||||
description: "Restart when you're ready to apply the update.",
|
|
||||||
duration: 8000,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus({ state: "error", message: toErrorMessage(err) });
|
setStatus({ state: "error", message: toErrorMessage(err) });
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function shouldShowSidebarUpdateCard(status: { state: string }) {
|
||||||
|
return status.state === "ready";
|
||||||
|
}
|
||||||
@@ -10,16 +10,8 @@ import {
|
|||||||
MessageCirclePlus,
|
MessageCirclePlus,
|
||||||
Zap,
|
Zap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
|
||||||
isRelayConnectionDegraded,
|
|
||||||
useRelayConnection,
|
|
||||||
} from "@/shared/api/useRelayConnection";
|
|
||||||
import { useReconnectRelay } from "@/shared/api/useReconnectRelay";
|
|
||||||
import {
|
|
||||||
isRelayUnreachableError,
|
|
||||||
RELAY_UNREACHABLE_SHORT,
|
|
||||||
} from "@/shared/lib/relayError";
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { AnimatePresence } from "motion/react";
|
||||||
import { FeatureGate } from "@/shared/features";
|
import { FeatureGate } from "@/shared/features";
|
||||||
import { SidebarDndContext } from "@/features/sidebar/ui/SidebarDnd";
|
import { SidebarDndContext } from "@/features/sidebar/ui/SidebarDnd";
|
||||||
|
|
||||||
@@ -48,11 +40,16 @@ import {
|
|||||||
import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog";
|
import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog";
|
||||||
import { NewDirectMessageDialog } from "@/features/sidebar/ui/NewDirectMessageDialog";
|
import { NewDirectMessageDialog } from "@/features/sidebar/ui/NewDirectMessageDialog";
|
||||||
import { SidebarProfileCard } from "@/features/sidebar/ui/SidebarProfileCard";
|
import { SidebarProfileCard } from "@/features/sidebar/ui/SidebarProfileCard";
|
||||||
|
import { SidebarRelayConnectionCard } from "@/features/sidebar/ui/SidebarRelayConnectionCard";
|
||||||
|
import { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard";
|
||||||
import {
|
import {
|
||||||
SidebarLoadingContent,
|
SidebarLoadingContent,
|
||||||
useSidebarLoadingShape,
|
useSidebarLoadingShape,
|
||||||
} from "@/features/sidebar/ui/sidebarLoadingSkeleton";
|
} from "@/features/sidebar/ui/sidebarLoadingSkeleton";
|
||||||
import { SECTION_ICON_BUTTON_CLASS } from "@/features/sidebar/ui/sidebarSectionStyles";
|
import { SECTION_ICON_BUTTON_CLASS } from "@/features/sidebar/ui/sidebarSectionStyles";
|
||||||
|
import { SidebarUpdateCard } from "@/features/settings/SidebarUpdateCard";
|
||||||
|
import { useUpdaterContext } from "@/features/settings/hooks/UpdaterProvider";
|
||||||
|
import { shouldShowSidebarUpdateCard } from "@/features/settings/sidebarUpdateCardVisibility";
|
||||||
import type {
|
import type {
|
||||||
Channel,
|
Channel,
|
||||||
ChannelVisibility,
|
ChannelVisibility,
|
||||||
@@ -60,6 +57,7 @@ import type {
|
|||||||
Profile,
|
Profile,
|
||||||
UserStatus,
|
UserStatus,
|
||||||
} from "@/shared/api/types";
|
} from "@/shared/api/types";
|
||||||
|
import { cn } from "@/shared/lib/cn";
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@@ -228,6 +226,31 @@ export function AppSidebar({
|
|||||||
onStarChannel,
|
onStarChannel,
|
||||||
onUnstarChannel,
|
onUnstarChannel,
|
||||||
}: AppSidebarProps) {
|
}: AppSidebarProps) {
|
||||||
|
const { status: updateStatus } = useUpdaterContext();
|
||||||
|
const canShowSidebarUpdateCard = shouldShowSidebarUpdateCard(updateStatus);
|
||||||
|
const sidebarRelayConnectionCard = useSidebarRelayConnectionCard(
|
||||||
|
errorMessage,
|
||||||
|
activeWorkspace?.relayUrl,
|
||||||
|
);
|
||||||
|
const [isSidebarUpdateCardDismissed, setIsSidebarUpdateCardDismissed] =
|
||||||
|
React.useState(false);
|
||||||
|
const showSidebarUpdateCard =
|
||||||
|
canShowSidebarUpdateCard && !isSidebarUpdateCardDismissed;
|
||||||
|
const sidebarFooterCardCount =
|
||||||
|
(sidebarRelayConnectionCard.showSidebarRelayConnectionCard ? 1 : 0) +
|
||||||
|
(showSidebarUpdateCard ? 1 : 0);
|
||||||
|
const sidebarContentBottomPaddingClass =
|
||||||
|
sidebarFooterCardCount >= 2
|
||||||
|
? "pb-[18rem]"
|
||||||
|
: sidebarFooterCardCount >= 1
|
||||||
|
? "pb-52"
|
||||||
|
: "pb-32";
|
||||||
|
const unreadBelowBottomClass =
|
||||||
|
sidebarFooterCardCount >= 2
|
||||||
|
? "bottom-56"
|
||||||
|
: sidebarFooterCardCount >= 1
|
||||||
|
? "bottom-44"
|
||||||
|
: "bottom-28";
|
||||||
const [isNewDmOpenInternal, setIsNewDmOpenInternal] = React.useState(false);
|
const [isNewDmOpenInternal, setIsNewDmOpenInternal] = React.useState(false);
|
||||||
const isNewDmOpen = isNewDmOpenProp ?? isNewDmOpenInternal;
|
const isNewDmOpen = isNewDmOpenProp ?? isNewDmOpenInternal;
|
||||||
const setIsNewDmOpen = onNewDmOpenChange ?? setIsNewDmOpenInternal;
|
const setIsNewDmOpen = onNewDmOpenChange ?? setIsNewDmOpenInternal;
|
||||||
@@ -236,6 +259,12 @@ export function AppSidebar({
|
|||||||
const [createDialogKind, setCreateDialogKind] =
|
const [createDialogKind, setCreateDialogKind] =
|
||||||
React.useState<CreateChannelKind | null>(null);
|
React.useState<CreateChannelKind | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!canShowSidebarUpdateCard) {
|
||||||
|
setIsSidebarUpdateCardDismissed(false);
|
||||||
|
}
|
||||||
|
}, [canShowSidebarUpdateCard]);
|
||||||
|
|
||||||
// Allow the create-channel dialog to be opened from outside (e.g. the
|
// Allow the create-channel dialog to be opened from outside (e.g. the
|
||||||
// ⌘⇧N global shortcut in AppShell), mirroring the controlled new-DM lift.
|
// ⌘⇧N global shortcut in AppShell), mirroring the controlled new-DM lift.
|
||||||
// When the external flag flips on, open the "stream" create dialog; the
|
// When the external flag flips on, open the "stream" create dialog; the
|
||||||
@@ -288,20 +317,6 @@ export function AppSidebar({
|
|||||||
unassignChannel,
|
unassignChannel,
|
||||||
} = useChannelSections(currentPubkey);
|
} = useChannelSections(currentPubkey);
|
||||||
|
|
||||||
const { isPending: isReconnectPending, reconnect } = useReconnectRelay();
|
|
||||||
|
|
||||||
// The sidebar reconnect prompt must surface the moment the relay drops, not
|
|
||||||
// only after `channelsQuery` finally errors (it has a 60s staleTime +
|
|
||||||
// refetchInterval, so the error lags 60-120s behind a dropped socket).
|
|
||||||
// OR-in the live, debounced connection state — same signal that drives
|
|
||||||
// ConnectionBanner — so the prompt flips within ~2s of degradation.
|
|
||||||
const relayConnectionState = useRelayConnection();
|
|
||||||
const hasRelayUnreachableError = errorMessage
|
|
||||||
? isRelayUnreachableError(errorMessage)
|
|
||||||
: false;
|
|
||||||
const isRelayConnectionDegradedNow =
|
|
||||||
hasRelayUnreachableError || isRelayConnectionDegraded(relayConnectionState);
|
|
||||||
|
|
||||||
const [createSectionState, setCreateSectionState] = React.useState<{
|
const [createSectionState, setCreateSectionState] = React.useState<{
|
||||||
open: boolean;
|
open: boolean;
|
||||||
pendingChannelId: string | null;
|
pendingChannelId: string | null;
|
||||||
@@ -563,7 +578,10 @@ export function AppSidebar({
|
|||||||
testId="sidebar-more-unread-above"
|
testId="sidebar-more-unread-above"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<SidebarContent className="pb-32" ref={scrollRef}>
|
<SidebarContent
|
||||||
|
className={cn(sidebarContentBottomPaddingClass)}
|
||||||
|
ref={scrollRef}
|
||||||
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<SidebarLoadingContent shape={sidebarLoadingShape} />
|
<SidebarLoadingContent shape={sidebarLoadingShape} />
|
||||||
) : null}
|
) : null}
|
||||||
@@ -754,25 +772,8 @@ export function AppSidebar({
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isRelayConnectionDegradedNow ? (
|
{errorMessage &&
|
||||||
<div
|
!sidebarRelayConnectionCard.hasRelayUnreachableError ? (
|
||||||
className="px-3 py-2 text-sm"
|
|
||||||
data-testid="sidebar-relay-unreachable"
|
|
||||||
>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{RELAY_UNREACHABLE_SHORT}{" "}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
className="text-primary hover:underline disabled:opacity-50"
|
|
||||||
data-testid="sidebar-reconnect"
|
|
||||||
disabled={isReconnectPending}
|
|
||||||
onClick={() => void reconnect()}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{isReconnectPending ? "Reconnecting…" : "Reconnect"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : errorMessage ? (
|
|
||||||
<div className="px-3 py-2 text-sm text-destructive">
|
<div className="px-3 py-2 text-sm text-destructive">
|
||||||
{errorMessage}
|
{errorMessage}
|
||||||
</div>
|
</div>
|
||||||
@@ -781,7 +782,7 @@ export function AppSidebar({
|
|||||||
|
|
||||||
{unreadBelowCount > 0 ? (
|
{unreadBelowCount > 0 ? (
|
||||||
<MoreUnreadButton
|
<MoreUnreadButton
|
||||||
bottomClassName="bottom-28"
|
bottomClassName={unreadBelowBottomClass}
|
||||||
count={unreadBelowCount}
|
count={unreadBelowCount}
|
||||||
icon={<ArrowDown />}
|
icon={<ArrowDown />}
|
||||||
onClick={scrollToNextBelow}
|
onClick={scrollToNextBelow}
|
||||||
@@ -791,6 +792,31 @@ export function AppSidebar({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<SidebarFooter className="absolute inset-x-0 bottom-0 z-30 bg-sidebar/55 backdrop-blur-xl supports-[backdrop-filter]:bg-sidebar/45 dark:bg-sidebar/45 dark:supports-[backdrop-filter]:bg-sidebar/35">
|
<SidebarFooter className="absolute inset-x-0 bottom-0 z-30 bg-sidebar/55 backdrop-blur-xl supports-[backdrop-filter]:bg-sidebar/45 dark:bg-sidebar/45 dark:supports-[backdrop-filter]:bg-sidebar/35">
|
||||||
|
<AnimatePresence>
|
||||||
|
{sidebarRelayConnectionCard.showSidebarRelayConnectionCard ? (
|
||||||
|
<SidebarRelayConnectionCard
|
||||||
|
className="mb-2 group-data-[collapsible=icon]:hidden"
|
||||||
|
isConnected={
|
||||||
|
sidebarRelayConnectionCard.isRelayConnectionSuccess
|
||||||
|
}
|
||||||
|
isReconnectPending={
|
||||||
|
sidebarRelayConnectionCard.isRelayReconnectPending
|
||||||
|
}
|
||||||
|
onDismiss={
|
||||||
|
sidebarRelayConnectionCard.onDismissRelayConnectionCard
|
||||||
|
}
|
||||||
|
onReconnect={sidebarRelayConnectionCard.onReconnectRelay}
|
||||||
|
key="sidebar-relay-connection-card"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
{showSidebarUpdateCard ? (
|
||||||
|
<div className="mb-2 group-data-[collapsible=icon]:hidden">
|
||||||
|
<SidebarUpdateCard
|
||||||
|
onDismiss={() => setIsSidebarUpdateCardDismissed(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarProfileCard
|
<SidebarProfileCard
|
||||||
|
|||||||
@@ -9,11 +9,6 @@ import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
|
|||||||
import type { Workspace } from "@/features/workspaces/types";
|
import type { Workspace } from "@/features/workspaces/types";
|
||||||
import { WorkspaceSwitcher } from "@/features/workspaces/ui/WorkspaceSwitcher";
|
import { WorkspaceSwitcher } from "@/features/workspaces/ui/WorkspaceSwitcher";
|
||||||
import type { PresenceStatus, Profile, UserStatus } from "@/shared/api/types";
|
import type { PresenceStatus, Profile, UserStatus } from "@/shared/api/types";
|
||||||
import { useReconnectRelay } from "@/shared/api/useReconnectRelay";
|
|
||||||
import {
|
|
||||||
isRelayConnectionDegraded,
|
|
||||||
useRelayConnection,
|
|
||||||
} from "@/shared/api/useRelayConnection";
|
|
||||||
import { cn } from "@/shared/lib/cn";
|
import { cn } from "@/shared/lib/cn";
|
||||||
|
|
||||||
type SidebarProfileCardProps = {
|
type SidebarProfileCardProps = {
|
||||||
@@ -54,17 +49,7 @@ export function SidebarProfileCard({
|
|||||||
selfUserStatus,
|
selfUserStatus,
|
||||||
workspaces,
|
workspaces,
|
||||||
}: SidebarProfileCardProps) {
|
}: SidebarProfileCardProps) {
|
||||||
// Called locally rather than threading props from AppShell — both hooks are
|
|
||||||
// workspace-provider and QueryClient safe at this level.
|
|
||||||
const selfProfileCache = useSelfProfileCache();
|
const selfProfileCache = useSelfProfileCache();
|
||||||
const { isPending, reconnect } = useReconnectRelay();
|
|
||||||
// Only offer reconnect when the relay is actually degraded — keep the item
|
|
||||||
// visible while a reconnect is in flight so it does not vanish mid-click if
|
|
||||||
// the live state briefly flips.
|
|
||||||
const isRelayConnectionDegradedNow = isRelayConnectionDegraded(
|
|
||||||
useRelayConnection(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const [profilePopoverOpen, setProfilePopoverOpen] = React.useState(false);
|
const [profilePopoverOpen, setProfilePopoverOpen] = React.useState(false);
|
||||||
const profileCardRef = React.useRef<HTMLDivElement | null>(null);
|
const profileCardRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const toggleProfilePopover = React.useCallback(
|
const toggleProfilePopover = React.useCallback(
|
||||||
@@ -140,15 +125,9 @@ export function SidebarProfileCard({
|
|||||||
avatarUrl={profile?.avatarUrl ?? null}
|
avatarUrl={profile?.avatarUrl ?? null}
|
||||||
currentStatus={selfPresenceStatus}
|
currentStatus={selfPresenceStatus}
|
||||||
displayName={resolvedDisplayName}
|
displayName={resolvedDisplayName}
|
||||||
isReconnectPending={isPending}
|
|
||||||
isStatusPending={isPresencePending}
|
isStatusPending={isPresencePending}
|
||||||
onClearUserStatus={onClearUserStatus}
|
onClearUserStatus={onClearUserStatus}
|
||||||
onOpenSettings={onOpenSettings}
|
onOpenSettings={onOpenSettings}
|
||||||
onReconnect={
|
|
||||||
isRelayConnectionDegradedNow || isPending
|
|
||||||
? () => void reconnect()
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onSetStatus={onSetPresenceStatus ?? (() => {})}
|
onSetStatus={onSetPresenceStatus ?? (() => {})}
|
||||||
onSetUserStatus={onSetUserStatus}
|
onSetUserStatus={onSetUserStatus}
|
||||||
triggerContainerRef={profileCardRef}
|
triggerContainerRef={profileCardRef}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { Check, CloudOff } from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SidebarCompactActionCard,
|
||||||
|
type SidebarActionCardSurface,
|
||||||
|
} from "@/shared/ui/sidebar-action-card";
|
||||||
|
import { Spinner } from "@/shared/ui/spinner";
|
||||||
|
|
||||||
|
type SidebarRelayConnectionCardProps = {
|
||||||
|
isActionDisabled?: boolean;
|
||||||
|
actionTestId?: string;
|
||||||
|
className?: string;
|
||||||
|
isConnected?: boolean;
|
||||||
|
isReconnectPending: boolean;
|
||||||
|
onDismiss?: () => void;
|
||||||
|
onReconnect: () => void;
|
||||||
|
surface?: SidebarActionCardSurface;
|
||||||
|
testId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SidebarRelayConnectionCard({
|
||||||
|
actionTestId,
|
||||||
|
className,
|
||||||
|
isActionDisabled = false,
|
||||||
|
isConnected = false,
|
||||||
|
isReconnectPending,
|
||||||
|
onDismiss,
|
||||||
|
onReconnect,
|
||||||
|
surface,
|
||||||
|
}: SidebarRelayConnectionCardProps) {
|
||||||
|
return (
|
||||||
|
<SidebarRelayConnectionCompactCard
|
||||||
|
actionTestId={actionTestId ?? "sidebar-reconnect"}
|
||||||
|
className={className}
|
||||||
|
isActionDisabled={isActionDisabled}
|
||||||
|
isConnected={isConnected}
|
||||||
|
isReconnectPending={isReconnectPending}
|
||||||
|
onDismiss={onDismiss}
|
||||||
|
onReconnect={onReconnect}
|
||||||
|
surface={surface}
|
||||||
|
testId="sidebar-relay-unreachable"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarRelayConnectionCompactCard({
|
||||||
|
actionTestId,
|
||||||
|
className,
|
||||||
|
isActionDisabled = false,
|
||||||
|
isConnected = false,
|
||||||
|
isReconnectPending,
|
||||||
|
onDismiss,
|
||||||
|
onReconnect,
|
||||||
|
surface,
|
||||||
|
testId = "sidebar-relay-unreachable-compact",
|
||||||
|
}: SidebarRelayConnectionCardProps) {
|
||||||
|
return (
|
||||||
|
<SidebarCompactActionCard
|
||||||
|
actionAriaLabel={isConnected ? "Connected" : "Connect to relay"}
|
||||||
|
actionDisabled={isActionDisabled || isReconnectPending || isConnected}
|
||||||
|
actionTestId={actionTestId}
|
||||||
|
description={
|
||||||
|
isConnected
|
||||||
|
? undefined
|
||||||
|
: isReconnectPending
|
||||||
|
? "Reconnecting"
|
||||||
|
: "Click to connect"
|
||||||
|
}
|
||||||
|
dismissLabel="Dismiss relay notification"
|
||||||
|
iconKey={
|
||||||
|
isConnected ? "connected" : isReconnectPending ? "pending" : "idle"
|
||||||
|
}
|
||||||
|
icon={
|
||||||
|
isConnected ? (
|
||||||
|
<Check aria-hidden="true" className="h-5 w-5" />
|
||||||
|
) : isReconnectPending ? (
|
||||||
|
<Spinner aria-hidden="true" className="h-5 w-5 border-2" />
|
||||||
|
) : (
|
||||||
|
<CloudOff aria-hidden="true" className="h-5 w-5" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className={className}
|
||||||
|
onAction={onReconnect}
|
||||||
|
onDismiss={onDismiss}
|
||||||
|
role={isConnected ? "status" : "alert"}
|
||||||
|
surface={surface}
|
||||||
|
testId={testId}
|
||||||
|
title={
|
||||||
|
isConnected
|
||||||
|
? "Connected"
|
||||||
|
: isReconnectPending
|
||||||
|
? "Connecting"
|
||||||
|
: "Can't reach the relay"
|
||||||
|
}
|
||||||
|
tone={isConnected ? "success" : "neutral"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
222
desktop/src/features/sidebar/ui/useSidebarRelayConnectionCard.ts
Normal file
222
desktop/src/features/sidebar/ui/useSidebarRelayConnectionCard.ts
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { useRelayConnection } from "@/shared/api/useRelayConnection";
|
||||||
|
import { useReconnectRelay } from "@/shared/api/useReconnectRelay";
|
||||||
|
import { isRelayUnreachableError } from "@/shared/lib/relayError";
|
||||||
|
|
||||||
|
const SIDEBAR_CONNECTIVITY_SUCCESS_AUTO_DISMISS_MS = 6_000;
|
||||||
|
const DEFAULT_RELAY_SUCCESS_KEY = "__default-relay__";
|
||||||
|
|
||||||
|
let relayConnectivitySuccessKey: string | null = null;
|
||||||
|
const relayConnectivitySuccessListeners = new Set<() => void>();
|
||||||
|
|
||||||
|
function relaySuccessKey(relayUrl: string | null | undefined) {
|
||||||
|
return relayUrl ?? DEFAULT_RELAY_SUCCESS_KEY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribeRelayConnectivitySuccess(listener: () => void) {
|
||||||
|
relayConnectivitySuccessListeners.add(listener);
|
||||||
|
return () => relayConnectivitySuccessListeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRelayConnectivitySuccessSnapshot(
|
||||||
|
relayUrl: string | null | undefined,
|
||||||
|
) {
|
||||||
|
return relayConnectivitySuccessKey === relaySuccessKey(relayUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRelayConnectivitySuccess(
|
||||||
|
relayUrl: string | null | undefined,
|
||||||
|
next: boolean,
|
||||||
|
) {
|
||||||
|
const nextKey = next ? relaySuccessKey(relayUrl) : null;
|
||||||
|
if (relayConnectivitySuccessKey === nextKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't let one workspace clear another workspace's success state.
|
||||||
|
if (!next && relayConnectivitySuccessKey !== relaySuccessKey(relayUrl)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
relayConnectivitySuccessKey = nextKey;
|
||||||
|
for (const listener of relayConnectivitySuccessListeners) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetSidebarRelayConnectionCardState() {
|
||||||
|
if (relayConnectivitySuccessKey === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
relayConnectivitySuccessKey = null;
|
||||||
|
for (const listener of relayConnectivitySuccessListeners) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDocumentVisible() {
|
||||||
|
return document.visibilityState === "visible";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSidebarRelayConnectionCard(
|
||||||
|
errorMessage?: string,
|
||||||
|
relayUrl?: string | null,
|
||||||
|
) {
|
||||||
|
const relayConnectionState = useRelayConnection();
|
||||||
|
const hasRelayUnreachableError = errorMessage
|
||||||
|
? isRelayUnreachableError(errorMessage)
|
||||||
|
: false;
|
||||||
|
const isRelayConnectionStateDegraded =
|
||||||
|
relayConnectionState === "reconnecting" ||
|
||||||
|
relayConnectionState === "stalled";
|
||||||
|
const isRelayConnectionConnected = relayConnectionState === "connected";
|
||||||
|
const isRelayConnectionDisconnected = relayConnectionState === "disconnected";
|
||||||
|
const [isDismissed, setIsDismissed] = React.useState(false);
|
||||||
|
const hasSuccess = React.useSyncExternalStore(
|
||||||
|
subscribeRelayConnectivitySuccess,
|
||||||
|
() => getRelayConnectivitySuccessSnapshot(relayUrl),
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
const [isWindowVisible, setIsWindowVisible] =
|
||||||
|
React.useState(isDocumentVisible);
|
||||||
|
const hasActiveRelayUnreachableError =
|
||||||
|
hasRelayUnreachableError && !hasSuccess;
|
||||||
|
const isRelayConnectionActuallyDegraded =
|
||||||
|
hasActiveRelayUnreachableError || isRelayConnectionStateDegraded;
|
||||||
|
const isRelayConnectionSuccess = hasSuccess && isRelayConnectionConnected;
|
||||||
|
const canShow = isRelayConnectionActuallyDegraded || isRelayConnectionSuccess;
|
||||||
|
const show = canShow && !isDismissed;
|
||||||
|
const wasProblemCardVisibleRef = React.useRef(false);
|
||||||
|
const { isPending: isReconnectPending, reconnect } = useReconnectRelay();
|
||||||
|
const [connectivityAction, setConnectivityAction] = React.useState<
|
||||||
|
"relay-connection" | null
|
||||||
|
>(null);
|
||||||
|
const connectivityActionRef = React.useRef<"relay-connection" | null>(null);
|
||||||
|
const connectivityFrameRef = React.useRef<number | null>(null);
|
||||||
|
const connectivityTimeoutRef = React.useRef<number | null>(null);
|
||||||
|
const isRelayReconnectPending =
|
||||||
|
isReconnectPending || connectivityAction === "relay-connection";
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isRelayConnectionActuallyDegraded && !isRelayConnectionSuccess) {
|
||||||
|
setIsDismissed(false);
|
||||||
|
}
|
||||||
|
}, [isRelayConnectionSuccess, isRelayConnectionActuallyDegraded]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isRelayConnectionStateDegraded || isRelayConnectionDisconnected) {
|
||||||
|
setRelayConnectivitySuccess(relayUrl, false);
|
||||||
|
setIsDismissed(false);
|
||||||
|
}
|
||||||
|
}, [isRelayConnectionDisconnected, isRelayConnectionStateDegraded, relayUrl]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isRelayConnectionActuallyDegraded) {
|
||||||
|
wasProblemCardVisibleRef.current = show && !isRelayConnectionSuccess;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wasProblemCardVisibleRef.current && isRelayConnectionConnected) {
|
||||||
|
wasProblemCardVisibleRef.current = false;
|
||||||
|
setRelayConnectivitySuccess(relayUrl, true);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isRelayConnectionSuccess,
|
||||||
|
relayUrl,
|
||||||
|
show,
|
||||||
|
isRelayConnectionActuallyDegraded,
|
||||||
|
isRelayConnectionConnected,
|
||||||
|
]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isRelayConnectionSuccess) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isWindowVisible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
setRelayConnectivitySuccess(relayUrl, false);
|
||||||
|
setIsDismissed(true);
|
||||||
|
}, SIDEBAR_CONNECTIVITY_SUCCESS_AUTO_DISMISS_MS);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timeout);
|
||||||
|
}, [isRelayConnectionSuccess, isWindowVisible, relayUrl]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const updateWindowVisible = () => setIsWindowVisible(isDocumentVisible());
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", updateWindowVisible);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("visibilitychange", updateWindowVisible);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (connectivityFrameRef.current !== null) {
|
||||||
|
window.cancelAnimationFrame(connectivityFrameRef.current);
|
||||||
|
}
|
||||||
|
if (connectivityTimeoutRef.current !== null) {
|
||||||
|
window.clearTimeout(connectivityTimeoutRef.current);
|
||||||
|
}
|
||||||
|
connectivityActionRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startConnectivityAction = React.useCallback(
|
||||||
|
(runAction: () => Promise<void>) => {
|
||||||
|
if (connectivityActionRef.current !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectivityActionRef.current = "relay-connection";
|
||||||
|
setConnectivityAction("relay-connection");
|
||||||
|
connectivityFrameRef.current = window.requestAnimationFrame(() => {
|
||||||
|
connectivityFrameRef.current = null;
|
||||||
|
connectivityTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
connectivityTimeoutRef.current = null;
|
||||||
|
void Promise.resolve()
|
||||||
|
.then(runAction)
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("[AppSidebar] connectivity action failed:", error);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
connectivityActionRef.current = null;
|
||||||
|
setConnectivityAction(null);
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleReconnectRelay = React.useCallback(() => {
|
||||||
|
startConnectivityAction(async () => {
|
||||||
|
setRelayConnectivitySuccess(relayUrl, false);
|
||||||
|
const didReconnect = await reconnect();
|
||||||
|
if (didReconnect) {
|
||||||
|
wasProblemCardVisibleRef.current = false;
|
||||||
|
setIsDismissed(false);
|
||||||
|
setRelayConnectivitySuccess(relayUrl, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [reconnect, relayUrl, startConnectivityAction]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasRelayUnreachableError,
|
||||||
|
isRelayConnectionSuccess,
|
||||||
|
isRelayReconnectPending,
|
||||||
|
onDismissRelayConnectionCard: () => {
|
||||||
|
setRelayConnectivitySuccess(relayUrl, false);
|
||||||
|
setIsDismissed(true);
|
||||||
|
},
|
||||||
|
onReconnectRelay: handleReconnectRelay,
|
||||||
|
showSidebarRelayConnectionCard: show,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { resetMediaCaches } from "@/shared/lib/mediaUrl";
|
|||||||
import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache";
|
import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache";
|
||||||
import { clearAllDrafts } from "@/features/messages/lib/useDrafts";
|
import { clearAllDrafts } from "@/features/messages/lib/useDrafts";
|
||||||
import { resetAgentObserverStore } from "@/features/agents/observerRelayStore";
|
import { resetAgentObserverStore } from "@/features/agents/observerRelayStore";
|
||||||
|
import { resetSidebarRelayConnectionCardState } from "@/features/sidebar/ui/useSidebarRelayConnectionCard";
|
||||||
import { resetVideoPlayerState } from "@/shared/ui/videoPlayerState";
|
import { resetVideoPlayerState } from "@/shared/ui/videoPlayerState";
|
||||||
|
|
||||||
import { initFirstWorkspace } from "./workspaceStorage";
|
import { initFirstWorkspace } from "./workspaceStorage";
|
||||||
@@ -25,6 +26,7 @@ import type { Workspace } from "./types";
|
|||||||
function resetWorkspaceState(): void {
|
function resetWorkspaceState(): void {
|
||||||
relayClient.disconnect();
|
relayClient.disconnect();
|
||||||
resetAgentObserverStore();
|
resetAgentObserverStore();
|
||||||
|
resetSidebarRelayConnectionCardState();
|
||||||
resetMediaCaches();
|
resetMediaCaches();
|
||||||
resetVideoPlayerState();
|
resetVideoPlayerState();
|
||||||
clearSearchHitEventCache();
|
clearSearchHitEventCache();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { migrateLegacyWorkspaceStorageBeforeRender } from "@/features/workspaces
|
|||||||
import { WorkspacesProvider } from "@/features/workspaces/useWorkspaces";
|
import { WorkspacesProvider } from "@/features/workspaces/useWorkspaces";
|
||||||
import { ThemeProvider } from "@/shared/theme/ThemeProvider";
|
import { ThemeProvider } from "@/shared/theme/ThemeProvider";
|
||||||
import { EmojiBurstProvider } from "@/shared/ui/EmojiBurstProvider";
|
import { EmojiBurstProvider } from "@/shared/ui/EmojiBurstProvider";
|
||||||
|
import { PoofBurstProvider } from "@/shared/ui/PoofBurstProvider";
|
||||||
import { Toaster } from "@/shared/ui/sonner";
|
import { Toaster } from "@/shared/ui/sonner";
|
||||||
import { TooltipProvider } from "@/shared/ui/tooltip";
|
import { TooltipProvider } from "@/shared/ui/tooltip";
|
||||||
|
|
||||||
@@ -53,10 +54,12 @@ function renderApp() {
|
|||||||
<ThemeProvider defaultTheme="houston">
|
<ThemeProvider defaultTheme="houston">
|
||||||
<TooltipProvider delayDuration={300}>
|
<TooltipProvider delayDuration={300}>
|
||||||
<EmojiBurstProvider>
|
<EmojiBurstProvider>
|
||||||
<UpdaterProvider>
|
<PoofBurstProvider>
|
||||||
<App />
|
<UpdaterProvider>
|
||||||
</UpdaterProvider>
|
<App />
|
||||||
<Toaster />
|
</UpdaterProvider>
|
||||||
|
<Toaster />
|
||||||
|
</PoofBurstProvider>
|
||||||
</EmojiBurstProvider>
|
</EmojiBurstProvider>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* Deliberately uses `relayClient.preconnect()` + `queryClient.invalidateQueries()`
|
* Deliberately uses `relayClient.preconnect()` + `queryClient.invalidateQueries()`
|
||||||
* rather than the full `reconnectWorkspace()` path, which unmounts the entire
|
* rather than the full `reconnectWorkspace()` path, which unmounts the entire
|
||||||
* React tree and clears drafts. The goal here is a transparent re-handshake
|
* React tree and clears drafts. The goal here is a transparent re-handshake
|
||||||
* when WARP VPN comes back online; the user should not lose their in-progress
|
* when the transport comes back online; the user should not lose their in-progress
|
||||||
* compose state.
|
* compose state.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -16,8 +16,30 @@ import { toast } from "sonner";
|
|||||||
|
|
||||||
import { relayClient } from "@/shared/api/relayClient";
|
import { relayClient } from "@/shared/api/relayClient";
|
||||||
|
|
||||||
|
const RECONNECT_HOOK_TIMEOUT_MS = 20_000;
|
||||||
|
const RELAY_PRECONNECT_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
|
function withTimeout<T>(
|
||||||
|
promise: Promise<T>,
|
||||||
|
timeoutMs: number,
|
||||||
|
label: string,
|
||||||
|
): Promise<T> {
|
||||||
|
let timeoutId: number | null = null;
|
||||||
|
const timeout = new Promise<never>((_, reject) => {
|
||||||
|
timeoutId = window.setTimeout(() => {
|
||||||
|
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
|
||||||
|
}, timeoutMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.race([promise, timeout]).finally(() => {
|
||||||
|
if (timeoutId !== null) {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useReconnectRelay(): {
|
export function useReconnectRelay(): {
|
||||||
reconnect: () => Promise<void>;
|
reconnect: () => Promise<boolean>;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
} {
|
} {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -28,25 +50,44 @@ export function useReconnectRelay(): {
|
|||||||
const inFlightRef = React.useRef(false);
|
const inFlightRef = React.useRef(false);
|
||||||
|
|
||||||
const reconnect = React.useCallback(async () => {
|
const reconnect = React.useCallback(async () => {
|
||||||
if (inFlightRef.current) return;
|
if (inFlightRef.current) return false;
|
||||||
inFlightRef.current = true;
|
inFlightRef.current = true;
|
||||||
setIsPending(true);
|
setIsPending(true);
|
||||||
try {
|
try {
|
||||||
// Run transport-layer reconnect hook (e.g. WARP VPN re-auth for internal builds).
|
// Run the transport-layer reconnect hook configured by internal builds.
|
||||||
// No-op in OSS builds. Non-fatal — transport failure shouldn't block relay reconnect.
|
// No-op in OSS builds. Non-fatal — transport failure shouldn't block relay reconnect.
|
||||||
try {
|
try {
|
||||||
await invoke("relay_reconnect_hook");
|
await withTimeout(
|
||||||
|
invoke("relay_reconnect_hook"),
|
||||||
|
RECONNECT_HOOK_TIMEOUT_MS,
|
||||||
|
"reconnect hook",
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[useReconnectRelay] reconnect hook failed:", err);
|
console.warn("[useReconnectRelay] reconnect hook failed:", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
await relayClient.preconnect();
|
await withTimeout(
|
||||||
await queryClient.invalidateQueries();
|
relayClient.preconnect(),
|
||||||
|
RELAY_PRECONNECT_TIMEOUT_MS,
|
||||||
|
"relay preconnect",
|
||||||
|
);
|
||||||
|
// Let callers render the recovered/connected state before refetching the
|
||||||
|
// sidebar data. The refetch can briefly swap the sidebar into loading UI.
|
||||||
|
window.setTimeout(() => {
|
||||||
|
void queryClient.invalidateQueries().catch((error) => {
|
||||||
|
console.error(
|
||||||
|
"[useReconnectRelay] failed to refresh queries after reconnect:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
// No success toast — the banner auto-hides once the connection state
|
// No success toast — the banner auto-hides once the connection state
|
||||||
// transitions back to "connected", which is the user-visible confirmation.
|
// transitions back to "connected", which is the user-visible confirmation.
|
||||||
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error("Reconnect failed — check your VPN or network.");
|
toast.error("Reconnect failed — check your network.");
|
||||||
console.error("[useReconnectRelay] reconnect failed:", err);
|
console.error("[useReconnectRelay] reconnect failed:", err);
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
inFlightRef.current = false;
|
inFlightRef.current = false;
|
||||||
setIsPending(false);
|
setIsPending(false);
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
import {
|
import { isRelayUnreachableError } from "./relayError.ts";
|
||||||
isRelayUnreachableError,
|
|
||||||
relayErrorDetail,
|
|
||||||
RELAY_UNREACHABLE_MESSAGE,
|
|
||||||
} from "./relayError.ts";
|
|
||||||
|
|
||||||
// ── isRelayUnreachableError ───────────────────────────────────────────────────
|
// ── isRelayUnreachableError ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -49,34 +45,3 @@ test("isRelayUnreachableError: plain object returns false", () => {
|
|||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── relayErrorDetail ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test("relayErrorDetail: strips prefix and trims for Error", () => {
|
|
||||||
const err = new Error("relay unreachable: connection refused ");
|
|
||||||
assert.equal(relayErrorDetail(err), "connection refused");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("relayErrorDetail: strips prefix and trims for string", () => {
|
|
||||||
assert.equal(
|
|
||||||
relayErrorDetail("relay unreachable: 403 Forbidden from Cloudflare Access"),
|
|
||||||
"403 Forbidden from Cloudflare Access",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("relayErrorDetail: prefix with no detail returns RELAY_UNREACHABLE_MESSAGE", () => {
|
|
||||||
assert.equal(
|
|
||||||
relayErrorDetail("relay unreachable:"),
|
|
||||||
RELAY_UNREACHABLE_MESSAGE,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("relayErrorDetail: unrelated Error returns generic message", () => {
|
|
||||||
const detail = relayErrorDetail(new Error("something else"));
|
|
||||||
assert.equal(detail, RELAY_UNREACHABLE_MESSAGE);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("relayErrorDetail: null returns generic message", () => {
|
|
||||||
const detail = relayErrorDetail(null);
|
|
||||||
assert.equal(detail, RELAY_UNREACHABLE_MESSAGE);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -32,21 +32,3 @@ export function isRelayUnreachableError(error: unknown): boolean {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a human-readable detail string for an error.
|
|
||||||
*
|
|
||||||
* When the error is classified as a relay-unreachable error, strips the
|
|
||||||
* prefix and trims whitespace so the UI sees only the Rust-authored detail
|
|
||||||
* (e.g. "connection refused" or "403 Forbidden from Cloudflare Access").
|
|
||||||
*
|
|
||||||
* Falls back to a generic connectivity message for anything unclassified.
|
|
||||||
*/
|
|
||||||
export function relayErrorDetail(error: unknown): string {
|
|
||||||
if (isRelayUnreachableError(error)) {
|
|
||||||
const message = error instanceof Error ? error.message : (error as string);
|
|
||||||
const detail = message.slice(RELAY_UNREACHABLE_PREFIX.length).trim();
|
|
||||||
return detail || RELAY_UNREACHABLE_MESSAGE;
|
|
||||||
}
|
|
||||||
return RELAY_UNREACHABLE_MESSAGE;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -142,6 +142,169 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.buzz-poof-layer {
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 2147483647;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buzz-poof-burst {
|
||||||
|
contain: layout paint style;
|
||||||
|
height: var(--buzz-poof-size);
|
||||||
|
left: 0;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
transform: translate3d(
|
||||||
|
calc(var(--buzz-poof-x) - 50%),
|
||||||
|
calc(var(--buzz-poof-y) - 50%),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
width: var(--buzz-poof-size);
|
||||||
|
}
|
||||||
|
|
||||||
|
.buzz-poof-frame {
|
||||||
|
animation: buzz-poof-frame 400ms linear both;
|
||||||
|
height: 100%;
|
||||||
|
inset: 0;
|
||||||
|
object-fit: contain;
|
||||||
|
opacity: 0;
|
||||||
|
position: absolute;
|
||||||
|
transform: translate3d(0, 0, 0) scale(0.92);
|
||||||
|
transform-origin: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buzz-poof-frame-1 {
|
||||||
|
animation-name: buzz-poof-frame-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buzz-poof-frame-2 {
|
||||||
|
animation-name: buzz-poof-frame-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buzz-poof-frame-3 {
|
||||||
|
animation-name: buzz-poof-frame-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buzz-poof-frame-4 {
|
||||||
|
animation-name: buzz-poof-frame-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buzz-poof-frame-5 {
|
||||||
|
animation-name: buzz-poof-frame-5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes buzz-poof-frame-1 {
|
||||||
|
0%,
|
||||||
|
19.99% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate3d(0, 0, 0) scale(0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
20%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate3d(0, 0, 0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes buzz-poof-frame-2 {
|
||||||
|
0%,
|
||||||
|
19.99% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
20%,
|
||||||
|
39.99% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate3d(0, 0, 0) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
40%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate3d(0, 0, 0) scale(1.01);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes buzz-poof-frame-3 {
|
||||||
|
0%,
|
||||||
|
39.99% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
40%,
|
||||||
|
59.99% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate3d(0, 0, 0) scale(1.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
60%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate3d(0, 0, 0) scale(1.02);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes buzz-poof-frame-4 {
|
||||||
|
0%,
|
||||||
|
59.99% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
60%,
|
||||||
|
79.99% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate3d(0, 0, 0) scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
80%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate3d(0, 0, 0) scale(1.03);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes buzz-poof-frame-5 {
|
||||||
|
0%,
|
||||||
|
79.99% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
80%,
|
||||||
|
99.99% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate3d(0, 0, 0) scale(1.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate3d(0, 0, 0) scale(1.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.buzz-poof-frame {
|
||||||
|
animation: buzz-poof-frame-reduced 180ms ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buzz-poof-frame:not(.buzz-poof-frame-3) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes buzz-poof-frame-reduced {
|
||||||
|
0% {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@property --buzz-grainient-x-0 {
|
@property --buzz-grainient-x-0 {
|
||||||
syntax: "<percentage>";
|
syntax: "<percentage>";
|
||||||
inherits: false;
|
inherits: false;
|
||||||
@@ -404,6 +567,95 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.buzz-sidebar-action-card--success {
|
||||||
|
background-color: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--status-added) 12%,
|
||||||
|
hsl(var(--background)) 88%
|
||||||
|
);
|
||||||
|
border-color: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--status-added) 34%,
|
||||||
|
hsl(var(--border)) 66%
|
||||||
|
);
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.buzz-sidebar-action-card--success:hover {
|
||||||
|
background-color: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--status-added) 16%,
|
||||||
|
hsl(var(--background)) 84%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .buzz-sidebar-action-card--success {
|
||||||
|
background-color: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--status-added) 15%,
|
||||||
|
hsl(var(--background)) 85%
|
||||||
|
);
|
||||||
|
border-color: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--status-added) 42%,
|
||||||
|
hsl(var(--border)) 58%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .buzz-sidebar-action-card--success:hover {
|
||||||
|
background-color: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--status-added) 19%,
|
||||||
|
hsl(var(--background)) 81%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.buzz-sidebar-action-card__success-icon {
|
||||||
|
color: var(--status-added);
|
||||||
|
}
|
||||||
|
|
||||||
|
.buzz-sidebar-action-description {
|
||||||
|
--buzz-sidebar-action-description-line-height: 1.375em;
|
||||||
|
display: inline-block;
|
||||||
|
line-height: var(--buzz-sidebar-action-description-line-height);
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buzz-sidebar-action-description__motion {
|
||||||
|
display: block;
|
||||||
|
height: var(--buzz-sidebar-action-description-line-height);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buzz-sidebar-action-description__reel {
|
||||||
|
animation: buzz-sidebar-action-description-roll-up 260ms
|
||||||
|
cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: var(--buzz-sidebar-action-description-line-height);
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buzz-sidebar-action-description__reel > span {
|
||||||
|
display: block;
|
||||||
|
height: var(--buzz-sidebar-action-description-line-height);
|
||||||
|
line-height: var(--buzz-sidebar-action-description-line-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes buzz-sidebar-action-description-roll-up {
|
||||||
|
from {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translateY(
|
||||||
|
calc(-1 * var(--buzz-sidebar-action-description-line-height))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.buzz-animated-count__slot {
|
.buzz-animated-count__slot {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
height: 1em;
|
height: 1em;
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
} from "@/shared/api/useRelayConnection";
|
} from "@/shared/api/useRelayConnection";
|
||||||
import { useReconnectRelay } from "@/shared/api/useReconnectRelay";
|
import { useReconnectRelay } from "@/shared/api/useReconnectRelay";
|
||||||
import type { ConnectionState } from "@/shared/api/relayClientShared";
|
import type { ConnectionState } from "@/shared/api/relayClientShared";
|
||||||
|
import { isRelayUnreachableError } from "@/shared/lib/relayError";
|
||||||
|
import { useSidebar } from "@/shared/ui/sidebar";
|
||||||
|
|
||||||
const COPY: Partial<Record<ConnectionState, string>> = {
|
const COPY: Partial<Record<ConnectionState, string>> = {
|
||||||
reconnecting: "Reconnecting to relay…",
|
reconnecting: "Reconnecting to relay…",
|
||||||
@@ -20,17 +22,29 @@ const COPY: Partial<Record<ConnectionState, string>> = {
|
|||||||
* The strip auto-disappears once the state transitions back to "connected" —
|
* The strip auto-disappears once the state transitions back to "connected" —
|
||||||
* no success toast needed.
|
* no success toast needed.
|
||||||
*/
|
*/
|
||||||
export function ConnectionBanner() {
|
type ConnectionBannerProps = {
|
||||||
|
errorMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ConnectionBanner({ errorMessage }: ConnectionBannerProps) {
|
||||||
const state = useRelayConnection();
|
const state = useRelayConnection();
|
||||||
const { isPending, reconnect } = useReconnectRelay();
|
const { isPending, reconnect } = useReconnectRelay();
|
||||||
|
const { state: sidebarState } = useSidebar();
|
||||||
|
const hasCollapsedRelayError =
|
||||||
|
sidebarState === "collapsed" &&
|
||||||
|
Boolean(errorMessage && isRelayUnreachableError(errorMessage));
|
||||||
|
|
||||||
if (!isRelayConnectionDegraded(state)) return null;
|
if (!isRelayConnectionDegraded(state) && !hasCollapsedRelayError) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const message = COPY[state] ?? "Connection issue detected.";
|
const message = hasCollapsedRelayError
|
||||||
|
? "Can't reach the relay."
|
||||||
|
: (COPY[state] ?? "Connection issue detected.");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex shrink-0 items-center gap-2 border-b border-warning/30 bg-warning/5 px-3 py-2 text-xs"
|
className="relative z-30 mt-10 flex shrink-0 items-center gap-2 border-b border-warning/30 bg-warning/5 px-3 py-2 text-xs"
|
||||||
data-testid="connection-banner"
|
data-testid="connection-banner"
|
||||||
role="alert"
|
role="alert"
|
||||||
>
|
>
|
||||||
|
|||||||
190
desktop/src/shared/ui/PoofBurstProvider.tsx
Normal file
190
desktop/src/shared/ui/PoofBurstProvider.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import React, { type CSSProperties, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export const POOF_TRIGGER_CLASS = "buzz-poof-trigger";
|
||||||
|
export const POOF_ORIGIN_CLASS = "buzz-poof-origin";
|
||||||
|
|
||||||
|
export const POOF_DURATION_MS = 430;
|
||||||
|
|
||||||
|
const POOF_SOUND_URL = "/pow/plop.m4a";
|
||||||
|
const POOF_SIZE_SCALE = 0.6375;
|
||||||
|
const POOF_FRAMES = [
|
||||||
|
{ id: "poof-1", src: "/pow/poof1@3x.png" },
|
||||||
|
{ id: "poof-2", src: "/pow/poof2@3x.png" },
|
||||||
|
{ id: "poof-3", src: "/pow/poof3@3x.png" },
|
||||||
|
{ id: "poof-4", src: "/pow/poof4@3x.png" },
|
||||||
|
{ id: "poof-5", src: "/pow/poof5@3x.png" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
let poofAudio: HTMLAudioElement | null = null;
|
||||||
|
let lastPointerDownTrigger: Element | null = null;
|
||||||
|
|
||||||
|
type PoofBurst = {
|
||||||
|
id: number;
|
||||||
|
size: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PoofStyle = CSSProperties & {
|
||||||
|
"--buzz-poof-size": string;
|
||||||
|
"--buzz-poof-x": string;
|
||||||
|
"--buzz-poof-y": string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getPoofOrigin(target: Element) {
|
||||||
|
const origin = target.closest(`.${POOF_ORIGIN_CLASS}`) ?? target;
|
||||||
|
const rect = origin.getBoundingClientRect();
|
||||||
|
const baseSize = Math.min(Math.max(rect.width * 0.54, 104), 190);
|
||||||
|
|
||||||
|
return {
|
||||||
|
size: baseSize * POOF_SIZE_SCALE,
|
||||||
|
x: rect.left + rect.width / 2,
|
||||||
|
y: rect.top + rect.height / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function playPoofSound() {
|
||||||
|
try {
|
||||||
|
poofAudio ??= new Audio(POOF_SOUND_URL);
|
||||||
|
poofAudio.volume = 0.34;
|
||||||
|
poofAudio.currentTime = 0;
|
||||||
|
poofAudio.play().catch(() => {
|
||||||
|
// Best-effort — browsers can still block audio playback.
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Best-effort only: audio may be unavailable or blocked.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PoofBurstProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [bursts, setBursts] = useState<PoofBurst[]>([]);
|
||||||
|
const idRef = useRef(0);
|
||||||
|
const timeoutIdsRef = useRef<number[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
for (const frame of POOF_FRAMES) {
|
||||||
|
const image = new Image();
|
||||||
|
image.src = frame.src;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
poofAudio ??= new Audio(POOF_SOUND_URL);
|
||||||
|
poofAudio.preload = "auto";
|
||||||
|
poofAudio.load();
|
||||||
|
} catch {
|
||||||
|
// Best-effort only.
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function emitPoof(target: Element) {
|
||||||
|
const id = idRef.current;
|
||||||
|
idRef.current += 1;
|
||||||
|
|
||||||
|
setBursts((current) => [
|
||||||
|
...current.slice(-5),
|
||||||
|
{ ...getPoofOrigin(target), id },
|
||||||
|
]);
|
||||||
|
playPoofSound();
|
||||||
|
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
setBursts((current) => current.filter((burst) => burst.id !== id));
|
||||||
|
}, POOF_DURATION_MS);
|
||||||
|
timeoutIdsRef.current.push(timeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTriggerTarget(event: Event) {
|
||||||
|
return event.target instanceof Element
|
||||||
|
? event.target.closest(`.${POOF_TRIGGER_CLASS}`)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDocumentPointerDown(event: PointerEvent) {
|
||||||
|
if (event.button !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = findTriggerTarget(event);
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastPointerDownTrigger = target;
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (lastPointerDownTrigger === target) {
|
||||||
|
lastPointerDownTrigger = null;
|
||||||
|
}
|
||||||
|
}, POOF_DURATION_MS);
|
||||||
|
emitPoof(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDocumentClick(event: MouseEvent) {
|
||||||
|
const target =
|
||||||
|
event.target instanceof Element
|
||||||
|
? event.target.closest(`.${POOF_TRIGGER_CLASS}`)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastPointerDownTrigger === target) {
|
||||||
|
lastPointerDownTrigger = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emitPoof(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("pointerdown", handleDocumentPointerDown, {
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
document.addEventListener("click", handleDocumentClick, { capture: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("pointerdown", handleDocumentPointerDown, {
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
document.removeEventListener("click", handleDocumentClick, {
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
for (const timeoutId of timeoutIdsRef.current) {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
timeoutIdsRef.current = [];
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{children}
|
||||||
|
<div aria-hidden="true" className="buzz-poof-layer">
|
||||||
|
{bursts.map((burst) => (
|
||||||
|
<div
|
||||||
|
className="buzz-poof-burst"
|
||||||
|
key={burst.id}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--buzz-poof-size": `${burst.size}px`,
|
||||||
|
"--buzz-poof-x": `${burst.x}px`,
|
||||||
|
"--buzz-poof-y": `${burst.y}px`,
|
||||||
|
} as PoofStyle
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{POOF_FRAMES.map((frame, index) => (
|
||||||
|
<img
|
||||||
|
alt=""
|
||||||
|
className={`buzz-poof-frame buzz-poof-frame-${index + 1}`}
|
||||||
|
decoding="async"
|
||||||
|
draggable={false}
|
||||||
|
key={`${burst.id}-${frame.id}`}
|
||||||
|
src={frame.src}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
337
desktop/src/shared/ui/sidebar-action-card.tsx
Normal file
337
desktop/src/shared/ui/sidebar-action-card.tsx
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import {
|
||||||
|
AnimatePresence,
|
||||||
|
motion,
|
||||||
|
type Transition,
|
||||||
|
useReducedMotion,
|
||||||
|
} from "motion/react";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/cn";
|
||||||
|
import {
|
||||||
|
POOF_DURATION_MS,
|
||||||
|
POOF_ORIGIN_CLASS,
|
||||||
|
POOF_TRIGGER_CLASS,
|
||||||
|
} from "@/shared/ui/PoofBurstProvider";
|
||||||
|
|
||||||
|
type SidebarActionCardTone = "neutral" | "success";
|
||||||
|
export type SidebarActionCardSurface = "background" | "secondary";
|
||||||
|
|
||||||
|
type SidebarCompactActionCardProps = {
|
||||||
|
actionAriaLabel: string;
|
||||||
|
actionDisabled?: boolean;
|
||||||
|
actionTestId?: string;
|
||||||
|
className?: string;
|
||||||
|
description?: string;
|
||||||
|
dismissClassName?: string;
|
||||||
|
dismissLabel?: string;
|
||||||
|
iconKey?: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
onAction: () => void;
|
||||||
|
onDismiss?: () => void;
|
||||||
|
role?: "alert" | "status";
|
||||||
|
surface?: SidebarActionCardSurface;
|
||||||
|
testId: string;
|
||||||
|
title: string;
|
||||||
|
tone?: SidebarActionCardTone;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SidebarActionDismissButtonProps = {
|
||||||
|
className?: string;
|
||||||
|
isDismissing: boolean;
|
||||||
|
label: string;
|
||||||
|
onDismiss: () => void;
|
||||||
|
onDismissStart: () => void;
|
||||||
|
testId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SidebarActionDescriptionTransition = {
|
||||||
|
current: string;
|
||||||
|
isAnimating: boolean;
|
||||||
|
previous: string;
|
||||||
|
version: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SIDEBAR_ACTION_DESCRIPTION_SETTLE_DELAY_MS = 260;
|
||||||
|
|
||||||
|
function SidebarActionDescriptionText({
|
||||||
|
shouldReduceMotion,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
shouldReduceMotion: boolean;
|
||||||
|
value: string;
|
||||||
|
}) {
|
||||||
|
const [transition, setTransition] =
|
||||||
|
React.useState<SidebarActionDescriptionTransition>(() => ({
|
||||||
|
current: value,
|
||||||
|
isAnimating: false,
|
||||||
|
previous: value,
|
||||||
|
version: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
setTransition((currentTransition) => {
|
||||||
|
if (currentTransition.current === value) {
|
||||||
|
return currentTransition;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
current: value,
|
||||||
|
isAnimating: !shouldReduceMotion,
|
||||||
|
previous: currentTransition.current,
|
||||||
|
version: currentTransition.version + 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [shouldReduceMotion, value]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!transition.isAnimating) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
setTransition((currentTransition) => {
|
||||||
|
if (currentTransition.version !== transition.version) {
|
||||||
|
return currentTransition;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...currentTransition,
|
||||||
|
isAnimating: false,
|
||||||
|
previous: currentTransition.current,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, SIDEBAR_ACTION_DESCRIPTION_SETTLE_DELAY_MS);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
|
}, [transition.isAnimating, transition.version]);
|
||||||
|
|
||||||
|
if (shouldReduceMotion || !transition.isAnimating) {
|
||||||
|
return (
|
||||||
|
<span className="buzz-sidebar-action-description">
|
||||||
|
{transition.current}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="buzz-sidebar-action-description">
|
||||||
|
<span className="sr-only">{transition.current}</span>
|
||||||
|
<span aria-hidden className="buzz-sidebar-action-description__motion">
|
||||||
|
<span
|
||||||
|
className="buzz-sidebar-action-description__reel"
|
||||||
|
key={`${transition.version}-${transition.previous}-${transition.current}`}
|
||||||
|
>
|
||||||
|
<span>{transition.previous}</span>
|
||||||
|
<span>{transition.current}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarActionDismissButton({
|
||||||
|
className,
|
||||||
|
isDismissing,
|
||||||
|
label,
|
||||||
|
onDismiss,
|
||||||
|
onDismissStart,
|
||||||
|
testId,
|
||||||
|
}: SidebarActionDismissButtonProps) {
|
||||||
|
const dismissTimeoutRef = React.useRef<number | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (dismissTimeoutRef.current !== null) {
|
||||||
|
window.clearTimeout(dismissTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
aria-label={label}
|
||||||
|
className={cn(
|
||||||
|
"group/dismiss pointer-events-none absolute -right-1 -top-2 z-10 h-6 w-6 rounded-full text-muted-foreground/45 transition-colors duration-150 ease-out hover:text-foreground/80 focus-visible:pointer-events-auto focus-visible:text-foreground/80 focus-visible:outline-hidden group-hover/sidebar-action-card:pointer-events-auto group-hover/sidebar-compact-action-card:pointer-events-auto",
|
||||||
|
POOF_TRIGGER_CLASS,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
data-testid={testId}
|
||||||
|
disabled={isDismissing}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (dismissTimeoutRef.current !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onDismissStart();
|
||||||
|
dismissTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
dismissTimeoutRef.current = null;
|
||||||
|
onDismiss();
|
||||||
|
}, POOF_DURATION_MS);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="flex h-full w-full scale-95 items-center justify-center rounded-full bg-background opacity-0 shadow-sm ring-1 ring-border/70 transition-[opacity,transform] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:scale-100 group-focus-visible/dismiss:scale-100 group-focus-visible/dismiss:opacity-100 group-focus-visible/dismiss:ring-2 group-focus-visible/dismiss:ring-muted-foreground/40 group-hover/sidebar-action-card:scale-100 group-hover/sidebar-action-card:opacity-100 group-hover/sidebar-compact-action-card:scale-100 group-hover/sidebar-compact-action-card:opacity-100">
|
||||||
|
<X aria-hidden="true" className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissTestId(testId: string) {
|
||||||
|
return testId === "sidebar-update-card"
|
||||||
|
? "sidebar-update-dismiss"
|
||||||
|
: `${testId}-dismiss`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarCompactActionCard({
|
||||||
|
actionAriaLabel,
|
||||||
|
actionDisabled = false,
|
||||||
|
actionTestId,
|
||||||
|
className,
|
||||||
|
description,
|
||||||
|
dismissClassName,
|
||||||
|
dismissLabel = "Dismiss notification",
|
||||||
|
icon,
|
||||||
|
iconKey,
|
||||||
|
onAction,
|
||||||
|
onDismiss,
|
||||||
|
role,
|
||||||
|
surface = "background",
|
||||||
|
testId,
|
||||||
|
title,
|
||||||
|
tone = "neutral",
|
||||||
|
}: SidebarCompactActionCardProps) {
|
||||||
|
const shouldReduceMotion = useReducedMotion();
|
||||||
|
const isSuccess = tone === "success";
|
||||||
|
const [isDismissing, setIsDismissing] = React.useState(false);
|
||||||
|
const resolvedIconKey = iconKey ?? title;
|
||||||
|
const cardTransition: Transition = shouldReduceMotion
|
||||||
|
? { duration: 0.08 }
|
||||||
|
: {
|
||||||
|
duration: 0.28,
|
||||||
|
ease: [0.22, 1, 0.36, 1] as const,
|
||||||
|
};
|
||||||
|
const cardHiddenState = shouldReduceMotion
|
||||||
|
? { opacity: 0 }
|
||||||
|
: { opacity: 0, scale: 0.9 };
|
||||||
|
const cardVisibleState = shouldReduceMotion
|
||||||
|
? { opacity: 1 }
|
||||||
|
: { opacity: 1, scale: 1 };
|
||||||
|
const contentTransition: Transition = shouldReduceMotion
|
||||||
|
? { duration: 0 }
|
||||||
|
: {
|
||||||
|
duration: 0.16,
|
||||||
|
ease: [0.22, 1, 0.36, 1] as const,
|
||||||
|
};
|
||||||
|
const contentInitial = shouldReduceMotion
|
||||||
|
? { opacity: 0 }
|
||||||
|
: { opacity: 0, y: 3 };
|
||||||
|
const contentExit = shouldReduceMotion
|
||||||
|
? { opacity: 0 }
|
||||||
|
: { opacity: 0, y: -3 };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
animate={isDismissing ? cardHiddenState : cardVisibleState}
|
||||||
|
className={cn(
|
||||||
|
"group/sidebar-compact-action-card relative w-full origin-bottom",
|
||||||
|
POOF_ORIGIN_CLASS,
|
||||||
|
isDismissing && "pointer-events-none",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
data-dismissing={isDismissing ? "true" : undefined}
|
||||||
|
data-testid={testId}
|
||||||
|
exit={cardHiddenState}
|
||||||
|
initial={cardHiddenState}
|
||||||
|
role={role}
|
||||||
|
transition={cardTransition}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-label={actionAriaLabel}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-3 rounded-xl border px-3 py-3 text-left shadow-xs transition-[background-color,border-color,color,box-shadow] duration-150 ease-out focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-muted-foreground/40 disabled:cursor-default disabled:opacity-100",
|
||||||
|
isSuccess
|
||||||
|
? "buzz-sidebar-action-card--success disabled:cursor-default disabled:opacity-100"
|
||||||
|
: surface === "secondary"
|
||||||
|
? "border-border/70 bg-secondary/80 text-secondary-foreground hover:border-border hover:bg-secondary dark:bg-secondary/60 dark:hover:bg-secondary/70"
|
||||||
|
: "border-border/70 bg-background/70 text-foreground hover:border-border hover:bg-muted/40 dark:bg-background/50 dark:hover:bg-muted/30",
|
||||||
|
)}
|
||||||
|
data-testid={actionTestId}
|
||||||
|
disabled={actionDisabled}
|
||||||
|
onClick={onAction}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<motion.span
|
||||||
|
className="relative top-[0.1875rem] flex min-h-10 min-w-0 flex-1 flex-col justify-center"
|
||||||
|
layout="position"
|
||||||
|
transition={contentTransition}
|
||||||
|
>
|
||||||
|
<AnimatePresence initial={false} mode="wait">
|
||||||
|
<motion.span
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="block text-sm font-semibold leading-tight"
|
||||||
|
exit={contentExit}
|
||||||
|
initial={contentInitial}
|
||||||
|
key={title}
|
||||||
|
transition={contentTransition}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</motion.span>
|
||||||
|
</AnimatePresence>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{description ? (
|
||||||
|
<motion.span
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="mt-1 block text-xs leading-snug text-muted-foreground"
|
||||||
|
exit={contentExit}
|
||||||
|
initial={contentInitial}
|
||||||
|
key="description"
|
||||||
|
transition={contentTransition}
|
||||||
|
>
|
||||||
|
<SidebarActionDescriptionText
|
||||||
|
shouldReduceMotion={Boolean(shouldReduceMotion)}
|
||||||
|
value={description}
|
||||||
|
/>
|
||||||
|
</motion.span>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.span>
|
||||||
|
<motion.span
|
||||||
|
className={cn(
|
||||||
|
"ml-auto flex h-10 w-10 shrink-0 items-center justify-center transition-colors duration-150 ease-out",
|
||||||
|
isSuccess
|
||||||
|
? "buzz-sidebar-action-card__success-icon"
|
||||||
|
: "text-muted-foreground group-hover/sidebar-compact-action-card:text-foreground",
|
||||||
|
)}
|
||||||
|
layout="position"
|
||||||
|
transition={contentTransition}
|
||||||
|
>
|
||||||
|
<AnimatePresence initial={false} mode="wait">
|
||||||
|
<motion.span
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="flex h-5 w-5 items-center justify-center"
|
||||||
|
exit={{ opacity: 0, scale: shouldReduceMotion ? 1 : 0.95 }}
|
||||||
|
initial={{ opacity: 0, scale: shouldReduceMotion ? 1 : 0.95 }}
|
||||||
|
key={resolvedIconKey}
|
||||||
|
transition={contentTransition}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</motion.span>
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.span>
|
||||||
|
</button>
|
||||||
|
{onDismiss ? (
|
||||||
|
<SidebarActionDismissButton
|
||||||
|
className={dismissClassName}
|
||||||
|
isDismissing={isDismissing}
|
||||||
|
label={dismissLabel}
|
||||||
|
onDismiss={onDismiss}
|
||||||
|
onDismissStart={() => setIsDismissing(true)}
|
||||||
|
testId={dismissTestId(testId)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -75,8 +75,13 @@ type E2eConfig = {
|
|||||||
profileReadDelayMs?: number;
|
profileReadDelayMs?: number;
|
||||||
profileReadError?: string;
|
profileReadError?: string;
|
||||||
profileUpdateError?: string;
|
profileUpdateError?: string;
|
||||||
|
profileUpdateErrors?: string[];
|
||||||
searchProfiles?: MockSearchProfileSeed[];
|
searchProfiles?: MockSearchProfileSeed[];
|
||||||
|
updateAvailable?: boolean;
|
||||||
updateChannelDelayMs?: number;
|
updateChannelDelayMs?: number;
|
||||||
|
updateDownloadDelayMs?: number;
|
||||||
|
restartDelayMs?: number;
|
||||||
|
updateVersion?: string;
|
||||||
stallWebsocketSends?: boolean;
|
stallWebsocketSends?: boolean;
|
||||||
userSearchDelayMs?: number;
|
userSearchDelayMs?: number;
|
||||||
// NIP-IA gate inputs — see tests/helpers/bridge.ts:MockBridgeOptions for
|
// NIP-IA gate inputs — see tests/helpers/bridge.ts:MockBridgeOptions for
|
||||||
@@ -2938,6 +2943,12 @@ async function handleUpdateProfile(
|
|||||||
const identity = getIdentity(config);
|
const identity = getIdentity(config);
|
||||||
if (!identity) {
|
if (!identity) {
|
||||||
const profileUpdateError = config?.mock?.profileUpdateError;
|
const profileUpdateError = config?.mock?.profileUpdateError;
|
||||||
|
const profileUpdateErrors = config?.mock?.profileUpdateErrors;
|
||||||
|
const nextProfileUpdateError = profileUpdateErrors?.shift();
|
||||||
|
if (nextProfileUpdateError) {
|
||||||
|
throw new Error(nextProfileUpdateError);
|
||||||
|
}
|
||||||
|
|
||||||
if (profileUpdateError) {
|
if (profileUpdateError) {
|
||||||
if (config?.mock) {
|
if (config?.mock) {
|
||||||
config.mock.profileUpdateError = undefined;
|
config.mock.profileUpdateError = undefined;
|
||||||
@@ -3677,6 +3688,56 @@ async function handleSetChannelPurpose(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MockUpdaterChannel = {
|
||||||
|
onmessage?: (event: { event: "Finished" }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function notifyUpdaterFinished(payload: unknown) {
|
||||||
|
const channel = (payload as { onEvent?: MockUpdaterChannel } | null)?.onEvent;
|
||||||
|
channel?.onmessage?.({ event: "Finished" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpdaterCheck(config: E2eConfig | undefined) {
|
||||||
|
if (!config?.mock?.updateAvailable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = config.mock.updateVersion ?? "0.3.18";
|
||||||
|
|
||||||
|
return {
|
||||||
|
rid: 42,
|
||||||
|
currentVersion: "0.3.17",
|
||||||
|
version,
|
||||||
|
date: "2026-06-12T00:00:00Z",
|
||||||
|
body: `Mock update ${version}`,
|
||||||
|
rawJson: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdaterDownloadAndInstall(
|
||||||
|
payload: unknown,
|
||||||
|
config: E2eConfig | undefined,
|
||||||
|
) {
|
||||||
|
const delayMs = config?.mock?.updateDownloadDelayMs ?? 0;
|
||||||
|
|
||||||
|
if (delayMs > 0) {
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyUpdaterFinished(payload);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRestart(config: E2eConfig | undefined) {
|
||||||
|
const delayMs = config?.mock?.restartDelayMs ?? 0;
|
||||||
|
|
||||||
|
if (delayMs > 0) {
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
async function handleArchiveChannel(
|
async function handleArchiveChannel(
|
||||||
args: { channelId: string },
|
args: { channelId: string },
|
||||||
config: E2eConfig | undefined,
|
config: E2eConfig | undefined,
|
||||||
@@ -5977,7 +6038,7 @@ export function maybeInstallE2eTauriMocks() {
|
|||||||
};
|
};
|
||||||
window.__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__ = (state) => {
|
window.__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__ = (state) => {
|
||||||
// Directly emit a connection state change on the relay client singleton,
|
// Directly emit a connection state change on the relay client singleton,
|
||||||
// for tests that need to drive ConnectionBanner without waiting for the
|
// for tests that need to drive degraded relay UI without waiting for the
|
||||||
// real auth-timeout + reconnect-debounce cycle (~10 s). Reaches the
|
// real auth-timeout + reconnect-debounce cycle (~10 s). Reaches the
|
||||||
// TS-private emitter via a cast so the production class carries no
|
// TS-private emitter via a cast so the production class carries no
|
||||||
// test-only seam.
|
// test-only seam.
|
||||||
@@ -6603,6 +6664,16 @@ export function maybeInstallE2eTauriMocks() {
|
|||||||
case "plugin:window|set_badge_count":
|
case "plugin:window|set_badge_count":
|
||||||
case "plugin:window|set_badge_label":
|
case "plugin:window|set_badge_label":
|
||||||
return null;
|
return null;
|
||||||
|
case "plugin:updater|check":
|
||||||
|
return handleUpdaterCheck(activeConfig);
|
||||||
|
case "plugin:updater|download_and_install":
|
||||||
|
return handleUpdaterDownloadAndInstall(payload, activeConfig);
|
||||||
|
case "relay_reconnect_hook":
|
||||||
|
return null;
|
||||||
|
case "plugin:resources|close":
|
||||||
|
return null;
|
||||||
|
case "plugin:process|restart":
|
||||||
|
return handleRestart(activeConfig);
|
||||||
case "get_channel_workflows":
|
case "get_channel_workflows":
|
||||||
return handleGetChannelWorkflows(
|
return handleGetChannelWorkflows(
|
||||||
payload as Parameters<typeof handleGetChannelWorkflows>[0],
|
payload as Parameters<typeof handleGetChannelWorkflows>[0],
|
||||||
|
|||||||
@@ -997,6 +997,7 @@ test("mention text is highlighted in sent messages", async ({ page }) => {
|
|||||||
const input = page.getByTestId("message-input");
|
const input = page.getByTestId("message-input");
|
||||||
await input.fill("Hey @bo");
|
await input.fill("Hey @bo");
|
||||||
await autocomplete(page).getByText("bob").click();
|
await autocomplete(page).getByText("bob").click();
|
||||||
|
await expect(input).toHaveText("Hey @bob ");
|
||||||
await page.keyboard.type(suffix);
|
await page.keyboard.type(suffix);
|
||||||
await page.getByTestId("send-message").click();
|
await page.getByTestId("send-message").click();
|
||||||
|
|
||||||
|
|||||||
@@ -1141,6 +1141,112 @@ test("failed first profile saves can be skipped for the current session", async
|
|||||||
await expectHomeView(page);
|
await expectHomeView(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("generic relay save failures use the generic reconnect card", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
|
||||||
|
await installMockBridge(
|
||||||
|
page,
|
||||||
|
{
|
||||||
|
profileUpdateError: "relay unreachable: could not connect to relay",
|
||||||
|
},
|
||||||
|
{ skipOnboardingSeed: true },
|
||||||
|
);
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
await page.getByTestId("onboarding-display-name").fill("Morty QA");
|
||||||
|
await page.getByTestId("onboarding-next").click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("onboarding-relay-reconnect-card"),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("onboarding-relay-reconnect-card"),
|
||||||
|
).toContainText("Can't reach the relay");
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("onboarding-relay-reconnect-card"),
|
||||||
|
).toContainText("Click to connect");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("custom relay proxy sign-in failures use the generic reconnect card", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
|
||||||
|
await installMockBridge(
|
||||||
|
page,
|
||||||
|
{
|
||||||
|
profileUpdateError:
|
||||||
|
"relay unreachable: relay returned an unexpected HTML page (network sign-in?)",
|
||||||
|
},
|
||||||
|
{ skipOnboardingSeed: true },
|
||||||
|
);
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
await page.getByTestId("onboarding-display-name").fill("Morty QA");
|
||||||
|
await page.getByTestId("onboarding-next").click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("onboarding-relay-reconnect-card"),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("onboarding-relay-reconnect-card"),
|
||||||
|
).toContainText("Can't reach the relay");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("relay access failures use the generic reconnect card", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
|
||||||
|
await installMockBridge(
|
||||||
|
page,
|
||||||
|
{
|
||||||
|
profileUpdateError: "relay unreachable: 403 Forbidden",
|
||||||
|
},
|
||||||
|
{ skipOnboardingSeed: true },
|
||||||
|
);
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
await page.getByTestId("onboarding-display-name").fill("Morty QA");
|
||||||
|
await page.getByTestId("onboarding-next").click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("onboarding-relay-reconnect-card"),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("onboarding-relay-reconnect-card"),
|
||||||
|
).toContainText("Can't reach the relay");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dismissed relay save failures reappear on retry", async ({ page }) => {
|
||||||
|
const relayError = "relay unreachable: could not connect to relay";
|
||||||
|
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
|
||||||
|
await installMockBridge(
|
||||||
|
page,
|
||||||
|
{
|
||||||
|
profileUpdateErrors: [relayError, relayError],
|
||||||
|
},
|
||||||
|
{ skipOnboardingSeed: true },
|
||||||
|
);
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
await page.getByTestId("onboarding-display-name").fill("Morty QA");
|
||||||
|
await page.getByTestId("onboarding-next").click();
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("onboarding-relay-reconnect-card"),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId("onboarding-relay-reconnect-card").hover();
|
||||||
|
await page.getByTestId("onboarding-relay-reconnect-card-dismiss").click();
|
||||||
|
await expect(page.getByTestId("onboarding-relay-reconnect-card")).toHaveCount(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.getByTestId("onboarding-next").click();
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("onboarding-relay-reconnect-card"),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test("existing relay profile with display name auto-completes onboarding", async ({
|
test("existing relay profile with display name auto-completes onboarding", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -49,22 +49,26 @@ async function driveConnectionDegraded(
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe("relay connectivity screenshots", () => {
|
test.describe("relay connectivity screenshots", () => {
|
||||||
test("01 — sidebar unreachable banner", async ({ page }) => {
|
test("01 — sidebar unreachable card", async ({ page }) => {
|
||||||
await installMockBridge(page, { channelsReadError: RELAY_UNREACHABLE });
|
await installMockBridge(page, { channelsReadError: RELAY_UNREACHABLE });
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
||||||
await expect(page.getByTestId("sidebar-relay-unreachable")).toBeVisible();
|
const relayCard = page.getByTestId("sidebar-relay-unreachable");
|
||||||
|
await expect(relayCard).toBeVisible();
|
||||||
|
await expect(relayCard).toContainText("Can't reach the relay");
|
||||||
|
await expect(relayCard).toContainText("Click to connect");
|
||||||
await expect(page.getByTestId("sidebar-reconnect")).toBeVisible();
|
await expect(page.getByTestId("sidebar-reconnect")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("connection-banner")).toHaveCount(0);
|
||||||
await settle(page);
|
await settle(page);
|
||||||
|
|
||||||
// Clip to sidebar width (256px) so the banner and channel list are both visible.
|
// Clip to sidebar width (256px) so the card and channel list are both visible.
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: `${SHOTS}/01-sidebar-unreachable.png`,
|
path: `${SHOTS}/01-sidebar-unreachable.png`,
|
||||||
clip: { x: 0, y: 0, width: 256, height: 720 },
|
clip: { x: 0, y: 0, width: 256, height: 720 },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("02 — connection banner while reconnecting", async ({ page }) => {
|
test("02 — sidebar reconnect card while reconnecting", async ({ page }) => {
|
||||||
await installMockBridge(page);
|
await installMockBridge(page);
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
||||||
@@ -72,40 +76,24 @@ test.describe("relay connectivity screenshots", () => {
|
|||||||
await expect(page.getByTestId("channel-general")).toBeVisible();
|
await expect(page.getByTestId("channel-general")).toBeVisible();
|
||||||
await driveConnectionDegraded(page);
|
await driveConnectionDegraded(page);
|
||||||
|
|
||||||
// ConnectionBanner debounces non-healthy states by 2 s before rendering.
|
// useRelayConnection debounces non-healthy states by 2 s before surfacing.
|
||||||
await expect(page.getByTestId("connection-banner")).toBeVisible({
|
const relayCard = page.getByTestId("sidebar-relay-unreachable");
|
||||||
|
await expect(relayCard).toBeVisible({
|
||||||
timeout: 5_000,
|
timeout: 5_000,
|
||||||
});
|
});
|
||||||
await expect(page.getByTestId("connection-banner-reconnect")).toBeVisible();
|
await expect(relayCard).toContainText("Can't reach the relay");
|
||||||
|
await expect(relayCard).toContainText("Click to connect");
|
||||||
|
await expect(page.getByTestId("sidebar-reconnect")).toBeVisible();
|
||||||
await settle(page);
|
await settle(page);
|
||||||
|
|
||||||
// Capture a horizontal strip spanning the full width that shows the banner
|
// Clip to the sidebar, where degraded relay state is now surfaced.
|
||||||
// above the content pane.
|
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: `${SHOTS}/02-connection-banner.png`,
|
path: `${SHOTS}/02-sidebar-reconnecting.png`,
|
||||||
clip: { x: 0, y: 0, width: 1280, height: 180 },
|
clip: { x: 0, y: 0, width: 256, height: 720 },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("03 — home feed unreachable", async ({ page }) => {
|
test("03 — canvas unreachable in management sheet", async ({ page }) => {
|
||||||
await installMockBridge(page, { feedReadError: RELAY_UNREACHABLE });
|
|
||||||
await page.goto("/");
|
|
||||||
|
|
||||||
// HomeView renders the error card when the feed query fails.
|
|
||||||
await expect(
|
|
||||||
page.getByText(
|
|
||||||
"Can't reach the relay — check your VPN or network connection.",
|
|
||||||
),
|
|
||||||
).toBeVisible();
|
|
||||||
await settle(page);
|
|
||||||
|
|
||||||
await page.screenshot({
|
|
||||||
path: `${SHOTS}/03-home-unreachable.png`,
|
|
||||||
clip: { x: 0, y: 0, width: 1280, height: 500 },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("04 — canvas unreachable in management sheet", async ({ page }) => {
|
|
||||||
await installMockBridge(page, { canvasReadError: RELAY_UNREACHABLE });
|
await installMockBridge(page, { canvasReadError: RELAY_UNREACHABLE });
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
||||||
@@ -135,11 +123,11 @@ test.describe("relay connectivity screenshots", () => {
|
|||||||
|
|
||||||
// Capture the whole sheet so the error renders in its Canvas-section context.
|
// Capture the whole sheet so the error renders in its Canvas-section context.
|
||||||
await sheet.screenshot({
|
await sheet.screenshot({
|
||||||
path: `${SHOTS}/04-canvas-unreachable.png`,
|
path: `${SHOTS}/03-canvas-unreachable.png`,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("05 — cached identity shown offline (avatar + display name)", async ({
|
test("04 — cached identity shown offline (avatar + display name)", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
// Seed the self-profile cache BEFORE installMockBridge so addInitScript
|
// Seed the self-profile cache BEFORE installMockBridge so addInitScript
|
||||||
@@ -168,11 +156,11 @@ test.describe("relay connectivity screenshots", () => {
|
|||||||
await settle(page);
|
await settle(page);
|
||||||
|
|
||||||
await profileCard.screenshot({
|
await profileCard.screenshot({
|
||||||
path: `${SHOTS}/05-cached-identity-offline.png`,
|
path: `${SHOTS}/04-cached-identity-offline.png`,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("06 — no-cache npub fallback when offline", async ({ page }) => {
|
test("05 — no-cache npub fallback when offline", async ({ page }) => {
|
||||||
// No cache seeded — profile card falls back to the mock identity npub name.
|
// No cache seeded — profile card falls back to the mock identity npub name.
|
||||||
await installMockBridge(page, { profileReadError: RELAY_UNREACHABLE });
|
await installMockBridge(page, { profileReadError: RELAY_UNREACHABLE });
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
@@ -183,11 +171,11 @@ test.describe("relay connectivity screenshots", () => {
|
|||||||
await settle(page);
|
await settle(page);
|
||||||
|
|
||||||
await profileCard.screenshot({
|
await profileCard.screenshot({
|
||||||
path: `${SHOTS}/06-no-cache-npub-fallback.png`,
|
path: `${SHOTS}/05-no-cache-npub-fallback.png`,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("07 — profile popover reconnect button while degraded", async ({
|
test("06 — sidebar card shows connected after external relay recovery", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await installMockBridge(page);
|
await installMockBridge(page);
|
||||||
@@ -196,31 +184,21 @@ test.describe("relay connectivity screenshots", () => {
|
|||||||
await expect(page.getByTestId("channel-general")).toBeVisible();
|
await expect(page.getByTestId("channel-general")).toBeVisible();
|
||||||
await driveConnectionDegraded(page);
|
await driveConnectionDegraded(page);
|
||||||
|
|
||||||
// 2 s debounce on the "reconnecting" state before ConnectionBanner shows.
|
const relayCard = page.getByTestId("sidebar-relay-unreachable");
|
||||||
await expect(page.getByTestId("connection-banner")).toBeVisible({
|
await expect(relayCard).toBeVisible({
|
||||||
timeout: 5_000,
|
timeout: 5_000,
|
||||||
});
|
});
|
||||||
|
await expect(relayCard).toContainText("Can't reach the relay");
|
||||||
|
await expect(relayCard).toContainText("Click to connect");
|
||||||
|
|
||||||
await page.getByTestId("sidebar-profile-avatar-button").click();
|
await driveConnectionDegraded(page, "connected");
|
||||||
const reconnectBtn = page.getByTestId("profile-popover-reconnect");
|
|
||||||
await expect(reconnectBtn).toBeVisible();
|
|
||||||
|
|
||||||
// Wait for the Radix popover open animation on the [data-state] ancestor —
|
await expect(relayCard).toContainText("Connected");
|
||||||
// the popper wrapper itself carries no animations, so querying it returns
|
await expect(relayCard).not.toContainText("Click to connect");
|
||||||
// an empty list and screenshots capture a half-faded popover.
|
await page.waitForTimeout(3_000);
|
||||||
await reconnectBtn.evaluate((el) =>
|
await expect(relayCard).toContainText("Connected");
|
||||||
Promise.all(
|
await expect(relayCard).toBeHidden({
|
||||||
el
|
timeout: 5_000,
|
||||||
.closest("[data-state]")
|
|
||||||
?.getAnimations()
|
|
||||||
.map((a) => a.finished) ?? [],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Clip to the sidebar-bottom region that includes the open popover.
|
|
||||||
await page.screenshot({
|
|
||||||
path: `${SHOTS}/07-profile-popover-reconnect.png`,
|
|
||||||
clip: { x: 0, y: 300, width: 480, height: 420 },
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ async function settle(page: import("@playwright/test").Page) {
|
|||||||
/** Drive the relay client into a state via the real E2E connection-state seam. */
|
/** Drive the relay client into a state via the real E2E connection-state seam. */
|
||||||
async function driveConnectionState(
|
async function driveConnectionState(
|
||||||
page: import("@playwright/test").Page,
|
page: import("@playwright/test").Page,
|
||||||
state: "connected" | "disconnected",
|
state: "connected" | "reconnecting" | "stalled" | "disconnected",
|
||||||
) {
|
) {
|
||||||
await page.evaluate((s) => {
|
await page.evaluate((s) => {
|
||||||
const setter = (
|
const setter = (
|
||||||
@@ -49,60 +49,8 @@ async function scrollSidebarToBottom(page: import("@playwright/test").Page) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openProfilePopover(page: import("@playwright/test").Page) {
|
|
||||||
await page.getByTestId("sidebar-profile-avatar-button").click();
|
|
||||||
// Anchor on a stable popover child so the screenshot captures the open menu.
|
|
||||||
await expect(page.getByTestId("profile-popover-settings")).toBeVisible();
|
|
||||||
// Await the Radix open animation on the [data-state] ancestor — the popper
|
|
||||||
// wrapper carries no animations, so screenshots would otherwise capture a
|
|
||||||
// half-faded popover.
|
|
||||||
await page.getByTestId("profile-popover-settings").evaluate((el) =>
|
|
||||||
Promise.all(
|
|
||||||
el
|
|
||||||
.closest("[data-state]")
|
|
||||||
?.getAnimations()
|
|
||||||
.map((a) => a.finished) ?? [],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("relay reconnect affordance screenshots", () => {
|
test.describe("relay reconnect affordance screenshots", () => {
|
||||||
test("01 — profile popover reconnect item hidden when healthy", async ({
|
test("01 — sidebar has no reconnect prompt when healthy", async ({
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await installMockBridge(page);
|
|
||||||
await page.goto("/");
|
|
||||||
|
|
||||||
await expect(page.getByTestId("channel-general")).toBeVisible();
|
|
||||||
await openProfilePopover(page);
|
|
||||||
await expect(page.getByTestId("profile-popover-reconnect")).toHaveCount(0);
|
|
||||||
await settle(page);
|
|
||||||
|
|
||||||
await page.screenshot({
|
|
||||||
path: `${SHOTS}/01-profile-popover-healthy.png`,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("02 — profile popover reconnect item shown when degraded", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await installMockBridge(page);
|
|
||||||
await page.goto("/");
|
|
||||||
|
|
||||||
await expect(page.getByTestId("channel-general")).toBeVisible();
|
|
||||||
await driveConnectionState(page, "disconnected");
|
|
||||||
await openProfilePopover(page);
|
|
||||||
await expect(page.getByTestId("profile-popover-reconnect")).toBeVisible({
|
|
||||||
timeout: 5_000,
|
|
||||||
});
|
|
||||||
await settle(page);
|
|
||||||
|
|
||||||
await page.screenshot({
|
|
||||||
path: `${SHOTS}/02-profile-popover-degraded.png`,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("03 — sidebar has no reconnect prompt when healthy", async ({
|
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await installMockBridge(page);
|
await installMockBridge(page);
|
||||||
@@ -111,8 +59,8 @@ test.describe("relay reconnect affordance screenshots", () => {
|
|||||||
await expect(page.getByTestId("channel-general")).toBeVisible();
|
await expect(page.getByTestId("channel-general")).toBeVisible();
|
||||||
await expect(page.getByTestId("sidebar-relay-unreachable")).toHaveCount(0);
|
await expect(page.getByTestId("sidebar-relay-unreachable")).toHaveCount(0);
|
||||||
// The relay block renders at the BOTTOM of the scrollable sidebar content,
|
// The relay block renders at the BOTTOM of the scrollable sidebar content,
|
||||||
// below the fold at this viewport. Scroll to the bottom so 03 frames the
|
// below the fold at this viewport. Scroll to the bottom so 01 frames the
|
||||||
// same region where 04 will show the block — making the absence legible.
|
// same region where 02 will show the block — making the absence legible.
|
||||||
await scrollSidebarToBottom(page);
|
await scrollSidebarToBottom(page);
|
||||||
await settle(page);
|
await settle(page);
|
||||||
|
|
||||||
@@ -120,30 +68,30 @@ test.describe("relay reconnect affordance screenshots", () => {
|
|||||||
// and a full-window shot makes its presence/absence illegible against the
|
// and a full-window shot makes its presence/absence illegible against the
|
||||||
// unrelated top connection banner.
|
// unrelated top connection banner.
|
||||||
await page.getByTestId("app-sidebar").screenshot({
|
await page.getByTestId("app-sidebar").screenshot({
|
||||||
path: `${SHOTS}/03-sidebar-healthy.png`,
|
path: `${SHOTS}/01-sidebar-healthy.png`,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("04 — sidebar reconnect prompt shown when degraded, channels visible", async ({
|
test("02 — sidebar reconnect prompt shown when degraded, channels visible", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await installMockBridge(page);
|
await installMockBridge(page);
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
||||||
await expect(page.getByTestId("channel-general")).toBeVisible();
|
await expect(page.getByTestId("channel-general")).toBeVisible();
|
||||||
await driveConnectionState(page, "disconnected");
|
await driveConnectionState(page, "stalled");
|
||||||
await expect(page.getByTestId("sidebar-relay-unreachable")).toBeVisible({
|
await expect(page.getByTestId("sidebar-relay-unreachable")).toBeVisible({
|
||||||
timeout: 5_000,
|
timeout: 10_000,
|
||||||
});
|
});
|
||||||
await expect(page.getByTestId("sidebar-reconnect")).toBeVisible();
|
await expect(page.getByTestId("sidebar-reconnect")).toBeVisible();
|
||||||
// The cached channel list stays visible alongside the prompt.
|
// The cached channel list stays visible alongside the prompt.
|
||||||
await expect(page.getByTestId("channel-general")).toBeVisible();
|
await expect(page.getByTestId("channel-general")).toBeVisible();
|
||||||
// Scroll the block clear of the occluding footer (symmetric with 03).
|
// Scroll the block clear of the occluding footer (symmetric with 01).
|
||||||
await scrollSidebarToBottom(page);
|
await scrollSidebarToBottom(page);
|
||||||
await settle(page);
|
await settle(page);
|
||||||
|
|
||||||
await page.getByTestId("app-sidebar").screenshot({
|
await page.getByTestId("app-sidebar").screenshot({
|
||||||
path: `${SHOTS}/04-sidebar-degraded.png`,
|
path: `${SHOTS}/02-sidebar-degraded.png`,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -79,12 +79,11 @@ test("sidebar reconnect prompt flips on live relay degradation without a query e
|
|||||||
// Drive ONLY the live connection state degraded — no channelsQuery error is
|
// Drive ONLY the live connection state degraded — no channelsQuery error is
|
||||||
// set. Pre-fix the block keyed off `channelsQuery.error` alone, so it stays
|
// set. Pre-fix the block keyed off `channelsQuery.error` alone, so it stays
|
||||||
// absent here; post-fix the dual signal surfaces it.
|
// absent here; post-fix the dual signal surfaces it.
|
||||||
await driveConnectionDegraded(page, "disconnected");
|
await driveConnectionDegraded(page, "stalled");
|
||||||
|
|
||||||
// `disconnected` reports immediately (reconnecting/stalled debounce ~2s),
|
// `stalled` is debounced before surfacing, then React needs a render tick.
|
||||||
// but allow margin for the React render to flush.
|
|
||||||
await expect(page.getByTestId("sidebar-relay-unreachable")).toBeVisible({
|
await expect(page.getByTestId("sidebar-relay-unreachable")).toBeVisible({
|
||||||
timeout: 5_000,
|
timeout: 10_000,
|
||||||
});
|
});
|
||||||
await expect(page.getByTestId("sidebar-reconnect")).toBeVisible();
|
await expect(page.getByTestId("sidebar-reconnect")).toBeVisible();
|
||||||
|
|
||||||
@@ -93,25 +92,21 @@ test("sidebar reconnect prompt flips on live relay degradation without a query e
|
|||||||
await expect(page.getByTestId("channel-general")).toBeVisible();
|
await expect(page.getByTestId("channel-general")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("profile popover reconnect item is hidden when healthy and shown when degraded", async ({
|
test("profile popover does not show relay reconnect controls", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
||||||
// Healthy boot: open the profile popover and wait for it to mount. The
|
|
||||||
// reconnect item must be ABSENT because the relay is connected. Anchoring on
|
|
||||||
// a stable popover item first ensures the count assertion reflects the gate,
|
|
||||||
// not an un-mounted popover. Pre-fix the item rendered unconditionally, so
|
|
||||||
// this fails before the gate is added.
|
|
||||||
await expect(page.getByTestId("channel-general")).toBeVisible();
|
await expect(page.getByTestId("channel-general")).toBeVisible();
|
||||||
await page.getByTestId("sidebar-profile-avatar-button").click();
|
await page.getByTestId("sidebar-profile-avatar-button").click();
|
||||||
await expect(page.getByTestId("profile-popover-settings")).toBeVisible();
|
await expect(page.getByTestId("profile-popover-settings")).toBeVisible();
|
||||||
await expect(page.getByTestId("profile-popover-reconnect")).toHaveCount(0);
|
await expect(page.getByTestId("profile-popover-reconnect")).toHaveCount(0);
|
||||||
|
|
||||||
// Drive the live connection degraded. The gate reads `useRelayConnection()`,
|
// The sidebar owns the relay reconnect affordance; the profile popover stays
|
||||||
// so the item surfaces reactively while the popover stays open.
|
// focused on profile/settings/workspace actions even while the relay is down.
|
||||||
await driveConnectionDegraded(page, "disconnected");
|
await driveConnectionDegraded(page, "stalled");
|
||||||
await expect(page.getByTestId("profile-popover-reconnect")).toBeVisible({
|
await expect(page.getByTestId("sidebar-relay-unreachable")).toBeVisible({
|
||||||
timeout: 5_000,
|
timeout: 10_000,
|
||||||
});
|
});
|
||||||
|
await expect(page.getByTestId("profile-popover-reconnect")).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|||||||
214
desktop/tests/e2e/sidebar-relay-card.spec.ts
Normal file
214
desktop/tests/e2e/sidebar-relay-card.spec.ts
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import { expect, type Page, test } from "@playwright/test";
|
||||||
|
|
||||||
|
import { installMockBridge } from "../helpers/bridge";
|
||||||
|
|
||||||
|
const CONNECT_ERROR = "relay unreachable: could not connect to relay";
|
||||||
|
const PROXY_ERROR =
|
||||||
|
"relay unreachable: relay returned an unexpected HTML page (network sign-in?)";
|
||||||
|
const ACCESS_ERROR = "relay unreachable: 403 Forbidden";
|
||||||
|
const RELAY_AUTH_ERROR = "Relay authentication rejected.";
|
||||||
|
|
||||||
|
type RelayConnectionState =
|
||||||
|
| "connected"
|
||||||
|
| "connecting"
|
||||||
|
| "disconnected"
|
||||||
|
| "idle"
|
||||||
|
| "reconnecting"
|
||||||
|
| "stalled";
|
||||||
|
|
||||||
|
async function setChannelsReadError(page: Page, error: string | null) {
|
||||||
|
await page.evaluate((nextError) => {
|
||||||
|
const testWindow = window as Window & {
|
||||||
|
__BUZZ_E2E__?: { mock?: { channelsReadError?: string } };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!testWindow.__BUZZ_E2E__?.mock) {
|
||||||
|
throw new Error("Mock bridge config is not installed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextError === null) {
|
||||||
|
delete testWindow.__BUZZ_E2E__.mock.channelsReadError;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
testWindow.__BUZZ_E2E__.mock.channelsReadError = nextError;
|
||||||
|
}, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setRelayConnectionState(
|
||||||
|
page: Page,
|
||||||
|
state: RelayConnectionState,
|
||||||
|
) {
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
typeof (
|
||||||
|
window as Window & {
|
||||||
|
__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: unknown;
|
||||||
|
}
|
||||||
|
).__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__ === "function",
|
||||||
|
);
|
||||||
|
await page.evaluate((nextState) => {
|
||||||
|
const testWindow = window as Window & {
|
||||||
|
__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: (
|
||||||
|
state: RelayConnectionState,
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setConnectionState =
|
||||||
|
testWindow.__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__;
|
||||||
|
if (!setConnectionState) {
|
||||||
|
throw new Error("Mock relay connection state helper is not installed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
setConnectionState(nextState);
|
||||||
|
}, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectGenericReconnectCard(page: Page) {
|
||||||
|
const card = page.getByTestId("sidebar-relay-unreachable");
|
||||||
|
await expect(card).toBeVisible();
|
||||||
|
await expect(card).toContainText("Can't reach the relay");
|
||||||
|
await expect(card).toContainText("Click to connect");
|
||||||
|
await expect(page.getByTestId("sidebar-reconnect")).toBeVisible();
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("sidebar generic relay failures use the reconnect card", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await installMockBridge(page, { channelsReadError: CONNECT_ERROR });
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
await expectGenericReconnectCard(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sidebar proxy sign-in failures use the reconnect card", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await installMockBridge(page, { channelsReadError: PROXY_ERROR });
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
await expectGenericReconnectCard(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sidebar access failures use the reconnect card", async ({ page }) => {
|
||||||
|
await installMockBridge(page, { channelsReadError: ACCESS_ERROR });
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
await expectGenericReconnectCard(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("collapsed sidebar relay failures use the connection banner", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await installMockBridge(page, { channelsReadError: CONNECT_ERROR });
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
await expectGenericReconnectCard(page);
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole("button", { exact: true, name: "Toggle Sidebar" })
|
||||||
|
.click();
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-state="collapsed"][data-collapsible="offcanvas"]'),
|
||||||
|
).toHaveCount(1);
|
||||||
|
|
||||||
|
const banner = page.getByTestId("connection-banner");
|
||||||
|
await expect(banner).toBeVisible();
|
||||||
|
await expect(banner).toContainText("Can't reach the relay.");
|
||||||
|
|
||||||
|
await setChannelsReadError(page, null);
|
||||||
|
await page.getByTestId("connection-banner-reconnect").click();
|
||||||
|
await expect(banner).toBeHidden({ timeout: 10_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sidebar stalled relay state uses the reconnect card", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await installMockBridge(page);
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.getByTestId("channel-general")).toBeVisible();
|
||||||
|
await setRelayConnectionState(page, "stalled");
|
||||||
|
|
||||||
|
await expectGenericReconnectCard(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sidebar application auth disconnects stay on the error path", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await installMockBridge(page, { channelsReadError: RELAY_AUTH_ERROR });
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await setRelayConnectionState(page, "disconnected");
|
||||||
|
|
||||||
|
await expect(page.getByText(RELAY_AUTH_ERROR)).toBeVisible();
|
||||||
|
await expect(page.getByTestId("sidebar-relay-unreachable")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sidebar reconnect action shows connected before hiding", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await installMockBridge(page, { channelsReadError: CONNECT_ERROR });
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
const card = await expectGenericReconnectCard(page);
|
||||||
|
|
||||||
|
await setChannelsReadError(page, null);
|
||||||
|
await page.getByTestId("sidebar-reconnect").click();
|
||||||
|
|
||||||
|
await expect(card).toContainText("Connected");
|
||||||
|
await expect(card).not.toContainText("Click to connect");
|
||||||
|
|
||||||
|
await page.waitForTimeout(3_000);
|
||||||
|
await expect(card).toContainText("Connected");
|
||||||
|
await expect(card).toBeHidden({ timeout: 5_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sidebar reconnect action suppresses stale refresh errors after success", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await installMockBridge(page, { channelsReadError: CONNECT_ERROR });
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
const card = await expectGenericReconnectCard(page);
|
||||||
|
|
||||||
|
await page.getByTestId("sidebar-reconnect").click();
|
||||||
|
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await expect(card).toBeVisible();
|
||||||
|
await expect(card).toContainText("Connected");
|
||||||
|
await expect(card).not.toContainText("Can't reach the relay");
|
||||||
|
|
||||||
|
await page.waitForTimeout(6_500);
|
||||||
|
await expect(card).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sidebar connected success clears when relay degrades again", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await installMockBridge(page, { channelsReadError: CONNECT_ERROR });
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
const card = await expectGenericReconnectCard(page);
|
||||||
|
|
||||||
|
await setChannelsReadError(page, null);
|
||||||
|
await page.getByTestId("sidebar-reconnect").click();
|
||||||
|
|
||||||
|
await expect(card).toContainText("Connected");
|
||||||
|
|
||||||
|
await setRelayConnectionState(page, "stalled");
|
||||||
|
|
||||||
|
await expect(card).toContainText("Can't reach the relay", {
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
await expect(card).toContainText("Click to connect");
|
||||||
|
await expect(card).not.toContainText("Connected");
|
||||||
|
});
|
||||||
@@ -66,3 +66,90 @@ test("resizes, persists, and snaps to the default sidebar width", async ({
|
|||||||
.poll(() => storedSidebarWidth(page))
|
.poll(() => storedSidebarWidth(page))
|
||||||
.toBe(String(DEFAULT_SIDEBAR_WIDTH));
|
.toBe(String(DEFAULT_SIDEBAR_WIDTH));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("shows a sidebar update card when an update is ready", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.getByTestId("app-sidebar")).toBeVisible();
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const testWindow = window as Window & {
|
||||||
|
__BUZZ_E2E__?: { mock?: { updateAvailable?: boolean } };
|
||||||
|
};
|
||||||
|
|
||||||
|
testWindow.__BUZZ_E2E__ = {
|
||||||
|
...(testWindow.__BUZZ_E2E__ ?? {}),
|
||||||
|
mock: {
|
||||||
|
...(testWindow.__BUZZ_E2E__?.mock ?? {}),
|
||||||
|
restartDelayMs: 500,
|
||||||
|
updateAvailable: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByTestId("sidebar-profile-card").click();
|
||||||
|
await page.getByTestId("profile-popover-settings").click();
|
||||||
|
await page.getByTestId("settings-nav-updates").click();
|
||||||
|
await page.getByRole("button", { name: "Check for Updates" }).click();
|
||||||
|
await expect(page.getByTestId("settings-panel-updates")).toContainText(
|
||||||
|
"Update installed. Restart to apply.",
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.getByTestId("settings-back-to-app").click();
|
||||||
|
|
||||||
|
const updateCard = page.getByTestId("sidebar-update-card");
|
||||||
|
await expect(updateCard).toBeVisible();
|
||||||
|
await expect(updateCard).toContainText("Ready to update!");
|
||||||
|
await expect(updateCard).toContainText("Click to restart");
|
||||||
|
await expect(page.getByTestId("sidebar-update-restart")).toBeVisible();
|
||||||
|
const reservedCardHeight = await updateCard.evaluate(
|
||||||
|
(element) => (element as HTMLElement).offsetHeight,
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.getByTestId("sidebar-update-restart").click();
|
||||||
|
await expect(updateCard).toContainText("Restarting");
|
||||||
|
await expect(page.getByTestId("sidebar-update-restart")).toBeDisabled();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate(
|
||||||
|
() =>
|
||||||
|
(
|
||||||
|
window as Window & {
|
||||||
|
__BUZZ_E2E_COMMANDS__?: string[];
|
||||||
|
}
|
||||||
|
).__BUZZ_E2E_COMMANDS__ ?? [],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toContain("plugin:process|restart");
|
||||||
|
|
||||||
|
const dismissButton = page.getByTestId("sidebar-update-dismiss");
|
||||||
|
await updateCard.hover();
|
||||||
|
const dismissButtonBox = await dismissButton.boundingBox();
|
||||||
|
expect(dismissButtonBox).not.toBeNull();
|
||||||
|
if (!dismissButtonBox) return;
|
||||||
|
|
||||||
|
await page.mouse.move(
|
||||||
|
dismissButtonBox.x + dismissButtonBox.width / 2,
|
||||||
|
dismissButtonBox.y + dismissButtonBox.height / 2,
|
||||||
|
);
|
||||||
|
await page.mouse.down();
|
||||||
|
await expect(page.locator(".buzz-poof-burst")).toHaveCount(1);
|
||||||
|
await expect(updateCard).toBeVisible();
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect(updateCard).toHaveAttribute("data-dismissing", "true");
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
updateCard.evaluate((element) => (element as HTMLElement).offsetHeight),
|
||||||
|
)
|
||||||
|
.toBe(reservedCardHeight);
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
updateCard.evaluate((element) =>
|
||||||
|
Number.parseFloat(getComputedStyle(element).opacity),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toBeLessThan(0.05);
|
||||||
|
await expect(updateCard).toBeHidden();
|
||||||
|
});
|
||||||
|
|||||||
@@ -99,8 +99,12 @@ type MockBridgeOptions = {
|
|||||||
profileReadDelayMs?: number;
|
profileReadDelayMs?: number;
|
||||||
profileReadError?: string;
|
profileReadError?: string;
|
||||||
profileUpdateError?: string;
|
profileUpdateError?: string;
|
||||||
|
profileUpdateErrors?: string[];
|
||||||
searchProfiles?: MockSearchProfileSeed[];
|
searchProfiles?: MockSearchProfileSeed[];
|
||||||
|
updateAvailable?: boolean;
|
||||||
updateChannelDelayMs?: number;
|
updateChannelDelayMs?: number;
|
||||||
|
updateDownloadDelayMs?: number;
|
||||||
|
updateVersion?: string;
|
||||||
stallWebsocketSends?: boolean;
|
stallWebsocketSends?: boolean;
|
||||||
userSearchDelayMs?: number;
|
userSearchDelayMs?: number;
|
||||||
// NIP-IA gate inputs — drive the archive-button gate matrix in
|
// NIP-IA gate inputs — drive the archive-button gate matrix in
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
// --viewport <WxH> Viewport dimensions (default: 1280x720)
|
// --viewport <WxH> Viewport dimensions (default: 1280x720)
|
||||||
// --outdir <path> Output directory (default: test-results/screenshots)
|
// --outdir <path> Output directory (default: test-results/screenshots)
|
||||||
// --messages <path> JSON file with messages to inject before capture
|
// --messages <path> JSON file with messages to inject before capture
|
||||||
|
// --update-ready Mock an available update so the sidebar update card renders
|
||||||
|
|
||||||
import { parseArgs } from "node:util";
|
import { parseArgs } from "node:util";
|
||||||
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
||||||
@@ -40,6 +41,7 @@ const { values: args } = parseArgs({
|
|||||||
viewport: { type: "string", default: "1280x720" },
|
viewport: { type: "string", default: "1280x720" },
|
||||||
outdir: { type: "string", default: "test-results/screenshots" },
|
outdir: { type: "string", default: "test-results/screenshots" },
|
||||||
messages: { type: "string" },
|
messages: { type: "string" },
|
||||||
|
"update-ready": { type: "boolean", default: false },
|
||||||
},
|
},
|
||||||
strict: true,
|
strict: true,
|
||||||
});
|
});
|
||||||
@@ -107,31 +109,37 @@ await page.addInitScript(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Install E2E mock bridge config + MockNotification (mirrors installBridge in bridge.ts)
|
// Install E2E mock bridge config + MockNotification (mirrors installBridge in bridge.ts)
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(
|
||||||
class MockNotification extends EventTarget {
|
({ updateReady }) => {
|
||||||
static permission = "granted";
|
class MockNotification extends EventTarget {
|
||||||
static async requestPermission() {
|
static permission = "granted";
|
||||||
return "granted";
|
static async requestPermission() {
|
||||||
|
return "granted";
|
||||||
|
}
|
||||||
|
body;
|
||||||
|
onclick = null;
|
||||||
|
title;
|
||||||
|
constructor(title, options) {
|
||||||
|
super();
|
||||||
|
this.title = title;
|
||||||
|
this.body = options?.body ?? null;
|
||||||
|
}
|
||||||
|
close() {}
|
||||||
}
|
}
|
||||||
body;
|
Object.defineProperty(window, "Notification", {
|
||||||
onclick = null;
|
configurable: true,
|
||||||
title;
|
value: MockNotification,
|
||||||
constructor(title, options) {
|
writable: true,
|
||||||
super();
|
});
|
||||||
this.title = title;
|
|
||||||
this.body = options?.body ?? null;
|
|
||||||
}
|
|
||||||
close() {}
|
|
||||||
}
|
|
||||||
Object.defineProperty(window, "Notification", {
|
|
||||||
configurable: true,
|
|
||||||
value: MockNotification,
|
|
||||||
writable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
window.__BUZZ_E2E__ = { mode: "mock" };
|
window.__BUZZ_E2E__ = {
|
||||||
window.__BUZZ_E2E_APP_BADGE_COUNT__ = 0;
|
mode: "mock",
|
||||||
});
|
...(updateReady ? { mock: { updateAvailable: true } } : {}),
|
||||||
|
};
|
||||||
|
window.__BUZZ_E2E_APP_BADGE_COUNT__ = 0;
|
||||||
|
},
|
||||||
|
{ updateReady: args["update-ready"] },
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (args.messages) {
|
if (args.messages) {
|
||||||
|
|||||||
Reference in New Issue
Block a user