mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Polish sidebar update and relay cards (#1009)
This commit is contained in:
@@ -62,6 +62,7 @@ export default defineConfig({
|
||||
"**/integration.spec.ts",
|
||||
"**/profile.spec.ts",
|
||||
"**/sidebar.spec.ts",
|
||||
"**/sidebar-relay-card.spec.ts",
|
||||
"**/tokens.spec.ts",
|
||||
"**/persona-env-vars.spec.ts",
|
||||
"**/mesh-compute.spec.ts",
|
||||
|
||||
@@ -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.
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.8 KiB |
@@ -219,6 +219,10 @@ export function AppShell() {
|
||||
const channelsQuery = useChannelsQuery();
|
||||
const { refetch: refetchChannels } = channelsQuery;
|
||||
const channels = channelsQuery.data ?? [];
|
||||
const channelsErrorMessage =
|
||||
channelsQuery.error instanceof Error
|
||||
? channelsQuery.error.message
|
||||
: undefined;
|
||||
const memberChannels = React.useMemo(
|
||||
() => channels.filter((channel) => channel.isMember),
|
||||
[channels],
|
||||
@@ -775,11 +779,7 @@ export function AppShell() {
|
||||
activeWorkspace={workspacesHook.activeWorkspace}
|
||||
channels={sidebarChannels}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
errorMessage={
|
||||
channelsQuery.error instanceof Error
|
||||
? channelsQuery.error.message
|
||||
: undefined
|
||||
}
|
||||
errorMessage={channelsErrorMessage}
|
||||
fallbackDisplayName={identityQuery.data?.displayName}
|
||||
homeBadgeCount={homeBadgeCount}
|
||||
isAddWorkspaceOpen={isAddWorkspaceOpen}
|
||||
@@ -911,7 +911,9 @@ export function AppShell() {
|
||||
className="min-h-0 min-w-0 overflow-hidden"
|
||||
style={chromeCssVarDefaults}
|
||||
>
|
||||
<ConnectionBanner />
|
||||
<ConnectionBanner
|
||||
errorMessage={channelsErrorMessage}
|
||||
/>
|
||||
<Outlet />
|
||||
</SidebarInset>
|
||||
</MainInsetProvider>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
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 { isRelayUnreachableError } from "@/shared/lib/relayError";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Spinner } from "@/shared/ui/spinner";
|
||||
import {
|
||||
@@ -17,11 +21,131 @@ type ProfileStepProps = {
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isRelayUnreachableError(message)) {
|
||||
return (
|
||||
<OnboardingRelayConnectionErrorCard
|
||||
isSaving={isSaving}
|
||||
key={message}
|
||||
message={message}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<p className="mt-4 rounded-md border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{message}
|
||||
@@ -117,7 +241,7 @@ export function ProfileStep({
|
||||
</label>
|
||||
|
||||
{saveRecovery.errorMessage ? (
|
||||
<ErrorBanner message={saveRecovery.errorMessage} />
|
||||
<ErrorBanner isSaving={isSaving} message={saveRecovery.errorMessage} />
|
||||
) : null}
|
||||
|
||||
<div className="mt-12 flex w-full max-w-[500px] flex-col gap-3">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
|
||||
@@ -28,8 +28,6 @@ interface ProfilePopoverProps {
|
||||
onSetUserStatus: (text: string, emoji: string) => void;
|
||||
onClearUserStatus: () => void;
|
||||
onOpenSettings: (section?: "profile" | "appearance") => void;
|
||||
onReconnect?: () => void;
|
||||
isReconnectPending?: boolean;
|
||||
children: React.ReactNode;
|
||||
// Optional outer container whose clicks should NOT close the popover.
|
||||
// Used when auxiliary triggers (avatar, status text) live alongside the
|
||||
@@ -68,8 +66,6 @@ export function ProfilePopover({
|
||||
onSetUserStatus,
|
||||
onClearUserStatus,
|
||||
onOpenSettings,
|
||||
onReconnect,
|
||||
isReconnectPending,
|
||||
children,
|
||||
triggerContainerRef,
|
||||
workspaceSwitcherSlot,
|
||||
@@ -275,27 +271,6 @@ export function ProfilePopover({
|
||||
</kbd>
|
||||
</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 ? (
|
||||
<>
|
||||
<hr className="my-1 h-px border-0 bg-border" />
|
||||
|
||||
@@ -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 { check, type Update } from "@tauri-apps/plugin-updater";
|
||||
import { relaunch } from "@tauri-apps/plugin-process";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export type UpdateStatus =
|
||||
| { state: "idle" }
|
||||
@@ -87,10 +86,6 @@ export function useUpdater() {
|
||||
|
||||
updateRef.current = null;
|
||||
setStatus({ state: "ready" });
|
||||
toast("Update ready", {
|
||||
description: "Restart when you're ready to apply the update.",
|
||||
duration: 8000,
|
||||
});
|
||||
} catch (err) {
|
||||
setStatus({ state: "error", message: toErrorMessage(err) });
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function shouldShowSidebarUpdateCard(status: { state: string }) {
|
||||
return status.state === "ready";
|
||||
}
|
||||
@@ -10,16 +10,8 @@ import {
|
||||
MessageCirclePlus,
|
||||
Zap,
|
||||
} 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 { AnimatePresence } from "motion/react";
|
||||
import { FeatureGate } from "@/shared/features";
|
||||
import { SidebarDndContext } from "@/features/sidebar/ui/SidebarDnd";
|
||||
|
||||
@@ -48,11 +40,16 @@ import {
|
||||
import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog";
|
||||
import { NewDirectMessageDialog } from "@/features/sidebar/ui/NewDirectMessageDialog";
|
||||
import { SidebarProfileCard } from "@/features/sidebar/ui/SidebarProfileCard";
|
||||
import { SidebarRelayConnectionCard } from "@/features/sidebar/ui/SidebarRelayConnectionCard";
|
||||
import { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard";
|
||||
import {
|
||||
SidebarLoadingContent,
|
||||
useSidebarLoadingShape,
|
||||
} from "@/features/sidebar/ui/sidebarLoadingSkeleton";
|
||||
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 {
|
||||
Channel,
|
||||
ChannelVisibility,
|
||||
@@ -60,6 +57,7 @@ import type {
|
||||
Profile,
|
||||
UserStatus,
|
||||
} from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -228,6 +226,31 @@ export function AppSidebar({
|
||||
onStarChannel,
|
||||
onUnstarChannel,
|
||||
}: 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 isNewDmOpen = isNewDmOpenProp ?? isNewDmOpenInternal;
|
||||
const setIsNewDmOpen = onNewDmOpenChange ?? setIsNewDmOpenInternal;
|
||||
@@ -236,6 +259,12 @@ export function AppSidebar({
|
||||
const [createDialogKind, setCreateDialogKind] =
|
||||
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
|
||||
// ⌘⇧N global shortcut in AppShell), mirroring the controlled new-DM lift.
|
||||
// When the external flag flips on, open the "stream" create dialog; the
|
||||
@@ -288,20 +317,6 @@ export function AppSidebar({
|
||||
unassignChannel,
|
||||
} = 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<{
|
||||
open: boolean;
|
||||
pendingChannelId: string | null;
|
||||
@@ -563,7 +578,10 @@ export function AppSidebar({
|
||||
testId="sidebar-more-unread-above"
|
||||
/>
|
||||
) : null}
|
||||
<SidebarContent className="pb-32" ref={scrollRef}>
|
||||
<SidebarContent
|
||||
className={cn(sidebarContentBottomPaddingClass)}
|
||||
ref={scrollRef}
|
||||
>
|
||||
{isLoading ? (
|
||||
<SidebarLoadingContent shape={sidebarLoadingShape} />
|
||||
) : null}
|
||||
@@ -754,25 +772,8 @@ export function AppSidebar({
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{isRelayConnectionDegradedNow ? (
|
||||
<div
|
||||
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 ? (
|
||||
{errorMessage &&
|
||||
!sidebarRelayConnectionCard.hasRelayUnreachableError ? (
|
||||
<div className="px-3 py-2 text-sm text-destructive">
|
||||
{errorMessage}
|
||||
</div>
|
||||
@@ -781,7 +782,7 @@ export function AppSidebar({
|
||||
|
||||
{unreadBelowCount > 0 ? (
|
||||
<MoreUnreadButton
|
||||
bottomClassName="bottom-28"
|
||||
bottomClassName={unreadBelowBottomClass}
|
||||
count={unreadBelowCount}
|
||||
icon={<ArrowDown />}
|
||||
onClick={scrollToNextBelow}
|
||||
@@ -791,6 +792,31 @@ export function AppSidebar({
|
||||
) : 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">
|
||||
<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>
|
||||
<SidebarMenuItem>
|
||||
<SidebarProfileCard
|
||||
|
||||
@@ -9,11 +9,6 @@ import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
|
||||
import type { Workspace } from "@/features/workspaces/types";
|
||||
import { WorkspaceSwitcher } from "@/features/workspaces/ui/WorkspaceSwitcher";
|
||||
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";
|
||||
|
||||
type SidebarProfileCardProps = {
|
||||
@@ -54,17 +49,7 @@ export function SidebarProfileCard({
|
||||
selfUserStatus,
|
||||
workspaces,
|
||||
}: SidebarProfileCardProps) {
|
||||
// Called locally rather than threading props from AppShell — both hooks are
|
||||
// workspace-provider and QueryClient safe at this level.
|
||||
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 profileCardRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const toggleProfilePopover = React.useCallback(
|
||||
@@ -140,15 +125,9 @@ export function SidebarProfileCard({
|
||||
avatarUrl={profile?.avatarUrl ?? null}
|
||||
currentStatus={selfPresenceStatus}
|
||||
displayName={resolvedDisplayName}
|
||||
isReconnectPending={isPending}
|
||||
isStatusPending={isPresencePending}
|
||||
onClearUserStatus={onClearUserStatus}
|
||||
onOpenSettings={onOpenSettings}
|
||||
onReconnect={
|
||||
isRelayConnectionDegradedNow || isPending
|
||||
? () => void reconnect()
|
||||
: undefined
|
||||
}
|
||||
onSetStatus={onSetPresenceStatus ?? (() => {})}
|
||||
onSetUserStatus={onSetUserStatus}
|
||||
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"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 { clearAllDrafts } from "@/features/messages/lib/useDrafts";
|
||||
import { resetAgentObserverStore } from "@/features/agents/observerRelayStore";
|
||||
import { resetSidebarRelayConnectionCardState } from "@/features/sidebar/ui/useSidebarRelayConnectionCard";
|
||||
import { resetVideoPlayerState } from "@/shared/ui/videoPlayerState";
|
||||
|
||||
import { initFirstWorkspace } from "./workspaceStorage";
|
||||
@@ -25,6 +26,7 @@ import type { Workspace } from "./types";
|
||||
function resetWorkspaceState(): void {
|
||||
relayClient.disconnect();
|
||||
resetAgentObserverStore();
|
||||
resetSidebarRelayConnectionCardState();
|
||||
resetMediaCaches();
|
||||
resetVideoPlayerState();
|
||||
clearSearchHitEventCache();
|
||||
|
||||
@@ -7,6 +7,7 @@ import { migrateLegacyWorkspaceStorageBeforeRender } from "@/features/workspaces
|
||||
import { WorkspacesProvider } from "@/features/workspaces/useWorkspaces";
|
||||
import { ThemeProvider } from "@/shared/theme/ThemeProvider";
|
||||
import { EmojiBurstProvider } from "@/shared/ui/EmojiBurstProvider";
|
||||
import { PoofBurstProvider } from "@/shared/ui/PoofBurstProvider";
|
||||
import { Toaster } from "@/shared/ui/sonner";
|
||||
import { TooltipProvider } from "@/shared/ui/tooltip";
|
||||
|
||||
@@ -53,10 +54,12 @@ function renderApp() {
|
||||
<ThemeProvider defaultTheme="houston">
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<EmojiBurstProvider>
|
||||
<UpdaterProvider>
|
||||
<App />
|
||||
</UpdaterProvider>
|
||||
<Toaster />
|
||||
<PoofBurstProvider>
|
||||
<UpdaterProvider>
|
||||
<App />
|
||||
</UpdaterProvider>
|
||||
<Toaster />
|
||||
</PoofBurstProvider>
|
||||
</EmojiBurstProvider>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Deliberately uses `relayClient.preconnect()` + `queryClient.invalidateQueries()`
|
||||
* rather than the full `reconnectWorkspace()` path, which unmounts the entire
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@@ -16,8 +16,30 @@ import { toast } from "sonner";
|
||||
|
||||
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(): {
|
||||
reconnect: () => Promise<void>;
|
||||
reconnect: () => Promise<boolean>;
|
||||
isPending: boolean;
|
||||
} {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -28,25 +50,44 @@ export function useReconnectRelay(): {
|
||||
const inFlightRef = React.useRef(false);
|
||||
|
||||
const reconnect = React.useCallback(async () => {
|
||||
if (inFlightRef.current) return;
|
||||
if (inFlightRef.current) return false;
|
||||
inFlightRef.current = true;
|
||||
setIsPending(true);
|
||||
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.
|
||||
try {
|
||||
await invoke("relay_reconnect_hook");
|
||||
await withTimeout(
|
||||
invoke("relay_reconnect_hook"),
|
||||
RECONNECT_HOOK_TIMEOUT_MS,
|
||||
"reconnect hook",
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn("[useReconnectRelay] reconnect hook failed:", err);
|
||||
}
|
||||
|
||||
await relayClient.preconnect();
|
||||
await queryClient.invalidateQueries();
|
||||
await withTimeout(
|
||||
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
|
||||
// transitions back to "connected", which is the user-visible confirmation.
|
||||
return true;
|
||||
} catch (err) {
|
||||
toast.error("Reconnect failed — check your VPN or network.");
|
||||
toast.error("Reconnect failed — check your network.");
|
||||
console.error("[useReconnectRelay] reconnect failed:", err);
|
||||
return false;
|
||||
} finally {
|
||||
inFlightRef.current = false;
|
||||
setIsPending(false);
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
isRelayUnreachableError,
|
||||
relayErrorDetail,
|
||||
RELAY_UNREACHABLE_MESSAGE,
|
||||
} from "./relayError.ts";
|
||||
import { isRelayUnreachableError } from "./relayError.ts";
|
||||
|
||||
// ── isRelayUnreachableError ───────────────────────────────────────────────────
|
||||
|
||||
@@ -49,34 +45,3 @@ test("isRelayUnreachableError: plain object returns 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
syntax: "<percentage>";
|
||||
inherits: false;
|
||||
@@ -404,6 +567,95 @@
|
||||
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 {
|
||||
display: inline-block;
|
||||
height: 1em;
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
} from "@/shared/api/useRelayConnection";
|
||||
import { useReconnectRelay } from "@/shared/api/useReconnectRelay";
|
||||
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>> = {
|
||||
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" —
|
||||
* no success toast needed.
|
||||
*/
|
||||
export function ConnectionBanner() {
|
||||
type ConnectionBannerProps = {
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
export function ConnectionBanner({ errorMessage }: ConnectionBannerProps) {
|
||||
const state = useRelayConnection();
|
||||
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 (
|
||||
<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"
|
||||
role="alert"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
profileReadError?: string;
|
||||
profileUpdateError?: string;
|
||||
profileUpdateErrors?: string[];
|
||||
searchProfiles?: MockSearchProfileSeed[];
|
||||
updateAvailable?: boolean;
|
||||
updateChannelDelayMs?: number;
|
||||
updateDownloadDelayMs?: number;
|
||||
restartDelayMs?: number;
|
||||
updateVersion?: string;
|
||||
stallWebsocketSends?: boolean;
|
||||
userSearchDelayMs?: number;
|
||||
// NIP-IA gate inputs — see tests/helpers/bridge.ts:MockBridgeOptions for
|
||||
@@ -2938,6 +2943,12 @@ async function handleUpdateProfile(
|
||||
const identity = getIdentity(config);
|
||||
if (!identity) {
|
||||
const profileUpdateError = config?.mock?.profileUpdateError;
|
||||
const profileUpdateErrors = config?.mock?.profileUpdateErrors;
|
||||
const nextProfileUpdateError = profileUpdateErrors?.shift();
|
||||
if (nextProfileUpdateError) {
|
||||
throw new Error(nextProfileUpdateError);
|
||||
}
|
||||
|
||||
if (profileUpdateError) {
|
||||
if (config?.mock) {
|
||||
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(
|
||||
args: { channelId: string },
|
||||
config: E2eConfig | undefined,
|
||||
@@ -5977,7 +6038,7 @@ export function maybeInstallE2eTauriMocks() {
|
||||
};
|
||||
window.__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__ = (state) => {
|
||||
// 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
|
||||
// TS-private emitter via a cast so the production class carries no
|
||||
// test-only seam.
|
||||
@@ -6603,6 +6664,16 @@ export function maybeInstallE2eTauriMocks() {
|
||||
case "plugin:window|set_badge_count":
|
||||
case "plugin:window|set_badge_label":
|
||||
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":
|
||||
return handleGetChannelWorkflows(
|
||||
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");
|
||||
await input.fill("Hey @bo");
|
||||
await autocomplete(page).getByText("bob").click();
|
||||
await expect(input).toHaveText("Hey @bob ");
|
||||
await page.keyboard.type(suffix);
|
||||
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);
|
||||
});
|
||||
|
||||
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 ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -49,22 +49,26 @@ async function driveConnectionDegraded(
|
||||
}
|
||||
|
||||
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 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("connection-banner")).toHaveCount(0);
|
||||
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({
|
||||
path: `${SHOTS}/01-sidebar-unreachable.png`,
|
||||
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 page.goto("/");
|
||||
|
||||
@@ -72,40 +76,24 @@ test.describe("relay connectivity screenshots", () => {
|
||||
await expect(page.getByTestId("channel-general")).toBeVisible();
|
||||
await driveConnectionDegraded(page);
|
||||
|
||||
// ConnectionBanner debounces non-healthy states by 2 s before rendering.
|
||||
await expect(page.getByTestId("connection-banner")).toBeVisible({
|
||||
// useRelayConnection debounces non-healthy states by 2 s before surfacing.
|
||||
const relayCard = page.getByTestId("sidebar-relay-unreachable");
|
||||
await expect(relayCard).toBeVisible({
|
||||
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);
|
||||
|
||||
// Capture a horizontal strip spanning the full width that shows the banner
|
||||
// above the content pane.
|
||||
// Clip to the sidebar, where degraded relay state is now surfaced.
|
||||
await page.screenshot({
|
||||
path: `${SHOTS}/02-connection-banner.png`,
|
||||
clip: { x: 0, y: 0, width: 1280, height: 180 },
|
||||
path: `${SHOTS}/02-sidebar-reconnecting.png`,
|
||||
clip: { x: 0, y: 0, width: 256, height: 720 },
|
||||
});
|
||||
});
|
||||
|
||||
test("03 — home feed unreachable", 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 }) => {
|
||||
test("03 — canvas unreachable in management sheet", async ({ page }) => {
|
||||
await installMockBridge(page, { canvasReadError: RELAY_UNREACHABLE });
|
||||
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.
|
||||
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,
|
||||
}) => {
|
||||
// Seed the self-profile cache BEFORE installMockBridge so addInitScript
|
||||
@@ -168,11 +156,11 @@ test.describe("relay connectivity screenshots", () => {
|
||||
await settle(page);
|
||||
|
||||
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.
|
||||
await installMockBridge(page, { profileReadError: RELAY_UNREACHABLE });
|
||||
await page.goto("/");
|
||||
@@ -183,11 +171,11 @@ test.describe("relay connectivity screenshots", () => {
|
||||
await settle(page);
|
||||
|
||||
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,
|
||||
}) => {
|
||||
await installMockBridge(page);
|
||||
@@ -196,31 +184,21 @@ test.describe("relay connectivity screenshots", () => {
|
||||
await expect(page.getByTestId("channel-general")).toBeVisible();
|
||||
await driveConnectionDegraded(page);
|
||||
|
||||
// 2 s debounce on the "reconnecting" state before ConnectionBanner shows.
|
||||
await expect(page.getByTestId("connection-banner")).toBeVisible({
|
||||
const relayCard = page.getByTestId("sidebar-relay-unreachable");
|
||||
await expect(relayCard).toBeVisible({
|
||||
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();
|
||||
const reconnectBtn = page.getByTestId("profile-popover-reconnect");
|
||||
await expect(reconnectBtn).toBeVisible();
|
||||
await driveConnectionDegraded(page, "connected");
|
||||
|
||||
// Wait for the Radix popover open animation on the [data-state] ancestor —
|
||||
// the popper wrapper itself carries no animations, so querying it returns
|
||||
// an empty list and screenshots capture a half-faded popover.
|
||||
await reconnectBtn.evaluate((el) =>
|
||||
Promise.all(
|
||||
el
|
||||
.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 },
|
||||
await expect(relayCard).toContainText("Connected");
|
||||
await expect(relayCard).not.toContainText("Click to connect");
|
||||
await page.waitForTimeout(3_000);
|
||||
await expect(relayCard).toContainText("Connected");
|
||||
await expect(relayCard).toBeHidden({
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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. */
|
||||
async function driveConnectionState(
|
||||
page: import("@playwright/test").Page,
|
||||
state: "connected" | "disconnected",
|
||||
state: "connected" | "reconnecting" | "stalled" | "disconnected",
|
||||
) {
|
||||
await page.evaluate((s) => {
|
||||
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("01 — profile popover reconnect item hidden 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 ({
|
||||
test("01 — sidebar has no reconnect prompt when healthy", async ({
|
||||
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("sidebar-relay-unreachable")).toHaveCount(0);
|
||||
// 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
|
||||
// same region where 04 will show the block — making the absence legible.
|
||||
// below the fold at this viewport. Scroll to the bottom so 01 frames the
|
||||
// same region where 02 will show the block — making the absence legible.
|
||||
await scrollSidebarToBottom(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
|
||||
// unrelated top connection banner.
|
||||
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,
|
||||
}) => {
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.getByTestId("channel-general")).toBeVisible();
|
||||
await driveConnectionState(page, "disconnected");
|
||||
await driveConnectionState(page, "stalled");
|
||||
await expect(page.getByTestId("sidebar-relay-unreachable")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByTestId("sidebar-reconnect")).toBeVisible();
|
||||
// The cached channel list stays visible alongside the prompt.
|
||||
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 settle(page);
|
||||
|
||||
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
|
||||
// set. Pre-fix the block keyed off `channelsQuery.error` alone, so it stays
|
||||
// absent here; post-fix the dual signal surfaces it.
|
||||
await driveConnectionDegraded(page, "disconnected");
|
||||
await driveConnectionDegraded(page, "stalled");
|
||||
|
||||
// `disconnected` reports immediately (reconnecting/stalled debounce ~2s),
|
||||
// but allow margin for the React render to flush.
|
||||
// `stalled` is debounced before surfacing, then React needs a render tick.
|
||||
await expect(page.getByTestId("sidebar-relay-unreachable")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
timeout: 10_000,
|
||||
});
|
||||
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();
|
||||
});
|
||||
|
||||
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,
|
||||
}) => {
|
||||
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 page.getByTestId("sidebar-profile-avatar-button").click();
|
||||
await expect(page.getByTestId("profile-popover-settings")).toBeVisible();
|
||||
await expect(page.getByTestId("profile-popover-reconnect")).toHaveCount(0);
|
||||
|
||||
// Drive the live connection degraded. The gate reads `useRelayConnection()`,
|
||||
// so the item surfaces reactively while the popover stays open.
|
||||
await driveConnectionDegraded(page, "disconnected");
|
||||
await expect(page.getByTestId("profile-popover-reconnect")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
// The sidebar owns the relay reconnect affordance; the profile popover stays
|
||||
// focused on profile/settings/workspace actions even while the relay is down.
|
||||
await driveConnectionDegraded(page, "stalled");
|
||||
await expect(page.getByTestId("sidebar-relay-unreachable")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByTestId("profile-popover-reconnect")).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -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))
|
||||
.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;
|
||||
profileReadError?: string;
|
||||
profileUpdateError?: string;
|
||||
profileUpdateErrors?: string[];
|
||||
searchProfiles?: MockSearchProfileSeed[];
|
||||
updateAvailable?: boolean;
|
||||
updateChannelDelayMs?: number;
|
||||
updateDownloadDelayMs?: number;
|
||||
updateVersion?: string;
|
||||
stallWebsocketSends?: boolean;
|
||||
userSearchDelayMs?: number;
|
||||
// NIP-IA gate inputs — drive the archive-button gate matrix in
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
// --viewport <WxH> Viewport dimensions (default: 1280x720)
|
||||
// --outdir <path> Output directory (default: test-results/screenshots)
|
||||
// --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 { existsSync, mkdirSync, readFileSync } from "node:fs";
|
||||
@@ -40,6 +41,7 @@ const { values: args } = parseArgs({
|
||||
viewport: { type: "string", default: "1280x720" },
|
||||
outdir: { type: "string", default: "test-results/screenshots" },
|
||||
messages: { type: "string" },
|
||||
"update-ready": { type: "boolean", default: false },
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
@@ -107,31 +109,37 @@ await page.addInitScript(
|
||||
);
|
||||
|
||||
// Install E2E mock bridge config + MockNotification (mirrors installBridge in bridge.ts)
|
||||
await page.addInitScript(() => {
|
||||
class MockNotification extends EventTarget {
|
||||
static permission = "granted";
|
||||
static async requestPermission() {
|
||||
return "granted";
|
||||
await page.addInitScript(
|
||||
({ updateReady }) => {
|
||||
class MockNotification extends EventTarget {
|
||||
static permission = "granted";
|
||||
static async requestPermission() {
|
||||
return "granted";
|
||||
}
|
||||
body;
|
||||
onclick = null;
|
||||
title;
|
||||
constructor(title, options) {
|
||||
super();
|
||||
this.title = title;
|
||||
this.body = options?.body ?? null;
|
||||
}
|
||||
close() {}
|
||||
}
|
||||
body;
|
||||
onclick = null;
|
||||
title;
|
||||
constructor(title, options) {
|
||||
super();
|
||||
this.title = title;
|
||||
this.body = options?.body ?? null;
|
||||
}
|
||||
close() {}
|
||||
}
|
||||
Object.defineProperty(window, "Notification", {
|
||||
configurable: true,
|
||||
value: MockNotification,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(window, "Notification", {
|
||||
configurable: true,
|
||||
value: MockNotification,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
window.__BUZZ_E2E__ = { mode: "mock" };
|
||||
window.__BUZZ_E2E_APP_BADGE_COUNT__ = 0;
|
||||
});
|
||||
window.__BUZZ_E2E__ = {
|
||||
mode: "mock",
|
||||
...(updateReady ? { mock: { updateAvailable: true } } : {}),
|
||||
};
|
||||
window.__BUZZ_E2E_APP_BADGE_COUNT__ = 0;
|
||||
},
|
||||
{ updateReady: args["update-ready"] },
|
||||
);
|
||||
|
||||
try {
|
||||
if (args.messages) {
|
||||
|
||||
Reference in New Issue
Block a user