diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 609b18a..3877196 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -4,6 +4,7 @@ import { ScrollToTop } from "./components/ScrollToTop"; import Index from "./pages/Index"; import { Explore } from "./pages/Explore"; import { Dashboard } from "./pages/Dashboard"; +import { DashboardEvents } from "./pages/DashboardEvents"; import { NIP19Page } from "./pages/NIP19Page"; import { Terms } from "./pages/Terms"; import { Privacy } from "./pages/Privacy"; @@ -17,6 +18,7 @@ export function AppRouter() { } /> } /> } /> + } /> } /> } /> {/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */} diff --git a/src/components/dashboard/EventExplorer.tsx b/src/components/dashboard/EventExplorer.tsx new file mode 100644 index 0000000..3971f8f --- /dev/null +++ b/src/components/dashboard/EventExplorer.tsx @@ -0,0 +1,370 @@ +import { useMemo, useState } from 'react'; +import type { NostrEvent } from '@nostrify/nostrify'; +import { useUserStats } from '@/hooks/useUserStats'; +import { useNostrPublish } from '@/hooks/useNostrPublish'; +import { useToast } from '@/hooks/useToast'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { Textarea } from '@/components/ui/textarea'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Trash2, Search, Info } from 'lucide-react'; + +const kindLabels: Record = { + 0: 'Metadata', + 1: 'Text Note', + 3: 'Contacts', + 4: 'DM', + 5: 'Deletion', + 6: 'Repost', + 7: 'Reaction', + 16: 'Generic Repost', + 40: 'Channel Create', + 41: 'Channel Metadata', + 42: 'Channel Message', + 43: 'Channel Hide', + 44: 'Channel Mute', + 1984: 'Report', + 9735: 'Zap', + 10002: 'Relay List', + 30023: 'Article', +}; + +interface EventExplorerProps { + pubkey: string; +} + +const EVENTS_PER_PAGE = 20; + +export function EventExplorer({ pubkey }: EventExplorerProps) { + const { data: stats, isLoading, isError } = useUserStats(pubkey); + const { mutateAsync: publishDeletion, isPending } = useNostrPublish(); + const { toast } = useToast(); + + const [search, setSearch] = useState(''); + const [page, setPage] = useState(1); + const [selectedEvent, setSelectedEvent] = useState(null); + const [reason, setReason] = useState('I would like this event to be deleted.'); + + const filteredEvents = useMemo(() => { + if (!stats) return []; + + const normalizedSearch = search.trim().toLowerCase(); + + if (!normalizedSearch) { + return stats.events; + } + + return stats.events.filter((event) => { + if (!event.content) return false; + return event.content.toLowerCase().includes(normalizedSearch); + }); + }, [stats, search]); + + const totalPages = useMemo(() => { + if (filteredEvents.length === 0) return 1; + return Math.ceil(filteredEvents.length / EVENTS_PER_PAGE); + }, [filteredEvents.length]); + + const pageSafe = Math.min(Math.max(page, 1), totalPages); + + const paginatedEvents = useMemo(() => { + const start = (pageSafe - 1) * EVENTS_PER_PAGE; + const end = start + EVENTS_PER_PAGE; + return filteredEvents.slice(start, end); + }, [filteredEvents, pageSafe]); + + const handleOpenDialog = (event: NostrEvent) => { + setSelectedEvent(event); + setReason('I would like this event to be deleted.'); + }; + + const handleRequestDeletion = async () => { + if (!selectedEvent) return; + + try { + await publishDeletion({ + kind: 5, + content: reason.trim() || 'Requesting deletion of this event.', + tags: [ + ['e', selectedEvent.id], + ['p', selectedEvent.pubkey], + ], + }); + + toast({ + title: 'Deletion request sent', + description: 'A Nostr deletion event has been published.', + }); + + setSelectedEvent(null); + } catch (error) { + console.error('Failed to publish deletion event:', error); + toast({ + title: 'Deletion request failed', + description: 'Unable to publish deletion request. Please try again.', + variant: 'destructive', + }); + } + }; + + if (isLoading) { + return ( + + + + + + +
+ {[...Array(5)].map((_, index) => ( +
+
+ + +
+ +
+ ))} +
+
+
+ ); + } + + if (isError || !stats) { + return ( + + + Your Events + + Explore your published events and manage deletion requests. + + + +

+ Unable to load your events. Please check your relay connections and try again. +

+
+
+ ); + } + + if (stats.events.length === 0) { + return ( + + + Your Events + + Explore your published events and manage deletion requests. + + + +
+ +

+ You have not published any events yet. Once you start posting on Nostr, your activity will appear here. +

+
+
+
+ ); + } + + return ( + <> + + +
+ Your Events + + Browse your published events and request deletion directly from your dashboard. + +
+
+
+ + { + setSearch(event.target.value); + setPage(1); + }} + /> +
+
+
+ + +
+ {paginatedEvents.map((event) => { + const label = kindLabels[event.kind] || `Kind ${event.kind}`; + const createdAt = new Date(event.created_at * 1000); + const contentPreview = event.content + ? event.content.slice(0, 120) + (event.content.length > 120 ? '…' : '') + : 'No content'; + + return ( +
+
+
+ + {label} + + + {createdAt.toLocaleString()} + +
+

+ {contentPreview} +

+
+
+ { + if (!open) { + setSelectedEvent((current) => + current?.id === event.id ? null : current, + ); + } else { + handleOpenDialog(event); + } + }} + > + + + + Request deletion of this event? + + This will publish a Nostr deletion event (kind 5) referencing the + selected event. Relays and clients may remove or hide the original + event in response. + + +
+
+
+ + {label} + + + {createdAt.toLocaleString()} + +
+

+ {contentPreview} +

+
+
+

+ Deletion reason (optional) +

+