From 0cebddaf3ece462fe70e8b69a2597394333bd5f7 Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 16 Mar 2026 10:17:16 -0700 Subject: [PATCH] Add desktop settings shell and doctor panel (#80) --- desktop/src-tauri/src/commands/media.rs | 7 +- desktop/src/app/AppShell.tsx | 530 +++++++-------- desktop/src/app/ChannelPane.tsx | 111 +++ .../features/agents/ui/CreateAgentDialog.tsx | 34 +- .../agents/ui/CreateAgentDialogSections.tsx | 76 --- .../settings/ui/DoctorSettingsPanel.tsx | 368 ++++++++++ .../settings/ui/ProfileSettingsCard.tsx | 315 +++++++++ .../features/settings/ui/SettingsPanels.tsx | 290 ++++++++ .../src/features/settings/ui/SettingsView.tsx | 643 ++++-------------- .../src/features/sidebar/ui/AppSidebar.tsx | 2 +- desktop/tests/e2e/messaging.spec.ts | 2 + desktop/tests/e2e/profile.spec.ts | 49 +- desktop/tests/e2e/tokens.spec.ts | 2 + 13 files changed, 1527 insertions(+), 902 deletions(-) create mode 100644 desktop/src/app/ChannelPane.tsx create mode 100644 desktop/src/features/settings/ui/DoctorSettingsPanel.tsx create mode 100644 desktop/src/features/settings/ui/ProfileSettingsCard.tsx create mode 100644 desktop/src/features/settings/ui/SettingsPanels.tsx diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index 99480ff8a..3da6e84f9 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -71,7 +71,12 @@ fn fd_real_path(file: &std::fs::File) -> Result { let handle = file.as_raw_handle() as isize; let mut buf = vec![0u16; 1024]; let len = unsafe { - GetFinalPathNameByHandleW(handle, buf.as_mut_ptr(), buf.len() as u32, FILE_NAME_NORMALIZED) + GetFinalPathNameByHandleW( + handle, + buf.as_mut_ptr(), + buf.len() as u32, + FILE_NAME_NORMALIZED, + ) }; if len == 0 { return Err(format!( diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index b119c686f..4cac69ead 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { useQueryClient } from "@tanstack/react-query"; import { Settings2 } from "lucide-react"; +import { ChannelPane } from "@/app/ChannelPane"; import { AgentsView } from "@/features/agents/ui/AgentsView"; import { ChatHeader } from "@/features/chat/ui/ChatHeader"; import { @@ -35,11 +36,13 @@ import { } from "@/features/presence/hooks"; import { PresenceBadge } from "@/features/presence/ui/PresenceBadge"; import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks"; -import { MessageComposer } from "@/features/messages/ui/MessageComposer"; -import { MessageTimeline } from "@/features/messages/ui/MessageTimeline"; import { ChannelBrowserDialog } from "@/features/channels/ui/ChannelBrowserDialog"; import { SearchDialog } from "@/features/search/ui/SearchDialog"; -import { SettingsView } from "@/features/settings/ui/SettingsView"; +import { + DEFAULT_SETTINGS_SECTION, + SettingsView, + type SettingsSection, +} from "@/features/settings/ui/SettingsView"; import { AppSidebar } from "@/features/sidebar/ui/AppSidebar"; import { relayClient } from "@/shared/api/relayClient"; import { getEventById, joinChannel } from "@/shared/api/tauri"; @@ -53,6 +56,7 @@ import { } from "@/shared/ui/sidebar"; type AppView = "home" | "channel" | "settings" | "agents"; +type MainView = Exclude; function createSearchAnchorEvent(hit: SearchHit): RelayEvent { return { @@ -68,6 +72,9 @@ function createSearchAnchorEvent(hit: SearchHit): RelayEvent { export function AppShell() { const [selectedView, setSelectedView] = React.useState("home"); + const [settingsSection, setSettingsSection] = React.useState( + DEFAULT_SETTINGS_SECTION, + ); const [isChannelManagementOpen, setIsChannelManagementOpen] = React.useState(false); const [isSearchOpen, setIsSearchOpen] = React.useState(false); @@ -81,6 +88,7 @@ export function AppShell() { const [searchAnchorEvent, setSearchAnchorEvent] = React.useState(null); const [replyTargetId, setReplyTargetId] = React.useState(null); + const lastNonSettingsViewRef = React.useRef("home"); const queryClient = useQueryClient(); const identityQuery = useIdentityQuery(); const profileQuery = useProfileQuery(); @@ -268,14 +276,29 @@ export function AppShell() { [queryClient], ); - const handleOpenSettings = React.useCallback(() => { - setIsSearchOpen(false); - setIsChannelManagementOpen(false); + const handleOpenSettings = React.useCallback( + (section: SettingsSection = DEFAULT_SETTINGS_SECTION) => { + setIsSearchOpen(false); + setIsChannelManagementOpen(false); + setSettingsSection(section); + + React.startTransition(() => { + setSelectedView("settings"); + }); + }, + [], + ); + + const handleCloseSettings = React.useCallback(() => { + const nextView: MainView = + lastNonSettingsViewRef.current === "channel" && !selectedChannel + ? "home" + : lastNonSettingsViewRef.current; React.startTransition(() => { - setSelectedView("settings"); + setSelectedView(nextView); }); - }, []); + }, [selectedChannel]); const handleOpenSearchResult = React.useCallback( (hit: SearchHit) => { @@ -329,6 +352,14 @@ export function AppShell() { requestedAncestorIdsRef.current.clear(); }, [activeChannelId]); + React.useEffect(() => { + if (selectedView === "settings") { + return; + } + + lastNonSettingsViewRef.current = selectedView; + }, [selectedView]); + React.useEffect(() => { if (replyTargetId && !replyTargetMessage) { setReplyTargetId(null); @@ -411,6 +442,11 @@ export function AppShell() { } event.preventDefault(); + if (selectedView === "settings") { + handleCloseSettings(); + return; + } + handleOpenSettings(); } @@ -418,273 +454,235 @@ export function AppShell() { return () => { window.removeEventListener("keydown", handleKeyDown); }; - }, [handleOpenSettings]); + }, [handleCloseSettings, handleOpenSettings, selectedView]); return ( - - { - const createdChannel = await createChannelMutation.mutateAsync({ - name, - description, - channelType: "stream", - visibility: "open", - }); + {selectedView === "settings" ? ( +
+ +
+ ) : ( + + + { + const createdChannel = await createChannelMutation.mutateAsync({ + name, + description, + channelType: "stream", + visibility: "open", + }); - React.startTransition(() => { - setSelectedChannelId(createdChannel.id); - setSelectedView("channel"); - }); - }} - onOpenBrowseChannels={() => { - setIsBrowseChannelsOpen(true); - void refetchChannels(); - }} - onOpenSearch={() => { - setIsSearchOpen(true); - void refetchChannels(); - }} - onSelectAgents={() => { - React.startTransition(() => { - setSelectedView("agents"); - }); - }} - onSelectHome={() => { - React.startTransition(() => { - setSelectedView("home"); - }); + React.startTransition(() => { + setSelectedChannelId(createdChannel.id); + setSelectedView("channel"); + }); + }} + onOpenBrowseChannels={() => { + setIsBrowseChannelsOpen(true); + void refetchChannels(); + }} + onOpenSearch={() => { + setIsSearchOpen(true); + void refetchChannels(); + }} + onSelectAgents={() => { + React.startTransition(() => { + setSelectedView("agents"); + }); + }} + onSelectHome={() => { + React.startTransition(() => { + setSelectedView("home"); + }); - void homeFeedQuery.refetch(); - }} + void homeFeedQuery.refetch(); + }} + onSelectChannel={handleOpenChannel} + onSelectSettings={handleOpenSettings} + profile={profileQuery.data} + selectedChannelId={selectedChannel?.id ?? null} + selectedView={selectedView} + unreadChannelIds={unreadChannelIds} + /> + + + {selectedView === "home" ? ( + + ) : selectedView === "agents" ? ( + + ) : ( + { + setIsChannelManagementOpen(true); + }} + size="icon" + type="button" + variant="outline" + > + + + ) : null + } + channelType={activeChannel?.channelType} + description={channelDescription} + statusBadge={ + activeChannel?.channelType === "dm" && + activeDmPresenceStatus ? ( + + ) : null + } + title={activeChannel?.name ?? "Channels"} + /> + )} + +
+ {selectedView === "home" ? ( + { + void homeFeedQuery.refetch(); + }} + /> + ) : selectedView === "agents" ? ( + + ) : ( + { + setReplyTargetId(null); + }} + onReply={(message) => { + setReplyTargetId((current) => + current === message.id ? null : message.id, + ); + }} + onSend={async (content, mentionPubkeys, mediaTags) => { + await sendMessageMutation.mutateAsync({ + content, + mentionPubkeys, + parentEventId: replyTargetId, + mediaTags, + }); + setReplyTargetId(null); + }} + onTargetReached={(messageId) => { + setSearchAnchor((current) => + current?.eventId === messageId ? null : current, + ); + }} + onToggleReaction={ + activeChannel && + activeChannel.archivedAt === null && + activeChannel.channelType !== "forum" + ? async (message, emoji, remove) => { + await toggleReactionMutation.mutateAsync({ + emoji, + eventId: message.id, + remove, + }); + } + : undefined + } + profiles={messageProfilesQuery.data?.profiles} + replyTargetId={replyTargetId} + replyTargetMessage={replyTargetMessage} + targetMessageId={ + activeChannel && + searchAnchor?.channelId === activeChannel.id + ? searchAnchor.eventId + : null + } + /> + )} +
+
+
+ )} + + - - {selectedView === "home" ? ( - - ) : selectedView === "agents" ? ( - - ) : selectedView === "settings" ? ( - - ) : ( - { - setIsChannelManagementOpen(true); - }} - size="icon" - type="button" - variant="outline" - > - - - ) : null - } - channelType={activeChannel?.channelType} - description={channelDescription} - statusBadge={ - activeChannel?.channelType === "dm" && activeDmPresenceStatus ? ( - - ) : null - } - title={activeChannel?.name ?? "Channels"} - /> - )} + -
- {selectedView === "home" ? ( - { - void homeFeedQuery.refetch(); - }} - /> - ) : selectedView === "agents" ? ( - - ) : selectedView === "settings" ? ( - - ) : ( - - { - setReplyTargetId((current) => - current === message.id ? null : message.id, - ); - }} - onToggleReaction={ - activeChannel && - activeChannel.archivedAt === null && - activeChannel.channelType !== "forum" - ? async (message, emoji, remove) => { - await toggleReactionMutation.mutateAsync({ - emoji, - eventId: message.id, - remove, - }); - } - : undefined - } - onTargetReached={(messageId) => { - setSearchAnchor((current) => - current?.eventId === messageId ? null : current, - ); - }} - targetMessageId={ - activeChannel && searchAnchor?.channelId === activeChannel.id - ? searchAnchor.eventId - : null - } - /> - { - setReplyTargetId(null); - }} - onSend={async (content, mentionPubkeys, mediaTags) => { - await sendMessageMutation.mutateAsync({ - content, - mentionPubkeys, - parentEventId: replyTargetId, - mediaTags, - }); - setReplyTargetId(null); - }} - placeholder={ - activeChannel?.archivedAt - ? "Archived channels are read-only." - : activeChannel && !activeChannel.isMember - ? "Join this channel to message." - : activeChannel?.channelType === "forum" - ? "Forum posting is not wired in this pass." - : activeChannel - ? `Message #${activeChannel.name}` - : "Select a channel" - } - replyTarget={ - replyTargetMessage - ? { - author: replyTargetMessage.author, - body: replyTargetMessage.body, - id: replyTargetMessage.id, - } - : null - } - /> - - )} -
- - - - - - { - React.startTransition(() => { - setIsChannelManagementOpen(false); - setSelectedView("home"); - }); - }} - onOpenChange={setIsChannelManagementOpen} - open={isChannelManagementOpen && activeChannel !== null} - /> -
+ { + React.startTransition(() => { + setIsChannelManagementOpen(false); + setSelectedView("home"); + }); + }} + onOpenChange={setIsChannelManagementOpen} + open={isChannelManagementOpen && activeChannel !== null} + />
); } diff --git a/desktop/src/app/ChannelPane.tsx b/desktop/src/app/ChannelPane.tsx new file mode 100644 index 000000000..f389cfc8a --- /dev/null +++ b/desktop/src/app/ChannelPane.tsx @@ -0,0 +1,111 @@ +import * as React from "react"; + +import { MessageComposer } from "@/features/messages/ui/MessageComposer"; +import { MessageTimeline } from "@/features/messages/ui/MessageTimeline"; +import type { TimelineMessage } from "@/features/messages/types"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import type { Channel } from "@/shared/api/types"; + +type ChannelPaneProps = { + activeChannel: Channel | null; + currentPubkey?: string; + isSending: boolean; + isTimelineLoading: boolean; + messages: TimelineMessage[]; + onCancelReply: () => void; + onReply: (message: TimelineMessage) => void; + onSend: ( + content: string, + mentionPubkeys: string[], + mediaTags?: string[][], + ) => Promise; + onTargetReached: (messageId: string) => void; + onToggleReaction?: ( + message: TimelineMessage, + emoji: string, + remove: boolean, + ) => Promise; + profiles?: UserProfileLookup; + replyTargetId: string | null; + replyTargetMessage: TimelineMessage | null; + targetMessageId: string | null; +}; + +export function ChannelPane({ + activeChannel, + currentPubkey, + isSending, + isTimelineLoading, + messages, + onCancelReply, + onReply, + onSend, + onTargetReached, + onToggleReaction, + profiles, + replyTargetId, + replyTargetMessage, + targetMessageId, +}: ChannelPaneProps) { + return ( + + + + + ); +} diff --git a/desktop/src/features/agents/ui/CreateAgentDialog.tsx b/desktop/src/features/agents/ui/CreateAgentDialog.tsx index 4254a55ef..763c04fb8 100644 --- a/desktop/src/features/agents/ui/CreateAgentDialog.tsx +++ b/desktop/src/features/agents/ui/CreateAgentDialog.tsx @@ -23,7 +23,6 @@ import { import { CreateAgentBasicsFields, CreateAgentOptionToggles, - CreateAgentPrerequisitesCard, CreateAgentRuntimeProviderField, CreateAgentRuntimeFields, CreateAgentTokenSection, @@ -74,27 +73,6 @@ export function CreateAgentDialog({ const spawnToggleDisabled = prereqsQuery.isLoading || (prereqs !== null && !isSpawnSupported); const isDiscoveryPending = providersQuery.isLoading || prereqsQuery.isLoading; - const prerequisiteCards = [ - { - id: "admin", - label: "Token minting", - info: prereqs?.admin ?? null, - command: prereqs?.admin.command ?? "sprout-admin", - }, - { - id: "acp", - label: "ACP harness", - info: prereqs?.acp ?? null, - command: prereqs?.acp.command ?? (acpCommand.trim() || "sprout-acp"), - }, - { - id: "mcp", - label: "MCP server", - info: prereqs?.mcp ?? null, - command: - prereqs?.mcp.command ?? (mcpCommand.trim() || "sprout-mcp-server"), - }, - ]; React.useEffect(() => { if (hasSyncedProviderSelection || providersQuery.isLoading) { @@ -302,8 +280,7 @@ export function CreateAgentDialog({ Advanced setup

- Relay overrides, raw commands, timeout, and local binary - checks. + Relay overrides, raw commands, timeout, and doctor guidance.

@@ -332,11 +309,10 @@ export function CreateAgentDialog({ turnTimeoutSeconds={turnTimeoutSeconds} /> - +

+ Local Sprout binary checks and ACP runtime discovery now + live in Settings > Doctor. +

{providersQuery.error instanceof Error ? (

diff --git a/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx b/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx index abde304d4..c78798387 100644 --- a/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx +++ b/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx @@ -1,6 +1,5 @@ import type { AcpProvider, - CommandAvailability, ManagedAgentPrereqs, TokenScope, } from "@/shared/api/types"; @@ -9,13 +8,6 @@ import { cn } from "@/shared/lib/cn"; import { Input } from "@/shared/ui/input"; import { describeResolvedCommand } from "./agentUi"; -export type PrerequisiteCard = { - id: string; - label: string; - info: CommandAvailability | null; - command: string; -}; - export function CreateAgentBasicsFields({ name, onNameChange, @@ -215,74 +207,6 @@ export function CreateAgentRuntimeFields({ ); } -export function CreateAgentPrerequisitesCard({ - isLoading, - prereqs, - prerequisiteCards, -}: { - isLoading: boolean; - prereqs: ManagedAgentPrereqs | null; - prerequisiteCards: PrerequisiteCard[]; -}) { - return ( -

-
-
-

- Local Sprout binaries -

-

- The desktop app uses these commands to mint tokens and spawn - harnesses. -

-
- {isLoading ? ( - Checking... - ) : null} -
- -
- {prerequisiteCards.map((card) => ( -
-

- {card.label} -

-

{card.command}

-

- {card.info?.resolvedPath - ? `Available via ${describeResolvedCommand(card.command, card.info.resolvedPath)}` - : isLoading - ? "Looking for a matching binary..." - : "Not currently available."} -

-
- ))} -
- - {prereqs && - (!prereqs.admin.available || - !prereqs.acp.available || - !prereqs.mcp.available) ? ( -

- Build the workspace binaries with `cargo build --release --workspace` - or point the command fields at installed binaries before enabling - token minting or spawn. -

- ) : null} -
- ); -} - export function CreateAgentOptionToggles({ isMintSupported, isSpawnSupported, diff --git a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx new file mode 100644 index 000000000..6097f7233 --- /dev/null +++ b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx @@ -0,0 +1,368 @@ +import { + AlertTriangle, + CheckCircle2, + RefreshCw, + Sparkles, + Stethoscope, + TerminalSquare, +} from "lucide-react"; +import * as React from "react"; + +import { + useAcpProvidersQuery, + useManagedAgentPrereqsQuery, +} from "@/features/agents/hooks"; +import { describeResolvedCommand } from "@/features/agents/ui/agentUi"; +import type { CommandAvailability } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; + +function StatusIcon({ available }: { available: boolean }) { + return available ? ( + + ) : ( + + ); +} + +function CommandCheckRow({ + availability, + id, + isLoading, + label, + purpose, +}: { + availability: CommandAvailability | null; + id: string; + isLoading: boolean; + label: string; + purpose: string; +}) { + const command = availability?.command ?? "Unavailable"; + const isAvailable = availability?.available ?? false; + + return ( +
+
+ +
+ +
+
+

{label}

+ + {command} + +
+

{purpose}

+

+ {availability?.resolvedPath + ? `Available via ${describeResolvedCommand(command, availability.resolvedPath)}` + : isLoading + ? "Checking for a matching binary..." + : "Not currently available."} +

+ {availability?.resolvedPath ? ( +

+ {availability.resolvedPath} +

+ ) : null} +
+
+ ); +} + +function ProviderRow({ + command, + defaultArgs, + label, + providerId, + resolvedPath, +}: { + command: string; + defaultArgs: string[]; + label: string; + providerId: string; + resolvedPath: string; +}) { + return ( +
+
+ +
+ +
+
+

{label}

+ + {command} + +
+

+ Available via {describeResolvedCommand(command, resolvedPath)}. +

+ {defaultArgs.length > 0 ? ( +

+ Default args:{" "} + {defaultArgs.join(", ")} +

+ ) : null} +

+ {resolvedPath} +

+
+
+ ); +} + +function SetupHelpCard() { + return ( +
+
+ +

Setup help

+
+ +
+

+ Build the local Sprout tools with{" "} + + cargo build --release --workspace + {" "} + when you want the desktop app to mint tokens or spawn ACP harnesses + from this checkout. +

+

+ If you keep binaries outside your PATH, use the custom ACP and MCP + commands below and then copy those same values into Create agent > + Advanced setup. +

+

+ ACP runtimes like Goose or Codex are optional. They appear + automatically once their commands are installed on your PATH. +

+
+
+ ); +} + +export function DoctorSettingsPanel() { + const [acpCommand, setAcpCommand] = React.useState("sprout-acp"); + const [mcpCommand, setMcpCommand] = React.useState("sprout-mcp-server"); + const providersQuery = useAcpProvidersQuery(); + const prereqsQuery = useManagedAgentPrereqsQuery(acpCommand, mcpCommand); + const prereqs = prereqsQuery.data ?? null; + const providers = providersQuery.data ?? []; + const isRefreshing = providersQuery.isFetching || prereqsQuery.isFetching; + + const toolChecks = [ + { + id: "admin", + label: "Token minting", + purpose: + "Desktop uses `sprout-admin` to mint managed-agent bearer tokens.", + availability: prereqs?.admin ?? null, + }, + { + id: "acp", + label: "ACP harness", + purpose: + "Desktop launches this command to bridge a local runtime into ACP.", + availability: prereqs?.acp ?? null, + }, + { + id: "mcp", + label: "MCP server", + purpose: + "Desktop uses this server when the ACP harness requests Sprout tools.", + availability: prereqs?.mcp ?? null, + }, + ]; + + const hasMissingSproutTools = + prereqs !== null && + (!prereqs.admin.available || + !prereqs.acp.available || + !prereqs.mcp.available); + + return ( +
+
+
+
+
+ +

Doctor

+
+

+ Verify the local Sprout tools and ACP runtime commands used by the + desktop app. +

+
+ + +
+ +
+
+
+
+ +

+ Local Sprout binaries +

+
+

+ These checks replace the old binary status card from Create + agent. +

+ +
+ {toolChecks.map((check) => ( + + ))} +
+ + {hasMissingSproutTools ? ( +

+ Build the workspace binaries with{" "} + + cargo build --release --workspace + {" "} + or point agent creation at custom ACP and MCP commands. +

+ ) : null} + + {prereqsQuery.error instanceof Error ? ( +

+ {prereqsQuery.error.message} +

+ ) : null} +
+ +
+

+ Custom harness commands +

+

+ Verify non-default ACP or MCP binaries before using them in + agent creation. +

+ +
+
+ + setAcpCommand(event.target.value)} + value={acpCommand} + /> +
+ +
+ + setMcpCommand(event.target.value)} + value={mcpCommand} + /> +
+
+ +

+ Token minting always checks the default{" "} + sprout-admin command. +

+
+
+ +
+
+

+ ACP runtimes +

+

+ Installed runtimes that the desktop app can offer in Create + agent. +

+ +
+ {providersQuery.isLoading ? ( +

+ Looking for installed ACP runtimes... +

+ ) : providers.length > 0 ? ( + providers.map((provider) => ( + + )) + ) : ( +
+ No known ACP runtime was detected on your PATH yet. You can + still use a custom command in Create agent. +
+ )} +
+ + {providersQuery.error instanceof Error ? ( +

+ {providersQuery.error.message} +

+ ) : null} +
+ + +
+
+
+
+ ); +} diff --git a/desktop/src/features/settings/ui/ProfileSettingsCard.tsx b/desktop/src/features/settings/ui/ProfileSettingsCard.tsx new file mode 100644 index 000000000..09541b2e8 --- /dev/null +++ b/desktop/src/features/settings/ui/ProfileSettingsCard.tsx @@ -0,0 +1,315 @@ +import { AtSign, Check, Fingerprint, Link2, UserRound } from "lucide-react"; +import * as React from "react"; + +import { + useProfileQuery, + useUpdateProfileMutation, +} from "@/features/profile/hooks"; +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; +import { Separator } from "@/shared/ui/separator"; +import { Textarea } from "@/shared/ui/textarea"; + +type ProfileSettingsCardProps = { + currentPubkey?: string; + fallbackDisplayName?: string; +}; + +function Section({ + title, + description, + children, +}: React.PropsWithChildren<{ + title: string; + description?: string; +}>) { + return ( +
+
+

{title}

+ {description ? ( +

{description}

+ ) : null} +
+ {children} +
+ ); +} + +function ReadOnlyField({ + label, + value, + testId, +}: { + label: string; + value: string; + testId: string; +}) { + return ( +
+

{label}

+
+ {value} +
+
+ ); +} + +export function ProfileSettingsCard({ + currentPubkey, + fallbackDisplayName, +}: ProfileSettingsCardProps) { + const profileQuery = useProfileQuery(); + const updateProfileMutation = useUpdateProfileMutation(); + const profile = profileQuery.data; + + const currentDisplayName = profile?.displayName ?? ""; + const currentAvatarUrl = profile?.avatarUrl ?? ""; + const currentAbout = profile?.about ?? ""; + const currentNip05Handle = profile?.nip05Handle ?? ""; + + const [displayNameDraft, setDisplayNameDraft] = React.useState(""); + const [avatarUrlDraft, setAvatarUrlDraft] = React.useState(""); + const [aboutDraft, setAboutDraft] = React.useState(""); + const [nip05HandleDraft, setNip05HandleDraft] = React.useState(""); + + React.useEffect(() => { + setDisplayNameDraft(currentDisplayName); + setAvatarUrlDraft(currentAvatarUrl); + setAboutDraft(currentAbout); + setNip05HandleDraft(currentNip05Handle); + }, [currentAbout, currentAvatarUrl, currentDisplayName, currentNip05Handle]); + + const nextDisplayName = displayNameDraft.trim(); + const nextAvatarUrl = avatarUrlDraft.trim(); + const nextAbout = aboutDraft.trim(); + const nextNip05Handle = nip05HandleDraft.trim(); + + const updatePayload: { + displayName?: string; + avatarUrl?: string; + about?: string; + nip05Handle?: string; + } = {}; + + if (nextDisplayName.length > 0 && nextDisplayName !== currentDisplayName) { + updatePayload.displayName = nextDisplayName; + } + if (nextAvatarUrl.length > 0 && nextAvatarUrl !== currentAvatarUrl) { + updatePayload.avatarUrl = nextAvatarUrl; + } + if (nextAbout.length > 0 && nextAbout !== currentAbout) { + updatePayload.about = nextAbout; + } + if (nextNip05Handle !== currentNip05Handle) { + updatePayload.nip05Handle = nextNip05Handle; + } + + const hasPendingClearRequest = + (currentDisplayName.length > 0 && nextDisplayName.length === 0) || + (currentAvatarUrl.length > 0 && nextAvatarUrl.length === 0) || + (currentAbout.length > 0 && nextAbout.length === 0); + const canSave = + Object.keys(updatePayload).length > 0 && !updateProfileMutation.isPending; + + const resolvedName = + nextDisplayName || + profile?.displayName || + fallbackDisplayName || + "Your profile"; + const resolvedPubkey = profile?.pubkey ?? currentPubkey ?? "Unavailable"; + const resolvedAvatarUrl = + nextAvatarUrl.length > 0 ? nextAvatarUrl : (profile?.avatarUrl ?? null); + const nip05Handle = profile?.nip05Handle ?? "Not set"; + + return ( +
+
+ +
+
+

+ {resolvedName} +

+

+ Manage how your identity appears across Sprout. +

+
+
+ + Your relay profile +
+
+
+ +
+ {profileQuery.error instanceof Error ? ( +

+ {profileQuery.error.message} +

+ ) : null} + + {updateProfileMutation.error instanceof Error ? ( +

+ {updateProfileMutation.error.message} +

+ ) : null} + + {updateProfileMutation.isSuccess ? ( +
+ + Profile saved. +
+ ) : null} + +
+
+ + +
+
+ + + +
+
{ + event.preventDefault(); + if (!canSave) { + return; + } + + void updateProfileMutation.mutateAsync(updatePayload); + }} + > +
+ +
+ + setDisplayNameDraft(event.target.value)} + placeholder="How people should see you" + value={displayNameDraft} + /> +
+
+ +
+ +
+ + setNip05HandleDraft(event.target.value)} + placeholder="alice@localhost" + value={nip05HandleDraft} + /> +
+

+ Must match this relay's domain. Leave blank to clear your + current handle. +

+
+ +
+ +
+ + setAvatarUrlDraft(event.target.value)} + placeholder="https://example.com/avatar.png" + value={avatarUrlDraft} + /> +
+
+ +
+ +
+ +