diff --git a/NIP.md b/NIP.md index 707b9678..1dbe7b6b 100644 --- a/NIP.md +++ b/NIP.md @@ -555,13 +555,14 @@ Community-scoped pledges inherit the community's moderation context. Clients ren "tags": [ ["d", "plant-a-tree-1729000000000"], ["title", "Plant a tree in your neighborhood"], - ["challenge-type", "photo"], ["bounty", "10000"], ["i", "iso3166:US"], ["A", "34550::"], ["K", "34550"], ["P", ""], ["t", "agora-action"], + ["t", "tree-planting"], + ["t", "local-action"], ["image", "https://example.com/cover.jpg"], ["start", "1729000000"], ["deadline", "1729604800"], @@ -576,13 +577,12 @@ Community-scoped pledges inherit the community's moderation context. Clients ren |------------------|----------|----------------------------------------------------------------------------------------------------------| | `d` | Yes | Unique identifier (typically slug + timestamp). Forms the addressable coordinate `36639::`. | | `title` | Yes | Short title shown on cards. | -| `challenge-type` | Yes | One of `photo`, `art`, `info`, `action`. Drives the display icon and submission expectations. | | `bounty` | Yes | Pledge amount in **sats**, as an unsigned integer string. Paid out via zaps or donation receipts to chosen submission(s). | | `i` | No | NIP-73 country identifier: `iso3166:XX` (preferred). Legacy `geo:XX` (length 6, country code only) is accepted as a read alias. Optionally combined with a `location` tag fallback. | | `A` | No | Community root coordinate for community-scoped pledges, e.g. `34550::`. | | `K` | No | Root kind hint for community-scoped pledges. Use `34550` when `A` points to a NIP-72 community. | -| `P` | No | Root author hint for community-scoped actions. Use the community definition author pubkey. | -| `t` | Yes | Discovery tag. Canonical write value is `agora-action`. Read aliases: `pathos-challenge`, `agora-challenge`. | +| `P` | No | Root author hint for community-scoped pledges. Use the community definition author pubkey. | +| `t` | Yes | Discovery and category tags. Canonical write value includes `agora-action`; additional `t` tags are optional hashtags/categories. Read aliases: `pathos-challenge`, `agora-challenge`. | | `image` | No | Cover image URL. | | `start` | No | Unix timestamp when the pledge becomes active. Defaults to `created_at`. | | `deadline` | No | Optional Unix timestamp when the pledge expires. Omit for open-ended pledges. | @@ -592,6 +592,10 @@ Community-scoped pledges inherit the community's moderation context. Clients ren Long-form description of the pledge. Plain text or light markdown. Clients render this as the pledge's body on the detail page. +### Categories + +Clients SHOULD use optional `t` tags for filtering and discovery instead of the deprecated `challenge-type` tag. Suggested user-entered tags include values like `beach-cleanup`, `protest-documentation`, `internet-blackout`, `legal-defense`, or `mutual-aid`. + ### Submissions Submissions are kind 1111 NIP-22 comments addressed to the pledge's coordinate (`["A", "36639::"]` and `["P", ""]`). Clients SHOULD: diff --git a/src/components/CreateActionDialog.tsx b/src/components/CreateActionDialog.tsx index b2926d27..414d8262 100644 --- a/src/components/CreateActionDialog.tsx +++ b/src/components/CreateActionDialog.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from 'react'; import { useQueryClient } from '@tanstack/react-query'; -import { Camera, Check, ChevronRight, Clock, Info, Loader2, Megaphone, Palette, Plus, Upload } from 'lucide-react'; +import { Check, ChevronRight, Clock, Loader2, Megaphone, Plus, Upload } from 'lucide-react'; import { TimezoneSwitcher } from '@/components/TimezoneSwitcher'; import { Button } from '@/components/ui/button'; @@ -10,7 +10,6 @@ import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } f import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useBtcPrice } from '@/hooks/useBtcPrice'; @@ -18,7 +17,6 @@ import { useIsMobile } from '@/hooks/useIsMobile'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useToast } from '@/hooks/useToast'; import { useUploadFile } from '@/hooks/useUploadFile'; -import type { Action } from '@/hooks/useActions'; import { createCountryIdentifier } from '@/lib/countryIdentifiers'; import { countryCodeToFlag, getAllCountries, getGeoDisplayName } from '@/lib/countries'; import { DEFAULT_ACTION_COVERS, DEFAULT_COVER_IMAGE } from '@/lib/defaultActionCovers'; @@ -35,7 +33,7 @@ interface CreateActionDialogProps { interface CreateActionFormState { title: string; description: string; - type: Action['type']; + tagInput: string; pledgeUsd: string; startDate: string; startTime: string; @@ -73,6 +71,22 @@ function parseCommunityAuthor(communityATag: string): string | undefined { return pubkey || undefined; } +function normalizePledgeTag(value: string): string { + return value.trim().replace(/^#+/, '').toLowerCase().replace(/\s+/g, '-'); +} + +function parsePledgeTagInput(value: string): string[] { + const seen = new Set(); + const tags: string[] = []; + for (const part of value.split(',')) { + const tag = normalizePledgeTag(part); + if (!tag || seen.has(tag)) continue; + seen.add(tag); + tags.push(tag); + } + return tags; +} + function CreateActionForm({ formData, setFormData, @@ -229,19 +243,11 @@ function CreateActionForm({
- - + + setFormData({ ...formData, tagInput: e.target.value })} />
- +
$ @@ -314,7 +320,7 @@ export function CreateActionDialog({ countryCode, communityATag, open, onOpenCha const [formData, setFormData] = useState({ title: '', description: '', - type: 'photo', + tagInput: '', pledgeUsd: '', startDate: '', startTime: '', @@ -334,14 +340,15 @@ export function CreateActionDialog({ countryCode, communityATag, open, onOpenCha const dTag = `${slug || 'pledge'}-${now}`; const pledgeSats = usdToSats(Number(formData.pledgeUsd.replace(/[, $]/g, '')), btcPrice); if (pledgeSats <= 0) throw new Error('Waiting for BTC/USD price to calculate the pledge amount.'); + const pledgeTags = parsePledgeTagInput(formData.tagInput); const tags: string[][] = [ ['d', dTag], ['title', formData.title], - ['challenge-type', formData.type], ['bounty', String(pledgeSats)], ['t', 'agora-action'], ['alt', `Agora pledge: ${formData.title}`], ]; + for (const tag of pledgeTags) tags.push(['t', tag]); if (formData.selectedCountry) tags.push(['i', createCountryIdentifier(formData.selectedCountry.toUpperCase())]); if (communityATag) { const communityAuthor = parseCommunityAuthor(communityATag); @@ -380,7 +387,7 @@ export function CreateActionDialog({ countryCode, communityATag, open, onOpenCha } setFormData({ - title: '', description: '', type: 'photo', pledgeUsd: '', + title: '', description: '', tagInput: '', pledgeUsd: '', startDate: '', startTime: '', deadline: '', time: '', coverImage: DEFAULT_COVER_IMAGE, selectedCountry: countryCode || '', diff --git a/src/hooks/useActions.ts b/src/hooks/useActions.ts index 52472cc7..584b4ffc 100644 --- a/src/hooks/useActions.ts +++ b/src/hooks/useActions.ts @@ -19,6 +19,7 @@ export interface Action { title: string; description: string; type: 'photo' | 'art' | 'info' | 'action'; + tags: string[]; /** Pledged amount in sats. Stored as the legacy `bounty` tag. */ bounty: number; countryCode?: string; @@ -41,6 +42,10 @@ export function parseAction(event: NostrEvent): Action | null { const title = event.tags.find(([name]) => name === 'title')?.[1]; const typeTag = event.tags.find(([name]) => name === 'challenge-type')?.[1]; const bountyTag = event.tags.find(([name]) => name === 'bounty')?.[1]; + const tags = event.tags + .filter(([name, value]) => name === 't' && !!value) + .map(([, value]) => value) + .filter((value) => !['agora-action', 'pathos-challenge', 'agora-challenge'].includes(value)); // Country code from #i tag (iso3166:XX or legacy geo:XX) or location tag fallback. const countryCode = (() => { @@ -70,11 +75,11 @@ export function parseAction(event: NostrEvent): Action | null { ? 'Invalid image URL in event (only https URLs are allowed).' : undefined; - if (!dTag || !title || !typeTag || !bountyTag) { + if (!dTag || !title || !bountyTag) { return null; } - const type = typeTag as Action['type']; + const type = (typeTag ?? 'action') as Action['type']; if (!['photo', 'art', 'info', 'action'].includes(type)) { return null; } @@ -101,6 +106,7 @@ export function parseAction(event: NostrEvent): Action | null { title, description: event.content, type, + tags, bounty: parseInt(bountyTag, 10) || 0, countryCode, startTime: startTimestamp, diff --git a/src/pages/ActionsPage.tsx b/src/pages/ActionsPage.tsx index 8462c02c..b0b43217 100644 --- a/src/pages/ActionsPage.tsx +++ b/src/pages/ActionsPage.tsx @@ -2,18 +2,17 @@ import { useEffect, useState, useMemo } from 'react'; import { useSeoMeta } from '@unhead/react'; import { Link as RouterLink, useNavigate } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; -import { format } from 'date-fns'; import { nip19 } from 'nostr-tools'; -import type { NostrMetadata } from '@nostrify/nostrify'; import { useActions, type Action } from '@/hooks/useActions'; import { useAuthor } from '@/hooks/useAuthor'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useBitcoinWallet } from '@/hooks/useBitcoinWallet'; +import { useComments } from '@/hooks/useComments'; import { useNostrPublish } from '@/hooks/useNostrPublish'; +import { useSubmissionZapTotals } from '@/hooks/useSubmissionZapTotals'; import { useToast } from '@/hooks/useToast'; import { getAllCountries, getGeoDisplayName, countryCodeToFlag } from '@/lib/countries'; -import { CountryFlag } from '@/components/CountryFlag'; import { getDisplayName } from '@/lib/genUserName'; import { DEFAULT_ACTION_COVERS, DEFAULT_COVER_IMAGE } from '@/lib/defaultActionCovers'; import { formatSats, satsToUSDWhole } from '@/lib/bitcoin'; @@ -23,9 +22,10 @@ import { cn } from '@/lib/utils'; import { HeroAtmosphere } from '@/components/HeroAtmosphere'; import { HeroBanner } from '@/components/HeroBanner'; -import { Card, CardContent } from '@/components/ui/card'; -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Badge } from '@/components/ui/badge'; +import { Card } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; +import { Progress } from '@/components/ui/progress'; import { Skeleton } from '@/components/ui/skeleton'; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, @@ -38,18 +38,11 @@ import { } from '@/components/ui/dropdown-menu'; import { - Camera, Palette, Info, Clock, Plus, ChevronRight, Loader2, + CalendarClock, Clock, HandHeart, MapPin, Plus, ChevronRight, Loader2, Link as LinkIcon, Check, MoreHorizontal, Trash2, ListFilter, - Calendar, DollarSign, Globe, Megaphone, + Calendar, DollarSign, Globe, Megaphone, Target, } from 'lucide-react'; -const ACTION_ICONS = { - photo: Camera, - art: Palette, - info: Info, - action: Megaphone, -} as const; - function formatPledgeAmount(sats: number, btcPrice: number | undefined): string { if (btcPrice) return satsToUSDWhole(sats, btcPrice); return `${formatSats(sats)} sats`; @@ -61,17 +54,15 @@ function formatPledgeAmount(sats: number, btcPrice: number | undefined): string function ActionSkeleton() { return ( - - - - + + +
+ -
- - -
- + + +
); } @@ -175,12 +166,58 @@ function ActionShareMenu({ action }: { action: Action }) { ); } +function formatDeadline(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 tagLabel(tag: string): string { + return tag.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); +} + +function PledgeProgress({ + pledgedSats, + fundedSats, + btcPrice, +}: { + pledgedSats: number; + fundedSats: number; + btcPrice: number | undefined; +}) { + const pct = pledgedSats > 0 ? Math.min(100, Math.round((fundedSats / pledgedSats) * 100)) : 0; + return ( +
+ +
+ + {formatPledgeAmount(fundedSats, btcPrice)} + funded + + of {formatPledgeAmount(pledgedSats, btcPrice)} pledged +
+
+ ); +} + function ActionCard({ action, isExpired, btcPrice }: { action: Action; isExpired?: boolean; btcPrice: number | undefined }) { const author = useAuthor(action.pubkey); - const metadata: NostrMetadata | undefined = author.data?.metadata; + const metadata = author.data?.metadata; const displayName = getDisplayName(metadata, action.pubkey); - const Icon = ACTION_ICONS[action.type]; const [imageLoadFailed, setImageLoadFailed] = useState(false); + const { data: commentsData } = useComments(action.event, 100); + const topLevel = useMemo(() => commentsData?.topLevelComments ?? [], [commentsData?.topLevelComments]); + const submissionIds = useMemo(() => topLevel.map((c) => c.id), [topLevel]); + const { data: zapTotals } = useSubmissionZapTotals(submissionIds); + const fundedSats = useMemo(() => { + const totals = zapTotals ?? new Map(); + return topLevel.reduce((sum, submission) => sum + (totals.get(submission.id) ?? 0), 0); + }, [topLevel, zapTotals]); const naddr = nip19.naddrEncode({ kind: 36639, @@ -194,92 +231,78 @@ function ActionCard({ action, isExpired, btcPrice }: { action: Action; isExpired ? action.image : DEFAULT_COVER_IMAGE; + const primaryTag = action.tags[0]; + const deadline = action.deadline ? formatDeadline(action.deadline) : null; + const countryLabel = action.countryCode ? getGeoDisplayName(action.countryCode) : undefined; + return ( - - - {/* Cover image — full bleed, modest height */} -
+ + +
{action.title} setImageLoadFailed(true)} loading="lazy" /> -
- - {/* Country flag — top-left, sitting on the image */} - {action.countryCode && ( - + {primaryTag && ( + + {tagLabel(primaryTag)} + )} - - {/* Deadline / expired pill — top-right */} - {isExpired ? ( -
- - Expired -
- ) : action.deadline ? ( -
- - {format(action.deadline * 1000, 'MMM d')} -
- ) : null} +
e.preventDefault()}> + {isExpired && ( + + Ended + + )} + +
- -
- -
-

- {action.title} -

-
-
e.preventDefault()}> - -
+
+
+

+ {action.title} +

+ {action.description.trim() && ( +

+ {action.description} +

+ )}
-

- {action.description} -

+
- {/* Meta row: pledge · author. No nested box. */} -
- - {formatPledgeAmount(action.bounty, btcPrice)} - {btcPrice && ~{formatSats(action.bounty)} sats} - · - - - - {displayName.slice(0, 2).toUpperCase()} - - - {displayName} + + +
+ + + {topLevel.length} {topLevel.length === 1 ? 'submission' : 'submissions'} + + {countryLabel && ( + + + {countryLabel} + + )} + {deadline && ( + + + {deadline.label} + + )}
- + +
+ by {displayName} +
+
); @@ -480,15 +503,20 @@ export default function ActionsPage() { onCreateAction={() => navigate(createActionHref)} /> -
+
{isLoading ? ( -
- {[...Array(4)].map((_, i) => )} +
+ {Array.from({ length: 8 }).map((_, i) => )}
) : (actions && actions.length > 0) ? (
-
-

{primarySectionTitle}

+
+
+

{primarySectionTitle}

+

+ Help fund the actions worth making. +

+
{headerControls}
@@ -554,28 +582,33 @@ export default function ActionsPage() {
) : ( <> -
-

Active pledges

+
+
+

Active pledges

+

+ Help fund the actions worth making. +

+
{headerControls}
-
-
- -
-
-

No pledges yet

-

+ +

+ +
+

No pledges yet

+

{selectedCountry ? `Be the first to create a pledge for ${selectedCountryName}.` : 'Be the first to create a pledge.'} -

+

+
+ {user && ( + + )}
- {user && ( - - )} -
+ )}
@@ -723,8 +756,8 @@ function ActionSection({ items: Action[]; total: number; visible: number; showAll: boolean; onToggle: () => void; isExpired: boolean; btcPrice: number | undefined; }) { return ( -
-
+
+
{items.map((action) => ( (); + const tags: string[] = []; + for (const part of value.split(',')) { + const tag = normalizePledgeTag(part); + if (!tag || seen.has(tag)) continue; + seen.add(tag); + tags.push(tag); + } + return tags; +} + export function CreateActionPage() { useLayoutOptions({ noMaxWidth: true, rightSidebar: null }); @@ -116,7 +112,7 @@ export function CreateActionPage() { const [title, setTitle] = useState(''); const [description, setDescription] = useState(''); - const [type, setType] = useState('action'); + const [tagInput, setTagInput] = useState(''); const [pledgeUsd, setPledgeUsd] = useState(''); const [startDate, setStartDate] = useState(''); const [startTime, setStartTime] = useState(''); @@ -124,9 +120,9 @@ export function CreateActionPage() { const [deadlineTime, setDeadlineTime] = useState(''); const [coverImage, setCoverImage] = useState(''); const [coverUploading, setCoverUploading] = useState(false); - const [selectedCountry, setSelectedCountry] = useState(pageCountryCode); + const [countryCode, setCountryCode] = useState(pageCountryCode); + const [countryQuery, setCountryQuery] = useState(pageCountryCode ? (COUNTRIES[pageCountryCode]?.name ?? pageCountryCode) : ''); const [timezone, setTimezone] = useState(browserTimezone); - const [countryPickerOpen, setCountryPickerOpen] = useState(false); const [formError, setFormError] = useState(''); const minDeadline = useMemo(() => getTodayDateInput(), []); @@ -142,31 +138,6 @@ export function CreateActionPage() { return usdToSats(n, btcPrice); }, [btcPrice, pledgeUsd]); - const allCountries = useMemo(() => getAllCountries(), []); - - const countryOptions = useMemo(() => { - const options: Array<{ value: string; label: string; flag: string }> = [ - { value: 'none', label: 'No country', flag: '🌍' }, - ]; - if (pageCountryCode) { - options.push({ - value: pageCountryCode, - label: getGeoDisplayName(pageCountryCode), - flag: countryCodeToFlag(pageCountryCode), - }); - } - allCountries.forEach((country) => { - if (country.code !== pageCountryCode) { - options.push({ - value: country.code, - label: country.name, - flag: countryCodeToFlag(country.code), - }); - } - }); - return options; - }, [pageCountryCode, allCountries]); - const submitMutation = useMutation({ mutationFn: async () => { if (!user) throw new Error('You must be logged in to create a pledge.'); @@ -193,18 +164,19 @@ export function CreateActionPage() { .replace(/[^a-z0-9]+/g, '-') .replace(/(^-|-$)/g, ''); const dTag = `${slug || 'pledge'}-${now}`; + const pledgeTags = parsePledgeTagInput(tagInput); const tags: string[][] = [ ['d', dTag], ['title', trimmedTitle], - ['challenge-type', type], ['bounty', String(pledgeSats)], ['t', 'agora-action'], ['alt', `Agora pledge: ${trimmedTitle}`], ]; + for (const tag of pledgeTags) tags.push(['t', tag]); - if (selectedCountry) { - tags.push(['i', createCountryIdentifier(selectedCountry.toUpperCase())]); + if (countryCode) { + tags.push(['i', createCountryIdentifier(countryCode.toUpperCase())]); } const trimmedCoverImage = coverImage.trim(); @@ -319,7 +291,7 @@ export function CreateActionPage() { {/* Title */} setTitle(e.target.value)} maxLength={200} @@ -327,43 +299,63 @@ export function CreateActionPage() { /> -
- {/* Type */} - - - + {/* Country */} + + { + setCountryQuery(value); + const selectedCountry = countryCode ? COUNTRIES[countryCode] : undefined; + if (selectedCountry && value !== selectedCountry.name && value.toUpperCase() !== countryCode) { + setCountryCode(''); + } + }} + onSelect={(country) => { + setCountryCode(country.code); + setCountryQuery(country.name); + }} + onClear={() => { + setCountryCode(''); + setCountryQuery(''); + }} + /> + + {/* Tags */} + + setTagInput(e.target.value)} + placeholder="beach-cleanup, protest-documentation, internet-blackout" + /> + + + {/* Cover image */} + + + + + {/* Description */} + +