diff --git a/desktop/src-tauri/src/commands/profile.rs b/desktop/src-tauri/src/commands/profile.rs index f2ff43bf5..9ffc4f2ff 100644 --- a/desktop/src-tauri/src/commands/profile.rs +++ b/desktop/src-tauri/src/commands/profile.rs @@ -8,8 +8,8 @@ use crate::{ app_state::AppState, events, models::{ - GetUsersBatchBody, ProfileInfo, SearchUsersResponse, SetPresenceBody, SetPresenceResponse, - UsersBatchResponse, + GetUserNotesQuery, GetUsersBatchBody, ProfileInfo, SearchUsersResponse, SetPresenceBody, + SetPresenceResponse, UserNotesResponse, UsersBatchResponse, }, relay::{build_authed_request, send_json_request, submit_event}, }; @@ -102,6 +102,26 @@ pub async fn get_users_batch( send_json_request(request).await } +#[tauri::command] +pub async fn get_user_notes( + pubkey: String, + limit: Option, + before: Option, + before_id: Option, + state: State<'_, AppState>, +) -> Result { + let path = format!("/api/users/{pubkey}/notes"); + let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?.query( + &GetUserNotesQuery { + limit, + before, + before_id: before_id.as_deref(), + }, + ); + + send_json_request(request).await +} + #[tauri::command] pub async fn search_users( query: String, diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 5aa89028b..2be0cfabf 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -347,6 +347,7 @@ pub fn run() { update_profile, get_user_profile, get_users_batch, + get_user_notes, search_users, get_presence, set_presence, diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs index 7eb511c17..a07fc67b9 100644 --- a/desktop/src-tauri/src/models.rs +++ b/desktop/src-tauri/src/models.rs @@ -45,6 +45,26 @@ pub struct SearchUsersResponse { pub users: Vec, } +#[derive(Serialize, Deserialize)] +pub struct UserNoteInfo { + pub id: String, + pub pubkey: String, + pub created_at: i64, + pub content: String, +} + +#[derive(Serialize, Deserialize)] +pub struct UserNotesCursor { + pub before: i64, + pub before_id: String, +} + +#[derive(Serialize, Deserialize)] +pub struct UserNotesResponse { + pub notes: Vec, + pub next_cursor: Option, +} + #[derive(Serialize, Deserialize)] pub struct SetPresenceResponse { pub status: PresenceStatus, @@ -259,6 +279,16 @@ pub struct GetUsersBatchBody<'a> { pub pubkeys: &'a [String], } +#[derive(Serialize)] +pub struct GetUserNotesQuery<'a> { + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub before: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub before_id: Option<&'a str>, +} + #[derive(Serialize, Deserialize)] pub struct ThreadSummary { pub reply_count: u32, diff --git a/desktop/src/features/profile/hooks.ts b/desktop/src/features/profile/hooks.ts index cb99ce507..cb2c29ebf 100644 --- a/desktop/src/features/profile/hooks.ts +++ b/desktop/src/features/profile/hooks.ts @@ -1,6 +1,7 @@ import { useEffect } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { getUserNotes } from "@/shared/api/social"; import { getProfile, searchUsers, @@ -11,6 +12,7 @@ import { import type { Profile, UpdateProfileInput, + UserNotesResponse, UserSearchResult, UsersBatchResponse, } from "@/shared/api/types"; @@ -73,6 +75,25 @@ export function useUsersBatchQuery( return query; } +export function useUserNotesQuery( + pubkey?: string, + options?: { + enabled?: boolean; + limit?: number; + }, +) { + const resolvedPubkey = typeof pubkey === "string" ? pubkey : ""; + const enabled = (options?.enabled ?? true) && resolvedPubkey.length > 0; + + return useQuery({ + enabled, + queryKey: ["user-notes", resolvedPubkey.toLowerCase(), options?.limit ?? 3], + queryFn: () => getUserNotes(resolvedPubkey, { limit: options?.limit ?? 3 }), + staleTime: 60_000, + gcTime: 5 * 60 * 1_000, + }); +} + export function useUserSearchQuery( query: string, options?: { diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index eb6766855..ff84dc0d8 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -1,9 +1,14 @@ import * as React from "react"; -import { useUserProfileQuery } from "@/features/profile/hooks"; -import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; +import { + useUserNotesQuery, + useUserProfileQuery, +} from "@/features/profile/hooks"; import { usePresenceQuery } from "@/features/presence/hooks"; import { PresenceBadge } from "@/features/presence/ui/PresenceBadge"; +import { formatRelativeTime } from "@/features/forum/lib/time"; +import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; +import { Markdown } from "@/shared/ui/markdown"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; type UserProfilePopoverProps = { @@ -25,17 +30,21 @@ export function UserProfilePopover({ }: UserProfilePopoverProps) { const [open, setOpen] = React.useState(false); const profileQuery = useUserProfileQuery(open ? pubkey : undefined); + const notesQuery = useUserNotesQuery(open ? pubkey : undefined, { + limit: 3, + }); const presenceQuery = usePresenceQuery(open ? [pubkey] : [], { enabled: open, }); const profile = profileQuery.data; + const notes = notesQuery.data?.notes ?? []; const presenceStatus = presenceQuery.data?.[pubkey.toLowerCase()]; return ( {children} - +
{profile?.avatarUrl ? ( @@ -76,6 +85,50 @@ export function UserProfilePopover({

{truncatePubkey(pubkey)}

+ + {notesQuery.isLoading ? ( +
+ Loading recent notes… +
+ ) : null} + + {!notesQuery.isLoading && notes.length > 0 ? ( +
+

+ Recent Notes +

+
+ {notes.map((note) => ( +
+

+ {formatRelativeTime(note.createdAt)} +

+ +
+ ))} +
+
+ ) : null} + + {notesQuery.isError ? ( +

+ Recent notes are unavailable right now. +

+ ) : null}
diff --git a/desktop/src/shared/api/social.ts b/desktop/src/shared/api/social.ts new file mode 100644 index 000000000..860605430 --- /dev/null +++ b/desktop/src/shared/api/social.ts @@ -0,0 +1,55 @@ +import type { UserNote, UserNotesResponse } from "@/shared/api/socialTypes"; + +import { invokeTauri } from "./tauri"; + +type RawUserNote = { + id: string; + pubkey: string; + created_at: number; + content: string; +}; + +type RawUserNotesCursor = { + before: number; + before_id: string; +}; + +type RawUserNotesResponse = { + notes: RawUserNote[]; + next_cursor: RawUserNotesCursor | null; +}; + +function fromRawUserNote(note: RawUserNote): UserNote { + return { + id: note.id, + pubkey: note.pubkey, + createdAt: note.created_at, + content: note.content, + }; +} + +export async function getUserNotes( + pubkey: string, + options?: { + limit?: number; + before?: number; + beforeId?: string; + }, +): Promise { + const response = await invokeTauri("get_user_notes", { + pubkey, + limit: options?.limit ?? null, + before: options?.before ?? null, + beforeId: options?.beforeId ?? null, + }); + + return { + notes: response.notes.map(fromRawUserNote), + nextCursor: response.next_cursor + ? { + before: response.next_cursor.before, + beforeId: response.next_cursor.before_id, + } + : null, + }; +} diff --git a/desktop/src/shared/api/socialTypes.ts b/desktop/src/shared/api/socialTypes.ts new file mode 100644 index 000000000..289231085 --- /dev/null +++ b/desktop/src/shared/api/socialTypes.ts @@ -0,0 +1,16 @@ +export type UserNote = { + id: string; + pubkey: string; + createdAt: number; + content: string; +}; + +export type UserNotesCursor = { + before: number; + beforeId: string; +}; + +export type UserNotesResponse = { + notes: UserNote[]; + nextCursor: UserNotesCursor | null; +}; diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 9d502e55c..cda23f68c 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -389,8 +389,6 @@ export type ManagedAgentPrereqs = { mcp: CommandAvailability; }; -// ── Model discovery types ───────────────────────────────────────────────────── - export type AgentModelsResponse = { agentName: string; agentVersion: string; @@ -399,19 +397,16 @@ export type AgentModelsResponse = { selectedModel: string | null; supportsSwitching: boolean; }; - export type AgentModelInfo = { id: string; name: string | null; description: string | null; }; - export type UpdateManagedAgentInput = { pubkey: string; model?: string | null; systemPrompt?: string | null; }; - export type AgentPersona = { id: string; displayName: string; @@ -442,9 +437,6 @@ export type UpdatePersonaInput = { provider?: string; model?: string; }; - -// ── Team types ──────────────────────────────────────────────────────────────── - export type AgentTeam = { id: string; name: string; @@ -466,8 +458,6 @@ export type UpdateTeamInput = { description?: string; personaIds: string[]; }; - -// ── Workflow types (re-exported from workflowTypes.ts) ──────────────────── export type { ApprovalActionResponse, Workflow, @@ -480,8 +470,11 @@ export type { TraceEntry, TriggerWorkflowResponse, } from "@/shared/api/workflowTypes"; - -// ── Forum types ─────────────────────────────────────────────────────────────── +export type { + UserNote, + UserNotesCursor, + UserNotesResponse, +} from "./socialTypes"; export type ThreadSummary = { replyCount: number; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 9fa575868..2eaa16772 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -207,6 +207,23 @@ type RawForumThreadResponse = { next_cursor: string | null; }; +type RawUserNote = { + id: string; + pubkey: string; + created_at: number; + content: string; +}; + +type RawUserNotesCursor = { + before: number; + before_id: string; +}; + +type RawUserNotesResponse = { + notes: RawUserNote[]; + next_cursor: RawUserNotesCursor | null; +}; + type RawSearchHit = { event_id: string; content: string; @@ -1719,6 +1736,91 @@ async function handleGetForumThread(args: { }; } +function getMockUserNotes(pubkey: string): RawUserNote[] { + const now = Math.floor(Date.now() / 1000); + + if (pubkey === DEFAULT_MOCK_IDENTITY.pubkey) { + return [ + { + id: "mock-note-launch", + pubkey, + created_at: now - 20 * 60, + content: "Shipped the new desktop sidebar polish today.", + }, + { + id: "mock-note-forum", + pubkey, + created_at: now - 3 * 60 * 60, + content: "Forum threads feel like the right home for slower decisions.", + }, + ]; + } + + if (pubkey === ALICE_PUBKEY) { + return [ + { + id: "mock-alice-note-release", + pubkey, + created_at: now - 45 * 60, + content: "Release checklist is ready for async feedback.", + }, + { + id: "mock-alice-note-design", + pubkey, + created_at: now - 5 * 60 * 60, + content: "Trying a lighter forum layout for longer-form notes.", + }, + ]; + } + + return []; +} + +async function handleGetUserNotes( + args: { + pubkey: string; + limit?: number | null; + before?: number | null; + beforeId?: string | null; + }, + config: E2eConfig | undefined, +): Promise { + const identity = getIdentity(config); + if (!identity) { + const notes = getMockUserNotes(args.pubkey) + .filter((note) => (args.before ? note.created_at < args.before : true)) + .sort((left, right) => right.created_at - left.created_at) + .slice(0, args.limit ?? 50); + + return { + notes, + next_cursor: null, + }; + } + + const url = new URL( + `/api/users/${args.pubkey}/notes`, + getRelayHttpUrl(config), + ); + if (args.limit !== undefined && args.limit !== null) { + url.searchParams.set("limit", String(args.limit)); + } + if (args.before !== undefined && args.before !== null) { + url.searchParams.set("before", String(args.before)); + } + if (args.beforeId) { + url.searchParams.set("before_id", args.beforeId); + } + + const response = await fetch(url, { + headers: { + "X-Pubkey": identity.pubkey, + }, + }); + await assertOk(response); + return response.json(); +} + function createMockEvent( kind: number, content: string, @@ -3743,6 +3845,11 @@ export function maybeInstallE2eTauriMocks() { payload as Parameters[0], activeConfig, ); + case "get_user_notes": + return handleGetUserNotes( + payload as Parameters[0], + activeConfig, + ); case "search_users": return handleSearchUsers( payload as Parameters[0], diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 4a0dd3b1a..01fc76b52 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -142,6 +142,9 @@ test("clicking author name opens user profile popover", async ({ page }) => { const popover = page.locator("[data-radix-popper-content-wrapper]"); await expect(popover).toBeVisible(); await expect(popover).toContainText("deadbeef"); + await expect(page.getByTestId("user-profile-notes")).toContainText( + "Shipped the new desktop sidebar polish today.", + ); }); test("clicking avatar opens user profile popover", async ({ page }) => {