Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 105 additions & 1 deletion desktop/src/features/profile/ui/UserProfilePopover.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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";
Expand All @@ -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). */
Expand Down Expand Up @@ -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 (
<button
className={`min-w-20 rounded-full px-3 py-1.5 text-xs font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
isFollowing
? "border border-border/70 bg-background text-foreground hover:bg-muted"
: "bg-foreground text-background hover:bg-foreground/90"
} disabled:cursor-not-allowed disabled:opacity-60`}
disabled={followMutation.isPending || unfollowMutation.isPending}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
if (isFollowing) {
unfollowMutation.mutate();
} else {
followMutation.mutate();
}
}}
type="button"
>
{followMutation.isPending || unfollowMutation.isPending
? "Updating..."
: isFollowing
? "Following"
: "Follow"}
</button>
);
}

export function UserProfilePopover({
children,
pubkey,
triggerElement = "div",
actions,
role,
botIdenticonValue,
}: UserProfilePopoverProps) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -208,7 +307,12 @@ export function UserProfilePopover({
) : null}
</div>

{presenceStatus ? <PresenceBadge status={presenceStatus} /> : null}
<div className="ml-auto flex shrink-0 items-center gap-2">
{profileActions}
{presenceStatus ? (
<PresenceBadge status={presenceStatus} />
) : null}
</div>
</div>

{userStatus ? (
Expand Down
12 changes: 8 additions & 4 deletions desktop/src/features/pulse/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from "@/shared/api/social";
import type {
ContactListResponse,
ContactEntry,
UserNotesResponse,
} from "@/shared/api/socialTypes";

Expand All @@ -26,7 +27,7 @@ export const pulseQueryKeys = {
// ── Contact list ────────────────────────────────────────────────────────────

export function useContactListQuery(pubkey?: string) {
return useQuery<ContactListResponse>({
return useQuery<ContactListResponse | null>({
queryKey: pulseQueryKeys.contactList(pubkey ?? ""),
// biome-ignore lint/style/noNonNullAssertion: guarded by enabled: !!pubkey
queryFn: () => getContactList(pubkey!),
Expand Down Expand Up @@ -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: () => {
Expand All @@ -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: () => {
Expand Down
84 changes: 52 additions & 32 deletions desktop/src/features/pulse/ui/NoteCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -54,6 +56,7 @@ export function NoteCard({
isAgent,
isOwnNote,
isFollowing,
isFollowPending,
onFollow,
onReply,
onShare,
Expand All @@ -71,6 +74,27 @@ export function NoteCard({
const activeActionClass = "text-foreground";
const countPlaceholder = <span aria-hidden className="w-2.5" />;
const currentUserAvatarUrl = currentUserProfile?.avatarUrl ?? null;
const renderProfilePopoverActions = () =>
!isOwnNote ? (
<button
className={`min-w-20 rounded-full px-3 py-1.5 text-xs font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
isFollowing
? "border border-border/70 bg-background text-foreground hover:bg-muted"
: "bg-foreground text-background hover:bg-foreground/90"
} disabled:cursor-not-allowed disabled:opacity-60`}
disabled={isFollowPending}
onClick={() => {
if (isFollowing) {
onUnfollow?.(note.pubkey);
} else {
onFollow?.(note.pubkey);
}
}}
type="button"
>
{isFollowPending ? "Updating..." : isFollowing ? "Following" : "Follow"}
</button>
) : null;

React.useEffect(() => {
if (!isReplyComposerOpen) return;
Expand All @@ -86,22 +110,37 @@ export function NoteCard({

return (
<article className="flex items-start gap-2.5 rounded-2xl px-1 pb-1 pt-4 sm:px-2">
<div className="relative shrink-0">
<UserAvatar
avatarUrl={avatarUrl}
className="!h-9 !w-9 shrink-0"
displayName={displayName}
/>
{isAgent ? (
<Bot className="absolute -bottom-0.5 -right-0.5 h-3.5 w-3.5 rounded-full bg-background p-0.5 text-muted-foreground" />
) : null}
</div>
<UserProfilePopover
actions={renderProfilePopoverActions()}
botIdenticonValue={displayName}
pubkey={note.pubkey}
role={isAgent ? "bot" : undefined}
>
<div className="relative shrink-0 rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<UserAvatar
avatarUrl={avatarUrl}
className="!h-9 !w-9 shrink-0"
displayName={displayName}
/>
{isAgent ? (
<Bot className="absolute -bottom-0.5 -right-0.5 h-3.5 w-3.5 rounded-full bg-background p-0.5 text-muted-foreground" />
) : null}
</div>
</UserProfilePopover>

<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0">
<span className="truncate text-sm font-semibold leading-none tracking-tight">
{displayName}
</span>
<UserProfilePopover
actions={renderProfilePopoverActions()}
botIdenticonValue={displayName}
pubkey={note.pubkey}
role={isAgent ? "bot" : undefined}
triggerElement="span"
>
<span className="truncate text-sm font-semibold leading-none tracking-tight hover:underline">
{displayName}
</span>
</UserProfilePopover>
{isAgent ? (
<span className="inline-flex h-4 items-center rounded bg-muted px-1 text-[10px] font-medium text-muted-foreground">
bot
Expand Down Expand Up @@ -164,25 +203,6 @@ export function NoteCard({
>
<PenSquare className="h-4 w-4" />
</button>
{!isOwnNote ? (
isFollowing ? (
<button
className="text-muted-foreground/60 transition-colors hover:text-foreground hover:underline focus-visible:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onUnfollow?.(note.pubkey)}
type="button"
>
Unfollow
</button>
) : (
<button
className="text-muted-foreground/60 transition-colors hover:text-foreground hover:underline focus-visible:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onFollow?.(note.pubkey)}
type="button"
>
Follow
</button>
)
) : null}
</div>
<button
aria-label={isBookmarked ? "Remove bookmark" : "Save"}
Expand Down
6 changes: 6 additions & 0 deletions desktop/src/features/pulse/ui/PulseView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,12 @@ export function PulseView({ currentPubkey }: PulseViewProps) {
currentUserDisplayName={currentDisplayName}
currentUserProfile={currentProfile}
isAgent={agentPubkeySet.has(note.pubkey)}
isFollowPending={
(followMutation.isPending &&
followMutation.variables === note.pubkey) ||
(unfollowMutation.isPending &&
unfollowMutation.variables === note.pubkey)
}
isFollowing={followingSet.has(note.pubkey)}
isOwnNote={note.pubkey === currentPubkey}
key={note.id}
Expand Down
19 changes: 15 additions & 4 deletions desktop/src/shared/api/social.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,21 @@ export async function publishNote(

export async function getContactList(
pubkey: string,
): Promise<ContactListResponse> {
const raw = await invokeTauri<RawContactListResponse>("get_contact_list", {
pubkey,
});
): Promise<ContactListResponse | null> {
let raw: RawContactListResponse;
try {
raw = await invokeTauri<RawContactListResponse>("get_contact_list", {
pubkey,
});
} catch (error) {
if (
error instanceof Error &&
error.message.toLowerCase().includes("contact list not found")
) {
return null;
}
throw error;
}

// Parse p-tags into contact entries.
const contacts = raw.tags
Expand Down
Loading