740fc1c63c
Pulls in 387 commits from ditto/main while preserving Agora-specific features. Where the two codebases diverged on the same concept, kept the Agora side per project direction. Kept Agora-specific: - SparkWallet stack (over Ditto's nostr-derived Bitcoin wallet) - Communities (NIP-72 + chat + members), Messages, Organizers, Actions, Verified, Appearance settings - DMProviderWrapper, country/organizer moderation in NoteMoreMenu - 'Agora' branding, pub.agora.app bundle ID, version 2.8.0 - Built-in theme system (src/themes.ts) only Rejected from Ditto: - All Blobbi virtual pet code (80+ files, route, provider, sidebar, kind labels, feed setting, NIP.md entries, CSS animations) - Custom theme events (kinds 36767/16767) — ThemesPage, ThemeContent, active profile themes, theme snapshot recovery - On-chain zaps (kind 8333) and the entire Bitcoin wallet implementation (useBitcoinWallet, bitcoin-signers, BitcoinContentHeader, bitcoinjs-lib / @bitcoinerlab/secp256k1 / ecpair / tiny-secp256k1) - ZapSuccessScreen (depended on dropped bitcoin lib) Pulled in from Ditto: - .agents/skills/* (12 new specialized skills, slim AGENTS.md) - @nostrify bumps to 0.52 / 0.6 / 0.37 - New routes/pages: Music, Podcasts, Videos, Vines, Wikipedia, Books, Bluesky, Archive, AIChat, Trends, Webxdc, Highlights, Decks, Emojis, Development, Treasures, Colors, Packs - Birdstar feed integration (kinds 2473, 12473, 30621) - Wikipedia/Wikidata/Scryfall lookup in ExternalContentPage - release-notes CI job + extract-release-notes.mjs script - nsite:// URI handling in feed/sidebar - iOS fastlane setup - src/lib/avatarShape.ts + Avatar shape prop (kept for new Music/People components that depend on it) Preserved Agora's ABSOLUTE 'NEVER COMMIT' rule at the top of AGENTS.md and dropped Ditto's contradicting 'Commit at the end of every task' section. Validation: npm run test passes (tsc, eslint, 40/40 vitest, vite build).
228 lines
7.3 KiB
TypeScript
228 lines
7.3 KiB
TypeScript
import { useState } from 'react';
|
|
import { Target } from 'lucide-react';
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from '@/components/ui/dialog';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { ImageUploadField } from '@/components/ImageUploadField';
|
|
import { useAppContext } from '@/hooks/useAppContext';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { getEffectiveRelays } from '@/lib/appRelays';
|
|
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
import { ZAP_GOAL_KIND } from '@/lib/goalUtils';
|
|
|
|
interface CreateGoalDialogProps {
|
|
/** The community `a` tag coordinate (e.g. `34550:<pubkey>:<d-tag>`). */
|
|
communityATag: string;
|
|
children?: React.ReactNode;
|
|
/** Controlled open state. When provided, the component is controlled externally. */
|
|
open?: boolean;
|
|
/** Callback when the open state changes (for controlled mode). */
|
|
onOpenChange?: (open: boolean) => void;
|
|
}
|
|
|
|
export function CreateGoalDialog({ communityATag, children, open: controlledOpen, onOpenChange }: CreateGoalDialogProps) {
|
|
const [internalOpen, setInternalOpen] = useState(false);
|
|
const open = controlledOpen ?? internalOpen;
|
|
const setOpen = onOpenChange ?? setInternalOpen;
|
|
const { user } = useCurrentUser();
|
|
const { toast } = useToast();
|
|
const { config } = useAppContext();
|
|
const { mutateAsync: publishEvent, isPending } = useNostrPublish();
|
|
const queryClient = useQueryClient();
|
|
|
|
const [title, setTitle] = useState('');
|
|
const [amountSats, setAmountSats] = useState('');
|
|
const [summary, setSummary] = useState('');
|
|
const [imageUrl, setImageUrl] = useState('');
|
|
const [deadlineDate, setDeadlineDate] = useState('');
|
|
const [isImageUploading, setIsImageUploading] = useState(false);
|
|
|
|
const resetForm = () => {
|
|
setTitle('');
|
|
setAmountSats('');
|
|
setSummary('');
|
|
setImageUrl('');
|
|
setDeadlineDate('');
|
|
setIsImageUploading(false);
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!user) return;
|
|
if (isImageUploading) {
|
|
toast({ title: 'Image is still uploading', description: 'Please wait for the upload to finish.' });
|
|
return;
|
|
}
|
|
|
|
const sats = parseInt(amountSats, 10);
|
|
if (isNaN(sats) || sats <= 0) {
|
|
toast({ title: 'Enter a valid amount in sats', variant: 'destructive' });
|
|
return;
|
|
}
|
|
|
|
if (!title.trim()) {
|
|
toast({ title: 'Enter a title for the goal', variant: 'destructive' });
|
|
return;
|
|
}
|
|
|
|
const msats = sats * 1000;
|
|
|
|
// NIP-75 relay hints are where zap receipts should be published and tallied.
|
|
const relayUrls = getEffectiveRelays(config.relayMetadata, config.useAppRelays, config.useUserRelays).relays
|
|
.filter((r) => r.write)
|
|
.map((r) => r.url);
|
|
if (relayUrls.length === 0) {
|
|
toast({ title: 'No write relays configured', variant: 'destructive' });
|
|
return;
|
|
}
|
|
|
|
const tags: string[][] = [
|
|
['amount', String(msats)],
|
|
['relays', ...relayUrls],
|
|
['a', communityATag],
|
|
['alt', `Zap goal: ${title.trim()}`],
|
|
];
|
|
|
|
if (summary.trim()) {
|
|
tags.push(['summary', summary.trim()]);
|
|
}
|
|
if (imageUrl.trim()) {
|
|
const sanitizedImage = sanitizeUrl(imageUrl.trim());
|
|
if (!sanitizedImage) {
|
|
toast({ title: 'Image URL must be a valid https URL', variant: 'destructive' });
|
|
return;
|
|
}
|
|
tags.push(['image', sanitizedImage]);
|
|
}
|
|
if (deadlineDate) {
|
|
const deadline = Math.floor(new Date(deadlineDate).getTime() / 1000);
|
|
if (!isNaN(deadline) && deadline > 0) {
|
|
tags.push(['closed_at', String(deadline)]);
|
|
}
|
|
}
|
|
|
|
try {
|
|
await publishEvent({
|
|
kind: ZAP_GOAL_KIND,
|
|
content: title.trim(),
|
|
tags,
|
|
});
|
|
|
|
// Refresh the goals tab and the community activity feed
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: ['community-goals', communityATag] }),
|
|
queryClient.invalidateQueries({
|
|
predicate: (q) => {
|
|
const [root, aTagsKey] = q.queryKey;
|
|
return root === 'community-activity-feed'
|
|
&& typeof aTagsKey === 'string'
|
|
&& aTagsKey.split(',').includes(communityATag);
|
|
},
|
|
}),
|
|
]);
|
|
|
|
toast({ title: 'Goal created!' });
|
|
resetForm();
|
|
setOpen(false);
|
|
} catch {
|
|
toast({ title: 'Failed to create goal', variant: 'destructive' });
|
|
}
|
|
};
|
|
|
|
if (!user) return null;
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
{controlledOpen === undefined && (
|
|
<DialogTrigger asChild>
|
|
{children ?? (
|
|
<Button variant="outline" size="sm" className="gap-1.5">
|
|
<Target className="size-4" />
|
|
New Goal
|
|
</Button>
|
|
)}
|
|
</DialogTrigger>
|
|
)}
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Target className="size-5" />
|
|
Create Goal
|
|
</DialogTitle>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-4 mt-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="goal-title">Title</Label>
|
|
<Input
|
|
id="goal-title"
|
|
placeholder="e.g. Community meetup expenses"
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="goal-amount">Amount (sats)</Label>
|
|
<Input
|
|
id="goal-amount"
|
|
type="number"
|
|
min="1"
|
|
placeholder="e.g. 100000"
|
|
value={amountSats}
|
|
onChange={(e) => setAmountSats(e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="goal-deadline">Deadline (optional)</Label>
|
|
<Input
|
|
id="goal-deadline"
|
|
type="datetime-local"
|
|
value={deadlineDate}
|
|
onChange={(e) => setDeadlineDate(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="goal-summary">Description (optional)</Label>
|
|
<Textarea
|
|
id="goal-summary"
|
|
placeholder="Tell people what this goal is for..."
|
|
value={summary}
|
|
onChange={(e) => setSummary(e.target.value)}
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
|
|
<ImageUploadField
|
|
id="goal-image"
|
|
label="Image (recommended)"
|
|
value={imageUrl}
|
|
onChange={setImageUrl}
|
|
onUploadingChange={setIsImageUploading}
|
|
previewAlt="Goal image preview"
|
|
/>
|
|
|
|
<Button type="submit" className="w-full" disabled={isPending || isImageUploading}>
|
|
{isPending ? 'Creating...' : isImageUploading ? 'Uploading...' : 'Create Goal'}
|
|
</Button>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|