From c91fabe54c8d73d4cd9bb03fecdca517720379c0 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Wed, 3 Jun 2026 11:23:53 -0400 Subject: [PATCH] fix(desktop): refine profile menu interactions Polish the sidebar profile card, profile popover, and settings entry points so status, workspace, and preferences are easier to discover without layout churn. Co-authored-by: Cursor --- desktop/src/app/AppShell.tsx | 7 +- .../features/profile/ui/ProfilePopover.tsx | 265 +++++++++--------- .../features/settings/ui/SettingsPanels.tsx | 10 +- .../features/settings/ui/SettingsScreen.tsx | 3 + .../src/features/settings/ui/SettingsView.tsx | 87 +++--- .../src/features/sidebar/ui/AppSidebar.tsx | 100 ++----- .../sidebar/ui/SidebarProfileCard.tsx | 175 ++++++++++++ desktop/src/features/user-status/hooks.ts | 5 + .../workspaces/ui/WorkspaceSwitcher.tsx | 51 ++-- desktop/src/shared/styles/globals.css | 24 ++ desktop/tests/helpers/settings.ts | 8 +- 11 files changed, 470 insertions(+), 265 deletions(-) create mode 100644 desktop/src/features/sidebar/ui/SidebarProfileCard.tsx diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 8ff8eadc4..3a7dea54b 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -169,6 +169,9 @@ export function AppShell() { const [settingsSection, setSettingsSection] = React.useState( DEFAULT_SETTINGS_SECTION, ); + const [settingsMode, setSettingsMode] = React.useState< + "profile" | "preferences" + >("preferences"); const [isChannelManagementOpen, setIsChannelManagementOpen] = React.useState(false); @@ -402,8 +405,9 @@ export function AppShell() { ); const handleOpenSettings = React.useCallback( - (section: SettingsSection = DEFAULT_SETTINGS_SECTION) => { + (section: SettingsSection = "appearance") => { setIsChannelManagementOpen(false); + setSettingsMode(section === "profile" ? "profile" : "preferences"); setSettingsSection(section); setSettingsOpen(true); }, @@ -845,6 +849,7 @@ export function AppShell() { notificationSettings={notificationSettings.settings} onClose={handleCloseSettings} onSectionChange={setSettingsSection} + mode={settingsMode} onSetDesktopNotificationsEnabled={ notificationSettings.setDesktopEnabled } diff --git a/desktop/src/features/profile/ui/ProfilePopover.tsx b/desktop/src/features/profile/ui/ProfilePopover.tsx index 515870736..5f98e9fb5 100644 --- a/desktop/src/features/profile/ui/ProfilePopover.tsx +++ b/desktop/src/features/profile/ui/ProfilePopover.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { ChevronRight, MessageSquare, Settings } from "lucide-react"; +import { ChevronRight, Smile } from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; @@ -16,7 +16,6 @@ interface ProfilePopoverProps { open: boolean; onOpenChange: (open: boolean) => void; displayName: string; - nip05?: string | null; avatarUrl: string | null; currentStatus: PresenceStatus; isStatusPending?: boolean; @@ -25,8 +24,16 @@ interface ProfilePopoverProps { onSetStatus: (status: PresenceStatus) => void; onSetUserStatus: (text: string, emoji: string) => void; onClearUserStatus: () => void; - onOpenSettings: () => void; + onOpenSettings: (section?: "profile" | "appearance") => void; children: React.ReactNode; + // Optional outer container whose clicks should NOT close the popover. + // Used when auxiliary triggers (avatar, status text) live alongside the + // primary PopoverTrigger and toggle the popover via controlled `open`. + triggerContainerRef?: React.RefObject; + // 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; } // --------------------------------------------------------------------------- @@ -34,7 +41,7 @@ interface ProfilePopoverProps { // --------------------------------------------------------------------------- const MENU_ITEM_CLASS = - "flex w-full items-center gap-3 px-3 py-2.5 rounded-lg text-left hover:bg-accent cursor-pointer transition-colors"; + "flex w-full items-center gap-2 px-4 py-2 text-left text-sm text-popover-foreground hover:bg-accent focus-visible:bg-accent cursor-pointer transition-colors outline-hidden focus:outline-none focus-visible:outline-none"; const ALL_STATUSES: PresenceStatus[] = ["online", "away", "offline"]; @@ -46,7 +53,6 @@ export function ProfilePopover({ open, onOpenChange, displayName, - nip05, avatarUrl, currentStatus, isStatusPending, @@ -57,10 +63,9 @@ export function ProfilePopover({ onClearUserStatus, onOpenSettings, children, + triggerContainerRef, + workspaceSwitcherSlot, }: ProfilePopoverProps) { - const isMac = - typeof navigator !== "undefined" && - /Mac|iPod|iPhone|iPad/.test(navigator.userAgent); const [statusDialogOpen, setStatusDialogOpen] = React.useState(false); const [presenceMenuOpen, setPresenceMenuOpen] = React.useState(false); const presenceHoverTimer = React.useRef(null); @@ -119,10 +124,19 @@ export function ProfilePopover({ sideOffset={8} className="w-[280px] rounded-xl border border-border bg-popover p-0 shadow-lg" data-testid="profile-popover" + onInteractOutside={(event) => { + const target = event.target as Node | null; + if (target && triggerContainerRef?.current?.contains(target)) { + // Click on an auxiliary trigger inside the same card + // (e.g. avatar or status) — let that trigger toggle the + // controlled state instead of auto-closing here. + event.preventDefault(); + } + }} >
{/* ── Identity block ─────────────────────────────────── */} -
+
{displayName}

-
- {nip05 ? @{nip05} : null} - {nip05 ? : null} - - - {getPresenceLabel(currentStatus)} - -
- {hasUserStatus ? ( -

- {userStatusEmoji ? ( - {userStatusEmoji} - ) : null} - {userStatusText} -

- ) : null} +

+ + {getPresenceLabel(currentStatus)} +

-
- - {/* ── User status ──────────────────────────────────── */} -
+ {/* ── Status input (Slack-style) ──────────────────────── */} +
-
- - {/* ── Presence status options ───────────────────────── */} -
- - - - - + + - ))} -
- - -
- -
- - {/* ── Settings ───────────────────────────────────────── */} -
- + + schedulePresenceMenu(true)} + onMouseLeave={() => schedulePresenceMenu(false)} + side="right" + sideOffset={4} > - - - Settings - - - {isMac ? "⌘," : "Ctrl+,"} - - -
+
+ {ALL_STATUSES.map((status) => ( + + ))} +
+ + + +
+ + {/* ── Profile / preferences ──────────────────────────── */} + + + + {workspaceSwitcherSlot ? ( + <> +
+ {/* ── Workspace / relay selector ─────────────────── */} +
+ {workspaceSwitcherSlot} +
+ + ) : null} + +
diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index dadef25ab..e9f230197 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -78,6 +78,11 @@ export type SettingsPanelProps = { }; export const settingsSections: SettingsSectionDescriptor[] = [ + { + value: "appearance", + label: "Appearance", + icon: MonitorCog, + }, { value: "profile", label: "Profile", @@ -103,11 +108,6 @@ export const settingsSections: SettingsSectionDescriptor[] = [ label: "Compute", icon: Cpu, }, - { - value: "appearance", - label: "Appearance", - icon: MonitorCog, - }, { value: "shortcuts", label: "Shortcuts", diff --git a/desktop/src/features/settings/ui/SettingsScreen.tsx b/desktop/src/features/settings/ui/SettingsScreen.tsx index 03ef5e86d..c17c60667 100644 --- a/desktop/src/features/settings/ui/SettingsScreen.tsx +++ b/desktop/src/features/settings/ui/SettingsScreen.tsx @@ -7,6 +7,7 @@ type SettingsScreenProps = { currentPubkey?: string; fallbackDisplayName?: string; isUpdatingDesktopNotifications: boolean; + mode: "profile" | "preferences"; notificationErrorMessage: string | null; notificationPermission: DesktopNotificationPermissionState; notificationSettings: NotificationSettings; @@ -24,6 +25,7 @@ export function SettingsScreen({ currentPubkey, fallbackDisplayName, isUpdatingDesktopNotifications, + mode, notificationErrorMessage, notificationPermission, notificationSettings, @@ -41,6 +43,7 @@ export function SettingsScreen({ currentPubkey={currentPubkey} fallbackDisplayName={fallbackDisplayName} isUpdatingDesktopNotifications={isUpdatingDesktopNotifications} + mode={mode} notificationErrorMessage={notificationErrorMessage} notificationPermission={notificationPermission} notificationSettings={notificationSettings} diff --git a/desktop/src/features/settings/ui/SettingsView.tsx b/desktop/src/features/settings/ui/SettingsView.tsx index edd4fce79..26d1c77d1 100644 --- a/desktop/src/features/settings/ui/SettingsView.tsx +++ b/desktop/src/features/settings/ui/SettingsView.tsx @@ -17,6 +17,7 @@ export { } from "./SettingsPanels"; type SettingsViewProps = SettingsPanelProps & { + mode: "profile" | "preferences"; onClose: () => void; onSectionChange: (section: SettingsSection) => void; section: SettingsSection; @@ -69,6 +70,7 @@ export function SettingsView({ notificationErrorMessage, notificationPermission, notificationSettings, + mode, onClose, onSectionChange, onSetDesktopNotificationsEnabled, @@ -82,6 +84,9 @@ export function SettingsView({ const visibleSections = React.useMemo(() => { const membership = myMembershipQuery.data; return settingsSections.filter((s) => { + if (mode === "preferences" && s.value === "profile") { + return false; + } if (s.value === "relay-members") { return ( membership != null && @@ -90,7 +95,7 @@ export function SettingsView({ } return true; }); - }, [myMembershipQuery.data]); + }, [mode, myMembershipQuery.data]); const [isLoaded, setIsLoaded] = React.useState(false); const [appVersion, setAppVersion] = React.useState(null); @@ -103,10 +108,19 @@ export function SettingsView({ }, []); React.useEffect(() => { - if (!visibleSections.some((entry) => entry.value === section)) { - onSectionChange("profile"); + if (mode === "profile") { + if (section !== "profile") { + onSectionChange("profile"); + } + return; } - }, [onSectionChange, section, visibleSections]); + + if (!visibleSections.some((entry) => entry.value === section)) { + onSectionChange(visibleSections[0]?.value ?? "appearance"); + } + }, [mode, onSectionChange, section, visibleSections]); + + const showSectionNav = mode === "preferences"; React.useEffect(() => { function handleKeyDown(event: KeyboardEvent) { @@ -168,35 +182,44 @@ export function SettingsView({ -
- + + {appVersion ? ( +

+ v{appVersion} +

+ ) : null} + + ) : null}
void; onSelectHome: () => void; onSelectChannel: (channelId: string) => void; - onSelectSettings: () => void; + onSelectSettings: (section?: "profile" | "appearance") => void; onSetPresenceStatus?: (status: "online" | "away" | "offline") => void; onSetUserStatus: (text: string, emoji: string) => void; onClearUserStatus: () => void; @@ -212,7 +208,6 @@ export function AppSidebar({ const setIsNewDmOpen = onNewDmOpenChange ?? setIsNewDmOpenInternal; const scrollRef = React.useRef(null); useSidebarScrollLock(scrollRef); - const [profilePopoverOpen, setProfilePopoverOpen] = React.useState(false); const [createDialogKind, setCreateDialogKind] = React.useState(null); @@ -664,80 +659,23 @@ export function AppSidebar({ -
-
-
- - - - -
-
- {})} - onSetUserStatus={onSetUserStatus} - onClearUserStatus={onClearUserStatus} - onOpenSettings={onSelectSettings} - > - - - - {selfUserStatus?.text || selfUserStatus?.emoji ? ( -

- {selfUserStatus.emoji ? ( - {selfUserStatus.emoji} - ) : null} - {selfUserStatus.text} -

- ) : null} -
-
-
+
diff --git a/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx b/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx new file mode 100644 index 000000000..fcf489836 --- /dev/null +++ b/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx @@ -0,0 +1,175 @@ +import * as React from "react"; + +import { getPresenceLabel } from "@/features/presence/lib/presence"; +import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import { ProfilePopover } from "@/features/profile/ui/ProfilePopover"; +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; + onClearUserStatus: () => void; + onOpenAddWorkspace: () => void; + onOpenSettings: (section?: "profile" | "appearance") => void; + onRemoveWorkspace: (id: string) => void; + onSetPresenceStatus?: (status: PresenceStatus) => void; + onSetUserStatus: (text: string, emoji: string) => void; + onSwitchWorkspace: (id: string) => void; + onUpdateWorkspace: ( + id: string, + updates: Partial>, + ) => void; + profile?: Profile; + resolvedDisplayName: string; + selfPresenceStatus: PresenceStatus; + selfUserStatus?: UserStatus; + workspaces: Workspace[]; +}; + +export function SidebarProfileCard({ + activeWorkspace, + isPresencePending, + onClearUserStatus, + onOpenAddWorkspace, + onOpenSettings, + onRemoveWorkspace, + onSetPresenceStatus, + onSetUserStatus, + onSwitchWorkspace, + onUpdateWorkspace, + profile, + resolvedDisplayName, + selfPresenceStatus, + selfUserStatus, + workspaces, +}: SidebarProfileCardProps) { + const [profilePopoverOpen, setProfilePopoverOpen] = React.useState(false); + const profileCardRef = React.useRef(null); + const toggleProfilePopover = React.useCallback( + () => setProfilePopoverOpen((prev) => !prev), + [], + ); + const hasStatus = Boolean(selfUserStatus?.text || selfUserStatus?.emoji); + + return ( +
+
+ + +
+ {})} + onSetUserStatus={onSetUserStatus} + onClearUserStatus={onClearUserStatus} + onOpenSettings={onOpenSettings} + triggerContainerRef={profileCardRef} + workspaceSwitcherSlot={ + + } + > + + + + {hasStatus ? ( +
+ +
+ + {activeWorkspace?.name ?? "No workspace"} + +
+
+ ) : ( +
+ +
+ )} +
+
+
+ ); +} diff --git a/desktop/src/features/user-status/hooks.ts b/desktop/src/features/user-status/hooks.ts index 772bf7371..f5095efa5 100644 --- a/desktop/src/features/user-status/hooks.ts +++ b/desktop/src/features/user-status/hooks.ts @@ -166,6 +166,11 @@ export function useSetUserStatusMutation(pubkey?: string) { ? { text, emoji, updatedAt: Math.floor(Date.now() / 1_000) } : null; + queryClient.setQueryData( + userStatusQueryKey([normalizedPubkey]), + (old) => ({ ...(old ?? {}), [normalizedPubkey]: status }), + ); + queryClient.setQueriesData( { queryKey: ["user-status"] }, (old) => { diff --git a/desktop/src/features/workspaces/ui/WorkspaceSwitcher.tsx b/desktop/src/features/workspaces/ui/WorkspaceSwitcher.tsx index df40c6afd..9970b1a2f 100644 --- a/desktop/src/features/workspaces/ui/WorkspaceSwitcher.tsx +++ b/desktop/src/features/workspaces/ui/WorkspaceSwitcher.tsx @@ -1,6 +1,7 @@ import { Check, ChevronDown, + ChevronRight, MoreHorizontal, Plus, WifiOff, @@ -26,7 +27,6 @@ import { isRelayConnectionDegraded, useRelayConnection, } from "@/shared/api/useRelayConnection"; - import { EditWorkspaceDialog } from "./EditWorkspaceDialog"; const CONNECTION_STATE_LABEL: Record = { @@ -41,7 +41,7 @@ const CONNECTION_STATE_LABEL: Record = { type WorkspaceSwitcherProps = { activeWorkspace: Workspace | null; workspaces: Workspace[]; - variant?: "sidebar" | "profile"; + variant?: "sidebar" | "profile" | "profile-menu"; onSwitchWorkspace: (id: string) => void; onAddWorkspace: () => void; onUpdateWorkspace: ( @@ -66,35 +66,34 @@ export function WorkspaceSwitcher({ const connectionState = useRelayConnection(); const degraded = isRelayConnectionDegraded(connectionState); const connectionLabel = CONNECTION_STATE_LABEL[connectionState]; + const isProfileLike = variant === "profile" || variant === "profile-menu"; const triggerContent = ( <> - {degraded ? ( + {variant === "profile-menu" ? null : degraded ? ( - + - + {connectionLabel} ) : ( {activeWorkspace?.name ?? "No workspace"} - + {variant === "profile-menu" ? ( + + ) : ( + + )} ); const switcherDropdown = ( - {variant === "profile" ? ( + {isProfileLike ? (