From 900608c265495b54175d06ad4fd6aad0fd0d4417 Mon Sep 17 00:00:00 2001 From: Chad Curtis Date: Sat, 21 Feb 2026 04:35:47 -0600 Subject: [PATCH 01/11] Add post deletion support (NIP-09) with confirmation dialog and feed filtering --- src/components/Feed.tsx | 6 +- src/components/NoteMoreMenu.tsx | 59 +++++++++++++ src/components/RightSidebar.tsx | 12 ++- src/hooks/useDeleteEvent.ts | 143 ++++++++++++++++++++++++++++++++ src/hooks/useStreamPosts.ts | 5 +- src/pages/DomainFeedPage.tsx | 12 ++- src/pages/HashtagPage.tsx | 12 ++- src/pages/NotificationsPage.tsx | 6 +- src/pages/PostDetailPage.tsx | 37 ++++++++- src/pages/ProfilePage.tsx | 6 +- src/pages/SearchPage.tsx | 12 ++- 11 files changed, 289 insertions(+), 21 deletions(-) create mode 100644 src/hooks/useDeleteEvent.ts diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx index c887537e..a5b39f7c 100644 --- a/src/components/Feed.tsx +++ b/src/components/Feed.tsx @@ -14,6 +14,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAuthors } from '@/hooks/useAuthors'; import { useBatchEventStats } from '@/hooks/useTrending'; import { useMuteList } from '@/hooks/useMuteList'; +import { useDeletedEvents } from '@/hooks/useDeleteEvent'; import { isEventMuted } from '@/lib/muteHelpers'; import { cn } from '@/lib/utils'; @@ -22,6 +23,7 @@ import type { FeedItem } from '@/hooks/useFeed'; export function Feed() { const { user } = useCurrentUser(); const { muteItems } = useMuteList(); + const { isDeleted } = useDeletedEvents(); // Load feed tab settings from localStorage const showGlobalFeed = (() => { @@ -102,9 +104,11 @@ export function Feed() { seen.add(key); // Filter out muted events if (muteItems.length > 0 && isEventMuted(item.event, muteItems)) return false; + // Filter out deleted events + if (isDeleted(item.event.id)) return false; return true; }) || []; - }, [data?.pages, muteItems]); + }, [data?.pages, muteItems, isDeleted]); // Batch-prefetch all author profiles in a single relay query instead of // firing N individual useAuthor() calls from each NoteCard. The results diff --git a/src/components/NoteMoreMenu.tsx b/src/components/NoteMoreMenu.tsx index 131e85fe..2c9b0dda 100644 --- a/src/components/NoteMoreMenu.tsx +++ b/src/components/NoteMoreMenu.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { nip19 } from 'nostr-tools'; import { @@ -11,12 +12,23 @@ import { Pin, FileJson, FileDigit, + Trash2, } from 'lucide-react'; import { Dialog, DialogContent, DialogTitle, } from '@/components/ui/dialog'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { Separator } from '@/components/ui/separator'; import { Button } from '@/components/ui/button'; @@ -27,6 +39,7 @@ import { usePinnedNotes } from '@/hooks/usePinnedNotes'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAuthor } from '@/hooks/useAuthor'; import { useMuteList } from '@/hooks/useMuteList'; +import { useDeleteEvent } from '@/hooks/useDeleteEvent'; import { genUserName } from '@/lib/genUserName'; import { timeAgo } from '@/lib/timeAgo'; import { toast } from '@/hooks/useToast'; @@ -73,6 +86,8 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) { const displayName = metadata?.name || genUserName(event.pubkey); const { addMute, removeMute, isMuted } = useMuteList(); const userMuted = isMuted('pubkey', event.pubkey); + const { mutate: deleteEvent, isPending: isDeleting } = useDeleteEvent(); + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const neventId = nip19.neventEncode({ id: event.id, author: event.pubkey }); @@ -161,6 +176,21 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) { close(); }; + const handleDelete = () => { + deleteEvent( + { eventId: event.id, eventKind: event.kind }, + { + onSuccess: () => { + toast({ title: 'Post deleted' }); + close(); + }, + onError: () => { + toast({ title: 'Failed to delete post', variant: 'destructive' }); + }, + }, + ); + }; + return ( @@ -239,6 +269,14 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) { onClick={handleTogglePin} /> )} + {isOwnPost && ( + } + label="Delete post" + onClick={() => setDeleteConfirmOpen(true)} + destructive + /> + )} {!isOwnPost && ( } @@ -280,6 +318,27 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) { + + + + + Delete post? + + This will request deletion from relays. Some relays may still keep a copy of the original event. This action cannot be undone. + + + + Cancel + + {isDeleting ? 'Deleting...' : 'Delete'} + + + + ); } diff --git a/src/components/RightSidebar.tsx b/src/components/RightSidebar.tsx index 01cefbaa..c4c753f9 100644 --- a/src/components/RightSidebar.tsx +++ b/src/components/RightSidebar.tsx @@ -6,6 +6,7 @@ import { EmojifiedText } from '@/components/CustomEmoji'; import { useTrendingTags, useLatestAccounts, useSortedPosts, useTagSparklines } from '@/hooks/useTrending'; import { useAuthor } from '@/hooks/useAuthor'; import { useMuteList } from '@/hooks/useMuteList'; +import { useDeletedEvents } from '@/hooks/useDeleteEvent'; import { isEventMuted } from '@/lib/muteHelpers'; import { genUserName } from '@/lib/genUserName'; import { timeAgo } from '@/lib/timeAgo'; @@ -92,11 +93,16 @@ export function RightSidebar() { const { data: rawHotPosts, isLoading: hotLoading } = useSortedPosts('hot', 5, isXl && sidebarEnabled); const { data: latestAccounts, isLoading: accountsLoading } = useLatestAccounts(isXl && sidebarEnabled); const { muteItems } = useMuteList(); + const { isDeleted } = useDeletedEvents(); const hotPosts = useMemo(() => { - if (!rawHotPosts || muteItems.length === 0) return rawHotPosts; - return rawHotPosts.filter((e) => !isEventMuted(e, muteItems)); - }, [rawHotPosts, muteItems]); + if (!rawHotPosts) return rawHotPosts; + return rawHotPosts.filter((e) => { + if (muteItems.length > 0 && isEventMuted(e, muteItems)) return false; + if (isDeleted(e.id)) return false; + return true; + }); + }, [rawHotPosts, muteItems, isDeleted]); // Fetch real sparkline data for the visible trending tags const visibleTags = useMemo(() => (trendingTags ?? []).slice(0, 5).map((t) => t.tag), [trendingTags]); diff --git a/src/hooks/useDeleteEvent.ts b/src/hooks/useDeleteEvent.ts new file mode 100644 index 00000000..f0c52ebd --- /dev/null +++ b/src/hooks/useDeleteEvent.ts @@ -0,0 +1,143 @@ +import { useNostr } from '@nostrify/react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import type { NostrEvent } from '@nostrify/nostrify'; + +import { useCurrentUser } from './useCurrentUser'; +import { useNostrPublish } from './useNostrPublish'; + +/** localStorage key for cached deleted event IDs. */ +const DELETE_CACHE_KEY = 'mew:deletedEventsCache'; + +/** Read cached deleted event IDs from localStorage for a given user. */ +function getCachedDeletedIds(pubkey: string): string[] | undefined { + try { + const raw = localStorage.getItem(DELETE_CACHE_KEY); + if (!raw) return undefined; + const cached = JSON.parse(raw); + if (cached.pubkey !== pubkey || !Array.isArray(cached.ids)) return undefined; + return cached.ids; + } catch { + return undefined; + } +} + +/** Persist deleted event IDs to localStorage. */ +function setCachedDeletedIds(pubkey: string, ids: string[]): void { + try { + localStorage.setItem(DELETE_CACHE_KEY, JSON.stringify({ pubkey, ids })); + } catch { + // Storage full or unavailable — non-critical + } +} + +/** Extract deleted event IDs from kind 5 deletion request events. */ +function extractDeletedIds(deletionEvents: NostrEvent[]): string[] { + const ids = new Set(); + for (const event of deletionEvents) { + for (const tag of event.tags) { + if (tag[0] === 'e' && tag[1]) { + ids.add(tag[1]); + } + } + } + return Array.from(ids); +} + +/** + * Hook to query the current user's kind 5 deletion requests and provide + * a set of deleted event IDs for filtering. + */ +export function useDeletedEvents() { + const { nostr } = useNostr(); + const { user } = useCurrentUser(); + + const cachedIds = user ? getCachedDeletedIds(user.pubkey) : undefined; + + const query = useQuery({ + queryKey: ['deletedEvents', user?.pubkey], + queryFn: async () => { + if (!user) return []; + + const events = await nostr.query([{ + kinds: [5], + authors: [user.pubkey], + limit: 500, + }]); + + const ids = extractDeletedIds(events); + + // Cache for quick loading on next page visit + setCachedDeletedIds(user.pubkey, ids); + + return ids; + }, + enabled: !!user, + placeholderData: cachedIds, + staleTime: 60_000, // Refetch at most every minute + }); + + const deletedIds = query.data ?? []; + + /** Check if an event ID has been deleted by the current user. */ + const isDeleted = (eventId: string): boolean => { + return deletedIds.includes(eventId); + }; + + return { + deletedIds, + isDeleted, + isLoading: query.isLoading, + }; +} + +/** + * Hook to publish a kind 5 deletion request event (NIP-09). + * Also optimistically updates the local deleted events cache. + */ +export function useDeleteEvent() { + const { user } = useCurrentUser(); + const queryClient = useQueryClient(); + const { mutateAsync: publishEvent } = useNostrPublish(); + + return useMutation({ + mutationFn: async ({ eventId, eventKind }: { eventId: string; eventKind: number }) => { + if (!user) throw new Error('User is not logged in'); + + await publishEvent({ + kind: 5, + content: '', + tags: [ + ['e', eventId], + ['k', String(eventKind)], + ], + }); + + return eventId; + }, + onSuccess: (deletedEventId) => { + // Optimistically add the deleted event ID to the cache + queryClient.setQueryData( + ['deletedEvents', user?.pubkey], + (prev) => { + const ids = prev ?? []; + if (ids.includes(deletedEventId)) return ids; + const updated = [...ids, deletedEventId]; + + // Also update localStorage + if (user) { + setCachedDeletedIds(user.pubkey, updated); + } + + return updated; + }, + ); + + // Invalidate feed queries so deleted posts disappear from timelines + queryClient.invalidateQueries({ queryKey: ['feed'] }); + queryClient.invalidateQueries({ queryKey: ['profileFeed'] }); + queryClient.invalidateQueries({ queryKey: ['profileLikes'] }); + queryClient.invalidateQueries({ queryKey: ['replies'] }); + queryClient.invalidateQueries({ queryKey: ['notifications'] }); + }, + }); +} diff --git a/src/hooks/useStreamPosts.ts b/src/hooks/useStreamPosts.ts index 8e97a129..093323e4 100644 --- a/src/hooks/useStreamPosts.ts +++ b/src/hooks/useStreamPosts.ts @@ -2,6 +2,7 @@ import { useNostr } from '@nostrify/react'; import { useState, useEffect, useMemo } from 'react'; import { useFeedSettings } from './useFeedSettings'; import { useMuteList } from './useMuteList'; +import { useDeletedEvents } from './useDeleteEvent'; import { getEnabledFeedKinds } from '@/lib/extraKinds'; import { isEventMuted } from '@/lib/muteHelpers'; import type { NostrEvent, NostrFilter } from '@nostrify/nostrify'; @@ -85,6 +86,7 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) { const { nostr } = useNostr(); const { feedSettings } = useFeedSettings(); const { muteItems } = useMuteList(); + const { isDeleted } = useDeletedEvents(); const [allEvents, setAllEvents] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -218,9 +220,10 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) { const posts = useMemo(() => { return allEvents.filter((event) => { if (muteItems.length > 0 && isEventMuted(event, muteItems)) return false; + if (isDeleted(event.id)) return false; return filterEvent(event, options, query); }); - }, [allEvents, options.includeReplies, options.mediaType, query, muteItems]); + }, [allEvents, options.includeReplies, options.mediaType, query, muteItems, isDeleted]); return { posts, isLoading }; } diff --git a/src/pages/DomainFeedPage.tsx b/src/pages/DomainFeedPage.tsx index d42d331c..b32987f9 100644 --- a/src/pages/DomainFeedPage.tsx +++ b/src/pages/DomainFeedPage.tsx @@ -12,6 +12,7 @@ import { useFeedSettings } from '@/hooks/useFeedSettings'; import { useAppContext } from '@/hooks/useAppContext'; import { useMuteList } from '@/hooks/useMuteList'; import { getEnabledFeedKinds } from '@/lib/extraKinds'; +import { useDeletedEvents } from '@/hooks/useDeleteEvent'; import { isEventMuted } from '@/lib/muteHelpers'; import { cn, STICKY_HEADER_CLASS } from '@/lib/utils'; import type { NostrEvent } from '@nostrify/nostrify'; @@ -72,6 +73,7 @@ export function DomainFeedPage() { }); const { muteItems } = useMuteList(); + const { isDeleted } = useDeletedEvents(); const { data: pubkeys, isLoading: pubkeysLoading, isError: pubkeysError } = useDomainPubkeys(domain, config.corsProxy); const { data: events, isLoading: eventsLoading } = useQuery({ @@ -88,9 +90,13 @@ export function DomainFeedPage() { }); const filteredEvents = useMemo(() => { - if (!events || muteItems.length === 0) return events; - return events.filter((e) => !isEventMuted(e, muteItems)); - }, [events, muteItems]); + if (!events) return events; + return events.filter((e) => { + if (muteItems.length > 0 && isEventMuted(e, muteItems)) return false; + if (isDeleted(e.id)) return false; + return true; + }); + }, [events, muteItems, isDeleted]); const isLoading = pubkeysLoading || eventsLoading; diff --git a/src/pages/HashtagPage.tsx b/src/pages/HashtagPage.tsx index 4a7b8b27..bb5e2a33 100644 --- a/src/pages/HashtagPage.tsx +++ b/src/pages/HashtagPage.tsx @@ -9,6 +9,7 @@ import { NoteCard } from '@/components/NoteCard'; import { Skeleton } from '@/components/ui/skeleton'; import { useFeedSettings } from '@/hooks/useFeedSettings'; import { useMuteList } from '@/hooks/useMuteList'; +import { useDeletedEvents } from '@/hooks/useDeleteEvent'; import { getEnabledFeedKinds } from '@/lib/extraKinds'; import { isEventMuted } from '@/lib/muteHelpers'; import { cn, STICKY_HEADER_CLASS } from '@/lib/utils'; @@ -19,6 +20,7 @@ export function HashtagPage() { const { nostr } = useNostr(); const { feedSettings } = useFeedSettings(); const { muteItems } = useMuteList(); + const { isDeleted } = useDeletedEvents(); const kinds = getEnabledFeedKinds(feedSettings).filter((k) => k !== 6); const kindsKey = [...kinds].sort().join(','); @@ -42,9 +44,13 @@ export function HashtagPage() { }); const filteredEvents = useMemo(() => { - if (!events || muteItems.length === 0) return events; - return events.filter((e) => !isEventMuted(e, muteItems)); - }, [events, muteItems]); + if (!events) return events; + return events.filter((e) => { + if (muteItems.length > 0 && isEventMuted(e, muteItems)) return false; + if (isDeleted(e.id)) return false; + return true; + }); + }, [events, muteItems, isDeleted]); return ( diff --git a/src/pages/NotificationsPage.tsx b/src/pages/NotificationsPage.tsx index 6b185739..6fdacf50 100644 --- a/src/pages/NotificationsPage.tsx +++ b/src/pages/NotificationsPage.tsx @@ -23,6 +23,7 @@ import { useEvent } from '@/hooks/useEvent'; import { useEventStats } from '@/hooks/useTrending'; import { useNotifications } from '@/hooks/useNotifications'; import { useMuteList } from '@/hooks/useMuteList'; +import { useDeletedEvents } from '@/hooks/useDeleteEvent'; import { isEventMuted } from '@/lib/muteHelpers'; import { genUserName } from '@/lib/genUserName'; import { getProfileUrl } from '@/lib/profileUrl'; @@ -45,6 +46,7 @@ export function NotificationsPage() { const { user } = useCurrentUser(); const { notifications, newNotifications, isLoading, hasFetched, markAsRead } = useNotifications(); const { muteItems } = useMuteList(); + const { isDeleted } = useDeletedEvents(); // Mark notifications as read when user visits the page useEffect(() => { @@ -65,11 +67,13 @@ export function NotificationsPage() { if (muteItems.length > 0) { filtered = filtered.filter((e) => !isEventMuted(e, muteItems)); } + // Filter out deleted events + filtered = filtered.filter((e) => !isDeleted(e.id)); if (activeTab === 'mentions') { filtered = filtered.filter((e) => e.kind === 1); } return filtered; - }, [notifications, activeTab, muteItems]); + }, [notifications, activeTab, muteItems, isDeleted]); // Create a set of new notification IDs for quick lookup const newNotificationIds = useMemo( diff --git a/src/pages/PostDetailPage.tsx b/src/pages/PostDetailPage.tsx index 5de79f01..7ba286c0 100644 --- a/src/pages/PostDetailPage.tsx +++ b/src/pages/PostDetailPage.tsx @@ -1,6 +1,6 @@ import { useMemo, useState, useCallback } from 'react'; import { Link, useNavigate } from 'react-router-dom'; -import { ArrowLeft, MessageCircle, Zap, MoreHorizontal, Radio, Loader2, AlertCircle, Copy, Check, ChevronRight } from 'lucide-react'; +import { ArrowLeft, MessageCircle, Zap, MoreHorizontal, Radio, Loader2, AlertCircle, Copy, Check, ChevronRight, Trash2 } from 'lucide-react'; import { RepostIcon } from '@/components/icons/RepostIcon'; import { nip19 } from 'nostr-tools'; import type { NostrEvent } from '@nostrify/nostrify'; @@ -43,6 +43,7 @@ import { useReplies } from '@/hooks/useReplies'; import { useAuthor } from '@/hooks/useAuthor'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useMuteList } from '@/hooks/useMuteList'; +import { useDeletedEvents } from '@/hooks/useDeleteEvent'; import { isEventMuted } from '@/lib/muteHelpers'; import { useEventStats } from '@/hooks/useTrending'; import { getDisplayName } from '@/lib/getDisplayName'; @@ -223,6 +224,7 @@ function getParentEventId(event: NostrEvent): string | undefined { export function PostDetailPage({ eventId, relays, authorHint }: PostDetailPageProps) { const { data: event, isLoading, isError } = useEvent(eventId, relays, authorHint); + const { isDeleted } = useDeletedEvents(); const [retryEvent, setRetryEvent] = useState(null); useSeoMeta({ @@ -254,6 +256,28 @@ export function PostDetailPage({ eventId, relays, authorHint }: PostDetailPagePr ); } + if (isDeleted(resolvedEvent.id)) { + return ( + + +
+
+
+ +
+
+

Post Deleted

+

+ This post has been deleted by its author. +

+
+
+
+
+
+ ); + } + return ( @@ -622,6 +646,7 @@ function VineDetailContent({ event }: { event: NostrEvent }) { function PostDetailContent({ event }: { event: NostrEvent }) { const { user } = useCurrentUser(); const { muteItems } = useMuteList(); + const { isDeleted } = useDeletedEvents(); const author = useAuthor(event.pubkey); const metadata = author.data?.metadata; const displayName = getDisplayName(metadata, event.pubkey); @@ -644,9 +669,13 @@ function PostDetailContent({ event }: { event: NostrEvent }) { const { data: stats } = useEventStats(event.id); const { data: rawReplies, isLoading: repliesLoading } = useReplies(event.id); const replies = useMemo(() => { - if (!rawReplies || muteItems.length === 0) return rawReplies; - return rawReplies.filter((r) => !isEventMuted(r, muteItems)); - }, [rawReplies, muteItems]); + if (!rawReplies) return rawReplies; + return rawReplies.filter((r) => { + if (muteItems.length > 0 && isEventMuted(r, muteItems)) return false; + if (isDeleted(r.id)) return false; + return true; + }); + }, [rawReplies, muteItems, isDeleted]); const [moreMenuOpen, setMoreMenuOpen] = useState(false); const [replyOpen, setReplyOpen] = useState(false); const [interactionsOpen, setInteractionsOpen] = useState(false); diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index 8c6a1b0f..23d2d8fd 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -23,6 +23,7 @@ import { useProfileFollowing } from '@/hooks/useProfileFollowing'; import { usePinnedNotes } from '@/hooks/usePinnedNotes'; import { useFollowList, useFollowActions } from '@/hooks/useFollowActions'; import { useMuteList } from '@/hooks/useMuteList'; +import { useDeletedEvents } from '@/hooks/useDeleteEvent'; import { isEventMuted } from '@/lib/muteHelpers'; import { useProfileFeed, useProfileLikes as useProfileLikesInfinite } from '@/hooks/useProfileFeed'; import type { ProfileTab } from '@/hooks/useProfileFeed'; @@ -569,7 +570,7 @@ export function ProfilePage() { const { user } = useCurrentUser(); const { toast } = useToast(); const { muteItems } = useMuteList(); - + const { isDeleted } = useDeletedEvents(); const [activeTab, setActiveTab] = useState('posts'); const [moreMenuOpen, setMoreMenuOpen] = useState(false); @@ -687,12 +688,13 @@ export function ProfilePage() { if (!seen.has(key)) { seen.add(key); if (muteItems.length > 0 && isEventMuted(item.event, muteItems)) continue; + if (isDeleted(item.event.id)) continue; items.push(item); } } } return items; - }, [feedData?.pages, muteItems]); + }, [feedData?.pages, muteItems, isDeleted]); // Flatten likes pages and deduplicate const likedItems = useMemo(() => { diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx index 060ff318..4778ae98 100644 --- a/src/pages/SearchPage.tsx +++ b/src/pages/SearchPage.tsx @@ -17,6 +17,7 @@ import { useSearchProfiles } from '@/hooks/useSearchProfiles'; import { useStreamPosts } from '@/hooks/useStreamPosts'; import { useTrendingTags, useSortedPosts, type SortMode } from '@/hooks/useTrending'; import { useMuteList } from '@/hooks/useMuteList'; +import { useDeletedEvents } from '@/hooks/useDeleteEvent'; import { isEventMuted } from '@/lib/muteHelpers'; import { genUserName } from '@/lib/genUserName'; import { getNostrIdentifierPath } from '@/lib/nostrIdentifier'; @@ -109,11 +110,16 @@ export function SearchPage() { const { data: trends, isLoading: trendsLoading } = useTrendingTags(isTrendsTab); const { data: rawSortedPosts, isLoading: sortedLoading } = useSortedPosts(trendSort, 5, isTrendsTab); const { muteItems } = useMuteList(); + const { isDeleted } = useDeletedEvents(); const sortedPosts = useMemo(() => { - if (!rawSortedPosts || muteItems.length === 0) return rawSortedPosts; - return rawSortedPosts.filter((e) => !isEventMuted(e, muteItems)); - }, [rawSortedPosts, muteItems]); + if (!rawSortedPosts) return rawSortedPosts; + return rawSortedPosts.filter((e) => { + if (muteItems.length > 0 && isEventMuted(e, muteItems)) return false; + if (isDeleted(e.id)) return false; + return true; + }); + }, [rawSortedPosts, muteItems, isDeleted]); // Filter by platform (Nostr/Mastodon) client-side const posts = useMemo(() => { From 9ffe893d59d0ab801b2c5f2f8f198032e6b0406b Mon Sep 17 00:00:00 2001 From: Chad Curtis Date: Sat, 21 Feb 2026 04:40:25 -0600 Subject: [PATCH 02/11] Remove client-side deletion filtering; rely on relays to handle kind 5 deletion --- src/components/Feed.tsx | 6 +- src/components/RightSidebar.tsx | 12 +--- src/hooks/useDeleteEvent.ts | 114 ++------------------------------ src/hooks/useStreamPosts.ts | 5 +- src/pages/DomainFeedPage.tsx | 12 +--- src/pages/HashtagPage.tsx | 12 +--- src/pages/NotificationsPage.tsx | 6 +- src/pages/PostDetailPage.tsx | 37 ++--------- src/pages/ProfilePage.tsx | 5 +- src/pages/SearchPage.tsx | 12 +--- 10 files changed, 26 insertions(+), 195 deletions(-) diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx index a5b39f7c..c887537e 100644 --- a/src/components/Feed.tsx +++ b/src/components/Feed.tsx @@ -14,7 +14,6 @@ import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAuthors } from '@/hooks/useAuthors'; import { useBatchEventStats } from '@/hooks/useTrending'; import { useMuteList } from '@/hooks/useMuteList'; -import { useDeletedEvents } from '@/hooks/useDeleteEvent'; import { isEventMuted } from '@/lib/muteHelpers'; import { cn } from '@/lib/utils'; @@ -23,7 +22,6 @@ import type { FeedItem } from '@/hooks/useFeed'; export function Feed() { const { user } = useCurrentUser(); const { muteItems } = useMuteList(); - const { isDeleted } = useDeletedEvents(); // Load feed tab settings from localStorage const showGlobalFeed = (() => { @@ -104,11 +102,9 @@ export function Feed() { seen.add(key); // Filter out muted events if (muteItems.length > 0 && isEventMuted(item.event, muteItems)) return false; - // Filter out deleted events - if (isDeleted(item.event.id)) return false; return true; }) || []; - }, [data?.pages, muteItems, isDeleted]); + }, [data?.pages, muteItems]); // Batch-prefetch all author profiles in a single relay query instead of // firing N individual useAuthor() calls from each NoteCard. The results diff --git a/src/components/RightSidebar.tsx b/src/components/RightSidebar.tsx index c4c753f9..01cefbaa 100644 --- a/src/components/RightSidebar.tsx +++ b/src/components/RightSidebar.tsx @@ -6,7 +6,6 @@ import { EmojifiedText } from '@/components/CustomEmoji'; import { useTrendingTags, useLatestAccounts, useSortedPosts, useTagSparklines } from '@/hooks/useTrending'; import { useAuthor } from '@/hooks/useAuthor'; import { useMuteList } from '@/hooks/useMuteList'; -import { useDeletedEvents } from '@/hooks/useDeleteEvent'; import { isEventMuted } from '@/lib/muteHelpers'; import { genUserName } from '@/lib/genUserName'; import { timeAgo } from '@/lib/timeAgo'; @@ -93,16 +92,11 @@ export function RightSidebar() { const { data: rawHotPosts, isLoading: hotLoading } = useSortedPosts('hot', 5, isXl && sidebarEnabled); const { data: latestAccounts, isLoading: accountsLoading } = useLatestAccounts(isXl && sidebarEnabled); const { muteItems } = useMuteList(); - const { isDeleted } = useDeletedEvents(); const hotPosts = useMemo(() => { - if (!rawHotPosts) return rawHotPosts; - return rawHotPosts.filter((e) => { - if (muteItems.length > 0 && isEventMuted(e, muteItems)) return false; - if (isDeleted(e.id)) return false; - return true; - }); - }, [rawHotPosts, muteItems, isDeleted]); + if (!rawHotPosts || muteItems.length === 0) return rawHotPosts; + return rawHotPosts.filter((e) => !isEventMuted(e, muteItems)); + }, [rawHotPosts, muteItems]); // Fetch real sparkline data for the visible trending tags const visibleTags = useMemo(() => (trendingTags ?? []).slice(0, 5).map((t) => t.tag), [trendingTags]); diff --git a/src/hooks/useDeleteEvent.ts b/src/hooks/useDeleteEvent.ts index f0c52ebd..90b38a9c 100644 --- a/src/hooks/useDeleteEvent.ts +++ b/src/hooks/useDeleteEvent.ts @@ -1,98 +1,12 @@ -import { useNostr } from '@nostrify/react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import type { NostrEvent } from '@nostrify/nostrify'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useCurrentUser } from './useCurrentUser'; import { useNostrPublish } from './useNostrPublish'; -/** localStorage key for cached deleted event IDs. */ -const DELETE_CACHE_KEY = 'mew:deletedEventsCache'; - -/** Read cached deleted event IDs from localStorage for a given user. */ -function getCachedDeletedIds(pubkey: string): string[] | undefined { - try { - const raw = localStorage.getItem(DELETE_CACHE_KEY); - if (!raw) return undefined; - const cached = JSON.parse(raw); - if (cached.pubkey !== pubkey || !Array.isArray(cached.ids)) return undefined; - return cached.ids; - } catch { - return undefined; - } -} - -/** Persist deleted event IDs to localStorage. */ -function setCachedDeletedIds(pubkey: string, ids: string[]): void { - try { - localStorage.setItem(DELETE_CACHE_KEY, JSON.stringify({ pubkey, ids })); - } catch { - // Storage full or unavailable — non-critical - } -} - -/** Extract deleted event IDs from kind 5 deletion request events. */ -function extractDeletedIds(deletionEvents: NostrEvent[]): string[] { - const ids = new Set(); - for (const event of deletionEvents) { - for (const tag of event.tags) { - if (tag[0] === 'e' && tag[1]) { - ids.add(tag[1]); - } - } - } - return Array.from(ids); -} - -/** - * Hook to query the current user's kind 5 deletion requests and provide - * a set of deleted event IDs for filtering. - */ -export function useDeletedEvents() { - const { nostr } = useNostr(); - const { user } = useCurrentUser(); - - const cachedIds = user ? getCachedDeletedIds(user.pubkey) : undefined; - - const query = useQuery({ - queryKey: ['deletedEvents', user?.pubkey], - queryFn: async () => { - if (!user) return []; - - const events = await nostr.query([{ - kinds: [5], - authors: [user.pubkey], - limit: 500, - }]); - - const ids = extractDeletedIds(events); - - // Cache for quick loading on next page visit - setCachedDeletedIds(user.pubkey, ids); - - return ids; - }, - enabled: !!user, - placeholderData: cachedIds, - staleTime: 60_000, // Refetch at most every minute - }); - - const deletedIds = query.data ?? []; - - /** Check if an event ID has been deleted by the current user. */ - const isDeleted = (eventId: string): boolean => { - return deletedIds.includes(eventId); - }; - - return { - deletedIds, - isDeleted, - isLoading: query.isLoading, - }; -} - /** * Hook to publish a kind 5 deletion request event (NIP-09). - * Also optimistically updates the local deleted events cache. + * After publishing, invalidates feed caches so relays are re-queried + * and the deleted event is no longer returned. */ export function useDeleteEvent() { const { user } = useCurrentUser(); @@ -114,25 +28,9 @@ export function useDeleteEvent() { return eventId; }, - onSuccess: (deletedEventId) => { - // Optimistically add the deleted event ID to the cache - queryClient.setQueryData( - ['deletedEvents', user?.pubkey], - (prev) => { - const ids = prev ?? []; - if (ids.includes(deletedEventId)) return ids; - const updated = [...ids, deletedEventId]; - - // Also update localStorage - if (user) { - setCachedDeletedIds(user.pubkey, updated); - } - - return updated; - }, - ); - - // Invalidate feed queries so deleted posts disappear from timelines + onSuccess: () => { + // Invalidate feed queries so relays are re-queried. + // The relay should no longer return the deleted event. queryClient.invalidateQueries({ queryKey: ['feed'] }); queryClient.invalidateQueries({ queryKey: ['profileFeed'] }); queryClient.invalidateQueries({ queryKey: ['profileLikes'] }); diff --git a/src/hooks/useStreamPosts.ts b/src/hooks/useStreamPosts.ts index 093323e4..8e97a129 100644 --- a/src/hooks/useStreamPosts.ts +++ b/src/hooks/useStreamPosts.ts @@ -2,7 +2,6 @@ import { useNostr } from '@nostrify/react'; import { useState, useEffect, useMemo } from 'react'; import { useFeedSettings } from './useFeedSettings'; import { useMuteList } from './useMuteList'; -import { useDeletedEvents } from './useDeleteEvent'; import { getEnabledFeedKinds } from '@/lib/extraKinds'; import { isEventMuted } from '@/lib/muteHelpers'; import type { NostrEvent, NostrFilter } from '@nostrify/nostrify'; @@ -86,7 +85,6 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) { const { nostr } = useNostr(); const { feedSettings } = useFeedSettings(); const { muteItems } = useMuteList(); - const { isDeleted } = useDeletedEvents(); const [allEvents, setAllEvents] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -220,10 +218,9 @@ export function useStreamPosts(query: string, options: StreamPostsOptions) { const posts = useMemo(() => { return allEvents.filter((event) => { if (muteItems.length > 0 && isEventMuted(event, muteItems)) return false; - if (isDeleted(event.id)) return false; return filterEvent(event, options, query); }); - }, [allEvents, options.includeReplies, options.mediaType, query, muteItems, isDeleted]); + }, [allEvents, options.includeReplies, options.mediaType, query, muteItems]); return { posts, isLoading }; } diff --git a/src/pages/DomainFeedPage.tsx b/src/pages/DomainFeedPage.tsx index b32987f9..d42d331c 100644 --- a/src/pages/DomainFeedPage.tsx +++ b/src/pages/DomainFeedPage.tsx @@ -12,7 +12,6 @@ import { useFeedSettings } from '@/hooks/useFeedSettings'; import { useAppContext } from '@/hooks/useAppContext'; import { useMuteList } from '@/hooks/useMuteList'; import { getEnabledFeedKinds } from '@/lib/extraKinds'; -import { useDeletedEvents } from '@/hooks/useDeleteEvent'; import { isEventMuted } from '@/lib/muteHelpers'; import { cn, STICKY_HEADER_CLASS } from '@/lib/utils'; import type { NostrEvent } from '@nostrify/nostrify'; @@ -73,7 +72,6 @@ export function DomainFeedPage() { }); const { muteItems } = useMuteList(); - const { isDeleted } = useDeletedEvents(); const { data: pubkeys, isLoading: pubkeysLoading, isError: pubkeysError } = useDomainPubkeys(domain, config.corsProxy); const { data: events, isLoading: eventsLoading } = useQuery({ @@ -90,13 +88,9 @@ export function DomainFeedPage() { }); const filteredEvents = useMemo(() => { - if (!events) return events; - return events.filter((e) => { - if (muteItems.length > 0 && isEventMuted(e, muteItems)) return false; - if (isDeleted(e.id)) return false; - return true; - }); - }, [events, muteItems, isDeleted]); + if (!events || muteItems.length === 0) return events; + return events.filter((e) => !isEventMuted(e, muteItems)); + }, [events, muteItems]); const isLoading = pubkeysLoading || eventsLoading; diff --git a/src/pages/HashtagPage.tsx b/src/pages/HashtagPage.tsx index bb5e2a33..4a7b8b27 100644 --- a/src/pages/HashtagPage.tsx +++ b/src/pages/HashtagPage.tsx @@ -9,7 +9,6 @@ import { NoteCard } from '@/components/NoteCard'; import { Skeleton } from '@/components/ui/skeleton'; import { useFeedSettings } from '@/hooks/useFeedSettings'; import { useMuteList } from '@/hooks/useMuteList'; -import { useDeletedEvents } from '@/hooks/useDeleteEvent'; import { getEnabledFeedKinds } from '@/lib/extraKinds'; import { isEventMuted } from '@/lib/muteHelpers'; import { cn, STICKY_HEADER_CLASS } from '@/lib/utils'; @@ -20,7 +19,6 @@ export function HashtagPage() { const { nostr } = useNostr(); const { feedSettings } = useFeedSettings(); const { muteItems } = useMuteList(); - const { isDeleted } = useDeletedEvents(); const kinds = getEnabledFeedKinds(feedSettings).filter((k) => k !== 6); const kindsKey = [...kinds].sort().join(','); @@ -44,13 +42,9 @@ export function HashtagPage() { }); const filteredEvents = useMemo(() => { - if (!events) return events; - return events.filter((e) => { - if (muteItems.length > 0 && isEventMuted(e, muteItems)) return false; - if (isDeleted(e.id)) return false; - return true; - }); - }, [events, muteItems, isDeleted]); + if (!events || muteItems.length === 0) return events; + return events.filter((e) => !isEventMuted(e, muteItems)); + }, [events, muteItems]); return ( diff --git a/src/pages/NotificationsPage.tsx b/src/pages/NotificationsPage.tsx index 6fdacf50..6b185739 100644 --- a/src/pages/NotificationsPage.tsx +++ b/src/pages/NotificationsPage.tsx @@ -23,7 +23,6 @@ import { useEvent } from '@/hooks/useEvent'; import { useEventStats } from '@/hooks/useTrending'; import { useNotifications } from '@/hooks/useNotifications'; import { useMuteList } from '@/hooks/useMuteList'; -import { useDeletedEvents } from '@/hooks/useDeleteEvent'; import { isEventMuted } from '@/lib/muteHelpers'; import { genUserName } from '@/lib/genUserName'; import { getProfileUrl } from '@/lib/profileUrl'; @@ -46,7 +45,6 @@ export function NotificationsPage() { const { user } = useCurrentUser(); const { notifications, newNotifications, isLoading, hasFetched, markAsRead } = useNotifications(); const { muteItems } = useMuteList(); - const { isDeleted } = useDeletedEvents(); // Mark notifications as read when user visits the page useEffect(() => { @@ -67,13 +65,11 @@ export function NotificationsPage() { if (muteItems.length > 0) { filtered = filtered.filter((e) => !isEventMuted(e, muteItems)); } - // Filter out deleted events - filtered = filtered.filter((e) => !isDeleted(e.id)); if (activeTab === 'mentions') { filtered = filtered.filter((e) => e.kind === 1); } return filtered; - }, [notifications, activeTab, muteItems, isDeleted]); + }, [notifications, activeTab, muteItems]); // Create a set of new notification IDs for quick lookup const newNotificationIds = useMemo( diff --git a/src/pages/PostDetailPage.tsx b/src/pages/PostDetailPage.tsx index 7ba286c0..5de79f01 100644 --- a/src/pages/PostDetailPage.tsx +++ b/src/pages/PostDetailPage.tsx @@ -1,6 +1,6 @@ import { useMemo, useState, useCallback } from 'react'; import { Link, useNavigate } from 'react-router-dom'; -import { ArrowLeft, MessageCircle, Zap, MoreHorizontal, Radio, Loader2, AlertCircle, Copy, Check, ChevronRight, Trash2 } from 'lucide-react'; +import { ArrowLeft, MessageCircle, Zap, MoreHorizontal, Radio, Loader2, AlertCircle, Copy, Check, ChevronRight } from 'lucide-react'; import { RepostIcon } from '@/components/icons/RepostIcon'; import { nip19 } from 'nostr-tools'; import type { NostrEvent } from '@nostrify/nostrify'; @@ -43,7 +43,6 @@ import { useReplies } from '@/hooks/useReplies'; import { useAuthor } from '@/hooks/useAuthor'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useMuteList } from '@/hooks/useMuteList'; -import { useDeletedEvents } from '@/hooks/useDeleteEvent'; import { isEventMuted } from '@/lib/muteHelpers'; import { useEventStats } from '@/hooks/useTrending'; import { getDisplayName } from '@/lib/getDisplayName'; @@ -224,7 +223,6 @@ function getParentEventId(event: NostrEvent): string | undefined { export function PostDetailPage({ eventId, relays, authorHint }: PostDetailPageProps) { const { data: event, isLoading, isError } = useEvent(eventId, relays, authorHint); - const { isDeleted } = useDeletedEvents(); const [retryEvent, setRetryEvent] = useState(null); useSeoMeta({ @@ -256,28 +254,6 @@ export function PostDetailPage({ eventId, relays, authorHint }: PostDetailPagePr ); } - if (isDeleted(resolvedEvent.id)) { - return ( - - -
-
-
- -
-
-

Post Deleted

-

- This post has been deleted by its author. -

-
-
-
-
-
- ); - } - return ( @@ -646,7 +622,6 @@ function VineDetailContent({ event }: { event: NostrEvent }) { function PostDetailContent({ event }: { event: NostrEvent }) { const { user } = useCurrentUser(); const { muteItems } = useMuteList(); - const { isDeleted } = useDeletedEvents(); const author = useAuthor(event.pubkey); const metadata = author.data?.metadata; const displayName = getDisplayName(metadata, event.pubkey); @@ -669,13 +644,9 @@ function PostDetailContent({ event }: { event: NostrEvent }) { const { data: stats } = useEventStats(event.id); const { data: rawReplies, isLoading: repliesLoading } = useReplies(event.id); const replies = useMemo(() => { - if (!rawReplies) return rawReplies; - return rawReplies.filter((r) => { - if (muteItems.length > 0 && isEventMuted(r, muteItems)) return false; - if (isDeleted(r.id)) return false; - return true; - }); - }, [rawReplies, muteItems, isDeleted]); + if (!rawReplies || muteItems.length === 0) return rawReplies; + return rawReplies.filter((r) => !isEventMuted(r, muteItems)); + }, [rawReplies, muteItems]); const [moreMenuOpen, setMoreMenuOpen] = useState(false); const [replyOpen, setReplyOpen] = useState(false); const [interactionsOpen, setInteractionsOpen] = useState(false); diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index 23d2d8fd..236ef408 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -23,7 +23,6 @@ import { useProfileFollowing } from '@/hooks/useProfileFollowing'; import { usePinnedNotes } from '@/hooks/usePinnedNotes'; import { useFollowList, useFollowActions } from '@/hooks/useFollowActions'; import { useMuteList } from '@/hooks/useMuteList'; -import { useDeletedEvents } from '@/hooks/useDeleteEvent'; import { isEventMuted } from '@/lib/muteHelpers'; import { useProfileFeed, useProfileLikes as useProfileLikesInfinite } from '@/hooks/useProfileFeed'; import type { ProfileTab } from '@/hooks/useProfileFeed'; @@ -570,7 +569,6 @@ export function ProfilePage() { const { user } = useCurrentUser(); const { toast } = useToast(); const { muteItems } = useMuteList(); - const { isDeleted } = useDeletedEvents(); const [activeTab, setActiveTab] = useState('posts'); const [moreMenuOpen, setMoreMenuOpen] = useState(false); @@ -688,13 +686,12 @@ export function ProfilePage() { if (!seen.has(key)) { seen.add(key); if (muteItems.length > 0 && isEventMuted(item.event, muteItems)) continue; - if (isDeleted(item.event.id)) continue; items.push(item); } } } return items; - }, [feedData?.pages, muteItems, isDeleted]); + }, [feedData?.pages, muteItems]); // Flatten likes pages and deduplicate const likedItems = useMemo(() => { diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx index 4778ae98..060ff318 100644 --- a/src/pages/SearchPage.tsx +++ b/src/pages/SearchPage.tsx @@ -17,7 +17,6 @@ import { useSearchProfiles } from '@/hooks/useSearchProfiles'; import { useStreamPosts } from '@/hooks/useStreamPosts'; import { useTrendingTags, useSortedPosts, type SortMode } from '@/hooks/useTrending'; import { useMuteList } from '@/hooks/useMuteList'; -import { useDeletedEvents } from '@/hooks/useDeleteEvent'; import { isEventMuted } from '@/lib/muteHelpers'; import { genUserName } from '@/lib/genUserName'; import { getNostrIdentifierPath } from '@/lib/nostrIdentifier'; @@ -110,16 +109,11 @@ export function SearchPage() { const { data: trends, isLoading: trendsLoading } = useTrendingTags(isTrendsTab); const { data: rawSortedPosts, isLoading: sortedLoading } = useSortedPosts(trendSort, 5, isTrendsTab); const { muteItems } = useMuteList(); - const { isDeleted } = useDeletedEvents(); const sortedPosts = useMemo(() => { - if (!rawSortedPosts) return rawSortedPosts; - return rawSortedPosts.filter((e) => { - if (muteItems.length > 0 && isEventMuted(e, muteItems)) return false; - if (isDeleted(e.id)) return false; - return true; - }); - }, [rawSortedPosts, muteItems, isDeleted]); + if (!rawSortedPosts || muteItems.length === 0) return rawSortedPosts; + return rawSortedPosts.filter((e) => !isEventMuted(e, muteItems)); + }, [rawSortedPosts, muteItems]); // Filter by platform (Nostr/Mastodon) client-side const posts = useMemo(() => { From 08787c542ebea26d836426010bc2f74c2a138930 Mon Sep 17 00:00:00 2001 From: Chad Curtis Date: Sat, 21 Feb 2026 04:54:00 -0600 Subject: [PATCH 03/11] Add stream card rendering in feeds with action headers and pause-on-scroll Stream events (kind 30311) now render properly in mixed feeds via NoteCard with inline LiveStreamPlayer for live streams, thumbnail for ended/planned, title + summary clickable to open stream details, and 'is streaming' / 'streamed' action headers. LiveStreamPlayer pauses when scrolled out of view. --- src/components/LiveStreamPlayer.tsx | 19 ++++ src/components/NoteCard.tsx | 163 +++++++++++++++++++++++++++- 2 files changed, 180 insertions(+), 2 deletions(-) diff --git a/src/components/LiveStreamPlayer.tsx b/src/components/LiveStreamPlayer.tsx index 812c37cc..ed511243 100644 --- a/src/components/LiveStreamPlayer.tsx +++ b/src/components/LiveStreamPlayer.tsx @@ -93,6 +93,25 @@ export function LiveStreamPlayer({ src, poster, className }: LiveStreamPlayerPro }; }, [src]); + // Pause video when scrolled out of view + useEffect(() => { + const video = videoRef.current; + const container = containerRef.current; + if (!video || !container) return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (!entry.isIntersecting && !video.paused) { + video.pause(); + } + }, + { threshold: 0.25 }, + ); + + observer.observe(container); + return () => observer.disconnect(); + }, []); + // Track playback & buffering state useEffect(() => { const video = videoRef.current; diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx index 204c15b9..d06938ed 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -1,7 +1,8 @@ import { Link, useNavigate } from 'react-router-dom'; -import { MessageCircle, Zap, MoreHorizontal, Play } from 'lucide-react'; +import { MessageCircle, Zap, MoreHorizontal, Play, Radio, Users } from 'lucide-react'; import { RepostIcon } from '@/components/icons/RepostIcon'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; +import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; import { NoteContent } from '@/components/NoteContent'; import { VideoPlayer } from '@/components/VideoPlayer'; @@ -14,6 +15,7 @@ import { FoundLogContent } from '@/components/FoundLogContent'; import { ColorMomentContent } from '@/components/ColorMomentContent'; import { FollowPackContent } from '@/components/FollowPackContent'; import { ArticleContent } from '@/components/ArticleContent'; +import { LiveStreamPlayer } from '@/components/LiveStreamPlayer'; import { ChestIcon } from '@/components/icons/ChestIcon'; import { ReplyContext } from '@/components/ReplyContext'; import { Nip05Badge } from '@/components/Nip05Badge'; @@ -178,7 +180,8 @@ export function NoteCard({ event, className, repostedBy, compact }: NoteCardProp const isColor = event.kind === 3367; const isFollowPack = event.kind === 39089 || event.kind === 30000; const isArticle = event.kind === 30023; - const isTextNote = !isVine && !isPoll && !isGeocache && !isFoundLog && !isColor && !isFollowPack && !isArticle; + const isStream = event.kind === 30311; + const isTextNote = !isVine && !isPoll && !isGeocache && !isFoundLog && !isColor && !isFollowPack && !isArticle && !isStream; // Kind 1 specific const images = useMemo(() => isTextNote ? extractImages(event.content) : [], [event.content, isTextNote]); @@ -231,6 +234,11 @@ export function NoteCard({ event, className, repostedBy, compact }: NoteCardProp )} + {/* Stream header — " is streaming / streamed" */} + {isStream && ( + + )} + {/* Header: avatar + name/handle stacked */}
{author.isLoading ? ( @@ -307,6 +315,8 @@ export function NoteCard({ event, className, repostedBy, compact }: NoteCardProp ) : isArticle ? ( + ) : isStream ? ( + ) : ( <>
@@ -483,6 +493,125 @@ function VineMedia({ imeta, hashtags }: { imeta?: { url?: string; thumbnail?: st ); } +/** Stream status badge config. */ +function getStreamStatusConfig(status: string | undefined) { + switch (status) { + case 'live': + return { label: 'LIVE', className: 'bg-red-600 hover:bg-red-600 text-white border-red-600' }; + case 'ended': + return { label: 'ENDED', className: 'bg-muted text-muted-foreground border-border' }; + case 'planned': + return { label: 'PLANNED', className: 'bg-blue-600/90 hover:bg-blue-600/90 text-white border-blue-600' }; + default: + return { label: status?.toUpperCase() || 'UNKNOWN', className: 'bg-muted text-muted-foreground border-border' }; + } +} + +/** Inline content for kind 30311 live stream events. */ +function StreamContent({ event }: { event: NostrEvent }) { + const navigate = useNavigate(); + const title = getTag(event.tags, 'title') || 'Untitled Stream'; + const summary = getTag(event.tags, 'summary'); + const imageUrl = getTag(event.tags, 'image'); + const streamingUrl = getTag(event.tags, 'streaming'); + const status = getTag(event.tags, 'status'); + const currentParticipants = getTag(event.tags, 'current_participants'); + const statusConfig = getStreamStatusConfig(status); + + const isLive = status === 'live' && !!streamingUrl; + + const encodedId = useMemo(() => { + const dTag = getTag(event.tags, 'd') || ''; + return nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: dTag }); + }, [event]); + + return ( +
+ {/* Stream player / thumbnail */} +
+ {isLive ? ( + // Inline live player — clicks on the player are intercepted so they don't navigate away + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
e.stopPropagation()}> + + {/* Status + viewer overlay on top of the player */} +
+ +
+ {statusConfig.label} + + {currentParticipants && ( + + + {currentParticipants} + + )} +
+
+ ) : imageUrl ? ( +
+ { + (e.currentTarget.parentElement as HTMLElement).style.display = 'none'; + }} + /> +
+ + {statusConfig.label} + +
+ {currentParticipants && ( +
+ + {currentParticipants} +
+ )} +
+ ) : ( + // No image, no live stream — show a minimal placeholder with status +
+ + + {status === 'live' &&
} + {statusConfig.label} + + {currentParticipants && ( + + + {currentParticipants} + + )} +
+ )} +
+ + {/* Title + summary — clickable to open stream details */} + +
+ ); +} + function RepostHeader({ pubkey }: { pubkey: string }) { const author = useAuthor(pubkey); const name = author.data?.metadata?.name || genUserName(pubkey); @@ -511,6 +640,36 @@ function RepostHeader({ pubkey }: { pubkey: string }) { ); } +function StreamHeader({ pubkey, isLive }: { pubkey: string; isLive: boolean }) { + const author = useAuthor(pubkey); + const name = author.data?.metadata?.name || genUserName(pubkey); + const url = useMemo(() => getProfileUrl(pubkey, author.data?.metadata), [pubkey, author.data?.metadata]); + + return ( +
+
+ +
+
+ {author.isLoading ? ( + + ) : ( + e.stopPropagation()} + > + {author.data?.event ? {name} : name} + + )} + + {isLive ? 'is streaming' : 'streamed'} + +
+
+ ); +} + function TreasureHeader({ pubkey, variant }: { pubkey: string; variant: 'hid' | 'found' }) { const author = useAuthor(pubkey); const name = author.data?.metadata?.name || genUserName(pubkey); From ffc7672183e3326e3a0f63e8452e4dddf1a72348 Mon Sep 17 00:00:00 2001 From: Chad Curtis Date: Sat, 21 Feb 2026 05:03:46 -0600 Subject: [PATCH 04/11] Add Magic: The Gathering deck lists as extra kind (NIP-MG, kind 37381) Support kind 37381 addressable events for MTG deck sharing per NIP-MG spec. Includes MagicDeckContent component with card list rendering, format/archetype badges, commander/companion display, sideboard, foil indicators, and expand/collapse for large decks. Adds /decks route and sidebar navigation. --- src/App.tsx | 2 + src/AppRouter.tsx | 3 +- src/components/AppProvider.tsx | 2 + src/components/InitialSyncGate.tsx | 2 + src/components/LeftSidebar.tsx | 3 +- src/components/MagicDeckContent.tsx | 312 ++++++++++++++++++++++++++++ src/components/MobileDrawer.tsx | 3 +- src/components/NoteCard.tsx | 6 +- src/contexts/AppContext.ts | 4 + src/lib/extraKinds.ts | 10 + src/test/TestApp.tsx | 2 + 11 files changed, 345 insertions(+), 4 deletions(-) create mode 100644 src/components/MagicDeckContent.tsx diff --git a/src/App.tsx b/src/App.tsx index 5b83ccef..b1de73a9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -62,6 +62,8 @@ const defaultConfig: AppConfig = { feedIncludeColors: false, feedIncludePacks: false, feedIncludeStreams: false, + showDecks: false, + feedIncludeDecks: false, }, nip85StatsPubkey: "5f68e85ee174102ca8978eef302129f081f03456c884185d5ec1c1224ab633ea", nip85OnlyMode: true, diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index d842f043..6978e035 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -1,6 +1,6 @@ import { lazy } from "react"; import { BrowserRouter, Route, Routes } from "react-router-dom"; -import { Clapperboard, BarChart3, Palette, PartyPopper, FileText } from "lucide-react"; +import { Clapperboard, BarChart3, Palette, PartyPopper, FileText, Layers } from "lucide-react"; import { ScrollToTop } from "./components/ScrollToTop"; // Eager: critical path (home page + 404) @@ -40,6 +40,7 @@ export function AppRouter() { } />} /> } /> } />} /> + } />} /> } /> diff --git a/src/components/AppProvider.tsx b/src/components/AppProvider.tsx index dc8a119e..ce3f95e2 100644 --- a/src/components/AppProvider.tsx +++ b/src/components/AppProvider.tsx @@ -46,6 +46,8 @@ const FeedSettingsSchema = z.object({ feedIncludeColors: z.boolean().optional(), feedIncludePacks: z.boolean().optional(), feedIncludeStreams: z.boolean().optional(), + showDecks: z.boolean().optional(), + feedIncludeDecks: z.boolean().optional(), }).passthrough(); // Zod schema for AppConfig validation diff --git a/src/components/InitialSyncGate.tsx b/src/components/InitialSyncGate.tsx index 7b50b576..3851922b 100644 --- a/src/components/InitialSyncGate.tsx +++ b/src/components/InitialSyncGate.tsx @@ -374,6 +374,8 @@ function SetupQuestionnaire({ onComplete, onPreload, isSignup = false }: { feedIncludeColors: selectedContent.has('colors'), feedIncludePacks: selectedContent.has('packs'), feedIncludeStreams: selectedContent.has('streams'), + showDecks: selectedContent.has('decks'), + feedIncludeDecks: selectedContent.has('decks'), }; updateConfig((current) => ({ diff --git a/src/components/LeftSidebar.tsx b/src/components/LeftSidebar.tsx index 6b4a46df..88ba8943 100644 --- a/src/components/LeftSidebar.tsx +++ b/src/components/LeftSidebar.tsx @@ -1,6 +1,6 @@ import { useState, useMemo } from 'react'; import { Link, useLocation, useNavigate } from 'react-router-dom'; -import { Home, Bell, Search, Clapperboard, BarChart3, Palette, PartyPopper, Radio, FileText, User, Settings, Bookmark, UserPlus, LogOut, Check, Moon, Sun, Cat, Heart, ChevronDown } from 'lucide-react'; +import { Home, Bell, Search, Clapperboard, BarChart3, Palette, PartyPopper, Radio, FileText, Layers, User, Settings, Bookmark, UserPlus, LogOut, Check, Moon, Sun, Cat, Heart, ChevronDown } from 'lucide-react'; import { ChestIcon } from '@/components/icons/ChestIcon'; import { Button } from '@/components/ui/button'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; @@ -75,6 +75,7 @@ export function LeftSidebar() { packs: , streams: , articles: , + decks: , }; const navItems = useMemo(() => { diff --git a/src/components/MagicDeckContent.tsx b/src/components/MagicDeckContent.tsx new file mode 100644 index 00000000..16205595 --- /dev/null +++ b/src/components/MagicDeckContent.tsx @@ -0,0 +1,312 @@ +import { useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { Layers, Shield, Sparkles, Swords, ChevronDown, ChevronUp } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; +import type { NostrEvent } from '@nostrify/nostrify'; + +function getTag(tags: string[][], name: string): string | undefined { + return tags.find(([n]) => n === name)?.[1]; +} + +function getAllTagValues(tags: string[][], name: string): string[] { + return tags.filter(([n]) => n === name).map(([, v]) => v); +} + +/** A parsed card entry from `c` or `b` tags. */ +interface CardEntry { + name: string; + quantity: number; + setId: string; + artId: string; + lang: string; + foil: boolean; +} + +/** Parse a card tag (`c` or `b`) into a CardEntry. */ +function parseCardTag(tag: string[]): CardEntry | null { + if (tag.length < 3) return null; + const [, name, qty, setId, artId, lang, foil] = tag; + const quantity = parseInt(qty, 10); + if (!name || isNaN(quantity) || quantity < 1) return null; + return { + name, + quantity, + setId: setId ?? '', + artId: artId ?? '', + lang: lang ?? '', + foil: foil === 'foil' || foil === 'true', + }; +} + +/** Format labels for MTG formats. */ +const FORMAT_LABELS: Record = { + standard: 'Standard', + modern: 'Modern', + commander: 'Commander', + legacy: 'Legacy', + vintage: 'Vintage', + pioneer: 'Pioneer', + pauper: 'Pauper', + cedh: 'cEDH', + limited: 'Limited', + draft: 'Draft', + sealed: 'Sealed', + brawl: 'Brawl', + historic: 'Historic', + explorer: 'Explorer', + alchemy: 'Alchemy', + timeless: 'Timeless', +}; + +/** Non-format archetype labels. */ +const ARCHETYPE_LABELS: Record = { + aggro: 'Aggro', + midrange: 'Midrange', + control: 'Control', + combo: 'Combo', + tempo: 'Tempo', + ramp: 'Ramp', + tribal: 'Tribal', + burn: 'Burn', + mill: 'Mill', + stax: 'Stax', + tokens: 'Tokens', + reanimator: 'Reanimator', + voltron: 'Voltron', + aristocrats: 'Aristocrats', +}; + +/** Max cards to show in the preview before collapsing. */ +const PREVIEW_CARD_COUNT = 8; + +export function MagicDeckContent({ event }: { event: NostrEvent }) { + const title = getTag(event.tags, 'title'); + const banner = getTag(event.tags, 'banner'); + const commanders = getAllTagValues(event.tags, 'C'); + const companion = getTag(event.tags, 'S'); + const tTags = getAllTagValues(event.tags, 't'); + + const [expanded, setExpanded] = useState(false); + + // Parse main deck and sideboard + const mainDeck = useMemo(() => { + return event.tags + .filter(([n]) => n === 'c') + .map(parseCardTag) + .filter((c): c is CardEntry => c !== null); + }, [event.tags]); + + const sideboard = useMemo(() => { + return event.tags + .filter(([n]) => n === 'b') + .map(parseCardTag) + .filter((c): c is CardEntry => c !== null); + }, [event.tags]); + + // Separate format tags from archetype/other tags + const formatTags = useMemo(() => tTags.filter((t) => t in FORMAT_LABELS), [tTags]); + const archetypeTags = useMemo(() => tTags.filter((t) => t in ARCHETYPE_LABELS), [tTags]); + const otherTags = useMemo( + () => tTags.filter((t) => !(t in FORMAT_LABELS) && !(t in ARCHETYPE_LABELS)), + [tTags], + ); + + const totalCards = useMemo(() => mainDeck.reduce((sum, c) => sum + c.quantity, 0), [mainDeck]); + const totalSideboard = useMemo(() => sideboard.reduce((sum, c) => sum + c.quantity, 0), [sideboard]); + + const isCommander = formatTags.includes('commander') || formatTags.includes('cedh'); + const displayCards = expanded ? mainDeck : mainDeck.slice(0, PREVIEW_CARD_COUNT); + const hasMore = mainDeck.length > PREVIEW_CARD_COUNT; + + return ( +
+ {/* Banner image */} + {banner && ( +
+ {title { + (e.currentTarget.parentElement as HTMLElement).style.display = 'none'; + }} + /> +
+ )} + + {/* Title */} + {title && ( +
+ + {title} +
+ )} + + {/* Format + archetype badges */} + {(formatTags.length > 0 || archetypeTags.length > 0 || otherTags.length > 0) && ( +
+ {formatTags.map((tag) => ( + e.stopPropagation()} + > + + + {FORMAT_LABELS[tag] ?? tag} + + + ))} + {archetypeTags.map((tag) => ( + e.stopPropagation()} + > + + {ARCHETYPE_LABELS[tag] ?? tag} + + + ))} + {otherTags.map((tag) => ( + e.stopPropagation()} + > + + {tag} + + + ))} +
+ )} + + {/* Commander(s) */} + {commanders.length > 0 && ( +
+ + + {isCommander ? 'Commander' : 'Commander'} + {commanders.length > 1 ? 's' : ''}: + + + {commanders.join(' & ')} + +
+ )} + + {/* Companion */} + {companion && ( +
+ + Companion: + {companion} +
+ )} + + {/* Card count summary */} +
+ + + {totalCards} cards + + {totalSideboard > 0 && ( + + {totalSideboard} sideboard + + )} +
+ + {/* Card list */} + {mainDeck.length > 0 && ( +
+
+ {displayCards.map((card, i) => ( +
+
+ + {card.quantity}x + + + {card.name} + + {card.foil && ( + + )} +
+ {card.setId && ( + + {card.setId} + + )} +
+ ))} +
+ + {/* Expand/collapse */} + {hasMore && ( + + )} +
+ )} + + {/* Sideboard (only in expanded view) */} + {expanded && sideboard.length > 0 && ( +
+ Sideboard +
+
+ {sideboard.map((card, i) => ( +
+
+ + {card.quantity}x + + + {card.name} + + {card.foil && ( + + )} +
+ {card.setId && ( + + {card.setId} + + )} +
+ ))} +
+
+
+ )} +
+ ); +} diff --git a/src/components/MobileDrawer.tsx b/src/components/MobileDrawer.tsx index ae37fd02..76d3ed61 100644 --- a/src/components/MobileDrawer.tsx +++ b/src/components/MobileDrawer.tsx @@ -1,5 +1,5 @@ import { Link, useNavigate } from 'react-router-dom'; -import { User, Bookmark, Settings, LogOut, ChevronDown, ChevronUp, Cat, Sun, Moon, Heart, Clapperboard, BarChart3, Palette, PartyPopper, Radio, FileText } from 'lucide-react'; +import { User, Bookmark, Settings, LogOut, ChevronDown, ChevronUp, Cat, Sun, Moon, Heart, Clapperboard, BarChart3, Palette, PartyPopper, Radio, FileText, Layers } from 'lucide-react'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet'; import { Separator } from '@/components/ui/separator'; @@ -24,6 +24,7 @@ const ROUTE_ICONS: Record = { packs: , streams: , articles: , + decks: , }; diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx index d06938ed..c8d29b4f 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -15,6 +15,7 @@ import { FoundLogContent } from '@/components/FoundLogContent'; import { ColorMomentContent } from '@/components/ColorMomentContent'; import { FollowPackContent } from '@/components/FollowPackContent'; import { ArticleContent } from '@/components/ArticleContent'; +import { MagicDeckContent } from '@/components/MagicDeckContent'; import { LiveStreamPlayer } from '@/components/LiveStreamPlayer'; import { ChestIcon } from '@/components/icons/ChestIcon'; import { ReplyContext } from '@/components/ReplyContext'; @@ -180,8 +181,9 @@ export function NoteCard({ event, className, repostedBy, compact }: NoteCardProp const isColor = event.kind === 3367; const isFollowPack = event.kind === 39089 || event.kind === 30000; const isArticle = event.kind === 30023; + const isMagicDeck = event.kind === 37381; const isStream = event.kind === 30311; - const isTextNote = !isVine && !isPoll && !isGeocache && !isFoundLog && !isColor && !isFollowPack && !isArticle && !isStream; + const isTextNote = !isVine && !isPoll && !isGeocache && !isFoundLog && !isColor && !isFollowPack && !isArticle && !isMagicDeck && !isStream; // Kind 1 specific const images = useMemo(() => isTextNote ? extractImages(event.content) : [], [event.content, isTextNote]); @@ -315,6 +317,8 @@ export function NoteCard({ event, className, repostedBy, compact }: NoteCardProp ) : isArticle ? ( + ) : isMagicDeck ? ( + ) : isStream ? ( ) : ( diff --git a/src/contexts/AppContext.ts b/src/contexts/AppContext.ts index 72965571..2213984a 100644 --- a/src/contexts/AppContext.ts +++ b/src/contexts/AppContext.ts @@ -60,6 +60,10 @@ export interface FeedSettings { feedIncludePacks: boolean; /** Include Streams in the follows/global feed */ feedIncludeStreams: boolean; + /** Show Magic Decks (kind 37381) link in sidebar */ + showDecks: boolean; + /** Include Magic Decks in the follows/global feed */ + feedIncludeDecks: boolean; } export interface AppConfig { diff --git a/src/lib/extraKinds.ts b/src/lib/extraKinds.ts index cdd7cbf2..03a702ff 100644 --- a/src/lib/extraKinds.ts +++ b/src/lib/extraKinds.ts @@ -136,6 +136,16 @@ export const EXTRA_KINDS: ExtraKindDef[] = [ addressable: false, section: 'whimsy', }, + { + kind: 37381, + showKey: 'showDecks', + feedKey: 'feedIncludeDecks', + label: 'Magic Decks', + description: 'Magic: The Gathering deck lists', + route: 'decks', + addressable: true, + section: 'whimsy', + }, { kind: 37516, showKey: 'showTreasures', diff --git a/src/test/TestApp.tsx b/src/test/TestApp.tsx index 38cdcb63..50921c34 100644 --- a/src/test/TestApp.tsx +++ b/src/test/TestApp.tsx @@ -50,6 +50,8 @@ export function TestApp({ children }: TestAppProps) { feedIncludeColors: false, feedIncludePacks: false, feedIncludeStreams: false, + showDecks: false, + feedIncludeDecks: false, }, nip85StatsPubkey: '5f68e85ee174102ca8978eef302129f081f03456c884185d5ec1c1224ab633ea', nip85OnlyMode: false, From b7596c12653f519838e3d9a08d34e44517f22da0 Mon Sep 17 00:00:00 2001 From: Chad Curtis Date: Sat, 21 Feb 2026 05:07:01 -0600 Subject: [PATCH 05/11] Add CardsIcon SVG and hide t:hidden events for magic decks and geocaches Replace Layers icon with custom CardsIcon (stacked cards SVG) across all navigation and settings UIs. Filter out events tagged t:unlisted for kind 37381 (magic decks) and t:hidden for kind 37516 (geocaches) in NoteCard. --- src/AppRouter.tsx | 5 +++-- src/components/ContentSettings.tsx | 2 ++ src/components/FeedSettingsForm.tsx | 2 ++ src/components/LeftSidebar.tsx | 5 +++-- src/components/MagicDeckContent.tsx | 7 ++++--- src/components/MobileDrawer.tsx | 5 +++-- src/components/NoteCard.tsx | 8 ++++++++ src/components/icons/CardsIcon.tsx | 25 +++++++++++++++++++++++++ 8 files changed, 50 insertions(+), 9 deletions(-) create mode 100644 src/components/icons/CardsIcon.tsx diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 6978e035..644d57ee 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -1,6 +1,7 @@ import { lazy } from "react"; import { BrowserRouter, Route, Routes } from "react-router-dom"; -import { Clapperboard, BarChart3, Palette, PartyPopper, FileText, Layers } from "lucide-react"; +import { Clapperboard, BarChart3, Palette, PartyPopper, FileText } from "lucide-react"; +import { CardsIcon } from "./components/icons/CardsIcon"; import { ScrollToTop } from "./components/ScrollToTop"; // Eager: critical path (home page + 404) @@ -40,7 +41,7 @@ export function AppRouter() { } />} /> } /> } />} /> - } />} /> + } />} /> } /> diff --git a/src/components/ContentSettings.tsx b/src/components/ContentSettings.tsx index d77f48d7..cf506f81 100644 --- a/src/components/ContentSettings.tsx +++ b/src/components/ContentSettings.tsx @@ -203,6 +203,7 @@ export function ContentSettings() { // Import the internals from FeedSettingsForm (we'll need to export them) import { Clapperboard, BarChart3, Palette, PartyPopper, Radio, MessageSquare, Repeat2, FileText } from 'lucide-react'; import { ChestIcon } from '@/components/icons/ChestIcon'; +import { CardsIcon } from '@/components/icons/CardsIcon'; import { useFeedSettings } from '@/hooks/useFeedSettings'; import { useEncryptedSettings } from '@/hooks/useEncryptedSettings'; import { useCurrentUser } from '@/hooks/useCurrentUser'; @@ -219,6 +220,7 @@ const ICONS: Record = { packs: , streams: , articles: , + decks: , // Feed-only items (keyed by kind number) '1': , '6': , diff --git a/src/components/FeedSettingsForm.tsx b/src/components/FeedSettingsForm.tsx index 1f460de8..dc792b00 100644 --- a/src/components/FeedSettingsForm.tsx +++ b/src/components/FeedSettingsForm.tsx @@ -1,5 +1,6 @@ import { Clapperboard, BarChart3, Palette, PartyPopper, Radio, MessageSquare, Repeat2, FileText } from 'lucide-react'; import { ChestIcon } from '@/components/icons/ChestIcon'; +import { CardsIcon } from '@/components/icons/CardsIcon'; import { Switch } from '@/components/ui/switch'; import { useFeedSettings } from '@/hooks/useFeedSettings'; import { useEncryptedSettings } from '@/hooks/useEncryptedSettings'; @@ -17,6 +18,7 @@ const ICONS: Record = { packs: , streams: , articles: , + decks: , // Feed-only items (keyed by kind number) '1': , '6': , diff --git a/src/components/LeftSidebar.tsx b/src/components/LeftSidebar.tsx index 88ba8943..c02f91a5 100644 --- a/src/components/LeftSidebar.tsx +++ b/src/components/LeftSidebar.tsx @@ -1,7 +1,8 @@ import { useState, useMemo } from 'react'; import { Link, useLocation, useNavigate } from 'react-router-dom'; -import { Home, Bell, Search, Clapperboard, BarChart3, Palette, PartyPopper, Radio, FileText, Layers, User, Settings, Bookmark, UserPlus, LogOut, Check, Moon, Sun, Cat, Heart, ChevronDown } from 'lucide-react'; +import { Home, Bell, Search, Clapperboard, BarChart3, Palette, PartyPopper, Radio, FileText, User, Settings, Bookmark, UserPlus, LogOut, Check, Moon, Sun, Cat, Heart, ChevronDown } from 'lucide-react'; import { ChestIcon } from '@/components/icons/ChestIcon'; +import { CardsIcon } from '@/components/icons/CardsIcon'; import { Button } from '@/components/ui/button'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; @@ -75,7 +76,7 @@ export function LeftSidebar() { packs: , streams: , articles: , - decks: , + decks: , }; const navItems = useMemo(() => { diff --git a/src/components/MagicDeckContent.tsx b/src/components/MagicDeckContent.tsx index 16205595..66635801 100644 --- a/src/components/MagicDeckContent.tsx +++ b/src/components/MagicDeckContent.tsx @@ -1,6 +1,7 @@ import { useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; -import { Layers, Shield, Sparkles, Swords, ChevronDown, ChevronUp } from 'lucide-react'; +import { Shield, Sparkles, Swords, ChevronDown, ChevronUp } from 'lucide-react'; +import { CardsIcon } from '@/components/icons/CardsIcon'; import { Badge } from '@/components/ui/badge'; import { cn } from '@/lib/utils'; import type { NostrEvent } from '@nostrify/nostrify'; @@ -139,7 +140,7 @@ export function MagicDeckContent({ event }: { event: NostrEvent }) { {/* Title */} {title && (
- + {title}
)} @@ -210,7 +211,7 @@ export function MagicDeckContent({ event }: { event: NostrEvent }) { {/* Card count summary */}
- + {totalCards} cards {totalSideboard > 0 && ( diff --git a/src/components/MobileDrawer.tsx b/src/components/MobileDrawer.tsx index 76d3ed61..f9752d85 100644 --- a/src/components/MobileDrawer.tsx +++ b/src/components/MobileDrawer.tsx @@ -1,9 +1,10 @@ import { Link, useNavigate } from 'react-router-dom'; -import { User, Bookmark, Settings, LogOut, ChevronDown, ChevronUp, Cat, Sun, Moon, Heart, Clapperboard, BarChart3, Palette, PartyPopper, Radio, FileText, Layers } from 'lucide-react'; +import { User, Bookmark, Settings, LogOut, ChevronDown, ChevronUp, Cat, Sun, Moon, Heart, Clapperboard, BarChart3, Palette, PartyPopper, Radio, FileText } from 'lucide-react'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet'; import { Separator } from '@/components/ui/separator'; import { ChestIcon } from '@/components/icons/ChestIcon'; +import { CardsIcon } from '@/components/icons/CardsIcon'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useLoginActions } from '@/hooks/useLoginActions'; import { useLoggedInAccounts } from '@/hooks/useLoggedInAccounts'; @@ -24,7 +25,7 @@ const ROUTE_ICONS: Record = { packs: , streams: , articles: , - decks: , + decks: , }; diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx index c8d29b4f..1b631d55 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -218,6 +218,14 @@ export function NoteCard({ event, className, repostedBy, compact }: NoteCardProp return null; } + // Hide magic decks tagged t:unlisted and geocaches tagged t:hidden + if (isMagicDeck && event.tags.some(([n, v]) => n === 't' && v === 'unlisted')) { + return null; + } + if (isGeocache && event.tags.some(([n, v]) => n === 't' && v === 'hidden')) { + return null; + } + return (
>( + ({ className, ...props }, ref) => ( + + + + ), +); + +CardsIcon.displayName = 'CardsIcon'; From d0135d101422e1434df3e39ca5e76b869f24cc72 Mon Sep 17 00:00:00 2001 From: Chad Curtis Date: Sat, 21 Feb 2026 05:10:08 -0600 Subject: [PATCH 06/11] Rework MagicDeckContent: single-line meta row and scrollable card list Merge format badges, card count, and sideboard count onto one line. Replace expand/collapse with a ScrollArea box (max 240px) containing the full decklist with sideboard inline below a separator header. --- src/components/MagicDeckContent.tsx | 214 +++++++++++----------------- 1 file changed, 81 insertions(+), 133 deletions(-) diff --git a/src/components/MagicDeckContent.tsx b/src/components/MagicDeckContent.tsx index 66635801..ad73a780 100644 --- a/src/components/MagicDeckContent.tsx +++ b/src/components/MagicDeckContent.tsx @@ -1,8 +1,9 @@ -import { useMemo, useState } from 'react'; +import { useMemo } from 'react'; import { Link } from 'react-router-dom'; -import { Shield, Sparkles, Swords, ChevronDown, ChevronUp } from 'lucide-react'; +import { Shield, Sparkles, Swords } from 'lucide-react'; import { CardsIcon } from '@/components/icons/CardsIcon'; import { Badge } from '@/components/ui/badge'; +import { ScrollArea } from '@/components/ui/scroll-area'; import { cn } from '@/lib/utils'; import type { NostrEvent } from '@nostrify/nostrify'; @@ -78,8 +79,29 @@ const ARCHETYPE_LABELS: Record = { aristocrats: 'Aristocrats', }; -/** Max cards to show in the preview before collapsing. */ -const PREVIEW_CARD_COUNT = 8; +/** Render a single card row. */ +function CardRow({ card }: { card: CardEntry }) { + return ( +
+
+ + {card.quantity}x + + + {card.name} + + {card.foil && ( + + )} +
+ {card.setId && ( + + {card.setId} + + )} +
+ ); +} export function MagicDeckContent({ event }: { event: NostrEvent }) { const title = getTag(event.tags, 'title'); @@ -88,8 +110,6 @@ export function MagicDeckContent({ event }: { event: NostrEvent }) { const companion = getTag(event.tags, 'S'); const tTags = getAllTagValues(event.tags, 't'); - const [expanded, setExpanded] = useState(false); - // Parse main deck and sideboard const mainDeck = useMemo(() => { return event.tags @@ -116,10 +136,6 @@ export function MagicDeckContent({ event }: { event: NostrEvent }) { const totalCards = useMemo(() => mainDeck.reduce((sum, c) => sum + c.quantity, 0), [mainDeck]); const totalSideboard = useMemo(() => sideboard.reduce((sum, c) => sum + c.quantity, 0), [sideboard]); - const isCommander = formatTags.includes('commander') || formatTags.includes('cedh'); - const displayCards = expanded ? mainDeck : mainDeck.slice(0, PREVIEW_CARD_COUNT); - const hasMore = mainDeck.length > PREVIEW_CARD_COUNT; - return (
{/* Banner image */} @@ -145,53 +161,12 @@ export function MagicDeckContent({ event }: { event: NostrEvent }) {
)} - {/* Format + archetype badges */} - {(formatTags.length > 0 || archetypeTags.length > 0 || otherTags.length > 0) && ( -
- {formatTags.map((tag) => ( - e.stopPropagation()} - > - - - {FORMAT_LABELS[tag] ?? tag} - - - ))} - {archetypeTags.map((tag) => ( - e.stopPropagation()} - > - - {ARCHETYPE_LABELS[tag] ?? tag} - - - ))} - {otherTags.map((tag) => ( - e.stopPropagation()} - > - - {tag} - - - ))} -
- )} - {/* Commander(s) */} {commanders.length > 0 && (
- {isCommander ? 'Commander' : 'Commander'} - {commanders.length > 1 ? 's' : ''}: + Commander{commanders.length > 1 ? 's' : ''}: {commanders.join(' & ')} @@ -208,8 +183,42 @@ export function MagicDeckContent({ event }: { event: NostrEvent }) {
)} - {/* Card count summary */} -
+ {/* Format badges + card count + sideboard — all on one line */} +
+ {formatTags.map((tag) => ( + e.stopPropagation()} + > + + + {FORMAT_LABELS[tag] ?? tag} + + + ))} + {archetypeTags.map((tag) => ( + e.stopPropagation()} + > + + {ARCHETYPE_LABELS[tag] ?? tag} + + + ))} + {otherTags.map((tag) => ( + e.stopPropagation()} + > + + {tag} + + + ))} {totalCards} cards @@ -221,91 +230,30 @@ export function MagicDeckContent({ event }: { event: NostrEvent }) { )}
- {/* Card list */} + {/* Card list — scrollable box */} {mainDeck.length > 0 && ( -
-
- {displayCards.map((card, i) => ( -
-
- - {card.quantity}x - - - {card.name} - - {card.foil && ( - - )} -
- {card.setId && ( - - {card.setId} - - )} -
- ))} -
+
e.stopPropagation()}> + +
+ {mainDeck.map((card, i) => ( + + ))} - {/* Expand/collapse */} - {hasMore && ( - - )} -
- )} - - {/* Sideboard (only in expanded view) */} - {expanded && sideboard.length > 0 && ( -
- Sideboard -
-
- {sideboard.map((card, i) => ( -
-
- - {card.quantity}x - - - {card.name} - - {card.foil && ( - - )} -
- {card.setId && ( - - {card.setId} - - )} -
- ))}
-
+
)}
From b4e3e63a8f92a53974f7a5c1e1feaed1cf4c38c4 Mon Sep 17 00:00:00 2001 From: Chad Curtis Date: Sat, 21 Feb 2026 05:17:11 -0600 Subject: [PATCH 07/11] Add visual spoiler toggle for magic deck card lists Decklist box now has a List/Visual toggle in the header. Visual mode renders a 4-column grid of card images loaded from Scryfall's API using set/collector_number for exact printings or card name as fallback. Includes quantity badges, foil shimmer overlay, and graceful fallback to card name text when images fail to load. --- src/components/MagicDeckContent.tsx | 149 +++++++++++++++++++++++----- 1 file changed, 125 insertions(+), 24 deletions(-) diff --git a/src/components/MagicDeckContent.tsx b/src/components/MagicDeckContent.tsx index ad73a780..9a1b5e4e 100644 --- a/src/components/MagicDeckContent.tsx +++ b/src/components/MagicDeckContent.tsx @@ -1,6 +1,6 @@ -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; -import { Shield, Sparkles, Swords } from 'lucide-react'; +import { Shield, Sparkles, Swords, Image, List } from 'lucide-react'; import { CardsIcon } from '@/components/icons/CardsIcon'; import { Badge } from '@/components/ui/badge'; import { ScrollArea } from '@/components/ui/scroll-area'; @@ -41,6 +41,18 @@ function parseCardTag(tag: string[]): CardEntry | null { }; } +/** + * Build a Scryfall image URL for a card. + * Uses set/collector_number when available for exact printing, + * otherwise falls back to exact name lookup. + */ +function scryfallImageUrl(card: CardEntry, version: 'small' | 'normal' = 'small'): string { + if (card.setId && card.artId) { + return `https://api.scryfall.com/cards/${encodeURIComponent(card.setId.toLowerCase())}/${encodeURIComponent(card.artId)}?format=image&version=${version}`; + } + return `https://api.scryfall.com/cards/named?exact=${encodeURIComponent(card.name)}&format=image&version=${version}`; +} + /** Format labels for MTG formats. */ const FORMAT_LABELS: Record = { standard: 'Standard', @@ -79,7 +91,7 @@ const ARCHETYPE_LABELS: Record = { aristocrats: 'Aristocrats', }; -/** Render a single card row. */ +/** Render a single card row in list view. */ function CardRow({ card }: { card: CardEntry }) { return (
@@ -103,12 +115,83 @@ function CardRow({ card }: { card: CardEntry }) { ); } +/** Render a card as a visual image tile. */ +function CardTile({ card }: { card: CardEntry }) { + const [failed, setFailed] = useState(false); + + if (failed) { + // Fallback: show a placeholder with the card name + return ( +
+ + {card.name} + + {card.quantity > 1 && } +
+ ); + } + + return ( +
+ {card.name} setFailed(true)} + /> + {card.foil && ( +
+ )} + {card.quantity > 1 && } +
+ ); +} + +function QuantityBadge({ quantity }: { quantity: number }) { + return ( + + x{quantity} + + ); +} + +/** Visual spoiler grid of card images. */ +function CardGrid({ cards, sideboard }: { cards: CardEntry[]; sideboard: CardEntry[] }) { + return ( + +
+
+ {cards.map((card, i) => ( + + ))} +
+ {sideboard.length > 0 && ( + <> +
+ + Sideboard + +
+
+ {sideboard.map((card, i) => ( + + ))} +
+ + )} +
+
+ ); +} + export function MagicDeckContent({ event }: { event: NostrEvent }) { const title = getTag(event.tags, 'title'); const banner = getTag(event.tags, 'banner'); const commanders = getAllTagValues(event.tags, 'C'); const companion = getTag(event.tags, 'S'); const tTags = getAllTagValues(event.tags, 't'); + const [visualView, setVisualView] = useState(false); // Parse main deck and sideboard const mainDeck = useMemo(() => { @@ -230,30 +313,48 @@ export function MagicDeckContent({ event }: { event: NostrEvent }) { )}
- {/* Card list — scrollable box */} + {/* Card list / visual spoiler */} {mainDeck.length > 0 && (
e.stopPropagation()}> - -
- {mainDeck.map((card, i) => ( - - ))} + {/* View toggle header */} +
+ + {visualView ? 'Visual Spoiler' : 'Decklist'} + + +
- {/* Sideboard inline */} - {sideboard.length > 0 && ( - <> -
- - Sideboard - -
- {sideboard.map((card, i) => ( - - ))} - - )} -
-
+ {visualView ? ( + + ) : ( + +
+ {mainDeck.map((card, i) => ( + + ))} + + {/* Sideboard inline */} + {sideboard.length > 0 && ( + <> +
+ + Sideboard + +
+ {sideboard.map((card, i) => ( + + ))} + + )} +
+
+ )}
)}
From 3c4d6d0efc3eddb016692ca64e2c7d4ed42a9de8 Mon Sep 17 00:00:00 2001 From: Chad Curtis Date: Sat, 21 Feb 2026 05:19:38 -0600 Subject: [PATCH 08/11] Fix scroll and add card lightbox gallery to magic deck content Replace ScrollArea with overflow-y-auto for reliable scrolling in both list and visual views. Clicking any card (in either view) opens a full-screen lightbox showing the card image from Scryfall at large size, with prev/next navigation via arrows, keyboard, and swipe gestures. Shows card name and foil indicator in the lightbox header. --- src/components/MagicDeckContent.tsx | 273 ++++++++++++++++++++++------ 1 file changed, 220 insertions(+), 53 deletions(-) diff --git a/src/components/MagicDeckContent.tsx b/src/components/MagicDeckContent.tsx index 9a1b5e4e..4f96e5c8 100644 --- a/src/components/MagicDeckContent.tsx +++ b/src/components/MagicDeckContent.tsx @@ -1,9 +1,8 @@ -import { useMemo, useState } from 'react'; +import { useMemo, useState, useCallback, useEffect } from 'react'; import { Link } from 'react-router-dom'; -import { Shield, Sparkles, Swords, Image, List } from 'lucide-react'; +import { Shield, Sparkles, Swords, Image, List, ChevronLeft, ChevronRight, X } from 'lucide-react'; import { CardsIcon } from '@/components/icons/CardsIcon'; import { Badge } from '@/components/ui/badge'; -import { ScrollArea } from '@/components/ui/scroll-area'; import { cn } from '@/lib/utils'; import type { NostrEvent } from '@nostrify/nostrify'; @@ -46,7 +45,7 @@ function parseCardTag(tag: string[]): CardEntry | null { * Uses set/collector_number when available for exact printing, * otherwise falls back to exact name lookup. */ -function scryfallImageUrl(card: CardEntry, version: 'small' | 'normal' = 'small'): string { +function scryfallImageUrl(card: CardEntry, version: 'small' | 'normal' | 'large' = 'small'): string { if (card.setId && card.artId) { return `https://api.scryfall.com/cards/${encodeURIComponent(card.setId.toLowerCase())}/${encodeURIComponent(card.artId)}?format=image&version=${version}`; } @@ -92,9 +91,12 @@ const ARCHETYPE_LABELS: Record = { }; /** Render a single card row in list view. */ -function CardRow({ card }: { card: CardEntry }) { +function CardRow({ card, onClick }: { card: CardEntry; onClick?: () => void }) { return ( -
+
{card.quantity}x @@ -116,13 +118,15 @@ function CardRow({ card }: { card: CardEntry }) { } /** Render a card as a visual image tile. */ -function CardTile({ card }: { card: CardEntry }) { +function CardTile({ card, onClick }: { card: CardEntry; onClick?: () => void }) { const [failed, setFailed] = useState(false); if (failed) { - // Fallback: show a placeholder with the card name return ( -
+
{card.name} @@ -132,7 +136,7 @@ function CardTile({ card }: { card: CardEntry }) { } return ( -
+
{card.name} void; + onNext: () => void; + onPrev: () => void; +}) { + const [isLoaded, setIsLoaded] = useState(false); + const [touchStart, setTouchStart] = useState(null); + const [touchDelta, setTouchDelta] = useState(0); + const [isDragging, setIsDragging] = useState(false); + + const card = cards[currentIndex]; + const hasMultiple = cards.length > 1; + const imageUrl = scryfallImageUrl(card, 'large'); + + useEffect(() => { setIsLoaded(false); }, [currentIndex]); + + // Keyboard navigation + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + else if (e.key === 'ArrowRight' && hasMultiple) onNext(); + else if (e.key === 'ArrowLeft' && hasMultiple) onPrev(); + }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [onClose, onNext, onPrev, hasMultiple]); + + // Lock body scroll + useEffect(() => { + const original = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { document.body.style.overflow = original; }; + }, []); + + const handleTouchStart = (e: React.TouchEvent) => { setTouchStart(e.touches[0].clientX); setIsDragging(true); }; + const handleTouchMove = (e: React.TouchEvent) => { if (touchStart !== null) setTouchDelta(e.touches[0].clientX - touchStart); }; + const handleTouchEnd = () => { + if (Math.abs(touchDelta) > 60 && hasMultiple) { touchDelta > 0 ? onPrev() : onNext(); } + setTouchStart(null); setTouchDelta(0); setIsDragging(false); + }; + + const handleBackdropClick = (e: React.MouseEvent) => { + const target = e.target as HTMLElement; + if (target.tagName === 'IMG' || target.closest('button') || target.closest('[data-gallery-topbar]')) return; + e.stopPropagation(); e.preventDefault(); onClose(); + }; + return ( - -
-
- {cards.map((card, i) => ( - +
+
+ + {/* Top bar */} +
+
+ {hasMultiple && ( + + {currentIndex + 1} / {cards.length} + + )} + + {card.name} + + {card.foil && } +
+ +
+ + {/* Prev/Next buttons */} + {hasMultiple && ( + + )} + {hasMultiple && ( + + )} + + {/* Card image */} +
+ {!isLoaded && ( +
+
+
+ )} + {card.name} setIsLoaded(true)} + draggable={false} + /> +
+ + {/* Dot indicators (mobile, small decks) */} + {hasMultiple && cards.length <= 20 && ( +
+ {cards.map((_, i) => ( +
))}
- {sideboard.length > 0 && ( - <> -
- - Sideboard - -
-
- {sideboard.map((card, i) => ( - - ))} -
- - )} -
- + )} +
); } @@ -192,6 +311,7 @@ export function MagicDeckContent({ event }: { event: NostrEvent }) { const companion = getTag(event.tags, 'S'); const tTags = getAllTagValues(event.tags, 't'); const [visualView, setVisualView] = useState(false); + const [lightboxIndex, setLightboxIndex] = useState(null); // Parse main deck and sideboard const mainDeck = useMemo(() => { @@ -208,6 +328,9 @@ export function MagicDeckContent({ event }: { event: NostrEvent }) { .filter((c): c is CardEntry => c !== null); }, [event.tags]); + // All cards in one flat list for lightbox navigation + const allCards = useMemo(() => [...mainDeck, ...sideboard], [mainDeck, sideboard]); + // Separate format tags from archetype/other tags const formatTags = useMemo(() => tTags.filter((t) => t in FORMAT_LABELS), [tTags]); const archetypeTags = useMemo(() => tTags.filter((t) => t in ARCHETYPE_LABELS), [tTags]); @@ -219,6 +342,11 @@ export function MagicDeckContent({ event }: { event: NostrEvent }) { const totalCards = useMemo(() => mainDeck.reduce((sum, c) => sum + c.quantity, 0), [mainDeck]); const totalSideboard = useMemo(() => sideboard.reduce((sum, c) => sum + c.quantity, 0), [sideboard]); + const openLightbox = useCallback((index: number) => { setLightboxIndex(index); }, []); + const closeLightbox = useCallback(() => { setLightboxIndex(null); }, []); + const goNext = useCallback(() => { setLightboxIndex((prev) => (prev !== null ? (prev + 1) % allCards.length : null)); }, [allCards.length]); + const goPrev = useCallback(() => { setLightboxIndex((prev) => (prev !== null ? (prev - 1 + allCards.length) % allCards.length : null)); }, [allCards.length]); + return (
{/* Banner image */} @@ -331,32 +459,71 @@ export function MagicDeckContent({ event }: { event: NostrEvent }) {
{visualView ? ( - - ) : ( - -
+ /* Visual spoiler grid */ +
+
{mainDeck.map((card, i) => ( - + openLightbox(i)} /> ))} - - {/* Sideboard inline */} - {sideboard.length > 0 && ( - <> -
- - Sideboard - -
- {sideboard.map((card, i) => ( - - ))} - - )}
- + {sideboard.length > 0 && ( + <> +
+ + Sideboard + +
+
+ {sideboard.map((card, i) => ( + openLightbox(mainDeck.length + i)} + /> + ))} +
+ + )} +
+ ) : ( + /* Text decklist */ +
+ {mainDeck.map((card, i) => ( + openLightbox(i)} /> + ))} + + {/* Sideboard inline */} + {sideboard.length > 0 && ( + <> +
+ + Sideboard + +
+ {sideboard.map((card, i) => ( + openLightbox(mainDeck.length + i)} + /> + ))} + + )} +
)}
)} + + {/* Card lightbox */} + {lightboxIndex !== null && allCards.length > 0 && ( + + )}
); } From 3d1ec85f7731f92fd68b83db2187893b30c0335b Mon Sep 17 00:00:00 2001 From: Chad Curtis Date: Sat, 21 Feb 2026 05:22:46 -0600 Subject: [PATCH 09/11] Use Palette icon for visual toggle and render deck content on post detail page Replace Image icon with Palette for the list/visual toggle button. Add MagicDeckContent to PostDetailPage kind dispatch so deck events show their full content when viewed as a post detail (naddr route). --- src/components/MagicDeckContent.tsx | 4 ++-- src/pages/PostDetailPage.tsx | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/components/MagicDeckContent.tsx b/src/components/MagicDeckContent.tsx index 4f96e5c8..88fd0724 100644 --- a/src/components/MagicDeckContent.tsx +++ b/src/components/MagicDeckContent.tsx @@ -1,6 +1,6 @@ import { useMemo, useState, useCallback, useEffect } from 'react'; import { Link } from 'react-router-dom'; -import { Shield, Sparkles, Swords, Image, List, ChevronLeft, ChevronRight, X } from 'lucide-react'; +import { Shield, Sparkles, Swords, Palette, List, ChevronLeft, ChevronRight, X } from 'lucide-react'; import { CardsIcon } from '@/components/icons/CardsIcon'; import { Badge } from '@/components/ui/badge'; import { cn } from '@/lib/utils'; @@ -453,7 +453,7 @@ export function MagicDeckContent({ event }: { event: NostrEvent }) { onClick={() => setVisualView(!visualView)} className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground transition-colors" > - {visualView ? : } + {visualView ? : } {visualView ? 'List' : 'Visual'}
diff --git a/src/pages/PostDetailPage.tsx b/src/pages/PostDetailPage.tsx index 5de79f01..3e014a30 100644 --- a/src/pages/PostDetailPage.tsx +++ b/src/pages/PostDetailPage.tsx @@ -31,6 +31,7 @@ import { ColorMomentContent } from '@/components/ColorMomentContent'; import { FollowPackContent } from '@/components/FollowPackContent'; import { FollowPackDetailContent } from '@/components/FollowPackDetailContent'; import { ArticleContent } from '@/components/ArticleContent'; +import { MagicDeckContent } from '@/components/MagicDeckContent'; import { LiveStreamPage } from '@/components/LiveStreamPage'; import { useEvent, useAddrEvent, type AddrCoords } from '@/hooks/useEvent'; @@ -636,7 +637,8 @@ function PostDetailContent({ event }: { event: NostrEvent }) { const isColor = event.kind === 3367; const isFollowPack = event.kind === 39089 || event.kind === 30000; const isArticle = event.kind === 30023; - const isTextNote = !isVine && !isPoll && !isGeocache && !isFoundLog && !isColor && !isFollowPack && !isArticle; + const isMagicDeck = event.kind === 37381; + const isTextNote = !isVine && !isPoll && !isGeocache && !isFoundLog && !isColor && !isFollowPack && !isArticle && !isMagicDeck; const images = useMemo(() => isTextNote ? extractImages(event.content) : [], [event.content, isTextNote]); const videos = useMemo(() => isTextNote ? extractVideos(event.content) : [], [event.content, isTextNote]); @@ -725,6 +727,8 @@ function PostDetailContent({ event }: { event: NostrEvent }) { {isArticle ? ( + ) : isMagicDeck ? ( + ) : isVine || isPoll || isGeocache || isFoundLog || isColor || isFollowPack ? ( <> {isVine && } From 7b38637af5457810d96d3c274f9f1760fdc4379b Mon Sep 17 00:00:00 2001 From: Chad Curtis Date: Sat, 21 Feb 2026 05:27:56 -0600 Subject: [PATCH 10/11] Add 'shared a deck' action header for magic deck events in feed Shows ' shared a deck' above magic deck cards in the feed, matching the pattern of treasure/stream action headers. Suppressed when the card is already showing a repost header to avoid double headers. --- src/components/NoteCard.tsx | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx index 1b631d55..902d6fca 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -18,6 +18,7 @@ import { ArticleContent } from '@/components/ArticleContent'; import { MagicDeckContent } from '@/components/MagicDeckContent'; import { LiveStreamPlayer } from '@/components/LiveStreamPlayer'; import { ChestIcon } from '@/components/icons/ChestIcon'; +import { CardsIcon } from '@/components/icons/CardsIcon'; import { ReplyContext } from '@/components/ReplyContext'; import { Nip05Badge } from '@/components/Nip05Badge'; import { ProfileHoverCard } from '@/components/ProfileHoverCard'; @@ -244,6 +245,11 @@ export function NoteCard({ event, className, repostedBy, compact }: NoteCardProp )} + {/* Deck header — " shared a deck" */} + {isMagicDeck && !repostedBy && ( + + )} + {/* Stream header — " is streaming / streamed" */} {isStream && ( @@ -682,6 +688,34 @@ function StreamHeader({ pubkey, isLive }: { pubkey: string; isLive: boolean }) { ); } +function DeckHeader({ pubkey }: { pubkey: string }) { + const author = useAuthor(pubkey); + const name = author.data?.metadata?.name || genUserName(pubkey); + const url = useMemo(() => getProfileUrl(pubkey, author.data?.metadata), [pubkey, author.data?.metadata]); + + return ( +
+
+ +
+
+ {author.isLoading ? ( + + ) : ( + e.stopPropagation()} + > + {author.data?.event ? {name} : name} + + )} + shared a deck +
+
+ ); +} + function TreasureHeader({ pubkey, variant }: { pubkey: string; variant: 'hid' | 'found' }) { const author = useAuthor(pubkey); const name = author.data?.metadata?.name || genUserName(pubkey); From c5650507795a7bd6d7102664f24f9c9c33bb2f13 Mon Sep 17 00:00:00 2001 From: Chad Curtis Date: Sat, 21 Feb 2026 05:34:24 -0600 Subject: [PATCH 11/11] Build v2026.02.21: Update mew.apk (6.3M) --- android/app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 2616ba56..34a07b9c 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -13,8 +13,8 @@ android { applicationId "com.mew.app" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 20260220 - versionName "2026.02.20" + versionCode 20260221 + versionName "2026.02.21" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.