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. 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..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 } from "lucide-react"; +import { CardsIcon } from "./components/icons/CardsIcon"; import { ScrollToTop } from "./components/ScrollToTop"; // Eager: critical path (home page + 404) @@ -40,6 +41,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/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/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..c02f91a5 100644 --- a/src/components/LeftSidebar.tsx +++ b/src/components/LeftSidebar.tsx @@ -2,6 +2,7 @@ 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 { 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,6 +76,7 @@ export function LeftSidebar() { packs: , streams: , articles: , + decks: , }; const navItems = useMemo(() => { 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/MagicDeckContent.tsx b/src/components/MagicDeckContent.tsx new file mode 100644 index 00000000..88fd0724 --- /dev/null +++ b/src/components/MagicDeckContent.tsx @@ -0,0 +1,529 @@ +import { useMemo, useState, useCallback, useEffect } from 'react'; +import { Link } from 'react-router-dom'; +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'; +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', + }; +} + +/** + * 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' | '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}`; + } + 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', + 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', +}; + +/** Render a single card row in list view. */ +function CardRow({ card, onClick }: { card: CardEntry; onClick?: () => void }) { + return ( +
+
+ + {card.quantity}x + + + {card.name} + + {card.foil && ( + + )} +
+ {card.setId && ( + + {card.setId} + + )} +
+ ); +} + +/** Render a card as a visual image tile. */ +function CardTile({ card, onClick }: { card: CardEntry; onClick?: () => void }) { + const [failed, setFailed] = useState(false); + + if (failed) { + return ( +
+ + {card.name} + + {card.quantity > 1 && } +
+ ); + } + + return ( +
+ {card.name} setFailed(true)} + /> + {card.foil && ( +
+ )} + {card.quantity > 1 && } +
+ ); +} + +function QuantityBadge({ quantity }: { quantity: number }) { + return ( + + x{quantity} + + ); +} + +/** Full-screen card lightbox with prev/next navigation. */ +function CardLightbox({ cards, currentIndex, onClose, onNext, onPrev }: { + cards: CardEntry[]; + currentIndex: number; + onClose: () => 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 ( +
+
+ + {/* 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) => ( +
+ ))} +
+ )} +
+ ); +} + +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); + const [lightboxIndex, setLightboxIndex] = useState(null); + + // 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]); + + // 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]); + 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 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 */} + {banner && ( +
+ {title { + (e.currentTarget.parentElement as HTMLElement).style.display = 'none'; + }} + /> +
+ )} + + {/* Title */} + {title && ( +
+ + {title} +
+ )} + + {/* Commander(s) */} + {commanders.length > 0 && ( +
+ + + Commander{commanders.length > 1 ? 's' : ''}: + + + {commanders.join(' & ')} + +
+ )} + + {/* Companion */} + {companion && ( +
+ + Companion: + {companion} +
+ )} + + {/* 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 + + {totalSideboard > 0 && ( + + {totalSideboard} sideboard + + )} +
+ + {/* Card list / visual spoiler */} + {mainDeck.length > 0 && ( +
e.stopPropagation()}> + {/* View toggle header */} +
+ + {visualView ? 'Visual Spoiler' : 'Decklist'} + + +
+ + {visualView ? ( + /* Visual spoiler grid */ +
+
+ {mainDeck.map((card, i) => ( + openLightbox(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 && ( + + )} +
+ ); +} diff --git a/src/components/MobileDrawer.tsx b/src/components/MobileDrawer.tsx index ae37fd02..f9752d85 100644 --- a/src/components/MobileDrawer.tsx +++ b/src/components/MobileDrawer.tsx @@ -4,6 +4,7 @@ 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,6 +25,7 @@ const ROUTE_ICONS: Record = { packs: , streams: , articles: , + decks: , }; diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx index 5ecc7a1c..cedfb912 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'; @@ -15,7 +16,10 @@ import { ColorMomentContent } from '@/components/ColorMomentContent'; import { FollowPackContent } from '@/components/FollowPackContent'; import { ArticleContent } from '@/components/ArticleContent'; import { WebxdcEmbed } from '@/components/WebxdcEmbed'; +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'; @@ -185,7 +189,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 isTextNote = !isVine && !isPoll && !isGeocache && !isFoundLog && !isColor && !isFollowPack && !isArticle; + const isMagicDeck = event.kind === 37381; + const isStream = event.kind === 30311; + const isTextNote = !isVine && !isPoll && !isGeocache && !isFoundLog && !isColor && !isFollowPack && !isArticle && !isMagicDeck && !isStream; // Kind 1 specific const images = useMemo(() => isTextNote ? extractImages(event.content) : [], [event.content, isTextNote]); @@ -228,6 +234,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 (
)} + {/* Deck header — " shared a deck" */} + {isMagicDeck && !repostedBy && ( + + )} + + {/* Stream header — " is streaming / streamed" */} + {isStream && ( + + )} + {/* Header: avatar + name/handle stacked */}
{author.isLoading ? ( @@ -322,6 +346,10 @@ export function NoteCard({ event, className, repostedBy, compact }: NoteCardProp ) : isArticle ? ( + ) : isMagicDeck ? ( + + ) : isStream ? ( + ) : ( <>
@@ -503,6 +531,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); @@ -531,6 +678,64 @@ 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 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); 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/icons/CardsIcon.tsx b/src/components/icons/CardsIcon.tsx new file mode 100644 index 00000000..faa56453 --- /dev/null +++ b/src/components/icons/CardsIcon.tsx @@ -0,0 +1,25 @@ +import React from 'react'; + +/** + * Stacked cards icon — used for Magic: The Gathering decks. + * Rendered as a filled SVG component styled with currentColor. + */ +export const CardsIcon = React.forwardRef>( + ({ className, ...props }, ref) => ( + + + + ), +); + +CardsIcon.displayName = 'CardsIcon'; 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/hooks/useDeleteEvent.ts b/src/hooks/useDeleteEvent.ts new file mode 100644 index 00000000..90b38a9c --- /dev/null +++ b/src/hooks/useDeleteEvent.ts @@ -0,0 +1,41 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { useCurrentUser } from './useCurrentUser'; +import { useNostrPublish } from './useNostrPublish'; + +/** + * Hook to publish a kind 5 deletion request event (NIP-09). + * After publishing, invalidates feed caches so relays are re-queried + * and the deleted event is no longer returned. + */ +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: () => { + // 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'] }); + queryClient.invalidateQueries({ queryKey: ['replies'] }); + queryClient.invalidateQueries({ queryKey: ['notifications'] }); + }, + }); +} 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/pages/PostDetailPage.tsx b/src/pages/PostDetailPage.tsx index 98dae305..8866342f 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 { WebxdcEmbed } from '@/components/WebxdcEmbed'; import { useEvent, useAddrEvent, type AddrCoords } from '@/hooks/useEvent'; @@ -642,7 +643,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]); @@ -740,6 +742,8 @@ function PostDetailContent({ event }: { event: NostrEvent }) { {isArticle ? ( + ) : isMagicDeck ? ( + ) : isVine || isPoll || isGeocache || isFoundLog || isColor || isFollowPack ? ( <> {isVine && } diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index 8c6a1b0f..236ef408 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -570,7 +570,6 @@ export function ProfilePage() { const { toast } = useToast(); const { muteItems } = useMuteList(); - const [activeTab, setActiveTab] = useState('posts'); const [moreMenuOpen, setMoreMenuOpen] = useState(false); const [followingModalOpen, setFollowingModalOpen] = useState(false); 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,