diff --git a/README.md b/README.md index ce562fc..7ccfff6 100644 --- a/README.md +++ b/README.md @@ -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 # select the active profile cargo run --release -- publish "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 cargo run --release -- set-password # encrypt the vault (or change its password) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 49ae62b..be6814f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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' && ( setCreateOpen(true)} /> )} + {screen === 'feed' && } {screen === 'profiles' && setCreateOpen(true)} />} {screen === 'compose' && } {screen === 'relays' && } diff --git a/frontend/src/components/Icon.tsx b/frontend/src/components/Icon.tsx index 50efe32..318d3d6 100644 --- a/frontend/src/components/Icon.tsx +++ b/frontend/src/components/Icon.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from 'react'; export type IconName = | 'home' + | 'list' | 'users' | 'edit' | 'relay' @@ -32,6 +33,12 @@ const PATHS: Record = { ), + list: ( + <> + + + + ), edit: , relay: ( <> diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 1d1aa0a..3337ffb 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -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' }, diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index e95b3a3..bedd554 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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('select_profile', { npub }), publishNote: (content: string) => call('publish_note', { content }), + feedGet: (limit?: number) => call('feed_get', limit ? { limit } : {}), relayAdd: (url: string) => call('relay_add', { url }), relayRemove: (url: string) => call('relay_remove', { url }), relaySetEnabled: (url: string, enabled: boolean) => diff --git a/frontend/src/lib/navigation.ts b/frontend/src/lib/navigation.ts index b0a3173..ee97e85 100644 --- a/frontend/src/lib/navigation.ts +++ b/frontend/src/lib/navigation.ts @@ -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 = { home: 'Home', + feed: 'Feed', profiles: 'Profiles', compose: 'Compose', relays: 'Relays', diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 85b4a66..89ff526 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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; diff --git a/frontend/src/screens/FeedScreen.tsx b/frontend/src/screens/FeedScreen.tsx new file mode 100644 index 0000000..9655afb --- /dev/null +++ b/frontend/src/screens/FeedScreen.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(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 ( +
+
+ } + title="No relays enabled" + description={ + + The feed reads notes from your enabled relays. Enable at least one relay to see + recent notes here. + + } + action={ + + } + /> +
+
+ ); + } + + return ( +
+
+
+
+

Feed

+

+ Recent notes aggregated from your {enabledRelays.length} enabled relay + {enabledRelays.length === 1 ? '' : 's'}. +

+
+ +
+ + {error && ( + + {error} + + )} + + {loading ? ( + + ) : items.length === 0 ? ( + } + 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." + /> + ) : ( +
    + {items.map((item) => ( +
  • + +
    +
    + + {shortenNpub(item.author_npub, shorten)} + + {formatDate(item.created_at)} + {item.relays.length > 1 && ( + {item.relays.length} relays + )} +
    +

    {item.content}

    + + {shortenNpub(item.id, true)} + +
    +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/state/AppProvider.tsx b/frontend/src/state/AppProvider.tsx index e8e8f88..a3112a3 100644 --- a/frontend/src/state/AppProvider.tsx +++ b/frontend/src/state/AppProvider.tsx @@ -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; recordPublishFailure: (message: string, details?: string | null) => void; clearLastPublish: () => void; + feedGet: (limit?: number) => Promise; relayAdd: (url: string) => Promise; relayRemove: (url: string) => Promise; relaySetEnabled: (url: string, enabled: boolean) => Promise; @@ -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, diff --git a/frontend/src/test/FeedScreen.test.tsx b/frontend/src/test/FeedScreen.test.tsx new file mode 100644 index 0000000..c478040 --- /dev/null +++ b/frontend/src/test/FeedScreen.test.tsx @@ -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) { + installFakeBackend(backend); + return userEvent.setup(); +} + +describe('FeedScreen', () => { + it('lists recent notes with author, content and time', async () => { + const backend = createFakeBackend(); + renderFeed(backend); + renderWithApp(); + + 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(); + + 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(); + + 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(); + + 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, + ); + }); + }); +}); diff --git a/frontend/src/test/fakeBackend.ts b/frontend/src/test/fakeBackend.ts index 12affd6..43c4148 100644 --- a/frontend/src/test/fakeBackend.ts +++ b/frontend/src/test/fakeBackend.ts @@ -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): Promise { @@ -148,6 +169,9 @@ export function createFakeBackend(initial?: AppState): FakeBackend { return report; } + case 'feed_get': + return [...backend.feedItems]; + case 'pick_image': return [...backend.pickedImages]; diff --git a/src/feed.rs b/src/feed.rs new file mode 100644 index 0000000..c88a4ef --- /dev/null +++ b/src/feed.rs @@ -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, +} + +/// 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, 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, + 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) -> 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 { + let mut items: Vec = 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) -> Option { + 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 = 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()); + } +} diff --git a/src/ipc.rs b/src/ipc.rs index d205fc4..a8e392f 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -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, + }, RelayAdd { url: String, }, @@ -291,6 +297,12 @@ async fn run_with_app(app: &mut App, request: Request) -> Result { + 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()?; diff --git a/src/lib.rs b/src/lib.rs index 18eedbf..fd7547f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/main.rs b/src/main.rs index f0b5483..2e96a13 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,6 +19,7 @@ Commands: list List stored profiles (no secret keys) switch Select the active profile publish 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 Add a relay relays remove 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 { Ok(lines.join("\n")) } +async fn cli_feed(args: &[String]) -> Result { + let limit = args + .get(2) + .and_then(|raw| raw.parse::().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::>().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 { let sub = args.get(2).ok_or_else(|| { AppError::config(