From 98a30e0fe88163c75eaba1114187a181d97ccb50 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 11 Mar 2026 10:29:03 -0700 Subject: [PATCH] Add desktop settings page --- desktop/src/app/AppShell.tsx | 64 ++- desktop/src/features/chat/ui/ChatHeader.tsx | 10 +- .../src/features/profile/ui/ProfileSheet.tsx | 333 -------------- .../src/features/settings/ui/SettingsView.tsx | 417 ++++++++++++++++++ .../src/features/sidebar/ui/AppSidebar.tsx | 41 +- desktop/src/shared/theme/ThemeToggle.tsx | 100 ----- desktop/src/shared/ui/sidebar.tsx | 10 +- desktop/tests/e2e/profile.spec.ts | 34 +- 8 files changed, 525 insertions(+), 484 deletions(-) delete mode 100644 desktop/src/features/profile/ui/ProfileSheet.tsx create mode 100644 desktop/src/features/settings/ui/SettingsView.tsx delete mode 100644 desktop/src/shared/theme/ThemeToggle.tsx diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 8509bdf1f..afc885a4c 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -20,8 +20,8 @@ import { import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages"; import { MessageComposer } from "@/features/messages/ui/MessageComposer"; import { MessageTimeline } from "@/features/messages/ui/MessageTimeline"; -import { ProfileSheet } from "@/features/profile/ui/ProfileSheet"; import { SearchDialog } from "@/features/search/ui/SearchDialog"; +import { SettingsView } from "@/features/settings/ui/SettingsView"; import { AppSidebar } from "@/features/sidebar/ui/AppSidebar"; import { getEventById } from "@/shared/api/tauri"; import { useIdentityQuery } from "@/shared/api/hooks"; @@ -33,7 +33,7 @@ import { SidebarTrigger, } from "@/shared/ui/sidebar"; -type AppView = "home" | "channel"; +type AppView = "home" | "channel" | "settings"; function createSearchAnchorEvent(hit: SearchHit): RelayEvent { return { @@ -51,7 +51,6 @@ export function AppShell() { const [selectedView, setSelectedView] = React.useState("home"); const [isChannelManagementOpen, setIsChannelManagementOpen] = React.useState(false); - const [isProfileOpen, setIsProfileOpen] = React.useState(false); const [isSearchOpen, setIsSearchOpen] = React.useState(false); const [searchAnchor, setSearchAnchor] = React.useState( null, @@ -129,7 +128,11 @@ export function AppShell() { .join(" ") || "Channel details and activity." : "Connect to the relay to browse channels and read messages."; const contentPaneKey = - selectedView === "home" ? "home" : `channel:${activeChannel?.id ?? "none"}`; + selectedView === "home" + ? "home" + : selectedView === "settings" + ? "settings" + : `channel:${activeChannel?.id ?? "none"}`; const isTimelineLoading = messagesQuery.isLoading && resolvedMessages.length === 0; @@ -143,6 +146,15 @@ export function AppShell() { [setSelectedChannelId], ); + const handleOpenSettings = React.useCallback(() => { + setIsSearchOpen(false); + setIsChannelManagementOpen(false); + + React.startTransition(() => { + setSelectedView("settings"); + }); + }, []); + const handleOpenSearchResult = React.useCallback( (hit: SearchHit) => { setSearchAnchor(hit); @@ -171,6 +183,28 @@ export function AppShell() { [handleOpenChannel], ); + React.useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + const isSettingsShortcut = + (event.key === "," || event.code === "Comma") && + (event.metaKey || event.ctrlKey) && + !event.altKey && + !event.shiftKey; + + if (!isSettingsShortcut) { + return; + } + + event.preventDefault(); + handleOpenSettings(); + } + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [handleOpenSettings]); + return ( @@ -200,9 +234,6 @@ export function AppShell() { onOpenSearch={() => { setIsSearchOpen(true); }} - onOpenProfile={() => { - setIsProfileOpen(true); - }} onSelectHome={() => { React.startTransition(() => { setSelectedView("home"); @@ -211,6 +242,7 @@ export function AppShell() { void homeFeedQuery.refetch(); }} onSelectChannel={handleOpenChannel} + onSelectSettings={handleOpenSettings} selectedChannelId={selectedChannel?.id ?? null} selectedView={selectedView} /> @@ -236,6 +268,12 @@ export function AppShell() { mode="home" title="Home" /> + ) : selectedView === "settings" ? ( + ) : ( + ) : selectedView === "settings" ? ( + ) : ( <> - - ); diff --git a/desktop/src/features/chat/ui/ChatHeader.tsx b/desktop/src/features/chat/ui/ChatHeader.tsx index 02469f3e7..b27800a4e 100644 --- a/desktop/src/features/chat/ui/ChatHeader.tsx +++ b/desktop/src/features/chat/ui/ChatHeader.tsx @@ -1,5 +1,5 @@ import { getCurrentWindow } from "@tauri-apps/api/window"; -import { CircleDot, FileText, Hash, Home } from "lucide-react"; +import { CircleDot, FileText, Hash, Home, Settings2 } from "lucide-react"; import type * as React from "react"; import type { ChannelType } from "@/shared/api/types"; @@ -9,7 +9,7 @@ type ChatHeaderProps = { title: string; description: string; channelType?: ChannelType; - mode?: "home" | "channel"; + mode?: "home" | "channel" | "settings"; }; function ChannelIcon({ @@ -17,12 +17,16 @@ function ChannelIcon({ mode = "channel", }: { channelType?: ChannelType; - mode?: "home" | "channel"; + mode?: "home" | "channel" | "settings"; }) { if (mode === "home") { return ; } + if (mode === "settings") { + return ; + } + if (channelType === "dm") { return ; } diff --git a/desktop/src/features/profile/ui/ProfileSheet.tsx b/desktop/src/features/profile/ui/ProfileSheet.tsx deleted file mode 100644 index fc64df255..000000000 --- a/desktop/src/features/profile/ui/ProfileSheet.tsx +++ /dev/null @@ -1,333 +0,0 @@ -import { AtSign, Fingerprint, Link2, UserRound } from "lucide-react"; -import * as React from "react"; - -import { - useProfileQuery, - useUpdateProfileMutation, -} from "@/features/profile/hooks"; -import { Button } from "@/shared/ui/button"; -import { Input } from "@/shared/ui/input"; -import { Separator } from "@/shared/ui/separator"; -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from "@/shared/ui/sheet"; -import { Textarea } from "@/shared/ui/textarea"; - -type ProfileSheetProps = { - currentPubkey?: string; - fallbackDisplayName?: string; - open: boolean; - onOpenChange: (open: boolean) => void; -}; - -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} -
-
- ); -} - -function AvatarPreview({ - avatarUrl, - label, -}: { - avatarUrl: string | null; - label: string; -}) { - const [hasError, setHasError] = React.useState(false); - - const initials = label - .trim() - .split(/\s+/) - .map((part) => part[0] ?? "") - .join("") - .slice(0, 2) - .toUpperCase(); - - if (avatarUrl && !hasError) { - return ( - {`${label} { - setHasError(true); - }} - referrerPolicy="no-referrer" - src={avatarUrl} - /> - ); - } - - return ( -
- {initials.length > 0 ? initials : } -
- ); -} - -export function ProfileSheet({ - currentPubkey, - fallbackDisplayName, - open, - onOpenChange, -}: ProfileSheetProps) { - const profileQuery = useProfileQuery(open); - const updateProfileMutation = useUpdateProfileMutation(); - const profile = profileQuery.data; - - const currentDisplayName = profile?.displayName ?? ""; - const currentAvatarUrl = profile?.avatarUrl ?? ""; - const currentAbout = profile?.about ?? ""; - - const [displayNameDraft, setDisplayNameDraft] = React.useState(""); - const [avatarUrlDraft, setAvatarUrlDraft] = React.useState(""); - const [aboutDraft, setAboutDraft] = React.useState(""); - - React.useEffect(() => { - if (!open) { - return; - } - - setDisplayNameDraft(currentDisplayName); - setAvatarUrlDraft(currentAvatarUrl); - setAboutDraft(currentAbout); - }, [currentAbout, currentAvatarUrl, currentDisplayName, open]); - - const nextDisplayName = displayNameDraft.trim(); - const nextAvatarUrl = avatarUrlDraft.trim(); - const nextAbout = aboutDraft.trim(); - - const updatePayload: { - displayName?: string; - avatarUrl?: string; - about?: 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; - } - - 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} - -
-
- - -
-
- - - -
-
{ - event.preventDefault(); - if (!canSave) { - return; - } - - void updateProfileMutation.mutateAsync(updatePayload); - }} - > -
- -
- - - setDisplayNameDraft(event.target.value) - } - placeholder="How people should see you" - value={displayNameDraft} - /> -
-
- -
- -
- - setAvatarUrlDraft(event.target.value)} - placeholder="https://example.com/avatar.png" - value={avatarUrlDraft} - /> -
-
- -
- -
- -