Feed: 'My notes' scope with per-profile dropdown filter
This commit is contained in:
parent
8f637618bc
commit
55a49b442b
8 changed files with 212 additions and 17 deletions
|
|
@ -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 }),
|
||||||
|
|
|
||||||
|
|
@ -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.'
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -252,6 +252,10 @@ a {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.feed-profile-select {
|
||||||
|
height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------
|
/* -------------------------------------------------------------------------
|
||||||
Sidebar
|
Sidebar
|
||||||
------------------------------------------------------------------------- */
|
------------------------------------------------------------------------- */
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
37
src/feed.rs
37
src/feed.rs
|
|
@ -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 {
|
||||||
|
|
|
||||||
28
src/ipc.rs
28
src/ipc.rs
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue