mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
Add desktop profile management UI (#25)
This commit is contained in:
@@ -29,7 +29,11 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "integration",
|
name: "integration",
|
||||||
testMatch: ["**/stream.spec.ts", "**/integration.spec.ts"],
|
testMatch: [
|
||||||
|
"**/stream.spec.ts",
|
||||||
|
"**/integration.spec.ts",
|
||||||
|
"**/profile.spec.ts",
|
||||||
|
],
|
||||||
use: {
|
use: {
|
||||||
...devices["Desktop Chrome"],
|
...devices["Desktop Chrome"],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,6 +16,15 @@ pub struct IdentityInfo {
|
|||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct ProfileInfo {
|
||||||
|
pub pubkey: String,
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
pub about: Option<String>,
|
||||||
|
pub nip05_handle: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct ChannelInfo {
|
pub struct ChannelInfo {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -108,6 +117,16 @@ struct AddMembersBody<'a> {
|
|||||||
role: Option<&'a str>,
|
role: Option<&'a str>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct UpdateProfileBody<'a> {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
display_name: Option<&'a str>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
avatar_url: Option<&'a str>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
about: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct GetFeedQuery<'a> {
|
struct GetFeedQuery<'a> {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -278,6 +297,46 @@ fn get_relay_ws_url() -> String {
|
|||||||
relay_ws_url()
|
relay_ws_url()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn get_profile(state: tauri::State<'_, AppState>) -> Result<ProfileInfo, String> {
|
||||||
|
let request = build_authed_request(
|
||||||
|
&state.http_client,
|
||||||
|
Method::GET,
|
||||||
|
"/api/users/me/profile",
|
||||||
|
&state,
|
||||||
|
)?;
|
||||||
|
send_json_request(request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn update_profile(
|
||||||
|
display_name: Option<String>,
|
||||||
|
avatar_url: Option<String>,
|
||||||
|
about: Option<String>,
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<ProfileInfo, String> {
|
||||||
|
let request = build_authed_request(
|
||||||
|
&state.http_client,
|
||||||
|
Method::PUT,
|
||||||
|
"/api/users/me/profile",
|
||||||
|
&state,
|
||||||
|
)?
|
||||||
|
.json(&UpdateProfileBody {
|
||||||
|
display_name: display_name.as_deref(),
|
||||||
|
avatar_url: avatar_url.as_deref(),
|
||||||
|
about: about.as_deref(),
|
||||||
|
});
|
||||||
|
send_empty_request(request).await?;
|
||||||
|
|
||||||
|
let request = build_authed_request(
|
||||||
|
&state.http_client,
|
||||||
|
Method::GET,
|
||||||
|
"/api/users/me/profile",
|
||||||
|
&state,
|
||||||
|
)?;
|
||||||
|
send_json_request(request).await
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn sign_event(
|
fn sign_event(
|
||||||
kind: u16,
|
kind: u16,
|
||||||
@@ -558,6 +617,8 @@ pub fn run() {
|
|||||||
.manage(app_state)
|
.manage(app_state)
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
get_identity,
|
get_identity,
|
||||||
|
get_profile,
|
||||||
|
update_profile,
|
||||||
get_relay_ws_url,
|
get_relay_ws_url,
|
||||||
sign_event,
|
sign_event,
|
||||||
create_auth_event,
|
create_auth_event,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages";
|
import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages";
|
||||||
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
|
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
|
||||||
import { MessageTimeline } from "@/features/messages/ui/MessageTimeline";
|
import { MessageTimeline } from "@/features/messages/ui/MessageTimeline";
|
||||||
|
import { ProfileSheet } from "@/features/profile/ui/ProfileSheet";
|
||||||
import { SearchDialog } from "@/features/search/ui/SearchDialog";
|
import { SearchDialog } from "@/features/search/ui/SearchDialog";
|
||||||
import { AppSidebar } from "@/features/sidebar/ui/AppSidebar";
|
import { AppSidebar } from "@/features/sidebar/ui/AppSidebar";
|
||||||
import { getEventById } from "@/shared/api/tauri";
|
import { getEventById } from "@/shared/api/tauri";
|
||||||
@@ -45,6 +46,7 @@ export function AppShell() {
|
|||||||
const [selectedView, setSelectedView] = React.useState<AppView>("home");
|
const [selectedView, setSelectedView] = React.useState<AppView>("home");
|
||||||
const [isChannelManagementOpen, setIsChannelManagementOpen] =
|
const [isChannelManagementOpen, setIsChannelManagementOpen] =
|
||||||
React.useState(false);
|
React.useState(false);
|
||||||
|
const [isProfileOpen, setIsProfileOpen] = React.useState(false);
|
||||||
const [isSearchOpen, setIsSearchOpen] = React.useState(false);
|
const [isSearchOpen, setIsSearchOpen] = React.useState(false);
|
||||||
const [searchAnchor, setSearchAnchor] = React.useState<SearchHit | null>(
|
const [searchAnchor, setSearchAnchor] = React.useState<SearchHit | null>(
|
||||||
null,
|
null,
|
||||||
@@ -192,6 +194,9 @@ export function AppShell() {
|
|||||||
onOpenSearch={() => {
|
onOpenSearch={() => {
|
||||||
setIsSearchOpen(true);
|
setIsSearchOpen(true);
|
||||||
}}
|
}}
|
||||||
|
onOpenProfile={() => {
|
||||||
|
setIsProfileOpen(true);
|
||||||
|
}}
|
||||||
onSelectHome={() => {
|
onSelectHome={() => {
|
||||||
React.startTransition(() => {
|
React.startTransition(() => {
|
||||||
setSelectedView("home");
|
setSelectedView("home");
|
||||||
@@ -331,6 +336,13 @@ export function AppShell() {
|
|||||||
onOpenChange={setIsChannelManagementOpen}
|
onOpenChange={setIsChannelManagementOpen}
|
||||||
open={isChannelManagementOpen && activeChannel !== null}
|
open={isChannelManagementOpen && activeChannel !== null}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ProfileSheet
|
||||||
|
currentPubkey={identityQuery.data?.pubkey}
|
||||||
|
fallbackDisplayName={identityQuery.data?.displayName}
|
||||||
|
onOpenChange={setIsProfileOpen}
|
||||||
|
open={isProfileOpen}
|
||||||
|
/>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
29
desktop/src/features/profile/hooks.ts
Normal file
29
desktop/src/features/profile/hooks.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { getProfile, updateProfile } from "@/shared/api/tauri";
|
||||||
|
import type { Profile, UpdateProfileInput } from "@/shared/api/types";
|
||||||
|
|
||||||
|
export const profileQueryKey = ["profile"] as const;
|
||||||
|
|
||||||
|
export function useProfileQuery(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
enabled,
|
||||||
|
queryKey: profileQueryKey,
|
||||||
|
queryFn: getProfile,
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateProfileMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: UpdateProfileInput) => updateProfile(input),
|
||||||
|
onSuccess: (profile: Profile) => {
|
||||||
|
queryClient.setQueryData(profileQueryKey, profile);
|
||||||
|
},
|
||||||
|
onSettled: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: profileQueryKey });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
333
desktop/src/features/profile/ui/ProfileSheet.tsx
Normal file
333
desktop/src/features/profile/ui/ProfileSheet.tsx
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,12 @@
|
|||||||
import { CircleDot, FileText, Hash, Home, Plus, Search } from "lucide-react";
|
import {
|
||||||
|
CircleDot,
|
||||||
|
FileText,
|
||||||
|
Hash,
|
||||||
|
Home,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
UserRound,
|
||||||
|
} from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import type { Channel } from "@/shared/api/types";
|
import type { Channel } from "@/shared/api/types";
|
||||||
@@ -33,6 +41,7 @@ type AppSidebarProps = {
|
|||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
|
onOpenProfile: () => void;
|
||||||
onOpenSearch: () => void;
|
onOpenSearch: () => void;
|
||||||
onSelectHome: () => void;
|
onSelectHome: () => void;
|
||||||
onSelectChannel: (channelId: string) => void;
|
onSelectChannel: (channelId: string) => void;
|
||||||
@@ -227,6 +236,7 @@ export function AppSidebar({
|
|||||||
selectedChannelId,
|
selectedChannelId,
|
||||||
selectedView,
|
selectedView,
|
||||||
onCreateChannel,
|
onCreateChannel,
|
||||||
|
onOpenProfile,
|
||||||
onOpenSearch,
|
onOpenSearch,
|
||||||
onSelectHome,
|
onSelectHome,
|
||||||
onSelectChannel,
|
onSelectChannel,
|
||||||
@@ -426,8 +436,26 @@ export function AppSidebar({
|
|||||||
) : null}
|
) : null}
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
|
||||||
<SidebarFooter className="items-end">
|
<SidebarFooter>
|
||||||
<ThemeToggle className="text-sidebar-foreground/65 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground" />
|
<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>
|
||||||
|
</div>
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ import type {
|
|||||||
GetHomeFeedInput,
|
GetHomeFeedInput,
|
||||||
HomeFeedResponse,
|
HomeFeedResponse,
|
||||||
Identity,
|
Identity,
|
||||||
|
Profile,
|
||||||
RelayEvent,
|
RelayEvent,
|
||||||
SearchMessagesInput,
|
SearchMessagesInput,
|
||||||
SearchMessagesResponse,
|
SearchMessagesResponse,
|
||||||
SetChannelPurposeInput,
|
SetChannelPurposeInput,
|
||||||
SetChannelTopicInput,
|
SetChannelTopicInput,
|
||||||
|
UpdateProfileInput,
|
||||||
UpdateChannelInput,
|
UpdateChannelInput,
|
||||||
} from "@/shared/api/types";
|
} from "@/shared/api/types";
|
||||||
|
|
||||||
@@ -24,6 +26,14 @@ type RawIdentity = {
|
|||||||
display_name: string;
|
display_name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RawProfile = {
|
||||||
|
pubkey: string;
|
||||||
|
display_name: string | null;
|
||||||
|
avatar_url: string | null;
|
||||||
|
about: string | null;
|
||||||
|
nip05_handle: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
type RawChannel = {
|
type RawChannel = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -183,6 +193,16 @@ function fromRawSearchHit(hit: RawSearchHit) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fromRawProfile(profile: RawProfile): Profile {
|
||||||
|
return {
|
||||||
|
pubkey: profile.pubkey,
|
||||||
|
displayName: profile.display_name,
|
||||||
|
avatarUrl: profile.avatar_url,
|
||||||
|
about: profile.about,
|
||||||
|
nip05Handle: profile.nip05_handle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function getIdentity(): Promise<Identity> {
|
export async function getIdentity(): Promise<Identity> {
|
||||||
const identity = await invoke<RawIdentity>("get_identity");
|
const identity = await invoke<RawIdentity>("get_identity");
|
||||||
|
|
||||||
@@ -192,6 +212,18 @@ export async function getIdentity(): Promise<Identity> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getProfile(): Promise<Profile> {
|
||||||
|
const profile = await invoke<RawProfile>("get_profile");
|
||||||
|
return fromRawProfile(profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProfile(
|
||||||
|
input: UpdateProfileInput,
|
||||||
|
): Promise<Profile> {
|
||||||
|
const profile = await invoke<RawProfile>("update_profile", input);
|
||||||
|
return fromRawProfile(profile);
|
||||||
|
}
|
||||||
|
|
||||||
export function getRelayWsUrl(): Promise<string> {
|
export function getRelayWsUrl(): Promise<string> {
|
||||||
return invoke<string>("get_relay_ws_url");
|
return invoke<string>("get_relay_ws_url");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,20 @@ export type Identity = {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Profile = {
|
||||||
|
pubkey: string;
|
||||||
|
displayName: string | null;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
about: string | null;
|
||||||
|
nip05Handle: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateProfileInput = {
|
||||||
|
displayName?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
about?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type RelayEvent = {
|
export type RelayEvent = {
|
||||||
id: string;
|
id: string;
|
||||||
pubkey: string;
|
pubkey: string;
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ type E2eConfig = {
|
|||||||
identity?: TestIdentity;
|
identity?: TestIdentity;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RawProfile = {
|
||||||
|
pubkey: string;
|
||||||
|
display_name: string | null;
|
||||||
|
avatar_url: string | null;
|
||||||
|
about: string | null;
|
||||||
|
nip05_handle: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
type RawChannel = {
|
type RawChannel = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -265,6 +273,10 @@ function getMockIdentity() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cloneProfile(profile: RawProfile): RawProfile {
|
||||||
|
return { ...profile };
|
||||||
|
}
|
||||||
|
|
||||||
function listMockChannels(): RawChannel[] {
|
function listMockChannels(): RawChannel[] {
|
||||||
return mockChannels.map(toRawChannel);
|
return mockChannels.map(toRawChannel);
|
||||||
}
|
}
|
||||||
@@ -510,6 +522,18 @@ const mockChannels: MockChannel[] = [
|
|||||||
const mockMessages = new Map<string, RelayEvent[]>();
|
const mockMessages = new Map<string, RelayEvent[]>();
|
||||||
const mockSockets = new Map<number, MockSocket>();
|
const mockSockets = new Map<number, MockSocket>();
|
||||||
const realSockets = new Map<number, WebSocket>();
|
const realSockets = new Map<number, WebSocket>();
|
||||||
|
const mockProfiles = new Map<string, RawProfile>([
|
||||||
|
[
|
||||||
|
MOCK_IDENTITY_PUBKEY,
|
||||||
|
{
|
||||||
|
pubkey: MOCK_IDENTITY_PUBKEY,
|
||||||
|
display_name: DEFAULT_MOCK_IDENTITY.display_name,
|
||||||
|
avatar_url: null,
|
||||||
|
about: null,
|
||||||
|
nip05_handle: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
let installed = false;
|
let installed = false;
|
||||||
let nextSocketId = 1;
|
let nextSocketId = 1;
|
||||||
@@ -538,6 +562,41 @@ function getIdentity(config: E2eConfig | undefined): TestIdentity | undefined {
|
|||||||
return config?.identity ?? DEFAULT_REAL_IDENTITY;
|
return config?.identity ?? DEFAULT_REAL_IDENTITY;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureMockProfile(config: E2eConfig | undefined): RawProfile {
|
||||||
|
const pubkey = getMockMemberPubkey(config);
|
||||||
|
const existing = mockProfiles.get(pubkey);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = {
|
||||||
|
pubkey,
|
||||||
|
display_name: getMockMemberDisplayName(config),
|
||||||
|
avatar_url: null,
|
||||||
|
about: null,
|
||||||
|
nip05_handle: null,
|
||||||
|
};
|
||||||
|
mockProfiles.set(pubkey, profile);
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMockDisplayName(pubkey: string, displayName: string | null) {
|
||||||
|
if (displayName) {
|
||||||
|
mockDisplayNames.set(pubkey, displayName);
|
||||||
|
} else {
|
||||||
|
mockDisplayNames.delete(pubkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const channel of mockChannels) {
|
||||||
|
for (const member of channel.members) {
|
||||||
|
if (member.pubkey === pubkey) {
|
||||||
|
member.display_name = displayName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
syncMockChannel(channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function resolveHandler(handler: unknown): WsHandler {
|
function resolveHandler(handler: unknown): WsHandler {
|
||||||
if (typeof handler === "function") {
|
if (typeof handler === "function") {
|
||||||
return handler as WsHandler;
|
return handler as WsHandler;
|
||||||
@@ -720,6 +779,56 @@ async function handleGetChannels(config: E2eConfig | undefined) {
|
|||||||
return relayJsonRequest<RawChannel[]>(config, "/api/channels");
|
return relayJsonRequest<RawChannel[]>(config, "/api/channels");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleGetProfile(config: E2eConfig | undefined) {
|
||||||
|
const identity = getIdentity(config);
|
||||||
|
if (!identity) {
|
||||||
|
return cloneProfile(ensureMockProfile(config));
|
||||||
|
}
|
||||||
|
|
||||||
|
return relayJsonRequest<RawProfile>(config, "/api/users/me/profile");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdateProfile(
|
||||||
|
args: {
|
||||||
|
displayName?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
about?: string;
|
||||||
|
},
|
||||||
|
config: E2eConfig | undefined,
|
||||||
|
) {
|
||||||
|
const identity = getIdentity(config);
|
||||||
|
if (!identity) {
|
||||||
|
const profile = ensureMockProfile(config);
|
||||||
|
const nextDisplayName = args.displayName?.trim();
|
||||||
|
const nextAvatarUrl = args.avatarUrl?.trim();
|
||||||
|
const nextAbout = args.about?.trim();
|
||||||
|
|
||||||
|
if (nextDisplayName && nextDisplayName !== profile.display_name) {
|
||||||
|
profile.display_name = nextDisplayName;
|
||||||
|
applyMockDisplayName(profile.pubkey, nextDisplayName);
|
||||||
|
}
|
||||||
|
if (nextAvatarUrl && nextAvatarUrl !== profile.avatar_url) {
|
||||||
|
profile.avatar_url = nextAvatarUrl;
|
||||||
|
}
|
||||||
|
if (nextAbout && nextAbout !== profile.about) {
|
||||||
|
profile.about = nextAbout;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cloneProfile(profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
await relayEmptyRequest(config, "/api/users/me/profile", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
display_name: args.displayName,
|
||||||
|
avatar_url: args.avatarUrl,
|
||||||
|
about: args.about,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return relayJsonRequest<RawProfile>(config, "/api/users/me/profile");
|
||||||
|
}
|
||||||
|
|
||||||
async function handleCreateChannel(
|
async function handleCreateChannel(
|
||||||
args: {
|
args: {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -1524,6 +1633,13 @@ export function maybeInstallE2eTauriMocks() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return DEFAULT_MOCK_IDENTITY;
|
return DEFAULT_MOCK_IDENTITY;
|
||||||
|
case "get_profile":
|
||||||
|
return handleGetProfile(activeConfig);
|
||||||
|
case "update_profile":
|
||||||
|
return handleUpdateProfile(
|
||||||
|
payload as Parameters<typeof handleUpdateProfile>[0],
|
||||||
|
activeConfig,
|
||||||
|
);
|
||||||
case "get_relay_ws_url":
|
case "get_relay_ws_url":
|
||||||
return getRelayWsUrl(activeConfig);
|
return getRelayWsUrl(activeConfig);
|
||||||
case "get_channels":
|
case "get_channels":
|
||||||
|
|||||||
44
desktop/tests/e2e/profile.spec.ts
Normal file
44
desktop/tests/e2e/profile.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
import { installMockBridge } from "../helpers/bridge";
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await installMockBridge(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updates the relay-backed profile from the sidebar", async ({ page }) => {
|
||||||
|
const stamp = Date.now();
|
||||||
|
const displayName = `Tyler QA ${stamp}`;
|
||||||
|
const avatarUrl = `https://example.com/avatar-${stamp}.png`;
|
||||||
|
const about = `Coordinating relay profile setup ${stamp}`;
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
await page.getByTestId("open-profile").click();
|
||||||
|
await expect(page.getByTestId("profile-sheet")).toBeVisible();
|
||||||
|
|
||||||
|
await expect(page.getByTestId("profile-pubkey")).toContainText("deadbeef");
|
||||||
|
await expect(page.getByTestId("profile-nip05")).toContainText("Not set");
|
||||||
|
|
||||||
|
await page.getByTestId("profile-display-name").fill(displayName);
|
||||||
|
await page.getByTestId("profile-avatar-url").fill(avatarUrl);
|
||||||
|
await page.getByTestId("profile-about").fill(about);
|
||||||
|
await page.getByTestId("profile-save").click();
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(page.getByTestId("profile-sheet")).not.toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId("open-profile").click();
|
||||||
|
await expect(page.getByTestId("profile-sheet")).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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user