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
|
|
@ -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];
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue