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() { { + const uniqueChannels = new Map(); + + for (const channel of channels) { + uniqueChannels.set(channel.id, channel); + } + + return [...uniqueChannels.values()].sort((left, right) => { const typeOrder = channelTypeOrder[left.channelType] - channelTypeOrder[right.channelType]; diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 6c6dbd3cd..72d4b6c84 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -8,6 +8,11 @@ import { type LucideIcon, } from "lucide-react"; +import { + resolveUserLabel, + type UserProfileLookup, +} from "@/features/profile/lib/identity"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; import type { FeedItem, HomeFeedResponse } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; @@ -18,18 +23,6 @@ const relativeTimeFormatter = new Intl.RelativeTimeFormat("en-US", { numeric: "auto", }); -function truncatePubkey(pubkey: string) { - return `${pubkey.slice(0, 8)}…${pubkey.slice(-4)}`; -} - -function formatActor(pubkey: string, currentPubkey: string | undefined) { - if (currentPubkey && pubkey === currentPubkey) { - return "You"; - } - - return truncatePubkey(pubkey); -} - function formatRelativeTime(unixSeconds: number) { const diff = unixSeconds - Math.floor(Date.now() / 1_000); const absoluteDiff = Math.abs(diff); @@ -128,6 +121,7 @@ type FeedSectionProps = { icon: LucideIcon; items: FeedItem[]; currentPubkey?: string; + profiles?: UserProfileLookup; availableChannelIds: ReadonlySet; 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 deduped: RelayEvent[] = []; + + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + + if (seenIds.has(message.id)) { + continue; + } + + seenIds.add(message.id); + deduped.push(message); + } + + return deduped.reverse(); +} + export function mergeMessages( current: RelayEvent[], incoming: RelayEvent, ): RelayEvent[] { - const deduped = current.filter( + const normalizedCurrent = dedupeMessagesById(current); + const deduped = normalizedCurrent.filter( (message) => message.id !== incoming.id && !(message.pending && incoming.content === message.content), ); - return [...deduped, incoming].sort( + return dedupeMessagesById([...deduped, incoming]).sort( (left, right) => left.created_at - right.created_at, ); } @@ -52,7 +71,8 @@ export function useChannelMessagesQuery(channel: Channel | null) { throw new Error("No channel selected."); } - return relayClient.fetchChannelHistory(channel.id); + const history = await relayClient.fetchChannelHistory(channel.id); + return dedupeMessagesById(history); }, staleTime: Number.POSITIVE_INFINITY, gcTime: 30 * 60 * 1_000, diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.ts b/desktop/src/features/messages/lib/formatTimelineMessages.ts index 2ed994f17..8f67a70a9 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.ts +++ b/desktop/src/features/messages/lib/formatTimelineMessages.ts @@ -1,40 +1,52 @@ import type { Channel, RelayEvent } from "@/shared/api/types"; import type { TimelineMessage } from "@/features/messages/types"; - -function truncatePubkey(pubkey: string) { - return `${pubkey.slice(0, 8)}…${pubkey.slice(-4)}`; -} +import { + resolveUserLabel, + type UserProfileLookup, +} from "@/features/profile/lib/identity"; function formatMessageAuthor( event: RelayEvent, channel: Channel | null, currentPubkey: string | undefined, + profiles: UserProfileLookup | undefined, ) { - if (currentPubkey && event.pubkey === currentPubkey) { - return "You"; - } + const fallbackName = + channel?.channelType === "dm" + ? (() => { + 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..42f318c45 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,18 +44,32 @@ 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} +
+ )} -
+

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

{message.time}

- +
); 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. +

+
+