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:
parent
8cbe0b81bc
commit
37a9d945c9
15 changed files with 581 additions and 3 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 />}
|
||||
|
|
|
|||
|
|
@ -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: (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
140
frontend/src/screens/FeedScreen.tsx
Normal file
140
frontend/src/screens/FeedScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
71
frontend/src/test/FeedScreen.test.tsx
Normal file
71
frontend/src/test/FeedScreen.test.tsx
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
259
src/feed.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
12
src/ipc.rs
12
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<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()?;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
35
src/main.rs
35
src/main.rs
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue