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:
Avi 2026-08-06 11:01:27 -05:00
commit a1445d17a8
10 changed files with 388 additions and 32 deletions

View file

@ -51,7 +51,11 @@ 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 } : {}),
feedGet: (limit?: number, contactsOnly = false) =>
call<FeedItem[]>('feed_get', {
...(limit ? { limit } : {}),
...(contactsOnly ? { contacts_only: true } : {}),
}),
relayAdd: (url: string) => call<Settings>('relay_add', { url }),
relayRemove: (url: string) => call<Settings>('relay_remove', { url }),
relaySetEnabled: (url: string, enabled: boolean) =>

View file

@ -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<FeedItem[]>([]);
const [scope, setScope] = useState<FeedScope>('everyone');
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 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'}.
</p>
</div>
<Button variant="ghost" onClick={() => void load(true)} loading={refreshing}>
<Icon name="refresh" size={16} />
Refresh
</Button>
<div className="feed-actions">
<div className="segmented" role="group" aria-label="Feed scope">
<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>
{error && (
@ -106,9 +140,13 @@ export function FeedScreen({ onNavigate }: FeedScreenProps) {
<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."
icon={<Icon name={effectiveScope === 'contacts' ? 'users' : 'list'} size={26} />}
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.'
}
/>
) : (
<ul className="feed-list">

View file

@ -41,7 +41,7 @@ interface AppContextValue {
publishNote: (content: string) => Promise<PublishReport>;
recordPublishFailure: (message: string, details?: string | null) => void;
clearLastPublish: () => void;
feedGet: (limit?: number) => Promise<FeedItem[]>;
feedGet: (limit?: number, contactsOnly?: boolean) => Promise<FeedItem[]>;
relayAdd: (url: string) => Promise<Settings>;
relayRemove: (url: string) => Promise<Settings>;
relaySetEnabled: (url: string, enabled: boolean) => Promise<Settings>;
@ -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));

View file

@ -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
------------------------------------------------------------------------- */

View file

@ -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();
});
});

View file

@ -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<string, unknown>): Promise<unknown> {
@ -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];