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