mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Add desktop settings page (#30)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user