Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 0 additions & 17 deletions desktop/src/features/profile/ui/ProfilePopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -68,7 +64,6 @@ export function ProfilePopover({
onOpenSettings,
children,
triggerContainerRef,
workspaceSwitcherSlot,
}: ProfilePopoverProps) {
const [statusDialogOpen, setStatusDialogOpen] = React.useState(false);
const [presenceMenuOpen, setPresenceMenuOpen] = React.useState(false);
Expand Down Expand Up @@ -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}
Expand All @@ -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>
Expand Down
18 changes: 12 additions & 6 deletions desktop/src/features/sidebar/ui/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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>
Expand Down
94 changes: 19 additions & 75 deletions desktop/src/features/sidebar/ui/SidebarProfileCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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.
Expand Down Expand Up @@ -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) => {
Expand All @@ -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>
Expand Down
93 changes: 86 additions & 7 deletions desktop/src/features/workspaces/ui/WorkspaceSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {
Expand All @@ -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: (
Expand All @@ -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>
);
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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={
Expand Down Expand Up @@ -336,6 +413,8 @@ export function WorkspaceSwitcher({
switcherDropdown
) : variant === "profile-menu" ? (
profileMenuPopover
) : isSidebarCardVariant ? (
switcherDropdown
) : (
<SidebarMenu>
<SidebarMenuItem>{switcherDropdown}</SidebarMenuItem>
Expand Down