diff --git a/README.md b/README.md index 7ccfff6..2304ccf 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,8 @@ distribution repositories. Expect some API churn until 1.0. No Docker or databas - **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** — 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 + enabled relays (24h window), de-duplicated across relays, from a dedicated GUI screen or the CLI. + Switch the feed to **My contacts** to show only notes from the active profile's kind 3 contact list - **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 @@ -211,7 +212,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 -- feed [--contacts] [limit] # fetch recent notes; --contacts = your contacts 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/lib/api.ts b/frontend/src/lib/api.ts index bedd554..af54501 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -51,7 +51,11 @@ 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 } : {}), + feedGet: (limit?: number, contactsOnly = false) => + call('feed_get', { + ...(limit ? { limit } : {}), + ...(contactsOnly ? { contacts_only: true } : {}), + }), relayAdd: (url: string) => call('relay_add', { url }), relayRemove: (url: string) => call('relay_remove', { url }), relaySetEnabled: (url: string, enabled: boolean) => diff --git a/frontend/src/screens/FeedScreen.tsx b/frontend/src/screens/FeedScreen.tsx index 9655afb..4dff080 100644 --- a/frontend/src/screens/FeedScreen.tsx +++ b/frontend/src/screens/FeedScreen.tsx @@ -15,15 +15,21 @@ interface FeedScreenProps { onNavigate: (screen: Screen) => void; } +type FeedScope = 'everyone' | 'contacts'; + export function FeedScreen({ onNavigate }: FeedScreenProps) { const { state, feedGet } = useApp(); const [items, setItems] = useState([]); + const [scope, setScope] = useState('everyone'); 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 hasActiveProfile = state?.active_profile != null; + const contactsScope = scope === 'contacts'; + const effectiveScope: FeedScope = contactsScope && !hasActiveProfile ? 'everyone' : scope; const load = useCallback( async (background: boolean) => { @@ -34,7 +40,7 @@ export function FeedScreen({ onNavigate }: FeedScreenProps) { setLoading(true); } try { - setItems(await feedGet()); + setItems(await feedGet(undefined, effectiveScope === 'contacts')); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { @@ -42,7 +48,7 @@ export function FeedScreen({ onNavigate }: FeedScreenProps) { setRefreshing(false); } }, - [feedGet], + [feedGet, effectiveScope], ); useEffect(() => { @@ -90,10 +96,38 @@ export function FeedScreen({ onNavigate }: FeedScreenProps) { {enabledRelays.length === 1 ? '' : 's'}.

- +
+
+ + +
+ +
{error && ( @@ -106,9 +140,13 @@ export function FeedScreen({ onNavigate }: FeedScreenProps) { ) : 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." + icon={} + title={effectiveScope === 'contacts' ? 'No notes from your contacts' : 'No notes found'} + description={ + effectiveScope === 'contacts' + ? 'No notes were returned from the people the active profile follows in the last 24 hours. Try refreshing, or switch to Everyone.' + : 'No notes were returned by the enabled relays in the last 24 hours. Try refreshing or check the relays screen.' + } /> ) : (
    diff --git a/frontend/src/state/AppProvider.tsx b/frontend/src/state/AppProvider.tsx index a3112a3..7be9de1 100644 --- a/frontend/src/state/AppProvider.tsx +++ b/frontend/src/state/AppProvider.tsx @@ -41,7 +41,7 @@ interface AppContextValue { publishNote: (content: string) => Promise; recordPublishFailure: (message: string, details?: string | null) => void; clearLastPublish: () => void; - feedGet: (limit?: number) => Promise; + feedGet: (limit?: number, contactsOnly?: boolean) => Promise; relayAdd: (url: string) => Promise; relayRemove: (url: string) => Promise; relaySetEnabled: (url: string, enabled: boolean) => Promise; @@ -126,7 +126,9 @@ export function AppProvider({ children }: { children: ReactNode }) { setLastPublish(null); }, []); - const feedGet = useCallback((limit?: number) => api.feedGet(limit), []); + const feedGet = useCallback((limit?: number, contactsOnly?: boolean) => { + return api.feedGet(limit, contactsOnly); + }, []); const applySettings = useCallback((fresh: Settings) => { setState((prev) => (prev ? { ...prev, settings: fresh } : prev)); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index ed4f42c..cb20666 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -202,6 +202,56 @@ a { font-size: 14px; } +.feed-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.segmented { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 3px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 8px; +} + +.segmented-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 10px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--text-muted); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: + background 0.12s ease, + color 0.12s ease; +} + +.segmented-btn:hover:not(:disabled):not(.is-active) { + background: var(--surface-hover); + color: var(--text); +} + +.segmented-btn.is-active { + background: var(--surface); + color: var(--text); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12); +} + +.segmented-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + /* ------------------------------------------------------------------------- Sidebar ------------------------------------------------------------------------- */ diff --git a/frontend/src/test/FeedScreen.test.tsx b/frontend/src/test/FeedScreen.test.tsx index c478040..d0bacc8 100644 --- a/frontend/src/test/FeedScreen.test.tsx +++ b/frontend/src/test/FeedScreen.test.tsx @@ -68,4 +68,43 @@ describe('FeedScreen', () => { ); }); }); + + it('filters to contacts when the "My contacts" scope is selected', async () => { + const backend = createFakeBackend(); + const user = renderFeed(backend); + renderWithApp(); + + await screen.findByText('Hello from the feed.'); + await user.click(screen.getByRole('button', { name: /My contacts/i })); + + expect(await screen.findByText('A note from a contact.')).toBeInTheDocument(); + expect(screen.queryByText('Hello from the feed.')).not.toBeInTheDocument(); + await waitFor(() => { + const contactsRequest = backend.requests.find( + (r) => r.method === 'feed_get' && r.params.contacts_only === true, + ); + expect(contactsRequest).toBeDefined(); + }); + }); + + it('disables the contacts scope when no profile is active', async () => { + const backend = createFakeBackend(makeEmptyState()); + renderFeed(backend); + renderWithApp(); + + const contactsButton = await screen.findByRole('button', { name: /My contacts/i }); + expect(contactsButton).toBeDisabled(); + }); + + it('shows a contacts empty state when there are no contact notes', async () => { + const backend = createFakeBackend(); + backend.contactFeedItems = []; + const user = renderFeed(backend); + renderWithApp(); + + await screen.findByText('Hello from the feed.'); + await user.click(screen.getByRole('button', { name: /My contacts/i })); + + expect(await screen.findByText('No notes from your contacts')).toBeInTheDocument(); + }); }); diff --git a/frontend/src/test/fakeBackend.ts b/frontend/src/test/fakeBackend.ts index 43c4148..06f441d 100644 --- a/frontend/src/test/fakeBackend.ts +++ b/frontend/src/test/fakeBackend.ts @@ -41,6 +41,8 @@ export interface FakeBackend { setSigner: (next: SignerStatus) => void; /** Notes returned by `feed_get`. */ feedItems: FeedItem[]; + /** Notes returned by `feed_get` with `contacts_only: true`. */ + contactFeedItems: FeedItem[]; } export function createFakeBackend(initial?: AppState): FakeBackend { @@ -114,6 +116,16 @@ export function createFakeBackend(initial?: AppState): FakeBackend { relays: ['wss://relay.damus.io', 'wss://relay.nostr.band'], }, ], + contactFeedItems: [ + { + id: 'note1cccccccccccccccccccccccccccccccccccccccccccccccc', + author: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f', + author_npub: ALICE, + content: 'A note from a contact.', + created_at: 1700000100, + relays: ['wss://relay.damus.io'], + }, + ], }; async function dispatch(method: string, params: Record): Promise { @@ -169,8 +181,16 @@ export function createFakeBackend(initial?: AppState): FakeBackend { return report; } - case 'feed_get': + case 'feed_get': { + const contactsOnly = Boolean(params.contacts_only); + if (contactsOnly) { + if (!state.active_profile) { + throw new Error('No profile is selected. Choose a profile before publishing.'); + } + return [...backend.contactFeedItems]; + } return [...backend.feedItems]; + } case 'pick_image': return [...backend.pickedImages]; diff --git a/src/feed.rs b/src/feed.rs index c88a4ef..19258f2 100644 --- a/src/feed.rs +++ b/src/feed.rs @@ -4,7 +4,7 @@ //! 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::collections::{HashMap, HashSet}; use std::time::Duration; use nostr_sdk::prelude::*; @@ -14,6 +14,18 @@ use crate::errors::AppError; use crate::relays; use crate::settings::Settings; +/// Resolve a profile's bech32 `npub` (or hex) back to a `PublicKey` for use as +/// a feed owner. Exposed so the CLI and IPC can share one parser. +pub fn owner_pubkey(npub: &str) -> Result { + if npub.starts_with("npub1") { + PublicKey::from_bech32(npub) + .map_err(|e| AppError::internal(format!("Could not parse the profile public key: {e}"))) + } else { + PublicKey::from_hex(npub) + .map_err(|e| AppError::internal(format!("Could not parse the profile public key: {e}"))) + } +} + /// 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. @@ -39,13 +51,94 @@ pub struct FeedItem { pub relays: Vec, } -/// Pull recent kind 1 notes from every enabled relay. +/// 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); + aggregate_for(settings, limit, None).await +} + +/// A contact-aware feed: only notes whose authors are followed by a profile. +/// +/// Fetches the profile's kind 3 contact list from the enabled relays, then +/// fetches recent kind 1 notes limited to those authors. When the profile has +/// no contacts (or none are found) it returns an empty list. +pub async fn contact_feed( + settings: &Settings, + limit: usize, + owner_hex: &str, +) -> Result, AppError> { + let contacts = contact_pubkeys(settings, owner_hex).await?; + if contacts.is_empty() { + return Ok(Vec::new()); + } + aggregate_for(settings, limit, Some(contacts)).await +} + +/// Fetch the hex public keys followed by an owner profile (kind 3 contact list). +async fn contact_pubkeys(settings: &Settings, owner_hex: &str) -> Result, AppError> { + let owner = owner_pubkey(owner_hex)?; + let relay_urls = enabled_relays(settings); + if relay_urls.is_empty() { + return Ok(Vec::new()); + } + + 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 filter = Filter::new().kind(Kind::ContactList).author(owner); + client + .subscribe(filter, None) + .await + .map_err(|e| AppError::network(format!("Could not subscribe for contacts: {e}")))?; + + let mut contacts: HashSet = HashSet::new(); + 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, .. })) => { + if event.kind == Kind::ContactList { + for pubkey in event + .tags + .iter() + .filter_map(|tag| match tag.as_standardized() { + Some(TagStandard::PublicKey { public_key, .. }) => Some(*public_key), + _ => None, + }) + { + contacts.insert(pubkey); + } + } + } + Ok(Ok(_)) => continue, + Ok(Err(_)) | Err(_) => break, + } + } + + client.disconnect().await; + let mut contacts: Vec = contacts.into_iter().collect(); + contacts.sort(); + Ok(contacts) +} + +/// Shared feed query. When `authors` is `Some`, the query only looks at notes +/// from those authors and the builder drops notes from anyone else. +async fn aggregate_for( + settings: &Settings, + limit: usize, + authors: Option>, +) -> Result, AppError> { + let relay_urls = enabled_relays(settings); if relay_urls.is_empty() { return Ok(Vec::new()); } @@ -67,12 +160,16 @@ pub async fn aggregate_feed(settings: &Settings, limit: usize) -> Result filter.authors(authors.iter().copied()), + None => filter, + }; 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 feed = FeedBuilder::new(effective_limit, authors.as_deref()); let mut notifications = client.notifications(); let deadline = tokio::time::Instant::now() + QUERY_TIMEOUT; loop { @@ -93,17 +190,25 @@ pub async fn aggregate_feed(settings: &Settings, limit: usize) -> Result Vec { + relays::enabled_urls(settings) +} + /// Accumulates notes into a bounded, de-duplicated, newest-first feed. struct FeedBuilder { items: HashMap, limit: usize, + /// When set, notes from anyone else are ignored (see `add`). + authors: Option>, } impl FeedBuilder { - fn new(limit: usize) -> Self { + fn new(limit: usize, authors: Option<&[PublicKey]>) -> Self { Self { items: HashMap::new(), limit, + authors: authors.map(|list| list.iter().copied().collect::>()), } } @@ -113,6 +218,11 @@ impl FeedBuilder { if event.kind != Kind::TextNote { return self.items.len() < self.limit; } + if let Some(authors) = &self.authors { + if !authors.contains(&event.pubkey) { + 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. @@ -187,7 +297,7 @@ mod tests { #[test] fn finish_sorts_newest_first() { - let mut builder = FeedBuilder::new(10); + let mut builder = FeedBuilder::new(10, None); for created in [10u64, 50, 30] { let id = format!("event-{created}"); builder.items.insert( @@ -210,7 +320,7 @@ mod tests { #[tokio::test] async fn duplicate_events_from_multi_relays_dedupe() { let event = note("hello feed", 100).await; - let mut builder = FeedBuilder::new(10); + let mut builder = FeedBuilder::new(10, None); builder.add(&event, Some(RelayUrl::parse("wss://a.example").unwrap())); builder.add(&event, Some(RelayUrl::parse("wss://b.example").unwrap())); let items = builder.finish(); @@ -221,7 +331,7 @@ mod tests { #[tokio::test] async fn non_text_note_kinds_are_ignored() { - let mut builder = FeedBuilder::new(10); + let mut builder = FeedBuilder::new(10, None); let keys = Keys::generate(); let other = EventBuilder::new(Kind::Metadata, "{}") .sign(&keys) @@ -233,7 +343,7 @@ mod tests { #[tokio::test] async fn limit_stops_collection_when_full() { - let mut builder = FeedBuilder::new(2); + let mut builder = FeedBuilder::new(2, None); let a = note("a", 3).await; let b = note("b", 2).await; let c = note("c", 1).await; @@ -246,6 +356,59 @@ mod tests { assert_eq!(builder.items.len(), 2); } + #[tokio::test] + async fn authors_filter_keeps_only_those_public_keys() { + let followed = Keys::generate(); + let stranger = Keys::generate(); + let mut builder = FeedBuilder::new(10, Some(&[followed.public_key()])); + + let from_followed = EventBuilder::new(Kind::TextNote, "from a contact".to_string()) + .custom_created_at(Timestamp::from(5)) + .sign(&followed) + .await + .unwrap(); + let from_stranger = EventBuilder::new(Kind::TextNote, "from a stranger".to_string()) + .custom_created_at(Timestamp::from(6)) + .sign(&stranger) + .await + .unwrap(); + + assert!(builder.add(&from_followed, None)); + assert!( + builder.add(&from_stranger, None), + "a stranger's note must be skipped, not fill the feed" + ); + + let items = builder.finish(); + assert_eq!(items.len(), 1); + assert_eq!(items[0].content, "from a contact"); + assert_eq!(items[0].author, followed.public_key().to_hex()); + } + + #[tokio::test] + async fn contact_feed_with_no_relays_is_empty() { + let settings = Settings { + relays: Vec::new(), + ..Default::default() + }; + let feed = contact_feed(&settings, DEFAULT_LIMIT, "00".repeat(32).as_str()) + .await + .unwrap(); + assert!(feed.is_empty()); + } + + #[tokio::test] + async fn contact_feed_with_invalid_owner_errors() { + let settings = Settings { + relays: Vec::new(), + ..Default::default() + }; + let err = contact_feed(&settings, DEFAULT_LIMIT, "not-hex") + .await + .expect_err("an invalid hex owner must error"); + assert_eq!(err.kind(), crate::errors::ErrorKind::Internal); + } + #[test] fn zero_limit_still_returns_an_empty_feed_without_relays() { let settings = Settings { diff --git a/src/ipc.rs b/src/ipc.rs index a8e392f..450c5bc 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -45,6 +45,9 @@ pub enum Request { FeedGet { /// Optional cap on how many notes to return; leave `None` for the default. limit: Option, + /// When true, only return notes authored by the active profile's + /// contacts. When no active profile is selected the request errors. + contacts_only: Option, }, RelayAdd { url: String, @@ -297,9 +300,22 @@ 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?; + Request::FeedGet { + limit, + contacts_only, + } => { + let limit = limit.unwrap_or(feed::DEFAULT_LIMIT); + let items = if contacts_only.unwrap_or(false) { + let npub = app + .vault + .active_profile + .as_deref() + .ok_or_else(AppError::no_active_profile)?; + let pubkey = feed::owner_pubkey(npub)?; + feed::contact_feed(&app.settings, limit, &pubkey.to_hex()).await? + } else { + feed::aggregate_feed(&app.settings, limit).await? + }; Ok(json!(items)) } diff --git a/src/main.rs b/src/main.rs index 2e96a13..f4a6768 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,7 +19,8 @@ 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) + feed [--contacts] [limit] Fetch recent notes from enabled relays (default 50); + --contacts filters to the active profile's contacts relays list List configured relays relays add Add a relay relays remove Remove a relay @@ -159,14 +160,36 @@ async fn cli_publish(args: &[String]) -> Result { } async fn cli_feed(args: &[String]) -> Result { - let limit = args - .get(2) + let mut contacts = false; + let mut rest = &args[2..]; + if rest.first().map(|s| s.as_str()) == Some("--contacts") { + contacts = true; + rest = &rest[1..]; + } + let limit = rest + .first() .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?; + let items = if contacts { + let npub = app + .vault + .active_profile + .as_deref() + .ok_or_else(AppError::no_active_profile)?; + let pubkey = nostr_manager_backend::feed::owner_pubkey(npub)?; + nostr_manager_backend::feed::contact_feed(&app.settings, limit, &pubkey.to_hex()).await? + } else { + 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()); + return Ok(if contacts { + "No notes found from your contacts on the enabled relays in the last 24 hours." + .to_string() + } else { + "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 {