mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
108
desktop/src-tauri/src/commands/social.rs
Normal file
108
desktop/src-tauri/src/commands/social.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
23
desktop/src/app/routes/pulse.tsx
Normal file
23
desktop/src/app/routes/pulse.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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" />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
97
desktop/src/features/home/ui/RecentNotesSection.tsx
Normal file
97
desktop/src/features/home/ui/RecentNotesSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
144
desktop/src/features/pulse/hooks.ts
Normal file
144
desktop/src/features/pulse/hooks.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
55
desktop/src/features/pulse/lib/groupAgentNotes.ts
Normal file
55
desktop/src/features/pulse/lib/groupAgentNotes.ts
Normal 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;
|
||||
}
|
||||
115
desktop/src/features/pulse/ui/AgentActivityCard.tsx
Normal file
115
desktop/src/features/pulse/ui/AgentActivityCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
desktop/src/features/pulse/ui/ComposeNote.tsx
Normal file
78
desktop/src/features/pulse/ui/ComposeNote.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
desktop/src/features/pulse/ui/NoteCard.tsx
Normal file
104
desktop/src/features/pulse/ui/NoteCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
desktop/src/features/pulse/ui/PulseScreen.tsx
Normal file
18
desktop/src/features/pulse/ui/PulseScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
412
desktop/src/features/pulse/ui/PulseView.tsx
Normal file
412
desktop/src/features/pulse/ui/PulseView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -481,6 +481,9 @@ export type {
|
||||
TriggerWorkflowResponse,
|
||||
} from "@/shared/api/workflowTypes";
|
||||
export type {
|
||||
ContactEntry,
|
||||
ContactListResponse,
|
||||
PublishNoteResult,
|
||||
UserNote,
|
||||
UserNotesCursor,
|
||||
UserNotesResponse,
|
||||
|
||||
70
desktop/src/shared/ui/UserAvatar.tsx
Normal file
70
desktop/src/shared/ui/UserAvatar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user