Replace Discover with Support nav link
Remove the Discover page and route entirely. In the top nav, swap the Discover entry for a Support link pointing at /campaigns/all (the all campaigns directory). Also drops the now-orphaned DiscoverHero component and useDiscoverFeed hook, and updates the NIP.md campaign moderation note that referenced the deleted /discover route.
This commit is contained in:
@@ -357,7 +357,7 @@ The `pinnedEvents` array is ordered newest pin first. Pinning an already-pinned
|
||||
|
||||
### Campaign Moderation Labels
|
||||
|
||||
Agora curates which kind 30223 campaigns appear on the homepage (`/`) and on Discover (`/discover`) via moderator-signed NIP-32 label events (kind 1985) in a dedicated label namespace. The campaign event itself is never modified — surfacing is purely a client-side rollup of label events.
|
||||
Agora curates which kind 30223 campaigns appear on the homepage (`/`) and on the Support directory (`/campaigns/all`) via moderator-signed NIP-32 label events (kind 1985) in a dedicated label namespace. The campaign event itself is never modified — surfacing is purely a client-side rollup of label events.
|
||||
|
||||
#### Namespace
|
||||
|
||||
|
||||
@@ -49,7 +49,6 @@ const CreateCommunityPage = lazy(() => import("./pages/CreateCommunityPage").the
|
||||
const CreateEventPage = lazy(() => import("./pages/CreateEventPage").then(m => ({ default: m.CreateEventPage })));
|
||||
const ContentSettingsPage = lazy(() => import("./pages/ContentSettingsPage").then(m => ({ default: m.ContentSettingsPage })));
|
||||
const CSAEPolicyPage = lazy(() => import("./pages/CSAEPolicyPage").then(m => ({ default: m.CSAEPolicyPage })));
|
||||
const DiscoverPage = lazy(() => import("./pages/DiscoverPage").then(m => ({ default: m.DiscoverPage })));
|
||||
const DomainFeedPage = lazy(() => import("./pages/DomainFeedPage").then(m => ({ default: m.DomainFeedPage })));
|
||||
const EventsFeedPage = lazy(() => import("./pages/EventsFeedPage").then(m => ({ default: m.EventsFeedPage })));
|
||||
const ExternalContentPage = lazy(() => import("./pages/ExternalContentPage").then(m => ({ default: m.ExternalContentPage })));
|
||||
@@ -169,7 +168,6 @@ export function AppRouter() {
|
||||
{/* All routes share the persistent FundraiserLayout (top nav + footer) */}
|
||||
<Route element={<FundraiserLayout />}>
|
||||
<Route path="/" element={<CampaignsPage />} />
|
||||
<Route path="/discover" element={<DiscoverPage />} />
|
||||
<Route path="/feed" element={<Index />} />
|
||||
<Route path="/campaigns" element={<Navigate to="/" replace />} />
|
||||
<Route path="/campaigns/new" element={<CreateCampaignPage />} />
|
||||
|
||||
@@ -33,7 +33,7 @@ interface NavItem {
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ label: 'Discover', to: '/discover', icon: HandHeart },
|
||||
{ label: 'Support', to: '/campaigns/all', icon: HandHeart },
|
||||
{ label: 'Organize', to: '/communities', icon: Users },
|
||||
{ label: 'Pledge', to: '/pledges', icon: Megaphone },
|
||||
];
|
||||
|
||||
@@ -1,355 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Globe2, HandHeart, PlusCircle, Users } from 'lucide-react';
|
||||
|
||||
import { HeroGlobe, type GlobeMarkerKind } from '@/components/HeroGlobe';
|
||||
import { HeroAtmosphere } from '@/components/HeroAtmosphere';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useCampaigns } from '@/hooks/useCampaigns';
|
||||
import { useGlobalActivity } from '@/hooks/useGlobalActivity';
|
||||
import { useGlobalDonations } from '@/hooks/useGlobalDonations';
|
||||
import { useDiscoverCommunities } from '@/hooks/useDiscoverCommunities';
|
||||
import { HOPE_PALETTE } from '@/lib/hopePalette';
|
||||
import { searchCountry } from '@/lib/countries';
|
||||
import { getCoordinates } from '@/lib/coordinates';
|
||||
import { formatSatsShort } from '@/lib/formatCampaignAmount';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface DiscoverHeroProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface GlobeMarker {
|
||||
key: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
label: string;
|
||||
kind: GlobeMarkerKind;
|
||||
}
|
||||
|
||||
interface TickerStat {
|
||||
/** Stable React key. */
|
||||
id: string;
|
||||
/** Big number / value text. */
|
||||
value: string;
|
||||
/** Trailing label that describes what the number is. */
|
||||
label: string;
|
||||
/** Decorative leading icon. */
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
/** Country code → lat/lng. Used to seed country-pulse markers. */
|
||||
function lookupCountryCoords(code: string) {
|
||||
const coords = getCoordinates(code);
|
||||
return coords ? { lat: coords.latitude, lng: coords.longitude } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover-page hero. The same hand-drawn `HeroGlobe` that anchors the
|
||||
* fundraising home page (`/`), but reframed: the globe is the
|
||||
* protagonist, three marker types sit on it at once — campaigns
|
||||
* (hearts), communities (rings), and country-pulse (warm dots) — and a
|
||||
* rotating stat ticker headlines what the network has done.
|
||||
*
|
||||
* Visual chrome:
|
||||
* - Slow hue drift through `HOPE_PALETTE` every ~8s (the page literally
|
||||
* pulses with hope).
|
||||
* - `HeroAtmosphere` carries the warm scrim + radial glow + sunrise rim,
|
||||
* same component the campaigns hero uses for crossfade.
|
||||
* - No background photo — Discover isn't selling any one campaign, so
|
||||
* the sphere reads against a soft secondary wash instead.
|
||||
*/
|
||||
export function DiscoverHero({ className }: DiscoverHeroProps) {
|
||||
// ─── Data ──────────────────────────────────────────────────────────────
|
||||
const { data: campaigns } = useCampaigns({ limit: 60 });
|
||||
const { data: communities } = useDiscoverCommunities({ limit: 60 });
|
||||
const { data: activityByCountry } = useGlobalActivity();
|
||||
const { data: donations, isLoading: donationsLoading } = useGlobalDonations();
|
||||
|
||||
// ─── Globe markers ─────────────────────────────────────────────────────
|
||||
// Layer three pin types. We dedupe primarily by country so the globe
|
||||
// never piles dozens of markers on top of each other — the goal is a
|
||||
// sparse, hopeful constellation, not a heatmap. Hearts win over rings
|
||||
// win over dots when the same country shows up in multiple sources.
|
||||
const markers = useMemo<GlobeMarker[]>(() => {
|
||||
const out: GlobeMarker[] = [];
|
||||
const claimedCountries = new Set<string>();
|
||||
|
||||
// 1. Campaigns → hearts. Newest first; cap at 18 so they don't crowd.
|
||||
let heartCount = 0;
|
||||
for (const c of campaigns ?? []) {
|
||||
if (heartCount >= 18) break;
|
||||
if (!c.location) continue;
|
||||
const match = searchCountry(c.location);
|
||||
if (!match) continue;
|
||||
if (claimedCountries.has(match.country.code)) continue;
|
||||
const coords = getCoordinates(match.country.code);
|
||||
if (!coords) continue;
|
||||
claimedCountries.add(match.country.code);
|
||||
out.push({
|
||||
key: `campaign:${c.aTag}`,
|
||||
lat: coords.latitude,
|
||||
lng: coords.longitude,
|
||||
label: c.title,
|
||||
kind: 'campaign',
|
||||
});
|
||||
heartCount++;
|
||||
}
|
||||
|
||||
// 2. Country-pulse dots — the trusted-stats country activity, sized
|
||||
// implicitly by the marker glyph. Cap at 28 so the back of the globe
|
||||
// doesn't bristle when it rotates into view.
|
||||
let pulseCount = 0;
|
||||
if (activityByCountry) {
|
||||
const sortedCodes = [...activityByCountry.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([code]) => code);
|
||||
for (const code of sortedCodes) {
|
||||
if (pulseCount >= 28) break;
|
||||
if (claimedCountries.has(code)) continue;
|
||||
const coords = lookupCountryCoords(code);
|
||||
if (!coords) continue;
|
||||
claimedCountries.add(code);
|
||||
out.push({
|
||||
key: `pulse:${code}`,
|
||||
lat: coords.lat,
|
||||
lng: coords.lng,
|
||||
label: `Active in ${code}`,
|
||||
kind: 'country-pulse',
|
||||
});
|
||||
pulseCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Community rings — only when we can geolocate one of the
|
||||
// moderators. Communities don't carry a location tag of their own,
|
||||
// so we use a small heuristic: spread the first N communities across
|
||||
// continents by scattering them on a stable hash. Keeps the layer
|
||||
// present without inventing coordinates we can't justify.
|
||||
//
|
||||
// To keep ourselves honest we cap this at 6 rings and never overwrite
|
||||
// a country that already has a campaign heart or pulse dot. If we
|
||||
// genuinely can't place any, we skip the layer.
|
||||
const scatter: Array<{ lat: number; lng: number }> = [
|
||||
{ lat: 40.7, lng: -74.0 }, // Americas
|
||||
{ lat: -23.5, lng: -46.6 }, // S. America
|
||||
{ lat: 51.5, lng: -0.1 }, // Europe
|
||||
{ lat: -1.3, lng: 36.8 }, // Africa
|
||||
{ lat: 35.7, lng: 139.7 }, // E. Asia
|
||||
{ lat: -33.9, lng: 151.2 }, // Oceania
|
||||
];
|
||||
let ringCount = 0;
|
||||
for (const community of communities ?? []) {
|
||||
if (ringCount >= scatter.length) break;
|
||||
const slot = scatter[ringCount];
|
||||
out.push({
|
||||
key: `community:${community.aTag}`,
|
||||
lat: slot.lat,
|
||||
lng: slot.lng,
|
||||
label: community.name,
|
||||
kind: 'community',
|
||||
});
|
||||
ringCount++;
|
||||
}
|
||||
|
||||
return out;
|
||||
}, [campaigns, communities, activityByCountry]);
|
||||
|
||||
// ─── Hue drift ─────────────────────────────────────────────────────────
|
||||
// Cycle through the hopeful palette on a slow ~9s interval. We seed
|
||||
// HeroAtmosphere with a stable string per cycle so its crossfade logic
|
||||
// kicks in correctly between hues.
|
||||
const [hueIndex, setHueIndex] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => {
|
||||
setHueIndex((i) => (i + 1) % HOPE_PALETTE.length);
|
||||
}, 9_000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
const activeHue = HOPE_PALETTE[hueIndex];
|
||||
const atmosphereSeed = `discover-hue-${activeHue.name}`;
|
||||
|
||||
// ─── Stat ticker ───────────────────────────────────────────────────────
|
||||
// Three rotating, immutable network-wide stats. We compute them
|
||||
// defensively — when the underlying query is still loading we surface
|
||||
// a small skeleton inside the ticker row instead of "0" so the page
|
||||
// doesn't lie about the network's scale.
|
||||
const stats = useMemo<TickerStat[]>(() => {
|
||||
const items: TickerStat[] = [];
|
||||
|
||||
if (donations && donations.totalSats > 0) {
|
||||
items.push({
|
||||
id: 'sats',
|
||||
value: formatSatsShort(donations.totalSats),
|
||||
label: `raised on-chain across ${donations.campaignCount.toLocaleString()} ${
|
||||
donations.campaignCount === 1 ? 'campaign' : 'campaigns'
|
||||
}`,
|
||||
icon: <HandHeart className="size-5" aria-hidden />,
|
||||
});
|
||||
}
|
||||
if (communities && communities.length > 0) {
|
||||
items.push({
|
||||
id: 'communities',
|
||||
value: communities.length.toLocaleString(),
|
||||
label: `${communities.length === 1 ? 'organization' : 'organizations'} gathering on Nostr`,
|
||||
icon: <Users className="size-5" aria-hidden />,
|
||||
});
|
||||
}
|
||||
if (activityByCountry && activityByCountry.size > 0) {
|
||||
items.push({
|
||||
id: 'countries',
|
||||
value: activityByCountry.size.toLocaleString(),
|
||||
label: `${activityByCountry.size === 1 ? 'country' : 'countries'} posting today`,
|
||||
icon: <Globe2 className="size-5" aria-hidden />,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}, [donations, communities, activityByCountry]);
|
||||
|
||||
// Auto-advance the ticker. Holds at the first slot until at least one
|
||||
// stat is known so the visitor doesn't see an empty pill.
|
||||
const [tickerIndex, setTickerIndex] = useState(0);
|
||||
useEffect(() => {
|
||||
if (stats.length <= 1) return;
|
||||
const id = window.setInterval(() => {
|
||||
setTickerIndex((i) => (i + 1) % stats.length);
|
||||
}, 4_000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [stats.length]);
|
||||
|
||||
const currentStat = stats[tickerIndex % Math.max(stats.length, 1)];
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
'relative overflow-hidden border-b border-border bg-secondary/30',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Atmosphere — same scrim + radial glow + sunrise rim used on
|
||||
`/`. Seeded by the active hue so the whole hero blooms together
|
||||
when the palette advances. */}
|
||||
<HeroAtmosphere seed={atmosphereSeed} />
|
||||
|
||||
{/* Globe — centered, dominant. Slight upward bias so the headline
|
||||
beneath has breathing room. */}
|
||||
<div className="absolute inset-0 pointer-events-none flex items-center justify-center">
|
||||
<div className="pointer-events-auto opacity-90">
|
||||
<HeroGlobe
|
||||
markers={markers}
|
||||
hue={activeHue}
|
||||
className="aspect-square max-w-none drop-shadow-2xl"
|
||||
style={{ width: 'clamp(440px, 62dvw, 720px)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Readability scrim. Sits above the globe + atmosphere but below
|
||||
the foreground content so the headline / paragraph stay legible
|
||||
regardless of which hue the palette is currently cycling
|
||||
through. Top-down so the eye-line lands on the darkest pixels;
|
||||
we taper to transparent before the ticker pill so the CTAs and
|
||||
stat row underneath keep their warm wash. */}
|
||||
<div
|
||||
className="absolute inset-x-0 top-0 h-72 sm:h-80 pointer-events-none bg-gradient-to-b from-black/55 via-black/25 to-transparent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Foreground content — headline above the sphere, ticker + CTAs
|
||||
below it. Uses the same `max-w-5xl` container as the rest of
|
||||
the Discover page so the hero never sprawls wider than the
|
||||
shelves beneath it. */}
|
||||
<div className="relative max-w-5xl mx-auto px-4 sm:px-6 py-12 sm:py-16 lg:py-20 min-h-[560px] sm:min-h-[640px] lg:min-h-[680px] flex flex-col items-center text-center">
|
||||
<div className="relative space-y-3 max-w-3xl">
|
||||
<p className="text-xs sm:text-sm font-semibold uppercase tracking-[0.18em] text-white/80 drop-shadow">
|
||||
Discover
|
||||
</p>
|
||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight leading-[1.05] text-white drop-shadow-[0_2px_12px_rgb(0_0_0/0.55)]">
|
||||
The world,
|
||||
<br className="sm:hidden" /> gathering.
|
||||
</h1>
|
||||
<p className="text-base sm:text-lg text-white/85 max-w-2xl mx-auto drop-shadow-[0_1px_6px_rgb(0_0_0/0.5)]">
|
||||
Campaigns, communities, and conversations from every corner of the
|
||||
globe. Backed by Bitcoin, broadcast on Nostr, owned by no one.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Spacer so the next block lands beneath the sphere. */}
|
||||
<div className="flex-1 min-h-[180px] sm:min-h-[220px]" aria-hidden="true" />
|
||||
|
||||
{/* Rotating stat ticker. The fixed min-height stops the layout
|
||||
from jumping as labels swap; the keyed inner span re-mounts on
|
||||
every change to trigger the fade-in transition. */}
|
||||
<div
|
||||
className="relative w-full max-w-md mx-auto rounded-full bg-background/55 backdrop-blur-xl backdrop-saturate-150 border border-white/20 dark:border-white/10 px-5 py-3 shadow-lg shadow-amber-500/10"
|
||||
aria-live="polite"
|
||||
>
|
||||
{currentStat ? (
|
||||
<div
|
||||
key={currentStat.id}
|
||||
className="flex items-center justify-center gap-3 motion-safe:animate-in motion-safe:fade-in motion-safe:duration-500"
|
||||
>
|
||||
<span className="text-primary shrink-0">{currentStat.icon}</span>
|
||||
<span className="text-sm sm:text-base font-semibold tracking-tight">
|
||||
{currentStat.value}
|
||||
</span>
|
||||
<span className="text-xs sm:text-sm text-muted-foreground line-clamp-1">
|
||||
{currentStat.label}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
{donationsLoading ? (
|
||||
<>
|
||||
<Skeleton className="size-5 rounded-full" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Connecting to relays…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* CTAs — clean glass pills, same vocabulary as `/`. Two clear
|
||||
actions: start something (campaign creation), or browse the
|
||||
world map for inspiration. */}
|
||||
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
|
||||
<Button
|
||||
size="lg"
|
||||
asChild
|
||||
className={cn(
|
||||
'relative rounded-full text-white font-semibold text-base h-12 px-7 [&_svg]:size-[18px]',
|
||||
'bg-gradient-to-br from-white/14 via-amber-100/10 to-rose-100/10 hover:from-white/20 hover:via-amber-100/14 hover:to-rose-100/14',
|
||||
'backdrop-blur-xl backdrop-saturate-150',
|
||||
'border border-white/25 hover:border-white/35',
|
||||
'shadow-[inset_0_0_0_1px_rgb(255_255_255/0.08),0_10px_28px_-12px_hsl(24_85%_45%/0.4)]',
|
||||
'hover:shadow-[inset_0_0_0_1px_rgb(255_255_255/0.12),0_12px_32px_-10px_hsl(24_85%_45%/0.5)]',
|
||||
'motion-safe:transition-colors motion-safe:duration-200',
|
||||
)}
|
||||
>
|
||||
<Link to="/campaigns/new">
|
||||
<PlusCircle className="mr-2" />
|
||||
Start a campaign
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
asChild
|
||||
className="rounded-full bg-background/60 backdrop-blur h-12 px-6 text-base"
|
||||
>
|
||||
<Link to="/world">
|
||||
<Globe2 className="size-4 mr-2" />
|
||||
Browse the world
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||
|
||||
import { CAMPAIGN_KIND } from '@/lib/campaign';
|
||||
import { shouldHideFeedEvent } from '@/lib/feedUtils';
|
||||
|
||||
const DISCOVER_PAGE_SIZE = 30;
|
||||
|
||||
/**
|
||||
* Kinds surfaced in the mixed Discover feed:
|
||||
*
|
||||
* - **30223** — new campaign creations (addressable). When a campaign is
|
||||
* minted or revised it bubbles back to the top, the same way a new
|
||||
* Substack post would.
|
||||
* - **1111** — NIP-22 comments. We pull two slices: comments scoped to
|
||||
* countries (`#K = iso3166`) and comments scoped to communities
|
||||
* (`#K = 34550`). Together these are "posts from the world" + "voices
|
||||
* inside the communities".
|
||||
* - **36639** — Agora pledges (challenges / civic calls). Always
|
||||
* included because they're the most action-oriented funding signal.
|
||||
*
|
||||
* We deliberately *exclude* free-form kind 1 notes here — the Discover
|
||||
* page is the place to see content that's tagged to a real-world thread
|
||||
* (country, community, campaign), not the global text-note firehose. The
|
||||
* old plain feed still lives at `/feed`.
|
||||
*/
|
||||
|
||||
/** Tag scopes we accept on kind 1111 comments. */
|
||||
const COMMENT_K_SCOPES = ['iso3166', 'geo', '34550'];
|
||||
|
||||
/** Aliases we accept on kind 36639 pledge `t` tags. */
|
||||
const ACTION_T_ALIASES = ['agora-action', 'pathos-challenge', 'agora-challenge'];
|
||||
|
||||
/**
|
||||
* Apply Discover-specific filtering after relay fetch. Drops events that
|
||||
* `shouldHideFeedEvent` flags (mutes, content filters happen later) and
|
||||
* any 1111 comment that lacks a recognised scope tag, since relays may
|
||||
* over-return when we union filters.
|
||||
*/
|
||||
function filterDiscoverEvents(events: NostrEvent[]): NostrEvent[] {
|
||||
return events
|
||||
.filter((event) => {
|
||||
if (shouldHideFeedEvent(event)) return false;
|
||||
if (event.kind === 1111) {
|
||||
const kTags = event.tags
|
||||
.filter(([n]) => n === 'k' || n === 'K')
|
||||
.map(([, v]) => v);
|
||||
return kTags.some((v) => COMMENT_K_SCOPES.includes(v));
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => b.created_at - a.created_at);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public infinite feed for the Discover page. Streams together new
|
||||
* campaigns, world-tagged comments, community comments, and Agora
|
||||
* pledges, paginated by `created_at` cursor.
|
||||
*
|
||||
* Each page issues exactly one relay request (the union of all relevant
|
||||
* filters) to stay inside per-page rate budgets — the same pattern
|
||||
* `useWorldFeed` uses.
|
||||
*
|
||||
* Returns the standard `useInfiniteQuery` surface plus a flattened
|
||||
* `events` list for convenient consumption.
|
||||
*/
|
||||
export function useDiscoverFeed(enabled = true) {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: ['discover-feed'],
|
||||
queryFn: async ({ pageParam, signal: querySignal }) => {
|
||||
const signal = AbortSignal.any([querySignal, AbortSignal.timeout(8_000)]);
|
||||
const until = pageParam as number | undefined;
|
||||
|
||||
const filters: NostrFilter[] = [
|
||||
// New / revised campaigns — addressable, so we lean on a small
|
||||
// limit and let the relay's natural newest-first ordering surface
|
||||
// recent edits. No `#k` scoping needed.
|
||||
{
|
||||
kinds: [CAMPAIGN_KIND],
|
||||
limit: Math.floor(DISCOVER_PAGE_SIZE / 3),
|
||||
...(until && { until }),
|
||||
},
|
||||
// Community + country-scoped comments.
|
||||
{
|
||||
kinds: [1111],
|
||||
'#K': COMMENT_K_SCOPES,
|
||||
limit: DISCOVER_PAGE_SIZE,
|
||||
...(until && { until }),
|
||||
},
|
||||
// Agora pledges.
|
||||
{
|
||||
kinds: [36639],
|
||||
'#t': ACTION_T_ALIASES,
|
||||
limit: Math.floor(DISCOVER_PAGE_SIZE / 3),
|
||||
...(until && { until }),
|
||||
},
|
||||
];
|
||||
|
||||
const raw = await nostr.query(filters, { signal });
|
||||
const filtered = filterDiscoverEvents(raw);
|
||||
const page = filtered.slice(0, DISCOVER_PAGE_SIZE);
|
||||
|
||||
const oldestTimestamp = page.length > 0
|
||||
? page[page.length - 1].created_at
|
||||
: null;
|
||||
|
||||
return {
|
||||
events: page,
|
||||
oldestTimestamp,
|
||||
totalFetched: filtered.length,
|
||||
};
|
||||
},
|
||||
initialPageParam: undefined as number | undefined,
|
||||
getNextPageParam: (lastPage) => {
|
||||
if (lastPage.totalFetched < DISCOVER_PAGE_SIZE || !lastPage.oldestTimestamp) {
|
||||
return undefined;
|
||||
}
|
||||
return lastPage.oldestTimestamp - 1;
|
||||
},
|
||||
enabled,
|
||||
staleTime: 30_000,
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
|
||||
// Flatten + dedupe. Each addressable event may legitimately appear
|
||||
// across pages if a newer revision lands; we keep the newest version.
|
||||
const seen = new Set<string>();
|
||||
const events: NostrEvent[] = [];
|
||||
for (const page of query.data?.pages ?? []) {
|
||||
for (const event of page.events) {
|
||||
if (seen.has(event.id)) continue;
|
||||
seen.add(event.id);
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
events,
|
||||
isLoading: query.isPending,
|
||||
isFetchingNextPage: query.isFetchingNextPage,
|
||||
hasNextPage: query.hasNextPage,
|
||||
fetchNextPage: query.fetchNextPage,
|
||||
pageCount: query.data?.pages.length,
|
||||
};
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
|
||||
import { Feed } from '@/components/Feed';
|
||||
import { useLayoutOptions } from '@/contexts/LayoutContext';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
|
||||
/** `/discover` — the Agora activity feed. */
|
||||
export function DiscoverPage() {
|
||||
const { config } = useAppContext();
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
useLayoutOptions({ showFAB: true, fabKind: 1, hasSubHeader: !!user });
|
||||
|
||||
useSeoMeta({
|
||||
title: `Discover | ${config.appName}`,
|
||||
description: 'Campaigns, pledges, donations, and conversations happening on Agora.',
|
||||
});
|
||||
|
||||
return <Feed />;
|
||||
}
|
||||
|
||||
export default DiscoverPage;
|
||||
Reference in New Issue
Block a user