From 277ec580b962b7156e801b795e76aac05014b5d7 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 11 Mar 2026 11:33:01 -0700 Subject: [PATCH 1/2] Plumb desktop profile identity UI --- desktop/src-tauri/src/lib.rs | 55 +++++++- desktop/src/app/AppShell.tsx | 27 +++- desktop/src/features/home/ui/HomeView.tsx | 46 +++++-- .../messages/lib/formatTimelineMessages.ts | 50 ++++--- desktop/src/features/messages/types.ts | 1 + .../features/messages/ui/MessageTimeline.tsx | 35 +++-- desktop/src/features/profile/hooks.ts | 44 ++++++- desktop/src/features/profile/lib/identity.ts | 80 +++++++++++ .../src/features/search/ui/SearchDialog.tsx | 32 +++++ .../src/features/settings/ui/SettingsView.tsx | 42 +++++- desktop/src/shared/api/tauri.ts | 44 +++++++ desktop/src/shared/api/types.ts | 11 ++ desktop/src/testing/e2eBridge.ts | 124 ++++++++++++++++++ desktop/tests/e2e/messaging.spec.ts | 29 ++++ desktop/tests/e2e/profile.spec.ts | 10 ++ desktop/tests/e2e/smoke.spec.ts | 34 +++++ 16 files changed, 615 insertions(+), 49 deletions(-) create mode 100644 desktop/src/features/profile/lib/identity.ts diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 0aa110a8a..dbb587484 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1,4 +1,4 @@ -use std::sync::Mutex; +use std::{collections::HashMap, sync::Mutex}; use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, ToBech32}; use reqwest::Method; @@ -25,6 +25,18 @@ pub struct ProfileInfo { pub nip05_handle: Option, } +#[derive(Serialize, Deserialize)] +pub struct UserProfileSummaryInfo { + pub display_name: Option, + pub nip05_handle: Option, +} + +#[derive(Serialize, Deserialize)] +pub struct UsersBatchResponse { + pub profiles: HashMap, + pub missing: Vec, +} + #[derive(Serialize, Deserialize)] pub struct ChannelInfo { pub id: String, @@ -125,6 +137,8 @@ struct UpdateProfileBody<'a> { avatar_url: Option<&'a str>, #[serde(skip_serializing_if = "Option::is_none")] about: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + nip05_handle: Option<&'a str>, } #[derive(Serialize)] @@ -313,6 +327,7 @@ async fn update_profile( display_name: Option, avatar_url: Option, about: Option, + nip05_handle: Option, state: tauri::State<'_, AppState>, ) -> Result { let request = build_authed_request( @@ -325,6 +340,7 @@ async fn update_profile( display_name: display_name.as_deref(), avatar_url: avatar_url.as_deref(), about: about.as_deref(), + nip05_handle: nip05_handle.as_deref(), }); send_empty_request(request).await?; @@ -337,6 +353,41 @@ async fn update_profile( send_json_request(request).await } +#[tauri::command] +async fn get_user_profile( + pubkey: Option, + state: tauri::State<'_, AppState>, +) -> Result { + let path = match pubkey { + Some(pubkey) => format!("/api/users/{pubkey}/profile"), + None => "/api/users/me/profile".to_string(), + }; + let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?; + send_json_request(request).await +} + +#[derive(Serialize)] +struct GetUsersBatchBody<'a> { + pubkeys: &'a [String], +} + +#[tauri::command] +async fn get_users_batch( + pubkeys: Vec, + state: tauri::State<'_, AppState>, +) -> Result { + let request = build_authed_request( + &state.http_client, + Method::POST, + "/api/users/batch", + &state, + )? + .json(&GetUsersBatchBody { + pubkeys: pubkeys.as_slice(), + }); + send_json_request(request).await +} + #[tauri::command] fn sign_event( kind: u16, @@ -619,6 +670,8 @@ pub fn run() { get_identity, get_profile, update_profile, + get_user_profile, + get_users_batch, get_relay_ws_url, sign_event, create_auth_event, diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 58202f0ec..b2f3c3119 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -14,11 +14,15 @@ import { useHomeFeedQuery } from "@/features/home/hooks"; import { HomeView } from "@/features/home/ui/HomeView"; import { useChannelMessagesQuery, - useChannelSubscription, mergeMessages, useSendMessageMutation, + useChannelSubscription, } from "@/features/messages/hooks"; -import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages"; +import { + collectMessageAuthorPubkeys, + formatTimelineMessages, +} from "@/features/messages/lib/formatTimelineMessages"; +import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks"; import { MessageComposer } from "@/features/messages/ui/MessageComposer"; import { MessageTimeline } from "@/features/messages/ui/MessageTimeline"; import { SearchDialog } from "@/features/search/ui/SearchDialog"; @@ -62,6 +66,7 @@ export function AppShell() { const [searchAnchorEvent, setSearchAnchorEvent] = React.useState(null); const identityQuery = useIdentityQuery(); + const profileQuery = useProfileQuery(); const homeFeedQuery = useHomeFeedQuery(); const channelsQuery = useChannelsQuery(); const channels = channelsQuery.data ?? []; @@ -105,6 +110,13 @@ export function AppShell() { searchAnchorChannelId, searchAnchorEvent, ]); + const messageAuthorPubkeys = React.useMemo( + () => collectMessageAuthorPubkeys(resolvedMessages), + [resolvedMessages], + ); + const messageProfilesQuery = useUsersBatchQuery(messageAuthorPubkeys, { + enabled: resolvedMessages.length > 0, + }); const timelineMessages = React.useMemo( () => @@ -112,8 +124,16 @@ export function AppShell() { resolvedMessages, activeChannel, identityQuery.data?.pubkey, + profileQuery.data?.avatarUrl ?? null, + messageProfilesQuery.data?.profiles, ), - [activeChannel, identityQuery.data?.pubkey, resolvedMessages], + [ + activeChannel, + identityQuery.data?.pubkey, + profileQuery.data?.avatarUrl, + messageProfilesQuery.data?.profiles, + resolvedMessages, + ], ); const channelDescription = activeChannel @@ -382,6 +402,7 @@ export function AppShell() { ; onOpenChannel: (channelId: string) => void; }; @@ -140,6 +134,7 @@ function FeedSection({ icon: Icon, items, currentPubkey, + profiles, availableChannelIds, onOpenChannel, }: FeedSectionProps) { @@ -195,7 +190,12 @@ function FeedSection({ {feedHeadline(item)}

- {formatActor(item.pubkey, currentPubkey)} + {resolveUserLabel({ + pubkey: item.pubkey, + currentPubkey, + profiles, + preferResolvedSelfLabel: true, + })}

{item.channelName ? (

@@ -356,6 +356,22 @@ export function HomeView({ onOpenChannel, onRefresh, }: HomeViewProps) { + const feedItems = feed + ? [ + ...feed.feed.mentions, + ...feed.feed.needsAction, + ...feed.feed.activity, + ...feed.feed.agentActivity, + ] + : []; + const feedProfilesQuery = useUsersBatchQuery( + feedItems.map((item) => item.pubkey), + { + enabled: feedItems.length > 0, + }, + ); + const feedProfiles = feedProfilesQuery.data?.profiles; + if (isLoading && !feed) { return ; } @@ -459,6 +475,7 @@ export function HomeView({ { + const participantIndex = channel.participantPubkeys.indexOf( + event.pubkey, + ); + if (participantIndex < 0) { + return null; + } - if (channel?.channelType === "dm") { - const participantIndex = channel.participantPubkeys.indexOf(event.pubkey); - if (participantIndex >= 0) { - return ( - channel.participants[participantIndex] ?? truncatePubkey(event.pubkey) - ); - } - } + return channel.participants[participantIndex] ?? null; + })() + : null; - return truncatePubkey(event.pubkey); + return resolveUserLabel({ + pubkey: event.pubkey, + currentPubkey, + fallbackName, + profiles, + preferResolvedSelfLabel: true, + }); } export function formatTimelineMessages( events: RelayEvent[], channel: Channel | null, currentPubkey: string | undefined, + currentUserAvatarUrl: string | null, + profiles?: UserProfileLookup, ): TimelineMessage[] { return events.map((event) => ({ id: event.id, - author: formatMessageAuthor(event, channel, currentPubkey), + author: formatMessageAuthor(event, channel, currentPubkey, profiles), + avatarUrl: + currentPubkey === event.pubkey ? (currentUserAvatarUrl ?? null) : null, time: new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", @@ -44,3 +56,7 @@ export function formatTimelineMessages( pending: event.pending, })); } + +export function collectMessageAuthorPubkeys(events: RelayEvent[]) { + return [...new Set(events.map((event) => event.pubkey.toLowerCase()))]; +} diff --git a/desktop/src/features/messages/types.ts b/desktop/src/features/messages/types.ts index 4b4290b25..304f551ce 100644 --- a/desktop/src/features/messages/types.ts +++ b/desktop/src/features/messages/types.ts @@ -1,6 +1,7 @@ export type TimelineMessage = { id: string; author: string; + avatarUrl?: string | null; role?: string; time: string; body: string; diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 453978ca6..90dc9f9d8 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -27,6 +27,7 @@ function isNearBottom(container: HTMLDivElement) { } function MessageRow({ message }: { message: TimelineMessage }) { + const [hasAvatarError, setHasAvatarError] = React.useState(false); const initials = message.author .split(" ") .map((part) => part[0]) @@ -43,16 +44,30 @@ function MessageRow({ message }: { message: TimelineMessage }) { data-message-id={message.id} data-testid="message-row" > -

- {initials} -
+ {message.avatarUrl && !hasAvatarError ? ( + {`${message.author} { + setHasAvatarError(true); + }} + referrerPolicy="no-referrer" + src={message.avatarUrl} + /> + ) : ( +
+ {initials} +
+ )}
diff --git a/desktop/src/features/profile/hooks.ts b/desktop/src/features/profile/hooks.ts index 76f45acec..ed47fb2d3 100644 --- a/desktop/src/features/profile/hooks.ts +++ b/desktop/src/features/profile/hooks.ts @@ -1,7 +1,16 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { getProfile, updateProfile } from "@/shared/api/tauri"; -import type { Profile, UpdateProfileInput } from "@/shared/api/types"; +import { + getProfile, + getUserProfile, + getUsersBatch, + updateProfile, +} from "@/shared/api/tauri"; +import type { + Profile, + UpdateProfileInput, + UsersBatchResponse, +} from "@/shared/api/types"; export const profileQueryKey = ["profile"] as const; @@ -14,6 +23,37 @@ export function useProfileQuery(enabled = true) { }); } +export function useUserProfileQuery(pubkey?: string) { + return useQuery({ + enabled: typeof pubkey === "string" && pubkey.length > 0, + queryKey: ["user-profile", pubkey?.toLowerCase() ?? ""], + queryFn: () => getUserProfile(pubkey), + staleTime: 60_000, + }); +} + +export function useUsersBatchQuery( + pubkeys: string[], + options?: { + enabled?: boolean; + }, +) { + const normalizedPubkeys = [ + ...new Set(pubkeys.map((pubkey) => pubkey.toLowerCase())), + ] + .filter((pubkey) => pubkey.length > 0) + .sort(); + const enabled = (options?.enabled ?? true) && normalizedPubkeys.length > 0; + + return useQuery({ + enabled, + queryKey: ["users-batch", ...normalizedPubkeys], + queryFn: () => getUsersBatch(normalizedPubkeys), + staleTime: 60_000, + gcTime: 5 * 60 * 1_000, + }); +} + export function useUpdateProfileMutation() { const queryClient = useQueryClient(); diff --git a/desktop/src/features/profile/lib/identity.ts b/desktop/src/features/profile/lib/identity.ts new file mode 100644 index 000000000..f4808ff02 --- /dev/null +++ b/desktop/src/features/profile/lib/identity.ts @@ -0,0 +1,80 @@ +import type { UserProfileSummary } from "@/shared/api/types"; + +export type UserProfileLookup = Record; + +export function truncatePubkey(pubkey: string) { + return `${pubkey.slice(0, 8)}…${pubkey.slice(-4)}`; +} + +function normalizePubkey(pubkey: string) { + return pubkey.toLowerCase(); +} + +function getResolvedProfile( + pubkey: string, + profiles: UserProfileLookup | undefined, +) { + if (!profiles) { + return null; + } + + return profiles[normalizePubkey(pubkey)] ?? null; +} + +export function resolveUserLabel(input: { + pubkey: string; + currentPubkey?: string; + fallbackName?: string | null; + profiles?: UserProfileLookup; + preferResolvedSelfLabel?: boolean; +}) { + const { + currentPubkey, + fallbackName, + preferResolvedSelfLabel = false, + profiles, + pubkey, + } = input; + + if ( + typeof currentPubkey === "string" && + normalizePubkey(currentPubkey) === normalizePubkey(pubkey) + ) { + if (!preferResolvedSelfLabel) { + return "You"; + } + } + + const profile = getResolvedProfile(pubkey, profiles); + const displayName = profile?.displayName?.trim(); + if (displayName) { + return displayName; + } + + const nip05Handle = profile?.nip05Handle?.trim(); + if (nip05Handle) { + return nip05Handle; + } + + const safeFallback = fallbackName?.trim(); + if (safeFallback) { + return safeFallback; + } + + return truncatePubkey(pubkey); +} + +export function resolveUserSecondaryLabel(input: { + pubkey: string; + profiles?: UserProfileLookup; +}) { + const profile = getResolvedProfile(input.pubkey, input.profiles); + const displayName = profile?.displayName?.trim(); + const nip05Handle = profile?.nip05Handle?.trim(); + + if (displayName && nip05Handle) { + return nip05Handle; + } + + return null; +} diff --git a/desktop/src/features/search/ui/SearchDialog.tsx b/desktop/src/features/search/ui/SearchDialog.tsx index 87f30c5fc..cc71b4125 100644 --- a/desktop/src/features/search/ui/SearchDialog.tsx +++ b/desktop/src/features/search/ui/SearchDialog.tsx @@ -10,6 +10,11 @@ import { type LucideIcon, } from "lucide-react"; +import { + resolveUserLabel, + resolveUserSecondaryLabel, +} from "@/features/profile/lib/identity"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; import { useSearchMessagesQuery } from "@/features/search/hooks"; import type { Channel, SearchHit } from "@/shared/api/types"; import { @@ -118,6 +123,7 @@ function SearchLoadingState() { type SearchDialogProps = { channels: Channel[]; + currentPubkey?: string; open: boolean; onOpenChange: (open: boolean) => void; onOpenResult: (hit: SearchHit) => void; @@ -125,6 +131,7 @@ type SearchDialogProps = { export function SearchDialog({ channels, + currentPubkey, open, onOpenChange, onOpenResult, @@ -144,6 +151,13 @@ export function SearchDialog({ }); const results = searchQuery.data?.hits ?? []; + const resultProfilesQuery = useUsersBatchQuery( + results.map((hit) => hit.pubkey), + { + enabled: open && results.length > 0, + }, + ); + const resultProfiles = resultProfilesQuery.data?.profiles; const openResult = React.useCallback( (hit: SearchHit) => { @@ -293,6 +307,16 @@ export function SearchDialog({
{results.map((hit, index) => { const channel = channelLookup.get(hit.channelId); + const authorLabel = resolveUserLabel({ + pubkey: hit.pubkey, + currentPubkey, + profiles: resultProfiles, + preferResolvedSelfLabel: true, + }); + const authorSecondaryLabel = resolveUserSecondaryLabel({ + pubkey: hit.pubkey, + profiles: resultProfiles, + }); return (
+ {authorSecondaryLabel ? ( +

+ {authorSecondaryLabel} +

+ ) : null}

{truncateContent(hit.content)}

diff --git a/desktop/src/features/settings/ui/SettingsView.tsx b/desktop/src/features/settings/ui/SettingsView.tsx index 676c8538b..77fb3be2d 100644 --- a/desktop/src/features/settings/ui/SettingsView.tsx +++ b/desktop/src/features/settings/ui/SettingsView.tsx @@ -190,25 +190,30 @@ function ProfileSettingsCard({ const currentDisplayName = profile?.displayName ?? ""; const currentAvatarUrl = profile?.avatarUrl ?? ""; const currentAbout = profile?.about ?? ""; + const currentNip05Handle = profile?.nip05Handle ?? ""; const [displayNameDraft, setDisplayNameDraft] = React.useState(""); const [avatarUrlDraft, setAvatarUrlDraft] = React.useState(""); const [aboutDraft, setAboutDraft] = React.useState(""); + const [nip05HandleDraft, setNip05HandleDraft] = React.useState(""); React.useEffect(() => { setDisplayNameDraft(currentDisplayName); setAvatarUrlDraft(currentAvatarUrl); setAboutDraft(currentAbout); - }, [currentAbout, currentAvatarUrl, currentDisplayName]); + setNip05HandleDraft(currentNip05Handle); + }, [currentAbout, currentAvatarUrl, currentDisplayName, currentNip05Handle]); const nextDisplayName = displayNameDraft.trim(); const nextAvatarUrl = avatarUrlDraft.trim(); const nextAbout = aboutDraft.trim(); + const nextNip05Handle = nip05HandleDraft.trim(); const updatePayload: { displayName?: string; avatarUrl?: string; about?: string; + nip05Handle?: string; } = {}; if (nextDisplayName.length > 0 && nextDisplayName !== currentDisplayName) { @@ -220,6 +225,9 @@ function ProfileSettingsCard({ if (nextAbout.length > 0 && nextAbout !== currentAbout) { updatePayload.about = nextAbout; } + if (nextNip05Handle !== currentNip05Handle) { + updatePayload.nip05Handle = nextNip05Handle; + } const hasPendingClearRequest = (currentDisplayName.length > 0 && nextDisplayName.length === 0) || @@ -272,6 +280,12 @@ function ProfileSettingsCard({

) : null} + {updateProfileMutation.error instanceof Error ? ( +

+ {updateProfileMutation.error.message} +

+ ) : null} + {updateProfileMutation.isSuccess ? (
@@ -280,7 +294,7 @@ function ProfileSettingsCard({ ) : null}
@@ -335,6 +349,28 @@ function ProfileSettingsCard({
+
+ +
+ + setNip05HandleDraft(event.target.value)} + placeholder="alice@localhost" + value={nip05HandleDraft} + /> +
+

+ Must match this relay's domain. Leave blank to clear your + current handle. +

+
+
)} -
+

{message.author} @@ -88,7 +88,7 @@ function MessageRow({ message }: { message: TimelineMessage }) {

{message.time}

- +
); diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index a7b73da00..0e5cf2a58 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -9,6 +9,7 @@ type MarkdownProps = { className?: string; compact?: boolean; content: string; + tight?: boolean; }; const markdownComponents: Components = { @@ -126,6 +127,7 @@ export function Markdown({ className, compact = false, content, + tight = false, }: MarkdownProps) { let processedContent = content; @@ -140,9 +142,11 @@ export function Markdown({ return (
*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*]:my-1.5" - : "max-w-none break-words text-sm leading-7 text-foreground/90 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*]:my-3", + tight + ? "max-w-none break-words text-sm leading-5 text-foreground/90 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*]:my-1" + : compact + ? "max-w-none break-words text-[15px] leading-6 text-foreground/90 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*]:my-1.5" + : "max-w-none break-words text-sm leading-7 text-foreground/90 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*]:my-3", className, )} >