From dfad0f53f3c63469d132cd455c1d3879f635e664 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Wed, 20 May 2026 17:57:37 -0400 Subject: [PATCH] feat(desktop): wire Pulse follows through profile popovers Co-authored-by: Cursor --- .../profile/ui/UserProfilePopover.tsx | 106 +++++++++++++++++- desktop/src/features/pulse/hooks.ts | 12 +- desktop/src/features/pulse/ui/NoteCard.tsx | 84 ++++++++------ desktop/src/features/pulse/ui/PulseView.tsx | 6 + desktop/src/shared/api/social.ts | 19 +++- 5 files changed, 186 insertions(+), 41 deletions(-) diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index 26226b619..28ee46c61 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -1,5 +1,6 @@ import * as React from "react"; import { Activity } from "lucide-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useUserProfileQuery } from "@/features/profile/hooks"; import { @@ -12,6 +13,9 @@ import { PresenceBadge } from "@/features/presence/ui/PresenceBadge"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { useAgentSession } from "@/shared/context/AgentSessionContext"; import { useProfilePanel } from "@/shared/context/ProfilePanelContext"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { getContactList, setContactList } from "@/shared/api/social"; +import type { ContactEntry } from "@/shared/api/socialTypes"; import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover"; import { BotIdenticon } from "@/features/messages/ui/BotIdenticon"; @@ -20,6 +24,7 @@ type UserProfilePopoverProps = { children: React.ReactNode; pubkey: string; triggerElement?: "div" | "span"; + actions?: React.ReactNode; /** When set to "bot", a BotIdenticon badge renders next to the display name. */ role?: string; /** Value used to generate the BotIdenticon glyph (typically the author name). */ @@ -56,10 +61,102 @@ function truncatePubkey(pubkey: string) { return `${pubkey.slice(0, 8)}…${pubkey.slice(-8)}`; } +function useProfileFollowAction(pubkey: string, open: boolean) { + const queryClient = useQueryClient(); + const identityQuery = useIdentityQuery(); + const currentPubkey = identityQuery.data?.pubkey; + const isSelf = + Boolean(currentPubkey) && + currentPubkey?.toLowerCase() === pubkey.toLowerCase(); + const contactListQuery = useQuery({ + queryKey: ["contact-list", currentPubkey ?? ""], + queryFn: () => getContactList(currentPubkey ?? ""), + enabled: open && Boolean(currentPubkey) && !isSelf, + staleTime: 60_000, + gcTime: 5 * 60_000, + }); + const contacts = contactListQuery.data?.contacts ?? []; + const isFollowing = contacts.some( + (contact) => contact.pubkey.toLowerCase() === pubkey.toLowerCase(), + ); + + const followMutation = useMutation({ + mutationFn: async () => { + if (!currentPubkey) throw new Error("No identity"); + const current = await getContactList(currentPubkey); + const latestContacts = current?.contacts ?? []; + if ( + latestContacts.some( + (contact) => contact.pubkey.toLowerCase() === pubkey.toLowerCase(), + ) + ) { + return; + } + const updated: ContactEntry[] = [...latestContacts, { pubkey }]; + return setContactList(updated); + }, + onSuccess: () => { + if (!currentPubkey) return; + void queryClient.invalidateQueries({ + queryKey: ["contact-list", currentPubkey], + }); + void queryClient.invalidateQueries({ queryKey: ["pulse-timeline"] }); + }, + }); + + const unfollowMutation = useMutation({ + mutationFn: async () => { + if (!currentPubkey) throw new Error("No identity"); + const current = await getContactList(currentPubkey); + const updated = (current?.contacts ?? []).filter( + (contact) => contact.pubkey.toLowerCase() !== pubkey.toLowerCase(), + ); + return setContactList(updated); + }, + onSuccess: () => { + if (!currentPubkey) return; + void queryClient.invalidateQueries({ + queryKey: ["contact-list", currentPubkey], + }); + void queryClient.invalidateQueries({ queryKey: ["pulse-timeline"] }); + }, + }); + + if (!currentPubkey || isSelf) return null; + + return ( + + ); +} + export function UserProfilePopover({ children, pubkey, triggerElement = "div", + actions, role, botIdenticonValue, }: UserProfilePopoverProps) { @@ -89,6 +186,8 @@ export function UserProfilePopover({ const profile = profileQuery.data; const presenceStatus = presenceQuery.data?.[pubkey.toLowerCase()]; const userStatus = userStatusQuery.data?.[pubkey.toLowerCase()]; + const defaultFollowAction = useProfileFollowAction(pubkey, open); + const profileActions = actions ?? defaultFollowAction; const clearHoverTimer = React.useCallback(() => { if (hoverTimerRef.current !== null) { @@ -208,7 +307,12 @@ export function UserProfilePopover({ ) : null} - {presenceStatus ? : null} +
+ {profileActions} + {presenceStatus ? ( + + ) : null} +
{userStatus ? ( diff --git a/desktop/src/features/pulse/hooks.ts b/desktop/src/features/pulse/hooks.ts index 8bfc2af2c..0c5169f0c 100644 --- a/desktop/src/features/pulse/hooks.ts +++ b/desktop/src/features/pulse/hooks.ts @@ -9,6 +9,7 @@ import { } from "@/shared/api/social"; import type { ContactListResponse, + ContactEntry, UserNotesResponse, } from "@/shared/api/socialTypes"; @@ -26,7 +27,7 @@ export const pulseQueryKeys = { // ── Contact list ──────────────────────────────────────────────────────────── export function useContactListQuery(pubkey?: string) { - return useQuery({ + return useQuery({ queryKey: pulseQueryKeys.contactList(pubkey ?? ""), // biome-ignore lint/style/noNonNullAssertion: guarded by enabled: !!pubkey queryFn: () => getContactList(pubkey!), @@ -109,10 +110,11 @@ export function useFollowMutation(currentPubkey?: string) { if (!currentPubkey) throw new Error("No identity"); // Fresh read to avoid overwriting concurrent mutations. const current = await getContactList(currentPubkey); - if (current.contacts.some((c) => c.pubkey === targetPubkey)) { + const contacts = current?.contacts ?? []; + if (contacts.some((c) => c.pubkey === targetPubkey)) { return; // already following } - const updated = [...current.contacts, { pubkey: targetPubkey }]; + const updated: ContactEntry[] = [...contacts, { pubkey: targetPubkey }]; return setContactList(updated); }, onSuccess: () => { @@ -136,7 +138,9 @@ export function useUnfollowMutation(currentPubkey?: string) { if (!currentPubkey) throw new Error("No identity"); // Fresh read to avoid overwriting concurrent mutations. const current = await getContactList(currentPubkey); - const updated = current.contacts.filter((c) => c.pubkey !== targetPubkey); + const updated = (current?.contacts ?? []).filter( + (c) => c.pubkey !== targetPubkey, + ); return setContactList(updated); }, onSuccess: () => { diff --git a/desktop/src/features/pulse/ui/NoteCard.tsx b/desktop/src/features/pulse/ui/NoteCard.tsx index 1ad34dc44..a27b2b879 100644 --- a/desktop/src/features/pulse/ui/NoteCard.tsx +++ b/desktop/src/features/pulse/ui/NoteCard.tsx @@ -12,6 +12,7 @@ import { } from "lucide-react"; import * as React from "react"; +import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import type { UserNote } from "@/shared/api/socialTypes"; import type { UserProfileSummary } from "@/shared/api/types"; import { Markdown } from "@/shared/ui/markdown"; @@ -25,6 +26,7 @@ type NoteCardProps = { isAgent?: boolean; isOwnNote: boolean; isFollowing: boolean; + isFollowPending?: boolean; onFollow?: (pubkey: string) => void; onReply?: (note: UserNote) => void; onShare?: (note: UserNote) => void; @@ -54,6 +56,7 @@ export function NoteCard({ isAgent, isOwnNote, isFollowing, + isFollowPending, onFollow, onReply, onShare, @@ -71,6 +74,27 @@ export function NoteCard({ const activeActionClass = "text-foreground"; const countPlaceholder = ; const currentUserAvatarUrl = currentUserProfile?.avatarUrl ?? null; + const renderProfilePopoverActions = () => + !isOwnNote ? ( + + ) : null; React.useEffect(() => { if (!isReplyComposerOpen) return; @@ -86,22 +110,37 @@ export function NoteCard({ return (
-
- - {isAgent ? ( - - ) : null} -
+ +
+ + {isAgent ? ( + + ) : null} +
+
- - {displayName} - + + + {displayName} + + {isAgent ? ( bot @@ -164,25 +203,6 @@ export function NoteCard({ > - {!isOwnNote ? ( - isFollowing ? ( - - ) : ( - - ) - ) : null}