Add feed aggregation from enabled relays

- src/feed.rs: aggregate recent kind 1 notes from enabled relays into a
  newest-first, de-duplicated feed using a throwaway identity
- IPC: FeedGet; CLI: feed [limit]; frontend: Feed screen + nav + tests
- README: mark feed aggregation as shipped
This commit is contained in:
Avi 2026-08-06 10:31:45 -05:00
commit 37a9d945c9
15 changed files with 581 additions and 3 deletions

View file

@ -45,7 +45,8 @@ distribution repositories. Expect some API churn until 1.0. No Docker or databas
- **Link preview cards** — up to three URLs per note get a title/image/description preview
- **Publishing receipts** — per-relay publish reports, so you always know where a note landed
- **Relay management** — add, remove, enable/disable, and latency-test relays from GUI or CLI
- **Feed aggregation** *(coming soon)* — an aggregated view of incoming text notes and link cards
- **Feed aggregation** — a read-only, newest-first feed of recent text notes aggregated from your
enabled relays (24h window), de-duplicated across relays, from a dedicated GUI screen or the CLI
- **Encrypted vault** — secret keys encrypted at rest with **AES-256-GCM** under a key derived via
**Argon2id** from your password
- **Secret key recovery** — reveal a key (hex + `nsec1...`) only after unlocking, from GUI or CLI
@ -210,6 +211,7 @@ cargo run --release -- create "Alice" # create a profile
cargo run --release -- list # list profiles (no secret keys)
cargo run --release -- switch <npub> # select the active profile
cargo run --release -- publish <npub> "Hello" # publish a text note
cargo run --release -- feed [limit] # fetch recent notes from enabled relays
cargo run --release -- relays list|add|remove|enable|disable|test
cargo run --release -- settings get|set <key> <value>
cargo run --release -- set-password # encrypt the vault (or change its password)

View file

@ -6,6 +6,7 @@ import { Button } from './components/Button';
import { Icon } from './components/Icon';
import { UnlockModal } from './components/UnlockModal';
import { HomeScreen } from './screens/HomeScreen';
import { FeedScreen } from './screens/FeedScreen';
import { ProfilesScreen } from './screens/ProfilesScreen';
import { ComposeScreen } from './screens/ComposeScreen';
import { RelaysScreen } from './screens/RelaysScreen';
@ -68,6 +69,7 @@ function Shell() {
{screen === 'home' && (
<HomeScreen onNavigate={setScreen} onCreateProfile={() => setCreateOpen(true)} />
)}
{screen === 'feed' && <FeedScreen onNavigate={setScreen} />}
{screen === 'profiles' && <ProfilesScreen onCreateProfile={() => setCreateOpen(true)} />}
{screen === 'compose' && <ComposeScreen />}
{screen === 'relays' && <RelaysScreen />}

View file

@ -2,6 +2,7 @@ import type { ReactNode } from 'react';
export type IconName =
| 'home'
| 'list'
| 'users'
| 'edit'
| 'relay'
@ -32,6 +33,12 @@ const PATHS: Record<IconName, ReactNode> = {
<path d="M16 14.5c1.8.3 3.5 1.5 4.3 3.5" />
</>
),
list: (
<>
<path d="M9 6h11M9 12h11M9 18h11" />
<path d="M3.5 6h.01M3.5 12h.01M3.5 18h.01" />
</>
),
edit: <path d="M4 20h4L19.5 8.5a2.1 2.1 0 0 0-3-3L5 17v3z" />,
relay: (
<>

View file

@ -7,6 +7,7 @@ import { Icon, type IconName } from './Icon';
const NAV_ITEMS: { id: Screen; label: string; icon: IconName }[] = [
{ id: 'home', label: 'Home', icon: 'home' },
{ id: 'feed', label: 'Feed', icon: 'list' },
{ id: 'profiles', label: 'Profiles', icon: 'users' },
{ id: 'compose', label: 'Compose', icon: 'edit' },
{ id: 'relays', label: 'Relays', icon: 'relay' },

View file

@ -1,6 +1,7 @@
import type {
AppState,
BackendResponse,
FeedItem,
LinkPreview,
PickedImage,
ProfileSummary,
@ -50,6 +51,7 @@ export const api = {
call<{ profile: ProfileSummary; state: AppState }>('create_profile', { label }),
selectProfile: (npub: string) => call<AppState>('select_profile', { npub }),
publishNote: (content: string) => call<PublishReport>('publish_note', { content }),
feedGet: (limit?: number) => call<FeedItem[]>('feed_get', limit ? { limit } : {}),
relayAdd: (url: string) => call<Settings>('relay_add', { url }),
relayRemove: (url: string) => call<Settings>('relay_remove', { url }),
relaySetEnabled: (url: string, enabled: boolean) =>

View file

@ -1,7 +1,8 @@
export type Screen = 'home' | 'profiles' | 'compose' | 'relays' | 'signer' | 'settings';
export type Screen = 'home' | 'feed' | 'profiles' | 'compose' | 'relays' | 'signer' | 'settings';
export const SCREEN_TITLES: Record<Screen, string> = {
home: 'Home',
feed: 'Feed',
profiles: 'Profiles',
compose: 'Compose',
relays: 'Relays',

View file

@ -63,6 +63,21 @@ export interface PublishReport {
failed: RelayFailure[];
}
/** A single note shown in the aggregated feed. */
export interface FeedItem {
/** Bech32 note id. */
id: string;
/** Hex public key of the author. */
author: string;
/** Bech32 `npub` of the author, for display. */
author_npub: string;
content: string;
/** Unix timestamp the note was created. */
created_at: number;
/** Relays that served this note, de-duplicated. */
relays: string[];
}
export interface RelayTestResult {
url: string;
connected: boolean;

View file

@ -0,0 +1,140 @@
import { useCallback, useEffect, useState } from 'react';
import { Alert } from '../components/Alert';
import { Avatar } from '../components/Avatar';
import { Badge } from '../components/Badge';
import { Button } from '../components/Button';
import { EmptyState } from '../components/EmptyState';
import { Icon } from '../components/Icon';
import { Spinner } from '../components/Spinner';
import { formatDate, shortenNpub } from '../lib/format';
import type { Screen } from '../lib/navigation';
import type { FeedItem } from '../lib/types';
import { useApp } from '../state/AppProvider';
interface FeedScreenProps {
onNavigate: (screen: Screen) => void;
}
export function FeedScreen({ onNavigate }: FeedScreenProps) {
const { state, feedGet } = useApp();
const [items, setItems] = useState<FeedItem[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const enabledRelays = (state?.settings.relays ?? []).filter((r) => r.enabled);
const shorten = state?.settings.shorten_npub ?? true;
const load = useCallback(
async (background: boolean) => {
setError(null);
if (background) {
setRefreshing(true);
} else {
setLoading(true);
}
try {
setItems(await feedGet());
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
setRefreshing(false);
}
},
[feedGet],
);
useEffect(() => {
if (enabledRelays.length > 0) {
void load(false);
} else {
setLoading(false);
setItems([]);
}
}, [enabledRelays.length, load]);
if (enabledRelays.length === 0) {
return (
<div className="screen">
<div className="screen-inner">
<EmptyState
icon={<Icon name="list" size={30} />}
title="No relays enabled"
description={
<span>
The feed reads notes from your enabled relays. Enable at least one relay to see
recent notes here.
</span>
}
action={
<Button variant="primary" onClick={() => onNavigate('relays')}>
<Icon name="relay" size={18} />
Manage relays
</Button>
}
/>
</div>
</div>
);
}
return (
<div className="screen">
<div className="screen-inner">
<header className="page-head">
<div>
<h1>Feed</h1>
<p className="page-subtitle">
Recent notes aggregated from your {enabledRelays.length} enabled relay
{enabledRelays.length === 1 ? '' : 's'}.
</p>
</div>
<Button variant="ghost" onClick={() => void load(true)} loading={refreshing}>
<Icon name="refresh" size={16} />
Refresh
</Button>
</header>
{error && (
<Alert tone="error" title="Could not load the feed">
{error}
</Alert>
)}
{loading ? (
<Spinner label="Fetching recent notes…" />
) : items.length === 0 ? (
<EmptyState
icon={<Icon name="list" size={26} />}
title="No notes found"
description="No notes were returned by the enabled relays in the last 24 hours. Try refreshing or check the relays screen."
/>
) : (
<ul className="feed-list">
{items.map((item) => (
<li key={item.id} className="feed-item">
<Avatar npub={item.author_npub} label={shortenNpub(item.author_npub, shorten)} />
<div className="feed-item-body">
<div className="feed-item-meta">
<span className="mono" title={item.author_npub}>
{shortenNpub(item.author_npub, shorten)}
</span>
<span className="feed-item-time">{formatDate(item.created_at)}</span>
{item.relays.length > 1 && (
<Badge tone="neutral">{item.relays.length} relays</Badge>
)}
</div>
<p className="feed-item-content">{item.content}</p>
<code className="mono feed-item-id" title={item.id}>
{shortenNpub(item.id, true)}
</code>
</div>
</li>
))}
</ul>
)}
</div>
</div>
);
}

View file

@ -10,6 +10,7 @@ import {
import { api, BackendError } from '../lib/api';
import type {
AppState,
FeedItem,
LinkPreview,
PickedImage,
ProfileSummary,
@ -40,6 +41,7 @@ interface AppContextValue {
publishNote: (content: string) => Promise<PublishReport>;
recordPublishFailure: (message: string, details?: string | null) => void;
clearLastPublish: () => void;
feedGet: (limit?: number) => Promise<FeedItem[]>;
relayAdd: (url: string) => Promise<Settings>;
relayRemove: (url: string) => Promise<Settings>;
relaySetEnabled: (url: string, enabled: boolean) => Promise<Settings>;
@ -124,6 +126,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
setLastPublish(null);
}, []);
const feedGet = useCallback((limit?: number) => api.feedGet(limit), []);
const applySettings = useCallback((fresh: Settings) => {
setState((prev) => (prev ? { ...prev, settings: fresh } : prev));
return fresh;
@ -196,6 +200,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
publishNote,
recordPublishFailure,
clearLastPublish,
feedGet,
relayAdd,
relayRemove,
relaySetEnabled,
@ -227,6 +232,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
publishNote,
recordPublishFailure,
clearLastPublish,
feedGet,
relayAdd,
relayRemove,
relaySetEnabled,

View file

@ -0,0 +1,71 @@
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FeedScreen } from '../screens/FeedScreen';
import { renderWithApp } from './render';
import { makeEmptyState } from './apiMock';
import { createFakeBackend, installFakeBackend } from './fakeBackend';
function renderFeed(backend: ReturnType<typeof createFakeBackend>) {
installFakeBackend(backend);
return userEvent.setup();
}
describe('FeedScreen', () => {
it('lists recent notes with author, content and time', async () => {
const backend = createFakeBackend();
renderFeed(backend);
renderWithApp(<FeedScreen onNavigate={vi.fn()} />);
expect(await screen.findByText('Hello from the feed.')).toBeInTheDocument();
expect(screen.getByText('A second note worth reading.')).toBeInTheDocument();
expect(screen.getByText(/npub1alice/)).toBeInTheDocument();
expect(screen.getByText('2 relays')).toBeInTheDocument();
});
it('disable relays shows an empty state that can navigate to relays', async () => {
const settings = {
theme: 'system' as const,
confirm_before_publish: true,
shorten_npub: true,
relays: [
{ url: 'wss://relay.damus.io', enabled: false },
{ url: 'wss://relay.nostr.band', enabled: false },
],
};
const backend = createFakeBackend(makeEmptyState({ settings }));
const onNavigate = vi.fn();
const user = renderFeed(backend);
renderWithApp(<FeedScreen onNavigate={onNavigate} />);
expect(await screen.findByText('No relays enabled')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /Manage relays/i }));
expect(onNavigate).toHaveBeenCalledWith('relays');
});
it('shows an error when the backend fails to load the feed', async () => {
const backend = createFakeBackend();
backend.nextErrors.feed_get = {
message: 'Could not connect to the relay.',
details: 'network error',
};
renderFeed(backend);
renderWithApp(<FeedScreen onNavigate={vi.fn()} />);
expect(await screen.findByText('Could not load the feed')).toBeInTheDocument();
expect(screen.getByText('Could not connect to the relay.')).toBeInTheDocument();
});
it('requests the feed again on refresh', async () => {
const backend = createFakeBackend();
const user = renderFeed(backend);
renderWithApp(<FeedScreen onNavigate={vi.fn()} />);
await screen.findByText('Hello from the feed.');
await user.click(screen.getByRole('button', { name: /Refresh/i }));
await waitFor(() => {
expect(backend.requests.filter((r) => r.method === 'feed_get').length).toBeGreaterThanOrEqual(
2,
);
});
});
});

View file

@ -1,13 +1,14 @@
import type {
AppState,
BackendResponse,
FeedItem,
ProfileSummary,
PublishReport,
RelayTestResult,
Settings,
SignerStatus,
} from '../lib/types';
import { makePublishReport, makeRelayTest, makeSignerStatus, makeState } from './apiMock';
import { ALICE, makePublishReport, makeRelayTest, makeSignerStatus, makeState } from './apiMock';
/**
* An in-memory stand-in for the Rust `serve` IPC server. Exposes the same
@ -38,6 +39,8 @@ export interface FakeBackend {
/** Current NIP-46 signer status. */
signer: SignerStatus;
setSigner: (next: SignerStatus) => void;
/** Notes returned by `feed_get`. */
feedItems: FeedItem[];
}
export function createFakeBackend(initial?: AppState): FakeBackend {
@ -93,6 +96,24 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
setSigner(next) {
backend.signer = next;
},
feedItems: [
{
id: 'note1aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
author: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f',
author_npub: ALICE,
content: 'Hello from the feed.',
created_at: 1700000300,
relays: ['wss://relay.damus.io'],
},
{
id: 'note1bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
author: 'aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899',
author_npub: 'npub1ccc',
content: 'A second note worth reading.',
created_at: 1700000200,
relays: ['wss://relay.damus.io', 'wss://relay.nostr.band'],
},
],
};
async function dispatch(method: string, params: Record<string, unknown>): Promise<unknown> {
@ -148,6 +169,9 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
return report;
}
case 'feed_get':
return [...backend.feedItems];
case 'pick_image':
return [...backend.pickedImages];

259
src/feed.rs Normal file
View file

@ -0,0 +1,259 @@
//! Feed aggregation: fetch recent kind 1 text notes from the enabled relays.
//!
//! This is a read-only view of the public Nostr network. It uses a throwaway
//! key so the user's stored keys never touch the network while reading, and it
//! never signs or publishes anything.
use std::collections::HashMap;
use std::time::Duration;
use nostr_sdk::prelude::*;
use serde::Serialize;
use crate::errors::AppError;
use crate::relays;
use crate::settings::Settings;
/// How long to wait for relays to accept a connection attempt.
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
/// How long to keep listening for events before returning what we have.
const QUERY_TIMEOUT: Duration = Duration::from_secs(15);
/// Only fetch notes newer than this window.
const LOOKBACK: Duration = Duration::from_secs(24 * 3600);
/// Default number of notes to fetch when the caller does not specify one.
pub const DEFAULT_LIMIT: usize = 50;
/// A single note shown in the aggregated feed.
#[derive(Debug, Clone, Serialize)]
pub struct FeedItem {
/// Bech32 note id.
pub id: String,
/// Hex public key of the author.
pub author: String,
/// Bech32 `npub` of the author, for display.
pub author_npub: String,
pub content: String,
/// Unix timestamp the note was created.
pub created_at: u64,
/// Relays that served this note (de-duplicated, first-seen order).
pub relays: Vec<String>,
}
/// Pull recent kind 1 notes from every enabled relay.
///
/// Returns notes sorted newest-first, de-duplicated across relays, limited to
/// `limit`. When no notes come back (or no relays are enabled) it returns an
/// empty list; callers treat that as a quiet, empty feed.
pub async fn aggregate_feed(settings: &Settings, limit: usize) -> Result<Vec<FeedItem>, AppError> {
let relay_urls = relays::enabled_urls(settings);
if relay_urls.is_empty() {
return Ok(Vec::new());
}
let effective_limit = if limit == 0 { DEFAULT_LIMIT } else { limit };
// A throwaway identity keeps reading the network completely off the user's keys.
let client = Client::new(Keys::generate());
for url in &relay_urls {
client
.add_relay(url.as_str())
.await
.map_err(|e| AppError::network(format!("Could not add relay {url}: {e}")))?;
}
client.connect().await;
client.wait_for_connection(CONNECT_TIMEOUT).await;
let since = Timestamp::now() - LOOKBACK;
let filter = Filter::new()
.kind(Kind::TextNote)
.since(since)
.limit(effective_limit);
client
.subscribe(filter, None)
.await
.map_err(|e| AppError::network(format!("Could not subscribe for the feed: {e}")))?;
let mut feed = FeedBuilder::new(effective_limit);
let mut notifications = client.notifications();
let deadline = tokio::time::Instant::now() + QUERY_TIMEOUT;
loop {
match tokio::time::timeout_at(deadline, notifications.recv()).await {
Ok(Ok(RelayPoolNotification::Event {
event, relay_url, ..
})) => {
if !feed.add(&event, Some(relay_url)) {
break;
}
}
Ok(Ok(_)) => continue,
Ok(Err(_)) | Err(_) => break,
}
}
client.disconnect().await;
Ok(feed.finish())
}
/// Accumulates notes into a bounded, de-duplicated, newest-first feed.
struct FeedBuilder {
items: HashMap<String, FeedItem>,
limit: usize,
}
impl FeedBuilder {
fn new(limit: usize) -> Self {
Self {
items: HashMap::new(),
limit,
}
}
/// Remember a note. Returns `true` when the feed is not yet full and the
/// reader should keep listening; `false` when we have enough.
fn add(&mut self, event: &Event, relay: Option<RelayUrl>) -> bool {
if event.kind != Kind::TextNote {
return self.items.len() < self.limit;
}
let id = event.id.to_hex();
if let Some(entry) = self.items.get_mut(&id) {
// Already seen (e.g. on another relay): only record the extra source.
if let Some(url) = relay {
let url = url.to_string();
if !entry.relays.contains(&url) {
entry.relays.push(url);
}
}
return true;
}
if self.items.len() >= self.limit {
return false;
}
if let Some(item) = FeedItem::from_event(event, relay) {
self.items.insert(id, item);
}
self.items.len() < self.limit
}
/// Fold the collected notes into a sorted, truncated list.
fn finish(self) -> Vec<FeedItem> {
let mut items: Vec<FeedItem> = self.items.into_values().collect();
items.sort_by_key(|b| std::cmp::Reverse(b.created_at));
items.truncate(self.limit);
items
}
}
impl FeedItem {
/// Build a feed item from a relay event. Returns `None` only if one of the
/// bech32 encodings fails, which should not happen for a valid event.
fn from_event(event: &Event, relay: Option<RelayUrl>) -> Option<FeedItem> {
let id = event.id.to_bech32().ok()?;
let author_npub = event.pubkey.to_bech32().ok()?;
Some(FeedItem {
id,
author: event.pubkey.to_hex(),
author_npub,
content: event.content.trim().to_string(),
created_at: event.created_at.as_u64(),
relays: relay.map(|url| vec![url.to_string()]).unwrap_or_default(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
async fn note(content: &str, created_at: u64) -> Event {
let keys = Keys::generate();
EventBuilder::new(Kind::TextNote, content.to_string())
.custom_created_at(Timestamp::from(created_at))
.sign(&keys)
.await
.unwrap()
}
#[test]
fn empty_no_relays_yields_empty_feed() {
let settings = Settings {
relays: Vec::new(),
..Default::default()
};
let runtime = tokio::runtime::Runtime::new().unwrap();
let feed = runtime
.block_on(aggregate_feed(&settings, DEFAULT_LIMIT))
.unwrap();
assert!(feed.is_empty());
}
#[test]
fn finish_sorts_newest_first() {
let mut builder = FeedBuilder::new(10);
for created in [10u64, 50, 30] {
let id = format!("event-{created}");
builder.items.insert(
id.clone(),
FeedItem {
id,
author: "a".into(),
author_npub: "npub1a".into(),
content: "c".into(),
created_at: created,
relays: vec![],
},
);
}
let items = builder.finish();
let order: Vec<u64> = items.iter().map(|i| i.created_at).collect();
assert_eq!(order, vec![50, 30, 10]);
}
#[tokio::test]
async fn duplicate_events_from_multi_relays_dedupe() {
let event = note("hello feed", 100).await;
let mut builder = FeedBuilder::new(10);
builder.add(&event, Some(RelayUrl::parse("wss://a.example").unwrap()));
builder.add(&event, Some(RelayUrl::parse("wss://b.example").unwrap()));
let items = builder.finish();
assert_eq!(items.len(), 1);
assert_eq!(items[0].content, "hello feed");
assert_eq!(items[0].relays.len(), 2);
}
#[tokio::test]
async fn non_text_note_kinds_are_ignored() {
let mut builder = FeedBuilder::new(10);
let keys = Keys::generate();
let other = EventBuilder::new(Kind::Metadata, "{}")
.sign(&keys)
.await
.unwrap();
builder.add(&other, None);
assert!(builder.items.is_empty());
}
#[tokio::test]
async fn limit_stops_collection_when_full() {
let mut builder = FeedBuilder::new(2);
let a = note("a", 3).await;
let b = note("b", 2).await;
let c = note("c", 1).await;
assert!(builder.add(&a, None));
assert!(
!builder.add(&b, None),
"filling the feed must stop collection"
);
assert!(!builder.add(&c, None), "full feed must reject more notes");
assert_eq!(builder.items.len(), 2);
}
#[test]
fn zero_limit_still_returns_an_empty_feed_without_relays() {
let settings = Settings {
relays: Vec::new(),
..Default::default()
};
let runtime = tokio::runtime::Runtime::new().unwrap();
let feed = runtime.block_on(aggregate_feed(&settings, 0)).unwrap();
assert!(feed.is_empty());
}
}

View file

@ -6,6 +6,7 @@ use serde_json::json;
use crate::app::App;
use crate::errors::AppError;
use crate::feed;
use crate::profiles;
use crate::publish;
use crate::relays;
@ -40,6 +41,11 @@ pub enum Request {
PublishNote {
content: String,
},
/// Fetch a bounded, newest-first list of recent notes from enabled relays.
FeedGet {
/// Optional cap on how many notes to return; leave `None` for the default.
limit: Option<usize>,
},
RelayAdd {
url: String,
},
@ -291,6 +297,12 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
Ok(json!(report))
}
Request::FeedGet { limit } => {
let items =
feed::aggregate_feed(&app.settings, limit.unwrap_or(feed::DEFAULT_LIMIT)).await?;
Ok(json!(items))
}
Request::RelayAdd { url } => {
relays::add_relay(&mut app.settings, &url)?;
app.save_settings()?;

View file

@ -1,6 +1,7 @@
pub mod app;
pub mod crypto;
pub mod errors;
pub mod feed;
pub mod ipc;
pub mod profiles;
pub mod publish;

View file

@ -19,6 +19,7 @@ Commands:
list List stored profiles (no secret keys)
switch <npub> Select the active profile
publish <npub> <content> Publish a text note as a specific profile
feed [limit] Fetch recent notes from enabled relays (default 50)
relays list List configured relays
relays add <url> Add a relay
relays remove <url> Remove a relay
@ -62,6 +63,7 @@ async fn main() -> ExitCode {
"list" => cli_list(),
"switch" => cli_switch(&args),
"publish" => cli_publish(&args).await,
"feed" => cli_feed(&args).await,
"relays" => cli_relays(&args).await,
"settings" => cli_settings(&args),
"set-password" => cli_set_password(),
@ -156,6 +158,39 @@ async fn cli_publish(args: &[String]) -> Result<String, AppError> {
Ok(lines.join("\n"))
}
async fn cli_feed(args: &[String]) -> Result<String, AppError> {
let limit = args
.get(2)
.and_then(|raw| raw.parse::<usize>().ok().filter(|n| *n > 0))
.unwrap_or(nostr_manager_backend::feed::DEFAULT_LIMIT);
let app = App::load()?;
let items = nostr_manager_backend::feed::aggregate_feed(&app.settings, limit).await?;
if items.is_empty() {
return Ok("No notes found on the enabled relays in the last 24 hours.".to_string());
}
let mut lines = vec![format!("{} recent note(s):", items.len())];
for item in items {
lines.push(format!(
"• {} | {} | {}",
shorten_note(&item.content),
item.author_npub,
item.created_at
));
}
Ok(lines.join("\n"))
}
/// A one-line preview of a note for the CLI feed.
fn shorten_note(content: &str) -> String {
let collapsed = content.split_whitespace().collect::<Vec<_>>().join(" ");
if collapsed.chars().count() > 200 {
let truncated: String = collapsed.chars().take(200).collect();
format!("{truncated}")
} else {
collapsed
}
}
async fn cli_relays(args: &[String]) -> Result<String, AppError> {
let sub = args.get(2).ok_or_else(|| {
AppError::config(