mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Move workspace switcher to sidebar header
This commit is contained in:
@@ -33,10 +33,6 @@ interface ProfilePopoverProps {
|
||||
// Used when auxiliary triggers (avatar, status text) live alongside the
|
||||
// primary PopoverTrigger and toggle the popover via controlled `open`.
|
||||
triggerContainerRef?: React.RefObject<HTMLElement | null>;
|
||||
// Optional slot rendered between the identity block and the menu items.
|
||||
// Used by the sidebar to surface the workspace/relay selector inside the
|
||||
// profile menu instead of on the sidebar card.
|
||||
workspaceSwitcherSlot?: React.ReactNode;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -68,7 +64,6 @@ export function ProfilePopover({
|
||||
onOpenSettings,
|
||||
children,
|
||||
triggerContainerRef,
|
||||
workspaceSwitcherSlot,
|
||||
}: ProfilePopoverProps) {
|
||||
const [statusDialogOpen, setStatusDialogOpen] = React.useState(false);
|
||||
const [presenceMenuOpen, setPresenceMenuOpen] = React.useState(false);
|
||||
@@ -250,8 +245,6 @@ export function ProfilePopover({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<hr className="my-1 h-px border-0 bg-border" />
|
||||
|
||||
{/* ── Settings ───────────────────────────────────────── */}
|
||||
<button
|
||||
className={MENU_ITEM_CLASS}
|
||||
@@ -270,16 +263,6 @@ export function ProfilePopover({
|
||||
{settingsShortcutLabel}
|
||||
</kbd>
|
||||
</button>
|
||||
|
||||
{workspaceSwitcherSlot ? (
|
||||
<>
|
||||
<hr className="my-1 h-px border-0 bg-border" />
|
||||
{/* ── Workspace / relay selector ─────────────────── */}
|
||||
<div data-testid="profile-popover-workspace">
|
||||
{workspaceSwitcherSlot}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { TopbarSearch } from "@/features/search/ui/TopbarSearch";
|
||||
|
||||
import type { Workspace } from "@/features/workspaces/types";
|
||||
import { AddWorkspaceDialog } from "@/features/workspaces/ui/AddWorkspaceDialog";
|
||||
import { WorkspaceSwitcher } from "@/features/workspaces/ui/WorkspaceSwitcher";
|
||||
import { useDeferredLoad } from "@/shared/hooks/useDeferredStartup";
|
||||
import {
|
||||
useChannelSections,
|
||||
@@ -529,6 +530,17 @@ export function AppSidebar({
|
||||
className="mt-(--buzz-top-chrome-height,2.5rem) shrink-0 px-2 pt-2"
|
||||
data-testid="sidebar-pinned-header"
|
||||
>
|
||||
<div className="mb-2.5 group-data-[collapsible=icon]:hidden">
|
||||
<WorkspaceSwitcher
|
||||
activeWorkspace={activeWorkspace}
|
||||
onAddWorkspace={onOpenAddWorkspace}
|
||||
onRemoveWorkspace={onRemoveWorkspace}
|
||||
onSwitchWorkspace={onSwitchWorkspace}
|
||||
onUpdateWorkspace={onUpdateWorkspace}
|
||||
variant="sidebar-card"
|
||||
workspaces={workspaces}
|
||||
/>
|
||||
</div>
|
||||
<TopbarSearch
|
||||
channels={searchChannels}
|
||||
currentPubkey={currentPubkey}
|
||||
@@ -860,21 +872,15 @@ export function AppSidebar({
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarProfileCard
|
||||
activeWorkspace={activeWorkspace}
|
||||
isPresencePending={isPresencePending}
|
||||
onOpenAddWorkspace={onOpenAddWorkspace}
|
||||
onOpenSettings={onSelectSettings}
|
||||
onRemoveWorkspace={onRemoveWorkspace}
|
||||
onSetPresenceStatus={onSetPresenceStatus}
|
||||
onSetUserStatus={onSetUserStatus}
|
||||
onClearUserStatus={onClearUserStatus}
|
||||
onSwitchWorkspace={onSwitchWorkspace}
|
||||
onUpdateWorkspace={onUpdateWorkspace}
|
||||
profile={profile}
|
||||
resolvedDisplayName={resolvedDisplayName}
|
||||
selfPresenceStatus={selfPresenceStatus}
|
||||
selfUserStatus={selfUserStatus}
|
||||
workspaces={workspaces}
|
||||
/>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
|
||||
@@ -6,48 +6,30 @@ import { useSelfProfileCache } from "@/features/profile/hooks";
|
||||
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
|
||||
import { ProfilePopover } from "@/features/profile/ui/ProfilePopover";
|
||||
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 { cn } from "@/shared/lib/cn";
|
||||
|
||||
type SidebarProfileCardProps = {
|
||||
activeWorkspace: Workspace | null;
|
||||
isPresencePending?: boolean;
|
||||
onOpenAddWorkspace: () => void;
|
||||
onOpenSettings: (section?: "profile" | "appearance") => void;
|
||||
onRemoveWorkspace: (id: string) => void;
|
||||
onSetPresenceStatus?: (status: PresenceStatus) => void;
|
||||
onSetUserStatus: (text: string, emoji: string) => void;
|
||||
onClearUserStatus: () => void;
|
||||
onSwitchWorkspace: (id: string) => void;
|
||||
onUpdateWorkspace: (
|
||||
id: string,
|
||||
updates: Partial<Pick<Workspace, "name" | "relayUrl" | "token">>,
|
||||
) => void;
|
||||
profile?: Profile;
|
||||
resolvedDisplayName: string;
|
||||
selfPresenceStatus: PresenceStatus;
|
||||
selfUserStatus?: UserStatus;
|
||||
workspaces: Workspace[];
|
||||
};
|
||||
|
||||
export function SidebarProfileCard({
|
||||
activeWorkspace,
|
||||
isPresencePending,
|
||||
onOpenAddWorkspace,
|
||||
onOpenSettings,
|
||||
onRemoveWorkspace,
|
||||
onSetPresenceStatus,
|
||||
onSetUserStatus,
|
||||
onClearUserStatus,
|
||||
onSwitchWorkspace,
|
||||
onUpdateWorkspace,
|
||||
profile,
|
||||
resolvedDisplayName,
|
||||
selfPresenceStatus,
|
||||
selfUserStatus,
|
||||
workspaces,
|
||||
}: SidebarProfileCardProps) {
|
||||
const selfProfileCache = useSelfProfileCache();
|
||||
const [profilePopoverOpen, setProfilePopoverOpen] = React.useState(false);
|
||||
@@ -70,18 +52,6 @@ export function SidebarProfileCard({
|
||||
[toggleProfilePopover],
|
||||
);
|
||||
const hasStatus = Boolean(selfUserStatus?.text || selfUserStatus?.emoji);
|
||||
const workspaceLabel = activeWorkspace?.name ?? "No workspace";
|
||||
const readonlyWorkspaceLabel = (
|
||||
<span className="flex min-w-0 cursor-pointer items-center gap-1 text-xs leading-snug text-sidebar-foreground/70">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex w-3.5 shrink-0 items-center justify-center text-2xs"
|
||||
>
|
||||
<span className="-translate-y-px leading-normal">🐝</span>
|
||||
</span>
|
||||
<span className="truncate">{workspaceLabel}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions lint/a11y/useKeyWithClickEvents: child buttons provide keyboard access; wrapper fills pointer gaps between them.
|
||||
@@ -136,17 +106,6 @@ export function SidebarProfileCard({
|
||||
triggerContainerRef={profileCardRef}
|
||||
userStatusEmoji={selfUserStatus?.emoji}
|
||||
userStatusText={selfUserStatus?.text}
|
||||
workspaceSwitcherSlot={
|
||||
<WorkspaceSwitcher
|
||||
activeWorkspace={activeWorkspace}
|
||||
onAddWorkspace={onOpenAddWorkspace}
|
||||
onRemoveWorkspace={onRemoveWorkspace}
|
||||
onSwitchWorkspace={onSwitchWorkspace}
|
||||
onUpdateWorkspace={onUpdateWorkspace}
|
||||
variant="profile-menu"
|
||||
workspaces={workspaces}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<button
|
||||
onClick={(event) => {
|
||||
@@ -167,40 +126,25 @@ export function SidebarProfileCard({
|
||||
</ProfilePopover>
|
||||
|
||||
{hasStatus ? (
|
||||
<div className="relative mt-0.5">
|
||||
<button
|
||||
aria-label={`Open profile menu for ${resolvedDisplayName}`}
|
||||
className={cn(
|
||||
"flex w-full min-w-0 items-center truncate rounded-sm text-left text-xs leading-snug text-sidebar-foreground/70 outline-hidden transition-opacity duration-150 focus:outline-none focus-visible:outline-none group-hover/profile-card:opacity-0",
|
||||
profilePopoverOpen && "opacity-100",
|
||||
)}
|
||||
data-testid="sidebar-profile-user-status"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
toggleProfilePopover();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{selfUserStatus?.emoji ? (
|
||||
<StatusEmoji
|
||||
className="mr-1 w-4 shrink-0 text-xs"
|
||||
value={selfUserStatus.emoji}
|
||||
/>
|
||||
) : null}
|
||||
<span className="truncate">{selfUserStatus?.text}</span>
|
||||
</button>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 flex min-w-0 items-center text-xs leading-snug text-sidebar-foreground/70 opacity-0 transition-opacity duration-150 group-hover/profile-card:opacity-100",
|
||||
profilePopoverOpen && "opacity-0",
|
||||
)}
|
||||
>
|
||||
{readonlyWorkspaceLabel}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative mt-0.5">{readonlyWorkspaceLabel}</div>
|
||||
)}
|
||||
<button
|
||||
aria-label={`Open profile menu for ${resolvedDisplayName}`}
|
||||
className="mt-0.5 flex w-full min-w-0 items-center truncate rounded-sm text-left text-xs leading-snug text-sidebar-foreground/70 outline-hidden focus:outline-none focus-visible:outline-none"
|
||||
data-testid="sidebar-profile-user-status"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
toggleProfilePopover();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{selfUserStatus?.emoji ? (
|
||||
<StatusEmoji
|
||||
className="mr-1 w-4 shrink-0 text-xs"
|
||||
value={selfUserStatus.emoji}
|
||||
/>
|
||||
) : null}
|
||||
<span className="truncate">{selfUserStatus?.text}</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
isRelayConnectionDegraded,
|
||||
useRelayConnection,
|
||||
} from "@/shared/api/useRelayConnection";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { EditWorkspaceDialog } from "./EditWorkspaceDialog";
|
||||
|
||||
const CONNECTION_STATE_LABEL: Record<ConnectionState, string> = {
|
||||
@@ -42,7 +43,7 @@ const CONNECTION_STATE_LABEL: Record<ConnectionState, string> = {
|
||||
type WorkspaceSwitcherProps = {
|
||||
activeWorkspace: Workspace | null;
|
||||
workspaces: Workspace[];
|
||||
variant?: "sidebar" | "profile" | "profile-menu";
|
||||
variant?: "sidebar" | "sidebar-card" | "profile" | "profile-menu";
|
||||
onSwitchWorkspace: (id: string) => void;
|
||||
onAddWorkspace: () => void;
|
||||
onUpdateWorkspace: (
|
||||
@@ -52,10 +53,43 @@ type WorkspaceSwitcherProps = {
|
||||
onRemoveWorkspace: (id: string) => void;
|
||||
};
|
||||
|
||||
function WorkspaceEmojiIcon({ className }: { className: string }) {
|
||||
function getWorkspaceInitial(name: string): string {
|
||||
return name.trim().charAt(0).toUpperCase() || "W";
|
||||
}
|
||||
|
||||
function WorkspaceAvatar({
|
||||
className,
|
||||
emoji,
|
||||
imageUrl,
|
||||
name,
|
||||
}: {
|
||||
className: string;
|
||||
emoji?: string;
|
||||
imageUrl?: string | null;
|
||||
name: string;
|
||||
}) {
|
||||
return (
|
||||
<span aria-hidden="true" className={className}>
|
||||
<span className="-translate-y-px leading-normal">🐝</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"overflow-hidden rounded-xl bg-sidebar-accent/40 text-sidebar-foreground/80",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<img
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
draggable={false}
|
||||
src={imageUrl}
|
||||
/>
|
||||
) : emoji ? (
|
||||
<span className="-translate-y-px leading-normal">{emoji}</span>
|
||||
) : (
|
||||
<span className="font-medium leading-none">
|
||||
{getWorkspaceInitial(name)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -77,6 +111,8 @@ export function WorkspaceSwitcher({
|
||||
const degraded = isRelayConnectionDegraded(connectionState);
|
||||
const connectionLabel = CONNECTION_STATE_LABEL[connectionState];
|
||||
const isProfileVariant = variant === "profile";
|
||||
const isSidebarCardVariant = variant === "sidebar-card";
|
||||
const workspaceName = activeWorkspace?.name ?? "No workspace";
|
||||
|
||||
function clearProfileMenuHoverTimer() {
|
||||
if (profileMenuHoverTimer.current !== null) {
|
||||
@@ -137,12 +173,14 @@ export function WorkspaceSwitcher({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<WorkspaceEmojiIcon
|
||||
<WorkspaceAvatar
|
||||
className={
|
||||
isProfileVariant
|
||||
? "flex w-5 shrink-0 items-center justify-center rounded-md border border-sidebar-border/70 bg-sidebar-accent/40 text-2xs"
|
||||
: "flex w-5 shrink-0 items-center justify-center text-xs"
|
||||
? "flex h-5 w-5 shrink-0 items-center justify-center rounded-md text-2xs"
|
||||
: "flex h-5 w-5 shrink-0 items-center justify-center text-xs"
|
||||
}
|
||||
emoji="🐝"
|
||||
name={workspaceName}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
@@ -270,6 +308,45 @@ export function WorkspaceSwitcher({
|
||||
>
|
||||
{triggerContent}
|
||||
</button>
|
||||
) : isSidebarCardVariant ? (
|
||||
<button
|
||||
aria-label={
|
||||
degraded
|
||||
? `${workspaceName} — ${connectionLabel}`
|
||||
: `Switch workspace: ${workspaceName}`
|
||||
}
|
||||
className="group/workspace-card inline-flex max-w-full min-w-0 items-center gap-0.5 rounded-xl px-2 py-1.5 text-left text-sidebar-foreground outline-hidden transition-colors hover:bg-sidebar-border/35 focus:outline-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-sidebar-ring dark:hover:bg-sidebar-border/30"
|
||||
data-testid="sidebar-workspace-card"
|
||||
type="button"
|
||||
>
|
||||
{degraded ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="-ml-1.5 flex h-7 w-7 shrink-0 items-center justify-center text-sm"
|
||||
>
|
||||
<WifiOff className="h-3.5 w-3.5 text-destructive" />
|
||||
</span>
|
||||
) : (
|
||||
<WorkspaceAvatar
|
||||
className="-ml-1.5 flex h-7 w-7 shrink-0 items-center justify-center text-sm"
|
||||
emoji="🐝"
|
||||
name={workspaceName}
|
||||
/>
|
||||
)}
|
||||
<span className="flex min-w-0 max-w-full items-center gap-1">
|
||||
<span
|
||||
className={
|
||||
degraded
|
||||
? "min-w-0 truncate text-sm leading-tight text-destructive"
|
||||
: "min-w-0 truncate text-sm leading-tight text-sidebar-foreground"
|
||||
}
|
||||
data-testid="sidebar-workspace-name"
|
||||
>
|
||||
{workspaceName}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-sidebar-foreground/45 opacity-0 transition-[opacity,transform] group-hover/workspace-card:opacity-100 group-focus-visible/workspace-card:opacity-100 group-data-[state=open]/workspace-card:rotate-180 group-data-[state=open]/workspace-card:opacity-100" />
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<SidebarMenuButton
|
||||
aria-label={
|
||||
@@ -336,6 +413,8 @@ export function WorkspaceSwitcher({
|
||||
switcherDropdown
|
||||
) : variant === "profile-menu" ? (
|
||||
profileMenuPopover
|
||||
) : isSidebarCardVariant ? (
|
||||
switcherDropdown
|
||||
) : (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>{switcherDropdown}</SidebarMenuItem>
|
||||
|
||||
Reference in New Issue
Block a user