diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 544453a03c..22513aa916 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -29,7 +29,11 @@ export default defineConfig({ }, { name: "integration", - testMatch: ["**/stream.spec.ts", "**/integration.spec.ts"], + testMatch: [ + "**/stream.spec.ts", + "**/integration.spec.ts", + "**/profile.spec.ts", + ], use: { ...devices["Desktop Chrome"], }, diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index e709babbed..f19af15db2 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -16,6 +16,15 @@ pub struct IdentityInfo { pub display_name: String, } +#[derive(Serialize, Deserialize)] +pub struct ProfileInfo { + pub pubkey: String, + pub display_name: Option, + pub avatar_url: Option, + pub about: Option, + pub nip05_handle: Option, +} + #[derive(Serialize, Deserialize)] pub struct ChannelInfo { pub id: String, @@ -108,6 +117,16 @@ struct AddMembersBody<'a> { 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)] struct GetFeedQuery<'a> { #[serde(skip_serializing_if = "Option::is_none")] @@ -278,6 +297,46 @@ fn get_relay_ws_url() -> String { relay_ws_url() } +#[tauri::command] +async fn get_profile(state: tauri::State<'_, AppState>) -> Result { + 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, + avatar_url: Option, + about: Option, + state: tauri::State<'_, AppState>, +) -> Result { + 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] fn sign_event( kind: u16, @@ -558,6 +617,8 @@ pub fn run() { .manage(app_state) .invoke_handler(tauri::generate_handler![ get_identity, + get_profile, + update_profile, get_relay_ws_url, sign_event, create_auth_event, diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 17e3a44269..b5528eb064 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -19,6 +19,7 @@ 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 { AppSidebar } from "@/features/sidebar/ui/AppSidebar"; import { getEventById } from "@/shared/api/tauri"; @@ -45,6 +46,7 @@ export function AppShell() { const [selectedView, setSelectedView] = React.useState("home"); const [isChannelManagementOpen, setIsChannelManagementOpen] = React.useState(false); + const [isProfileOpen, setIsProfileOpen] = React.useState(false); const [isSearchOpen, setIsSearchOpen] = React.useState(false); const [searchAnchor, setSearchAnchor] = React.useState( null, @@ -192,6 +194,9 @@ export function AppShell() { onOpenSearch={() => { setIsSearchOpen(true); }} + onOpenProfile={() => { + setIsProfileOpen(true); + }} onSelectHome={() => { React.startTransition(() => { setSelectedView("home"); @@ -331,6 +336,13 @@ export function AppShell() { onOpenChange={setIsChannelManagementOpen} open={isChannelManagementOpen && activeChannel !== null} /> + + ); diff --git a/desktop/src/features/profile/hooks.ts b/desktop/src/features/profile/hooks.ts new file mode 100644 index 0000000000..76f45acec3 --- /dev/null +++ b/desktop/src/features/profile/hooks.ts @@ -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 }); + }, + }); +} diff --git a/desktop/src/features/profile/ui/ProfileSheet.tsx b/desktop/src/features/profile/ui/ProfileSheet.tsx new file mode 100644 index 0000000000..fc64df2559 --- /dev/null +++ b/desktop/src/features/profile/ui/ProfileSheet.tsx @@ -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 ( +
+
+

{title}

+ {description ? ( +

{description}

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

{label}

+
+ {value} +
+
+ ); +} + +function AvatarPreview({ + avatarUrl, + label, +}: { + avatarUrl: string | null; + label: string; +}) { + const [hasError, setHasError] = React.useState(false); + + const initials = label + .trim() + .split(/\s+/) + .map((part) => part[0] ?? "") + .join("") + .slice(0, 2) + .toUpperCase(); + + if (avatarUrl && !hasError) { + return ( + {`${label} { + setHasError(true); + }} + referrerPolicy="no-referrer" + src={avatarUrl} + /> + ); + } + + return ( +
+ {initials.length > 0 ? initials : } +
+ ); +} + +export function ProfileSheet({ + currentPubkey, + fallbackDisplayName, + open, + onOpenChange, +}: ProfileSheetProps) { + const profileQuery = useProfileQuery(open); + const updateProfileMutation = useUpdateProfileMutation(); + const profile = profileQuery.data; + + const currentDisplayName = profile?.displayName ?? ""; + const currentAvatarUrl = profile?.avatarUrl ?? ""; + const currentAbout = profile?.about ?? ""; + + const [displayNameDraft, setDisplayNameDraft] = React.useState(""); + const [avatarUrlDraft, setAvatarUrlDraft] = React.useState(""); + const [aboutDraft, setAboutDraft] = React.useState(""); + + React.useEffect(() => { + if (!open) { + return; + } + + setDisplayNameDraft(currentDisplayName); + setAvatarUrlDraft(currentAvatarUrl); + setAboutDraft(currentAbout); + }, [currentAbout, currentAvatarUrl, currentDisplayName, open]); + + const nextDisplayName = displayNameDraft.trim(); + const nextAvatarUrl = avatarUrlDraft.trim(); + const nextAbout = aboutDraft.trim(); + + const updatePayload: { + displayName?: string; + avatarUrl?: string; + about?: string; + } = {}; + + if (nextDisplayName.length > 0 && nextDisplayName !== currentDisplayName) { + updatePayload.displayName = nextDisplayName; + } + if (nextAvatarUrl.length > 0 && nextAvatarUrl !== currentAvatarUrl) { + updatePayload.avatarUrl = nextAvatarUrl; + } + if (nextAbout.length > 0 && nextAbout !== currentAbout) { + updatePayload.about = nextAbout; + } + + const hasPendingClearRequest = + (currentDisplayName.length > 0 && nextDisplayName.length === 0) || + (currentAvatarUrl.length > 0 && nextAvatarUrl.length === 0) || + (currentAbout.length > 0 && nextAbout.length === 0); + const canSave = + Object.keys(updatePayload).length > 0 && !updateProfileMutation.isPending; + + const resolvedName = + nextDisplayName || + profile?.displayName || + fallbackDisplayName || + "Your profile"; + const resolvedPubkey = profile?.pubkey ?? currentPubkey ?? "Unavailable"; + const resolvedAvatarUrl = + nextAvatarUrl.length > 0 ? nextAvatarUrl : (profile?.avatarUrl ?? null); + const nip05Handle = profile?.nip05Handle ?? "Not set"; + + return ( + + + +
+ +
+ + {resolvedName} + + + Manage how your identity appears across Sprout. + +
+ + Your relay profile +
+
+
+
+ +
+ {profileQuery.error instanceof Error ? ( +

+ {profileQuery.error.message} +

+ ) : null} + +
+
+ + +
+
+ + + +
+
{ + event.preventDefault(); + if (!canSave) { + return; + } + + void updateProfileMutation.mutateAsync(updatePayload); + }} + > +
+ +
+ + + setDisplayNameDraft(event.target.value) + } + placeholder="How people should see you" + value={displayNameDraft} + /> +
+
+ +
+ +
+ + setAvatarUrlDraft(event.target.value)} + placeholder="https://example.com/avatar.png" + value={avatarUrlDraft} + /> +
+
+ +
+ +
+ +