diff --git a/NIP.md b/NIP.md index 0d4fbb3f..da508a44 100644 --- a/NIP.md +++ b/NIP.md @@ -22,7 +22,7 @@ |--------------------------|-----------------------------------------|-----------------------------------------------------------------| | Flat Communities | 34550, 30009, 8, 1111, 1984 | One-level badge membership with explicit moderators (NIP-72 ext) | | Community Chat | 34550, 1311 | Realtime member chat scoped to a NIP-72 community | -| Campaign Moderation | 33863, 1985, 39089 | Homepage curation (approved / hidden / featured axes) via moderator-signed labels in the `agora.moderation` namespace, gated by a follow-pack moderator roster | +| Campaign Moderation | 33863, 34550, 36639, 1985, 39089 | Discovery curation (approved / hidden / featured axes) via moderator-signed labels in the `agora.moderation` namespace, gated by a follow-pack moderator roster. Covers campaigns (all three axes), organizations (hidden + featured), and pledges (hidden + featured). | | HD Wallet Derivation | — | BIP-39 mnemonic deterministically derived from the user's nsec via HKDF; seeds a BIP-86 Taproot + BIP-352 silent-payment wallet importable into any BIP-39-compatible wallet (see [Agora HD Wallet](#agora-hd-wallet-derivation) below). | ### Agora Content Marker @@ -498,14 +498,15 @@ The `pinnedEvents` array is ordered newest pin first. Pinning an already-pinned ### Agora Moderation Labels -Agora curates which kind 33863 campaigns appear on the homepage (`/`) and on the Support directory (`/campaigns/all`), and which kind 34550 organizations appear in the Featured shelf on `/communities`, via moderator-signed NIP-32 label events (kind 1985) in a dedicated label namespace. The labeled event itself is never modified — surfacing is purely a client-side rollup of label events. +Agora curates which kind 33863 campaigns appear on the homepage (`/`) and on the Support directory (`/campaigns/all`), which kind 34550 organizations appear in the Featured shelf on `/communities`, and which kind 36639 pledges appear in the discovery surfaces on `/pledges`, via moderator-signed NIP-32 label events (kind 1985) in a dedicated label namespace. The labeled event itself is never modified — surfacing is purely a client-side rollup of label events. -Campaigns and organizations share a single label namespace and a single moderator pack (Team Soapbox); the only thing distinguishing the two streams is the kind prefix on the `a` tag of each label: +Campaigns, organizations, and pledges share a single label namespace and a single moderator pack (Team Soapbox); the only thing distinguishing the three streams is the kind prefix on the `a` tag of each label: - `33863::` — campaign (kind 33863, see "Open Campaigns" above). - `34550::` — organization (kind 34550, NIP-72 community definition). +- `36639::` — pledge (kind 36639, see "Pledge" below). -A client surfacing campaigns MUST filter folded labels to those whose `a` tag starts with `33863:`. A client surfacing organizations MUST filter to `34550:`. Mixing the two streams would let a moderator's `featured` label on a campaign appear to feature an unrelated organization with the same `d` tag, or vice versa. +A client surfacing campaigns MUST filter folded labels to those whose `a` tag starts with `33863:`. A client surfacing organizations MUST filter to `34550:`. A client surfacing pledges MUST filter to `36639:`. Mixing the streams would let a moderator's `featured` label on a campaign appear to feature an unrelated pledge with the same `d` tag, or any other cross-surface bleed. #### Namespace @@ -520,13 +521,13 @@ Each label event carries the namespace twice, per NIP-32: #### Label values -Three independent axes are defined; the newest moderator-signed label per axis per coordinate wins. **Campaigns** use all three axes (`approval`, `hide`, `featured`). **Organizations** use only two — `hide` and `featured` — because every Agora-tagged organization is publicly visible by default; there is no approval gate for orgs. Moderators MUST NOT publish `approved` or `unapproved` labels against kind 34550 coordinates, and clients MUST ignore any such labels they receive. +Three independent axes are defined; the newest moderator-signed label per axis per coordinate wins. **Campaigns** use all three axes (`approval`, `hide`, `featured`). **Organizations** and **pledges** use only two — `hide` and `featured` — because every Agora-tagged organization or pledge is publicly visible by default; there is no approval gate. Moderators MUST NOT publish `approved` or `unapproved` labels against kind 34550 or kind 36639 coordinates, and clients MUST ignore any such labels they receive. -| Axis | Values | Surfaces | Meaning | -|----------|---------------------------|----------------|-------------------------------------------------------------------------| -| approval | `approved`, `unapproved` | campaigns only | `approved` allows the campaign on its discovery surfaces. `unapproved` retracts a previous approval. | -| hide | `hidden`, `unhidden` | both | `hidden` suppresses the campaign/organization everywhere it would otherwise appear. `unhidden` retracts a previous hide. | -| featured | `featured`, `unfeatured` | both | `featured` places the campaign in the hand-picked Featured row on `/`, or the organization in the Featured shelf on `/communities`. `unfeatured` retracts. | +| Axis | Values | Surfaces | Meaning | +|----------|---------------------------|------------------------------------|-------------------------------------------------------------------------| +| approval | `approved`, `unapproved` | campaigns only | `approved` allows the campaign on its discovery surfaces. `unapproved` retracts a previous approval. | +| hide | `hidden`, `unhidden` | campaigns, organizations, pledges | `hidden` suppresses the target everywhere it would otherwise appear. `unhidden` retracts a previous hide. | +| featured | `featured`, `unfeatured` | campaigns, organizations, pledges | `featured` places the target in a hand-picked Featured row. `unfeatured` retracts. | Surfacing rules (hide always wins): @@ -546,6 +547,13 @@ Surfacing rules (hide always wins): - **Moderator-only "Hidden"** — iff hidden. - **Hide enforcement on other organization discovery surfaces** — clients SHOULD suppress `hidden` organizations from any future "All organizations" / browse surface for non-moderators. Moderators MAY see hidden organizations with a "Hidden" treatment so they can unhide. +**Pledges** + +- **Discovery surfaces on `/pledges`** — non-moderators MUST NOT see `hidden` pledges in the active / upcoming / past sections, the search results grid, or any future browse surface. Moderators MAY opt-in to seeing hidden pledges via a Show-hidden toggle so they can unhide. +- **Author-own surfaces** — a pledge author's own pledges in their profile always render regardless of moderation state. Moderation governs public discovery, not authorship. +- **Direct-URL access** — a pledge's detail page (`/`) renders regardless of moderation state. Hidden pledges remain reachable by anyone who has the link; moderation only governs which surfaces enumerate them. +- **Featured** — reserved for a future curated pledge shelf. The `featured` axis is defined for symmetry with campaigns/organizations, and clients MAY use it when implementing such a shelf. + #### Event Structure ```json @@ -576,12 +584,27 @@ An organization label has the same shape with a kind 34550 `a` tag: } ``` +A pledge label has the same shape with a kind 36639 `a` tag: + +```json +{ + "kind": 1985, + "content": "", + "tags": [ + ["L", "agora.moderation"], + ["l", "hidden", "agora.moderation"], + ["a", "36639::"], + ["alt", "Pledge moderation: hidden"] + ] +} +``` + Required tags: - `L` set to `agora.moderation`. - `l` with the label value as the 2nd element and `agora.moderation` as the 3rd. -- `a` referencing the target coordinate (`33863::` for a campaign, `34550::` for an organization). -- `alt` (NIP-31) — clients without label support will display this string. The `alt` value SHOULD identify the surface (e.g. `Campaign moderation: featured` or `Organization moderation: featured`) so non-Agora clients can read it. +- `a` referencing the target coordinate (`33863::` for a campaign, `34550::` for an organization, `36639::` for a pledge). +- `alt` (NIP-31) — clients without label support will display this string. The `alt` value SHOULD identify the surface (e.g. `Campaign moderation: featured`, `Organization moderation: featured`, or `Pledge moderation: hidden`) so non-Agora clients can read it. #### Trust Model diff --git a/src/components/AgoraInlinePreview.tsx b/src/components/AgoraInlinePreview.tsx new file mode 100644 index 00000000..ad4dbc26 --- /dev/null +++ b/src/components/AgoraInlinePreview.tsx @@ -0,0 +1,201 @@ +import type { NostrEvent } from '@nostrify/nostrify'; +import type { ReactNode } from 'react'; +import { CalendarClock, HandHeart, MapPin, Megaphone, ShieldCheck, Users } from 'lucide-react'; +import { nip19 } from 'nostr-tools'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; + +import { useBtcPrice } from '@/hooks/useBtcPrice'; +import { useCampaignDonations } from '@/hooks/useCampaignDonations'; +import { parseAction } from '@/hooks/useActions'; +import { getGeoDisplayName } from '@/lib/countries'; +import { parseCampaign, getCampaignCountryLabel } from '@/lib/campaign'; +import { parseCommunityEvent } from '@/lib/communityUtils'; +import { formatCampaignAmount, formatUsdGoal, satsToUsd } from '@/lib/formatCampaignAmount'; +import { formatCompactPledgeDeadline, formatPledgeAmount } from '@/lib/pledges'; +import { cn } from '@/lib/utils'; + +function getDeadlineLabel(unixSeconds: number): { label: string; isPast: boolean } { + const now = Math.floor(Date.now() / 1000); + const diff = unixSeconds - now; + if (diff <= 0) return { label: 'Ended', isPast: true }; + const days = Math.ceil(diff / 86_400); + if (days <= 1) return { label: 'Ends today', isPast: false }; + if (days < 30) return { label: `${days} days left`, isPast: false }; + const months = Math.round(days / 30); + return { label: `${months} mo left`, isPast: false }; +} + +function InlineShell({ + image, + fallbackIcon, + title, + description, + meta, +}: { + image?: string; + fallbackIcon: ReactNode; + title: string; + description?: string; + meta?: ReactNode; +}) { + return ( +
+
+ {image ? ( + + ) : ( +
+ {fallbackIcon} +
+ )} +
+
+

{title}

+ {description?.trim() ? ( +

{description}

+ ) : null} + {meta} +
+
+ ); +} + +export function CampaignInlinePreview({ event }: { event: NostrEvent }) { + const campaign = parseCampaign(event); + const { data: btcPrice } = useBtcPrice(); + const { data: stats } = useCampaignDonations(campaign ?? undefined); + if (!campaign) return null; + + const naddr = nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: campaign.identifier }); + const countryLabel = getCampaignCountryLabel(campaign); + const deadline = campaign.deadline ? getDeadlineLabel(campaign.deadline) : undefined; + const isSilentPayment = !campaign.wallets.onchain; + const goalLabel = campaign.goalUsd && campaign.goalUsd > 0 ? formatUsdGoal(campaign.goalUsd) : undefined; + const raisedSats = stats?.totalSats ?? 0; + const raisedLabel = isSilentPayment ? undefined : formatCampaignAmount(raisedSats, btcPrice); + const raisedUsd = isSilentPayment ? undefined : satsToUsd(raisedSats, btcPrice); + const progress = campaign.goalUsd && raisedUsd !== undefined + ? Math.min(100, Math.round((raisedUsd / campaign.goalUsd) * 100)) + : 0; + + return ( + e.stopPropagation()} className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background"> + } + title={campaign.title} + description={campaign.summary || campaign.story} + meta={( +
+ {campaign.goalUsd && !isSilentPayment ? ( +
+
+
+ ) : null} +
+ {isSilentPayment ? ( + + + {goalLabel ?? 'Private campaign'} + + ) : ( + + {raisedLabel} {goalLabel ? `/ ${goalLabel}` : 'raised'} + + )} + {countryLabel && ( + + + {countryLabel} + + )} + {deadline && ( + + + {deadline.label} + + )} +
+
+ )} + /> + + ); +} + +export function PledgeInlinePreview({ event }: { event: NostrEvent }) { + const { t } = useTranslation(); + const pledge = parseAction(event); + const { data: btcPrice } = useBtcPrice(); + if (!pledge) return null; + + const naddr = nip19.naddrEncode({ kind: 36639, pubkey: pledge.pubkey, identifier: pledge.id }); + const countryLabel = pledge.countryCode ? getGeoDisplayName(pledge.countryCode) : undefined; + const deadline = pledge.deadline ? formatCompactPledgeDeadline(pledge.deadline) : undefined; + + return ( + e.stopPropagation()} className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background"> + } + title={pledge.title} + description={pledge.description} + meta={( +
+ + {t('pledges.card.pledged')} + {formatPledgeAmount(pledge.bounty, btcPrice)} + + {countryLabel && ( + + + {countryLabel} + + )} + {deadline && ( + + + {deadline.label} + + )} +
+ )} + /> + + ); +} + +export function GroupInlinePreview({ event }: { event: NostrEvent }) { + const { t } = useTranslation(); + const group = parseCommunityEvent(event); + if (!group) return null; + + const naddr = nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: group.dTag }); + const countryLabel = group.countryCode ? getGeoDisplayName(group.countryCode) : undefined; + + return ( + e.stopPropagation()} className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background"> + } + title={group.name} + description={group.description} + meta={( +
+ + + {t('groups.create.moderatorsCount', { count: group.moderatorPubkeys.length })} + + {countryLabel && ( + + + {countryLabel} + + )} +
+ )} + /> + + ); +} diff --git a/src/components/AuthorByline.tsx b/src/components/AuthorByline.tsx new file mode 100644 index 00000000..8c1f75ad --- /dev/null +++ b/src/components/AuthorByline.tsx @@ -0,0 +1,143 @@ +import { Trans } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; + +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { useAuthor } from '@/hooks/useAuthor'; +import { useProfileUrl } from '@/hooks/useProfileUrl'; +import { getDisplayName } from '@/lib/genUserName'; +import { sanitizeUrl } from '@/lib/sanitizeUrl'; +import { cn } from '@/lib/utils'; + +/** + * Unified author display used by every card and detail page that + * surfaces the human behind an event. Standardizes: + * + * - Avatar component (shadcn `Avatar` + initials fallback). + * - Display-name resolution via `getDisplayName` (display_name → name → "Anonymous"). + * - Link target via `useProfileUrl` (nip05 path when verified, npub otherwise). + * - i18n: the "by {name}" label uses the shared `common.byAuthor` + * key so every surface ships the same translated string in every + * locale. + * - Click semantics inside a card: when `insideLink` is true the + * byline renders as a ` + ); + } + + return ( + { + // Use SPA navigation; preserve modifier-clicks for new tabs. + if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return; + e.preventDefault(); + e.stopPropagation(); + navigate(profileUrl); + }} + className={wrapperClass} + aria-label={displayName} + > + {inner} + + ); +} + diff --git a/src/components/CampaignCard.tsx b/src/components/CampaignCard.tsx index 690a86fe..794af10f 100644 --- a/src/components/CampaignCard.tsx +++ b/src/components/CampaignCard.tsx @@ -1,17 +1,16 @@ import { useMemo } from 'react'; import type { ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; -import { CalendarClock, EyeOff, HandHeart, MapPin, ShieldCheck } from 'lucide-react'; +import { CalendarClock, HandHeart, MapPin, ShieldCheck } from 'lucide-react'; -import { Badge } from '@/components/ui/badge'; +import { AuthorByline } from '@/components/AuthorByline'; import { Card } from '@/components/ui/card'; import { Progress } from '@/components/ui/progress'; import { Skeleton } from '@/components/ui/skeleton'; -import { CampaignModerationMenu } from '@/components/CampaignModerationMenu'; -import { useAuthor } from '@/hooks/useAuthor'; +import { ModerationOverlay } from '@/components/moderation'; import { useBtcPrice } from '@/hooks/useBtcPrice'; import { useCampaignDonations } from '@/hooks/useCampaignDonations'; -import { useCampaignModeration } from '@/hooks/useCampaignModeration'; import { useEventTranslation } from '@/hooks/useEventTranslation'; import { type ParsedCampaign, @@ -20,7 +19,6 @@ import { parseCampaign, } from '@/lib/campaign'; import { formatCampaignAmount, formatUsdGoal, satsToUsd } from '@/lib/formatCampaignAmount'; -import { genUserName } from '@/lib/genUserName'; import { sanitizeUrl } from '@/lib/sanitizeUrl'; import { cn } from '@/lib/utils'; @@ -123,8 +121,15 @@ function CampaignPrivateNotice({ interface CampaignCardProps { campaign: ParsedCampaign; - /** Visual variant: `compact` for grid items, `featured` for hero placement. */ - variant?: 'compact' | 'featured'; + /** + * Visual variant. + * + * - `compact` — default grid item. + * - `featured` — hero placement (wider, side-by-side on `sm+`). + * - `shelf` — fixed-width card for horizontal scroll rails (e.g. group + * official-activity). Caller no longer hand-rolls the size wrapper. + */ + variant?: 'compact' | 'featured' | 'shelf'; className?: string; /** Optional footer affordance rendered opposite the author line. */ footerBadge?: ReactNode; @@ -135,22 +140,17 @@ interface CampaignCardProps { * `` to the campaign's naddr-based detail route. */ export function CampaignCard({ campaign, variant = 'compact', className, footerBadge }: CampaignCardProps) { + const { t } = useTranslation(); const { translatedEvent, translateAction } = useEventTranslation(campaign.event, { iconOnly: true, buttonClassName: 'size-8 rounded-full p-0 text-muted-foreground hover:text-primary hover:bg-primary/10', }); const displayCampaign = parseCampaign(translatedEvent) ?? campaign; - const author = useAuthor(campaign.pubkey); const { data: stats } = useCampaignDonations(campaign); const { data: btcPrice } = useBtcPrice(); - const { data: moderation } = useCampaignModeration(); const naddr = useMemo(() => encodeCampaignNaddr(campaign), [campaign]); const cover = sanitizeUrl(displayCampaign.banner); - const creatorName = - author.data?.metadata?.display_name || - author.data?.metadata?.name || - genUserName(campaign.pubkey); const deadline = campaign.deadline ? formatDeadline(campaign.deadline) : null; const raisedSats = stats?.totalSats ?? 0; const countryLabel = getCampaignCountryLabel(campaign); @@ -159,15 +159,14 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB const isSilentPayment = !campaign.wallets.onchain; const isFeaturedVariant = variant === 'featured'; - const isApproved = moderation.approvedCoords.has(campaign.aTag); - const isHidden = moderation.hiddenCoords.has(campaign.aTag); - const isFeatured = moderation.featuredCoords.has(campaign.aTag); + const isShelfVariant = variant === 'shelf'; return ( @@ -239,24 +238,14 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB
)} -
- {isHidden && ( - - - Hidden - - )} - -
+ {/* Body — deterministic structure: title (1 line, truncates) → @@ -294,11 +283,11 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB )}
-
- by {creatorName} +
+ {!isSilentPayment && stats && stats.donorCount > 0 && ( - - · {stats.donorCount} {stats.donorCount === 1 ? 'donor' : 'donors'} + + · {t('common.donors', { count: stats.donorCount })} )}
diff --git a/src/components/CampaignModerationMenu.tsx b/src/components/CampaignModerationMenu.tsx deleted file mode 100644 index 5059cf0e..00000000 --- a/src/components/CampaignModerationMenu.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { useState } from 'react'; -import { Check, EyeOff, Eye, Loader2, MoreHorizontal, ShieldCheck, ShieldOff, Sparkles, SparklesIcon } from 'lucide-react'; - -import { Button } from '@/components/ui/button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { useCampaignModeration, type ModerationLabel } from '@/hooks/useCampaignModeration'; -import { useCampaignModerators } from '@/hooks/useCampaignModerators'; -import { useCurrentUser } from '@/hooks/useCurrentUser'; -import { useToast } from '@/hooks/useToast'; - -interface CampaignModerationMenuProps { - /** The campaign's `30223::` coordinate. */ - coord: string; - /** Visible label for the campaign (for toast feedback). */ - campaignTitle: string; - /** Whether the campaign is currently approved. */ - isApproved: boolean; - /** Whether the campaign is currently hidden. */ - isHidden: boolean; - /** Whether the campaign is currently featured. */ - isFeatured: boolean; - className?: string; -} - -/** - * Per-card kebab menu exposing the six moderation actions: - * Approve / Unapprove (axis = approval) - * Hide / Unhide (axis = hide) - * Feature / Unfeature (axis = featured) - * - * Renders `null` for users who are not Team Soapbox pack members. Sits - * inside the clickable `CampaignCard` ``, so the trigger swallows - * its own click + the dropdown content stops propagation, otherwise every - * menu interaction would navigate to the campaign detail page. - */ -export function CampaignModerationMenu({ - coord, - campaignTitle, - isApproved, - isHidden, - isFeatured, - className, -}: CampaignModerationMenuProps) { - const { user } = useCurrentUser(); - const { data: moderators } = useCampaignModerators(); - const { moderate } = useCampaignModeration(); - const { toast } = useToast(); - const [busy, setBusy] = useState(null); - - const isMod = !!user && !!moderators && moderators.includes(user.pubkey); - if (!isMod) return null; - - const runAction = async (action: ModerationLabel, verbPast: string) => { - if (busy) return; - setBusy(action); - try { - await moderate.mutateAsync({ coord, action }); - toast({ title: `${verbPast}`, description: campaignTitle }); - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error'; - toast({ - title: `Failed to ${action}`, - description: message, - variant: 'destructive', - }); - } finally { - setBusy(null); - } - }; - - return ( - - e.preventDefault()}> - - - e.stopPropagation()}> - - Moderator actions - - - {isApproved ? ( - runAction('unapproved', 'Removed from homepage')}> - - Unapprove - - Approved - - - ) : ( - runAction('approved', 'Approved for homepage')}> - - Approve - - )} - {isHidden ? ( - runAction('unhidden', 'Unhidden')}> - - Unhide - - Hidden - - - ) : ( - runAction('hidden', 'Hidden')} - className="text-destructive focus:text-destructive" - > - - Hide - - )} - - {isFeatured ? ( - runAction('unfeatured', 'Removed from featured')}> - - Unfeature - - Featured - - - ) : ( - runAction('featured', 'Featured on homepage')}> - - Feature - - )} - - - ); -} diff --git a/src/components/CampaignNoteCardContent.tsx b/src/components/CampaignNoteCardContent.tsx deleted file mode 100644 index c120af78..00000000 --- a/src/components/CampaignNoteCardContent.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import type { NostrEvent } from '@nostrify/nostrify'; - -import { CampaignCard } from '@/components/CampaignCard'; -import { parseCampaign } from '@/lib/campaign'; - -/** - * Renders a kind 33863 Campaign event inside the activity feed using the - * same polished {@link CampaignCard} component that powers the campaign - * directory. The whole card is a `` to the campaign's naddr-based - * detail route, so taps from the feed land directly on the campaign page. - * - * Malformed events (missing required fields, invalid wallet endpoint, - * etc.) silently drop — `parseCampaign` returns `null` and we return - * `null` from the component. A future enhancement could render a - * "Malformed campaign" fallback, but for now keeping the feed clean - * wins over surfacing parse errors to viewers. - */ -export function CampaignNoteCardContent({ event }: { event: NostrEvent }) { - const campaign = parseCampaign(event); - if (!campaign) return null; - return ; -} diff --git a/src/components/CommentsSection.tsx b/src/components/CommentsSection.tsx new file mode 100644 index 00000000..a287c8ca --- /dev/null +++ b/src/components/CommentsSection.tsx @@ -0,0 +1,51 @@ +import type { ReactNode } from 'react'; + +import { cn } from '@/lib/utils'; + +interface CommentsSectionProps { + /** Section heading rendered above the muted panel. */ + title: string; + /** Optional count chip rendered opposite the heading. */ + countLabel?: ReactNode; + /** + * Panel contents. Composer + threaded list + empty state are owned + * by the caller — this wrapper just provides the canonical visual + * surface so the three detail pages (campaign / community / pledge) + * stop drifting. + * + * The wrap uses `bg-muted/60` with `border-primary/20` accents and + * retints child `
` borders so per-note dividers read as a + * single consistent edge color. The composer inside uses `bg-card` + * for its own focused-surface contrast against this backdrop. + */ + children: ReactNode; + className?: string; +} + +/** + * Canonical visual surface for the comments section on detail pages. + * Extracted from the previous campaign-detail-only treatment so + * Campaigns, Communities, and Pledges all present comments inside the + * same muted, rounded, primary-tinted panel. + */ +export function CommentsSection({ title, countLabel, children, className }: CommentsSectionProps) { + return ( +
+
+

{title}

+ {countLabel ? ( + {countLabel} + ) : null} +
+ + {/* Muted surface wraps the composer and comment list. The wrap + carries the outer L/R/B border so the rounded corners curve + naturally without any 1px gaps at the join. Per-article + `border-b` divides items. The composer's own border closes + the top. */} +
+ {children} +
+
+ ); +} diff --git a/src/components/CommunityContent.tsx b/src/components/CommunityContent.tsx deleted file mode 100644 index 68c137ae..00000000 --- a/src/components/CommunityContent.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { useMemo } from 'react'; -import { Users, Globe } from 'lucide-react'; -import type { NostrEvent } from '@nostrify/nostrify'; - -import { sanitizeUrl } from '@/lib/sanitizeUrl'; - -// --- Helpers --- - -function getTag(tags: string[][], name: string): string | undefined { - return tags.find(([n]) => n === name)?.[1]; -} - -function parseCommunityEvent(event: NostrEvent) { - const name = getTag(event.tags, 'name') || getTag(event.tags, 'd') || 'Unnamed Group'; - const description = getTag(event.tags, 'description') || ''; - const image = getTag(event.tags, 'image'); - - // Extract moderators from p tags with "moderator" role - const moderators = event.tags - .filter(([n, , , role]) => n === 'p' && role === 'moderator') - .map(([, pubkey]) => pubkey) - .filter(Boolean); - - // Extract relays - const relays = event.tags - .filter(([n]) => n === 'relay') - .map(([, url, marker]) => ({ url, marker })) - .filter((r) => !!r.url); - - return { name, description, image, moderators, relays }; -} - -// --- Main Component --- - -export function CommunityContent({ event }: { event: NostrEvent }) { - const { name, description, image } = useMemo( - () => parseCommunityEvent(event), - [event], - ); - - // Extract website URL from description if present - const descriptionUrl = useMemo(() => { - const urlMatch = description.match(/https?:\/\/[^\s]+/); - return sanitizeUrl(urlMatch?.[0]); - }, [description]); - - // Description text without trailing URL (if the URL is the last thing) - const descriptionText = useMemo(() => { - if (!descriptionUrl) return description; - return description.replace(new RegExp(`\\s*${descriptionUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`), '').trim(); - }, [description, descriptionUrl]); - - return ( -
- {/* Community hero image */} - {image ? ( -
- {name} - {/* Gradient overlay for text readability */} -
- {/* Community name overlaid on image */} -
-

{name}

-
-
- ) : ( -
- -
-

{name}

-
-
- )} - - {/* Description */} - {descriptionText && ( -

{descriptionText}

- )} - - {/* Website link */} - {descriptionUrl && ( - - - {descriptionUrl.replace(/^https?:\/\//, '').replace(/\/$/, '')} - - )} - -
- ); -} diff --git a/src/components/CommunityDetailPage.tsx b/src/components/CommunityDetailPage.tsx index c8bbe878..3448b817 100644 --- a/src/components/CommunityDetailPage.tsx +++ b/src/components/CommunityDetailPage.tsx @@ -4,7 +4,6 @@ import { Link, useNavigate } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; import { nip19 } from 'nostr-tools'; import { - CalendarClock, CalendarDays, ChevronLeft, ChevronRight, @@ -27,7 +26,9 @@ import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify'; import { CampaignCard } from '@/components/CampaignCard'; import { DetailReplySkeleton } from '@/components/DetailStory'; import { PeopleAvatarStack } from '@/components/PeopleAvatarStack'; +import { PledgeCard } from '@/components/PledgeCard'; import { PostActionBar } from '@/components/PostActionBar'; +import { CommentsSection } from '@/components/CommentsSection'; import { DetailCommentComposer } from '@/components/DetailCommentComposer'; import { PinnedCommentHeader } from '@/components/PinnedCommentHeader'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; @@ -71,12 +72,10 @@ import { useEventTranslation } from '@/hooks/useEventTranslation'; import { CommunityModerationContext } from '@/contexts/CommunityModerationContext'; import { applyCommunityModerationToEvents, parseCommunityEvent } from '@/lib/communityUtils'; import type { ParsedCampaign } from '@/lib/campaign'; -import { parseAction, type Action } from '@/hooks/useActions'; +import { type Action } from '@/hooks/useActions'; import { getGeoDisplayName } from '@/lib/countries'; -import { DEFAULT_COVER_IMAGE } from '@/lib/defaultActionCovers'; import { formatNumber } from '@/lib/formatNumber'; import { genUserName, getDisplayName } from '@/lib/genUserName'; -import { formatCompactPledgeDeadline, formatPledgeAmount } from '@/lib/pledges'; import { sanitizeUrl } from '@/lib/sanitizeUrl'; import { cn } from '@/lib/utils'; @@ -227,96 +226,17 @@ function ActivityTypePill({ icon, label }: { icon: React.ReactNode; label: strin function PledgeShelfCard({ pledge }: { pledge: Action }) { const { t } = useTranslation(); const { data: btcPrice } = useBtcPrice(); - const { translatedEvent, translateAction } = useEventTranslation(pledge.event, { - iconOnly: true, - buttonClassName: 'size-8 rounded-full p-0 text-muted-foreground hover:text-primary hover:bg-primary/10', - }); - const displayPledge = parseAction(translatedEvent) ?? pledge; - const author = useAuthor(pledge.pubkey); - const metadata = author.data?.metadata; - const displayName = getDisplayName(metadata, pledge.pubkey); - const [imageLoadFailed, setImageLoadFailed] = useState(false); - - const sanitizedCover = sanitizeUrl(displayPledge.image); - const coverImage = sanitizedCover && !imageLoadFailed ? sanitizedCover : DEFAULT_COVER_IMAGE; - const deadline = displayPledge.deadline ? formatCompactPledgeDeadline(displayPledge.deadline) : null; - const countryLabel = displayPledge.countryCode ? getGeoDisplayName(displayPledge.countryCode) : undefined; - const naddr = nip19.naddrEncode({ - kind: pledge.event.kind, - pubkey: pledge.pubkey, - identifier: pledge.id, - }); return ( - - -
- setImageLoadFailed(true)} - loading="lazy" - /> - {deadline?.isPast && ( -
- - {t('pledges.card.ended')} - -
- )} -
- -
-
-

- {displayPledge.title} -

- {displayPledge.description.trim() && ( -

- {displayPledge.description} -

- )} -
- -
- -
-

{t('pledges.card.pledged')}

-

- {formatPledgeAmount(pledge.bounty, btcPrice)} -

-
- -
- {countryLabel && ( - - - {countryLabel} - - )} - {deadline && ( - - - {deadline.label} - - )} -
- -
-
- {t('groups.detail.byAuthor', { name: displayName })} -
-
- } label={t('groups.detail.pledge')} /> - {translateAction} -
-
-
- - + } label={t('groups.detail.pledge')} /> + } + /> ); } @@ -545,13 +465,12 @@ function OfficialActivityShelves({ {mixedActivity.map((item) => { if (item.type === 'campaign') { return ( -
- } label={t('groups.detail.campaign')} />} - /> -
+ } label={t('groups.detail.campaign')} />} + /> ); } @@ -1146,21 +1065,19 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) { {/* Comments — NIP-22 thread on the community event itself. */}
-
-
-

{t('groups.detail.comments')}

- {engagementStats?.replies ? ( - - {formatNumber(engagementStats.replies)}{' '} - {t('groups.detail.commentNoun', { count: engagementStats.replies })} - - ) : null} -
- - + + {formatNumber(engagementStats.replies)}{' '} + {t('groups.detail.commentNoun', { count: engagementStats.replies })} + + ) : undefined} + > + {commentsLoading && statsLoading && replyTree.length === 0 ? ( -
+
{Array.from({ length: 3 }).map((_, i) => ( ))} @@ -1181,7 +1098,7 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) { )} -
+
diff --git a/src/components/CommunityModerationMenu.tsx b/src/components/CommunityModerationMenu.tsx deleted file mode 100644 index 3a9ca7fb..00000000 --- a/src/components/CommunityModerationMenu.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { useState } from 'react'; -import { Check, EyeOff, Eye, Loader2, MoreHorizontal, Sparkles, SparklesIcon } from 'lucide-react'; - -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { useCampaignModerators } from '@/hooks/useCampaignModerators'; -import { useCurrentUser } from '@/hooks/useCurrentUser'; -import { useOrganizationModeration } from '@/hooks/useOrganizationModeration'; -import { useToast } from '@/hooks/useToast'; -import type { ModerationLabel } from '@/lib/agoraModeration'; - -interface CommunityModerationMenuProps { - /** The organization's `34550::` coordinate. */ - coord: string; - /** Visible name for the organization (for toast feedback). */ - organizationName: string; - className?: string; -} - -function CommunityModerationMenuInner({ - coord, - organizationName, - className, -}: CommunityModerationMenuProps) { - const { data: moderation, moderate } = useOrganizationModeration(); - const { toast } = useToast(); - const [busy, setBusy] = useState(null); - - const isHidden = moderation.hiddenCoords.has(coord); - const isFeatured = moderation.featuredCoords.has(coord); - - const runAction = async (action: ModerationLabel, verbPast: string) => { - if (busy) return; - setBusy(action); - try { - await moderate.mutateAsync({ coord, action }); - toast({ title: verbPast, description: organizationName }); - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error'; - toast({ - title: `Failed to ${action}`, - description: message, - variant: 'destructive', - }); - } finally { - setBusy(null); - } - }; - - return ( - - e.preventDefault()}> - - - e.stopPropagation()}> - - Moderator actions - - - {isFeatured ? ( - runAction('unfeatured', 'Removed from featured')}> - - Unfeature - - Featured - - - ) : ( - runAction('featured', 'Featured group')}> - - Feature - - )} - - {isHidden ? ( - runAction('unhidden', 'Unhidden')}> - - Unhide - - Hidden - - - ) : ( - runAction('hidden', 'Hidden')} - className="text-destructive focus:text-destructive" - > - - Hide - - )} - - - ); -} - -/** - * Banner-overlay wrapper for `CommunityMiniCard` cards. Renders the - * moderator kebab plus a "Hidden" badge when applicable, both - * absolutely-positioned at the card's top-right. Returns `null` for - * non-moderators so non-mod grids never subscribe to the moderation - * query at all. - * - * Pulling the overlay (and its `useOrganizationModeration` subscription) - * out of `CommunityMiniCard` into a single moderator-gated component is - * the perf win that lets `/communities` paint Featured/My orgs - * immediately without waiting for the moderator pack or the label query - * for every card on the page. - */ -export function CommunityModerationOverlay({ - coord, - organizationName, -}: { - coord: string; - organizationName: string; -}) { - const { user } = useCurrentUser(); - const { data: moderators } = useCampaignModerators(); - const isMod = !!user && !!moderators && moderators.includes(user.pubkey); - - if (!isMod) return null; - - return ( - - ); -} - -function CommunityModerationOverlayInner({ - coord, - organizationName, -}: { - coord: string; - organizationName: string; -}) { - const { data: moderation } = useOrganizationModeration(); - const isHidden = moderation.hiddenCoords.has(coord); - - return ( -
- {isHidden && ( - - - Hidden - - )} - {/* The kebab inner uses the same moderation cache subscription, so - no extra round-trip is incurred. */} - -
- ); -} diff --git a/src/components/CountryPickerButton.tsx b/src/components/CountryPickerButton.tsx new file mode 100644 index 00000000..d51e0c36 --- /dev/null +++ b/src/components/CountryPickerButton.tsx @@ -0,0 +1,112 @@ +import { useMemo, useState } from 'react'; +import { Check, Globe } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +import { Button } from '@/components/ui/button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { countryCodeToFlag, getAllCountries } from '@/lib/countries'; +import { cn } from '@/lib/utils'; + +interface CountryPickerButtonProps { + /** + * Selected ISO 3166-1 alpha-2 country code (e.g. `"US"`), or `undefined` + * for the global / no-filter state. The button renders a flag emoji + * when a country is selected, otherwise a brand-orange Globe icon — + * matching the affordance the pledges page introduced. + */ + value: string | undefined; + /** Called when the user picks a country, or `undefined` for Global. */ + onChange: (next: string | undefined) => void; + /** Extra classes on the trigger button. */ + className?: string; +} + +/** + * Globe-icon country filter button shared by the discovery pages + * (Campaigns, Communities, Pledges). Opens a searchable country list in + * a popover; the first item is "Global" which clears the filter. + * + * The trigger collapses to the picked country's flag emoji when a + * country is selected, so the active state reads without opening the + * popover. Brand-orange `Globe` icon in the neutral state matches the + * other filter icons in the cluster. + * + * Callers own the selected country (state lives on the page so it can + * be threaded into NIP-50 queries or URL params). + */ +export function CountryPickerButton({ value, onChange, className }: CountryPickerButtonProps) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + + const allCountries = useMemo(() => getAllCountries(), []); + const countryOptions = useMemo(() => { + const options: Array<{ value: string; label: string; flag: string }> = [ + { value: 'global', label: t('common.countryGlobal'), flag: '🌍' }, + ]; + for (const country of allCountries) { + options.push({ + value: country.code, + label: country.name, + flag: countryCodeToFlag(country.code), + }); + } + return options; + }, [allCountries, t]); + + return ( + + + + + + + + + {t('common.countryNoResults')} + + {countryOptions.map((option) => ( + { + onChange(option.value === 'global' ? undefined : option.value); + setOpen(false); + }} + className="gap-2" + > + {option.flag} + {option.label} + + + ))} + + + + + + ); +} diff --git a/src/components/CountrySelect.tsx b/src/components/CountrySelect.tsx index e672d97e..68bdac95 100644 --- a/src/components/CountrySelect.tsx +++ b/src/components/CountrySelect.tsx @@ -68,7 +68,7 @@ export function CountrySelect({ setOpen(false); } }} - className="h-9 rounded-full border-0 bg-secondary pl-10 pr-10 text-sm focus-visible:ring-0 focus-visible:ring-offset-0" + className="h-9 rounded-full border-0 bg-secondary pl-10 pr-10 text-base md:text-sm focus-visible:ring-0 focus-visible:ring-offset-0" placeholder={placeholder ?? t('forms.countrySearchPlaceholder')} autoComplete="off" role="combobox" diff --git a/src/components/DebouncedSearchInput.tsx b/src/components/DebouncedSearchInput.tsx new file mode 100644 index 00000000..98102aef --- /dev/null +++ b/src/components/DebouncedSearchInput.tsx @@ -0,0 +1,68 @@ +import { Search, X } from 'lucide-react'; + +import { Input } from '@/components/ui/input'; +import { cn } from '@/lib/utils'; + +interface DebouncedSearchInputProps { + /** Current input value. Parent owns the state so it can debounce into a query. */ + value: string; + /** Called on every keystroke. */ + onChange: (next: string) => void; + /** Placeholder text. */ + placeholder: string; + /** `aria-label` for the input. Required because there's no visible label. */ + ariaLabel: string; + /** `aria-label` for the clear button. */ + clearLabel: string; + /** Extra classes on the wrapper. */ + className?: string; +} + +/** + * Search input used on the discovery pages (Campaigns, Communities, Pledges) + * for on-page NIP-50 search. Renders a shadcn `Input` with a left-aligned + * lucide `Search` icon and a right-aligned clear button that appears once + * the user has typed something. + * + * This component owns no state — the caller is expected to pair it with + * `useDebounce` and feed the debounced value into a query hook. Keeping + * it stateless means the same component can be reused for URL-synced + * searches, in-memory searches, or anywhere else. + */ +export function DebouncedSearchInput({ + value, + onChange, + placeholder, + ariaLabel, + clearLabel, + className, +}: DebouncedSearchInputProps) { + return ( +
+ + onChange(e.target.value)} + className="pl-9 pr-9 h-11 rounded-lg" + /> + {value && ( + + )} +
+ ); +} diff --git a/src/components/DiscoverySearchToolbar.tsx b/src/components/DiscoverySearchToolbar.tsx new file mode 100644 index 00000000..0d47d4c9 --- /dev/null +++ b/src/components/DiscoverySearchToolbar.tsx @@ -0,0 +1,180 @@ +import { useTranslation } from 'react-i18next'; +import { Check, Clock, EyeOff, LayoutGrid, ListFilter, TrendingUp } from 'lucide-react'; + +import { CountryPickerButton } from '@/components/CountryPickerButton'; +import { DebouncedSearchInput } from '@/components/DebouncedSearchInput'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { cn } from '@/lib/utils'; +import type { Nip50Sort } from '@/hooks/useNip50Search'; + +const SORT_OPTIONS: { value: Nip50Sort; labelKey: string; icon: typeof TrendingUp }[] = [ + { value: 'default', labelKey: 'common.sortDefault', icon: LayoutGrid }, + { value: 'top', labelKey: 'common.sortTop', icon: TrendingUp }, + { value: 'new', labelKey: 'common.sortNew', icon: Clock }, +]; + +interface DiscoverySearchToolbarProps { + /** Search input value (parent state, undebounced). */ + query: string; + /** Called on every keystroke. Parent is expected to debounce before querying. */ + onQueryChange: (next: string) => void; + /** Active sort. */ + sort: Nip50Sort; + /** Called when the user picks a different sort. */ + onSortChange: (next: Nip50Sort) => void; + /** Subset of sort options to expose. Defaults to all three. */ + sortOptions?: Nip50Sort[]; + /** i18n placeholder key for the input, e.g. `pledges.list.searchPlaceholder`. */ + searchPlaceholderKey: string; + /** i18n aria-label key for the input, e.g. `pledges.list.searchAriaLabel`. */ + searchAriaLabelKey: string; + /** + * Show-hidden switch state + handler. When `undefined`, the show-hidden + * row is omitted from the menu. + */ + showHidden?: { + /** Switch value. */ + value: boolean; + /** Called when the user toggles the switch. */ + onChange: (next: boolean) => void; + /** Optional count to render next to the label, e.g. (3). */ + count?: number; + }; + /** + * Selected ISO 3166-1 alpha-2 country code (e.g. `"US"`), or + * `undefined` for the global / no-filter state. Drives the country + * picker button rendered to the right of the filter dropdown. + */ + country?: string; + /** Called when the user picks a country, or `undefined` for Global. */ + onCountryChange?: (next: string | undefined) => void; + /** Extra classes on the outer container. */ + className?: string; +} + +/** + * Filter cluster shared by every discovery page (Campaigns home, All-Campaigns, + * Communities, Pledges). Designed to sit on the **right** of a section + * heading row, paired with an `h2 + tagline` block on the left: + * + *
+ *
+ *

+ *

+ *
+ * + *
+ * + * Layout: a horizontal cluster with a compact debounced search input + * (left) and a single Filter button (right) whose `DropdownMenu` + * contains the sort options and the optional Show-hidden switch — same + * `ListFilter` icon-button pattern the pledges page already uses for + * its sort dropdown, so the affordance is consistent. + * + * Fully controlled — parent owns search / sort / show-hidden state. + * Keeps URL sync, debounce, and storage decisions where they belong + * (in the page). + */ +export function DiscoverySearchToolbar({ + query, + onQueryChange, + sort, + onSortChange, + sortOptions, + searchPlaceholderKey, + searchAriaLabelKey, + showHidden, + country, + onCountryChange, + className, +}: DiscoverySearchToolbarProps) { + const { t } = useTranslation(); + + const sorts = sortOptions + ? SORT_OPTIONS.filter((o) => sortOptions.includes(o.value)) + : SORT_OPTIONS; + + return ( +
+ + + + + + + + + {t('common.sortAriaLabel')} + + {sorts.map(({ value, labelKey, icon: Icon }) => ( + { + if (checked) onSortChange(value); + }} + // The checkbox slot on the left is hidden in favour of an + // explicit `Check` on the right (matches the + // pledges-page sort dropdown). We keep the variant + // because it gives us the radio-like "one checked at a + // time" semantics for free. + className={cn( + '[&>span:first-child]:hidden pl-2', + sort === value && 'bg-primary/10', + )} + > + + {t(labelKey)} + {sort === value && } + + ))} + + {showHidden && ( + <> + + + + {t('common.showHidden')} + {showHidden.count !== undefined && showHidden.count > 0 && ( + + ({showHidden.count}) + + )} + + + )} + + + + {onCountryChange && ( + + )} +
+ ); +} diff --git a/src/components/EmojiPackDialog.tsx b/src/components/EmojiPackDialog.tsx index 3d92a0a2..973fd375 100644 --- a/src/components/EmojiPackDialog.tsx +++ b/src/components/EmojiPackDialog.tsx @@ -431,7 +431,7 @@ export function EmojiPackDialog({ open, onOpenChange, editEvent }: EmojiPackDial value={idTouched ? identifier : effectiveIdentifier} onChange={(e) => handleIdChange(e.target.value)} disabled={isEditMode || isSubmitting} - className={`font-mono text-sm ${isEditMode ? 'text-muted-foreground' : ''}`} + className={`font-mono text-base md:text-sm ${isEditMode ? 'text-muted-foreground' : ''}`} /> {isEditMode && (

Cannot be changed.

diff --git a/src/components/HDSendBitcoinDialog.tsx b/src/components/HDSendBitcoinDialog.tsx index 892c69b0..a491a42b 100644 --- a/src/components/HDSendBitcoinDialog.tsx +++ b/src/components/HDSendBitcoinDialog.tsx @@ -587,7 +587,7 @@ export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoin placeholder={t('walletSend.recipient.placeholder')} autoComplete="off" spellCheck={false} - className="font-mono text-sm" + className="font-mono text-base md:text-sm" /> {recipient && (

diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx index fdf1a70f..1d7a90eb 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -33,11 +33,9 @@ import { PodcastEpisodeContent, PodcastTrailerContent, } from "@/components/AudioKindContent"; -import { ActionContent } from "@/components/ActionContent"; +import { CampaignInlinePreview, GroupInlinePreview, PledgeInlinePreview } from "@/components/AgoraInlinePreview"; import { BadgeAwardCard } from "@/components/BadgeAwardCard"; import { BadgeContent } from "@/components/BadgeContent"; -import { CampaignNoteCardContent } from "@/components/CampaignNoteCardContent"; -import { CommunityContent } from "@/components/CommunityContent"; import { CalendarEventContent } from "@/components/CalendarEventContent"; import { ColorMomentContent } from "@/components/ColorMomentContent"; import { CommentContext, CountryCommentPill } from "@/components/CommentContext"; @@ -556,7 +554,11 @@ export const NoteCard = memo(function NoteCard({ const isComment = event.kind === 1111; const isReply = isTextNote && !isComment && isReplyEvent(event); - const { translatedEvent: contentEvent, translateAction } = useEventTranslation(event, { includePlainContent: isTextNote }); + const { translatedEvent: contentEvent, translateAction } = useEventTranslation(event, { + includePlainContent: isTextNote, + iconOnly: true, + buttonClassName: "h-9 w-9 p-0", + }); // Find all people being replied to (for "Replying to @user1 and @user2") const replyToPubkeys = useMemo(() => { @@ -696,15 +698,15 @@ export const NoteCard = memo(function NoteCard({ ) : isBadgeAward ? ( ) : isCommunity ? ( - + ) : isZapGoal ? ( ) : isAction ? ( - + ) : isCampaign ? ( - + ) : isVoiceMessage ? ( @@ -896,8 +898,6 @@ export const NoteCard = memo(function NoteCard({

- {translateAction} - +
+ ); + const headerControls = !compact ? ( +
+ {translateAction}
- ); + ) : null; // ── Vanish layout (kind 62) — dramatic card, no author row ── if (isVanish) { @@ -932,13 +937,14 @@ export const NoteCard = memo(function NoteCard({ return (
+ {headerControls &&
{headerControls}
}
{avatarElement} @@ -964,12 +970,13 @@ export const NoteCard = memo(function NoteCard({ return (
+ {headerControls &&
{headerControls}
} {!compact && ( <> @@ -1150,6 +1157,7 @@ export const NoteCard = memo(function NoteCard({ {authorInfo}
+ {headerControls}
{contentBlock} {actionButtons} @@ -1230,6 +1238,7 @@ export const NoteCard = memo(function NoteCard({ event={event} className="shrink-0 [text-shadow:none]" /> + {headerControls}
diff --git a/src/components/PendingBadge.tsx b/src/components/PendingBadge.tsx new file mode 100644 index 00000000..47a7a64b --- /dev/null +++ b/src/components/PendingBadge.tsx @@ -0,0 +1,37 @@ +import { useTranslation } from 'react-i18next'; +import { RefreshCw } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +interface PendingBadgeProps { + /** + * Optional formatted amount (e.g. "$1.23"). When present the badge reads + * "{amount} pending"; when omitted it reads just "pending". + */ + amountLabel?: string; + /** Additional classes appended to the base styling. */ + className?: string; +} + +/** + * Small orange inline indicator used wherever a Bitcoin amount is awaiting + * mempool confirmation — currently on the wallet headline and on campaign + * donation surfaces. Centralised so the visual treatment (orange + spinning + * RefreshCw) stays consistent across pages. + */ +export function PendingBadge({ amountLabel, className }: PendingBadgeProps) { + const { t } = useTranslation(); + return ( + + + {amountLabel + ? t('wallet.amountPending', { amount: amountLabel }) + : t('wallet.pending')} + + ); +} diff --git a/src/components/PersonSearch.tsx b/src/components/PersonSearch.tsx index 6cb46706..95bd6645 100644 --- a/src/components/PersonSearch.tsx +++ b/src/components/PersonSearch.tsx @@ -153,7 +153,7 @@ export function PersonSearch({ } }} placeholder="Search people..." - className="pl-10 pr-10 rounded-full bg-secondary border-0 focus-visible:ring-0 focus-visible:ring-offset-0 h-9 text-sm" + className="pl-10 pr-10 rounded-full bg-secondary border-0 focus-visible:ring-0 focus-visible:ring-offset-0 h-9 text-base md:text-sm" autoComplete="off" />
diff --git a/src/components/PinnedCommentHeader.tsx b/src/components/PinnedCommentHeader.tsx index 436d7968..410ec269 100644 --- a/src/components/PinnedCommentHeader.tsx +++ b/src/components/PinnedCommentHeader.tsx @@ -11,6 +11,25 @@ interface PinnedCommentHeaderProps { children?: ReactNode; } +/** + * Companion overlay for a note in a `ThreadedReplyList`. Positions the + * pin affordance in the note's top-right corner via the wrapping + * `group/note` container that `ReplyThread` provides. One slot, three + * states: + * + * - Not pinned, can manage → "Pin" button. Hidden until hover on + * fine-pointer devices (mouse / trackpad); always visible on touch + * devices so mobile moderators can find it. + * - Pinned, can manage → "Unpin" button, always visible. + * - Pinned, cannot manage → "Pinned" badge, always visible. + * - Not pinned, cannot manage → nothing rendered. + * + * The previous design rendered a full-width header bar above every + * comment; this slot model removes that vertical noise. + * + * `children` (custom inline badges, e.g. a country flag) is rendered + * as a flow row above the comment, unchanged. + */ export function PinnedCommentHeader({ isPinned, canManagePins, @@ -21,17 +40,14 @@ export function PinnedCommentHeader({ if (!isPinned && !canManagePins && !children) return null; return ( -
-
- {isPinned && ( - - - Pinned - - )} - {children} -
- {canManagePins && ( + <> + {children && ( +
+ {children} +
+ )} + + {canManagePins ? ( + ) : isPinned ? ( + - - {isPinned ? 'Unpin' : 'Pin'} - - )} -
+ + Pinned + + ) : null} + ); } diff --git a/src/components/PledgeCard.tsx b/src/components/PledgeCard.tsx new file mode 100644 index 00000000..fa493b5c --- /dev/null +++ b/src/components/PledgeCard.tsx @@ -0,0 +1,174 @@ +import type { ReactNode } from 'react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; +import { CalendarClock, MapPin } from 'lucide-react'; +import { nip19 } from 'nostr-tools'; + +import { AuthorByline } from '@/components/AuthorByline'; +import { Badge } from '@/components/ui/badge'; +import { Card } from '@/components/ui/card'; +import { useEventTranslation } from '@/hooks/useEventTranslation'; +import { parseAction, type Action } from '@/hooks/useActions'; +import { getGeoDisplayName } from '@/lib/countries'; +import { DEFAULT_COVER_IMAGE } from '@/lib/defaultActionCovers'; +import { formatCompactPledgeDeadline, formatPledgeAmount } from '@/lib/pledges'; +import { cn } from '@/lib/utils'; + +interface PledgeCardProps { + action: Action; + btcPrice: number | undefined; + /** Presentation variant for standalone surfaces. Inline note cards use PledgeInlinePreview instead. */ + variant?: 'grid' | 'shelf' | 'rail'; + /** Force an ended badge from a parent that already split active/ended sections. */ + isExpired?: boolean; + /** Render author footer. Standalone discovery cards usually do; profile-owned cards usually don't. */ + showAuthor?: boolean; + /** Render the translation control in the footer. Use only when no parent shell owns translation. */ + showTranslate?: boolean; + /** Menu/badges overlaid on the cover image, e.g. share/delete menu. */ + topRight?: ReactNode; + /** Extra footer affordance, e.g. group official-activity type pill. */ + footerAddon?: ReactNode; + className?: string; +} + +export function PledgeCard({ + action, + btcPrice, + variant = 'grid', + isExpired, + showAuthor = false, + showTranslate = false, + topRight, + footerAddon, + className, +}: PledgeCardProps) { + const { t } = useTranslation(); + const { translatedEvent, translateAction } = useEventTranslation(action.event, { + iconOnly: true, + buttonClassName: 'size-8 rounded-full p-0 text-muted-foreground hover:text-primary hover:bg-primary/10', + }); + const displayAction = showTranslate ? (parseAction(translatedEvent) ?? action) : action; + const [imageLoadFailed, setImageLoadFailed] = useState(false); + + const naddr = nip19.naddrEncode({ + kind: 36639, + pubkey: action.pubkey, + identifier: action.id, + }); + + const coverImage = displayAction.image && !imageLoadFailed ? displayAction.image : DEFAULT_COVER_IMAGE; + const deadline = displayAction.deadline ? formatCompactPledgeDeadline(displayAction.deadline) : null; + const ended = isExpired || !!deadline?.isPast; + const countryLabel = displayAction.countryCode ? getGeoDisplayName(displayAction.countryCode) : undefined; + const isRail = variant === 'rail'; + + const footer = showAuthor || showTranslate || footerAddon; + + return ( + + +
+ setImageLoadFailed(true)} + loading="lazy" + /> + {(ended || topRight) && ( +
e.preventDefault()}> + {ended && ( + + {t('pledges.card.ended')} + + )} + {topRight} +
+ )} +
+ +
+
+

+ {displayAction.title} +

+ {!isRail && displayAction.description.trim() && ( +

{displayAction.description}

+ )} +
+ + {!isRail &&
} + + {isRail ? ( +
+ {t('pledges.card.pledged')} + {formatPledgeAmount(action.bounty, btcPrice)} +
+ ) : ( +
+

{t('pledges.card.pledged')}

+

+ {formatPledgeAmount(action.bounty, btcPrice)} +

+
+ )} + + {(countryLabel || deadline) && ( +
+ {countryLabel && ( + + {!isRail && } + {countryLabel} + + )} + {deadline && ( + + + {deadline.label} + + )} +
+ )} + + {footer && !isRail && ( +
+
+ {showAuthor ? : null} +
+ {(footerAddon || (showTranslate && translateAction)) && ( +
+ {footerAddon} + {showTranslate && translateAction} +
+ )} +
+ )} +
+ + + ); +} diff --git a/src/components/RepostMenu.tsx b/src/components/RepostMenu.tsx index 7423e611..d939219a 100644 --- a/src/components/RepostMenu.tsx +++ b/src/components/RepostMenu.tsx @@ -29,6 +29,7 @@ export function RepostMenu({ event, children }: RepostMenuProps) { const [open, setOpen] = useState(false); const [quoteOpen, setQuoteOpen] = useState(false); const [quoteInitialContent, setQuoteInitialContent] = useState(undefined); + const [attachQuotedEvent, setAttachQuotedEvent] = useState(true); const { user } = useCurrentUser(); const { mutate: publishEvent } = useNostrPublish(); const { mutate: deleteEvent } = useDeleteEvent(); @@ -149,12 +150,14 @@ export function RepostMenu({ event, children }: RepostMenuProps) { const handleQuote = () => { setQuoteInitialContent(undefined); + setAttachQuotedEvent(true); setOpen(false); setQuoteOpen(true); }; const handleBoost = () => { setQuoteInitialContent(`\n\n${buildAgoraUrl(encodeEventAddress(event))}`); + setAttachQuotedEvent(false); setOpen(false); setQuoteOpen(true); }; @@ -225,7 +228,7 @@ export function RepostMenu({ event, children }: RepostMenuProps) { +
{renderItemHeader?.(node.event)} +
{renderItemHeader?.(node.event)} +
{renderItemHeader?.(node.event)}
@@ -396,6 +391,18 @@ function PledgeHero({ {t('pledges.detail.back')} + {/* Moderator-only kebab. Returns null for non-moderators so + non-mod viewers don't subscribe to the moderation query. + Matches the dark hero styling — translucent black pill so + it reads against the photo at the same weight as the back + button on the left. */} +
diff --git a/src/pages/ActionsPage.tsx b/src/pages/ActionsPage.tsx index f92333dd..8d4317d5 100644 --- a/src/pages/ActionsPage.tsx +++ b/src/pages/ActionsPage.tsx @@ -1,46 +1,46 @@ import { useEffect, useState, useMemo } from 'react'; import { useSeoMeta } from '@unhead/react'; -import { Link as RouterLink, useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; -import { useTranslation, Trans } from 'react-i18next'; +import { useTranslation } from 'react-i18next'; import { nip19 } from 'nostr-tools'; import { parseAction, useActions, type Action } from '@/hooks/useActions'; import { useAppContext } from '@/hooks/useAppContext'; -import { useAuthor } from '@/hooks/useAuthor'; +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 { useEventTranslation } from '@/hooks/useEventTranslation'; -import { getAllCountries, getGeoDisplayName, countryCodeToFlag } from '@/lib/countries'; -import { getDisplayName } from '@/lib/genUserName'; -import { DEFAULT_ACTION_COVERS, DEFAULT_COVER_IMAGE } from '@/lib/defaultActionCovers'; -import { formatCompactPledgeDeadline, formatPledgeAmount } from '@/lib/pledges'; +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 { PledgeCard } from '@/components/PledgeCard'; -import { Badge } from '@/components/ui/badge'; import { Card } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; -import { - Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, -} from '@/components/ui/command'; -import { - Popover, PopoverContent, PopoverTrigger, -} from '@/components/ui/popover'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { - CalendarClock, Clock, HandHeart, MapPin, Plus, ChevronRight, Loader2, - Link as LinkIcon, Check, MoreHorizontal, Trash2, ListFilter, - Calendar, DollarSign, Globe, Megaphone, + HandHeart, PlusCircle, ChevronDown, ChevronUp, Loader2, + Link as LinkIcon, Check, MoreHorizontal, Trash2, + Megaphone, Hourglass, EyeOff, } from 'lucide-react'; // ───────────────────────────────────────────────────────────────────────────── @@ -62,9 +62,10 @@ function ActionSkeleton() { ); } -function ActionShareMenu({ action }: { action: Action }) { +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(); @@ -73,6 +74,12 @@ function ActionShareMenu({ action }: { action: Action }) { 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, @@ -150,7 +157,8 @@ function ActionShareMenu({ action }: { action: Action }) { @@ -177,120 +185,27 @@ function ActionShareMenu({ action }: { action: Action }) { )} {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 && } + ); } -function ActionCard({ action, isExpired, btcPrice }: { action: Action; isExpired?: boolean; btcPrice: number | undefined }) { - const { t } = useTranslation(); - const { translatedEvent, translateAction } = useEventTranslation(action.event, { - iconOnly: true, - buttonClassName: 'size-8 rounded-full p-0 text-muted-foreground hover:text-primary hover:bg-primary/10', - }); - const displayAction = parseAction(translatedEvent) ?? action; - const author = useAuthor(action.pubkey); - const metadata = author.data?.metadata; - const displayName = getDisplayName(metadata, action.pubkey); - const [imageLoadFailed, setImageLoadFailed] = useState(false); - - const naddr = nip19.naddrEncode({ - kind: 36639, - pubkey: action.pubkey, - identifier: action.id, - }); - - // Always show a cover — fall back to the default if the author didn't set - // one, or the URL failed to validate / load. - const coverImage = (displayAction.image && !imageLoadFailed) - ? displayAction.image - : DEFAULT_COVER_IMAGE; - - const deadline = displayAction.deadline ? formatCompactPledgeDeadline(displayAction.deadline) : null; - const countryLabel = displayAction.countryCode ? getGeoDisplayName(displayAction.countryCode) : undefined; - - return ( - - -
- setImageLoadFailed(true)} - loading="lazy" - /> -
e.preventDefault()}> - {isExpired && ( - - {t('pledges.card.ended')} - - )} - -
-
- -
-
-

- {displayAction.title} -

- {displayAction.description.trim() && ( -

- {displayAction.description} -

- )} -
- -
- -
-

{t('pledges.card.pledged')}

-

- {formatPledgeAmount(action.bounty, btcPrice)} -

-
- -
- {countryLabel && ( - - - {countryLabel} - - )} - {deadline && ( - - - {deadline.label} - - )} -
- -
-
- }} - /> -
- {translateAction} -
-
- - - ); -} - // ───────────────────────────────────────────────────────────────────────────── // Page // ───────────────────────────────────────────────────────────────────────────── -type SortOption = 'recent' | 'bounty' | 'deadline'; - export default function ActionsPage() { const { t } = useTranslation(); const { config } = useAppContext(); @@ -299,14 +214,117 @@ export default function ActionsPage() { const navigate = useNavigate(); const [selectedCountry, setSelectedCountry] = useState(undefined); - const [sortBy, setSortBy] = useState('recent'); - const [headerCountryPickerOpen, setHeaderCountryPickerOpen] = useState(false); + + // 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]; + }, + }); + + // Pledges now ride the same `agora.moderation` namespace as campaigns + // and organizations (two-axis: hide + featured). Filter hidden pledges + // out of search results unless the moderator opts in via the + // Show-hidden switch. Computed here so the search hook stays + // kind-agnostic. + const { data: pledgeModeration, isReady: pledgeModerationReady } = usePledgeModeration(); + 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 = `36639:${a.pubkey}:${a.id}`; + if (hiddenCoords.has(coord)) { + hidden += 1; + if (showHidden) visible.push(a); + } else { + visible.push(a); + } + } + return { searchHits: visible, searchHiddenCount: hidden }; + }, [searchHitsRaw, pledgeModeration, showHidden]); const { data: actions, isLoading: actionsLoading } = useActions({ countryCode: selectedCountry, limit: 300, }); + // Moderator gate. Reuses the campaign moderator pack (Team Soapbox) — + // the pledge moderation namespace rides the same signer set as the + // campaign and group surfaces. Drives the Pending and Hidden review + // sections at the bottom of the page. + const { data: moderators } = useCampaignModerators(); + const isMod = !!user && !!moderators && moderators.includes(user.pubkey); + + // For moderators we pull the full (unfiltered-by-country) pledge + // stream so the review queue isn't blinkered to whatever country the + // viewer happens to have selected. Same `agora-action` t-tag filter + // as the public list, just wider. Skipped entirely for non-mods so we + // don't pay the round-trip cost for everyone. + const { data: allPledgesForMods, isLoading: allPledgesLoading } = useActions({ + limit: 300, + enabled: isMod, + }); + + // Pending (mod-only): pledges that haven't been featured or hidden. + // Mirrors the campaign/group "needs review" semantics — pledges have + // a two-axis model, so "pending" means "no curation decision yet". + // Gated on `pledgeModerationReady` so a cold load doesn't briefly + // dump every pledge into "Pending" before label folding completes. + const pendingPledges = useMemo(() => { + if (!isMod || !pledgeModerationReady) return []; + return (allPledgesForMods ?? []).filter((p) => { + const coord = `36639:${p.pubkey}:${p.id}`; + return !pledgeModeration.featuredCoords.has(coord) + && !pledgeModeration.hiddenCoords.has(coord); + }); + }, [isMod, pledgeModerationReady, pledgeModeration, allPledgesForMods]); + + // Hidden (mod-only): pledges where the latest hide-axis label is `hidden`. + const hiddenPledges = useMemo(() => { + if (!isMod || !pledgeModerationReady) return []; + return (allPledgesForMods ?? []).filter((p) => { + const coord = `36639:${p.pubkey}:${p.id}`; + return pledgeModeration.hiddenCoords.has(coord); + }); + }, [isMod, pledgeModerationReady, pledgeModeration, allPledgesForMods]); + // 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. @@ -314,22 +332,6 @@ export default function ActionsPage() { ? `/pledges/new?country=${encodeURIComponent(selectedCountry)}` : '/pledges/new'; - const allCountries = useMemo(() => getAllCountries(), []); - - const countryOptions = useMemo(() => { - const options: Array<{ value: string; label: string; flag: string }> = [ - { value: 'global', label: t('pledges.list.global'), flag: '🌍' }, - ]; - allCountries.forEach((country) => { - options.push({ - value: country.code, - label: country.name, - flag: countryCodeToFlag(country.code), - }); - }); - return options; - }, [allCountries, t]); - const selectedCountryName = selectedCountry ? getGeoDisplayName(selectedCountry) : t('pledges.list.global'); @@ -356,23 +358,13 @@ export default function ActionsPage() { }) ?? []; const pastUnsorted = actions?.filter((c) => c.deadline && c.deadline <= now) ?? []; - const sortActions = (cs: Action[]) => { - const sorted = [...cs]; - const isPastOnlyList = sorted.length > 0 && sorted.every((c) => !!c.deadline && c.deadline <= now); - switch (sortBy) { - case 'recent': - return sorted.sort((a, b) => b.createdAt - a.createdAt); - case 'bounty': - return sorted.sort((a, b) => b.bounty - a.bounty); - case 'deadline': - return sorted.sort((a, b) => { - if (!a.deadline) return 1; - if (!b.deadline) return -1; - // Upcoming/current: soonest deadline first. Past: most recently ended first. - return isPastOnlyList ? b.deadline - a.deadline : a.deadline - b.deadline; - }); - } - }; + // Within each lifecycle section we sort newest-first by `createdAt`. + // The page used to expose Recent / Bounty / Deadline as a dropdown, + // but those modes were superseded by the shared DiscoverySearchToolbar + // (Default / Top / New) which drives a relay-ranked search view — + // keeping a parallel client-side sort on top of the curated layout + // duplicated the affordance for no real gain. + const sortActions = (cs: Action[]) => [...cs].sort((a, b) => b.createdAt - a.createdAt); const currentActions = sortActions(currentUnsorted); const upcomingActions = sortActions(upcomingUnsorted); @@ -388,7 +380,6 @@ export default function ActionsPage() { const visiblePast = showAllPast ? pastActions : pastActions.slice(0, DEFAULT_VISIBLE); const hasCurrent = currentActions.length > 0; const hasUpcoming = upcomingActions.length > 0; - const isOnlyPastView = !hasCurrent && !hasUpcoming && pastActions.length > 0; const primarySectionTitle = hasCurrent ? t('pledges.list.sectionActive') : hasUpcoming @@ -396,77 +387,23 @@ export default function ActionsPage() { : pastActions.length > 0 ? t('pledges.list.sectionPast') : t('pledges.list.sectionDefault'); - const deadlineSortLabel = isOnlyPastView - ? t('pledges.list.sortDeadlinePast') - : t('pledges.list.sortDeadline'); const headerControls = ( -
- - - - - -
{t('pledges.list.sortBy')}
- setSortBy('recent')} className={sortBy === 'recent' ? 'bg-primary/10' : ''}> - {t('pledges.list.sortRecent')} - {sortBy === 'recent' && } - - setSortBy('bounty')} className={sortBy === 'bounty' ? 'bg-primary/10' : ''}> - {t('pledges.list.sortBounty')} - {sortBy === 'bounty' && } - - setSortBy('deadline')} className={sortBy === 'deadline' ? 'bg-primary/10' : ''}> - {deadlineSortLabel} - {sortBy === 'deadline' && } - -
-
- - - - - - - - - - {t('pledges.list.noResults')} - - {countryOptions.map((option) => ( - { - setSelectedCountry(option.value === 'global' ? undefined : option.value); - setHeaderCountryPickerOpen(false); - }} - className="gap-2" - > - {option.flag} - {option.label} - - - ))} - - - - - -
+ ); return ( @@ -477,6 +414,83 @@ export default function ActionsPage() { onCreateAction={() => navigate(createActionHref)} /> + {isSearching ? ( +
+
+
+
+

+ {trimmedSearch + ? t('common.search') + : sortMode === 'top' + ? t('common.sortTop') + : t('common.sortNew')} +

+ {searchHits && ( +

+ {t('common.searchResultsCount', { count: searchHits.length })} +

+ )} +
+ {headerControls} +
+ + {isSearchFetching && !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 ? (
@@ -484,7 +498,7 @@ export default function ActionsPage() {
) : (actions && actions.length > 0) ? (
-
+

{primarySectionTitle}

@@ -556,7 +570,7 @@ export default function ActionsPage() {

) : ( <> -
+

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

@@ -579,7 +593,7 @@ export default function ActionsPage() {

{user && ( )} @@ -588,6 +602,95 @@ export default function ActionsPage() { )}
+ )} + + {/* Moderator-only review queues at the bottom of the page — + consistent with the campaigns and groups index pages so + reviewers see the same "Pending / Hidden" affordance + everywhere. Pulls the unfiltered-by-country pledge stream so + the queue isn't blinkered by the viewer's country filter. */} + {isMod && ( +
+ } + title={t('pledges.list.needsReview')} + description={t('pledges.list.needsReviewDesc', { appName: config.appName })} + count={pendingPledges.length} + isLoading={allPledgesLoading || !pledgeModerationReady} + emptyText={t('pledges.list.needsReviewEmpty')} + skeleton={ +
+ {Array.from({ length: 4 }).map((_, i) => )} +
+ } + > +
+ {pendingPledges.map((action) => ( + + + + + } + /> + ))} +
+
+ } + title={t('pledges.list.hidden')} + description={t('pledges.list.hiddenDesc')} + count={hiddenPledges.length} + isLoading={allPledgesLoading || !pledgeModerationReady} + emptyText={t('pledges.list.hiddenEmpty')} + skeleton={ +
+ {Array.from({ length: 4 }).map((_, i) => )} +
+ } + > +
+ {hiddenPledges.map((action) => ( + + + + + } + /> + ))} +
+
+
+ )} ); } @@ -685,15 +788,15 @@ function ActionsHero({ actionCount, canCreate, onCreateAction }: ActionsHeroProp only carries a single fact — the current pledge count — so it stays calm and the headline does the heavy lifting. */}
- - + + {actionCount.toLocaleString()} - + {t('pledges.list.openCount', { count: actionCount })}
@@ -717,7 +820,7 @@ function ActionsHero({ actionCount, canCreate, onCreateAction }: ActionsHeroProp )} aria-label={canCreate ? t('pledges.list.createPledge') : t('pledges.list.loginToCreate')} > - + {t('pledges.list.createPledge')}
@@ -736,21 +839,48 @@ function ActionSection({
{items.map((action) => ( - + + + + } /> ))}
{total > visible && ( -
-
diff --git a/src/pages/AllCampaignsPage.tsx b/src/pages/AllCampaignsPage.tsx index 728ae8e8..96ce64d4 100644 --- a/src/pages/AllCampaignsPage.tsx +++ b/src/pages/AllCampaignsPage.tsx @@ -2,31 +2,41 @@ import { useEffect, useMemo, useState } from 'react'; import { Link, useSearchParams } from 'react-router-dom'; import { useSeoMeta } from '@unhead/react'; import { useTranslation } from 'react-i18next'; -import { Clock, EyeOff, HandHeart, PlusCircle, Search, TrendingUp, X } from 'lucide-react'; +import { HandHeart, PlusCircle } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; -import { Input } from '@/components/ui/input'; -import { Switch } from '@/components/ui/switch'; -import { Label } from '@/components/ui/label'; import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard'; +import { DiscoverySearchToolbar } from '@/components/DiscoverySearchToolbar'; +import { HeroAtmosphere } from '@/components/HeroAtmosphere'; +import { HeroBanner } from '@/components/HeroBanner'; import { useAllCampaigns, type CampaignSort } from '@/hooks/useAllCampaigns'; import { useCampaignModeration } from '@/hooks/useCampaignModeration'; -import { useDebounce } from '@/hooks/useDebounce'; import { useAppContext } from '@/hooks/useAppContext'; +import { useDebounce } from '@/hooks/useDebounce'; +import { HOPE_PALETTE } from '@/lib/hopePalette'; import { cn } from '@/lib/utils'; +import type { Nip50Sort } from '@/hooks/useNip50Search'; import type { ParsedCampaign } from '@/lib/campaign'; -const SORT_OPTIONS: { value: CampaignSort; labelKey: string; icon: typeof TrendingUp }[] = [ - { value: 'top', labelKey: 'campaigns.all.sortTop', icon: TrendingUp }, - { value: 'none', labelKey: 'campaigns.all.sortNew', icon: Clock }, -]; - /** Type-guard for the `?sort=` URL param. Default is `top` (most-zapped). */ function parseSort(value: string | null): CampaignSort { return value === 'none' ? 'none' : 'top'; } +/** + * Map between the shared toolbar's sort vocabulary (`default` / `top` / + * `new`) and the `useAllCampaigns` hook's vocabulary (`top` / `none`). + * + * AllCampaignsPage doesn't have a curated/default layout — it's the + * "show me everything" page — so the toolbar's 'default' option falls + * through to 'top' here, the page's canonical ranked view. The legacy + * `none` value is preserved on the URL so existing share links keep + * working. + */ +const toToolbarSort = (s: CampaignSort): Nip50Sort => (s === 'none' ? 'new' : 'top'); +const toQuerySort = (s: Nip50Sort): CampaignSort => (s === 'new' ? 'none' : 'top'); + /** * Lists every campaign found on relays. Two sort modes: * @@ -37,25 +47,21 @@ function parseSort(value: string | null): CampaignSort { * summary, story, location, and category tags client-side. * * Hidden campaigns are excluded by default — flip the "Show hidden" - * toggle to include them. The toggle filters client-side after the - * campaign list resolves. + * toggle (inside the toolbar's filter popover) to include them. * * URL state: `?sort=none&q=`. Default values are stripped so the * canonical URL stays clean. Useful for sharing search results. */ export function AllCampaignsPage() { const { t } = useTranslation(); - // `noMaxWidth: true` drops MainLayout's default `sidebar:max-w-[600px]` - // cap so the campaign grid can spread to 3-4 columns on desktop, the - // same width budget the Pledge index gets. The inner `max-w-7xl` - // wrapper still keeps the content from sprawling on ultrawide - // monitors. const { config } = useAppContext(); - // URL state — sort and query live in the URL so results are shareable. + // URL state — sort, query, and country live in the URL so results are + // shareable. const [searchParams, setSearchParams] = 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. @@ -86,16 +92,27 @@ export function AllCampaignsPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [urlQuery]); - const setSort = (value: CampaignSort) => { + const setSortFromToolbar = (value: Nip50Sort) => { const next = new URLSearchParams(searchParams); - if (value === 'none') next.set('sort', 'none'); + const queryValue = toQuerySort(value); + if (queryValue === 'none') next.set('sort', 'none'); 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({ sort, search: debouncedSearch.trim(), + countryCode: urlCountry, limit: 200, }); const { data: moderation, isReady: moderationReady } = useCampaignModeration(); @@ -125,100 +142,50 @@ export function AllCampaignsPage() { const showSkeleton = isLoading || !moderationReady; const activeQuery = debouncedSearch.trim(); + const totalCampaigns = campaigns?.length ?? 0; return (
+ +
-
-

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

-
- - {/* Toolbar */} -
- {/* Search input */} -
- - setSearchInput(e.target.value)} - className="pl-9 pr-9" - /> - {searchInput && ( - - )} -
- - {/* Controls row */} -
-
- {/* Sort pills */} -
- {SORT_OPTIONS.map(({ value, labelKey, icon: Icon }) => ( - - ))} -
- - {/* Show-hidden switch */} -
- - -
-
- - + {/* Section heading — matches the `/pledges` and `/groups` pages + so the discovery surfaces all share the same large-bold + section header pattern. Title switches between Search / Top / + New based on toolbar state; tagline stays constant. + Search input + filter button cluster on the right, paired + with the heading on the left in a single row. */} +
+
+

+ {activeQuery + ? t('common.search') + : sort === 'top' + ? t('common.sortTop') + : t('common.sortNew')} +

+

+ {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 @@ -282,3 +249,112 @@ export function AllCampaignsPage() { } export default AllCampaignsPage; + +// ═══════════════════════════════════════════════════════════════════════════════ +// Hero +// ═══════════════════════════════════════════════════════════════════════════════ + +interface AllCampaignsHeroProps { + /** Total campaigns currently loaded — fuels the live stat pill. */ + campaignCount: number; +} + +/** + * Photo-led hero for the All-Campaigns page. Mirrors the Pledges / + * Communities hero recipe (rotating banner + atmospheric tint + scrims + * + overlay copy + glassy CTA) so the three discovery pages share the + * same visual shape. The campaign home (`/campaigns`) keeps its bespoke + * lightning-map hero as the brand-leading entry point; this surface + * gets the photo-led treatment because it's the actual browseable index. + */ +function AllCampaignsHero({ campaignCount }: AllCampaignsHeroProps) { + const { t } = useTranslation(); + // Cycle through warm hues on the same cadence as the banner so the + // whole hero feels like one coordinated moment instead of two + // unrelated rotations. + const [hueIndex, setHueIndex] = useState(0); + useEffect(() => { + const id = window.setInterval(() => { + setHueIndex((i) => (i + 1) % HOPE_PALETTE.length); + }, 9_000); + return () => window.clearInterval(id); + }, []); + const activeHue = HOPE_PALETTE[hueIndex]; + + return ( +
+ {/* Rotating photo banner — uses the default WLC photo set so this + page matches the Communities hero's photographic vocabulary. */} + + + {/* Warm atmosphere — campaigns-side hue, same as the Pledges hero. */} + + + {/* Top scrim so the headline stays legible across every photo. */} +
+ ); +} diff --git a/src/pages/CampaignDetailPage.tsx b/src/pages/CampaignDetailPage.tsx index eac19b75..85a6df67 100644 --- a/src/pages/CampaignDetailPage.tsx +++ b/src/pages/CampaignDetailPage.tsx @@ -17,7 +17,8 @@ import { Trash2, } from 'lucide-react'; -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { AuthorByline } from '@/components/AuthorByline'; +import { CommentsSection } from '@/components/CommentsSection'; import { CampaignWalletDonatePanel, } from '@/components/CampaignWalletDonatePanel'; @@ -39,11 +40,11 @@ import { DetailReplySkeleton, DetailStory } from '@/components/DetailStory'; import { InteractionsModal, type InteractionTab } from '@/components/InteractionsModal'; import { PostActionBar } from '@/components/PostActionBar'; import { PinnedCommentHeader } from '@/components/PinnedCommentHeader'; +import { PendingBadge } from '@/components/PendingBadge'; import { ReplyComposeModal } from '@/components/ReplyComposeModal'; import { NoteMoreMenu } from '@/components/NoteMoreMenu'; import { Progress } from '@/components/ui/progress'; import { ThreadedReplyList, type ReplyNode } from '@/components/ThreadedReplyList'; -import { useAuthor } from '@/hooks/useAuthor'; import { useAppContext } from '@/hooks/useAppContext'; import { useBtcPrice } from '@/hooks/useBtcPrice'; import { useCampaign } from '@/hooks/useCampaign'; @@ -53,7 +54,6 @@ import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useDeleteEvent } from '@/hooks/useDeleteEvent'; import { useEventStats } from '@/hooks/useTrending'; import { usePinnedEventComments } from '@/hooks/usePinnedEventComments'; -import { useProfileUrl } from '@/hooks/useProfileUrl'; import { useShareOrigin } from '@/hooks/useShareOrigin'; import { useToast } from '@/hooks/useToast'; import { useEventTranslation } from '@/hooks/useEventTranslation'; @@ -66,7 +66,6 @@ import { import { satsToUSDWhole } from '@/lib/bitcoin'; import { formatUsdGoal } from '@/lib/formatCampaignAmount'; import { formatNumber } from '@/lib/formatNumber'; -import { genUserName } from '@/lib/genUserName'; import { sanitizeUrl } from '@/lib/sanitizeUrl'; import { timeAgo } from '@/lib/timeAgo'; import NotFound from './NotFound'; @@ -129,7 +128,6 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) { const { config } = useAppContext(); const { user } = useCurrentUser(); const { data: btcPrice } = useBtcPrice(); - const author = useAuthor(campaign.pubkey); const { data: stats, isLoading: statsLoading } = useCampaignDonations(campaign); const navigate = useNavigate(); const { toast } = useToast(); @@ -246,14 +244,12 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) { }, [feedEventsById, pinnedEvents, pinnedIds]); const cover = sanitizeUrl(campaign.banner); - const creatorMetadata = author.data?.metadata; - const creatorName = - creatorMetadata?.display_name || creatorMetadata?.name || genUserName(campaign.pubkey); - const creatorProfileUrl = useProfileUrl(campaign.pubkey, creatorMetadata); const deadline = campaign.deadline ? formatDeadline(campaign.deadline, t) : null; const countryLabel = getCampaignCountryLabel(campaign); const raisedSats = stats?.totalSats ?? 0; + const pendingSats = stats?.pendingSats ?? 0; + const confirmedByTxid = stats?.confirmedByTxid; const isCreator = user?.pubkey === campaign.pubkey; const naddr = useMemo(() => encodeCampaignNaddr(campaign), [campaign]); @@ -347,9 +343,11 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) { )} -
-
-

- {t('campaignsDetail.commentsAndDonations')} -

-
+ + queryClient.invalidateQueries({ queryKey: ['nostr', 'comments'] })} + /> - {/* Muted surface wraps the composer and comment list. - The composer inside uses `bg-card` (one shade - lighter) so the input area reads as the focused - surface against this muted backdrop. `bg-muted/60` - softens the wrap so contained cards feel less - boxed-in. All internal dividers (top separator - plus the per-note `border-b`) are retinted to - `border-primary/20` so they read as a single - consistent edge color matching the composer's - bottom border. */} - {/* Muted surface wraps the composer and comment list. - The wrap carries the outer L/R/B border so the - rounded corners curve naturally without any 1px - gaps at the join. Per-article `border-b` divides - items. The composer's own border closes the top. */} -
- queryClient.invalidateQueries({ queryKey: ['nostr', 'comments'] })} - /> - - {commentsLoading && statsLoading && replyTree.length === 0 ? ( -
- {Array.from({ length: 3 }).map((_, i) => ( - - ))} -
- ) : replyTree.length > 0 ? ( -
- - event.pubkey === campaign.pubkey ? : null - } - renderItemHeader={(event) => ( - handleTogglePin(event)} - /> - )} - /> -
- ) : ( - - )} -
-
+ {commentsLoading && statsLoading && replyTree.length === 0 ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : replyTree.length > 0 ? ( +
+ + event.pubkey === campaign.pubkey ? : null + } + renderItemHeader={(event) => ( + handleTogglePin(event)} + /> + )} + /> +
+ ) : ( + + )} +
@@ -666,9 +639,7 @@ function CampaignerBadge() { interface CampaignHeroProps { campaign: ParsedCampaign; cover: string | undefined; - creatorName: string; - creatorProfileUrl: string; - creatorPicture: string | undefined; + creatorPubkey: string; deadline: { label: string; isPast: boolean } | null; countryLabel: string | undefined; isCreator: boolean; @@ -684,9 +655,7 @@ interface CampaignHeroProps { function CampaignHero({ campaign, cover, - creatorName, - creatorProfileUrl, - creatorPicture, + creatorPubkey, deadline, countryLabel, isCreator, @@ -699,7 +668,6 @@ function CampaignHero({ translateAction, }: CampaignHeroProps) { const { t } = useTranslation(); - const initials = creatorName.slice(0, 2).toUpperCase(); return ( // True full-bleed: no max-width wrapper, no horizontal padding, no @@ -796,25 +764,9 @@ function CampaignHero({

)} - e.stopPropagation()} - className="mt-5 inline-flex items-center gap-2.5 text-sm sm:text-base text-white/90 hover:text-white motion-safe:transition-colors group [text-shadow:none]" - > - - {creatorPicture && } - - {initials} - - - - }} - /> - - +
+ +
{(countryLabel || deadline) && (
@@ -885,10 +837,22 @@ function CampaignStory({ interface DonateColumnProps { campaign: ParsedCampaign; raisedSats: number; + /** + * Unconfirmed mempool delta in sats. Positive = inbound pending, negative + * = beneficiary spending. Displayed as a pending badge under the raised + * total when non-zero. + */ + pendingSats: number; statsLoading: boolean; btcPrice: number | undefined; /** Aggregated kind 8333 donation events, newest first. */ donations: NostrEvent[]; + /** + * `txid → confirmed` lookup from the verified receipts. Undefined while + * verification is still in flight. Donor rows whose txid maps to `false` + * render a pending badge in place of the relative timestamp. + */ + confirmedByTxid: Map | undefined; deadline: { label: string; isPast: boolean } | null; onShare: () => void; /** Scroll the inline activity list into view (donations + comments). */ @@ -898,9 +862,11 @@ interface DonateColumnProps { function DonateColumn({ campaign, raisedSats, + pendingSats, statsLoading, btcPrice, donations, + confirmedByTxid, deadline, onShare, onSeeAll, @@ -955,6 +921,12 @@ function DonateColumn({ {t('campaignsDetail.donationCount', { count: donations.length })}
) : null} + {pendingSats !== 0 && ( + + )}
{campaign.goalUsd && raisedUsd(raisedSats, btcPrice) !== undefined && ( {t('campaignsDetail.recentDonations')}
- +
diff --git a/src/pages/CampaignsPage.tsx b/src/pages/CampaignsPage.tsx index 3cfec189..b7b3fd62 100644 --- a/src/pages/CampaignsPage.tsx +++ b/src/pages/CampaignsPage.tsx @@ -1,25 +1,20 @@ -import { useMemo, useState } from 'react'; +import { useMemo } from 'react'; import { Link } from 'react-router-dom'; import { useSeoMeta } from '@unhead/react'; import { Trans, useTranslation } from 'react-i18next'; -import { ArrowRight, ChevronDown, EyeOff, HandHeart, Hourglass, PlusCircle, ShieldCheck } from 'lucide-react'; +import { ArrowRight, EyeOff, HandHeart, Hourglass, PlusCircle, ShieldCheck } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from '@/components/ui/collapsible'; import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard'; import { HeroLightningMap } from '@/components/HeroLightningMap'; -import { cn } from '@/lib/utils'; +import { ModeratorCollapsibleSection } from '@/components/moderation'; import { useCampaigns } from '@/hooks/useCampaigns'; import { useCampaignModeration } from '@/hooks/useCampaignModeration'; import { useCampaignModerators } from '@/hooks/useCampaignModerators'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAppContext } from '@/hooks/useAppContext'; -import { type ParsedCampaign } from '@/lib/campaign'; +import type { ParsedCampaign } from '@/lib/campaign'; /** Cap on how many featured campaigns we render in the home-page row. */ const MAX_FEATURED = 4; @@ -319,28 +314,40 @@ export function CampaignsPage() { {/* Moderator-only: campaigns awaiting an approval decision. */} {isMod && ( - } title={t('campaigns.home.pending')} description={t('campaigns.home.pendingDesc')} count={pendingCampaigns.length} - campaigns={pendingCampaigns} isLoading={allLoading} emptyText={t('campaigns.home.pendingEmpty')} - /> + skeleton={} + > +
+ {pendingCampaigns.map((campaign) => ( + + ))} +
+ )} {/* Moderator-only: campaigns currently hidden. */} {isMod && ( - } title={t('campaigns.home.hidden')} description={t('campaigns.home.hiddenDesc')} count={hiddenCampaigns.length} - campaigns={hiddenCampaigns} isLoading={allLoading} emptyText={t('campaigns.home.hiddenEmpty')} - /> + skeleton={} + > +
+ {hiddenCampaigns.map((campaign) => ( + + ))} +
+ )} {/* Non-mod creator: surface their own not-yet-approved campaigns @@ -369,77 +376,6 @@ export function CampaignsPage() { ); } -/** - * Collapsible moderator-only section listing campaigns in a particular - * moderation state (pending / hidden). Defaults to expanded when the list - * is short, collapsed otherwise. - */ -function ModeratorSection({ - icon, - title, - description, - count, - campaigns, - isLoading, - emptyText, -}: { - icon: React.ReactNode; - title: string; - description: string; - count: number; - campaigns: ParsedCampaign[]; - isLoading: boolean; - emptyText: string; -}) { - const [open, setOpen] = useState(count <= 6); - - return ( - -
- - - - - {isLoading && campaigns.length === 0 ? ( - - ) : campaigns.length === 0 ? ( - - - {emptyText} - - - ) : ( -
- {campaigns.map((campaign) => ( - - ))} -
- )} -
-
-
- ); -} - /** * Returns the grid class string for an adaptive featured row. * Mobile stays 1-column; desktop expands to 2/3/4 columns based on count. diff --git a/src/pages/CommunitiesPage.tsx b/src/pages/CommunitiesPage.tsx index 20ddac3c..b92ecbee 100644 --- a/src/pages/CommunitiesPage.tsx +++ b/src/pages/CommunitiesPage.tsx @@ -9,30 +9,28 @@ import { HeroBanner } from '@/components/HeroBanner'; import { LoginArea } from '@/components/auth/LoginArea'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from '@/components/ui/collapsible'; import { Skeleton } from '@/components/ui/skeleton'; import { CommunityGrid } from '@/components/discovery/CommunityGrid'; import { CommunityMiniCard, CommunityMiniCardSkeleton } from '@/components/discovery/CommunityMiniCard'; -import { SectionHeader } from '@/components/discovery/SectionHeader'; +import { DiscoverySearchToolbar } from '@/components/DiscoverySearchToolbar'; +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 { hasAgoraTag } from '@/lib/agoraNoteTags'; import { formatSatsShort } from '@/lib/formatCampaignAmount'; -import type { ParsedCommunity } from '@/lib/communityUtils'; +import { COMMUNITY_DEFINITION_KIND, parseCommunityEvent, type ParsedCommunity } from '@/lib/communityUtils'; // ─── Page ────────────────────────────────────────────────────────────────────── @@ -71,35 +69,190 @@ export function CommunitiesPage() { navigate('/groups/new'); }; + // On-page NIP-50 search + sort + show-hidden toolbar state. + // + // Default sort, empty query → curated "My groups" / "Featured" / + // moderator shelves below. + // 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). The + // ModeratorReviewSections subtree below still calls its own copy of + // the hook — query results are cached, so the second call is free. + const { data: orgModeration } = 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]); + + // Search + sort + show-hidden cluster reused on both branches' top + // section row. Factored out so the "My groups" heading (curated + // layout) and the "Search / Top / New" heading (search-active layout) + // both render the same affordance without duplicating the prop list. + const searchToolbar = ( + + ); + return (
-
-
- - -
+ {isSearching ? ( +
+
+
+
+

+ {trimmedSearch + ? t('common.search') + : sortMode === 'top' + ? t('common.sortTop') + : t('common.sortNew')} +

+ {searchHits && ( +

+ {t('common.searchResultsCount', { count: searchHits.length })} +

+ )} +
+ {searchToolbar} +
-
- - -
+ {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 })} +

+ )} +
+
+ )} +
+
+ ) : ( +
+
+
+
+

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

+

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

+
+ {searchToolbar} +
+ +
- {/* Moderator-only review sections: "Needs review" and "Hidden". - Organizations have a two-axis moderation model (featured / - hidden — no approval gate), so anything not yet labelled - simply lives in the public Featured-or-not space. The Needs - review queue surfaces unlabelled Agora-tagged orgs so - moderators can pick what to feature or hide. */} - {isMod && } -
+
+
+
+

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

+

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

+
+
+ +
+ + {/* Moderator-only review sections: "Needs review" and "Hidden". + Organizations have a two-axis moderation model (featured / + hidden — no approval gate), so anything not yet labelled + simply lives in the public Featured-or-not space. The Needs + review queue surfaces unlabelled Agora-tagged orgs so + moderators can pick what to feature or hide. */} + {isMod && } +
+ )}
); } @@ -158,106 +311,56 @@ function ModeratorReviewSections() { return ( <> - } title={t('groups.list.needsReview')} description={t('groups.list.needsReviewDesc', { appName: config.appName })} count={needsReviewOrgs.length} - orgs={needsReviewOrgs} isLoading={sectionsLoading} emptyText={t('groups.list.needsReviewEmpty')} - /> - + {Array.from({ length: 4 }).map((_, i) => ( + + ))} + + } + > + + {needsReviewOrgs.map((org) => ( + + ))} + + + } title={t('groups.list.hidden')} description={t('groups.list.hiddenDesc')} count={hiddenOrgs.length} - orgs={hiddenOrgs} isLoading={sectionsLoading} emptyText={t('groups.list.hiddenEmpty')} - /> + size="compact" + triggerPaddingClassName="pb-3" + skeleton={ + + {Array.from({ length: 4 }).map((_, i) => ( + + ))} + + } + > + + {hiddenOrgs.map((org) => ( + + ))} + + ); } -/** - * Collapsible moderator-only section listing organizations in a particular - * moderation state (pending / hidden). Defaults to expanded when the list - * is short (≤ 6 items), collapsed otherwise — same heuristic as the - * campaign version. - */ -function ModeratorOrgSection({ - icon, - title, - description, - count, - orgs, - isLoading, - emptyText, -}: { - icon: React.ReactNode; - title: string; - description: string; - count: number; - orgs: ParsedCommunity[]; - isLoading: boolean; - emptyText: string; -}) { - const [open, setOpen] = useState(count <= 6); - - return ( - -
- - - - - {isLoading && orgs.length === 0 ? ( - - {Array.from({ length: 4 }).map((_, i) => ( - - ))} - - ) : orgs.length === 0 ? ( -
- - - {emptyText} - - -
- ) : ( - - {orgs.map((org) => ( - - ))} - - )} -
-
-
- ); -} - // ═══════════════════════════════════════════════════════════════════════════════ // Hero // ═══════════════════════════════════════════════════════════════════════════════ @@ -373,7 +476,7 @@ function CommunitiesHero({ onCreateCommunity }: CommunitiesHeroProps) {