mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(reconnect): replace top banner with animated sidebar overlay (#1510)
This commit is contained in:
@@ -79,8 +79,9 @@ import { chromeCssVarDefaults } from "@/shared/layout/chromeLayout";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { hasPrimaryShortcutModifier } from "@/shared/lib/platform";
|
||||
import { useMessageDeepLinks } from "@/shared/useMessageDeepLinks";
|
||||
import { ConnectionBanner } from "@/shared/ui/ConnectionBanner";
|
||||
import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar";
|
||||
import { RelayConnectionOverlay } from "@/app/RelayConnectionOverlay";
|
||||
import { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard";
|
||||
|
||||
const LazySettingsScreen = React.lazy(async () => {
|
||||
const module = await import("@/features/settings/ui/SettingsScreen");
|
||||
@@ -180,6 +181,10 @@ export function AppShell() {
|
||||
channelsQuery.error instanceof Error
|
||||
? channelsQuery.error.message
|
||||
: undefined;
|
||||
const relayConnectionCard = useSidebarRelayConnectionCard(
|
||||
channelsErrorMessage,
|
||||
workspacesHook.activeWorkspace?.relayUrl,
|
||||
);
|
||||
const memberChannels = React.useMemo(
|
||||
() => channels.filter((channel) => channel.isMember),
|
||||
[channels],
|
||||
@@ -688,6 +693,7 @@ export function AppShell() {
|
||||
fallbackDisplayName={identityQuery.data?.displayName}
|
||||
homeBadgeCount={homeBadgeCount + dueReminderBadge}
|
||||
isAddWorkspaceOpen={isAddWorkspaceOpen}
|
||||
relayConnectionCard={relayConnectionCard}
|
||||
isCreatingChannel={createChannelMutation.isPending}
|
||||
isCreatingForum={createForumMutation.isPending}
|
||||
isLoading={channelsQuery.isLoading}
|
||||
@@ -821,13 +827,19 @@ export function AppShell() {
|
||||
style={chromeCssVarDefaults}
|
||||
>
|
||||
<div className="relative z-10 mb-2 ml-px mr-2 mt-px flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl bg-background shadow-[-1px_-1px_0_0_hsl(var(--sidebar-border)/0.45)]">
|
||||
<ConnectionBanner
|
||||
errorMessage={channelsErrorMessage}
|
||||
/>
|
||||
<Outlet />
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</MainInsetProvider>
|
||||
<RelayConnectionOverlay
|
||||
card={relayConnectionCard}
|
||||
errorMessage={channelsErrorMessage}
|
||||
hasWorkspaceRail={
|
||||
workspaceRailEnabled &&
|
||||
workspacesHook.workspaces.length > 1
|
||||
}
|
||||
isHuddleDrawerOpen={isHuddleDrawerOpen}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<AppShellOverlays
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
|
||||
import { SidebarRelayConnectionCard } from "@/features/sidebar/ui/SidebarRelayConnectionCard";
|
||||
import type { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { useIsMobile } from "@/shared/hooks/use-mobile";
|
||||
import { useRelayConnection } from "@/shared/api/useRelayConnection";
|
||||
import { useSidebar } from "@/shared/ui/sidebar";
|
||||
|
||||
type RelayConnectionOverlayProps = {
|
||||
card: ReturnType<typeof useSidebarRelayConnectionCard>;
|
||||
errorMessage?: string;
|
||||
hasWorkspaceRail?: boolean;
|
||||
isHuddleDrawerOpen?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fixed bottom-left overlay that shows the relay reconnect card when the
|
||||
* sidebar is collapsed. When the sidebar is open, the card lives in the
|
||||
* sidebar footer instead (and this overlay is hidden). Offsets itself for
|
||||
* the workspace rail (48px) and huddle drawer when present.
|
||||
*
|
||||
* Also surfaces non-unreachable disconnect errors (e.g. auth rejections)
|
||||
* when the sidebar is hidden, since those errors are only rendered inside
|
||||
* the sidebar content area which is off-canvas when collapsed.
|
||||
*/
|
||||
export function RelayConnectionOverlay({
|
||||
card,
|
||||
errorMessage,
|
||||
hasWorkspaceRail,
|
||||
isHuddleDrawerOpen,
|
||||
}: RelayConnectionOverlayProps) {
|
||||
const { open: sidebarOpen, openMobile } = useSidebar();
|
||||
const isMobile = useIsMobile();
|
||||
const connectionState = useRelayConnection();
|
||||
|
||||
// Show the overlay when the sidebar surface isn't visible:
|
||||
// - Desktop: sidebar is collapsed (open === false)
|
||||
// - Mobile: the sheet is closed (openMobile === false)
|
||||
const isSidebarSurfaceHidden = isMobile ? !openMobile : !sidebarOpen;
|
||||
const shouldShowReconnectCard =
|
||||
card.showSidebarRelayConnectionCard && isSidebarSurfaceHidden;
|
||||
|
||||
// Show a non-unreachable error (e.g. auth rejection) when the sidebar is
|
||||
// hidden and the reconnect card isn't already covering it.
|
||||
const hasNonUnreachableError =
|
||||
Boolean(errorMessage) &&
|
||||
!card.hasRelayUnreachableError &&
|
||||
connectionState === "disconnected";
|
||||
const shouldShowErrorFallback =
|
||||
hasNonUnreachableError &&
|
||||
isSidebarSurfaceHidden &&
|
||||
!shouldShowReconnectCard;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{shouldShowReconnectCard ? (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={cn(
|
||||
"pointer-events-none fixed z-50 w-[284px]",
|
||||
hasWorkspaceRail ? "left-[60px]" : "left-3",
|
||||
isHuddleDrawerOpen
|
||||
? "bottom-[calc(var(--buzz-huddle-drawer-height,0px)+12px)]"
|
||||
: "bottom-3",
|
||||
)}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
key="relay-connection-overlay"
|
||||
transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
<div className="pointer-events-auto rounded-xl bg-background shadow-md">
|
||||
<SidebarRelayConnectionCard
|
||||
isConnected={card.isRelayConnectionSuccess}
|
||||
isReconnectPending={card.isRelayReconnectPending}
|
||||
isWaitingOnReconnectHook={card.isWaitingOnReconnectHook}
|
||||
onDismiss={card.onDismissRelayConnectionCard}
|
||||
onReconnect={card.onReconnectRelay}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
{shouldShowErrorFallback ? (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={cn(
|
||||
"pointer-events-none fixed z-50 w-[284px]",
|
||||
hasWorkspaceRail ? "left-[60px]" : "left-3",
|
||||
isHuddleDrawerOpen
|
||||
? "bottom-[calc(var(--buzz-huddle-drawer-height,0px)+12px)]"
|
||||
: "bottom-3",
|
||||
)}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
key="relay-error-overlay"
|
||||
transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
<div
|
||||
className="pointer-events-auto flex items-center gap-2 rounded-xl bg-background px-3 py-2.5 text-sm text-destructive shadow-md"
|
||||
data-testid="relay-error-overlay"
|
||||
role="alert"
|
||||
>
|
||||
<AlertCircle aria-hidden="true" className="h-4 w-4 shrink-0" />
|
||||
<span className="flex-1">{errorMessage}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
// biome-ignore format: keep compact to stay within file size limit
|
||||
import { MessageCirclePlus } from "lucide-react";
|
||||
|
||||
import * as React from "react";
|
||||
import { AnimatePresence } from "motion/react";
|
||||
import { FeatureGate } from "@/shared/features";
|
||||
import { SidebarDndContext } from "@/features/sidebar/ui/SidebarDnd";
|
||||
|
||||
import type { Workspace } from "@/features/workspaces/types";
|
||||
import { AddWorkspaceDialog } from "@/features/workspaces/ui/AddWorkspaceDialog";
|
||||
import { useIsMobile } from "@/shared/hooks/use-mobile";
|
||||
import { useDeferredLoad } from "@/shared/hooks/useDeferredStartup";
|
||||
import {
|
||||
useChannelSections,
|
||||
@@ -34,7 +35,7 @@ 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 type { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard";
|
||||
import {
|
||||
SidebarLoadingContent,
|
||||
useSidebarLoadingShape,
|
||||
@@ -62,6 +63,7 @@ import {
|
||||
SidebarMenu,
|
||||
SidebarMenuItem,
|
||||
SidebarRail,
|
||||
useSidebar,
|
||||
} from "@/shared/ui/sidebar";
|
||||
|
||||
type CollapsibleSidebarGroup =
|
||||
@@ -84,6 +86,7 @@ type AppSidebarProps = {
|
||||
isCreatingForum: boolean;
|
||||
isOpeningDm: boolean;
|
||||
profile?: Profile;
|
||||
relayConnectionCard: ReturnType<typeof useSidebarRelayConnectionCard>;
|
||||
selfPresenceStatus: PresenceStatus;
|
||||
errorMessage?: string;
|
||||
selectedChannelId: string | null;
|
||||
@@ -174,6 +177,7 @@ export function AppSidebar({
|
||||
isCreatingForum,
|
||||
isOpeningDm,
|
||||
profile,
|
||||
relayConnectionCard,
|
||||
selfPresenceStatus,
|
||||
errorMessage,
|
||||
selectedChannelId,
|
||||
@@ -225,10 +229,8 @@ export function AppSidebar({
|
||||
const activeWorkingByChannelId = useActiveWorkingChannelsById();
|
||||
const { status: updateStatus } = useUpdaterContext();
|
||||
const canShowSidebarUpdateCard = shouldShowSidebarUpdateCard(updateStatus);
|
||||
const sidebarRelayConnectionCard = useSidebarRelayConnectionCard(
|
||||
errorMessage,
|
||||
activeWorkspace?.relayUrl,
|
||||
);
|
||||
const { open: sidebarOpen, openMobile } = useSidebar();
|
||||
const isMobile = useIsMobile();
|
||||
const [isSidebarUpdateCardDismissed, setIsSidebarUpdateCardDismissed] =
|
||||
React.useState(false);
|
||||
const showSidebarUpdateCard =
|
||||
@@ -741,8 +743,7 @@ export function AppSidebar({
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{errorMessage &&
|
||||
!sidebarRelayConnectionCard.hasRelayUnreachableError ? (
|
||||
{errorMessage && !relayConnectionCard.hasRelayUnreachableError ? (
|
||||
<div className="px-3 py-2 text-sm text-destructive">
|
||||
{errorMessage}
|
||||
</div>
|
||||
@@ -762,27 +763,19 @@ export function AppSidebar({
|
||||
) : null}
|
||||
|
||||
<SidebarFooter>
|
||||
<AnimatePresence>
|
||||
{sidebarRelayConnectionCard.showSidebarRelayConnectionCard ? (
|
||||
<SidebarRelayConnectionCard
|
||||
className="mb-2 group-data-[collapsible=icon]:hidden"
|
||||
isConnected={
|
||||
sidebarRelayConnectionCard.isRelayConnectionSuccess
|
||||
}
|
||||
isReconnectPending={
|
||||
sidebarRelayConnectionCard.isRelayReconnectPending
|
||||
}
|
||||
isWaitingOnReconnectHook={
|
||||
sidebarRelayConnectionCard.isWaitingOnReconnectHook
|
||||
}
|
||||
onDismiss={
|
||||
sidebarRelayConnectionCard.onDismissRelayConnectionCard
|
||||
}
|
||||
onReconnect={sidebarRelayConnectionCard.onReconnectRelay}
|
||||
key="sidebar-relay-connection-card"
|
||||
/>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
{relayConnectionCard.showSidebarRelayConnectionCard &&
|
||||
(isMobile ? openMobile : sidebarOpen) ? (
|
||||
<SidebarRelayConnectionCard
|
||||
className="mb-2"
|
||||
isConnected={relayConnectionCard.isRelayConnectionSuccess}
|
||||
isReconnectPending={relayConnectionCard.isRelayReconnectPending}
|
||||
isWaitingOnReconnectHook={
|
||||
relayConnectionCard.isWaitingOnReconnectHook
|
||||
}
|
||||
onDismiss={relayConnectionCard.onDismissRelayConnectionCard}
|
||||
onReconnect={relayConnectionCard.onReconnectRelay}
|
||||
/>
|
||||
) : null}
|
||||
{showSidebarUpdateCard ? (
|
||||
<div className="mb-2 group-data-[collapsible=icon]:hidden">
|
||||
<SidebarUpdateCard
|
||||
|
||||
@@ -68,9 +68,16 @@ export function useSidebarRelayConnectionCard(
|
||||
const hasRelayUnreachableError = errorMessage
|
||||
? isRelayUnreachableError(errorMessage)
|
||||
: false;
|
||||
// True when the error is an application-level issue (e.g. auth rejection)
|
||||
// rather than a network-level relay-unreachable error. In this case, the
|
||||
// disconnected state should NOT trigger the reconnect card — the app shows
|
||||
// a dedicated error path instead.
|
||||
const hasNonUnreachableError =
|
||||
Boolean(errorMessage) && !hasRelayUnreachableError;
|
||||
const isRelayConnectionStateDegraded =
|
||||
relayConnectionState === "reconnecting" ||
|
||||
relayConnectionState === "stalled";
|
||||
relayConnectionState === "stalled" ||
|
||||
(relayConnectionState === "disconnected" && !hasNonUnreachableError);
|
||||
const isRelayConnectionConnected = relayConnectionState === "connected";
|
||||
const isRelayConnectionDisconnected = relayConnectionState === "disconnected";
|
||||
const [isDismissed, setIsDismissed] = React.useState(false);
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { WifiOff } from "lucide-react";
|
||||
|
||||
import {
|
||||
isRelayConnectionDegraded,
|
||||
useRelayConnection,
|
||||
} 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…",
|
||||
stalled: "Connection lost — relay is not responding.",
|
||||
disconnected: "Disconnected from relay.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Thin warning strip surfaced when the relay connection is degraded.
|
||||
*
|
||||
* Renders null while the connection is healthy so it takes up no layout space.
|
||||
* The strip auto-disappears once the state transitions back to "connected" —
|
||||
* no success toast needed.
|
||||
*/
|
||||
type ConnectionBannerProps = {
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
export function ConnectionBanner({ errorMessage }: ConnectionBannerProps) {
|
||||
const state = useRelayConnection();
|
||||
const { isPending, isWaitingOnReconnectHook, reconnect } =
|
||||
useReconnectRelay();
|
||||
const { state: sidebarState } = useSidebar();
|
||||
const hasCollapsedRelayError =
|
||||
sidebarState === "collapsed" &&
|
||||
state !== "connected" &&
|
||||
Boolean(errorMessage && isRelayUnreachableError(errorMessage));
|
||||
|
||||
if (!isRelayConnectionDegraded(state) && !hasCollapsedRelayError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const message = hasCollapsedRelayError
|
||||
? "Can't reach the relay."
|
||||
: (COPY[state] ?? "Connection issue detected.");
|
||||
|
||||
const buttonLabel = isWaitingOnReconnectHook
|
||||
? "Waiting to reconnect…"
|
||||
: isPending
|
||||
? "Reconnecting…"
|
||||
: "Reconnect";
|
||||
|
||||
return (
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<WifiOff className="h-3 w-3 shrink-0 text-warning" />
|
||||
<span className="flex-1 text-muted-foreground">{message}</span>
|
||||
<button
|
||||
className="font-medium text-warning hover:underline disabled:opacity-50"
|
||||
data-testid="connection-banner-reconnect"
|
||||
disabled={isPending}
|
||||
onClick={reconnect}
|
||||
type="button"
|
||||
>
|
||||
{buttonLabel}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -264,7 +264,10 @@ export function SidebarCompactActionCard({
|
||||
type="button"
|
||||
>
|
||||
<motion.span
|
||||
className="relative top-[0.1875rem] flex min-h-10 min-w-0 flex-1 flex-col justify-center"
|
||||
className={cn(
|
||||
"relative flex min-h-10 min-w-0 flex-1 flex-col justify-center",
|
||||
description && "top-[0.1875rem]",
|
||||
)}
|
||||
layout="position"
|
||||
transition={contentTransition}
|
||||
>
|
||||
|
||||
@@ -133,14 +133,12 @@ test("sidebar access failures use the reconnect card", async ({ page }) => {
|
||||
await expectGenericReconnectCard(page);
|
||||
});
|
||||
|
||||
test("collapsed sidebar relay failures use the connection banner", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("collapsed sidebar still shows the reconnect card", async ({ page }) => {
|
||||
await installMockBridge(page, { channelsReadError: CONNECT_ERROR });
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
// Drive degraded state so the card (and subsequently banner) appears.
|
||||
// Drive degraded state so the card appears.
|
||||
await setRelayConnectionState(page, "disconnected");
|
||||
|
||||
await expectGenericReconnectCard(page);
|
||||
@@ -152,15 +150,17 @@ test("collapsed sidebar relay failures use the connection banner", async ({
|
||||
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.");
|
||||
// The card remains visible via the fixed overlay even with sidebar collapsed.
|
||||
const card = page.getByTestId("sidebar-relay-unreachable");
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card).toContainText("Can't reach the relay");
|
||||
|
||||
await setChannelsReadError(page, null);
|
||||
await page.getByTestId("connection-banner-reconnect").click();
|
||||
// Drive connected so the banner shows "Connected" and auto-dismisses.
|
||||
await page.getByTestId("sidebar-reconnect").click();
|
||||
// Drive connected so the card shows success and auto-dismisses.
|
||||
await setRelayConnectionState(page, "connected");
|
||||
await expect(banner).toBeHidden({ timeout: 10_000 });
|
||||
await expect(card).toContainText("Connected");
|
||||
await expect(card).toBeHidden({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("sidebar stalled relay state uses the reconnect card", async ({
|
||||
|
||||
Reference in New Issue
Block a user