Merge remote-tracking branch 'origin/main' into hdwallet
# Conflicts: # src/components/DonateDialog.tsx # src/hooks/useDonateCampaign.ts # src/hooks/useOnchainZaps.ts
This commit is contained in:
+31
-32
@@ -43,7 +43,6 @@ const hardcodedConfig: AppConfig = {
|
||||
shareOrigin: import.meta.env.VITE_SHARE_ORIGIN || undefined,
|
||||
homePage: "campaigns",
|
||||
client: "naddr1qvzqqqru7cpzq7q6z5ns2hm5c8msyv83qwzxpxe52j8c4d4q5m92wsp9sflelkh9qqzkg6t5w3hswjl4yp",
|
||||
magicMouse: false,
|
||||
theme: "system",
|
||||
useAppRelays: true,
|
||||
useUserRelays: false,
|
||||
@@ -57,48 +56,48 @@ const hardcodedConfig: AppConfig = {
|
||||
feedIncludeReposts: true,
|
||||
feedIncludeGenericReposts: true,
|
||||
feedIncludeReactions: false,
|
||||
feedIncludeZaps: false,
|
||||
feedIncludeZaps: true,
|
||||
feedIncludeArticles: true,
|
||||
showArticles: true,
|
||||
showHighlights: true,
|
||||
feedIncludeHighlights: false,
|
||||
feedIncludeHighlights: true,
|
||||
showEvents: true,
|
||||
feedIncludeEvents: true,
|
||||
showVines: true,
|
||||
showVines: false,
|
||||
showPolls: true,
|
||||
showTreasures: true,
|
||||
showTreasureGeocaches: true,
|
||||
showTreasureFoundLogs: true,
|
||||
showColors: true,
|
||||
showTreasures: false,
|
||||
showTreasureGeocaches: false,
|
||||
showTreasureFoundLogs: false,
|
||||
showColors: false,
|
||||
showPeopleLists: true,
|
||||
feedIncludeVines: true,
|
||||
feedIncludeVines: false,
|
||||
feedIncludePolls: true,
|
||||
feedIncludeTreasureGeocaches: true,
|
||||
feedIncludeTreasureFoundLogs: true,
|
||||
feedIncludeColors: true,
|
||||
feedIncludeTreasureGeocaches: false,
|
||||
feedIncludeTreasureFoundLogs: false,
|
||||
feedIncludeColors: false,
|
||||
feedIncludePeopleLists: true,
|
||||
showDecks: true,
|
||||
feedIncludeDecks: true,
|
||||
showWebxdc: true,
|
||||
feedIncludeWebxdc: true,
|
||||
showDecks: false,
|
||||
feedIncludeDecks: false,
|
||||
showWebxdc: false,
|
||||
feedIncludeWebxdc: false,
|
||||
showPhotos: true,
|
||||
feedIncludePhotos: true,
|
||||
showVideos: true,
|
||||
feedIncludeNormalVideos: true,
|
||||
feedIncludeShortVideos: true,
|
||||
feedIncludeVoiceMessages: true,
|
||||
showEmojiPacks: true,
|
||||
feedIncludeEmojiPacks: true,
|
||||
showCustomEmojis: true,
|
||||
showUserStatuses: true,
|
||||
showMusic: true,
|
||||
feedIncludeMusicTracks: true,
|
||||
feedIncludeMusicPlaylists: true,
|
||||
showPodcasts: true,
|
||||
feedIncludePodcastEpisodes: true,
|
||||
feedIncludePodcastTrailers: true,
|
||||
showDevelopment: true,
|
||||
feedIncludeDevelopment: true,
|
||||
showEmojiPacks: false,
|
||||
feedIncludeEmojiPacks: false,
|
||||
showCustomEmojis: false,
|
||||
showUserStatuses: false,
|
||||
showMusic: false,
|
||||
feedIncludeMusicTracks: false,
|
||||
feedIncludeMusicPlaylists: false,
|
||||
showPodcasts: false,
|
||||
feedIncludePodcastEpisodes: false,
|
||||
feedIncludePodcastTrailers: false,
|
||||
showDevelopment: false,
|
||||
feedIncludeDevelopment: false,
|
||||
showCommunities: true,
|
||||
feedIncludeCommunities: true,
|
||||
showBadges: true,
|
||||
@@ -109,10 +108,10 @@ const hardcodedConfig: AppConfig = {
|
||||
feedIncludeProfileBadges: true,
|
||||
feedIncludeBadgeAwards: true,
|
||||
feedIncludeVanish: true,
|
||||
showBirdstar: true,
|
||||
feedIncludeBirdDetections: true,
|
||||
feedIncludeBirdex: true,
|
||||
feedIncludeConstellations: true,
|
||||
showBirdstar: false,
|
||||
feedIncludeBirdDetections: false,
|
||||
feedIncludeBirdex: false,
|
||||
feedIncludeConstellations: false,
|
||||
followsFeedShowReplies: true,
|
||||
},
|
||||
sidebarOrder: [
|
||||
|
||||
+1
-9
@@ -25,7 +25,7 @@ const ReplyComposeModal = lazy(() => import("@/components/ReplyComposeModal").th
|
||||
const EmojiPackDialog = lazy(() => import("@/components/EmojiPackDialog").then(m => ({ default: m.EmojiPackDialog })));
|
||||
|
||||
// Campaigns: home + create. (Campaign detail is dispatched from NIP19Page
|
||||
// when an naddr resolves to kind 30223.) The campaigns list IS the homepage;
|
||||
// when an naddr resolves to kind 33863.) The campaigns list IS the homepage;
|
||||
// the configurable HomePage delegation from the Twitter-era app is gone.
|
||||
const CampaignsPage = lazy(() => import("./pages/CampaignsPage").then(m => ({ default: m.CampaignsPage })));
|
||||
const CreateCampaignPage = lazy(() => import("./pages/CreateCampaignPage").then(m => ({ default: m.CreateCampaignPage })));
|
||||
@@ -47,10 +47,8 @@ const ChangelogPage = lazy(() => import("./pages/ChangelogPage").then(m => ({ de
|
||||
const CommunitiesPage = lazy(() => import("./pages/CommunitiesPage").then(m => ({ default: m.CommunitiesPage })));
|
||||
const CreateCommunityPage = lazy(() => import("./pages/CreateCommunityPage").then(m => ({ default: m.CreateCommunityPage })));
|
||||
const CreateEventPage = lazy(() => import("./pages/CreateEventPage").then(m => ({ default: m.CreateEventPage })));
|
||||
const ContentPage = lazy(() => import("./pages/ContentPage").then(m => ({ default: m.ContentPage })));
|
||||
const ContentSettingsPage = lazy(() => import("./pages/ContentSettingsPage").then(m => ({ default: m.ContentSettingsPage })));
|
||||
const CSAEPolicyPage = lazy(() => import("./pages/CSAEPolicyPage").then(m => ({ default: m.CSAEPolicyPage })));
|
||||
const DiscoverPage = lazy(() => import("./pages/DiscoverPage").then(m => ({ default: m.DiscoverPage })));
|
||||
const DomainFeedPage = lazy(() => import("./pages/DomainFeedPage").then(m => ({ default: m.DomainFeedPage })));
|
||||
const EventsFeedPage = lazy(() => import("./pages/EventsFeedPage").then(m => ({ default: m.EventsFeedPage })));
|
||||
const ExternalContentPage = lazy(() => import("./pages/ExternalContentPage").then(m => ({ default: m.ExternalContentPage })));
|
||||
@@ -63,7 +61,6 @@ const KindFeedPage = lazy(() => import("./pages/KindFeedPage").then(m => ({ defa
|
||||
const LetterComposePage = lazy(() => import("./pages/LetterComposePage").then(m => ({ default: m.LetterComposePage })));
|
||||
const LetterPreferencesPage = lazy(() => import("./pages/LetterPreferencesPage").then(m => ({ default: m.LetterPreferencesPage })));
|
||||
const LettersPage = lazy(() => import("./pages/LettersPage").then(m => ({ default: m.LettersPage })));
|
||||
const MagicSettingsPage = lazy(() => import("./pages/MagicSettingsPage").then(m => ({ default: m.MagicSettingsPage })));
|
||||
const MusicPage = lazy(() => import("./pages/MusicPage").then(m => ({ default: m.MusicPage })));
|
||||
const NetworkSettingsPage = lazy(() => import("./pages/NetworkSettingsPage").then(m => ({ default: m.NetworkSettingsPage })));
|
||||
const NIP19Page = lazy(() => import("./pages/NIP19Page").then(m => ({ default: m.NIP19Page })));
|
||||
@@ -93,7 +90,6 @@ const WikipediaPage = lazy(() => import("./pages/WikipediaPage").then(m => ({ de
|
||||
const WorldPage = lazy(() => import("./pages/WorldPage").then(m => ({ default: m.WorldPage })));
|
||||
const FollowPage = lazy(() => import("./pages/FollowPage").then(m => ({ default: m.FollowPage })));
|
||||
const ReceivePage = lazy(() => import("./pages/ReceivePage").then(m => ({ default: m.ReceivePage })));
|
||||
const ClaimPage = lazy(() => import("./pages/ClaimPage").then(m => ({ default: m.ClaimPage })));
|
||||
const RemoteLoginSuccessPage = lazy(() => import("./pages/RemoteLoginSuccessPage").then(m => ({ default: m.RemoteLoginSuccessPage })));
|
||||
|
||||
const pollsDef = getExtraKindDef("polls")!;
|
||||
@@ -167,12 +163,10 @@ export function AppRouter() {
|
||||
{/* Auto-follow deep link: fullscreen immersive (no sidebars/nav) */}
|
||||
<Route path="/follow/:npub" element={<FollowPage />} />
|
||||
<Route path="/receive" element={<ReceivePage />} />
|
||||
<Route path="/claim" element={<ClaimPage />} />
|
||||
|
||||
{/* All routes share the persistent FundraiserLayout (top nav + footer) */}
|
||||
<Route element={<FundraiserLayout />}>
|
||||
<Route path="/" element={<CampaignsPage />} />
|
||||
<Route path="/discover" element={<DiscoverPage />} />
|
||||
<Route path="/feed" element={<Index />} />
|
||||
<Route path="/campaigns" element={<Navigate to="/" replace />} />
|
||||
<Route path="/campaigns/new" element={<CreateCampaignPage />} />
|
||||
@@ -189,7 +183,6 @@ export function AppRouter() {
|
||||
<Route path="/settings/appearance" element={<AppearanceSettingsPage />} />
|
||||
<Route path="/settings/profile" element={<ProfileSettings />} />
|
||||
<Route path="/settings/feed" element={<ContentSettingsPage />} />
|
||||
<Route path="/settings/content" element={<ContentPage />} />
|
||||
<Route path="/settings/wallet" element={<WalletSettingsPage />} />
|
||||
<Route
|
||||
path="/settings/notifications"
|
||||
@@ -199,7 +192,6 @@ export function AppRouter() {
|
||||
path="/settings/advanced"
|
||||
element={<AdvancedSettingsPage />}
|
||||
/>
|
||||
<Route path="/settings/magic" element={<MagicSettingsPage />} />
|
||||
<Route path="/settings/network" element={<NetworkSettingsPage />} />
|
||||
<Route path="/lists" element={<UserListsPage />} />
|
||||
<Route path="/events" element={<EventsFeedPage />} />
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { AlertTriangle, Check, Copy, ExternalLink } from 'lucide-react';
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { BitcoinPublicDisclaimer } from '@/components/BitcoinPublicDisclaimer';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { QRCodeCanvas } from '@/components/ui/qrcode';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { nostrPubkeyToBitcoinAddress } from '@/lib/bitcoin';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
interface BeneficiaryDonatePanelProps {
|
||||
/** Hex pubkey of the beneficiary. */
|
||||
pubkey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline panel rendering a beneficiary's Taproot address as a scannable
|
||||
* BIP-21 QR code, a copyable string, and an "Open in wallet" button.
|
||||
*
|
||||
* Used both by `BeneficiaryDonateDialog` (modal context) and embedded
|
||||
* directly into the campaign page when there's a single beneficiary.
|
||||
*
|
||||
* Always shows the beneficiary's profile preview (avatar + name) as a
|
||||
* link to their Nostr profile — even when the surrounding page also
|
||||
* identifies a campaign organizer, the beneficiary is a distinct party
|
||||
* (the organizer may be running the campaign on someone else's behalf).
|
||||
*
|
||||
* Intentionally minimal: no amount input, no PSBT/in-app wallet flow —
|
||||
* that's `DonateDialog`'s job.
|
||||
*/
|
||||
export function BeneficiaryDonatePanel({
|
||||
pubkey,
|
||||
}: BeneficiaryDonatePanelProps) {
|
||||
const { toast } = useToast();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName =
|
||||
metadata?.display_name || metadata?.name || genUserName(pubkey);
|
||||
const picture = sanitizeUrl(metadata?.picture);
|
||||
const profileUrl = useProfileUrl(pubkey, metadata);
|
||||
|
||||
const address = useMemo(
|
||||
() => nostrPubkeyToBitcoinAddress(pubkey),
|
||||
[pubkey],
|
||||
);
|
||||
// BIP-21 URI: most wallets recognize the `bitcoin:` scheme when scanning.
|
||||
// No amount field — donor picks one in their wallet.
|
||||
const bip21 = address ? `bitcoin:${address}` : '';
|
||||
|
||||
const copyAddress = async () => {
|
||||
if (!address) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(address);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
toast({ title: 'Address copied' });
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Copy failed',
|
||||
description: 'Select and copy the address manually.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!address) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<AlertTriangle className="size-5 text-orange-500 shrink-0" />
|
||||
<span>We couldn't derive a Bitcoin address for this beneficiary.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Link
|
||||
to={profileUrl}
|
||||
className="flex items-center gap-3 rounded-md -mx-2 px-2 py-1.5 motion-safe:transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<Avatar className="size-10 ring-1 ring-border">
|
||||
{picture && <AvatarImage src={picture} alt="" />}
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{displayName.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate">{displayName}</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* QR code */}
|
||||
<div className="flex justify-center">
|
||||
<div className="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<QRCodeCanvas value={bip21} size={200} level="M" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Copyable address */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||
Bitcoin address
|
||||
</Label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyAddress}
|
||||
className="w-full flex items-center justify-between gap-2 rounded-lg border bg-muted/40 px-3 py-2.5 font-mono text-xs break-all text-left hover:bg-muted/60 motion-safe:transition-colors"
|
||||
aria-label="Copy Bitcoin address"
|
||||
>
|
||||
<span className="break-all">{address}</span>
|
||||
{copied ? (
|
||||
<Check className="size-4 text-green-500 shrink-0" />
|
||||
) : (
|
||||
<Copy className="size-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Privacy notice — informational only. Bitcoin is a public
|
||||
ledger, so the donation can be traced back to the donor's
|
||||
wallet. */}
|
||||
<BitcoinPublicDisclaimer
|
||||
tone="soft"
|
||||
includeCashOutAdvice={false}
|
||||
leadText="Donations are public and can be traced back to you."
|
||||
/>
|
||||
|
||||
{/* Open in wallet — relies on the `bitcoin:` URI handler. */}
|
||||
<Button asChild className="w-full">
|
||||
<a href={bip21}>
|
||||
<ExternalLink className="size-4 mr-1.5" />
|
||||
Open in wallet
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BeneficiaryDonateDialogProps {
|
||||
/** Hex pubkey of the beneficiary. */
|
||||
pubkey: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal wrapper around `BeneficiaryDonatePanel` for places that still want
|
||||
* the dialog UX (e.g. multi-beneficiary campaigns, where each row's
|
||||
* "Donate" button opens this dialog).
|
||||
*/
|
||||
export function BeneficiaryDonateDialog({
|
||||
pubkey,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: BeneficiaryDonateDialogProps) {
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName =
|
||||
metadata?.display_name || metadata?.name || genUserName(pubkey);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Donate to {displayName}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Scan the QR code or copy the Bitcoin address below to donate.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<BeneficiaryDonatePanel pubkey={pubkey} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -67,7 +67,12 @@ export function BitcoinPublicDisclaimer({
|
||||
role={isSoft ? 'note' : 'alert'}
|
||||
className={cn(
|
||||
isSoft
|
||||
? 'border-amber-300/60 bg-amber-50 text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100'
|
||||
// Use the project's foreground token (not raw amber-900) so
|
||||
// the text always contrasts against the page in both light
|
||||
// and dark themes. The faint amber tint keeps the
|
||||
// "informational notice" cue without leaning on hard-coded
|
||||
// amber text that disappears on the wrong backdrop.
|
||||
? 'border-amber-500/30 bg-amber-500/10 text-foreground'
|
||||
: 'border-destructive/50 bg-destructive/5 text-destructive dark:border-destructive',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useMemo, useCallback, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft,
|
||||
CalendarDays,
|
||||
ChevronLeft,
|
||||
MapPin,
|
||||
Clock,
|
||||
Users,
|
||||
@@ -18,10 +18,12 @@ import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { DetailCommentComposer } from '@/components/DetailCommentComposer';
|
||||
import { NoteContent } from '@/components/NoteContent';
|
||||
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
|
||||
import { PostActionBar } from '@/components/PostActionBar';
|
||||
import { PinnedCommentHeader } from '@/components/PinnedCommentHeader';
|
||||
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
|
||||
import { CreateCommunityEventDialog } from '@/components/CreateCommunityEventDialog';
|
||||
import { RSVPAvatars } from '@/components/RSVPAvatars';
|
||||
@@ -33,9 +35,11 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useEventRSVPs } from '@/hooks/useEventRSVPs';
|
||||
import { useMyRSVP } from '@/hooks/useMyRSVP';
|
||||
import { usePublishRSVP } from '@/hooks/usePublishRSVP';
|
||||
import { usePinnedEventComments } from '@/hooks/usePinnedEventComments';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { openUrl } from '@/lib/downloadFile';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -126,6 +130,26 @@ function formatDetailDate(event: NostrEvent): string {
|
||||
return startStr;
|
||||
}
|
||||
|
||||
function formatCalendarHeroDate(event: NostrEvent): string | null {
|
||||
const startRaw = getTag(event.tags, 'start');
|
||||
if (!startRaw) return null;
|
||||
|
||||
if (event.kind === 31922) {
|
||||
const [year, month, day] = startRaw.split('-').map(Number);
|
||||
const date = new Date(year, month - 1, day);
|
||||
if (isNaN(date.getTime())) return startRaw;
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
const timestamp = Number(startRaw);
|
||||
if (!Number.isFinite(timestamp)) return startRaw;
|
||||
const startTzid = getTag(event.tags, 'start_tzid');
|
||||
return new Date(timestamp * 1000).toLocaleDateString('en-US', {
|
||||
month: 'short', day: 'numeric', year: 'numeric',
|
||||
...(startTzid ? { timeZone: startTzid } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
const ROLE_ORDER = ['host', 'speaker', 'moderator', 'participant'];
|
||||
function roleSort(a: string, b: string): number {
|
||||
const ai = ROLE_ORDER.indexOf(a.toLowerCase());
|
||||
@@ -162,6 +186,15 @@ function PersonRow({ pubkey, label, size = 'md' }: { pubkey: string; label?: str
|
||||
);
|
||||
}
|
||||
|
||||
function EventDetailRow({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-xl bg-muted/40 px-3 py-3">
|
||||
<div className="mt-0.5 text-primary shrink-0">{icon}</div>
|
||||
<div className="min-w-0 text-sm leading-relaxed">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main Component ---
|
||||
|
||||
export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
|
||||
@@ -170,7 +203,7 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
|
||||
const { toast } = useToast();
|
||||
|
||||
const title = getTag(event.tags, 'title') ?? 'Untitled Event';
|
||||
const image = getTag(event.tags, 'image');
|
||||
const image = sanitizeUrl(getTag(event.tags, 'image'));
|
||||
const locationRaw = getTag(event.tags, 'location');
|
||||
const location = locationRaw ? parseLocation(locationRaw) : undefined;
|
||||
const summary = getTag(event.tags, 'summary');
|
||||
@@ -179,6 +212,7 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
|
||||
|
||||
const eventCoord = useMemo(() => getEventCoord(event), [event]);
|
||||
const dateStr = useMemo(() => formatDetailDate(event), [event]);
|
||||
const heroDate = useMemo(() => formatCalendarHeroDate(event), [event]);
|
||||
|
||||
// Participants grouped by role
|
||||
const participantsByRole = useMemo(() => {
|
||||
@@ -200,6 +234,12 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
|
||||
const myRsvp = useMyRSVP(eventCoord);
|
||||
const publishRSVP = usePublishRSVP();
|
||||
const { data: commentsData, isLoading: commentsLoading } = useComments(event, 500);
|
||||
const {
|
||||
pinnedEvents,
|
||||
isPinned,
|
||||
canManagePins,
|
||||
togglePin,
|
||||
} = usePinnedEventComments(eventCoord, event.pubkey);
|
||||
const [replyOpen, setReplyOpen] = useState(false);
|
||||
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
@@ -225,6 +265,11 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
|
||||
.map((comment) => buildNode(comment));
|
||||
}, [commentsData]);
|
||||
|
||||
const pinnedNodes = useMemo(
|
||||
() => pinnedEvents.map((event): ReplyNode => ({ event, children: [] })),
|
||||
[pinnedEvents],
|
||||
);
|
||||
|
||||
const handleRSVP = useCallback(async (status: 'accepted' | 'declined' | 'tentative') => {
|
||||
if (status === myRsvp.status) return;
|
||||
try {
|
||||
@@ -240,202 +285,339 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
|
||||
}, [eventCoord, event.pubkey, myRsvp.status, publishRSVP, toast]);
|
||||
|
||||
const showRSVP = !!user;
|
||||
const attendingCount = rsvps.accepted.length;
|
||||
const interestedCount = rsvps.tentative.length;
|
||||
const rsvpStatusLabel = myRsvp.status === 'accepted'
|
||||
? 'You are going'
|
||||
: myRsvp.status === 'tentative'
|
||||
? 'You are interested'
|
||||
: myRsvp.status === 'declined'
|
||||
? "You can't go"
|
||||
: 'Choose your RSVP';
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto pb-16">
|
||||
{/* ── Standard top bar ── */}
|
||||
<div className="flex items-center gap-4 px-4 pt-4 pb-5">
|
||||
<button
|
||||
onClick={() => window.history.length > 1 ? navigate(-1) : navigate('/')}
|
||||
className="p-1.5 -ml-1.5 rounded-full hover:bg-secondary/60 transition-colors"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<ArrowLeft className="size-5" />
|
||||
</button>
|
||||
<h1 className="text-xl font-bold flex-1">Event Details</h1>
|
||||
{canEdit && (
|
||||
<button
|
||||
className="p-2 rounded-full hover:bg-secondary/60 transition-colors"
|
||||
onClick={() => setEditOpen(true)}
|
||||
aria-label="Edit event"
|
||||
>
|
||||
<Pencil className="size-5" />
|
||||
</button>
|
||||
const eventDetailsCard = (
|
||||
<Card className="overflow-hidden">
|
||||
<CardContent className="p-5 space-y-5">
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Hosted by</div>
|
||||
<PersonRow pubkey={event.pubkey} size="sm" />
|
||||
</div>
|
||||
|
||||
{(event.content || summary) && (
|
||||
<div className="space-y-2 border-t border-border/60 pt-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Description</div>
|
||||
{event.content ? (
|
||||
<NoteContent event={event} className="text-sm leading-relaxed text-foreground" hideEmbedImages={!!image} disableEmbeds disableNoteEmbeds />
|
||||
) : (
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{summary}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Cover image ── */}
|
||||
{image ? (
|
||||
<div className="aspect-[2/1] w-full overflow-hidden">
|
||||
<img src={image} alt={title} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="aspect-[3/1] w-full bg-gradient-to-br from-primary/15 via-primary/5 to-transparent flex items-center justify-center">
|
||||
<CalendarDays className="size-20 text-primary/20" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Content ── */}
|
||||
<div className="px-5 mt-5 space-y-5">
|
||||
{/* Title */}
|
||||
<h2 className="text-2xl font-bold leading-tight tracking-tight">{title}</h2>
|
||||
{/* Organizer row */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<PersonRow pubkey={event.pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date & Location — sidebar-style pills */}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-4 px-3 py-3 rounded-full bg-background/85">
|
||||
<Clock className="size-5 text-primary shrink-0" />
|
||||
<span className="text-sm">{dateStr}</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<EventDetailRow icon={<Clock className="size-5" />}>
|
||||
{dateStr}
|
||||
</EventDetailRow>
|
||||
{location && (
|
||||
<div className="flex items-center gap-4 px-3 py-3 rounded-full bg-background/85">
|
||||
<MapPin className="size-5 text-primary shrink-0" />
|
||||
<span className="text-sm">{location}</span>
|
||||
</div>
|
||||
<EventDetailRow icon={<MapPin className="size-5" />}>
|
||||
{location}
|
||||
</EventDetailRow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hashtags */}
|
||||
{hashtags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{hashtags.map((tag) => (
|
||||
<Link key={tag} to={`/t/${tag}`}>
|
||||
<Badge variant="secondary" className="cursor-pointer hover:bg-secondary/80 text-xs px-2.5 py-0.5">
|
||||
#{tag}
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
{showRSVP && (
|
||||
<div className="space-y-3 border-t border-border/60 pt-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">RSVP</div>
|
||||
<span className="text-xs font-medium text-muted-foreground">{rsvpStatusLabel}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={myRsvp.status === 'accepted' ? 'default' : 'outline'}
|
||||
disabled={publishRSVP.isPending}
|
||||
className={cn('rounded-full px-2', myRsvp.status === 'accepted' && 'bg-green-600 hover:bg-green-700 text-white')}
|
||||
onClick={() => handleRSVP('accepted')}
|
||||
>
|
||||
<Check className="size-3.5 mr-1" />
|
||||
Going
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={myRsvp.status === 'tentative' ? 'default' : 'outline'}
|
||||
disabled={publishRSVP.isPending}
|
||||
className={cn('rounded-full px-2', myRsvp.status === 'tentative' && 'bg-amber-500 hover:bg-amber-600 text-white')}
|
||||
onClick={() => handleRSVP('tentative')}
|
||||
>
|
||||
<Star className="size-3.5 mr-1" />
|
||||
Interested
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={myRsvp.status === 'declined' ? 'default' : 'outline'}
|
||||
disabled={publishRSVP.isPending}
|
||||
className={cn('rounded-full px-2', myRsvp.status === 'declined' && 'bg-destructive hover:bg-destructive/90 text-destructive-foreground')}
|
||||
onClick={() => handleRSVP('declined')}
|
||||
>
|
||||
<XIcon className="size-3.5 mr-1" />
|
||||
Can't Go
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{(event.content || summary) && (
|
||||
<>
|
||||
<Separator />
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">About</h2>
|
||||
{event.content ? (
|
||||
<NoteContent event={event} className="text-sm leading-relaxed text-foreground" hideEmbedImages={!!image} />
|
||||
) : (
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{summary}</p>
|
||||
{rsvps.total > 0 && (
|
||||
<div className="space-y-3 border-t border-border/60 pt-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Attendees</div>
|
||||
<div className="space-y-3">
|
||||
{([
|
||||
['Going', rsvps.accepted, 'border-green-500/50 bg-green-500/5 text-green-600'],
|
||||
['Interested', rsvps.tentative, 'border-amber-500/50 bg-amber-500/5 text-amber-600'],
|
||||
["Can't Go", rsvps.declined, 'border-muted-foreground/30 bg-muted/30 text-muted-foreground'],
|
||||
] as const).map(([label, pks, cls]) => pks.length > 0 && (
|
||||
<div key={label} className="space-y-2">
|
||||
<Badge variant="outline" className={cn(cls, 'shrink-0 text-xs')}>{label} ({pks.length})</Badge>
|
||||
<RSVPAvatars pubkeys={pks} maxVisible={8} size="sm" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{links.length > 0 && (
|
||||
<div className="space-y-2 border-t border-border/60 pt-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Links</div>
|
||||
<div className="space-y-1">
|
||||
{links.map((url) => (
|
||||
<button
|
||||
key={url}
|
||||
type="button"
|
||||
onClick={() => void openUrl(url)}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<LinkIcon className="size-4 text-primary shrink-0" />
|
||||
<span className="truncate flex-1">{url.replace(/^https?:\/\//, '')}</span>
|
||||
<ExternalLink className="size-3.5 text-muted-foreground shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const participantsCard = participantsByRole.length > 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-5 space-y-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Participants</div>
|
||||
<div className="space-y-2">
|
||||
{participantsByRole.map(([role, pubkeys]) =>
|
||||
pubkeys.map((pk) => <PersonRow key={`${role}-${pk}`} pubkey={pk} label={role} size="sm" />),
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen pb-16">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 pt-4">
|
||||
<div className="relative aspect-[16/9] sm:aspect-[21/9] rounded-t-xl rounded-b-none overflow-hidden bg-gradient-to-br from-primary/30 via-primary/15 to-secondary">
|
||||
{image ? (
|
||||
<img src={image} alt="" className="absolute inset-0 size-full object-cover" />
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<CalendarDays className="size-16 sm:size-20 text-primary/40" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/25 to-black/45" />
|
||||
|
||||
<div className="absolute left-0 right-0 top-0 z-10 flex items-center justify-between gap-3 px-4 pt-4">
|
||||
<button
|
||||
onClick={() => window.history.length > 1 ? navigate(-1) : navigate('/')}
|
||||
className="p-2.5 -ml-2 rounded-full text-white/90 hover:bg-white/15 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/80 motion-safe:transition-colors"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<ChevronLeft className="size-6 drop-shadow-[0_1px_2px_rgba(0,0,0,0.85)]" />
|
||||
</button>
|
||||
{canEdit && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEditOpen(true)}
|
||||
className="rounded-full bg-transparent text-white/90 shadow-none hover:bg-white/15 hover:text-white focus-visible:ring-white/80"
|
||||
>
|
||||
<Pencil className="size-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 z-10 space-y-2 p-5 sm:p-6 [text-shadow:0_1px_4px_rgba(0,0,0,0.75),0_2px_10px_rgba(0,0,0,0.45)]">
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<h1 className="text-3xl sm:text-4xl font-bold leading-tight tracking-tight text-white">
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs sm:text-sm font-medium text-white/85">
|
||||
{heroDate && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<CalendarDays className="size-3.5 sm:size-4" />
|
||||
{heroDate}
|
||||
</span>
|
||||
)}
|
||||
{location && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<MapPin className="size-3.5 sm:size-4" />
|
||||
{location}
|
||||
</span>
|
||||
)}
|
||||
{attendingCount > 0 && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Users className="size-3.5 sm:size-4" />
|
||||
{attendingCount} attending
|
||||
</span>
|
||||
)}
|
||||
{interestedCount > 0 && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Users className="size-3.5 sm:size-4" />
|
||||
{interestedCount} interested
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{summary && (
|
||||
<p className="max-w-2xl text-base sm:text-lg text-white/90 line-clamp-3">
|
||||
{summary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
<div className="rounded-b-xl rounded-t-none bg-card border border-t-0 border-border/60 shadow-sm px-4 sm:px-5 py-3">
|
||||
<PostActionBar
|
||||
event={event}
|
||||
replyLabel="Comment"
|
||||
onReply={() => setReplyOpen(true)}
|
||||
onMore={() => setMoreMenuOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pinnedNodes.length > 0 && (
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 pt-6">
|
||||
<div className="rounded-2xl bg-card border border-border/60 overflow-hidden">
|
||||
<ThreadedReplyList
|
||||
roots={pinnedNodes}
|
||||
renderItemHeader={(event) => (
|
||||
<EventPinHeader
|
||||
isPinned={isPinned(event.id)}
|
||||
canManagePins={canManagePins}
|
||||
pinPending={togglePin.isPending}
|
||||
onTogglePin={() => handleTogglePin(event)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-6 lg:py-10">
|
||||
<div className="lg:hidden mb-6 space-y-4">
|
||||
{eventDetailsCard}
|
||||
{participantsCard}
|
||||
</div>
|
||||
|
||||
<div className="lg:flex lg:gap-8 lg:items-start">
|
||||
<div className="flex-1 min-w-0 space-y-8">
|
||||
<section className="space-y-5">
|
||||
{hashtags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{hashtags.map((tag) => (
|
||||
<Link key={tag} to={`/t/${tag}`}>
|
||||
<Badge variant="secondary" className="cursor-pointer hover:bg-secondary/80 text-xs px-2.5 py-0.5">
|
||||
#{tag}
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(event.content || summary) && (
|
||||
<article className="prose prose-neutral dark:prose-invert max-w-none">
|
||||
{event.content ? (
|
||||
<NoteContent event={event} hideEmbedImages={!!image} />
|
||||
) : (
|
||||
<p className="text-muted-foreground">{summary}</p>
|
||||
)}
|
||||
</article>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* External links */}
|
||||
{links.length > 0 && (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{links.map((url) => (
|
||||
<a
|
||||
key={url}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 px-3 py-3 rounded-full bg-background/85 hover:bg-secondary/60 transition-colors"
|
||||
>
|
||||
<LinkIcon className="size-5 text-primary shrink-0" />
|
||||
<span className="text-sm truncate flex-1">{url.replace(/^https?:\/\//, '')}</span>
|
||||
<ExternalLink className="size-4 text-muted-foreground shrink-0" />
|
||||
</a>
|
||||
))}
|
||||
<section id="event-comments" className="scroll-mt-20">
|
||||
<div className="flex items-baseline justify-between gap-3 mb-3 px-1">
|
||||
<h2 className="text-lg font-semibold tracking-tight">Comments</h2>
|
||||
{replyTree.length > 0 ? (
|
||||
<span className="text-sm text-muted-foreground tabular-nums">
|
||||
{replyTree.length.toLocaleString()} {replyTree.length === 1 ? 'comment' : 'comments'}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DetailCommentComposer event={event} className="mb-3" />
|
||||
|
||||
{commentsLoading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="rounded-2xl bg-card border border-border/60 px-4 py-3">
|
||||
<div className="flex gap-3">
|
||||
<Skeleton className="size-10 rounded-full shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : replyTree.length > 0 ? (
|
||||
<div className="rounded-2xl bg-card border border-border/60 overflow-hidden">
|
||||
<ThreadedReplyList
|
||||
roots={replyTree}
|
||||
renderItemHeader={(event) => (
|
||||
<EventPinHeader
|
||||
isPinned={isPinned(event.id)}
|
||||
canManagePins={canManagePins}
|
||||
pinPending={togglePin.isPending}
|
||||
onTogglePin={() => handleTogglePin(event)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReplyOpen(true)}
|
||||
className="block w-full rounded-2xl border border-dashed border-border/80 bg-card/50 px-6 py-10 text-center hover:bg-card hover:border-primary/40 transition-colors"
|
||||
>
|
||||
<p className="text-base font-medium text-foreground">No comments yet</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Be the first to comment.</p>
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Participants */}
|
||||
{participantsByRole.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
|
||||
<Users className="size-4" /> Participants
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{participantsByRole.map(([role, pubkeys]) =>
|
||||
pubkeys.map((pk) => <PersonRow key={pk} pubkey={pk} label={role} size="sm" />),
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Attendees */}
|
||||
{rsvps.total > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
|
||||
<Users className="size-4" /> Attendees
|
||||
</h2>
|
||||
<div className="space-y-2.5">
|
||||
{([
|
||||
['Going', rsvps.accepted, 'border-green-500/50 bg-green-500/5 text-green-600'],
|
||||
['Interested', rsvps.tentative, 'border-amber-500/50 bg-amber-500/5 text-amber-600'],
|
||||
["Can't Go", rsvps.declined, 'border-muted-foreground/30 bg-muted/30 text-muted-foreground'],
|
||||
] as const).map(([label, pks, cls]) => pks.length > 0 && (
|
||||
<div key={label} className="flex items-center gap-3">
|
||||
<Badge variant="outline" className={cn(cls, 'shrink-0 text-xs')}>{label} ({pks.length})</Badge>
|
||||
<RSVPAvatars pubkeys={pks} maxVisible={8} size="sm" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* RSVP section */}
|
||||
{showRSVP && (
|
||||
<>
|
||||
<Separator />
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
|
||||
<Check className="size-4" /> RSVP
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={myRsvp.status === 'accepted' ? 'default' : 'outline'}
|
||||
disabled={publishRSVP.isPending}
|
||||
className={cn('flex-1 rounded-full', myRsvp.status === 'accepted' && 'bg-green-600 hover:bg-green-700 text-white')}
|
||||
onClick={() => handleRSVP('accepted')}
|
||||
>
|
||||
<Check className="size-3.5 mr-1.5" /> Going
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={myRsvp.status === 'tentative' ? 'default' : 'outline'}
|
||||
disabled={publishRSVP.isPending}
|
||||
className={cn('flex-1 rounded-full', myRsvp.status === 'tentative' && 'bg-amber-500 hover:bg-amber-600 text-white')}
|
||||
onClick={() => handleRSVP('tentative')}
|
||||
>
|
||||
<Star className="size-3.5 mr-1.5" /> Interested
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={myRsvp.status === 'declined' ? 'default' : 'outline'}
|
||||
disabled={publishRSVP.isPending}
|
||||
className={cn('flex-1 rounded-full', myRsvp.status === 'declined' && 'bg-destructive hover:bg-destructive/90 text-destructive-foreground')}
|
||||
onClick={() => handleRSVP('declined')}
|
||||
>
|
||||
<XIcon className="size-3.5 mr-1.5" /> Can't Go
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
<PostActionBar
|
||||
event={event}
|
||||
replyLabel="Comments"
|
||||
onReply={() => setReplyOpen(true)}
|
||||
onMore={() => setMoreMenuOpen(true)}
|
||||
className="-mx-5 px-5"
|
||||
/>
|
||||
<aside className="hidden lg:block lg:w-[360px] lg:shrink-0 lg:self-start">
|
||||
<div className="lg:sticky lg:top-4 space-y-4">
|
||||
{eventDetailsCard}
|
||||
{participantsCard}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<NoteMoreMenu event={event} open={moreMenuOpen} onOpenChange={setMoreMenuOpen} />
|
||||
<ReplyComposeModal event={event} open={replyOpen} onOpenChange={setReplyOpen} />
|
||||
@@ -446,32 +628,40 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
|
||||
event={event}
|
||||
/>
|
||||
)}
|
||||
|
||||
<section>
|
||||
{commentsLoading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex gap-3">
|
||||
<Skeleton className="size-10 rounded-full shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : replyTree.length > 0 ? (
|
||||
<div className="-mx-5">
|
||||
<ThreadedReplyList roots={replyTree} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-8 text-center text-muted-foreground text-sm">
|
||||
No comments yet. Be the first to comment!
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
function handleTogglePin(event: NostrEvent) {
|
||||
const wasPinned = isPinned(event.id);
|
||||
togglePin.mutate(event.id, {
|
||||
onSuccess: () => {
|
||||
toast({ title: wasPinned ? 'Unpinned from event' : 'Pinned to event' });
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: 'Failed to update event pins', variant: 'destructive' });
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function EventPinHeader({
|
||||
isPinned,
|
||||
canManagePins,
|
||||
pinPending,
|
||||
onTogglePin,
|
||||
}: {
|
||||
isPinned: boolean;
|
||||
canManagePins: boolean;
|
||||
pinPending: boolean;
|
||||
onTogglePin: () => void;
|
||||
}) {
|
||||
return (
|
||||
<PinnedCommentHeader
|
||||
isPinned={isPinned}
|
||||
canManagePins={canManagePins}
|
||||
pinPending={pinPending}
|
||||
onTogglePin={onTogglePin}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { CalendarClock, HandHeart, MapPin, Target, Users, Archive } from 'lucide-react';
|
||||
import { CalendarClock, EyeOff, HandHeart, MapPin, ShieldCheck, Target } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card } from '@/components/ui/card';
|
||||
@@ -15,9 +16,8 @@ import {
|
||||
type ParsedCampaign,
|
||||
encodeCampaignNaddr,
|
||||
getCampaignCountryLabel,
|
||||
getCampaignPrimaryTagLabel,
|
||||
} from '@/lib/campaign';
|
||||
import { formatCampaignAmount } from '@/lib/formatCampaignAmount';
|
||||
import { formatCampaignAmount, formatUsdGoal, satsToUsd } from '@/lib/formatCampaignAmount';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -33,20 +33,32 @@ function formatDeadline(unixSeconds: number): { label: string; isPast: boolean }
|
||||
return { label: `${months} mo left`, isPast: false };
|
||||
}
|
||||
|
||||
/** Short helper rendered both inline (cards) and in the detail page. */
|
||||
/**
|
||||
* Short helper rendered both inline (cards) and in the detail page.
|
||||
*
|
||||
* Per NIP.md Kind 33863, the campaign **goal** is integer USD and the
|
||||
* **raised** total is the sum of verified sats. We render both in the
|
||||
* goal's unit (USD) for consistency, converting the sats total at view
|
||||
* time using the live BTC price. While the price is loading the raised
|
||||
* amount falls back to sats.
|
||||
*/
|
||||
export function CampaignProgress({
|
||||
raisedSats,
|
||||
goalSats,
|
||||
goalUsd,
|
||||
btcPrice,
|
||||
className,
|
||||
}: {
|
||||
raisedSats: number;
|
||||
goalSats?: number;
|
||||
goalUsd?: number;
|
||||
btcPrice?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const hasGoal = !!goalSats && goalSats > 0;
|
||||
const pct = hasGoal ? Math.min(100, Math.round((raisedSats / goalSats!) * 100)) : 0;
|
||||
const hasGoal = !!goalUsd && goalUsd > 0;
|
||||
const raisedUsd = satsToUsd(raisedSats, btcPrice);
|
||||
const pct = hasGoal && raisedUsd !== undefined
|
||||
? Math.min(100, Math.round((raisedUsd / goalUsd!) * 100))
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-1.5', className)}>
|
||||
{hasGoal && <Progress value={pct} className="h-2" />}
|
||||
@@ -56,32 +68,59 @@ export function CampaignProgress({
|
||||
{!hasGoal && <span className="ml-1 font-normal text-muted-foreground">raised</span>}
|
||||
</span>
|
||||
{hasGoal && (
|
||||
<span className="text-muted-foreground">of {formatCampaignAmount(goalSats!, btcPrice)} goal</span>
|
||||
<span className="text-muted-foreground">of {formatUsdGoal(goalUsd!)} goal</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces {@link CampaignProgress} for silent-payment campaigns, where
|
||||
* on-chain totals are unobservable by design. Shows the goal as a target
|
||||
* (if set) but no progress bar or raised amount.
|
||||
*/
|
||||
export function CampaignPrivateNotice({
|
||||
goalUsd,
|
||||
className,
|
||||
}: {
|
||||
goalUsd?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('space-y-1.5 text-sm', className)}>
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<ShieldCheck className="size-3.5" />
|
||||
<span>Private campaign — totals are not public</span>
|
||||
</div>
|
||||
{goalUsd && goalUsd > 0 && (
|
||||
<div className="text-xs text-muted-foreground">Target: {formatUsdGoal(goalUsd)}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CampaignCardProps {
|
||||
campaign: ParsedCampaign;
|
||||
/** Visual variant: `compact` for grid items, `featured` for hero placement. */
|
||||
variant?: 'compact' | 'featured';
|
||||
className?: string;
|
||||
/** Optional footer affordance rendered opposite the author line. */
|
||||
footerBadge?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single campaign as a clickable card. The whole card is a
|
||||
* `<Link>` to the campaign's naddr-based detail route.
|
||||
*/
|
||||
export function CampaignCard({ campaign, variant = 'compact', className }: CampaignCardProps) {
|
||||
export function CampaignCard({ campaign, variant = 'compact', className, footerBadge }: CampaignCardProps) {
|
||||
const author = useAuthor(campaign.pubkey);
|
||||
const { data: stats } = useCampaignDonations(campaign.aTag);
|
||||
const { data: stats } = useCampaignDonations(campaign);
|
||||
const { data: btcPrice } = useBtcPrice();
|
||||
const { data: moderation } = useCampaignModeration();
|
||||
|
||||
const naddr = useMemo(() => encodeCampaignNaddr(campaign), [campaign]);
|
||||
const cover = sanitizeUrl(campaign.image);
|
||||
const cover = sanitizeUrl(campaign.banner);
|
||||
const creatorName =
|
||||
author.data?.metadata?.display_name ||
|
||||
author.data?.metadata?.name ||
|
||||
@@ -89,7 +128,7 @@ export function CampaignCard({ campaign, variant = 'compact', className }: Campa
|
||||
const deadline = campaign.deadline ? formatDeadline(campaign.deadline) : null;
|
||||
const raisedSats = stats?.totalSats ?? 0;
|
||||
const countryLabel = getCampaignCountryLabel(campaign);
|
||||
const tagLabel = getCampaignPrimaryTagLabel(campaign);
|
||||
const isSilentPayment = campaign.wallet.mode === 'sp';
|
||||
|
||||
const isFeaturedVariant = variant === 'featured';
|
||||
const isApproved = moderation.approvedCoords.has(campaign.aTag);
|
||||
@@ -129,29 +168,22 @@ export function CampaignCard({ campaign, variant = 'compact', className }: Campa
|
||||
<HandHeart className="size-12 text-primary/40" />
|
||||
</div>
|
||||
)}
|
||||
{tagLabel && (
|
||||
{isSilentPayment && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="absolute top-3 left-3 backdrop-blur bg-background/80 border-border/40"
|
||||
>
|
||||
{tagLabel}
|
||||
<ShieldCheck className="size-3.5 mr-1" />
|
||||
Private
|
||||
</Badge>
|
||||
)}
|
||||
<div className="absolute top-3 right-3 flex items-center gap-2">
|
||||
{campaign.archived && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="backdrop-blur bg-background/85 border-border/40"
|
||||
>
|
||||
<Archive className="size-3.5 mr-1" />
|
||||
Archived
|
||||
</Badge>
|
||||
)}
|
||||
{isHidden && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="backdrop-blur bg-destructive/15 text-destructive border-destructive/30"
|
||||
>
|
||||
<EyeOff className="size-3.5 mr-1" />
|
||||
Hidden
|
||||
</Badge>
|
||||
)}
|
||||
@@ -190,16 +222,15 @@ export function CampaignCard({ campaign, variant = 'compact', className }: Campa
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<CampaignProgress raisedSats={raisedSats} goalSats={campaign.goalSats} btcPrice={btcPrice} />
|
||||
{isSilentPayment ? (
|
||||
<CampaignPrivateNotice goalUsd={campaign.goalUsd} />
|
||||
) : (
|
||||
<CampaignProgress raisedSats={raisedSats} goalUsd={campaign.goalUsd} btcPrice={btcPrice} />
|
||||
)}
|
||||
|
||||
{/* Meta row */}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground pt-1">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Users className="size-3.5" />
|
||||
{campaign.recipients.length}{' '}
|
||||
{campaign.recipients.length === 1 ? 'recipient' : 'recipients'}
|
||||
</span>
|
||||
{stats && stats.donorCount > 0 && (
|
||||
{!isSilentPayment && stats && stats.donorCount > 0 && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Target className="size-3.5" />
|
||||
{stats.donorCount} {stats.donorCount === 1 ? 'donor' : 'donors'}
|
||||
@@ -224,8 +255,11 @@ export function CampaignCard({ campaign, variant = 'compact', className }: Campa
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground border-t border-border/60 pt-3 truncate">
|
||||
by <span className="font-medium text-foreground">{creatorName}</span>
|
||||
<div className="flex items-center justify-between gap-3 border-t border-border/60 pt-3 text-xs text-muted-foreground">
|
||||
<div className="truncate">
|
||||
by <span className="font-medium text-foreground">{creatorName}</span>
|
||||
</div>
|
||||
{footerBadge && <div className="shrink-0">{footerBadge}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
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 `<Link>` 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 <CampaignCard campaign={campaign} className="mt-2" />;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useState } from 'react';
|
||||
import { AlertTriangle, Check, Copy, ExternalLink, ShieldCheck } from 'lucide-react';
|
||||
|
||||
import { BitcoinPublicDisclaimer } from '@/components/BitcoinPublicDisclaimer';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { QRCodeCanvas } from '@/components/ui/qrcode';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import type { CampaignWallet } from '@/lib/campaign';
|
||||
|
||||
interface CampaignWalletDonatePanelProps {
|
||||
/** Parsed wallet endpoint declared by the campaign's `w` tag. */
|
||||
wallet: CampaignWallet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline panel rendering the campaign's wallet endpoint as a scannable
|
||||
* QR code, a copyable string, and an "Open in wallet" button.
|
||||
*
|
||||
* Behavior forks on the wallet's mode:
|
||||
*
|
||||
* - **on-chain** (`bc1q…` / `bc1p…`) — BIP-21 QR with the address; a
|
||||
* public-ledger disclaimer reminds donors that the donation is
|
||||
* traceable.
|
||||
* - **sp** (`sp1…`) — raw silent-payment code QR; an "unlinkable by
|
||||
* design" notice replaces the traceability disclaimer.
|
||||
*
|
||||
* Intentionally minimal: no amount input, no PSBT/in-app wallet flow —
|
||||
* that's `DonateDialog`'s job. This panel is the always-available
|
||||
* "scan and pay from any wallet" affordance.
|
||||
*/
|
||||
export function CampaignWalletDonatePanel({
|
||||
wallet,
|
||||
}: CampaignWalletDonatePanelProps) {
|
||||
const { toast } = useToast();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Build the QR payload. For on-chain we use BIP-21 so any wallet that
|
||||
// recognizes the `bitcoin:` scheme can pre-fill the address; for SP we
|
||||
// use the BIP-21 `bitcoin:?sp=` extension. Donors pick the amount in
|
||||
// their wallet either way.
|
||||
const qrPayload = wallet.mode === 'onchain'
|
||||
? `bitcoin:${wallet.value}`
|
||||
: `bitcoin:?sp=${wallet.value}`;
|
||||
|
||||
const copyValue = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(wallet.value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
toast({ title: wallet.mode === 'sp' ? 'Silent-payment code copied' : 'Address copied' });
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Copy failed',
|
||||
description: 'Select and copy the value manually.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* QR — large, centered on a clean white tile with the Agora logo
|
||||
embedded in an orange circular badge in the center.
|
||||
Error-correction level H tolerates the centered occlusion
|
||||
(~30% of modules can be missing and the code still scans). */}
|
||||
<div className="flex justify-center">
|
||||
<div className="relative rounded-2xl bg-white p-4 shadow-sm">
|
||||
<QRCodeCanvas value={qrPayload} size={280} level="H" />
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 flex items-center justify-center pointer-events-none"
|
||||
>
|
||||
<div className="rounded-full bg-primary p-2 ring-[6px] ring-white">
|
||||
<img
|
||||
src="/logo.svg"
|
||||
alt=""
|
||||
className="size-16 object-contain brightness-0 invert"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Copyable value — single line, tap to copy. No wrapping
|
||||
container; sits flush with the rest of the column. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyValue}
|
||||
className="w-full flex items-center gap-2 rounded-lg border bg-muted/40 px-3 py-2.5 font-mono text-xs text-left hover:bg-muted/60 motion-safe:transition-colors"
|
||||
aria-label={wallet.mode === 'sp' ? 'Copy silent-payment code' : 'Copy Bitcoin address'}
|
||||
>
|
||||
<span className="flex-1 min-w-0 truncate" title={wallet.value}>
|
||||
{wallet.value}
|
||||
</span>
|
||||
{copied ? (
|
||||
<Check className="size-4 text-green-500 shrink-0" />
|
||||
) : (
|
||||
<Copy className="size-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{wallet.mode === 'onchain' ? (
|
||||
<BitcoinPublicDisclaimer
|
||||
tone="soft"
|
||||
includeCashOutAdvice={false}
|
||||
leadText="Donations are public and can be traced back to you."
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-start gap-2 rounded-lg bg-muted/40 px-3 py-2.5 text-xs text-muted-foreground">
|
||||
<ShieldCheck className="size-4 shrink-0 mt-0.5 text-primary" />
|
||||
<span>
|
||||
Silent-payment campaigns are unlinkable by design. Your donation
|
||||
cannot be tied to the campaign by anyone other than the organizer.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Open in wallet — relies on the `bitcoin:` URI handler. SP codes
|
||||
inside `bitcoin:?sp=` are still understood by BIP-352-aware
|
||||
wallets. Older wallets that don't know about SP will ignore
|
||||
the parameter and either refuse the link or show an error — at
|
||||
which point the donor falls back to copy/paste anyway. */}
|
||||
<Button asChild className="w-full text-white">
|
||||
<a href={qrPayload}>
|
||||
<ExternalLink className="size-4 mr-1.5" />
|
||||
Open in wallet
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback rendered when the wallet failed to parse. The detail page
|
||||
* should normally never reach this — `parseCampaign` rejects events
|
||||
* without a valid `w` tag — but a defensive surface is cheap and helps
|
||||
* debugging.
|
||||
*/
|
||||
export function CampaignWalletMissing() {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<AlertTriangle className="size-5 text-orange-500 shrink-0" />
|
||||
<span>This campaign is missing a valid wallet endpoint.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import type React from 'react';
|
||||
import { type ReactNode, useMemo, useState } from 'react';
|
||||
import { type ReactNode, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import {
|
||||
Award, BarChart3, Bird, BookOpen, Camera, Clapperboard, FileText, Film,
|
||||
GitBranch, GitPullRequest, Highlighter, Mail, MapPin, Megaphone, MessageSquare, Mic, Music,
|
||||
GitBranch, GitPullRequest, HandHeart, Highlighter, Mail, MapPin, Megaphone, MessageSquare, Mic, Music,
|
||||
Package, Palette, PartyPopper, Podcast, Radio, Rocket, SmilePlus,
|
||||
Stars, Target, Users, UserCheck, Vote, Zap,
|
||||
} from 'lucide-react';
|
||||
@@ -29,13 +29,11 @@ import { useLinkPreview } from '@/hooks/useLinkPreview';
|
||||
import { useScryfallCard } from '@/hooks/useScryfallCard';
|
||||
import { getDisplayName } from '@/lib/getDisplayName';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { getCountryInfo, getWikipediaTitle } from '@/lib/countries';
|
||||
import { getCountryInfo } from '@/lib/countries';
|
||||
import { CountryFlag } from '@/components/CountryFlag';
|
||||
import { customFlagAsset, hasCustomFlag } from '@/lib/customFlags';
|
||||
import { hasCustomFlag } from '@/lib/customFlags';
|
||||
import { useCountryFeed } from '@/contexts/CountryFeedContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useFlagPalette } from '@/lib/flagPalette';
|
||||
import { useWikipediaSummary } from '@/hooks/useWikipediaSummary';
|
||||
import { extractGathererCard, type GathererCard } from '@/lib/linkEmbed';
|
||||
import { cardPrimaryImage } from '@/lib/scryfall';
|
||||
|
||||
@@ -148,6 +146,7 @@ const KIND_LABELS: Record<number, string> = {
|
||||
34236: 'a divine',
|
||||
34550: 'an organization',
|
||||
9041: 'a goal',
|
||||
33863: 'a campaign',
|
||||
35128: 'an nsite',
|
||||
36639: 'a pledge',
|
||||
36787: 'a track',
|
||||
@@ -205,6 +204,7 @@ const KIND_ICONS: Partial<Record<number, React.ComponentType<{ className?: strin
|
||||
39089: PartyPopper,
|
||||
3367: Palette,
|
||||
9041: Target,
|
||||
33863: HandHeart,
|
||||
9735: Zap,
|
||||
9802: Highlighter,
|
||||
2473: Bird,
|
||||
@@ -247,6 +247,7 @@ const KIND_SUFFIXES: Partial<Record<number, string>> = {
|
||||
37516: 'treasure',
|
||||
30621: 'constellation',
|
||||
34550: 'organization',
|
||||
33863: 'campaign',
|
||||
30054: 'episode',
|
||||
30055: 'trailer',
|
||||
34139: 'playlist',
|
||||
@@ -912,10 +913,9 @@ function useCountryRootContext(event: NostrEvent): { iTag: string; code: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the given event is rendering with country chrome (pill + flag
|
||||
* backdrop) in the current context. Useful for sibling components that want
|
||||
* to coordinate styling — e.g. NoteCard switching its text to white when a
|
||||
* flag is showing through behind the author row.
|
||||
* Whether the given event is rendering with country chrome (the corner
|
||||
* flag pill) in the current context. Useful for sibling components that
|
||||
* want to coordinate styling.
|
||||
*/
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useIsCountryRooted(event: NostrEvent): boolean {
|
||||
@@ -939,100 +939,11 @@ export function CountryCommentPill({ event, className }: { event: NostrEvent; cl
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorative flag backdrop for country-rooted kind-1111 posts. Renders the
|
||||
* country's Wikipedia lead image (the flag, for country articles) faded
|
||||
* behind the post, echoing the country detail page's hero
|
||||
* (`CountryContentHeader` in `ExternalContentHeader.tsx`) but scaled down
|
||||
* to a card. Pairs with `CountryCommentPill`.
|
||||
*
|
||||
* Designed to be rendered as the first child of a `relative overflow-hidden`
|
||||
* parent. The wrapper is absolutely positioned at `z-0`; its foreground
|
||||
* siblings must declare `relative` (any positioned value works) so they
|
||||
* paint above the backdrop. Pointer events are disabled so the post body
|
||||
* stays fully interactive.
|
||||
*
|
||||
* The Wikipedia summary fetch is cached for 24 h across all cards
|
||||
* referencing the same country code, so a feed of N Venezuelan posts only
|
||||
* pays the network cost once.
|
||||
*
|
||||
* Visibility rules: see `useCountryRootContext` (identical to the pill).
|
||||
* Decorative flag backdrop for country-rooted kind-1111 posts has been
|
||||
* removed in favor of a cleaner card surface. The `CountryCommentPill`
|
||||
* in the upper-right of the header is now the sole country chrome for
|
||||
* world posts.
|
||||
*/
|
||||
export function CountryFlagBackdrop({ event }: { event: NostrEvent }) {
|
||||
const ctx = useCountryRootContext(event);
|
||||
const info = ctx ? getCountryInfo(ctx.code) : null;
|
||||
const wikiTitle = ctx ? getWikipediaTitle(ctx.code) : null;
|
||||
const { data: wiki } = useWikipediaSummary(wikiTitle);
|
||||
// Sample dominant colors from the flag emoji at render time. Used as the
|
||||
// fallback gradient while Wikipedia is still resolving and after image
|
||||
// load failures, so the backdrop never reverts to a giant blurred emoji.
|
||||
const palette = useFlagPalette(info?.flag);
|
||||
// Track image load failures so we cleanly fall back to the flag-color
|
||||
// gradient. Wikipedia hosts these PNGs from upload.wikimedia.org which is
|
||||
// generally CORS-friendly, but hotlink-protection or transient 4xx
|
||||
// responses can still happen.
|
||||
const [imageFailed, setImageFailed] = useState(false);
|
||||
|
||||
if (!ctx) return null;
|
||||
|
||||
// Bundled-asset override: for codes with a curated flag SVG (currently
|
||||
// CN-XZ / Tibet) we skip Wikipedia entirely — its lead image is often
|
||||
// a parent-country map or an administrative-region inset, neither of
|
||||
// which reads as a flag behind a note. The Snow Lion SVG is what the
|
||||
// post is editorially "about", so it earns the backdrop slot.
|
||||
const bundledAsset = customFlagAsset(ctx.code);
|
||||
// For country articles Wikipedia returns the flag as the page's lead image
|
||||
// — the same source used by `CountryContentHeader`. Prefer the original
|
||||
// (full-resolution) over the 330px thumbnail; the thumbnail gets upscaled
|
||||
// and looks fuzzy when stretched across a full-width feed card.
|
||||
const flagImage = !imageFailed
|
||||
? (bundledAsset ?? wiki?.originalImage?.source ?? wiki?.thumbnail?.source ?? null)
|
||||
: null;
|
||||
|
||||
// Pre-built gradient using the palette (sampled from the flag emoji at
|
||||
// mount). Used as the fallback when Wikipedia hasn't returned an image or
|
||||
// its image failed to load. Single-color palettes get duplicated so
|
||||
// linear-gradient still has two stops.
|
||||
const paletteGradient =
|
||||
palette && palette.length > 0
|
||||
? `linear-gradient(135deg, ${palette.length === 1 ? `${palette[0]}, ${palette[0]}` : palette.join(', ')})`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div aria-hidden className="pointer-events-none absolute inset-0 z-0 overflow-hidden">
|
||||
<div className="absolute top-0 left-0 right-0 h-64 sm:h-72">
|
||||
{flagImage ? (
|
||||
// Full-width flag banner across the top of the card. A mask-image
|
||||
// gradient fades the image to nothing at its bottom edge, so the
|
||||
// flag dissolves into the card with no hard seam.
|
||||
<img
|
||||
src={flagImage}
|
||||
alt=""
|
||||
decoding="async"
|
||||
onError={() => setImageFailed(true)}
|
||||
className="w-full h-full object-cover opacity-20 select-none"
|
||||
style={{
|
||||
maskImage: 'linear-gradient(to bottom, black 0%, black 35%, transparent 100%)',
|
||||
WebkitMaskImage: 'linear-gradient(to bottom, black 0%, black 35%, transparent 100%)',
|
||||
}}
|
||||
/>
|
||||
) : paletteGradient ? (
|
||||
// Wikipedia not yet resolved (or its image failed) — paint the
|
||||
// flag-color gradient as a placeholder/fallback. Same opacity and
|
||||
// mask shape as the image so the visual swap is seamless when the
|
||||
// image arrives.
|
||||
<div
|
||||
className="absolute inset-0 opacity-20"
|
||||
style={{
|
||||
backgroundImage: paletteGradient,
|
||||
maskImage: 'linear-gradient(to bottom, black 0%, black 35%, transparent 100%)',
|
||||
WebkitMaskImage: 'linear-gradient(to bottom, black 0%, black 35%, transparent 100%)',
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Body-level comment context for ISO 3166 roots — intentionally renders
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo, useCallback, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import {
|
||||
CalendarClock,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
Pencil,
|
||||
Shield,
|
||||
Share2,
|
||||
Trash2,
|
||||
UserCheck,
|
||||
UserMinus,
|
||||
UserPlus,
|
||||
@@ -25,17 +27,27 @@ import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
|
||||
import { CampaignCard } from '@/components/CampaignCard';
|
||||
import { PeopleAvatarStack } from '@/components/PeopleAvatarStack';
|
||||
import { PostActionBar } from '@/components/PostActionBar';
|
||||
import { DetailCommentComposer } from '@/components/DetailCommentComposer';
|
||||
import { PinnedCommentHeader } from '@/components/PinnedCommentHeader';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { DonateDialog } from '@/components/DonateDialog';
|
||||
import { NoteContent } from '@/components/NoteContent';
|
||||
import { FollowToggleButton } from '@/components/FollowButton';
|
||||
import { InteractionsModal, type InteractionTab } from '@/components/InteractionsModal';
|
||||
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
|
||||
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
|
||||
import { ThreadedReplyList, type ReplyNode } from '@/components/ThreadedReplyList';
|
||||
@@ -46,9 +58,11 @@ import { useBitcoinWallet } from '@/hooks/useBitcoinWallet';
|
||||
import { useCommunityBookmarks } from '@/hooks/useCommunityBookmarks';
|
||||
import { useCommunityMembers } from '@/hooks/useCommunityMembers';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useDeleteEvent } from '@/hooks/useDeleteEvent';
|
||||
import { useEventStats } from '@/hooks/useTrending';
|
||||
import { useNow } from '@/hooks/useNow';
|
||||
import { useOrganizationActivity } from '@/hooks/useOrganizationActivity';
|
||||
import { usePinnedEventComments } from '@/hooks/usePinnedEventComments';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useEventRSVPs } from '@/hooks/useEventRSVPs';
|
||||
@@ -217,7 +231,7 @@ function parseShelfLocation(raw: string): string {
|
||||
|
||||
function ActivityTypePill({ icon, label }: { icon: React.ReactNode; label: string }) {
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1.5 rounded-full border border-border/70 bg-background/95 px-2.5 py-1 text-xs font-semibold text-muted-foreground shadow-sm">
|
||||
<div className="inline-flex items-center gap-1.5 rounded-full border border-border/70 bg-background/95 px-2.5 py-1 text-xs font-semibold text-muted-foreground">
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
@@ -243,11 +257,8 @@ function PledgeShelfCard({ pledge }: { pledge: Action }) {
|
||||
return (
|
||||
<Link
|
||||
to={`/${naddr}`}
|
||||
className="group block w-[280px] shrink-0 rounded-xl overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-safe:transition-transform motion-safe:duration-200 motion-safe:hover:-translate-y-0.5"
|
||||
className="group block h-[430px] w-[280px] shrink-0 rounded-xl overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-safe:transition-transform motion-safe:duration-200 motion-safe:hover:-translate-y-0.5"
|
||||
>
|
||||
<div className="mb-2">
|
||||
<ActivityTypePill icon={<Megaphone className="size-3.5 text-primary" />} label="Pledge" />
|
||||
</div>
|
||||
<Card className="overflow-hidden border-border/70 shadow-sm motion-safe:transition-shadow motion-safe:duration-200 group-hover:shadow-lg h-full flex flex-col">
|
||||
<div className="relative w-full aspect-[16/9] bg-gradient-to-br from-primary/15 via-primary/5 to-secondary">
|
||||
<img
|
||||
@@ -302,8 +313,11 @@ function PledgeShelfCard({ pledge }: { pledge: Action }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground border-t border-border/60 pt-3 truncate">
|
||||
by <span className="font-medium text-foreground">{displayName}</span>
|
||||
<div className="flex items-center justify-between gap-3 border-t border-border/60 pt-3 text-xs text-muted-foreground">
|
||||
<div className="truncate">
|
||||
by <span className="font-medium text-foreground">{displayName}</span>
|
||||
</div>
|
||||
<ActivityTypePill icon={<Megaphone className="size-3.5 text-primary" />} label="Pledge" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -336,11 +350,8 @@ function CalendarEventShelfCard({ event }: { event: NostrEvent }) {
|
||||
return (
|
||||
<Link
|
||||
to={`/${naddr}`}
|
||||
className="group block w-[280px] shrink-0 rounded-xl overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-safe:transition-transform motion-safe:duration-200 motion-safe:hover:-translate-y-0.5"
|
||||
className="group block h-[430px] w-[280px] shrink-0 rounded-xl overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-safe:transition-transform motion-safe:duration-200 motion-safe:hover:-translate-y-0.5"
|
||||
>
|
||||
<div className="mb-2">
|
||||
<ActivityTypePill icon={<CalendarDays className="size-3.5 text-primary" />} label="Event" />
|
||||
</div>
|
||||
<Card className="overflow-hidden border-border/70 shadow-sm motion-safe:transition-shadow motion-safe:duration-200 group-hover:shadow-lg h-full flex flex-col">
|
||||
<div className="relative w-full aspect-[16/9] overflow-hidden bg-gradient-to-br from-primary/15 via-primary/5 to-secondary">
|
||||
{coverImage ? (
|
||||
@@ -405,8 +416,11 @@ function CalendarEventShelfCard({ event }: { event: NostrEvent }) {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground border-t border-border/60 pt-3 truncate">
|
||||
by <span className="font-medium text-foreground">{displayName}</span>
|
||||
<div className="flex items-center justify-between gap-3 border-t border-border/60 pt-3 text-xs text-muted-foreground">
|
||||
<div className="truncate">
|
||||
by <span className="font-medium text-foreground">{displayName}</span>
|
||||
</div>
|
||||
<ActivityTypePill icon={<CalendarDays className="size-3.5 text-primary" />} label="Event" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -438,7 +452,7 @@ function OfficialShelf({ title, count, isLoading, isEmpty, children }: OfficialS
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="-mx-4 sm:-mx-6 px-4 sm:px-6 flex gap-3 overflow-x-auto scrollbar-none pb-1">
|
||||
<div className="-mx-4 sm:-mx-6 px-4 sm:px-6 flex items-stretch gap-3 overflow-x-auto scrollbar-none pb-1">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
@@ -462,11 +476,9 @@ function OfficialActivityShelves({
|
||||
eventsLoading: boolean;
|
||||
now: number;
|
||||
}) {
|
||||
// Drop archived campaigns; mixed activity is sorted newest publish first.
|
||||
const liveCampaigns = useMemo(
|
||||
() => campaigns.filter((c) => !c.archived),
|
||||
[campaigns],
|
||||
);
|
||||
// All loaded campaigns. Closure is via NIP-09 deletion (relay-level),
|
||||
// so anything that reached us is current.
|
||||
const liveCampaigns = campaigns;
|
||||
|
||||
// Drop expired pledges; mixed activity is sorted newest publish first.
|
||||
const livePledges = useMemo(() => {
|
||||
@@ -529,11 +541,12 @@ function OfficialActivityShelves({
|
||||
{mixedActivity.map((item) => {
|
||||
if (item.type === 'campaign') {
|
||||
return (
|
||||
<div key={`campaign:${item.id}`} className="w-[280px] shrink-0">
|
||||
<div className="mb-2">
|
||||
<ActivityTypePill icon={<HandHeart className="size-3.5 text-primary" />} label="Campaign" />
|
||||
</div>
|
||||
<CampaignCard campaign={item.campaign} />
|
||||
<div key={`campaign:${item.id}`} className="h-[430px] w-[280px] shrink-0">
|
||||
<CampaignCard
|
||||
campaign={item.campaign}
|
||||
className="h-full"
|
||||
footerBadge={<ActivityTypePill icon={<HandHeart className="size-3.5 text-primary" />} label="Campaign" />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -609,17 +622,15 @@ function CommunityCreateActions({
|
||||
|
||||
export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const { user } = useCurrentUser();
|
||||
const { btcPrice } = useBitcoinWallet();
|
||||
|
||||
const [membersDialogOpen, setMembersDialogOpen] = useState(false);
|
||||
const [descriptionDialogOpen, setDescriptionDialogOpen] = useState(false);
|
||||
const [donateOpen, setDonateOpen] = useState(false);
|
||||
const [replyOpen, setReplyOpen] = useState(false);
|
||||
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
||||
const [interactionsOpen, setInteractionsOpen] = useState(false);
|
||||
const [interactionsTab, setInteractionsTab] = useState<InteractionTab>('reposts');
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const deleteMutation = useDeleteEvent();
|
||||
|
||||
// Parse community definition
|
||||
const community = useMemo(() => parseCommunityEvent(event), [event]);
|
||||
@@ -669,29 +680,6 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
// (content bans, reports) used by the comment thread below.
|
||||
const { moderation, rankMap, isLoading: membersLoading } = useCommunityMembers(community);
|
||||
|
||||
const communityDonationTarget = useMemo<ParsedCampaign | null>(() => {
|
||||
if (!community) return null;
|
||||
const recipients = [
|
||||
{ pubkey: community.founderPubkey, weight: 1 },
|
||||
...community.moderatorPubkeys.map((pubkey) => ({ pubkey, weight: 1 })),
|
||||
];
|
||||
return {
|
||||
event,
|
||||
pubkey: event.pubkey,
|
||||
identifier: community.dTag,
|
||||
aTag: community.aTag,
|
||||
title: community.name,
|
||||
summary: community.description,
|
||||
story: community.description,
|
||||
image: community.image,
|
||||
category: 'community',
|
||||
tags: ['community'],
|
||||
recipients,
|
||||
createdAt: event.created_at,
|
||||
archived: false,
|
||||
};
|
||||
}, [community, event]);
|
||||
|
||||
// Only the founder can edit organization metadata. Moderators can
|
||||
// moderate content via the community context but don't get the
|
||||
// "Edit community" action.
|
||||
@@ -742,6 +730,12 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
|
||||
// ── Comments (NIP-22 on the community event) ───────────────────────────────
|
||||
const { data: commentsData, isLoading: commentsLoading } = useComments(event, 500);
|
||||
const {
|
||||
pinnedEvents,
|
||||
isPinned,
|
||||
canManagePins,
|
||||
togglePin,
|
||||
} = usePinnedEventComments(communityATag || undefined, event.pubkey);
|
||||
|
||||
// ── Official activity shelves ─────────────────────────────────────────────
|
||||
// Author-filtered to founder + moderators (see useOrganizationActivity).
|
||||
@@ -765,21 +759,8 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
}, [community, event.kind, event.pubkey]);
|
||||
|
||||
// ── Engagement stats for the community event itself ──────────────────────
|
||||
// Pulled from NIP-85. Used both for the inline counters above the action
|
||||
// bar and for the threaded-comments header. Matches the rhythm of the
|
||||
// campaign and pledge detail pages.
|
||||
// Pulled from NIP-85 for the threaded-comments header.
|
||||
const { data: engagementStats, isLoading: statsLoading } = useEventStats(event.id, event);
|
||||
const hasStats =
|
||||
!!engagementStats?.replies ||
|
||||
!!engagementStats?.reposts ||
|
||||
!!engagementStats?.quotes ||
|
||||
!!engagementStats?.reactions;
|
||||
|
||||
const openInteractions = useCallback((tab: InteractionTab) => {
|
||||
setInteractionsTab(tab);
|
||||
setInteractionsOpen(true);
|
||||
}, []);
|
||||
|
||||
const replyTree = useMemo((): ReplyNode[] => {
|
||||
if (!commentsData) return [];
|
||||
const topLevel = commentsData.topLevelComments ?? [];
|
||||
@@ -811,6 +792,11 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
.map((r) => buildNode(r));
|
||||
}, [commentsData, moderation]);
|
||||
|
||||
const pinnedNodes = useMemo(
|
||||
() => pinnedEvents.map((event): ReplyNode => ({ event, children: [] })),
|
||||
[pinnedEvents],
|
||||
);
|
||||
|
||||
// ── Share handler ───────────────────────────────────────────────────────────
|
||||
const handleShare = useCallback(async () => {
|
||||
const d = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
|
||||
@@ -828,6 +814,51 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
}
|
||||
}, [event, toast]);
|
||||
|
||||
// ── Delete handler ─────────────────────────────────────────────────────────
|
||||
// Founder-only. Publishes a NIP-09 kind 5 deletion request referencing the
|
||||
// community definition (kind 34550) by both `e` and `a` tags so relays can
|
||||
// drop it from both id-based and addressable lookups. After the request
|
||||
// ships we invalidate every org-related cache so the page the user lands
|
||||
// on (`/communities`) shows the deletion immediately, even if some relays
|
||||
// haven't propagated yet.
|
||||
const handleDeleteOrganization = useCallback(() => {
|
||||
if (!community) return;
|
||||
deleteMutation.mutate(
|
||||
{
|
||||
eventId: event.id,
|
||||
eventKind: event.kind,
|
||||
eventPubkey: event.pubkey,
|
||||
eventDTag: community.dTag,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: 'Organization deleted',
|
||||
description:
|
||||
'A deletion request was published. Well-behaved relays will drop the organization from feeds.',
|
||||
});
|
||||
setDeleteConfirmOpen(false);
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['addr-event', event.kind, event.pubkey, community.dTag],
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: ['community-definition', community.aTag] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['manageable-organizations'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['featured-organizations'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['followed-organizations'] });
|
||||
navigate('/communities');
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
toast({
|
||||
title: 'Could not delete organization',
|
||||
description: msg,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [community, deleteMutation, event, navigate, queryClient, toast]);
|
||||
|
||||
useLayoutOptions({
|
||||
noMaxWidth: true,
|
||||
rightSidebar: null,
|
||||
@@ -848,7 +879,7 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 pt-4">
|
||||
<CommunityModerationContext.Provider value={moderationCtx}>
|
||||
{/* ── Hero ─────────────────────────────────────────────────────── */}
|
||||
<div className="relative aspect-[16/9] sm:aspect-[21/9] rounded-xl overflow-hidden bg-gradient-to-br from-primary/40 via-primary/20 to-secondary">
|
||||
<div className="relative aspect-[16/9] sm:aspect-[21/9] rounded-t-xl rounded-b-none overflow-hidden bg-gradient-to-br from-primary/40 via-primary/20 to-secondary">
|
||||
{cover ? (
|
||||
<img src={cover} alt="" className="absolute inset-0 size-full object-cover" />
|
||||
) : (
|
||||
@@ -970,6 +1001,18 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
Edit organization
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isFounder && community && (
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
setDeleteConfirmOpen(true);
|
||||
}}
|
||||
className="text-destructive focus:text-destructive focus:bg-destructive/10"
|
||||
>
|
||||
<Trash2 className="size-4 mr-2" />
|
||||
Delete organization
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
@@ -977,21 +1020,39 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-b-xl rounded-t-none bg-card border border-t-0 border-border/60 shadow-sm px-4 sm:px-5 py-3">
|
||||
<PostActionBar
|
||||
event={event}
|
||||
replyLabel="Comment"
|
||||
hideZap
|
||||
onReply={() => setReplyOpen(true)}
|
||||
onMore={() => setMoreMenuOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{pinnedNodes.length > 0 && (
|
||||
<div className="pt-6">
|
||||
<div className="rounded-2xl bg-card border border-border/60 overflow-hidden">
|
||||
<ThreadedReplyList
|
||||
roots={pinnedNodes}
|
||||
renderItemHeader={(event) => (
|
||||
<OrganizationPinHeader
|
||||
isPinned={isPinned(event.id)}
|
||||
canManagePins={canManagePins}
|
||||
pinPending={togglePin.isPending}
|
||||
onTogglePin={() => handleTogglePin(event)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Body — single column, pledge-detail-style ─────────────────── */}
|
||||
<div className="py-6 lg:py-10 space-y-8">
|
||||
{/* Donate (when there's a member set) and Share buttons. Sits
|
||||
just below the hero like the pledge page's action row. */}
|
||||
<div className={cn('grid gap-2', communityDonationTarget ? 'grid-cols-4' : 'grid-cols-1')}>
|
||||
{communityDonationTarget && (
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full col-span-3"
|
||||
onClick={() => setDonateOpen(true)}
|
||||
>
|
||||
<HandHeart className="size-5 mr-2" />
|
||||
Donate
|
||||
</Button>
|
||||
)}
|
||||
<div className="grid gap-2 grid-cols-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -1022,48 +1083,9 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
now={now}
|
||||
/>
|
||||
|
||||
{/* Engagement card — stats counters + post action bar. Matches
|
||||
the pledge / campaign detail layout. No funding progress bar
|
||||
here; an organization isn't a fundraising target itself. */}
|
||||
{/* Comments — NIP-22 thread on the community event itself. */}
|
||||
<div id="org-activity" className="scroll-mt-20">
|
||||
<div className="rounded-2xl bg-card border border-border/60 shadow-sm px-4 sm:px-5 py-4 sm:py-5">
|
||||
{hasStats && (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs sm:text-sm text-muted-foreground pb-2">
|
||||
{engagementStats?.reposts ? (
|
||||
<button onClick={() => openInteractions('reposts')} className="hover:underline transition-colors">
|
||||
<span className="font-bold text-foreground">{formatNumber(engagementStats.reposts)}</span>{' '}
|
||||
Repost{engagementStats.reposts !== 1 ? 's' : ''}
|
||||
</button>
|
||||
) : null}
|
||||
{engagementStats?.quotes ? (
|
||||
<button onClick={() => openInteractions('quotes')} className="hover:underline transition-colors">
|
||||
<span className="font-bold text-foreground">{formatNumber(engagementStats.quotes)}</span>{' '}
|
||||
Quote{engagementStats.quotes !== 1 ? 's' : ''}
|
||||
</button>
|
||||
) : null}
|
||||
{engagementStats?.reactions ? (
|
||||
<button onClick={() => openInteractions('reactions')} className="hover:underline transition-colors">
|
||||
<span className="font-bold text-foreground">{formatNumber(engagementStats.reactions)}</span>{' '}
|
||||
Like{engagementStats.reactions !== 1 ? 's' : ''}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PostActionBar
|
||||
event={event}
|
||||
replyLabel="Comment"
|
||||
hideZap
|
||||
onReply={() => setReplyOpen(true)}
|
||||
onMore={() => setMoreMenuOpen(true)}
|
||||
className={hasStats ? 'pt-3 border-t border-border/60' : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Comments — NIP-22 thread on the community event itself.
|
||||
Member-filter aware (see replyTree) and routed through
|
||||
CommunityModerationContext so per-reply ban actions work. */}
|
||||
<div className="mt-6">
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between gap-3 mb-3 px-1">
|
||||
<h2 className="text-lg font-semibold tracking-tight">Comments</h2>
|
||||
{engagementStats?.replies ? (
|
||||
@@ -1074,6 +1096,8 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DetailCommentComposer event={event} className="mb-3" />
|
||||
|
||||
{commentsLoading && statsLoading && replyTree.length === 0 ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
@@ -1081,8 +1105,18 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
))}
|
||||
</div>
|
||||
) : replyTree.length > 0 ? (
|
||||
<div className="-mx-2 sm:-mx-4 rounded-2xl bg-card border border-border/60 overflow-hidden">
|
||||
<ThreadedReplyList roots={replyTree} />
|
||||
<div className="rounded-2xl bg-card border border-border/60 overflow-hidden">
|
||||
<ThreadedReplyList
|
||||
roots={replyTree}
|
||||
renderItemHeader={(event) => (
|
||||
<OrganizationPinHeader
|
||||
isPinned={isPinned(event.id)}
|
||||
canManagePins={canManagePins}
|
||||
pinPending={togglePin.isPending}
|
||||
onTogglePin={() => handleTogglePin(event)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
@@ -1103,15 +1137,6 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
</div>
|
||||
</CommunityModerationContext.Provider>
|
||||
|
||||
{communityDonationTarget && (
|
||||
<DonateDialog
|
||||
campaign={communityDonationTarget}
|
||||
open={donateOpen}
|
||||
onOpenChange={setDonateOpen}
|
||||
btcPrice={btcPrice}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Description dialog — opened by clicking the truncated description in
|
||||
the banner. Renders the full raw description plus a clickable
|
||||
website link when the description ends with a URL. */}
|
||||
@@ -1195,16 +1220,74 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
onOpenChange={setMoreMenuOpen}
|
||||
/>
|
||||
|
||||
{/* Tapping a repost / quote / like counter on the stats row opens
|
||||
a modal listing the people who took that action. */}
|
||||
<InteractionsModal
|
||||
eventId={event.id}
|
||||
open={interactionsOpen}
|
||||
onOpenChange={setInteractionsOpen}
|
||||
initialTab={interactionsTab}
|
||||
/>
|
||||
{/* Founder-only delete confirmation. NIP-09 is advisory — relays decide
|
||||
whether to honor the request — so the copy makes the limitation
|
||||
explicit and steers founders toward "Edit organization" if they
|
||||
just want to change something. */}
|
||||
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete this organization?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This publishes a NIP-09 deletion request for{' '}
|
||||
<span className="font-medium text-foreground">{name}</span>.
|
||||
Well-behaved relays will drop the organization from feeds and
|
||||
direct links. Campaigns, pledges, and posts published under
|
||||
the organization stay on-chain regardless. This action cannot
|
||||
be undone — to change the name, banner, or moderators, edit
|
||||
the organization instead.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleDeleteOrganization();
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleteMutation.isPending ? 'Deleting…' : 'Delete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
function handleTogglePin(event: NostrEvent) {
|
||||
const wasPinned = isPinned(event.id);
|
||||
togglePin.mutate(event.id, {
|
||||
onSuccess: () => {
|
||||
toast({ title: wasPinned ? 'Unpinned from organization' : 'Pinned to organization' });
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: 'Failed to update organization pins', variant: 'destructive' });
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function OrganizationPinHeader({
|
||||
isPinned,
|
||||
canManagePins,
|
||||
pinPending,
|
||||
onTogglePin,
|
||||
}: {
|
||||
isPinned: boolean;
|
||||
canManagePins: boolean;
|
||||
pinPending: boolean;
|
||||
onTogglePin: () => void;
|
||||
}) {
|
||||
return (
|
||||
<PinnedCommentHeader
|
||||
isPinned={isPinned}
|
||||
canManagePins={canManagePins}
|
||||
pinPending={pinPending}
|
||||
onTogglePin={onTogglePin}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
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:<pubkey>:<d>` coordinate. */
|
||||
coord: string;
|
||||
/** Visible name for the organization (for toast feedback). */
|
||||
organizationName: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-card kebab menu exposing the moderator actions for an organization:
|
||||
*
|
||||
* Hide / Unhide (axis = hide)
|
||||
* Feature / Unfeature (axis = featured)
|
||||
*
|
||||
* Organizations intentionally do **not** have an `approved` axis — unlike
|
||||
* campaigns, which gate homepage placement on moderator approval, every
|
||||
* Agora-tagged organization is publicly visible by default. Moderators
|
||||
* curate via two narrower controls: lifting an org into the Featured
|
||||
* shelf, or suppressing it with a Hidden label.
|
||||
*
|
||||
* Renders `null` for users who are not Team Soapbox pack members. Sits
|
||||
* inside the clickable `CommunityMiniCard` `<Link>`, so the trigger
|
||||
* swallows its own click and the dropdown content stops propagation —
|
||||
* otherwise every menu interaction would navigate to the organization
|
||||
* detail page.
|
||||
*
|
||||
* The moderation rollup is read inside this component (after the
|
||||
* moderator gate) instead of at the parent so non-moderator viewers
|
||||
* never subscribe to the heavy `useOrganizationModeration` query — every
|
||||
* `CommunityMiniCard` in a grid would otherwise wake the same cache
|
||||
* subscription up to 18+ times per page.
|
||||
*/
|
||||
export function CommunityModerationMenu({
|
||||
coord,
|
||||
organizationName,
|
||||
className,
|
||||
}: CommunityModerationMenuProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { data: moderators } = useCampaignModerators();
|
||||
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
|
||||
|
||||
// Bail before the heavy moderation query subscribes. Non-moderators
|
||||
// (the overwhelming majority) never pay the network or render cost.
|
||||
if (!isMod) return null;
|
||||
|
||||
return <CommunityModerationMenuInner coord={coord} organizationName={organizationName} className={className} />;
|
||||
}
|
||||
|
||||
function CommunityModerationMenuInner({
|
||||
coord,
|
||||
organizationName,
|
||||
className,
|
||||
}: CommunityModerationMenuProps) {
|
||||
const { data: moderation, moderate } = useOrganizationModeration();
|
||||
const { toast } = useToast();
|
||||
const [busy, setBusy] = useState<ModerationLabel | null>(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 (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e) => e.preventDefault()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Moderate organization"
|
||||
className={className ?? 'h-8 w-8 bg-background/80 backdrop-blur text-muted-foreground hover:text-foreground'}
|
||||
>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <MoreHorizontal className="h-4 w-4" />}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
Moderator actions
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{isFeatured ? (
|
||||
<DropdownMenuItem onClick={() => runAction('unfeatured', 'Removed from featured')}>
|
||||
<SparklesIcon className="h-4 w-4 mr-2" />
|
||||
Unfeature
|
||||
<span className="ml-auto text-xs text-muted-foreground inline-flex items-center gap-1">
|
||||
<Check className="h-3 w-3" /> Featured
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={() => runAction('featured', 'Featured organization')}>
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
Feature
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
{isHidden ? (
|
||||
<DropdownMenuItem onClick={() => runAction('unhidden', 'Unhidden')}>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
Unhide
|
||||
<span className="ml-auto text-xs text-muted-foreground inline-flex items-center gap-1">
|
||||
<Check className="h-3 w-3" /> Hidden
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
onClick={() => runAction('hidden', 'Hidden')}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<EyeOff className="h-4 w-4 mr-2" />
|
||||
Hide
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<CommunityModerationOverlayInner coord={coord} organizationName={organizationName} />
|
||||
);
|
||||
}
|
||||
|
||||
function CommunityModerationOverlayInner({
|
||||
coord,
|
||||
organizationName,
|
||||
}: {
|
||||
coord: string;
|
||||
organizationName: string;
|
||||
}) {
|
||||
const { data: moderation } = useOrganizationModeration();
|
||||
const isHidden = moderation.hiddenCoords.has(coord);
|
||||
|
||||
return (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1.5">
|
||||
{isHidden && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="backdrop-blur bg-destructive/15 text-destructive border-destructive/30 h-6 px-1.5 text-[10px]"
|
||||
>
|
||||
<EyeOff className="size-3 mr-1" />
|
||||
Hidden
|
||||
</Badge>
|
||||
)}
|
||||
{/* The kebab inner uses the same moderation cache subscription, so
|
||||
no extra round-trip is incurred. */}
|
||||
<CommunityModerationMenuInner coord={coord} organizationName={organizationName} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+205
-44
@@ -1,6 +1,6 @@
|
||||
import { lazy, Suspense, useState, useRef, useCallback, useMemo, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Paperclip, Smile, AlertTriangle, X, Loader2, Mic, Square, Sticker, BarChart3, Plus, ChevronLeft, HelpCircle } from 'lucide-react';
|
||||
import { Paperclip, Smile, AlertTriangle, X, Loader2, Mic, Square, Sticker, BarChart3, Plus, ChevronLeft, Check, Globe, HelpCircle } from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { encode as blurhashEncode } from 'blurhash';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
@@ -12,7 +12,21 @@ import { Input } from '@/components/ui/input';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { useCustomEmojis } from '@/hooks/useCustomEmojis';
|
||||
import { useFeedSettings } from '@/hooks/useFeedSettings';
|
||||
import { GifPicker } from '@/components/GifPicker';
|
||||
@@ -29,7 +43,8 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { usePostComment } from '@/hooks/usePostComment';
|
||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||
import { useCountryFollows } from '@/hooks/useCountryFollows';
|
||||
import { getCountryInfo } from '@/lib/countries';
|
||||
import { useDefaultPostCountry } from '@/hooks/useDefaultPostCountry';
|
||||
import { COUNTRY_LIST, getCountryInfo } from '@/lib/countries';
|
||||
import { createCountryIdentifier } from '@/lib/countryIdentifiers';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
@@ -50,6 +65,7 @@ import { DITTO_RELAY } from '@/lib/appRelays';
|
||||
import { resizeImage } from '@/lib/resizeImage';
|
||||
import { extractHashtags } from '@/lib/hashtag';
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
import { AGORA_DEFAULT_NOTE_TAGS } from '@/lib/agoraNoteTags';
|
||||
|
||||
const MAX_CHARS = 5000;
|
||||
|
||||
@@ -156,7 +172,15 @@ interface ComposeBoxProps {
|
||||
hidePoll?: boolean;
|
||||
/** Label for the primary submit button. */
|
||||
submitLabel?: string;
|
||||
/** Tags added to new top-level kind 1 notes without putting them in content. */
|
||||
/**
|
||||
* Tags added to new top-level kind 1 notes without putting them in content.
|
||||
*
|
||||
* Defaults to {@link AGORA_DEFAULT_NOTE_TAGS} (the silent `t:agora` tag) when
|
||||
* the composer is producing a top-level kind 1 note (no replyTo, not a quote,
|
||||
* not poll mode, no custom publish, no country-scoped destination). Replies,
|
||||
* quotes, polls, comments, and custom-kind publishes do not receive these
|
||||
* tags regardless of this prop. Pass `[]` to opt out explicitly.
|
||||
*/
|
||||
defaultTags?: string[][];
|
||||
/** If true, the composer starts expanded without taking modal/flex behavior. */
|
||||
defaultExpanded?: boolean;
|
||||
@@ -218,7 +242,7 @@ export function ComposeBox({
|
||||
customPublish,
|
||||
hidePoll = false,
|
||||
submitLabel = 'Post!',
|
||||
defaultTags = [],
|
||||
defaultTags,
|
||||
defaultExpanded = false,
|
||||
}: ComposeBoxProps) {
|
||||
const { user, metadata, isLoading: isProfileLoading } = useCurrentUser();
|
||||
@@ -269,23 +293,39 @@ export function ComposeBox({
|
||||
// from the home feed (no replyTo, not a custom-kind publish). When a
|
||||
// country code is selected, the post is published as a NIP-22 kind
|
||||
// 1111 comment rooted on that country instead of a plain kind 1 note.
|
||||
// Dropdown lists only the countries the user follows, with "Global"
|
||||
// always at the top.
|
||||
//
|
||||
// The dropdown shows: Global + the countries the user follows (quick
|
||||
// picks) + a "Choose another country…" item that opens a searchable
|
||||
// dialog over the full country list. So a user can post about any
|
||||
// country, even one they don't follow.
|
||||
const { followedCountries } = useCountryFollows();
|
||||
const canChooseDestination =
|
||||
!replyTo && !customPublish && mode === 'post' && !!user && followedCountries.length > 0;
|
||||
!replyTo && !customPublish && mode === 'post' && !!user;
|
||||
/**
|
||||
* User's saved default destination (persisted to localStorage). Used as
|
||||
* the initial value of `destination` on every fresh compose, and updated
|
||||
* when the user clicks "Set as default" in the destination menu.
|
||||
*/
|
||||
const [defaultPostCountry, setDefaultPostCountry] = useDefaultPostCountry();
|
||||
/** `'world'` for a regular kind-1 note, or an ISO 3166 country code for a kind-1111 community post. */
|
||||
const [destination, setDestination] = useState<'world' | string>('world');
|
||||
const [destination, setDestination] = useState<'world' | string>(defaultPostCountry);
|
||||
/** Open state for the "Choose another country" searchable picker dialog. */
|
||||
const [countryPickerOpen, setCountryPickerOpen] = useState(false);
|
||||
const selectedCountryCode = destination !== 'world' ? destination : null;
|
||||
const selectedCountryInfo = selectedCountryCode ? getCountryInfo(selectedCountryCode) : null;
|
||||
// If the user unfollows the currently-selected country mid-session,
|
||||
// snap back to world so we don't try to publish a kind 1111 with
|
||||
// a root the user no longer cares about.
|
||||
// Snap back to world if the currently selected destination is an
|
||||
// invalid ISO code (e.g. a previously-followed country that was later
|
||||
// removed from the country directory). Picking a non-followed but
|
||||
// valid country is allowed — users can post about any country via the
|
||||
// "Choose another country" picker, so following is not a prerequisite.
|
||||
useEffect(() => {
|
||||
if (selectedCountryCode && !followedCountries.includes(selectedCountryCode)) {
|
||||
if (selectedCountryCode && !getCountryInfo(selectedCountryCode)) {
|
||||
setDestination('world');
|
||||
if (defaultPostCountry === selectedCountryCode) {
|
||||
setDefaultPostCountry('world');
|
||||
}
|
||||
}
|
||||
}, [selectedCountryCode, followedCountries]);
|
||||
}, [selectedCountryCode, defaultPostCountry, setDefaultPostCountry]);
|
||||
const [pollOptions, setPollOptions] = useState([
|
||||
{ id: pollOptionId(), label: '' },
|
||||
{ id: pollOptionId(), label: '' },
|
||||
@@ -323,10 +363,10 @@ export function ComposeBox({
|
||||
setUploadedFileGroups(new Map());
|
||||
setWebxdcUuids(new Map());
|
||||
setWebxdcMetas(new Map());
|
||||
setDestination('world');
|
||||
setDestination(defaultPostCountry);
|
||||
// Clear the auto-saved draft
|
||||
try { localStorage.removeItem(draftKey); } catch { /* ignore */ }
|
||||
}, [initialMode, draftKey, defaultExpanded]);
|
||||
}, [initialMode, draftKey, defaultExpanded, defaultPostCountry]);
|
||||
|
||||
// Use controlled preview mode if provided, otherwise use internal state
|
||||
const previewMode = controlledPreviewMode !== undefined ? controlledPreviewMode : internalPreviewMode;
|
||||
@@ -1114,10 +1154,14 @@ export function ComposeBox({
|
||||
const countryRoot = new URL(createCountryIdentifier(selectedCountryCode));
|
||||
await postComment({ root: countryRoot, reply: undefined, content: finalContent, tags });
|
||||
} else {
|
||||
// Top-level kind 1 note. If the caller hasn't supplied `defaultTags`,
|
||||
// auto-attach the silent Agora tag so the post surfaces in the Agora
|
||||
// activity feed. Callers can opt out by passing `defaultTags={[]}`.
|
||||
const effectiveDefaultTags = defaultTags ?? AGORA_DEFAULT_NOTE_TAGS;
|
||||
await createEvent({
|
||||
kind: 1,
|
||||
content: finalContent,
|
||||
tags: [...defaultTags, ...tags],
|
||||
tags: [...effectiveDefaultTags, ...tags],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
}
|
||||
@@ -1537,14 +1581,14 @@ export function ComposeBox({
|
||||
})()}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Select value={destination} onValueChange={setDestination}>
|
||||
<SelectTrigger
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label="Post destination"
|
||||
className={cn(
|
||||
'h-8 w-auto gap-1.5 px-2.5 py-1 text-base leading-none',
|
||||
'border-0 bg-muted/50 hover:bg-muted shadow-none',
|
||||
'focus:ring-2 focus:ring-primary/50 focus:ring-offset-0',
|
||||
'rounded-lg',
|
||||
'inline-flex items-center justify-center h-8 w-auto gap-1.5 px-2.5 py-1 text-base leading-none',
|
||||
'bg-muted/50 hover:bg-muted shadow-none',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-0',
|
||||
'rounded-lg motion-safe:transition-colors',
|
||||
)}
|
||||
>
|
||||
{/* Show just the flag in the trigger to keep the row
|
||||
@@ -1553,28 +1597,145 @@ export function ComposeBox({
|
||||
<span aria-hidden="true">
|
||||
{selectedCountryInfo?.flag ?? '🌍'}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end" className="min-w-[180px]">
|
||||
<SelectItem value="world">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[240px]">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setDestination('world')}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2 flex-1">
|
||||
<span aria-hidden="true">🌍</span>
|
||||
<span>Global</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
{followedCountries.map((code) => {
|
||||
const info = getCountryInfo(code);
|
||||
if (!info) return null;
|
||||
return (
|
||||
<SelectItem key={code} value={code}>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span aria-hidden="true">{info.flag}</span>
|
||||
<span>{info.name}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{destination === 'world' && (
|
||||
<Check className="size-4 text-primary" aria-hidden />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
{/* Build the quick-pick list. Followed countries appear first;
|
||||
if the user has selected an ad-hoc country via the
|
||||
searchable picker that they don't follow, show it too so
|
||||
they have a one-tap way back to it. De-duplicates by code. */}
|
||||
{(() => {
|
||||
const codes = new Set<string>();
|
||||
const quickPicks: string[] = [];
|
||||
for (const code of followedCountries) {
|
||||
if (!codes.has(code) && getCountryInfo(code)) {
|
||||
codes.add(code);
|
||||
quickPicks.push(code);
|
||||
}
|
||||
}
|
||||
if (selectedCountryCode && !codes.has(selectedCountryCode) && getCountryInfo(selectedCountryCode)) {
|
||||
quickPicks.push(selectedCountryCode);
|
||||
}
|
||||
return quickPicks.map((code) => {
|
||||
const info = getCountryInfo(code);
|
||||
if (!info) return null;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={code}
|
||||
onSelect={() => setDestination(code)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2 flex-1">
|
||||
<span aria-hidden="true">{info.flag}</span>
|
||||
<span>{info.name}</span>
|
||||
</span>
|
||||
{destination === code && (
|
||||
<Check className="size-4 text-primary" aria-hidden />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
setCountryPickerOpen(true);
|
||||
}}
|
||||
className="cursor-pointer text-sm"
|
||||
>
|
||||
<Globe className="size-4 mr-2 text-muted-foreground" aria-hidden />
|
||||
Choose another country…
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{destination === defaultPostCountry ? (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{(() => {
|
||||
if (defaultPostCountry === 'world') return 'Global is your default';
|
||||
const info = getCountryInfo(defaultPostCountry);
|
||||
return info ? `${info.name} is your default` : 'This is your default';
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setDefaultPostCountry(destination);
|
||||
const info = destination === 'world'
|
||||
? null
|
||||
: getCountryInfo(destination);
|
||||
toast({
|
||||
title: 'Default updated',
|
||||
description: info
|
||||
? `New posts will go to ${info.name} by default.`
|
||||
: 'New posts will be global by default.',
|
||||
});
|
||||
}}
|
||||
className="cursor-pointer text-sm"
|
||||
>
|
||||
Set as default
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Searchable picker over the full country list. Opened from the
|
||||
"Choose another country…" item in the destination dropdown,
|
||||
so users can post to any country without having to follow it
|
||||
first. */}
|
||||
<CommandDialog
|
||||
open={countryPickerOpen}
|
||||
onOpenChange={setCountryPickerOpen}
|
||||
>
|
||||
<CommandInput placeholder="Search countries..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No countries found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="Global 🌍"
|
||||
onSelect={() => {
|
||||
setDestination('world');
|
||||
setCountryPickerOpen(false);
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true" className="mr-2">🌍</span>
|
||||
<span>Global</span>
|
||||
{destination === 'world' && (
|
||||
<Check className="ml-auto size-4 text-primary" aria-hidden />
|
||||
)}
|
||||
</CommandItem>
|
||||
{COUNTRY_LIST.map((country) => (
|
||||
<CommandItem
|
||||
key={country.code}
|
||||
// Include code + name in the searchable value so users
|
||||
// can type either "iran" or "IR".
|
||||
value={`${country.name} ${country.code}`}
|
||||
onSelect={() => {
|
||||
setDestination(country.code);
|
||||
setCountryPickerOpen(false);
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true" className="mr-2">{country.flag}</span>
|
||||
<span>{country.name}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{country.code}</span>
|
||||
{destination === country.code && (
|
||||
<Check className="ml-auto size-4 text-primary" aria-hidden />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1775,7 +1936,7 @@ export function ComposeBox({
|
||||
<Button
|
||||
onClick={handlePollSubmit}
|
||||
disabled={!isPollValid || isPollPending || !user}
|
||||
className="rounded-full px-5 font-bold"
|
||||
className="rounded-full px-5 font-bold text-white"
|
||||
size="sm"
|
||||
>
|
||||
{isPollPending ? 'Publishing...' : 'Publish poll'}
|
||||
@@ -1784,7 +1945,7 @@ export function ComposeBox({
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!content.trim() || isPending || isCommentPending || !user || charCount > MAX_CHARS}
|
||||
className="rounded-full px-5 font-bold"
|
||||
className="rounded-full px-5 font-bold text-white"
|
||||
size="sm"
|
||||
>
|
||||
{isPending || isCommentPending ? 'Posting...' : submitLabel}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { IntroImage } from '@/components/IntroImage';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import {
|
||||
Users, Download, Loader2, X, Pencil, Home, Globe, MapPin,
|
||||
Palette, Trash2, Plus, UserX, Hash, MessageSquareOff, ExternalLink, ShieldAlert,
|
||||
Trash2, Plus, UserX, Hash, MessageSquareOff, ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -26,220 +25,108 @@ import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { FeedEditModal } from '@/components/FeedEditModal';
|
||||
import { buildKindOptions } from '@/lib/feedFilterUtils';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { EXTRA_KINDS, FEED_KINDS, SECTION_ORDER, SECTION_LABELS } from '@/lib/extraKinds';
|
||||
import { CONTENT_KIND_ICONS, SIDEBAR_ITEMS } from '@/lib/sidebarItems';
|
||||
import type { SavedFeed, TabFilter, ContentWarningPolicy } from '@/contexts/AppContext';
|
||||
import type { ExtraKindDef, SubKindDef } from '@/lib/extraKinds';
|
||||
import { EXTRA_KINDS } from '@/lib/extraKinds';
|
||||
import { SIDEBAR_ITEMS } from '@/lib/sidebarItems';
|
||||
import type { FeedSettings, SavedFeed, TabFilter, ContentWarningPolicy } from '@/contexts/AppContext';
|
||||
import type { ExtraKindDef } from '@/lib/extraKinds';
|
||||
|
||||
export function ContentSettings() {
|
||||
return (
|
||||
<div>
|
||||
{/* Intro */}
|
||||
<div className="px-3 pt-2 pb-4">
|
||||
<h2 className="text-sm font-semibold">What You See</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Customize your feed, choose what content appears, and control what you want to hide.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Homepage Section */}
|
||||
<div className="space-y-8">
|
||||
<HomePageSetting />
|
||||
|
||||
{/* Feed Tabs Section */}
|
||||
<div>
|
||||
<div className="relative px-3 py-3.5">
|
||||
<h2 className="text-base font-semibold">Home Feed Tabs</h2>
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
|
||||
</div>
|
||||
<div className="pb-4">
|
||||
<FeedTabsSection />
|
||||
</div>
|
||||
</div>
|
||||
<Section title="Saved Feeds">
|
||||
<FeedTabsSection />
|
||||
</Section>
|
||||
|
||||
{/* Notes Section */}
|
||||
<div>
|
||||
<div className="relative px-3 py-3.5">
|
||||
<h2 className="text-base font-semibold">Basic Home Feed Options</h2>
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
|
||||
</div>
|
||||
<div className="pb-4">
|
||||
<div className="px-3 pt-3 pb-4">
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Core content types that appear in your feed.
|
||||
</p>
|
||||
</div>
|
||||
<Section title="Content in Home Feed">
|
||||
<FlatContentList />
|
||||
</Section>
|
||||
|
||||
{/* Column headers */}
|
||||
<div className="flex items-center justify-end gap-2 px-3 pb-2 border-b border-border">
|
||||
<span className="text-[11px] font-medium text-muted-foreground w-[52px] text-center">Feed</span>
|
||||
</div>
|
||||
|
||||
<NotesFeedSettings />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Other Stuff Section */}
|
||||
<div>
|
||||
<div className="relative px-3 py-3.5">
|
||||
<h2 className="text-base font-semibold">Show More Content Types in Home Feed</h2>
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
|
||||
</div>
|
||||
<div className="pb-4">
|
||||
{/* Intro section for Other Stuff */}
|
||||
<div className="flex items-center gap-4 px-3 pt-3 pb-4">
|
||||
<IntroImage src="/feed-intro.png" />
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold">Other Stuff</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Nostr isn't just text posts — people publish all kinds of things. Pick what shows up in your sidebar and feed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Column headers */}
|
||||
<div className="flex items-center justify-end gap-2 px-3 pb-2 border-b border-border">
|
||||
<span className="text-[11px] font-medium text-muted-foreground w-[52px] text-center">Feed</span>
|
||||
</div>
|
||||
|
||||
{/* Content type rows - reuse the internals from FeedSettingsForm */}
|
||||
<FeedSettingsFormInternals />
|
||||
</div>
|
||||
</div>
|
||||
<Section title="Muted">
|
||||
<MuteSettingsInternals />
|
||||
</Section>
|
||||
|
||||
<Section title="Sensitive Content">
|
||||
<SensitiveContentSection />
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
function KindBadge({ kind }: { kind: number }) {
|
||||
function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<span className="text-[10px] font-mono text-muted-foreground/60 shrink-0">
|
||||
[{kind}]
|
||||
</span>
|
||||
<section>
|
||||
<h2 className="text-base font-semibold px-3 pb-2 border-b border-border">{title}</h2>
|
||||
<div className="pt-2">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SubKindRow({ sub }: { sub: SubKindDef }) {
|
||||
const { feedSettings, updateFeedSettings } = useFeedSettings();
|
||||
const { updateSettings } = useEncryptedSettings();
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
const handleToggle = async (key: string, value: boolean) => {
|
||||
updateFeedSettings({ [key]: value });
|
||||
if (user) {
|
||||
await updateSettings.mutateAsync({ feedSettings: { ...feedSettings, [key]: value } });
|
||||
}
|
||||
};
|
||||
function FlatContentList() {
|
||||
// Flat, ordered list of curated kinds. No section grouping, no sub-rows, no kind badges.
|
||||
const orderedIds = [
|
||||
'posts', 'replies', 'reposts', 'articles', 'highlights',
|
||||
'photos', 'videos', 'voice',
|
||||
'events', 'polls', 'communities', 'badges',
|
||||
'reactions', 'zaps',
|
||||
];
|
||||
const byId = new Map(EXTRA_KINDS.map((def) => [def.id, def]));
|
||||
// Replies is id 'comments' in the registry; alias here for readability.
|
||||
byId.set('replies', byId.get('comments')!);
|
||||
const rows = orderedIds.map((id) => byId.get(id)).filter((d): d is ExtraKindDef => !!d && !!d.agora);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2.5 pl-12 pr-3 transition-colors">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm">{sub.label}</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
<KindBadge kind={sub.kind} />{' '}{sub.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="w-[52px] flex justify-center">
|
||||
<Switch
|
||||
checked={feedSettings[sub.feedKey]}
|
||||
onCheckedChange={(checked) => handleToggle(sub.feedKey, checked)}
|
||||
className="scale-90"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="divide-y divide-border">
|
||||
{rows.map((def) => (
|
||||
<li key={def.id}>
|
||||
<ContentTypeRow def={def} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
function ContentTypeRow({ def }: { def: ExtraKindDef }) {
|
||||
const { feedSettings, updateFeedSettings } = useFeedSettings();
|
||||
const { updateSettings } = useEncryptedSettings();
|
||||
const { user } = useCurrentUser();
|
||||
const IconComponent = CONTENT_KIND_ICONS[def.id] ?? Palette;
|
||||
const icon = <IconComponent className="size-5" />;
|
||||
const hasSubKinds = !!def.subKinds;
|
||||
|
||||
const handleToggle = async (key: string, value: boolean) => {
|
||||
updateFeedSettings({ [key]: value });
|
||||
// Toggle key: prefer the feed inclusion key; fall back to the sidebar visibility key
|
||||
// for kinds that have no direct feed key of their own (e.g. parent kinds with sub-kinds).
|
||||
const toggleKey: keyof FeedSettings | undefined = def.feedKey ?? def.showKey;
|
||||
if (!toggleKey) return null;
|
||||
|
||||
const checked = feedSettings[toggleKey] !== false;
|
||||
|
||||
const handleToggle = async (value: boolean) => {
|
||||
const next: Partial<FeedSettings> = { [toggleKey]: value };
|
||||
// Parent kinds with sub-kinds: toggle all sub-kind feed keys together so the
|
||||
// single parent switch governs everything below it.
|
||||
if (def.subKinds) {
|
||||
for (const sub of def.subKinds) {
|
||||
next[sub.feedKey] = value;
|
||||
}
|
||||
}
|
||||
updateFeedSettings(next);
|
||||
if (user) {
|
||||
await updateSettings.mutateAsync({ feedSettings: { ...feedSettings, [key]: value } });
|
||||
await updateSettings.mutateAsync({ feedSettings: { ...feedSettings, ...next } });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-b border-border last:border-b-0">
|
||||
<div className="flex items-center justify-between py-3.5 px-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className="text-muted-foreground shrink-0">{icon}</span>
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium">{def.label}</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
<KindBadge kind={def.kind} />{' '}{def.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="w-[52px] flex justify-center">
|
||||
{!hasSubKinds && def.feedKey ? (
|
||||
<Switch
|
||||
checked={feedSettings[def.feedKey]}
|
||||
onCheckedChange={(checked) => handleToggle(def.feedKey!, checked)}
|
||||
/>
|
||||
) : !hasSubKinds && def.feedOnly && def.showKey ? (
|
||||
<Switch
|
||||
checked={feedSettings[def.showKey] !== false}
|
||||
onCheckedChange={(checked) => handleToggle(def.showKey!, checked)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 py-3.5 px-3">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium">{def.label}</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{def.description}</p>
|
||||
</div>
|
||||
|
||||
{hasSubKinds && def.subKinds && def.subKinds.map((sub) => (
|
||||
<SubKindRow
|
||||
key={sub.feedKey}
|
||||
sub={sub}
|
||||
/>
|
||||
))}
|
||||
<Switch checked={checked} onCheckedChange={handleToggle} className="shrink-0" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotesFeedSettings() {
|
||||
return (
|
||||
<>
|
||||
{FEED_KINDS.map((def) => (
|
||||
<ContentTypeRow key={def.id} def={def} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedSettingsFormInternals() {
|
||||
return (
|
||||
<>
|
||||
{SECTION_ORDER.map((section) => {
|
||||
const sectionKinds = EXTRA_KINDS.filter((def) => def.section === section);
|
||||
if (sectionKinds.length === 0) return null;
|
||||
return (
|
||||
<div key={section}>
|
||||
<div className="px-3 pt-4 pb-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{SECTION_LABELS[section]}
|
||||
</span>
|
||||
</div>
|
||||
{sectionKinds.map((def) => (
|
||||
<ContentTypeRow key={def.id} def={def} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Feed Tabs Section Component
|
||||
function FeedTabsSection() {
|
||||
const { toast } = useToast();
|
||||
@@ -407,14 +294,11 @@ function FeedTabsSection() {
|
||||
return (
|
||||
<div>
|
||||
{/* Intro section for Feed Tabs */}
|
||||
<div className="flex items-center gap-4 px-3 pt-3 pb-4">
|
||||
<IntroImage src="/community-intro.png" />
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold">Feed Navigation</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Manage which feed tabs appear in your navigation and follow communities by domain.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-3 pt-3 pb-4">
|
||||
<h3 className="text-sm font-semibold">Feed Navigation</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Manage which feed tabs appear in your navigation and follow communities by domain.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Feed Tab Toggles */}
|
||||
@@ -922,16 +806,10 @@ export function SensitiveContentSection() {
|
||||
return (
|
||||
<div>
|
||||
{/* Intro */}
|
||||
<div className="flex items-center gap-4 px-3 pt-3 pb-4">
|
||||
<div className="w-40 shrink-0 flex items-center justify-center">
|
||||
<ShieldAlert className="size-16 text-muted-foreground/40" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold">Content Warnings</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Some posts are tagged with content warnings (NIP-36) by their authors. This can include NSFW material, spoilers, or other sensitive content.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-3 pt-3 pb-4">
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Some posts are tagged by their authors as sensitive — NSFW, graphic, or otherwise needing a content warning. Choose how to handle them.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Policy options — consistent row style with other settings */}
|
||||
|
||||
@@ -25,6 +25,14 @@ interface CoverImageFieldProps {
|
||||
onChange: (url: string) => void;
|
||||
/** Notifies parent forms so they can block submit while Blossom upload runs. */
|
||||
onUploadingChange?: (uploading: boolean) => void;
|
||||
/**
|
||||
* Fires after a successful Blossom upload with the NIP-94-style tag
|
||||
* array returned by `useUploadFile`:
|
||||
* `[["url", "<url>"], ["x", "<sha256>"], ["ox", "<sha256>"], ["size", "<bytes>"], ["m", "image/jpeg"]]`.
|
||||
* Parents that want to publish a paired NIP-92 `imeta` tag in their
|
||||
* Nostr event should convert this array — see Kind 33863 publishing.
|
||||
*/
|
||||
onUploadComplete?: (nip94Tags: string[][]) => void;
|
||||
/** Optional template gallery shown between the dropzone and the URL input. */
|
||||
templates?: readonly CoverImageTemplate[];
|
||||
}
|
||||
@@ -45,7 +53,7 @@ interface CoverImageFieldProps {
|
||||
* anything other than a well-formed https URL — that's deliberate, since
|
||||
* the same value is what gets published in the Nostr event's `image` tag.
|
||||
*/
|
||||
export function CoverImageField({ value, onChange, onUploadingChange, templates }: CoverImageFieldProps) {
|
||||
export function CoverImageField({ value, onChange, onUploadingChange, onUploadComplete, templates }: CoverImageFieldProps) {
|
||||
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||||
const { toast } = useToast();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
@@ -71,8 +79,21 @@ export function CoverImageField({ value, onChange, onUploadingChange, templates
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [[, url]] = await uploadFile(file);
|
||||
const tags = await uploadFile(file);
|
||||
const [[, url]] = tags;
|
||||
onChange(url);
|
||||
// Forward the raw NIP-94 tag array to the parent so it can build a
|
||||
// paired NIP-92 imeta tag. The URL inside the tags is what Blossom
|
||||
// returned; the parent's `value` may pick up an appended extension
|
||||
// via the useUploadFile post-processing, but the sha256 ("x") still
|
||||
// identifies the same byte stream.
|
||||
if (onUploadComplete) {
|
||||
// Replace the URL in the first tag with the extension-corrected
|
||||
// value the parent now holds (matches the rendered banner src).
|
||||
const adjusted = tags.map((t) => [...t]);
|
||||
if (adjusted[0]?.[0] === 'url') adjusted[0][1] = url;
|
||||
onUploadComplete(adjusted);
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Upload failed',
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useToast } from '@/hooks/useToast';
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
import { createOrganizationAssociationTags } from '@/lib/organizationContext';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { withAgoraTag } from '@/lib/agoraNoteTags';
|
||||
|
||||
interface CreateCommunityEventDialogProps {
|
||||
communityATag?: string;
|
||||
@@ -307,7 +308,7 @@ export function CreateCommunityEventDialog({ communityATag, open, onOpenChange,
|
||||
const publishedEvent = await publishEvent({
|
||||
kind,
|
||||
content: description.trim(),
|
||||
tags,
|
||||
tags: withAgoraTag(tags),
|
||||
prev: prev ?? undefined,
|
||||
});
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { getEffectiveRelays } from '@/lib/appRelays';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { ZAP_GOAL_KIND } from '@/lib/goalUtils';
|
||||
import { withAgoraTag } from '@/lib/agoraNoteTags';
|
||||
|
||||
interface CreateGoalDialogProps {
|
||||
/** The community `a` tag coordinate (e.g. `34550:<pubkey>:<d-tag>`). */
|
||||
@@ -116,7 +117,7 @@ export function CreateGoalDialog({ communityATag, children, open: controlledOpen
|
||||
await publishEvent({
|
||||
kind: ZAP_GOAL_KIND,
|
||||
content: title.trim(),
|
||||
tags,
|
||||
tags: withAgoraTag(tags),
|
||||
});
|
||||
|
||||
// Refresh the goals tab and the community activity feed
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface Particle {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
life: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface Ring {
|
||||
x: number;
|
||||
y: number;
|
||||
radius: number;
|
||||
maxRadius: number;
|
||||
life: number; // 1 → 0
|
||||
}
|
||||
|
||||
function parseHslString(hsl: string): { h: number; s: number; l: number } {
|
||||
const parts = hsl.trim().split(/\s+/);
|
||||
return {
|
||||
h: parseFloat(parts[0] ?? '30'),
|
||||
s: parseFloat(parts[1] ?? '100'),
|
||||
l: parseFloat(parts[2] ?? '55'),
|
||||
};
|
||||
}
|
||||
|
||||
export function CursorFireEffect() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const particles = useRef<Particle[]>([]);
|
||||
const rings = useRef<Ring[]>([]);
|
||||
const cursor = useRef<{ x: number; y: number } | null>(null);
|
||||
const active = useRef(false);
|
||||
const raf = useRef(0);
|
||||
const pulse = useRef(0);
|
||||
const frame = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
function resize() {
|
||||
if (!canvas) return;
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
}
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
cursor.current = { x: e.clientX, y: e.clientY };
|
||||
active.current = true;
|
||||
}
|
||||
function onTouchMove(e: TouchEvent) {
|
||||
const t = e.touches[0];
|
||||
if (t) { cursor.current = { x: t.clientX, y: t.clientY }; active.current = true; }
|
||||
}
|
||||
function onLeave() { active.current = false; }
|
||||
|
||||
function onClick(e: MouseEvent) {
|
||||
spawnClickBurst(e.clientX, e.clientY);
|
||||
}
|
||||
function onTouchStart(e: TouchEvent) {
|
||||
const t = e.touches[0];
|
||||
if (t) spawnClickBurst(t.clientX, t.clientY);
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('touchmove', onTouchMove, { passive: true });
|
||||
window.addEventListener('mouseleave', onLeave);
|
||||
window.addEventListener('touchend', onLeave);
|
||||
window.addEventListener('click', onClick);
|
||||
window.addEventListener('touchstart', onTouchStart, { passive: true });
|
||||
|
||||
function getPrimary() {
|
||||
const raw = getComputedStyle(document.documentElement).getPropertyValue('--primary').trim();
|
||||
return raw ? parseHslString(raw) : { h: 270, s: 80, l: 60 };
|
||||
}
|
||||
|
||||
function spawnWispParticles(x: number, y: number) {
|
||||
const count = Math.floor(Math.random() * 2) + 2;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const angle = -Math.PI / 2 + (Math.random() - 0.5) * 0.3;
|
||||
const speed = Math.random() * 0.6 + 0.3;
|
||||
particles.current.push({
|
||||
x: x + (Math.random() - 0.5) * 6,
|
||||
y,
|
||||
vx: Math.cos(angle) * speed * 0.2,
|
||||
vy: Math.sin(angle) * speed,
|
||||
life: 1,
|
||||
size: Math.random() * 28 + 20,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function spawnClickBurst(x: number, y: number) {
|
||||
// Expanding shockwave ring
|
||||
rings.current.push({ x, y, radius: 0, maxRadius: 120, life: 1 });
|
||||
|
||||
// Secondary smaller ring
|
||||
rings.current.push({ x, y, radius: 0, maxRadius: 60, life: 1 });
|
||||
|
||||
// Radial burst of particles in all directions
|
||||
const count = 18;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const angle = (i / count) * Math.PI * 2;
|
||||
const speed = Math.random() * 3.5 + 1.5;
|
||||
particles.current.push({
|
||||
x,
|
||||
y,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
life: 1,
|
||||
size: Math.random() * 20 + 12,
|
||||
});
|
||||
}
|
||||
|
||||
// Extra upward plume
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const angle = -Math.PI / 2 + (Math.random() - 0.5) * 0.8;
|
||||
const speed = Math.random() * 4 + 2;
|
||||
particles.current.push({
|
||||
x: x + (Math.random() - 0.5) * 10,
|
||||
y,
|
||||
vx: Math.cos(angle) * speed * 0.3,
|
||||
vy: Math.sin(angle) * speed,
|
||||
life: 1,
|
||||
size: Math.random() * 30 + 18,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function draw() {
|
||||
if (!canvas || !ctx) return;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const { h, s, l } = getPrimary();
|
||||
|
||||
// Spawn wisp particles every 4th frame
|
||||
frame.current++;
|
||||
if (active.current && cursor.current && frame.current % 4 === 0) {
|
||||
spawnWispParticles(cursor.current.x, cursor.current.y);
|
||||
}
|
||||
|
||||
ctx.globalCompositeOperation = 'screen';
|
||||
|
||||
// Draw expanding rings
|
||||
const aliveRings: Ring[] = [];
|
||||
for (const r of rings.current) {
|
||||
r.life -= 0.022;
|
||||
if (r.life <= 0) continue;
|
||||
r.radius += (r.maxRadius - r.radius) * 0.08;
|
||||
|
||||
const t = r.life;
|
||||
const lineAlpha = Math.pow(t, 1.5) * 0.8;
|
||||
const glowAlpha = Math.pow(t, 2) * 0.4;
|
||||
const lineWidth = t * 3;
|
||||
|
||||
// Outer glow halo
|
||||
ctx.beginPath();
|
||||
ctx.arc(r.x, r.y, r.radius, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = `hsla(${h}, ${s}%, ${Math.min(l + 20, 85)}%, ${glowAlpha})`;
|
||||
ctx.lineWidth = lineWidth + 8;
|
||||
ctx.stroke();
|
||||
|
||||
// Sharp ring
|
||||
ctx.beginPath();
|
||||
ctx.arc(r.x, r.y, r.radius, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = `hsla(${h - 10}, ${s}%, 90%, ${lineAlpha})`;
|
||||
ctx.lineWidth = lineWidth;
|
||||
ctx.stroke();
|
||||
|
||||
aliveRings.push(r);
|
||||
}
|
||||
rings.current = aliveRings;
|
||||
|
||||
// Draw flame particles
|
||||
const alive: Particle[] = [];
|
||||
for (const p of particles.current) {
|
||||
p.life -= 0.005 + Math.random() * 0.002;
|
||||
if (p.life <= 0) continue;
|
||||
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.vy -= 0.018;
|
||||
p.vx *= 0.98;
|
||||
p.size *= 0.985;
|
||||
|
||||
const t = p.life;
|
||||
const ph = h + (1 - t) * 25;
|
||||
const pl = Math.min(l + t * 40, 90);
|
||||
const alpha = Math.pow(t, 1.5) * 0.18;
|
||||
const radius = p.size * (0.4 + t * 0.6);
|
||||
|
||||
const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, radius);
|
||||
g.addColorStop(0, `hsla(${ph - 5}, ${s}%, ${pl}%, ${alpha})`);
|
||||
g.addColorStop(0.35, `hsla(${ph}, ${s}%, ${Math.max(l, 40)}%, ${alpha * 0.6})`);
|
||||
g.addColorStop(0.7, `hsla(${ph + 15}, ${s}%, ${Math.max(l - 15, 20)}%, ${alpha * 0.2})`);
|
||||
g.addColorStop(1, `hsla(${ph + 25}, ${s}%, ${Math.max(l - 25, 5)}%, 0)`);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = g;
|
||||
ctx.fill();
|
||||
|
||||
alive.push(p);
|
||||
}
|
||||
particles.current = alive;
|
||||
|
||||
// Orb: slow pulsing core glow at cursor
|
||||
if (active.current && cursor.current) {
|
||||
const { x, y } = cursor.current;
|
||||
pulse.current += 0.025;
|
||||
const pv = (Math.sin(pulse.current) + 1) / 2;
|
||||
const r = 20 + pv * 12;
|
||||
const a = 0.5 + pv * 0.3;
|
||||
|
||||
const orb = ctx.createRadialGradient(x, y, 0, x, y, r);
|
||||
orb.addColorStop(0, `hsla(${h - 10}, ${Math.max(s - 10, 0)}%, 95%, ${a})`);
|
||||
orb.addColorStop(0.4, `hsla(${h}, ${s}%, ${Math.min(l + 15, 85)}%, ${a * 0.5})`);
|
||||
orb.addColorStop(1, `hsla(${h + 15}, ${s}%, ${l}%, 0)`);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = orb;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
raf.current = requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
raf.current = requestAnimationFrame(draw);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf.current);
|
||||
window.removeEventListener('resize', resize);
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('touchmove', onTouchMove);
|
||||
window.removeEventListener('mouseleave', onLeave);
|
||||
window.removeEventListener('touchend', onLeave);
|
||||
window.removeEventListener('click', onClick);
|
||||
window.removeEventListener('touchstart', onTouchStart);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="pointer-events-none fixed inset-0 z-[9999]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { ComposeBox } from '@/components/ComposeBox';
|
||||
|
||||
interface DetailCommentComposerProps {
|
||||
event: NostrEvent;
|
||||
placeholder?: string;
|
||||
onSuccess?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DetailCommentComposer({
|
||||
event,
|
||||
placeholder = "What's on your mind?",
|
||||
onSuccess,
|
||||
className,
|
||||
}: DetailCommentComposerProps) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<ComposeBox
|
||||
compact
|
||||
defaultExpanded
|
||||
hideBorder
|
||||
replyTo={event}
|
||||
placeholder={placeholder}
|
||||
onSuccess={onSuccess}
|
||||
className="bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+361
-840
File diff suppressed because it is too large
Load Diff
@@ -29,7 +29,6 @@ import {
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import { z } from 'zod';
|
||||
import { IntroImage } from '@/components/IntroImage';
|
||||
import { ImageCropDialog } from '@/components/ImageCropDialog';
|
||||
|
||||
// Extended form schema that includes custom fields
|
||||
@@ -248,14 +247,11 @@ export const EditProfileForm: React.FC<EditProfileFormProps> = ({ onValuesChange
|
||||
return (
|
||||
<div>
|
||||
{/* Intro */}
|
||||
<div className="flex items-center gap-4 px-3 pt-2 pb-4">
|
||||
<IntroImage src="/profile-intro.png" />
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold">Your Identity</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Customize your profile with a name, bio, images, and verification. This is how others will see you on Nostr.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-3 pt-2 pb-4">
|
||||
<h2 className="text-sm font-semibold">Your Identity</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Customize your profile with a name, bio, images, and verification. This is how others will see you on Nostr.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Crop dialog */}
|
||||
|
||||
@@ -3,8 +3,7 @@ import { Link } from 'react-router-dom';
|
||||
import { ArrowLeft, BookOpen, Coins, ExternalLink, FileText, Globe, Landmark, Languages, MapPin, Megaphone, MessageCircle, Package, Pause, Play, Repeat2, Share2, User, UserCheck, UserMinus, UserPlus, Users } from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ExternalFavicon } from '@/components/ExternalFavicon';
|
||||
import { Skeleton } from '@/components/ui/skeleton';import { ExternalFavicon } from '@/components/ExternalFavicon';
|
||||
import { ExternalReactionButton } from '@/components/ExternalReactionButton';
|
||||
import { FollowToggleButton } from '@/components/FollowButton';
|
||||
import { LinkEmbed } from '@/components/LinkEmbed';
|
||||
@@ -22,7 +21,6 @@ import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useCountryFollows } from '@/hooks/useCountryFollows';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useWeather } from '@/hooks/useWeather';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { getCountryInfo, getWikipediaTitle } from '@/lib/countries';
|
||||
@@ -638,9 +636,11 @@ function WikipediaExtract({ extract, articleUrl }: { extract: string; articleUrl
|
||||
* above the Wikipedia extract doesn't draw against a phantom row.
|
||||
*/
|
||||
function WeatherVitalsRow({ code, facts }: { code: string; facts: CountryFacts | undefined }) {
|
||||
const { data: weather, isLoading } = useWeather(code);
|
||||
const capital = facts?.capital ?? null;
|
||||
|
||||
// Weather has been removed; this row now renders only the country vitals
|
||||
// (population / languages / currency). The legacy name is preserved so
|
||||
// the mount call sites don't churn — the row still vanishes when there
|
||||
// are no vitals to show, matching the original behavior.
|
||||
void code;
|
||||
const vitals: { key: string; icon: React.ReactNode; label: string; value: string }[] = [];
|
||||
if (facts) {
|
||||
if (facts.population !== null) {
|
||||
@@ -670,40 +670,17 @@ function WeatherVitalsRow({ code, facts }: { code: string; facts: CountryFacts |
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading && vitals.length === 0) {
|
||||
return (
|
||||
<div className="px-4 py-2 flex items-center gap-3">
|
||||
<Skeleton className="size-6 rounded-md" />
|
||||
<Skeleton className="h-4 w-40" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (vitals.length === 0) return null;
|
||||
|
||||
const hasWeatherSide = !!weather || !!capital;
|
||||
const hasVitalsSide = vitals.length > 0;
|
||||
if (!hasWeatherSide && !hasVitalsSide) return null;
|
||||
const capital = facts?.capital ?? null;
|
||||
const hasCapitalSide = !!capital;
|
||||
|
||||
return (
|
||||
<div className="px-4 py-2 flex flex-wrap items-center justify-between gap-x-4 gap-y-1.5 text-sm">
|
||||
{/* Left group — weather + capital. */}
|
||||
{hasWeatherSide && (
|
||||
{/* Left group — capital. */}
|
||||
{hasCapitalSide && (
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 min-w-0">
|
||||
{weather && (
|
||||
<>
|
||||
<span className="flex items-baseline gap-2 text-foreground">
|
||||
<span className="text-xl leading-none" role="img" aria-label={weather.description}>
|
||||
{weather.icon}
|
||||
</span>
|
||||
<span className="font-bold tabular-nums">{weather.temperature}°</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">{weather.description}</span>
|
||||
</>
|
||||
)}
|
||||
{capital && (
|
||||
// The country's capital is the stable national place anchor for
|
||||
// the header. The weather-station city is intentionally omitted
|
||||
// — it's often a smaller, less-recognised town nearby and
|
||||
// duplicates a less-meaningful place name on the same line.
|
||||
<span className="flex items-center gap-1 text-muted-foreground/80 text-xs">
|
||||
<Landmark className="size-3 shrink-0" />
|
||||
<span>{capital}</span>
|
||||
@@ -712,28 +689,21 @@ function WeatherVitalsRow({ code, facts }: { code: string; facts: CountryFacts |
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right group — vitals (population, language, currency). On narrow
|
||||
viewports this wraps onto its own line under the weather group
|
||||
rather than getting crushed beside it. Styled to match the
|
||||
capital chip on the left (text-xs muted-foreground/80 with a
|
||||
size-3 icon) so the row reads as a single uniform metadata
|
||||
strip rather than two competing weights. */}
|
||||
{hasVitalsSide && (
|
||||
<ul className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground/80 min-w-0">
|
||||
{vitals.map((item) => (
|
||||
<li
|
||||
key={item.key}
|
||||
className="flex items-center gap-1 min-w-0"
|
||||
title={`${item.label}: ${item.value}`}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="truncate max-w-[14ch] sm:max-w-[18ch]">
|
||||
{item.value}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{/* Right group — vitals (population, language, currency). */}
|
||||
<ul className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground/80 min-w-0">
|
||||
{vitals.map((item) => (
|
||||
<li
|
||||
key={item.key}
|
||||
className="flex items-center gap-1 min-w-0"
|
||||
title={`${item.label}: ${item.value}`}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="truncate max-w-[14ch] sm:max-w-[18ch]">
|
||||
{item.value}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -877,7 +847,6 @@ export function CountryContentHeader({ code }: { code: string }) {
|
||||
// Country facts are only fetched for sovereign countries (alpha-2 codes);
|
||||
// the hook's internal guard returns `null` for subdivisions like `US-CA`.
|
||||
const { data: facts } = useCountryFacts(info?.subdivision ? null : code);
|
||||
const { data: weather } = useWeather(code);
|
||||
const { user } = useCurrentUser();
|
||||
const { isFollowingCountry, toggleCountryFollow, isPending } = useCountryFollows();
|
||||
const { toast } = useToast();
|
||||
@@ -916,7 +885,10 @@ export function CountryContentHeader({ code }: { code: string }) {
|
||||
// map or administrative photo, which contradicts the editorial choice
|
||||
// to surface Tibet as a country in its own right.
|
||||
const heroImage = customFlagAsset(code) ?? wiki?.originalImage?.source ?? wiki?.thumbnail?.source ?? null;
|
||||
const isDay = weather?.isDay ?? true;
|
||||
// Always render the daytime sky overlay. Previously we keyed this off the
|
||||
// live `weather.isDay` flag to flip into a night palette; weather has been
|
||||
// removed so we default to the warm amber/rose daytime tint.
|
||||
const isDay = true;
|
||||
// Sky-tint gradient layered above the hero photo. Warm amber/rose during
|
||||
// local daytime, deep indigo/violet at night. Same gradient shape, only
|
||||
// the colour palette flips — preserves the cinematic curve while the mood
|
||||
|
||||
+118
-84
@@ -2,13 +2,13 @@ import { useState, useEffect, useMemo } from 'react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { usePageRefresh } from '@/hooks/usePageRefresh';
|
||||
import { ComposeBox } from '@/components/ComposeBox';
|
||||
import { HeroAtmosphere } from '@/components/HeroAtmosphere';
|
||||
import { HeroGlobe } from '@/components/HeroGlobe';
|
||||
import { LandingHero } from '@/components/LandingHero';
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { PullToRefresh } from '@/components/PullToRefresh';
|
||||
import { FeedEmptyState } from '@/components/FeedEmptyState';
|
||||
import { FeedModeSwitcher } from '@/components/FeedModeSwitcher';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import AuthDialog from '@/components/auth/AuthDialog';
|
||||
import { useFeed } from '@/hooks/useFeed';
|
||||
@@ -16,9 +16,8 @@ import { useFollowingFeed } from '@/hooks/useFollowingFeed';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useFeedTab } from '@/hooks/useFeedTab';
|
||||
import { useMuteList } from '@/hooks/useMuteList';
|
||||
import { useAgoraFeed } from '@/hooks/useAgoraFeed';
|
||||
import { useMixedFeed, type FeedMode } from '@/hooks/useMixedFeed';
|
||||
import { shouldHideFeedEvent } from '@/lib/feedUtils';
|
||||
import { HOPE_PALETTE } from '@/lib/hopePalette';
|
||||
import { isEventMuted } from '@/lib/muteHelpers';
|
||||
import { SubHeaderBar } from '@/components/SubHeaderBar';
|
||||
import { TabButton } from '@/components/TabButton';
|
||||
@@ -39,40 +38,14 @@ interface FeedProps {
|
||||
hideCompose?: boolean;
|
||||
/** Message shown when the feed is empty. */
|
||||
emptyMessage?: string;
|
||||
/** Unique identifier for this feed page, used to persist the active tab in sessionStorage. Defaults to 'home'. */
|
||||
/** Unique identifier for this feed page, used to persist the active tab/mode in localStorage. Defaults to 'home'. */
|
||||
feedId?: string;
|
||||
}
|
||||
|
||||
const FEED_BACKDROP_HUE_INTERVAL_MS = 45_000;
|
||||
const FEED_BACKDROP_HUE_FADE_MS = 18_000;
|
||||
const AGORA_DEFAULT_NOTE_TAGS = [['t', 'agora']];
|
||||
const FEED_MODES: readonly FeedMode[] = ['agora', 'all-nostr', 'following'] as const;
|
||||
|
||||
function FeedGlobeBackground() {
|
||||
const [hueIndex, setHueIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => {
|
||||
setHueIndex((i) => (i + 1) % HOPE_PALETTE.length);
|
||||
}, FEED_BACKDROP_HUE_INTERVAL_MS);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const activeHue = HOPE_PALETTE[hueIndex];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-0 pointer-events-none overflow-hidden bg-secondary/30" aria-hidden="true">
|
||||
<HeroAtmosphere hue={activeHue} fadeMs={FEED_BACKDROP_HUE_FADE_MS} className="opacity-55" />
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-background/10 via-background/20 to-background/55" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<HeroGlobe
|
||||
hue={activeHue}
|
||||
className="aspect-square max-w-none opacity-70 drop-shadow-2xl"
|
||||
style={{ width: 'clamp(552px, 86.4dvw, 984px)' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-background/70" />
|
||||
</div>
|
||||
);
|
||||
function isFeedMode(value: string): value is FeedMode {
|
||||
return (FEED_MODES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, feedId = 'home' }: FeedProps = {}) {
|
||||
@@ -83,14 +56,19 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
const [authDialogOpen, setAuthDialogOpen] = useState(false);
|
||||
const isHomeAgoraFeed = !kinds && !tagFilters;
|
||||
|
||||
// The home feed is Agora-only. Specialized feed pages keep Follows + Global.
|
||||
// For the home /feed page we use a three-mode picker instead of the
|
||||
// Follows/Global tab pair. Mode persists via the same useFeedTab storage,
|
||||
// keyed under the same feedId.
|
||||
const homeFeedMode: FeedMode = (() => {
|
||||
if (!isHomeAgoraFeed) return 'agora';
|
||||
if (isFeedMode(rawActiveTab)) return rawActiveTab;
|
||||
// Legacy values get coerced to the Agora default.
|
||||
return 'agora';
|
||||
})();
|
||||
|
||||
// Specialized feed pages keep the original Follows + Global tabs.
|
||||
const activeTab: FeedTab = (() => {
|
||||
if (isHomeAgoraFeed) return 'agora';
|
||||
if (!kinds) {
|
||||
if (rawActiveTab === 'global') return 'global';
|
||||
if (rawActiveTab === 'follows' && user) return 'follows';
|
||||
return user ? 'follows' : 'global';
|
||||
}
|
||||
if (isHomeAgoraFeed) return homeFeedMode;
|
||||
if (rawActiveTab === 'global') return 'global';
|
||||
if (rawActiveTab === 'follows' && user) return 'follows';
|
||||
return user ? 'follows' : 'global';
|
||||
@@ -106,43 +84,48 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
}
|
||||
}, [rawActiveTab, handleSetActiveTab]);
|
||||
|
||||
const handleModeChange = (mode: FeedMode) => {
|
||||
handleSetActiveTab(mode);
|
||||
};
|
||||
|
||||
// Kind-specific pages (e.g. Development, WebXDC) only show Follows + Global tabs.
|
||||
const isKindSpecificPage = !!kinds;
|
||||
|
||||
// When the Agora tab is active, show the mixed Agora activity feed.
|
||||
// Disabled on kind-specific pages — the Agora tab is not shown there.
|
||||
const isAgoraActive = isHomeAgoraFeed;
|
||||
// -------------------------------------------------------------------------
|
||||
// Home feed (mixed-mode): drives off useMixedFeed.
|
||||
// -------------------------------------------------------------------------
|
||||
const mixedFeed = useMixedFeed(homeFeedMode, isHomeAgoraFeed);
|
||||
|
||||
// Standard feed query (used when logged in, or on kind-specific pages, or core tabs)
|
||||
const isHomeFollowingActive = activeTab === 'follows' && !isKindSpecificPage && !tagFilters;
|
||||
// -------------------------------------------------------------------------
|
||||
// Specialized feed pages: original Follows/Global behavior.
|
||||
// -------------------------------------------------------------------------
|
||||
const isHomeFollowingActive = activeTab === 'follows' && !isKindSpecificPage && !tagFilters && !isHomeAgoraFeed;
|
||||
const isCoreFeedTab = activeTab === 'follows' || activeTab === 'network' || activeTab === 'global' || activeTab === 'communities' || activeTab === 'world' || activeTab === 'agora';
|
||||
type UseFeedTab = 'follows' | 'network' | 'global' | 'communities';
|
||||
const feedTabForQuery: UseFeedTab =
|
||||
activeTab === 'follows'
|
||||
? (isHomeFollowingActive ? 'network' : 'network')
|
||||
? 'network'
|
||||
: activeTab === 'network' || activeTab === 'global' || activeTab === 'communities'
|
||||
? (activeTab as UseFeedTab)
|
||||
: 'global';
|
||||
const standardFeedOptions = (kinds || tagFilters)
|
||||
? { kinds, tagFilters, enabled: !isHomeFollowingActive && !isAgoraActive }
|
||||
: { enabled: !isHomeFollowingActive && !isAgoraActive };
|
||||
? { kinds, tagFilters, enabled: !isHomeFollowingActive && !isHomeAgoraFeed }
|
||||
: { enabled: !isHomeFollowingActive && !isHomeAgoraFeed };
|
||||
const feedQuery = useFeed(
|
||||
isCoreFeedTab && !isAgoraActive ? feedTabForQuery : 'global',
|
||||
isCoreFeedTab && !isHomeAgoraFeed ? feedTabForQuery : 'global',
|
||||
standardFeedOptions,
|
||||
);
|
||||
|
||||
const followingFeed = useFollowingFeed(isHomeFollowingActive);
|
||||
|
||||
const agoraFeed = useAgoraFeed(isAgoraActive);
|
||||
|
||||
// For non-world tabs, use the standard feed query
|
||||
const queryKey = useMemo(
|
||||
() => isAgoraActive
|
||||
? ['agora-feed']
|
||||
() => isHomeAgoraFeed
|
||||
? ['mixed-feed', homeFeedMode]
|
||||
: isHomeFollowingActive
|
||||
? [['feed', 'network'], ['community-activity-feed'], ['following-country-feed']]
|
||||
: ['feed', activeTab],
|
||||
[isAgoraActive, isHomeFollowingActive, activeTab],
|
||||
[isHomeAgoraFeed, homeFeedMode, isHomeFollowingActive, activeTab],
|
||||
);
|
||||
|
||||
const handleRefresh = usePageRefresh(queryKey);
|
||||
@@ -157,16 +140,16 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
} = isHomeFollowingActive ? followingFeed : feedQuery;
|
||||
|
||||
// Unify pagination interface
|
||||
const fetchNextPage = isAgoraActive ? agoraFeed.fetchNextPage : fetchNextPageStandard;
|
||||
const hasNextPage = isAgoraActive ? agoraFeed.hasNextPage : hasNextPageStandard;
|
||||
const isFetchingNextPage = isAgoraActive ? agoraFeed.isFetchingNextPage : isFetchingNextPageStandard;
|
||||
const fetchNextPage = isHomeAgoraFeed ? mixedFeed.fetchNextPage : fetchNextPageStandard;
|
||||
const hasNextPage = isHomeAgoraFeed ? mixedFeed.hasNextPage : hasNextPageStandard;
|
||||
const isFetchingNextPage = isHomeAgoraFeed ? mixedFeed.isFetchingNextPage : isFetchingNextPageStandard;
|
||||
|
||||
// Auto-fetch page 2 as soon as page 1 arrives for smoother scrolling
|
||||
useEffect(() => {
|
||||
if (!isHomeFollowingActive && !isAgoraActive && hasNextPage && !isFetchingNextPage && rawData?.pages?.length === 1) {
|
||||
if (!isHomeFollowingActive && !isHomeAgoraFeed && hasNextPage && !isFetchingNextPage && rawData?.pages?.length === 1) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [isHomeFollowingActive, isAgoraActive, hasNextPage, isFetchingNextPage, rawData?.pages?.length, fetchNextPage]);
|
||||
}, [isHomeFollowingActive, isHomeAgoraFeed, hasNextPage, isFetchingNextPage, rawData?.pages?.length, fetchNextPage]);
|
||||
|
||||
// Intersection observer for infinite scroll
|
||||
const { ref: scrollRef, inView } = useInView({
|
||||
@@ -182,8 +165,8 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
|
||||
// Flatten, deduplicate, and filter muted content.
|
||||
const feedItems = useMemo(() => {
|
||||
if (isAgoraActive) {
|
||||
return agoraFeed.events.map((event): FeedItem => ({ event, sortTimestamp: event.created_at }));
|
||||
if (isHomeAgoraFeed) {
|
||||
return mixedFeed.items;
|
||||
}
|
||||
|
||||
if (!rawData?.pages) return [];
|
||||
@@ -199,24 +182,29 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
if (muteItems.length > 0 && isEventMuted(item.event, muteItems)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [isAgoraActive, agoraFeed.events, rawData?.pages, muteItems]);
|
||||
}, [isHomeAgoraFeed, mixedFeed.items, rawData?.pages, muteItems]);
|
||||
|
||||
// Show skeletons while loading.
|
||||
const showSkeleton = isAgoraActive
|
||||
? agoraFeed.isLoading
|
||||
const showSkeleton = isHomeAgoraFeed
|
||||
? mixedFeed.isLoading && feedItems.length === 0
|
||||
: (isPending || (isLoading && !rawData));
|
||||
|
||||
const useGlobeBackdrop = feedId === 'home' && !kinds && !tagFilters && !header;
|
||||
const translucentCardClassName = useGlobeBackdrop
|
||||
? 'bg-transparent border-border/50 hover:bg-transparent'
|
||||
: undefined;
|
||||
const transparentFeedSurfaceClassName = useGlobeBackdrop ? 'bg-transparent' : undefined;
|
||||
// Per-mode empty-state copy for the home feed.
|
||||
const homeEmptyMessage = (() => {
|
||||
if (homeFeedMode === 'agora') {
|
||||
return "Quiet moment on Agora. New campaigns, pledges, donations, and posts will appear here as they happen.";
|
||||
}
|
||||
if (homeFeedMode === 'following') {
|
||||
return user
|
||||
? "Your follow feed is empty. Follow some people to see what they're up to, or switch to Agora or All Nostr."
|
||||
: "Log in to see posts from people you follow.";
|
||||
}
|
||||
return 'Nothing to show. Check your relay connections or try again in a moment.';
|
||||
})();
|
||||
|
||||
return (
|
||||
<main className={cn('flex-1 min-w-0 min-h-dvh', useGlobeBackdrop && 'relative isolate overflow-x-clip')}>
|
||||
{useGlobeBackdrop && <FeedGlobeBackground />}
|
||||
|
||||
<div className={cn(useGlobeBackdrop && 'relative z-10')}>
|
||||
<main className="flex-1 min-w-0 min-h-dvh bg-background">
|
||||
<div>
|
||||
{header}
|
||||
|
||||
{/* CTA (logged out, main feed only) */}
|
||||
@@ -224,20 +212,31 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
<LandingHero onJoinClick={() => setAuthDialogOpen(true)} />
|
||||
)}
|
||||
|
||||
{/* Home-feed mode switcher: top-left, anchors the page visually */}
|
||||
{isHomeAgoraFeed && (
|
||||
<div className="px-4 pt-5 pb-3 sm:pt-6">
|
||||
<FeedModeSwitcher
|
||||
value={homeFeedMode}
|
||||
onChange={handleModeChange}
|
||||
followingAvailable={!!user}
|
||||
onLoginRequested={() => setAuthDialogOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hideCompose && (
|
||||
<ComposeBox
|
||||
compact
|
||||
hideBorder
|
||||
className={transparentFeedSurfaceClassName}
|
||||
defaultTags={AGORA_DEFAULT_NOTE_TAGS}
|
||||
hideBorder={isHomeAgoraFeed}
|
||||
defaultExpanded
|
||||
placeholder="What's happening?"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tabs are only kept for specialized feed pages. The home feed is Agora-only. */}
|
||||
{/* Tabs are only kept for specialized feed pages. The home feed uses
|
||||
the FeedModeSwitcher above. */}
|
||||
{user && (isKindSpecificPage || tagFilters) && (
|
||||
<SubHeaderBar backgroundFillClassName={transparentFeedSurfaceClassName && 'fill-transparent'}>
|
||||
<SubHeaderBar>
|
||||
<TabButton label={isKindSpecificPage || tagFilters ? 'Follows' : 'Following'} active={activeTab === 'follows'} onClick={() => handleSetActiveTab('follows')} />
|
||||
<TabButton label="Global" active={activeTab === 'global'} onClick={() => handleSetActiveTab('global')} />
|
||||
</SubHeaderBar>
|
||||
@@ -247,7 +246,7 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
{showSkeleton ? (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<NoteCardSkeleton key={i} className={translucentCardClassName} />
|
||||
<NoteCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : feedItems.length > 0 ? (
|
||||
@@ -257,7 +256,6 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
key={item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id}
|
||||
event={item.event}
|
||||
repostedBy={item.repostedBy}
|
||||
className={translucentCardClassName}
|
||||
/>
|
||||
))}
|
||||
{hasNextPage && (
|
||||
@@ -270,15 +268,20 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : isHomeAgoraFeed ? (
|
||||
<HomeFeedEmptyState
|
||||
mode={homeFeedMode}
|
||||
message={homeEmptyMessage}
|
||||
onSwitchToAgora={homeFeedMode !== 'agora' ? () => handleModeChange('agora') : undefined}
|
||||
onLoginClick={!user && homeFeedMode === 'following' ? () => setAuthDialogOpen(true) : undefined}
|
||||
/>
|
||||
) : (
|
||||
<FeedEmptyState
|
||||
message={
|
||||
emptyMessage ?? (
|
||||
activeTab === 'follows'
|
||||
? 'Your feed is empty. Follow some people to see their posts here.'
|
||||
: activeTab === 'agora'
|
||||
? 'No Agora activity found. Check your relay connections or come back soon.'
|
||||
: 'No posts found. Check your relay connections or come back soon.'
|
||||
: 'No posts found. Check your relay connections or come back soon.'
|
||||
)
|
||||
}
|
||||
showDiscover={!emptyMessage && activeTab === 'follows'}
|
||||
@@ -303,6 +306,37 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
);
|
||||
}
|
||||
|
||||
interface HomeFeedEmptyStateProps {
|
||||
mode: FeedMode;
|
||||
message: string;
|
||||
onSwitchToAgora?: () => void;
|
||||
onLoginClick?: () => void;
|
||||
}
|
||||
|
||||
function HomeFeedEmptyState({ mode, message, onSwitchToAgora, onLoginClick }: HomeFeedEmptyStateProps) {
|
||||
return (
|
||||
<div className="py-20 px-8 flex flex-col items-center text-center">
|
||||
<p className="text-muted-foreground max-w-sm leading-relaxed">{message}</p>
|
||||
<div className="flex flex-col gap-2 mt-6 w-full max-w-xs">
|
||||
{onLoginClick && (
|
||||
<Button className="rounded-full" onClick={onLoginClick}>
|
||||
Log in
|
||||
</Button>
|
||||
)}
|
||||
{onSwitchToAgora && (
|
||||
<Button
|
||||
variant={mode === 'following' ? 'default' : 'ghost'}
|
||||
className="rounded-full"
|
||||
onClick={onSwitchToAgora}
|
||||
>
|
||||
Browse the Agora feed
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoteCardSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn('px-4 py-3 border-b border-border', className)}>
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Check, ChevronDown, Globe, Sparkles, Users } from 'lucide-react';
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import type { FeedMode } from '@/hooks/useMixedFeed';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface FeedModeOption {
|
||||
mode: FeedMode;
|
||||
label: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
const OPTIONS: FeedModeOption[] = [
|
||||
{ mode: 'agora', label: 'Agora', icon: Sparkles },
|
||||
{ mode: 'all-nostr', label: 'All Nostr', icon: Globe },
|
||||
{ mode: 'following', label: 'Following', icon: Users },
|
||||
];
|
||||
|
||||
interface FeedModeSwitcherProps {
|
||||
value: FeedMode;
|
||||
onChange: (mode: FeedMode) => void;
|
||||
/** When false, Following mode is disabled (requires login). */
|
||||
followingAvailable: boolean;
|
||||
/** Click handler for the disabled Following item (typically opens the auth dialog). */
|
||||
onLoginRequested?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The primary feed-mode picker rendered at the top-left of the home feed page.
|
||||
*
|
||||
* Visually anchored as the page heading — the active mode label is the largest
|
||||
* text on the page. Clicking opens a compact dropdown menu offering the three
|
||||
* modes; the active one is marked with a check.
|
||||
*
|
||||
* Logged-out users see "Following" greyed out; clicking it invokes
|
||||
* {@link FeedModeSwitcherProps.onLoginRequested} to surface the auth dialog.
|
||||
*/
|
||||
export function FeedModeSwitcher({
|
||||
value,
|
||||
onChange,
|
||||
followingAvailable,
|
||||
onLoginRequested,
|
||||
className,
|
||||
}: FeedModeSwitcherProps) {
|
||||
const active = OPTIONS.find((opt) => opt.mode === value) ?? OPTIONS[0];
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
'group inline-flex items-center gap-2 rounded-lg -ml-1 px-1 py-1 outline-none',
|
||||
'text-foreground hover:text-foreground motion-safe:transition-colors',
|
||||
'focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||
className,
|
||||
)}
|
||||
aria-label={`Feed mode: ${active.label}. Click to change.`}
|
||||
>
|
||||
<span className="text-2xl sm:text-3xl font-bold tracking-tight leading-none">
|
||||
{active.label}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className="size-5 text-muted-foreground motion-safe:transition-transform group-data-[state=open]:rotate-180"
|
||||
aria-hidden
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" sideOffset={8} className="w-56 p-1.5">
|
||||
{OPTIONS.map((opt) => {
|
||||
const Icon = opt.icon;
|
||||
const isActive = opt.mode === value;
|
||||
const isFollowing = opt.mode === 'following';
|
||||
const disabled = isFollowing && !followingAvailable;
|
||||
|
||||
const handleSelect = (event: Event) => {
|
||||
if (disabled) {
|
||||
event.preventDefault();
|
||||
onLoginRequested?.();
|
||||
return;
|
||||
}
|
||||
onChange(opt.mode);
|
||||
};
|
||||
|
||||
const itemContent = (
|
||||
<DropdownMenuItem
|
||||
key={opt.mode}
|
||||
onSelect={handleSelect}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 cursor-pointer',
|
||||
disabled && 'opacity-60 data-[disabled]:opacity-60',
|
||||
)}
|
||||
data-disabled={disabled || undefined}
|
||||
>
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<span className="flex-1 text-sm font-medium">{opt.label}</span>
|
||||
{isActive && <Check className="size-4 shrink-0 text-primary" aria-hidden />}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<Tooltip key={opt.mode}>
|
||||
<TooltipTrigger asChild>{itemContent}</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
Log in to see posts from people you follow
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return itemContent;
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -102,7 +102,7 @@ function FundraiserLayoutInner() {
|
||||
|
||||
function SiteFooter() {
|
||||
return (
|
||||
<footer className="border-t border-border bg-background mt-auto">
|
||||
<footer className="bg-background mt-auto pt-12">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 py-8 flex flex-col sm:flex-row items-center justify-between gap-4 text-xs text-muted-foreground">
|
||||
<span>© {new Date().getFullYear()} Agora. Fundraisers on Nostr.</span>
|
||||
<nav className="flex items-center gap-5">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight, MapPin } from 'lucide-react';
|
||||
import { ArrowRight, MapPin, ShieldCheck } from 'lucide-react';
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
@@ -10,7 +10,7 @@ import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { useCampaignDonations } from '@/hooks/useCampaignDonations';
|
||||
import { useBtcPrice } from '@/hooks/useBtcPrice';
|
||||
import { formatCampaignAmount } from '@/lib/formatCampaignAmount';
|
||||
import { formatCampaignAmount, formatUsdGoal, satsToUsd } from '@/lib/formatCampaignAmount';
|
||||
|
||||
interface HeroCampaignSpotlightProps {
|
||||
/** Campaign to feature. `null` renders the empty placeholder. */
|
||||
@@ -39,7 +39,7 @@ export function HeroCampaignSpotlight({
|
||||
// when there's no campaign yet we pass an empty pubkey and ignore the
|
||||
// (no-op) result below. Same for donations + BTC price.
|
||||
const author = useAuthor(campaign?.pubkey ?? '');
|
||||
const { data: stats } = useCampaignDonations(campaign?.aTag);
|
||||
const { data: stats } = useCampaignDonations(campaign ?? undefined);
|
||||
const { data: btcPrice } = useBtcPrice();
|
||||
|
||||
if (isLoading && !campaign) {
|
||||
@@ -59,6 +59,7 @@ export function HeroCampaignSpotlight({
|
||||
const authorName = meta?.display_name || meta?.name || genUserName(campaign.pubkey);
|
||||
const authorPicture = sanitizeUrl(meta?.picture);
|
||||
const countryLabel = getCampaignCountryLabel(campaign);
|
||||
const isSilentPayment = campaign.wallet.mode === 'sp';
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -83,12 +84,28 @@ export function HeroCampaignSpotlight({
|
||||
so we can tune the bar for legibility on top of a photo: dark
|
||||
translucent track, glowing primary fill. When the campaign has no
|
||||
goal tag, the bar is omitted entirely and we only show the raised
|
||||
total. */}
|
||||
{(() => {
|
||||
total. Silent-payment campaigns hide totals by design (per
|
||||
NIP.md Kind 33863). */}
|
||||
{isSilentPayment ? (
|
||||
<div className="space-y-1.5 pt-1 max-w-xs">
|
||||
<div className="inline-flex items-center gap-1.5 text-[11px] text-white/85 [text-shadow:none]">
|
||||
<ShieldCheck className="size-3" />
|
||||
<span>Private campaign — totals not public</span>
|
||||
</div>
|
||||
{campaign.goalUsd && campaign.goalUsd > 0 && (
|
||||
<div className="text-[11px] text-white/70 [text-shadow:none]">
|
||||
Target: {formatUsdGoal(campaign.goalUsd)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (() => {
|
||||
const raised = stats?.totalSats ?? 0;
|
||||
const goal = campaign.goalSats;
|
||||
const goal = campaign.goalUsd;
|
||||
const hasGoal = !!goal && goal > 0;
|
||||
const pct = hasGoal ? Math.min(100, Math.round((raised / goal!) * 100)) : 0;
|
||||
const raisedUsd = satsToUsd(raised, btcPrice);
|
||||
const pct = hasGoal && raisedUsd !== undefined
|
||||
? Math.min(100, Math.round((raisedUsd / goal!) * 100))
|
||||
: 0;
|
||||
return (
|
||||
<div className="space-y-1.5 pt-1 max-w-xs">
|
||||
{hasGoal && (
|
||||
@@ -106,7 +123,7 @@ export function HeroCampaignSpotlight({
|
||||
</span>
|
||||
{hasGoal && (
|
||||
<span className="text-white/70">
|
||||
of {formatCampaignAmount(goal!, btcPrice)} goal
|
||||
of {formatUsdGoal(goal!)} goal
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
interface IntroImageProps {
|
||||
src: string;
|
||||
/** Tailwind size class, e.g. "w-40" (default) or "w-10" */
|
||||
size?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function IntroImage({ src, size = 'w-40', className }: IntroImageProps) {
|
||||
return (
|
||||
<div
|
||||
className={`${size} shrink-0 bg-primary opacity-90 ${className ?? ''}`}
|
||||
style={{
|
||||
maskImage: `url(${src})`,
|
||||
maskSize: 'contain',
|
||||
maskRepeat: 'no-repeat',
|
||||
maskPosition: 'center',
|
||||
WebkitMaskImage: `url(${src})`,
|
||||
WebkitMaskSize: 'contain',
|
||||
WebkitMaskRepeat: 'no-repeat',
|
||||
WebkitMaskPosition: 'center',
|
||||
aspectRatio: '1 / 1',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -4,10 +4,8 @@ import { LeftSidebar } from '@/components/LeftSidebar';
|
||||
import { MobileTopBar } from '@/components/MobileTopBar';
|
||||
import { MobileDrawer } from '@/components/MobileDrawer';
|
||||
import { FloatingComposeButton } from '@/components/FloatingComposeButton';
|
||||
import { CursorFireEffect } from '@/components/CursorFireEffect';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { CenterColumnContext, DrawerContext, LayoutStore, LayoutStoreContext, NavHiddenContext, useLayoutSnapshot } from '@/contexts/LayoutContext';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useScrollDirection } from '@/hooks/useScrollDirection';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -55,15 +53,11 @@ function MainLayoutInner() {
|
||||
const openDrawer = useCallback(() => setDrawerOpen(true), []);
|
||||
const centerColumnRef = useRef<HTMLDivElement>(null);
|
||||
const [centerColumnEl, setCenterColumnEl] = useState<HTMLElement | null>(null);
|
||||
const { config } = useAppContext();
|
||||
const { hidden: navHidden } = useScrollDirection(scrollContainer);
|
||||
return (
|
||||
<CenterColumnContext.Provider value={centerColumnEl}>
|
||||
<DrawerContext.Provider value={openDrawer}>
|
||||
<NavHiddenContext.Provider value={navHidden}>
|
||||
{/* Magic Mouse fire particle overlay */}
|
||||
{config.magicMouse && <CursorFireEffect />}
|
||||
|
||||
{/* Mobile top bar - only on small screens, hidden when page requests immersive mode */}
|
||||
{!hideTopBar && <MobileTopBar onAvatarClick={() => setDrawerOpen(true)} hasSubHeader={hasSubHeader} />}
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ export function MobileBottomNav() {
|
||||
{/* Organizations */}
|
||||
<NavItem
|
||||
icon={Users}
|
||||
label="Organize"
|
||||
label="Groups"
|
||||
active={isOnCommunities}
|
||||
to="/communities"
|
||||
onClick={() => { selectionChanged(); setSearchOpen(false); }}
|
||||
|
||||
+17
-27
@@ -6,6 +6,7 @@ import {
|
||||
FileText,
|
||||
GitBranch,
|
||||
GitPullRequest,
|
||||
HandHeart,
|
||||
Mail,
|
||||
Megaphone,
|
||||
MessageCircle,
|
||||
@@ -33,13 +34,14 @@ import {
|
||||
} from "@/components/AudioKindContent";
|
||||
import { ActionContent } from "@/components/ActionContent";
|
||||
import { BadgeContent } from "@/components/BadgeContent";
|
||||
import { CampaignNoteCardContent } from "@/components/CampaignNoteCardContent";
|
||||
import { CommunityContent } from "@/components/CommunityContent";
|
||||
import { CalendarEventContent } from "@/components/CalendarEventContent";
|
||||
import {
|
||||
ColorMomentContent,
|
||||
ColorMomentEyeButton,
|
||||
} from "@/components/ColorMomentContent";
|
||||
import { CommentContext, CountryCommentPill, CountryFlagBackdrop } from "@/components/CommentContext";
|
||||
import { CommentContext, CountryCommentPill } from "@/components/CommentContext";
|
||||
import { CommunityContentWarning } from "@/components/CommunityContentWarning";
|
||||
import { ContentWarningGuard } from "@/components/ContentWarningGuard";
|
||||
import { EmojifiedText, ReactionEmoji } from "@/components/CustomEmoji";
|
||||
@@ -58,7 +60,6 @@ import { ChestIcon } from "@/components/icons/ChestIcon";
|
||||
import { RepostIcon } from "@/components/icons/RepostIcon";
|
||||
import { LiveStreamPlayer } from "@/components/LiveStreamPlayer";
|
||||
import { MagicDeckContent } from "@/components/MagicDeckContent";
|
||||
import { Nip05Badge } from "@/components/Nip05Badge";
|
||||
import { NoteContent } from "@/components/NoteContent";
|
||||
import { NoteMoreMenu } from "@/components/NoteMoreMenu";
|
||||
import { PatchCard } from "@/components/PatchCard";
|
||||
@@ -86,7 +87,6 @@ import { ZapDialog } from "@/components/ZapDialog";
|
||||
import { useAppContext } from "@/hooks/useAppContext";
|
||||
import { useAuthor } from "@/hooks/useAuthor";
|
||||
import { useCurrentUser } from "@/hooks/useCurrentUser";
|
||||
import { useNip05Verify } from "@/hooks/useNip05Verify";
|
||||
import { useOpenPost } from "@/hooks/useOpenPost";
|
||||
import { useProfileUrl } from "@/hooks/useProfileUrl";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
@@ -398,11 +398,6 @@ export const NoteCard = memo(function NoteCard({
|
||||
const metadata = author.data?.metadata;
|
||||
const actionMetadata = actionEvent ? actionAuthor.data?.metadata : metadata;
|
||||
const displayName = getDisplayName(metadata, event.pubkey);
|
||||
const nip05 = metadata?.nip05;
|
||||
const { data: nip05Verified, isPending: nip05Pending } = useNip05Verify(
|
||||
nip05,
|
||||
event.pubkey,
|
||||
);
|
||||
const profileUrl = useProfileUrl(event.pubkey, metadata);
|
||||
const encodedId = useMemo(() => encodeEventId(actionTarget), [actionTarget]);
|
||||
const { data: stats } = useEventStats(actionTarget.id, actionTarget);
|
||||
@@ -474,6 +469,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
const isCommunity = event.kind === 34550;
|
||||
const isZapGoal = event.kind === 9041;
|
||||
const isAction = event.kind === 36639;
|
||||
const isCampaign = event.kind === 33863;
|
||||
const isReaction = event.kind === 7;
|
||||
const isPollVote = event.kind === 1018;
|
||||
const isRepost = event.kind === 6 || event.kind === 16;
|
||||
@@ -520,6 +516,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
!isCommunity &&
|
||||
!isZapGoal &&
|
||||
!isAction &&
|
||||
!isCampaign &&
|
||||
!isReaction &&
|
||||
!isPollVote &&
|
||||
!isRepost &&
|
||||
@@ -683,6 +680,9 @@ export const NoteCard = memo(function NoteCard({
|
||||
) : isAction ? (
|
||||
<ActionContent event={event} />
|
||||
|
||||
) : isCampaign ? (
|
||||
<CampaignNoteCardContent event={event} />
|
||||
|
||||
) : isVoiceMessage ? (
|
||||
<VoiceMessagePlayer event={event} />
|
||||
) : isCalendarEvent ? (
|
||||
@@ -776,12 +776,6 @@ export const NoteCard = memo(function NoteCard({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground min-w-0 pr-2">
|
||||
{nip05 && nip05Pending && <Skeleton className="h-3 w-24" />}
|
||||
{nip05 && nip05Pending && <span className="shrink-0">·</span>}
|
||||
{nip05 && nip05Verified && (
|
||||
<Nip05Badge nip05={nip05} pubkey={event.pubkey} />
|
||||
)}
|
||||
{nip05 && nip05Verified && <span className="shrink-0">·</span>}
|
||||
<span className="shrink-0 hover:underline whitespace-nowrap">
|
||||
{timeAgo(event.created_at)}
|
||||
</span>
|
||||
@@ -1111,9 +1105,6 @@ export const NoteCard = memo(function NoteCard({
|
||||
onClick={handleCardClick}
|
||||
onAuxClick={handleAuxClick}
|
||||
>
|
||||
<CountryFlagBackdrop event={event} />
|
||||
{/* Foreground wrapper — `relative` lifts the entire post above the
|
||||
absolute backdrop layer rendered by CountryFlagBackdrop. */}
|
||||
<div className="relative">
|
||||
{threadedKindHeader && (
|
||||
<div>
|
||||
@@ -1164,9 +1155,6 @@ export const NoteCard = memo(function NoteCard({
|
||||
onClick={handleCardClick}
|
||||
onAuxClick={handleAuxClick}
|
||||
>
|
||||
<CountryFlagBackdrop event={event} />
|
||||
{/* Foreground wrapper — `relative` lifts the entire post above the
|
||||
absolute backdrop layer rendered by CountryFlagBackdrop. */}
|
||||
<div className="relative">
|
||||
<div>
|
||||
{/* Action header — repost takes priority, otherwise derived from event kind */}
|
||||
@@ -1206,17 +1194,13 @@ export const NoteCard = memo(function NoteCard({
|
||||
})()
|
||||
)}
|
||||
|
||||
{/* Header: avatar + name/handle stacked. The country pill is
|
||||
appended outside this flag-mode wrapper as a flex sibling, so
|
||||
it keeps its own surface treatment. */}
|
||||
{/* Header: avatar + name/handle with the country pill anchored
|
||||
right. The pill is a flex sibling of the author row so it
|
||||
keeps its own surface treatment regardless of context. */}
|
||||
<div className="flex items-center gap-3">
|
||||
{avatarElement}
|
||||
{authorInfo}
|
||||
{isColor && <ColorMomentEyeButton event={event} />}
|
||||
{/* Country pill — rendered outside the flag-mode color flip via
|
||||
`[&]:` to escape the parent's color rules. It's wrapped in
|
||||
its own flex slot so the row layout matches the non-flag
|
||||
case (pill anchored right). */}
|
||||
<CountryCommentPill
|
||||
event={event}
|
||||
className="shrink-0 [text-shadow:none]"
|
||||
@@ -1814,6 +1798,12 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
|
||||
noun: "organization",
|
||||
nounRoute: "/communities",
|
||||
},
|
||||
33863: {
|
||||
icon: HandHeart,
|
||||
action: (event) => publishedAtAction(event, { created: "launched a", updated: "updated a", fallback: "shared a" }),
|
||||
noun: "campaign",
|
||||
nounRoute: "/campaigns/all",
|
||||
},
|
||||
30009: {
|
||||
icon: Award,
|
||||
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "created a" }),
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Pin } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface PinnedCommentHeaderProps {
|
||||
isPinned: boolean;
|
||||
canManagePins: boolean;
|
||||
pinPending: boolean;
|
||||
onTogglePin: () => void;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function PinnedCommentHeader({
|
||||
isPinned,
|
||||
canManagePins,
|
||||
pinPending,
|
||||
onTogglePin,
|
||||
children,
|
||||
}: PinnedCommentHeaderProps) {
|
||||
if (!isPinned && !canManagePins && !children) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 px-4 pt-3 pb-0 text-xs text-muted-foreground">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{isPinned && (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-2 py-0.5 font-medium text-primary">
|
||||
<Pin className="size-3 rotate-45 fill-current" />
|
||||
Pinned
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
{canManagePins && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTogglePin();
|
||||
}}
|
||||
disabled={pinPending}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 font-medium transition-colors hover:bg-primary/10 hover:text-primary disabled:cursor-not-allowed disabled:opacity-60',
|
||||
isPinned && 'text-primary',
|
||||
)}
|
||||
>
|
||||
<Pin className={cn('size-3 rotate-45', isPinned && 'fill-current')} />
|
||||
{isPinned ? 'Unpin' : 'Pin'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
import { useEffect, useRef, memo } from 'react';
|
||||
|
||||
import type { PrecipitationIntensity, PrecipitationType } from '@/hooks/useWeather';
|
||||
|
||||
interface PrecipitationEffectProps {
|
||||
type: PrecipitationType;
|
||||
intensity: PrecipitationIntensity;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Particle pool sizes by intensity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const RAIN_COUNT: Record<PrecipitationIntensity, number> = {
|
||||
light: 80,
|
||||
moderate: 160,
|
||||
heavy: 280,
|
||||
};
|
||||
|
||||
const SNOW_COUNT: Record<PrecipitationIntensity, number> = {
|
||||
light: 50,
|
||||
moderate: 100,
|
||||
heavy: 180,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Raindrop particle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface RainDrop {
|
||||
x: number;
|
||||
y: number;
|
||||
speed: number;
|
||||
length: number;
|
||||
opacity: number;
|
||||
drift: number;
|
||||
}
|
||||
|
||||
function createRainDrop(w: number, h: number, intensity: PrecipitationIntensity): RainDrop {
|
||||
const speedBase = intensity === 'heavy' ? 14 : intensity === 'moderate' ? 10 : 7;
|
||||
const speedRange = intensity === 'heavy' ? 8 : intensity === 'moderate' ? 5 : 3;
|
||||
return {
|
||||
x: Math.random() * (w + 100) - 50,
|
||||
y: Math.random() * h * -1 - 20,
|
||||
speed: speedBase + Math.random() * speedRange,
|
||||
length: intensity === 'heavy' ? 18 + Math.random() * 12 : 10 + Math.random() * 10,
|
||||
opacity: 0.15 + Math.random() * 0.2,
|
||||
drift: intensity === 'heavy' ? 1.5 + Math.random() : 0.5 + Math.random() * 0.8,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Snowflake particle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SnowFlake {
|
||||
x: number;
|
||||
y: number;
|
||||
speed: number;
|
||||
radius: number;
|
||||
opacity: number;
|
||||
wobbleAmp: number;
|
||||
wobbleFreq: number;
|
||||
wobblePhase: number;
|
||||
}
|
||||
|
||||
function createSnowFlake(w: number, h: number, intensity: PrecipitationIntensity): SnowFlake {
|
||||
const sizeBase = intensity === 'heavy' ? 2.5 : intensity === 'moderate' ? 2 : 1.5;
|
||||
const sizeRange = intensity === 'heavy' ? 3 : intensity === 'moderate' ? 2.5 : 2;
|
||||
return {
|
||||
x: Math.random() * (w + 60) - 30,
|
||||
y: Math.random() * h * -1 - 10,
|
||||
speed: 0.5 + Math.random() * (intensity === 'heavy' ? 1.8 : intensity === 'moderate' ? 1.2 : 0.8),
|
||||
radius: sizeBase + Math.random() * sizeRange,
|
||||
opacity: 0.4 + Math.random() * 0.4,
|
||||
wobbleAmp: 0.3 + Math.random() * 0.8,
|
||||
wobbleFreq: 0.01 + Math.random() * 0.02,
|
||||
wobblePhase: Math.random() * Math.PI * 2,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Canvas precipitation renderer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const PrecipitationEffect = memo(function PrecipitationEffect({
|
||||
type,
|
||||
intensity,
|
||||
}: PrecipitationEffectProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const rafRef = useRef(0);
|
||||
const rainDrops = useRef<RainDrop[]>([]);
|
||||
const snowFlakes = useRef<SnowFlake[]>([]);
|
||||
const frameRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || !type) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
function resize() {
|
||||
if (!canvas) return;
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
}
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
// Initialize particle pools
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
|
||||
if (type === 'rain') {
|
||||
const count = RAIN_COUNT[intensity];
|
||||
rainDrops.current = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const drop = createRainDrop(w, h, intensity);
|
||||
// Scatter initial y positions across the screen for instant coverage
|
||||
drop.y = Math.random() * h;
|
||||
rainDrops.current.push(drop);
|
||||
}
|
||||
} else {
|
||||
const count = SNOW_COUNT[intensity];
|
||||
snowFlakes.current = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const flake = createSnowFlake(w, h, intensity);
|
||||
flake.y = Math.random() * h;
|
||||
snowFlakes.current.push(flake);
|
||||
}
|
||||
}
|
||||
|
||||
function drawRain() {
|
||||
if (!canvas || !ctx) return;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
for (const drop of rainDrops.current) {
|
||||
drop.y += drop.speed;
|
||||
drop.x += drop.drift;
|
||||
|
||||
// Reset when off screen
|
||||
if (drop.y > canvas.height + 20) {
|
||||
drop.y = -drop.length - Math.random() * 40;
|
||||
drop.x = Math.random() * (canvas.width + 100) - 50;
|
||||
}
|
||||
if (drop.x > canvas.width + 50) {
|
||||
drop.x = -50;
|
||||
}
|
||||
|
||||
// Draw the raindrop as a thin line with a subtle glow
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(drop.x, drop.y);
|
||||
ctx.lineTo(drop.x + drop.drift * 0.5, drop.y + drop.length);
|
||||
ctx.strokeStyle = `rgba(174, 194, 224, ${drop.opacity})`;
|
||||
ctx.lineWidth = 1.2;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(drawRain);
|
||||
}
|
||||
|
||||
function drawSnow() {
|
||||
if (!canvas || !ctx) return;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
frameRef.current++;
|
||||
|
||||
for (const flake of snowFlakes.current) {
|
||||
flake.y += flake.speed;
|
||||
flake.x += Math.sin(frameRef.current * flake.wobbleFreq + flake.wobblePhase) * flake.wobbleAmp;
|
||||
|
||||
// Reset when off screen
|
||||
if (flake.y > canvas.height + 10) {
|
||||
flake.y = -flake.radius * 2 - Math.random() * 30;
|
||||
flake.x = Math.random() * (canvas.width + 60) - 30;
|
||||
}
|
||||
if (flake.x > canvas.width + 30) {
|
||||
flake.x = -30;
|
||||
} else if (flake.x < -30) {
|
||||
flake.x = canvas.width + 30;
|
||||
}
|
||||
|
||||
// Draw the snowflake as a soft glowing circle
|
||||
ctx.beginPath();
|
||||
ctx.arc(flake.x, flake.y, flake.radius, 0, Math.PI * 2);
|
||||
|
||||
const gradient = ctx.createRadialGradient(
|
||||
flake.x, flake.y, 0,
|
||||
flake.x, flake.y, flake.radius,
|
||||
);
|
||||
gradient.addColorStop(0, `rgba(255, 255, 255, ${flake.opacity})`);
|
||||
gradient.addColorStop(0.5, `rgba(230, 238, 255, ${flake.opacity * 0.6})`);
|
||||
gradient.addColorStop(1, `rgba(210, 225, 250, 0)`);
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(drawSnow);
|
||||
}
|
||||
|
||||
if (type === 'rain') {
|
||||
rafRef.current = requestAnimationFrame(drawRain);
|
||||
} else {
|
||||
rafRef.current = requestAnimationFrame(drawSnow);
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
window.removeEventListener('resize', resize);
|
||||
rainDrops.current = [];
|
||||
snowFlakes.current = [];
|
||||
};
|
||||
}, [type, intensity]);
|
||||
|
||||
if (!type) return null;
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="pointer-events-none fixed inset-0 z-[100]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -214,8 +214,6 @@ export function ReactionButton({
|
||||
<span className={cn('tabular-nums', variant === 'chip' ? '' : 'text-sm', hasReacted && 'text-pink-500')}>
|
||||
{formatNumber(reactionCount)}
|
||||
</span>
|
||||
) : variant === 'chip' ? (
|
||||
<span className="hidden sm:inline">React</span>
|
||||
) : null}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
|
||||
@@ -13,8 +13,6 @@ import { ComposeBox, type ExternalReplyRoot } from '@/components/ComposeBox';
|
||||
import { LinkEmbed } from '@/components/LinkEmbed';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const AGORA_DEFAULT_NOTE_TAGS = [['t', 'agora']];
|
||||
|
||||
interface ReplyComposeModalProps {
|
||||
/** The event being replied to, a URL for commenting on web content, or a NIP-73 identifier (e.g. `bitcoin:tx:...`, `isbn:...`). When `null`, the modal acts as a "New post" composer. */
|
||||
event?: NostrEvent | ExternalReplyRoot | null;
|
||||
@@ -162,7 +160,6 @@ export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSu
|
||||
onHasPreviewableContentChange={setHasPreviewableContent}
|
||||
initialContent={initialContent}
|
||||
initialMode={initialMode}
|
||||
defaultTags={!isReply && !isQuote && initialMode !== 'poll' ? AGORA_DEFAULT_NOTE_TAGS : undefined}
|
||||
/>
|
||||
</div>
|
||||
</PortalContainerProvider>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Quote, Rocket, Undo2 } from 'lucide-react';
|
||||
import { Megaphone, Quote, Undo2 } from 'lucide-react';
|
||||
import { RepostIcon } from '@/components/icons/RepostIcon';
|
||||
import { useState } from 'react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
@@ -195,7 +195,7 @@ export function RepostMenu({ event, children }: RepostMenuProps) {
|
||||
}}
|
||||
className="flex items-center gap-3 w-full px-4 py-3 text-[15px] text-foreground hover:bg-secondary/60 transition-colors"
|
||||
>
|
||||
<Rocket className="size-5" />
|
||||
<Megaphone className="size-5" />
|
||||
<span>Boost</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -44,7 +44,7 @@ type KindOption = {
|
||||
|
||||
// ─── Kind options (built once) ───────────────────────────────────────────────
|
||||
|
||||
import { buildKindOptions } from '@/lib/feedFilterUtils';
|
||||
import { buildKindOptions, AGORA_PRESET_KIND_VALUES } from '@/lib/feedFilterUtils';
|
||||
|
||||
// ─── useScrollCarets ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -152,7 +152,26 @@ export function KindPicker({ value, options, onChange }: {
|
||||
);
|
||||
}, [options, search]);
|
||||
|
||||
const selected = value === 'all' || value === 'custom' ? null : options.find((o) => o.value === value);
|
||||
// Partition into Agora preset (top of picker) and the rest.
|
||||
// When the user is searching, we skip the partition and show a flat list.
|
||||
const presetSet = useMemo(() => new Set(AGORA_PRESET_KIND_VALUES), []);
|
||||
const { presetOptions, otherOptions } = useMemo(() => {
|
||||
if (search) return { presetOptions: [], otherOptions: filtered };
|
||||
const preset: KindOption[] = [];
|
||||
const other: KindOption[] = [];
|
||||
// Preserve AGORA_PRESET_KIND_VALUES order for the preset section.
|
||||
const byValue = new Map(filtered.map((o) => [o.value, o]));
|
||||
for (const v of AGORA_PRESET_KIND_VALUES) {
|
||||
const opt = byValue.get(v);
|
||||
if (opt) preset.push(opt);
|
||||
}
|
||||
for (const o of filtered) {
|
||||
if (!presetSet.has(o.value)) other.push(o);
|
||||
}
|
||||
return { presetOptions: preset, otherOptions: other };
|
||||
}, [filtered, presetSet, search]);
|
||||
|
||||
const selected = value === 'all' || value === 'agora' || value === 'custom' ? null : options.find((o) => o.value === value);
|
||||
const SelectedIcon = selected?.icon;
|
||||
|
||||
const handleSelect = (v: string) => { onChange(v); setOpen(false); setSearch(''); };
|
||||
@@ -170,7 +189,13 @@ export function KindPicker({ value, options, onChange }: {
|
||||
? <SelectedIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
: <Hash className="size-3.5 shrink-0 text-muted-foreground" />}
|
||||
<span className="flex-1 truncate">
|
||||
{value === 'all' ? 'All' : value === 'custom' ? 'Custom...' : (selected?.label ?? value)}
|
||||
{value === 'all'
|
||||
? 'All kinds'
|
||||
: value === 'agora'
|
||||
? 'Agora content'
|
||||
: value === 'custom'
|
||||
? 'Custom...'
|
||||
: (selected?.label ?? value)}
|
||||
</span>
|
||||
<ChevronDown className="size-3 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
@@ -198,8 +223,28 @@ export function KindPicker({ value, options, onChange }: {
|
||||
</div>
|
||||
{canScrollUp && <KindScrollCaret direction="up" onMouseEnter={() => startScroll('up')} onMouseLeave={stopScroll} />}
|
||||
<div ref={refCallback} className="overflow-y-auto flex-1 min-h-0" onScroll={onScroll}>
|
||||
{!search && <KindPickerItem icon={null} label="All kinds" active={value === 'all'} onClick={() => handleSelect('all')} />}
|
||||
{filtered.map((opt) => (
|
||||
{!search && (
|
||||
<>
|
||||
<KindPickerItem icon={null} label="Agora content" active={value === 'agora'} onClick={() => handleSelect('agora')} />
|
||||
<KindPickerItem icon={null} label="All kinds" active={value === 'all'} onClick={() => handleSelect('all')} />
|
||||
</>
|
||||
)}
|
||||
{!search && presetOptions.length > 0 && (
|
||||
<>
|
||||
<div className="px-2.5 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Agora content
|
||||
</div>
|
||||
{presetOptions.map((opt) => (
|
||||
<KindPickerItem key={opt.value} icon={opt.icon ?? null} label={opt.label} active={value === opt.value} onClick={() => handleSelect(opt.value)} />
|
||||
))}
|
||||
{otherOptions.length > 0 && (
|
||||
<div className="px-2.5 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
All kinds
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{otherOptions.map((opt) => (
|
||||
<KindPickerItem key={opt.value} icon={opt.icon ?? null} label={opt.label} active={value === opt.value} onClick={() => handleSelect(opt.value)} />
|
||||
))}
|
||||
{(!search || 'custom'.includes(search.toLowerCase())) && (
|
||||
|
||||
@@ -45,6 +45,7 @@ import { useNip05Resolve } from '@/hooks/useNip05Resolve';
|
||||
import { detectIdentifier, type IdentifierMatch } from '@/lib/nostrIdentifier';
|
||||
import { isNostrId } from '@/lib/nostrId';
|
||||
import { notificationSuccess } from '@/lib/haptics';
|
||||
import { withAgoraTag } from '@/lib/agoraNoteTags';
|
||||
import {
|
||||
nostrPubkeyToBitcoinAddress,
|
||||
validateBitcoinAddress,
|
||||
@@ -333,12 +334,12 @@ export function SendBitcoinDialog({ isOpen, onClose, btcPrice }: SendBitcoinDial
|
||||
await publishEvent({
|
||||
kind: 8333,
|
||||
content: '',
|
||||
tags: [
|
||||
tags: withAgoraTag([
|
||||
['i', `bitcoin:tx:${txid}`],
|
||||
['p', recipient.pubkey],
|
||||
['amount', String(amountSats)],
|
||||
['alt', `On-chain zap: ${amountSats.toLocaleString()} sats`],
|
||||
],
|
||||
]),
|
||||
});
|
||||
} catch (err) {
|
||||
// The Bitcoin transaction already broadcast — the kind 8333 is a
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -14,17 +15,23 @@ export interface ReplyNode {
|
||||
}
|
||||
|
||||
/** Renders a fully threaded reply tree with collapsible deep branches. */
|
||||
export function ThreadedReplyList({ roots }: { roots: ReplyNode[] }) {
|
||||
export function ThreadedReplyList({ roots, renderItemHeader }: { roots: ReplyNode[]; renderItemHeader?: (event: NostrEvent) => ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
// Drop the trailing border on the last comment in the list — when
|
||||
// the surrounding page doesn't wrap us in a card, that border
|
||||
// floats orphaned below the final note. Two selectors are needed
|
||||
// because the last root may be either a bare <article> (no
|
||||
// children) or a <div> wrapping an <article> chain. `!important`
|
||||
// overrides NoteCard's own `border-b border-border` utility.
|
||||
<div className="[&>article:last-child]:!border-b-transparent [&>div:last-child_article]:!border-b-transparent">
|
||||
{roots.map((node) => (
|
||||
<ReplyThread key={node.event.id} node={node} depth={0} />
|
||||
<ReplyThread key={node.event.id} node={node} depth={0} renderItemHeader={renderItemHeader} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReplyThread({ node, depth, depthless }: { node: ReplyNode; depth: number; depthless?: boolean }) {
|
||||
function ReplyThread({ node, depth, depthless, renderItemHeader }: { node: ReplyNode; depth: number; depthless?: boolean; renderItemHeader?: (event: NostrEvent) => ReactNode }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [showHidden, setShowHidden] = useState(false);
|
||||
const hasChildren = node.children.length > 0;
|
||||
@@ -34,6 +41,7 @@ function ReplyThread({ node, depth, depthless }: { node: ReplyNode; depth: numbe
|
||||
if (shouldCollapse) {
|
||||
return (
|
||||
<div>
|
||||
{renderItemHeader?.(node.event)}
|
||||
<NoteCard event={node.event} threaded />
|
||||
<ExpandThreadButton count={countDescendants(node)} onClick={() => setExpanded(true)} isLast />
|
||||
</div>
|
||||
@@ -41,7 +49,12 @@ function ReplyThread({ node, depth, depthless }: { node: ReplyNode; depth: numbe
|
||||
}
|
||||
|
||||
if (!hasChildren) {
|
||||
return <NoteCard event={node.event} />;
|
||||
return (
|
||||
<div>
|
||||
{renderItemHeader?.(node.event)}
|
||||
<NoteCard event={node.event} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Once expanded past the depth cap, skip further caps for this subtree
|
||||
@@ -49,6 +62,7 @@ function ReplyThread({ node, depth, depthless }: { node: ReplyNode; depth: numbe
|
||||
|
||||
return (
|
||||
<div>
|
||||
{renderItemHeader?.(node.event)}
|
||||
<NoteCard event={node.event} threaded />
|
||||
{/* Show hidden sibling count between parent and first child */}
|
||||
{hiddenCount > 0 && !showHidden && (
|
||||
@@ -56,10 +70,13 @@ function ReplyThread({ node, depth, depthless }: { node: ReplyNode; depth: numbe
|
||||
)}
|
||||
{/* Revealed hidden siblings render as threaded items before the inline child */}
|
||||
{showHidden && node.hiddenChildren!.map((child) => (
|
||||
<NoteCard key={child.event.id} event={child.event} threaded threadedLineClassName="bg-primary/30" />
|
||||
<div key={child.event.id}>
|
||||
{renderItemHeader?.(child.event)}
|
||||
<NoteCard event={child.event} threaded threadedLineClassName="bg-primary/30" />
|
||||
</div>
|
||||
))}
|
||||
{node.children.map((child) => (
|
||||
<ReplyThread key={child.event.id} node={child} depth={depth + 1} depthless={childDepthless} />
|
||||
<ReplyThread key={child.event.id} node={child} depth={depth + 1} depthless={childDepthless} renderItemHeader={renderItemHeader} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, type ComponentType } from 'react';
|
||||
import { Link, NavLink } from 'react-router-dom';
|
||||
import { Link, NavLink, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Activity,
|
||||
Bell,
|
||||
@@ -33,8 +33,9 @@ interface NavItem {
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ label: 'Discover', to: '/discover', icon: HandHeart },
|
||||
{ label: 'Organize', to: '/communities', icon: Users },
|
||||
{ label: 'Activity', to: '/feed', icon: Activity },
|
||||
{ label: 'Campaigns', to: '/campaigns/all', icon: HandHeart },
|
||||
{ label: 'Groups', to: '/communities', icon: Users },
|
||||
{ label: 'Pledge', to: '/pledges', icon: Megaphone },
|
||||
];
|
||||
|
||||
@@ -53,6 +54,9 @@ export function TopNav() {
|
||||
const { user } = useCurrentUser();
|
||||
const { orderedItems } = useFeedSettings();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const goToSearch = () => navigate('/search');
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 w-full border-b border-border bg-background/85 backdrop-blur supports-[backdrop-filter]:bg-background/70">
|
||||
@@ -88,6 +92,18 @@ export function TopNav() {
|
||||
|
||||
{/* Right cluster */}
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
{/* Search — navigates to the /search page. Visible on all
|
||||
breakpoints so users can always reach search from the chrome. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToSearch}
|
||||
className="shrink-0 size-9 rounded-full flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-secondary motion-safe:transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label="Search"
|
||||
title="Search"
|
||||
>
|
||||
<Search className="size-5" />
|
||||
</button>
|
||||
|
||||
{/* LoginArea handles both logged-in (account avatar dropdown) and
|
||||
logged-out (Log in / Sign up) states. We render it inline-flex
|
||||
and let it style its own children. */}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CommunityGridProps {
|
||||
children: React.ReactNode;
|
||||
/** Extra classes on the grid container. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Responsive grid container for community/organization cards on the
|
||||
* `/communities` page. Replaces the previous horizontal-scroll shelves so
|
||||
* organizations wrap onto multiple rows instead of disappearing off the
|
||||
* right edge.
|
||||
*
|
||||
* Column counts are tuned to the page's `max-w-5xl` (~1024px) content
|
||||
* column so each cell ends up close to the legacy 256px `CommunityMiniCard`
|
||||
* width at the `lg` breakpoint:
|
||||
* - <640px: 1 column
|
||||
* - sm 640+: 2 columns
|
||||
* - md 768+: 3 columns
|
||||
* - lg 1024+: 4 columns
|
||||
*
|
||||
* Cards passed in should be `w-full` so they fill their grid cell — the
|
||||
* default `w-64` on `CommunityMiniCard` can be overridden with
|
||||
* `className="w-full"` thanks to `tailwind-merge`.
|
||||
*/
|
||||
export function CommunityGrid({ children, className }: CommunityGridProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 px-4 sm:px-6',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Link } from 'react-router-dom';
|
||||
import { Users } from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
import { CommunityModerationOverlay } from '@/components/CommunityModerationMenu';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
@@ -29,6 +30,13 @@ interface CommunityMiniCardProps {
|
||||
* - founder avatar + display name in a muted row.
|
||||
*
|
||||
* Kept narrow enough to fit ~4 cards across a desktop content column.
|
||||
*
|
||||
* Moderators (Team Soapbox pack members) see a kebab menu overlaid on the
|
||||
* banner exposing the Feature / Hide actions plus a Hidden badge when the
|
||||
* org is currently hidden. Non-moderators see no overlay — the whole
|
||||
* moderation pipeline (including the heavy `useOrganizationModeration`
|
||||
* query) is bypassed for them so grids of dozens of cards don't fan out
|
||||
* a per-card cache subscription on every viewer.
|
||||
*/
|
||||
export function CommunityMiniCard({ community, className }: CommunityMiniCardProps) {
|
||||
const founder = useAuthor(community.founderPubkey);
|
||||
@@ -67,6 +75,11 @@ export function CommunityMiniCard({ community, className }: CommunityMiniCardPro
|
||||
<Users className="size-10 text-primary/40" />
|
||||
</div>
|
||||
)}
|
||||
{/* Moderator overlay (Hidden badge + kebab). Renders `null` for
|
||||
non-moderators, which is why this component owns the
|
||||
`useOrganizationModeration` subscription rather than the
|
||||
card — keeps non-mod grids free of the heavy label query. */}
|
||||
<CommunityModerationOverlay coord={community.aTag} organizationName={community.name} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 p-3.5 flex-1">
|
||||
<h3 className="font-semibold leading-tight text-sm tracking-tight line-clamp-1">
|
||||
|
||||
@@ -1,355 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Globe2, HandHeart, PlusCircle, Users } from 'lucide-react';
|
||||
|
||||
import { HeroGlobe, type GlobeMarkerKind } from '@/components/HeroGlobe';
|
||||
import { HeroAtmosphere } from '@/components/HeroAtmosphere';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useCampaigns } from '@/hooks/useCampaigns';
|
||||
import { useGlobalActivity } from '@/hooks/useGlobalActivity';
|
||||
import { useGlobalDonations } from '@/hooks/useGlobalDonations';
|
||||
import { useDiscoverCommunities } from '@/hooks/useDiscoverCommunities';
|
||||
import { HOPE_PALETTE } from '@/lib/hopePalette';
|
||||
import { searchCountry } from '@/lib/countries';
|
||||
import { getCoordinates } from '@/lib/coordinates';
|
||||
import { formatSatsShort } from '@/lib/formatCampaignAmount';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface DiscoverHeroProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface GlobeMarker {
|
||||
key: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
label: string;
|
||||
kind: GlobeMarkerKind;
|
||||
}
|
||||
|
||||
interface TickerStat {
|
||||
/** Stable React key. */
|
||||
id: string;
|
||||
/** Big number / value text. */
|
||||
value: string;
|
||||
/** Trailing label that describes what the number is. */
|
||||
label: string;
|
||||
/** Decorative leading icon. */
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
/** Country code → lat/lng. Used to seed country-pulse markers. */
|
||||
function lookupCountryCoords(code: string) {
|
||||
const coords = getCoordinates(code);
|
||||
return coords ? { lat: coords.latitude, lng: coords.longitude } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover-page hero. The same hand-drawn `HeroGlobe` that anchors the
|
||||
* fundraising home page (`/`), but reframed: the globe is the
|
||||
* protagonist, three marker types sit on it at once — campaigns
|
||||
* (hearts), communities (rings), and country-pulse (warm dots) — and a
|
||||
* rotating stat ticker headlines what the network has done.
|
||||
*
|
||||
* Visual chrome:
|
||||
* - Slow hue drift through `HOPE_PALETTE` every ~8s (the page literally
|
||||
* pulses with hope).
|
||||
* - `HeroAtmosphere` carries the warm scrim + radial glow + sunrise rim,
|
||||
* same component the campaigns hero uses for crossfade.
|
||||
* - No background photo — Discover isn't selling any one campaign, so
|
||||
* the sphere reads against a soft secondary wash instead.
|
||||
*/
|
||||
export function DiscoverHero({ className }: DiscoverHeroProps) {
|
||||
// ─── Data ──────────────────────────────────────────────────────────────
|
||||
const { data: campaigns } = useCampaigns({ limit: 60 });
|
||||
const { data: communities } = useDiscoverCommunities({ limit: 60 });
|
||||
const { data: activityByCountry } = useGlobalActivity();
|
||||
const { data: donations, isLoading: donationsLoading } = useGlobalDonations();
|
||||
|
||||
// ─── Globe markers ─────────────────────────────────────────────────────
|
||||
// Layer three pin types. We dedupe primarily by country so the globe
|
||||
// never piles dozens of markers on top of each other — the goal is a
|
||||
// sparse, hopeful constellation, not a heatmap. Hearts win over rings
|
||||
// win over dots when the same country shows up in multiple sources.
|
||||
const markers = useMemo<GlobeMarker[]>(() => {
|
||||
const out: GlobeMarker[] = [];
|
||||
const claimedCountries = new Set<string>();
|
||||
|
||||
// 1. Campaigns → hearts. Newest first; cap at 18 so they don't crowd.
|
||||
let heartCount = 0;
|
||||
for (const c of campaigns ?? []) {
|
||||
if (heartCount >= 18) break;
|
||||
if (!c.location) continue;
|
||||
const match = searchCountry(c.location);
|
||||
if (!match) continue;
|
||||
if (claimedCountries.has(match.country.code)) continue;
|
||||
const coords = getCoordinates(match.country.code);
|
||||
if (!coords) continue;
|
||||
claimedCountries.add(match.country.code);
|
||||
out.push({
|
||||
key: `campaign:${c.aTag}`,
|
||||
lat: coords.latitude,
|
||||
lng: coords.longitude,
|
||||
label: c.title,
|
||||
kind: 'campaign',
|
||||
});
|
||||
heartCount++;
|
||||
}
|
||||
|
||||
// 2. Country-pulse dots — the trusted-stats country activity, sized
|
||||
// implicitly by the marker glyph. Cap at 28 so the back of the globe
|
||||
// doesn't bristle when it rotates into view.
|
||||
let pulseCount = 0;
|
||||
if (activityByCountry) {
|
||||
const sortedCodes = [...activityByCountry.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([code]) => code);
|
||||
for (const code of sortedCodes) {
|
||||
if (pulseCount >= 28) break;
|
||||
if (claimedCountries.has(code)) continue;
|
||||
const coords = lookupCountryCoords(code);
|
||||
if (!coords) continue;
|
||||
claimedCountries.add(code);
|
||||
out.push({
|
||||
key: `pulse:${code}`,
|
||||
lat: coords.lat,
|
||||
lng: coords.lng,
|
||||
label: `Active in ${code}`,
|
||||
kind: 'country-pulse',
|
||||
});
|
||||
pulseCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Community rings — only when we can geolocate one of the
|
||||
// moderators. Communities don't carry a location tag of their own,
|
||||
// so we use a small heuristic: spread the first N communities across
|
||||
// continents by scattering them on a stable hash. Keeps the layer
|
||||
// present without inventing coordinates we can't justify.
|
||||
//
|
||||
// To keep ourselves honest we cap this at 6 rings and never overwrite
|
||||
// a country that already has a campaign heart or pulse dot. If we
|
||||
// genuinely can't place any, we skip the layer.
|
||||
const scatter: Array<{ lat: number; lng: number }> = [
|
||||
{ lat: 40.7, lng: -74.0 }, // Americas
|
||||
{ lat: -23.5, lng: -46.6 }, // S. America
|
||||
{ lat: 51.5, lng: -0.1 }, // Europe
|
||||
{ lat: -1.3, lng: 36.8 }, // Africa
|
||||
{ lat: 35.7, lng: 139.7 }, // E. Asia
|
||||
{ lat: -33.9, lng: 151.2 }, // Oceania
|
||||
];
|
||||
let ringCount = 0;
|
||||
for (const community of communities ?? []) {
|
||||
if (ringCount >= scatter.length) break;
|
||||
const slot = scatter[ringCount];
|
||||
out.push({
|
||||
key: `community:${community.aTag}`,
|
||||
lat: slot.lat,
|
||||
lng: slot.lng,
|
||||
label: community.name,
|
||||
kind: 'community',
|
||||
});
|
||||
ringCount++;
|
||||
}
|
||||
|
||||
return out;
|
||||
}, [campaigns, communities, activityByCountry]);
|
||||
|
||||
// ─── Hue drift ─────────────────────────────────────────────────────────
|
||||
// Cycle through the hopeful palette on a slow ~9s interval. We seed
|
||||
// HeroAtmosphere with a stable string per cycle so its crossfade logic
|
||||
// kicks in correctly between hues.
|
||||
const [hueIndex, setHueIndex] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => {
|
||||
setHueIndex((i) => (i + 1) % HOPE_PALETTE.length);
|
||||
}, 9_000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
const activeHue = HOPE_PALETTE[hueIndex];
|
||||
const atmosphereSeed = `discover-hue-${activeHue.name}`;
|
||||
|
||||
// ─── Stat ticker ───────────────────────────────────────────────────────
|
||||
// Three rotating, immutable network-wide stats. We compute them
|
||||
// defensively — when the underlying query is still loading we surface
|
||||
// a small skeleton inside the ticker row instead of "0" so the page
|
||||
// doesn't lie about the network's scale.
|
||||
const stats = useMemo<TickerStat[]>(() => {
|
||||
const items: TickerStat[] = [];
|
||||
|
||||
if (donations && donations.totalSats > 0) {
|
||||
items.push({
|
||||
id: 'sats',
|
||||
value: formatSatsShort(donations.totalSats),
|
||||
label: `raised on-chain across ${donations.campaignCount.toLocaleString()} ${
|
||||
donations.campaignCount === 1 ? 'campaign' : 'campaigns'
|
||||
}`,
|
||||
icon: <HandHeart className="size-5" aria-hidden />,
|
||||
});
|
||||
}
|
||||
if (communities && communities.length > 0) {
|
||||
items.push({
|
||||
id: 'communities',
|
||||
value: communities.length.toLocaleString(),
|
||||
label: `${communities.length === 1 ? 'organization' : 'organizations'} gathering on Nostr`,
|
||||
icon: <Users className="size-5" aria-hidden />,
|
||||
});
|
||||
}
|
||||
if (activityByCountry && activityByCountry.size > 0) {
|
||||
items.push({
|
||||
id: 'countries',
|
||||
value: activityByCountry.size.toLocaleString(),
|
||||
label: `${activityByCountry.size === 1 ? 'country' : 'countries'} posting today`,
|
||||
icon: <Globe2 className="size-5" aria-hidden />,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}, [donations, communities, activityByCountry]);
|
||||
|
||||
// Auto-advance the ticker. Holds at the first slot until at least one
|
||||
// stat is known so the visitor doesn't see an empty pill.
|
||||
const [tickerIndex, setTickerIndex] = useState(0);
|
||||
useEffect(() => {
|
||||
if (stats.length <= 1) return;
|
||||
const id = window.setInterval(() => {
|
||||
setTickerIndex((i) => (i + 1) % stats.length);
|
||||
}, 4_000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [stats.length]);
|
||||
|
||||
const currentStat = stats[tickerIndex % Math.max(stats.length, 1)];
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
'relative overflow-hidden border-b border-border bg-secondary/30',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Atmosphere — same scrim + radial glow + sunrise rim used on
|
||||
`/`. Seeded by the active hue so the whole hero blooms together
|
||||
when the palette advances. */}
|
||||
<HeroAtmosphere seed={atmosphereSeed} />
|
||||
|
||||
{/* Globe — centered, dominant. Slight upward bias so the headline
|
||||
beneath has breathing room. */}
|
||||
<div className="absolute inset-0 pointer-events-none flex items-center justify-center">
|
||||
<div className="pointer-events-auto opacity-90">
|
||||
<HeroGlobe
|
||||
markers={markers}
|
||||
hue={activeHue}
|
||||
className="aspect-square max-w-none drop-shadow-2xl"
|
||||
style={{ width: 'clamp(440px, 62dvw, 720px)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Readability scrim. Sits above the globe + atmosphere but below
|
||||
the foreground content so the headline / paragraph stay legible
|
||||
regardless of which hue the palette is currently cycling
|
||||
through. Top-down so the eye-line lands on the darkest pixels;
|
||||
we taper to transparent before the ticker pill so the CTAs and
|
||||
stat row underneath keep their warm wash. */}
|
||||
<div
|
||||
className="absolute inset-x-0 top-0 h-72 sm:h-80 pointer-events-none bg-gradient-to-b from-black/55 via-black/25 to-transparent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Foreground content — headline above the sphere, ticker + CTAs
|
||||
below it. Uses the same `max-w-5xl` container as the rest of
|
||||
the Discover page so the hero never sprawls wider than the
|
||||
shelves beneath it. */}
|
||||
<div className="relative max-w-5xl mx-auto px-4 sm:px-6 py-12 sm:py-16 lg:py-20 min-h-[560px] sm:min-h-[640px] lg:min-h-[680px] flex flex-col items-center text-center">
|
||||
<div className="relative space-y-3 max-w-3xl">
|
||||
<p className="text-xs sm:text-sm font-semibold uppercase tracking-[0.18em] text-white/80 drop-shadow">
|
||||
Discover
|
||||
</p>
|
||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight leading-[1.05] text-white drop-shadow-[0_2px_12px_rgb(0_0_0/0.55)]">
|
||||
The world,
|
||||
<br className="sm:hidden" /> gathering.
|
||||
</h1>
|
||||
<p className="text-base sm:text-lg text-white/85 max-w-2xl mx-auto drop-shadow-[0_1px_6px_rgb(0_0_0/0.5)]">
|
||||
Campaigns, communities, and conversations from every corner of the
|
||||
globe. Backed by Bitcoin, broadcast on Nostr, owned by no one.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Spacer so the next block lands beneath the sphere. */}
|
||||
<div className="flex-1 min-h-[180px] sm:min-h-[220px]" aria-hidden="true" />
|
||||
|
||||
{/* Rotating stat ticker. The fixed min-height stops the layout
|
||||
from jumping as labels swap; the keyed inner span re-mounts on
|
||||
every change to trigger the fade-in transition. */}
|
||||
<div
|
||||
className="relative w-full max-w-md mx-auto rounded-full bg-background/55 backdrop-blur-xl backdrop-saturate-150 border border-white/20 dark:border-white/10 px-5 py-3 shadow-lg shadow-amber-500/10"
|
||||
aria-live="polite"
|
||||
>
|
||||
{currentStat ? (
|
||||
<div
|
||||
key={currentStat.id}
|
||||
className="flex items-center justify-center gap-3 motion-safe:animate-in motion-safe:fade-in motion-safe:duration-500"
|
||||
>
|
||||
<span className="text-primary shrink-0">{currentStat.icon}</span>
|
||||
<span className="text-sm sm:text-base font-semibold tracking-tight">
|
||||
{currentStat.value}
|
||||
</span>
|
||||
<span className="text-xs sm:text-sm text-muted-foreground line-clamp-1">
|
||||
{currentStat.label}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
{donationsLoading ? (
|
||||
<>
|
||||
<Skeleton className="size-5 rounded-full" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Connecting to relays…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* CTAs — clean glass pills, same vocabulary as `/`. Two clear
|
||||
actions: start something (campaign creation), or browse the
|
||||
world map for inspiration. */}
|
||||
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
|
||||
<Button
|
||||
size="lg"
|
||||
asChild
|
||||
className={cn(
|
||||
'relative rounded-full text-white font-semibold text-base h-12 px-7 [&_svg]:size-[18px]',
|
||||
'bg-gradient-to-br from-white/14 via-amber-100/10 to-rose-100/10 hover:from-white/20 hover:via-amber-100/14 hover:to-rose-100/14',
|
||||
'backdrop-blur-xl backdrop-saturate-150',
|
||||
'border border-white/25 hover:border-white/35',
|
||||
'shadow-[inset_0_0_0_1px_rgb(255_255_255/0.08),0_10px_28px_-12px_hsl(24_85%_45%/0.4)]',
|
||||
'hover:shadow-[inset_0_0_0_1px_rgb(255_255_255/0.12),0_12px_32px_-10px_hsl(24_85%_45%/0.5)]',
|
||||
'motion-safe:transition-colors motion-safe:duration-200',
|
||||
)}
|
||||
>
|
||||
<Link to="/campaigns/new">
|
||||
<PlusCircle className="mr-2" />
|
||||
Start a campaign
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
asChild
|
||||
className="rounded-full bg-background/60 backdrop-blur h-12 px-6 text-base"
|
||||
>
|
||||
<Link to="/world">
|
||||
<Globe2 className="size-4 mr-2" />
|
||||
Browse the world
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -208,8 +208,6 @@ export interface AppConfig {
|
||||
clientName?: string;
|
||||
/** NIP-19 `naddr1…` identifying this client's kind 31990 handler event. Decoded at publish time to produce the `31990:<pubkey>:<d-tag>` addr and relay hint for the "client" tag per NIP-89. */
|
||||
client?: string;
|
||||
/** Enable Magic Mouse mode: cursor/finger emanates magical fire in the primary color */
|
||||
magicMouse: boolean;
|
||||
/** Current theme */
|
||||
theme: Theme;
|
||||
/** Custom theme config (colors, fonts, background). Only used when theme === "custom". */
|
||||
|
||||
+74
-27
@@ -10,16 +10,16 @@ import { isEventMuted } from '@/lib/muteHelpers';
|
||||
|
||||
const AGORA_PAGE_SIZE = 25;
|
||||
const PLEDGE_KIND = 36639;
|
||||
const COMMUNITY_KIND = 34550;
|
||||
const POLL_KIND = 1068;
|
||||
const COMMENT_KIND = 1111;
|
||||
const NOTE_KIND = 1;
|
||||
const ONCHAIN_ZAP_KIND = 8333;
|
||||
const LIGHTNING_ZAP_KIND = 9735;
|
||||
|
||||
const AGORA_ENTITY_KINDS = [CAMPAIGN_KIND, PLEDGE_KIND, ONCHAIN_ZAP_KIND];
|
||||
const COMMENT_ROOT_KINDS = [String(CAMPAIGN_KIND), String(PLEDGE_KIND)];
|
||||
const AGORA_ENTITY_KINDS = [CAMPAIGN_KIND, PLEDGE_KIND, COMMUNITY_KIND, ONCHAIN_ZAP_KIND];
|
||||
const COMMENT_ROOT_KINDS = [String(CAMPAIGN_KIND), String(PLEDGE_KIND), String(COMMUNITY_KIND)];
|
||||
const WORLD_K_TAGS = ['iso3166', 'geo'];
|
||||
const PLEDGE_T_ALIASES = ['agora-action', 'pathos-challenge', 'agora-challenge'];
|
||||
const AGORA_T_TAGS = ['agora', 'Agora'];
|
||||
const IGNORED_AGORA_NOTE_AUTHORS = new Set([
|
||||
'4fe14ef28934b4093d71d43a8c9e9ec42ab4243febfff38470bfef05f51992ec',
|
||||
@@ -40,26 +40,47 @@ function hasTagValue(event: NostrEvent, name: string, values: readonly string[])
|
||||
return tagValues(event, name).some((value) => accepted.has(value.toLowerCase()));
|
||||
}
|
||||
|
||||
function hasAgoraTag(event: NostrEvent): boolean {
|
||||
return hasTagValue(event, 't', AGORA_T_TAGS);
|
||||
}
|
||||
|
||||
function isWorldComment(event: NostrEvent): boolean {
|
||||
return event.kind === COMMENT_KIND && hasTagValue(event, 'k', WORLD_K_TAGS);
|
||||
}
|
||||
|
||||
function isWorldPoll(event: NostrEvent): boolean {
|
||||
return event.kind === POLL_KIND && hasTagValue(event, 'k', WORLD_K_TAGS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict Agora filter — accepts an event only if it is genuinely Agora-created
|
||||
* content (carries the `t:agora` marker) OR is a world-layer event (country-
|
||||
* rooted comment / poll), which is intentionally surfaced cross-client.
|
||||
*
|
||||
* See `src/lib/agoraNoteTags.ts` and `NIP.md` (§ Agora Content Marker).
|
||||
*/
|
||||
function isRelevantAgoraEvent(event: NostrEvent): boolean {
|
||||
if (shouldHideFeedEvent(event)) return false;
|
||||
|
||||
if (event.kind === CAMPAIGN_KIND) return true;
|
||||
// World-layer posts are kept regardless of the Agora marker.
|
||||
if (isWorldComment(event) || isWorldPoll(event)) return true;
|
||||
|
||||
if (event.kind === PLEDGE_KIND) {
|
||||
return hasTagValue(event, 't', PLEDGE_T_ALIASES);
|
||||
}
|
||||
// Everything else must carry the Agora content marker.
|
||||
if (!hasAgoraTag(event)) return false;
|
||||
|
||||
if (event.kind === CAMPAIGN_KIND) return true;
|
||||
if (event.kind === COMMUNITY_KIND) return true;
|
||||
if (event.kind === PLEDGE_KIND) return true;
|
||||
|
||||
if (event.kind === COMMENT_KIND) {
|
||||
return hasTagValue(event, 'K', COMMENT_ROOT_KINDS) || hasTagValue(event, 'k', WORLD_K_TAGS);
|
||||
}
|
||||
|
||||
if (event.kind === POLL_KIND) {
|
||||
return hasTagValue(event, 'k', WORLD_K_TAGS);
|
||||
// Comment must reference an Agora entity root (campaign / pledge / community).
|
||||
return hasTagValue(event, 'K', COMMENT_ROOT_KINDS)
|
||||
|| tagValues(event, 'A').some((value) => value.startsWith(`${COMMUNITY_KIND}:`));
|
||||
}
|
||||
|
||||
if (event.kind === NOTE_KIND) {
|
||||
if (IGNORED_AGORA_NOTE_AUTHORS.has(event.pubkey)) return false;
|
||||
return hasTagValue(event, 't', AGORA_T_TAGS);
|
||||
return true; // already verified `t:agora` above
|
||||
}
|
||||
|
||||
if (event.kind === ONCHAIN_ZAP_KIND || event.kind === LIGHTNING_ZAP_KIND) {
|
||||
@@ -71,7 +92,7 @@ function isRelevantAgoraEvent(event: NostrEvent): boolean {
|
||||
|
||||
function isAgoraAddress(value: string): boolean {
|
||||
const kind = value.split(':')[0];
|
||||
return kind === String(CAMPAIGN_KIND) || kind === String(PLEDGE_KIND);
|
||||
return kind === String(CAMPAIGN_KIND) || kind === String(PLEDGE_KIND) || kind === String(COMMUNITY_KIND);
|
||||
}
|
||||
|
||||
function getAddressableCoordinate(event: NostrEvent): string | undefined {
|
||||
@@ -102,35 +123,61 @@ function extractDonationTargets(events: NostrEvent[]): { coordinates: string[];
|
||||
};
|
||||
}
|
||||
|
||||
/** Mixed Agora activity feed: campaigns, pledges, world posts, #Agora notes, and donation receipts. */
|
||||
export function useAgoraFeed(enabled: boolean) {
|
||||
export interface UseAgoraFeedOptions {
|
||||
/**
|
||||
* Restrict the feed to events authored by these pubkeys. Applied as an
|
||||
* `authors:` filter on every relay query (server-side filtering). Empty
|
||||
* array disables the query — used for "Following" mode when the user
|
||||
* follows nobody.
|
||||
*/
|
||||
authors?: string[];
|
||||
}
|
||||
|
||||
/** Strict Agora activity feed: campaigns, pledges, communities, world posts, #Agora notes, and donations. */
|
||||
export function useAgoraFeed(enabled: boolean, options?: UseAgoraFeedOptions) {
|
||||
const { nostr } = useNostr();
|
||||
const { muteItems } = useMuteList();
|
||||
const { shouldFilterEvent } = useContentFilters();
|
||||
|
||||
const authors = options?.authors;
|
||||
const authorsKey = authors ? [...authors].sort().join(',') : '';
|
||||
// If `authors` is provided but empty, the feed is intentionally empty
|
||||
// (e.g. the user follows nobody) — skip the query entirely.
|
||||
const authorsEmpty = authors !== undefined && authors.length === 0;
|
||||
const queryEnabled = enabled && !authorsEmpty;
|
||||
|
||||
const query = useInfiniteQuery<AgoraFeedPage, Error>({
|
||||
queryKey: ['agora-feed'],
|
||||
queryKey: ['agora-feed', authorsKey],
|
||||
queryFn: async ({ pageParam, signal: querySignal }) => {
|
||||
const signal = AbortSignal.any([querySignal, AbortSignal.timeout(8_000)]);
|
||||
const until = pageParam as number | undefined;
|
||||
const authorsFilter = authors && authors.length > 0 ? { authors } : {};
|
||||
|
||||
const filters: NostrFilter[] = [
|
||||
{ kinds: AGORA_ENTITY_KINDS, limit: AGORA_PAGE_SIZE, ...(until && { until }) },
|
||||
{ kinds: [COMMENT_KIND], '#K': COMMENT_ROOT_KINDS, limit: AGORA_PAGE_SIZE, ...(until && { until }) },
|
||||
{ kinds: [COMMENT_KIND, POLL_KIND], '#k': WORLD_K_TAGS, limit: AGORA_PAGE_SIZE, ...(until && { until }) },
|
||||
{ kinds: [NOTE_KIND], '#t': AGORA_T_TAGS, limit: Math.ceil(AGORA_PAGE_SIZE / 2), ...(until && { until }) },
|
||||
// Agora entity kinds — strict `t:agora` required.
|
||||
{ kinds: AGORA_ENTITY_KINDS, '#t': AGORA_T_TAGS, limit: AGORA_PAGE_SIZE, ...authorsFilter, ...(until && { until }) },
|
||||
// Comments on Agora entities — strict `t:agora` required.
|
||||
{ kinds: [COMMENT_KIND], '#t': AGORA_T_TAGS, '#K': COMMENT_ROOT_KINDS, limit: AGORA_PAGE_SIZE, ...authorsFilter, ...(until && { until }) },
|
||||
// World layer — country/geo-rooted comments and polls. Intentionally
|
||||
// cross-client; the `#k=iso3166|geo` filter is the entire gate.
|
||||
{ kinds: [COMMENT_KIND, POLL_KIND], '#k': WORLD_K_TAGS, limit: AGORA_PAGE_SIZE, ...authorsFilter, ...(until && { until }) },
|
||||
// `#Agora`-tagged kind 1 notes — accepts any author opting in via the tag.
|
||||
{ kinds: [NOTE_KIND], '#t': AGORA_T_TAGS, limit: Math.ceil(AGORA_PAGE_SIZE / 2), ...authorsFilter, ...(until && { until }) },
|
||||
];
|
||||
|
||||
const raw = await nostr.query(filters, { signal });
|
||||
const filtered = raw.filter(isRelevantAgoraEvent);
|
||||
const { coordinates, eventIds } = extractDonationTargets(filtered);
|
||||
|
||||
// Donation enrichment: pull lightning + onchain zaps that reference
|
||||
// the Agora entities visible on this page. Donation events must also
|
||||
// carry the Agora marker to be included (per `isRelevantAgoraEvent`).
|
||||
const donationFilters: NostrFilter[] = [];
|
||||
if (coordinates.length > 0) {
|
||||
donationFilters.push({ kinds: [LIGHTNING_ZAP_KIND, ONCHAIN_ZAP_KIND], '#a': coordinates, limit: coordinates.length * 10 });
|
||||
donationFilters.push({ kinds: [LIGHTNING_ZAP_KIND, ONCHAIN_ZAP_KIND], '#t': AGORA_T_TAGS, '#a': coordinates, limit: coordinates.length * 10 });
|
||||
}
|
||||
if (eventIds.length > 0) {
|
||||
donationFilters.push({ kinds: [LIGHTNING_ZAP_KIND, ONCHAIN_ZAP_KIND], '#e': eventIds, limit: eventIds.length * 10 });
|
||||
donationFilters.push({ kinds: [LIGHTNING_ZAP_KIND, ONCHAIN_ZAP_KIND], '#t': AGORA_T_TAGS, '#e': eventIds, limit: eventIds.length * 10 });
|
||||
}
|
||||
|
||||
const donationEvents = donationFilters.length > 0
|
||||
@@ -141,7 +188,7 @@ export function useAgoraFeed(enabled: boolean) {
|
||||
const combined = [
|
||||
...filtered,
|
||||
// Donation enrichment is already scoped by exact #a/#e targets from this page.
|
||||
...donationEvents.filter((event) => !shouldHideFeedEvent(event)),
|
||||
...donationEvents.filter((event) => !shouldHideFeedEvent(event) && hasAgoraTag(event)),
|
||||
]
|
||||
.filter((event) => {
|
||||
if (seen.has(event.id)) return false;
|
||||
@@ -166,7 +213,7 @@ export function useAgoraFeed(enabled: boolean) {
|
||||
if (lastPage.totalFetched < AGORA_PAGE_SIZE || !lastPage.oldestTimestamp) return undefined;
|
||||
return lastPage.oldestTimestamp - 1;
|
||||
},
|
||||
enabled,
|
||||
enabled: queryEnabled,
|
||||
staleTime: 30_000,
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
@@ -183,9 +230,9 @@ export function useAgoraFeed(enabled: boolean) {
|
||||
|
||||
return {
|
||||
events,
|
||||
isLoading: query.isPending,
|
||||
isLoading: queryEnabled ? query.isPending : false,
|
||||
isFetchingNextPage: query.isFetchingNextPage,
|
||||
hasNextPage: query.hasNextPage,
|
||||
hasNextPage: !authorsEmpty && query.hasNextPage,
|
||||
fetchNextPage: query.fetchNextPage,
|
||||
pageCount: query.data?.pages.length,
|
||||
};
|
||||
|
||||
@@ -30,7 +30,7 @@ interface CampaignScore {
|
||||
const EMPTY_SCORE: CampaignScore = { totalSats: 0, donorCount: 0 };
|
||||
|
||||
/**
|
||||
* Loads kind 30223 campaigns with optional Top ranking (most-zapped first)
|
||||
* Loads kind 33863 campaigns with optional Top ranking (most-zapped first)
|
||||
* and free-text search applied client-side.
|
||||
*
|
||||
* **Why client-side rather than NIP-50?** Ditto's `sort:top` / `sort:hot`
|
||||
@@ -46,7 +46,7 @@ const EMPTY_SCORE: CampaignScore = { totalSats: 0, donorCount: 0 };
|
||||
* - **Full relay coverage** — we fetch from the user's default pool, not
|
||||
* just Ditto, so campaigns published anywhere are discoverable.
|
||||
* - **Search that actually matches** — substring across title, summary,
|
||||
* story, location, and category tags.
|
||||
* and story.
|
||||
*
|
||||
* Tradeoff: we fetch up to `limit` (default 200) campaigns regardless of
|
||||
* search, then filter in JavaScript. At current campaign volume this is
|
||||
@@ -70,7 +70,7 @@ export function useAllCampaigns({
|
||||
[{ kinds: [CAMPAIGN_KIND], limit }],
|
||||
{ signal: AbortSignal.any([c.signal, AbortSignal.timeout(10_000)]) },
|
||||
);
|
||||
return parseCampaignEvents(events, { includeArchived: false, sortByCreatedAt: true });
|
||||
return parseCampaignEvents(events, { sortByCreatedAt: true });
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
@@ -185,9 +185,5 @@ function matchesQuery(campaign: ParsedCampaign, lowerQuery: string): boolean {
|
||||
if (campaign.title.toLowerCase().includes(lowerQuery)) return true;
|
||||
if (campaign.summary.toLowerCase().includes(lowerQuery)) return true;
|
||||
if (campaign.story.toLowerCase().includes(lowerQuery)) return true;
|
||||
// Location and `t` tags are short but worth matching so users can type
|
||||
// "kenya" or "mutual aid" and get useful results.
|
||||
if (campaign.location?.toLowerCase().includes(lowerQuery)) return true;
|
||||
if (campaign.tags.some((t) => t.toLowerCase().includes(lowerQuery))) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
import {
|
||||
CAMPAIGN_KIND,
|
||||
parseCampaign,
|
||||
type ParsedCampaign,
|
||||
} from '@/lib/campaign';
|
||||
|
||||
interface ArchiveCampaignArgs {
|
||||
campaign: ParsedCampaign;
|
||||
/** `true` to mark archived, `false` to reopen by removing the status tag. */
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive (or reopen) a fundraising campaign without deleting it.
|
||||
*
|
||||
* Archiving republishes the campaign with `["status", "archived"]` so the
|
||||
* UI can hide it from the main fundraisers feed while still loading it by
|
||||
* direct link. Past donations remain intact because the addressable
|
||||
* coordinate (kind, pubkey, d) is unchanged.
|
||||
*
|
||||
* Unarchive removes the status tag (or any other status value), bringing
|
||||
* the campaign back into the main list.
|
||||
*
|
||||
* Only the campaign author can archive — the relay would reject anyone
|
||||
* else's republish under the same coordinate, but we also guard at the
|
||||
* UI layer.
|
||||
*/
|
||||
export function useArchiveCampaign() {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ campaign, archived }: ArchiveCampaignArgs) => {
|
||||
if (!user) throw new Error('You must be logged in.');
|
||||
if (user.pubkey !== campaign.pubkey) {
|
||||
throw new Error('Only the campaign author can change its status.');
|
||||
}
|
||||
|
||||
// Read-modify-write: never trust the local cache for addressable mutations.
|
||||
const prev = await fetchFreshEvent(nostr, {
|
||||
kinds: [CAMPAIGN_KIND],
|
||||
authors: [user.pubkey],
|
||||
'#d': [campaign.identifier],
|
||||
});
|
||||
if (!prev || !parseCampaign(prev)) {
|
||||
throw new Error('Could not load the latest version of this campaign.');
|
||||
}
|
||||
|
||||
// Carry over every tag except any existing `status` tag, which we own here.
|
||||
const nextTags = prev.tags.filter(([name]) => name !== 'status');
|
||||
if (archived) nextTags.push(['status', 'archived']);
|
||||
|
||||
const published = await publishEvent({
|
||||
kind: CAMPAIGN_KIND,
|
||||
content: prev.content,
|
||||
tags: nextTags,
|
||||
prev,
|
||||
});
|
||||
|
||||
const parsed = parseCampaign(published);
|
||||
if (!parsed) {
|
||||
throw new Error('Updated campaign failed validation.');
|
||||
}
|
||||
return parsed;
|
||||
},
|
||||
onSuccess: (campaign) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['campaign', campaign.pubkey, campaign.identifier],
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: ['campaigns'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['campaign-featured'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,76 +1,133 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQueries, useQuery } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { verifyOnchainZap, extractOnchainZapTxid, type OnchainZapEntry } from '@/hooks/useOnchainZaps';
|
||||
import type { ParsedCampaign } from '@/lib/campaign';
|
||||
|
||||
export interface CampaignDonationStats {
|
||||
/** Total satoshis pledged across all kind 8333 receipts (self-reported sum). */
|
||||
/** Total satoshis pledged across all verified kind 8333 receipts. */
|
||||
totalSats: number;
|
||||
/** Number of unique on-chain transactions counted. */
|
||||
txCount: number;
|
||||
/** Number of unique donor pubkeys. */
|
||||
donorCount: number;
|
||||
/** All kind 8333 receipts for the campaign, newest first. */
|
||||
/** All raw kind 8333 receipts for the campaign, newest first. */
|
||||
receipts: NostrEvent[];
|
||||
/** Verified entries (one per unique txid). */
|
||||
verified: OnchainZapEntry[];
|
||||
/**
|
||||
* True while underlying verification queries are still in flight.
|
||||
* Callers may use this to defer rendering "0 sats raised" until
|
||||
* the verifier has had a chance to validate the receipts.
|
||||
*/
|
||||
isVerifying: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_RECEIPTS: NostrEvent[] = [];
|
||||
|
||||
/**
|
||||
* Aggregates donation receipts (kind 8333 events) for a campaign by its
|
||||
* addressable coordinate.
|
||||
* Aggregates donation receipts (kind 8333 events) for a campaign and
|
||||
* **verifies each one on-chain** before counting it toward the campaign
|
||||
* total.
|
||||
*
|
||||
* Each kind 8333 event's `amount` tag is the total sats paid to the
|
||||
* recipients listed in that event (see `NIP.md`). New donations publish a
|
||||
* single event per tx covering every recipient; legacy donations published
|
||||
* one event per recipient. In either case, summing `amount` across all
|
||||
* events that tag the campaign yields the campaign's total — the legacy
|
||||
* per-recipient amounts sum to the full donation, and the new per-tx
|
||||
* amount IS the full donation.
|
||||
* Per NIP.md §Kind 33863, each receipt:
|
||||
*
|
||||
* The returned `totalSats` is **self-reported**. Per the NIP.md spec a
|
||||
* strict client would verify each receipt against the on-chain transaction
|
||||
* before counting it; that's left to a future iteration (see TODO inline).
|
||||
* - Targets the campaign via an `a` tag (`33863:<pubkey>:<d>`).
|
||||
* - Carries an `i bitcoin:tx:<txid>` tag.
|
||||
* - Carries an `amount <sats>` tag (self-reported, capped at verified).
|
||||
* - Carries **no `p` tags** — campaigns are not Nostr-identity recipients.
|
||||
*
|
||||
* Verification re-fetches the tx from the configured Esplora endpoint and
|
||||
* sums the outputs paying the campaign's `w` address. The self-reported
|
||||
* `amount` is capped at the verified amount.
|
||||
*
|
||||
* Silent-payment campaigns (`w` starts with `sp1…`) short-circuit to
|
||||
* zeros — donations to SP campaigns are unlinkable by design and clients
|
||||
* MUST NOT publish receipts.
|
||||
*/
|
||||
export function useCampaignDonations(aTag: string | undefined) {
|
||||
export function useCampaignDonations(campaign: ParsedCampaign | undefined): {
|
||||
data: CampaignDonationStats;
|
||||
isLoading: boolean;
|
||||
} {
|
||||
const { nostr } = useNostr();
|
||||
const { config } = useAppContext();
|
||||
const { esploraApis } = config;
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['campaign-donations', aTag ?? ''],
|
||||
queryFn: async (c): Promise<CampaignDonationStats> => {
|
||||
if (!aTag) {
|
||||
return { totalSats: 0, txCount: 0, donorCount: 0, receipts: [] };
|
||||
}
|
||||
const aTag = campaign?.aTag;
|
||||
const wallet = campaign?.wallet;
|
||||
const isSilentPayment = wallet?.mode === 'sp';
|
||||
|
||||
// Step 1: fetch raw receipts. Disabled for SP campaigns.
|
||||
const receiptsQuery = useQuery({
|
||||
queryKey: ['campaign-donations', 'events', aTag ?? ''],
|
||||
queryFn: async ({ signal }): Promise<NostrEvent[]> => {
|
||||
if (!aTag) return EMPTY_RECEIPTS;
|
||||
const events = await nostr.query(
|
||||
[{ kinds: [8333], '#a': [aTag], limit: 500 }],
|
||||
{ signal: c.signal },
|
||||
{ signal },
|
||||
);
|
||||
|
||||
let totalSats = 0;
|
||||
const txids = new Set<string>();
|
||||
const donors = new Set<string>();
|
||||
for (const event of events) {
|
||||
const txid = event.tags.find(([n]) => n === 'i')?.[1]?.replace(/^bitcoin:tx:/, '');
|
||||
const amountTag = event.tags.find(([n]) => n === 'amount')?.[1];
|
||||
const amount = amountTag ? Number(amountTag) : NaN;
|
||||
if (!txid || !Number.isFinite(amount) || amount <= 0) continue;
|
||||
|
||||
totalSats += amount;
|
||||
txids.add(txid);
|
||||
donors.add(event.pubkey);
|
||||
}
|
||||
|
||||
// TODO: verify each txid against mempool.space and sum only the outputs
|
||||
// that pay listed recipients' derived Taproot addresses. Until then the
|
||||
// total is best-effort and trivially spoofable.
|
||||
|
||||
const receipts = [...events].sort((a, b) => b.created_at - a.created_at);
|
||||
|
||||
return {
|
||||
totalSats,
|
||||
txCount: txids.size,
|
||||
donorCount: donors.size,
|
||||
receipts,
|
||||
};
|
||||
return events;
|
||||
},
|
||||
enabled: !!aTag,
|
||||
enabled: !!aTag && !isSilentPayment,
|
||||
staleTime: 15_000,
|
||||
});
|
||||
|
||||
// Dedupe by txid; prefer the earliest receipt per tx (first to claim).
|
||||
const receipts = receiptsQuery.data ?? EMPTY_RECEIPTS;
|
||||
const dedupedByTxid = (() => {
|
||||
const byTxid = new Map<string, NostrEvent>();
|
||||
for (const event of receipts) {
|
||||
const txid = extractOnchainZapTxid(event);
|
||||
if (!txid) continue;
|
||||
const existing = byTxid.get(txid);
|
||||
if (!existing || event.created_at < existing.created_at) {
|
||||
byTxid.set(txid, event);
|
||||
}
|
||||
}
|
||||
return Array.from(byTxid.values());
|
||||
})();
|
||||
|
||||
// Step 2: verify each unique-txid receipt against the campaign's `w`
|
||||
// wallet address. SP campaigns are short-circuited above so the
|
||||
// wallet here is always `onchain` mode when present.
|
||||
const walletValue = wallet?.value;
|
||||
const verifications = useQueries({
|
||||
queries: dedupedByTxid.map((event) => ({
|
||||
queryKey: ['onchain-zaps', 'verify', esploraApis, event.id, walletValue ?? ''],
|
||||
queryFn: ({ signal }: { signal: AbortSignal }) =>
|
||||
verifyOnchainZap(event, esploraApis, walletValue, signal),
|
||||
staleTime: 60_000,
|
||||
enabled: !!walletValue && !isSilentPayment,
|
||||
})),
|
||||
});
|
||||
|
||||
const verified: OnchainZapEntry[] = verifications
|
||||
.map((v) => v.data)
|
||||
.filter((v): v is OnchainZapEntry => !!v);
|
||||
|
||||
const totalSats = verified.reduce((sum, v) => sum + v.amountSats, 0);
|
||||
const txids = new Set<string>();
|
||||
const donors = new Set<string>();
|
||||
for (const v of verified) {
|
||||
txids.add(v.txid);
|
||||
donors.add(v.senderPubkey);
|
||||
}
|
||||
|
||||
const sortedReceipts = [...receipts].sort((a, b) => b.created_at - a.created_at);
|
||||
|
||||
const isVerifying = !isSilentPayment && (receiptsQuery.isLoading || verifications.some((v) => v.isLoading));
|
||||
|
||||
return {
|
||||
data: {
|
||||
totalSats,
|
||||
txCount: txids.size,
|
||||
donorCount: donors.size,
|
||||
receipts: sortedReceipts,
|
||||
verified,
|
||||
isVerifying,
|
||||
},
|
||||
isLoading: isVerifying,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,130 +1,31 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useNostrPublish } from './useNostrPublish';
|
||||
import { useCampaignModerators } from './useCampaignModerators';
|
||||
import { CAMPAIGN_KIND } from '@/lib/campaign';
|
||||
import {
|
||||
AGORA_MODERATION_NAMESPACE,
|
||||
EMPTY_MODERATION_DATA,
|
||||
LABEL_KIND,
|
||||
type ModerationData,
|
||||
type ModerationLabel,
|
||||
type ModerationState,
|
||||
foldModerationLabels,
|
||||
} from '@/lib/agoraModeration';
|
||||
|
||||
/** NIP-32 label kind. */
|
||||
const LABEL_KIND = 1985;
|
||||
/** Label namespace for Agora's moderation labels. */
|
||||
export const AGORA_MODERATION_NAMESPACE = 'agora.moderation';
|
||||
|
||||
/** The six possible label values in the moderation namespace. */
|
||||
export type ModerationLabel =
|
||||
| 'approved'
|
||||
| 'unapproved'
|
||||
| 'hidden'
|
||||
| 'unhidden'
|
||||
| 'featured'
|
||||
| 'unfeatured';
|
||||
|
||||
/** A single label event narrowed to its decision axis. */
|
||||
interface AxisDecision {
|
||||
/** Latest label observed on this axis. */
|
||||
label: ModerationLabel;
|
||||
/** Author of the latest label. */
|
||||
pubkey: string;
|
||||
/** Created-at of the latest label. */
|
||||
createdAt: number;
|
||||
}
|
||||
// Re-exports for existing import sites. The namespace constant and the
|
||||
// `ModerationLabel` type are imported from this module by the campaign
|
||||
// moderation menu and other surfaces; keep those exports stable so the
|
||||
// shared-module refactor stays a no-op for callers.
|
||||
export { AGORA_MODERATION_NAMESPACE };
|
||||
export type { ModerationLabel };
|
||||
|
||||
/** Per-campaign rollup of approval + hide + featured state. */
|
||||
export interface CampaignModerationState {
|
||||
approval?: AxisDecision; // `approved` or `unapproved`
|
||||
hide?: AxisDecision; // `hidden` or `unhidden`
|
||||
featured?: AxisDecision; // `featured` or `unfeatured`
|
||||
}
|
||||
export type CampaignModerationState = ModerationState;
|
||||
|
||||
export interface CampaignModerationData {
|
||||
/** Map of `30223:<pubkey>:<d>` -> rollup. */
|
||||
byCoord: Map<string, CampaignModerationState>;
|
||||
/** Coordinates where the latest approval label is `approved`. */
|
||||
approvedCoords: Set<string>;
|
||||
/** Coordinates where the latest hide label is `hidden`. */
|
||||
hiddenCoords: Set<string>;
|
||||
/** Coordinates where the latest featured label is `featured`. */
|
||||
featuredCoords: Set<string>;
|
||||
/**
|
||||
* Map of `coord` -> `created_at` of the latest `featured` label.
|
||||
* Used to sort the home-page featured row newest-first.
|
||||
*/
|
||||
featuredOrder: Map<string, number>;
|
||||
/** Pubkeys that were considered moderators when the query ran. */
|
||||
moderators: string[];
|
||||
}
|
||||
|
||||
const EMPTY_DATA: CampaignModerationData = {
|
||||
byCoord: new Map(),
|
||||
approvedCoords: new Set(),
|
||||
hiddenCoords: new Set(),
|
||||
featuredCoords: new Set(),
|
||||
featuredOrder: new Map(),
|
||||
moderators: [],
|
||||
};
|
||||
|
||||
/** True if a label value belongs to the approval axis. */
|
||||
function isApprovalLabel(value: string): value is 'approved' | 'unapproved' {
|
||||
return value === 'approved' || value === 'unapproved';
|
||||
}
|
||||
|
||||
/** True if a label value belongs to the hide axis. */
|
||||
function isHideLabel(value: string): value is 'hidden' | 'unhidden' {
|
||||
return value === 'hidden' || value === 'unhidden';
|
||||
}
|
||||
|
||||
/** True if a label value belongs to the featured axis. */
|
||||
function isFeaturedLabel(value: string): value is 'featured' | 'unfeatured' {
|
||||
return value === 'featured' || value === 'unfeatured';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a flat list of label events into per-coordinate rollups by axis.
|
||||
* The newest event per `(coord, axis)` wins. Events not addressing a
|
||||
* campaign coordinate or carrying a value outside the namespace are dropped.
|
||||
*/
|
||||
function foldLabelEvents(events: NostrEvent[], moderators: string[]): CampaignModerationData {
|
||||
const byCoord = new Map<string, CampaignModerationState>();
|
||||
|
||||
for (const event of events) {
|
||||
const value = event.tags.find(([n, , ns]) => n === 'l' && ns === AGORA_MODERATION_NAMESPACE)?.[1];
|
||||
if (!value) continue;
|
||||
const aTag = event.tags.find(([n, v]) => n === 'a' && typeof v === 'string' && v.startsWith(`${CAMPAIGN_KIND}:`))?.[1];
|
||||
if (!aTag) continue;
|
||||
|
||||
const state = byCoord.get(aTag) ?? {};
|
||||
if (isApprovalLabel(value)) {
|
||||
if (!state.approval || event.created_at > state.approval.createdAt) {
|
||||
state.approval = { label: value, pubkey: event.pubkey, createdAt: event.created_at };
|
||||
}
|
||||
} else if (isHideLabel(value)) {
|
||||
if (!state.hide || event.created_at > state.hide.createdAt) {
|
||||
state.hide = { label: value, pubkey: event.pubkey, createdAt: event.created_at };
|
||||
}
|
||||
} else if (isFeaturedLabel(value)) {
|
||||
if (!state.featured || event.created_at > state.featured.createdAt) {
|
||||
state.featured = { label: value, pubkey: event.pubkey, createdAt: event.created_at };
|
||||
}
|
||||
}
|
||||
byCoord.set(aTag, state);
|
||||
}
|
||||
|
||||
const approvedCoords = new Set<string>();
|
||||
const hiddenCoords = new Set<string>();
|
||||
const featuredCoords = new Set<string>();
|
||||
const featuredOrder = new Map<string, number>();
|
||||
for (const [coord, state] of byCoord) {
|
||||
if (state.approval?.label === 'approved') approvedCoords.add(coord);
|
||||
if (state.hide?.label === 'hidden') hiddenCoords.add(coord);
|
||||
if (state.featured?.label === 'featured') {
|
||||
featuredCoords.add(coord);
|
||||
featuredOrder.set(coord, state.featured.createdAt);
|
||||
}
|
||||
}
|
||||
|
||||
return { byCoord, approvedCoords, hiddenCoords, featuredCoords, featuredOrder, moderators };
|
||||
}
|
||||
/** Surface-scoped alias so existing callers keep working. */
|
||||
export type CampaignModerationData = ModerationData;
|
||||
|
||||
/**
|
||||
* Fetches and folds campaign-moderation label events authored by Team
|
||||
@@ -154,7 +55,8 @@ export function useCampaignModeration() {
|
||||
// an empty `authors:` filter (which would return everything matching the
|
||||
// namespace from any author and break our trust model — see AGENTS.md).
|
||||
// Once moderators arrives empty, the query runs and immediately resolves
|
||||
// to EMPTY_DATA — no rendering can promote a campaign without a moderator.
|
||||
// to EMPTY_MODERATION_DATA — no rendering can promote a campaign without
|
||||
// a moderator.
|
||||
const moderatorsKey = moderators ? [...moderators].sort().join(',') : '';
|
||||
|
||||
const moderationQuery = useQuery({
|
||||
@@ -162,7 +64,7 @@ export function useCampaignModeration() {
|
||||
enabled: moderators !== undefined,
|
||||
queryFn: async ({ signal }): Promise<CampaignModerationData> => {
|
||||
if (!moderators || moderators.length === 0) {
|
||||
return { ...EMPTY_DATA, moderators: [] };
|
||||
return { ...EMPTY_MODERATION_DATA, moderators: [] };
|
||||
}
|
||||
const events = await nostr.query(
|
||||
[
|
||||
@@ -179,7 +81,7 @@ export function useCampaignModeration() {
|
||||
],
|
||||
{ signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]) },
|
||||
);
|
||||
return foldLabelEvents(events, moderators);
|
||||
return foldModerationLabels(events, moderators, CAMPAIGN_KIND);
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
@@ -207,7 +109,7 @@ export function useCampaignModeration() {
|
||||
});
|
||||
|
||||
return {
|
||||
data: moderationQuery.data ?? EMPTY_DATA,
|
||||
data: moderationQuery.data ?? EMPTY_MODERATION_DATA,
|
||||
isPending: moderationQuery.isPending,
|
||||
isLoading: moderationQuery.isLoading,
|
||||
isReady: moderationQuery.isSuccess,
|
||||
|
||||
+13
-40
@@ -2,16 +2,10 @@ import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||
|
||||
import { CAMPAIGN_KIND, type CampaignCategory, parseCampaign, type ParsedCampaign } from '@/lib/campaign';
|
||||
import { CAMPAIGN_KIND, parseCampaign, type ParsedCampaign } from '@/lib/campaign';
|
||||
import { createCountryIdentifier } from '@/lib/countryIdentifiers';
|
||||
|
||||
interface ParseCampaignEventsOptions {
|
||||
/**
|
||||
* Include campaigns whose latest revision carries `["status", "archived"]`.
|
||||
* Defaults to `false` so archived campaigns never appear in the main
|
||||
* fundraisers listing.
|
||||
*/
|
||||
includeArchived?: boolean;
|
||||
/**
|
||||
* When `true`, sort the parsed campaigns newest-`created_at`-first.
|
||||
* When `false`, preserve the order in which events were returned —
|
||||
@@ -23,7 +17,7 @@ interface ParseCampaignEventsOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate, parse, and (optionally) reorder a flat list of kind 30223
|
||||
* Deduplicate, parse, and (optionally) reorder a flat list of kind 33863
|
||||
* events into `ParsedCampaign` objects.
|
||||
*
|
||||
* For each `(pubkey, d)` pair we keep only the latest event — relays may
|
||||
@@ -35,7 +29,7 @@ interface ParseCampaignEventsOptions {
|
||||
*/
|
||||
export function parseCampaignEvents(
|
||||
events: NostrEvent[],
|
||||
{ includeArchived = false, sortByCreatedAt = true }: ParseCampaignEventsOptions = {},
|
||||
{ sortByCreatedAt = true }: ParseCampaignEventsOptions = {},
|
||||
): ParsedCampaign[] {
|
||||
// Track insertion order keyed by coord so we can preserve relay-scored
|
||||
// order when we don't want to re-sort. `Map` iteration order is insertion
|
||||
@@ -62,7 +56,6 @@ export function parseCampaignEvents(
|
||||
if (!event) continue;
|
||||
const campaign = parseCampaign(event);
|
||||
if (!campaign) continue;
|
||||
if (!includeArchived && campaign.archived) continue;
|
||||
parsed.push(campaign);
|
||||
}
|
||||
|
||||
@@ -74,8 +67,6 @@ export function parseCampaignEvents(
|
||||
}
|
||||
|
||||
interface UseCampaignsOptions {
|
||||
/** Optional category filter (`t` tag). */
|
||||
category?: CampaignCategory;
|
||||
/** Optional ISO 3166-1 alpha-2 country filter (`i` tag). */
|
||||
countryCode?: string;
|
||||
/** Maximum number of events to fetch from relays. Default: 60. */
|
||||
@@ -83,12 +74,7 @@ interface UseCampaignsOptions {
|
||||
/** Authors to fetch from, e.g. for a profile's campaigns. */
|
||||
authors?: string[];
|
||||
/**
|
||||
* Restrict to campaigns whose recipient `p` tags include any of these
|
||||
* pubkeys. Used by the /claim page to find campaigns set up *for* a user.
|
||||
*/
|
||||
recipientPubkeys?: string[];
|
||||
/**
|
||||
* Restrict to a specific set of `30223:<pubkey>:<d>` coordinates.
|
||||
* Restrict to a specific set of `33863:<pubkey>:<d>` coordinates.
|
||||
*
|
||||
* Used by moderator-curated surfaces (the home page, Discover) that only
|
||||
* want to render campaigns labeled `approved` by a Team Soapbox moderator
|
||||
@@ -102,24 +88,19 @@ interface UseCampaignsOptions {
|
||||
* the unfiltered behavior while their moderator query loads.
|
||||
*/
|
||||
coordinates?: string[];
|
||||
/**
|
||||
* Include campaigns that have been archived by their creator
|
||||
* (`["status", "archived"]`). Defaults to `false` so archived
|
||||
* campaigns never appear in the main fundraisers listing.
|
||||
*/
|
||||
includeArchived?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads kind 30223 campaign events and returns them as fully-parsed
|
||||
* Loads kind 33863 campaign events and returns them as fully-parsed
|
||||
* {@link ParsedCampaign} objects, newest first.
|
||||
*
|
||||
* Campaigns that fail validation (missing title, no recipients, etc.) are
|
||||
* Campaigns that fail validation (missing title, no `w` wallet, etc.) are
|
||||
* dropped so the UI never has to defensively check for missing fields.
|
||||
*
|
||||
* Archived campaigns (`status=archived`) are excluded by default. Pass
|
||||
* `includeArchived: true` to load them — used by the author's own profile
|
||||
* view so they can see and reopen their own archives.
|
||||
* To stop a campaign from appearing the creator publishes a NIP-09 kind 5
|
||||
* deletion request referencing the campaign's `a` coordinate; well-behaved
|
||||
* relays honor the deletion and the campaign drops out of result sets
|
||||
* automatically.
|
||||
*
|
||||
* For each `(pubkey, d)` pair we keep only the latest event — relays may
|
||||
* return older revisions of an addressable event alongside the current one.
|
||||
@@ -127,13 +108,10 @@ interface UseCampaignsOptions {
|
||||
export function useCampaigns(options: UseCampaignsOptions = {}) {
|
||||
const { nostr } = useNostr();
|
||||
const {
|
||||
category,
|
||||
countryCode,
|
||||
limit = 60,
|
||||
authors,
|
||||
recipientPubkeys,
|
||||
coordinates,
|
||||
includeArchived = false,
|
||||
} = options;
|
||||
|
||||
// Stable cache key for the coordinates option; sort so order doesn't
|
||||
@@ -143,7 +121,7 @@ export function useCampaigns(options: UseCampaignsOptions = {}) {
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
'campaigns',
|
||||
{ category, countryCode, limit, authors, recipientPubkeys, coordinatesKey, includeArchived },
|
||||
{ countryCode, limit, authors, coordinatesKey },
|
||||
],
|
||||
queryFn: async (c) => {
|
||||
// Sentinel: empty allowlist = empty result. Skip the relay entirely.
|
||||
@@ -156,7 +134,7 @@ export function useCampaigns(options: UseCampaignsOptions = {}) {
|
||||
if (coordinates && coordinates.length > 0) {
|
||||
const byAuthor = new Map<string, string[]>();
|
||||
for (const coord of coordinates) {
|
||||
// Expected: `30223:<pubkey>:<d>`
|
||||
// Expected: `33863:<pubkey>:<d>`
|
||||
const parts = coord.split(':');
|
||||
if (parts.length < 3) continue;
|
||||
const kindPart = Number(parts[0]);
|
||||
@@ -171,23 +149,18 @@ export function useCampaigns(options: UseCampaignsOptions = {}) {
|
||||
if (byAuthor.size === 0) return [] as ParsedCampaign[];
|
||||
filters = Array.from(byAuthor, ([author, dTags]) => {
|
||||
const f: NostrFilter = { kinds: [CAMPAIGN_KIND], authors: [author], '#d': dTags };
|
||||
if (category) f['#t'] = [category];
|
||||
if (countryCode) f['#i'] = [createCountryIdentifier(countryCode)];
|
||||
return f;
|
||||
});
|
||||
} else {
|
||||
const filter: NostrFilter = { kinds: [CAMPAIGN_KIND], limit };
|
||||
if (category) filter['#t'] = [category];
|
||||
if (countryCode) filter['#i'] = [createCountryIdentifier(countryCode)];
|
||||
if (authors && authors.length > 0) filter.authors = authors;
|
||||
if (recipientPubkeys && recipientPubkeys.length > 0) {
|
||||
filter['#p'] = recipientPubkeys;
|
||||
}
|
||||
filters = [filter];
|
||||
}
|
||||
|
||||
const events = await nostr.query(filters, { signal: c.signal });
|
||||
return parseCampaignEvents(events, { includeArchived, sortByCreatedAt: true });
|
||||
return parseCampaignEvents(events, { sortByCreatedAt: true });
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { getCountryInfo } from '@/lib/countries';
|
||||
import { getStorageKey } from '@/lib/storageKey';
|
||||
|
||||
/**
|
||||
* Sentinel value for "post to the global / world feed" (a plain kind 1 note
|
||||
* with no country root). Matches the value used by ComposeBox's `destination`
|
||||
* state.
|
||||
*/
|
||||
export type PostCountryDestination = 'world' | string;
|
||||
|
||||
const WORLD = 'world' as const;
|
||||
const STORAGE_SUFFIX = 'compose-default-country';
|
||||
|
||||
function readStored(key: string): PostCountryDestination {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw || raw === WORLD) return WORLD;
|
||||
// Validate against the country directory so a stale code (or an
|
||||
// invalid string from a different version) doesn't pin the composer
|
||||
// to a country that no longer parses.
|
||||
return getCountryInfo(raw) ? raw : WORLD;
|
||||
} catch {
|
||||
return WORLD;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's preferred default post destination — either `'world'` (plain
|
||||
* kind 1) or an ISO 3166 country code (NIP-22 country-rooted kind 1111).
|
||||
*
|
||||
* Persisted to localStorage so the choice survives reloads. Hydrates
|
||||
* synchronously from storage on first render, so the composer never flashes
|
||||
* the wrong default.
|
||||
*
|
||||
* The act of selecting a destination in ComposeBox does NOT auto-save the
|
||||
* default — there is an explicit "Set as default" affordance for that. This
|
||||
* means a user posting once to a country they don't normally post to does
|
||||
* not unintentionally change their default.
|
||||
*/
|
||||
export function useDefaultPostCountry(): [
|
||||
PostCountryDestination,
|
||||
(value: PostCountryDestination) => void,
|
||||
] {
|
||||
const { config } = useAppContext();
|
||||
const key = getStorageKey(config.appId, STORAGE_SUFFIX);
|
||||
|
||||
const [value, setValue] = useState<PostCountryDestination>(() => readStored(key));
|
||||
|
||||
const setDefault = useCallback(
|
||||
(next: PostCountryDestination) => {
|
||||
setValue(next);
|
||||
try {
|
||||
if (next === WORLD) {
|
||||
localStorage.setItem(key, WORLD);
|
||||
} else if (getCountryInfo(next)) {
|
||||
localStorage.setItem(key, next);
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable — non-critical.
|
||||
}
|
||||
},
|
||||
[key],
|
||||
);
|
||||
|
||||
return [value, setDefault];
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||
|
||||
import { CAMPAIGN_KIND } from '@/lib/campaign';
|
||||
import { shouldHideFeedEvent } from '@/lib/feedUtils';
|
||||
|
||||
const DISCOVER_PAGE_SIZE = 30;
|
||||
|
||||
/**
|
||||
* Kinds surfaced in the mixed Discover feed:
|
||||
*
|
||||
* - **30223** — new campaign creations (addressable). When a campaign is
|
||||
* minted or revised it bubbles back to the top, the same way a new
|
||||
* Substack post would.
|
||||
* - **1111** — NIP-22 comments. We pull two slices: comments scoped to
|
||||
* countries (`#K = iso3166`) and comments scoped to communities
|
||||
* (`#K = 34550`). Together these are "posts from the world" + "voices
|
||||
* inside the communities".
|
||||
* - **36639** — Agora pledges (challenges / civic calls). Always
|
||||
* included because they're the most action-oriented funding signal.
|
||||
*
|
||||
* We deliberately *exclude* free-form kind 1 notes here — the Discover
|
||||
* page is the place to see content that's tagged to a real-world thread
|
||||
* (country, community, campaign), not the global text-note firehose. The
|
||||
* old plain feed still lives at `/feed`.
|
||||
*/
|
||||
|
||||
/** Tag scopes we accept on kind 1111 comments. */
|
||||
const COMMENT_K_SCOPES = ['iso3166', 'geo', '34550'];
|
||||
|
||||
/** Aliases we accept on kind 36639 pledge `t` tags. */
|
||||
const ACTION_T_ALIASES = ['agora-action', 'pathos-challenge', 'agora-challenge'];
|
||||
|
||||
/**
|
||||
* Apply Discover-specific filtering after relay fetch. Drops events that
|
||||
* `shouldHideFeedEvent` flags (mutes, content filters happen later) and
|
||||
* any 1111 comment that lacks a recognised scope tag, since relays may
|
||||
* over-return when we union filters.
|
||||
*/
|
||||
function filterDiscoverEvents(events: NostrEvent[]): NostrEvent[] {
|
||||
return events
|
||||
.filter((event) => {
|
||||
if (shouldHideFeedEvent(event)) return false;
|
||||
if (event.kind === 1111) {
|
||||
const kTags = event.tags
|
||||
.filter(([n]) => n === 'k' || n === 'K')
|
||||
.map(([, v]) => v);
|
||||
return kTags.some((v) => COMMENT_K_SCOPES.includes(v));
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => b.created_at - a.created_at);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public infinite feed for the Discover page. Streams together new
|
||||
* campaigns, world-tagged comments, community comments, and Agora
|
||||
* pledges, paginated by `created_at` cursor.
|
||||
*
|
||||
* Each page issues exactly one relay request (the union of all relevant
|
||||
* filters) to stay inside per-page rate budgets — the same pattern
|
||||
* `useWorldFeed` uses.
|
||||
*
|
||||
* Returns the standard `useInfiniteQuery` surface plus a flattened
|
||||
* `events` list for convenient consumption.
|
||||
*/
|
||||
export function useDiscoverFeed(enabled = true) {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: ['discover-feed'],
|
||||
queryFn: async ({ pageParam, signal: querySignal }) => {
|
||||
const signal = AbortSignal.any([querySignal, AbortSignal.timeout(8_000)]);
|
||||
const until = pageParam as number | undefined;
|
||||
|
||||
const filters: NostrFilter[] = [
|
||||
// New / revised campaigns — addressable, so we lean on a small
|
||||
// limit and let the relay's natural newest-first ordering surface
|
||||
// recent edits. No `#k` scoping needed.
|
||||
{
|
||||
kinds: [CAMPAIGN_KIND],
|
||||
limit: Math.floor(DISCOVER_PAGE_SIZE / 3),
|
||||
...(until && { until }),
|
||||
},
|
||||
// Community + country-scoped comments.
|
||||
{
|
||||
kinds: [1111],
|
||||
'#K': COMMENT_K_SCOPES,
|
||||
limit: DISCOVER_PAGE_SIZE,
|
||||
...(until && { until }),
|
||||
},
|
||||
// Agora pledges.
|
||||
{
|
||||
kinds: [36639],
|
||||
'#t': ACTION_T_ALIASES,
|
||||
limit: Math.floor(DISCOVER_PAGE_SIZE / 3),
|
||||
...(until && { until }),
|
||||
},
|
||||
];
|
||||
|
||||
const raw = await nostr.query(filters, { signal });
|
||||
const filtered = filterDiscoverEvents(raw);
|
||||
const page = filtered.slice(0, DISCOVER_PAGE_SIZE);
|
||||
|
||||
const oldestTimestamp = page.length > 0
|
||||
? page[page.length - 1].created_at
|
||||
: null;
|
||||
|
||||
return {
|
||||
events: page,
|
||||
oldestTimestamp,
|
||||
totalFetched: filtered.length,
|
||||
};
|
||||
},
|
||||
initialPageParam: undefined as number | undefined,
|
||||
getNextPageParam: (lastPage) => {
|
||||
if (lastPage.totalFetched < DISCOVER_PAGE_SIZE || !lastPage.oldestTimestamp) {
|
||||
return undefined;
|
||||
}
|
||||
return lastPage.oldestTimestamp - 1;
|
||||
},
|
||||
enabled,
|
||||
staleTime: 30_000,
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
|
||||
// Flatten + dedupe. Each addressable event may legitimately appear
|
||||
// across pages if a newer revision lands; we keep the newest version.
|
||||
const seen = new Set<string>();
|
||||
const events: NostrEvent[] = [];
|
||||
for (const page of query.data?.pages ?? []) {
|
||||
for (const event of page.events) {
|
||||
if (seen.has(event.id)) continue;
|
||||
seen.add(event.id);
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
events,
|
||||
isLoading: query.isPending,
|
||||
isFetchingNextPage: query.isFetchingNextPage,
|
||||
hasNextPage: query.hasNextPage,
|
||||
fetchNextPage: query.fetchNextPage,
|
||||
pageCount: query.data?.pages.length,
|
||||
};
|
||||
}
|
||||
@@ -5,25 +5,25 @@ import { isSignerCapabilityError, reportSignerUnsupported, useBitcoinSigner } fr
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import {
|
||||
BITCOIN_DUST_LIMIT,
|
||||
broadcastTransaction,
|
||||
buildUnsignedMultiOutputPsbt,
|
||||
buildUnsignedPsbt,
|
||||
fetchUTXOs,
|
||||
finalizePsbt,
|
||||
getFeeRates,
|
||||
nostrPubkeyToBitcoinAddress,
|
||||
} from '@/lib/bitcoin';
|
||||
import type { FeeRates } from '@/lib/bitcoin';
|
||||
import { minDonationForSplit, type ParsedCampaign, splitDonation } from '@/lib/campaign';
|
||||
import { CAMPAIGN_KIND, type ParsedCampaign } from '@/lib/campaign';
|
||||
import { withAgoraTag } from '@/lib/agoraNoteTags';
|
||||
|
||||
/** Supported on-chain fee speeds (mirrors {@link SendBitcoinDialog}). */
|
||||
export type DonationFeeSpeed = 'fastest' | 'halfHour' | 'hour' | 'economy';
|
||||
|
||||
export interface DonateCampaignArgs {
|
||||
campaign: ParsedCampaign;
|
||||
/** Total donation amount in satoshis. Split across recipients per the campaign weights. */
|
||||
/** Donation amount in satoshis. */
|
||||
amountSats: number;
|
||||
/** Optional public comment included in each kind 8333 receipt. */
|
||||
/** Optional public comment included in the kind 8333 receipt. */
|
||||
comment?: string;
|
||||
/** Fee speed for the on-chain tx. Default: `halfHour`. */
|
||||
feeSpeed?: DonationFeeSpeed;
|
||||
@@ -34,9 +34,7 @@ export interface DonateCampaignResult {
|
||||
txid: string;
|
||||
/** On-chain fee paid in satoshis. */
|
||||
fee: number;
|
||||
/** Number of recipients that received funds in the tx. */
|
||||
recipientCount: number;
|
||||
/** Total sent to recipients (donation amount; excludes fee). */
|
||||
/** Sats paid to the campaign wallet (excludes fee and donor change). */
|
||||
totalSats: number;
|
||||
/** Whether the kind 8333 donation receipt published successfully. */
|
||||
receiptPublished: boolean;
|
||||
@@ -58,17 +56,29 @@ function errorMessage(error: unknown): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutation hook that sends a single multi-output Bitcoin transaction to all
|
||||
* of a campaign's recipients (split per their weights), broadcasts it via
|
||||
* mempool.space, and then publishes a single kind 8333 onchain-zap receipt
|
||||
* for the transaction referencing the campaign's addressable coordinate and
|
||||
* listing every recipient under its own `p` tag.
|
||||
* Mutation hook that donates to a campaign by paying its declared wallet
|
||||
* endpoint with a single Bitcoin transaction, then publishing a kind 8333
|
||||
* donation receipt referencing the campaign's addressable coordinate.
|
||||
*
|
||||
* Returns an async function that throws on any pre-broadcast failure
|
||||
* (insufficient funds, signer not available, dust, etc.). Once the tx is
|
||||
* broadcast, the function always resolves: a kind 8333 publish failure is
|
||||
* reported in {@link DonateCampaignResult.receiptPublished} rather than
|
||||
* thrown, because the donation itself is already final on-chain.
|
||||
* The campaign's `w` tag drives the destination:
|
||||
*
|
||||
* - **on-chain** (`bc1q…` / `bc1p…`) — the donor's client builds a
|
||||
* single-output PSBT paying the campaign address, broadcasts it, then
|
||||
* publishes a kind 8333 receipt with no `p` tags (campaigns are not
|
||||
* Nostr-identity recipients; verification matches tx outputs against
|
||||
* the campaign's `w` address).
|
||||
* - **silent payment** (`sp1…`) — this hook refuses the request.
|
||||
* Donating to a silent-payment campaign requires a BIP-352-aware
|
||||
* wallet that derives a fresh one-time output from the SP code; the
|
||||
* in-app Taproot signer does not support that. Donors are directed to
|
||||
* an external wallet via a copy/QR affordance instead, and no Nostr
|
||||
* event is ever published.
|
||||
*
|
||||
* Throws on any pre-broadcast failure (insufficient funds, signer not
|
||||
* available, SP mode, etc.). Once the tx is broadcast, the function
|
||||
* always resolves: a kind 8333 publish failure is reported in
|
||||
* {@link DonateCampaignResult.receiptPublished} rather than thrown,
|
||||
* because the donation itself is already final on-chain.
|
||||
*/
|
||||
export function useDonateCampaign() {
|
||||
const { user } = useCurrentUser();
|
||||
@@ -92,30 +102,21 @@ export function useDonateCampaign() {
|
||||
throw new Error('Enter a valid donation amount in satoshis.');
|
||||
}
|
||||
|
||||
// Split the donation across the campaign's payable recipients.
|
||||
const splits = splitDonation(campaign.recipients, amountSats, user.pubkey);
|
||||
|
||||
// Dust guard: every output must clear the BIP-141 dust limit for P2TR.
|
||||
const tooSmall = splits.find((s) => s.amountSats < BITCOIN_DUST_LIMIT);
|
||||
if (tooSmall) {
|
||||
const min = minDonationForSplit(campaign.recipients, user.pubkey, BITCOIN_DUST_LIMIT);
|
||||
if (campaign.wallet.mode === 'sp') {
|
||||
throw new Error(
|
||||
`Donation is too small to split: each recipient would get less than the dust limit (${BITCOIN_DUST_LIMIT} sats). Minimum: ${min.toLocaleString()} sats.`,
|
||||
'This campaign uses silent payments. Donate from an external BIP-352-capable wallet using the QR code.',
|
||||
);
|
||||
}
|
||||
|
||||
// Build the multi-output PSBT.
|
||||
// Donor cannot donate to their own campaign (the tx output would just
|
||||
// pay the donor's own wallet — an obvious foot-gun).
|
||||
if (campaign.pubkey === user.pubkey) {
|
||||
throw new Error('You cannot donate to your own campaign.');
|
||||
}
|
||||
|
||||
const senderAddress = nostrPubkeyToBitcoinAddress(user.pubkey);
|
||||
if (!senderAddress) throw new Error('Failed to derive your Bitcoin address.');
|
||||
|
||||
const outputs = splits.map((s) => {
|
||||
const address = nostrPubkeyToBitcoinAddress(s.pubkey);
|
||||
if (!address) {
|
||||
throw new Error(`Failed to derive Bitcoin address for ${s.pubkey.slice(0, 8)}…`);
|
||||
}
|
||||
return { address, amountSats: s.amountSats };
|
||||
});
|
||||
|
||||
const [utxos, rates] = await Promise.all([fetchUTXOs(senderAddress, esploraApis), getFeeRates(esploraApis)]);
|
||||
if (utxos.length === 0) {
|
||||
throw new Error('Your Bitcoin wallet has no spendable funds.');
|
||||
@@ -124,9 +125,10 @@ export function useDonateCampaign() {
|
||||
let signedHex: string;
|
||||
let fee: number;
|
||||
try {
|
||||
const unsigned = buildUnsignedMultiOutputPsbt(
|
||||
const unsigned = buildUnsignedPsbt(
|
||||
user.pubkey,
|
||||
outputs,
|
||||
campaign.wallet.value,
|
||||
amountSats,
|
||||
utxos,
|
||||
feeRateForSpeed(rates, feeSpeed),
|
||||
);
|
||||
@@ -142,36 +144,23 @@ export function useDonateCampaign() {
|
||||
const txHex = finalizePsbt(signedHex);
|
||||
const txid = await broadcastTransaction(txHex, esploraApis);
|
||||
|
||||
// Publish a single kind 8333 receipt covering the whole transaction. The
|
||||
// event lists every recipient under its own `p` tag; the `amount` tag is
|
||||
// the combined total paid to all recipients (i.e. the full donation,
|
||||
// excluding the donor's change). Per-recipient amounts are recomputed
|
||||
// from the on-chain tx at display time by matching each recipient's
|
||||
// derived Taproot address against the tx outputs.
|
||||
//
|
||||
// The on-chain tx is already final at this point; we record a publish
|
||||
// failure rather than throwing so the donor sees a successful result
|
||||
// even if the relay hiccups.
|
||||
const totalSats = splits.reduce((sum, s) => sum + s.amountSats, 0);
|
||||
// Publish the kind 8333 receipt. Per NIP.md §Kind 33863 §Donation flow,
|
||||
// campaign donation receipts MUST NOT carry `p` tags — the recipient is
|
||||
// the campaign's `w` wallet, not a Nostr identity. Viewers verify by
|
||||
// matching tx outputs against the campaign's `w` address.
|
||||
let receiptPublished = false;
|
||||
let receiptPublishError: string | undefined;
|
||||
try {
|
||||
await publishEvent({
|
||||
kind: 8333,
|
||||
content: comment,
|
||||
tags: [
|
||||
tags: withAgoraTag([
|
||||
['i', `bitcoin:tx:${txid}`],
|
||||
...splits.map((s) => ['p', s.pubkey]),
|
||||
['amount', String(totalSats)],
|
||||
['amount', String(amountSats)],
|
||||
['a', campaign.aTag],
|
||||
['K', String(campaign.event.kind)],
|
||||
[
|
||||
'alt',
|
||||
splits.length === 1
|
||||
? `Donation to ${campaign.title}: ${totalSats.toLocaleString()} sats`
|
||||
: `Donation to ${campaign.title}: ${totalSats.toLocaleString()} sats across ${splits.length} recipients`,
|
||||
],
|
||||
],
|
||||
['K', String(CAMPAIGN_KIND)],
|
||||
['alt', `Donation to ${campaign.title}: ${amountSats.toLocaleString()} sats`],
|
||||
]),
|
||||
});
|
||||
receiptPublished = true;
|
||||
} catch (error) {
|
||||
@@ -188,8 +177,7 @@ export function useDonateCampaign() {
|
||||
return {
|
||||
txid,
|
||||
fee,
|
||||
recipientCount: splits.length,
|
||||
totalSats,
|
||||
totalSats: amountSats,
|
||||
receiptPublished,
|
||||
receiptPublishError,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||
|
||||
import { useOrganizationModeration } from '@/hooks/useOrganizationModeration';
|
||||
import {
|
||||
COMMUNITY_DEFINITION_KIND,
|
||||
parseCommunityEvent,
|
||||
@@ -9,56 +10,126 @@ import {
|
||||
} from '@/lib/communityUtils';
|
||||
import { dedupeAddressableLatest } from '@/lib/addressableEvents';
|
||||
|
||||
/**
|
||||
* Hand-curated list of featured organization authors.
|
||||
*
|
||||
* We query all organizations authored by these accounts in one relay request,
|
||||
* then latest-wins dedupe addressable revisions client-side.
|
||||
*/
|
||||
export const FEATURED_ORGANIZATION_AUTHORS = [
|
||||
'932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
|
||||
] as const;
|
||||
|
||||
export interface FeaturedOrganization {
|
||||
community: ParsedCommunity;
|
||||
event: NostrEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch featured organizations by author.
|
||||
* Parse a kind 34550 coordinate string (`34550:<pubkey>:<d>`) into its
|
||||
* pubkey and d-tag components. Returns `null` for malformed coords so the
|
||||
* caller can skip them without crashing the query.
|
||||
*/
|
||||
function parseCoord(coord: string): { pubkey: string; dTag: string } | null {
|
||||
const colon1 = coord.indexOf(':');
|
||||
if (colon1 < 0) return null;
|
||||
const colon2 = coord.indexOf(':', colon1 + 1);
|
||||
if (colon2 < 0) return null;
|
||||
const pubkey = coord.slice(colon1 + 1, colon2);
|
||||
const dTag = coord.slice(colon2 + 1);
|
||||
if (!pubkey || !dTag) return null;
|
||||
return { pubkey, dTag };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the featured organizations selected by Agora moderators.
|
||||
*
|
||||
* One author-filtered query is more reliable than pinning individual event IDs
|
||||
* because kind 34550 definitions are addressable and can be revised.
|
||||
* Featured selection rides the shared `agora.moderation` namespace (kind
|
||||
* 1985 NIP-32 labels): a moderator publishes a `featured` label tagging
|
||||
* the organization's `34550:<pubkey>:<d>` coordinate, and the home/communities
|
||||
* page surfaces it here. A `hidden` label on the same coord always wins —
|
||||
* even if `featured` is set, a hidden org never reaches this list.
|
||||
*
|
||||
* Coords are grouped by author into one filter per unique author, then OR'd
|
||||
* into a single `nostr.query` call. For the typical case (a small handful
|
||||
* of featured orgs across a few authors) this stays one relay round-trip.
|
||||
* Results are sorted by the `created_at` of the latest `featured` label so
|
||||
* moderators control ordering by simply re-publishing the label to bump an
|
||||
* org to the top.
|
||||
*/
|
||||
export function useFeaturedOrganizations() {
|
||||
const { nostr } = useNostr();
|
||||
const { data: moderation, isReady: moderationReady } = useOrganizationModeration();
|
||||
|
||||
// Derive the curated coord set: featured minus hidden, sorted by the
|
||||
// recency of the `featured` label. No cap — the moderator pack controls
|
||||
// how many orgs surface.
|
||||
const featuredCoords = moderationReady
|
||||
? Array.from(moderation.featuredCoords)
|
||||
.filter((coord) => !moderation.hiddenCoords.has(coord))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(moderation.featuredOrder.get(b) ?? 0) -
|
||||
(moderation.featuredOrder.get(a) ?? 0),
|
||||
)
|
||||
: [];
|
||||
|
||||
// Include the coord set in the query key so the cache busts whenever the
|
||||
// curation changes (mutations invalidate `featured-organizations` too, so
|
||||
// there's no one-tick gap).
|
||||
const featuredCoordsKey = featuredCoords.join('|');
|
||||
|
||||
return useQuery<FeaturedOrganization[]>({
|
||||
queryKey: ['featured-organizations', FEATURED_ORGANIZATION_AUTHORS.join(',')],
|
||||
queryKey: ['featured-organizations', featuredCoordsKey],
|
||||
enabled: moderationReady,
|
||||
queryFn: async ({ signal }) => {
|
||||
const combinedSignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]);
|
||||
if (featuredCoords.length === 0) return [];
|
||||
|
||||
const events = await nostr.query(
|
||||
[{
|
||||
// Group coord d-tags by author so we can issue one filter per author
|
||||
// instead of one per coord. Most featured orgs cluster around a few
|
||||
// founders, so this typically collapses to a single-digit number of
|
||||
// filters in one round-trip.
|
||||
const dTagsByAuthor = new Map<string, string[]>();
|
||||
for (const coord of featuredCoords) {
|
||||
const parsed = parseCoord(coord);
|
||||
if (!parsed) continue;
|
||||
const bucket = dTagsByAuthor.get(parsed.pubkey);
|
||||
if (bucket) {
|
||||
bucket.push(parsed.dTag);
|
||||
} else {
|
||||
dTagsByAuthor.set(parsed.pubkey, [parsed.dTag]);
|
||||
}
|
||||
}
|
||||
if (dTagsByAuthor.size === 0) return [];
|
||||
|
||||
const filters: NostrFilter[] = Array.from(dTagsByAuthor.entries()).map(
|
||||
([pubkey, dTags]) => ({
|
||||
kinds: [COMMUNITY_DEFINITION_KIND],
|
||||
authors: [...FEATURED_ORGANIZATION_AUTHORS],
|
||||
limit: 60,
|
||||
}],
|
||||
{ signal: combinedSignal },
|
||||
authors: [pubkey],
|
||||
'#d': dTags,
|
||||
}),
|
||||
);
|
||||
|
||||
const entries: FeaturedOrganization[] = [];
|
||||
const combinedSignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]);
|
||||
const events = await nostr.query(filters, { signal: combinedSignal });
|
||||
|
||||
// Latest-wins dedupe of addressable revisions, then index by coord so
|
||||
// we can return them in the moderator-controlled `featuredOrder`.
|
||||
const byCoord = new Map<string, FeaturedOrganization>();
|
||||
for (const event of dedupeAddressableLatest(events)) {
|
||||
const community = parseCommunityEvent(event);
|
||||
if (!community) continue;
|
||||
entries.push({ community, event });
|
||||
byCoord.set(community.aTag, { community, event });
|
||||
}
|
||||
|
||||
entries.sort((a, b) => b.event.created_at - a.event.created_at);
|
||||
|
||||
return entries;
|
||||
// Preserve the moderator's chosen ordering by walking `featuredCoords`
|
||||
// (already sorted newest-label-first) and emitting entries in that
|
||||
// order. Drops coords whose underlying 34550 event we couldn't fetch
|
||||
// (e.g. it was deleted or never reached the queried relays).
|
||||
const ordered: FeaturedOrganization[] = [];
|
||||
for (const coord of featuredCoords) {
|
||||
const entry = byCoord.get(coord);
|
||||
if (entry) ordered.push(entry);
|
||||
}
|
||||
return ordered;
|
||||
},
|
||||
// 5 minutes — featured list is hand-curated, doesn't churn.
|
||||
// Featured org definitions don't change often — orgs publish a
|
||||
// new revision when their banner or description changes, not minute
|
||||
// to minute — so a generous staleTime makes back-navigation to
|
||||
// /communities feel instant. The moderation hook explicitly
|
||||
// invalidates this key on mutation, so moderator-driven churn is
|
||||
// still visible immediately.
|
||||
staleTime: 5 * 60_000,
|
||||
gcTime: 60 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,8 +5,7 @@ import { getStorageKey } from '@/lib/storageKey';
|
||||
|
||||
/**
|
||||
* Manages the active feed tab for a specific feed page, persisting
|
||||
* the selection in sessionStorage so it survives navigation within
|
||||
* the same browser session.
|
||||
* the selection in localStorage so it survives reloads and new sessions.
|
||||
*
|
||||
* Each feed page should pass a unique `feedId` (e.g. 'home', 'vines', 'videos').
|
||||
*
|
||||
@@ -24,17 +23,17 @@ export function useFeedTab<T extends string = string>(
|
||||
const [activeTab, setActiveTab] = useState<T>(() => {
|
||||
const defaultTab = (user ? 'follows' : 'world') as T;
|
||||
try {
|
||||
const stored = sessionStorage.getItem(key);
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored) {
|
||||
if (feedId === 'home' && stored === 'network') {
|
||||
sessionStorage.setItem(key, defaultTab);
|
||||
localStorage.setItem(key, defaultTab);
|
||||
return defaultTab;
|
||||
}
|
||||
if (!validTabs || validTabs.includes(stored as T)) {
|
||||
return stored as T;
|
||||
}
|
||||
}
|
||||
} catch { /* sessionStorage unavailable */ }
|
||||
} catch { /* localStorage unavailable */ }
|
||||
// Validate the default tab against validTabs. If it's not in the list,
|
||||
// fall back to the last valid tab (typically 'global').
|
||||
if (validTabs && !validTabs.includes(defaultTab)) {
|
||||
@@ -45,7 +44,7 @@ export function useFeedTab<T extends string = string>(
|
||||
|
||||
const setTab = useCallback((tab: T) => {
|
||||
setActiveTab(tab);
|
||||
try { sessionStorage.setItem(key, tab); } catch { /* ignore */ }
|
||||
try { localStorage.setItem(key, tab); } catch { /* ignore */ }
|
||||
}, [key]);
|
||||
|
||||
return [activeTab, setTab];
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||
|
||||
import { useAgoraFeed } from '@/hooks/useAgoraFeed';
|
||||
import { type FeedItem } from '@/hooks/useFeed';
|
||||
import { useFollowList } from '@/hooks/useFollowActions';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useMuteList } from '@/hooks/useMuteList';
|
||||
import { getPaginationCursor, shouldHideFeedEvent, isRepostKind } from '@/lib/feedUtils';
|
||||
import { isEventMuted } from '@/lib/muteHelpers';
|
||||
import { parseRepostContent } from '@/lib/feedUtils';
|
||||
|
||||
/**
|
||||
* The three feed modes available on the home `/feed` page.
|
||||
*
|
||||
* - `agora` — Agora content only (campaigns, pledges, donations, comments
|
||||
* on Agora entities, communities, `#Agora`-tagged notes).
|
||||
* - `all-nostr` — global kind 1 stream from across the network plus the
|
||||
* full Agora content mix, interleaved chronologically.
|
||||
* - `following` — same as `all-nostr` but every layer is filtered to authors
|
||||
* you follow.
|
||||
*/
|
||||
export type FeedMode = 'agora' | 'all-nostr' | 'following';
|
||||
|
||||
const NOSTR_PAGE_SIZE = 30;
|
||||
/** Kinds included in the "all Nostr" / "following" kind 1 layer. */
|
||||
const NOSTR_LAYER_KINDS = [1, 6, 16];
|
||||
|
||||
interface NostrLayerPage {
|
||||
items: FeedItem[];
|
||||
oldestTimestamp: number | null;
|
||||
rawCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A deliberately broad kind 1 + reposts query for the home feed's
|
||||
* non-Agora layers. Unlike `useFeed('global')`, which uses Ditto's
|
||||
* curated `sort:hot` NIP-50 extension and is filtered by user feed
|
||||
* settings, this query is a straight chronological pull of recent
|
||||
* kind 1 notes (plus reposts) so "All Nostr" actually lives up to
|
||||
* its name.
|
||||
*/
|
||||
function useNostrLayer({
|
||||
enabled,
|
||||
authors,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
/** When provided, restrict to these authors (Following mode). */
|
||||
authors?: string[];
|
||||
}) {
|
||||
const { nostr } = useNostr();
|
||||
const authorsKey = authors ? [...authors].sort().join(',') : '';
|
||||
// If `authors` is provided but empty, the query is intentionally empty
|
||||
// (e.g. the user follows nobody).
|
||||
const authorsEmpty = authors !== undefined && authors.length === 0;
|
||||
const queryEnabled = enabled && !authorsEmpty;
|
||||
|
||||
return useInfiniteQuery<NostrLayerPage, Error>({
|
||||
queryKey: ['nostr-layer', authorsKey],
|
||||
queryFn: async ({ pageParam, signal: querySignal }) => {
|
||||
const signal = AbortSignal.any([querySignal, AbortSignal.timeout(8_000)]);
|
||||
const until = pageParam as number | undefined;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const filter: NostrFilter = {
|
||||
kinds: NOSTR_LAYER_KINDS,
|
||||
limit: NOSTR_PAGE_SIZE,
|
||||
...(authors && authors.length > 0 ? { authors } : {}),
|
||||
...(until ? { until } : {}),
|
||||
};
|
||||
|
||||
const raw = await nostr.query([filter], { signal });
|
||||
const valid = raw.filter((ev) => ev.created_at <= now);
|
||||
|
||||
const items: FeedItem[] = [];
|
||||
for (const ev of valid) {
|
||||
if (isRepostKind(ev.kind)) {
|
||||
const embedded = parseRepostContent(ev);
|
||||
if (embedded && embedded.created_at <= now) {
|
||||
items.push({ event: embedded, repostedBy: ev.pubkey, sortTimestamp: ev.created_at });
|
||||
}
|
||||
} else {
|
||||
items.push({ event: ev, sortTimestamp: ev.created_at });
|
||||
}
|
||||
}
|
||||
|
||||
const oldestTimestamp = valid.length > 0 ? getPaginationCursor(valid) : null;
|
||||
return { items, oldestTimestamp, rawCount: valid.length };
|
||||
},
|
||||
initialPageParam: undefined as number | undefined,
|
||||
getNextPageParam: (lastPage) => {
|
||||
if (lastPage.rawCount === 0 || lastPage.oldestTimestamp === null) return undefined;
|
||||
return lastPage.oldestTimestamp - 1;
|
||||
},
|
||||
enabled: queryEnabled,
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: false,
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrates the three-mode home feed. Internally wires the appropriate
|
||||
* combination of {@link useNostrLayer} (a broad chronological kind 1 +
|
||||
* reposts query) and {@link useAgoraFeed} (Agora activity mix),
|
||||
* interleaving their results chronologically and exposing a single
|
||||
* pagination interface.
|
||||
*/
|
||||
export function useMixedFeed(mode: FeedMode, enabled: boolean) {
|
||||
const { user } = useCurrentUser();
|
||||
const { data: followData } = useFollowList();
|
||||
const { muteItems } = useMuteList();
|
||||
|
||||
// In Following mode, both layers are filtered to authors the user
|
||||
// follows (plus themselves, matching the network feed convention).
|
||||
// Passing `authors: []` intentionally disables queries until the
|
||||
// follow list has loaded — without this guard a logged-in user
|
||||
// would briefly see the global mix before their follows arrive.
|
||||
const followAuthors = useMemo<string[] | undefined>(() => {
|
||||
if (mode !== 'following') return undefined;
|
||||
if (!user) return [];
|
||||
const follows = followData?.pubkeys;
|
||||
if (follows === undefined) return undefined; // still loading
|
||||
return follows.length > 0 ? [...follows, user.pubkey] : [user.pubkey];
|
||||
}, [mode, user, followData?.pubkeys]);
|
||||
|
||||
// Following mode is gated on the follow list being loaded.
|
||||
const layersReady = enabled && (mode !== 'following' || followAuthors !== undefined);
|
||||
const agoraOptions = useMemo(
|
||||
() => (followAuthors !== undefined ? { authors: followAuthors } : undefined),
|
||||
[followAuthors],
|
||||
);
|
||||
const agoraFeed = useAgoraFeed(layersReady, agoraOptions);
|
||||
|
||||
// Kind 1 layer: only used by 'all-nostr' and 'following'. 'agora' mode skips it.
|
||||
const useNostr = mode === 'all-nostr' || mode === 'following';
|
||||
const nostrLayer = useNostrLayer({
|
||||
enabled: layersReady && useNostr,
|
||||
authors: followAuthors,
|
||||
});
|
||||
|
||||
// Flatten kind 1 layer pages → FeedItem[].
|
||||
const nostrItems = useMemo<FeedItem[]>(() => {
|
||||
if (!useNostr) return [];
|
||||
return nostrLayer.data?.pages.flatMap((page) => page.items) ?? [];
|
||||
}, [useNostr, nostrLayer.data?.pages]);
|
||||
|
||||
// Flatten Agora layer events → FeedItem[].
|
||||
const agoraItems = useMemo<FeedItem[]>(
|
||||
() => agoraFeed.events.map((event: NostrEvent) => ({ event, sortTimestamp: event.created_at })),
|
||||
[agoraFeed.events],
|
||||
);
|
||||
|
||||
// When the Nostr layer is active, clip the Agora layer to the recency
|
||||
// window the Nostr layer has already loaded. Without this, the Nostr
|
||||
// firehose advances its `until` cursor by minutes per page while the
|
||||
// (sparser) Agora layer covers weeks per page — so at the bottom of
|
||||
// the merged feed only Agora items remain, giving the impression that
|
||||
// the "All Nostr" feed has degenerated back into the Agora-only feed.
|
||||
//
|
||||
// The clip floor is the oldest Nostr item we've loaded. Agora items
|
||||
// older than that are held back until the Nostr layer paginates far
|
||||
// enough to keep them company.
|
||||
const nostrFloor = useMemo<number | null>(() => {
|
||||
if (!useNostr || nostrItems.length === 0) return null;
|
||||
let oldest = nostrItems[0].sortTimestamp;
|
||||
for (const item of nostrItems) {
|
||||
if (item.sortTimestamp < oldest) oldest = item.sortTimestamp;
|
||||
}
|
||||
return oldest;
|
||||
}, [useNostr, nostrItems]);
|
||||
|
||||
const visibleAgoraItems = useMemo<FeedItem[]>(() => {
|
||||
if (!useNostr) return agoraItems;
|
||||
if (nostrFloor === null) return [];
|
||||
return agoraItems.filter((item) => item.sortTimestamp >= nostrFloor);
|
||||
}, [useNostr, agoraItems, nostrFloor]);
|
||||
|
||||
// Merge both layers, dedupe by event id, apply mute filter, sort newest-first.
|
||||
const items = useMemo<FeedItem[]>(() => {
|
||||
const seen = new Map<string, FeedItem>();
|
||||
const consider = (item: FeedItem) => {
|
||||
const key = item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id;
|
||||
if (!key) return;
|
||||
if (shouldHideFeedEvent(item.event)) return;
|
||||
if (muteItems.length > 0 && isEventMuted(item.event, muteItems)) return;
|
||||
const existing = seen.get(key);
|
||||
if (!existing || item.sortTimestamp > existing.sortTimestamp) {
|
||||
seen.set(key, item);
|
||||
}
|
||||
};
|
||||
for (const item of visibleAgoraItems) consider(item);
|
||||
for (const item of nostrItems) consider(item);
|
||||
return Array.from(seen.values()).sort((a, b) => b.sortTimestamp - a.sortTimestamp);
|
||||
}, [visibleAgoraItems, nostrItems, muteItems]);
|
||||
|
||||
// Unified pagination. The Agora layer is sparser than Nostr, so it
|
||||
// exhausts its pages first. Once that happens, only Nostr keeps
|
||||
// advancing — which is fine because we already clip Agora to the
|
||||
// Nostr recency window. We only fetch Agora when the Nostr floor is
|
||||
// about to dip below the Agora floor (i.e. we're about to scroll into
|
||||
// a region where the Agora buffer is empty).
|
||||
const agoraHasNext = agoraFeed.hasNextPage;
|
||||
const agoraFetchNext = agoraFeed.fetchNextPage;
|
||||
const nostrHasNext = nostrLayer.hasNextPage;
|
||||
const nostrFetchNext = nostrLayer.fetchNextPage;
|
||||
|
||||
// Oldest Agora item currently loaded — used to decide whether we need
|
||||
// to fetch more Agora when the Nostr cursor advances past it.
|
||||
const agoraFloor = useMemo<number | null>(() => {
|
||||
if (agoraItems.length === 0) return null;
|
||||
let oldest = agoraItems[0].sortTimestamp;
|
||||
for (const item of agoraItems) {
|
||||
if (item.sortTimestamp < oldest) oldest = item.sortTimestamp;
|
||||
}
|
||||
return oldest;
|
||||
}, [agoraItems]);
|
||||
|
||||
const fetchNextPage = useCallback(async () => {
|
||||
if (!useNostr) {
|
||||
// Pure Agora mode — just advance Agora.
|
||||
if (agoraHasNext) await agoraFetchNext();
|
||||
return;
|
||||
}
|
||||
// Mixed mode: always advance Nostr (it's the dense layer driving the
|
||||
// scroll). Only advance Agora if its floor is at or above the Nostr
|
||||
// floor — i.e. the Agora buffer is on the verge of being uncovered
|
||||
// by further Nostr pagination. Otherwise we'd fetch Agora pages we
|
||||
// can't display yet because they fall below the visible window.
|
||||
const advanceAgora =
|
||||
agoraHasNext && agoraFloor !== null && nostrFloor !== null && agoraFloor >= nostrFloor;
|
||||
await Promise.all([
|
||||
nostrHasNext ? nostrFetchNext() : Promise.resolve(),
|
||||
advanceAgora ? agoraFetchNext() : Promise.resolve(),
|
||||
]);
|
||||
}, [useNostr, agoraHasNext, agoraFetchNext, agoraFloor, nostrHasNext, nostrFetchNext, nostrFloor]);
|
||||
|
||||
const hasNextPage = useNostr
|
||||
? !!nostrHasNext || !!agoraHasNext
|
||||
: !!agoraHasNext;
|
||||
const isFetchingNextPage = agoraFeed.isFetchingNextPage
|
||||
|| (useNostr && nostrLayer.isFetchingNextPage);
|
||||
const isLoading = agoraFeed.isLoading || (useNostr && nostrLayer.isPending);
|
||||
|
||||
return {
|
||||
items,
|
||||
isLoading,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { notificationSuccess } from '@/lib/haptics';
|
||||
import { withAgoraTag } from '@/lib/agoraNoteTags';
|
||||
import {
|
||||
nostrPubkeyToBitcoinAddress,
|
||||
fetchUTXOs,
|
||||
@@ -162,7 +163,7 @@ export function useOnchainZap(
|
||||
const event = await publishEvent({
|
||||
kind: 8333,
|
||||
content: comment,
|
||||
tags,
|
||||
tags: withAgoraTag(tags),
|
||||
});
|
||||
|
||||
return { txid, amountSats, fee, event };
|
||||
|
||||
+120
-42
@@ -4,6 +4,7 @@ import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { fetchTxDetail, nostrPubkeyToBitcoinAddress } from '@/lib/bitcoin';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { CAMPAIGN_KIND, parseCampaign } from '@/lib/campaign';
|
||||
/** A single verified on-chain zap, with the amount that actually paid the recipient(s) on-chain. */
|
||||
export interface OnchainZapEntry {
|
||||
/** The kind 8333 event. */
|
||||
@@ -13,15 +14,18 @@ export interface OnchainZapEntry {
|
||||
/** Pubkey of the sender (the 8333 event author). */
|
||||
senderPubkey: string;
|
||||
/**
|
||||
* Pubkeys of the recipients (one per `p` tag). For legacy single-recipient
|
||||
* events this has length 1; for multi-output events (campaign donations,
|
||||
* community batch zaps) it has one entry per recipient.
|
||||
* Pubkeys of the recipients (one per `p` tag). For identity zaps this has
|
||||
* length 1 (or more for batch community zaps). For campaign donations
|
||||
* (kind 33863 targets) this is always empty — campaigns are not
|
||||
* Nostr-identity recipients and the receipt carries no `p` tags.
|
||||
*/
|
||||
recipientPubkeys: string[];
|
||||
/**
|
||||
* Verified total in sats — sum of tx outputs that pay any of the listed
|
||||
* recipients' derived Taproot addresses. Excludes the sender's change
|
||||
* output even if some helpful soul tagged the sender as a recipient.
|
||||
* Verified total in sats — sum of tx outputs paying any expected
|
||||
* destination. In identity-recipient mode this is "any of the listed
|
||||
* recipients' derived Taproot addresses." In campaign-wallet mode
|
||||
* (target is a kind 33863 campaign) it is "outputs paying the campaign's
|
||||
* `w` address." Excludes the sender's change output in either case.
|
||||
*/
|
||||
amountSats: number;
|
||||
/** Sender's self-reported amount tag (may differ from verified). */
|
||||
@@ -54,9 +58,9 @@ export function extractOnchainZapClaimedAmount(event: NostrEvent): number {
|
||||
/**
|
||||
* Parse the recipient pubkey(s) from a kind 8333 event.
|
||||
*
|
||||
* Legacy single-recipient events have exactly one `p` tag; multi-output
|
||||
* events (campaigns, community batch zaps) list every recipient under its
|
||||
* own `p` tag. Returns the pubkeys in `p`-tag order, deduplicated.
|
||||
* Identity-recipient events carry one or more `p` tags; campaign-donation
|
||||
* events carry none (a campaign is not a Nostr identity). Returns the
|
||||
* pubkeys in `p`-tag order, deduplicated.
|
||||
*/
|
||||
export function extractOnchainZapRecipients(event: NostrEvent): string[] {
|
||||
const seen = new Set<string>();
|
||||
@@ -82,43 +86,84 @@ export function extractOnchainZapRecipient(event: NostrEvent): string {
|
||||
return extractOnchainZapRecipients(event)[0] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the addressable coordinate from the receipt's `a` tag if it
|
||||
* points at a kind 33863 campaign, or null otherwise.
|
||||
*/
|
||||
function extractCampaignTarget(event: NostrEvent): string | null {
|
||||
const aTag = event.tags.find(([n]) => n === 'a')?.[1];
|
||||
if (typeof aTag !== 'string') return null;
|
||||
if (!aTag.startsWith(`${CAMPAIGN_KIND}:`)) return null;
|
||||
return aTag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a kind 8333 on-chain zap event against the Bitcoin blockchain.
|
||||
*
|
||||
* Returns the verified amount (sum of tx outputs paying any listed
|
||||
* recipient's derived Taproot address) and confirmation status. Returns
|
||||
* `null` if the event is malformed or the transaction cannot be verified.
|
||||
* Returns the verified amount (sum of tx outputs paying the expected
|
||||
* destination) and confirmation status. Returns `null` if the event is
|
||||
* malformed or the transaction cannot be verified.
|
||||
*
|
||||
* A verified amount of 0 means the transaction exists but does not pay
|
||||
* any listed recipient — callers should discard such events.
|
||||
* Verification has two modes depending on the event shape:
|
||||
*
|
||||
* @param event The kind 8333 event to verify.
|
||||
* @param esploraApis Ordered list of Esplora REST roots tried with failover.
|
||||
* @param signal Optional abort signal (e.g. from TanStack Query).
|
||||
* - **Identity-recipient mode** — event has one or more `p` tags and no
|
||||
* campaign `a` tag. The expected destinations are the recipients'
|
||||
* derived Taproot addresses (`nostrPubkeyToBitcoinAddress`).
|
||||
* - **Campaign-wallet mode** — event has an `a` tag pointing at a kind
|
||||
* 33863 campaign and no `p` tags. The expected destination is the
|
||||
* campaign's declared `w` bech32(m) address. Silent-payment campaigns
|
||||
* (`w` starts with `sp1…`) cannot be verified on-chain by definition
|
||||
* and are rejected.
|
||||
*
|
||||
* @param event The kind 8333 event to verify.
|
||||
* @param esploraApis Ordered list of Esplora REST roots tried with failover.
|
||||
* @param campaignWallet When the receipt targets a kind 33863 campaign,
|
||||
* the campaign's `w` value. Required for campaign-wallet
|
||||
* mode; ignored otherwise.
|
||||
* @param signal Optional abort signal (e.g. from TanStack Query).
|
||||
*/
|
||||
export async function verifyOnchainZap(
|
||||
event: NostrEvent,
|
||||
esploraApis: string[],
|
||||
campaignWallet?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<OnchainZapEntry | null> {
|
||||
const txid = extractOnchainZapTxid(event);
|
||||
const recipientPubkeys = extractOnchainZapRecipients(event);
|
||||
if (!txid || recipientPubkeys.length === 0) return null;
|
||||
if (!txid) return null;
|
||||
|
||||
// Reject self-zaps: the sender already controls each derived destination
|
||||
// address, so any output paying the sender is change. We strip the sender
|
||||
// from the recipient set rather than discarding the whole event so a tx
|
||||
// that pays the sender plus legitimate recipients still verifies for the
|
||||
// others.
|
||||
const externalRecipients = recipientPubkeys.filter((p) => p !== event.pubkey);
|
||||
if (externalRecipients.length === 0) return null;
|
||||
const campaignTarget = extractCampaignTarget(event);
|
||||
|
||||
const recipientAddresses = new Set<string>();
|
||||
for (const pubkey of externalRecipients) {
|
||||
const address = nostrPubkeyToBitcoinAddress(pubkey);
|
||||
if (address) recipientAddresses.add(address);
|
||||
// Determine the set of expected destination addresses for verification.
|
||||
const expectedAddresses = new Set<string>();
|
||||
let recipientPubkeys: string[] = [];
|
||||
|
||||
if (campaignTarget) {
|
||||
// Campaign-wallet mode: match outputs against the campaign's declared
|
||||
// `w` address. Silent-payment campaigns publish no receipts, so a
|
||||
// receipt referencing an `sp1…` campaign is malformed.
|
||||
if (!campaignWallet) return null;
|
||||
if (campaignWallet.startsWith('sp1')) return null;
|
||||
expectedAddresses.add(campaignWallet);
|
||||
// No identity recipients in this mode.
|
||||
} else {
|
||||
// Identity-recipient mode.
|
||||
const recipients = extractOnchainZapRecipients(event);
|
||||
if (recipients.length === 0) return null;
|
||||
|
||||
// Reject self-zaps: the sender already controls each derived destination
|
||||
// address, so any output paying the sender is change. Strip the sender
|
||||
// from the recipient set rather than discarding the whole event so a tx
|
||||
// that pays the sender plus legitimate recipients still verifies for
|
||||
// the others.
|
||||
recipientPubkeys = recipients.filter((p) => p !== event.pubkey);
|
||||
if (recipientPubkeys.length === 0) return null;
|
||||
|
||||
for (const pubkey of recipientPubkeys) {
|
||||
const address = nostrPubkeyToBitcoinAddress(pubkey);
|
||||
if (address) expectedAddresses.add(address);
|
||||
}
|
||||
if (expectedAddresses.size === 0) return null;
|
||||
}
|
||||
if (recipientAddresses.size === 0) return null;
|
||||
|
||||
let detail;
|
||||
try {
|
||||
@@ -128,7 +173,7 @@ export async function verifyOnchainZap(
|
||||
}
|
||||
|
||||
const amountSats = detail.outputs
|
||||
.filter((o) => o.address && recipientAddresses.has(o.address))
|
||||
.filter((o) => o.address && expectedAddresses.has(o.address))
|
||||
.reduce((sum, o) => sum + o.value, 0);
|
||||
|
||||
if (amountSats === 0) return null;
|
||||
@@ -141,7 +186,7 @@ export async function verifyOnchainZap(
|
||||
event,
|
||||
txid,
|
||||
senderPubkey: event.pubkey,
|
||||
recipientPubkeys: externalRecipients,
|
||||
recipientPubkeys,
|
||||
amountSats: effectiveClaim,
|
||||
claimedAmountSats: claimed,
|
||||
comment: event.content,
|
||||
@@ -153,6 +198,11 @@ export async function verifyOnchainZap(
|
||||
/**
|
||||
* Query all kind 8333 on-chain zaps targeting a specific event, then verify
|
||||
* each one on-chain. Returns only verified entries (deduped by txid).
|
||||
*
|
||||
* When the target is a kind 33863 campaign, verification matches against
|
||||
* the campaign's `w` wallet address rather than derived recipient
|
||||
* addresses. Silent-payment campaigns (`w` starts with `sp1…`) return an
|
||||
* empty list — donations to those campaigns are unlinkable by design.
|
||||
*/
|
||||
export function useOnchainZaps(target: NostrEvent | undefined) {
|
||||
const { nostr } = useNostr();
|
||||
@@ -164,6 +214,14 @@ export function useOnchainZaps(target: NostrEvent | undefined) {
|
||||
: '';
|
||||
const aCoord = isAddressable && target ? `${target.kind}:${target.pubkey}:${dTag}` : '';
|
||||
|
||||
// If the target is a campaign, parse its `w` wallet for campaign-wallet
|
||||
// mode verification. Silent-payment campaigns short-circuit to "no
|
||||
// verifiable donations" — we don't issue any verifier queries.
|
||||
const campaignWallet = target && target.kind === CAMPAIGN_KIND
|
||||
? parseCampaign(target)?.wallet
|
||||
: undefined;
|
||||
const isSilentPayment = campaignWallet?.mode === 'sp';
|
||||
|
||||
// Step 1: fetch the raw kind 8333 events for this target
|
||||
const eventsQuery = useQuery({
|
||||
queryKey: ['onchain-zaps', 'events', target?.id ?? '', aCoord],
|
||||
@@ -198,16 +256,18 @@ export function useOnchainZaps(target: NostrEvent | undefined) {
|
||||
|
||||
return Array.from(byTxid.values());
|
||||
},
|
||||
enabled: !!target,
|
||||
enabled: !!target && !isSilentPayment,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
// Step 2: verify each event on-chain (parallel, cached per event)
|
||||
const events = eventsQuery.data ?? [];
|
||||
const walletValue = campaignWallet?.value;
|
||||
const verifications = useQueries({
|
||||
queries: events.map((event) => ({
|
||||
queryKey: ['onchain-zaps', 'verify', esploraApis, event.id],
|
||||
queryFn: ({ signal }: { signal: AbortSignal }) => verifyOnchainZap(event, esploraApis, signal),
|
||||
queryKey: ['onchain-zaps', 'verify', esploraApis, event.id, walletValue ?? ''],
|
||||
queryFn: ({ signal }: { signal: AbortSignal }) =>
|
||||
verifyOnchainZap(event, esploraApis, walletValue, signal),
|
||||
staleTime: 60_000,
|
||||
})),
|
||||
});
|
||||
@@ -220,7 +280,7 @@ export function useOnchainZaps(target: NostrEvent | undefined) {
|
||||
verified.sort((a, b) => b.amountSats - a.amountSats);
|
||||
|
||||
const totalSats = verified.reduce((s, v) => s + v.amountSats, 0);
|
||||
const isLoading = eventsQuery.isLoading || verifications.some((v) => v.isLoading);
|
||||
const isLoading = !isSilentPayment && (eventsQuery.isLoading || verifications.some((v) => v.isLoading));
|
||||
|
||||
return {
|
||||
zaps: verified,
|
||||
@@ -238,17 +298,35 @@ export function useOnchainZaps(target: NostrEvent | undefined) {
|
||||
*
|
||||
* Returns `undefined` while loading, `null` if the event fails verification
|
||||
* (invalid tx, wrong recipient, self-zap, etc.), or the entry.
|
||||
*
|
||||
* If the receipt targets a kind 33863 campaign, pass the campaign's `w`
|
||||
* value as `campaignWallet` so the verifier can match outputs against the
|
||||
* campaign address. Without it, campaign-targeted receipts always fail
|
||||
* verification (no `p` tags, no fallback).
|
||||
*/
|
||||
export function useVerifiedOnchainZap(event: NostrEvent | undefined): OnchainZapEntry | null | undefined {
|
||||
export function useVerifiedOnchainZap(
|
||||
event: NostrEvent | undefined,
|
||||
campaignWallet?: string,
|
||||
): OnchainZapEntry | null | undefined {
|
||||
const { config } = useAppContext();
|
||||
const { esploraApis } = config;
|
||||
const txid = event ? extractOnchainZapTxid(event) : null;
|
||||
const hasRecipient = event ? extractOnchainZapRecipients(event).length > 0 : false;
|
||||
const targetsCampaign = event ? !!extractCampaignTarget(event) : false;
|
||||
const hasIdentityRecipient = event ? extractOnchainZapRecipients(event).length > 0 : false;
|
||||
|
||||
// Enable verification when:
|
||||
// - we have a txid AND
|
||||
// - either the receipt targets a campaign AND we know the wallet, OR
|
||||
// - the receipt has an identity recipient (`p` tag).
|
||||
const enabled = !!event && !!txid && (
|
||||
(targetsCampaign && !!campaignWallet)
|
||||
|| (!targetsCampaign && hasIdentityRecipient)
|
||||
);
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['onchain-zaps', 'verify', esploraApis, event?.id ?? ''],
|
||||
queryFn: ({ signal }) => verifyOnchainZap(event!, esploraApis, signal),
|
||||
enabled: !!event && !!txid && hasRecipient,
|
||||
queryKey: ['onchain-zaps', 'verify', esploraApis, event?.id ?? '', campaignWallet ?? ''],
|
||||
queryFn: ({ signal }) => verifyOnchainZap(event!, esploraApis, campaignWallet, signal),
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import type { UserOrganization } from '@/hooks/useUserOrganizations';
|
||||
import { CAMPAIGN_KIND } from '@/lib/campaign';
|
||||
import { getPaginationCursor } from '@/lib/feedUtils';
|
||||
|
||||
const PLEDGE_KIND = 36639;
|
||||
const CALENDAR_EVENT_KINDS = [31922, 31923];
|
||||
const OFFICIAL_ACTIVITY_KINDS = [CAMPAIGN_KIND, PLEDGE_KIND, ...CALENDAR_EVENT_KINDS];
|
||||
const OFFICIAL_ACTIVITY_KIND_SET = new Set(OFFICIAL_ACTIVITY_KINDS);
|
||||
const COMMENTS_PAGE_SIZE = 60;
|
||||
const OFFICIAL_PAGE_SIZE = 40;
|
||||
|
||||
interface OrganizationFeedPage {
|
||||
events: NostrEvent[];
|
||||
commentsRawCount: number;
|
||||
officialRawCount: number;
|
||||
commentsOldestTimestamp: number | null;
|
||||
officialOldestTimestamp: number | null;
|
||||
}
|
||||
|
||||
interface OrganizationFeedPageParam {
|
||||
commentsUntil?: number;
|
||||
officialUntil?: number;
|
||||
commentsDone?: boolean;
|
||||
officialDone?: boolean;
|
||||
}
|
||||
|
||||
function getEventOrganizationATag(event: NostrEvent): string | undefined {
|
||||
return event.tags.find(([name]) => name === 'A')?.[1];
|
||||
}
|
||||
|
||||
function buildLeadershipByATag(organizations: UserOrganization[]) {
|
||||
const leadershipByATag = new Map<string, Set<string>>();
|
||||
for (const entry of organizations) {
|
||||
leadershipByATag.set(
|
||||
entry.community.aTag,
|
||||
new Set([entry.community.founderPubkey, ...entry.community.moderatorPubkeys]),
|
||||
);
|
||||
}
|
||||
return leadershipByATag;
|
||||
}
|
||||
|
||||
/** Feed of comments and official activity from the user's founded/moderated/followed organizations. */
|
||||
export function useOrganizationHomeActivityFeed(
|
||||
organizations: UserOrganization[] | undefined,
|
||||
membersOnly: boolean,
|
||||
enabled = true,
|
||||
) {
|
||||
const { nostr } = useNostr();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const aTags = useMemo(
|
||||
() => organizations?.map((entry) => entry.community.aTag) ?? [],
|
||||
[organizations],
|
||||
);
|
||||
const aTagsKey = aTags.join(',');
|
||||
const leadershipByATag = useMemo(
|
||||
() => buildLeadershipByATag(organizations ?? []),
|
||||
[organizations],
|
||||
);
|
||||
const officialAuthors = useMemo(() => {
|
||||
const authors = new Set<string>();
|
||||
for (const leaders of leadershipByATag.values()) {
|
||||
for (const pubkey of leaders) authors.add(pubkey);
|
||||
}
|
||||
return [...authors];
|
||||
}, [leadershipByATag]);
|
||||
|
||||
const query = useInfiniteQuery<OrganizationFeedPage, Error>({
|
||||
queryKey: ['organization-home-activity-feed', aTagsKey, membersOnly],
|
||||
queryFn: async ({ pageParam, signal }) => {
|
||||
if (aTags.length === 0) {
|
||||
return {
|
||||
events: [],
|
||||
commentsRawCount: 0,
|
||||
officialRawCount: 0,
|
||||
commentsOldestTimestamp: null,
|
||||
officialOldestTimestamp: null,
|
||||
};
|
||||
}
|
||||
|
||||
const cursor = pageParam as OrganizationFeedPageParam | undefined;
|
||||
const filters: NostrFilter[] = [];
|
||||
|
||||
if (!cursor?.commentsDone) {
|
||||
filters.push({
|
||||
kinds: [1111],
|
||||
'#A': aTags,
|
||||
...(membersOnly && officialAuthors.length > 0 ? { authors: officialAuthors } : {}),
|
||||
limit: COMMENTS_PAGE_SIZE,
|
||||
...(cursor?.commentsUntil ? { until: cursor.commentsUntil } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
if (!cursor?.officialDone && officialAuthors.length > 0) {
|
||||
filters.push({
|
||||
kinds: OFFICIAL_ACTIVITY_KINDS,
|
||||
authors: officialAuthors,
|
||||
'#A': aTags,
|
||||
limit: OFFICIAL_PAGE_SIZE,
|
||||
...(cursor?.officialUntil ? { until: cursor.officialUntil } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.length === 0) {
|
||||
return {
|
||||
events: [],
|
||||
commentsRawCount: 0,
|
||||
officialRawCount: 0,
|
||||
commentsOldestTimestamp: null,
|
||||
officialOldestTimestamp: null,
|
||||
};
|
||||
}
|
||||
|
||||
const combinedSignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]);
|
||||
const raw = await nostr.query(filters, { signal: combinedSignal });
|
||||
const rawComments = raw.filter((event) => event.kind === 1111);
|
||||
const rawOfficial = raw.filter((event) => OFFICIAL_ACTIVITY_KIND_SET.has(event.kind));
|
||||
const seen = new Set<string>();
|
||||
const events: NostrEvent[] = [];
|
||||
|
||||
for (const event of raw.sort((a, b) => b.created_at - a.created_at)) {
|
||||
if (seen.has(event.id)) continue;
|
||||
seen.add(event.id);
|
||||
|
||||
const aTag = getEventOrganizationATag(event);
|
||||
if (!aTag) continue;
|
||||
const leaders = leadershipByATag.get(aTag);
|
||||
if (!leaders) continue;
|
||||
|
||||
if (event.kind === 1111) {
|
||||
if (membersOnly && !leaders.has(event.pubkey)) continue;
|
||||
events.push(event);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!leaders.has(event.pubkey)) continue;
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
for (const event of events) {
|
||||
if (!queryClient.getQueryData(['event', event.id])) {
|
||||
queryClient.setQueryData(['event', event.id], event);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
events,
|
||||
commentsRawCount: rawComments.length,
|
||||
officialRawCount: rawOfficial.length,
|
||||
commentsOldestTimestamp: rawComments.length > 0 ? getPaginationCursor(rawComments) : null,
|
||||
officialOldestTimestamp: rawOfficial.length > 0 ? getPaginationCursor(rawOfficial) : null,
|
||||
};
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
const commentsCursor = lastPage.commentsOldestTimestamp;
|
||||
const officialCursor = lastPage.officialOldestTimestamp;
|
||||
const commentsDone = lastPage.commentsRawCount < COMMENTS_PAGE_SIZE || commentsCursor === null;
|
||||
const officialDone = lastPage.officialRawCount < OFFICIAL_PAGE_SIZE || officialCursor === null;
|
||||
|
||||
if (commentsDone && officialDone) return undefined;
|
||||
|
||||
return {
|
||||
commentsDone,
|
||||
officialDone,
|
||||
commentsUntil: commentsDone ? undefined : commentsCursor - 1,
|
||||
officialUntil: officialDone ? undefined : officialCursor - 1,
|
||||
} satisfies OrganizationFeedPageParam;
|
||||
},
|
||||
initialPageParam: undefined as OrganizationFeedPageParam | undefined,
|
||||
enabled: enabled && aTags.length > 0,
|
||||
staleTime: 60_000,
|
||||
placeholderData: (prev) => prev,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
return useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const events = (query.data?.pages ?? [])
|
||||
.flatMap((page) => page.events)
|
||||
.filter((event) => {
|
||||
if (seen.has(event.id)) return false;
|
||||
seen.add(event.id);
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => b.created_at - a.created_at);
|
||||
|
||||
return {
|
||||
events,
|
||||
isLoading: query.isLoading,
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
hasNextPage: query.hasNextPage,
|
||||
isFetchingNextPage: query.isFetchingNextPage,
|
||||
fetchNextPage: query.fetchNextPage,
|
||||
pageCount: query.data?.pages.length,
|
||||
};
|
||||
}, [query.data, query.error, query.fetchNextPage, query.hasNextPage, query.isError, query.isFetchingNextPage, query.isLoading]);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useCallback, useSyncExternalStore } from 'react';
|
||||
|
||||
const STORAGE_KEY = 'organization-feed:members-only';
|
||||
|
||||
function readFromStorage(): boolean {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw === null) return false;
|
||||
return JSON.parse(raw) === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let cached = typeof localStorage !== 'undefined' ? readFromStorage() : false;
|
||||
const subscribers = new Set<() => void>();
|
||||
|
||||
function notify() {
|
||||
for (const cb of subscribers) cb();
|
||||
}
|
||||
|
||||
function setMembersOnly(next: boolean) {
|
||||
if (cached === next) return;
|
||||
cached = next;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
// Keep the in-memory preference even when storage is unavailable.
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('storage', (event) => {
|
||||
if (event.key !== STORAGE_KEY) return;
|
||||
const next = readFromStorage();
|
||||
if (next === cached) return;
|
||||
cached = next;
|
||||
notify();
|
||||
});
|
||||
}
|
||||
|
||||
function subscribe(cb: () => void) {
|
||||
subscribers.add(cb);
|
||||
return () => subscribers.delete(cb);
|
||||
}
|
||||
|
||||
function getSnapshot() {
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** Global preference for showing only founder/moderator comments in org feeds. */
|
||||
export function useOrganizationMembersOnlyFilter() {
|
||||
const membersOnly = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
const toggle = useCallback(() => setMembersOnly(!cached), []);
|
||||
|
||||
return { membersOnly, setMembersOnly, toggle };
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { useNostrPublish } from './useNostrPublish';
|
||||
import { useCampaignModerators } from './useCampaignModerators';
|
||||
import { COMMUNITY_DEFINITION_KIND } from '@/lib/communityUtils';
|
||||
import {
|
||||
AGORA_MODERATION_NAMESPACE,
|
||||
EMPTY_MODERATION_DATA,
|
||||
LABEL_KIND,
|
||||
type ModerationData,
|
||||
type ModerationLabel,
|
||||
foldModerationLabels,
|
||||
} from '@/lib/agoraModeration';
|
||||
|
||||
/** Surface-scoped alias so call sites read naturally. */
|
||||
export type OrganizationModerationData = ModerationData;
|
||||
|
||||
/**
|
||||
* Fetches and folds organization-moderation label events authored by Team
|
||||
* Soapbox members. Returns hide / featured rollups per community
|
||||
* coordinate (`34550:<pubkey>:<d>`).
|
||||
*
|
||||
* Organizations ride the same `agora.moderation` namespace and the same
|
||||
* moderator pack as campaigns; we just narrow the fold to labels whose `a`
|
||||
* tag points at a kind 34550 coordinate. The relay-side query is identical
|
||||
* to the campaign side (we fetch every namespace-tagged label authored by
|
||||
* moderators) — the surface separation is purely client-side.
|
||||
*
|
||||
* **Two-axis model.** Unlike campaigns, organizations don't have an
|
||||
* `approved` axis. Every Agora-tagged organization is publicly visible
|
||||
* by default; moderation reduces to `featured` (lift into the curated
|
||||
* shelf) and `hidden` (suppress from public discovery). The shared
|
||||
* fold helper still tracks `approvedCoords` for type symmetry with the
|
||||
* campaign hook, but the org UI never emits or reads it — moderators
|
||||
* SHOULD NOT publish `approved` / `unapproved` labels against kind
|
||||
* 34550 coordinates.
|
||||
*
|
||||
* **Display rule** consumers should follow:
|
||||
* - Featured shelf on `/communities` iff
|
||||
* `featuredCoords.has(coord) && !hiddenCoords.has(coord)`.
|
||||
* - Future "All organizations" / discovery surfaces iff
|
||||
* `!hiddenCoords.has(coord)` for non-moderators; moderators may see hidden
|
||||
* orgs with a dimmed treatment.
|
||||
* - "My organizations" intentionally ignores moderation — a user's own
|
||||
* founded / moderated / followed orgs always render regardless of label.
|
||||
* - Hide always wins over featured.
|
||||
*
|
||||
* The mutation `moderate({ coord, action })` publishes a single kind 1985
|
||||
* event labeling one organization in the `agora.moderation` namespace.
|
||||
* Callers MUST be in the moderator set or the relay-side `authors:` filter
|
||||
* on read will silently ignore the new event.
|
||||
*/
|
||||
export function useOrganizationModeration() {
|
||||
const { nostr } = useNostr();
|
||||
const queryClient = useQueryClient();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const { data: moderators } = useCampaignModerators();
|
||||
|
||||
// Same gating as the campaign hook: never fire with an empty `authors:`
|
||||
// filter, since that would return labels from any author and break the
|
||||
// trust model.
|
||||
const moderatorsKey = moderators ? [...moderators].sort().join(',') : '';
|
||||
|
||||
const moderationQuery = useQuery({
|
||||
queryKey: ['organization-moderation', moderatorsKey],
|
||||
enabled: moderators !== undefined,
|
||||
queryFn: async ({ signal }): Promise<OrganizationModerationData> => {
|
||||
if (!moderators || moderators.length === 0) {
|
||||
return { ...EMPTY_MODERATION_DATA, moderators: [] };
|
||||
}
|
||||
const events = await nostr.query(
|
||||
[
|
||||
{
|
||||
kinds: [LABEL_KIND],
|
||||
authors: moderators,
|
||||
'#L': [AGORA_MODERATION_NAMESPACE],
|
||||
limit: 2000,
|
||||
},
|
||||
],
|
||||
{ signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]) },
|
||||
);
|
||||
return foldModerationLabels(events, moderators, COMMUNITY_DEFINITION_KIND);
|
||||
},
|
||||
// Moderation labels change slowly — moderators feature or hide on
|
||||
// human timescales, not seconds. A generous staleTime keeps repeat
|
||||
// visits to /communities instant (no relay round-trip on remount),
|
||||
// and explicit invalidation in the `moderate` mutation below catches
|
||||
// local changes immediately. The hour-long gcTime survives tab
|
||||
// switches and back-button navigation without refetching.
|
||||
staleTime: 5 * 60_000,
|
||||
gcTime: 60 * 60_000,
|
||||
});
|
||||
|
||||
const moderate = useMutation({
|
||||
mutationFn: async ({ coord, action }: { coord: string; action: ModerationLabel }) => {
|
||||
if (!coord.startsWith(`${COMMUNITY_DEFINITION_KIND}:`)) {
|
||||
throw new Error(`Coordinate must start with ${COMMUNITY_DEFINITION_KIND}:`);
|
||||
}
|
||||
// Organizations use a two-axis model — only `featured` / `unfeatured`
|
||||
// / `hidden` / `unhidden` are valid here. Reject `approved` /
|
||||
// `unapproved` defensively so a stray UI bug can't poison the
|
||||
// label stream with axis-mixed events.
|
||||
if (action === 'approved' || action === 'unapproved') {
|
||||
throw new Error(`Organizations do not support the ${action} label`);
|
||||
}
|
||||
return publishEvent({
|
||||
kind: LABEL_KIND,
|
||||
content: '',
|
||||
tags: [
|
||||
['L', AGORA_MODERATION_NAMESPACE],
|
||||
['l', action, AGORA_MODERATION_NAMESPACE],
|
||||
['a', coord],
|
||||
['alt', `Organization moderation: ${action}`],
|
||||
],
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate both the moderation rollup and the derived featured
|
||||
// query so the grid reflects the new state immediately.
|
||||
queryClient.invalidateQueries({ queryKey: ['organization-moderation'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['featured-organizations'] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
data: moderationQuery.data ?? EMPTY_MODERATION_DATA,
|
||||
isPending: moderationQuery.isPending,
|
||||
isLoading: moderationQuery.isLoading,
|
||||
isReady: moderationQuery.isSuccess,
|
||||
moderate,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
|
||||
const PIN_LIST_KIND = 30078;
|
||||
const PIN_D_TAG_PREFIX = 'agora-pinned-comments:';
|
||||
|
||||
function pinDTag(rootATag: string): string {
|
||||
return `${PIN_D_TAG_PREFIX}${rootATag}`;
|
||||
}
|
||||
|
||||
function parsePinnedIds(event: NostrEvent | null | undefined): string[] {
|
||||
if (!event) return [];
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(event.content) as { pinnedEvents?: unknown };
|
||||
if (!Array.isArray(parsed.pinnedEvents)) return [];
|
||||
return parsed.pinnedEvents.filter((id): id is string => typeof id === 'string');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function usePinnedEventComments(rootATag: string | undefined, ownerPubkey: string | undefined) {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const queryClient = useQueryClient();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const dTag = rootATag ? pinDTag(rootATag) : undefined;
|
||||
const canManagePins = !!user && !!ownerPubkey && user.pubkey === ownerPubkey;
|
||||
|
||||
const pinnedListQuery = useQuery({
|
||||
queryKey: ['pinned-event-comments-list', rootATag, ownerPubkey],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!rootATag || !ownerPubkey || !dTag) return null;
|
||||
const events = await nostr.query(
|
||||
[{ kinds: [PIN_LIST_KIND], authors: [ownerPubkey], '#d': [dTag], limit: 1 }],
|
||||
{ signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) },
|
||||
);
|
||||
return events[0] ?? null;
|
||||
},
|
||||
enabled: !!rootATag && !!ownerPubkey && !!dTag,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const pinnedIds = parsePinnedIds(pinnedListQuery.data);
|
||||
|
||||
const pinnedEventsQuery = useQuery({
|
||||
queryKey: ['pinned-event-comments', rootATag, pinnedIds],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (pinnedIds.length === 0) return [];
|
||||
const events = await nostr.query(
|
||||
[{ ids: pinnedIds, limit: pinnedIds.length }],
|
||||
{ signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) },
|
||||
);
|
||||
return events.sort((a, b) => pinnedIds.indexOf(a.id) - pinnedIds.indexOf(b.id));
|
||||
},
|
||||
enabled: !!rootATag && pinnedIds.length > 0,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const togglePin = useMutation({
|
||||
mutationFn: async (eventId: string) => {
|
||||
if (!user) throw new Error('User is not logged in');
|
||||
if (!rootATag || !ownerPubkey || !dTag) throw new Error('Missing pin context.');
|
||||
if (user.pubkey !== ownerPubkey) throw new Error('Only the event owner can pin comments.');
|
||||
|
||||
const prev = await fetchFreshEvent(nostr, {
|
||||
kinds: [PIN_LIST_KIND],
|
||||
authors: [ownerPubkey],
|
||||
'#d': [dTag],
|
||||
});
|
||||
|
||||
const current = parsePinnedIds(prev);
|
||||
const next = current.includes(eventId)
|
||||
? current.filter((id) => id !== eventId)
|
||||
: [eventId, ...current.filter((id) => id !== eventId)];
|
||||
|
||||
await publishEvent({
|
||||
kind: PIN_LIST_KIND,
|
||||
content: JSON.stringify({ pinnedEvents: next }),
|
||||
tags: [
|
||||
['d', dTag],
|
||||
['a', rootATag],
|
||||
['k', rootATag.split(':')[0] ?? ''],
|
||||
['alt', 'Pinned event comments'],
|
||||
],
|
||||
prev: prev ?? undefined,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['pinned-event-comments-list', rootATag, ownerPubkey] });
|
||||
queryClient.invalidateQueries({ queryKey: ['pinned-event-comments', rootATag] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
pinnedIds,
|
||||
pinnedEvents: pinnedEventsQuery.data ?? [],
|
||||
isLoading: pinnedListQuery.isLoading || pinnedEventsQuery.isLoading,
|
||||
isPinned: (eventId: string) => pinnedIds.includes(eventId),
|
||||
canManagePins,
|
||||
togglePin,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMutation, useQueryClient, type InfiniteData } from '@tanstack/react-query';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { withAgoraTag } from '@/lib/agoraNoteTags';
|
||||
import { NKinds, type NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
interface PaginatedFeedPage {
|
||||
@@ -45,7 +46,7 @@ export function usePostComment() {
|
||||
const event = await publishEvent({
|
||||
kind: 1111,
|
||||
content,
|
||||
tags,
|
||||
tags: withAgoraTag(tags),
|
||||
});
|
||||
|
||||
return event;
|
||||
|
||||
@@ -27,6 +27,13 @@ interface StreamPostsOptions {
|
||||
* Each entry accepts raw hex or npub-encoded pubkeys.
|
||||
*/
|
||||
authorPubkeys?: string[];
|
||||
/**
|
||||
* When set, narrows results to events whose NIP-89 `client` tag's first
|
||||
* value matches this string. Relays that index the multi-letter `client`
|
||||
* tag (e.g. Ditto's relay) honor this server-side; on relays that don't,
|
||||
* the filter is silently dropped on their end and results are unfiltered.
|
||||
*/
|
||||
clientName?: string;
|
||||
/** NIP-50 sort preference. 'recent' = default (no sort: term). */
|
||||
sort?: 'recent' | 'hot' | 'trending';
|
||||
}
|
||||
@@ -295,9 +302,16 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) {
|
||||
streamFilter.authors = resolvedAuthorPubkeys;
|
||||
}
|
||||
|
||||
// NIP-89 client tag filter (Ditto relay indexes the multi-letter `client`
|
||||
// tag; other relays silently ignore the constraint).
|
||||
if (options.clientName) {
|
||||
searchFilter['#client'] = [options.clientName];
|
||||
streamFilter['#client'] = [options.clientName];
|
||||
}
|
||||
|
||||
return { searchFilter, streamFilter };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- enabledKinds is stabilized via kindsKey; options.protocols via protocolsKey; kindsOverride via kindsOverrideKey; authorPubkeys via authorPubkeysKey
|
||||
}, [query, isDedicatedKindQuery, kindsKey, options.language, options.mediaType, protocolsKey, kindsOverrideKey, authorPubkeysKey, options.sort]);
|
||||
}, [query, isDedicatedKindQuery, kindsKey, options.language, options.mediaType, protocolsKey, kindsOverrideKey, authorPubkeysKey, options.sort, options.clientName]);
|
||||
|
||||
// Shared ref for the event map and known IDs — persists across initial fetch + pagination
|
||||
const eventMapRef = useRef(new Map<string, NostrEvent>());
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { getCoordinates } from '@/lib/coordinates';
|
||||
|
||||
/** WMO weather interpretation codes → descriptions and icons. */
|
||||
const WMO_CODES: Record<number, { description: string; icon: string }> = {
|
||||
0: { description: 'Clear sky', icon: '☀️' },
|
||||
1: { description: 'Mainly clear', icon: '🌤️' },
|
||||
2: { description: 'Partly cloudy', icon: '⛅' },
|
||||
3: { description: 'Overcast', icon: '☁️' },
|
||||
45: { description: 'Foggy', icon: '🌫️' },
|
||||
48: { description: 'Depositing rime fog', icon: '🌫️' },
|
||||
51: { description: 'Light drizzle', icon: '🌦️' },
|
||||
53: { description: 'Moderate drizzle', icon: '🌦️' },
|
||||
55: { description: 'Dense drizzle', icon: '🌧️' },
|
||||
56: { description: 'Light freezing drizzle', icon: '🌧️' },
|
||||
57: { description: 'Dense freezing drizzle', icon: '🌧️' },
|
||||
61: { description: 'Slight rain', icon: '🌦️' },
|
||||
63: { description: 'Moderate rain', icon: '🌧️' },
|
||||
65: { description: 'Heavy rain', icon: '🌧️' },
|
||||
66: { description: 'Light freezing rain', icon: '🌧️' },
|
||||
67: { description: 'Heavy freezing rain', icon: '🌧️' },
|
||||
71: { description: 'Slight snowfall', icon: '🌨️' },
|
||||
73: { description: 'Moderate snowfall', icon: '🌨️' },
|
||||
75: { description: 'Heavy snowfall', icon: '❄️' },
|
||||
77: { description: 'Snow grains', icon: '❄️' },
|
||||
80: { description: 'Slight rain showers', icon: '🌦️' },
|
||||
81: { description: 'Moderate rain showers', icon: '🌧️' },
|
||||
82: { description: 'Violent rain showers', icon: '🌧️' },
|
||||
85: { description: 'Slight snow showers', icon: '🌨️' },
|
||||
86: { description: 'Heavy snow showers', icon: '❄️' },
|
||||
95: { description: 'Thunderstorm', icon: '⛈️' },
|
||||
96: { description: 'Thunderstorm with slight hail', icon: '⛈️' },
|
||||
99: { description: 'Thunderstorm with heavy hail', icon: '⛈️' },
|
||||
};
|
||||
|
||||
export type PrecipitationType = 'rain' | 'snow' | null;
|
||||
|
||||
/** Intensity: 'light' | 'moderate' | 'heavy'. */
|
||||
export type PrecipitationIntensity = 'light' | 'moderate' | 'heavy';
|
||||
|
||||
/** Determine the precipitation type and intensity from a WMO code. */
|
||||
export function getPrecipitation(code: number): { type: PrecipitationType; intensity: PrecipitationIntensity } {
|
||||
// Snow codes
|
||||
if ([71, 85].includes(code)) return { type: 'snow', intensity: 'light' };
|
||||
if ([73].includes(code)) return { type: 'snow', intensity: 'moderate' };
|
||||
if ([75, 77, 86].includes(code)) return { type: 'snow', intensity: 'heavy' };
|
||||
|
||||
// Rain codes (including drizzle, freezing rain, showers, thunderstorms)
|
||||
if ([51, 56, 61, 80].includes(code)) return { type: 'rain', intensity: 'light' };
|
||||
if ([53, 63, 66, 81, 95].includes(code)) return { type: 'rain', intensity: 'moderate' };
|
||||
if ([55, 57, 65, 67, 82, 96, 99].includes(code)) return { type: 'rain', intensity: 'heavy' };
|
||||
|
||||
return { type: null, intensity: 'light' };
|
||||
}
|
||||
|
||||
export interface WeatherData {
|
||||
/** Current temperature in °C. */
|
||||
temperature: number;
|
||||
/** Apparent (feels like) temperature in °C. */
|
||||
apparentTemperature: number;
|
||||
/** Relative humidity percentage. */
|
||||
humidity: number;
|
||||
/** Wind speed in km/h. */
|
||||
windSpeed: number;
|
||||
/** WMO weather code. */
|
||||
weatherCode: number;
|
||||
/** Human-readable weather description. */
|
||||
description: string;
|
||||
/** Weather emoji icon. */
|
||||
icon: string;
|
||||
/** Whether it's currently daytime. */
|
||||
isDay: boolean;
|
||||
/** City name for display. */
|
||||
city: string;
|
||||
}
|
||||
|
||||
async function fetchWeather(
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Omit<WeatherData, 'city'> | null> {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
latitude: latitude.toString(),
|
||||
longitude: longitude.toString(),
|
||||
current: 'temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m,is_day',
|
||||
timezone: 'auto',
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.open-meteo.com/v1/forecast?${params}`,
|
||||
{ signal, headers: { Accept: 'application/json' } },
|
||||
);
|
||||
|
||||
if (!response.ok) return null;
|
||||
|
||||
const data = await response.json();
|
||||
const current = data.current;
|
||||
if (!current) return null;
|
||||
|
||||
const code = current.weather_code ?? 0;
|
||||
const wmo = WMO_CODES[code] ?? { description: 'Unknown', icon: '🌡️' };
|
||||
|
||||
return {
|
||||
temperature: Math.round(current.temperature_2m),
|
||||
apparentTemperature: Math.round(current.apparent_temperature),
|
||||
humidity: Math.round(current.relative_humidity_2m),
|
||||
windSpeed: Math.round(current.wind_speed_10m),
|
||||
weatherCode: code,
|
||||
description: wmo.description,
|
||||
icon: wmo.icon,
|
||||
isDay: current.is_day === 1,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch current weather for an ISO 3166 code (country or subdivision).
|
||||
* Uses the free Open-Meteo API (no API key required).
|
||||
*/
|
||||
export function useWeather(code: string | null) {
|
||||
const coords = code ? getCoordinates(code) : null;
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['weather', code],
|
||||
queryFn: async ({ signal }): Promise<WeatherData | null> => {
|
||||
if (!coords) return null;
|
||||
const weather = await fetchWeather(coords.latitude, coords.longitude, signal);
|
||||
if (!weather) return null;
|
||||
return { ...weather, city: coords.city };
|
||||
},
|
||||
enabled: !!coords,
|
||||
staleTime: 1000 * 60 * 15, // 15 minutes
|
||||
gcTime: 1000 * 60 * 60, // 1 hour
|
||||
retry: 1,
|
||||
});
|
||||
}
|
||||
@@ -242,26 +242,6 @@
|
||||
animation: ping-3 4s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Slow continuous pulse for nagging buttons */
|
||||
@keyframes pulse-slow {
|
||||
0%, 100% { transform: scale(1); opacity: 0.5; }
|
||||
50% { transform: scale(1.8); opacity: 0; }
|
||||
}
|
||||
|
||||
.animate-pulse-slow {
|
||||
animation: pulse-slow 2.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Eerie sigil glow pulse */
|
||||
@keyframes sigil-glow {
|
||||
0%, 100% { opacity: 0.12; filter: drop-shadow(0 0 4px hsl(var(--primary) / 0.3)); }
|
||||
50% { opacity: 0.28; filter: drop-shadow(0 0 12px hsl(var(--primary) / 0.6)) drop-shadow(0 0 24px hsl(var(--primary) / 0.25)); }
|
||||
}
|
||||
|
||||
.animate-sigil-glow {
|
||||
animation: sigil-glow 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Eyeball blink animation - eyelid sweeps down then back up */
|
||||
@keyframes eyelid-blink {
|
||||
0% { transform: translateY(-100%); }
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
/**
|
||||
* Shared building blocks for Agora's moderation labels (NIP-32 kind 1985 in
|
||||
* the `agora.moderation` namespace). Both campaigns (kind 33863) and
|
||||
* organizations (kind 34550) ride the same label stream and the same
|
||||
* moderator pack (Team Soapbox); the only thing that varies between them is
|
||||
* the kind prefix on the `a` tag.
|
||||
*
|
||||
* Centralizing the constants, types, and folding logic here keeps the two
|
||||
* per-surface hooks (`useCampaignModeration`, `useOrganizationModeration`)
|
||||
* from drifting apart on namespace strings, axis semantics, or the
|
||||
* surfacing-rule contract documented in NIP.md.
|
||||
*/
|
||||
|
||||
/** NIP-32 label kind. */
|
||||
export const LABEL_KIND = 1985;
|
||||
|
||||
/** Label namespace for Agora's moderation labels. */
|
||||
export const AGORA_MODERATION_NAMESPACE = 'agora.moderation';
|
||||
|
||||
/** The six possible label values in the moderation namespace. */
|
||||
export type ModerationLabel =
|
||||
| 'approved'
|
||||
| 'unapproved'
|
||||
| 'hidden'
|
||||
| 'unhidden'
|
||||
| 'featured'
|
||||
| 'unfeatured';
|
||||
|
||||
/** A single label event narrowed to its decision axis. */
|
||||
export interface AxisDecision {
|
||||
/** Latest label observed on this axis. */
|
||||
label: ModerationLabel;
|
||||
/** Author of the latest label. */
|
||||
pubkey: string;
|
||||
/** Created-at of the latest label. */
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/** Per-coordinate rollup of approval + hide + featured state. */
|
||||
export interface ModerationState {
|
||||
approval?: AxisDecision; // `approved` or `unapproved`
|
||||
hide?: AxisDecision; // `hidden` or `unhidden`
|
||||
featured?: AxisDecision; // `featured` or `unfeatured`
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-surface rollup. Keys are the addressable coordinates we filtered for
|
||||
* (e.g. `33863:<pubkey>:<d>` for campaigns or `34550:<pubkey>:<d>` for
|
||||
* organizations).
|
||||
*/
|
||||
export interface ModerationData {
|
||||
/** Map of `<kind>:<pubkey>:<d>` -> rollup. */
|
||||
byCoord: Map<string, ModerationState>;
|
||||
/** Coordinates where the latest approval label is `approved`. */
|
||||
approvedCoords: Set<string>;
|
||||
/** Coordinates where the latest hide label is `hidden`. */
|
||||
hiddenCoords: Set<string>;
|
||||
/** Coordinates where the latest featured label is `featured`. */
|
||||
featuredCoords: Set<string>;
|
||||
/**
|
||||
* Map of `coord` -> `created_at` of the latest `featured` label. Used to
|
||||
* sort featured rows newest-first.
|
||||
*/
|
||||
featuredOrder: Map<string, number>;
|
||||
/** Pubkeys that were considered moderators when the query ran. */
|
||||
moderators: string[];
|
||||
}
|
||||
|
||||
export const EMPTY_MODERATION_DATA: ModerationData = {
|
||||
byCoord: new Map(),
|
||||
approvedCoords: new Set(),
|
||||
hiddenCoords: new Set(),
|
||||
featuredCoords: new Set(),
|
||||
featuredOrder: new Map(),
|
||||
moderators: [],
|
||||
};
|
||||
|
||||
function isApprovalLabel(value: string): value is 'approved' | 'unapproved' {
|
||||
return value === 'approved' || value === 'unapproved';
|
||||
}
|
||||
|
||||
function isHideLabel(value: string): value is 'hidden' | 'unhidden' {
|
||||
return value === 'hidden' || value === 'unhidden';
|
||||
}
|
||||
|
||||
function isFeaturedLabel(value: string): value is 'featured' | 'unfeatured' {
|
||||
return value === 'featured' || value === 'unfeatured';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a flat list of label events into per-coordinate rollups by axis.
|
||||
* The newest event per `(coord, axis)` wins.
|
||||
*
|
||||
* Events are filtered to only those carrying an `a` tag that starts with
|
||||
* `<coordKind>:` so the campaign and organization label streams never bleed
|
||||
* into each other even though they share a namespace and signer set.
|
||||
*
|
||||
* Events with a value outside the moderation namespace, or with no `l` tag
|
||||
* in that namespace, are dropped.
|
||||
*/
|
||||
export function foldModerationLabels(
|
||||
events: NostrEvent[],
|
||||
moderators: string[],
|
||||
coordKind: number,
|
||||
): ModerationData {
|
||||
const coordPrefix = `${coordKind}:`;
|
||||
const byCoord = new Map<string, ModerationState>();
|
||||
|
||||
for (const event of events) {
|
||||
const value = event.tags.find(
|
||||
([n, , ns]) => n === 'l' && ns === AGORA_MODERATION_NAMESPACE,
|
||||
)?.[1];
|
||||
if (!value) continue;
|
||||
const aTag = event.tags.find(
|
||||
([n, v]) => n === 'a' && typeof v === 'string' && v.startsWith(coordPrefix),
|
||||
)?.[1];
|
||||
if (!aTag) continue;
|
||||
|
||||
const state = byCoord.get(aTag) ?? {};
|
||||
if (isApprovalLabel(value)) {
|
||||
if (!state.approval || event.created_at > state.approval.createdAt) {
|
||||
state.approval = { label: value, pubkey: event.pubkey, createdAt: event.created_at };
|
||||
}
|
||||
} else if (isHideLabel(value)) {
|
||||
if (!state.hide || event.created_at > state.hide.createdAt) {
|
||||
state.hide = { label: value, pubkey: event.pubkey, createdAt: event.created_at };
|
||||
}
|
||||
} else if (isFeaturedLabel(value)) {
|
||||
if (!state.featured || event.created_at > state.featured.createdAt) {
|
||||
state.featured = { label: value, pubkey: event.pubkey, createdAt: event.created_at };
|
||||
}
|
||||
}
|
||||
byCoord.set(aTag, state);
|
||||
}
|
||||
|
||||
const approvedCoords = new Set<string>();
|
||||
const hiddenCoords = new Set<string>();
|
||||
const featuredCoords = new Set<string>();
|
||||
const featuredOrder = new Map<string, number>();
|
||||
for (const [coord, state] of byCoord) {
|
||||
if (state.approval?.label === 'approved') approvedCoords.add(coord);
|
||||
if (state.hide?.label === 'hidden') hiddenCoords.add(coord);
|
||||
if (state.featured?.label === 'featured') {
|
||||
featuredCoords.add(coord);
|
||||
featuredOrder.set(coord, state.featured.createdAt);
|
||||
}
|
||||
}
|
||||
|
||||
return { byCoord, approvedCoords, hiddenCoords, featuredCoords, featuredOrder, moderators };
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* The single-letter `t` tag that marks an event as first-class Agora content.
|
||||
*
|
||||
* Every event Agora creates carries `["t", "agora"]` so the Agora activity
|
||||
* feed can filter strictly server-side via the relay-indexed `#t` filter
|
||||
* (multi-letter tags like `client` are not indexed by relays, so NIP-89
|
||||
* cannot serve this purpose).
|
||||
*
|
||||
* Tagged kinds: 1 (notes, replies, quotes), 1111 (comments via usePostComment),
|
||||
* 8333 (onchain zaps), 9041 (zap goals), 30223 (campaigns), 31922 / 31923
|
||||
* (calendar events), 34550 (communities), 36639 (pledges).
|
||||
*
|
||||
* Country-rooted world posts (kind 1111 / 1068 with `#k=iso3166` or `geo`)
|
||||
* are surfaced regardless of this tag — the world layer is intentionally
|
||||
* cross-client.
|
||||
*
|
||||
* Untagged kinds (intentional): 0, 3, 6, 7, 8, 16, 62, 1311, 30009,
|
||||
* 10000-series, 30078, encrypted DMs. Reactions, reposts, follows, profile
|
||||
* metadata, lists, settings, badges, and chat are user-state or response
|
||||
* events — not first-class Agora content.
|
||||
*
|
||||
* See `NIP.md` (§ Agora Content Marker) for the canonical protocol doc.
|
||||
*/
|
||||
export const AGORA_DEFAULT_NOTE_TAGS: string[][] = [['t', 'agora']];
|
||||
|
||||
/**
|
||||
* Append `["t", "agora"]` to a tag array if it is not already present.
|
||||
*
|
||||
* Use at every Agora publish site so the feed can strictly filter by
|
||||
* `#t=agora`. Dedupes user-supplied `t` tags (e.g. campaigns let users
|
||||
* enter free-text hashtags — typing "agora" must not yield two tags).
|
||||
*/
|
||||
export function withAgoraTag(tags: string[][]): string[][] {
|
||||
const hasAgora = tags.some(
|
||||
([name, value]) => name === 't' && value?.toLowerCase() === 'agora',
|
||||
);
|
||||
return hasAgora ? tags : [...tags, ['t', 'agora']];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` when an event carries Agora's `["t", "agora"]` marker
|
||||
* (case-insensitive on the value).
|
||||
*
|
||||
* Use to client-side filter discovery surfaces — e.g. the moderator
|
||||
* "Pending review" list on `/communities` only surfaces orgs minted
|
||||
* through Agora's create flow, so reviewers aren't drowning in every
|
||||
* kind 34550 community on the network.
|
||||
*/
|
||||
export function hasAgoraTag(tags: string[][]): boolean {
|
||||
return tags.some(
|
||||
([name, value]) => name === 't' && value?.toLowerCase() === 'agora',
|
||||
);
|
||||
}
|
||||
+144
-241
@@ -1,79 +1,67 @@
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import * as bitcoin from 'bitcoinjs-lib';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
import { COUNTRIES } from '@/lib/countries';
|
||||
import { parseCountryIdentifier } from '@/lib/countryIdentifiers';
|
||||
|
||||
/** Addressable kind number for fundraising campaigns (see NIP.md). */
|
||||
export const CAMPAIGN_KIND = 30223;
|
||||
/**
|
||||
* Addressable kind number for fundraising campaigns (see NIP.md, Kind 33863).
|
||||
*
|
||||
* Campaigns are self-authored — the event author owns the wallet declared
|
||||
* in the `w` tag and is the sole beneficiary of donations. There is no
|
||||
* recipient list, no split logic, and no on-behalf-of authorship.
|
||||
*/
|
||||
export const CAMPAIGN_KIND = 33863;
|
||||
|
||||
/** Canonical category slugs shown in the create-campaign form. */
|
||||
export const CAMPAIGN_CATEGORIES = [
|
||||
'human-rights',
|
||||
'civil-liberties',
|
||||
'democracy',
|
||||
'political-prisoners',
|
||||
'legal-defense',
|
||||
'independent-media',
|
||||
'humanitarian-aid',
|
||||
'emergency-relief',
|
||||
'education',
|
||||
'community',
|
||||
'medical-aid',
|
||||
'other',
|
||||
] as const;
|
||||
/**
|
||||
* Two ways a campaign can accept donations, distinguished by the `w` tag's
|
||||
* bech32(m) prefix:
|
||||
*
|
||||
* - **`onchain`** — the wallet is a public mainnet on-chain bech32(m)
|
||||
* address (`bc1q…` segwit v0 or `bc1p…` Taproot). Donations are
|
||||
* traceable; clients show progress, totals, and recent donations.
|
||||
* - **`sp`** — the wallet is a BIP-352 silent-payment code (`sp1…`).
|
||||
* Donations are unlinkable by design; clients MUST hide all aggregate
|
||||
* UI and MUST NOT publish donation receipts.
|
||||
*/
|
||||
export type CampaignWalletMode = 'onchain' | 'sp';
|
||||
|
||||
export type CampaignCategory = typeof CAMPAIGN_CATEGORIES[number];
|
||||
/** Parsed wallet endpoint declared by a campaign's `w` tag. */
|
||||
export interface CampaignWallet {
|
||||
/** Raw bech32(m) string as it appears in the `w` tag. */
|
||||
value: string;
|
||||
/** Mode derived from the prefix. */
|
||||
mode: CampaignWalletMode;
|
||||
}
|
||||
|
||||
/** Human-readable labels for category slugs. */
|
||||
export const CAMPAIGN_CATEGORY_LABELS: Record<CampaignCategory, string> = {
|
||||
'human-rights': 'Human Rights',
|
||||
'civil-liberties': 'Civil Liberties',
|
||||
democracy: 'Democracy & Free Elections',
|
||||
'political-prisoners': 'Political Prisoners',
|
||||
'legal-defense': 'Legal Defense',
|
||||
'independent-media': 'Independent Media',
|
||||
'humanitarian-aid': 'Humanitarian Aid',
|
||||
'emergency-relief': 'Emergency Relief',
|
||||
education: 'Education & Training',
|
||||
community: 'Community Organizing',
|
||||
'medical-aid': 'Medical Aid',
|
||||
other: 'Other',
|
||||
};
|
||||
|
||||
const LEGACY_CAMPAIGN_CATEGORY_ALIASES: Record<string, CampaignCategory> = {
|
||||
medical: 'medical-aid',
|
||||
emergency: 'emergency-relief',
|
||||
animals: 'humanitarian-aid',
|
||||
sports: 'community',
|
||||
creative: 'community',
|
||||
business: 'community',
|
||||
faith: 'community',
|
||||
memorial: 'other',
|
||||
};
|
||||
|
||||
/** A 64-character lowercase hex string (Nostr pubkey or event id). */
|
||||
const HEX_64_RE = /^[0-9a-f]{64}$/;
|
||||
|
||||
/** A campaign recipient parsed from a single `p` tag. */
|
||||
export interface CampaignRecipient {
|
||||
/** Lowercase hex pubkey. */
|
||||
pubkey: string;
|
||||
/** Optional relay hint provided in the `p` tag. */
|
||||
relay?: string;
|
||||
/** Positive split weight. Defaults to 1 when the `p` tag does not supply one. */
|
||||
weight: number;
|
||||
/**
|
||||
* NIP-92 imeta block parsed from a campaign event. Pairs with the
|
||||
* `banner` tag (`url` MUST match the banner URL — clients ignore an
|
||||
* imeta whose URL does not match).
|
||||
*/
|
||||
export interface CampaignBannerImeta {
|
||||
url: string;
|
||||
/** MIME type, e.g. `image/jpeg`. */
|
||||
m?: string;
|
||||
/** SHA-256 of the file (lowercase hex). */
|
||||
x?: string;
|
||||
/** `WIDTHxHEIGHT` as published, kept verbatim. */
|
||||
dim?: string;
|
||||
blurhash?: string;
|
||||
/** Accessibility alt text for the banner (distinct from event-level NIP-31 alt). */
|
||||
alt?: string;
|
||||
}
|
||||
|
||||
/** A fully-parsed campaign with everything the UI needs. */
|
||||
export interface ParsedCampaign {
|
||||
/** The original event. */
|
||||
event: NostrEvent;
|
||||
/** Campaign creator's hex pubkey. */
|
||||
/** Campaign creator's hex pubkey (the beneficiary). */
|
||||
pubkey: string;
|
||||
/** The campaign's `d` tag (slug). */
|
||||
identifier: string;
|
||||
/** Addressable coordinate `30223:<pubkey>:<d>`. */
|
||||
/** Addressable coordinate `33863:<pubkey>:<d>`. */
|
||||
aTag: string;
|
||||
/** Campaign title. */
|
||||
title: string;
|
||||
@@ -81,31 +69,20 @@ export interface ParsedCampaign {
|
||||
summary: string;
|
||||
/** Markdown story (the event content). */
|
||||
story: string;
|
||||
/** Sanitized HTTPS cover image URL, or `undefined` if missing/invalid. */
|
||||
image?: string;
|
||||
/** Category slug from the first `t` tag matching a known category, or `undefined`. */
|
||||
category?: CampaignCategory;
|
||||
/** Campaign tags parsed from all `t` tags, in event order. */
|
||||
tags: string[];
|
||||
/** Goal in satoshis, or `undefined` if not set. */
|
||||
goalSats?: number;
|
||||
/** Sanitized HTTPS banner URL, or `undefined` if missing/invalid. */
|
||||
banner?: string;
|
||||
/** NIP-92 imeta for the banner. Only present when the imeta's `url` matches the banner. */
|
||||
bannerImeta?: CampaignBannerImeta;
|
||||
/** Bitcoin wallet endpoint (required). */
|
||||
wallet: CampaignWallet;
|
||||
/** Fundraising goal in **integer US Dollars**, or `undefined` if not set. */
|
||||
goalUsd?: number;
|
||||
/** Deadline (Unix seconds), or `undefined` if not set. */
|
||||
deadline?: number;
|
||||
/** Human-readable location string. */
|
||||
location?: string;
|
||||
/** ISO 3166-1 alpha-2 country code parsed from a NIP-73 `i` tag. */
|
||||
countryCode?: string;
|
||||
/** Validated recipient list (always at least one). */
|
||||
recipients: CampaignRecipient[];
|
||||
/** Created-at from the event. */
|
||||
createdAt: number;
|
||||
/**
|
||||
* True when the creator has marked the campaign closed via
|
||||
* `["status", "archived"]`. Archived campaigns are hidden from main
|
||||
* listings but still load by direct link so existing donors can find
|
||||
* them and donation history is preserved.
|
||||
*/
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
/** Returns the first value of a tag, or undefined. */
|
||||
@@ -130,29 +107,85 @@ function getCountryCode(event: NostrEvent): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getCampaignTags(event: NostrEvent): string[] {
|
||||
const seen = new Set<string>();
|
||||
const tags: string[] = [];
|
||||
for (const [name, value] of event.tags) {
|
||||
if (name !== 't' || typeof value !== 'string') continue;
|
||||
const tag = value.trim();
|
||||
if (!tag || seen.has(tag)) continue;
|
||||
seen.add(tag);
|
||||
tags.push(tag);
|
||||
/**
|
||||
* Parse a campaign wallet endpoint, returning the parsed wallet on success
|
||||
* or `null` if the string is missing, malformed, or for a network we
|
||||
* don't accept (testnet, regtest, etc.).
|
||||
*
|
||||
* Mode is inferred from the bech32 HRP/prefix:
|
||||
*
|
||||
* - `bc1q…` / `bc1p…` → mainnet on-chain (validated via bitcoinjs-lib).
|
||||
* - `sp1…` → BIP-352 silent-payment code (validated via prefix + bech32m
|
||||
* checksum from `@scure/base`-style parsing in `bitcoinjs-lib`).
|
||||
*
|
||||
* Any other prefix (`tb1…`, `bcrt1…`, `tsp1…`, `lnbc…`, etc.) is rejected.
|
||||
*/
|
||||
export function parseCampaignWallet(value: string | undefined): CampaignWallet | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
// On-chain mainnet: bc1q (segwit v0) or bc1p (Taproot v1).
|
||||
if (/^bc1[qp]/i.test(trimmed)) {
|
||||
try {
|
||||
bitcoin.address.toOutputScript(trimmed, bitcoin.networks.bitcoin);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return { value: trimmed, mode: 'onchain' };
|
||||
}
|
||||
return tags;
|
||||
|
||||
// Silent payments: sp1 followed by bech32m payload (mainnet only).
|
||||
if (/^sp1[02-9ac-hj-np-z]+$/i.test(trimmed)) {
|
||||
// bitcoinjs-lib doesn't currently parse BIP-352 codes, so we accept any
|
||||
// bech32m-shaped sp1 string. The checksum is verified by the donor's
|
||||
// wallet when it derives the payment output; an invalid code there
|
||||
// simply fails the donation flow.
|
||||
return { value: trimmed, mode: 'sp' };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a kind 30223 event into a strongly-typed campaign, or returns
|
||||
* `null` if the event is missing required fields (title, `d` tag, or at
|
||||
* least one valid recipient).
|
||||
*
|
||||
* `p` tag rules:
|
||||
* - 2nd element MUST be a 64-char lowercase hex pubkey.
|
||||
* - 3rd element is treated as a relay hint when it looks like a relay URL.
|
||||
* - 4th element is a positive decimal weight; missing/invalid -> 1.
|
||||
* - Duplicate pubkeys collapse to the first occurrence.
|
||||
* Parse the NIP-92 `imeta` tag whose `url` matches the campaign's banner.
|
||||
* Returns `undefined` if no matching imeta is found.
|
||||
*/
|
||||
function getBannerImeta(event: NostrEvent, bannerUrl: string | undefined): CampaignBannerImeta | undefined {
|
||||
if (!bannerUrl) return undefined;
|
||||
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] !== 'imeta') continue;
|
||||
// Each entry after the tag name is a space-separated `key value` pair.
|
||||
const fields: Record<string, string> = {};
|
||||
for (let i = 1; i < tag.length; i++) {
|
||||
const entry = tag[i];
|
||||
if (typeof entry !== 'string') continue;
|
||||
const spaceIdx = entry.indexOf(' ');
|
||||
if (spaceIdx <= 0) continue;
|
||||
const key = entry.slice(0, spaceIdx);
|
||||
const val = entry.slice(spaceIdx + 1);
|
||||
fields[key] = val;
|
||||
}
|
||||
if (fields.url !== bannerUrl) continue;
|
||||
|
||||
return {
|
||||
url: fields.url,
|
||||
m: fields.m,
|
||||
x: fields.x,
|
||||
dim: fields.dim,
|
||||
blurhash: fields.blurhash,
|
||||
alt: fields.alt,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a kind 33863 event into a strongly-typed campaign, or returns
|
||||
* `null` if the event is missing a required field (kind, `d`, `title`, or
|
||||
* a valid `w` wallet endpoint).
|
||||
*/
|
||||
export function parseCampaign(event: NostrEvent): ParsedCampaign | null {
|
||||
if (event.kind !== CAMPAIGN_KIND) return null;
|
||||
@@ -161,49 +194,15 @@ export function parseCampaign(event: NostrEvent): ParsedCampaign | null {
|
||||
const title = getTag(event, 'title');
|
||||
if (!identifier || !title) return null;
|
||||
|
||||
const seen = new Set<string>();
|
||||
const recipients: CampaignRecipient[] = [];
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] !== 'p') continue;
|
||||
const pubkey = tag[1];
|
||||
if (typeof pubkey !== 'string' || !HEX_64_RE.test(pubkey)) continue;
|
||||
if (seen.has(pubkey)) continue;
|
||||
seen.add(pubkey);
|
||||
const wallet = parseCampaignWallet(getTag(event, 'w'));
|
||||
if (!wallet) return null;
|
||||
|
||||
const maybeRelay = typeof tag[2] === 'string' && tag[2].startsWith('ws') ? tag[2] : undefined;
|
||||
const rawWeight = typeof tag[3] === 'string' ? Number(tag[3]) : NaN;
|
||||
const weight = Number.isFinite(rawWeight) && rawWeight > 0 ? rawWeight : 1;
|
||||
|
||||
recipients.push({ pubkey, relay: maybeRelay, weight });
|
||||
}
|
||||
|
||||
if (recipients.length === 0) return null;
|
||||
|
||||
// Category from the first `t` tag whose value is a recognized slug.
|
||||
let category: CampaignCategory | undefined;
|
||||
for (const [name, value] of event.tags) {
|
||||
if (name !== 't' || typeof value !== 'string') continue;
|
||||
if ((CAMPAIGN_CATEGORIES as readonly string[]).includes(value)) {
|
||||
category = value as CampaignCategory;
|
||||
break;
|
||||
}
|
||||
const legacyCategory = LEGACY_CAMPAIGN_CATEGORY_ALIASES[value];
|
||||
if (legacyCategory) {
|
||||
category = legacyCategory;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Image — only accept https URLs. We do the formal sanitizeUrl pass at the
|
||||
// render site (since this lib runs in tests without DOM); strip non-https here.
|
||||
const rawImage = getTag(event, 'image');
|
||||
const image = rawImage && /^https:\/\//i.test(rawImage) ? rawImage : undefined;
|
||||
|
||||
// Status tag. We only recognize `archived` today; any other value is
|
||||
// ignored so future statuses (e.g. `paused`, `funded`) don't accidentally
|
||||
// get treated as archived.
|
||||
const archived = getTag(event, 'status') === 'archived';
|
||||
const tags = getCampaignTags(event);
|
||||
// Banner — only accept https URLs. Formal sanitizeUrl pass happens at
|
||||
// the render site (this lib runs in tests without DOM); strip non-https
|
||||
// here so the parsed value is safe to interpolate into a fetch().
|
||||
const rawBanner = getTag(event, 'banner');
|
||||
const banner = rawBanner && /^https:\/\//i.test(rawBanner) ? rawBanner : undefined;
|
||||
const bannerImeta = getBannerImeta(event, banner);
|
||||
|
||||
return {
|
||||
event,
|
||||
@@ -213,117 +212,21 @@ export function parseCampaign(event: NostrEvent): ParsedCampaign | null {
|
||||
title: title.trim(),
|
||||
summary: getTag(event, 'summary')?.trim() ?? '',
|
||||
story: event.content,
|
||||
image,
|
||||
category,
|
||||
tags,
|
||||
goalSats: parsePositiveInt(getTag(event, 'goal')),
|
||||
banner,
|
||||
bannerImeta,
|
||||
wallet,
|
||||
goalUsd: parsePositiveInt(getTag(event, 'goal')),
|
||||
deadline: parsePositiveInt(getTag(event, 'deadline')),
|
||||
location: getTag(event, 'location')?.trim() || undefined,
|
||||
countryCode: getCountryCode(event),
|
||||
recipients,
|
||||
createdAt: event.created_at,
|
||||
archived,
|
||||
};
|
||||
}
|
||||
|
||||
/** Human display for a campaign's structured country, falling back to legacy location text. */
|
||||
/** Human display for a campaign's country code, including the flag emoji. */
|
||||
export function getCampaignCountryLabel(campaign: ParsedCampaign): string | undefined {
|
||||
const country = campaign.countryCode ? COUNTRIES[campaign.countryCode] : undefined;
|
||||
if (country) return `${country.flag} ${country.name}`;
|
||||
return campaign.location;
|
||||
}
|
||||
|
||||
export function getCampaignPrimaryTagLabel(campaign: ParsedCampaign): string | undefined {
|
||||
const firstTag = campaign.tags[0] ?? campaign.category;
|
||||
if (!firstTag) return undefined;
|
||||
if ((CAMPAIGN_CATEGORIES as readonly string[]).includes(firstTag)) {
|
||||
return CAMPAIGN_CATEGORY_LABELS[firstTag as CampaignCategory];
|
||||
}
|
||||
const legacyCategory = LEGACY_CAMPAIGN_CATEGORY_ALIASES[firstTag];
|
||||
if (legacyCategory) return CAMPAIGN_CATEGORY_LABELS[legacyCategory];
|
||||
return firstTag.replace(/-/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
/** Output of {@link splitDonation}: per-recipient amounts in sats. */
|
||||
export interface DonationSplit {
|
||||
pubkey: string;
|
||||
weight: number;
|
||||
/** Whole satoshis allocated to this recipient. */
|
||||
amountSats: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a donation across the campaign's recipients according to each
|
||||
* recipient's weight (defaulting to equal shares when weights are uniform).
|
||||
*
|
||||
* Rules:
|
||||
* 1. Each recipient's share is `floor(totalSats * weight / sumOfWeights)`.
|
||||
* 2. Any rounding remainder is appended to the recipient with the largest
|
||||
* weight (ties broken by original `p` tag order) so the entire donation
|
||||
* reaches the campaign.
|
||||
* 3. Self-donations (recipient pubkey equals donor pubkey) are dropped from
|
||||
* the output. The donor's share would be a no-op output paying their own
|
||||
* Taproot address and only inflates the on-chain fee.
|
||||
*
|
||||
* Throws if `totalSats` is not a positive finite integer, or if there are no
|
||||
* non-self recipients.
|
||||
*/
|
||||
export function splitDonation(
|
||||
recipients: CampaignRecipient[],
|
||||
totalSats: number,
|
||||
donorPubkey: string | undefined,
|
||||
): DonationSplit[] {
|
||||
if (!Number.isFinite(totalSats) || !Number.isInteger(totalSats) || totalSats <= 0) {
|
||||
throw new Error('Donation amount must be a positive integer (satoshis).');
|
||||
}
|
||||
|
||||
const payable = recipients.filter((r) => r.pubkey !== donorPubkey);
|
||||
if (payable.length === 0) {
|
||||
throw new Error('No eligible recipients (donor cannot donate to themselves).');
|
||||
}
|
||||
|
||||
const totalWeight = payable.reduce((sum, r) => sum + r.weight, 0);
|
||||
if (totalWeight <= 0) {
|
||||
throw new Error('Recipient weights must sum to a positive number.');
|
||||
}
|
||||
|
||||
const splits: DonationSplit[] = payable.map((r) => ({
|
||||
pubkey: r.pubkey,
|
||||
weight: r.weight,
|
||||
amountSats: Math.floor((totalSats * r.weight) / totalWeight),
|
||||
}));
|
||||
|
||||
const allocated = splits.reduce((sum, s) => sum + s.amountSats, 0);
|
||||
const remainder = totalSats - allocated;
|
||||
if (remainder > 0) {
|
||||
let largestIdx = 0;
|
||||
for (let i = 1; i < splits.length; i++) {
|
||||
if (splits[i].weight > splits[largestIdx].weight) largestIdx = i;
|
||||
}
|
||||
splits[largestIdx].amountSats += remainder;
|
||||
}
|
||||
|
||||
return splits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the smallest donation total (in sats) where every recipient
|
||||
* receives at least `dustLimit` sats. Used to surface a helpful minimum
|
||||
* before the donor tries to sign a PSBT that would throw on dust.
|
||||
*/
|
||||
export function minDonationForSplit(
|
||||
recipients: CampaignRecipient[],
|
||||
donorPubkey: string | undefined,
|
||||
dustLimit: number,
|
||||
): number {
|
||||
const payable = recipients.filter((r) => r.pubkey !== donorPubkey);
|
||||
if (payable.length === 0) return dustLimit;
|
||||
|
||||
const totalWeight = payable.reduce((sum, r) => sum + r.weight, 0);
|
||||
const smallestShare = Math.min(...payable.map((r) => r.weight));
|
||||
// Need: floor(T * smallestShare / totalWeight) >= dustLimit
|
||||
// -> T >= ceil(dustLimit * totalWeight / smallestShare)
|
||||
return Math.ceil((dustLimit * totalWeight) / smallestShare);
|
||||
if (!country) return undefined;
|
||||
return `${country.flag} ${country.name}`;
|
||||
}
|
||||
|
||||
/** Encodes a campaign's addressable coordinate as a NIP-19 `naddr1...` string. */
|
||||
|
||||
@@ -63,6 +63,12 @@ export interface ParsedCommunity {
|
||||
relays: string[];
|
||||
/** The `a` tag coordinate for the community: `34550:<pubkey>:<d-tag>`. */
|
||||
aTag: string;
|
||||
/**
|
||||
* Raw tag array from the underlying kind 34550 event. Carried through so
|
||||
* non-essential, non-indexed markers (e.g. Agora's `["t", "agora"]`
|
||||
* content marker) can be inspected without re-fetching the event.
|
||||
*/
|
||||
tags: string[][];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,6 +107,7 @@ export function parseCommunityEvent(event: NostrEvent): ParsedCommunity | null {
|
||||
moderatorPubkeys,
|
||||
relays,
|
||||
aTag: `${COMMUNITY_DEFINITION_KIND}:${event.pubkey}:${dTag}`,
|
||||
tags: event.tags,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -486,7 +493,7 @@ export function canModerateOrganization(
|
||||
*
|
||||
* Agora restricts creation of organization-tagged campaigns / pledges /
|
||||
* events in the client to this set, but anyone could technically publish
|
||||
* a kind 30223 / 36639 / 31922 / 31923 with the organization's uppercase
|
||||
* a kind 33863 / 36639 / 31922 / 31923 with the organization's uppercase
|
||||
* `A` root-scope tag outside the client. Queries that surface "official"
|
||||
* organization activity MUST pass this list as the `authors` filter so
|
||||
* forged events from non-moderators never reach the UI.
|
||||
|
||||
+34
-27
@@ -74,6 +74,8 @@ export interface ExtraKindDef {
|
||||
blurb?: string;
|
||||
/** External sites where users can create or participate in this kind of content. */
|
||||
sites?: ExtraKindSite[];
|
||||
/** If true, this kind is part of the Agora-curated content set and surfaces in the settings UI. */
|
||||
agora?: boolean;
|
||||
}
|
||||
|
||||
/** All supported extra content kinds, ordered by section (feed → media → social → whimsy). */
|
||||
@@ -88,46 +90,41 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
|
||||
addressable: false,
|
||||
section: 'feed',
|
||||
feedOnly: true,
|
||||
},
|
||||
{
|
||||
kind: 1111,
|
||||
id: 'comments',
|
||||
feedKey: 'feedIncludeComments',
|
||||
label: 'Comments',
|
||||
description: 'NIP-22 comments on posts and external content',
|
||||
addressable: false,
|
||||
section: 'feed',
|
||||
feedOnly: true,
|
||||
agora: true,
|
||||
},
|
||||
{
|
||||
kind: 6,
|
||||
id: 'reposts',
|
||||
feedKey: 'feedIncludeReposts',
|
||||
label: 'Reposted Notes',
|
||||
description: 'Shared posts from others',
|
||||
extraFeedKinds: [16],
|
||||
label: 'Reposts',
|
||||
description: 'Posts and media reshared by others',
|
||||
addressable: false,
|
||||
section: 'feed',
|
||||
feedOnly: true,
|
||||
agora: true,
|
||||
},
|
||||
{
|
||||
kind: 16,
|
||||
id: 'generic-reposts',
|
||||
feedKey: 'feedIncludeGenericReposts',
|
||||
label: 'Reposted Other Content',
|
||||
description: 'Shared non-text-note posts from others',
|
||||
kind: 1111,
|
||||
id: 'comments',
|
||||
feedKey: 'feedIncludeComments',
|
||||
label: 'Replies',
|
||||
description: 'Threaded replies to posts',
|
||||
addressable: false,
|
||||
section: 'feed',
|
||||
feedOnly: true,
|
||||
agora: true,
|
||||
},
|
||||
{
|
||||
kind: 7,
|
||||
id: 'reactions',
|
||||
feedKey: 'feedIncludeReactions',
|
||||
label: 'Reactions',
|
||||
description: 'People reacting to posts (likes and emoji reactions). Disabled by default.',
|
||||
description: 'Likes and emoji reactions on posts',
|
||||
addressable: false,
|
||||
section: 'feed',
|
||||
feedOnly: true,
|
||||
agora: true,
|
||||
},
|
||||
{
|
||||
kind: 9735,
|
||||
@@ -137,10 +134,11 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
|
||||
// toggle so users don't have to think about which rail was used.
|
||||
extraFeedKinds: [8333],
|
||||
label: 'Zaps',
|
||||
description: 'People zapping posts (Lightning and on-chain Bitcoin). Disabled by default.',
|
||||
description: 'Bitcoin tips sent to posts',
|
||||
addressable: false,
|
||||
section: 'feed',
|
||||
feedOnly: true,
|
||||
agora: true,
|
||||
},
|
||||
{
|
||||
kind: 30023,
|
||||
@@ -153,6 +151,7 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
|
||||
addressable: true,
|
||||
section: 'feed',
|
||||
blurb: 'Blog posts, essays, and guides. Write and publish long-form articles.',
|
||||
agora: true,
|
||||
},
|
||||
// Media
|
||||
{
|
||||
@@ -161,24 +160,26 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
|
||||
showKey: 'showPhotos',
|
||||
feedKey: 'feedIncludePhotos',
|
||||
label: 'Photos',
|
||||
description: 'Picture-first posts (NIP-68)',
|
||||
description: 'Picture-first posts',
|
||||
route: 'photos',
|
||||
addressable: false,
|
||||
section: 'media',
|
||||
blurb: 'Instagram-style photo posts. Share images with captions and tags.',
|
||||
sites: [{ url: 'https://nostr.build', name: 'nostr.build' }],
|
||||
agora: true,
|
||||
},
|
||||
{
|
||||
kind: 21,
|
||||
id: 'videos',
|
||||
showKey: 'showVideos',
|
||||
label: 'Videos',
|
||||
description: 'Video posts (NIP-71 kinds 21 & 22) and live streams',
|
||||
description: 'Recorded videos and live streams',
|
||||
route: 'videos',
|
||||
addressable: false,
|
||||
section: 'media',
|
||||
blurb: 'Watch and discover videos and live streams in a YouTube/Twitch-style interface.',
|
||||
sites: [{ url: 'https://zap.stream', name: 'zap.stream' }, { url: 'https://vidstr.shakespeare.wtf', name: 'Vidstr' }],
|
||||
agora: true,
|
||||
subKinds: [
|
||||
{
|
||||
kind: 21,
|
||||
@@ -203,11 +204,12 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
|
||||
id: 'voice',
|
||||
feedKey: 'feedIncludeVoiceMessages',
|
||||
label: 'Voice Messages',
|
||||
description: 'Short audio voice messages (NIP-A0)',
|
||||
description: 'Short audio recordings',
|
||||
addressable: false,
|
||||
section: 'media',
|
||||
feedOnly: true,
|
||||
blurb: 'Record and share short voice messages, up to 60 seconds long.',
|
||||
agora: true,
|
||||
},
|
||||
{
|
||||
kind: 34236,
|
||||
@@ -300,12 +302,13 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
|
||||
feedKey: 'feedIncludeEvents',
|
||||
extraFeedKinds: [31922],
|
||||
label: 'Events',
|
||||
description: 'Calendar events and meetups (NIP-52)',
|
||||
description: 'Calendar events and meetups',
|
||||
route: 'events',
|
||||
addressable: true,
|
||||
section: 'social',
|
||||
blurb: 'Events and meetups on Nostr. RSVP and see who else is going. Create and manage events on Plektos.',
|
||||
sites: [{ url: 'https://plektos.app', name: 'Plektos' }],
|
||||
agora: true,
|
||||
},
|
||||
{
|
||||
kind: 1063,
|
||||
@@ -332,6 +335,7 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
|
||||
section: 'social',
|
||||
blurb: 'Ask a question, let people vote. Create polls from a polling app.',
|
||||
sites: [{ url: 'https://pollerama.fun' }],
|
||||
agora: true,
|
||||
},
|
||||
{
|
||||
kind: 39089,
|
||||
@@ -356,11 +360,12 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
|
||||
feedKey: 'feedIncludeCommunities',
|
||||
extraFeedKinds: [9041],
|
||||
label: 'Organizations',
|
||||
description: 'Agora organizations with founder + moderator trust model (NIP-72)',
|
||||
description: 'Coalitions and groups with founders and moderators',
|
||||
route: 'communities',
|
||||
addressable: true,
|
||||
section: 'social',
|
||||
blurb: 'Organizations on Nostr with an explicit founder, listed moderators, and event-level moderation.',
|
||||
agora: true,
|
||||
},
|
||||
{
|
||||
kind: 62,
|
||||
@@ -417,11 +422,12 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
|
||||
id: 'badges',
|
||||
showKey: 'showBadges',
|
||||
label: 'Badges',
|
||||
description: 'Badges and awards (NIP-58)',
|
||||
description: 'Awards and recognition issued to people',
|
||||
route: 'badges',
|
||||
addressable: true,
|
||||
section: 'whimsy',
|
||||
section: 'social',
|
||||
blurb: 'Discover badges created on Nostr. Badge issuers award them for recognition, participation, or appreciation.',
|
||||
agora: true,
|
||||
subKinds: [
|
||||
{
|
||||
kind: 30009,
|
||||
@@ -485,11 +491,12 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
|
||||
showKey: 'showHighlights',
|
||||
feedKey: 'feedIncludeHighlights',
|
||||
label: 'Highlights',
|
||||
description: 'Noteworthy excerpts from articles, posts, and the web (NIP-84)',
|
||||
description: 'Noteworthy excerpts from articles, posts, and the web',
|
||||
route: 'highlights',
|
||||
addressable: false,
|
||||
section: 'social',
|
||||
blurb: "Highlights are excerpts people find valuable — a paragraph from an article, a passage from a blog post, or a quote from anywhere on the web. Browse what people are reading and what's resonating.",
|
||||
agora: true,
|
||||
},
|
||||
// Birdstar (feed-only — external app, no Ditto page)
|
||||
{
|
||||
|
||||
@@ -10,9 +10,54 @@ type KindOption = {
|
||||
icon: React.ComponentType<{ className?: string }> | undefined;
|
||||
};
|
||||
|
||||
/** Build the kind options from EXTRA_KINDS definitions. */
|
||||
/**
|
||||
* Agora-native kinds that are not modeled in EXTRA_KINDS (which drives the
|
||||
* sidebar / feed-settings UI). We surface them in the search Kind picker
|
||||
* because they are first-class searchable content on Agora.
|
||||
*/
|
||||
const AGORA_NATIVE_KIND_OPTIONS: KindOption[] = [
|
||||
{
|
||||
value: '33863',
|
||||
label: 'Campaigns (33863)',
|
||||
description: 'Fundraising campaigns',
|
||||
parentId: 'campaigns',
|
||||
icon: CONTENT_KIND_ICONS['campaigns'],
|
||||
},
|
||||
{
|
||||
value: '36639',
|
||||
label: 'Pledges (36639)',
|
||||
description: 'Donor pledges for concrete actions',
|
||||
parentId: 'actions',
|
||||
icon: CONTENT_KIND_ICONS['actions'],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Agora's curated "main content" kinds, surfaced as a preset section at the
|
||||
* top of the KindPicker. Order is the order they appear in the picker.
|
||||
*
|
||||
* Includes Agora-native kinds plus the existing-NIP kinds Agora foregrounds
|
||||
* (campaigns, pledges, communities, posts, articles, events, polls, photos,
|
||||
* videos). Excludes social-signal kinds (reactions, reposts, zaps) and
|
||||
* stats snapshots, which users rarely filter on directly.
|
||||
*/
|
||||
export const AGORA_PRESET_KIND_VALUES: readonly string[] = [
|
||||
'33863', // Campaigns
|
||||
'36639', // Pledges
|
||||
'34550', // Communities
|
||||
'1', // Posts
|
||||
'30023', // Articles
|
||||
'31923', // Events (time)
|
||||
'1068', // Polls
|
||||
'20', // Photos
|
||||
'21', // Videos
|
||||
] as const;
|
||||
|
||||
/** Build the kind options from EXTRA_KINDS definitions plus Agora-native kinds. */
|
||||
export function buildKindOptions(): KindOption[] {
|
||||
const options: KindOption[] = [];
|
||||
// Agora-native kinds appear first so they're easy to find.
|
||||
for (const opt of AGORA_NATIVE_KIND_OPTIONS) options.push(opt);
|
||||
for (const def of EXTRA_KINDS) {
|
||||
if (def.subKinds) {
|
||||
for (const sub of def.subKinds) {
|
||||
|
||||
@@ -25,3 +25,23 @@ export function formatCampaignAmount(sats: number, btcPrice: number | undefined)
|
||||
if (btcPrice) return satsToUSDWhole(sats, btcPrice);
|
||||
return formatSatsShort(sats);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats an integer USD amount (the campaign goal unit per NIP.md Kind
|
||||
* 33863). Uses thousands separators and a leading `$`. Negative or
|
||||
* non-finite values render as `$0`.
|
||||
*/
|
||||
export function formatUsdGoal(usd: number): string {
|
||||
if (!Number.isFinite(usd) || usd <= 0) return '$0';
|
||||
return `$${Math.floor(usd).toLocaleString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert sats to USD using a live BTC/USD price. Returns `undefined` if
|
||||
* the price isn't available — callers should fall back to the sats
|
||||
* representation in that case.
|
||||
*/
|
||||
export function satsToUsd(sats: number, btcPrice: number | undefined): number | undefined {
|
||||
if (!btcPrice || !Number.isFinite(btcPrice) || btcPrice <= 0) return undefined;
|
||||
return (sats / 100_000_000) * btcPrice;
|
||||
}
|
||||
|
||||
@@ -295,7 +295,6 @@ export const KIND_LABELS: Record<number, string> = {
|
||||
// NIP-23
|
||||
30023: 'Article',
|
||||
30024: 'Draft long-form content',
|
||||
30223: 'Campaign',
|
||||
// NIP-30
|
||||
30030: 'Emoji set',
|
||||
// NKBIP-01
|
||||
@@ -360,6 +359,8 @@ export const KIND_LABELS: Record<number, string> = {
|
||||
// Corny Chat
|
||||
32388: 'User room favorites',
|
||||
33388: 'High scores',
|
||||
// Agora
|
||||
33863: 'Campaign',
|
||||
// NIP-71
|
||||
34235: 'Addressable video',
|
||||
34236: 'Addressable short video',
|
||||
|
||||
@@ -221,7 +221,6 @@ export const AppConfigSchema = z.object({
|
||||
clientName: z.string().optional(),
|
||||
/** NIP-19 naddr1 string for the kind 31990 handler event. */
|
||||
client: z.string().startsWith('naddr1').optional(),
|
||||
magicMouse: z.boolean().optional(),
|
||||
theme: ThemeSchema,
|
||||
customTheme: ThemeConfigCompatSchema.optional(),
|
||||
themes: ThemesConfigSchema.optional(),
|
||||
|
||||
@@ -193,7 +193,7 @@ export const SIDEBAR_ITEMS: SidebarItemDef[] = [
|
||||
{ id: "emojis", label: "Emojis", path: "/emojis", icon: SmilePlus },
|
||||
{ id: "development", label: "Development", path: "/development", icon: Code },
|
||||
{ id: "badges", label: "Badges", path: "/badges", icon: Award },
|
||||
{ id: "communities", label: "Organize", path: "/communities", icon: Users },
|
||||
{ id: "communities", label: "Groups", path: "/communities", icon: Users },
|
||||
{ id: "world", label: "World", path: "/world", icon: Earth },
|
||||
];
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { AGORA_PRESET_KIND_VALUES } from "./feedFilterUtils"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
@@ -9,12 +10,15 @@ export function cn(...inputs: ClassValue[]) {
|
||||
* Parse a kindFilter string into an array of kind numbers.
|
||||
* Supports:
|
||||
* - 'all' → undefined (no override)
|
||||
* - 'agora' → the AGORA_PRESET_KIND_VALUES set (Campaigns, Pledges,
|
||||
* Communities, Posts, Articles, Events, Polls, Photos, Videos)
|
||||
* - 'custom' → parse customKindText as comma/space-separated numbers
|
||||
* - Single kind number (e.g. '1') → [1]
|
||||
* - Comma-separated kind numbers (e.g. '1,30023,20') → [1, 30023, 20]
|
||||
*/
|
||||
export function parseKindFilter(kindFilter: string, customKindText?: string): number[] | undefined {
|
||||
if (kindFilter === 'all' || kindFilter === '') return undefined;
|
||||
if (kindFilter === 'agora') return AGORA_PRESET_KIND_VALUES.map(Number);
|
||||
if (kindFilter === 'custom') {
|
||||
if (!customKindText) return undefined;
|
||||
const parsed = customKindText.trim().split(/[\s,]+/).map(Number).filter((n) => Number.isInteger(n) && n > 0);
|
||||
|
||||
@@ -106,7 +106,7 @@ export function getTargetEventId(event: NostrEvent): string | undefined {
|
||||
* returns undefined for those — callers that need the kind in that case must
|
||||
* fetch the target event themselves.
|
||||
*
|
||||
* Used to distinguish on-chain donations to campaigns (kind 30223) from
|
||||
* Used to distinguish on-chain donations to campaigns (kind 33863) from
|
||||
* other zap targets so the UI can label them as "donated" rather than the
|
||||
* generic "sent / zapped".
|
||||
*/
|
||||
|
||||
@@ -17,7 +17,6 @@ import { useAction, type Action } from '@/hooks/useActions';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useBitcoinWallet } from '@/hooks/useBitcoinWallet';
|
||||
import { useComments } from '@/hooks/useComments';
|
||||
import { useEventStats } from '@/hooks/useTrending';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useSubmissionZapTotals } from '@/hooks/useSubmissionZapTotals';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
@@ -33,14 +32,13 @@ import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { DetailCommentComposer } from '@/components/DetailCommentComposer';
|
||||
import { PostActionBar } from '@/components/PostActionBar';
|
||||
import { PinnedCommentHeader } from '@/components/PinnedCommentHeader';
|
||||
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
|
||||
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
|
||||
import {
|
||||
InteractionsModal,
|
||||
type InteractionTab,
|
||||
} from '@/components/InteractionsModal';
|
||||
import { ThreadedReplyList, type ReplyNode } from '@/components/ThreadedReplyList';
|
||||
import { usePinnedEventComments } from '@/hooks/usePinnedEventComments';
|
||||
import NotFound from '@/pages/NotFound';
|
||||
|
||||
function formatDeadline(unixSeconds: number): { label: string; isPast: boolean } {
|
||||
@@ -81,13 +79,17 @@ function PledgeDetailContent({ action }: { action: Action }) {
|
||||
const author = useAuthor(action.pubkey);
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const { data: engagementStats } = useEventStats(action.event.id, action.event);
|
||||
const { data: commentsData, isLoading: commentsLoading } = useComments(action.event, 500);
|
||||
const rootATag = `36639:${action.pubkey}:${action.id}`;
|
||||
const {
|
||||
pinnedEvents,
|
||||
isPinned,
|
||||
canManagePins,
|
||||
togglePin,
|
||||
} = usePinnedEventComments(rootATag, action.pubkey);
|
||||
|
||||
const [replyOpen, setReplyOpen] = useState(false);
|
||||
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
||||
const [interactionsOpen, setInteractionsOpen] = useState(false);
|
||||
const [interactionsTab, setInteractionsTab] = useState<InteractionTab>('reposts');
|
||||
|
||||
const topLevel = useMemo(
|
||||
() => commentsData?.topLevelComments ?? [],
|
||||
@@ -130,17 +132,17 @@ function PledgeDetailContent({ action }: { action: Action }) {
|
||||
.map((c) => buildNode(c));
|
||||
}, [commentsData, topLevel, zapTotals]);
|
||||
|
||||
const pinnedNodes = useMemo(
|
||||
() => pinnedEvents.map((event): ReplyNode => ({ event, children: [] })),
|
||||
[pinnedEvents],
|
||||
);
|
||||
|
||||
const metadata: NostrMetadata | undefined = author.data?.metadata;
|
||||
const creatorName = getDisplayName(metadata, action.pubkey);
|
||||
const creatorProfileUrl = useProfileUrl(action.pubkey, metadata);
|
||||
const deadline = action.deadline ? formatDeadline(action.deadline) : null;
|
||||
const cover = sanitizeUrl(action.image);
|
||||
const progressValue = action.bounty > 0 ? Math.min(100, Math.round((fundedSats / action.bounty) * 100)) : 0;
|
||||
const hasStats =
|
||||
!!engagementStats?.replies ||
|
||||
!!engagementStats?.reposts ||
|
||||
!!engagementStats?.quotes ||
|
||||
!!engagementStats?.reactions;
|
||||
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 36639,
|
||||
@@ -156,11 +158,6 @@ function PledgeDetailContent({ action }: { action: Action }) {
|
||||
[action.event],
|
||||
);
|
||||
|
||||
const openInteractions = (tab: InteractionTab) => {
|
||||
setInteractionsTab(tab);
|
||||
setInteractionsOpen(true);
|
||||
};
|
||||
|
||||
const handleShare = async () => {
|
||||
const url = `${window.location.origin}/${naddr}`;
|
||||
try {
|
||||
@@ -187,6 +184,35 @@ function PledgeDetailContent({ action }: { action: Action }) {
|
||||
onBack={() => navigate(-1)}
|
||||
/>
|
||||
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
<div className="rounded-b-xl rounded-t-none bg-card border border-t-0 border-border/60 shadow-sm px-4 sm:px-5 py-3">
|
||||
<PostActionBar
|
||||
event={action.event}
|
||||
replyLabel="Submit"
|
||||
onReply={() => setReplyOpen(true)}
|
||||
onMore={() => setMoreMenuOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pinnedNodes.length > 0 && (
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 pt-6">
|
||||
<div className="rounded-2xl bg-card border border-border/60 overflow-hidden">
|
||||
<ThreadedReplyList
|
||||
roots={pinnedNodes}
|
||||
renderItemHeader={(event) => (
|
||||
<PledgePinHeader
|
||||
isPinned={isPinned(event.id)}
|
||||
canManagePins={canManagePins}
|
||||
pinPending={togglePin.isPending}
|
||||
onTogglePin={() => handleTogglePin(event)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-6 lg:py-10">
|
||||
<div className="lg:flex lg:gap-8 lg:items-start">
|
||||
<div className="lg:hidden mb-6">
|
||||
@@ -205,39 +231,7 @@ function PledgeDetailContent({ action }: { action: Action }) {
|
||||
<PledgeStory storyEvent={storyEvent} hasContent={action.description.trim().length > 0} />
|
||||
|
||||
<div id="pledge-activity" className="scroll-mt-20">
|
||||
<div className="rounded-2xl bg-card border border-border/60 shadow-sm px-4 sm:px-5 py-4 sm:py-5">
|
||||
{hasStats && (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs sm:text-sm text-muted-foreground">
|
||||
{engagementStats?.reposts ? (
|
||||
<button onClick={() => openInteractions('reposts')} className="hover:underline transition-colors">
|
||||
<span className="font-bold text-foreground">{engagementStats.reposts.toLocaleString()}</span>{' '}
|
||||
Repost{engagementStats.reposts !== 1 ? 's' : ''}
|
||||
</button>
|
||||
) : null}
|
||||
{engagementStats?.quotes ? (
|
||||
<button onClick={() => openInteractions('quotes')} className="hover:underline transition-colors">
|
||||
<span className="font-bold text-foreground">{engagementStats.quotes.toLocaleString()}</span>{' '}
|
||||
Quote{engagementStats.quotes !== 1 ? 's' : ''}
|
||||
</button>
|
||||
) : null}
|
||||
{engagementStats?.reactions ? (
|
||||
<button onClick={() => openInteractions('reactions')} className="hover:underline transition-colors">
|
||||
<span className="font-bold text-foreground">{engagementStats.reactions.toLocaleString()}</span>{' '}
|
||||
Like{engagementStats.reactions !== 1 ? 's' : ''}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PostActionBar
|
||||
event={action.event}
|
||||
replyLabel="Submit"
|
||||
onReply={() => setReplyOpen(true)}
|
||||
onMore={() => setMoreMenuOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between gap-3 mb-3 px-1">
|
||||
<h2 className="text-lg font-semibold tracking-tight">Submissions</h2>
|
||||
{topLevel.length > 0 ? (
|
||||
@@ -247,13 +241,29 @@ function PledgeDetailContent({ action }: { action: Action }) {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DetailCommentComposer
|
||||
event={action.event}
|
||||
placeholder="Share proof, evidence, or completed work..."
|
||||
className="mb-3"
|
||||
/>
|
||||
|
||||
{commentsLoading && replyTree.length === 0 ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => <PledgeReplySkeleton key={i} />)}
|
||||
</div>
|
||||
) : replyTree.length > 0 ? (
|
||||
<div className="-mx-2 sm:-mx-4 rounded-2xl bg-card border border-border/60 overflow-hidden">
|
||||
<ThreadedReplyList roots={replyTree} />
|
||||
<div className="rounded-2xl bg-card border border-border/60 overflow-hidden">
|
||||
<ThreadedReplyList
|
||||
roots={replyTree}
|
||||
renderItemHeader={(event) => (
|
||||
<PledgePinHeader
|
||||
isPinned={isPinned(event.id)}
|
||||
canManagePins={canManagePins}
|
||||
pinPending={togglePin.isPending}
|
||||
onTogglePin={() => handleTogglePin(event)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
@@ -289,14 +299,41 @@ function PledgeDetailContent({ action }: { action: Action }) {
|
||||
|
||||
<ReplyComposeModal event={action.event} open={replyOpen} onOpenChange={setReplyOpen} />
|
||||
<NoteMoreMenu event={action.event} open={moreMenuOpen} onOpenChange={setMoreMenuOpen} />
|
||||
<InteractionsModal
|
||||
eventId={action.event.id}
|
||||
open={interactionsOpen}
|
||||
onOpenChange={setInteractionsOpen}
|
||||
initialTab={interactionsTab}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
|
||||
function handleTogglePin(event: NostrEvent) {
|
||||
const wasPinned = isPinned(event.id);
|
||||
togglePin.mutate(event.id, {
|
||||
onSuccess: () => {
|
||||
toast({ title: wasPinned ? 'Unpinned from pledge' : 'Pinned to pledge' });
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: 'Failed to update pledge pins', variant: 'destructive' });
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function PledgePinHeader({
|
||||
isPinned,
|
||||
canManagePins,
|
||||
pinPending,
|
||||
onTogglePin,
|
||||
}: {
|
||||
isPinned: boolean;
|
||||
canManagePins: boolean;
|
||||
pinPending: boolean;
|
||||
onTogglePin: () => void;
|
||||
}) {
|
||||
return (
|
||||
<PinnedCommentHeader
|
||||
isPinned={isPinned}
|
||||
canManagePins={canManagePins}
|
||||
pinPending={pinPending}
|
||||
onTogglePin={onTogglePin}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface PledgeHeroProps {
|
||||
@@ -315,7 +352,7 @@ function PledgeHero({ action, cover, creatorName, creatorProfileUrl, deadline, o
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 pt-4">
|
||||
<div className="relative aspect-[16/9] sm:aspect-[21/9] rounded-xl overflow-hidden bg-gradient-to-br from-primary/15 via-primary/5 to-secondary">
|
||||
<div className="relative aspect-[16/9] sm:aspect-[21/9] rounded-t-xl rounded-b-none overflow-hidden bg-gradient-to-br from-primary/15 via-primary/5 to-secondary">
|
||||
<img
|
||||
src={coverImage}
|
||||
alt=""
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { IntroImage } from '@/components/IntroImage';
|
||||
import { AdvancedSettings } from '@/components/AdvancedSettings';
|
||||
import { WalletSettings } from '@/components/WalletSettings';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
@@ -38,14 +37,11 @@ export function AdvancedSettingsPage() {
|
||||
|
||||
<div className="p-4">
|
||||
{/* Intro */}
|
||||
<div className="flex items-center gap-4 px-3 pt-2 pb-4">
|
||||
<IntroImage src="/advanced-intro.png" />
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold">Power User Settings</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Wallet connections, system configuration, and other advanced options.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-3 pt-2 pb-4">
|
||||
<h2 className="text-sm font-semibold">Power User Settings</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Wallet connections, system configuration, and other advanced options.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Wallet collapsible — only when logged in */}
|
||||
|
||||
@@ -44,7 +44,12 @@ function parseSort(value: string | null): CampaignSort {
|
||||
* canonical URL stays clean. Useful for sharing search results.
|
||||
*/
|
||||
export function AllCampaignsPage() {
|
||||
useLayoutOptions({ rightSidebar: null });
|
||||
// `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.
|
||||
useLayoutOptions({ noMaxWidth: true, rightSidebar: null });
|
||||
const { config } = useAppContext();
|
||||
|
||||
// URL state — sort and query live in the URL so results are shareable.
|
||||
@@ -216,10 +221,13 @@ export function AllCampaignsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid — capped at 2 columns per row regardless of viewport. */}
|
||||
{/* Grid — widens to 3 columns at lg and 4 at xl so desktop users
|
||||
can scan more campaigns at once, matching the Pledge index's
|
||||
card density. Mobile and small tablets stay single / double
|
||||
column so the cards keep their tappable size. */}
|
||||
{showSkeleton ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<CampaignCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
@@ -265,7 +273,7 @@ export function AllCampaignsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
|
||||
{visible.map((campaign) => (
|
||||
<CampaignCard key={campaign.aTag} campaign={campaign} />
|
||||
))}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { Monitor, Moon, Sun } from 'lucide-react';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { IntroImage } from '@/components/IntroImage';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -64,14 +63,11 @@ export function AppearanceSettingsPage() {
|
||||
|
||||
<div className="p-4">
|
||||
{/* Intro */}
|
||||
<div className="flex items-center gap-4 px-3 pt-2 pb-6">
|
||||
<IntroImage src="/theme-intro.png" />
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold">Color Mode</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Pick your preferred color mode. System will automatically match your device's light or dark setting.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-3 pt-2 pb-6">
|
||||
<h2 className="text-sm font-semibold">Color Mode</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Pick your preferred color mode. System will automatically match your device's light or dark setting.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Theme options */}
|
||||
|
||||
+448
-430
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useLayoutOptions } from '@/contexts/LayoutContext';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { type ParsedCampaign } from '@/lib/campaign';
|
||||
import { searchCountry } from '@/lib/countries';
|
||||
|
||||
import { getCoordinates } from '@/lib/coordinates';
|
||||
|
||||
/** Cap on how many featured campaigns we render in the home-page row. */
|
||||
@@ -67,7 +67,7 @@ export function CampaignsPage() {
|
||||
if (!moderation || !featuredCampaigns) return [];
|
||||
const order = moderation.featuredOrder;
|
||||
return [...featuredCampaigns]
|
||||
.filter((c) => featuredCoords.includes(c.aTag) && !c.archived)
|
||||
.filter((c) => featuredCoords.includes(c.aTag))
|
||||
.sort((a, b) => (order.get(b.aTag) ?? 0) - (order.get(a.aTag) ?? 0))
|
||||
.slice(0, MAX_FEATURED);
|
||||
}, [featuredCampaigns, featuredCoords, moderation]);
|
||||
@@ -150,12 +150,10 @@ export function CampaignsPage() {
|
||||
);
|
||||
}, [isMod, user, moderation, ownCampaigns]);
|
||||
|
||||
// Build the spotlight pool from the featured campaigns only.
|
||||
// New events use NIP-73 `i` country tags; legacy campaigns can still
|
||||
// resolve from the old free-form `location` field. A featured campaign
|
||||
// without a resolvable country is silently dropped — the globe needs
|
||||
// coordinates to pin it, and the banner cycles in lockstep with the
|
||||
// globe so the two stay in sync.
|
||||
// Build the spotlight pool from the featured campaigns only. A featured
|
||||
// campaign without a resolvable country is silently dropped — the globe
|
||||
// needs coordinates to pin it, and the banner cycles in lockstep with
|
||||
// the globe so the two stay in sync.
|
||||
const spotlightables = useMemo(() => {
|
||||
type Entry = {
|
||||
key: string;
|
||||
@@ -169,7 +167,7 @@ export function CampaignsPage() {
|
||||
|
||||
const add = (c: ParsedCampaign) => {
|
||||
if (seenAtag.has(c.aTag)) return;
|
||||
const countryCode = c.countryCode ?? (c.location ? searchCountry(c.location)?.country.code : undefined);
|
||||
const countryCode = c.countryCode;
|
||||
if (!countryCode) return;
|
||||
const coords = getCoordinates(countryCode);
|
||||
if (!coords) return;
|
||||
@@ -257,7 +255,7 @@ export function CampaignsPage() {
|
||||
Inspired by the Treasures HeroGallery pattern: the photo IS the
|
||||
background, everything else floats over it. */}
|
||||
<section className="relative overflow-hidden border-b border-border bg-secondary/40">
|
||||
<CampaignHeroBackground imageUrl={spotlightCampaign?.image} />
|
||||
<CampaignHeroBackground imageUrl={spotlightCampaign?.banner} />
|
||||
|
||||
{/* Per-campaign hopeful color atmosphere. Sits on top of the
|
||||
photo and below the globe so its mix-blend-mode: screen layers
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Check, Copy, HandHeart, LogIn, Send, Sparkles } from 'lucide-react';
|
||||
|
||||
import { AgoraLogo } from '@/components/AgoraLogo';
|
||||
import AuthDialog from '@/components/auth/AuthDialog';
|
||||
import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useCampaigns } from '@/hooks/useCampaigns';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
||||
/**
|
||||
* Landing page for someone who already has a Nostr account and was told a
|
||||
* campaign was started on their behalf. The page splits cleanly:
|
||||
*
|
||||
* - Logged out: hero + a single "Sign in" button that opens the auth dialog.
|
||||
* - Logged in: query for campaigns whose `p` tags include `user.pubkey`
|
||||
* and surface them as a list of CampaignCards, so the user can click
|
||||
* through to view, edit metadata (if they ever take ownership), or just
|
||||
* watch donations arrive.
|
||||
*
|
||||
* "Claim" is a UI metaphor — there's nothing on-chain to claim, since
|
||||
* donations already land in the recipient's Taproot address derived from
|
||||
* their pubkey. Filling in a profile is the only real action needed.
|
||||
*/
|
||||
export function ClaimPage() {
|
||||
const { user } = useCurrentUser();
|
||||
const { config } = useAppContext();
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
|
||||
useSeoMeta({
|
||||
title: `Claim your campaign | ${config.appName}`,
|
||||
description:
|
||||
'Sign in to see fundraising campaigns started for you on Agora and start receiving Bitcoin donations.',
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="min-h-dvh bg-gradient-to-br from-primary/10 via-background to-secondary/40">
|
||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-10 lg:py-16">
|
||||
<header className="flex items-center justify-between mb-10">
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<AgoraLogo size={36} />
|
||||
<span className="font-bold text-lg">{config.appName}</span>
|
||||
</Link>
|
||||
<Button variant="ghost" asChild>
|
||||
<Link to="/receive">Need an account?</Link>
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{user ? <ClaimedCampaigns pubkey={user.pubkey} /> : <ClaimLoggedOut onLogin={() => setLoginOpen(true)} />}
|
||||
|
||||
<AuthDialog
|
||||
isOpen={loginOpen}
|
||||
onClose={() => setLoginOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function ClaimLoggedOut({ onLogin }: { onLogin: () => void }) {
|
||||
return (
|
||||
<section className="space-y-6 text-center sm:text-left">
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-background/70 backdrop-blur px-3 py-1 border border-border text-xs font-medium">
|
||||
<Sparkles className="size-3.5 text-primary" />
|
||||
Someone started a fundraiser for you
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl sm:text-5xl font-bold tracking-tight leading-[1.1]">
|
||||
Sign in to claim your campaign.
|
||||
</h1>
|
||||
<p className="text-base sm:text-lg text-muted-foreground max-w-2xl">
|
||||
Sign in with the Nostr account the fundraiser was set up for, and you'll see every
|
||||
campaign that lists you as a beneficiary. Donations are sent directly to your wallet — no
|
||||
balance to withdraw, no platform holding your funds.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 pt-2">
|
||||
<Button size="lg" onClick={onLogin} className="rounded-full">
|
||||
<LogIn className="size-4 mr-2" />
|
||||
Sign in to claim
|
||||
</Button>
|
||||
<Button size="lg" variant="outline" asChild className="rounded-full">
|
||||
<Link to="/receive">I don't have an account yet</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="mt-4">
|
||||
<CardContent className="py-6 px-6 space-y-3">
|
||||
<h2 className="text-base font-semibold flex items-center gap-2">
|
||||
<HandHeart className="size-4 text-primary" />
|
||||
What "claiming" means
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
There's nothing on-chain to redeem — donations are sent straight to the Bitcoin
|
||||
address derived from your Nostr key, whether you've signed in to Agora yet or not.
|
||||
Signing in just lets you see the campaign, update your public profile, and confirm
|
||||
what's been donated so far.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ClaimedCampaigns({ pubkey }: { pubkey: string }) {
|
||||
// includeArchived so that even an archived campaign the user is the
|
||||
// recipient of still shows up here — they may want to ask the creator
|
||||
// to reopen it.
|
||||
const { data: campaigns, isLoading } = useCampaigns({
|
||||
recipientPubkeys: [pubkey],
|
||||
includeArchived: true,
|
||||
limit: 60,
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-background/70 backdrop-blur px-3 py-1 border border-border text-xs font-medium">
|
||||
<Sparkles className="size-3.5 text-primary" />
|
||||
You're signed in
|
||||
</div>
|
||||
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight leading-[1.1]">
|
||||
Campaigns started for you
|
||||
</h1>
|
||||
<p className="text-base text-muted-foreground max-w-2xl">
|
||||
Any campaign that lists your account as a beneficiary appears here. Donations land
|
||||
directly in your Bitcoin wallet — no action required to start receiving.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
|
||||
<CampaignCardSkeleton />
|
||||
<CampaignCardSkeleton />
|
||||
</div>
|
||||
) : campaigns && campaigns.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
|
||||
{campaigns.map((c) => (
|
||||
<CampaignCard key={c.aTag} campaign={c} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
// Empty state. The primary action a freshly-signed-up invitee needs
|
||||
// here is sending their new npub back to whoever invited them — once
|
||||
// the inviter adds them as a recipient, the campaign will show up on
|
||||
// a future visit. The "no campaigns matched" detail copy stays, but
|
||||
// demoted below the send-npub card.
|
||||
<div className="space-y-5">
|
||||
<SendNpubCard pubkey={pubkey} />
|
||||
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-8 px-6 text-center space-y-3">
|
||||
<HandHeart className="size-8 text-muted-foreground/60 mx-auto" />
|
||||
<h2 className="text-base font-semibold">Expecting a campaign already?</h2>
|
||||
<p className="text-muted-foreground max-w-md mx-auto text-sm">
|
||||
We didn't find a campaign that lists this account as a beneficiary. If you were
|
||||
told one was set up for you, double-check with the organizer that they used the
|
||||
same Nostr public key (npub) you just signed in with.
|
||||
</p>
|
||||
<div className="pt-1">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to="/settings/profile">Edit my profile</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-sm text-muted-foreground border-t pt-6">
|
||||
Next step: complete your{' '}
|
||||
<Link to="/settings/profile" className="text-primary hover:underline">
|
||||
public profile
|
||||
</Link>{' '}
|
||||
so donors recognize you.
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default ClaimPage;
|
||||
|
||||
/**
|
||||
* Surfaced in the empty-state path of {@link ClaimedCampaigns}.
|
||||
*
|
||||
* When a freshly-signed-up invitee lands here and no campaign yet lists them
|
||||
* as a beneficiary, the most likely explanation is that the inviter is still
|
||||
* waiting on the invitee's npub to add them. This card hands them a
|
||||
* one-tap copy of their own npub so they can paste it back into whatever
|
||||
* channel the original invite came from.
|
||||
*
|
||||
* Pure UX — no event publishing, no analytics ping back to the inviter. The
|
||||
* invitee still has to send the message manually; we just remove the friction
|
||||
* of hunting for their npub in settings.
|
||||
*/
|
||||
function SendNpubCard({ pubkey }: { pubkey: string }) {
|
||||
const { toast } = useToast();
|
||||
const npub = useMemo(() => nip19.npubEncode(pubkey), [pubkey]);
|
||||
const [copiedNpub, setCopiedNpub] = useState(false);
|
||||
|
||||
const writeClipboard = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(npub);
|
||||
setCopiedNpub(true);
|
||||
setTimeout(() => setCopiedNpub(false), 1500);
|
||||
toast({ title: 'Npub copied' });
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Copy failed',
|
||||
description: 'Your browser blocked clipboard access. Select and copy the npub manually.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="border-2 border-primary/50 bg-primary/5">
|
||||
<CardContent className="py-6 px-6 space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-full bg-primary/15 p-2 shrink-0">
|
||||
<Send className="size-5 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<h2 className="text-lg font-semibold">
|
||||
Finished setting up? Send your npub to whoever invited you.
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
They need your npub to add you as a beneficiary on the campaign. Once they do, it
|
||||
will show up here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Your npub
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={writeClipboard}
|
||||
className="w-full flex items-center justify-between gap-2 rounded-lg border bg-background px-3 py-2.5 font-mono text-xs text-left hover:bg-muted/60 motion-safe:transition-colors"
|
||||
aria-label="Copy npub"
|
||||
>
|
||||
<span className="break-all">{npub}</span>
|
||||
{copiedNpub ? (
|
||||
<Check className="size-4 text-green-500 shrink-0" />
|
||||
) : (
|
||||
<Copy className="size-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Button type="button" onClick={writeClipboard} className="w-full">
|
||||
{copiedNpub ? (
|
||||
<Check className="size-4 mr-1.5 text-green-200" />
|
||||
) : (
|
||||
<Copy className="size-4 mr-1.5" />
|
||||
)}
|
||||
Copy my npub
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Paste it into the same conversation where they invited you — Telegram, iMessage, email,
|
||||
or wherever.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+239
-163
@@ -1,36 +1,38 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { Globe2, HandHeart, Loader2, PlusCircle, Shield, ShieldCheck, Users } from 'lucide-react';
|
||||
import { ChevronDown, ChevronUp, EyeOff, Globe2, HandHeart, Hourglass, PlusCircle, Users } from 'lucide-react';
|
||||
|
||||
import { CampaignCard } from '@/components/CampaignCard';
|
||||
import { FeedCard } from '@/components/FeedCard';
|
||||
import { HeroAtmosphere } from '@/components/HeroAtmosphere';
|
||||
import { HeroBanner } from '@/components/HeroBanner';
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
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 { HorizontalScroll } from '@/components/discovery/HorizontalScroll';
|
||||
import { CommunityGrid } from '@/components/discovery/CommunityGrid';
|
||||
import { CommunityMiniCard, CommunityMiniCardSkeleton } from '@/components/discovery/CommunityMiniCard';
|
||||
import { SectionHeader } from '@/components/discovery/SectionHeader';
|
||||
import { COOL_PALETTE } from '@/lib/hopePalette';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useLayoutOptions } from '@/contexts/LayoutContext';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useDiscoverCommunities } from '@/hooks/useDiscoverCommunities';
|
||||
import { useFeaturedOrganizations } from '@/hooks/useFeaturedOrganizations';
|
||||
import { useGlobalActivity } from '@/hooks/useGlobalActivity';
|
||||
import { useGlobalDonations } from '@/hooks/useGlobalDonations';
|
||||
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll';
|
||||
import { useOrganizationHomeActivityFeed } from '@/hooks/useOrganizationHomeActivityFeed';
|
||||
import { useOrganizationMembersOnlyFilter } from '@/hooks/useOrganizationMembersOnlyFilter';
|
||||
import { useOrganizationModeration } from '@/hooks/useOrganizationModeration';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useUserOrganizations } from '@/hooks/useUserOrganizations';
|
||||
import { CAMPAIGN_KIND, parseCampaign } from '@/lib/campaign';
|
||||
import { hasAgoraTag } from '@/lib/agoraNoteTags';
|
||||
import { formatSatsShort } from '@/lib/formatCampaignAmount';
|
||||
import type { ParsedCommunity } from '@/lib/communityUtils';
|
||||
|
||||
// ─── Page ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -41,6 +43,17 @@ export function CommunitiesPage() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Moderator gate. Reuses the campaign moderator pack (Team Soapbox) —
|
||||
// see useOrganizationModeration for why the same pack governs both
|
||||
// surfaces. The `isMod` boolean drives the visibility of the two
|
||||
// collapsible review sections at the bottom of the page; the heavy
|
||||
// discovery + moderation queries that power them are themselves
|
||||
// mounted INSIDE the moderator-only subtree so non-mod viewers don't
|
||||
// pay the cost of fetching 200 orgs and folding labels just to never
|
||||
// render anything.
|
||||
const { data: moderators } = useCampaignModerators();
|
||||
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
|
||||
|
||||
useLayoutOptions({
|
||||
noMaxWidth: true,
|
||||
rightSidebar: null,
|
||||
@@ -76,7 +89,7 @@ export function CommunitiesPage() {
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="pt-4">
|
||||
<section className="pt-4 pb-8">
|
||||
<SectionHeader
|
||||
title="Featured organizations"
|
||||
className="pb-3 sm:px-6"
|
||||
@@ -84,14 +97,170 @@ export function CommunitiesPage() {
|
||||
<FeaturedOrganizationsShelf />
|
||||
</section>
|
||||
|
||||
<section className="pt-4 pb-8">
|
||||
<OrganizationActivityFeed userOrganizations={userOrganizations} />
|
||||
</section>
|
||||
{/* 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 && <ModeratorReviewSections />}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Moderator review sections
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Renders the "Needs review" and "Hidden" rails for moderators only.
|
||||
*
|
||||
* Organizations don't have an `approved` axis (unlike campaigns) — every
|
||||
* Agora-tagged org is publicly visible by default. Moderation reduces to:
|
||||
*
|
||||
* - **Needs review** — orgs minted through Agora (carry `t:agora`) that
|
||||
* have no featured or hidden label yet. These are candidates for
|
||||
* either lifting into Featured or suppressing with Hidden.
|
||||
* - **Hidden** — orgs whose latest hide-axis label is `hidden`.
|
||||
*
|
||||
* The component owns its own data fetches (discovery pool + moderation
|
||||
* rollup) so the page can mount it conditionally on `isMod` and skip
|
||||
* both queries entirely for the overwhelmingly common non-moderator
|
||||
* case. Mirrors the campaign side's `ModeratorSection` pattern
|
||||
* (collapsible, defaults to open when the list is short).
|
||||
*/
|
||||
function ModeratorReviewSections() {
|
||||
// Wider pull than the public discovery shelf so reviewers see deeper
|
||||
// history. Bumping the limit further would just add network cost —
|
||||
// anything truly old can be reviewed by visiting it directly.
|
||||
const { data: allOrgs, isLoading } = useDiscoverCommunities({ limit: 200 });
|
||||
const { data: moderation, isReady } = useOrganizationModeration();
|
||||
|
||||
const needsReviewOrgs = useMemo(() => {
|
||||
if (!moderation || !allOrgs) return [] as ParsedCommunity[];
|
||||
return allOrgs.filter(
|
||||
(org) =>
|
||||
// Restrict the review queue to orgs minted through Agora's create
|
||||
// flow. Without this gate, every kind 34550 community on the
|
||||
// network would appear here — including badge-gated NIP-72
|
||||
// communities, music scenes, etc. — none of which Agora moderators
|
||||
// should be expected to triage.
|
||||
hasAgoraTag(org.tags) &&
|
||||
!moderation.featuredCoords.has(org.aTag) &&
|
||||
!moderation.hiddenCoords.has(org.aTag),
|
||||
);
|
||||
}, [moderation, allOrgs]);
|
||||
|
||||
const hiddenOrgs = useMemo(() => {
|
||||
if (!moderation || !allOrgs) return [] as ParsedCommunity[];
|
||||
return allOrgs.filter((org) => moderation.hiddenCoords.has(org.aTag));
|
||||
}, [moderation, allOrgs]);
|
||||
|
||||
const sectionsLoading = isLoading || !isReady;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModeratorOrgSection
|
||||
icon={<Hourglass className="size-4" />}
|
||||
title="Needs review"
|
||||
description="Agora organizations that haven't been featured or hidden yet. Lift one into the Featured shelf or suppress it with Hide."
|
||||
count={needsReviewOrgs.length}
|
||||
orgs={needsReviewOrgs}
|
||||
isLoading={sectionsLoading}
|
||||
emptyText="Nothing awaiting review."
|
||||
/>
|
||||
<ModeratorOrgSection
|
||||
icon={<EyeOff className="size-4" />}
|
||||
title="Hidden"
|
||||
description="Organizations suppressed from public discovery. Use the kebab menu on a card to unhide."
|
||||
count={hiddenOrgs.length}
|
||||
orgs={hiddenOrgs}
|
||||
isLoading={sectionsLoading}
|
||||
emptyText="No organizations are currently hidden."
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<Collapsible open={open} onOpenChange={setOpen} asChild>
|
||||
<section className="pt-4 pb-2">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-end justify-between gap-4 rounded-lg text-left px-4 sm:px-6 pb-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
|
||||
>
|
||||
<div>
|
||||
<h2 className="text-xl sm:text-2xl font-bold tracking-tight inline-flex items-center gap-2">
|
||||
<span className="text-muted-foreground">{icon}</span>
|
||||
{title}
|
||||
<span className="text-sm font-medium text-muted-foreground">({count})</span>
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1 max-w-2xl">{description}</p>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-5 text-muted-foreground motion-safe:transition-transform shrink-0',
|
||||
open && 'rotate-180',
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
{isLoading && orgs.length === 0 ? (
|
||||
<CommunityGrid>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<CommunityMiniCardSkeleton key={i} className="w-full" />
|
||||
))}
|
||||
</CommunityGrid>
|
||||
) : orgs.length === 0 ? (
|
||||
<div className="px-4 sm:px-6">
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
{emptyText}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<CommunityGrid>
|
||||
{orgs.map((org) => (
|
||||
<CommunityMiniCard key={org.aTag} community={org} className="w-full" />
|
||||
))}
|
||||
</CommunityGrid>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</section>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Hero
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -194,7 +363,7 @@ function CommunitiesHero({ onCreateCommunity }: CommunitiesHeroProps) {
|
||||
<div className="relative max-w-5xl mx-auto px-4 sm:px-6 py-10 sm:py-12 lg:py-14 min-h-[380px] sm:min-h-[420px] lg:min-h-[460px] flex flex-col items-center text-center">
|
||||
<div className="relative space-y-3 max-w-3xl">
|
||||
<p className="text-xs sm:text-sm font-semibold uppercase tracking-[0.18em] text-white/85 drop-shadow">
|
||||
Organize
|
||||
Groups
|
||||
</p>
|
||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight leading-[1.05] text-white drop-shadow-[0_2px_12px_rgb(0_0_0/0.55)]">
|
||||
Strength
|
||||
@@ -310,14 +479,15 @@ function MyCommunitiesShelfContent({
|
||||
// Sorting is founder first, moderator second, followed-only last, with
|
||||
// newest community definition revisions first inside each bucket.
|
||||
const { data: organizations, isLoading } = userOrganizations;
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<HorizontalScroll className="sm:px-6">
|
||||
<CommunityGrid>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<CommunityMiniCardSkeleton key={i} />
|
||||
<CommunityMiniCardSkeleton key={i} className="w-full" />
|
||||
))}
|
||||
</HorizontalScroll>
|
||||
</CommunityGrid>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -337,12 +507,45 @@ function MyCommunitiesShelfContent({
|
||||
);
|
||||
}
|
||||
|
||||
const COLLAPSED_COUNT = 4;
|
||||
const visible = expanded ? organizations : organizations.slice(0, COLLAPSED_COUNT);
|
||||
const canExpand = organizations.length > COLLAPSED_COUNT;
|
||||
|
||||
return (
|
||||
<HorizontalScroll className="sm:px-6">
|
||||
{organizations.slice(0, 18).map((entry) => (
|
||||
<CommunityMiniCard key={entry.community.aTag} community={entry.community} />
|
||||
))}
|
||||
</HorizontalScroll>
|
||||
<div className="space-y-4">
|
||||
<CommunityGrid>
|
||||
{visible.map((entry) => (
|
||||
<CommunityMiniCard
|
||||
key={entry.community.aTag}
|
||||
community={entry.community}
|
||||
className="w-full"
|
||||
/>
|
||||
))}
|
||||
</CommunityGrid>
|
||||
{canExpand && (
|
||||
<div className="flex justify-center px-4 sm:px-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="rounded-full text-sm"
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded ? (
|
||||
<>
|
||||
<ChevronUp className="size-4 mr-1.5" />
|
||||
Show less
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="size-4 mr-1.5" />
|
||||
Show {organizations.length - COLLAPSED_COUNT} more
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -352,11 +555,11 @@ function FeaturedOrganizationsShelf() {
|
||||
|
||||
if (isLoading && !hasFeatured) {
|
||||
return (
|
||||
<HorizontalScroll className="sm:px-6">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<CommunityMiniCardSkeleton key={i} />
|
||||
<CommunityGrid>
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<CommunityMiniCardSkeleton key={i} className="w-full" />
|
||||
))}
|
||||
</HorizontalScroll>
|
||||
</CommunityGrid>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -364,150 +567,23 @@ function FeaturedOrganizationsShelf() {
|
||||
return (
|
||||
<EmptyShelf
|
||||
icon={<Users className="size-7 text-primary/70" />}
|
||||
title="No featured organizations available"
|
||||
body="The curated list is currently unreachable. Try refreshing in a moment."
|
||||
title="No featured organizations yet"
|
||||
body="Agora moderators feature standout organizations here. Check back soon."
|
||||
action={null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<HorizontalScroll className="sm:px-6">
|
||||
<CommunityGrid>
|
||||
{featured.map((entry) => (
|
||||
<CommunityMiniCard key={entry.community.aTag} community={entry.community} />
|
||||
<CommunityMiniCard
|
||||
key={entry.community.aTag}
|
||||
community={entry.community}
|
||||
className="w-full"
|
||||
/>
|
||||
))}
|
||||
</HorizontalScroll>
|
||||
);
|
||||
}
|
||||
|
||||
function OrganizationActivityFeed({ userOrganizations }: { userOrganizations: UserOrganizationsResult }) {
|
||||
const { user } = useCurrentUser();
|
||||
const { data: organizations, isLoading: organizationsLoading } = userOrganizations;
|
||||
const { membersOnly, toggle } = useOrganizationMembersOnlyFilter();
|
||||
const feed = useOrganizationHomeActivityFeed(organizations, membersOnly, !!user && !organizationsLoading);
|
||||
const { scrollRef } = useInfiniteScroll({
|
||||
hasNextPage: feed.hasNextPage,
|
||||
isFetchingNextPage: feed.isFetchingNextPage,
|
||||
fetchNextPage: feed.fetchNextPage,
|
||||
pageCount: feed.pageCount,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<EmptyShelf
|
||||
icon={<Shield className="size-7 text-primary/70" />}
|
||||
title="Log in to see organization activity"
|
||||
body="Comments and official activity from organizations you follow, founded, or moderate will appear here."
|
||||
action={<LoginArea className="max-w-60" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isLoading = organizationsLoading || feed.isLoading;
|
||||
const hasOrganizations = !!organizations && organizations.length > 0;
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="px-4 sm:px-6 mb-3 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight">
|
||||
Organization activity
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1 max-w-xl">
|
||||
Comments, campaigns, pledges, and events from organizations you follow,
|
||||
founded, or moderate.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant={membersOnly ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={toggle}
|
||||
className="rounded-full self-start sm:self-auto shrink-0"
|
||||
>
|
||||
{membersOnly ? <ShieldCheck className="size-4 mr-2" /> : <Shield className="size-4 mr-2" />}
|
||||
Members only
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!hasOrganizations && !isLoading ? (
|
||||
<Card className="border-dashed mx-4 sm:mx-6">
|
||||
<CardContent className="py-12 px-8 text-center space-y-2">
|
||||
<p className="text-base font-semibold">No organization activity yet</p>
|
||||
<p className="text-sm text-muted-foreground max-w-md mx-auto">
|
||||
Follow, found, or moderate an organization to build a feed from its comments and official activity.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : isLoading && feed.events.length === 0 ? (
|
||||
<FeedCard className="divide-y divide-border/60">
|
||||
{Array.from({ length: 3 }).map((_, i) => <FeedRowSkeleton key={i} />)}
|
||||
</FeedCard>
|
||||
) : feed.events.length > 0 ? (
|
||||
<>
|
||||
<FeedCard>
|
||||
{feed.events.map((event) => (
|
||||
<OrganizationFeedRow key={event.id} event={event} />
|
||||
))}
|
||||
</FeedCard>
|
||||
|
||||
{feed.hasNextPage && (
|
||||
<div ref={scrollRef} className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
{feed.isFetchingNextPage && <Loader2 className="size-5 animate-spin" aria-hidden />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!feed.hasNextPage && (
|
||||
<div className="py-8 text-center text-xs text-muted-foreground">
|
||||
You're caught up. Check back soon for new organization activity.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Card className="border-dashed mx-4 sm:mx-6">
|
||||
<CardContent className="py-12 px-8 text-center space-y-2">
|
||||
<p className="text-base font-semibold">No matching activity</p>
|
||||
<p className="text-sm text-muted-foreground max-w-md mx-auto">
|
||||
{membersOnly
|
||||
? 'No member-only activity was found. Turn off Members only to see all organization comments.'
|
||||
: 'No comments or official activity were found for these organizations yet.'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OrganizationFeedRow({ event }: { event: NostrEvent }) {
|
||||
if (event.kind === CAMPAIGN_KIND) {
|
||||
const campaign = parseCampaign(event);
|
||||
if (!campaign || campaign.archived) return null;
|
||||
return (
|
||||
<div className="p-4 sm:p-5 border-b border-border">
|
||||
<CampaignCard campaign={campaign} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <NoteCard event={event} />;
|
||||
}
|
||||
|
||||
function FeedRowSkeleton() {
|
||||
return (
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-11 rounded-full shrink-0" />
|
||||
<div className="min-w-0 space-y-1.5 flex-1">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-3 w-36" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 space-y-1.5">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-4/5" />
|
||||
</div>
|
||||
</div>
|
||||
</CommunityGrid>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
import { MuteSettingsInternals, SensitiveContentSection } from '@/components/ContentSettings';
|
||||
import { MuteListRecoveryDialog } from '@/components/MuteListRecoveryDialog';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { IntroImage } from '@/components/IntroImage';
|
||||
import { HelpTip } from '@/components/HelpTip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
|
||||
export function ContentPage() {
|
||||
const { config } = useAppContext();
|
||||
const { user } = useCurrentUser();
|
||||
const [recoveryOpen, setRecoveryOpen] = useState(false);
|
||||
|
||||
useSeoMeta({
|
||||
title: `Content | Settings | ${config.appName}`,
|
||||
description: 'Muted users, hashtags, and sensitive content settings',
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="">
|
||||
{/* Header with back link */}
|
||||
<PageHeader
|
||||
backTo="/settings"
|
||||
alwaysShowBack
|
||||
titleContent={
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-xl font-bold">Content</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Control what you see. Mute users, hashtags, or words, and choose how content warnings are handled. Mutes are encrypted and private.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Lead image — Muted Content */}
|
||||
<div className="flex items-center gap-4 px-7 py-5">
|
||||
<IntroImage src="/mute-intro.png" size="w-28" />
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-base font-semibold">Content Control</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Hide posts from specific users, hashtags, words, or entire threads. All mutes are encrypted and private.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-0">
|
||||
|
||||
{/* Muted Content Section */}
|
||||
<div>
|
||||
<div className="relative px-3 py-3.5 flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold flex items-center gap-1.5">Muted Content <HelpTip faqId="report-content" /></h2>
|
||||
{user && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs gap-1.5 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setRecoveryOpen(true)}
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
Recovery
|
||||
</Button>
|
||||
)}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
|
||||
</div>
|
||||
<div className="pb-4">
|
||||
<MuteSettingsInternals />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user && (
|
||||
<MuteListRecoveryDialog
|
||||
open={recoveryOpen}
|
||||
onOpenChange={setRecoveryOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sensitive Content Section */}
|
||||
<div>
|
||||
<div className="relative px-3 py-3.5">
|
||||
<h2 className="text-base font-semibold flex items-center gap-1.5">Sensitive Content <HelpTip faqId="report-content" /></h2>
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
|
||||
</div>
|
||||
<div className="pb-4">
|
||||
<SensitiveContentSection />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { ContentSettings } from '@/components/ContentSettings';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { HelpTip } from '@/components/HelpTip';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
|
||||
export function ContentSettingsPage() {
|
||||
@@ -13,16 +12,15 @@ export function ContentSettingsPage() {
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="">
|
||||
{/* Header with back link */}
|
||||
<main>
|
||||
<PageHeader
|
||||
backTo="/settings"
|
||||
alwaysShowBack
|
||||
titleContent={
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-xl font-bold flex items-center gap-1.5">Home Feed <HelpTip faqId="fyp" /></h1>
|
||||
<h1 className="text-xl font-bold">Home Feed</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Nostr supports many content types beyond text posts. Customize which appear in your home feed.
|
||||
Choose what appears in your feed, manage saved searches, and hide content you don't want to see.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import { createOrganizationAssociationTags, decodeOrganizationParam } from '@/li
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { unixSecondsInTimezone } from '@/lib/timezone';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { withAgoraTag } from '@/lib/agoraNoteTags';
|
||||
|
||||
function normalizePledgeTag(value: string): string {
|
||||
return value.trim().replace(/^#+/, '').toLowerCase().replace(/\s+/g, '-');
|
||||
@@ -197,7 +198,7 @@ export function CreateActionPage() {
|
||||
]);
|
||||
}
|
||||
|
||||
await createEvent({ kind: 36639, content: trimmedDescription, tags });
|
||||
await createEvent({ kind: 36639, content: trimmedDescription, tags: withAgoraTag(tags) });
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['agora-actions'] });
|
||||
|
||||
+176
-437
@@ -1,10 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import {
|
||||
AlertTriangle,
|
||||
@@ -12,45 +11,38 @@ import {
|
||||
HandHeart,
|
||||
Loader2,
|
||||
MapPin,
|
||||
MessageCircle,
|
||||
Share2,
|
||||
UserPlus,
|
||||
ShieldCheck,
|
||||
Wallet,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { PersonSearch } from '@/components/PersonSearch';
|
||||
import { CoverImageField } from '@/components/CoverImageField';
|
||||
import { FormSection } from '@/components/FormSection';
|
||||
import { OrganizationContextChip } from '@/components/OrganizationContextChip';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { parseAuthorEvent } from '@/hooks/useAuthor';
|
||||
import { useBitcoinWallet } from '@/hooks/useBitcoinWallet';
|
||||
import { useCampaign } from '@/hooks/useCampaign';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useManageableOrganizations } from '@/hooks/useManageableOrganizations';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import type { SearchProfile } from '@/hooks/useSearchProfiles';
|
||||
import { useLayoutOptions } from '@/contexts/LayoutContext';
|
||||
import { formatSats, satsToUSDWhole, usdToSats } from '@/lib/bitcoin';
|
||||
import {
|
||||
CAMPAIGN_KIND,
|
||||
encodeCampaignNaddr,
|
||||
parseCampaign,
|
||||
parseCampaignWallet,
|
||||
slugifyCampaignIdentifier,
|
||||
} from '@/lib/campaign';
|
||||
import { getTodayDateInput } from '@/lib/dateInput';
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { createOrganizationAssociationTags, decodeOrganizationParam } from '@/lib/organizationContext';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { COUNTRIES, searchCountries, searchCountry, type CountryEntry } from '@/lib/countries';
|
||||
import { withAgoraTag } from '@/lib/agoraNoteTags';
|
||||
import { COUNTRIES, searchCountries, type CountryEntry } from '@/lib/countries';
|
||||
import { createCountryIdentifier } from '@/lib/countryIdentifiers';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -60,57 +52,6 @@ interface EditTarget {
|
||||
relays?: string[];
|
||||
}
|
||||
|
||||
/** Canonical origin used in shareable invite / notify links. */
|
||||
function getShareOrigin(): string {
|
||||
return 'https://agora.spot';
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy text to clipboard with a uniform toast reaction. Returns true on
|
||||
* success so the caller can update transient UI state.
|
||||
*/
|
||||
async function copyShareText(
|
||||
text: string,
|
||||
toast: ReturnType<typeof useToast>['toast'],
|
||||
successTitle: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast({ title: successTitle, description: 'Paste it into a DM, email, or text message.' });
|
||||
return true;
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Copy failed',
|
||||
description: 'Your browser blocked clipboard access. Select and copy the text manually.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function shareTextOrCopy(
|
||||
text: string,
|
||||
toast: ReturnType<typeof useToast>['toast'],
|
||||
fallbackSuccessTitle: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
const { Share } = await import('@capacitor/share');
|
||||
await Share.share({ text });
|
||||
return;
|
||||
}
|
||||
|
||||
if (navigator.share) {
|
||||
await navigator.share({ text });
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') return;
|
||||
}
|
||||
|
||||
await copyShareText(text, toast, fallbackSuccessTitle);
|
||||
}
|
||||
|
||||
function getEditTarget(value: string | null): EditTarget | null {
|
||||
if (!value) return null;
|
||||
|
||||
@@ -127,73 +68,39 @@ function getEditTarget(value: string | null): EditTarget | null {
|
||||
}
|
||||
}
|
||||
|
||||
function makeRecipientProfile(pubkey: string): SearchProfile {
|
||||
return {
|
||||
pubkey,
|
||||
metadata: {},
|
||||
event: {
|
||||
id: '',
|
||||
pubkey,
|
||||
created_at: 0,
|
||||
kind: 0,
|
||||
tags: [],
|
||||
content: '{}',
|
||||
sig: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeRecipientProfileFromAuthor(
|
||||
pubkey: string,
|
||||
author: { event?: NostrEvent; metadata?: NostrMetadata } | undefined,
|
||||
): SearchProfile {
|
||||
if (!author?.event) return makeRecipientProfile(pubkey);
|
||||
|
||||
return {
|
||||
pubkey,
|
||||
metadata: author.metadata ?? {},
|
||||
event: author.event,
|
||||
};
|
||||
}
|
||||
|
||||
function formatGoalUsd(goalSats: number | undefined, btcPrice: number | undefined): string {
|
||||
if (!goalSats || !btcPrice) return '';
|
||||
const usd = (goalSats / 100_000_000) * btcPrice;
|
||||
if (!Number.isFinite(usd) || usd <= 0) return '';
|
||||
return usd.toFixed(usd >= 100 ? 0 : 2);
|
||||
}
|
||||
|
||||
function normalizeCampaignTag(value: string): string {
|
||||
return value.trim().replace(/^#+/, '').toLowerCase().replace(/\s+/g, '-');
|
||||
}
|
||||
|
||||
function parseCampaignTagInput(value: string): string[] {
|
||||
const seen = new Set<string>();
|
||||
const tags: string[] = [];
|
||||
for (const part of value.split(',')) {
|
||||
const tag = normalizeCampaignTag(part);
|
||||
if (!tag || seen.has(tag)) continue;
|
||||
seen.add(tag);
|
||||
tags.push(tag);
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
function getExactCountryCode(query: string): string | undefined {
|
||||
const match = searchCountry(query);
|
||||
return match?.exact ? match.country.code : undefined;
|
||||
}
|
||||
|
||||
function formatDateInput(unixSeconds: number | undefined): string {
|
||||
if (!unixSeconds) return '';
|
||||
return new Date(unixSeconds * 1000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a NIP-92 `imeta` tag from a Blossom upload's NIP-94 tag array.
|
||||
*
|
||||
* NIP-94 returns pairs like `[["url", "<url>"], ["x", "<sha256>"], ["m", "image/jpeg"], ...]`.
|
||||
* NIP-92 packs the same key/value pairs into a single space-separated tag:
|
||||
* `["imeta", "url <url>", "x <sha256>", "m image/jpeg", ...]`.
|
||||
*
|
||||
* The `url` entry MUST come first (NIP-92 convention). Other keys are
|
||||
* emitted in their original order.
|
||||
*/
|
||||
function buildImetaFromNip94(nip94Tags: string[][]): string[] {
|
||||
const result: string[] = ['imeta'];
|
||||
// url first
|
||||
const urlPair = nip94Tags.find((t) => t[0] === 'url');
|
||||
if (urlPair && urlPair[1]) result.push(`url ${urlPair[1]}`);
|
||||
for (const [key, value] of nip94Tags) {
|
||||
if (key === 'url') continue;
|
||||
if (typeof key !== 'string' || typeof value !== 'string') continue;
|
||||
if (key.includes(' ')) continue;
|
||||
result.push(`${key} ${value}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function CreateCampaignPage() {
|
||||
useLayoutOptions({ noMaxWidth: true, rightSidebar: null });
|
||||
|
||||
const { user } = useCurrentUser();
|
||||
const { btcPrice } = useBitcoinWallet();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -204,39 +111,28 @@ export function CreateCampaignPage() {
|
||||
const [title, setTitle] = useState('');
|
||||
const [summary, setSummary] = useState('');
|
||||
const [story, setStory] = useState('');
|
||||
const [imageUrl, setImageUrl] = useState('');
|
||||
const [bannerUrl, setBannerUrl] = useState('');
|
||||
/** NIP-94-format tag pairs from the most recent banner upload, used to build the NIP-92 imeta tag on publish. */
|
||||
const [bannerNip94Tags, setBannerNip94Tags] = useState<string[][] | null>(null);
|
||||
const [coverUploading, setCoverUploading] = useState(false);
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
const [walletInput, setWalletInput] = useState('');
|
||||
const [goalUsd, setGoalUsd] = useState('');
|
||||
const [goalTouched, setGoalTouched] = useState(false);
|
||||
const [deadline, setDeadline] = useState('');
|
||||
const [countryQuery, setCountryQuery] = useState('');
|
||||
const [countryCode, setCountryCode] = useState('');
|
||||
const [recipients, setRecipients] = useState<SearchProfile[]>([]);
|
||||
const [organizationATag, setOrganizationATag] = useState('');
|
||||
const [formError, setFormError] = useState('');
|
||||
const [prepopulatedEventId, setPrepopulatedEventId] = useState<string | null>(null);
|
||||
const [prepopulatedGoalEventId, setPrepopulatedGoalEventId] = useState<string | null>(null);
|
||||
|
||||
const editNaddr = searchParams.get('edit');
|
||||
const editTarget = useMemo(() => getEditTarget(editNaddr), [editNaddr]);
|
||||
const isEditMode = !!editNaddr;
|
||||
|
||||
// ── Organization context (implicit) ────────────────────────────────────
|
||||
// `?org=` carries the org coordinate from the entry point — typically
|
||||
// an org detail page CTA. We accept either an `naddr1...` (preferred,
|
||||
// canonical) or a raw `34550:<pubkey>:<d-tag>` coordinate. The form
|
||||
// never exposes a user-editable selector — the campaign is "under the
|
||||
// user" by default, and "under the org" when the user started from
|
||||
// inside that org's page.
|
||||
const orgParam = searchParams.get('org');
|
||||
const orgFromParam = useMemo(() => decodeOrganizationParam(orgParam), [orgParam]);
|
||||
const { data: manageableOrgs, isLoading: manageableOrgsLoading } = useManageableOrganizations();
|
||||
|
||||
// The org we'll actually attach to the published event. We only honor
|
||||
// the param when the current user is the founder or a moderator of
|
||||
// that org — otherwise drop it silently so a stale link can't forge
|
||||
// an org association.
|
||||
const authorizedOrgFromParam = useMemo(() => {
|
||||
if (!orgFromParam || !manageableOrgs) return null;
|
||||
return manageableOrgs.find((entry) => entry.community.aTag === orgFromParam.aTag) ?? null;
|
||||
@@ -257,68 +153,15 @@ export function CreateCampaignPage() {
|
||||
...(editTarget?.relays ? { relays: editTarget.relays } : {}),
|
||||
});
|
||||
const editCampaign = isEditMode ? editCampaignQuery.data : null;
|
||||
const editRecipientPubkeys = useMemo(
|
||||
() => editCampaign?.recipients.map((recipient) => recipient.pubkey) ?? [],
|
||||
[editCampaign],
|
||||
);
|
||||
const editRecipientProfiles = useQuery({
|
||||
queryKey: ['campaign-edit-recipients', editRecipientPubkeys],
|
||||
queryFn: async ({ signal }): Promise<SearchProfile[]> => {
|
||||
const cachedProfiles = new Map<string, SearchProfile>();
|
||||
const missingPubkeys: string[] = [];
|
||||
|
||||
for (const pubkey of editRecipientPubkeys) {
|
||||
const cachedAuthor = queryClient.getQueryData<{ event?: NostrEvent; metadata?: NostrMetadata }>([
|
||||
'author',
|
||||
pubkey,
|
||||
]);
|
||||
|
||||
if (cachedAuthor?.event) {
|
||||
cachedProfiles.set(pubkey, makeRecipientProfileFromAuthor(pubkey, cachedAuthor));
|
||||
} else {
|
||||
missingPubkeys.push(pubkey);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingPubkeys.length > 0) {
|
||||
const events = await nostr.query(
|
||||
[{ kinds: [0], authors: missingPubkeys, limit: missingPubkeys.length }],
|
||||
{ signal },
|
||||
);
|
||||
|
||||
const latestByPubkey = new Map<string, NostrEvent>();
|
||||
for (const event of events) {
|
||||
const existing = latestByPubkey.get(event.pubkey);
|
||||
if (!existing || event.created_at > existing.created_at) {
|
||||
latestByPubkey.set(event.pubkey, event);
|
||||
}
|
||||
}
|
||||
|
||||
for (const pubkey of missingPubkeys) {
|
||||
const event = latestByPubkey.get(pubkey);
|
||||
if (!event) continue;
|
||||
const parsed = parseAuthorEvent(event);
|
||||
queryClient.setQueryData(['author', pubkey], parsed);
|
||||
cachedProfiles.set(pubkey, makeRecipientProfileFromAuthor(pubkey, parsed));
|
||||
}
|
||||
}
|
||||
|
||||
return editRecipientPubkeys.map((pubkey) => cachedProfiles.get(pubkey) ?? makeRecipientProfile(pubkey));
|
||||
},
|
||||
enabled: isEditMode && editRecipientPubkeys.length > 0,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// The slug is protocol plumbing: derive it from the title instead of asking
|
||||
// fundraisers to understand Nostr d-tags.
|
||||
const derivedIdentifier = useMemo(() => slugifyCampaignIdentifier(title), [title]);
|
||||
const activeIdentifier = editCampaign?.identifier ?? derivedIdentifier;
|
||||
const goalSatsPreview = useMemo(() => {
|
||||
const n = Number(goalUsd.replace(/[, $]/g, ''));
|
||||
return usdToSats(n, btcPrice);
|
||||
}, [btcPrice, goalUsd]);
|
||||
const minDeadline = useMemo(() => getTodayDateInput(), []);
|
||||
|
||||
const parsedWallet = useMemo(() => parseCampaignWallet(walletInput), [walletInput]);
|
||||
|
||||
useSeoMeta({
|
||||
title: isEditMode ? 'Edit campaign | Agora' : 'Start a campaign | Agora',
|
||||
description: isEditMode ? 'Update your fundraising campaign on Agora.' : 'Launch a fundraising campaign on Agora.',
|
||||
@@ -330,17 +173,17 @@ export function CreateCampaignPage() {
|
||||
setTitle(editCampaign.title);
|
||||
setSummary(editCampaign.summary);
|
||||
setStory(editCampaign.story);
|
||||
setImageUrl(editCampaign.image ?? '');
|
||||
setTagInput(editCampaign.tags.join(', '));
|
||||
setBannerUrl(editCampaign.banner ?? '');
|
||||
// We don't have NIP-94 tags for an existing event — the imeta is
|
||||
// already on the event. We'll re-emit it from the original event
|
||||
// tags below if the URL is unchanged.
|
||||
setBannerNip94Tags(null);
|
||||
setWalletInput(editCampaign.wallet.value);
|
||||
setGoalUsd(editCampaign.goalUsd !== undefined ? String(editCampaign.goalUsd) : '');
|
||||
setDeadline(formatDateInput(editCampaign.deadline));
|
||||
const editCountryCode = editCampaign.countryCode ?? getExactCountryCode(editCampaign.location ?? '') ?? '';
|
||||
const editCountryCode = editCampaign.countryCode ?? '';
|
||||
setCountryCode(editCountryCode);
|
||||
setCountryQuery(editCountryCode ? COUNTRIES[editCountryCode]?.name ?? editCountryCode : '');
|
||||
setRecipients(editCampaign.recipients.map((recipient) => makeRecipientProfile(recipient.pubkey)));
|
||||
// Restore the organization root-scope tag (uppercase `A`) so the
|
||||
// selector hydrates with the same org the campaign was originally
|
||||
// attached to. We accept the tag as-is; the publish branch verifies
|
||||
// the current user is still authorized to publish under that org.
|
||||
const existingOrgATag = editCampaign.event.tags.find(
|
||||
([n, v]) => n === 'A' && typeof v === 'string' && v.startsWith('34550:'),
|
||||
)?.[1] ?? '';
|
||||
@@ -348,47 +191,6 @@ export function CreateCampaignPage() {
|
||||
setPrepopulatedEventId(editCampaign.event.id);
|
||||
}, [editCampaign, prepopulatedEventId]);
|
||||
|
||||
useEffect(() => {
|
||||
const profiles = editRecipientProfiles.data;
|
||||
if (!profiles || profiles.length === 0) return;
|
||||
|
||||
setRecipients((prev) => prev.map((recipient) => {
|
||||
const profile = profiles.find((item) => item.pubkey === recipient.pubkey);
|
||||
return profile ?? recipient;
|
||||
}));
|
||||
}, [editRecipientProfiles.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editCampaign || prepopulatedGoalEventId === editCampaign.event.id || goalUsd.trim()) return;
|
||||
|
||||
const formattedGoal = formatGoalUsd(editCampaign.goalSats, btcPrice);
|
||||
if (!formattedGoal) return;
|
||||
|
||||
setGoalUsd(formattedGoal);
|
||||
setPrepopulatedGoalEventId(editCampaign.event.id);
|
||||
}, [btcPrice, editCampaign, goalUsd, prepopulatedGoalEventId]);
|
||||
|
||||
const addRecipient = (profile: SearchProfile) => {
|
||||
setRecipients((prev) => prev.some((r) => r.pubkey === profile.pubkey) ? prev : [...prev, profile]);
|
||||
};
|
||||
|
||||
const addRecipients = (profiles: SearchProfile[]) => {
|
||||
setRecipients((prev) => {
|
||||
const seen = new Set(prev.map((r) => r.pubkey));
|
||||
const next = [...prev];
|
||||
for (const profile of profiles) {
|
||||
if (seen.has(profile.pubkey)) continue;
|
||||
seen.add(profile.pubkey);
|
||||
next.push(profile);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const removeRecipient = (pubkey: string) => {
|
||||
setRecipients((prev) => prev.filter((r) => r.pubkey !== pubkey));
|
||||
};
|
||||
|
||||
const submitMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!user) throw new Error('You must be logged in to create a campaign.');
|
||||
@@ -405,34 +207,23 @@ export function CreateCampaignPage() {
|
||||
throw new Error('Identifier must be lowercase letters, numbers, and hyphens.');
|
||||
}
|
||||
|
||||
// Validate recipients.
|
||||
const parsedRecipients: { pubkey: string }[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const r of recipients) {
|
||||
const pubkey = r.pubkey;
|
||||
if (seen.has(pubkey)) continue;
|
||||
seen.add(pubkey);
|
||||
parsedRecipients.push({ pubkey });
|
||||
// Validate wallet — required.
|
||||
const wallet = parseCampaignWallet(walletInput);
|
||||
if (!wallet) {
|
||||
throw new Error(
|
||||
'Wallet endpoint is required. Provide a Bitcoin mainnet address (bc1q… / bc1p…) or a silent-payment code (sp1…).',
|
||||
);
|
||||
}
|
||||
|
||||
if (parsedRecipients.length === 0) {
|
||||
throw new Error('Add at least one recipient.');
|
||||
}
|
||||
|
||||
// Goal / deadline.
|
||||
// In edit mode, preserve the exact stored sats unless the user changed the field.
|
||||
// Goal — integer USD (no unit, no currency conversion).
|
||||
let goalNum: number | undefined;
|
||||
if (isEditMode && !goalTouched) {
|
||||
goalNum = editCampaign?.goalSats;
|
||||
} else if (goalUsd.trim()) {
|
||||
const n = Number(goalUsd.replace(/[, $]/g, ''));
|
||||
if (!Number.isFinite(n) || n <= 0) {
|
||||
throw new Error('Goal must be a positive USD amount.');
|
||||
}
|
||||
goalNum = usdToSats(n, btcPrice);
|
||||
if (goalNum <= 0) {
|
||||
throw new Error('Bitcoin price is unavailable. Try again before setting a USD goal.');
|
||||
const trimmedGoal = goalUsd.replace(/[, $]/g, '').trim();
|
||||
if (trimmedGoal) {
|
||||
const n = Number(trimmedGoal);
|
||||
if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) {
|
||||
throw new Error('Goal must be a positive whole-dollar amount.');
|
||||
}
|
||||
goalNum = n;
|
||||
}
|
||||
|
||||
let deadlineNum: number | undefined;
|
||||
@@ -447,8 +238,7 @@ export function CreateCampaignPage() {
|
||||
deadlineNum = ts;
|
||||
}
|
||||
|
||||
const resolvedCountryCode = countryCode || getExactCountryCode(countryQuery);
|
||||
const campaignTags = parseCampaignTagInput(tagInput);
|
||||
const resolvedCountryCode = countryCode;
|
||||
|
||||
let prev: NostrEvent | null = null;
|
||||
if (isEditMode) {
|
||||
@@ -474,46 +264,62 @@ export function CreateCampaignPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate image URL (must be https).
|
||||
const trimmedImageUrl = imageUrl.trim();
|
||||
const sanitizedImage = trimmedImageUrl ? sanitizeUrl(trimmedImageUrl) : undefined;
|
||||
if (trimmedImageUrl && !sanitizedImage) {
|
||||
throw new Error('Cover image must be a valid https:// URL.');
|
||||
// Validate banner URL (must be https).
|
||||
const trimmedBanner = bannerUrl.trim();
|
||||
const sanitizedBanner = trimmedBanner ? sanitizeUrl(trimmedBanner) : undefined;
|
||||
if (trimmedBanner && !sanitizedBanner) {
|
||||
throw new Error('Banner must be a valid https:// URL.');
|
||||
}
|
||||
|
||||
const tags: string[][] = [
|
||||
['d', slug],
|
||||
['title', trimmedTitle],
|
||||
['alt', `Fundraising campaign: ${trimmedTitle}`],
|
||||
];
|
||||
if (summary.trim()) tags.splice(2, 0, ['summary', summary.trim()]);
|
||||
for (const tag of campaignTags) tags.push(['t', tag]);
|
||||
if (sanitizedImage) tags.push(['image', sanitizedImage]);
|
||||
if (summary.trim()) tags.push(['summary', summary.trim()]);
|
||||
if (sanitizedBanner) {
|
||||
tags.push(['banner', sanitizedBanner]);
|
||||
// NIP-92 imeta pairs with the banner. Two sources, in priority order:
|
||||
// 1. A fresh upload during this session — convert the NIP-94 tag
|
||||
// array from the uploader into the NIP-92 space-separated form.
|
||||
// 2. The existing event's imeta tag — re-emit it verbatim when the
|
||||
// URL hasn't changed during an edit.
|
||||
const imeta = (() => {
|
||||
if (bannerNip94Tags) {
|
||||
const url = bannerNip94Tags.find((t) => t[0] === 'url')?.[1];
|
||||
if (url === sanitizedBanner) {
|
||||
return buildImetaFromNip94(bannerNip94Tags);
|
||||
}
|
||||
}
|
||||
if (isEditMode && editCampaign?.banner === sanitizedBanner) {
|
||||
const existing = editCampaign.event.tags.find(([n]) => n === 'imeta');
|
||||
if (existing) return existing;
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
if (imeta) tags.push(imeta);
|
||||
}
|
||||
tags.push(['alt', `Fundraising campaign: ${trimmedTitle}`]);
|
||||
tags.push(['w', wallet.value]);
|
||||
if (goalNum !== undefined) tags.push(['goal', String(goalNum)]);
|
||||
if (deadlineNum !== undefined) tags.push(['deadline', String(deadlineNum)]);
|
||||
if (resolvedCountryCode) {
|
||||
tags.push(['i', createCountryIdentifier(resolvedCountryCode)]);
|
||||
tags.push(['k', 'iso3166']);
|
||||
tags.push(['k', 'iso3166-1']);
|
||||
}
|
||||
// Organization association (NIP-22 root-scope convention): an
|
||||
// uppercase `A` tag points at the NIP-72 community definition so
|
||||
// the campaign surfaces as official activity on that org's page.
|
||||
// The `K` companion tag records the referenced kind, and `P` hints
|
||||
// at the org founder for clients that batch-resolve authors.
|
||||
const publishOrganizationATag = isEditMode
|
||||
? authorizedOrgForAttachedATag?.community.aTag ?? ''
|
||||
: organizationATag;
|
||||
if (publishOrganizationATag) {
|
||||
tags.push(...createOrganizationAssociationTags(publishOrganizationATag));
|
||||
}
|
||||
for (const r of parsedRecipients) {
|
||||
tags.push(['p', r.pubkey]);
|
||||
}
|
||||
|
||||
const published = await publishEvent({
|
||||
kind: CAMPAIGN_KIND,
|
||||
content: story,
|
||||
tags,
|
||||
tags: withAgoraTag(tags),
|
||||
prev: prev ?? undefined,
|
||||
});
|
||||
|
||||
@@ -661,7 +467,7 @@ export function CreateCampaignPage() {
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-card/50 p-2">
|
||||
{/* Title & identifier */}
|
||||
{/* Title */}
|
||||
<FormSection title="Title" requirement="Required">
|
||||
<Input
|
||||
value={title}
|
||||
@@ -670,13 +476,24 @@ export function CreateCampaignPage() {
|
||||
maxLength={200}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground flex items-baseline gap-1 min-w-0">
|
||||
<span className="shrink-0">URL preview:</span>
|
||||
<span className="font-mono text-foreground truncate min-w-0">
|
||||
/{activeIdentifier || 'your-campaign-title'}{!isEditMode && derivedIdentifier.length >= 64 && '...'}
|
||||
</span>
|
||||
{isEditMode && <span className="shrink-0">(kept from original)</span>}
|
||||
</p>
|
||||
</FormSection>
|
||||
|
||||
{/* Wallet (required) */}
|
||||
<FormSection title="Bitcoin wallet" requirement="Required">
|
||||
<div className="relative">
|
||||
<Wallet className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="campaign-wallet"
|
||||
value={walletInput}
|
||||
onChange={(e) => setWalletInput(e.target.value.trim())}
|
||||
placeholder="bc1p… or sp1…"
|
||||
className="pl-9 font-mono text-xs"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
/>
|
||||
</div>
|
||||
<WalletHint walletInput={walletInput} parsed={parsedWallet} />
|
||||
</FormSection>
|
||||
|
||||
{/* Country */}
|
||||
@@ -702,76 +519,27 @@ export function CreateCampaignPage() {
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
{/* Tags */}
|
||||
<FormSection title="Tags" requirement="Recommended">
|
||||
<Input
|
||||
id="campaign-tags"
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
placeholder="human rights, legal defense, independent media"
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
{/* Recipients */}
|
||||
<FormSection
|
||||
title="Beneficiaries"
|
||||
requirement="Required"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<PersonSearch
|
||||
onAdd={addRecipient}
|
||||
onAddMany={addRecipients}
|
||||
excludePubkeys={recipients.map((r) => r.pubkey)}
|
||||
/>
|
||||
|
||||
{/* "Recipient not here yet?" invite affordance. Always visible
|
||||
because even campaigns with existing recipients may have one
|
||||
beneficiary who's still off-Nostr. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const url = `${getShareOrigin()}/receive`;
|
||||
const message = `I want to create a fundraiser for you on Agora! Sign up to create your account and start receiving donations directly to your Bitcoin wallet: ${url}`;
|
||||
void shareTextOrCopy(message, toast, 'Invite copied');
|
||||
}}
|
||||
className="w-full flex items-center justify-center gap-2 rounded-lg border border-dashed border-border bg-background px-3 py-2.5 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted/40 motion-safe:transition-colors"
|
||||
>
|
||||
<UserPlus className="size-4" />
|
||||
Recipient not here yet? Invite them
|
||||
<Share2 className="size-3.5 opacity-70" />
|
||||
</button>
|
||||
|
||||
{recipients.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Beneficiaries ({recipients.length})
|
||||
</Label>
|
||||
<div className="space-y-1.5">
|
||||
{recipients.map((recipient) => (
|
||||
<RecipientRow
|
||||
key={recipient.pubkey}
|
||||
profile={recipient}
|
||||
campaignTitle={title}
|
||||
onRemove={() => removeRecipient(recipient.pubkey)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* Cover image */}
|
||||
<FormSection title="Cover image" requirement="Optional">
|
||||
{/* Banner image */}
|
||||
<FormSection title="Banner image" requirement="Recommended">
|
||||
<CoverImageField
|
||||
value={imageUrl}
|
||||
onChange={setImageUrl}
|
||||
value={bannerUrl}
|
||||
onChange={(url) => {
|
||||
setBannerUrl(url);
|
||||
// Discard stale NIP-94 tags whenever the URL changes — a manual
|
||||
// paste or template pick won't carry matching metadata.
|
||||
setBannerNip94Tags(null);
|
||||
}}
|
||||
onUploadingChange={setCoverUploading}
|
||||
onUploadComplete={(nip94Tags) => {
|
||||
// Capture the NIP-94 tag array so we can convert it into a
|
||||
// NIP-92 imeta tag at publish time.
|
||||
setBannerNip94Tags(nip94Tags);
|
||||
}}
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
{/* Summary */}
|
||||
<FormSection title="Summary" requirement="Optional">
|
||||
<FormSection title="Summary" requirement="Recommended">
|
||||
<Textarea
|
||||
id="campaign-summary"
|
||||
value={summary}
|
||||
@@ -795,7 +563,7 @@ export function CreateCampaignPage() {
|
||||
</FormSection>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{/* Goal */}
|
||||
{/* Goal — integer USD */}
|
||||
<FormSection title="Goal" requirement="Optional">
|
||||
<div className="relative">
|
||||
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
|
||||
@@ -804,35 +572,19 @@ export function CreateCampaignPage() {
|
||||
<Input
|
||||
id="campaign-goal"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
placeholder="100,000"
|
||||
inputMode="numeric"
|
||||
placeholder="25,000"
|
||||
value={goalUsd}
|
||||
onChange={(e) => {
|
||||
setGoalUsd(e.target.value);
|
||||
setGoalTouched(true);
|
||||
}}
|
||||
onChange={(e) => setGoalUsd(e.target.value)}
|
||||
className="pl-7 pr-14"
|
||||
/>
|
||||
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs font-medium text-muted-foreground">
|
||||
USD
|
||||
</span>
|
||||
</div>
|
||||
{isEditMode && editCampaign?.goalSats && !goalTouched && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Current saved goal: {formatSats(editCampaign.goalSats)} sats
|
||||
{btcPrice
|
||||
? <> — about {satsToUSDWhole(editCampaign.goalSats, btcPrice)} today</>
|
||||
: null
|
||||
}. Only edit this field if you want to change the goal.
|
||||
</p>
|
||||
)}
|
||||
{(!isEditMode || goalTouched) && goalSatsPreview > 0 && btcPrice && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Your goal will be saved as {formatSats(goalSatsPreview)} sats — about{' '}
|
||||
{satsToUSDWhole(goalSatsPreview, btcPrice)} today.
|
||||
The dollar estimate may change with Bitcoin's price.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Whole US Dollars. Donors pay in Bitcoin; clients estimate the USD-equivalent at view time.
|
||||
</p>
|
||||
</FormSection>
|
||||
|
||||
{/* Deadline */}
|
||||
@@ -866,7 +618,7 @@ export function CreateCampaignPage() {
|
||||
) : coverUploading ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Uploading cover…
|
||||
Uploading banner…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -883,6 +635,48 @@ export function CreateCampaignPage() {
|
||||
|
||||
// ─── Layout helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function WalletHint({
|
||||
walletInput,
|
||||
parsed,
|
||||
}: {
|
||||
walletInput: string;
|
||||
parsed: ReturnType<typeof parseCampaignWallet>;
|
||||
}) {
|
||||
const trimmed = walletInput.trim();
|
||||
if (!trimmed) {
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use a mainnet bech32 address (<span className="font-mono">bc1q…</span> /{' '}
|
||||
<span className="font-mono">bc1p…</span>) for a public, traceable campaign,
|
||||
or a BIP-352 silent-payment code (<span className="font-mono">sp1…</span>)
|
||||
for a private campaign where totals are hidden.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (!parsed) {
|
||||
return (
|
||||
<p className="text-xs text-destructive">
|
||||
Not a recognized mainnet wallet endpoint. Provide a <span className="font-mono">bc1q…</span>,{' '}
|
||||
<span className="font-mono">bc1p…</span>, or <span className="font-mono">sp1…</span> string.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (parsed.mode === 'onchain') {
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground inline-flex items-center gap-1.5">
|
||||
<Wallet className="size-3.5" />
|
||||
Public on-chain address. Donations are traceable; the campaign page shows progress and totals.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground inline-flex items-center gap-1.5">
|
||||
<ShieldCheck className="size-3.5" />
|
||||
Silent payment. Donations are unlinkable by design; totals will not be shown to anyone.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function CountrySelect({
|
||||
query,
|
||||
selectedCode,
|
||||
@@ -989,66 +783,11 @@ function CountrySelect({
|
||||
|
||||
{selectedCountry && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Publishes <span className="font-mono text-foreground">i: iso3166:{selectedCode}</span> for country sorting.
|
||||
Publishes <span className="font-mono text-foreground">i: iso3166-1:{selectedCode}</span> for country sorting.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecipientRow({
|
||||
profile,
|
||||
campaignTitle,
|
||||
onRemove,
|
||||
}: {
|
||||
profile: SearchProfile;
|
||||
campaignTitle: string;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const displayName = profile.metadata.display_name || profile.metadata.name || genUserName(profile.pubkey);
|
||||
const picture = sanitizeUrl(profile.metadata.picture);
|
||||
|
||||
const handleNotify = () => {
|
||||
const url = `${getShareOrigin()}/claim`;
|
||||
const titleClause = campaignTitle.trim() ? ` called "${campaignTitle.trim()}"` : '';
|
||||
const message = `I just started a fundraiser for you on Agora${titleClause}! Sign in here for more info and to claim your donations: ${url}`;
|
||||
void shareTextOrCopy(message, toast, `Message for ${displayName} copied`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg bg-secondary/30 p-2.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="size-8 shrink-0">
|
||||
{picture && <AvatarImage src={picture} alt="" />}
|
||||
<AvatarFallback className="text-xs">{displayName.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{displayName}</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleNotify}
|
||||
className="h-8 shrink-0 gap-1.5 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<MessageCircle className="size-3.5" />
|
||||
Notify
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onRemove}
|
||||
aria-label={`Remove ${displayName}`}
|
||||
className="shrink-0"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateCampaignPage;
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { withAgoraTag } from '@/lib/agoraNoteTags';
|
||||
|
||||
/**
|
||||
* Convert text into a URL-safe slug for the NIP-72 community's d-tag.
|
||||
@@ -401,7 +402,7 @@ export function CreateCommunityPage() {
|
||||
const created = await publishEvent({
|
||||
kind: COMMUNITY_DEFINITION_KIND,
|
||||
content: '',
|
||||
tags,
|
||||
tags: withAgoraTag(tags),
|
||||
});
|
||||
|
||||
return { event: created, slug, edited: false };
|
||||
|
||||
@@ -26,6 +26,7 @@ import { getTodayDateInput } from '@/lib/dateInput';
|
||||
import { createOrganizationAssociationTags, decodeOrganizationParam } from '@/lib/organizationContext';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { unixSecondsInTimezone } from '@/lib/timezone';
|
||||
import { withAgoraTag } from '@/lib/agoraNoteTags';
|
||||
|
||||
function slugify(text: string): string {
|
||||
return text
|
||||
@@ -164,7 +165,7 @@ export function CreateEventPage() {
|
||||
const publishedEvent = await publishEvent({
|
||||
kind,
|
||||
content: trimmedDescription,
|
||||
tags,
|
||||
tags: withAgoraTag(tags),
|
||||
});
|
||||
|
||||
const eventCoord = `${kind}:${user.pubkey}:${dTag}`;
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
|
||||
import { Feed } from '@/components/Feed';
|
||||
import { useLayoutOptions } from '@/contexts/LayoutContext';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
|
||||
/** `/discover` — the Agora activity feed. */
|
||||
export function DiscoverPage() {
|
||||
const { config } = useAppContext();
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
useLayoutOptions({ showFAB: true, fabKind: 1, hasSubHeader: !!user });
|
||||
|
||||
useSeoMeta({
|
||||
title: `Discover | ${config.appName}`,
|
||||
description: 'Campaigns, pledges, donations, and conversations happening on Agora.',
|
||||
});
|
||||
|
||||
return <Feed />;
|
||||
}
|
||||
|
||||
export default DiscoverPage;
|
||||
@@ -23,12 +23,10 @@ import {
|
||||
BookContentHeader,
|
||||
CountryContentHeader,
|
||||
} from '@/components/ExternalContentHeader';
|
||||
import { PrecipitationEffect } from '@/components/PrecipitationEffect';
|
||||
import { parseExternalUri, headerLabel, seoTitle, type ExternalContent } from '@/lib/externalContent';
|
||||
import { ratingToStars } from '@/lib/bookstr';
|
||||
import { formatNumber } from '@/lib/formatNumber';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useWeather, getPrecipitation } from '@/hooks/useWeather';
|
||||
import { useComments } from '@/hooks/useComments';
|
||||
import { usePaginatedFeed } from '@/hooks/usePaginatedFeed';
|
||||
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll';
|
||||
@@ -361,11 +359,6 @@ export function ExternalContentPage() {
|
||||
// FAB opens the comment compose dialog
|
||||
const [composeOpen, setComposeOpen] = useState(false);
|
||||
const openCompose = useCallback(() => setComposeOpen(true), []);
|
||||
const { data: weather } = useWeather(countryCode);
|
||||
const precipitation = useMemo(() => {
|
||||
if (!weather) return null;
|
||||
return getPrecipitation(weather.weatherCode);
|
||||
}, [weather]);
|
||||
|
||||
useLayoutOptions({
|
||||
showFAB: true,
|
||||
@@ -378,11 +371,6 @@ export function ExternalContentPage() {
|
||||
|
||||
return (
|
||||
<main className="">
|
||||
{/* Precipitation overlay for country pages */}
|
||||
{precipitation?.type && (
|
||||
<PrecipitationEffect type={precipitation.type} intensity={precipitation.intensity} />
|
||||
)}
|
||||
|
||||
{/* Non-sticky transparent header — skipped on country pages because
|
||||
the country hero carries its own back arrow overlaid on the
|
||||
photo, which lets the cinematic banner reach all the way to the
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { IntroImage } from '@/components/IntroImage';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
|
||||
export function MagicSettingsPage() {
|
||||
const { config, updateConfig } = useAppContext();
|
||||
|
||||
useSeoMeta({
|
||||
title: `Magic | Settings | ${config.appName}`,
|
||||
description: 'Magical cursor effects and enchanted interface settings',
|
||||
});
|
||||
|
||||
function toggleMagicMouse(checked: boolean) {
|
||||
updateConfig((c) => ({ ...c, magicMouse: checked }));
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="">
|
||||
{/* Header with back link */}
|
||||
<PageHeader
|
||||
backTo="/settings"
|
||||
alwaysShowBack
|
||||
titleContent={
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-xl font-bold">Magic</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Harness the mystical energies of your device. Imbue your cursor with elemental fire.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="p-4">
|
||||
{/* Intro */}
|
||||
<div className="flex items-center gap-4 px-3 pt-2 pb-6">
|
||||
<IntroImage src="/magic-intro.png" />
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold">Arcane Configuration</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Harness the mystical energies of your device. Imbue your cursor with elemental fire and make every interaction feel enchanted.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ornament */}
|
||||
<div className="flex items-center gap-3 px-2 pb-5">
|
||||
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-primary/40 to-primary/60" />
|
||||
<span className="text-primary/50 text-xs tracking-[0.3em] select-none">✦</span>
|
||||
<div className="h-px flex-1 bg-gradient-to-l from-transparent via-primary/40 to-primary/60" />
|
||||
</div>
|
||||
|
||||
{/* Magic Mouse toggle */}
|
||||
<div
|
||||
className="flex items-start gap-4 rounded-xl px-4 py-4 transition-colors hover:bg-muted/40"
|
||||
style={{ background: config.magicMouse ? 'radial-gradient(ellipse 120% 80% at 50% 50%, hsl(var(--primary) / 0.07), transparent)' : undefined }}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Label htmlFor="magic-mouse-toggle" className="text-sm font-semibold cursor-pointer flex items-center gap-1.5">
|
||||
Magic Mouse
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Your cursor (or finger on touch devices) will emanate magical fire in the glow of your primary color. Move with purpose — every path you trace becomes a trail of flame.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="magic-mouse-toggle"
|
||||
checked={config.magicMouse}
|
||||
onCheckedChange={toggleMagicMouse}
|
||||
className="mt-0.5 shrink-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bottom ornament */}
|
||||
<div className="flex items-center gap-3 px-2 pt-6 pb-4">
|
||||
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-primary/20 to-primary/30" />
|
||||
<span className="text-primary/30 text-[10px] tracking-[0.4em] select-none">◆</span>
|
||||
<div className="h-px flex-1 bg-gradient-to-l from-transparent via-primary/20 to-primary/30" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { Navigate } from 'react-router-dom';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { RelayListManager } from '@/components/RelayListManager';
|
||||
import { BlossomSettings } from '@/components/BlossomSettings';
|
||||
import { IntroImage } from '@/components/IntroImage';
|
||||
import { HelpTip } from '@/components/HelpTip';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
@@ -41,14 +40,11 @@ export function NetworkSettingsPage() {
|
||||
|
||||
<div className="p-4">
|
||||
{/* Intro */}
|
||||
<div className="flex items-center gap-4 px-3 pt-2 pb-4">
|
||||
<IntroImage src="/relay-intro.png" />
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold">Network Connections</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Manage your relay connections. Relays are servers that store and distribute Nostr events across the network.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-3 pt-2 pb-4">
|
||||
<h2 className="text-sm font-semibold">Network Connections</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Manage your relay connections. Relays are servers that store and distribute Nostr events across the network.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Relays */}
|
||||
|
||||
@@ -37,6 +37,7 @@ const CustomNipCard = lazy(() => import("@/components/CustomNipCard").then(m =>
|
||||
import { FileMetadataContent } from "@/components/FileMetadataContent";
|
||||
import { FollowPackContent } from "@/components/FollowPackContent";
|
||||
import { GoalCard } from "@/components/GoalCard";
|
||||
import { DetailCommentComposer } from "@/components/DetailCommentComposer";
|
||||
import { FollowPackDetailContent } from "@/components/FollowPackDetailContent";
|
||||
import { FoundLogContent } from "@/components/FoundLogContent";
|
||||
import { GeocacheContent } from "@/components/GeocacheContent";
|
||||
@@ -51,7 +52,6 @@ import { LiveStreamPage } from "@/components/LiveStreamPage";
|
||||
import { MagicDeckContent } from "@/components/MagicDeckContent";
|
||||
import { MusicDetailContent } from "@/components/MusicDetailContent";
|
||||
import { ActivityCard, EventActionHeader, NoteCard } from "@/components/NoteCard";
|
||||
import { FeedCard } from "@/components/FeedCard";
|
||||
import { publishedAtAction } from "@/lib/publishedAtAction";
|
||||
import { NoteContent } from "@/components/NoteContent";
|
||||
import { NsiteCard } from "@/components/NsiteCard";
|
||||
@@ -437,20 +437,18 @@ function ProfileBadgesDetailView({ event }: { event: NostrEvent }) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FeedCard>
|
||||
<NoteCard event={event} />
|
||||
</FeedCard>
|
||||
<NoteCard event={event} />
|
||||
<div className="pb-16 sidebar:pb-0">
|
||||
{commentsLoading ? (
|
||||
<FeedCard className="mt-4 divide-y divide-border">
|
||||
<div className="mt-4 divide-y divide-border">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<ReplyCardSkeleton key={i} />
|
||||
))}
|
||||
</FeedCard>
|
||||
</div>
|
||||
) : orderedReplies.length > 0 ? (
|
||||
<FeedCard className="mt-4">
|
||||
<div className="mt-4">
|
||||
<FlatThreadedReplyList replies={orderedReplies} />
|
||||
</FeedCard>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-12 text-center text-muted-foreground text-sm">
|
||||
No replies yet. Be the first to reply!
|
||||
@@ -1398,12 +1396,9 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
|
||||
return (
|
||||
<CommunityModerationContext.Provider value={communityModContext}>
|
||||
<div>
|
||||
{/* Focused post card — ancestor previews, ancestor thread, and the
|
||||
focused event itself share one rounded surface so the page
|
||||
reads as "thread context → this post" instead of an
|
||||
edge-to-edge Twitter timeline. Replies sit in their own
|
||||
FeedCard below. */}
|
||||
<FeedCard>
|
||||
{/* Focused post — ancestor previews, ancestor thread, and the
|
||||
focused event render edge-to-edge to match the Activity feed
|
||||
and the Search results. */}
|
||||
{/* Content preview for kind 1111 comments: external content, profile, or community */}
|
||||
{externalIdentifier && (
|
||||
<ExternalContentPreview identifier={externalIdentifier} />
|
||||
@@ -2072,25 +2067,53 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
|
||||
/>
|
||||
</article>
|
||||
)}
|
||||
</FeedCard>
|
||||
|
||||
{/* Replies */}
|
||||
<div className="pb-16 sidebar:pb-0">
|
||||
<div className="mt-6 pb-16 sidebar:pb-0">
|
||||
<div className="flex items-baseline justify-between gap-3 mb-3 px-1">
|
||||
<h2 className="text-lg font-semibold tracking-tight">
|
||||
{isKind1 ? 'Replies' : 'Comments'}
|
||||
</h2>
|
||||
{replyTree.length > 0 ? (
|
||||
<span className="text-sm text-muted-foreground tabular-nums">
|
||||
{formatNumber(replyTree.length)}{' '}
|
||||
{replyTree.length === 1
|
||||
? isKind1 ? 'reply' : 'comment'
|
||||
: isKind1 ? 'replies' : 'comments'}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DetailCommentComposer
|
||||
event={event}
|
||||
className="mb-3"
|
||||
placeholder={isKind1 ? 'Write a reply...' : "What's on your mind?"}
|
||||
/>
|
||||
|
||||
{repliesLoading ? (
|
||||
<FeedCard className="mt-4 divide-y divide-border">
|
||||
<div className="rounded-2xl bg-card border border-border/60 shadow-sm overflow-hidden divide-y divide-border">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<ReplyCardSkeleton key={i} />
|
||||
))}
|
||||
</FeedCard>
|
||||
) : replyTree.length > 0 ? (
|
||||
<FeedCard className="mt-4">
|
||||
<ThreadedReplyList roots={replyTree} />
|
||||
</FeedCard>
|
||||
) : !parentEventId ? (
|
||||
<div className="py-12 text-center text-muted-foreground text-sm">
|
||||
No replies yet. Be the first to reply!
|
||||
</div>
|
||||
) : null}
|
||||
) : replyTree.length > 0 ? (
|
||||
<div className="rounded-2xl bg-card border border-border/60 shadow-sm overflow-hidden">
|
||||
<ThreadedReplyList roots={replyTree} />
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReplyOpen(true)}
|
||||
className="block w-full rounded-2xl border border-dashed border-border/80 bg-card/50 px-6 py-10 text-center hover:bg-card hover:border-primary/40 transition-colors"
|
||||
>
|
||||
<p className="text-base font-medium text-foreground">
|
||||
No {isKind1 ? 'replies' : 'comments'} yet
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Be the first to {isKind1 ? 'reply' : 'start the discussion'}.
|
||||
</p>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CommunityModerationContext.Provider>
|
||||
@@ -2272,11 +2295,11 @@ export function PostDetailSkeleton() {
|
||||
</div>
|
||||
|
||||
{/* Replies skeleton */}
|
||||
<FeedCard className="mt-4 divide-y divide-border">
|
||||
<div className="mt-4 divide-y divide-border">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<ReplyCardSkeleton key={i} />
|
||||
))}
|
||||
</FeedCard>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import { ProfileCard } from '@/components/ProfileCard';
|
||||
import { ProfileRightSidebar } from '@/components/ProfileRightSidebar';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { IntroImage } from '@/components/IntroImage';
|
||||
import { HelpTip } from '@/components/HelpTip';
|
||||
import { ImageCropDialog } from '@/components/ImageCropDialog';
|
||||
import { SortableList, SortableItem } from '@/components/SortableList';
|
||||
@@ -701,14 +700,11 @@ export function ProfileSettings() {
|
||||
<form id="profile-settings-form" onSubmit={form.handleSubmit(onSubmit)} className="max-w-xl mx-auto px-4 pb-10 space-y-6">
|
||||
|
||||
{/* Intro */}
|
||||
<div className="flex items-center gap-4 px-3 pt-2 pb-2">
|
||||
<IntroImage src="/profile-intro.png" />
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold">Your Identity</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Tap any field on the card to edit. Click your avatar or banner to upload and crop a new image.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-3 pt-2 pb-2">
|
||||
<h2 className="text-sm font-semibold">Your Identity</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Tap any field on the card to edit. Click your avatar or banner to upload and crop a new image.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Interactive profile card */}
|
||||
|
||||
+63
-202
@@ -4,11 +4,7 @@ import {
|
||||
SlidersHorizontal,
|
||||
Search as SearchIcon,
|
||||
UserRoundCheck,
|
||||
User,
|
||||
RotateCcw,
|
||||
BookmarkPlus,
|
||||
Check,
|
||||
Loader2,
|
||||
Globe, Users, UserSearch,
|
||||
Clock, Flame, TrendingUp,
|
||||
} from 'lucide-react';
|
||||
@@ -16,7 +12,6 @@ import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { FeedCard } from '@/components/FeedCard';
|
||||
import { PullToRefresh } from '@/components/PullToRefresh';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -35,10 +30,6 @@ import { KindPicker, AuthorChip, AuthorFilterDropdown } from '@/components/Saved
|
||||
import { useSearchProfiles } from '@/hooks/useSearchProfiles';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useStreamPosts } from '@/hooks/useStreamPosts';
|
||||
import { useSavedFeeds } from '@/hooks/useSavedFeeds';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useProfileTabs } from '@/hooks/useProfileTabs';
|
||||
import { usePublishProfileTabs } from '@/hooks/usePublishProfileTabs';
|
||||
import { useFollowList } from '@/hooks/useFollowActions';
|
||||
import { useUserLists, useMatchedListId } from '@/hooks/useUserLists';
|
||||
import { useFollowPacks } from '@/hooks/useFollowPacks';
|
||||
@@ -50,18 +41,20 @@ import { VerifiedNip05Text } from '@/components/Nip05Badge';
|
||||
import { SubHeaderBar } from '@/components/SubHeaderBar';
|
||||
import { TabButton } from '@/components/TabButton';
|
||||
import { cn, parseKindFilter } from '@/lib/utils';
|
||||
import type { TabFilter } from '@/contexts/AppContext';
|
||||
import { useLayoutOptions, useNavHidden } from '@/contexts/LayoutContext';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { isRepostKind, parseRepostContent } from '@/lib/feedUtils';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
type TabType = 'communities' | 'posts' | 'accounts';
|
||||
type TabType = 'agora' | 'posts' | 'accounts';
|
||||
|
||||
const VALID_TABS: TabType[] = ['communities', 'posts', 'accounts'];
|
||||
const VALID_TABS: TabType[] = ['agora', 'posts', 'accounts'];
|
||||
|
||||
function parseTab(value: string | null): TabType {
|
||||
return VALID_TABS.includes(value as TabType) ? (value as TabType) : 'communities';
|
||||
// Back-compat: ?tab=communities and ?tab=activity used to be the default tab;
|
||||
// alias them both to agora.
|
||||
if (value === 'communities' || value === 'activity') return 'agora';
|
||||
return VALID_TABS.includes(value as TabType) ? (value as TabType) : 'agora';
|
||||
}
|
||||
|
||||
const VALID_AUTHOR_SCOPES = ['anyone', 'follows', 'people'] as const;
|
||||
@@ -75,7 +68,7 @@ const DEFAULT_FILTERS = {
|
||||
mediaType: 'all' as const,
|
||||
language: 'global',
|
||||
platform: 'nostr' as const,
|
||||
kindFilter: 'all',
|
||||
kindFilter: 'agora',
|
||||
customKindText: '',
|
||||
authorScope: 'anyone' as AuthorScope,
|
||||
sort: 'recent' as SortPref,
|
||||
@@ -198,7 +191,7 @@ export function SearchPage() {
|
||||
const setActiveTab = useCallback((tab: TabType) => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (tab === 'communities') {
|
||||
if (tab === 'agora') {
|
||||
next.delete('tab');
|
||||
} else {
|
||||
next.set('tab', tab);
|
||||
@@ -262,15 +255,18 @@ export function SearchPage() {
|
||||
const allKindNumbers = useMemo(() => kindOptions.map((o) => Number(o.value)), [kindOptions]);
|
||||
|
||||
// Resolve kindsOverride from the current kind filter state.
|
||||
// "all" means every kind in the picker list, not undefined (which would let
|
||||
// useStreamPosts fall back to only the user's enabled feed-settings kinds).
|
||||
// "all" means every kind in the picker list, not undefined (which would
|
||||
// let useStreamPosts fall back to only the user's enabled feed-settings
|
||||
// kinds). "agora" expands to the curated Agora preset set.
|
||||
const kindsOverride = useMemo<number[]>(
|
||||
() => kindFilter === 'all' ? allKindNumbers : (parseKindFilter(kindFilter, customKindText) ?? allKindNumbers),
|
||||
[kindFilter, customKindText, allKindNumbers],
|
||||
);
|
||||
|
||||
// Detect kind + media type conflict: a specific kind is selected AND a media type is set
|
||||
const hasKindMediaConflict = kindFilter !== 'all' && kindsOverride.length > 0 && mediaType !== 'all';
|
||||
// Detect kind + media type conflict: a non-broad kind is selected AND a
|
||||
// media type is set. "all" and "agora" are both broad selections that
|
||||
// don't conflict with media filters.
|
||||
const hasKindMediaConflict = kindFilter !== 'all' && kindFilter !== 'agora' && kindsOverride.length > 0 && mediaType !== 'all';
|
||||
|
||||
// Determine if any filter differs from the default
|
||||
const hasActiveFilters = !includeReplies || mediaType !== DEFAULT_FILTERS.mediaType ||
|
||||
@@ -302,7 +298,7 @@ export function SearchPage() {
|
||||
: ['protocol:nostr'];
|
||||
if (debouncedSearchQuery.trim()) parts.push(debouncedSearchQuery.trim());
|
||||
if (language !== 'global') parts.push(`language:${language}`);
|
||||
const isDedicatedKindQuery = kindFilter === 'all' && (mediaType === 'vines' || mediaType === 'images' || mediaType === 'videos');
|
||||
const isDedicatedKindQuery = (kindFilter === 'all' || kindFilter === 'agora') && (mediaType === 'vines' || mediaType === 'images' || mediaType === 'videos');
|
||||
if (!isDedicatedKindQuery && !hasKindMediaConflict) {
|
||||
if (mediaType === 'images') { parts.push('media:true'); parts.push('video:false'); }
|
||||
else if (mediaType === 'videos') parts.push('video:true');
|
||||
@@ -321,7 +317,13 @@ export function SearchPage() {
|
||||
if (language !== 'global') labels.push(language.toUpperCase());
|
||||
if (platform !== 'nostr') labels.push({ activitypub: 'Mastodon', atproto: 'Bluesky' }[platform] ?? platform);
|
||||
if (sort !== 'recent') labels.push(sort === 'hot' ? 'Hot' : 'Trending');
|
||||
if (kindFilter !== 'all' && kindFilter !== 'custom') {
|
||||
if (kindFilter === 'agora') {
|
||||
// 'agora' is the default — no chip needed.
|
||||
} else if (kindFilter === 'all') {
|
||||
labels.push('All kinds');
|
||||
} else if (kindFilter === 'custom') {
|
||||
if (customKindText) labels.push(`Kind: ${customKindText}`);
|
||||
} else {
|
||||
const kindValues = kindFilter.split(',').filter(Boolean);
|
||||
if (kindValues.length === 1) {
|
||||
const opt = kindOptions.find(o => o.value === kindValues[0]);
|
||||
@@ -330,8 +332,6 @@ export function SearchPage() {
|
||||
} else if (kindValues.length > 1) {
|
||||
labels.push(`${kindValues.length} kinds`);
|
||||
}
|
||||
} else if (kindFilter === 'custom' && customKindText) {
|
||||
labels.push(`Kind: ${customKindText}`);
|
||||
}
|
||||
if (authorScope === 'follows') labels.push('My follows');
|
||||
if (authorScope === 'people' && authorPubkeys.length > 0) labels.push(`${authorPubkeys.length} author${authorPubkeys.length > 1 ? 's' : ''}`);
|
||||
@@ -339,61 +339,13 @@ export function SearchPage() {
|
||||
}, [includeReplies, mediaType, language, platform, sort, kindFilter, customKindText, authorScope, authorPubkeys, kindOptions]);
|
||||
|
||||
// Hooks
|
||||
const { user } = useCurrentUser();
|
||||
const { data: followData } = useFollowList();
|
||||
const followPubkeys = useMemo(() => followData?.pubkeys ?? [], [followData?.pubkeys]);
|
||||
const { lists } = useUserLists();
|
||||
const { data: followPacks = [] } = useFollowPacks();
|
||||
const { savedFeeds, addSavedFeed, isPending: isSavingFeed } = useSavedFeeds();
|
||||
const profileTabsQuery = useProfileTabs(user?.pubkey);
|
||||
const { publishProfileTabs, isPending: isPublishingTabs } = usePublishProfileTabs();
|
||||
const [savePopoverOpen, setSavePopoverOpen] = useState(false);
|
||||
const [saveFeedLabel, setSaveFeedLabel] = useState('');
|
||||
const [savedJustNow, setSavedJustNow] = useState(false);
|
||||
|
||||
const listPickerValue = useMatchedListId(authorPubkeys);
|
||||
|
||||
// 'people' scope with explicit authors = user-specific; not eligible for profile tab
|
||||
const isAuthorSpecific = authorScope === 'people' && authorPubkeys.length > 0;
|
||||
|
||||
// Build a standard NIP-01 TabFilter from the current search state
|
||||
const currentFilter = useMemo<TabFilter>(() => {
|
||||
const filter: TabFilter = {};
|
||||
if (debouncedSearchQuery.trim()) filter.search = debouncedSearchQuery.trim();
|
||||
if (kindsOverride && kindsOverride.length > 0) filter.kinds = kindsOverride;
|
||||
if (authorScope === 'people' && authorPubkeys.length > 0) filter.authors = authorPubkeys;
|
||||
return filter;
|
||||
}, [debouncedSearchQuery, kindsOverride, authorScope, authorPubkeys]);
|
||||
|
||||
const alreadySaved = savedFeeds.some(
|
||||
(f) => JSON.stringify(f.filter) === JSON.stringify(currentFilter),
|
||||
);
|
||||
|
||||
const handleSaveFeed = async () => {
|
||||
if (!saveFeedLabel.trim() || isSavingFeed) return;
|
||||
const varsToSave = authorScope === 'follows' && user
|
||||
? [{ name: '$follows', tagName: 'p', pointer: `a:3:${user.pubkey}:` }]
|
||||
: [];
|
||||
await addSavedFeed(saveFeedLabel, currentFilter, varsToSave);
|
||||
setSavePopoverOpen(false);
|
||||
setSaveFeedLabel('');
|
||||
setSavedJustNow(true);
|
||||
setTimeout(() => setSavedJustNow(false), 2000);
|
||||
};
|
||||
|
||||
const handleSaveProfileTab = async () => {
|
||||
if (!saveFeedLabel.trim() || isPublishingTabs || !user) return;
|
||||
const existing = profileTabsQuery.data ?? { tabs: [], vars: [] };
|
||||
await publishProfileTabs({
|
||||
tabs: [...existing.tabs, { label: saveFeedLabel.trim(), filter: currentFilter }],
|
||||
vars: existing.vars,
|
||||
});
|
||||
setSavePopoverOpen(false);
|
||||
setSaveFeedLabel('');
|
||||
setSavedJustNow(true);
|
||||
setTimeout(() => setSavedJustNow(false), 2000);
|
||||
};
|
||||
|
||||
// Resolve author pubkeys for the stream
|
||||
const streamAuthorPubkeys = authorScope === 'follows'
|
||||
? followPubkeys
|
||||
@@ -421,8 +373,8 @@ export function SearchPage() {
|
||||
<main className="flex-1 min-w-0">
|
||||
<PageHeader title="Search" icon={<SearchIcon className="size-5" />} />
|
||||
<SubHeaderBar>
|
||||
<TabButton label="Communities" active={activeTab === 'communities'} onClick={() => setActiveTab('communities')} />
|
||||
<TabButton label="Posts" active={activeTab === 'posts'} onClick={() => setActiveTab('posts')} />
|
||||
<TabButton label="Agora" active={activeTab === 'agora'} onClick={() => setActiveTab('agora')} />
|
||||
<TabButton label="Nostr" active={activeTab === 'posts'} onClick={() => setActiveTab('posts')} />
|
||||
<TabButton label="Accounts" active={activeTab === 'accounts'} onClick={() => setActiveTab('accounts')} />
|
||||
</SubHeaderBar>
|
||||
|
||||
@@ -434,81 +386,8 @@ export function SearchPage() {
|
||||
onDebouncedChange={setDebouncedSearchQuery}
|
||||
/>
|
||||
|
||||
{/* Add to feed button (posts & communities tabs) */}
|
||||
{(activeTab === 'posts' || activeTab === 'communities') && user && (
|
||||
<div className={cn(!debouncedSearchQuery.trim() && !hasActiveFilters ? 'hidden' : undefined)}>
|
||||
<Popover open={savePopoverOpen} onOpenChange={(o) => {
|
||||
setSavePopoverOpen(o);
|
||||
if (o && !saveFeedLabel) {
|
||||
if (debouncedSearchQuery.trim()) {
|
||||
setSaveFeedLabel(debouncedSearchQuery.trim());
|
||||
} else if (listPickerValue) {
|
||||
const matched =
|
||||
listPickerValue.startsWith('set:')
|
||||
? lists.find((l) => l.id === listPickerValue.slice(4))?.title
|
||||
: followPacks.find((p) => p.id === listPickerValue.slice(5))?.title;
|
||||
if (matched) setSaveFeedLabel(matched);
|
||||
}
|
||||
}
|
||||
}}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'shrink-0 h-10 w-10 rounded-lg border flex items-center justify-center transition-colors',
|
||||
alreadySaved || savedJustNow
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border bg-secondary/50 hover:bg-secondary text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
style={{ outline: 'none' }}
|
||||
aria-label="Add to feed"
|
||||
>
|
||||
{savedJustNow ? <Check className="size-4" /> : <BookmarkPlus className="size-4" />}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-64 p-3 space-y-3">
|
||||
<p className="font-semibold text-sm">Save as tab</p>
|
||||
|
||||
{alreadySaved ? (
|
||||
<p className="text-sm text-muted-foreground">Already saved.</p>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
placeholder="Tab name…"
|
||||
value={saveFeedLabel}
|
||||
onChange={(e) => setSaveFeedLabel(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSaveFeed(); }}
|
||||
className="bg-secondary/50 border-border focus-visible:ring-1 text-base md:text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<SaveDestinationRow
|
||||
icon={<BookmarkPlus className="size-4 text-muted-foreground" />}
|
||||
label="Home feed"
|
||||
description="Tab on your home page"
|
||||
onClick={() => handleSaveFeed()}
|
||||
disabled={!saveFeedLabel.trim() || isSavingFeed || isPublishingTabs}
|
||||
loading={isSavingFeed}
|
||||
/>
|
||||
{!isAuthorSpecific && (
|
||||
<SaveDestinationRow
|
||||
icon={<User className="size-4 text-muted-foreground" />}
|
||||
label="Profile tab"
|
||||
description="Your posts matching this search"
|
||||
onClick={() => handleSaveProfileTab()}
|
||||
disabled={!saveFeedLabel.trim() || isSavingFeed || isPublishingTabs}
|
||||
loading={isPublishingTabs}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter popover (posts & communities tabs) */}
|
||||
{(activeTab === 'posts' || activeTab === 'communities') && (
|
||||
{/* Filter popover (posts & agora tabs) */}
|
||||
{(activeTab === 'posts' || activeTab === 'agora') && (
|
||||
<Popover open={filtersOpen} onOpenChange={setFiltersOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
@@ -629,7 +508,7 @@ export function SearchPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Posts-only filters (hidden on communities tab) */}
|
||||
{/* Posts-only filters (hidden on agora tab) */}
|
||||
{activeTab === 'posts' && (
|
||||
<>
|
||||
<Separator />
|
||||
@@ -715,8 +594,8 @@ export function SearchPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Active filter summary chips (posts & communities tabs) */}
|
||||
{(activeTab === 'posts' || activeTab === 'communities') && activeFilterLabels.length > 0 && (
|
||||
{/* Active filter summary chips (posts & agora tabs) */}
|
||||
{(activeTab === 'posts' || activeTab === 'agora') && activeFilterLabels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{activeFilterLabels.map((label) => (
|
||||
<Badge key={label} variant="secondary" className="text-xs font-normal">
|
||||
@@ -732,8 +611,8 @@ export function SearchPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* NIP-50 search query debug block (posts & communities tabs) */}
|
||||
{(activeTab === 'posts' || activeTab === 'communities') && debouncedSearchQuery.trim() && (
|
||||
{/* NIP-50 search query debug block (posts & agora tabs) */}
|
||||
{(activeTab === 'posts' || activeTab === 'agora') && debouncedSearchQuery.trim() && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -753,9 +632,9 @@ export function SearchPage() {
|
||||
</div>
|
||||
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
{/* ─── Communities Tab ─── */}
|
||||
{activeTab === 'communities' && (
|
||||
<CommunitiesSearchTab
|
||||
{/* ─── Agora Tab ─── */}
|
||||
{activeTab === 'agora' && (
|
||||
<AgoraSearchTab
|
||||
searchQuery={debouncedSearchQuery}
|
||||
includeReplies={includeReplies}
|
||||
mediaType={mediaType}
|
||||
@@ -797,13 +676,13 @@ export function SearchPage() {
|
||||
)}
|
||||
{/* Post results — stream */}
|
||||
{postsLoading && posts.length === 0 ? (
|
||||
<FeedCard className="mt-2 divide-y divide-border">
|
||||
<div className="mt-2 divide-y divide-border">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<PostSkeleton key={i} />
|
||||
))}
|
||||
</FeedCard>
|
||||
</div>
|
||||
) : posts.length > 0 ? (
|
||||
<FeedCard className="mt-2">
|
||||
<div className="mt-2">
|
||||
{posts.map((event) => {
|
||||
const isNew = flushedIds.has(event.id);
|
||||
if (isRepostKind(event.kind)) {
|
||||
@@ -815,15 +694,15 @@ export function SearchPage() {
|
||||
}
|
||||
return <NoteCard key={event.id} event={event} highlight={isNew} />;
|
||||
})}
|
||||
</FeedCard>
|
||||
</div>
|
||||
) : debouncedSearchQuery.trim() ? (
|
||||
<EmptyState
|
||||
message="No posts found matching your search."
|
||||
message="No results found matching your search."
|
||||
activeFilters={activeFilterLabels}
|
||||
onResetFilters={hasActiveFilters ? resetFilters : undefined}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState message="Enter a search query to find posts." />
|
||||
<EmptyState message="Enter a search query to find Nostr content." />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -834,17 +713,17 @@ export function SearchPage() {
|
||||
<div>
|
||||
{debouncedSearchQuery.trim() ? (
|
||||
profilesLoading ? (
|
||||
<FeedCard className="mt-2 divide-y divide-border">
|
||||
<div className="mt-2 divide-y divide-border">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<AccountSkeleton key={i} />
|
||||
))}
|
||||
</FeedCard>
|
||||
</div>
|
||||
) : profiles && profiles.length > 0 ? (
|
||||
<FeedCard className="mt-2 divide-y divide-border">
|
||||
<div className="mt-2 divide-y divide-border">
|
||||
{profiles.map((profile) => (
|
||||
<AccountItem key={profile.pubkey} profile={profile} isFollowed={followedPubkeys.has(profile.pubkey)} />
|
||||
))}
|
||||
</FeedCard>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState message="No accounts found matching your search." />
|
||||
)
|
||||
@@ -930,7 +809,7 @@ function FollowsList() {
|
||||
}
|
||||
|
||||
return (
|
||||
<FeedCard className="mt-2 divide-y divide-border">
|
||||
<div className="mt-2 divide-y divide-border">
|
||||
{visiblePubkeys.map((pubkey) => (
|
||||
<FollowItem key={pubkey} pubkey={pubkey} />
|
||||
))}
|
||||
@@ -941,7 +820,7 @@ function FollowsList() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</FeedCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1112,8 +991,12 @@ function SearchInput({
|
||||
);
|
||||
}
|
||||
|
||||
/** Communities tab — isolated component so useStreamPosts only subscribes when active. */
|
||||
function CommunitiesSearchTab({
|
||||
/** Agora tab — isolated component so useStreamPosts only subscribes when active.
|
||||
* Pins kinds to [33863 Campaigns, 36639 Pledges, 34550 Groups/Communities] —
|
||||
* the non-kind-1 Agora content stream — and narrows to events whose NIP-89
|
||||
* `client` tag is the running app name (e.g. "Agora"). Kind 1 posts and the
|
||||
* unconstrained Nostr firehose live on the Nostr tab instead. */
|
||||
function AgoraSearchTab({
|
||||
searchQuery,
|
||||
includeReplies,
|
||||
mediaType,
|
||||
@@ -1136,71 +1019,49 @@ function CommunitiesSearchTab({
|
||||
hasActiveFilters: boolean;
|
||||
resetFilters: () => void;
|
||||
}) {
|
||||
const { config } = useAppContext();
|
||||
const clientName = config.clientName ?? config.appName;
|
||||
const { posts, isLoading: postsLoading } = useStreamPosts(searchQuery, {
|
||||
includeReplies,
|
||||
mediaType,
|
||||
language,
|
||||
protocols,
|
||||
kindsOverride: [34550],
|
||||
kindsOverride: [33863, 36639, 34550],
|
||||
authorPubkeys,
|
||||
sort,
|
||||
clientName,
|
||||
});
|
||||
|
||||
if (postsLoading && posts.length === 0) {
|
||||
return (
|
||||
<FeedCard className="mt-2 divide-y divide-border">
|
||||
<div className="mt-2 divide-y divide-border">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<PostSkeleton key={i} />
|
||||
))}
|
||||
</FeedCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (posts.length > 0) {
|
||||
return (
|
||||
<FeedCard className="mt-2">
|
||||
<div className="mt-2">
|
||||
{posts.map((event) => (
|
||||
<NoteCard key={event.id} event={event} />
|
||||
))}
|
||||
</FeedCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
return (
|
||||
<EmptyState
|
||||
message="No communities found matching your search."
|
||||
message="No Agora campaigns, pledges, or groups found matching your search."
|
||||
activeFilters={activeFilterLabels}
|
||||
onResetFilters={hasActiveFilters ? resetFilters : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <EmptyState message="Search for communities or browse the latest." />;
|
||||
}
|
||||
|
||||
function SaveDestinationRow({
|
||||
icon, label, description, onClick, disabled, loading,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
description: string;
|
||||
onClick: () => void;
|
||||
disabled: boolean;
|
||||
loading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-secondary/60 disabled:opacity-40 disabled:pointer-events-none transition-colors text-left"
|
||||
>
|
||||
<span className="shrink-0">{loading ? <Loader2 className="size-4 animate-spin text-muted-foreground" /> : icon}</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-sm font-medium">{label}</span>
|
||||
<span className="block text-xs text-muted-foreground">{description}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
return <EmptyState message="Search Agora campaigns, pledges, and groups, or browse the latest." />;
|
||||
}
|
||||
|
||||
|
||||
+33
-142
@@ -1,13 +1,11 @@
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { lazy, Suspense, useState, useEffect, useRef } from 'react';
|
||||
import { lazy, Suspense, useState } from 'react';
|
||||
import { ChevronRight, Settings } from 'lucide-react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { IntroImage } from '@/components/IntroImage';
|
||||
import { useLayoutOptions } from '@/contexts/LayoutContext';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
import { isAdmin } from '@/lib/admins';
|
||||
|
||||
const RequestToVanishDialog = lazy(() => import('@/components/RequestToVanishDialog').then(m => ({ default: m.RequestToVanishDialog })));
|
||||
@@ -16,7 +14,6 @@ interface SettingsSection {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
illustration?: string;
|
||||
path: string;
|
||||
requiresAuth?: boolean;
|
||||
/** When true, only shown to platform admins (see `isAdmin` in `@/lib/admins`). */
|
||||
@@ -27,67 +24,46 @@ const settingsSections: SettingsSection[] = [
|
||||
{
|
||||
id: 'profile',
|
||||
label: 'Profile',
|
||||
description: 'Edit your display name, bio, and avatar',
|
||||
illustration: '/profile-intro.png',
|
||||
description: 'Display name, bio, avatar, and verification.',
|
||||
path: '/settings/profile',
|
||||
requiresAuth: true,
|
||||
},
|
||||
{
|
||||
id: 'appearance',
|
||||
label: 'Appearance',
|
||||
description: 'Switch between system, light, and dark mode',
|
||||
illustration: '/theme-intro.png',
|
||||
description: 'System, light, or dark mode.',
|
||||
path: '/settings/appearance',
|
||||
},
|
||||
{
|
||||
id: 'feed',
|
||||
label: 'Home Feed',
|
||||
description: 'Choose what types of posts appear in your home feed',
|
||||
illustration: '/community-intro.png',
|
||||
description: 'Choose which post types appear in your home feed, and manage muted users, hashtags, and sensitive content.',
|
||||
path: '/settings/feed',
|
||||
},
|
||||
{
|
||||
id: 'content',
|
||||
label: 'Content',
|
||||
description: 'Muted users, hashtags, and sensitive content settings',
|
||||
illustration: '/mute-intro.png',
|
||||
path: '/settings/content',
|
||||
},
|
||||
{
|
||||
id: 'network',
|
||||
label: 'Network',
|
||||
description: 'Relays and file upload servers',
|
||||
illustration: '/relay-intro.png',
|
||||
description: 'Relays and file upload servers.',
|
||||
path: '/settings/network',
|
||||
requiresAuth: true,
|
||||
},
|
||||
{
|
||||
id: 'notifications',
|
||||
label: 'Notifications',
|
||||
description: 'Configure push notification preferences',
|
||||
illustration: '/notification-intro.png',
|
||||
description: 'Push notification preferences.',
|
||||
path: '/settings/notifications',
|
||||
requiresAuth: true,
|
||||
},
|
||||
{
|
||||
id: 'advanced',
|
||||
label: 'Advanced',
|
||||
description: 'Wallet, system, and power user settings',
|
||||
illustration: '/advanced-intro.png',
|
||||
description: 'Wallet, system, and power-user options.',
|
||||
path: '/settings/advanced',
|
||||
},
|
||||
{
|
||||
id: 'magic',
|
||||
label: 'Magic',
|
||||
description: 'Enchanted cursor effects and mystical interface powers',
|
||||
illustration: '/magic-intro.png',
|
||||
path: '/settings/magic',
|
||||
},
|
||||
{
|
||||
id: 'organizers',
|
||||
label: 'Organizers',
|
||||
description: 'Appoint country organizers who can pin posts to country feeds',
|
||||
illustration: '/community-intro.png',
|
||||
description: 'Appoint country organizers who can pin posts to country feeds.',
|
||||
path: '/organizers',
|
||||
requiresAuth: true,
|
||||
requiresAdmin: true,
|
||||
@@ -96,20 +72,10 @@ const settingsSections: SettingsSection[] = [
|
||||
|
||||
export function SettingsPage() {
|
||||
const { user } = useCurrentUser();
|
||||
const { config, updateConfig } = useAppContext();
|
||||
const { config } = useAppContext();
|
||||
const navigate = useNavigate();
|
||||
const [sigilFlash, setSigilFlash] = useState(false);
|
||||
const [sigilVisible, setSigilVisible] = useState(false);
|
||||
const [deleteAccountOpen, setDeleteAccountOpen] = useState(false);
|
||||
const inactivityTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (config.magicMouse) return;
|
||||
inactivityTimer.current = setTimeout(() => setSigilVisible(true), 2 * 60 * 1000);
|
||||
return () => {
|
||||
if (inactivityTimer.current) clearTimeout(inactivityTimer.current);
|
||||
};
|
||||
}, [config.magicMouse]);
|
||||
useLayoutOptions({});
|
||||
|
||||
useSeoMeta({
|
||||
@@ -117,86 +83,48 @@ export function SettingsPage() {
|
||||
description: `Manage your ${config.appName} settings`,
|
||||
});
|
||||
|
||||
// Magic section only appears once unlocked; admin-only sections gate by isAdmin.
|
||||
const visibleSections = settingsSections.filter((section) => {
|
||||
if (section.requiresAuth && !user) return false;
|
||||
if (section.requiresAdmin && !isAdmin(user?.pubkey)) return false;
|
||||
if (section.id === 'magic' && !config.magicMouse) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
function unlockMagic() {
|
||||
if (config.magicMouse) {
|
||||
navigate('/settings/magic');
|
||||
return;
|
||||
}
|
||||
setSigilFlash(true);
|
||||
setTimeout(() => setSigilFlash(false), 1000);
|
||||
updateConfig((c) => ({ ...c, magicMouse: true }));
|
||||
toast({
|
||||
title: '✨ Magical potential unlocked',
|
||||
description: 'You have awakened the arcane. Your cursor now burns with enchanted fire.',
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="relative min-h-screen pb-16 sidebar:pb-0">
|
||||
{/* Page header */}
|
||||
<main className="min-h-screen pb-16 sidebar:pb-0">
|
||||
<PageHeader title="Settings" icon={<Settings className="size-5" />} backTo="/" />
|
||||
|
||||
{/* Codex heading + exposition */}
|
||||
<div className="px-7 pb-4 pt-4 text-center space-y-2.5">
|
||||
<p className="text-xs text-muted-foreground leading-relaxed select-none">
|
||||
Shape your identity, tune your feed, and manage how you connect to the Nostr network.<br />Everything you need to make this place feel like yours.
|
||||
</p>
|
||||
<p className="text-[10px] tracking-[0.5em] uppercase text-primary/60 select-none pt-6">Codex of Configuration</p>
|
||||
</div>
|
||||
|
||||
{/* Tome ornament */}
|
||||
<div className="flex items-center gap-3 px-6 pb-5">
|
||||
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-primary/40 to-primary/60" />
|
||||
<span className="text-primary/50 text-xs tracking-[0.3em] select-none">✦</span>
|
||||
<div className="h-px flex-1 bg-gradient-to-l from-transparent via-primary/40 to-primary/60" />
|
||||
</div>
|
||||
|
||||
{/* Settings menu */}
|
||||
<div className="px-4">
|
||||
{visibleSections.map((section, i) => {
|
||||
return (
|
||||
<div key={section.id}>
|
||||
<div
|
||||
className="flex items-center gap-4 px-3 py-2 my-1 cursor-pointer rounded-xl transition-colors hover:bg-muted/60 active:bg-muted/80 group"
|
||||
{/* Settings list */}
|
||||
<nav aria-label="Settings" className="px-4 sm:px-6 pt-2">
|
||||
<ul className="divide-y divide-border">
|
||||
{visibleSections.map((section) => (
|
||||
<li key={section.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(section.path)}
|
||||
className="flex w-full items-center gap-4 px-2 py-4 text-left transition-colors hover:bg-muted/40 focus-visible:bg-muted/40 focus-visible:outline-none"
|
||||
>
|
||||
<div className="flex items-center justify-center size-20 shrink-0">
|
||||
{section.illustration && (
|
||||
<IntroImage src={section.illustration} size="w-22" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold">{section.label}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{section.description}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="size-4 text-primary/40 shrink-0 group-hover:text-primary/70 transition-colors" strokeWidth={4} />
|
||||
</div>
|
||||
{i < visibleSections.length - 1 && (
|
||||
<div className="mx-6 h-px bg-primary/10" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ChevronRight className="size-4 text-muted-foreground shrink-0" aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* Delete Account */}
|
||||
{/* Delete account */}
|
||||
{user && (
|
||||
<div className="flex justify-center pt-4 pb-1">
|
||||
<div className="flex justify-center pt-8 pb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteAccountOpen(true)}
|
||||
className="text-xs text-destructive-foreground bg-destructive/80 hover:bg-destructive rounded-full px-4 py-1.5 transition-colors"
|
||||
className="text-xs font-medium text-destructive hover:underline focus-visible:underline focus-visible:outline-none"
|
||||
>
|
||||
Delete Account
|
||||
Delete account
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -207,50 +135,13 @@ export function SettingsPage() {
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{/* Bottom ornament */}
|
||||
<div className="flex items-center gap-3 px-6 pt-4 pb-2">
|
||||
<div className="h-px flex-1 bg-gradient-to-r from-transparent via-primary/20 to-primary/30" />
|
||||
<span className="text-primary/30 text-[10px] tracking-[0.4em] select-none">◆</span>
|
||||
<div className="h-px flex-1 bg-gradient-to-l from-transparent via-primary/20 to-primary/30" />
|
||||
</div>
|
||||
|
||||
{/* Version footer */}
|
||||
<Link to="/changelog" className="block text-center text-[11px] text-muted-foreground/50 hover:text-muted-foreground transition-colors select-none pt-1 pb-2">
|
||||
<Link
|
||||
to="/changelog"
|
||||
className="block text-center text-[11px] text-muted-foreground/70 hover:text-muted-foreground transition-colors pt-2 pb-4"
|
||||
>
|
||||
v{import.meta.env.VERSION}{import.meta.env.COMMIT_TAG ? '' : '+'} ({new Date(import.meta.env.BUILD_DATE).toLocaleDateString()})
|
||||
</Link>
|
||||
|
||||
{/* Magic sigil — appears after 2 min inactivity, only when magic is locked */}
|
||||
{!config.magicMouse && sigilVisible && (<div className="flex justify-center pt-16 pb-12">
|
||||
<button
|
||||
onClick={unlockMagic}
|
||||
className="relative group focus:outline-none"
|
||||
aria-label={config.magicMouse ? 'Open Magic settings' : 'Unlock magical potential'}
|
||||
>
|
||||
{/* Ambient radial glow pool — tight, close to the image */}
|
||||
<div
|
||||
className="absolute inset-0 rounded-full blur-xl animate-pulse-slow"
|
||||
style={{
|
||||
background: 'radial-gradient(circle, hsl(var(--primary) / 0.2), transparent 60%)',
|
||||
opacity: sigilFlash ? 1 : undefined,
|
||||
transform: sigilFlash ? 'scale(1.5)' : undefined,
|
||||
transition: 'opacity 0.8s, transform 0.8s',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={!sigilFlash && !config.magicMouse ? 'animate-sigil-glow' : undefined}
|
||||
style={sigilFlash || config.magicMouse ? {
|
||||
opacity: sigilFlash ? 1 : 0.55,
|
||||
filter: sigilFlash
|
||||
? 'drop-shadow(0 0 20px hsl(var(--primary))) drop-shadow(0 0 40px hsl(var(--primary) / 0.4))'
|
||||
: 'drop-shadow(0 0 8px hsl(var(--primary) / 0.7))',
|
||||
transform: sigilFlash ? 'scale(1.12)' : 'scale(1)',
|
||||
transition: 'opacity 0.8s, filter 0.8s, transform 0.5s',
|
||||
} : undefined}
|
||||
>
|
||||
<IntroImage src="/magic-intro.png" size="w-72" />
|
||||
</div>
|
||||
</button>
|
||||
</div>)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { IntroImage } from '@/components/IntroImage';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
@@ -234,14 +233,11 @@ export function UserListsPage() {
|
||||
|
||||
<div className="p-4">
|
||||
{/* Intro block */}
|
||||
<div className="flex items-center gap-4 px-3 pt-2 pb-4">
|
||||
<IntroImage src="/lists-intro.png" />
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold">Lists</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Group people into named lists. Use any list as the source for a custom feed, or filter searches by list members.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-3 pt-2 pb-4">
|
||||
<h2 className="text-sm font-semibold">Lists</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Group people into named lists. Use any list as the source for a custom feed, or filter searches by list members.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Create new list */}
|
||||
|
||||
@@ -107,7 +107,6 @@ export function TestApp({ children }: TestAppProps) {
|
||||
faviconUrl: "https://ditto.pub/api/favicon/{hostname}",
|
||||
linkPreviewUrl: "https://ditto.pub/api/link-preview/{url}",
|
||||
corsProxy: "https://proxy.shakespeare.diy/?url={href}",
|
||||
magicMouse: false,
|
||||
contentWarningPolicy: "blur",
|
||||
sentryDsn: "",
|
||||
sentryEnabled: false,
|
||||
|
||||
Reference in New Issue
Block a user