Files
buzz/crates/buzz-cli/src/commands/inbox.rs
2026-07-08 16:46:20 -04:00

1990 lines
63 KiB
Rust

use std::cmp::Reverse;
use std::collections::{BTreeSet, HashMap};
use std::io;
use std::time::{Duration, Instant};
use buzz_core::kind::{
KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_MANAGED_AGENT, KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_V2, KIND_TEXT_NOTE,
KIND_WORKFLOW_APPROVAL_DENIED, KIND_WORKFLOW_APPROVAL_GRANTED,
KIND_WORKFLOW_APPROVAL_REQUESTED,
};
use chrono::{Local, TimeZone};
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use futures_util::stream::{self, StreamExt};
use nostr::PublicKey;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap};
use ratatui::{Frame, Terminal};
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use crate::client::BuzzClient;
use crate::commands::messages::{send_message_literal, SendMessageParams};
use crate::error::CliError;
const DEFAULT_TYPES: &[InboxCategory] = &[
InboxCategory::NeedsAction,
InboxCategory::Mention,
InboxCategory::Activity,
];
const ACTIVITY_CONTEXT_CONCURRENCY: usize = 8;
const INPUT_POLL_MS: u64 = 50;
const THREAD_CONTEXT_LIMIT: u32 = 100;
const GUI_MENTION_KINDS: &[u32] = &[
KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_V2,
KIND_TEXT_NOTE,
KIND_FORUM_POST,
KIND_FORUM_COMMENT,
];
const GUI_NEEDS_ACTION_KINDS: &[u32] = &[
KIND_WORKFLOW_APPROVAL_REQUESTED,
KIND_WORKFLOW_APPROVAL_GRANTED,
KIND_WORKFLOW_APPROVAL_DENIED,
];
const THREAD_KINDS: &[u32] = &[
KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_V2,
KIND_STREAM_MESSAGE_EDIT,
KIND_STREAM_MESSAGE_DIFF,
KIND_FORUM_COMMENT,
];
pub struct InboxOptions {
pub limit: Option<u32>,
pub poll_seconds: u64,
pub types: Option<String>,
}
pub async fn dispatch(cmd: crate::InboxCmd, client: &BuzzClient) -> Result<(), CliError> {
let options = InboxOptions {
limit: cmd.limit,
poll_seconds: cmd.poll_seconds,
types: cmd.types,
};
if cmd.debug_json {
print_inbox_debug_json(client, options).await
} else {
run_inbox_tui(client, options).await
}
}
async fn print_inbox_debug_json(
client: &BuzzClient,
options: InboxOptions,
) -> Result<(), CliError> {
let categories = parse_categories(options.types.as_deref())?;
let limit = options.limit.unwrap_or(50).clamp(1, 100);
let snapshot = fetch_inbox_snapshot(client, limit, &categories).await?;
let rows: Vec<serde_json::Value> = snapshot
.items
.iter()
.map(|item| {
serde_json::json!({
"id": item.event.id,
"thread_id": item.id,
"kind": item.event.kind,
"category": item.primary_category().feed_type(),
"categories": item.categories.iter().map(|category| category.feed_type()).collect::<Vec<_>>(),
"created_at": item.event.created_at,
"latest_activity_at": item.latest_activity_at,
"channel_id": item.event.channel_id,
"author": item.event.pubkey,
"preview": truncate_for_list(&single_line(&item.event.content), 160),
})
})
.collect();
let output = serde_json::to_string_pretty(&rows)
.map_err(|e| CliError::Other(format!("failed to serialize inbox rows: {e}")))?;
println!("{output}");
Ok(())
}
async fn run_inbox_tui(client: &BuzzClient, options: InboxOptions) -> Result<(), CliError> {
let categories = parse_categories(options.types.as_deref())?;
let limit = options.limit.unwrap_or(50).clamp(1, 100);
let mut app = InboxApp::new(limit, options.poll_seconds, categories);
app.refresh(client).await?;
app.load_selected_context(client).await?;
let mut session = TerminalSession::enter()?;
let (task_tx, mut task_rx) = mpsc::unbounded_channel();
let mut tasks = InboxTasks::new(task_tx);
loop {
while let Ok(result) = task_rx.try_recv() {
tasks.apply_result(result, &mut app, client.clone());
}
if app.should_poll() && !tasks.is_refreshing() {
tasks.start_refresh(client.clone(), &mut app);
}
session.draw(&app)?;
if !event::poll(Duration::from_millis(INPUT_POLL_MS)).map_err(terminal_err)? {
continue;
}
let Event::Key(key) = event::read().map_err(terminal_err)? else {
continue;
};
if key.kind != KeyEventKind::Press {
continue;
}
match app.handle_key(key) {
InboxAction::None => {}
InboxAction::Quit => break,
InboxAction::Refresh => {
tasks.start_refresh(client.clone(), &mut app);
}
InboxAction::LoadContext => {
tasks.start_selected_context_load(client.clone(), &mut app);
}
InboxAction::SendReply => {
app.send_reply(client).await;
}
}
}
Ok(())
}
struct TerminalSession {
terminal: Terminal<CrosstermBackend<io::Stdout>>,
}
impl TerminalSession {
fn enter() -> Result<Self, CliError> {
enable_raw_mode().map_err(terminal_err)?;
let mut stdout = io::stdout();
if let Err(err) = execute!(stdout, EnterAlternateScreen) {
let _ = disable_raw_mode();
return Err(terminal_err(err));
}
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend).map_err(terminal_err)?;
terminal.clear().map_err(terminal_err)?;
terminal.hide_cursor().map_err(terminal_err)?;
Ok(Self { terminal })
}
fn draw(&mut self, app: &InboxApp) -> Result<(), CliError> {
self.terminal
.draw(|frame| draw_inbox(frame, app))
.map(|_| ())
.map_err(terminal_err)
}
}
impl Drop for TerminalSession {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen);
let _ = self.terminal.show_cursor();
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum InboxMode {
Browsing,
Composing,
}
enum InboxAction {
None,
Quit,
Refresh,
LoadContext,
SendReply,
}
struct InboxTasks {
tx: mpsc::UnboundedSender<InboxTaskResult>,
refresh_task: Option<JoinHandle<()>>,
context_task: Option<JoinHandle<()>>,
next_context_request_id: u64,
active_context_request_id: Option<u64>,
}
impl InboxTasks {
fn new(tx: mpsc::UnboundedSender<InboxTaskResult>) -> Self {
Self {
tx,
refresh_task: None,
context_task: None,
next_context_request_id: 1,
active_context_request_id: None,
}
}
fn is_refreshing(&self) -> bool {
self.refresh_task
.as_ref()
.is_some_and(|task| !task.is_finished())
}
fn start_refresh(&mut self, client: BuzzClient, app: &mut InboxApp) {
if self.is_refreshing() {
app.status = "Refresh already running.".to_string();
return;
}
app.mark_refresh_started();
let tx = self.tx.clone();
let limit = app.limit;
let categories = app.categories.clone();
self.refresh_task = Some(tokio::spawn(async move {
let result = fetch_inbox_snapshot(&client, limit, &categories).await;
let _ = tx.send(InboxTaskResult::Refresh(result));
}));
}
fn start_selected_context_load(&mut self, client: BuzzClient, app: &mut InboxApp) {
if let Some(task) = self.context_task.take() {
task.abort();
}
self.active_context_request_id = None;
let Some(request) = app.begin_selected_context_load() else {
return;
};
let request_id = self.next_context_request_id;
self.next_context_request_id = self.next_context_request_id.saturating_add(1);
self.active_context_request_id = Some(request_id);
let tx = self.tx.clone();
self.context_task = Some(tokio::spawn(async move {
let result = fetch_context_snapshot(&client, &request.event, &request.channel_id).await;
let _ = tx.send(InboxTaskResult::Context {
request_id,
item_id: request.item_id,
result,
});
}));
}
fn apply_result(&mut self, result: InboxTaskResult, app: &mut InboxApp, client: BuzzClient) {
match result {
InboxTaskResult::Refresh(result) => {
self.refresh_task = None;
match result {
Ok(snapshot) => {
app.apply_refresh(snapshot);
self.start_selected_context_load(client, app);
}
Err(err) => {
app.status = format!("Refresh failed: {err}");
}
}
}
InboxTaskResult::Context {
request_id,
item_id,
result,
} => {
if self.active_context_request_id != Some(request_id) {
return;
}
self.context_task = None;
self.active_context_request_id = None;
app.apply_context_result(item_id, result);
}
}
}
}
impl Drop for InboxTasks {
fn drop(&mut self) {
if let Some(task) = self.refresh_task.take() {
task.abort();
}
if let Some(task) = self.context_task.take() {
task.abort();
}
self.active_context_request_id = None;
}
}
enum InboxTaskResult {
Refresh(Result<InboxSnapshot, CliError>),
Context {
request_id: u64,
item_id: String,
result: Result<ContextSnapshot, CliError>,
},
}
struct ContextLoadRequest {
item_id: String,
event: EventView,
channel_id: String,
}
struct InboxApp {
items: Vec<InboxItem>,
selected: usize,
context: Vec<EventView>,
context_cache: HashMap<String, Vec<EventView>>,
pending_context_id: Option<String>,
context_scroll: u16,
draft: String,
mode: InboxMode,
status: String,
profiles: HashMap<String, String>,
channel_names: HashMap<String, String>,
agent_pubkeys: BTreeSet<String>,
expanded_agent_events: BTreeSet<String>,
last_refresh: Option<Instant>,
last_refresh_label: String,
limit: u32,
poll_seconds: u64,
categories: Vec<InboxCategory>,
}
impl InboxApp {
fn new(limit: u32, poll_seconds: u64, categories: Vec<InboxCategory>) -> Self {
Self {
items: Vec::new(),
selected: 0,
context: Vec::new(),
context_cache: HashMap::new(),
pending_context_id: None,
context_scroll: 0,
draft: String::new(),
mode: InboxMode::Browsing,
status: "Loading inbox...".to_string(),
profiles: HashMap::new(),
channel_names: HashMap::new(),
agent_pubkeys: BTreeSet::new(),
expanded_agent_events: BTreeSet::new(),
last_refresh: None,
last_refresh_label: String::new(),
limit,
poll_seconds,
categories,
}
}
fn should_poll(&self) -> bool {
self.poll_seconds > 0
&& self
.last_refresh
.map(|last| last.elapsed() >= Duration::from_secs(self.poll_seconds))
.unwrap_or(false)
}
async fn refresh(&mut self, client: &BuzzClient) -> Result<(), CliError> {
let snapshot = fetch_inbox_snapshot(client, self.limit, &self.categories).await?;
self.apply_refresh(snapshot);
Ok(())
}
fn mark_refresh_started(&mut self) {
self.last_refresh = Some(Instant::now());
self.status = "Refreshing inbox...".to_string();
}
fn apply_refresh(&mut self, snapshot: InboxSnapshot) {
let previous_id = self.selected_item().map(|item| item.id.clone());
self.items = snapshot.items;
self.profiles.extend(snapshot.profiles);
self.channel_names.extend(snapshot.channel_names);
self.agent_pubkeys.extend(snapshot.agent_pubkeys);
self.selected = previous_id
.and_then(|id| self.items.iter().position(|item| item.id == id))
.unwrap_or_else(|| self.selected.min(self.items.len().saturating_sub(1)));
self.context_scroll = 0;
self.pending_context_id = None;
let item_ids: BTreeSet<String> = self.items.iter().map(|item| item.id.clone()).collect();
self.context_cache.retain(|id, _| item_ids.contains(id));
if self.items.is_empty() {
self.context.clear();
}
self.last_refresh = Some(Instant::now());
self.last_refresh_label = format_clock_now();
self.status = if self.items.is_empty() {
"No inbox items found. Press R to refresh, q to quit.".to_string()
} else {
format!(
"Loaded {} inbox item{}. Press r to reply, R to refresh, q to quit.",
self.items.len(),
if self.items.len() == 1 { "" } else { "s" }
)
};
}
async fn load_selected_context(&mut self, client: &BuzzClient) -> Result<(), CliError> {
let Some(item) = self.selected_item().cloned() else {
self.context.clear();
return Ok(());
};
let snapshot = match item.event.channel_id.as_deref() {
Some(channel_id) => fetch_context_snapshot(client, &item.event, channel_id).await?,
None => ContextSnapshot {
events: vec![item.event.clone()],
profiles: HashMap::new(),
agent_pubkeys: BTreeSet::new(),
},
};
self.apply_context_snapshot(item.id, snapshot);
Ok(())
}
fn begin_selected_context_load(&mut self) -> Option<ContextLoadRequest> {
let Some(item) = self.selected_item().cloned() else {
self.context.clear();
self.pending_context_id = None;
return None;
};
if let Some(cached) = self.context_cache.get(&item.id) {
self.context = cached.clone();
self.context_scroll = 0;
self.pending_context_id = None;
return None;
}
let Some(channel_id) = item.event.channel_id.clone() else {
self.context = vec![item.event.clone()];
self.context_cache
.insert(item.id.clone(), self.context.clone());
self.context_scroll = 0;
self.pending_context_id = None;
return None;
};
self.context = vec![item.event.clone()];
self.context_scroll = 0;
self.pending_context_id = Some(item.id.clone());
self.status = format!("Loading thread {}...", short_hex(&item.event.id));
Some(ContextLoadRequest {
item_id: item.id,
event: item.event,
channel_id,
})
}
fn apply_context_result(&mut self, item_id: String, result: Result<ContextSnapshot, CliError>) {
if self.pending_context_id.as_deref() == Some(item_id.as_str()) {
self.pending_context_id = None;
}
match result {
Ok(snapshot) => self.apply_context_snapshot(item_id, snapshot),
Err(err) => {
if self.selected_item().map(|item| item.id.as_str()) == Some(item_id.as_str()) {
self.status = format!("Thread load failed: {err}");
}
}
}
}
fn apply_context_snapshot(&mut self, item_id: String, snapshot: ContextSnapshot) {
self.profiles.extend(snapshot.profiles);
self.agent_pubkeys.extend(snapshot.agent_pubkeys);
self.context_cache
.insert(item_id.clone(), snapshot.events.clone());
if self.selected_item().map(|item| item.id.as_str()) == Some(item_id.as_str()) {
self.context = snapshot.events;
self.context_scroll = 0;
self.status = "Thread loaded. Press r to reply, R to refresh, q to quit.".to_string();
}
}
fn is_agent_pubkey(&self, pubkey: &str) -> bool {
normalize_pubkey(pubkey)
.as_ref()
.is_some_and(|pubkey| self.agent_pubkeys.contains(pubkey))
}
async fn send_reply(&mut self, client: &BuzzClient) {
let content = self.draft.trim().to_string();
if content.is_empty() {
self.status = "Reply is empty.".to_string();
return;
}
let Some(item) = self.selected_item().cloned() else {
self.status = "No inbox item selected.".to_string();
return;
};
let Some(channel_id) = item.event.channel_id.clone() else {
self.status = "This inbox item has no channel target for replies.".to_string();
return;
};
let kind = match item.event.kind {
45001 | 45003 => Some(45003),
_ => None,
};
let params = SendMessageParams {
channel_id,
content,
kind,
reply_to: Some(item.event.id.clone()),
broadcast: false,
files: Vec::new(),
};
self.status = "Sending reply...".to_string();
match send_message_literal(client, params).await {
Ok(raw) => {
let sent_id = parse_write_event_id(&raw)
.map(|id| short_hex(&id))
.unwrap_or_else(|| "accepted".to_string());
self.mode = InboxMode::Browsing;
self.draft.clear();
self.status = format!("Reply sent ({sent_id}). Refreshing...");
if let Err(err) = self.refresh(client).await {
self.status = format!("Reply sent, but refresh failed: {err}");
return;
}
if let Err(err) = self.load_selected_context(client).await {
self.status = format!("Reply sent, but thread reload failed: {err}");
} else {
self.status = format!("Reply sent ({sent_id}).");
}
}
Err(err) => {
self.status = format!("Send failed: {err}");
}
}
}
fn selected_item(&self) -> Option<&InboxItem> {
self.items.get(self.selected)
}
fn handle_key(&mut self, key: KeyEvent) -> InboxAction {
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
return InboxAction::Quit;
}
match self.mode {
InboxMode::Browsing => self.handle_browsing_key(key),
InboxMode::Composing => self.handle_composing_key(key),
}
}
fn handle_browsing_key(&mut self, key: KeyEvent) -> InboxAction {
match key.code {
KeyCode::Char('q') => InboxAction::Quit,
KeyCode::Char('R') => InboxAction::Refresh,
KeyCode::Char('r') | KeyCode::Enter => {
if self
.selected_item()
.and_then(|item| item.event.channel_id.as_ref())
.is_some()
{
self.mode = InboxMode::Composing;
self.status = "Composing reply. Enter sends, Esc cancels.".to_string();
} else {
self.status = "This inbox item has no channel target for replies.".to_string();
}
InboxAction::None
}
KeyCode::Down | KeyCode::Char('j') => {
if self.selected + 1 < self.items.len() {
self.selected += 1;
return InboxAction::LoadContext;
}
InboxAction::None
}
KeyCode::Up | KeyCode::Char('k') => {
if self.selected > 0 {
self.selected -= 1;
return InboxAction::LoadContext;
}
InboxAction::None
}
KeyCode::Home => {
if self.selected != 0 {
self.selected = 0;
return InboxAction::LoadContext;
}
InboxAction::None
}
KeyCode::End => {
let last = self.items.len().saturating_sub(1);
if self.selected != last {
self.selected = last;
return InboxAction::LoadContext;
}
InboxAction::None
}
KeyCode::PageDown => {
self.context_scroll = self.context_scroll.saturating_add(8);
InboxAction::None
}
KeyCode::PageUp => {
self.context_scroll = self.context_scroll.saturating_sub(8);
InboxAction::None
}
KeyCode::Char('x') => {
self.toggle_visible_agent_replies();
InboxAction::None
}
_ => InboxAction::None,
}
}
fn toggle_visible_agent_replies(&mut self) {
let agent_event_ids: Vec<String> = self
.context
.iter()
.filter(|event| self.is_agent_pubkey(&event.pubkey))
.map(|event| event.id.clone())
.collect();
if agent_event_ids.is_empty() {
self.status = "No agent replies in this thread.".to_string();
return;
}
let has_collapsed = agent_event_ids
.iter()
.any(|id| !self.expanded_agent_events.contains(id));
if has_collapsed {
for id in &agent_event_ids {
self.expanded_agent_events.insert(id.clone());
}
self.status = format!(
"Expanded {} agent repl{}.",
agent_event_ids.len(),
if agent_event_ids.len() == 1 {
"y"
} else {
"ies"
}
);
} else {
for id in &agent_event_ids {
self.expanded_agent_events.remove(id);
}
self.status = format!(
"Collapsed {} agent repl{}.",
agent_event_ids.len(),
if agent_event_ids.len() == 1 {
"y"
} else {
"ies"
}
);
}
}
fn handle_composing_key(&mut self, key: KeyEvent) -> InboxAction {
match key.code {
KeyCode::Esc => {
self.mode = InboxMode::Browsing;
self.status = "Reply cancelled.".to_string();
InboxAction::None
}
KeyCode::Enter => InboxAction::SendReply,
KeyCode::Backspace => {
self.draft.pop();
InboxAction::None
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.draft.clear();
InboxAction::None
}
KeyCode::Char(c)
if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT =>
{
self.draft.push(c);
InboxAction::None
}
KeyCode::Char(c) if key.modifiers.contains(KeyModifiers::ALT) => {
self.draft.push(c);
InboxAction::None
}
_ => InboxAction::None,
}
}
}
#[derive(Clone, Debug)]
struct InboxItem {
id: String,
event: EventView,
categories: Vec<InboxCategory>,
latest_activity_at: i64,
group_count: usize,
}
impl InboxItem {
fn primary_category(&self) -> InboxCategory {
self.categories
.iter()
.copied()
.min_by_key(|category| category.priority())
.unwrap_or(InboxCategory::Mention)
}
}
#[derive(Clone, Debug)]
struct EventView {
id: String,
pubkey: String,
kind: u32,
content: String,
created_at: i64,
tags: Vec<Vec<String>>,
channel_id: Option<String>,
}
struct InboxSnapshot {
items: Vec<InboxItem>,
profiles: HashMap<String, String>,
channel_names: HashMap<String, String>,
agent_pubkeys: BTreeSet<String>,
}
struct ContextSnapshot {
events: Vec<EventView>,
profiles: HashMap<String, String>,
agent_pubkeys: BTreeSet<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum InboxCategory {
NeedsAction,
Mention,
AgentActivity,
Activity,
}
impl InboxCategory {
fn parse(value: &str) -> Option<Self> {
match value {
"mentions" | "mention" => Some(Self::Mention),
"needs_action" | "needs-action" => Some(Self::NeedsAction),
"activity" => Some(Self::Activity),
"agent_activity" | "agent-activity" => Some(Self::AgentActivity),
_ => None,
}
}
fn feed_type(self) -> &'static str {
match self {
Self::NeedsAction => "needs_action",
Self::Mention => "mentions",
Self::AgentActivity => "agent_activity",
Self::Activity => "activity",
}
}
fn label(self) -> &'static str {
match self {
Self::NeedsAction => "Needs action",
Self::Mention => "Mention",
Self::AgentActivity => "Agent",
Self::Activity => "Activity",
}
}
fn priority(self) -> usize {
match self {
Self::NeedsAction => 0,
Self::Mention => 1,
Self::AgentActivity => 2,
Self::Activity => 3,
}
}
fn is_activity_like(self) -> bool {
matches!(self, Self::Activity | Self::AgentActivity)
}
}
fn draw_inbox(frame: &mut Frame<'_>, app: &InboxApp) {
let area = frame.area();
let bottom_height = if app.mode == InboxMode::Composing {
5
} else {
3
};
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(8), Constraint::Length(bottom_height)])
.split(area);
let panes = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(38), Constraint::Percentage(62)])
.split(vertical[0]);
draw_inbox_list(frame, app, panes[0]);
draw_thread(frame, app, panes[1]);
draw_footer(frame, app, vertical[1]);
}
fn draw_inbox_list(frame: &mut Frame<'_>, app: &InboxApp, area: Rect) {
let items: Vec<ListItem<'_>> = app
.items
.iter()
.map(|item| {
let sender = app.author_label(&item.event.pubkey);
let channel = item
.event
.channel_id
.as_ref()
.and_then(|id| app.channel_names.get(id))
.map(|name| format!("#{name}"))
.unwrap_or_else(|| {
item.event
.channel_id
.as_ref()
.map(|id| format!("#{}", short_hex(id)))
.unwrap_or_else(|| "global".to_string())
});
let count = if item.group_count > 1 {
format!(" +{}", item.group_count - 1)
} else {
String::new()
};
let preview = truncate_for_list(&single_line(&item.event.content), 86);
ListItem::new(Text::from(vec![
Line::from(vec![
Span::styled(
item.primary_category().label(),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(count),
Span::raw(" "),
Span::styled(sender, Style::default().add_modifier(Modifier::BOLD)),
]),
Line::from(vec![
Span::styled(channel, Style::default().fg(Color::Cyan)),
Span::raw(" "),
Span::styled(
format_timestamp(item.latest_activity_at),
Style::default().fg(Color::DarkGray),
),
]),
Line::from(Span::raw(preview)),
]))
})
.collect();
let title = if app.last_refresh_label.is_empty() {
"Inbox".to_string()
} else {
format!("Inbox {}", app.last_refresh_label)
};
let list = List::new(items)
.block(Block::default().title(title).borders(Borders::ALL))
.highlight_style(Style::default().bg(Color::DarkGray))
.highlight_symbol("> ");
let mut state = ListState::default();
if !app.items.is_empty() {
state.select(Some(app.selected));
}
frame.render_stateful_widget(list, area, &mut state);
}
fn draw_thread(frame: &mut Frame<'_>, app: &InboxApp, area: Rect) {
let title = app
.selected_item()
.map(|item| format!("Thread {}", short_hex(&item.event.id)))
.unwrap_or_else(|| "Thread".to_string());
let lines = if app.context.is_empty() {
vec![Line::from(Span::styled(
"Select an inbox item to view its context.",
Style::default().fg(Color::DarkGray),
))]
} else {
render_thread_lines(app)
};
let paragraph = Paragraph::new(Text::from(lines))
.block(Block::default().title(title).borders(Borders::ALL))
.wrap(Wrap { trim: false })
.scroll((app.context_scroll, 0));
frame.render_widget(paragraph, area);
}
fn draw_footer(frame: &mut Frame<'_>, app: &InboxApp, area: Rect) {
let (title, body) = match app.mode {
InboxMode::Browsing => (
"Keys",
format!(
"j/k move PgUp/PgDn scroll x agents r reply R refresh q quit {}",
app.status
),
),
InboxMode::Composing => (
"Reply",
if app.draft.is_empty() {
"Type a reply. Enter sends, Esc cancels, Ctrl-U clears.".to_string()
} else {
app.draft.clone()
},
),
};
let style = if app.mode == InboxMode::Composing {
Style::default().fg(Color::White)
} else {
Style::default().fg(Color::Gray)
};
let paragraph = Paragraph::new(body)
.block(Block::default().title(title).borders(Borders::ALL))
.style(style)
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
}
fn render_thread_lines(app: &InboxApp) -> Vec<Line<'_>> {
let selected_id = app.selected_item().map(|item| item.event.id.as_str());
let mut lines = Vec::new();
for event in &app.context {
let is_selected = selected_id == Some(event.id.as_str());
let is_agent = app.is_agent_pubkey(&event.pubkey);
let is_collapsed_agent = is_agent && !app.expanded_agent_events.contains(&event.id);
let author_style = if is_selected {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().add_modifier(Modifier::BOLD)
};
let mut header = vec![
Span::styled(app.author_label(&event.pubkey), author_style),
Span::raw(" "),
Span::styled(
format_timestamp(event.created_at),
Style::default().fg(Color::DarkGray),
),
Span::raw(" "),
Span::styled(short_hex(&event.id), Style::default().fg(Color::DarkGray)),
];
if is_agent {
header.push(Span::raw(" "));
header.push(Span::styled(
if is_collapsed_agent {
"agent hidden"
} else {
"agent"
},
Style::default().fg(Color::DarkGray),
));
}
lines.push(Line::from(header));
if is_collapsed_agent {
lines.push(Line::from(Span::styled(
" [agent reply hidden] Press x to expand agent replies in this thread.",
Style::default().fg(Color::DarkGray),
)));
lines.push(Line::from(""));
continue;
}
if event.content.trim().is_empty() {
lines.push(Line::from(Span::styled(
" (no content)",
Style::default().fg(Color::DarkGray),
)));
} else {
for content_line in event.content.lines() {
lines.push(Line::from(Span::raw(format!(" {content_line}"))));
}
}
lines.push(Line::from(""));
}
lines
}
impl InboxApp {
fn author_label(&self, pubkey: &str) -> String {
self.profiles
.get(&pubkey.to_ascii_lowercase())
.filter(|name| !name.trim().is_empty())
.cloned()
.unwrap_or_else(|| short_hex(pubkey))
}
}
async fn fetch_inbox_snapshot(
client: &BuzzClient,
limit: u32,
categories: &[InboxCategory],
) -> Result<InboxSnapshot, CliError> {
let items = fetch_inbox_items(client, limit, categories).await?;
let pubkeys: Vec<String> = items.iter().map(|item| item.event.pubkey.clone()).collect();
let profiles = fetch_profiles(client, &pubkeys).await.unwrap_or_default();
let agent_pubkeys = fetch_agent_pubkeys(client, &pubkeys)
.await
.unwrap_or_default();
let channel_ids: Vec<String> = items
.iter()
.filter_map(|item| item.event.channel_id.clone())
.collect();
let channel_names = fetch_channel_names(client, &channel_ids)
.await
.unwrap_or_default();
Ok(InboxSnapshot {
items,
profiles,
channel_names,
agent_pubkeys,
})
}
async fn fetch_context_snapshot(
client: &BuzzClient,
selected: &EventView,
channel_id: &str,
) -> Result<ContextSnapshot, CliError> {
let events = fetch_thread_context(client, selected, channel_id).await?;
let pubkeys: Vec<String> = events.iter().map(|event| event.pubkey.clone()).collect();
let profiles = fetch_profiles(client, &pubkeys).await.unwrap_or_default();
let agent_pubkeys = fetch_agent_pubkeys(client, &pubkeys)
.await
.unwrap_or_default();
Ok(ContextSnapshot {
events,
profiles,
agent_pubkeys,
})
}
async fn fetch_inbox_items(
client: &BuzzClient,
limit: u32,
categories: &[InboxCategory],
) -> Result<Vec<InboxItem>, CliError> {
let mut entries = Vec::new();
let my_pk = client.keys().public_key().to_hex().to_ascii_lowercase();
for category in categories {
let query_limit = if category.is_activity_like() {
limit.saturating_mul(5).clamp(limit, 100)
} else {
limit
};
let filter = inbox_category_filter(*category, &my_pk, query_limit);
let raw = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse feed response: {e}")))?;
let mut events: Vec<EventView> = events.iter().filter_map(event_from_value).collect();
if category.is_activity_like() {
events = filter_relevant_activity_events(client, events, &my_pk).await;
}
entries.extend(events.into_iter().map(|event| (*category, event)));
}
Ok(group_inbox_items(entries))
}
fn inbox_category_filter(
category: InboxCategory,
my_pubkey: &str,
limit: u32,
) -> serde_json::Value {
match category {
InboxCategory::Mention => serde_json::json!({
"kinds": GUI_MENTION_KINDS,
"#p": [my_pubkey],
"limit": limit,
}),
InboxCategory::NeedsAction => serde_json::json!({
"kinds": GUI_NEEDS_ACTION_KINDS,
"#p": [my_pubkey],
"limit": limit.min(20),
}),
InboxCategory::Activity | InboxCategory::AgentActivity => serde_json::json!({
"#p": [my_pubkey],
"feed_types": [category.feed_type()],
"limit": limit,
}),
}
}
async fn filter_relevant_activity_events(
client: &BuzzClient,
events: Vec<EventView>,
my_pubkey: &str,
) -> Vec<EventView> {
let mut candidates = Vec::new();
let mut context_requests = Vec::new();
let mut requested_roots = BTreeSet::new();
for event in events {
if !is_relevant_activity_candidate(&event, my_pubkey) {
continue;
}
let Some(channel_id) = event.channel_id.as_deref() else {
continue;
};
let root_id = thread_key(&event);
if requested_roots.insert(root_id.clone()) {
context_requests.push((root_id.clone(), channel_id.to_string(), event.clone()));
}
candidates.push((root_id, event));
}
let context_results = stream::iter(context_requests.into_iter().map(
|(root_id, channel_id, event)| {
let client = client.clone();
async move {
let context = fetch_thread_context(&client, &event, &channel_id)
.await
.ok();
(root_id, context)
}
},
))
.buffer_unordered(ACTIVITY_CONTEXT_CONCURRENCY)
.collect::<Vec<_>>()
.await;
let context_by_root: HashMap<String, Vec<EventView>> = context_results
.into_iter()
.filter_map(|(root_id, context)| context.map(|context| (root_id, context)))
.collect();
let mut relevant = Vec::new();
for (root_id, event) in candidates {
let Some(context) = context_by_root.get(&root_id) else {
continue;
};
if thread_context_relevant_to_user(&event, context, my_pubkey) {
relevant.push(event);
}
}
relevant
}
fn is_relevant_activity_candidate(event: &EventView, my_pubkey: &str) -> bool {
event.pubkey.to_ascii_lowercase() != my_pubkey
&& is_thread_reply(&event.tags)
&& !has_mention_for_pubkey(event, my_pubkey)
}
fn thread_context_relevant_to_user(
candidate: &EventView,
context: &[EventView],
my_pubkey: &str,
) -> bool {
let root_id = thread_key(candidate);
context.iter().any(|event| {
event.id != candidate.id
&& (event.pubkey.to_ascii_lowercase() == my_pubkey
|| has_mention_for_pubkey(event, my_pubkey)
|| (event.id == root_id && event.pubkey.to_ascii_lowercase() == my_pubkey))
})
}
async fn fetch_thread_context(
client: &BuzzClient,
selected: &EventView,
channel_id: &str,
) -> Result<Vec<EventView>, CliError> {
let root_id = thread_key(selected);
let reply_filter = serde_json::json!({
"kinds": THREAD_KINDS,
"#h": [channel_id],
"#e": [root_id],
"limit": THREAD_CONTEXT_LIMIT,
});
let root_filter = serde_json::json!({
"ids": [root_id],
"limit": 1,
});
let selected_filter = serde_json::json!({
"ids": [selected.id],
"limit": 1,
});
let raw = client
.query_multi(&[reply_filter, root_filter, selected_filter])
.await?;
let values: Vec<serde_json::Value> = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse thread response: {e}")))?;
let mut events = dedupe_events(
std::iter::once(selected.clone())
.chain(values.iter().filter_map(event_from_value))
.collect(),
);
events.sort_by_key(|event| event.created_at);
Ok(events)
}
async fn fetch_profiles(
client: &BuzzClient,
pubkeys: &[String],
) -> Result<HashMap<String, String>, CliError> {
let authors = unique_non_empty(pubkeys);
if authors.is_empty() {
return Ok(HashMap::new());
}
let filter = serde_json::json!({
"kinds": [0],
"authors": authors,
"limit": authors.len(),
});
let raw = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse profiles response: {e}")))?;
let mut profiles = HashMap::new();
for event in events {
let Some(pubkey) = event.get("pubkey").and_then(|value| value.as_str()) else {
continue;
};
let Some(content) = event.get("content").and_then(|value| value.as_str()) else {
continue;
};
let Ok(profile) = serde_json::from_str::<serde_json::Value>(content) else {
continue;
};
let label = profile
.get("display_name")
.or_else(|| profile.get("name"))
.and_then(|value| value.as_str())
.filter(|value| !value.trim().is_empty())
.map(str::to_string);
if let Some(label) = label {
profiles.insert(pubkey.to_ascii_lowercase(), label);
}
}
Ok(profiles)
}
async fn fetch_channel_names(
client: &BuzzClient,
channel_ids: &[String],
) -> Result<HashMap<String, String>, CliError> {
let ids = unique_non_empty(channel_ids);
if ids.is_empty() {
return Ok(HashMap::new());
}
let filter = serde_json::json!({
"kinds": [39000],
"#d": ids,
"limit": ids.len(),
});
let raw = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse channels response: {e}")))?;
let mut channels = HashMap::new();
for event in events {
let tags = parse_tags(event.get("tags"));
let Some(id) = first_tag_value(&tags, "d") else {
continue;
};
let Some(name) = first_tag_value(&tags, "name") else {
continue;
};
channels.insert(id, name);
}
Ok(channels)
}
async fn fetch_agent_pubkeys(
client: &BuzzClient,
pubkeys: &[String],
) -> Result<BTreeSet<String>, CliError> {
let mut agent_pubkeys = BTreeSet::new();
let authors: Vec<String> = pubkeys
.iter()
.filter_map(|value| normalize_pubkey(value))
.collect();
let authors = unique_owned(authors);
if !authors.is_empty() {
let filter = serde_json::json!({
"kinds": [0],
"authors": authors,
"limit": authors.len(),
});
let raw = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&raw).map_err(|e| {
CliError::Other(format!("failed to parse agent profiles response: {e}"))
})?;
for event in events {
if !profile_has_valid_oa_owner_value(&event) {
continue;
}
let Some(pubkey) = event
.get("pubkey")
.and_then(|value| value.as_str())
.and_then(normalize_pubkey)
else {
continue;
};
agent_pubkeys.insert(pubkey);
}
}
let owner_pubkey = client
.auth_tag_owner_hex()
.unwrap_or_else(|| client.keys().public_key().to_hex());
if let Some(owner_pubkey) = normalize_pubkey(&owner_pubkey) {
let filter = serde_json::json!({
"kinds": [KIND_MANAGED_AGENT],
"authors": [owner_pubkey],
"limit": 200,
});
let raw = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse managed-agent response: {e}")))?;
for event in events {
for tag in parse_tags(event.get("tags")) {
if tag.first().map(|value| value.as_str()) != Some("d") {
continue;
}
let Some(pubkey) = tag.get(1).and_then(|value| normalize_pubkey(value)) else {
continue;
};
agent_pubkeys.insert(pubkey);
}
}
}
Ok(agent_pubkeys)
}
fn profile_has_valid_oa_owner_value(event: &serde_json::Value) -> bool {
let Some(target_pubkey) = event
.get("pubkey")
.and_then(|value| value.as_str())
.and_then(normalize_pubkey)
.and_then(|value| PublicKey::from_hex(&value).ok())
else {
return false;
};
for tag in parse_tags(event.get("tags")) {
if tag.first().map(|value| value.as_str()) != Some("auth") || tag.len() != 4 {
continue;
}
let Ok(json) = serde_json::to_string(&tag) else {
continue;
};
if buzz_sdk::nip_oa::verify_auth_tag(&json, &target_pubkey).is_ok() {
return true;
}
}
false
}
fn group_inbox_items(entries: Vec<(InboxCategory, EventView)>) -> Vec<InboxItem> {
struct Group {
event: EventView,
categories: Vec<InboxCategory>,
event_ids: BTreeSet<String>,
latest_activity_at: i64,
}
let mut groups: HashMap<String, Group> = HashMap::new();
for (category, event) in entries {
let key = thread_key(&event);
let group = groups.entry(key).or_insert_with(|| Group {
event: event.clone(),
categories: Vec::new(),
event_ids: BTreeSet::new(),
latest_activity_at: event.created_at,
});
if !group.categories.contains(&category) {
group.categories.push(category);
group.categories.sort_by_key(|value| value.priority());
}
group.event_ids.insert(event.id.clone());
if event.created_at >= group.latest_activity_at {
group.latest_activity_at = event.created_at;
group.event = event;
}
}
let mut items: Vec<InboxItem> = groups
.into_iter()
.map(|(id, group)| InboxItem {
id,
event: group.event,
categories: group.categories,
latest_activity_at: group.latest_activity_at,
group_count: group.event_ids.len(),
})
.collect();
items.sort_by_key(|item| Reverse(item.latest_activity_at));
items
}
fn event_from_value(value: &serde_json::Value) -> Option<EventView> {
let tags = parse_tags(value.get("tags"));
Some(EventView {
id: value.get("id")?.as_str()?.to_string(),
pubkey: value.get("pubkey")?.as_str()?.to_string(),
kind: value.get("kind")?.as_u64()? as u32,
content: value
.get("content")
.and_then(|value| value.as_str())
.unwrap_or("")
.to_string(),
created_at: value.get("created_at")?.as_i64()?,
channel_id: first_tag_value(&tags, "h"),
tags,
})
}
fn parse_tags(value: Option<&serde_json::Value>) -> Vec<Vec<String>> {
value
.and_then(|value| value.as_array())
.map(|tags| {
tags.iter()
.filter_map(|tag| {
let parts: Vec<String> = tag
.as_array()?
.iter()
.filter_map(|value| value.as_str().map(str::to_string))
.collect();
(!parts.is_empty()).then_some(parts)
})
.collect()
})
.unwrap_or_default()
}
fn first_tag_value(tags: &[Vec<String>], key: &str) -> Option<String> {
tags.iter()
.find(|tag| tag.first().map(|value| value.as_str()) == Some(key))
.and_then(|tag| tag.get(1))
.filter(|value| !value.is_empty())
.cloned()
}
fn has_mention_for_pubkey(event: &EventView, pubkey: &str) -> bool {
let normalized = pubkey.to_ascii_lowercase();
event.tags.iter().any(|tag| {
tag.first().map(|value| value.as_str()) == Some("p")
&& tag
.get(1)
.map(|value| value.to_ascii_lowercase() == normalized)
.unwrap_or(false)
})
}
fn is_thread_reply(tags: &[Vec<String>]) -> bool {
thread_reference(tags).0.is_some() && !is_broadcast_reply(tags)
}
fn is_broadcast_reply(tags: &[Vec<String>]) -> bool {
tags.iter().any(|tag| {
tag.first().map(|value| value.as_str()) == Some("broadcast")
&& tag.get(1).map(|value| value.as_str()) == Some("1")
})
}
fn thread_reference(tags: &[Vec<String>]) -> (Option<String>, Option<String>) {
let mut root = None;
let mut reply = None;
for tag in tags {
if tag.first().map(|value| value.as_str()) != Some("e") {
continue;
}
let Some(id) = tag.get(1).filter(|id| is_event_id(id)) else {
continue;
};
match tag.get(3).map(|value| value.as_str()) {
Some("root") => root = Some(id.clone()),
Some("reply") => reply = Some(id.clone()),
_ => {}
}
}
let Some(parent_id) = reply else {
return (None, None);
};
let root_id = root.unwrap_or_else(|| parent_id.clone());
(Some(parent_id), Some(root_id))
}
fn thread_key(event: &EventView) -> String {
thread_reference(&event.tags)
.1
.unwrap_or_else(|| event.id.clone())
}
fn dedupe_events(events: Vec<EventView>) -> Vec<EventView> {
let mut by_id = HashMap::new();
for event in events {
by_id.insert(event.id.clone(), event);
}
by_id.into_values().collect()
}
fn parse_categories(input: Option<&str>) -> Result<Vec<InboxCategory>, CliError> {
let values: Vec<InboxCategory> = match input {
Some(raw) => raw
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| {
InboxCategory::parse(value).ok_or_else(|| {
CliError::Usage(format!(
"invalid inbox type {value:?}; expected mentions, needs_action, activity, or agent_activity"
))
})
})
.collect::<Result<_, _>>()?,
None => DEFAULT_TYPES.to_vec(),
};
if values.is_empty() {
return Err(CliError::Usage("--types cannot be empty".into()));
}
let mut deduped = Vec::new();
for value in values {
if !deduped.contains(&value) {
deduped.push(value);
}
}
deduped.sort_by_key(|value| value.priority());
Ok(deduped)
}
fn unique_non_empty(values: &[String]) -> Vec<&str> {
let mut seen = BTreeSet::new();
values
.iter()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.filter(|value| seen.insert((*value).to_string()))
.collect()
}
fn unique_owned(values: Vec<String>) -> Vec<String> {
let mut seen = BTreeSet::new();
values
.into_iter()
.filter(|value| seen.insert(value.clone()))
.collect()
}
fn normalize_pubkey(value: &str) -> Option<String> {
let value = value.trim();
is_event_id(value).then(|| value.to_ascii_lowercase())
}
fn parse_write_event_id(raw: &str) -> Option<String> {
serde_json::from_str::<serde_json::Value>(raw)
.ok()?
.get("event_id")?
.as_str()
.map(str::to_string)
}
fn single_line(value: &str) -> String {
value.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn truncate_for_list(value: &str, max_chars: usize) -> String {
if value.chars().count() <= max_chars {
return value.to_string();
}
let suffix = "...";
let mut out = value
.chars()
.take(max_chars.saturating_sub(suffix.len()))
.collect::<String>();
out.push_str(suffix);
out
}
fn short_hex(value: &str) -> String {
value.chars().take(8).collect()
}
fn is_event_id(value: &str) -> bool {
value.len() == 64 && value.chars().all(|ch| ch.is_ascii_hexdigit())
}
fn format_timestamp(unix_seconds: i64) -> String {
Local
.timestamp_opt(unix_seconds, 0)
.single()
.map(|dt| dt.format("%b %-d %H:%M").to_string())
.unwrap_or_else(|| unix_seconds.to_string())
}
fn format_clock_now() -> String {
Local::now().format("%H:%M:%S").to_string()
}
fn terminal_err(err: io::Error) -> CliError {
CliError::Other(format!("terminal error: {err}"))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const SELF_PK: &str = "1111111111111111111111111111111111111111111111111111111111111111";
const OTHER_PK: &str = "2222222222222222222222222222222222222222222222222222222222222222";
const CHANNEL_ID: &str = "80a927fd-a695-4895-971e-e49c974b0fff";
fn event(id: &str, created_at: i64, tags: Vec<Vec<&str>>) -> EventView {
let value = json!({
"id": id,
"pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"kind": 40002,
"content": "body",
"created_at": created_at,
"tags": tags,
});
event_from_value(&value).expect("valid event")
}
fn event_with_pubkey(
id: &str,
pubkey: &str,
created_at: i64,
tags: Vec<Vec<&str>>,
) -> EventView {
let value = json!({
"id": id,
"pubkey": pubkey,
"kind": 9,
"content": "body",
"created_at": created_at,
"tags": tags,
});
event_from_value(&value).expect("valid event")
}
fn rendered_text(lines: &[Line<'_>]) -> String {
lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn thread_key_prefers_root_marker() {
let root = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
let parent = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
let event = event(
"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
1,
vec![vec!["e", root, "", "root"], vec!["e", parent, "", "reply"]],
);
assert_eq!(thread_key(&event), root);
}
#[test]
fn thread_key_uses_reply_marker_for_direct_replies() {
let parent = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
let event = event(
"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
1,
vec![vec!["e", parent, "", "reply"]],
);
assert_eq!(thread_key(&event), parent);
}
#[test]
fn group_inbox_items_keeps_latest_event_per_thread() {
let root = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
let older = event(root, 10, vec![]);
let newer = event(
"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
20,
vec![vec!["e", root, "", "reply"]],
);
let grouped = group_inbox_items(vec![
(InboxCategory::Mention, older),
(InboxCategory::NeedsAction, newer),
]);
assert_eq!(grouped.len(), 1);
assert_eq!(grouped[0].event.created_at, 20);
assert_eq!(grouped[0].group_count, 2);
assert_eq!(
grouped[0].categories,
vec![InboxCategory::NeedsAction, InboxCategory::Mention]
);
}
#[test]
fn parse_categories_defaults_to_gui_all_inbox() {
assert_eq!(
parse_categories(None).expect("defaults"),
vec![
InboxCategory::NeedsAction,
InboxCategory::Mention,
InboxCategory::Activity
]
);
}
#[test]
fn mention_filter_matches_desktop_feed_kinds() {
let filter = inbox_category_filter(InboxCategory::Mention, "abc123", 50);
assert_eq!(filter.get("#p"), Some(&json!(["abc123"])));
assert_eq!(filter.get("feed_types"), None);
let kinds = filter
.get("kinds")
.and_then(serde_json::Value::as_array)
.expect("mention kinds");
assert!(kinds.contains(&json!(KIND_TEXT_NOTE)));
assert!(kinds.contains(&json!(KIND_STREAM_MESSAGE)));
assert!(kinds.contains(&json!(KIND_STREAM_MESSAGE_V2)));
assert!(kinds.contains(&json!(KIND_FORUM_POST)));
assert!(kinds.contains(&json!(KIND_FORUM_COMMENT)));
}
#[test]
fn needs_action_filter_matches_desktop_feed_kinds() {
let filter = inbox_category_filter(InboxCategory::NeedsAction, "abc123", 50);
assert_eq!(filter.get("#p"), Some(&json!(["abc123"])));
assert_eq!(filter.get("limit"), Some(&json!(20)));
let kinds = filter
.get("kinds")
.and_then(serde_json::Value::as_array)
.expect("needs-action kinds");
assert!(kinds.contains(&json!(KIND_WORKFLOW_APPROVAL_REQUESTED)));
assert!(kinds.contains(&json!(KIND_WORKFLOW_APPROVAL_GRANTED)));
assert!(kinds.contains(&json!(KIND_WORKFLOW_APPROVAL_DENIED)));
}
#[test]
fn activity_filter_uses_feed_extension() {
let filter = inbox_category_filter(InboxCategory::Activity, "abc123", 50);
assert_eq!(filter.get("#p"), Some(&json!(["abc123"])));
assert_eq!(filter.get("kinds"), None);
assert_eq!(filter.get("feed_types"), Some(&json!(["activity"])));
}
#[test]
fn activity_candidate_requires_external_non_mention_thread_reply() {
let root = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
let parent = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
let threaded = event_with_pubkey(
"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
OTHER_PK,
1,
vec![
vec!["h", CHANNEL_ID],
vec!["e", root, "", "root"],
vec!["e", parent, "", "reply"],
],
);
assert!(is_relevant_activity_candidate(&threaded, SELF_PK));
let top_level = event_with_pubkey(
"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
OTHER_PK,
1,
vec![vec!["h", CHANNEL_ID]],
);
assert!(!is_relevant_activity_candidate(&top_level, SELF_PK));
let self_authored = event_with_pubkey(
"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
SELF_PK,
1,
vec![
vec!["h", CHANNEL_ID],
vec!["e", root, "", "root"],
vec!["e", parent, "", "reply"],
],
);
assert!(!is_relevant_activity_candidate(&self_authored, SELF_PK));
let direct_mention = event_with_pubkey(
"9999999999999999999999999999999999999999999999999999999999999999",
OTHER_PK,
1,
vec![
vec!["h", CHANNEL_ID],
vec!["p", SELF_PK],
vec!["e", root, "", "root"],
vec!["e", parent, "", "reply"],
],
);
assert!(!is_relevant_activity_candidate(&direct_mention, SELF_PK));
let broadcast = event_with_pubkey(
"8888888888888888888888888888888888888888888888888888888888888888",
OTHER_PK,
1,
vec![
vec!["h", CHANNEL_ID],
vec!["e", root, "", "root"],
vec!["e", parent, "", "reply"],
vec!["broadcast", "1"],
],
);
assert!(!is_relevant_activity_candidate(&broadcast, SELF_PK));
}
#[test]
fn activity_context_requires_self_participation_or_thread_mention() {
let root = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
let parent = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
let candidate = event_with_pubkey(
"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
OTHER_PK,
3,
vec![
vec!["h", CHANNEL_ID],
vec!["e", root, "", "root"],
vec!["e", parent, "", "reply"],
],
);
let root_by_self = event_with_pubkey(root, SELF_PK, 1, vec![vec!["h", CHANNEL_ID]]);
assert!(thread_context_relevant_to_user(
&candidate,
&[root_by_self.clone(), candidate.clone()],
SELF_PK,
));
let prior_self_reply = event_with_pubkey(
parent,
SELF_PK,
2,
vec![vec!["h", CHANNEL_ID], vec!["e", root, "", "reply"]],
);
assert!(thread_context_relevant_to_user(
&candidate,
&[prior_self_reply, candidate.clone()],
SELF_PK,
));
let prior_mention = event_with_pubkey(
parent,
OTHER_PK,
2,
vec![
vec!["h", CHANNEL_ID],
vec!["p", SELF_PK],
vec!["e", root, "", "reply"],
],
);
assert!(thread_context_relevant_to_user(
&candidate,
&[prior_mention, candidate.clone()],
SELF_PK,
));
let unrelated_root = event_with_pubkey(root, OTHER_PK, 1, vec![vec!["h", CHANNEL_ID]]);
assert!(!thread_context_relevant_to_user(
&candidate,
&[unrelated_root, candidate.clone()],
SELF_PK,
));
}
#[test]
fn render_thread_lines_hides_agent_replies_by_default() {
let root = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
let agent_id = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
let root_event = event_with_pubkey(root, SELF_PK, 1, vec![vec!["h", CHANNEL_ID]]);
let mut agent_reply = event_with_pubkey(
agent_id,
OTHER_PK,
2,
vec![vec!["h", CHANNEL_ID], vec!["e", root, "", "reply"]],
);
agent_reply.content = "agent body should be hidden".to_string();
let mut app = InboxApp::new(10, 0, vec![InboxCategory::Mention]);
app.items.push(InboxItem {
id: root.to_string(),
event: root_event.clone(),
categories: vec![InboxCategory::Mention],
latest_activity_at: 2,
group_count: 1,
});
app.context = vec![root_event, agent_reply];
app.agent_pubkeys.insert(OTHER_PK.to_string());
let collapsed = rendered_text(&render_thread_lines(&app));
assert!(collapsed.contains("[agent reply hidden]"));
assert!(!collapsed.contains("agent body should be hidden"));
app.expanded_agent_events.insert(agent_id.to_string());
let expanded = rendered_text(&render_thread_lines(&app));
assert!(expanded.contains("agent body should be hidden"));
}
#[test]
fn toggle_visible_agent_replies_expands_and_collapses_thread_agents() {
let root = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
let agent_id = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
let root_event = event_with_pubkey(root, SELF_PK, 1, vec![vec!["h", CHANNEL_ID]]);
let agent_reply = event_with_pubkey(
agent_id,
OTHER_PK,
2,
vec![vec!["h", CHANNEL_ID], vec!["e", root, "", "reply"]],
);
let mut app = InboxApp::new(10, 0, vec![InboxCategory::Mention]);
app.context = vec![root_event, agent_reply];
app.agent_pubkeys.insert(OTHER_PK.to_string());
app.toggle_visible_agent_replies();
assert!(app.expanded_agent_events.contains(agent_id));
assert!(app.status.contains("Expanded 1 agent reply."));
app.toggle_visible_agent_replies();
assert!(!app.expanded_agent_events.contains(agent_id));
assert!(app.status.contains("Collapsed 1 agent reply."));
}
}