diff --git a/NIP.md b/NIP.md index 177f952d..e2f34faf 100644 --- a/NIP.md +++ b/NIP.md @@ -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 diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 28835a17..f8cdc8fd 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -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) */} }> } /> - } /> } /> } /> } /> diff --git a/src/components/TopNav.tsx b/src/components/TopNav.tsx index 9ffee0e6..7720cffa 100644 --- a/src/components/TopNav.tsx +++ b/src/components/TopNav.tsx @@ -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 }, ]; diff --git a/src/components/discovery/DiscoverHero.tsx b/src/components/discovery/DiscoverHero.tsx deleted file mode 100644 index d00219a7..00000000 --- a/src/components/discovery/DiscoverHero.tsx +++ /dev/null @@ -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(() => { - const out: GlobeMarker[] = []; - const claimedCountries = new Set(); - - // 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(() => { - 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: , - }); - } - if (communities && communities.length > 0) { - items.push({ - id: 'communities', - value: communities.length.toLocaleString(), - label: `${communities.length === 1 ? 'organization' : 'organizations'} gathering on Nostr`, - icon: , - }); - } - if (activityByCountry && activityByCountry.size > 0) { - items.push({ - id: 'countries', - value: activityByCountry.size.toLocaleString(), - label: `${activityByCountry.size === 1 ? 'country' : 'countries'} posting today`, - icon: , - }); - } - 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 ( -
- {/* Atmosphere — same scrim + radial glow + sunrise rim used on - `/`. Seeded by the active hue so the whole hero blooms together - when the palette advances. */} - - - {/* Globe — centered, dominant. Slight upward bias so the headline - beneath has breathing room. */} -
-
- -
-
- - {/* 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. */} -
- ); -} diff --git a/src/hooks/useDiscoverFeed.ts b/src/hooks/useDiscoverFeed.ts deleted file mode 100644 index f3abbb32..00000000 --- a/src/hooks/useDiscoverFeed.ts +++ /dev/null @@ -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(); - 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, - }; -} diff --git a/src/pages/DiscoverPage.tsx b/src/pages/DiscoverPage.tsx deleted file mode 100644 index dfd8e6e8..00000000 --- a/src/pages/DiscoverPage.tsx +++ /dev/null @@ -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 ; -} - -export default DiscoverPage;