feat(desktop): add Pulse social notes surface (#296)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wes
2026-04-10 17:09:38 -07:00
committed by GitHub
parent 3ef19f2554
commit 1211b0edc6
35 changed files with 1610 additions and 88 deletions

View File

@@ -38,7 +38,7 @@ const overrides = new Map([
["src/features/messages/hooks.ts", 500], // message query/mutation hooks + optimistic updates
["src/features/messages/ui/MessageComposer.tsx", 700], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape)
["src/features/settings/ui/SettingsView.tsx", 600],
["src/features/sidebar/ui/AppSidebar.tsx", 850], // channels + forums creation forms
["src/features/sidebar/ui/AppSidebar.tsx", 860], // channels + forums creation forms + Pulse nav
["src/features/tokens/ui/TokenSettingsCard.tsx", 800],
["src/shared/api/relayClientSession.ts", 790], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore
["src/shared/api/tauri.ts", 1100], // remote agent provider API bindings + canvas API functions

View File

@@ -11,6 +11,7 @@ mod media;
mod messages;
mod personas;
mod profile;
mod social;
mod teams;
mod tokens;
mod workflows;
@@ -27,6 +28,7 @@ pub use media::*;
pub use messages::*;
pub use personas::*;
pub use profile::*;
pub use social::*;
pub use teams::*;
pub use tokens::*;
pub use workflows::*;

View File

@@ -0,0 +1,108 @@
use nostr::EventId;
use reqwest::Method;
use tauri::State;
use crate::{
app_state::AppState,
events,
models::{ContactEntry, ContactListResponse, UserNotesResponse},
relay::{api_path, build_authed_request, send_json_request, submit_event, SubmitEventResponse},
};
/// Publish a global kind:1 text note (NIP-01).
#[tauri::command]
pub async fn publish_note(
content: String,
reply_to: Option<String>,
state: State<'_, AppState>,
) -> Result<SubmitEventResponse, String> {
let reply_id = reply_to
.map(|hex| EventId::from_hex(&hex).map_err(|e| format!("invalid reply_to event id: {e}")))
.transpose()?;
let builder = events::build_note(&content, reply_id)?;
submit_event(builder, &state).await
}
/// Fetch a user's NIP-02 contact list (kind:3).
#[tauri::command]
pub async fn get_contact_list(
pubkey: String,
state: State<'_, AppState>,
) -> Result<ContactListResponse, String> {
let path = api_path(&["users", &pubkey, "contact-list"]);
let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?;
send_json_request(request).await
}
/// Replace the full contact list (kind:3, NIP-02). Read-before-write required
/// for delta updates — the caller must merge with the existing list.
#[tauri::command]
pub async fn set_contact_list(
contacts: Vec<ContactEntry>,
state: State<'_, AppState>,
) -> Result<SubmitEventResponse, String> {
let tuples: Vec<(&str, Option<&str>, Option<&str>)> = contacts
.iter()
.map(|c| {
(
c.pubkey.as_str(),
c.relay_url.as_deref(),
c.petname.as_deref(),
)
})
.collect();
let builder = events::build_contact_list(&tuples)?;
submit_event(builder, &state).await
}
/// Maximum number of pubkeys per timeline request to prevent unbounded
/// sequential HTTP requests.
const MAX_TIMELINE_PUBKEYS: usize = 100;
/// Fetch notes for multiple pubkeys sequentially and return a merged, sorted timeline.
#[tauri::command]
pub async fn get_notes_timeline(
pubkeys: Vec<String>,
limit_per_user: Option<u32>,
state: State<'_, AppState>,
) -> Result<UserNotesResponse, String> {
if pubkeys.len() > MAX_TIMELINE_PUBKEYS {
return Err(format!(
"too many pubkeys (max {MAX_TIMELINE_PUBKEYS}, got {})",
pubkeys.len()
));
}
let per_user = limit_per_user.unwrap_or(10).min(50);
let mut all_notes = Vec::new();
let mut errors = 0u32;
for pk in &pubkeys {
let path = api_path(&["users", pk, "notes"]);
let req = build_authed_request(&state.http_client, Method::GET, &path, &state)
.map(|r| r.query(&[("limit", per_user.to_string())]));
match req {
Ok(r) => match send_json_request::<UserNotesResponse>(r).await {
Ok(resp) => all_notes.extend(resp.notes),
Err(_) => errors += 1,
},
Err(_) => errors += 1,
}
}
// If all requests failed, surface the error instead of returning empty.
if errors > 0 && all_notes.is_empty() && !pubkeys.is_empty() {
return Err("failed to fetch notes from any user".into());
}
// Sort newest-first.
all_notes.sort_by(|a, b| b.created_at.cmp(&a.created_at));
all_notes.truncate(200);
Ok(UserNotesResponse {
notes: all_notes,
next_cursor: None,
})
}

View File

@@ -356,3 +356,48 @@ pub fn build_profile(
let content = serde_json::Value::Object(map).to_string();
Ok(EventBuilder::new(Kind::Custom(0), content))
}
// ── Social notes ────────────────────────────────────────────────────────────
/// Kind 1 — NIP-01 short text note (global, no channel scope).
pub fn build_note(
content: &str,
reply_to_event_id: Option<EventId>,
) -> Result<EventBuilder, String> {
check_content(content)?;
let mut tags = Vec::new();
if let Some(parent) = reply_to_event_id {
tags.push(tag(vec!["e", &parent.to_hex(), "", "reply"])?);
}
Ok(EventBuilder::new(Kind::TextNote, content).tags(tags))
}
/// Maximum contacts per contact list event.
const MAX_CONTACTS: usize = 10_000;
/// Kind 3 — NIP-02 contact list (replaceable, full snapshot).
pub fn build_contact_list(
contacts: &[(&str, Option<&str>, Option<&str>)],
) -> Result<EventBuilder, String> {
if contacts.len() > MAX_CONTACTS {
return Err(format!(
"too many contacts (max {MAX_CONTACTS}, got {})",
contacts.len()
));
}
let mut seen = std::collections::HashSet::new();
let mut tags = Vec::new();
for &(pubkey, relay_url, petname) in contacts {
check_pubkey(pubkey)?;
let lower = pubkey.to_ascii_lowercase();
if seen.insert(lower.clone()) {
tags.push(tag(vec![
"p",
&lower,
relay_url.unwrap_or(""),
petname.unwrap_or(""),
])?);
}
}
Ok(EventBuilder::new(Kind::ContactList, "").tags(tags))
}

View File

@@ -494,6 +494,10 @@ pub fn run() {
trigger_workflow,
grant_approval,
deny_approval,
publish_note,
get_contact_list,
set_contact_list,
get_notes_timeline,
])
.build(tauri::generate_context!())
.expect("error while building tauri application");

View File

@@ -370,3 +370,23 @@ where
fn default_true() -> bool {
true
}
// ── Social / Contact list ───────────────────────────────────────────────────
#[derive(Serialize, Deserialize)]
pub struct ContactListResponse {
pub id: String,
pub pubkey: String,
pub created_at: i64,
pub tags: Vec<Vec<String>>,
pub content: String,
}
#[derive(Serialize, Deserialize)]
pub struct ContactEntry {
pub pubkey: String,
#[serde(default)]
pub relay_url: Option<String>,
#[serde(default)]
pub petname: Option<String>,
}

View File

@@ -302,7 +302,7 @@ mod tests {
// ── Signed-event submission ──────────────────────────────────────────────────
/// Response from `POST /api/events`.
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, serde::Serialize)]
pub struct SubmitEventResponse {
pub event_id: String,
pub accepted: bool,

View File

@@ -42,7 +42,7 @@ import {
SidebarTrigger,
} from "@/shared/ui/sidebar";
type AppView = "home" | "channel" | "agents" | "workflows";
type AppView = "home" | "channel" | "agents" | "workflows" | "pulse";
const DEFAULT_SETTINGS_SECTION: SettingsSection = "profile";
const LazySettingsScreen = React.lazy(async () => {
@@ -93,6 +93,13 @@ function deriveShellRoute(pathname: string): {
};
}
if (pathname === "/pulse") {
return {
selectedChannelId: null,
selectedView: "pulse",
};
}
return {
selectedChannelId: null,
selectedView: "home",
@@ -113,7 +120,7 @@ export function AppShell() {
React.useState<BrowseDialogType>(null);
const location = useLocation();
const queryClient = useQueryClient();
const { goAgents, goChannel, goHome, goWorkflows, openSearchHit } =
const { goAgents, goChannel, goHome, goPulse, goWorkflows, openSearchHit } =
useAppNavigation();
const { canGoBack, canGoForward, goBack, goForward } =
useBackForwardControls();
@@ -471,6 +478,9 @@ export function AppShell() {
onSelectHome={() => {
void goHome();
}}
onSelectPulse={() => {
void goPulse();
}}
onSelectSettings={handleOpenSettings}
onSelectWorkflows={() => {
void goWorkflows();

View File

@@ -68,6 +68,17 @@ export function useAppNavigation() {
[commitNavigation],
);
const goPulse = React.useCallback(
(behavior?: NavigationBehavior) =>
commitNavigation(
{
to: "/pulse",
},
behavior,
),
[commitNavigation],
);
const goWorkflows = React.useCallback(
(behavior?: NavigationBehavior) =>
commitNavigation(
@@ -193,6 +204,7 @@ export function useAppNavigation() {
goChannel,
goForumPost,
goHome,
goPulse,
goWorkflow,
goWorkflows,
openSearchHit,

View File

@@ -6,6 +6,7 @@
import { Route as rootRouteImport } from "./routes/root";
import { Route as workflowsRouteImport } from "./routes/workflows";
import { Route as pulseRouteImport } from "./routes/pulse";
import { Route as agentsRouteImport } from "./routes/agents";
import { Route as indexRouteImport } from "./routes/index";
import { Route as workflowsDotworkflowIdRouteImport } from "./routes/workflows.$workflowId";
@@ -17,6 +18,11 @@ const workflowsRoute = workflowsRouteImport.update({
path: "/workflows",
getParentRoute: () => rootRouteImport,
} as any);
const pulseRoute = pulseRouteImport.update({
id: "/pulse",
path: "/pulse",
getParentRoute: () => rootRouteImport,
} as any);
const agentsRoute = agentsRouteImport.update({
id: "/agents",
path: "/agents",
@@ -47,6 +53,7 @@ const channelsDotchannelIdDotpostsDotpostIdRoute =
export interface FileRoutesByFullPath {
"/": typeof indexRoute;
"/agents": typeof agentsRoute;
"/pulse": typeof pulseRoute;
"/workflows": typeof workflowsRoute;
"/channels/$channelId": typeof channelsDotchannelIdRoute;
"/workflows/$workflowId": typeof workflowsDotworkflowIdRoute;
@@ -55,6 +62,7 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo {
"/": typeof indexRoute;
"/agents": typeof agentsRoute;
"/pulse": typeof pulseRoute;
"/workflows": typeof workflowsRoute;
"/channels/$channelId": typeof channelsDotchannelIdRoute;
"/workflows/$workflowId": typeof workflowsDotworkflowIdRoute;
@@ -64,6 +72,7 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport;
"/": typeof indexRoute;
"/agents": typeof agentsRoute;
"/pulse": typeof pulseRoute;
"/workflows": typeof workflowsRoute;
"/channels/$channelId": typeof channelsDotchannelIdRoute;
"/workflows/$workflowId": typeof workflowsDotworkflowIdRoute;
@@ -74,6 +83,7 @@ export interface FileRouteTypes {
fullPaths:
| "/"
| "/agents"
| "/pulse"
| "/workflows"
| "/channels/$channelId"
| "/workflows/$workflowId"
@@ -82,6 +92,7 @@ export interface FileRouteTypes {
to:
| "/"
| "/agents"
| "/pulse"
| "/workflows"
| "/channels/$channelId"
| "/workflows/$workflowId"
@@ -90,6 +101,7 @@ export interface FileRouteTypes {
| "__root__"
| "/"
| "/agents"
| "/pulse"
| "/workflows"
| "/channels/$channelId"
| "/workflows/$workflowId"
@@ -99,6 +111,7 @@ export interface FileRouteTypes {
export interface RootRouteChildren {
indexRoute: typeof indexRoute;
agentsRoute: typeof agentsRoute;
pulseRoute: typeof pulseRoute;
workflowsRoute: typeof workflowsRoute;
channelsDotchannelIdRoute: typeof channelsDotchannelIdRoute;
workflowsDotworkflowIdRoute: typeof workflowsDotworkflowIdRoute;
@@ -114,6 +127,13 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof workflowsRouteImport;
parentRoute: typeof rootRouteImport;
};
"/pulse": {
id: "/pulse";
path: "/pulse";
fullPath: "/pulse";
preLoaderRoute: typeof pulseRouteImport;
parentRoute: typeof rootRouteImport;
};
"/agents": {
id: "/agents";
path: "/agents";
@@ -155,6 +175,7 @@ declare module "@tanstack/react-router" {
const rootRouteChildren: RootRouteChildren = {
indexRoute: indexRoute,
agentsRoute: agentsRoute,
pulseRoute: pulseRoute,
workflowsRoute: workflowsRoute,
channelsDotchannelIdRoute: channelsDotchannelIdRoute,
workflowsDotworkflowIdRoute: workflowsDotworkflowIdRoute,

View File

@@ -3,6 +3,7 @@ import { index, rootRoute, route } from "@tanstack/virtual-file-routes";
export const routes = rootRoute("root.tsx", [
index("index.tsx"),
route("/agents", "agents.tsx"),
route("/pulse", "pulse.tsx"),
route("/workflows", "workflows.tsx"),
route("/workflows/$workflowId", "workflows.$workflowId.tsx"),
route("/channels/$channelId", "channels.$channelId.tsx"),

View File

@@ -10,7 +10,7 @@ export const Route = createFileRoute("/")({
});
function HomeRouteComponent() {
const { goChannel } = useAppNavigation();
const { goChannel, goPulse } = useAppNavigation();
const channelsQuery = useChannelsQuery();
const identityQuery = useIdentityQuery();
const channels = channelsQuery.data ?? [];
@@ -23,6 +23,9 @@ function HomeRouteComponent() {
onOpenChannel={(channelId) => {
void goChannel(channelId);
}}
onOpenPulse={() => {
void goPulse();
}}
/>
);
}

View File

@@ -0,0 +1,23 @@
import * as React from "react";
import { createFileRoute } from "@tanstack/react-router";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
const PulseScreen = React.lazy(async () => {
const module = await import("@/features/pulse/ui/PulseScreen");
return { default: module.PulseScreen };
});
export const Route = createFileRoute("/pulse")({
component: PulseRouteComponent,
});
function PulseRouteComponent() {
return (
<React.Suspense
fallback={<ViewLoadingFallback includeHeader kind="pulse" />}
>
<PulseScreen />
</React.Suspense>
);
}

View File

@@ -1,4 +1,13 @@
import { Bot, CircleDot, FileText, Hash, Home, Lock, Zap } from "lucide-react";
import {
Activity,
Bot,
CircleDot,
FileText,
Hash,
Home,
Lock,
Zap,
} from "lucide-react";
import type * as React from "react";
import type { ChannelType, ChannelVisibility } from "@/shared/api/types";
@@ -9,7 +18,7 @@ type ChatHeaderProps = {
description: string;
channelType?: ChannelType;
visibility?: ChannelVisibility;
mode?: "home" | "channel" | "agents" | "workflows";
mode?: "home" | "channel" | "agents" | "workflows" | "pulse";
statusBadge?: React.ReactNode;
};
@@ -20,7 +29,7 @@ function ChannelIcon({
}: {
channelType?: ChannelType;
visibility?: ChannelVisibility;
mode?: "home" | "channel" | "agents" | "workflows";
mode?: "home" | "channel" | "agents" | "workflows" | "pulse";
}) {
if (mode === "home") {
return <Home className="h-5 w-5 text-primary" />;
@@ -34,6 +43,10 @@ function ChannelIcon({
return <Zap className="h-5 w-5 text-primary" />;
}
if (mode === "pulse") {
return <Activity className="h-5 w-5 text-primary" />;
}
if (channelType === "dm") {
return <CircleDot className="h-5 w-5 text-primary" />;
}

View File

@@ -1,11 +1,11 @@
import { MessageSquare, MoreHorizontal, Trash2 } from "lucide-react";
import * as React from "react";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import {
resolveUserLabel,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import type { ForumPost } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
@@ -77,12 +77,7 @@ export function ForumPostCard({
type="button"
>
<div className="flex items-center gap-2">
<ProfileAvatar
avatarUrl={avatarUrl}
className="h-6 w-6 rounded-full text-[10px]"
iconClassName="h-3 w-3"
label={authorLabel}
/>
<UserAvatar avatarUrl={avatarUrl} displayName={authorLabel} size="sm" />
<span className="text-sm font-medium text-foreground">
{authorLabel}
</span>

View File

@@ -1,11 +1,11 @@
import { ArrowLeft, MessageSquare, MoreHorizontal, Trash2 } from "lucide-react";
import * as React from "react";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import {
resolveUserLabel,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import type { ForumThreadResponse, ThreadReply } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
@@ -123,11 +123,10 @@ function ReplyRow({
return (
<div className="group px-4 py-3" data-forum-event-id={reply.eventId}>
<div className="flex items-center gap-2">
<ProfileAvatar
<UserAvatar
avatarUrl={replyAvatarUrl}
className="h-6 w-6 rounded-full text-[10px]"
iconClassName="h-3 w-3"
label={replyAuthorLabel}
displayName={replyAuthorLabel}
size="sm"
/>
<span className="text-sm font-medium text-foreground">
{replyAuthorLabel}
@@ -281,11 +280,9 @@ export function ForumThreadPanel({
data-forum-event-id={post.eventId}
>
<div className="flex items-center gap-2">
<ProfileAvatar
<UserAvatar
avatarUrl={postAvatarUrl}
className="h-8 w-8 rounded-full text-xs"
iconClassName="h-4 w-4"
label={postAuthorLabel}
displayName={postAuthorLabel}
/>
<div>
<span className="text-sm font-semibold text-foreground">

View File

@@ -6,12 +6,14 @@ type HomeScreenProps = {
availableChannelIds: ReadonlySet<string>;
currentPubkey?: string;
onOpenChannel: (channelId: string) => void;
onOpenPulse: () => void;
};
export function HomeScreen({
availableChannelIds,
currentPubkey,
onOpenChannel,
onOpenPulse,
}: HomeScreenProps) {
const homeFeedQuery = useHomeFeedQuery();
@@ -35,6 +37,7 @@ export function HomeScreen({
feed={homeFeedQuery.data}
isLoading={homeFeedQuery.isLoading}
onOpenChannel={onOpenChannel}
onOpenPulse={onOpenPulse}
onRefresh={() => {
void homeFeedQuery.refetch();
}}

View File

@@ -1,8 +1,10 @@
import * as React from "react";
import { AtSign, CircleAlert, RefreshCcw } from "lucide-react";
import { useRelayAgentsQuery } from "@/features/agents/hooks";
import { useFeedItemState } from "@/features/home/useFeedItemState";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { useContactListQuery, useTimelineQuery } from "@/features/pulse/hooks";
import type { HomeFeedResponse } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { Skeleton } from "@/shared/ui/skeleton";
@@ -12,6 +14,11 @@ const FeedSection = React.lazy(async () => {
return { default: module.FeedSection };
});
const RecentNotesSection = React.lazy(async () => {
const module = await import("./RecentNotesSection");
return { default: module.RecentNotesSection };
});
type FeedFilter = "all" | "mention" | "needs_action";
function HomeLoadingState() {
@@ -48,6 +55,7 @@ type HomeViewProps = {
currentPubkey?: string;
availableChannelIds: ReadonlySet<string>;
onOpenChannel: (channelId: string) => void;
onOpenPulse: () => void;
onRefresh: () => void;
};
@@ -58,11 +66,44 @@ export function HomeView({
currentPubkey,
availableChannelIds,
onOpenChannel,
onOpenPulse,
onRefresh,
}: HomeViewProps) {
const [filter, setFilter] = React.useState<FeedFilter>("all");
const { doneSet, markDone, undoDone } = useFeedItemState(currentPubkey);
// Recent notes for the Pulse widget
const contactListQuery = useContactListQuery(currentPubkey);
const contactPubkeys = React.useMemo(
() => (contactListQuery.data?.contacts ?? []).map((c) => c.pubkey),
[contactListQuery.data],
);
const notesPubkeys = React.useMemo(
() =>
currentPubkey
? [...new Set([currentPubkey, ...contactPubkeys])]
: contactPubkeys,
[currentPubkey, contactPubkeys],
);
const notesTimelineQuery = useTimelineQuery(
notesPubkeys,
notesPubkeys.length > 0,
);
const recentNotes = notesTimelineQuery.data?.notes?.slice(0, 5) ?? [];
const noteAuthorPubkeys = React.useMemo(
() => [...new Set(recentNotes.map((n) => n.pubkey))],
[recentNotes],
);
const noteProfilesQuery = useUsersBatchQuery(noteAuthorPubkeys, {
enabled: noteAuthorPubkeys.length > 0,
});
const noteProfiles = noteProfilesQuery.data?.profiles ?? {};
const relayAgentsQuery = useRelayAgentsQuery();
const agentPubkeySet = React.useMemo(
() => new Set((relayAgentsQuery.data ?? []).map((a) => a.pubkey)),
[relayAgentsQuery.data],
);
const feedItems = feed
? [...feed.feed.mentions, ...feed.feed.needsAction]
: [];
@@ -121,6 +162,17 @@ export function HomeView({
))}
</div>
{recentNotes.length > 0 ? (
<React.Suspense fallback={null}>
<RecentNotesSection
agentPubkeys={agentPubkeySet}
notes={recentNotes}
onOpenPulse={onOpenPulse}
profiles={noteProfiles}
/>
</React.Suspense>
) : null}
<React.Suspense fallback={null}>
<div className={`grid gap-5 ${singleColumn ? "" : "xl:grid-cols-2"}`}>
{showMentions ? (

View File

@@ -0,0 +1,97 @@
import { Activity, Bot } from "lucide-react";
import type { UserNote } from "@/shared/api/socialTypes";
import type { UserProfileSummary } from "@/shared/api/types";
import { Markdown } from "@/shared/ui/markdown";
import { UserAvatar } from "@/shared/ui/UserAvatar";
type RecentNotesSectionProps = {
notes: UserNote[];
profiles: Record<string, UserProfileSummary>;
agentPubkeys: ReadonlySet<string>;
onOpenPulse: () => void;
};
function formatRelativeTime(unixSeconds: number): string {
const now = Date.now() / 1_000;
const diff = now - unixSeconds;
if (diff < 60) return "just now";
if (diff < 3_600) return `${Math.floor(diff / 60)}m`;
if (diff < 86_400) return `${Math.floor(diff / 3_600)}h`;
return `${Math.floor(diff / 86_400)}d`;
}
export function RecentNotesSection({
notes,
profiles,
agentPubkeys,
onOpenPulse,
}: RecentNotesSectionProps) {
if (notes.length === 0) return null;
return (
<div>
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-semibold text-foreground">
Recent Notes
</h3>
</div>
<button
className="text-xs text-primary hover:underline"
onClick={onOpenPulse}
type="button"
>
View all in Pulse
</button>
</div>
<div className="space-y-0 overflow-hidden rounded-md border border-border/60">
{notes.slice(0, 5).map((note) => {
const profile = profiles[note.pubkey.toLowerCase()];
const displayName =
profile?.displayName ?? `${note.pubkey.slice(0, 8)}...`;
const isAgent = agentPubkeys.has(note.pubkey);
return (
<div
className="flex items-start gap-2.5 border-b border-border/40 px-3 py-2.5 last:border-b-0"
key={note.id}
>
<div className="relative shrink-0 pt-0.5">
<UserAvatar
avatarUrl={profile?.avatarUrl ?? null}
displayName={displayName}
size="sm"
/>
{isAgent ? (
<Bot className="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full bg-background p-0.5 text-muted-foreground" />
) : null}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-xs font-semibold">
{displayName}
</span>
{isAgent ? (
<span className="inline-flex h-3.5 items-center rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">
bot
</span>
) : null}
<span className="shrink-0 text-[10px] text-muted-foreground">
{formatRelativeTime(note.createdAt)}
</span>
</div>
<div className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
<Markdown compact content={note.content} />
</div>
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -6,7 +6,7 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { KIND_STREAM_MESSAGE_DIFF } from "@/shared/constants/kinds";
import { cn } from "@/shared/lib/cn";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
import { parseImetaTags } from "@/features/messages/lib/parseImeta";
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
@@ -47,7 +47,6 @@ export const MessageRow = React.memo(
onReply?: (message: TimelineMessage) => void;
profiles?: UserProfileLookup;
}) {
const [hasAvatarError, setHasAvatarError] = React.useState(false);
const [expandedDiffId, setExpandedDiffId] = React.useState<string | null>(
null,
);
@@ -73,13 +72,6 @@ export const MessageRow = React.memo(
const visibleDepth = Math.min(message.depth, 6);
const indentPx = visibleDepth * 28;
const initials = message.author
.split(" ")
.map((part) => part[0])
.join("")
.slice(0, 2)
.toUpperCase();
const getTag = (name: string) =>
message.tags?.find((tag) => tag[0] === name)?.[1];
@@ -198,55 +190,22 @@ export const MessageRow = React.memo(
className="shrink-0 rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
type="button"
>
{message.avatarUrl && !hasAvatarError ? (
<img
alt={`${message.author} avatar`}
className="h-8 w-8 rounded-lg bg-secondary object-cover shadow-sm"
data-testid="message-avatar-image"
onError={() => {
setHasAvatarError(true);
}}
referrerPolicy="no-referrer"
src={rewriteRelayUrl(message.avatarUrl)}
/>
) : (
<div
className={cn(
"flex h-8 w-8 items-center justify-center rounded-lg text-[11px] font-semibold shadow-sm",
message.accent
? "bg-primary text-primary-foreground"
: "bg-secondary text-secondary-foreground",
)}
data-testid="message-avatar-fallback"
>
{initials}
</div>
)}
<UserAvatar
accent={message.accent}
avatarUrl={message.avatarUrl ?? null}
displayName={message.author}
testId="message-avatar"
/>
</button>
</UserProfilePopover>
) : message.avatarUrl && !hasAvatarError ? (
<img
alt={`${message.author} avatar`}
className="h-8 w-8 shrink-0 rounded-lg bg-secondary object-cover shadow-sm"
data-testid="message-avatar-image"
onError={() => {
setHasAvatarError(true);
}}
referrerPolicy="no-referrer"
src={rewriteRelayUrl(message.avatarUrl)}
/>
) : (
<div
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-[11px] font-semibold shadow-sm",
message.accent
? "bg-primary text-primary-foreground"
: "bg-secondary text-secondary-foreground",
)}
data-testid="message-avatar-fallback"
>
{initials}
</div>
<UserAvatar
accent={message.accent}
avatarUrl={message.avatarUrl ?? null}
className="shrink-0"
displayName={message.author}
testId="message-avatar"
/>
)}
</div>

View File

@@ -29,9 +29,10 @@ export function UserProfilePopover({
pubkey,
}: UserProfilePopoverProps) {
const [open, setOpen] = React.useState(false);
const [showAllNotes, setShowAllNotes] = React.useState(false);
const profileQuery = useUserProfileQuery(open ? pubkey : undefined);
const notesQuery = useUserNotesQuery(open ? pubkey : undefined, {
limit: 3,
limit: showAllNotes ? 20 : 3,
});
const presenceQuery = usePresenceQuery(open ? [pubkey] : [], {
enabled: open,
@@ -100,10 +101,23 @@ export function UserProfilePopover({
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">
<div className="mb-2 flex items-center justify-between">
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
Recent Notes
</p>
{notes.length >= 3 ? (
<button
className="text-[11px] text-primary hover:underline"
onClick={() => setShowAllNotes(!showAllNotes)}
type="button"
>
{showAllNotes ? "Show less" : "View all"}
</button>
) : null}
</div>
<div
className={`space-y-2 ${showAllNotes ? "max-h-64 overflow-y-auto" : ""}`}
>
{notes.map((note) => (
<article
className="rounded-lg border border-border/60 bg-muted/20 px-3 py-2"

View File

@@ -0,0 +1,144 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
getContactList,
getNotesTimeline,
getUserNotes,
publishNote,
setContactList,
} from "@/shared/api/social";
import type {
ContactListResponse,
UserNotesResponse,
} from "@/shared/api/socialTypes";
// ── Query keys ──────────────────────────────────────────────────────────────
export const pulseQueryKeys = {
contactList: (pubkey: string) => ["contact-list", pubkey] as const,
myNotes: (pubkey: string) => ["my-notes", pubkey] as const,
// Use a stable sorted string key to avoid reference-equality refetch churn.
timeline: (pubkeys: string[]) =>
["pulse-timeline", [...pubkeys].sort().join(",")] as const,
allTimelines: ["pulse-timeline"] as const,
};
// ── Contact list ────────────────────────────────────────────────────────────
export function useContactListQuery(pubkey?: string) {
return useQuery<ContactListResponse>({
queryKey: pulseQueryKeys.contactList(pubkey ?? ""),
// biome-ignore lint/style/noNonNullAssertion: guarded by enabled: !!pubkey
queryFn: () => getContactList(pubkey!),
enabled: !!pubkey,
staleTime: 60_000,
gcTime: 5 * 60_000,
});
}
// ── Own notes ───────────────────────────────────────────────────────────────
export function useMyNotesQuery(pubkey?: string) {
return useQuery<UserNotesResponse>({
queryKey: pulseQueryKeys.myNotes(pubkey ?? ""),
// biome-ignore lint/style/noNonNullAssertion: guarded by enabled: !!pubkey
queryFn: () => getUserNotes(pubkey!, { limit: 50 }),
enabled: !!pubkey,
staleTime: 15_000,
gcTime: 5 * 60_000,
refetchInterval: 30_000,
});
}
// ── Timeline (notes from contacts) ─────────────────────────────────────────
export function useTimelineQuery(contactPubkeys: string[], enabled: boolean) {
return useQuery<UserNotesResponse>({
queryKey: pulseQueryKeys.timeline(contactPubkeys),
queryFn: () => getNotesTimeline(contactPubkeys, 10),
enabled: enabled && contactPubkeys.length > 0,
staleTime: 15_000,
gcTime: 5 * 60_000,
refetchInterval: 30_000,
});
}
// ── Publish note mutation ───────────────────────────────────────────────────
export function usePublishNoteMutation(currentPubkey?: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ content, replyTo }: { content: string; replyTo?: string }) =>
publishNote(content, replyTo),
onSuccess: () => {
if (currentPubkey) {
void queryClient.invalidateQueries({
queryKey: pulseQueryKeys.myNotes(currentPubkey),
});
}
// Also invalidate timeline queries so the new note appears immediately.
void queryClient.invalidateQueries({
queryKey: pulseQueryKeys.allTimelines,
});
},
});
}
// ── Follow / unfollow mutations ─────────────────────────────────────────────
/**
* Follow mutation re-fetches the contact list inside the mutationFn to prevent
* race conditions when clicking Follow on multiple users quickly. The kind:3
* contact list is a full-snapshot replaceable event — stale reads cause data loss.
*/
export function useFollowMutation(currentPubkey?: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (targetPubkey: 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)) {
return; // already following
}
const updated = [...current.contacts, { pubkey: targetPubkey }];
return setContactList(updated);
},
onSuccess: () => {
if (currentPubkey) {
void queryClient.invalidateQueries({
queryKey: pulseQueryKeys.contactList(currentPubkey),
});
void queryClient.invalidateQueries({
queryKey: pulseQueryKeys.allTimelines,
});
}
},
});
}
export function useUnfollowMutation(currentPubkey?: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (targetPubkey: 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);
return setContactList(updated);
},
onSuccess: () => {
if (currentPubkey) {
void queryClient.invalidateQueries({
queryKey: pulseQueryKeys.contactList(currentPubkey),
});
void queryClient.invalidateQueries({
queryKey: pulseQueryKeys.allTimelines,
});
}
},
});
}

View File

@@ -0,0 +1,55 @@
import type { UserNote } from "@/shared/api/socialTypes";
/** A group of consecutive notes from the same agent within a time window. */
export type AgentNoteGroup = {
/** Agent pubkey. */
pubkey: string;
/** All notes in this group, newest first. */
notes: UserNote[];
/** Timestamp of the most recent note in the group. */
latestAt: number;
/** Timestamp of the oldest note in the group. */
earliestAt: number;
};
/**
* Groups notes by agent pubkey + time proximity.
* Consecutive notes from the same agent are collapsed into a single group
* as long as the gap between adjacent notes is within `windowSeconds`.
* Input notes must be sorted newest-first.
*/
export function groupAgentNotes(
notes: UserNote[],
windowSeconds = 300,
): AgentNoteGroup[] {
if (notes.length === 0) return [];
const groups: AgentNoteGroup[] = [];
let current: AgentNoteGroup | null = null;
for (const note of notes) {
// Compare against the previous (most recently added) note in the group,
// not the group's earliest — prevents the window from growing unboundedly.
const lastNoteInGroup = current?.notes[current.notes.length - 1];
if (
current &&
lastNoteInGroup &&
current.pubkey === note.pubkey &&
lastNoteInGroup.createdAt - note.createdAt <= windowSeconds
) {
current.notes.push(note);
current.earliestAt = note.createdAt;
} else {
if (current) groups.push(current);
current = {
pubkey: note.pubkey,
notes: [note],
latestAt: note.createdAt,
earliestAt: note.createdAt,
};
}
}
if (current) groups.push(current);
return groups;
}

View File

@@ -0,0 +1,115 @@
import { Bot, ChevronDown, ChevronRight } from "lucide-react";
import * as React from "react";
import type { AgentNoteGroup } from "@/features/pulse/lib/groupAgentNotes";
import type { UserProfileSummary } from "@/shared/api/types";
import { Markdown } from "@/shared/ui/markdown";
import { UserAvatar } from "@/shared/ui/UserAvatar";
type AgentActivityCardProps = {
group: AgentNoteGroup;
profile?: UserProfileSummary | null;
agentStatus?: "online" | "away" | "offline";
};
function formatRelativeTime(unixSeconds: number): string {
const now = Date.now() / 1_000;
const diff = now - unixSeconds;
if (diff < 60) return "just now";
if (diff < 3_600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86_400) return `${Math.floor(diff / 3_600)}h ago`;
if (diff < 604_800) return `${Math.floor(diff / 86_400)}d ago`;
return new Date(unixSeconds * 1_000).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
}
function StatusDot({ status }: { status: "online" | "away" | "offline" }) {
const color =
status === "online"
? "bg-emerald-500"
: status === "away"
? "bg-amber-500"
: "bg-zinc-400";
return <span className={`inline-block h-2 w-2 rounded-full ${color}`} />;
}
export function AgentActivityCard({
group,
profile,
agentStatus,
}: AgentActivityCardProps) {
const [expanded, setExpanded] = React.useState(false);
const displayName = profile?.displayName ?? `${group.pubkey.slice(0, 8)}...`;
const avatarUrl = profile?.avatarUrl ?? null;
const isSingleNote = group.notes.length === 1;
// Show the latest note content as the summary.
const summaryNote = group.notes[0];
return (
<div className="border-b border-border/50 px-4 py-3 sm:px-6">
{/* Header */}
<div className="flex items-center gap-2.5">
<div className="relative shrink-0">
<UserAvatar avatarUrl={avatarUrl} displayName={displayName} />
<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" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold">
{displayName}
</span>
{agentStatus ? <StatusDot status={agentStatus} /> : null}
<span className="shrink-0 text-xs text-muted-foreground">
{formatRelativeTime(group.latestAt)}
</span>
</div>
</div>
{!isSingleNote ? (
<button
className="flex h-6 items-center gap-1 rounded px-1.5 text-xs text-muted-foreground hover:bg-muted/50 hover:text-foreground"
onClick={() => setExpanded(!expanded)}
type="button"
>
{expanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
{group.notes.length} updates
</button>
) : null}
</div>
{/* Content */}
{isSingleNote || !expanded ? (
<div className="mt-2 ml-[42px] rounded-lg border border-border/50 bg-muted/20 px-3 py-2 text-sm">
<Markdown content={summaryNote.content} />
</div>
) : (
<div className="mt-2 ml-[42px] space-y-1.5">
{group.notes.map((note, idx) => (
<div
className="flex gap-2 rounded-lg border border-border/50 bg-muted/20 px-3 py-2"
key={note.id}
>
<span className="mt-0.5 shrink-0 text-xs font-medium text-muted-foreground">
{idx + 1}.
</span>
<div className="min-w-0 flex-1 text-sm">
<Markdown content={note.content} />
<p className="mt-1 text-[10px] text-muted-foreground">
{formatRelativeTime(note.createdAt)}
</p>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,78 @@
import { Send } from "lucide-react";
import * as React from "react";
import { Button } from "@/shared/ui/button";
type ComposeNoteProps = {
isPending: boolean;
errorMessage?: string;
onPublish: (content: string) => Promise<void>;
};
export function ComposeNote({
isPending,
errorMessage,
onPublish,
}: ComposeNoteProps) {
const [draft, setDraft] = React.useState("");
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const canSubmit = draft.trim().length > 0 && !isPending;
async function handleSubmit() {
if (!canSubmit) return;
try {
await onPublish(draft.trim());
setDraft("");
textareaRef.current?.focus();
} catch {
// Draft preserved — error shown via errorMessage prop.
}
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && canSubmit) {
e.preventDefault();
void handleSubmit();
}
}
return (
<div className="border-t border-border/60 px-4 py-3 sm:px-6">
<div className="flex gap-3">
<div className="min-w-0 flex-1">
<textarea
ref={textareaRef}
aria-label="Compose a note"
className="w-full resize-none rounded-lg border border-border/60 bg-muted/30 px-3 py-2 text-sm placeholder:text-muted-foreground/60 focus:border-primary/50 focus:outline-none focus:ring-1 focus:ring-primary/30"
disabled={isPending}
maxLength={65536}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Post to Pulse..."
rows={2}
value={draft}
/>
<div className="mt-1.5 flex items-center justify-between">
<div className="flex items-center gap-2">
{errorMessage ? (
<span aria-live="polite" className="text-xs text-destructive">
{errorMessage}
</span>
) : null}
</div>
<Button
className="gap-1.5"
disabled={!canSubmit}
onClick={() => void handleSubmit()}
size="sm"
>
<Send className="h-3.5 w-3.5" />
Post
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,104 @@
import { Bot, UserMinus, UserPlus } from "lucide-react";
import type { UserNote } from "@/shared/api/socialTypes";
import type { UserProfileSummary } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { Markdown } from "@/shared/ui/markdown";
import { UserAvatar } from "@/shared/ui/UserAvatar";
type NoteCardProps = {
note: UserNote;
profile?: UserProfileSummary | null;
isAgent?: boolean;
isOwnNote: boolean;
isFollowing: boolean;
onFollow?: (pubkey: string) => void;
onUnfollow?: (pubkey: string) => void;
};
function formatRelativeTime(unixSeconds: number): string {
const now = Date.now() / 1_000;
const diff = now - unixSeconds;
if (diff < 60) return "just now";
if (diff < 3_600) return `${Math.floor(diff / 60)}m`;
if (diff < 86_400) return `${Math.floor(diff / 3_600)}h`;
if (diff < 604_800) return `${Math.floor(diff / 86_400)}d`;
return new Date(unixSeconds * 1_000).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
}
export function NoteCard({
note,
profile,
isAgent,
isOwnNote,
isFollowing,
onFollow,
onUnfollow,
}: NoteCardProps) {
const displayName = profile?.displayName ?? `${note.pubkey.slice(0, 8)}...`;
const avatarUrl = profile?.avatarUrl ?? null;
return (
<article className="group flex gap-3 border-b border-border/50 px-4 py-3 transition-colors hover:bg-muted/30 sm:px-6">
<div className="relative shrink-0 pt-0.5">
<UserAvatar avatarUrl={avatarUrl} 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>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold">{displayName}</span>
{isAgent ? (
<span className="inline-flex h-4 items-center rounded bg-muted px-1 text-[10px] font-medium text-muted-foreground">
bot
</span>
) : null}
{profile?.nip05Handle ? (
<span className="truncate text-xs text-muted-foreground">
{profile.nip05Handle}
</span>
) : null}
<span className="shrink-0 text-xs text-muted-foreground">
{formatRelativeTime(note.createdAt)}
</span>
{!isOwnNote ? (
<div className="ml-auto hidden shrink-0 group-hover:block">
{isFollowing ? (
<Button
className="h-6 gap-1 px-2 text-xs"
onClick={() => onUnfollow?.(note.pubkey)}
size="sm"
variant="ghost"
>
<UserMinus className="h-3 w-3" />
Unfollow
</Button>
) : (
<Button
className="h-6 gap-1 px-2 text-xs"
onClick={() => onFollow?.(note.pubkey)}
size="sm"
variant="ghost"
>
<UserPlus className="h-3 w-3" />
Follow
</Button>
)}
</div>
) : null}
</div>
<div className="mt-1 text-sm leading-relaxed">
<Markdown content={note.content} />
</div>
</div>
</article>
);
}

View File

@@ -0,0 +1,18 @@
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
import { PulseView } from "@/features/pulse/ui/PulseView";
import { useIdentityQuery } from "@/shared/api/hooks";
export function PulseScreen() {
const identityQuery = useIdentityQuery();
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<ChatHeader
description="Notes from people and agents you follow"
mode="pulse"
title="Pulse"
/>
<PulseView currentPubkey={identityQuery.data?.pubkey} />
</div>
);
}

View File

@@ -0,0 +1,412 @@
import { Filter, RefreshCw } from "lucide-react";
import * as React from "react";
import { useRelayAgentsQuery } from "@/features/agents/hooks";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import {
useContactListQuery,
useFollowMutation,
useMyNotesQuery,
usePublishNoteMutation,
useTimelineQuery,
useUnfollowMutation,
} from "@/features/pulse/hooks";
import { groupAgentNotes } from "@/features/pulse/lib/groupAgentNotes";
import { AgentActivityCard } from "@/features/pulse/ui/AgentActivityCard";
import { ComposeNote } from "@/features/pulse/ui/ComposeNote";
import { NoteCard } from "@/features/pulse/ui/NoteCard";
import type { UserNote } from "@/shared/api/socialTypes";
import type { RelayAgent, UserProfileSummary } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { Skeleton } from "@/shared/ui/skeleton";
type PulseTab = "foryou" | "people" | "agents" | "mine";
type PulseViewProps = {
currentPubkey?: string;
};
function EmptyState({ message }: { message: string }) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-4 py-12 text-center">
<p className="text-sm text-muted-foreground">{message}</p>
</div>
);
}
function TimelineSkeleton() {
return (
<div className="space-y-1">
{[1, 2, 3, 4].map((i) => (
<div className="flex gap-3 px-4 py-3 sm:px-6" key={i}>
<Skeleton className="h-8 w-8 shrink-0 rounded-full" />
<div className="min-w-0 flex-1 space-y-2">
<Skeleton className="h-3.5 w-32" />
<Skeleton className="h-4 w-full max-w-md" />
<Skeleton className="h-4 w-3/4 max-w-sm" />
</div>
</div>
))}
</div>
);
}
// ── Agent filter dropdown ──────────────────────────────────────────────────
function AgentFilter({
agents,
profiles,
selectedPubkey,
onSelect,
}: {
agents: RelayAgent[];
profiles: Record<string, UserProfileSummary>;
selectedPubkey: string | null;
onSelect: (pubkey: string | null) => void;
}) {
const [open, setOpen] = React.useState(false);
const ref = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!open) return;
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [open]);
const selectedName = selectedPubkey
? (profiles[selectedPubkey.toLowerCase()]?.displayName ??
agents.find((a) => a.pubkey === selectedPubkey)?.name ??
`${selectedPubkey.slice(0, 8)}...`)
: null;
return (
<div className="relative" ref={ref}>
<Button
className="h-7 gap-1.5 px-2 text-xs"
onClick={() => setOpen(!open)}
size="sm"
variant={selectedPubkey ? "secondary" : "ghost"}
>
<Filter className="h-3 w-3" />
{selectedName ?? "All agents"}
</Button>
{open ? (
<div className="absolute right-0 top-8 z-50 max-h-48 w-48 overflow-y-auto rounded-lg border border-border bg-popover p-1 shadow-lg">
<button
className={`w-full rounded-md px-2 py-1.5 text-left text-xs hover:bg-accent ${
!selectedPubkey ? "bg-accent font-medium" : ""
}`}
onClick={() => {
onSelect(null);
setOpen(false);
}}
type="button"
>
All agents
</button>
{agents.map((agent) => {
const name =
profiles[agent.pubkey.toLowerCase()]?.displayName ??
agent.name ??
`${agent.pubkey.slice(0, 8)}...`;
return (
<button
className={`w-full rounded-md px-2 py-1.5 text-left text-xs hover:bg-accent ${
selectedPubkey === agent.pubkey ? "bg-accent font-medium" : ""
}`}
key={agent.pubkey}
onClick={() => {
onSelect(agent.pubkey);
setOpen(false);
}}
type="button"
>
{name}
</button>
);
})}
</div>
) : null}
</div>
);
}
// ── Main PulseView ─────────────────────────────────────────────────────────
export function PulseView({ currentPubkey }: PulseViewProps) {
const [activeTab, setActiveTab] = React.useState<PulseTab>("foryou");
const [agentFilter, setAgentFilter] = React.useState<string | null>(null);
// ── Contact list & follow state ────────────────────────────────────────
const contactListQuery = useContactListQuery(currentPubkey);
const contacts = contactListQuery.data?.contacts ?? [];
const contactPubkeys = React.useMemo(
() => contacts.map((c) => c.pubkey),
[contacts],
);
const followingSet = React.useMemo(
() => new Set(contactPubkeys),
[contactPubkeys],
);
// People-only pubkeys (contacts + self, no agents)
const peoplePubkeys = React.useMemo(
() =>
currentPubkey
? [...new Set([currentPubkey, ...contactPubkeys])]
: contactPubkeys,
[currentPubkey, contactPubkeys],
);
// ── Agents ─────────────────────────────────────────────────────────────
const relayAgentsQuery = useRelayAgentsQuery();
const relayAgents = relayAgentsQuery.data ?? [];
const agentPubkeys = React.useMemo(
() => relayAgents.map((a) => a.pubkey),
[relayAgents],
);
const agentPubkeySet = React.useMemo(
() => new Set(agentPubkeys),
[agentPubkeys],
);
const agentStatusMap = React.useMemo(() => {
const map: Record<string, "online" | "away" | "offline"> = {};
for (const a of relayAgents) {
map[a.pubkey] = a.status;
}
return map;
}, [relayAgents]);
// ── "For You" combined pubkeys (contacts + agents + self) ──────────────
const forYouPubkeys = React.useMemo(
() => [...new Set([...peoplePubkeys, ...agentPubkeys])],
[peoplePubkeys, agentPubkeys],
);
// ── Queries per tab ────────────────────────────────────────────────────
const forYouQuery = useTimelineQuery(forYouPubkeys, activeTab === "foryou");
const peopleQuery = useTimelineQuery(peoplePubkeys, activeTab === "people");
const agentTimelineQuery = useTimelineQuery(
agentFilter ? [agentFilter] : agentPubkeys,
activeTab === "agents",
);
const myNotesQuery = useMyNotesQuery(
activeTab === "mine" ? currentPubkey : undefined,
);
const publishMutation = usePublishNoteMutation(currentPubkey);
const followMutation = useFollowMutation(currentPubkey);
const unfollowMutation = useUnfollowMutation(currentPubkey);
// ── Visible notes per tab ──────────────────────────────────────────────
const visibleNotes: UserNote[] = React.useMemo(() => {
if (activeTab === "foryou") {
return forYouQuery.data?.notes ?? [];
}
if (activeTab === "people") {
// Filter out agent notes from the people timeline.
return (peopleQuery.data?.notes ?? []).filter(
(n) => !agentPubkeySet.has(n.pubkey),
);
}
if (activeTab === "agents") {
return agentTimelineQuery.data?.notes ?? [];
}
return myNotesQuery.data?.notes ?? [];
}, [
activeTab,
forYouQuery.data,
peopleQuery.data,
agentTimelineQuery.data,
myNotesQuery.data,
agentPubkeySet,
]);
// Agent note groups for the agents tab.
const agentNoteGroups = React.useMemo(
() => (activeTab === "agents" ? groupAgentNotes(visibleNotes) : []),
[activeTab, visibleNotes],
);
// ── Profile lookups ────────────────────────────────────────────────────
const notePubkeys = React.useMemo(
() => [...new Set(visibleNotes.map((n) => n.pubkey))],
[visibleNotes],
);
const profilesQuery = useUsersBatchQuery(notePubkeys, {
enabled: notePubkeys.length > 0,
});
const profiles: Record<string, UserProfileSummary> =
profilesQuery.data?.profiles ?? {};
// ── Loading / refresh state ────────────────────────────────────────────
const activeQuery =
activeTab === "foryou"
? forYouQuery
: activeTab === "people"
? peopleQuery
: activeTab === "agents"
? agentTimelineQuery
: myNotesQuery;
const isLoading = activeQuery.isLoading;
const isRefetching = activeQuery.isRefetching;
function handleRefresh() {
void activeQuery.refetch();
}
function handleFollow(pubkey: string) {
followMutation.mutate(pubkey);
}
function handleUnfollow(pubkey: string) {
unfollowMutation.mutate(pubkey);
}
// ── Render ─────────────────────────────────────────────────────────────
const emptyMessages: Record<PulseTab, string> = {
foryou:
"No notes yet. Follow people and agents to build your personalized feed.",
people: "No notes yet. Follow people to see their updates here.",
agents:
agentPubkeys.length === 0
? "No agents registered yet."
: "No agent notes yet. Agents will post updates as they work.",
mine: "You haven't posted any notes yet.",
};
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{/* Tab bar */}
<div className="flex items-center gap-1 border-b border-border/60 px-4 sm:px-6">
<TabButton
active={activeTab === "foryou"}
label="For You"
onClick={() => setActiveTab("foryou")}
/>
<TabButton
active={activeTab === "people"}
label="People"
onClick={() => setActiveTab("people")}
/>
<TabButton
active={activeTab === "agents"}
count={relayAgents.length}
label="Agents"
onClick={() => setActiveTab("agents")}
/>
<TabButton
active={activeTab === "mine"}
label="My Notes"
onClick={() => setActiveTab("mine")}
/>
<div className="ml-auto flex items-center gap-1">
{activeTab === "agents" && relayAgents.length > 1 ? (
<AgentFilter
agents={relayAgents}
onSelect={setAgentFilter}
profiles={profiles}
selectedPubkey={agentFilter}
/>
) : null}
<Button
className="h-7 w-7"
disabled={isRefetching}
onClick={handleRefresh}
size="icon"
variant="ghost"
>
<RefreshCw
className={`h-3.5 w-3.5 ${isRefetching ? "animate-spin" : ""}`}
/>
</Button>
</div>
</div>
{/* Timeline */}
<div className="min-h-0 flex-1 overflow-y-auto">
{isLoading ? (
<TimelineSkeleton />
) : activeTab === "agents" ? (
agentNoteGroups.length === 0 ? (
<EmptyState message={emptyMessages.agents} />
) : (
agentNoteGroups.map((group) => (
<AgentActivityCard
agentStatus={agentStatusMap[group.pubkey]}
group={group}
key={`${group.pubkey}-${group.latestAt}`}
profile={profiles[group.pubkey.toLowerCase()] ?? null}
/>
))
)
) : visibleNotes.length === 0 ? (
<EmptyState message={emptyMessages[activeTab]} />
) : (
visibleNotes.map((note) => (
<NoteCard
isAgent={agentPubkeySet.has(note.pubkey)}
isFollowing={followingSet.has(note.pubkey)}
isOwnNote={note.pubkey === currentPubkey}
key={note.id}
note={note}
onFollow={handleFollow}
onUnfollow={handleUnfollow}
profile={profiles[note.pubkey.toLowerCase()] ?? null}
/>
))
)}
</div>
<ComposeNote
errorMessage={
publishMutation.error instanceof Error
? publishMutation.error.message
: publishMutation.error
? String(publishMutation.error)
: undefined
}
isPending={publishMutation.isPending}
onPublish={async (content) => {
await publishMutation.mutateAsync({ content });
}}
/>
</div>
);
}
function TabButton({
active,
count,
label,
onClick,
}: {
active: boolean;
count?: number;
label: string;
onClick: () => void;
}) {
return (
<button
className={`border-b-2 px-3 py-2.5 text-sm font-medium transition-colors ${
active
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
onClick={onClick}
type="button"
>
{label}
{count != null && count > 0 ? (
<span className="ml-1.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-muted px-1 text-[10px] font-medium text-muted-foreground">
{count}
</span>
) : null}
</button>
);
}

View File

@@ -30,6 +30,8 @@ const MIN_QUERY_LENGTH = 2;
function describeSearchHit(hit: SearchHit) {
switch (hit.kind) {
case 1:
return "Note";
case 45001:
return "Forum post";
case 45003:

View File

@@ -1,4 +1,5 @@
import { Bot, Home, Lock, PenSquare, Plus, Search, Zap } from "lucide-react";
// biome-ignore format: keep compact to stay within file size limit
import { Activity, Bot, Home, Lock, PenSquare, Plus, Search, Zap } from "lucide-react";
import * as React from "react";
/** Default TTL for ephemeral channels: 1 day of inactivity. */
@@ -65,7 +66,7 @@ type AppSidebarProps = {
selfPresenceStatus: PresenceStatus;
errorMessage?: string;
selectedChannelId: string | null;
selectedView: "home" | "channel" | "agents" | "workflows";
selectedView: "home" | "channel" | "agents" | "workflows" | "pulse";
unreadChannelIds: Set<string>;
onCreateChannel: (input: {
name: string;
@@ -85,6 +86,7 @@ type AppSidebarProps = {
onHideDm: (channelId: string) => void;
onOpenDm: (input: { pubkeys: string[] }) => Promise<void>;
onSelectAgents: () => void;
onSelectPulse: () => void;
onSelectWorkflows: () => void;
onSelectHome: () => void;
onSelectChannel: (channelId: string) => void;
@@ -537,6 +539,7 @@ export function AppSidebar({
onHideDm,
onOpenDm,
onSelectAgents,
onSelectPulse,
onSelectWorkflows,
onSelectHome,
onSelectChannel,
@@ -634,6 +637,18 @@ export function AppSidebar({
</SidebarMenuBadge>
) : null}
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
data-testid="open-pulse-view"
isActive={selectedView === "pulse"}
onClick={onSelectPulse}
tooltip="Pulse"
type="button"
>
<Activity className="h-4 w-4" />
<span>Pulse</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
data-testid="open-agents-view"

View File

@@ -1,4 +1,9 @@
import type { UserNote, UserNotesResponse } from "@/shared/api/socialTypes";
import type {
ContactListResponse,
PublishNoteResult,
UserNote,
UserNotesResponse,
} from "@/shared/api/socialTypes";
import { invokeTauri } from "./tauri";
@@ -19,6 +24,14 @@ type RawUserNotesResponse = {
next_cursor: RawUserNotesCursor | null;
};
type RawContactListResponse = {
id: string;
pubkey: string;
created_at: number;
tags: string[][];
content: string;
};
function fromRawUserNote(note: RawUserNote): UserNote {
return {
id: note.id,
@@ -53,3 +66,92 @@ export async function getUserNotes(
: null,
};
}
type RawPublishNoteResult = {
event_id: string;
accepted: boolean;
message: string;
};
export async function publishNote(
content: string,
replyTo?: string,
): Promise<PublishNoteResult> {
const raw = await invokeTauri<RawPublishNoteResult>("publish_note", {
content,
replyTo: replyTo ?? null,
});
return {
eventId: raw.event_id,
accepted: raw.accepted,
message: raw.message,
};
}
export async function getContactList(
pubkey: string,
): Promise<ContactListResponse> {
const raw = await invokeTauri<RawContactListResponse>("get_contact_list", {
pubkey,
});
// Parse p-tags into contact entries.
const contacts = raw.tags
.filter((t) => t[0] === "p" && t[1])
.map((t) => ({
pubkey: t[1],
relayUrl: t[2] || undefined,
petname: t[3] || undefined,
}));
return {
id: raw.id,
pubkey: raw.pubkey,
createdAt: raw.created_at,
contacts,
};
}
export async function setContactList(
contacts: Array<{
pubkey: string;
relayUrl?: string;
petname?: string;
}>,
): Promise<PublishNoteResult> {
const raw = await invokeTauri<RawPublishNoteResult>("set_contact_list", {
contacts: contacts.map((c) => ({
pubkey: c.pubkey,
relay_url: c.relayUrl ?? null,
petname: c.petname ?? null,
})),
});
return {
eventId: raw.event_id,
accepted: raw.accepted,
message: raw.message,
};
}
export async function getNotesTimeline(
pubkeys: string[],
limitPerUser?: number,
): Promise<UserNotesResponse> {
const response = await invokeTauri<RawUserNotesResponse>(
"get_notes_timeline",
{
pubkeys,
limitPerUser: limitPerUser ?? null,
},
);
return {
notes: response.notes.map(fromRawUserNote),
nextCursor: response.next_cursor
? {
before: response.next_cursor.before,
beforeId: response.next_cursor.before_id,
}
: null,
};
}

View File

@@ -14,3 +14,22 @@ export type UserNotesResponse = {
notes: UserNote[];
nextCursor: UserNotesCursor | null;
};
export type PublishNoteResult = {
eventId: string;
accepted: boolean;
message: string;
};
export type ContactEntry = {
pubkey: string;
relayUrl?: string;
petname?: string;
};
export type ContactListResponse = {
id: string;
pubkey: string;
createdAt: number;
contacts: ContactEntry[];
};

View File

@@ -481,6 +481,9 @@ export type {
TriggerWorkflowResponse,
} from "@/shared/api/workflowTypes";
export type {
ContactEntry,
ContactListResponse,
PublishNoteResult,
UserNote,
UserNotesCursor,
UserNotesResponse,

View File

@@ -0,0 +1,70 @@
import * as React from "react";
import { cn } from "@/shared/lib/cn";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
type UserAvatarSize = "xs" | "sm" | "md";
const sizeClasses: Record<UserAvatarSize, string> = {
xs: "h-5 w-5 text-[8px]",
sm: "h-6 w-6 text-[9px]",
md: "h-8 w-8 text-[11px]",
};
type UserAvatarProps = {
avatarUrl: string | null;
displayName: string;
size?: UserAvatarSize;
accent?: boolean;
className?: string;
testId?: string;
};
export function UserAvatar({
avatarUrl,
displayName,
size = "md",
accent = false,
className,
testId,
}: UserAvatarProps) {
const [failedUrl, setFailedUrl] = React.useState<string | null>(null);
const hasError = failedUrl === avatarUrl;
const initials = displayName
.split(" ")
.map((part) => part[0])
.join("")
.slice(0, 2)
.toUpperCase();
const base = cn(sizeClasses[size], "rounded-lg shadow-sm", className);
if (avatarUrl && !hasError) {
return (
<img
alt={`${displayName} avatar`}
className={cn(base, "bg-secondary object-cover")}
data-testid={testId ? `${testId}-image` : undefined}
onError={() => setFailedUrl(avatarUrl)}
referrerPolicy="no-referrer"
src={rewriteRelayUrl(avatarUrl)}
/>
);
}
return (
<div
className={cn(
base,
"flex items-center justify-center font-semibold",
accent
? "bg-primary text-primary-foreground"
: "bg-secondary text-secondary-foreground",
)}
data-testid={testId ? `${testId}-fallback` : undefined}
>
{initials}
</div>
);
}

View File

@@ -1,6 +1,11 @@
import { Skeleton } from "@/shared/ui/skeleton";
type ViewLoadingFallbackKind = "agents" | "channel" | "forum" | "workflows";
type ViewLoadingFallbackKind =
| "agents"
| "channel"
| "forum"
| "pulse"
| "workflows";
type ViewLoadingFallbackProps = {
includeHeader?: boolean;
@@ -255,6 +260,7 @@ export function ViewLoadingFallback({
{kind === "workflows" ? <WorkflowsLoadingBody /> : null}
{kind === "channel" ? <ChannelLoadingBody /> : null}
{kind === "forum" ? <ForumLoadingBody /> : null}
{kind === "pulse" ? <ChannelLoadingBody /> : null}
</div>
);
}