Feed: 'My notes' scope with per-profile dropdown filter

This commit is contained in:
Avi 2026-08-24 00:13:58 -05:00
commit 55a49b442b
8 changed files with 212 additions and 17 deletions

View file

@ -69,10 +69,11 @@ export const api = {
nip05, nip05,
}), }),
publishNote: (content: string) => call<PublishReport>('publish_note', { content }), publishNote: (content: string) => call<PublishReport>('publish_note', { content }),
feedGet: (limit?: number, contactsOnly = false) => feedGet: (limit?: number, contactsOnly = false, authorNpub?: string) =>
call<FeedItem[]>('feed_get', { call<FeedItem[]>('feed_get', {
...(limit ? { limit } : {}), ...(limit ? { limit } : {}),
...(contactsOnly ? { contacts_only: true } : {}), ...(contactsOnly ? { contacts_only: true } : {}),
...(authorNpub ? { author: authorNpub } : {}),
}), }),
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 }),

View file

@ -15,21 +15,33 @@ interface FeedScreenProps {
onNavigate: (screen: Screen) => void; onNavigate: (screen: Screen) => void;
} }
type FeedScope = 'everyone' | 'contacts'; type FeedScope = 'everyone' | 'contacts' | 'profile';
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 [scope, setScope] = useState<FeedScope>('everyone');
const [pickedProfile, setPickedProfile] = useState<string | null>(null);
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 profiles = state?.profiles ?? [];
const hasActiveProfile = state?.active_profile != null; const hasActiveProfile = state?.active_profile != null;
const hasProfiles = profiles.length > 0;
// Default the author filter to the active profile until one is picked.
const selectedProfileNpub =
pickedProfile ?? state?.active_profile?.npub ?? profiles[0]?.npub ?? null;
const contactsScope = scope === 'contacts'; const contactsScope = scope === 'contacts';
const effectiveScope: FeedScope = contactsScope && !hasActiveProfile ? 'everyone' : scope; const profileScope = scope === 'profile';
const effectiveScope: FeedScope =
contactsScope && !hasActiveProfile
? 'everyone'
: profileScope && !hasProfiles
? 'everyone'
: scope;
const load = useCallback( const load = useCallback(
async (background: boolean) => { async (background: boolean) => {
@ -40,7 +52,13 @@ export function FeedScreen({ onNavigate }: FeedScreenProps) {
setLoading(true); setLoading(true);
} }
try { try {
setItems(await feedGet(undefined, effectiveScope === 'contacts')); setItems(
await feedGet(
undefined,
effectiveScope === 'contacts',
effectiveScope === 'profile' ? (selectedProfileNpub ?? undefined) : undefined,
),
);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
} finally { } finally {
@ -48,7 +66,7 @@ export function FeedScreen({ onNavigate }: FeedScreenProps) {
setRefreshing(false); setRefreshing(false);
} }
}, },
[feedGet, effectiveScope], [feedGet, effectiveScope, selectedProfileNpub],
); );
useEffect(() => { useEffect(() => {
@ -122,7 +140,37 @@ export function FeedScreen({ onNavigate }: FeedScreenProps) {
<Icon name="users" size={16} /> <Icon name="users" size={16} />
My contacts My contacts
</button> </button>
<button
type="button"
className={`segmented-btn${effectiveScope === 'profile' ? ' is-active' : ''}`}
onClick={() => setScope('profile')}
disabled={!hasProfiles}
title={
hasProfiles
? 'Only notes published by one of your profiles'
: 'Create a profile to filter by it'
}
aria-pressed={effectiveScope === 'profile'}
>
<Icon name="edit" size={16} />
My notes
</button>
</div> </div>
{effectiveScope === 'profile' && (
<select
className="feed-profile-select"
aria-label="Show notes from profile"
value={selectedProfileNpub ?? ''}
onChange={(event) => setPickedProfile(event.target.value)}
>
{profiles.map((profile) => (
<option key={profile.npub} value={profile.npub}>
{profile.label}
{profile.is_active ? ' (active)' : ''}
</option>
))}
</select>
)}
<Button variant="ghost" onClick={() => void load(true)} loading={refreshing}> <Button variant="ghost" onClick={() => void load(true)} loading={refreshing}>
<Icon name="refresh" size={16} /> <Icon name="refresh" size={16} />
Refresh Refresh
@ -140,11 +188,30 @@ 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={effectiveScope === 'contacts' ? 'users' : 'list'} size={26} />} icon={
title={effectiveScope === 'contacts' ? 'No notes from your contacts' : 'No notes found'} <Icon
name={
effectiveScope === 'contacts'
? 'users'
: effectiveScope === 'profile'
? 'edit'
: 'list'
}
size={26}
/>
}
title={
effectiveScope === 'contacts'
? 'No notes from your contacts'
: effectiveScope === 'profile'
? 'No notes from this profile'
: 'No notes found'
}
description={ description={
effectiveScope === 'contacts' 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 from the people the active profile follows in the last 24 hours. Try refreshing, or switch to Everyone.'
: effectiveScope === 'profile'
? 'This profile has not published any notes on your enabled relays in the last 24 hours. Notes older than a day, or notes only on other relays, will not appear here.'
: 'No notes were returned by the enabled relays in the last 24 hours. Try refreshing or check the relays screen.' : 'No notes were returned by the enabled relays in the last 24 hours. Try refreshing or check the relays screen.'
} }
/> />

View file

@ -46,7 +46,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, contactsOnly?: boolean) => Promise<FeedItem[]>; feedGet: (limit?: number, contactsOnly?: boolean, authorNpub?: string) => 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>;
@ -168,8 +168,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
setLastPublish(null); setLastPublish(null);
}, []); }, []);
const feedGet = useCallback((limit?: number, contactsOnly?: boolean) => { const feedGet = useCallback((limit?: number, contactsOnly?: boolean, authorNpub?: string) => {
return api.feedGet(limit, contactsOnly); return api.feedGet(limit, contactsOnly, authorNpub);
}, []); }, []);
const applySettings = useCallback((fresh: Settings) => { const applySettings = useCallback((fresh: Settings) => {

View file

@ -252,6 +252,10 @@ a {
cursor: not-allowed; cursor: not-allowed;
} }
.feed-profile-select {
height: 34px;
}
/* ------------------------------------------------------------------------- /* -------------------------------------------------------------------------
Sidebar Sidebar
------------------------------------------------------------------------- */ ------------------------------------------------------------------------- */

View file

@ -107,4 +107,55 @@ describe('FeedScreen', () => {
expect(await screen.findByText('No notes from your contacts')).toBeInTheDocument(); expect(await screen.findByText('No notes from your contacts')).toBeInTheDocument();
}); });
it('defaults the "My notes" scope to the active profile', 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 notes/i }));
expect(await screen.findByText('A note from my own profile.')).toBeInTheDocument();
expect(screen.queryByText('Hello from the feed.')).not.toBeInTheDocument();
const authorRequest = backend.requests.find(
(r) => r.method === 'feed_get' && r.params.author != null,
);
expect(authorRequest).toBeDefined();
expect(authorRequest?.params.author).toBe('npub1aliceaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
});
it('switches profiles with the dropdown', async () => {
const backend = createFakeBackend();
backend.profileFeedItems = [];
const user = renderFeed(backend);
renderWithApp(<FeedScreen onNavigate={vi.fn()} />);
await screen.findByText('Hello from the feed.');
await user.click(screen.getByRole('button', { name: /My notes/i }));
await screen.findByText('No notes from this profile');
await user.selectOptions(
screen.getByLabelText('Show notes from profile'),
'npub1bobbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
);
await waitFor(() => {
const bobRequests = backend.requests.filter(
(r) =>
r.method === 'feed_get' &&
r.params.author === 'npub1bobbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
);
expect(bobRequests.length).toBeGreaterThanOrEqual(1);
});
});
it('disables the "My notes" scope when there are no profiles', async () => {
const backend = createFakeBackend(makeEmptyState());
renderFeed(backend);
renderWithApp(<FeedScreen onNavigate={vi.fn()} />);
const myNotesButton = await screen.findByRole('button', { name: /My notes/i });
expect(myNotesButton).toBeDisabled();
});
}); });

View file

@ -43,6 +43,8 @@ export interface FakeBackend {
feedItems: FeedItem[]; feedItems: FeedItem[];
/** Notes returned by `feed_get` with `contacts_only: true`. */ /** Notes returned by `feed_get` with `contacts_only: true`. */
contactFeedItems: FeedItem[]; contactFeedItems: FeedItem[];
/** Notes returned by `feed_get` with an `author` filter. */
profileFeedItems: FeedItem[];
} }
export function createFakeBackend(initial?: AppState): FakeBackend { export function createFakeBackend(initial?: AppState): FakeBackend {
@ -126,6 +128,17 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
relays: ['wss://relay.damus.io'], relays: ['wss://relay.damus.io'],
}, },
], ],
/** Notes returned by `feed_get` with an `author` filter. */
profileFeedItems: [
{
id: 'note1dddddddddddddddddddddddddddddddddddddddddddddddd',
author: '7f8b9a0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f',
author_npub: ALICE,
content: 'A note from my own profile.',
created_at: 1700000400,
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> {
@ -234,6 +247,12 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
} }
case 'feed_get': { case 'feed_get': {
const author = typeof params.author === 'string' ? params.author : null;
if (author) {
return backend.profileFeedItems.filter(
(item) => item.author_npub === author || item.author === author,
);
}
const contactsOnly = Boolean(params.contacts_only); const contactsOnly = Boolean(params.contacts_only);
if (contactsOnly) { if (contactsOnly) {
if (!state.active_profile) { if (!state.active_profile) {

View file

@ -77,6 +77,19 @@ pub async fn contact_feed(
aggregate_for(settings, limit, Some(contacts)).await aggregate_for(settings, limit, Some(contacts)).await
} }
/// Only notes authored by one account (npub or hex).
///
/// Used by the feed screen's "My notes" scope so the user can read just the
/// posts of one of their own profiles.
pub async fn profile_feed(
settings: &Settings,
limit: usize,
author: &str,
) -> Result<Vec<FeedItem>, AppError> {
let pubkey = owner_pubkey(author)?;
aggregate_for(settings, limit, Some(vec![pubkey])).await
}
/// Fetch the hex public keys followed by an owner profile (kind 3 contact list). /// 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> { async fn contact_pubkeys(settings: &Settings, owner_hex: &str) -> Result<Vec<PublicKey>, AppError> {
let owner = owner_pubkey(owner_hex)?; let owner = owner_pubkey(owner_hex)?;
@ -397,6 +410,30 @@ mod tests {
assert!(feed.is_empty()); assert!(feed.is_empty());
} }
#[tokio::test]
async fn profile_feed_with_no_relays_is_empty() {
let settings = Settings {
relays: Vec::new(),
..Default::default()
};
let feed = profile_feed(&settings, DEFAULT_LIMIT, "00".repeat(32).as_str())
.await
.unwrap();
assert!(feed.is_empty());
}
#[tokio::test]
async fn profile_feed_with_invalid_author_errors() {
let settings = Settings {
relays: Vec::new(),
..Default::default()
};
let err = profile_feed(&settings, DEFAULT_LIMIT, "not-a-key")
.await
.expect_err("an invalid author must error");
assert_eq!(err.kind(), crate::errors::ErrorKind::Internal);
}
#[tokio::test] #[tokio::test]
async fn contact_feed_with_invalid_owner_errors() { async fn contact_feed_with_invalid_owner_errors() {
let settings = Settings { let settings = Settings {

View file

@ -69,9 +69,13 @@ 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 /// When true (and no `author` is given), only return notes authored by
/// contacts. When no active profile is selected the request errors. /// the active profile's contacts. When no active profile is selected
/// the request errors.
contacts_only: Option<bool>, contacts_only: Option<bool>,
/// When set (npub or hex), only return notes authored by that account.
/// Takes precedence over `contacts_only`.
author: Option<String>,
}, },
RelayAdd { RelayAdd {
url: String, url: String,
@ -311,14 +315,23 @@ async fn run(
Request::FeedGet { Request::FeedGet {
limit, limit,
contacts_only, contacts_only,
author,
} => { } => {
let limit = limit.unwrap_or(feed::DEFAULT_LIMIT); let limit = limit.unwrap_or(feed::DEFAULT_LIMIT);
let contacts_only = contacts_only.unwrap_or(false); let contacts_only = contacts_only.unwrap_or(false);
// Resolve the requested author outside any lock: parsing a key is
// pure and must not queue behind vault mutations.
let author_hex = match author.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
Some(raw) => Some(feed::owner_pubkey(raw)?.to_hex()),
None => None,
};
// Copy the inputs out of shared state under a short lock so the // Copy the inputs out of shared state under a short lock so the
// multi-second relay fetches below never block a Select or save. // multi-second relay fetches below never block a Select or save.
let (settings, owner_hex) = { let (settings, owner_hex) = {
let guard = app.lock().await; let guard = app.lock().await;
let owner_hex = if contacts_only { let owner_hex = if author_hex.is_some() {
None
} else if contacts_only {
let npub = guard let npub = guard
.vault .vault
.active_profile .active_profile
@ -330,9 +343,12 @@ async fn run(
}; };
(guard.settings.clone(), owner_hex) (guard.settings.clone(), owner_hex)
}; };
let items = match owner_hex { let items = if let Some(hex) = author_hex {
Some(hex) => feed::contact_feed(&settings, limit, &hex).await?, feed::profile_feed(&settings, limit, &hex).await?
None => feed::aggregate_feed(&settings, limit).await?, } else if let Some(hex) = owner_hex {
feed::contact_feed(&settings, limit, &hex).await?
} else {
feed::aggregate_feed(&settings, limit).await?
}; };
Ok(json!(items)) Ok(json!(items))
} }