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 {