mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
[codex] Show recent notes in user popovers (#270)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -347,6 +347,7 @@ pub fn run() {
|
||||
update_profile,
|
||||
get_user_profile,
|
||||
get_users_batch,
|
||||
get_user_notes,
|
||||
search_users,
|
||||
get_presence,
|
||||
set_presence,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user