Default the Search Kind filter to Agora content only

Search previously defaulted to 'all kinds' — every kind in the picker
(50+), which means a user typing 'bitcoin' into the search bar got a
firehose of music tracks, podcasts, bird detections, geocaches, and
every other supported NIP. The Posts tab is meant to surface Agora
content; the long-tail kinds belong behind an explicit opt-in.

Introduce 'agora' as a new sentinel value for the Kind filter that
expands to AGORA_PRESET_KIND_VALUES (Campaigns, Pledges, Communities,
Posts, Articles, Events, Polls, Photos, Videos). Make it the default
in DEFAULT_FILTERS, teach parseKindFilter to resolve it, and render
both 'Agora content' and 'All kinds' as top-level selectable rows in
the KindPicker (with the existing Agora-curated quick-list still
appearing as individual selectable items below).

The active-filter chip summary suppresses a chip when the default
'agora' is in effect, surfaces 'All kinds' as a chip when the user
explicitly broadens the search, and continues to show individual kind
labels for everything else.
This commit is contained in:
Chad Curtis
2026-05-21 23:01:03 -05:00
parent f762a8b0d7
commit edf9f77060
3 changed files with 34 additions and 12 deletions
+14 -3
View File
@@ -171,7 +171,7 @@ export function KindPicker({ value, options, onChange }: {
return { presetOptions: preset, otherOptions: other };
}, [filtered, presetSet, search]);
const selected = value === 'all' || value === 'custom' ? null : options.find((o) => o.value === value);
const selected = value === 'all' || value === 'agora' || value === 'custom' ? null : options.find((o) => o.value === value);
const SelectedIcon = selected?.icon;
const handleSelect = (v: string) => { onChange(v); setOpen(false); setSearch(''); };
@@ -189,7 +189,13 @@ export function KindPicker({ value, options, onChange }: {
? <SelectedIcon className="size-3.5 shrink-0 text-muted-foreground" />
: <Hash className="size-3.5 shrink-0 text-muted-foreground" />}
<span className="flex-1 truncate">
{value === 'all' ? 'All' : value === 'custom' ? 'Custom...' : (selected?.label ?? value)}
{value === 'all'
? 'All kinds'
: value === 'agora'
? 'Agora content'
: value === 'custom'
? 'Custom...'
: (selected?.label ?? value)}
</span>
<ChevronDown className="size-3 shrink-0 text-muted-foreground" />
</button>
@@ -217,7 +223,12 @@ export function KindPicker({ value, options, onChange }: {
</div>
{canScrollUp && <KindScrollCaret direction="up" onMouseEnter={() => startScroll('up')} onMouseLeave={stopScroll} />}
<div ref={refCallback} className="overflow-y-auto flex-1 min-h-0" onScroll={onScroll}>
{!search && <KindPickerItem icon={null} label="All kinds" active={value === 'all'} onClick={() => handleSelect('all')} />}
{!search && (
<>
<KindPickerItem icon={null} label="Agora content" active={value === 'agora'} onClick={() => handleSelect('agora')} />
<KindPickerItem icon={null} label="All kinds" active={value === 'all'} onClick={() => handleSelect('all')} />
</>
)}
{!search && presetOptions.length > 0 && (
<>
<div className="px-2.5 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
+4
View File
@@ -1,5 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import { AGORA_PRESET_KIND_VALUES } from "./feedFilterUtils"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
@@ -9,12 +10,15 @@ export function cn(...inputs: ClassValue[]) {
* Parse a kindFilter string into an array of kind numbers.
* Supports:
* - 'all' → undefined (no override)
* - 'agora' → the AGORA_PRESET_KIND_VALUES set (Campaigns, Pledges,
* Communities, Posts, Articles, Events, Polls, Photos, Videos)
* - 'custom' → parse customKindText as comma/space-separated numbers
* - Single kind number (e.g. '1') → [1]
* - Comma-separated kind numbers (e.g. '1,30023,20') → [1, 30023, 20]
*/
export function parseKindFilter(kindFilter: string, customKindText?: string): number[] | undefined {
if (kindFilter === 'all' || kindFilter === '') return undefined;
if (kindFilter === 'agora') return AGORA_PRESET_KIND_VALUES.map(Number);
if (kindFilter === 'custom') {
if (!customKindText) return undefined;
const parsed = customKindText.trim().split(/[\s,]+/).map(Number).filter((n) => Number.isInteger(n) && n > 0);
+16 -9
View File
@@ -65,7 +65,7 @@ const DEFAULT_FILTERS = {
mediaType: 'all' as const,
language: 'global',
platform: 'nostr' as const,
kindFilter: 'all',
kindFilter: 'agora',
customKindText: '',
authorScope: 'anyone' as AuthorScope,
sort: 'recent' as SortPref,
@@ -252,15 +252,18 @@ export function SearchPage() {
const allKindNumbers = useMemo(() => kindOptions.map((o) => Number(o.value)), [kindOptions]);
// Resolve kindsOverride from the current kind filter state.
// "all" means every kind in the picker list, not undefined (which would let
// useStreamPosts fall back to only the user's enabled feed-settings kinds).
// "all" means every kind in the picker list, not undefined (which would
// let useStreamPosts fall back to only the user's enabled feed-settings
// kinds). "agora" expands to the curated Agora preset set.
const kindsOverride = useMemo<number[]>(
() => kindFilter === 'all' ? allKindNumbers : (parseKindFilter(kindFilter, customKindText) ?? allKindNumbers),
[kindFilter, customKindText, allKindNumbers],
);
// Detect kind + media type conflict: a specific kind is selected AND a media type is set
const hasKindMediaConflict = kindFilter !== 'all' && kindsOverride.length > 0 && mediaType !== 'all';
// Detect kind + media type conflict: a non-broad kind is selected AND a
// media type is set. "all" and "agora" are both broad selections that
// don't conflict with media filters.
const hasKindMediaConflict = kindFilter !== 'all' && kindFilter !== 'agora' && kindsOverride.length > 0 && mediaType !== 'all';
// Determine if any filter differs from the default
const hasActiveFilters = !includeReplies || mediaType !== DEFAULT_FILTERS.mediaType ||
@@ -292,7 +295,7 @@ export function SearchPage() {
: ['protocol:nostr'];
if (debouncedSearchQuery.trim()) parts.push(debouncedSearchQuery.trim());
if (language !== 'global') parts.push(`language:${language}`);
const isDedicatedKindQuery = kindFilter === 'all' && (mediaType === 'vines' || mediaType === 'images' || mediaType === 'videos');
const isDedicatedKindQuery = (kindFilter === 'all' || kindFilter === 'agora') && (mediaType === 'vines' || mediaType === 'images' || mediaType === 'videos');
if (!isDedicatedKindQuery && !hasKindMediaConflict) {
if (mediaType === 'images') { parts.push('media:true'); parts.push('video:false'); }
else if (mediaType === 'videos') parts.push('video:true');
@@ -311,7 +314,13 @@ export function SearchPage() {
if (language !== 'global') labels.push(language.toUpperCase());
if (platform !== 'nostr') labels.push({ activitypub: 'Mastodon', atproto: 'Bluesky' }[platform] ?? platform);
if (sort !== 'recent') labels.push(sort === 'hot' ? 'Hot' : 'Trending');
if (kindFilter !== 'all' && kindFilter !== 'custom') {
if (kindFilter === 'agora') {
// 'agora' is the default — no chip needed.
} else if (kindFilter === 'all') {
labels.push('All kinds');
} else if (kindFilter === 'custom') {
if (customKindText) labels.push(`Kind: ${customKindText}`);
} else {
const kindValues = kindFilter.split(',').filter(Boolean);
if (kindValues.length === 1) {
const opt = kindOptions.find(o => o.value === kindValues[0]);
@@ -320,8 +329,6 @@ export function SearchPage() {
} else if (kindValues.length > 1) {
labels.push(`${kindValues.length} kinds`);
}
} else if (kindFilter === 'custom' && customKindText) {
labels.push(`Kind: ${customKindText}`);
}
if (authorScope === 'follows') labels.push('My follows');
if (authorScope === 'people' && authorPubkeys.length > 0) labels.push(`${authorPubkeys.length} author${authorPubkeys.length > 1 ? 's' : ''}`);