mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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 <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
7c896d339d
commit
c91fabe54c
@@ -169,6 +169,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);
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -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<number | null>(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();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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-3 px-4 pt-3 pb-2">
|
||||
<div className="relative shrink-0">
|
||||
<ProfileAvatar
|
||||
avatarUrl={avatarUrl}
|
||||
@@ -135,37 +149,20 @@ export function ProfilePopover({
|
||||
<p className="truncate text-sm font-semibold text-popover-foreground">
|
||||
{displayName}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{nip05 ? <span className="truncate">@{nip05}</span> : null}
|
||||
{nip05 ? <span aria-hidden="true">·</span> : null}
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5"
|
||||
data-testid="profile-popover-current-status"
|
||||
>
|
||||
<PresenceDot status={currentStatus} />
|
||||
<span>{getPresenceLabel(currentStatus)}</span>
|
||||
</span>
|
||||
</div>
|
||||
{hasUserStatus ? (
|
||||
<p
|
||||
className="mt-0.5 truncate text-xs text-muted-foreground"
|
||||
data-testid="profile-popover-user-status"
|
||||
>
|
||||
{userStatusEmoji ? (
|
||||
<span className="mr-1">{userStatusEmoji}</span>
|
||||
) : null}
|
||||
{userStatusText}
|
||||
</p>
|
||||
) : null}
|
||||
<p
|
||||
className="mt-0.5 flex items-center gap-1.5 truncate text-xs text-muted-foreground"
|
||||
data-testid="profile-popover-current-status"
|
||||
>
|
||||
<PresenceDot className="h-2 w-2" status={currentStatus} />
|
||||
<span>{getPresenceLabel(currentStatus)}</span>
|
||||
</p>
|
||||
</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-background 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();
|
||||
@@ -176,102 +173,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>
|
||||
|
||||
<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"
|
||||
<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}
|
||||
>
|
||||
<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>
|
||||
<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="mx-3 my-1 h-px border-0 bg-border" />
|
||||
|
||||
{/* ── 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>
|
||||
</button>
|
||||
|
||||
{workspaceSwitcherSlot ? (
|
||||
<>
|
||||
<hr className="mx-3 my-1 h-px border-0 bg-border" />
|
||||
{/* ── Workspace / relay selector ─────────────────── */}
|
||||
<div
|
||||
className="px-4 py-2"
|
||||
data-testid="profile-popover-workspace"
|
||||
>
|
||||
{workspaceSwitcherSlot}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="h-1" />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -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) {
|
||||
@@ -168,35 +182,44 @@ 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">
|
||||
<div
|
||||
|
||||
@@ -15,12 +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 {
|
||||
useChannelSections,
|
||||
type ChannelSection,
|
||||
@@ -42,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,
|
||||
@@ -139,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;
|
||||
@@ -212,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);
|
||||
|
||||
@@ -664,80 +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 ? (
|
||||
<span className="mr-1">{selfUserStatus.emoji}</span>
|
||||
) : null}
|
||||
{selfUserStatus.text}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SidebarProfileCard
|
||||
activeWorkspace={activeWorkspace}
|
||||
isPresencePending={isPresencePending}
|
||||
onClearUserStatus={onClearUserStatus}
|
||||
onOpenAddWorkspace={onOpenAddWorkspace}
|
||||
onOpenSettings={onSelectSettings}
|
||||
onRemoveWorkspace={onRemoveWorkspace}
|
||||
onSetPresenceStatus={onSetPresenceStatus}
|
||||
onSetUserStatus={onSetUserStatus}
|
||||
onSwitchWorkspace={onSwitchWorkspace}
|
||||
onUpdateWorkspace={onUpdateWorkspace}
|
||||
profile={profile}
|
||||
resolvedDisplayName={resolvedDisplayName}
|
||||
selfPresenceStatus={selfPresenceStatus}
|
||||
selfUserStatus={selfUserStatus}
|
||||
workspaces={workspaces}
|
||||
/>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
|
||||
@@ -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<Pick<Workspace, "name" | "relayUrl" | "token">>,
|
||||
) => 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<HTMLDivElement | null>(null);
|
||||
const toggleProfilePopover = React.useCallback(
|
||||
() => setProfilePopoverOpen((prev) => !prev),
|
||||
[],
|
||||
);
|
||||
const hasStatus = Boolean(selfUserStatus?.text || selfUserStatus?.emoji);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group/profile-card rounded-xl px-2 py-2 transition-colors hover:bg-sidebar-border/35 focus-within:bg-sidebar-border/35 dark:hover:bg-sidebar-border/30 dark:focus-within:bg-sidebar-border/30"
|
||||
data-testid="sidebar-profile-card"
|
||||
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={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}
|
||||
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
|
||||
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 text-sidebar-foreground/70 outline-hidden transition-opacity duration-150 focus:outline-none focus-visible:outline-none group-hover/profile-card:opacity-0 group-focus-within/profile-card:opacity-0",
|
||||
profilePopoverOpen && "opacity-100",
|
||||
)}
|
||||
data-testid="sidebar-profile-user-status"
|
||||
onClick={toggleProfilePopover}
|
||||
type="button"
|
||||
>
|
||||
{selfUserStatus?.emoji ? (
|
||||
<span className="mr-1">{selfUserStatus.emoji}</span>
|
||||
) : 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-none text-sidebar-foreground/70 opacity-0 transition-opacity duration-150 group-hover/profile-card:opacity-100 group-focus-within/profile-card:opacity-100",
|
||||
profilePopoverOpen && "opacity-0",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{activeWorkspace?.name ?? "No workspace"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative mt-0.5">
|
||||
<WorkspaceSwitcher
|
||||
activeWorkspace={activeWorkspace}
|
||||
onAddWorkspace={onOpenAddWorkspace}
|
||||
onRemoveWorkspace={onRemoveWorkspace}
|
||||
onSwitchWorkspace={onSwitchWorkspace}
|
||||
onUpdateWorkspace={onUpdateWorkspace}
|
||||
variant="profile"
|
||||
workspaces={workspaces}
|
||||
/>
|
||||
</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,
|
||||
@@ -26,7 +27,6 @@ import {
|
||||
isRelayConnectionDegraded,
|
||||
useRelayConnection,
|
||||
} from "@/shared/api/useRelayConnection";
|
||||
|
||||
import { EditWorkspaceDialog } from "./EditWorkspaceDialog";
|
||||
|
||||
const CONNECTION_STATE_LABEL: Record<ConnectionState, string> = {
|
||||
@@ -41,7 +41,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: (
|
||||
@@ -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 ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
aria-hidden="false"
|
||||
className={
|
||||
variant === "profile"
|
||||
isProfileLike
|
||||
? "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={isProfileLike ? "h-3 w-3" : "h-4 w-4"} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={variant === "profile" ? "top" : "bottom"}>
|
||||
<TooltipContent side={isProfileLike ? "top" : "bottom"}>
|
||||
{connectionLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span
|
||||
className={
|
||||
variant === "profile"
|
||||
isProfileLike
|
||||
? "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,27 +110,35 @@ 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={
|
||||
isProfileLike
|
||||
? "h-3 w-3 shrink-0 text-sidebar-foreground/45"
|
||||
: "h-3.5 w-3.5 shrink-0 text-sidebar-foreground/50"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const switcherDropdown = (
|
||||
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{variant === "profile" ? (
|
||||
{isProfileLike ? (
|
||||
<button
|
||||
aria-label={
|
||||
degraded
|
||||
? `${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={
|
||||
variant === "profile-menu"
|
||||
? "flex w-full min-w-0 items-center gap-3 py-0.5 text-left text-sm text-popover-foreground outline-hidden transition-colors hover:text-popover-foreground focus:outline-none focus-visible:outline-none data-[state=open]:text-popover-foreground"
|
||||
: "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"
|
||||
>
|
||||
@@ -156,7 +163,13 @@ export function WorkspaceSwitcher({
|
||||
align="start"
|
||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-[220px]"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
side={variant === "profile" ? "top" : "bottom"}
|
||||
side={
|
||||
variant === "profile-menu"
|
||||
? "right"
|
||||
: variant === "profile"
|
||||
? "top"
|
||||
: "bottom"
|
||||
}
|
||||
sideOffset={4}
|
||||
>
|
||||
{workspaces.map((workspace) => (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user