Add contact-aware feed scope
- src/feed.rs: contact_feed + contact_pubkeys fetch the active profile's kind 3 contact list and aggregate notes only from those authors; aggregate_for + FeedBuilder accept an optional author whitelist - IPC: FeedGet accepts contacts_only, resolves the active profile npub - CLI: feed --contacts [limit] filters to the active profile's contacts - Frontend: Feed screen Everyone/My contacts toggle, scope-aware empty states, feedGet(limit, contactsOnly) threading - Tests for author filtering and the contacts scope in feed.rs and FeedScreen.test.tsx
This commit is contained in:
parent
1fd057d3e1
commit
a1445d17a8
10 changed files with 388 additions and 32 deletions
|
|
@ -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
|
- **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
|
- **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
|
- **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
|
- **Encrypted vault** — secret keys encrypted at rest with **AES-256-GCM** under a key derived via
|
||||||
**Argon2id** from your password
|
**Argon2id** from your password
|
||||||
- **Secret key recovery** — reveal a key (hex + `nsec1...`) only after unlocking, from GUI or CLI
|
- **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 -- list # list profiles (no secret keys)
|
||||||
cargo run --release -- switch <npub> # select the active profile
|
cargo run --release -- switch <npub> # select the active profile
|
||||||
cargo run --release -- publish <npub> "Hello" # publish a text note
|
cargo run --release -- publish <npub> "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 -- relays list|add|remove|enable|disable|test
|
||||||
cargo run --release -- settings get|set <key> <value>
|
cargo run --release -- settings get|set <key> <value>
|
||||||
cargo run --release -- set-password # encrypt the vault (or change its password)
|
cargo run --release -- set-password # encrypt the vault (or change its password)
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,11 @@ export const api = {
|
||||||
call<{ profile: ProfileSummary; state: AppState }>('create_profile', { label }),
|
call<{ profile: ProfileSummary; state: AppState }>('create_profile', { label }),
|
||||||
selectProfile: (npub: string) => call<AppState>('select_profile', { npub }),
|
selectProfile: (npub: string) => call<AppState>('select_profile', { npub }),
|
||||||
publishNote: (content: string) => call<PublishReport>('publish_note', { content }),
|
publishNote: (content: string) => call<PublishReport>('publish_note', { content }),
|
||||||
feedGet: (limit?: number) => call<FeedItem[]>('feed_get', limit ? { limit } : {}),
|
feedGet: (limit?: number, contactsOnly = false) =>
|
||||||
|
call<FeedItem[]>('feed_get', {
|
||||||
|
...(limit ? { limit } : {}),
|
||||||
|
...(contactsOnly ? { contacts_only: true } : {}),
|
||||||
|
}),
|
||||||
relayAdd: (url: string) => call<Settings>('relay_add', { url }),
|
relayAdd: (url: string) => call<Settings>('relay_add', { url }),
|
||||||
relayRemove: (url: string) => call<Settings>('relay_remove', { url }),
|
relayRemove: (url: string) => call<Settings>('relay_remove', { url }),
|
||||||
relaySetEnabled: (url: string, enabled: boolean) =>
|
relaySetEnabled: (url: string, enabled: boolean) =>
|
||||||
|
|
|
||||||
|
|
@ -15,15 +15,21 @@ interface FeedScreenProps {
|
||||||
onNavigate: (screen: Screen) => void;
|
onNavigate: (screen: Screen) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FeedScope = 'everyone' | 'contacts';
|
||||||
|
|
||||||
export function FeedScreen({ onNavigate }: FeedScreenProps) {
|
export function FeedScreen({ onNavigate }: FeedScreenProps) {
|
||||||
const { state, feedGet } = useApp();
|
const { state, feedGet } = useApp();
|
||||||
const [items, setItems] = useState<FeedItem[]>([]);
|
const [items, setItems] = useState<FeedItem[]>([]);
|
||||||
|
const [scope, setScope] = useState<FeedScope>('everyone');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const enabledRelays = (state?.settings.relays ?? []).filter((r) => r.enabled);
|
const enabledRelays = (state?.settings.relays ?? []).filter((r) => r.enabled);
|
||||||
const shorten = state?.settings.shorten_npub ?? true;
|
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(
|
const load = useCallback(
|
||||||
async (background: boolean) => {
|
async (background: boolean) => {
|
||||||
|
|
@ -34,7 +40,7 @@ export function FeedScreen({ onNavigate }: FeedScreenProps) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
setItems(await feedGet());
|
setItems(await feedGet(undefined, effectiveScope === 'contacts'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -42,7 +48,7 @@ export function FeedScreen({ onNavigate }: FeedScreenProps) {
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[feedGet],
|
[feedGet, effectiveScope],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -90,10 +96,38 @@ export function FeedScreen({ onNavigate }: FeedScreenProps) {
|
||||||
{enabledRelays.length === 1 ? '' : 's'}.
|
{enabledRelays.length === 1 ? '' : 's'}.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" onClick={() => void load(true)} loading={refreshing}>
|
<div className="feed-actions">
|
||||||
<Icon name="refresh" size={16} />
|
<div className="segmented" role="group" aria-label="Feed scope">
|
||||||
Refresh
|
<button
|
||||||
</Button>
|
type="button"
|
||||||
|
className={`segmented-btn${effectiveScope === 'everyone' ? ' is-active' : ''}`}
|
||||||
|
onClick={() => setScope('everyone')}
|
||||||
|
aria-pressed={effectiveScope === 'everyone'}
|
||||||
|
>
|
||||||
|
<Icon name="list" size={16} />
|
||||||
|
Everyone
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`segmented-btn${effectiveScope === 'contacts' ? ' is-active' : ''}`}
|
||||||
|
onClick={() => setScope('contacts')}
|
||||||
|
disabled={!hasActiveProfile}
|
||||||
|
title={
|
||||||
|
hasActiveProfile
|
||||||
|
? 'Only notes from the active profile\u2019s contacts'
|
||||||
|
: 'Select a profile to filter by its contacts'
|
||||||
|
}
|
||||||
|
aria-pressed={effectiveScope === 'contacts'}
|
||||||
|
>
|
||||||
|
<Icon name="users" size={16} />
|
||||||
|
My contacts
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" onClick={() => void load(true)} loading={refreshing}>
|
||||||
|
<Icon name="refresh" size={16} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|
@ -106,9 +140,13 @@ export function FeedScreen({ onNavigate }: FeedScreenProps) {
|
||||||
<Spinner label="Fetching recent notes…" />
|
<Spinner label="Fetching recent notes…" />
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<Icon name="list" size={26} />}
|
icon={<Icon name={effectiveScope === 'contacts' ? 'users' : 'list'} size={26} />}
|
||||||
title="No notes found"
|
title={effectiveScope === 'contacts' ? 'No notes from your contacts' : 'No notes found'}
|
||||||
description="No notes were returned by the enabled relays in the last 24 hours. Try refreshing or check the relays screen."
|
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.'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ul className="feed-list">
|
<ul className="feed-list">
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ interface AppContextValue {
|
||||||
publishNote: (content: string) => Promise<PublishReport>;
|
publishNote: (content: string) => Promise<PublishReport>;
|
||||||
recordPublishFailure: (message: string, details?: string | null) => void;
|
recordPublishFailure: (message: string, details?: string | null) => void;
|
||||||
clearLastPublish: () => void;
|
clearLastPublish: () => void;
|
||||||
feedGet: (limit?: number) => Promise<FeedItem[]>;
|
feedGet: (limit?: number, contactsOnly?: boolean) => Promise<FeedItem[]>;
|
||||||
relayAdd: (url: string) => Promise<Settings>;
|
relayAdd: (url: string) => Promise<Settings>;
|
||||||
relayRemove: (url: string) => Promise<Settings>;
|
relayRemove: (url: string) => Promise<Settings>;
|
||||||
relaySetEnabled: (url: string, enabled: boolean) => Promise<Settings>;
|
relaySetEnabled: (url: string, enabled: boolean) => Promise<Settings>;
|
||||||
|
|
@ -126,7 +126,9 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
setLastPublish(null);
|
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) => {
|
const applySettings = useCallback((fresh: Settings) => {
|
||||||
setState((prev) => (prev ? { ...prev, settings: fresh } : prev));
|
setState((prev) => (prev ? { ...prev, settings: fresh } : prev));
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,56 @@ a {
|
||||||
font-size: 14px;
|
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
|
Sidebar
|
||||||
------------------------------------------------------------------------- */
|
------------------------------------------------------------------------- */
|
||||||
|
|
|
||||||
|
|
@ -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(<FeedScreen onNavigate={vi.fn()} />);
|
||||||
|
|
||||||
|
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(<FeedScreen onNavigate={vi.fn()} />);
|
||||||
|
|
||||||
|
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(<FeedScreen onNavigate={vi.fn()} />);
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,8 @@ export interface FakeBackend {
|
||||||
setSigner: (next: SignerStatus) => void;
|
setSigner: (next: SignerStatus) => void;
|
||||||
/** Notes returned by `feed_get`. */
|
/** Notes returned by `feed_get`. */
|
||||||
feedItems: FeedItem[];
|
feedItems: FeedItem[];
|
||||||
|
/** Notes returned by `feed_get` with `contacts_only: true`. */
|
||||||
|
contactFeedItems: FeedItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createFakeBackend(initial?: AppState): FakeBackend {
|
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'],
|
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<string, unknown>): Promise<unknown> {
|
async function dispatch(method: string, params: Record<string, unknown>): Promise<unknown> {
|
||||||
|
|
@ -169,8 +181,16 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
|
||||||
return report;
|
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];
|
return [...backend.feedItems];
|
||||||
|
}
|
||||||
|
|
||||||
case 'pick_image':
|
case 'pick_image':
|
||||||
return [...backend.pickedImages];
|
return [...backend.pickedImages];
|
||||||
|
|
|
||||||
181
src/feed.rs
181
src/feed.rs
|
|
@ -4,7 +4,7 @@
|
||||||
//! key so the user's stored keys never touch the network while reading, and it
|
//! key so the user's stored keys never touch the network while reading, and it
|
||||||
//! never signs or publishes anything.
|
//! never signs or publishes anything.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
@ -14,6 +14,18 @@ use crate::errors::AppError;
|
||||||
use crate::relays;
|
use crate::relays;
|
||||||
use crate::settings::Settings;
|
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<PublicKey, AppError> {
|
||||||
|
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.
|
/// How long to wait for relays to accept a connection attempt.
|
||||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
/// How long to keep listening for events before returning what we have.
|
/// How long to keep listening for events before returning what we have.
|
||||||
|
|
@ -39,13 +51,94 @@ pub struct FeedItem {
|
||||||
pub relays: Vec<String>,
|
pub relays: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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
|
/// 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
|
/// `limit`. When no notes come back (or no relays are enabled) it returns an
|
||||||
/// empty list; callers treat that as a quiet, empty feed.
|
/// empty list; callers treat that as a quiet, empty feed.
|
||||||
pub async fn aggregate_feed(settings: &Settings, limit: usize) -> Result<Vec<FeedItem>, AppError> {
|
pub async fn aggregate_feed(settings: &Settings, limit: usize) -> Result<Vec<FeedItem>, 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<Vec<FeedItem>, 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<Vec<PublicKey>, 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<PublicKey> = 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<PublicKey> = 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<Vec<PublicKey>>,
|
||||||
|
) -> Result<Vec<FeedItem>, AppError> {
|
||||||
|
let relay_urls = enabled_relays(settings);
|
||||||
if relay_urls.is_empty() {
|
if relay_urls.is_empty() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
|
|
@ -67,12 +160,16 @@ pub async fn aggregate_feed(settings: &Settings, limit: usize) -> Result<Vec<Fee
|
||||||
.kind(Kind::TextNote)
|
.kind(Kind::TextNote)
|
||||||
.since(since)
|
.since(since)
|
||||||
.limit(effective_limit);
|
.limit(effective_limit);
|
||||||
|
let filter = match &authors {
|
||||||
|
Some(authors) => filter.authors(authors.iter().copied()),
|
||||||
|
None => filter,
|
||||||
|
};
|
||||||
client
|
client
|
||||||
.subscribe(filter, None)
|
.subscribe(filter, None)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::network(format!("Could not subscribe for the feed: {e}")))?;
|
.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 mut notifications = client.notifications();
|
||||||
let deadline = tokio::time::Instant::now() + QUERY_TIMEOUT;
|
let deadline = tokio::time::Instant::now() + QUERY_TIMEOUT;
|
||||||
loop {
|
loop {
|
||||||
|
|
@ -93,17 +190,25 @@ pub async fn aggregate_feed(settings: &Settings, limit: usize) -> Result<Vec<Fee
|
||||||
Ok(feed.finish())
|
Ok(feed.finish())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// URLs of every enabled relay.
|
||||||
|
fn enabled_relays(settings: &Settings) -> Vec<String> {
|
||||||
|
relays::enabled_urls(settings)
|
||||||
|
}
|
||||||
|
|
||||||
/// Accumulates notes into a bounded, de-duplicated, newest-first feed.
|
/// Accumulates notes into a bounded, de-duplicated, newest-first feed.
|
||||||
struct FeedBuilder {
|
struct FeedBuilder {
|
||||||
items: HashMap<String, FeedItem>,
|
items: HashMap<String, FeedItem>,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
|
/// When set, notes from anyone else are ignored (see `add`).
|
||||||
|
authors: Option<HashSet<PublicKey>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FeedBuilder {
|
impl FeedBuilder {
|
||||||
fn new(limit: usize) -> Self {
|
fn new(limit: usize, authors: Option<&[PublicKey]>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
items: HashMap::new(),
|
items: HashMap::new(),
|
||||||
limit,
|
limit,
|
||||||
|
authors: authors.map(|list| list.iter().copied().collect::<HashSet<PublicKey>>()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -113,6 +218,11 @@ impl FeedBuilder {
|
||||||
if event.kind != Kind::TextNote {
|
if event.kind != Kind::TextNote {
|
||||||
return self.items.len() < self.limit;
|
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();
|
let id = event.id.to_hex();
|
||||||
if let Some(entry) = self.items.get_mut(&id) {
|
if let Some(entry) = self.items.get_mut(&id) {
|
||||||
// Already seen (e.g. on another relay): only record the extra source.
|
// Already seen (e.g. on another relay): only record the extra source.
|
||||||
|
|
@ -187,7 +297,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn finish_sorts_newest_first() {
|
fn finish_sorts_newest_first() {
|
||||||
let mut builder = FeedBuilder::new(10);
|
let mut builder = FeedBuilder::new(10, None);
|
||||||
for created in [10u64, 50, 30] {
|
for created in [10u64, 50, 30] {
|
||||||
let id = format!("event-{created}");
|
let id = format!("event-{created}");
|
||||||
builder.items.insert(
|
builder.items.insert(
|
||||||
|
|
@ -210,7 +320,7 @@ mod tests {
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn duplicate_events_from_multi_relays_dedupe() {
|
async fn duplicate_events_from_multi_relays_dedupe() {
|
||||||
let event = note("hello feed", 100).await;
|
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://a.example").unwrap()));
|
||||||
builder.add(&event, Some(RelayUrl::parse("wss://b.example").unwrap()));
|
builder.add(&event, Some(RelayUrl::parse("wss://b.example").unwrap()));
|
||||||
let items = builder.finish();
|
let items = builder.finish();
|
||||||
|
|
@ -221,7 +331,7 @@ mod tests {
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn non_text_note_kinds_are_ignored() {
|
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 keys = Keys::generate();
|
||||||
let other = EventBuilder::new(Kind::Metadata, "{}")
|
let other = EventBuilder::new(Kind::Metadata, "{}")
|
||||||
.sign(&keys)
|
.sign(&keys)
|
||||||
|
|
@ -233,7 +343,7 @@ mod tests {
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn limit_stops_collection_when_full() {
|
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 a = note("a", 3).await;
|
||||||
let b = note("b", 2).await;
|
let b = note("b", 2).await;
|
||||||
let c = note("c", 1).await;
|
let c = note("c", 1).await;
|
||||||
|
|
@ -246,6 +356,59 @@ mod tests {
|
||||||
assert_eq!(builder.items.len(), 2);
|
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]
|
#[test]
|
||||||
fn zero_limit_still_returns_an_empty_feed_without_relays() {
|
fn zero_limit_still_returns_an_empty_feed_without_relays() {
|
||||||
let settings = Settings {
|
let settings = Settings {
|
||||||
|
|
|
||||||
22
src/ipc.rs
22
src/ipc.rs
|
|
@ -45,6 +45,9 @@ pub enum Request {
|
||||||
FeedGet {
|
FeedGet {
|
||||||
/// Optional cap on how many notes to return; leave `None` for the default.
|
/// Optional cap on how many notes to return; leave `None` for the default.
|
||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
|
/// When true, only return notes authored by the active profile's
|
||||||
|
/// contacts. When no active profile is selected the request errors.
|
||||||
|
contacts_only: Option<bool>,
|
||||||
},
|
},
|
||||||
RelayAdd {
|
RelayAdd {
|
||||||
url: String,
|
url: String,
|
||||||
|
|
@ -297,9 +300,22 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
|
||||||
Ok(json!(report))
|
Ok(json!(report))
|
||||||
}
|
}
|
||||||
|
|
||||||
Request::FeedGet { limit } => {
|
Request::FeedGet {
|
||||||
let items =
|
limit,
|
||||||
feed::aggregate_feed(&app.settings, limit.unwrap_or(feed::DEFAULT_LIMIT)).await?;
|
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))
|
Ok(json!(items))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
33
src/main.rs
33
src/main.rs
|
|
@ -19,7 +19,8 @@ Commands:
|
||||||
list List stored profiles (no secret keys)
|
list List stored profiles (no secret keys)
|
||||||
switch <npub> Select the active profile
|
switch <npub> Select the active profile
|
||||||
publish <npub> <content> Publish a text note as a specific profile
|
publish <npub> <content> 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 list List configured relays
|
||||||
relays add <url> Add a relay
|
relays add <url> Add a relay
|
||||||
relays remove <url> Remove a relay
|
relays remove <url> Remove a relay
|
||||||
|
|
@ -159,14 +160,36 @@ async fn cli_publish(args: &[String]) -> Result<String, AppError> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cli_feed(args: &[String]) -> Result<String, AppError> {
|
async fn cli_feed(args: &[String]) -> Result<String, AppError> {
|
||||||
let limit = args
|
let mut contacts = false;
|
||||||
.get(2)
|
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::<usize>().ok().filter(|n| *n > 0))
|
.and_then(|raw| raw.parse::<usize>().ok().filter(|n| *n > 0))
|
||||||
.unwrap_or(nostr_manager_backend::feed::DEFAULT_LIMIT);
|
.unwrap_or(nostr_manager_backend::feed::DEFAULT_LIMIT);
|
||||||
|
|
||||||
let app = App::load()?;
|
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() {
|
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())];
|
let mut lines = vec![format!("{} recent note(s):", items.len())];
|
||||||
for item in items {
|
for item in items {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue