diff --git a/src/components/ActionShareMenu.tsx b/src/components/ActionShareMenu.tsx new file mode 100644 index 00000000..d9f7dd6c --- /dev/null +++ b/src/components/ActionShareMenu.tsx @@ -0,0 +1,196 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useQueryClient } from '@tanstack/react-query'; +import { nip19 } from 'nostr-tools'; +import { + Check, + Link as LinkIcon, + Loader2, + MoreHorizontal, + Trash2, +} from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { ModerationMenuItems } from '@/components/moderation'; +import { useCampaignModerators } from '@/hooks/useCampaignModerators'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useNostrPublish } from '@/hooks/useNostrPublish'; +import { useShareOrigin } from '@/hooks/useShareOrigin'; +import { useToast } from '@/hooks/useToast'; +import type { Action } from '@/hooks/useActions'; + +function getPledgeCoord(action: Action) { + return `36639:${action.pubkey}:${action.id}`; +} + +/** + * Per-card kebab menu for pledges. Surfaces: + * • Delete (owner only) — NIP-09 with both `e` and `a` tags so + * relays that ignore a-tag-only deletions still drop the event. + * • Copy link — naddr1 URL on the current share origin. + * • Moderation actions (mods only) — hide / feature, under a + * separator that only renders when the viewer is a moderator. + * + * Lives outside `ActionsPage` so both the page and the reusable + * `PledgesDiscoverySection` can pin it to the card's `topRight` slot + * without duplicating the logic. + */ +export function ActionShareMenu({ + action, + displayTitle, +}: { + action: Action; + displayTitle: string; +}) { + const { t } = useTranslation(); + const { user } = useCurrentUser(); + const { data: moderators } = useCampaignModerators(); + const { mutateAsync: createEvent } = useNostrPublish(); + const { toast } = useToast(); + const shareOrigin = useShareOrigin(); + const queryClient = useQueryClient(); + const [copied, setCopied] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + const isOwner = user?.pubkey === action.pubkey; + // Moderator gate is identical to the one in `ModerationMenuItems`, + // duplicated here so we can decide whether to render the trailing + // separator that introduces the moderator section. + // `ModerationMenuItems` returns `null` for non-mods, so without + // this check we'd render an orphaned separator at the bottom of + // the dropdown. + const isMod = !!user && !!moderators && moderators.includes(user.pubkey); + + const naddr = nip19.naddrEncode({ + kind: 36639, + pubkey: action.pubkey, + identifier: action.id, + }); + + const actionUrl = `${shareOrigin}/${naddr}`; + + const handleCopyLink = async () => { + try { + await navigator.clipboard.writeText(actionUrl); + setCopied(true); + toast({ title: t('pledges.card.linkCopied') }); + setTimeout(() => setCopied(false), 2000); + } catch (error) { + console.error('Failed to copy link:', error); + toast({ title: t('pledges.card.linkCopyFailed'), variant: 'destructive' }); + } + }; + + const handleDelete = async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!user || !isOwner) return; + + const confirmed = window.confirm(t('pledges.card.confirmDelete')); + if (!confirmed) return; + + setIsDeleting(true); + try { + // NIP-09 deletion. Include both 'e' and 'a' tags — some relays don't + // honour a-tag-only deletions for addressable events. + await createEvent({ + kind: 5, + content: t('pledges.card.deletedContent'), + tags: [ + ['e', action.event.id], + ['a', getPledgeCoord(action)], + ], + }); + // Extract any organization `A` tag the pledge was associated with so + // the org's activity shelf and community feeds refresh too. + const orgATag = action.event.tags.find(([n]) => n === 'A')?.[1]; + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['agora-actions'] }), + queryClient.invalidateQueries({ queryKey: ['agora-action'] }), + ...(orgATag + ? [ + queryClient.invalidateQueries({ + queryKey: ['organization-activity', orgATag], + }), + queryClient.invalidateQueries({ + queryKey: ['community-actions', orgATag], + }), + queryClient.invalidateQueries({ + predicate: (q) => { + const [root, aTagsKey] = q.queryKey; + return ( + root === 'community-activity-feed' && + typeof aTagsKey === 'string' && + aTagsKey.split(',').includes(orgATag) + ); + }, + }), + ] + : []), + ]); + toast({ title: t('pledges.card.deleted') }); + } catch (error) { + console.error('Failed to delete pledge:', error); + toast({ title: t('pledges.card.deleteFailed'), variant: 'destructive' }); + } finally { + setIsDeleting(false); + } + }; + + return ( + + e.preventDefault()}> + + + e.stopPropagation()}> + {isOwner && ( + <> + + {isDeleting ? ( + + ) : ( + + )} + {t('pledges.card.deletePledge')} + + + + )} + + {copied ? ( + + ) : ( + + )} + {t('pledges.card.copyLink')} + + {/* Moderator actions appear under a separator when the viewer + is a Team Soapbox moderator. `ModerationMenuItems` returns + null for non-mods, so we gate the trailing separator on + the same `isMod` check to avoid an orphan separator at + the bottom of non-mod dropdowns. */} + {isMod && } + + + + ); +} diff --git a/src/components/discovery/CampaignsDiscoverySection.tsx b/src/components/discovery/CampaignsDiscoverySection.tsx new file mode 100644 index 00000000..00c469e5 --- /dev/null +++ b/src/components/discovery/CampaignsDiscoverySection.tsx @@ -0,0 +1,266 @@ +import { useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { HandHeart, PlusCircle } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard'; +import { DiscoverySearchToolbar } from '@/components/DiscoverySearchToolbar'; +import { useAllCampaigns, type CampaignSort } from '@/hooks/useAllCampaigns'; +import { useCampaignModeration } from '@/hooks/useCampaignModeration'; +import { useCampaignModerators } from '@/hooks/useCampaignModerators'; +import { useCampaigns } from '@/hooks/useCampaigns'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useDiscoveryFilters } from '@/hooks/useDiscoveryFilters'; +import type { Nip50Sort } from '@/hooks/useNip50Search'; +import type { ParsedCampaign } from '@/lib/campaign'; + +/** + * Map the toolbar's sort vocabulary (`default` / `top` / `new`) to the + * `useAllCampaigns` hook's vocabulary (`top` / `none`). `'new'` and + * `'default'` both map to `'none'` (chronological) — the section + * handles the "show featured only" framing on top of that. + */ +const toQuerySort = (s: Nip50Sort): CampaignSort => (s === 'top' ? 'top' : 'none'); + +interface CampaignsDiscoverySectionProps { + /** + * Where this section's filter state lives: + * + * • `'url'` — flat URL params (`?q=&sort=&country=`). Used by the + * dedicated `/campaigns/all` page so search results are + * shareable and survive refresh. + * • `'local'` — local-only state. Used by `/` where three + * discovery sections coexist and can't all own `?q=`. + */ + filterPersistence: 'url' | 'local'; + /** + * Visible-row cap for the **idle** featured-first view. The active + * (search / sort / country) view always shows the full result set, + * because the user has explicitly asked to browse. Defaults to + * unlimited (`undefined`). + */ + idleLimit?: number; + /** + * Optional hoisted Show-hidden state. When provided, the toolbar + * exposes the mod-only switch and uses this state. The page can + * read the same value to drive a separate Hidden collapsible. When + * omitted, the switch never appears. + */ + showHidden?: { + value: boolean; + onChange: (next: boolean) => void; + /** Hidden-count badge for the toolbar chip. */ + count?: number; + }; +} + +/** + * Unified campaigns discovery section: section header + toolbar + + * idle/active grid. + * + * The section has two display modes: + * + * 1. **Idle** (no search, no sort, no country picked) — renders the + * moderator-curated featured grid. Falls back to the + * chronological grid when nothing is featured yet so the section + * is never blank. + * 2. **Active** — renders the full ranked / chronological / country- + * scoped result set. + * + * Hidden campaigns are excluded by default. Moderators can flip the + * Show-hidden switch in the toolbar; the section reads that state + * from the `showHidden` prop so a page can persist it across + * multiple shelves (e.g. the Hidden collapsible mod section). + * + * Search is post-filtered client-side across title / summary / story / + * location / categories — relay NIP-50 sort-by-top doesn't account + * for sats raised, which is the ranking signal users actually want + * when searching for campaigns. + */ +export function CampaignsDiscoverySection({ + filterPersistence, + idleLimit, + showHidden: showHiddenProp, +}: CampaignsDiscoverySectionProps) { + const { t } = useTranslation(); + const { user } = useCurrentUser(); + const { data: moderators } = useCampaignModerators(); + const isMod = !!user && !!moderators && moderators.includes(user.pubkey); + + const filters = useDiscoveryFilters({ + urlPrefix: filterPersistence === 'url' ? '' : undefined, + enableCountry: true, + }); + + const activeQuery = filters.debouncedSearch.trim(); + const isActive = + activeQuery !== '' || filters.sort !== 'default' || !!filters.country; + + const { data: campaigns, isLoading } = useAllCampaigns({ + sort: toQuerySort(filters.sort), + search: activeQuery, + countryCode: filters.country, + limit: 200, + }); + + const { data: moderation, isReady: moderationReady } = useCampaignModeration(); + + // Featured slot list — derived from moderation labels. Hidden + // coords dropped so a featured-then-hidden campaign disappears + // from the row. + const featuredCoords = useMemo(() => { + if (!moderationReady) return [] as string[]; + return Array.from(moderation.featuredCoords) + .filter((coord) => !moderation.hiddenCoords.has(coord)) + .sort( + (a, b) => + (moderation.featuredOrder.get(b) ?? 0) - + (moderation.featuredOrder.get(a) ?? 0), + ); + }, [moderation, moderationReady]); + + const { data: featuredCampaigns } = useCampaigns({ + coordinates: featuredCoords, + limit: featuredCoords.length || 1, + enabled: moderationReady, + }); + + const showHiddenValue = showHiddenProp?.value ?? false; + + // Visible campaigns in the **active** branch: every campaign + // matching the search / sort / country, minus hidden (unless the + // moderator opted in). Featured items are intentionally NOT pulled + // out — when the user is actively browsing, they want a ranked or + // chronological grid, not the curated shelf. + const visible = useMemo(() => { + const all = campaigns ?? []; + const hiddenCoords = moderation?.hiddenCoords ?? new Set(); + const out: ParsedCampaign[] = []; + for (const c of all) { + if (hiddenCoords.has(c.aTag)) { + if (isMod && showHiddenValue) out.push(c); + } else { + out.push(c); + } + } + return out; + }, [campaigns, isMod, moderation, showHiddenValue]); + + const orderedFeaturedCampaigns = useMemo(() => { + if (!featuredCampaigns) return [] as ParsedCampaign[]; + return [...featuredCampaigns].sort( + (a, b) => + (moderation.featuredOrder.get(b.aTag) ?? 0) - + (moderation.featuredOrder.get(a.aTag) ?? 0), + ); + }, [featuredCampaigns, moderation]); + + // Idle-mode list: featured first; if nothing is featured, fall back + // to the latest chronological grid so the section is never blank + // when there's content to show. + const idleCampaigns = useMemo(() => { + const list = + orderedFeaturedCampaigns.length > 0 ? orderedFeaturedCampaigns : visible; + return idleLimit ? list.slice(0, idleLimit) : list; + }, [orderedFeaturedCampaigns, visible, idleLimit]); + + const showSkeleton = isLoading || !moderationReady; + const listForRender = isActive ? visible : idleCampaigns; + const hiddenCount = showHiddenProp?.count ?? 0; + const hiddenAllOfThem = !isActive && hiddenCount > 0 && !showHiddenValue; + + return ( +
+
+
+

+ {activeQuery ? t('common.search') : t('campaigns.all.title')} +

+

+ {activeQuery + ? t('common.searchResultsCount', { count: visible.length }) + : t('campaigns.all.sectionTagline')} +

+
+ +
+ + {showSkeleton ? ( +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+ ) : listForRender.length === 0 ? ( + + + +
+ {activeQuery ? ( + <> +

+ {t('campaigns.all.noMatch', { query: activeQuery })} +

+

+ {t('campaigns.all.noMatchHint')} +

+ + ) : hiddenAllOfThem ? ( + <> +

+ {t('campaigns.all.allHidden')} +

+

+ {t('campaigns.all.allHiddenHint')} +

+ + ) : ( + <> +

+ {t('campaigns.all.empty')} +

+

+ {t('campaigns.all.emptyHint')} +

+ + )} +
+ +
+
+ ) : ( +
+ {listForRender.map((campaign) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/components/discovery/GroupsDiscoverySection.tsx b/src/components/discovery/GroupsDiscoverySection.tsx new file mode 100644 index 00000000..074065b9 --- /dev/null +++ b/src/components/discovery/GroupsDiscoverySection.tsx @@ -0,0 +1,253 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Card, CardContent } from '@/components/ui/card'; +import { CommunityGrid } from '@/components/discovery/CommunityGrid'; +import { + CommunityMiniCard, + CommunityMiniCardSkeleton, +} from '@/components/discovery/CommunityMiniCard'; +import { DiscoverySearchToolbar } from '@/components/DiscoverySearchToolbar'; +import { useAppContext } from '@/hooks/useAppContext'; +import { useCampaignModerators } from '@/hooks/useCampaignModerators'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useDiscoveryFilters } from '@/hooks/useDiscoveryFilters'; +import { useFeaturedOrganizations } from '@/hooks/useFeaturedOrganizations'; +import { useNip50Search } from '@/hooks/useNip50Search'; +import { useOrganizationModeration } from '@/hooks/useOrganizationModeration'; +import { + COMMUNITY_DEFINITION_KIND, + parseCommunityEvent, + type ParsedCommunity, +} from '@/lib/communityUtils'; + +interface GroupsDiscoverySectionProps { + /** + * Where this section's filter state lives. See + * `CampaignsDiscoverySection` for the rationale. + */ + filterPersistence: 'url' | 'local'; + /** + * Visible-row cap for the **idle** featured-first view. The active + * (search / sort) view always shows the full result set. Defaults + * to unlimited (`undefined`). + */ + idleLimit?: number; + /** + * Optional hoisted Show-hidden state. When provided, the toolbar + * exposes the mod-only switch and uses this state. The page can + * read the same value to drive a separate Hidden collapsible. + */ + showHidden?: { + value: boolean; + onChange: (next: boolean) => void; + /** Hidden-count badge for the toolbar chip. */ + count?: number; + }; +} + +/** + * Unified groups discovery section: section header + toolbar + + * idle/active grid. + * + * • **Idle** (default sort, empty query) — renders ONLY + * moderator-featured groups. No fallback to a chronological "all + * groups" grid: that produced a flash of unrelated communities + * while the relay returned every kind-34550 event before the + * client-side Agora-tag filter ran. The skeleton is gated on the + * featured query itself so the idle view goes + * skeleton → curated grid without an intermediate state. + * + * • **Active** (search / Top / New) — renders the full relay + * search result set, post-filtered against name / description / + * content client-side because group names live in tags and most + * NIP-50 relays only match `content`. + * + * Groups aren't country-scoped (a community is its own scope), so + * the country picker is intentionally omitted from the toolbar even + * though Campaigns and Pledges expose it. + */ +export function GroupsDiscoverySection({ + filterPersistence, + idleLimit, + showHidden: showHiddenProp, +}: GroupsDiscoverySectionProps) { + const { t } = useTranslation(); + const { config } = useAppContext(); + const { user } = useCurrentUser(); + const { data: moderators } = useCampaignModerators(); + const isMod = !!user && !!moderators && moderators.includes(user.pubkey); + + const filters = useDiscoveryFilters({ + urlPrefix: filterPersistence === 'url' ? '' : undefined, + enableCountry: false, + }); + + const trimmedSearch = filters.debouncedSearch.trim(); + const showHiddenValue = showHiddenProp?.value ?? false; + const hiddenCount = showHiddenProp?.count ?? 0; + + const { + data: searchHitsRaw, + isFetching: isSearchFetching, + isActive: isSearching, + } = useNip50Search({ + kind: COMMUNITY_DEFINITION_KIND, + query: filters.debouncedSearch, + sort: filters.sort, + parse: parseCommunityEvent, + // Group names and descriptions live in tags, not `content`. Relay + // NIP-50 implementations that only match content silently miss + // obvious title hits — widen client-side by also checking these + // tag values. + getKeywordHaystack: (event) => { + const name = event.tags.find(([n]) => n === 'name')?.[1] ?? ''; + const description = event.tags.find(([n]) => n === 'description')?.[1] ?? ''; + return [name, description, event.content]; + }, + }); + + const { data: orgModeration, isReady: orgModerationReady } = + useOrganizationModeration(); + + const searchHits = useMemo(() => { + if (!searchHitsRaw) return undefined; + const hiddenCoords = orgModeration?.hiddenCoords ?? new Set(); + const visible: ParsedCommunity[] = []; + for (const c of searchHitsRaw) { + if (hiddenCoords.has(c.aTag)) { + if (showHiddenValue) visible.push(c); + } else { + visible.push(c); + } + } + return visible; + }, [searchHitsRaw, orgModeration, showHiddenValue]); + + // Featured groups — the curated list moderators publish. This is + // the entire idle-mode payload: no chronological fallback, no + // client-side tag filter, no "fetch everything and pick the Agora + // ones out of it" dance. Hidden coords are dropped (unless a + // moderator has flipped Show hidden on). + const { data: featuredOrgs, isLoading: featuredOrgsLoading } = + useFeaturedOrganizations(); + + const featuredGroups = useMemo(() => { + if (!featuredOrgs) return []; + const hiddenCoords = orgModeration?.hiddenCoords ?? new Set(); + const list = featuredOrgs + .map((entry) => entry.community) + .filter((c) => (isMod && showHiddenValue) || !hiddenCoords.has(c.aTag)); + return idleLimit ? list.slice(0, idleLimit) : list; + }, [featuredOrgs, orgModeration, isMod, showHiddenValue, idleLimit]); + + // Idle-render skeleton gate. `useFeaturedOrganizations` is + // internally gated on `moderationReady`, so while the moderation + // labels are still loading, the hook is *disabled* and reports + // `isLoading: false` / `data: undefined`. Treating that as "not + // loading" would render the empty state for a moment before the + // curated grid pops in; tracking moderation-readiness here keeps + // the skeleton on screen until we know what's featured. + const idleLoading = + !orgModerationReady || featuredOrgsLoading || featuredOrgs === undefined; + + return ( +
+
+
+

+ {trimmedSearch ? t('common.search') : t('groups.list.allGroups')} +

+

+ {isSearching && searchHits + ? t('common.searchResultsCount', { count: searchHits.length }) + : t('groups.list.allGroupsTagline')} +

+
+ +
+ + {isSearching ? ( + <> + {isSearchFetching && !searchHits ? ( + + {Array.from({ length: 8 }).map((_, i) => ( + + ))} + + ) : searchHits && searchHits.length > 0 ? ( + + {searchHits.map((community) => ( + + ))} + + ) : ( + + + {trimmedSearch ? ( + <> +

+ {t('groups.list.noMatch', { query: trimmedSearch })} +

+

+ {t('groups.list.noMatchHint')} +

+ + ) : ( +

+ {t('groups.list.noFeaturedBody', { appName: config.appName })} +

+ )} +
+
+ )} + + ) : idleLoading ? ( + + {Array.from({ length: 8 }).map((_, i) => ( + + ))} + + ) : featuredGroups.length > 0 ? ( + + {featuredGroups.map((community) => ( + + ))} + + ) : ( + + +

+ {t('groups.list.noFeaturedBody', { appName: config.appName })} +

+
+
+ )} +
+ ); +} diff --git a/src/components/discovery/PledgesDiscoverySection.tsx b/src/components/discovery/PledgesDiscoverySection.tsx new file mode 100644 index 00000000..24cd6afd --- /dev/null +++ b/src/components/discovery/PledgesDiscoverySection.tsx @@ -0,0 +1,328 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { ActionShareMenu } from '@/components/ActionShareMenu'; +import { Card } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { DiscoverySearchToolbar } from '@/components/DiscoverySearchToolbar'; +import { ModerationOverlay } from '@/components/moderation'; +import { PledgeCard } from '@/components/PledgeCard'; +import { parseAction, useActions, type Action } from '@/hooks/useActions'; +import { useBtcPrice } from '@/hooks/useBtcPrice'; +import { useCampaignModerators } from '@/hooks/useCampaignModerators'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useDiscoveryFilters } from '@/hooks/useDiscoveryFilters'; +import { useNip50Search } from '@/hooks/useNip50Search'; +import { usePledgeModeration } from '@/hooks/usePledgeModeration'; + +function getPledgeCoord(action: Action) { + return `36639:${action.pubkey}:${action.id}`; +} + +function ActionSkeleton() { + return ( + + +
+ + + + + +
+
+ ); +} + +interface PledgesDiscoverySectionProps { + /** + * Where this section's filter state lives. See + * `CampaignsDiscoverySection` for the rationale. + */ + filterPersistence: 'url' | 'local'; + /** + * Visible-row cap for the **idle** featured-first view. The active + * (search / sort / country) view always shows the full result set. + * Defaults to unlimited (`undefined`). + */ + idleLimit?: number; + /** + * Optional hoisted Show-hidden state. When provided, the toolbar + * exposes the mod-only switch and uses this state. The page can + * read the same value to drive a separate Hidden collapsible. + */ + showHidden?: { + value: boolean; + onChange: (next: boolean) => void; + /** Hidden-count badge for the toolbar chip. */ + count?: number; + }; +} + +/** + * Unified pledges discovery section: section header + toolbar + + * idle/active grid. + * + * • **Idle** (no search / no sort / no country) — renders the + * moderator-featured pledges, falling back to chronological + * all-pledges when nothing is featured yet. + * + * • **Active** — renders the full search / sort / country-scoped + * result set, post-filtered against title / content client-side. + * Picking a country with an empty query still activates the + * search view — narrowing kind 36639 by NIP-73 `iso3166:XX` + + * legacy `geo:XX` tags produces a useful filtered grid even + * without a typed term. + */ +export function PledgesDiscoverySection({ + filterPersistence, + idleLimit, + showHidden: showHiddenProp, +}: PledgesDiscoverySectionProps) { + const { t } = useTranslation(); + const { user } = useCurrentUser(); + const { data: btcPrice } = useBtcPrice(); + const { data: moderators } = useCampaignModerators(); + const isMod = !!user && !!moderators && moderators.includes(user.pubkey); + + const filters = useDiscoveryFilters({ + urlPrefix: filterPersistence === 'url' ? '' : undefined, + enableCountry: true, + }); + + const trimmedSearch = filters.debouncedSearch.trim(); + const showHiddenValue = showHiddenProp?.value ?? false; + const canShowHidden = isMod && showHiddenValue; + const hiddenCount = showHiddenProp?.count ?? 0; + + // Country → NIP-73 `#i` tag list. Picking a country with no typed + // query still activates the search view; narrowing a kind by + // external identifier produces a useful filtered grid even without + // a typed term. + const iTags = useMemo(() => { + if (!filters.country) return undefined; + const code = filters.country.toUpperCase(); + return [`iso3166:${code}`, `geo:${code}`]; + }, [filters.country]); + + const { + data: searchHitsRaw, + isFetching: isSearchFetching, + isActive: isSearching, + } = useNip50Search({ + kind: 36639, + query: filters.debouncedSearch, + sort: filters.sort, + parse: parseAction, + iTags, + // Pledge titles live in a `title` tag, not `content`. Most NIP-50 + // implementations only match content; widen the net client-side. + getKeywordHaystack: (event) => { + const title = event.tags.find(([n]) => n === 'title')?.[1] ?? ''; + return [title, event.content]; + }, + }); + + const { data: rawActions, isLoading: actionsLoading } = useActions({ + countryCode: filters.country, + limit: 300, + }); + + const { data: pledgeModeration, isReady: pledgeModerationReady } = + usePledgeModeration(); + + const featuredPledgeCoords = useMemo(() => { + if (!pledgeModerationReady) return [] as string[]; + return Array.from(pledgeModeration.featuredCoords) + .filter((coord) => !pledgeModeration.hiddenCoords.has(coord)) + .sort( + (a, b) => + (pledgeModeration.featuredOrder.get(b) ?? 0) - + (pledgeModeration.featuredOrder.get(a) ?? 0), + ); + }, [pledgeModeration, pledgeModerationReady]); + + const { data: featuredPledges } = useActions({ + coordinates: featuredPledgeCoords, + limit: featuredPledgeCoords.length || 1, + enabled: pledgeModerationReady && featuredPledgeCoords.length > 0, + }); + + const orderedFeaturedPledges = useMemo(() => { + if (!featuredPledges || !pledgeModerationReady) return [] as Action[]; + const order = pledgeModeration.featuredOrder; + return [...featuredPledges].sort((a, b) => { + const aCoord = getPledgeCoord(a); + const bCoord = getPledgeCoord(b); + return (order.get(bCoord) ?? 0) - (order.get(aCoord) ?? 0); + }); + }, [featuredPledges, pledgeModeration, pledgeModerationReady]); + + const featuredPledgeCoordSet = useMemo( + () => new Set(featuredPledgeCoords), + [featuredPledgeCoords], + ); + + const searchHits = useMemo(() => { + if (!searchHitsRaw) return undefined; + const hiddenCoords = pledgeModeration?.hiddenCoords ?? new Set(); + const visible: Action[] = []; + for (const a of searchHitsRaw) { + const coord = getPledgeCoord(a); + if (hiddenCoords.has(coord)) { + if (canShowHidden) visible.push(a); + } else { + visible.push(a); + } + } + return visible; + }, [searchHitsRaw, pledgeModeration, canShowHidden]); + + // Chronological pledge list filtered by country, with + // moderator-hidden items dropped (unless `showHidden` is on). + // Featured pledges are NOT excluded here — the idle render path + // pulls them separately, and the active render path shows the + // full list. + const actions = useMemo(() => { + if (!rawActions) return undefined; + const hiddenCoords = pledgeModeration?.hiddenCoords ?? new Set(); + const visible: Action[] = []; + for (const action of rawActions) { + const coord = getPledgeCoord(action); + if (hiddenCoords.has(coord)) { + if (canShowHidden) visible.push(action); + } else { + visible.push(action); + } + } + return visible; + }, [rawActions, pledgeModeration, canShowHidden]); + + const isLoading = actionsLoading || !pledgeModerationReady; + const isSearchLoading = isSearchFetching || !pledgeModerationReady; + + // Idle list: featured first; if none are featured, fall back to + // the chronological all-pledges grid so the section is never blank. + const idlePledges = useMemo(() => { + const list = + orderedFeaturedPledges.length > 0 + ? orderedFeaturedPledges + : (actions ?? []).filter( + (action) => !featuredPledgeCoordSet.has(getPledgeCoord(action)), + ); + return idleLimit ? list.slice(0, idleLimit) : list; + }, [orderedFeaturedPledges, actions, featuredPledgeCoordSet, idleLimit]); + + const renderPledge = (action: Action) => ( + + + + + } + /> + ); + + return ( +
+
+
+

+ {trimmedSearch ? t('common.search') : t('pledges.list.allPledges')} +

+

+ {isSearching && searchHits + ? t('common.searchResultsCount', { count: searchHits.length }) + : t('pledges.list.allPledgesTagline')} +

+
+ +
+ + {isSearching ? ( + <> + {isSearchLoading && !searchHits ? ( +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+ ) : searchHits && searchHits.length > 0 ? ( +
+ {searchHits.map(renderPledge)} +
+ ) : ( + +
+ {trimmedSearch ? ( + <> +

+ {t('pledges.list.noMatch', { query: trimmedSearch })} +

+

+ {t('pledges.list.noMatchHint')} +

+ + ) : ( +

+ {t('pledges.list.emptyTitle')} +

+ )} +
+
+ )} + + ) : isLoading ? ( +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+ ) : idlePledges.length > 0 ? ( +
+ {idlePledges.map(renderPledge)} +
+ ) : ( + +
+

+ {t('pledges.list.emptyTitle')} +

+
+
+ )} +
+ ); +} diff --git a/src/hooks/useDiscoveryFilters.ts b/src/hooks/useDiscoveryFilters.ts new file mode 100644 index 00000000..c1b81d33 --- /dev/null +++ b/src/hooks/useDiscoveryFilters.ts @@ -0,0 +1,198 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; + +import { useDebounce } from '@/hooks/useDebounce'; +import type { Nip50Sort } from '@/hooks/useNip50Search'; + +/** + * Type-guard for the `?sort=` URL param value used by every discovery + * section (Campaigns, Groups, Pledges). + * + * - `'top'` and `'new'` map to the toolbar's active sort modes. + * - Anything else (missing, empty, legacy values) collapses to + * `'default'`, the curated featured-first idle state. + */ +function parseSort(value: string | null): Nip50Sort { + if (value === 'top') return 'top'; + if (value === 'new') return 'new'; + return 'default'; +} + +export interface DiscoveryFilters { + /** + * Live search input value, updated on every keystroke. Bind this to + * the toolbar's `` so typing stays responsive. + */ + searchInput: string; + setSearchInput: (next: string) => void; + /** + * Debounced search value. Use this as the input to relay queries + * and as the source for "is this section actively searching?" + * checks. URL writes also happen on this value, so the URL doesn't + * churn on every keystroke. + */ + debouncedSearch: string; + /** Active sort mode. */ + sort: Nip50Sort; + setSort: (next: Nip50Sort) => void; + /** Selected ISO-3166 alpha-2 country code, or `undefined` for global. */ + country: string | undefined; + setCountry: (next: string | undefined) => void; +} + +interface UseDiscoveryFiltersOptions { + /** + * URL-namespace for persisted filters, or `undefined` for local-only + * state. + * + * • `''` — flat URL params (`?q=…&sort=…&country=…`). The dedicated + * browse pages (`/campaigns/all`, `/groups`, `/pledges`) want + * this so search results are shareable / linkable and survive + * refresh. + * + * • `undefined` — purely local state, no URL writes. The home + * page (`/`) hosts all three sections at once. Pushing each + * section's filters into the URL there would either collide + * (three sections want `?q=`) or pollute the path with six to + * nine prefixed params on every keystroke. Keeping state local + * means refreshing `/` lands on the curated idle view, which + * matches what we want anyway. + * + * • Any other string — namespaced URL params + * (`?fooQ=&fooSort=&fooCountry=`). Reserved for future surfaces + * that need multiple coexisting filter sets in the URL. + */ + urlPrefix?: string; + /** + * Whether the section exposes a country picker. When `false`, the + * country slot stays `undefined` and the `country` URL param is + * never read or written even if a stale value sits in the URL. + * Defaults to `true`. + */ + enableCountry?: boolean; +} + +/** + * Filter state machine shared by every discovery section. + * + * Owns three pieces of state — search input (debounced), sort mode, + * country code — and (optionally) mirrors them to URL params so deep + * links and browser back/forward work. Defaults are stripped on write + * so the canonical URL stays clean (`/campaigns/all`, not + * `/campaigns/all?q=&sort=`). + * + * Debouncing lives inside this hook (300ms) so consumers don't have + * to thread the debounced value back in — that would create a + * circular dependency with the URL-sync effect. Consumers should + * pass `debouncedSearch` straight to their relay query. + * + * URL writes use `replace: true` so typing doesn't pile entries onto + * the history stack. + */ +export function useDiscoveryFilters({ + urlPrefix, + enableCountry = true, +}: UseDiscoveryFiltersOptions): DiscoveryFilters { + const useUrl = urlPrefix !== undefined; + // Always call the hook — React's rules — but only read/write through + // it when `useUrl` is true. + const [searchParams, setSearchParams] = useSearchParams(); + + const qKey = useUrl ? (urlPrefix === '' ? 'q' : `${urlPrefix}Q`) : ''; + const sortKey = useUrl ? (urlPrefix === '' ? 'sort' : `${urlPrefix}Sort`) : ''; + const countryKey = useUrl + ? urlPrefix === '' + ? 'country' + : `${urlPrefix}Country` + : ''; + + // Seed state from the URL on first render so deep links / refreshes + // restore the user's last view, then run the toolbar from local + // state and push debounced changes back to the URL. + const [searchInput, setSearchInputState] = useState( + useUrl ? (searchParams.get(qKey) ?? '') : '', + ); + const [sort, setSortState] = useState( + useUrl ? parseSort(searchParams.get(sortKey)) : 'default', + ); + const [country, setCountryState] = useState( + useUrl && enableCountry + ? (searchParams.get(countryKey) ?? undefined) + : undefined, + ); + + const debouncedSearch = useDebounce(searchInput, 300); + + // URL → state. Handles browser back/forward and direct deep-link + // navigation while a section is mounted (e.g. clicking an internal + // link that updates `?sort=top`). We compare before assigning to + // avoid React render loops. + useEffect(() => { + if (!useUrl) return; + const urlQuery = searchParams.get(qKey) ?? ''; + if (urlQuery !== searchInput && urlQuery !== debouncedSearch) { + setSearchInputState(urlQuery); + } + const urlSort = parseSort(searchParams.get(sortKey)); + if (urlSort !== sort) setSortState(urlSort); + if (enableCountry) { + const urlCountry = searchParams.get(countryKey) ?? undefined; + if (urlCountry !== country) setCountryState(urlCountry); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchParams]); + + // Debounced search → URL. Strip empty values so the canonical URL + // stays clean. + useEffect(() => { + if (!useUrl) return; + const next = new URLSearchParams(searchParams); + const trimmed = debouncedSearch.trim(); + if (trimmed) next.set(qKey, trimmed); + else next.delete(qKey); + if (next.toString() !== searchParams.toString()) { + setSearchParams(next, { replace: true }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedSearch, useUrl]); + + const setSort = useCallback( + (next: Nip50Sort) => { + setSortState(next); + if (!useUrl) return; + const params = new URLSearchParams(searchParams); + if (next === 'default') params.delete(sortKey); + else params.set(sortKey, next); + setSearchParams(params, { replace: true }); + }, + [useUrl, searchParams, setSearchParams, sortKey], + ); + + const setCountry = useCallback( + (next: string | undefined) => { + if (!enableCountry) return; + setCountryState(next); + if (!useUrl) return; + const params = new URLSearchParams(searchParams); + if (next) params.set(countryKey, next); + else params.delete(countryKey); + setSearchParams(params, { replace: true }); + }, + [enableCountry, useUrl, searchParams, setSearchParams, countryKey], + ); + + const setSearchInput = useCallback((next: string) => { + setSearchInputState(next); + // URL writes happen on `debouncedSearch` flipping, not per keystroke. + }, []); + + return { + searchInput, + setSearchInput, + debouncedSearch, + sort, + setSort, + country, + setCountry, + }; +} diff --git a/src/locales/ar.json b/src/locales/ar.json index 1f003e25..9eeeebe2 100644 --- a/src/locales/ar.json +++ b/src/locales/ar.json @@ -692,8 +692,6 @@ "community": "حملات المجتمع", "communityDesc": "ساعد في تمويل التغييرات التي تستحق العناء.", "browseAll": "تصفّح كل الحملات ←", - "browseAllGroups": "تصفّح كل المجموعات ←", - "browseAllPledges": "تصفّح كل التعهدات ←", "pending": "بانتظار الموافقة", "pendingDesc": "حملات موجودة على الشبكة لم يوافق عليها ولم يُخفها أي مشرف من فريق Soapbox بعد.", "pendingEmpty": "لا يوجد شيء بانتظار المراجعة.", diff --git a/src/locales/en.json b/src/locales/en.json index c8ba81d4..d2932e16 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1128,8 +1128,6 @@ "community": "Community Campaigns", "communityDesc": "Help fund the changes worth making.", "browseAll": "Browse all campaigns →", - "browseAllGroups": "Browse all groups →", - "browseAllPledges": "Browse all pledges →", "searchPlaceholder": "Search campaigns\u2026", "searchAriaLabel": "Search campaigns", "noMatch": "No campaigns match \u201c{{query}}\u201d", diff --git a/src/locales/es.json b/src/locales/es.json index cbf16b71..1966cf8a 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -704,8 +704,6 @@ "community": "Campañas de la comunidad", "communityDesc": "Ayuda a financiar los cambios que valen la pena.", "browseAll": "Ver todas las campañas →", - "browseAllGroups": "Ver todos los grupos →", - "browseAllPledges": "Ver todas las promesas →", "pending": "Pendientes de aprobación", "pendingDesc": "Campañas presentes en la red que ningún moderador del equipo Soapbox ha aprobado u ocultado todavía.", "pendingEmpty": "Nada pendiente de revisión.", diff --git a/src/locales/fa.json b/src/locales/fa.json index 8474f951..9d12c314 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -704,8 +704,6 @@ "community": "کمپین‌های جامعه", "communityDesc": "به تأمین مالی تغییراتی که ارزش انجام دادن دارند کمک کنید.", "browseAll": "← مرور همه کمپین‌ها", - "browseAllGroups": "← مرور همه گروه‌ها", - "browseAllPledges": "← مرور همه تعهدها", "pending": "در انتظار تأیید", "pendingDesc": "کمپین‌هایی که در شبکه هستند و هیچ ناظر تیم Soapbox هنوز آن‌ها را تأیید یا پنهان نکرده است.", "pendingEmpty": "چیزی برای بررسی نیست.", diff --git a/src/locales/fr.json b/src/locales/fr.json index ad915014..50b5c1fd 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -1126,8 +1126,6 @@ "community": "Campagnes communautaires", "communityDesc": "Aidez à financer les changements qui valent la peine d'être menés.", "browseAll": "Parcourir toutes les campagnes →", - "browseAllGroups": "Parcourir tous les groupes →", - "browseAllPledges": "Parcourir toutes les promesses →", "pending": "En attente d'approbation", "pendingDesc": "Campagnes sur le réseau qu'aucun modérateur de Team Soapbox n'a encore approuvées ou masquées.", "pendingEmpty": "Rien en attente d'examen.", diff --git a/src/locales/hi.json b/src/locales/hi.json index 9a75d7e9..1877ec6e 100644 --- a/src/locales/hi.json +++ b/src/locales/hi.json @@ -1136,8 +1136,6 @@ "community": "कम्युनिटी कैंपेन", "communityDesc": "उन बदलावों को फंड करने में मदद करें जिनके होने लायक़ हैं।", "browseAll": "सभी कैंपेन देखें →", - "browseAllGroups": "सभी ग्रुप देखें →", - "browseAllPledges": "सभी प्लेज देखें →", "pending": "मंज़ूरी का इंतज़ार", "pendingDesc": "नेटवर्क पर ऐसे कैंपेन जिन्हें Team Soapbox के किसी मॉडरेटर ने अभी मंज़ूरी या छुपाया नहीं है।", "pendingEmpty": "समीक्षा का इंतज़ार में कुछ नहीं।", diff --git a/src/locales/id.json b/src/locales/id.json index 455b029f..2079bb2c 100644 --- a/src/locales/id.json +++ b/src/locales/id.json @@ -1136,8 +1136,6 @@ "community": "Kampanye Komunitas", "communityDesc": "Bantu danai perubahan yang patut dilakukan.", "browseAll": "Telusuri semua kampanye →", - "browseAllGroups": "Telusuri semua grup →", - "browseAllPledges": "Telusuri semua ikrar →", "pending": "Menunggu persetujuan", "pendingDesc": "Kampanye di jaringan yang belum disetujui atau disembunyikan oleh moderator Team Soapbox.", "pendingEmpty": "Tidak ada yang menunggu peninjauan.", diff --git a/src/locales/km.json b/src/locales/km.json index bae9e204..8ea9366d 100644 --- a/src/locales/km.json +++ b/src/locales/km.json @@ -704,8 +704,6 @@ "community": "យុទ្ធនាការសហគមន៍", "communityDesc": "ជួយផ្ដល់មូលនិធិដល់ការផ្លាស់ប្ដូរដែលសក្តិសម។", "browseAll": "មើលយុទ្ធនាការទាំងអស់ →", - "browseAllGroups": "មើលក្រុមទាំងអស់ →", - "browseAllPledges": "មើលការសន្យាទាំងអស់ →", "pending": "កំពុងរង់ចាំការអនុម័ត", "pendingDesc": "យុទ្ធនាការនៅលើបណ្ដាញដែលគ្មានអ្នកសម្របសម្រួលក្រុម Soapbox អនុម័ត ឬលាក់នៅឡើយ។", "pendingEmpty": "គ្មានអ្វីរង់ចាំការពិនិត្យ។", diff --git a/src/locales/ps.json b/src/locales/ps.json index cd60501a..3581a33f 100644 --- a/src/locales/ps.json +++ b/src/locales/ps.json @@ -704,8 +704,6 @@ "community": "د ټولنې کمپاینونه", "communityDesc": "د هغو بدلونونو په تمویل کې مرسته وکړئ چې وړ دي.", "browseAll": "← ټول کمپاینونه وګورئ", - "browseAllGroups": "← ټولې ډلې وګورئ", - "browseAllPledges": "← ټولې ژمنې وګورئ", "pending": "د منلو په تمه", "pendingDesc": "هغه کمپاینونه چې په شبکه کې شته، خو د Soapbox ټیم هیڅ مدیر یې لا تر اوسه نه دی منلی او نه پټ کړی.", "pendingEmpty": "د بیاکتنې لپاره څه نشته.", diff --git a/src/locales/pt.json b/src/locales/pt.json index 3ab30bbb..3d230be5 100644 --- a/src/locales/pt.json +++ b/src/locales/pt.json @@ -1136,8 +1136,6 @@ "community": "Campanhas da comunidade", "communityDesc": "Ajude a financiar as mudanças que valem a pena.", "browseAll": "Navegar por todas as campanhas →", - "browseAllGroups": "Navegar por todos os grupos →", - "browseAllPledges": "Navegar por todas as promessas →", "pending": "Aguardando aprovação", "pendingDesc": "Campanhas na rede que nenhum moderador da Team Soapbox aprovou ou ocultou ainda.", "pendingEmpty": "Nada aguardando revisão.", diff --git a/src/locales/ru.json b/src/locales/ru.json index 395ea4d0..03d3d1b4 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1136,8 +1136,6 @@ "community": "Кампании сообщества", "communityDesc": "Помогите финансировать изменения, которые стоит сделать.", "browseAll": "Просмотреть все кампании →", - "browseAllGroups": "Просмотреть все группы →", - "browseAllPledges": "Просмотреть все обещания →", "pending": "Ожидают одобрения", "pendingDesc": "Кампании в сети, которые ещё не были одобрены или скрыты модератором Team Soapbox.", "pendingEmpty": "Ничего не ждёт проверки.", diff --git a/src/locales/sn.json b/src/locales/sn.json index 08afb06c..bcfd48e5 100644 --- a/src/locales/sn.json +++ b/src/locales/sn.json @@ -704,8 +704,6 @@ "community": "Mishandirapamwe yeNharaunda", "communityDesc": "Batsira kupa mari kushanduko dzakakodzera.", "browseAll": "Tarisa mishandirapamwe yose →", - "browseAllGroups": "Tarisa mapoka ose →", - "browseAllPledges": "Tarisa zvitsidziro zvose →", "pending": "Yakamirira kutenderwa", "pendingDesc": "Mishandirapamwe iri panetwork isati yatenderwa kana kuvanzwa naani zvake muTeam Soapbox.", "pendingEmpty": "Hapana chinomirira kutariswa.", diff --git a/src/locales/sw.json b/src/locales/sw.json index c87b6e37..490cfa64 100644 --- a/src/locales/sw.json +++ b/src/locales/sw.json @@ -1135,8 +1135,6 @@ "community": "Kampeni za Jumuiya", "communityDesc": "Saidia kufadhili mabadiliko yanayostahili kufanywa.", "browseAll": "Vinjari kampeni zote →", - "browseAllGroups": "Vinjari vikundi vyote →", - "browseAllPledges": "Vinjari ahadi zote →", "pending": "Inasubiri idhini", "pendingDesc": "Kampeni kwenye mtandao ambazo hakuna msimamizi wa Team Soapbox aliyezithibitisha au kuzificha bado.", "pendingEmpty": "Hakuna kinachosubiri ukaguzi.", diff --git a/src/locales/tr.json b/src/locales/tr.json index f8609cd2..ad4d573d 100644 --- a/src/locales/tr.json +++ b/src/locales/tr.json @@ -1135,8 +1135,6 @@ "community": "Topluluk Kampanyaları", "communityDesc": "Yapmaya değer değişiklikleri finanse etmeye yardım edin.", "browseAll": "Tüm kampanyalara göz at →", - "browseAllGroups": "Tüm gruplara göz at →", - "browseAllPledges": "Tüm taahhütlere göz at →", "pending": "Onay bekliyor", "pendingDesc": "Ağdaki, henüz hiçbir Team Soapbox moderatörünün onaylamadığı veya gizlemediği kampanyalar.", "pendingEmpty": "İncelemeyi bekleyen bir şey yok.", diff --git a/src/locales/zh-Hant.json b/src/locales/zh-Hant.json index 58736221..8d9e9d9c 100644 --- a/src/locales/zh-Hant.json +++ b/src/locales/zh-Hant.json @@ -704,8 +704,6 @@ "community": "社群活動", "communityDesc": "為值得做的改變提供資金。", "browseAll": "瀏覽所有活動 →", - "browseAllGroups": "瀏覽所有群組 →", - "browseAllPledges": "瀏覽所有懸賞 →", "pending": "等待審批", "pendingDesc": "網路上尚未被任何 Soapbox 團隊版主批准或隱藏的活動。", "pendingEmpty": "沒有等待審查的內容。", diff --git a/src/locales/zh.json b/src/locales/zh.json index 0783ca58..db6b35c7 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -704,8 +704,6 @@ "community": "社区活动", "communityDesc": "为值得做的改变提供资金。", "browseAll": "浏览所有活动 →", - "browseAllGroups": "浏览所有群组 →", - "browseAllPledges": "浏览所有悬赏 →", "pending": "等待审批", "pendingDesc": "网络上尚未被任何 Soapbox 团队版主批准或隐藏的活动。", "pendingEmpty": "没有等待审查的内容。", diff --git a/src/pages/ActionsPage.tsx b/src/pages/ActionsPage.tsx index 18d1c60c..ff32b096 100644 --- a/src/pages/ActionsPage.tsx +++ b/src/pages/ActionsPage.tsx @@ -1,47 +1,37 @@ import { useEffect, useState, useMemo } from 'react'; import { useSeoMeta } from '@unhead/react'; -import { useNavigate } from 'react-router-dom'; -import { useQueryClient } from '@tanstack/react-query'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { nip19 } from 'nostr-tools'; +import { + ChevronDown, + ChevronUp, + EyeOff, + Megaphone, + PlusCircle, +} from 'lucide-react'; -import { parseAction, useActions, type Action } from '@/hooks/useActions'; +import { ActionShareMenu } from '@/components/ActionShareMenu'; +import { PledgesDiscoverySection } from '@/components/discovery/PledgesDiscoverySection'; +import { useActions, type Action } from '@/hooks/useActions'; import { useAppContext } from '@/hooks/useAppContext'; import { useCampaignModerators } from '@/hooks/useCampaignModerators'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useBtcPrice } from '@/hooks/useBtcPrice'; -import { useDebounce } from '@/hooks/useDebounce'; -import { useNip50Search, type Nip50Sort } from '@/hooks/useNip50Search'; -import { useNostrPublish } from '@/hooks/useNostrPublish'; import { usePledgeModeration } from '@/hooks/usePledgeModeration'; -import { useShareOrigin } from '@/hooks/useShareOrigin'; -import { useToast } from '@/hooks/useToast'; import { getGeoDisplayName } from '@/lib/countries'; import { DEFAULT_ACTION_COVERS } from '@/lib/defaultActionCovers'; import { HOPE_PALETTE } from '@/lib/hopePalette'; import { cn } from '@/lib/utils'; -import { DiscoverySearchToolbar } from '@/components/DiscoverySearchToolbar'; import { HeroAtmosphere } from '@/components/HeroAtmosphere'; import { HeroBanner } from '@/components/HeroBanner'; -import { ModerationMenuItems, ModerationOverlay, ModeratorCollapsibleSection } from '@/components/moderation'; +import { + ModerationOverlay, + ModeratorCollapsibleSection, +} from '@/components/moderation'; import { PledgeCard } from '@/components/PledgeCard'; - import { Card } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; -import { - DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; - -import { - HandHeart, PlusCircle, ChevronDown, ChevronUp, Loader2, - Link as LinkIcon, Check, MoreHorizontal, Trash2, - Megaphone, EyeOff, -} from 'lucide-react'; - -// ───────────────────────────────────────────────────────────────────────────── -// Skeletons / Cards -// ───────────────────────────────────────────────────────────────────────────── function getPledgeCoord(action: Action) { return `36639:${action.pubkey}:${action.id}`; @@ -62,150 +52,19 @@ function ActionSkeleton() { ); } -function ActionShareMenu({ action, displayTitle }: { action: Action; displayTitle: string }) { - const { t } = useTranslation(); - const { user } = useCurrentUser(); - const { data: moderators } = useCampaignModerators(); - const { mutateAsync: createEvent } = useNostrPublish(); - const { toast } = useToast(); - const shareOrigin = useShareOrigin(); - const queryClient = useQueryClient(); - const [copied, setCopied] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); - - const isOwner = user?.pubkey === action.pubkey; - // Moderator gate is identical to the one in `ModerationMenuItems`, - // duplicated here so we can decide whether to render the trailing - // separator that introduces the moderator section. `ModerationMenuItems` - // returns `null` for non-mods, so without this check we'd render an - // orphaned separator at the bottom of the dropdown. - const isMod = !!user && !!moderators && moderators.includes(user.pubkey); - - const naddr = nip19.naddrEncode({ - kind: 36639, - pubkey: action.pubkey, - identifier: action.id, - }); - - const actionUrl = `${shareOrigin}/${naddr}`; - - const handleCopyLink = async () => { - try { - await navigator.clipboard.writeText(actionUrl); - setCopied(true); - toast({ title: t('pledges.card.linkCopied') }); - setTimeout(() => setCopied(false), 2000); - } catch (error) { - console.error('Failed to copy link:', error); - toast({ title: t('pledges.card.linkCopyFailed'), variant: 'destructive' }); - } - }; - - const handleDelete = async (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (!user || !isOwner) return; - - const confirmed = window.confirm(t('pledges.card.confirmDelete')); - if (!confirmed) return; - - setIsDeleting(true); - try { - // NIP-09 deletion. Include both 'e' and 'a' tags — some relays don't - // honour a-tag-only deletions for addressable events. - await createEvent({ - kind: 5, - content: t('pledges.card.deletedContent'), - tags: [ - ['e', action.event.id], - ['a', getPledgeCoord(action)], - ], - }); - // Extract any organization `A` tag the pledge was associated with so - // the org's activity shelf and community feeds refresh too. - const orgATag = action.event.tags.find(([n]) => n === 'A')?.[1]; - await Promise.all([ - queryClient.invalidateQueries({ queryKey: ['agora-actions'] }), - queryClient.invalidateQueries({ queryKey: ['agora-action'] }), - ...(orgATag - ? [ - queryClient.invalidateQueries({ queryKey: ['organization-activity', orgATag] }), - queryClient.invalidateQueries({ queryKey: ['community-actions', orgATag] }), - queryClient.invalidateQueries({ - predicate: (q) => { - const [root, aTagsKey] = q.queryKey; - return root === 'community-activity-feed' - && typeof aTagsKey === 'string' - && aTagsKey.split(',').includes(orgATag); - }, - }), - ] - : []), - ]); - toast({ title: t('pledges.card.deleted') }); - } catch (error) { - console.error('Failed to delete pledge:', error); - toast({ title: t('pledges.card.deleteFailed'), variant: 'destructive' }); - } finally { - setIsDeleting(false); - } - }; - - return ( - - e.preventDefault()}> - - - e.stopPropagation()}> - {isOwner && ( - <> - - {isDeleting ? ( - - ) : ( - - )} - {t('pledges.card.deletePledge')} - - - - )} - - {copied ? ( - - ) : ( - - )} - {t('pledges.card.copyLink')} - - {/* Moderator actions appear under a separator when the viewer - is a Team Soapbox moderator. `ModerationMenuItems` returns - null for non-mods, so we gate the trailing separator on the - same `isMod` check to avoid an orphan separator at the - bottom of non-mod dropdowns. */} - {isMod && } - - - - ); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Page -// ───────────────────────────────────────────────────────────────────────────── - +/** + * Dedicated `/pledges` page. + * + * Thin shell around the shared {@link PledgesDiscoverySection}: + * hero, optional "My pledges" shelf, the unified search-and-discover + * section, and a moderator-only Hidden collapsible. + * + * URL state (`?q=&sort=&country=`) lives inside the section's + * `useDiscoveryFilters` hook so search results stay shareable. The + * page reads `?country=` independently to thread it into the + * create-pledge href so "Create pledge" preserves the active country + * filter into the form. + */ export default function ActionsPage() { const { t } = useTranslation(); const { config } = useAppContext(); @@ -213,142 +72,44 @@ export default function ActionsPage() { const { data: btcPrice } = useBtcPrice(); const navigate = useNavigate(); - const [selectedCountry, setSelectedCountry] = useState(undefined); + // Mirror the section's `?country=` so the create-pledge href can + // carry it forward into the form pre-fill (matches the old modal's + // `countryCode` prop behaviour). The section's filters hook is the + // source of truth; we only read here. + const [searchParams] = useSearchParams(); + const selectedCountry = searchParams.get('country') ?? undefined; - // On-page NIP-50 search + sort + show-hidden toolbar state. - // - // Default sort, empty query → curated active / upcoming / past - // sections below. - // Default sort, with query → relay search for kind 36639, results - // post-filtered against title/content client-side. - // Top / New → always active. Top sends `sort:top`; - // New sends a raw chronological feed of the kind. - // - // The country filter is threaded through to the search as a NIP-73 - // `#i` tag filter (`iso3166:XX` + legacy `geo:XX`). Picking a country - // with an empty query still activates the search view — narrowing a - // kind by external identifier produces a useful filtered grid even - // without a typed term. - const [searchInput, setSearchInput] = useState(''); - const [sortMode, setSortMode] = useState('default'); - const [showHidden, setShowHidden] = useState(false); - const debouncedSearch = useDebounce(searchInput, 300); - const trimmedSearch = debouncedSearch.trim(); - const iTags = useMemo(() => { - if (!selectedCountry) return undefined; - const code = selectedCountry.toUpperCase(); - return [`iso3166:${code}`, `geo:${code}`]; - }, [selectedCountry]); - const { - data: searchHitsRaw, - isFetching: isSearchFetching, - isActive: isSearching, - } = useNip50Search({ - kind: 36639, - query: debouncedSearch, - sort: sortMode, - parse: parseAction, - iTags, - // Pledge titles live in a `title` tag, not `content`. Most NIP-50 - // implementations only match content; widen the net client-side. - getKeywordHaystack: (event) => { - const title = event.tags.find(([n]) => n === 'title')?.[1] ?? ''; - return [title, event.content]; - }, - }); - - // Moderator gate. Reuses the campaign moderator pack (Team Soapbox) — - // the pledge moderation namespace rides the same signer set as the - // campaign and group surfaces. + // Moderator gate. Reuses the campaign moderator pack (Team Soapbox) + // — the pledge moderation namespace rides the same signer set as + // the campaign and group surfaces. const { data: moderators } = useCampaignModerators(); const isMod = !!user && !!moderators && moderators.includes(user.pubkey); - const canShowHidden = isMod && showHidden; - - const { data: rawActions, isLoading: actionsLoading } = useActions({ - countryCode: selectedCountry, - limit: 300, - }); + const [showHidden, setShowHidden] = useState(false); const { data: myPledges } = useActions({ authors: user ? [user.pubkey] : undefined, limit: 100, enabled: !!user, }); + // Moderator-only feed of every pledge on the network — drives the + // Hidden collapsible and the toolbar's hidden-count badge. const { data: allPledgesForMods, isLoading: allPledgesLoading } = useActions({ limit: 300, enabled: isMod, }); + const { data: pledgeModeration, isReady: pledgeModerationReady } = + usePledgeModeration(); - const { data: pledgeModeration, isReady: pledgeModerationReady } = usePledgeModeration(); + const hiddenPledges = useMemo(() => { + if (!isMod || !pledgeModerationReady) return []; + return (allPledgesForMods ?? []).filter((pledge) => + pledgeModeration.hiddenCoords.has(getPledgeCoord(pledge)), + ); + }, [allPledgesForMods, isMod, pledgeModeration, pledgeModerationReady]); - const featuredPledgeCoords = useMemo(() => { - if (!pledgeModerationReady) return [] as string[]; - return Array.from(pledgeModeration.featuredCoords) - .filter((coord) => !pledgeModeration.hiddenCoords.has(coord)) - .sort((a, b) => (pledgeModeration.featuredOrder.get(b) ?? 0) - (pledgeModeration.featuredOrder.get(a) ?? 0)); - }, [pledgeModeration, pledgeModerationReady]); - - const { data: featuredPledges } = useActions({ - coordinates: featuredPledgeCoords, - limit: featuredPledgeCoords.length || 1, - enabled: pledgeModerationReady, - }); - - const orderedFeaturedPledges = useMemo(() => { - if (!featuredPledges) return [] as Action[]; - const order = pledgeModeration.featuredOrder; - return [...featuredPledges].sort((a, b) => { - const aCoord = getPledgeCoord(a); - const bCoord = getPledgeCoord(b); - return (order.get(bCoord) ?? 0) - (order.get(aCoord) ?? 0); - }); - }, [featuredPledges, pledgeModeration]); - - const featuredPledgeCoordSet = useMemo(() => new Set(featuredPledgeCoords), [featuredPledgeCoords]); - - const { searchHits, searchHiddenCount } = useMemo(() => { - if (!searchHitsRaw) return { searchHits: undefined, searchHiddenCount: 0 }; - const hiddenCoords = pledgeModeration?.hiddenCoords ?? new Set(); - let hidden = 0; - const visible: Action[] = []; - for (const a of searchHitsRaw) { - const coord = getPledgeCoord(a); - if (hiddenCoords.has(coord)) { - hidden += 1; - if (canShowHidden) visible.push(a); - } else { - visible.push(a); - } - } - return { searchHits: visible, searchHiddenCount: hidden }; - }, [searchHitsRaw, pledgeModeration, canShowHidden]); - - // `actions` is the chronological pledge list filtered by country, with - // moderator-hidden items dropped (unless `showHidden` is on). Featured - // pledges are NOT excluded here — the idle render path pulls them - // separately, and the active render path shows the full list. - const { actions, listHiddenCount } = useMemo(() => { - if (!rawActions) return { actions: undefined, listHiddenCount: 0 }; - const hiddenCoords = pledgeModeration?.hiddenCoords ?? new Set(); - let hidden = 0; - const visible: Action[] = []; - - for (const action of rawActions) { - const coord = getPledgeCoord(action); - if (hiddenCoords.has(coord)) { - hidden += 1; - if (canShowHidden) visible.push(action); - } else { - visible.push(action); - } - } - - return { actions: visible, listHiddenCount: hidden }; - }, [rawActions, pledgeModeration, canShowHidden]); - - // Route entry points for "Create pledge" all pass the currently-selected - // country via ?country= so the dedicated page can pre-fill it, matching - // the old modal's `countryCode` prop. + // Route entry points for "Create pledge" all pass the currently + // selected country via ?country= so the dedicated page can + // pre-fill it, matching the old modal's `countryCode` prop. const createActionHref = selectedCountry ? `/pledges/new?country=${encodeURIComponent(selectedCountry)}` : '/pledges/new'; @@ -358,57 +119,24 @@ export default function ActionsPage() { : t('pledges.list.global'); useSeoMeta({ - title: `${selectedCountry - ? t('pledges.list.seoTitleWithCountry', { country: selectedCountryName }) - : t('pledges.list.seoTitle')} | ${config.appName}`, + title: `${ + selectedCountry + ? t('pledges.list.seoTitleWithCountry', { country: selectedCountryName }) + : t('pledges.list.seoTitle') + } | ${config.appName}`, description: t('pledges.list.seoDescription'), }); - const isLoading = actionsLoading || !pledgeModerationReady; - const isSearchLoading = isSearchFetching || !pledgeModerationReady; - const DEFAULT_VISIBLE = 4; const [showAllMine, setShowAllMine] = useState(false); - - const visibleMine = showAllMine ? (myPledges ?? []) : (myPledges ?? []).slice(0, DEFAULT_VISIBLE); - - // Idle list: featured first; if none are featured, fall back to the - // chronological all-pledges grid so the page is never blank. - const idlePledges = useMemo(() => { - if (orderedFeaturedPledges.length > 0) return orderedFeaturedPledges; - return (actions ?? []).filter( - (action) => !featuredPledgeCoordSet.has(getPledgeCoord(action)), - ); - }, [orderedFeaturedPledges, actions, featuredPledgeCoordSet]); - - const hiddenPledges = useMemo(() => { - if (!isMod || !pledgeModerationReady) return []; - return (allPledgesForMods ?? []).filter((pledge) => pledgeModeration.hiddenCoords.has(getPledgeCoord(pledge))); - }, [allPledgesForMods, isMod, pledgeModeration, pledgeModerationReady]); - - const headerControls = ( - - ); + const visibleMine = showAllMine + ? (myPledges ?? []) + : (myPledges ?? []).slice(0, DEFAULT_VISIBLE); return (
navigate(createActionHref)} /> @@ -417,8 +145,12 @@ export default function ActionsPage() { {user && myPledges && myPledges.length > 0 && (
-

{t('pledges.list.myPledges')}

-

{t('pledges.list.myPledgesTagline')}

+

+ {t('pledges.list.myPledges')} +

+

+ {t('pledges.list.myPledgesTagline')} +

)} - {/* Unified Pledges section. - - Idle (no search / no sort / no country): renders the - moderator-featured grid, or a chronological fallback when - no pledges are featured yet. - - Active: renders the full search/sort/country result set. */} -
-
-
-

- {trimmedSearch - ? t('common.search') - : t('pledges.list.allPledges')} -

-

- {isSearching && searchHits - ? t('common.searchResultsCount', { count: searchHits.length }) - : t('pledges.list.allPledgesTagline')} -

-
- {headerControls} -
- - {isSearching ? ( - <> - {isSearchLoading && !searchHits ? ( -
- {Array.from({ length: 8 }).map((_, i) => )} -
- ) : searchHits && searchHits.length > 0 ? ( -
- {searchHits.map((action) => ( - - - - - } - /> - ))} -
- ) : ( - -
- {trimmedSearch ? ( - <> -

- {t('pledges.list.noMatch', { query: trimmedSearch })} -

-

- {t('pledges.list.noMatchHint')} -

- - ) : ( -

- {t('pledges.list.emptyTitle')} -

- )} -
-
- )} - - ) : isLoading ? ( -
- {Array.from({ length: 8 }).map((_, i) => )} -
- ) : idlePledges.length > 0 ? ( -
- {idlePledges.map((action) => ( - - - - - } - /> - ))} -
- ) : ( - -
- -
-

{t('pledges.list.emptyTitle')}

-

- {selectedCountry - ? t('pledges.list.emptyHintCountry', { country: selectedCountryName }) - : t('pledges.list.emptyHint')} -

-
- {user && ( - - )} -
-
- )} -
+ {isMod && ( - {Array.from({ length: 4 }).map((_, i) => )} + {Array.from({ length: 4 }).map((_, i) => ( + + ))} } > @@ -589,7 +210,10 @@ export default function ActionsPage() { showMenu={false} className="flex items-center" /> - + } /> @@ -630,10 +254,10 @@ interface ActionsHeroProps { /** * Photo-led hero for the Pledges index. Same structural recipe as the * Organize hero (rotating banner + atmospheric tint + scrims + overlay - * copy + glassy CTA), but tuned for the pledge page's "dawn / golden hour" vibe: - * uses {@link HOPE_PALETTE} instead of the cool palette so the warm - * hues land on top of the protest photography rather than competing - * with it. + * copy + glassy CTA), but tuned for the pledge page's "dawn / golden + * hour" vibe: uses {@link HOPE_PALETTE} instead of the cool palette + * so the warm hues land on top of the protest photography rather + * than competing with it. */ function ActionsHero({ actionCount, canCreate, onCreateAction }: ActionsHeroProps) { const { t } = useTranslation(); @@ -699,7 +323,10 @@ function ActionsHero({ actionCount, canCreate, onCreateAction }: ActionsHeroProp aria-live="polite" >
- + {actionCount.toLocaleString()} @@ -725,7 +352,11 @@ function ActionsHero({ actionCount, canCreate, onCreateAction }: ActionsHeroProp 'motion-safe:transition-colors motion-safe:duration-200', 'disabled:opacity-60 disabled:cursor-not-allowed', )} - aria-label={canCreate ? t('pledges.list.createPledge') : t('pledges.list.loginToCreate')} + aria-label={ + canCreate + ? t('pledges.list.createPledge') + : t('pledges.list.loginToCreate') + } > {t('pledges.list.createPledge')} @@ -737,9 +368,19 @@ function ActionsHero({ actionCount, canCreate, onCreateAction }: ActionsHeroProp } function ActionSection({ - items, total, visible, showAll, onToggle, btcPrice, + items, + total, + visible, + showAll, + onToggle, + btcPrice, }: { - items: Action[]; total: number; visible: number; showAll: boolean; onToggle: () => void; btcPrice: number | undefined; + items: Action[]; + total: number; + visible: number; + showAll: boolean; + onToggle: () => void; + btcPrice: number | undefined; }) { const { t } = useTranslation(); return ( diff --git a/src/pages/AllCampaignsPage.tsx b/src/pages/AllCampaignsPage.tsx index acb903a5..019b8b63 100644 --- a/src/pages/AllCampaignsPage.tsx +++ b/src/pages/AllCampaignsPage.tsx @@ -5,9 +5,8 @@ import { useTranslation } from 'react-i18next'; import { ChevronDown, ChevronUp, EyeOff, HandHeart, PlusCircle } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { Card, CardContent } from '@/components/ui/card'; import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard'; -import { DiscoverySearchToolbar } from '@/components/DiscoverySearchToolbar'; +import { CampaignsDiscoverySection } from '@/components/discovery/CampaignsDiscoverySection'; import { HeroAtmosphere } from '@/components/HeroAtmosphere'; import { HeroBanner } from '@/components/HeroBanner'; import { ModeratorCollapsibleSection } from '@/components/moderation'; @@ -16,7 +15,6 @@ import { useAllCampaigns, type CampaignSort } from '@/hooks/useAllCampaigns'; import { useCampaignModeration } from '@/hooks/useCampaignModeration'; import { useCampaignModerators } from '@/hooks/useCampaignModerators'; import { useAppContext } from '@/hooks/useAppContext'; -import { useDebounce } from '@/hooks/useDebounce'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { HOPE_PALETTE } from '@/lib/hopePalette'; import { cn } from '@/lib/utils'; @@ -24,11 +22,9 @@ import type { Nip50Sort } from '@/hooks/useNip50Search'; import type { ParsedCampaign } from '@/lib/campaign'; /** - * Type-guard for the `?sort=` URL param. - * - * - `top` and `new` map to the toolbar's active sort modes. - * - Anything else (missing, empty, legacy values) collapses to - * `'default'`, the curated featured-first idle state. + * Type-guard mirroring the one in `useDiscoveryFilters`. Pulled out + * to keep the page's hidden-section derivation self-contained, since + * it needs to read the same URL params the section reads. */ function parseSort(value: string | null): Nip50Sort { if (value === 'top') return 'top'; @@ -36,183 +32,84 @@ function parseSort(value: string | null): Nip50Sort { return 'default'; } -/** - * Map the toolbar's sort vocabulary (`default` / `top` / `new`) to the - * `useAllCampaigns` hook's vocabulary (`top` / `none`). `'new'` and - * `'default'` both map to `'none'` (chronological) — the page handles - * the "show featured only" framing on top of that. - */ +/** Toolbar sort vocabulary → useAllCampaigns vocabulary. */ const toQuerySort = (s: Nip50Sort): CampaignSort => (s === 'top' ? 'top' : 'none'); /** - * Lists every campaign found on relays. The page has two display modes: + * Lists every campaign found on relays. * - * 1. **Idle** (no search, no sort, no country picked) — shows - * moderator-featured campaigns only. If there are no featured - * campaigns, falls back to the latest chronological grid so the page - * is never blank. - * 2. **Active** (user typed a query, picked Top/New, or chose a - * country) — shows the full set, ranked by sats raised (Top) or - * chronological (New / search default). + * The page itself is a thin shell: hero, optional "Your campaigns" + * shelf, the shared {@link CampaignsDiscoverySection} (which owns + * search / sort / country + idle / active grids), and a + * moderator-only Hidden collapsible. * - * Search filters across title, summary, story, location, and category - * tags client-side. - * - * Hidden campaigns are excluded by default — flip the "Show hidden" - * toggle (inside the toolbar's filter popover) to include them. - * - * URL state: `?sort=top|new&q=&country=`. Default values - * are stripped so the canonical URL stays clean. Useful for sharing - * search results. + * URL state (`?q=&sort=&country=`) lives inside the section's + * `useDiscoveryFilters` hook so search results stay shareable. The + * page reads the same params independently to compute the Hidden + * collapsible's contents — TanStack Query dedupes the underlying + * `useAllCampaigns` call, so there's no extra network round-trip. */ export function AllCampaignsPage() { const { t } = useTranslation(); const { config } = useAppContext(); const { user } = useCurrentUser(); - // URL state — sort, query, and country live in the URL so results are - // shareable. - const [searchParams, setSearchParams] = useSearchParams(); + const [searchParams] = useSearchParams(); const sort = parseSort(searchParams.get('sort')); const urlQuery = searchParams.get('q') ?? ''; const urlCountry = searchParams.get('country') ?? undefined; - // Search input is local-state so typing is responsive; we debounce to - // the URL + the query. - const [searchInput, setSearchInput] = useState(urlQuery); - const debouncedSearch = useDebounce(searchInput, 300); - const [showHidden, setShowHidden] = useState(false); - const { data: moderators } = useCampaignModerators(); const isMod = !!user && !!moderators && moderators.includes(user.pubkey); + const [showHidden, setShowHidden] = useState(false); - // Sync the debounced search → URL. Empty / default values are stripped - // so the canonical URL is `/campaigns/all` (not - // `/campaigns/all?sort=&q=`). - useEffect(() => { - const next = new URLSearchParams(searchParams); - const trimmed = debouncedSearch.trim(); - if (trimmed) next.set('q', trimmed); - else next.delete('q'); - // Only replace history when the params actually change, to avoid - // looping when the URL is already in sync. - if (next.toString() !== searchParams.toString()) { - setSearchParams(next, { replace: true }); - } - }, [debouncedSearch, searchParams, setSearchParams]); - - // Sync URL → input (e.g. browser back/forward or a deep link). - useEffect(() => { - if (urlQuery !== debouncedSearch) { - setSearchInput(urlQuery); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [urlQuery]); - - const setSortFromToolbar = (value: Nip50Sort) => { - const next = new URLSearchParams(searchParams); - if (value === 'top') next.set('sort', 'top'); - else if (value === 'new') next.set('sort', 'new'); - else next.delete('sort'); - setSearchParams(next, { replace: true }); - }; - - // The country picker also rides the URL so country-scoped views are - // shareable / linkable. - const setCountry = (next: string | undefined) => { - const params = new URLSearchParams(searchParams); - if (next) params.set('country', next); - else params.delete('country'); - setSearchParams(params, { replace: true }); - }; - - const { data: campaigns, isLoading } = useAllCampaigns({ + // Mirror the section's underlying query so the Hidden collapsible + // can list the exact set of hidden items matching the current + // search / sort / country. TanStack dedupes; this is a cache read + // on the same key the section uses. + const { data: campaigns } = useAllCampaigns({ sort: toQuerySort(sort), - search: debouncedSearch.trim(), + search: urlQuery, countryCode: urlCountry, limit: 200, }); - const { data: moderation, isReady: moderationReady } = useCampaignModeration(); + + const { data: moderation } = useCampaignModeration(); + + const { hiddenCount, hiddenCampaigns } = useMemo(() => { + const all = campaigns ?? []; + const hiddenCoords = moderation?.hiddenCoords ?? new Set(); + let count = 0; + const list: ParsedCampaign[] = []; + for (const c of all) { + if (hiddenCoords.has(c.aTag)) { + count += 1; + list.push(c); + } + } + return { hiddenCount: count, hiddenCampaigns: list }; + }, [campaigns, moderation]); + const { data: myCampaigns } = useCampaigns({ authors: user ? [user.pubkey] : undefined, limit: 100, enabled: !!user, }); - const featuredCoords = useMemo(() => { - if (!moderationReady) return [] as string[]; - return Array.from(moderation.featuredCoords) - .filter((coord) => !moderation.hiddenCoords.has(coord)) - .sort((a, b) => (moderation.featuredOrder.get(b) ?? 0) - (moderation.featuredOrder.get(a) ?? 0)); - }, [moderation, moderationReady]); - - const { data: featuredCampaigns } = useCampaigns({ - coordinates: featuredCoords, - limit: featuredCoords.length || 1, - enabled: moderationReady, - }); - useSeoMeta({ title: `${t('campaigns.all.seoTitle')} | ${config.appName}`, description: t('campaigns.all.description'), }); - const activeQuery = debouncedSearch.trim(); - - // The unified section is in "active" mode when the user has expressed - // intent to browse the full set: typed a query, picked Top/New, or - // chosen a country. Otherwise it's the curated featured-first view. - const isActive = activeQuery !== '' || sort !== 'default' || !!urlCountry; - - // Visible campaigns in the **active** branch: every campaign matching - // the search/sort/country, minus hidden (unless the moderator opted - // in to seeing hidden). Featured items are intentionally NOT pulled - // out of this list — when the user is actively browsing, they want a - // ranked or chronological grid, not the curated shelf. - const { visible, hiddenCount, hiddenCampaigns } = useMemo(() => { - const all = campaigns ?? []; - const hiddenCoords = moderation?.hiddenCoords ?? new Set(); - let hiddenCount = 0; - const visible: ParsedCampaign[] = []; - const hiddenCampaigns: ParsedCampaign[] = []; - - for (const c of all) { - if (hiddenCoords.has(c.aTag)) { - hiddenCount += 1; - hiddenCampaigns.push(c); - if (isMod && showHidden) visible.push(c); - } else { - visible.push(c); - } - } - - return { visible, hiddenCount, hiddenCampaigns }; - }, [campaigns, isMod, moderation, showHidden]); - - const orderedFeaturedCampaigns = useMemo(() => { - if (!featuredCampaigns) return [] as ParsedCampaign[]; - return [...featuredCampaigns].sort( - (a, b) => (moderation.featuredOrder.get(b.aTag) ?? 0) - (moderation.featuredOrder.get(a.aTag) ?? 0), - ); - }, [featuredCampaigns, moderation]); - - // Idle-mode list: featured first; if none are featured, fall back to - // the latest chronological grid so the page never lands on an empty - // state when there's content to show. - const idleCampaigns = useMemo(() => { - if (orderedFeaturedCampaigns.length > 0) return orderedFeaturedCampaigns; - return visible; - }, [orderedFeaturedCampaigns, visible]); - const DEFAULT_VISIBLE = 4; const [showAllMine, setShowAllMine] = useState(false); - const visibleMine = showAllMine ? (myCampaigns ?? []) : (myCampaigns ?? []).slice(0, DEFAULT_VISIBLE); - - const showSkeleton = isLoading || !moderationReady; + const visibleMine = showAllMine + ? (myCampaigns ?? []) + : (myCampaigns ?? []).slice(0, DEFAULT_VISIBLE); return (
- +
{user && myCampaigns && myCampaigns.length > 0 && ( @@ -235,116 +132,38 @@ export function AllCampaignsPage() {
)} - {/* Unified Campaigns section. - - Idle (no search / no sort / no country): renders the - moderator-curated featured grid, or a chronological - fallback if nothing is featured yet. - - Active: renders the full search/sort/country result set. */} -
-
-
-

- {activeQuery - ? t('common.search') - : t('campaigns.all.title')} -

-

- {activeQuery - ? t('common.searchResultsCount', { count: visible.length }) - : t('campaigns.all.sectionTagline')} -

-
- -
+ - {/* Grid — widens to 3 columns at lg and 4 at xl so desktop - users can scan more campaigns at once, matching the Pledge - index's card density. Mobile and small tablets stay - single / double column so the cards keep their tappable - size. */} - {showSkeleton ? ( -
- {Array.from({ length: 8 }).map((_, i) => ( - - ))} -
- ) : (isActive ? visible : idleCampaigns).length === 0 ? ( - - - -
- {activeQuery ? ( - <> -

- {t('campaigns.all.noMatch', { query: activeQuery })} -

-

- {t('campaigns.all.noMatchHint')} -

- - ) : hiddenCount > 0 && !showHidden ? ( - <> -

{t('campaigns.all.allHidden')}

-

- {t('campaigns.all.allHiddenHint')} -

- - ) : ( - <> -

{t('campaigns.all.empty')}

-

- {t('campaigns.all.emptyHint')} -

- - )} -
- -
-
- ) : ( -
- {(isActive ? visible : idleCampaigns).map((campaign) => ( - - ))} -
- )} -
- - {/* Moderator-only: every hidden campaign on the network. Mirrors - the section on `/campaigns` so moderators see the same - "Hidden" affordance whether they're browsing the curated - home or the full index. */} + {/* Moderator-only: every hidden campaign on the network matching + the current section filters. The section drops hidden items + from its main grid unless the toolbar's Show-hidden switch + is on; this collapsible always exposes them so a moderator + can act on hidden coords without flipping the visibility + mode. */} {isMod && ( } title={t('campaigns.home.hidden')} description={t('campaigns.home.hiddenDesc')} count={hiddenCampaigns.length} - isLoading={showSkeleton} + isLoading={!moderation} emptyText={t('campaigns.home.hiddenEmpty')} skeleton={
- {Array.from({ length: 4 }).map((_, i) => )} + {Array.from({ length: 4 }).map((_, i) => ( + + ))}
} > @@ -355,7 +174,6 @@ export function AllCampaignsPage() {
)} -
); @@ -485,7 +303,10 @@ function AllCampaignsHero({ campaignCount }: AllCampaignsHeroProps) { aria-live="polite" >
- + {campaignCount.toLocaleString()} diff --git a/src/pages/CampaignsPage.tsx b/src/pages/CampaignsPage.tsx index c01f04ef..d3e3b43c 100644 --- a/src/pages/CampaignsPage.tsx +++ b/src/pages/CampaignsPage.tsx @@ -1,60 +1,33 @@ -import { useMemo } from 'react'; import { Link } from 'react-router-dom'; import { useSeoMeta } from '@unhead/react'; import { Trans, useTranslation } from 'react-i18next'; import { ArrowRight, PlusCircle } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { Card } from '@/components/ui/card'; -import { Skeleton } from '@/components/ui/skeleton'; -import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard'; -import { - CommunityMiniCard, - CommunityMiniCardSkeleton, -} from '@/components/discovery/CommunityMiniCard'; -import { CommunityGrid } from '@/components/discovery/CommunityGrid'; import { HeroLightningMap } from '@/components/HeroLightningMap'; -import { PledgeCard } from '@/components/PledgeCard'; -import { useActions, type Action } from '@/hooks/useActions'; +import { CampaignsDiscoverySection } from '@/components/discovery/CampaignsDiscoverySection'; +import { GroupsDiscoverySection } from '@/components/discovery/GroupsDiscoverySection'; +import { PledgesDiscoverySection } from '@/components/discovery/PledgesDiscoverySection'; import { useAppContext } from '@/hooks/useAppContext'; -import { useBtcPrice } from '@/hooks/useBtcPrice'; -import { useCampaigns } from '@/hooks/useCampaigns'; -import { useCampaignModeration } from '@/hooks/useCampaignModeration'; import { useCurrentUser } from '@/hooks/useCurrentUser'; -import { useFeaturedOrganizations } from '@/hooks/useFeaturedOrganizations'; -import { useOrganizationModeration } from '@/hooks/useOrganizationModeration'; -import { usePledgeModeration } from '@/hooks/usePledgeModeration'; -import type { ParsedCampaign } from '@/lib/campaign'; -import type { ParsedCommunity } from '@/lib/communityUtils'; - -/** Cap on how many featured campaigns we render in the home-page row. */ -const MAX_FEATURED_CAMPAIGNS = 4; -/** - * Cap on featured groups and featured pledges in their respective home-page - * sections. The dedicated pages render unlimited featured items; the home - * page is a launchpad, not the canonical list. - */ -const MAX_FEATURED_PER_SECTION = 8; - -function getPledgeCoord(action: Action) { - return `36639:${action.pubkey}:${action.id}`; -} /** * Home page (`/`). * - * A curated launchpad: hero on top, then three featured sections — campaigns, - * groups, pledges — each capped to a digestible row and linking out to its - * dedicated browse page (`/campaigns/all`, `/groups`, `/pledges`). The home - * page intentionally does *not* show community/pending/hidden grids, - * unmoderated streams, or per-viewer "your X" shelves — those live on the - * dedicated pages so the home stays scannable on every visit. + * Hero on top, then the three discovery sections back-to-back — + * Campaigns, Groups, Pledges — each with the same title, tagline, + * and search/sort/country toolbar as its dedicated page. Filter + * state is purely local here (`filterPersistence="local"`): + * persisting three sets of `?q=&sort=&country=` would either + * collide (three sections want `?q=`) or pollute the URL with + * prefixed variants on every keystroke. Refreshing `/` always + * lands on the curated idle view, which matches what we want + * anyway. Users who want shareable / persistent filters go to + * `/campaigns/all`, `/groups`, or `/pledges`. * - * Each section's "featured" set is derived from the same moderation labels - * used on its dedicated page, so what surfaces here matches what surfaces - * there (just truncated). Sections with no featured items collapse silently - * rather than render an empty card; the page can degrade to "hero + one - * section" without looking broken. + * The home page intentionally omits the moderator-only Hidden + * collapsibles and per-viewer "My X" shelves — those live on the + * dedicated pages so the home stays scannable on every visit. */ export function CampaignsPage() { const { t } = useTranslation(); @@ -70,10 +43,13 @@ export function CampaignsPage() {
-
); @@ -189,7 +165,7 @@ function Hero({ loggedIn }: { loggedIn: boolean }) { asChild className="rounded-full h-12 px-6 text-base border-white/30 bg-white/5 text-white hover:bg-white/10 hover:text-white hover:border-white/50" > - {t('campaigns.home.exploreCampaigns')} + {t('campaigns.home.exploreCampaigns')} )}
@@ -199,336 +175,4 @@ function Hero({ loggedIn }: { loggedIn: boolean }) { ); } -// ═══════════════════════════════════════════════════════════════════════════════ -// Section header -// ═══════════════════════════════════════════════════════════════════════════════ - -function SectionHeader({ - title, - description, - browseLabel, - browseHref, -}: { - title: string; - description: string; - browseLabel: string; - browseHref: string; -}) { - return ( -
-
-

{title}

-

{description}

-
- -
- ); -} - -// ═══════════════════════════════════════════════════════════════════════════════ -// Featured Campaigns -// ═══════════════════════════════════════════════════════════════════════════════ - -function FeaturedCampaignsSection() { - const { t } = useTranslation(); - const { config } = useAppContext(); - const { data: moderation, isReady: moderationReady } = useCampaignModeration(); - - // Featured slot list — derived from moderation labels. Sorted newest- - // featured first, capped, and hidden coords removed so a - // featured-then-hidden campaign disappears from the row. - const featuredCoords = useMemo(() => { - if (!moderation) return [] as string[]; - return Array.from(moderation.featuredCoords) - .filter((c) => !moderation.hiddenCoords.has(c)) - .sort( - (a, b) => - (moderation.featuredOrder.get(b) ?? 0) - - (moderation.featuredOrder.get(a) ?? 0), - ) - .slice(0, MAX_FEATURED_CAMPAIGNS); - }, [moderation]); - - const { data: featuredCampaigns, isLoading: featuredLoading } = useCampaigns( - moderationReady && featuredCoords.length > 0 - ? { coordinates: featuredCoords, limit: MAX_FEATURED_CAMPAIGNS } - : { coordinates: [], limit: MAX_FEATURED_CAMPAIGNS }, - ); - - // Sort the fetched featured campaigns to match the newest-label order. - // `useCampaigns` returns them in network order; we want the row to match - // the moderation-label ordering. - const orderedFeatured = useMemo(() => { - if (!moderation || !featuredCampaigns) return []; - const order = moderation.featuredOrder; - return [...featuredCampaigns] - .filter((c) => featuredCoords.includes(c.aTag)) - .sort((a, b) => (order.get(b.aTag) ?? 0) - (order.get(a.aTag) ?? 0)) - .slice(0, MAX_FEATURED_CAMPAIGNS); - }, [featuredCampaigns, featuredCoords, moderation]); - - const isLoading = !moderationReady || featuredLoading; - - // Once moderation is ready and there's nothing featured, collapse the - // section silently — the home page can degrade to "hero + the sections - // that have content". - if (moderationReady && featuredCoords.length === 0) return null; - - return ( -
- - - -
- ); -} - -/** - * Returns the grid class string for an adaptive featured row. - * Mobile stays 1-column; desktop expands to 2/3/4 columns based on count. - * Tailwind JIT requires literal class strings, so we spell each variant - * out rather than building the class name dynamically. - */ -function featuredGridClass(n: number): string { - if (n <= 1) return 'grid grid-cols-1 gap-5'; - if (n === 2) return 'grid grid-cols-1 md:grid-cols-2 gap-5'; - if (n === 3) return 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5'; - return 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5'; -} - -function FeaturedCampaignsRow({ - campaigns, - isLoading, - expectedCount, -}: { - campaigns: ParsedCampaign[]; - isLoading: boolean; - /** How many featured slots we expect once data resolves. Drives the skeleton column count. */ - expectedCount: number; -}) { - if (isLoading && campaigns.length === 0) { - const skeletonCount = Math.max( - 1, - Math.min(MAX_FEATURED_CAMPAIGNS, expectedCount || 2), - ); - return ( -
- {Array.from({ length: skeletonCount }).map((_, i) => ( - - ))} -
- ); - } - - if (campaigns.length === 0) { - // Defensive — the parent guards on `featuredCoords.length > 0`, but if - // a hidden-after-featured race leaves us with no campaigns to render, - // collapse silently rather than show an empty card. - return null; - } - - // 1 featured campaign gets the hero `variant="featured"` treatment; - // 2-4 use the regular compact card sized to the dynamic grid. - const useFeaturedVariant = campaigns.length === 1; - - return ( -
- {campaigns.map((campaign) => ( - - ))} -
- ); -} - -// ═══════════════════════════════════════════════════════════════════════════════ -// Featured Groups -// ═══════════════════════════════════════════════════════════════════════════════ - -function FeaturedGroupsSection() { - const { t } = useTranslation(); - const { data: orgModeration, isReady: orgModerationReady } = - useOrganizationModeration(); - const { data: featuredOrgs, isLoading: featuredOrgsLoading } = - useFeaturedOrganizations(); - - const featuredGroups = useMemo(() => { - if (!featuredOrgs) return []; - const hiddenCoords = orgModeration?.hiddenCoords ?? new Set(); - return featuredOrgs - .map((entry) => entry.community) - .filter((c) => !hiddenCoords.has(c.aTag)) - .slice(0, MAX_FEATURED_PER_SECTION); - }, [featuredOrgs, orgModeration]); - - // `useFeaturedOrganizations` is internally gated on moderation readiness, - // so while moderation labels are still resolving the underlying query is - // disabled and reports `isLoading: false` / `data: undefined`. Treat any - // of "moderation not ready / featured query in flight / featured data - // not yet defined" as loading so the skeleton stays on screen until we - // know what's featured. - const isLoading = - !orgModerationReady || featuredOrgsLoading || featuredOrgs === undefined; - - if (!isLoading && featuredGroups.length === 0) return null; - - return ( -
- - - {isLoading ? ( - - {Array.from({ length: 8 }).map((_, i) => ( - - ))} - - ) : ( - - {featuredGroups.map((community) => ( - - ))} - - )} -
- ); -} - -// ═══════════════════════════════════════════════════════════════════════════════ -// Featured Pledges -// ═══════════════════════════════════════════════════════════════════════════════ - -function FeaturedPledgesSection() { - const { t } = useTranslation(); - const { config } = useAppContext(); - const { data: btcPrice } = useBtcPrice(); - const { data: pledgeModeration, isReady: pledgeModerationReady } = - usePledgeModeration(); - - const featuredPledgeCoords = useMemo(() => { - if (!pledgeModerationReady) return [] as string[]; - return Array.from(pledgeModeration.featuredCoords) - .filter((coord) => !pledgeModeration.hiddenCoords.has(coord)) - .sort( - (a, b) => - (pledgeModeration.featuredOrder.get(b) ?? 0) - - (pledgeModeration.featuredOrder.get(a) ?? 0), - ) - .slice(0, MAX_FEATURED_PER_SECTION); - }, [pledgeModeration, pledgeModerationReady]); - - const { data: featuredPledges, isLoading: featuredPledgesLoading } = useActions({ - coordinates: featuredPledgeCoords, - limit: featuredPledgeCoords.length || 1, - enabled: pledgeModerationReady && featuredPledgeCoords.length > 0, - }); - - const orderedFeaturedPledges = useMemo(() => { - if (!featuredPledges || !pledgeModerationReady) return []; - const order = pledgeModeration.featuredOrder; - return [...featuredPledges] - .sort((a, b) => { - const aCoord = getPledgeCoord(a); - const bCoord = getPledgeCoord(b); - return (order.get(bCoord) ?? 0) - (order.get(aCoord) ?? 0); - }) - .slice(0, MAX_FEATURED_PER_SECTION); - }, [featuredPledges, pledgeModeration, pledgeModerationReady]); - - const isLoading = - !pledgeModerationReady || - (featuredPledgeCoords.length > 0 && featuredPledgesLoading); - - // Same silent-collapse rule as the other two sections: once we know - // there's nothing featured, drop the heading rather than render an - // empty container. - if (pledgeModerationReady && featuredPledgeCoords.length === 0) return null; - - return ( -
- - - {isLoading ? ( -
- {Array.from({ - length: Math.max( - 1, - Math.min(MAX_FEATURED_PER_SECTION, featuredPledgeCoords.length || 4), - ), - }).map((_, i) => ( - - ))} -
- ) : orderedFeaturedPledges.length > 0 ? ( -
- {orderedFeaturedPledges.map((action) => ( - - ))} -
- ) : ( - // Defensive — featured coords resolved to a non-empty set but the - // events didn't come back (e.g. relay miss). Collapse silently. - null - )} -
- ); -} - -function PledgeSkeleton() { - return ( - - -
- - - - - -
-
- ); -} - -// Re-exported so AppRouter's lazy import shape stays identical. export default CampaignsPage; diff --git a/src/pages/CommunitiesPage.tsx b/src/pages/CommunitiesPage.tsx index 0bfc8595..5e7a6154 100644 --- a/src/pages/CommunitiesPage.tsx +++ b/src/pages/CommunitiesPage.tsx @@ -7,31 +7,41 @@ import { ChevronDown, ChevronUp, EyeOff, Globe2, HandHeart, PlusCircle, Users } import { HeroAtmosphere } from '@/components/HeroAtmosphere'; import { HeroBanner } from '@/components/HeroBanner'; import { Button } from '@/components/ui/button'; -import { Card, CardContent } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; import { CommunityGrid } from '@/components/discovery/CommunityGrid'; -import { CommunityMiniCard, CommunityMiniCardSkeleton } from '@/components/discovery/CommunityMiniCard'; -import { DiscoverySearchToolbar } from '@/components/DiscoverySearchToolbar'; +import { + CommunityMiniCard, + CommunityMiniCardSkeleton, +} from '@/components/discovery/CommunityMiniCard'; +import { GroupsDiscoverySection } from '@/components/discovery/GroupsDiscoverySection'; import { ModeratorCollapsibleSection } from '@/components/moderation'; import { COOL_PALETTE } from '@/lib/hopePalette'; import { cn } from '@/lib/utils'; import { useAppContext } from '@/hooks/useAppContext'; import { useCampaignModerators } from '@/hooks/useCampaignModerators'; import { useCurrentUser } from '@/hooks/useCurrentUser'; -import { useDebounce } from '@/hooks/useDebounce'; import { useDiscoverCommunities } from '@/hooks/useDiscoverCommunities'; import { useFeaturedOrganizations } from '@/hooks/useFeaturedOrganizations'; import { useGlobalActivity } from '@/hooks/useGlobalActivity'; import { useGlobalDonations } from '@/hooks/useGlobalDonations'; -import { useNip50Search, type Nip50Sort } from '@/hooks/useNip50Search'; import { useOrganizationModeration } from '@/hooks/useOrganizationModeration'; import { useToast } from '@/hooks/useToast'; import { useUserOrganizations } from '@/hooks/useUserOrganizations'; import { formatSatsShort } from '@/lib/formatCampaignAmount'; -import { COMMUNITY_DEFINITION_KIND, parseCommunityEvent, type ParsedCommunity } from '@/lib/communityUtils'; - -// ─── Page ────────────────────────────────────────────────────────────────────── +import type { ParsedCommunity } from '@/lib/communityUtils'; +/** + * Dedicated `/groups` page. + * + * Thin shell around the shared {@link GroupsDiscoverySection}: hero, + * optional "My groups" shelf, the unified search-and-discover + * section, and a moderator-only Hidden collapsible. + * + * URL state (`?q=&sort=`) lives inside the section's + * `useDiscoveryFilters` hook so search results stay shareable. The + * page only owns the Show-hidden flag and the moderator-only data + * needed for the Hidden collapsible. + */ export function CommunitiesPage() { const { t } = useTranslation(); const { config } = useAppContext(); @@ -62,234 +72,46 @@ export function CommunitiesPage() { navigate('/groups/new'); }; - // On-page NIP-50 search + sort + show-hidden toolbar state. - // - // Default sort, empty query → curated featured-first idle view. - // If no groups are featured yet, falls back to the chronological - // "all groups" grid so the page is never blank. - // Default sort, with query → relay search for kind 34550, results - // post-filtered against name/description/content client-side. - // Top / New → always active. Top sends `sort:top`; - // New sends a raw chronological feed of the kind. - // - // Groups aren't country-scoped on the discovery surface (a community - // is its own scope), so the country picker is intentionally omitted - // from the toolbar here even though Campaigns and Pledges expose it. - const [searchInput, setSearchInput] = useState(''); - const [sortMode, setSortMode] = useState('default'); const [showHidden, setShowHidden] = useState(false); - const debouncedSearch = useDebounce(searchInput, 300); - const trimmedSearch = debouncedSearch.trim(); - const { - data: searchHitsRaw, - isFetching: isSearchFetching, - isActive: isSearching, - } = useNip50Search({ - kind: COMMUNITY_DEFINITION_KIND, - query: debouncedSearch, - sort: sortMode, - parse: parseCommunityEvent, - // Group names and descriptions live in tags, not `content`. Relay - // NIP-50 implementations that only match content silently miss - // obvious title hits — widen client-side by also checking these - // tag values. - getKeywordHaystack: (event) => { - const name = event.tags.find(([n]) => n === 'name')?.[1] ?? ''; - const description = event.tags.find(([n]) => n === 'description')?.[1] ?? ''; - return [name, description, event.content]; - }, - }); - // Lift org moderation to the page so search results can drop hidden - // groups (or include them when the Show-hidden switch is on). - const { data: orgModeration, isReady: orgModerationReady } = useOrganizationModeration(); - const { searchHits, searchHiddenCount } = useMemo(() => { - if (!searchHitsRaw) return { searchHits: undefined, searchHiddenCount: 0 }; - const hiddenCoords = orgModeration?.hiddenCoords ?? new Set(); - let hidden = 0; - const visible: ParsedCommunity[] = []; - for (const c of searchHitsRaw) { - if (hiddenCoords.has(c.aTag)) { - hidden += 1; - if (showHidden) visible.push(c); - } else { - visible.push(c); - } - } - return { searchHits: visible, searchHiddenCount: hidden }; - }, [searchHitsRaw, orgModeration, showHidden]); - - // The full kind-34550 fetch is only used by moderators — it feeds the - // "Hidden" collapsible section and the hidden-count badge inside the - // toolbar's filter menu. Non-moderators don't need it: the unified - // section renders moderator-featured groups directly (or relay - // search results when the user is actively browsing), so there's no - // "render everything, then filter for the Agora tag" round-trip and - // therefore no flash of unrelated groups before the curated set - // appears. + // Moderator-only: fetch the full kind-34550 universe so we can list + // hidden groups and surface a hidden-count badge on the toolbar. + // Non-moderators don't need this query — the section drives the + // public idle/active grids straight from featured + search. const { data: allOrgs, isLoading: allOrgsLoading } = useDiscoverCommunities({ limit: 200, enabled: isMod, }); - const { allHiddenCount, hiddenGroups } = useMemo(() => { + const { data: orgModeration } = useOrganizationModeration(); + const { hiddenGroups, hiddenCount } = useMemo(() => { const hiddenCoords = orgModeration?.hiddenCoords ?? new Set(); - let hidden = 0; - const hiddenList: ParsedCommunity[] = []; + const list: ParsedCommunity[] = []; for (const org of allOrgs ?? []) { - if (hiddenCoords.has(org.aTag)) { - hidden += 1; - hiddenList.push(org); - } + if (hiddenCoords.has(org.aTag)) list.push(org); } - return { allHiddenCount: hidden, hiddenGroups: hiddenList }; + return { hiddenGroups: list, hiddenCount: list.length }; }, [allOrgs, orgModeration]); - // Featured groups — the curated list moderators publish. This is the - // entire idle-mode payload: no chronological fallback, no client-side - // tag filter, no "fetch everything and pick the Agora ones out of it" - // dance. Hidden coords are dropped (unless a moderator has flipped - // Show hidden on). - const { - data: featuredOrgs, - isLoading: featuredOrgsLoading, - } = useFeaturedOrganizations(); - const featuredGroups = useMemo(() => { - if (!featuredOrgs) return []; - const hiddenCoords = orgModeration?.hiddenCoords ?? new Set(); - return featuredOrgs - .map((entry) => entry.community) - .filter((c) => (isMod && showHidden) || !hiddenCoords.has(c.aTag)); - }, [featuredOrgs, orgModeration, isMod, showHidden]); - - // Idle-render skeleton gate. `useFeaturedOrganizations` is internally - // gated on `moderationReady`, so while the moderation labels are - // still loading, the hook is *disabled* and reports `isLoading: false` - // / `data: undefined`. Treating that as "not loading" would render - // the empty state for a moment before the curated grid pops in; - // tracking moderation-readiness here keeps the skeleton on screen - // until we know what's featured. - const idleLoading = !orgModerationReady || featuredOrgsLoading || featuredOrgs === undefined; - - // Search + sort + show-hidden cluster for the unified section. - const searchToolbar = ( - - ); - return (
- + + - {/* Unified Groups section. - - Idle (no search / no sort): renders ONLY moderator-featured - groups. No fallback to a chronological "all groups" grid — - that produced a brief flash of unrelated communities while - the relay returned every kind-34550 event before the - client-side Agora-tag filter ran. The skeleton is gated on - the featured query itself, so the idle view goes - skeleton → curated grid without any intermediate state. - - Active (search / Top / New): renders the full relay - search result set. */} -
-
-
-

- {trimmedSearch - ? t('common.search') - : t('groups.list.allGroups')} -

-

- {isSearching && searchHits - ? t('common.searchResultsCount', { count: searchHits.length }) - : t('groups.list.allGroupsTagline')} -

-
- {searchToolbar} -
- - {isSearching ? ( - <> - {isSearchFetching && !searchHits ? ( - - {Array.from({ length: 8 }).map((_, i) => ( - - ))} - - ) : searchHits && searchHits.length > 0 ? ( - - {searchHits.map((community) => ( - - ))} - - ) : ( - - - {trimmedSearch ? ( - <> -

- {t('groups.list.noMatch', { query: trimmedSearch })} -

-

- {t('groups.list.noMatchHint')} -

- - ) : ( -

- {t('groups.list.noFeaturedBody', { appName: config.appName })} -

- )} -
-
- )} - - ) : idleLoading ? ( - - {Array.from({ length: 8 }).map((_, i) => ( - - ))} - - ) : featuredGroups.length > 0 ? ( - - {featuredGroups.map((community) => ( - - ))} - - ) : ( - - -

- {t('groups.list.noFeaturedBody', { appName: config.appName })} -

-
-
- )} -
- {isMod && ( } @@ -495,7 +317,7 @@ function CommunitiesHero({ onCreateCommunity }: CommunitiesHeroProps) { } // ═══════════════════════════════════════════════════════════════════════════════ -// Community shelves +// "My groups" shelf // ═══════════════════════════════════════════════════════════════════════════════ type UserOrganizationsResult = ReturnType; @@ -508,15 +330,17 @@ function MyCommunitiesShelf({ const { t } = useTranslation(); const { user } = useCurrentUser(); // "My organizations" = orgs the user founded, moderates, or follows. - // Sorting is founder first, moderator second, followed-only last, with - // newest community definition revisions first inside each bucket. + // Sorting is founder first, moderator second, followed-only last, + // with newest community definition revisions first inside each + // bucket. const { data: organizations } = userOrganizations; const [expanded, setExpanded] = useState(false); if (!user) return null; - // Suppress the entire section (header + tagline included) until at least - // one group is known. Rendering the header while the query is still - // pending causes a flash when the result resolves to an empty list. + // Suppress the entire section (header + tagline included) until at + // least one group is known. Rendering the header while the query is + // still pending causes a flash when the result resolves to an empty + // list. if (!organizations || organizations.length === 0) return null; const COLLAPSED_COUNT = 4; @@ -570,7 +394,3 @@ function MyCommunitiesShelf({ ); } - - - -