Redesign settings & presence UX: profile popover + modal overlay (#238)

This commit is contained in:
Wes
2026-04-06 08:25:42 -07:00
committed by GitHub
parent 006fb9e178
commit e10ca5193c
13 changed files with 660 additions and 590 deletions

View File

@@ -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",

27
desktop/pnpm-lock.yaml generated
View File

@@ -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)

View File

@@ -65,11 +65,11 @@ import {
} from "@/shared/ui/sidebar";
import { useWebviewZoomShortcuts } from "@/app/useWebviewZoomShortcuts";
type AppView = "home" | "channel" | "settings" | "agents" | "workflows";
type MainView = Exclude<AppView, "settings">;
type AppView = "home" | "channel" | "agents" | "workflows";
export function AppShell() {
useWebviewZoomShortcuts();
const [selectedView, setSelectedView] = React.useState<AppView>("home");
const [settingsOpen, setSettingsOpen] = React.useState(false);
const [settingsSection, setSettingsSection] = React.useState<SettingsSection>(
DEFAULT_SETTINGS_SECTION,
);
@@ -92,9 +92,8 @@ export function AppShell() {
React.useState<RelayEvent | null>(null);
const [replyTargetId, setReplyTargetId] = React.useState<string | null>(null);
const [editTargetId, setEditTargetId] = React.useState<string | null>(null);
const lastNonSettingsViewRef = React.useRef<MainView>("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 (
<ChannelNavigationProvider
@@ -567,257 +551,224 @@ export function AppShell() {
onOpenChannel={handleOpenChannel}
>
<SidebarProvider className="h-dvh overflow-hidden overscroll-none">
{selectedView === "settings" ? (
<div className="flex min-h-0 min-w-0 flex-1 overflow-hidden">
<SettingsView
currentPubkey={identityQuery.data?.pubkey}
fallbackDisplayName={identityQuery.data?.displayName}
isUpdatingDesktopNotifications={
notificationSettings.isUpdatingDesktopEnabled
}
isPresenceLoading={presenceSession.isLoading}
isUpdatingPresence={presenceSession.isPending}
notificationErrorMessage={notificationSettings.errorMessage}
notificationPermission={notificationSettings.permission}
notificationSettings={notificationSettings.settings}
onClose={handleCloseSettings}
onSectionChange={setSettingsSection}
onSetDesktopNotificationsEnabled={
notificationSettings.setDesktopEnabled
}
onSetHomeBadgeEnabled={notificationSettings.setHomeBadgeEnabled}
onSetMentionNotificationsEnabled={
notificationSettings.setMentionsEnabled
}
onSetNeedsActionNotificationsEnabled={
notificationSettings.setNeedsActionEnabled
}
onSetPresence={presenceSession.setStatus}
presenceError={presenceSession.error}
presenceStatus={presenceSession.currentStatus}
section={settingsSection}
<SidebarTrigger className="fixed left-[80px] top-[8px] z-50 h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground" />
<AppSidebar
channels={memberChannels}
currentPubkey={identityQuery.data?.pubkey}
errorMessage={
channelsQuery.error instanceof Error
? channelsQuery.error.message
: undefined
}
fallbackDisplayName={identityQuery.data?.displayName}
homeBadgeCount={homeBadgeCount}
isCreatingChannel={createChannelMutation.isPending}
isCreatingForum={createForumMutation.isPending}
isLoading={channelsQuery.isLoading}
isOpeningDm={openDmMutation.isPending}
selfPresenceStatus={presenceSession.currentStatus}
onCreateChannel={async ({ description, name, visibility }) => {
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}
/>
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
{selectedView === "home" ? (
<ChatHeader
description="Personalized feed for mentions, reminders, channel activity, and agent work."
mode="home"
title="Home"
/>
</div>
) : (
<React.Fragment>
<SidebarTrigger className="fixed left-[80px] top-[8px] z-50 h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground" />
<AppSidebar
channels={memberChannels}
currentPubkey={identityQuery.data?.pubkey}
errorMessage={
channelsQuery.error instanceof Error
? channelsQuery.error.message
: undefined
}
fallbackDisplayName={identityQuery.data?.displayName}
homeBadgeCount={homeBadgeCount}
isCreatingChannel={createChannelMutation.isPending}
isCreatingForum={createForumMutation.isPending}
isLoading={channelsQuery.isLoading}
isOpeningDm={openDmMutation.isPending}
selfPresenceStatus={presenceSession.currentStatus}
onCreateChannel={async ({ description, name, visibility }) => {
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 === "agents" ? (
<ChatHeader
description="Create local ACP workers, mint agent tokens, and monitor the relay-visible agent directory."
mode="agents"
title="Agents"
/>
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
{selectedView === "home" ? (
<ChatHeader
description="Personalized feed for mentions, reminders, channel activity, and agent work."
mode="home"
title="Home"
/>
) : selectedView === "agents" ? (
<ChatHeader
description="Create local ACP workers, mint agent tokens, and monitor the relay-visible agent directory."
mode="agents"
title="Agents"
/>
) : selectedView === "workflows" ? (
<ChatHeader
description="Create, manage, and monitor automated workflows across your channels."
mode="workflows"
title="Workflows"
/>
) : (
<ChatHeader
actions={
activeChannel ? (
<ChannelMembersBar
channel={activeChannel}
currentPubkey={identityQuery.data?.pubkey}
onManageChannel={() => {
setIsChannelManagementOpen(true);
}}
/>
) : null
}
channelType={activeChannel?.channelType}
visibility={activeChannel?.visibility}
description={channelDescription}
statusBadge={
activeChannel?.channelType === "dm" &&
activeDmPresenceStatus ? (
<PresenceBadge
data-testid="chat-presence-badge"
status={activeDmPresenceStatus}
/>
) : null
}
title={activeChannelTitle}
/>
)}
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<div
className={
selectedView === "home"
? "flex min-h-0 flex-1 flex-col"
: "hidden"
}
>
<HomeView
availableChannelIds={availableChannelIds}
) : selectedView === "workflows" ? (
<ChatHeader
description="Create, manage, and monitor automated workflows across your channels."
mode="workflows"
title="Workflows"
/>
) : (
<ChatHeader
actions={
activeChannel ? (
<ChannelMembersBar
channel={activeChannel}
currentPubkey={identityQuery.data?.pubkey}
errorMessage={
homeFeedQuery.error instanceof Error
? homeFeedQuery.error.message
: undefined
}
feed={homeFeedQuery.data}
isLoading={homeFeedQuery.isLoading}
onOpenChannel={handleOpenChannel}
onRefresh={() => {
void homeFeedQuery.refetch();
onManageChannel={() => {
setIsChannelManagementOpen(true);
}}
/>
</div>
<div
className={
selectedView === "agents"
? "flex min-h-0 flex-1 flex-col"
: "hidden"
) : null
}
channelType={activeChannel?.channelType}
visibility={activeChannel?.visibility}
description={channelDescription}
statusBadge={
activeChannel?.channelType === "dm" &&
activeDmPresenceStatus ? (
<PresenceBadge
data-testid="chat-presence-badge"
status={activeDmPresenceStatus}
/>
) : null
}
title={activeChannelTitle}
/>
)}
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<div
className={
selectedView === "home"
? "flex min-h-0 flex-1 flex-col"
: "hidden"
}
>
<HomeView
availableChannelIds={availableChannelIds}
currentPubkey={identityQuery.data?.pubkey}
errorMessage={
homeFeedQuery.error instanceof Error
? homeFeedQuery.error.message
: undefined
}
feed={homeFeedQuery.data}
isLoading={homeFeedQuery.isLoading}
onOpenChannel={handleOpenChannel}
onRefresh={() => {
void homeFeedQuery.refetch();
}}
/>
</div>
<div
className={
selectedView === "agents"
? "flex min-h-0 flex-1 flex-col"
: "hidden"
}
>
<AgentsView />
</div>
<div
className={
selectedView === "workflows"
? "flex min-h-0 flex-1 flex-col"
: "hidden"
}
>
<WorkflowsView channels={memberChannels} />
</div>
<div
className={
selectedView !== "home" &&
selectedView !== "agents" &&
selectedView !== "workflows"
? "flex min-h-0 flex-1 flex-col overflow-hidden"
: "hidden"
}
>
{activeChannel?.channelType === "forum" ? (
<ForumView
channel={activeChannel}
currentPubkey={identityQuery.data?.pubkey}
/>
) : (
<ChannelPane
activeChannel={activeChannel}
currentPubkey={identityQuery.data?.pubkey}
fetchOlder={fetchOlder}
hasOlderMessages={hasOlderMessages}
isFetchingOlder={isFetchingOlder}
editTarget={
editTargetMessage
? {
author: editTargetMessage.author,
body: editTargetMessage.body,
id: editTargetMessage.id,
}
: null
}
>
<AgentsView />
</div>
<div
className={
selectedView === "workflows"
? "flex min-h-0 flex-1 flex-col"
: "hidden"
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
}
>
<WorkflowsView channels={memberChannels} />
</div>
<div
className={
selectedView !== "home" &&
selectedView !== "agents" &&
selectedView !== "workflows"
? "flex min-h-0 flex-1 flex-col overflow-hidden"
: "hidden"
}
>
{activeChannel?.channelType === "forum" ? (
<ForumView
channel={activeChannel}
currentPubkey={identityQuery.data?.pubkey}
/>
) : (
<ChannelPane
activeChannel={activeChannel}
currentPubkey={identityQuery.data?.pubkey}
fetchOlder={fetchOlder}
hasOlderMessages={hasOlderMessages}
isFetchingOlder={isFetchingOlder}
editTarget={
editTargetMessage
? {
author: editTargetMessage.author,
body: editTargetMessage.body,
id: editTargetMessage.id,
}
: 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
}
typingPubkeys={typingPubkeys}
/>
)}
</div>
</div>
</SidebarInset>
</React.Fragment>
)}
typingPubkeys={typingPubkeys}
/>
)}
</div>
</div>
</SidebarInset>
<ChannelBrowserDialog
channels={channels}
@@ -846,6 +797,31 @@ export function AppShell() {
onOpenChange={setIsChannelManagementOpen}
open={isChannelManagementOpen && activeChannel !== null}
/>
{settingsOpen && (
<SettingsView
currentPubkey={identityQuery.data?.pubkey}
fallbackDisplayName={identityQuery.data?.displayName}
isUpdatingDesktopNotifications={
notificationSettings.isUpdatingDesktopEnabled
}
notificationErrorMessage={notificationSettings.errorMessage}
notificationPermission={notificationSettings.permission}
notificationSettings={notificationSettings.settings}
onClose={handleCloseSettings}
onSectionChange={setSettingsSection}
onSetDesktopNotificationsEnabled={
notificationSettings.setDesktopEnabled
}
onSetHomeBadgeEnabled={notificationSettings.setHomeBadgeEnabled}
onSetMentionNotificationsEnabled={
notificationSettings.setMentionsEnabled
}
onSetNeedsActionNotificationsEnabled={
notificationSettings.setNeedsActionEnabled
}
section={settingsSection}
/>
)}
</SidebarProvider>
</ChannelNavigationProvider>
);

View File

@@ -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 <Home className="h-5 w-5 text-primary" />;
@@ -43,10 +34,6 @@ function ChannelIcon({
return <Zap className="h-5 w-5 text-primary" />;
}
if (mode === "settings") {
return <Settings2 className="h-5 w-5 text-primary" />;
}
if (channelType === "dm") {
return <CircleDot className="h-5 w-5 text-primary" />;
}

View File

@@ -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<PresenceStatus, string> = {
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 (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent
side="top"
align="start"
sideOffset={8}
className="w-[280px] rounded-xl border border-border bg-popover p-0 shadow-lg"
data-testid="profile-popover"
>
<div aria-label="Profile menu" role="menu">
{/* ── Identity block ─────────────────────────────────── */}
<div className="flex items-center gap-3 px-4 py-3">
<div className="relative shrink-0">
<ProfileAvatar
avatarUrl={avatarUrl}
className="h-10 w-10 rounded-2xl text-sm"
iconClassName="h-5 w-5"
label={displayName}
/>
</div>
<div className="min-w-0 flex-1">
<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}
<PresenceBadge
className="border-0 bg-transparent px-0 py-0 text-xs"
data-testid="profile-popover-current-status"
status={currentStatus}
/>
</div>
</div>
</div>
<hr className="my-1 h-px border-0 bg-border" />
{/* ── Status options ─────────────────────────────────── */}
<div className="px-1.5 py-1">
{otherStatuses.map((status) => (
<button
key={status}
className={MENU_ITEM_CLASS}
data-testid={`profile-popover-status-${status}`}
disabled={isStatusPending}
onClick={() => {
onSetStatus(status);
onOpenChange(false);
}}
role="menuitem"
type="button"
>
<PresenceDot className="h-2.5 w-2.5" status={status} />
<span className="text-sm text-popover-foreground">
{STATUS_ACTION_LABELS[status]}
</span>
</button>
))}
</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={() => {
onOpenSettings();
onOpenChange(false);
}}
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>
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -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<void>;
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 (
<PresenceBadge data-testid="presence-current-status" status={status} />
);
}
function PresenceSettingsCard({
isLoading,
isUpdating,
onSetPresence,
presenceError,
presenceStatus,
}: {
isLoading: boolean;
isUpdating: boolean;
onSetPresence: (status: PresenceStatus) => Promise<void>;
presenceError: Error | null;
presenceStatus: PresenceStatus;
}) {
return (
<section
className="rounded-xl border border-border/80 bg-card/80 p-4 shadow-sm"
data-testid="settings-presence"
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<h2 className="text-sm font-semibold tracking-tight">Presence</h2>
<p className="text-sm text-muted-foreground">
Choose how this desktop session appears on the relay.
</p>
</div>
<PresenceStatusBadge status={presenceStatus} />
</div>
<div className="mt-4 grid gap-2 md:grid-cols-3">
{presenceOptions.map((option) => {
const isActive = presenceStatus === option.value;
return (
<button
aria-pressed={isActive}
className={cn(
"flex min-h-24 flex-col items-start justify-between rounded-xl border px-4 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
isActive
? "border-primary bg-primary/10 text-foreground"
: "border-border/80 bg-background/60 text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
data-testid={`presence-option-${option.value}`}
disabled={isLoading || isUpdating}
key={option.value}
onClick={() => {
void onSetPresence(option.value);
}}
type="button"
>
<div className="flex items-center gap-2">
<PresenceDot className="h-4 w-4" status={option.value} />
<span className="font-medium text-foreground">
{option.label}
</span>
</div>
<p className="text-sm text-muted-foreground">
{option.description}
</p>
</button>
);
})}
</div>
{presenceError ? (
<p className="mt-4 rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{presenceError.message}
</p>
) : null}
<p
className="mt-4 text-sm text-muted-foreground"
data-testid="presence-help"
>
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.
</p>
</section>
);
}
export function renderSettingsSection(
section: SettingsSection,
props: SettingsPanelProps,
@@ -286,16 +158,6 @@ export function renderSettingsSection(
fallbackDisplayName={props.fallbackDisplayName}
/>
);
case "presence":
return (
<PresenceSettingsCard
isLoading={props.isPresenceLoading}
isUpdating={props.isUpdatingPresence}
onSetPresence={props.onSetPresence}
presenceError={props.presenceError}
presenceStatus={props.presenceStatus}
/>
);
case "notifications":
return (
<NotificationSettingsCard

View File

@@ -1,7 +1,8 @@
import * as React from "react";
import { FocusScope } from "@radix-ui/react-focus-scope";
import { X } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import {
renderSettingsSection,
settingsSections,
@@ -22,10 +23,14 @@ type SettingsViewProps = SettingsPanelProps & {
function SettingsSectionButton({
active,
index,
isLoaded,
onSelect,
section,
}: {
active: boolean;
index: number;
isLoaded: boolean;
onSelect: (section: SettingsSection) => void;
section: (typeof settingsSections)[number];
}) {
@@ -35,13 +40,15 @@ function SettingsSectionButton({
<button
aria-pressed={active}
className={cn(
"group inline-flex min-w-fit items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium whitespace-nowrap transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring lg:w-full lg:justify-start",
"group inline-flex min-w-fit items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium whitespace-nowrap motion-safe:transition-all motion-safe:duration-300 motion-safe:ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
active
? "border-border bg-background text-foreground shadow-sm"
: "border-transparent bg-transparent text-muted-foreground hover:bg-background/70 hover:text-foreground",
isLoaded ? "opacity-100 translate-x-0" : "opacity-0 -translate-x-2",
)}
data-testid={`settings-nav-${section.value}`}
onClick={() => onSelect(section.value)}
style={{ transitionDelay: isLoaded ? "0ms" : `${index * 40 + 300}ms` }}
type="button"
>
<Icon
@@ -61,8 +68,6 @@ export function SettingsView({
currentPubkey,
fallbackDisplayName,
isUpdatingDesktopNotifications,
isPresenceLoading,
isUpdatingPresence,
notificationErrorMessage,
notificationPermission,
notificationSettings,
@@ -72,89 +77,131 @@ export function SettingsView({
onSetHomeBadgeEnabled,
onSetMentionNotificationsEnabled,
onSetNeedsActionNotificationsEnabled,
onSetPresence,
presenceError,
presenceStatus,
section,
}: SettingsViewProps) {
// Entrance animation state
const [isLoaded, setIsLoaded] = React.useState(false);
React.useEffect(() => {
const timer = setTimeout(() => setIsLoaded(true), 50);
return () => clearTimeout(timer);
}, []);
// Escape key to close
React.useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape" && !event.defaultPrevented) {
event.preventDefault();
event.stopPropagation();
onClose();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
return (
<div
className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background"
data-testid="settings-view"
className={cn(
"fixed inset-0 z-50 flex items-center justify-center motion-safe:transition-opacity motion-safe:duration-300",
isLoaded ? "opacity-100" : "opacity-0",
)}
data-testid="settings-overlay"
>
<header
className="flex items-start justify-between gap-4 border-b border-border/80 bg-background px-4 pb-4 pt-8 sm:px-6"
data-tauri-drag-region
>
<div className="min-w-0 pt-0.5">
<h1
className="text-lg font-semibold tracking-tight"
data-testid="settings-title"
>
Settings
</h1>
<p className="text-sm text-muted-foreground">
Manage your relay identity, desktop preferences, and local access
tokens.
</p>
</div>
{/* Backdrop — click to close */}
<div
aria-hidden="true"
className="absolute inset-0 bg-background/80 backdrop-blur-sm"
onClick={onClose}
/>
<Button
aria-label="Close settings"
className="shrink-0 text-muted-foreground hover:text-foreground"
data-testid="settings-close"
onClick={onClose}
size="icon"
title="Close settings"
type="button"
variant="ghost"
{/* Modal container */}
<FocusScope loop>
{/* biome-ignore lint/a11y/useKeyWithClickEvents: Click stops propagation to backdrop; keyboard dismiss handled by Escape key */}
<div
aria-labelledby="settings-title"
aria-modal="true"
className={cn(
"relative mx-auto flex h-[min(600px,calc(100vh-8rem))] w-[calc(100%-4rem)] max-w-3xl flex-col overflow-hidden rounded-xl border border-border bg-background shadow-lg motion-safe:transition-all motion-safe:duration-500 motion-safe:ease-out",
isLoaded ? "opacity-100 scale-100" : "opacity-0 scale-95",
)}
data-testid="settings-view"
onClick={(e) => e.stopPropagation()}
role="dialog"
>
<X className="h-4 w-4" />
</Button>
</header>
<div className="grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)] overflow-hidden lg:grid-cols-[260px_minmax(0,1fr)] lg:grid-rows-1">
<aside className="border-b border-border/70 bg-muted/20 lg:border-b-0 lg:border-r">
<nav
aria-label="Settings sections"
className="flex gap-2 overflow-x-auto px-3 py-4 lg:flex-col lg:overflow-y-auto"
{/* Header with title and close button */}
<header
className="flex shrink-0 items-center justify-between border-b border-border px-4 py-3"
data-tauri-drag-region
>
{settingsSections.map((entry) => (
<SettingsSectionButton
active={entry.value === section}
key={entry.value}
onSelect={onSectionChange}
section={entry}
/>
))}
</nav>
</aside>
<h2
className="text-base font-semibold"
data-testid="settings-title"
id="settings-title"
>
Settings
</h2>
<button
aria-label="Close settings"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-testid="settings-close"
onClick={onClose}
type="button"
>
<X className="h-4 w-4" />
</button>
</header>
<section className="min-h-0 overflow-y-auto px-4 py-4 sm:px-6">
<div
className="mx-auto flex w-full max-w-4xl flex-col gap-4"
data-testid={`settings-panel-${section}`}
>
{renderSettingsSection(section, {
currentPubkey,
fallbackDisplayName,
isUpdatingDesktopNotifications,
isPresenceLoading,
isUpdatingPresence,
notificationErrorMessage,
notificationPermission,
notificationSettings,
onSetDesktopNotificationsEnabled,
onSetHomeBadgeEnabled,
onSetMentionNotificationsEnabled,
onSetNeedsActionNotificationsEnabled,
onSetPresence,
presenceError,
presenceStatus,
})}
{/* Two-column layout */}
<div className="grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)] overflow-hidden lg:grid-cols-[220px_minmax(0,1fr)] lg:grid-rows-1">
{/* Sidebar nav */}
<aside
className={cn(
"border-b border-border/70 bg-muted/20 motion-safe:transition-all motion-safe:duration-700 motion-safe:ease-out lg:border-b-0 lg: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 lg:flex-col lg:overflow-y-auto lg:pt-1"
>
{settingsSections.map((entry, index) => (
<SettingsSectionButton
active={entry.value === section}
index={index}
isLoaded={isLoaded}
key={entry.value}
onSelect={onSectionChange}
section={entry}
/>
))}
</nav>
</aside>
{/* Content area */}
<section className="min-h-0 overflow-y-auto px-4 py-4 sm:px-6">
<div
className="mx-auto flex w-full max-w-4xl flex-col gap-4"
data-testid={`settings-panel-${section}`}
>
{renderSettingsSection(section, {
currentPubkey,
fallbackDisplayName,
isUpdatingDesktopNotifications,
notificationErrorMessage,
notificationPermission,
notificationSettings,
onSetDesktopNotificationsEnabled,
onSetHomeBadgeEnabled,
onSetMentionNotificationsEnabled,
onSetNeedsActionNotificationsEnabled,
})}
</div>
</section>
</div>
</section>
</div>
</div>
</FocusScope>
</div>
);
}

View File

@@ -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<string>;
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({
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
aria-pressed={selectedView === "settings"}
className="h-auto gap-3 rounded-xl px-2 py-2"
data-testid="open-settings"
isActive={selectedView === "settings"}
onClick={() => onSelectSettings()}
type="button"
<ProfilePopover
open={profilePopoverOpen}
onOpenChange={setProfilePopoverOpen}
displayName={resolvedDisplayName}
nip05={profile?.nip05Handle}
avatarUrl={profile?.avatarUrl ?? null}
currentStatus={selfPresenceStatus}
isStatusPending={isPresencePending}
onSetStatus={onSetPresenceStatus ?? (() => {})}
onOpenSettings={onSelectSettings}
>
<div
className="flex min-w-0 flex-1 items-center gap-3"
data-testid="sidebar-profile-card"
<SidebarMenuButton
className="h-auto gap-3 rounded-xl px-2 py-2"
data-testid="open-settings"
type="button"
>
<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}
<div
className="flex min-w-0 flex-1 items-center gap-3"
data-testid="sidebar-profile-card"
>
<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>
<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">
<p
className="truncate text-sm font-semibold text-current"
data-testid="sidebar-profile-name"
>
{resolvedDisplayName}
</p>
</div>
</div>
<div className="min-w-0">
<p
className="truncate text-sm font-semibold text-current"
data-testid="sidebar-profile-name"
>
{resolvedDisplayName}
</p>
</div>
</div>
</SidebarMenuButton>
</SidebarMenuButton>
</ProfilePopover>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>

View File

@@ -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(

View File

@@ -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();

View File

@@ -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 openProfileMenu(page);
await expect(
page.getByTestId("profile-popover-current-status"),
).toContainText("Offline");
await page.getByTestId("presence-option-away").click();
await expect(page.getByTestId("presence-current-status")).toContainText(
"Away",
);
await page.getByTestId("profile-popover-status-away").click();
await openProfileMenu(page);
await expect(
page.getByTestId("profile-popover-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 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(

View File

@@ -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")

View File

@@ -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();
}
}