diff --git a/desktop/package.json b/desktop/package.json index 60aaeea04..5551f8c80 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -30,6 +30,7 @@ "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-focus-scope": "^1.1.8", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml index f2201219e..9b249260d 100644 --- a/desktop/pnpm-lock.yaml +++ b/desktop/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@radix-ui/react-dropdown-menu': specifier: ^2.1.16 version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-scope': + specifier: ^1.1.8 + version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-popover': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -646,6 +649,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-focus-scope@1.1.8': + resolution: {integrity: sha512-BFjgXkfyRXxFJ0t/Xs4QSsb2wmkDfJ983j4vzC95on81gKPtJdJ+5ESHOuwKGm/umcWd2En33AiEMgyUGSKWQw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-id@1.1.1': resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} peerDependencies: @@ -2467,6 +2483,17 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-focus-scope@1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.4)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index eab4a3da8..cdb311abb 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -65,11 +65,11 @@ import { } from "@/shared/ui/sidebar"; import { useWebviewZoomShortcuts } from "@/app/useWebviewZoomShortcuts"; -type AppView = "home" | "channel" | "settings" | "agents" | "workflows"; -type MainView = Exclude; +type AppView = "home" | "channel" | "agents" | "workflows"; export function AppShell() { useWebviewZoomShortcuts(); const [selectedView, setSelectedView] = React.useState("home"); + const [settingsOpen, setSettingsOpen] = React.useState(false); const [settingsSection, setSettingsSection] = React.useState( DEFAULT_SETTINGS_SECTION, ); @@ -92,9 +92,8 @@ export function AppShell() { React.useState(null); const [replyTargetId, setReplyTargetId] = React.useState(null); const [editTargetId, setEditTargetId] = React.useState(null); - const lastNonSettingsViewRef = React.useRef("home"); const queryClient = useQueryClient(); - const selectView = React.useCallback((view: AppView | MainView) => { + const selectView = React.useCallback((view: AppView) => { React.startTransition(() => { setSelectedView(view); }); @@ -367,22 +366,14 @@ export function AppShell() { setIsSearchOpen(false); setIsChannelManagementOpen(false); setSettingsSection(section); - - React.startTransition(() => { - setSelectedView("settings"); - }); + setSettingsOpen(true); }, [], ); const handleCloseSettings = React.useCallback(() => { - const nextView: MainView = - lastNonSettingsViewRef.current === "channel" && !selectedChannel - ? "home" - : lastNonSettingsViewRef.current; - - selectView(nextView); - }, [selectView, selectedChannel]); + setSettingsOpen(false); + }, []); const handleOpenSearchResult = React.useCallback( (hit: SearchHit) => { @@ -441,13 +432,6 @@ export function AppShell() { setReplyTargetId(null); requestedAncestorIdsRef.current.clear(); }, [activeChannelId]); - React.useEffect(() => { - if (selectedView === "settings") { - return; - } - - lastNonSettingsViewRef.current = selectedView; - }, [selectedView]); React.useEffect(() => { if (replyTargetId && !replyTargetMessage) { setReplyTargetId(null); @@ -547,7 +531,7 @@ export function AppShell() { } event.preventDefault(); - if (selectedView === "settings") { + if (settingsOpen) { handleCloseSettings(); return; } @@ -559,7 +543,7 @@ export function AppShell() { return () => { window.removeEventListener("keydown", handleKeyDown); }; - }, [handleCloseSettings, handleOpenSettings, selectedView]); + }, [handleCloseSettings, handleOpenSettings, settingsOpen]); return ( - {selectedView === "settings" ? ( -
- + { + const createdChannel = await createChannelMutation.mutateAsync({ + name, + description, + channelType: "stream", + visibility, + }); + + openChannelView(createdChannel.id); + }} + onCreateForum={async ({ description, name, visibility }) => { + const createdForum = await createForumMutation.mutateAsync({ + name, + description, + channelType: "forum", + visibility, + }); + + openChannelView(createdForum.id); + }} + onOpenBrowseChannels={() => { + setBrowseDialogType("stream"); + void refetchChannels(); + }} + onOpenBrowseForums={() => { + setBrowseDialogType("forum"); + void refetchChannels(); + }} + onOpenSearch={() => { + setIsSearchOpen(true); + void refetchChannels(); + }} + onHideDm={handleHideDm} + onOpenDm={async ({ pubkeys }) => { + const directMessage = await openDmMutation.mutateAsync({ + pubkeys, + }); + openChannelView(directMessage.id); + }} + onSelectAgents={() => selectView("agents")} + onSelectWorkflows={() => selectView("workflows")} + onSelectHome={() => { + selectView("home"); + void homeFeedQuery.refetch(); + }} + onSelectChannel={handleOpenChannel} + onSelectSettings={handleOpenSettings} + onSetPresenceStatus={(status) => presenceSession.setStatus(status)} + isPresencePending={presenceSession.isPending} + profile={profileQuery.data} + selectedChannelId={selectedChannel?.id ?? null} + selectedView={selectedView} + unreadChannelIds={unreadChannelIds} + /> + + + {selectedView === "home" ? ( + + ) : selectedView === "agents" ? ( + + ) : selectedView === "workflows" ? ( + + ) : ( + { + setIsChannelManagementOpen(true); + }} + /> + ) : null } - isPresenceLoading={presenceSession.isLoading} - isUpdatingPresence={presenceSession.isPending} - notificationErrorMessage={notificationSettings.errorMessage} - notificationPermission={notificationSettings.permission} - notificationSettings={notificationSettings.settings} - onClose={handleCloseSettings} - onSectionChange={setSettingsSection} - onSetDesktopNotificationsEnabled={ - notificationSettings.setDesktopEnabled + channelType={activeChannel?.channelType} + visibility={activeChannel?.visibility} + description={channelDescription} + statusBadge={ + activeChannel?.channelType === "dm" && + activeDmPresenceStatus ? ( + + ) : null } - onSetHomeBadgeEnabled={notificationSettings.setHomeBadgeEnabled} - onSetMentionNotificationsEnabled={ - notificationSettings.setMentionsEnabled + title={activeChannelTitle} + /> + )} + +
+
+ { + void homeFeedQuery.refetch(); + }} + /> +
+
-
- ) : ( - - - + +
+
{ - const createdChannel = await createChannelMutation.mutateAsync({ - name, - description, - channelType: "stream", - visibility, - }); - - openChannelView(createdChannel.id); - }} - onCreateForum={async ({ description, name, visibility }) => { - const createdForum = await createForumMutation.mutateAsync({ - name, - description, - channelType: "forum", - visibility, - }); - - openChannelView(createdForum.id); - }} - onOpenBrowseChannels={() => { - setBrowseDialogType("stream"); - void refetchChannels(); - }} - onOpenBrowseForums={() => { - setBrowseDialogType("forum"); - void refetchChannels(); - }} - onOpenSearch={() => { - setIsSearchOpen(true); - void refetchChannels(); - }} - onHideDm={handleHideDm} - onOpenDm={async ({ pubkeys }) => { - const directMessage = await openDmMutation.mutateAsync({ - pubkeys, - }); - openChannelView(directMessage.id); - }} - onSelectAgents={() => selectView("agents")} - onSelectWorkflows={() => selectView("workflows")} - onSelectHome={() => { - selectView("home"); - void homeFeedQuery.refetch(); - }} - onSelectChannel={handleOpenChannel} - onSelectSettings={handleOpenSettings} - profile={profileQuery.data} - selectedChannelId={selectedChannel?.id ?? null} - selectedView={selectedView} - unreadChannelIds={unreadChannelIds} - /> - - - {selectedView === "home" ? ( - - ) : selectedView === "agents" ? ( - - ) : selectedView === "workflows" ? ( - + +
+
+ {activeChannel?.channelType === "forum" ? ( + ) : ( - { - setIsChannelManagementOpen(true); - }} - /> - ) : null + - ) : null + isSending={sendMessageMutation.isPending} + isTimelineLoading={isTimelineLoading} + messages={timelineMessages} + onCancelEdit={handleCancelEdit} + onCancelReply={handleCancelReply} + onDelete={handleDelete} + onEdit={handleEdit} + onEditSave={handleEditSave} + onReply={handleReply} + onSend={handleSend} + onTargetReached={handleTargetReached} + onToggleReaction={effectiveToggleReaction} + profiles={messageProfiles} + replyTargetId={replyTargetId} + replyTargetMessage={replyTargetMessage} + targetMessageId={ + activeChannel && + searchAnchor?.channelId === activeChannel.id + ? searchAnchor.eventId + : null } - title={activeChannelTitle} + typingPubkeys={typingPubkeys} /> )} - -
-
- { - void homeFeedQuery.refetch(); - }} - /> -
-
- -
-
- -
-
- {activeChannel?.channelType === "forum" ? ( - - ) : ( - - )} -
-
- - - )} +
+
+ + {settingsOpen && ( + + )}
); diff --git a/desktop/src/features/chat/ui/ChatHeader.tsx b/desktop/src/features/chat/ui/ChatHeader.tsx index b9a1180b9..affdeca2c 100644 --- a/desktop/src/features/chat/ui/ChatHeader.tsx +++ b/desktop/src/features/chat/ui/ChatHeader.tsx @@ -1,13 +1,4 @@ -import { - Bot, - CircleDot, - FileText, - Hash, - Home, - Lock, - Settings2, - Zap, -} from "lucide-react"; +import { Bot, CircleDot, FileText, Hash, Home, Lock, Zap } from "lucide-react"; import type * as React from "react"; import type { ChannelType, ChannelVisibility } from "@/shared/api/types"; @@ -18,7 +9,7 @@ type ChatHeaderProps = { description: string; channelType?: ChannelType; visibility?: ChannelVisibility; - mode?: "home" | "channel" | "settings" | "agents" | "workflows"; + mode?: "home" | "channel" | "agents" | "workflows"; statusBadge?: React.ReactNode; }; @@ -29,7 +20,7 @@ function ChannelIcon({ }: { channelType?: ChannelType; visibility?: ChannelVisibility; - mode?: "home" | "channel" | "settings" | "agents" | "workflows"; + mode?: "home" | "channel" | "agents" | "workflows"; }) { if (mode === "home") { return ; @@ -43,10 +34,6 @@ function ChannelIcon({ return ; } - if (mode === "settings") { - return ; - } - if (channelType === "dm") { return ; } diff --git a/desktop/src/features/profile/ui/ProfilePopover.tsx b/desktop/src/features/profile/ui/ProfilePopover.tsx new file mode 100644 index 000000000..b46a39384 --- /dev/null +++ b/desktop/src/features/profile/ui/ProfilePopover.tsx @@ -0,0 +1,155 @@ +import type * as React from "react"; +import { Settings } from "lucide-react"; + +import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import { + PresenceDot, + PresenceBadge, +} from "@/features/presence/ui/PresenceBadge"; +import type { PresenceStatus } from "@/shared/api/types"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface ProfilePopoverProps { + open: boolean; + onOpenChange: (open: boolean) => void; + displayName: string; + nip05?: string | null; + avatarUrl: string | null; + currentStatus: PresenceStatus; + isStatusPending?: boolean; + onSetStatus: (status: PresenceStatus) => void; + onOpenSettings: () => void; + children: React.ReactNode; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +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"; + +const ALL_STATUSES: PresenceStatus[] = ["online", "away", "offline"]; + +const STATUS_ACTION_LABELS: Record = { + online: "Set yourself as online", + away: "Set yourself as away", + offline: "Set yourself as offline", +}; + +// --------------------------------------------------------------------------- +// ProfilePopover +// --------------------------------------------------------------------------- + +export function ProfilePopover({ + open, + onOpenChange, + displayName, + nip05, + avatarUrl, + currentStatus, + isStatusPending, + onSetStatus, + onOpenSettings, + children, +}: ProfilePopoverProps) { + const otherStatuses = ALL_STATUSES.filter((s) => s !== currentStatus); + const isMac = + typeof navigator !== "undefined" && + /Mac|iPod|iPhone|iPad/.test(navigator.userAgent); + + return ( + + {children} + + +
+ {/* ── Identity block ─────────────────────────────────── */} +
+
+ +
+
+

+ {displayName} +

+
+ {nip05 ? @{nip05} : null} + {nip05 ? : null} + +
+
+
+ +
+ + {/* ── Status options ─────────────────────────────────── */} +
+ {otherStatuses.map((status) => ( + + ))} +
+ +
+ + {/* ── Settings ───────────────────────────────────────── */} +
+ +
+
+
+
+ ); +} diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index d9c49f1c2..a3c437f19 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -1,6 +1,5 @@ import { BellRing, - CircleDot, KeyRound, MonitorCog, Moon, @@ -13,12 +12,7 @@ import type { DesktopNotificationPermissionState, NotificationSettings, } from "@/features/notifications/hooks"; -import { - PresenceBadge, - PresenceDot, -} from "@/features/presence/ui/PresenceBadge"; import { TokenSettingsCard } from "@/features/tokens/ui/TokenSettingsCard"; -import type { PresenceStatus } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { useTheme } from "@/shared/theme/ThemeProvider"; import { DoctorSettingsPanel } from "./DoctorSettingsPanel"; @@ -27,7 +21,6 @@ import { ProfileSettingsCard } from "./ProfileSettingsCard"; export type SettingsSection = | "profile" - | "presence" | "notifications" | "appearance" | "tokens" @@ -45,8 +38,6 @@ export type SettingsPanelProps = { currentPubkey?: string; fallbackDisplayName?: string; isUpdatingDesktopNotifications: boolean; - isPresenceLoading: boolean; - isUpdatingPresence: boolean; notificationErrorMessage: string | null; notificationPermission: DesktopNotificationPermissionState; notificationSettings: NotificationSettings; @@ -54,9 +45,6 @@ export type SettingsPanelProps = { onSetHomeBadgeEnabled: (enabled: boolean) => void; onSetMentionNotificationsEnabled: (enabled: boolean) => void; onSetNeedsActionNotificationsEnabled: (enabled: boolean) => void; - onSetPresence: (status: PresenceStatus) => Promise; - presenceError: Error | null; - presenceStatus: PresenceStatus; }; type ThemeOption = { @@ -71,11 +59,6 @@ export const settingsSections: SettingsSectionDescriptor[] = [ label: "Profile", icon: UserRound, }, - { - value: "presence", - label: "Presence", - icon: CircleDot, - }, { value: "notifications", label: "Notifications", @@ -116,30 +99,6 @@ const themeOptions: ThemeOption[] = [ }, ]; -const presenceOptions: Array<{ - value: PresenceStatus; - label: string; - description: string; -}> = [ - { - value: "online", - label: "Online", - description: - "Automatically active while you use the app and away when idle.", - }, - { - value: "away", - label: "Away", - description: - "Forces this desktop session to appear idle until you change it.", - }, - { - value: "offline", - label: "Offline", - description: "Hides this desktop session and stops presence heartbeats.", - }, -]; - function ThemeSettingsCard() { const { setTheme, theme } = useTheme(); @@ -187,93 +146,6 @@ function ThemeSettingsCard() { ); } -function PresenceStatusBadge({ status }: { status: PresenceStatus }) { - return ( - - ); -} - -function PresenceSettingsCard({ - isLoading, - isUpdating, - onSetPresence, - presenceError, - presenceStatus, -}: { - isLoading: boolean; - isUpdating: boolean; - onSetPresence: (status: PresenceStatus) => Promise; - presenceError: Error | null; - presenceStatus: PresenceStatus; -}) { - return ( -
-
-
-

Presence

-

- Choose how this desktop session appears on the relay. -

-
- -
- -
- {presenceOptions.map((option) => { - const isActive = presenceStatus === option.value; - - return ( - - ); - })} -
- - {presenceError ? ( -

- {presenceError.message} -

- ) : null} - -

- Sprout refreshes presence every minute while it is running. Online will - switch to away after a few minutes of inactivity or when the app is - hidden. The relay expires presence after 90 seconds. -

-
- ); -} - export function renderSettingsSection( section: SettingsSection, props: SettingsPanelProps, @@ -286,16 +158,6 @@ export function renderSettingsSection( fallbackDisplayName={props.fallbackDisplayName} /> ); - case "presence": - return ( - - ); case "notifications": return ( void; section: (typeof settingsSections)[number]; }) { @@ -35,13 +40,15 @@ function SettingsSectionButton({ - - -
- +

+ Settings +

+ + -
-
- {renderSettingsSection(section, { - currentPubkey, - fallbackDisplayName, - isUpdatingDesktopNotifications, - isPresenceLoading, - isUpdatingPresence, - notificationErrorMessage, - notificationPermission, - notificationSettings, - onSetDesktopNotificationsEnabled, - onSetHomeBadgeEnabled, - onSetMentionNotificationsEnabled, - onSetNeedsActionNotificationsEnabled, - onSetPresence, - presenceError, - presenceStatus, - })} + {/* Two-column layout */} +
+ {/* Sidebar nav */} + + + {/* Content area */} +
+
+ {renderSettingsSection(section, { + currentPubkey, + fallbackDisplayName, + isUpdatingDesktopNotifications, + notificationErrorMessage, + notificationPermission, + notificationSettings, + onSetDesktopNotificationsEnabled, + onSetHomeBadgeEnabled, + onSetMentionNotificationsEnabled, + onSetNeedsActionNotificationsEnabled, + })} +
+
-
-
+ + ); } diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index c0c67a065..a89c413be 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -5,6 +5,7 @@ import { useManagedAgentsQuery } from "@/features/agents/hooks"; 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 { useDmSidebarMetadata } from "@/features/sidebar/useDmSidebarMetadata"; import { ChannelMenuButton, @@ -61,7 +62,7 @@ type AppSidebarProps = { selfPresenceStatus: PresenceStatus; errorMessage?: string; selectedChannelId: string | null; - selectedView: "home" | "channel" | "settings" | "agents" | "workflows"; + selectedView: "home" | "channel" | "agents" | "workflows"; unreadChannelIds: Set; onCreateChannel: (input: { name: string; @@ -83,6 +84,8 @@ type AppSidebarProps = { onSelectHome: () => void; onSelectChannel: (channelId: string) => void; onSelectSettings: () => void; + onSetPresenceStatus?: (status: "online" | "away" | "offline") => void; + isPresencePending?: boolean; }; // --------------------------------------------------------------------------- @@ -448,9 +451,12 @@ export function AppSidebar({ onSelectHome, onSelectChannel, onSelectSettings, + onSetPresenceStatus, + isPresencePending, }: AppSidebarProps) { const skeletonRows = ["first", "second", "third", "fourth", "fifth", "sixth"]; const [isNewDmOpen, setIsNewDmOpen] = React.useState(false); + const [profilePopoverOpen, setProfilePopoverOpen] = React.useState(false); const streamForm = useCreateForm(onCreateChannel, "stream"); const forumForm = useCreateForm(onCreateForum, "forum"); @@ -661,48 +667,57 @@ export function AppSidebar({ - onSelectSettings()} - type="button" + {})} + onOpenSettings={onSelectSettings} > -
-
- - - +
+ - -
-
-

- {resolvedDisplayName} -

+ + + +
+
+

+ {resolvedDisplayName} +

+
-
-
+ +
diff --git a/desktop/tests/e2e/integration.spec.ts b/desktop/tests/e2e/integration.spec.ts index c513d4923..2956e8a8e 100644 --- a/desktop/tests/e2e/integration.spec.ts +++ b/desktop/tests/e2e/integration.spec.ts @@ -1,6 +1,7 @@ import { expect, test, type Browser } from "@playwright/test"; import { installRelayBridge, TEST_IDENTITIES } from "../helpers/bridge"; +import { openSettings } from "../helpers/settings"; import { assertRelaySeeded } from "../helpers/seed"; async function createStream( @@ -35,9 +36,7 @@ async function closeChannelManagement(page: import("@playwright/test").Page) { async function enableDesktopNotifications( page: import("@playwright/test").Page, ) { - await page.getByTestId("open-settings").click(); - await expect(page.getByTestId("settings-view")).toBeVisible(); - await page.getByTestId("settings-nav-notifications").click(); + await openSettings(page, "notifications"); await expect(page.getByTestId("settings-notifications")).toBeVisible(); await page.getByTestId("notifications-desktop-toggle").click(); await expect(page.getByTestId("notifications-desktop-state")).toContainText( diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index 2d8eb7d4b..86ddb6da1 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "@playwright/test"; import { installMockBridge } from "../helpers/bridge"; +import { openSettings } from "../helpers/settings"; test.beforeEach(async ({ page }) => { await installMockBridge(page); @@ -244,8 +245,7 @@ test("shows your avatar on your own message when profile avatar is set", async ( 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"%3E%3Crect width="16" height="16" rx="4" fill="%2300a36c"/%3E%3C/svg%3E'; await page.goto("/"); - await page.getByTestId("open-settings").click(); - await page.getByTestId("settings-nav-profile").click(); + await openSettings(page, "profile"); await page.getByTestId("profile-avatar-url").fill(avatarUrl); await page.getByTestId("profile-save").click(); await page.getByTestId("settings-close").click(); diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index cf1ffd16f..be8069d07 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "@playwright/test"; import { installMockBridge } from "../helpers/bridge"; +import { openProfileMenu, openSettings } from "../helpers/settings"; test.beforeEach(async ({ page }) => { await installMockBridge(page); @@ -13,11 +14,8 @@ test("updates the relay-backed profile from settings", async ({ page }) => { const about = `Coordinating relay profile setup ${stamp}`; await page.goto("/"); - await page.getByTestId("open-settings").click(); - await expect(page.getByTestId("settings-view")).toBeVisible(); - await page.getByTestId("settings-nav-profile").click(); + await openSettings(page, "profile"); await expect(page.getByTestId("settings-title")).toHaveText("Settings"); - await expect(page.getByTestId("open-settings")).toHaveCount(0); await expect(page.getByTestId("profile-pubkey")).toContainText("deadbeef"); await expect(page.getByTestId("profile-nip05")).toContainText("Not set"); @@ -38,9 +36,7 @@ test("updates the relay-backed profile from settings", async ({ page }) => { await expect(page.getByTestId("chat-title")).toHaveText("Home"); await expect(page.getByTestId("open-settings")).toBeVisible(); - await page.getByTestId("open-settings").click(); - await expect(page.getByTestId("settings-view")).toBeVisible(); - await page.getByTestId("settings-nav-profile").click(); + await openSettings(page, "profile"); await expect(page.getByTestId("profile-display-name")).toHaveValue( displayName, ); @@ -49,34 +45,25 @@ test("updates the relay-backed profile from settings", async ({ page }) => { await expect(page.getByTestId("profile-about")).toHaveValue(about); }); -test("updates presence from settings", async ({ page }) => { +test("updates presence from the profile menu", async ({ page }) => { await page.goto("/"); - await page.getByTestId("open-settings").click(); - await expect(page.getByTestId("settings-view")).toBeVisible(); - await page.getByTestId("settings-nav-presence").click(); - await expect(page.getByTestId("presence-current-status")).toContainText( - "Offline", - ); - - await page.getByTestId("presence-option-away").click(); - await expect(page.getByTestId("presence-current-status")).toContainText( - "Away", - ); - - await page.getByTestId("settings-close").click(); - await expect(page.getByTestId("chat-title")).toHaveText("Home"); - - await page.getByTestId("open-settings").click(); - await page.getByTestId("settings-nav-presence").click(); - await expect(page.getByTestId("presence-current-status")).toContainText( - "Away", - ); - - await page.getByTestId("presence-option-offline").click(); - await expect(page.getByTestId("presence-current-status")).toContainText( - "Offline", - ); + await openProfileMenu(page); + await expect( + page.getByTestId("profile-popover-current-status"), + ).toContainText("Offline"); + + await page.getByTestId("profile-popover-status-away").click(); + await openProfileMenu(page); + await expect( + page.getByTestId("profile-popover-current-status"), + ).toContainText("Away"); + + await page.getByTestId("profile-popover-status-offline").click(); + await openProfileMenu(page); + await expect( + page.getByTestId("profile-popover-current-status"), + ).toContainText("Offline"); }); test("notification settings drive the Home badge and desktop alerts", async ({ @@ -85,9 +72,7 @@ test("notification settings drive the Home badge and desktop alerts", async ({ await page.goto("/"); await expect(page.getByTestId("sidebar-home-count")).toHaveCount(0); - await page.getByTestId("open-settings").click(); - await expect(page.getByTestId("settings-view")).toBeVisible(); - await page.getByTestId("settings-nav-notifications").click(); + await openSettings(page, "notifications"); await expect(page.getByTestId("settings-notifications")).toBeVisible(); await expect(page.getByTestId("notifications-desktop-state")).toContainText( "Off", @@ -171,15 +156,13 @@ test("notification settings drive the Home badge and desktop alerts", async ({ }, ]); - await page.getByTestId("open-settings").click(); - await page.getByTestId("settings-nav-notifications").click(); + await openSettings(page, "notifications"); await page.getByTestId("notifications-home-badge-toggle").click(); await page.getByTestId("settings-close").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await expect(page.getByTestId("sidebar-home-count")).toHaveCount(0); - await page.getByTestId("open-settings").click(); - await page.getByTestId("settings-nav-notifications").click(); + await openSettings(page, "notifications"); await page.getByTestId("notifications-home-badge-toggle").click(); await page.getByTestId("settings-close").click(); await expect(page.getByTestId("sidebar-home-count")).toHaveText("1"); @@ -257,9 +240,7 @@ test("supports webview zoom keyboard shortcuts", async ({ page }) => { test("shows doctor checks for local sprout tooling", async ({ page }) => { await page.goto("/"); - await page.getByTestId("open-settings").click(); - await expect(page.getByTestId("settings-view")).toBeVisible(); - await page.getByTestId("settings-nav-doctor").click(); + await openSettings(page, "doctor"); await expect(page.getByTestId("settings-doctor")).toBeVisible(); await expect(page.getByTestId("doctor-check-admin")).toContainText( diff --git a/desktop/tests/e2e/tokens.spec.ts b/desktop/tests/e2e/tokens.spec.ts index b9416edf7..fa7380334 100644 --- a/desktop/tests/e2e/tokens.spec.ts +++ b/desktop/tests/e2e/tokens.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "@playwright/test"; import { installMockBridge } from "../helpers/bridge"; +import { openSettings } from "../helpers/settings"; const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; const DESIGN_CHANNEL_ID = "b5e2f8a1-3c44-5912-9e67-4a8d1f2b3c4e"; @@ -11,9 +12,7 @@ test("creates a channel-scoped token from settings and can revoke it", async ({ await installMockBridge(page); await page.goto("/"); - await page.getByTestId("open-settings").click(); - await expect(page.getByTestId("settings-view")).toBeVisible(); - await page.getByTestId("settings-nav-tokens").click(); + await openSettings(page, "tokens"); const tokenCard = page.getByTestId("settings-tokens"); await tokenCard.getByRole("button", { name: "Create token" }).click(); @@ -62,9 +61,7 @@ test("surfaces token mint errors in the dialog", async ({ page }) => { }); await page.goto("/"); - await page.getByTestId("open-settings").click(); - await expect(page.getByTestId("settings-view")).toBeVisible(); - await page.getByTestId("settings-nav-tokens").click(); + await openSettings(page, "tokens"); await page .getByTestId("settings-tokens") diff --git a/desktop/tests/helpers/settings.ts b/desktop/tests/helpers/settings.ts new file mode 100644 index 000000000..ddf508b06 --- /dev/null +++ b/desktop/tests/helpers/settings.ts @@ -0,0 +1,23 @@ +import { expect, type Page } from "@playwright/test"; + +type SettingsSection = + | "profile" + | "notifications" + | "appearance" + | "tokens" + | "doctor"; + +export async function openProfileMenu(page: Page) { + await page.getByTestId("open-settings").click(); + await expect(page.getByTestId("profile-popover")).toBeVisible(); +} + +export async function openSettings(page: Page, section?: SettingsSection) { + await openProfileMenu(page); + await page.getByTestId("profile-popover-settings").click(); + await expect(page.getByTestId("settings-view")).toBeVisible(); + + if (section) { + await page.getByTestId(`settings-nav-${section}`).click(); + } +}