[codex] Show recent notes in user popovers (#270)

This commit is contained in:
Wes
2026-04-08 12:24:10 -07:00
committed by GitHub
parent 7a9218ba54
commit 7a1eb31492
10 changed files with 316 additions and 17 deletions
+22 -2
View File
@@ -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<u32>,
before: Option<i64>,
before_id: Option<String>,
state: State<'_, AppState>,
) -> Result<UserNotesResponse, String> {
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,
+1
View File
@@ -347,6 +347,7 @@ pub fn run() {
update_profile,
get_user_profile,
get_users_batch,
get_user_notes,
search_users,
get_presence,
set_presence,
+30
View File
@@ -45,6 +45,26 @@ pub struct SearchUsersResponse {
pub users: Vec<UserSearchResultInfo>,
}
#[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<UserNoteInfo>,
pub next_cursor: Option<UserNotesCursor>,
}
#[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<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub before: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub before_id: Option<&'a str>,
}
#[derive(Serialize, Deserialize)]
pub struct ThreadSummary {
pub reply_count: u32,
+21
View File
@@ -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<UserNotesResponse>({
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?: {
@@ -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 (
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent align="start" className="w-72" side="top" sideOffset={8}>
<PopoverContent align="start" className="w-80" side="top" sideOffset={8}>
<div className="flex flex-col gap-3">
<div className="flex items-start gap-3">
{profile?.avatarUrl ? (
@@ -76,6 +85,50 @@ export function UserProfilePopover({
<p className="truncate font-mono text-[10px] text-muted-foreground/60">
{truncatePubkey(pubkey)}
</p>
{notesQuery.isLoading ? (
<div
className="rounded-lg border border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground"
data-testid="user-profile-notes-loading"
>
Loading recent notes
</div>
) : null}
{!notesQuery.isLoading && notes.length > 0 ? (
<div
className="border-t border-border/60 pt-3"
data-testid="user-profile-notes"
>
<p className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
Recent Notes
</p>
<div className="space-y-2">
{notes.map((note) => (
<article
className="rounded-lg border border-border/60 bg-muted/20 px-3 py-2"
data-testid="user-profile-note"
key={note.id}
>
<p className="mb-1 text-[10px] uppercase tracking-wide text-muted-foreground/80">
{formatRelativeTime(note.createdAt)}
</p>
<Markdown
className="max-w-none text-xs text-foreground"
content={note.content}
tight
/>
</article>
))}
</div>
</div>
) : null}
{notesQuery.isError ? (
<p className="text-xs text-muted-foreground">
Recent notes are unavailable right now.
</p>
) : null}
</div>
</PopoverContent>
</Popover>
+55
View File
@@ -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<UserNotesResponse> {
const response = await invokeTauri<RawUserNotesResponse>("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,
};
}
+16
View File
@@ -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;
};
+5 -12
View File
@@ -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;
+107
View File
@@ -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<RawUserNotesResponse> {
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<typeof handleGetUsersBatch>[0],
activeConfig,
);
case "get_user_notes":
return handleGetUserNotes(
payload as Parameters<typeof handleGetUserNotes>[0],
activeConfig,
);
case "search_users":
return handleSearchUsers(
payload as Parameters<typeof handleSearchUsers>[0],
+3
View File
@@ -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 }) => {