Add desktop settings page (#30)

This commit is contained in:
Wes
2026-03-11 10:34:41 -07:00
committed by GitHub
parent 57cd81fd8b
commit 185c3affe4
8 changed files with 525 additions and 484 deletions
+50 -14
View File
@@ -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<AppView>("home");
const [isChannelManagementOpen, setIsChannelManagementOpen] =
React.useState(false);
const [isProfileOpen, setIsProfileOpen] = React.useState(false);
const [isSearchOpen, setIsSearchOpen] = React.useState(false);
const [searchAnchor, setSearchAnchor] = React.useState<SearchHit | null>(
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 (
<SidebarProvider className="h-dvh overflow-hidden overscroll-none">
<SidebarTrigger className="fixed left-[80px] top-[9px] z-50 h-6 w-6" />
@@ -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" ? (
<ChatHeader
description="Theme, appearance, and profile preferences for your current identity."
mode="settings"
title="Settings"
/>
) : (
<ChatHeader
actions={
@@ -278,6 +316,11 @@ export function AppShell() {
void homeFeedQuery.refetch();
}}
/>
) : selectedView === "settings" ? (
<SettingsView
currentPubkey={identityQuery.data?.pubkey}
fallbackDisplayName={identityQuery.data?.displayName}
/>
) : (
<>
<MessageTimeline
@@ -353,13 +396,6 @@ export function AppShell() {
onOpenChange={setIsChannelManagementOpen}
open={isChannelManagementOpen && activeChannel !== null}
/>
<ProfileSheet
currentPubkey={identityQuery.data?.pubkey}
fallbackDisplayName={identityQuery.data?.displayName}
onOpenChange={setIsProfileOpen}
open={isProfileOpen}
/>
</SidebarInset>
</SidebarProvider>
);
+7 -3
View File
@@ -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 <Home 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" />;
}
@@ -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 (
<section className="min-w-0 space-y-3">
<div className="space-y-1">
<h2 className="text-sm font-semibold tracking-tight">{title}</h2>
{description ? (
<p className="text-sm text-muted-foreground">{description}</p>
) : null}
</div>
{children}
</section>
);
}
function ReadOnlyField({
label,
value,
testId,
}: {
label: string;
value: string;
testId: string;
}) {
return (
<div className="min-w-0 space-y-1.5">
<p className="text-sm font-medium">{label}</p>
<div
className="min-w-0 break-all whitespace-normal rounded-xl border border-border/80 bg-muted/25 px-3 py-2 text-sm text-muted-foreground"
data-testid={testId}
>
{value}
</div>
</div>
);
}
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 (
<img
alt={`${label} avatar`}
className="h-16 w-16 rounded-3xl border border-border/80 object-cover shadow-sm"
onError={() => {
setHasError(true);
}}
referrerPolicy="no-referrer"
src={avatarUrl}
/>
);
}
return (
<div className="flex h-16 w-16 items-center justify-center rounded-3xl border border-border/80 bg-primary/10 text-lg font-semibold text-primary shadow-sm">
{initials.length > 0 ? initials : <UserRound className="h-6 w-6" />}
</div>
);
}
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 (
<Sheet onOpenChange={onOpenChange} open={open}>
<SheetContent
className="flex w-full min-w-0 flex-col gap-0 overflow-hidden border-l border-border/80 bg-background p-0 sm:max-w-lg"
data-testid="profile-sheet"
side="right"
>
<SheetHeader className="space-y-4 border-b border-border/80 bg-muted/20 px-6 py-6 text-left">
<div className="flex min-w-0 items-start gap-4">
<AvatarPreview
avatarUrl={resolvedAvatarUrl}
key={resolvedAvatarUrl ?? "profile-fallback-avatar"}
label={resolvedName}
/>
<div className="min-w-0 space-y-2">
<SheetTitle className="break-words pr-8">
{resolvedName}
</SheetTitle>
<SheetDescription>
Manage how your identity appears across Sprout.
</SheetDescription>
<div className="inline-flex items-center gap-2 rounded-full border border-border/80 bg-background/70 px-3 py-1 text-xs font-medium text-muted-foreground">
<Fingerprint className="h-3.5 w-3.5" />
<span>Your relay profile</span>
</div>
</div>
</div>
</SheetHeader>
<div className="min-w-0 flex-1 space-y-6 overflow-x-hidden overflow-y-auto px-6 py-6">
{profileQuery.error instanceof Error ? (
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{profileQuery.error.message}
</p>
) : null}
<Section
description="Identity comes from your keypair. These fields are read-only here."
title="Identity"
>
<div className="space-y-3">
<ReadOnlyField
label="Public key"
testId="profile-pubkey"
value={resolvedPubkey}
/>
<ReadOnlyField
label="NIP-05 handle"
testId="profile-nip05"
value={nip05Handle}
/>
</div>
</Section>
<Separator />
<Section
description="These values are stored on the relay for your current identity."
title="Profile"
>
<form
className="min-w-0 space-y-4"
onSubmit={(event) => {
event.preventDefault();
if (!canSave) {
return;
}
void updateProfileMutation.mutateAsync(updatePayload);
}}
>
<div className="space-y-1.5">
<label
className="text-sm font-medium"
htmlFor="profile-display-name"
>
Display name
</label>
<div className="relative min-w-0">
<UserRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-9"
data-testid="profile-display-name"
disabled={updateProfileMutation.isPending}
id="profile-display-name"
onChange={(event) =>
setDisplayNameDraft(event.target.value)
}
placeholder="How people should see you"
value={displayNameDraft}
/>
</div>
</div>
<div className="space-y-1.5">
<label
className="text-sm font-medium"
htmlFor="profile-avatar-url"
>
Avatar URL
</label>
<div className="relative min-w-0">
<Link2 className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-9"
data-testid="profile-avatar-url"
disabled={updateProfileMutation.isPending}
id="profile-avatar-url"
onChange={(event) => setAvatarUrlDraft(event.target.value)}
placeholder="https://example.com/avatar.png"
value={avatarUrlDraft}
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="profile-about">
About
</label>
<div className="relative min-w-0">
<AtSign className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Textarea
className="min-h-28 pl-9"
data-testid="profile-about"
disabled={updateProfileMutation.isPending}
id="profile-about"
onChange={(event) => setAboutDraft(event.target.value)}
placeholder="A short description for your profile"
value={aboutDraft}
/>
</div>
</div>
<Button
data-testid="profile-save"
disabled={!canSave}
size="sm"
type="submit"
>
{updateProfileMutation.isPending ? "Saving..." : "Save profile"}
</Button>
{hasPendingClearRequest ? (
<p className="text-sm text-muted-foreground">
Clearing existing profile fields is not supported yet. Blank
fields are ignored for now.
</p>
) : null}
{updateProfileMutation.error instanceof Error ? (
<p className="text-sm text-destructive">
{updateProfileMutation.error.message}
</p>
) : null}
</form>
</Section>
</div>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,417 @@
import {
AtSign,
Check,
Fingerprint,
Link2,
MonitorCog,
Moon,
Sun,
UserRound,
type LucideIcon,
} from "lucide-react";
import * as React from "react";
import {
useProfileQuery,
useUpdateProfileMutation,
} from "@/features/profile/hooks";
import { cn } from "@/shared/lib/cn";
import { useTheme } from "@/shared/theme/ThemeProvider";
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 SettingsViewProps = {
currentPubkey?: string;
fallbackDisplayName?: string;
};
type ThemeOption = {
value: "light" | "dark" | "system";
label: string;
icon: LucideIcon;
};
const themeOptions: ThemeOption[] = [
{
value: "light",
label: "Light",
icon: Sun,
},
{
value: "dark",
label: "Dark",
icon: Moon,
},
{
value: "system",
label: "System",
icon: MonitorCog,
},
];
function Section({
title,
description,
children,
}: React.PropsWithChildren<{
title: string;
description?: string;
}>) {
return (
<section className="min-w-0 space-y-3">
<div className="space-y-1">
<h2 className="text-sm font-semibold tracking-tight">{title}</h2>
{description ? (
<p className="text-sm text-muted-foreground">{description}</p>
) : null}
</div>
{children}
</section>
);
}
function ReadOnlyField({
label,
value,
testId,
}: {
label: string;
value: string;
testId: string;
}) {
return (
<div className="min-w-0 space-y-1.5">
<p className="text-sm font-medium">{label}</p>
<div
className="min-w-0 break-all whitespace-normal rounded-xl border border-border/80 bg-muted/25 px-3 py-2 text-sm text-muted-foreground"
data-testid={testId}
>
{value}
</div>
</div>
);
}
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 (
<img
alt={`${label} avatar`}
className="h-16 w-16 rounded-3xl border border-border/80 object-cover shadow-sm"
onError={() => {
setHasError(true);
}}
referrerPolicy="no-referrer"
src={avatarUrl}
/>
);
}
return (
<div className="flex h-16 w-16 items-center justify-center rounded-3xl border border-border/80 bg-primary/10 text-lg font-semibold text-primary shadow-sm">
{initials.length > 0 ? initials : <UserRound className="h-6 w-6" />}
</div>
);
}
function ThemeSettingsCard() {
const { setTheme, theme } = useTheme();
return (
<section
className="rounded-xl border border-border/80 bg-card/80 p-4 shadow-sm"
data-testid="settings-theme"
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<h2 className="text-sm font-semibold tracking-tight">Appearance</h2>
<p className="text-sm text-muted-foreground">
Choose how Sprout looks on this device.
</p>
</div>
<div className="inline-flex w-full flex-col gap-1 rounded-xl border border-border/70 bg-background/70 p-1 sm:w-auto sm:flex-row">
{themeOptions.map(({ value, label, icon: Icon }) => {
const isActive = theme === value;
return (
<button
aria-pressed={isActive}
className={cn(
"inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
isActive
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
data-testid={`theme-option-${value}`}
key={value}
onClick={() => {
setTheme(value);
}}
type="button"
>
<Icon className="h-4 w-4" />
<span>{label}</span>
</button>
);
})}
</div>
</div>
</section>
);
}
function ProfileSettingsCard({
currentPubkey,
fallbackDisplayName,
}: SettingsViewProps) {
const profileQuery = useProfileQuery();
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(() => {
setDisplayNameDraft(currentDisplayName);
setAvatarUrlDraft(currentAvatarUrl);
setAboutDraft(currentAbout);
}, [currentAbout, currentAvatarUrl, currentDisplayName]);
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 (
<section
className="rounded-xl border border-border/80 bg-card/80 p-5 shadow-sm"
data-testid="settings-profile"
>
<div className="flex min-w-0 items-start gap-4">
<AvatarPreview
avatarUrl={resolvedAvatarUrl}
key={resolvedAvatarUrl ?? "profile-fallback-avatar"}
label={resolvedName}
/>
<div className="min-w-0 space-y-2">
<div>
<h2 className="break-words text-base font-semibold tracking-tight">
{resolvedName}
</h2>
<p className="text-sm text-muted-foreground">
Manage how your identity appears across Sprout.
</p>
</div>
<div className="inline-flex items-center gap-2 rounded-full border border-border/80 bg-background/70 px-3 py-1 text-xs font-medium text-muted-foreground">
<Fingerprint className="h-3.5 w-3.5" />
<span>Your relay profile</span>
</div>
</div>
</div>
<div className="mt-6 space-y-6">
{profileQuery.error instanceof Error ? (
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{profileQuery.error.message}
</p>
) : null}
{updateProfileMutation.isSuccess ? (
<div className="flex items-center gap-2 rounded-xl border border-primary/20 bg-primary/10 px-3 py-2 text-sm text-primary">
<Check className="h-4 w-4" />
<span>Profile saved.</span>
</div>
) : null}
<Section
description="Identity comes from your keypair. These fields are read-only here."
title="Identity"
>
<div className="space-y-3">
<ReadOnlyField
label="Public key"
testId="profile-pubkey"
value={resolvedPubkey}
/>
<ReadOnlyField
label="NIP-05 handle"
testId="profile-nip05"
value={nip05Handle}
/>
</div>
</Section>
<Separator />
<Section
description="These values are stored on the relay for your current identity."
title="Profile"
>
<form
className="min-w-0 space-y-4"
onSubmit={(event) => {
event.preventDefault();
if (!canSave) {
return;
}
void updateProfileMutation.mutateAsync(updatePayload);
}}
>
<div className="space-y-1.5">
<label
className="text-sm font-medium"
htmlFor="profile-display-name"
>
Display name
</label>
<div className="relative min-w-0">
<UserRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-9"
data-testid="profile-display-name"
disabled={updateProfileMutation.isPending}
id="profile-display-name"
onChange={(event) => setDisplayNameDraft(event.target.value)}
placeholder="How people should see you"
value={displayNameDraft}
/>
</div>
</div>
<div className="space-y-1.5">
<label
className="text-sm font-medium"
htmlFor="profile-avatar-url"
>
Avatar URL
</label>
<div className="relative min-w-0">
<Link2 className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-9"
data-testid="profile-avatar-url"
disabled={updateProfileMutation.isPending}
id="profile-avatar-url"
onChange={(event) => setAvatarUrlDraft(event.target.value)}
placeholder="https://example.com/avatar.png"
value={avatarUrlDraft}
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="profile-about">
About
</label>
<div className="relative min-w-0">
<AtSign className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Textarea
className="min-h-28 pl-9"
data-testid="profile-about"
disabled={updateProfileMutation.isPending}
id="profile-about"
onChange={(event) => setAboutDraft(event.target.value)}
placeholder="A short description for your profile"
value={aboutDraft}
/>
</div>
</div>
<Button
data-testid="profile-save"
disabled={!canSave}
size="sm"
type="submit"
>
{updateProfileMutation.isPending ? "Saving..." : "Save profile"}
</Button>
{hasPendingClearRequest ? (
<p className="text-sm text-muted-foreground">
Clearing existing profile fields is not supported yet. Blank
fields are ignored for now.
</p>
) : null}
</form>
</Section>
</div>
</section>
);
}
export function SettingsView({
currentPubkey,
fallbackDisplayName,
}: SettingsViewProps) {
return (
<div
className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-4 sm:px-6"
data-testid="settings-view"
>
<div className="mx-auto flex w-full max-w-4xl flex-col gap-4">
<ThemeSettingsCard />
<ProfileSettingsCard
currentPubkey={currentPubkey}
fallbackDisplayName={fallbackDisplayName}
/>
</div>
</div>
);
}
+19 -22
View File
@@ -6,12 +6,11 @@ import {
Home,
Plus,
Search,
UserRound,
Settings2,
} from "lucide-react";
import * as React from "react";
import type { Channel } from "@/shared/api/types";
import { ThemeToggle } from "@/shared/theme/ThemeToggle";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import {
@@ -37,15 +36,15 @@ type AppSidebarProps = {
errorMessage?: string;
homeUrgentCount?: number;
selectedChannelId: string | null;
selectedView: "home" | "channel";
selectedView: "home" | "channel" | "settings";
onCreateChannel: (input: {
name: string;
description?: string;
}) => Promise<void>;
onOpenProfile: () => void;
onOpenSearch: () => void;
onSelectHome: () => void;
onSelectChannel: (channelId: string) => void;
onSelectSettings: () => void;
};
function SidebarChannelIcon({ channel }: { channel: Channel }) {
@@ -237,10 +236,10 @@ export function AppSidebar({
selectedChannelId,
selectedView,
onCreateChannel,
onOpenProfile,
onOpenSearch,
onSelectHome,
onSelectChannel,
onSelectSettings,
}: AppSidebarProps) {
const skeletonRows = ["first", "second", "third", "fourth", "fifth", "sixth"];
const [isCreateOpen, setIsCreateOpen] = React.useState(false);
@@ -448,23 +447,21 @@ export function AppSidebar({
<SidebarFooter>
<div className="w-full border-t border-sidebar-border/70 pt-2">
<div className="flex items-center justify-between gap-2 px-1">
<SidebarMenu className="w-auto">
<SidebarMenuItem>
<SidebarMenuButton
className="size-8 rounded-lg p-0 text-sidebar-foreground/55 hover:bg-sidebar-accent/70 hover:text-sidebar-foreground"
data-testid="open-profile"
onClick={onOpenProfile}
tooltip="Profile"
type="button"
>
<UserRound className="h-4 w-4" />
<span className="sr-only">Open profile</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
<ThemeToggle className="shrink-0 text-sidebar-foreground/55 hover:bg-sidebar-accent/70 hover:text-sidebar-foreground" />
</div>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
className="rounded-xl"
data-testid="open-settings"
isActive={selectedView === "settings"}
onClick={onSelectSettings}
tooltip="Settings"
type="button"
>
<Settings2 className="h-4 w-4" />
<span>Settings</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</div>
</SidebarFooter>
</Sidebar>
-100
View File
@@ -1,100 +0,0 @@
import { MonitorCog, Moon, Sun } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { useTheme } from "@/shared/theme/ThemeProvider";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
const themeOptions = [
{
value: "light",
label: "Light",
icon: Sun,
},
{
value: "dark",
label: "Dark",
icon: Moon,
},
{
value: "system",
label: "System",
icon: MonitorCog,
},
] as const;
function getThemeLabel(theme: "light" | "dark" | "system") {
if (theme === "light") {
return "Light";
}
if (theme === "dark") {
return "Dark";
}
return "System";
}
function getThemeIcon(theme: "light" | "dark" | "system") {
if (theme === "light") {
return Sun;
}
if (theme === "dark") {
return Moon;
}
return MonitorCog;
}
type ThemeToggleProps = {
className?: string;
};
export function ThemeToggle({ className }: ThemeToggleProps) {
const { resolvedTheme, setTheme, theme } = useTheme();
const ActiveIcon = getThemeIcon(theme);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={`Select theme. Current setting is ${theme}${theme === "system" ? `, resolved to ${resolvedTheme}` : ""}.`}
className={cn(
"h-8 w-8 rounded-lg text-muted-foreground hover:text-foreground",
className,
)}
size="icon"
title="Theme"
type="button"
variant="ghost"
>
<span className="sr-only">{getThemeLabel(theme)}</span>
<ActiveIcon className="h-4 w-4 shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup
onValueChange={(value) =>
setTheme(value as "light" | "dark" | "system")
}
value={theme}
>
{themeOptions.map(({ value, label, icon: Icon }) => (
<DropdownMenuRadioItem className="gap-2" key={value} value={value}>
<Icon className="h-4 w-4" />
<span>{label}</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
+5 -5
View File
@@ -520,7 +520,7 @@ const SidebarMenuItem = React.forwardRef<
SidebarMenuItem.displayName = "SidebarMenuItem";
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-primary data-[active=true]:font-semibold data-[active=true]:text-sidebar-primary-foreground data-[active=true]:shadow-sm data-[active=true]:hover:bg-sidebar-primary data-[active=true]:hover:text-sidebar-primary-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
@@ -622,7 +622,7 @@ const SidebarMenuAction = React.forwardRef<
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-primary-foreground md:opacity-0",
className,
)}
{...props}
@@ -640,7 +640,7 @@ const SidebarMenuBadge = React.forwardRef<
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-primary-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
@@ -730,8 +730,8 @@ const SidebarMenuSubButton = React.forwardRef<
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground data-[active=true]:[&>svg]:text-sidebar-primary-foreground",
"data-[active=true]:bg-sidebar-primary data-[active=true]:font-semibold data-[active=true]:text-sidebar-primary-foreground data-[active=true]:hover:bg-sidebar-primary data-[active=true]:hover:text-sidebar-primary-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
+27 -7
View File
@@ -6,7 +6,7 @@ test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
test("updates the relay-backed profile from the sidebar", async ({ page }) => {
test("updates the relay-backed profile from settings", async ({ page }) => {
const stamp = Date.now();
const displayName = `Tyler QA ${stamp}`;
const avatarUrl = `https://example.com/avatar-${stamp}.png`;
@@ -14,8 +14,9 @@ test("updates the relay-backed profile from the sidebar", async ({ page }) => {
await page.goto("/");
await page.getByTestId("open-profile").click();
await expect(page.getByTestId("profile-sheet")).toBeVisible();
await page.getByTestId("open-settings").click();
await expect(page.getByTestId("settings-view")).toBeVisible();
await expect(page.getByTestId("chat-title")).toHaveText("Settings");
await expect(page.getByTestId("profile-pubkey")).toContainText("deadbeef");
await expect(page.getByTestId("profile-nip05")).toContainText("Not set");
@@ -31,14 +32,33 @@ test("updates the relay-backed profile from the sidebar", async ({ page }) => {
await expect(page.getByTestId("profile-avatar-url")).toHaveValue(avatarUrl);
await expect(page.getByTestId("profile-about")).toHaveValue(about);
await page.keyboard.press("Escape");
await expect(page.getByTestId("profile-sheet")).not.toBeVisible();
await page.getByRole("button", { name: "Home" }).click();
await expect(page.getByTestId("chat-title")).toHaveText("Home");
await page.getByTestId("open-profile").click();
await expect(page.getByTestId("profile-sheet")).toBeVisible();
await page.getByTestId("open-settings").click();
await expect(page.getByTestId("settings-view")).toBeVisible();
await expect(page.getByTestId("profile-display-name")).toHaveValue(
displayName,
);
await expect(page.getByTestId("profile-avatar-url")).toHaveValue(avatarUrl);
await expect(page.getByTestId("profile-about")).toHaveValue(about);
});
test("opens settings with the keyboard shortcut and updates theme", async ({
page,
}) => {
await page.goto("/");
await page.keyboard.press(
process.platform === "darwin" ? "Meta+," : "Control+,",
);
await expect(page.getByTestId("settings-view")).toBeVisible();
await page.getByTestId("theme-option-dark").click();
await expect
.poll(() =>
page.evaluate(() => document.documentElement.classList.contains("dark")),
)
.toBe(true);
});