Polish desktop profile menu interactions (#836)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
thomaspblock
2026-06-04 08:48:57 -07:00
committed by GitHub
co-authored by Cursor Wes Brain
parent 0d9b8148f8
commit a13691b620
15 changed files with 777 additions and 392 deletions
+12 -27
View File
@@ -12,6 +12,7 @@ import { AppTopChrome } from "@/app/AppTopChrome";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useBackForwardControls } from "@/app/navigation/useBackForwardControls";
import { useMarkAsReadShortcuts } from "@/app/useMarkAsReadShortcuts";
import { useSettingsShortcuts } from "@/app/useSettingsShortcuts";
import { useWebviewZoomShortcuts } from "@/app/useWebviewZoomShortcuts";
import {
channelsQueryKey,
@@ -170,6 +171,9 @@ export function AppShell() {
const [settingsSection, setSettingsSection] = React.useState<SettingsSection>(
DEFAULT_SETTINGS_SECTION,
);
const [settingsMode, setSettingsMode] = React.useState<
"profile" | "preferences"
>("preferences");
const [isChannelManagementOpen, setIsChannelManagementOpen] =
React.useState(false);
@@ -407,8 +411,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);
},
@@ -601,32 +606,11 @@ export function AppShell() {
settingsOpen,
]);
React.useLayoutEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
const isSettingsShortcut =
(event.key === "," || event.code === "Comma") &&
hasPrimaryShortcutModifier(event) &&
!event.altKey &&
!event.shiftKey;
if (!isSettingsShortcut) {
return;
}
event.preventDefault();
if (settingsOpen) {
handleCloseSettings();
return;
}
handleOpenSettings();
}
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [handleCloseSettings, handleOpenSettings, settingsOpen]);
useSettingsShortcuts({
onClose: handleCloseSettings,
onOpenSettings: handleOpenSettings,
open: settingsOpen,
});
useMarkAsReadShortcuts({
activeChannelId: activeChannel?.id ?? null,
@@ -862,6 +846,7 @@ export function AppShell() {
notificationSettings={notificationSettings.settings}
onClose={handleCloseSettings}
onSectionChange={setSettingsSection}
mode={settingsMode}
onSetDesktopNotificationsEnabled={
notificationSettings.setDesktopEnabled
}
+42
View File
@@ -0,0 +1,42 @@
import * as React from "react";
import { hasPrimaryShortcutModifier } from "@/shared/lib/platform";
type UseSettingsShortcutsOptions = {
onClose: () => void;
onOpenSettings: () => void;
open: boolean;
};
export function useSettingsShortcuts({
onClose,
onOpenSettings,
open,
}: UseSettingsShortcutsOptions) {
React.useLayoutEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
const isSettingsShortcut =
hasPrimaryShortcutModifier(event) &&
!event.altKey &&
!event.shiftKey &&
(event.key === "," || event.code === "Comma");
if (!isSettingsShortcut) {
return;
}
event.preventDefault();
if (open) {
onClose();
return;
}
onOpenSettings();
}
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [onClose, onOpenSettings, open]);
}
+137 -103
View File
@@ -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";
@@ -8,6 +8,7 @@ import { getPresenceLabel } from "@/features/presence/lib/presence";
import { SetStatusDialog } from "@/features/user-status/ui/SetStatusDialog";
import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
import type { PresenceStatus } from "@/shared/api/types";
import { isMacPlatform } from "@/shared/lib/platform";
// ---------------------------------------------------------------------------
// Types
@@ -26,8 +27,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<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;
}
// ---------------------------------------------------------------------------
@@ -35,7 +44,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"];
@@ -58,14 +67,14 @@ 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<number | null>(null);
const hasUserStatus = Boolean(userStatusText || userStatusEmoji);
const preferencesShortcutLabel = isMacPlatform() ? "⌘," : "Ctrl+,";
function clearPresenceHoverTimer() {
if (presenceHoverTimer.current !== null) {
@@ -117,23 +126,32 @@ export function ProfilePopover({
<PopoverContent
side="top"
align="start"
sideOffset={8}
sideOffset={-32}
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();
}
}}
>
<div aria-label="Profile menu" role="menu">
{/* ── Identity block ─────────────────────────────────── */}
<div className="flex items-center gap-3 px-4 py-3">
<div className="flex items-center gap-2 px-4 pt-3 pb-2">
<div className="relative shrink-0">
<ProfileAvatar
avatarUrl={avatarUrl}
className="h-10 w-10 rounded-2xl text-sm"
iconClassName="h-5 w-5"
className="h-8 w-8 rounded-xl text-xs"
iconClassName="h-4 w-4"
label={displayName}
/>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-popover-foreground">
<p className="truncate text-sm font-semibold leading-tight text-popover-foreground">
{displayName}
</p>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
@@ -164,12 +182,10 @@ export function ProfilePopover({
</div>
</div>
<hr className="my-1 h-px border-0 bg-border" />
{/* ── User status ──────────────────────────────────── */}
<div className="px-1.5 py-1">
{/* ── Status input (Slack-style) ──────────────────────── */}
<div className="px-3 pt-0 pb-1">
<button
className={MENU_ITEM_CLASS}
className="flex w-full items-center gap-2 rounded-lg border border-input bg-popover px-3 py-2 text-left text-sm outline-hidden transition-colors hover:bg-accent focus:outline-none focus-visible:bg-accent focus-visible:outline-none"
data-testid="profile-popover-set-status"
onClick={() => {
closePopover();
@@ -180,102 +196,120 @@ export function ProfilePopover({
role="menuitem"
type="button"
>
<MessageSquare className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-popover-foreground">
{hasUserStatus ? "Update status" : "Set a status"}
</span>
<Smile className="h-4 w-4 shrink-0 text-muted-foreground" />
{hasUserStatus ? (
<span className="flex min-w-0 flex-1 items-center gap-1 truncate text-popover-foreground">
{userStatusEmoji ? (
<span className="shrink-0">{userStatusEmoji}</span>
) : null}
<span className="truncate">{userStatusText}</span>
</span>
) : (
<span className="flex-1 truncate text-muted-foreground">
Update your status
</span>
)}
</button>
</div>
<hr className="my-1 h-px border-0 bg-border" />
{/* ── Presence status options ───────────────────────── */}
<div className="px-1.5 py-1">
<Popover
onOpenChange={setPresenceMenuOpen}
open={presenceMenuOpen}
>
<PopoverTrigger asChild>
<button
aria-expanded={presenceMenuOpen}
aria-haspopup="menu"
className={MENU_ITEM_CLASS}
data-testid="profile-popover-presence-trigger"
disabled={isStatusPending}
onClick={() => {
clearPresenceHoverTimer();
setPresenceMenuOpen((prev) => !prev);
}}
onMouseEnter={() => schedulePresenceMenu(true)}
onMouseLeave={() => schedulePresenceMenu(false)}
role="menuitem"
type="button"
>
<PresenceDot
className="h-2.5 w-2.5"
status={currentStatus}
/>
<span className="flex-1 text-sm text-popover-foreground">
{getPresenceLabel(currentStatus)}
</span>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-44 rounded-xl border border-border bg-popover p-1.5 shadow-lg"
{/* ── Presence ────────────────────────────────────────── */}
<Popover onOpenChange={setPresenceMenuOpen} open={presenceMenuOpen}>
<PopoverTrigger asChild>
<button
aria-expanded={presenceMenuOpen}
aria-haspopup="menu"
className={MENU_ITEM_CLASS}
data-testid="profile-popover-presence-trigger"
disabled={isStatusPending}
onClick={() => {
clearPresenceHoverTimer();
setPresenceMenuOpen((prev) => !prev);
}}
onMouseEnter={() => schedulePresenceMenu(true)}
onMouseLeave={() => schedulePresenceMenu(false)}
side="right"
sideOffset={4}
role="menuitem"
type="button"
>
<div aria-label="Presence status" role="menu">
{ALL_STATUSES.map((status) => (
<button
className={MENU_ITEM_CLASS}
data-testid={`profile-popover-status-${status}`}
disabled={isStatusPending}
key={status}
onClick={() => handlePresenceSelect(status)}
role="menuitem"
type="button"
>
<PresenceDot className="h-2.5 w-2.5" status={status} />
<span className="text-sm text-popover-foreground">
{getPresenceLabel(status)}
</span>
</button>
))}
</div>
</PopoverContent>
</Popover>
</div>
<PresenceDot className="h-2.5 w-2.5" status={currentStatus} />
<span className="flex-1">
{getPresenceLabel(currentStatus)}
</span>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-44 rounded-xl border border-border bg-popover p-1 shadow-lg"
onMouseEnter={() => schedulePresenceMenu(true)}
onMouseLeave={() => schedulePresenceMenu(false)}
side="right"
sideOffset={4}
>
<div aria-label="Presence status" role="menu">
{ALL_STATUSES.map((status) => (
<button
className={MENU_ITEM_CLASS}
data-testid={`profile-popover-status-${status}`}
disabled={isStatusPending}
key={status}
onClick={() => handlePresenceSelect(status)}
role="menuitem"
type="button"
>
<PresenceDot className="h-2.5 w-2.5" status={status} />
<span>{getPresenceLabel(status)}</span>
</button>
))}
</div>
</PopoverContent>
</Popover>
<hr className="my-1 h-px border-0 bg-border" />
{/* ── Settings ───────────────────────────────────────── */}
<div className="px-1.5 py-1">
<button
className={MENU_ITEM_CLASS}
data-testid="profile-popover-settings"
onClick={() => {
closePopover();
window.requestAnimationFrame(() => {
onOpenSettings();
});
}}
role="menuitem"
type="button"
>
<Settings className="h-4 w-4 text-muted-foreground" />
<span className="flex-1 text-sm text-popover-foreground">
Settings
</span>
<kbd className="text-xs text-muted-foreground">
{isMac ? "⌘," : "Ctrl+,"}
</kbd>
</button>
</div>
{/* ── Profile / preferences ──────────────────────────── */}
<button
className={MENU_ITEM_CLASS}
data-testid="profile-popover-profile"
onClick={() => {
closePopover();
window.requestAnimationFrame(() => {
onOpenSettings("profile");
});
}}
role="menuitem"
type="button"
>
<span className="flex-1">Profile</span>
</button>
<button
className={MENU_ITEM_CLASS}
data-testid="profile-popover-settings"
onClick={() => {
closePopover();
window.requestAnimationFrame(() => {
onOpenSettings("appearance");
});
}}
role="menuitem"
type="button"
>
<span className="flex-1">Preferences</span>
<kbd className="text-xs text-muted-foreground">
{preferencesShortcutLabel}
</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 className="h-1" />
</div>
</PopoverContent>
</Popover>
@@ -216,19 +216,16 @@ function PairingDialog({
<p className="text-xs font-medium text-muted-foreground">
Pairing code
</p>
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 break-all rounded-lg border border-border bg-muted/50 px-3 py-2 text-xs">
{qrUri}
</code>
<Button
data-testid="copy-pairing-code"
onClick={handleCopy}
size="sm"
variant="outline"
>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
<button
className="flex w-full min-w-0 items-center gap-2 break-all rounded-lg border border-border bg-muted/50 px-3 py-2 text-left text-xs transition-colors hover:bg-muted/70"
data-testid="copy-pairing-code"
onClick={handleCopy}
title="Copy pairing code"
type="button"
>
<code className="min-w-0 flex-1 break-all">{qrUri}</code>
<Copy className="h-3.5 w-3.5 shrink-0" />
</button>
</div>
<p className="text-center text-xs text-muted-foreground">
@@ -1,12 +1,12 @@
import { AtSign, Check, UserRound } from "lucide-react";
import { AtSign, Check, Copy, UserRound } from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
import {
useProfileQuery,
useUpdateProfileMutation,
} from "@/features/profile/hooks";
import { AvatarUpload } from "@/features/profile/ui/AvatarUpload";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { Separator } from "@/shared/ui/separator";
@@ -42,20 +42,41 @@ function ReadOnlyField({
label,
value,
testId,
copyValue,
}: {
label: string;
value: string;
testId: string;
copyValue?: string;
}) {
const boxClassName =
"flex min-w-0 items-center gap-2 rounded-xl border border-border/80 bg-muted/25 px-3 py-2 text-sm text-muted-foreground";
return (
<div className="min-w-0 space-y-1.5">
<p className="text-sm font-medium">{label}</p>
<div
className="min-w-0 break-all whitespace-normal rounded-xl border border-border/80 bg-muted/25 px-3 py-2 text-sm text-muted-foreground"
data-testid={testId}
>
{value}
</div>
{copyValue ? (
<button
aria-label={`Copy ${label}`}
className={`${boxClassName} w-full text-left transition-colors hover:bg-muted/50`}
data-testid={`copy-${testId}`}
onClick={async () => {
await navigator.clipboard.writeText(copyValue);
toast.success("Copied to clipboard");
}}
title={`Copy ${label}`}
type="button"
>
<span className="min-w-0 flex-1 break-all" data-testid={testId}>
{value}
</span>
<Copy className="h-3.5 w-3.5 shrink-0" />
</button>
) : (
<div className={`${boxClassName} break-all`} data-testid={testId}>
{value}
</div>
)}
</div>
);
}
@@ -117,26 +138,7 @@ export function ProfileSettingsCard({
return (
<section className="min-w-0" data-testid="settings-profile">
<div className="flex min-w-0 items-start gap-4">
<ProfileAvatar
avatarUrl={profile?.avatarUrl ?? null}
className="h-16 w-16 rounded-3xl text-lg"
iconClassName="h-6 w-6"
label={resolvedName}
/>
<div className="min-w-0 space-y-2">
<div>
<h2 className="break-words text-base font-semibold tracking-tight">
{resolvedName}
</h2>
<p className="text-sm text-muted-foreground">
Manage how your identity appears across Sprout.
</p>
</div>
</div>
</div>
<div className="mt-6 space-y-6">
<div className="space-y-6">
{profileQuery.error instanceof Error ? (
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{profileQuery.error.message}
@@ -156,86 +158,66 @@ export function ProfileSettingsCard({
</div>
) : null}
<Section
description="These values are stored on the relay for your current identity."
title="Profile"
<form
className="min-w-0 space-y-4"
id="profile-settings-form"
onSubmit={(event) => {
event.preventDefault();
if (!canSave) {
return;
}
void updateProfileMutation.mutateAsync(updatePayload);
}}
>
<form
className="min-w-0 space-y-4"
onSubmit={(event) => {
event.preventDefault();
if (!canSave) {
return;
}
void updateProfileMutation.mutateAsync(updatePayload);
}}
>
<div className="space-y-1.5">
<label
className="text-sm font-medium"
htmlFor="profile-display-name"
>
Display name
</label>
<div className="relative min-w-0">
<UserRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-9"
data-testid="profile-display-name"
disabled={updateProfileMutation.isPending}
id="profile-display-name"
onChange={(event) => setDisplayNameDraft(event.target.value)}
placeholder="How people should see you"
value={displayNameDraft}
/>
</div>
</div>
<AvatarUpload
avatarUrl={avatarUrlDraft}
previewName={resolvedName}
onUrlChange={(url) => setAvatarUrlDraft(url)}
disabled={updateProfileMutation.isPending}
idleHint="Upload or paste a URL to change your avatar."
testIdPrefix="profile-avatar"
/>
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="profile-about">
About
</label>
<div className="relative min-w-0">
<AtSign className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Textarea
className="min-h-28 pl-9"
data-testid="profile-about"
disabled={updateProfileMutation.isPending}
id="profile-about"
onChange={(event) => setAboutDraft(event.target.value)}
placeholder="A short description for your profile"
value={aboutDraft}
/>
</div>
</div>
<Button
data-testid="profile-save"
disabled={!canSave}
size="sm"
type="submit"
<div className="space-y-1.5">
<label
className="text-sm font-medium"
htmlFor="profile-display-name"
>
{updateProfileMutation.isPending ? "Saving..." : "Save profile"}
</Button>
Display name
</label>
<div className="relative min-w-0">
<UserRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-9"
data-testid="profile-display-name"
disabled={updateProfileMutation.isPending}
id="profile-display-name"
onChange={(event) => setDisplayNameDraft(event.target.value)}
placeholder="How people should see you"
value={displayNameDraft}
/>
</div>
</div>
{hasPendingClearRequest ? (
<p className="text-sm text-muted-foreground">
Clearing existing profile fields is not supported yet. Blank
display name, avatar, and about values are ignored for now.
</p>
) : null}
</form>
</Section>
<AvatarUpload
avatarUrl={avatarUrlDraft}
previewName={resolvedName}
onUrlChange={(url) => setAvatarUrlDraft(url)}
disabled={updateProfileMutation.isPending}
idleHint="Upload or paste a URL to change your avatar."
testIdPrefix="profile-avatar"
/>
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="profile-about">
About
</label>
<div className="relative min-w-0">
<AtSign className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Textarea
className="min-h-28 pl-9"
data-testid="profile-about"
disabled={updateProfileMutation.isPending}
id="profile-about"
onChange={(event) => setAboutDraft(event.target.value)}
placeholder="A short description for your profile"
value={aboutDraft}
/>
</div>
</div>
</form>
<Separator />
@@ -245,11 +227,13 @@ export function ProfileSettingsCard({
>
<div className="space-y-3">
<ReadOnlyField
copyValue={profile?.pubkey ?? currentPubkey ?? undefined}
label="Public key"
testId="profile-pubkey"
value={resolvedPubkey}
/>
<ReadOnlyField
copyValue={profile?.nip05Handle ?? undefined}
label="NIP-05 handle"
testId="profile-nip05"
value={nip05Handle}
@@ -257,6 +241,25 @@ export function ProfileSettingsCard({
</div>
</Section>
</div>
<div className="sticky bottom-0 z-10 -mx-4 -mb-4 mt-6 flex flex-col gap-2 border-t border-border bg-background px-4 pt-4 pb-4 sm:-mx-6 sm:px-6">
<Button
data-testid="profile-save"
disabled={!canSave}
form="profile-settings-form"
size="sm"
type="submit"
>
{updateProfileMutation.isPending ? "Saving..." : "Save profile"}
</Button>
{hasPendingClearRequest ? (
<p className="text-sm text-muted-foreground">
Clearing existing profile fields is not supported yet. Blank display
name, avatar, and about values are ignored for now.
</p>
) : null}
</div>
</section>
);
}
@@ -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",
@@ -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}
@@ -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<string | null>(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) {
@@ -155,7 +169,7 @@ export function SettingsView({
data-testid="settings-title"
id="settings-title"
>
Settings
{mode === "profile" ? "Profile" : "Settings"}
</h2>
<button
aria-label="Close settings"
@@ -168,39 +182,48 @@ export function SettingsView({
</button>
</header>
<div className="grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)] overflow-hidden md:grid-cols-[220px_minmax(0,1fr)] md:grid-rows-1">
<aside
className={cn(
"flex flex-col border-b border-border/70 bg-muted/20 motion-safe:transition-all motion-safe:duration-200 motion-safe:ease-out md:border-b-0 md:border-r",
isLoaded
? "opacity-100 translate-x-0"
: "opacity-0 -translate-x-2",
)}
>
<nav
aria-label="Settings sections"
className="flex gap-1 overflow-x-auto px-3 py-3 md:flex-1 md:flex-col md:overflow-y-auto md:pt-1"
<div
className={cn(
"grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)] overflow-hidden md:grid-rows-1",
showSectionNav
? "md:grid-cols-[220px_minmax(0,1fr)]"
: "md:grid-cols-1",
)}
>
{showSectionNav ? (
<aside
className={cn(
"flex flex-col border-b border-border/70 bg-muted/20 motion-safe:transition-all motion-safe:duration-200 motion-safe:ease-out md:border-b-0 md:border-r",
isLoaded
? "opacity-100 translate-x-0"
: "opacity-0 -translate-x-2",
)}
>
{visibleSections.map((entry) => (
<SettingsSectionButton
active={entry.value === section}
isLoaded={isLoaded}
key={entry.value}
onSelect={onSectionChange}
section={entry}
/>
))}
</nav>
{appVersion ? (
<p className="hidden px-3 pb-3 text-xs text-muted-foreground/60 md:block">
v{appVersion}
</p>
) : null}
</aside>
<nav
aria-label="Settings sections"
className="flex gap-1 overflow-x-auto px-3 py-3 md:flex-1 md:flex-col md:overflow-y-auto md:pt-1"
>
{visibleSections.map((entry) => (
<SettingsSectionButton
active={entry.value === section}
isLoaded={isLoaded}
key={entry.value}
onSelect={onSectionChange}
section={entry}
/>
))}
</nav>
{appVersion ? (
<p className="hidden px-3 pb-3 text-xs text-muted-foreground/60 md:block">
v{appVersion}
</p>
) : null}
</aside>
) : null}
<section className="min-h-0 overflow-y-auto px-4 py-4 sm:px-6">
<section className="flex min-h-0 flex-col overflow-y-auto px-4 pt-4 sm:px-6">
<div
className="mx-auto flex w-full max-w-4xl flex-col gap-4"
className="mx-auto flex w-full max-w-4xl flex-1 flex-col gap-4"
data-testid={`settings-panel-${section}`}
>
{renderSettingsSection(section, {
+19 -85
View File
@@ -15,13 +15,7 @@ import { SidebarDndContext } from "@/features/sidebar/ui/SidebarDnd";
import { useManagedAgentsQuery } from "@/features/agents/hooks";
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 { 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 { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
import {
useChannelSections,
type ChannelSection,
@@ -43,6 +37,7 @@ import {
} from "@/features/sidebar/ui/CustomChannelSection";
import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog";
import { NewDirectMessageDialog } from "@/features/sidebar/ui/NewDirectMessageDialog";
import { SidebarProfileCard } from "@/features/sidebar/ui/SidebarProfileCard";
import type {
Channel,
ChannelVisibility,
@@ -140,7 +135,7 @@ type AppSidebarProps = {
onSelectWorkflows: () => 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;
@@ -213,7 +208,6 @@ export function AppSidebar({
const setIsNewDmOpen = onNewDmOpenChange ?? setIsNewDmOpenInternal;
const scrollRef = React.useRef<HTMLDivElement>(null);
useSidebarScrollLock(scrollRef);
const [profilePopoverOpen, setProfilePopoverOpen] = React.useState(false);
const [createDialogKind, setCreateDialogKind] =
React.useState<CreateChannelKind | null>(null);
@@ -665,83 +659,23 @@ export function AppSidebar({
<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">
<SidebarMenu>
<SidebarMenuItem>
<div
className="rounded-xl px-2 py-2 transition-colors hover:bg-sidebar-accent/35 focus-within:bg-sidebar-accent/35 dark:hover:bg-sidebar-accent/25 dark:focus-within:bg-sidebar-accent/25"
data-testid="sidebar-profile-card"
>
<div className="flex min-w-0 items-center gap-3">
<div className="relative shrink-0">
<ProfileAvatar
avatarUrl={profile?.avatarUrl ?? null}
className="h-10 w-10 rounded-2xl text-sm"
iconClassName="h-5 w-5"
label={resolvedDisplayName}
testId="sidebar-profile-avatar"
/>
<span
aria-label={getPresenceLabel(selfPresenceStatus)}
className="absolute -bottom-0.5 -right-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-sidebar"
data-testid="self-presence-badge"
role="img"
>
<PresenceDot
className="h-2.5 w-2.5"
status={selfPresenceStatus}
/>
</span>
</div>
<div className="min-w-0 flex-1">
<ProfilePopover
open={profilePopoverOpen}
onOpenChange={setProfilePopoverOpen}
displayName={resolvedDisplayName}
nip05={profile?.nip05Handle}
avatarUrl={profile?.avatarUrl ?? null}
currentStatus={selfPresenceStatus}
isStatusPending={isPresencePending}
userStatusText={selfUserStatus?.text}
userStatusEmoji={selfUserStatus?.emoji}
onSetStatus={onSetPresenceStatus ?? (() => {})}
onSetUserStatus={onSetUserStatus}
onClearUserStatus={onClearUserStatus}
onOpenSettings={onSelectSettings}
>
<button
className="block w-full min-w-0 text-left text-sidebar-foreground"
data-testid="open-settings"
type="button"
>
<p
className="truncate text-sm font-semibold text-current"
data-testid="sidebar-profile-name"
>
{resolvedDisplayName}
</p>
</button>
</ProfilePopover>
<WorkspaceSwitcher
activeWorkspace={activeWorkspace}
onAddWorkspace={onOpenAddWorkspace}
onRemoveWorkspace={onRemoveWorkspace}
onSwitchWorkspace={onSwitchWorkspace}
onUpdateWorkspace={onUpdateWorkspace}
variant="profile"
workspaces={workspaces}
/>
{selfUserStatus?.text || selfUserStatus?.emoji ? (
<p className="mt-0.5 truncate text-xs text-sidebar-foreground/50">
{selfUserStatus.emoji ? (
<StatusEmoji
className="mr-1 h-3.5 w-3.5"
value={selfUserStatus.emoji}
/>
) : null}
{selfUserStatus.text}
</p>
) : null}
</div>
</div>
</div>
<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>
</SidebarFooter>
@@ -0,0 +1,202 @@
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 { 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 [profilePopoverOpen, setProfilePopoverOpen] = React.useState(false);
const profileCardRef = React.useRef<HTMLDivElement | null>(null);
const toggleProfilePopover = React.useCallback(
() => setProfilePopoverOpen((prev) => !prev),
[],
);
const handleCardClick = React.useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
const target = event.target;
if (
!(target instanceof Node) ||
!profileCardRef.current?.contains(target)
) {
return;
}
toggleProfilePopover();
},
[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="shrink-0 text-[10px] leading-none">
🌱
</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.
<div
className="group/profile-card cursor-pointer rounded-xl px-2 py-2 transition-colors hover:bg-sidebar-border/35 dark:hover:bg-sidebar-border/30"
data-testid="sidebar-profile-card"
onClick={handleCardClick}
ref={profileCardRef}
>
<div className="flex min-w-0 items-center gap-3">
<button
aria-label={`Open profile menu for ${resolvedDisplayName}`}
className="relative shrink-0 rounded-xl outline-hidden focus:outline-none focus-visible:outline-none"
data-testid="sidebar-profile-avatar-button"
onClick={(event) => {
event.stopPropagation();
toggleProfilePopover();
}}
type="button"
>
<ProfileAvatar
avatarUrl={profile?.avatarUrl ?? null}
className="h-8 w-8 rounded-xl text-xs"
iconClassName="h-4 w-4"
label={resolvedDisplayName}
testId="sidebar-profile-avatar"
/>
<span
aria-label={getPresenceLabel(selfPresenceStatus)}
className="absolute -bottom-0.5 -right-0.5 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-sidebar"
data-testid="self-presence-badge"
role="img"
>
<PresenceDot className="h-2 w-2" status={selfPresenceStatus} />
</span>
</button>
<div className="min-w-0 flex-1">
<ProfilePopover
open={profilePopoverOpen}
onOpenChange={setProfilePopoverOpen}
displayName={resolvedDisplayName}
nip05={profile?.nip05Handle}
avatarUrl={profile?.avatarUrl ?? null}
currentStatus={selfPresenceStatus}
isStatusPending={isPresencePending}
userStatusText={selfUserStatus?.text}
userStatusEmoji={selfUserStatus?.emoji}
onSetStatus={onSetPresenceStatus ?? (() => {})}
onSetUserStatus={onSetUserStatus}
onClearUserStatus={onClearUserStatus}
onOpenSettings={onOpenSettings}
triggerContainerRef={profileCardRef}
workspaceSwitcherSlot={
<WorkspaceSwitcher
activeWorkspace={activeWorkspace}
onAddWorkspace={onOpenAddWorkspace}
onRemoveWorkspace={onRemoveWorkspace}
onSwitchWorkspace={onSwitchWorkspace}
onUpdateWorkspace={onUpdateWorkspace}
variant="profile-menu"
workspaces={workspaces}
/>
}
>
<button
onClick={(event) => {
event.stopPropagation();
toggleProfilePopover();
}}
className="block w-full min-w-0 rounded-sm text-left text-sidebar-foreground outline-hidden focus:outline-none focus-visible:outline-none"
data-testid="open-settings"
type="button"
>
<p
className="truncate text-sm font-semibold leading-tight text-current"
data-testid="sidebar-profile-name"
>
{resolvedDisplayName}
</p>
</button>
</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 h-3.5 w-3.5"
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>
)}
</div>
</div>
</div>
);
}
@@ -166,6 +166,11 @@ export function useSetUserStatusMutation(pubkey?: string) {
? { text, emoji, updatedAt: Math.floor(Date.now() / 1_000) }
: null;
queryClient.setQueryData<UserStatusLookup>(
userStatusQueryKey([normalizedPubkey]),
(old) => ({ ...(old ?? {}), [normalizedPubkey]: status }),
);
queryClient.setQueriesData<UserStatusLookup>(
{ queryKey: ["user-status"] },
(old) => {
@@ -1,6 +1,7 @@
import {
Check,
ChevronDown,
ChevronRight,
MoreHorizontal,
Plus,
WifiOff,
@@ -20,13 +21,13 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/shared/ui/sidebar";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import type { ConnectionState } from "@/shared/api/relayClientShared";
import {
isRelayConnectionDegraded,
useRelayConnection,
} from "@/shared/api/useRelayConnection";
import { EditWorkspaceDialog } from "./EditWorkspaceDialog";
const CONNECTION_STATE_LABEL: Record<ConnectionState, string> = {
@@ -41,7 +42,7 @@ const CONNECTION_STATE_LABEL: Record<ConnectionState, string> = {
type WorkspaceSwitcherProps = {
activeWorkspace: Workspace | null;
workspaces: Workspace[];
variant?: "sidebar" | "profile";
variant?: "sidebar" | "profile" | "profile-menu";
onSwitchWorkspace: (id: string) => void;
onAddWorkspace: () => void;
onUpdateWorkspace: (
@@ -63,9 +64,47 @@ export function WorkspaceSwitcher({
const [editingWorkspace, setEditingWorkspace] =
React.useState<Workspace | null>(null);
const [dropdownOpen, setDropdownOpen] = React.useState(false);
const profileMenuHoverTimer = React.useRef<number | null>(null);
const connectionState = useRelayConnection();
const degraded = isRelayConnectionDegraded(connectionState);
const connectionLabel = CONNECTION_STATE_LABEL[connectionState];
const isProfileVariant = variant === "profile";
function clearProfileMenuHoverTimer() {
if (profileMenuHoverTimer.current !== null) {
window.clearTimeout(profileMenuHoverTimer.current);
profileMenuHoverTimer.current = null;
}
}
function scheduleProfileMenu(nextOpen: boolean) {
if (variant !== "profile-menu") return;
clearProfileMenuHoverTimer();
profileMenuHoverTimer.current = window.setTimeout(
() => setDropdownOpen(nextOpen),
nextOpen ? 80 : 160,
);
}
function handleProfileMenuOpenChange(nextOpen: boolean) {
if (variant !== "profile-menu") {
setDropdownOpen(nextOpen);
return;
}
if (!nextOpen) {
clearProfileMenuHoverTimer();
}
setDropdownOpen(nextOpen);
}
React.useEffect(
() => () => {
if (profileMenuHoverTimer.current !== null) {
window.clearTimeout(profileMenuHoverTimer.current);
}
},
[],
);
const triggerContent = (
<>
@@ -75,26 +114,24 @@ export function WorkspaceSwitcher({
<span
aria-hidden="false"
className={
variant === "profile"
isProfileVariant
? "flex h-5 w-5 shrink-0 animate-pulse items-center justify-center rounded-md border border-sidebar-border/70 bg-sidebar-accent/40 text-destructive"
: "flex h-5 w-5 shrink-0 animate-pulse items-center justify-center text-destructive"
}
data-testid="relay-connection-warning"
role="img"
>
<WifiOff
className={variant === "profile" ? "h-3 w-3" : "h-4 w-4"}
/>
<WifiOff className={isProfileVariant ? "h-3 w-3" : "h-4 w-4"} />
</span>
</TooltipTrigger>
<TooltipContent side={variant === "profile" ? "top" : "bottom"}>
<TooltipContent side={isProfileVariant ? "top" : "bottom"}>
{connectionLabel}
</TooltipContent>
</Tooltip>
) : (
<span
className={
variant === "profile"
isProfileVariant
? "flex h-5 w-5 shrink-0 items-center justify-center rounded-md border border-sidebar-border/70 bg-sidebar-accent/40 text-[10px] leading-none"
: "flex h-5 w-5 shrink-0 items-center justify-center text-xs leading-none"
}
@@ -111,16 +148,106 @@ export function WorkspaceSwitcher({
>
{activeWorkspace?.name ?? "No workspace"}
</span>
<ChevronDown
className={
variant === "profile"
? "h-3 w-3 shrink-0 text-sidebar-foreground/45"
: "h-3.5 w-3.5 shrink-0 text-sidebar-foreground/50"
}
/>
{variant === "profile-menu" ? (
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<ChevronDown
className={
isProfileVariant
? "h-3 w-3 shrink-0 text-sidebar-foreground/45"
: "h-3.5 w-3.5 shrink-0 text-sidebar-foreground/50"
}
/>
)}
</>
);
const profileMenuPopover =
variant === "profile-menu" ? (
<Popover open={dropdownOpen} onOpenChange={handleProfileMenuOpenChange}>
<PopoverTrigger asChild>
<button
aria-expanded={dropdownOpen}
aria-haspopup="menu"
aria-label={
degraded
? `${activeWorkspace?.name ?? "Workspace"}${connectionLabel}`
: "Switch workspace"
}
className="flex w-full items-center gap-2 px-4 py-2 text-left text-sm text-popover-foreground outline-hidden transition-colors hover:bg-accent focus:bg-accent focus:outline-none focus-visible:bg-accent focus-visible:outline-none data-[state=open]:bg-accent data-[state=open]:text-popover-foreground"
data-testid="workspace-switcher"
onMouseEnter={() => scheduleProfileMenu(true)}
onMouseLeave={() => scheduleProfileMenu(false)}
role="menuitem"
type="button"
>
{triggerContent}
</button>
</PopoverTrigger>
<PopoverContent
align="end"
className="w-56 rounded-xl border border-border bg-popover p-1 shadow-lg"
onMouseEnter={() => scheduleProfileMenu(true)}
onMouseLeave={() => scheduleProfileMenu(false)}
side="right"
sideOffset={0}
>
<div aria-label="Workspaces" role="menu">
{workspaces.map((workspace) => (
<div
className="group flex items-center rounded-xs transition-colors hover:bg-accent focus-within:bg-accent"
key={workspace.id}
>
<button
className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-left text-sm outline-hidden focus:outline-none"
onClick={() => {
onSwitchWorkspace(workspace.id);
setDropdownOpen(false);
}}
role="menuitem"
type="button"
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
{activeWorkspace?.id === workspace.id ? (
<Check className="h-3.5 w-3.5 text-primary" />
) : null}
</span>
<span className="min-w-0 flex-1 truncate">
{workspace.name}
</span>
</button>
<button
aria-label={`Edit ${workspace.name}`}
className="mr-1 flex h-5 w-5 shrink-0 items-center justify-center rounded opacity-0 hover:bg-accent group-hover:opacity-100 group-focus-within:opacity-100"
onClick={(e) => {
e.stopPropagation();
setDropdownOpen(false);
setEditingWorkspace(workspace);
}}
type="button"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
</div>
))}
<div className="-mx-1 my-1 h-px bg-muted" />
<button
className="flex w-full items-center gap-2 rounded-xs px-2 py-1.5 text-left text-sm outline-hidden transition-colors hover:bg-accent focus:bg-accent focus:outline-none focus-visible:bg-accent focus-visible:outline-none"
onClick={() => {
setDropdownOpen(false);
onAddWorkspace();
}}
role="menuitem"
type="button"
>
<Plus className="h-4 w-4" />
<span>Add Workspace</span>
</button>
</div>
</PopoverContent>
</Popover>
) : null;
const switcherDropdown = (
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
@@ -131,7 +258,7 @@ export function WorkspaceSwitcher({
? `${activeWorkspace?.name ?? "Workspace"}${connectionLabel}`
: "Switch workspace"
}
className="flex min-w-0 max-w-full items-center gap-1.5 rounded-md py-0.5 text-left text-xs text-sidebar-foreground/50 transition-colors hover:text-sidebar-foreground data-[state=open]:text-sidebar-foreground"
className="flex min-w-0 max-w-full items-center gap-1.5 rounded-md py-0.5 text-left text-xs text-sidebar-foreground/50 outline-hidden transition-colors hover:text-sidebar-foreground focus:outline-none focus-visible:outline-none data-[state=open]:text-sidebar-foreground"
data-testid="workspace-switcher"
type="button"
>
@@ -201,6 +328,8 @@ export function WorkspaceSwitcher({
<>
{variant === "profile" ? (
switcherDropdown
) : variant === "profile-menu" ? (
profileMenuPopover
) : (
<SidebarMenu>
<SidebarMenuItem>{switcherDropdown}</SidebarMenuItem>
+24
View File
@@ -304,4 +304,28 @@
@apply bg-background text-foreground antialiased;
font-family: "Geist", "Avenir Next", "Segoe UI", sans-serif;
}
/*
* Tailwind v4's preflight no longer sets `cursor: pointer` on buttons.
* Restore it for clearly actionable elements so hover affordance is
* consistent across the app. Disabled / aria-disabled elements keep
* the default cursor; explicit `cursor-*` utility classes still win
* because utilities are in a later layer than base.
*/
button:not(:disabled),
a[href],
summary,
[role="button"]:not([aria-disabled="true"]),
[role="menuitem"]:not([aria-disabled="true"]),
[role="menuitemcheckbox"]:not([aria-disabled="true"]),
[role="menuitemradio"]:not([aria-disabled="true"]),
[role="tab"]:not([aria-disabled="true"]),
[role="link"] {
cursor: pointer;
}
button:disabled,
[aria-disabled="true"] {
cursor: default;
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ test("updates the relay-backed profile from settings", async ({ page }) => {
await page.goto("/");
await openSettings(page, "profile");
await expect(page.getByTestId("settings-title")).toHaveText("Settings");
await expect(page.getByTestId("settings-title")).toHaveText("Profile");
await expect(page.getByTestId("profile-pubkey")).toContainText("deadbeef");
await expect(page.getByTestId("profile-nip05")).toContainText("Not set");
+6 -2
View File
@@ -21,10 +21,14 @@ export async function openProfileMenu(page: Page) {
export async function openSettings(page: Page, section?: SettingsSection) {
await openProfileMenu(page);
await page.getByTestId("profile-popover-settings").click();
if (section === "profile") {
await page.getByTestId("profile-popover-profile").click();
} else {
await page.getByTestId("profile-popover-settings").click();
}
await expect(page.getByTestId("settings-view")).toBeVisible();
if (section) {
if (section && section !== "profile") {
await page.getByTestId(`settings-nav-${section}`).click();
}
}