Merge branch 'main' of gitlab.com:soapbox-pub/ditto-mew

This commit is contained in:
Alex Gleason
2026-02-21 12:48:29 -06:00
20 changed files with 919 additions and 6 deletions
+2 -2
View File
@@ -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.
+2
View File
@@ -62,6 +62,8 @@ const defaultConfig: AppConfig = {
feedIncludeColors: false,
feedIncludePacks: false,
feedIncludeStreams: false,
showDecks: false,
feedIncludeDecks: false,
},
nip85StatsPubkey: "5f68e85ee174102ca8978eef302129f081f03456c884185d5ec1c1224ab633ea",
nip85OnlyMode: true,
+2
View File
@@ -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() {
<Route path="/packs" element={<KindFeedPage kind={39089} title="Follow Packs" icon={<PartyPopper className="size-5" />} />} />
<Route path="/streams" element={<StreamsFeedPage />} />
<Route path="/articles" element={<KindFeedPage kind={30023} title="Articles" icon={<FileText className="size-5" />} />} />
<Route path="/decks" element={<KindFeedPage kind={37381} title="Magic Decks" icon={<CardsIcon className="size-5" />} />} />
<Route path="/bookmarks" element={<BookmarksPage />} />
+2
View File
@@ -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
+2
View File
@@ -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<string, React.ReactNode> = {
packs: <PartyPopper className="size-5" />,
streams: <Radio className="size-5" />,
articles: <FileText className="size-5" />,
decks: <CardsIcon className="size-5" />,
// Feed-only items (keyed by kind number)
'1': <MessageSquare className="size-5" />,
'6': <Repeat2 className="size-5" />,
+2
View File
@@ -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<string, React.ReactNode> = {
packs: <PartyPopper className="size-5" />,
streams: <Radio className="size-5" />,
articles: <FileText className="size-5" />,
decks: <CardsIcon className="size-5" />,
// Feed-only items (keyed by kind number)
'1': <MessageSquare className="size-5" />,
'6': <Repeat2 className="size-5" />,
+2
View File
@@ -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) => ({
+2
View File
@@ -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: <PartyPopper className="size-6" />,
streams: <Radio className="size-6" />,
articles: <FileText className="size-6" />,
decks: <CardsIcon className="size-6" />,
};
const navItems = useMemo(() => {
+19
View File
@@ -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;
+529
View File
@@ -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<string, string> = {
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<string, string> = {
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 (
<div
className="flex items-center justify-between px-3 py-1 text-[13px] hover:bg-secondary/30 transition-colors cursor-pointer"
onClick={onClick}
>
<div className="flex items-center gap-2 min-w-0">
<span className="text-muted-foreground tabular-nums text-xs w-5 text-right shrink-0">
{card.quantity}x
</span>
<span className={cn('truncate', card.foil && 'bg-gradient-to-r from-foreground via-primary to-foreground bg-clip-text text-transparent')}>
{card.name}
</span>
{card.foil && (
<Sparkles className="size-3 text-primary shrink-0" />
)}
</div>
{card.setId && (
<span className="text-[10px] text-muted-foreground uppercase tracking-wider shrink-0 ml-2">
{card.setId}
</span>
)}
</div>
);
}
/** Render a card as a visual image tile. */
function CardTile({ card, onClick }: { card: CardEntry; onClick?: () => void }) {
const [failed, setFailed] = useState(false);
if (failed) {
return (
<div
className="relative aspect-[5/7] rounded-lg bg-secondary/60 border border-border flex items-center justify-center p-1 cursor-pointer"
onClick={onClick}
>
<span className="text-[9px] text-center text-muted-foreground leading-tight line-clamp-3">
{card.name}
</span>
{card.quantity > 1 && <QuantityBadge quantity={card.quantity} />}
</div>
);
}
return (
<div className="relative aspect-[5/7] rounded-lg overflow-hidden group cursor-pointer" onClick={onClick}>
<img
src={scryfallImageUrl(card, 'normal')}
alt={card.name}
className="w-full h-full object-cover transition-transform group-hover:scale-105"
loading="lazy"
onError={() => setFailed(true)}
/>
{card.foil && (
<div className="absolute inset-0 bg-gradient-to-br from-transparent via-white/10 to-transparent pointer-events-none" />
)}
{card.quantity > 1 && <QuantityBadge quantity={card.quantity} />}
</div>
);
}
function QuantityBadge({ quantity }: { quantity: number }) {
return (
<span className="absolute top-1 right-1 bg-black/70 text-white text-[10px] font-bold px-1.5 py-0.5 rounded-full leading-none backdrop-blur-sm">
x{quantity}
</span>
);
}
/** 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<number | null>(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 (
<div
className="fixed inset-0 z-[100] flex items-center justify-center animate-in fade-in duration-200"
onClick={handleBackdropClick}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<div className="absolute inset-0 bg-black/90 backdrop-blur-md" />
{/* Top bar */}
<div data-gallery-topbar className="absolute top-0 left-0 right-0 z-10 flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-3">
{hasMultiple && (
<span className="text-white/80 text-sm font-medium tabular-nums">
{currentIndex + 1} / {cards.length}
</span>
)}
<span className="text-white text-sm font-medium truncate max-w-[200px]">
{card.name}
</span>
{card.foil && <Sparkles className="size-3.5 text-amber-400 shrink-0" />}
</div>
<button
onClick={(e) => { e.stopPropagation(); e.preventDefault(); onClose(); }}
className="p-2.5 rounded-full text-white/70 hover:text-white hover:bg-white/10 transition-colors"
title="Close (Esc)"
>
<X className="size-5" />
</button>
</div>
{/* Prev/Next buttons */}
{hasMultiple && (
<button
onClick={(e) => { e.stopPropagation(); onPrev(); }}
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 p-2 rounded-full bg-black/40 text-white/80 hover:text-white hover:bg-black/60 backdrop-blur-sm transition-all hidden sm:flex"
>
<ChevronLeft className="size-6" />
</button>
)}
{hasMultiple && (
<button
onClick={(e) => { e.stopPropagation(); onNext(); }}
className="absolute right-3 top-1/2 -translate-y-1/2 z-10 p-2 rounded-full bg-black/40 text-white/80 hover:text-white hover:bg-black/60 backdrop-blur-sm transition-all hidden sm:flex"
>
<ChevronRight className="size-6" />
</button>
)}
{/* Card image */}
<div
className="relative z-[1] flex items-center justify-center w-full h-full px-4 py-16 sm:px-16"
style={{
transform: isDragging ? `translateX(${touchDelta}px)` : undefined,
transition: isDragging ? 'none' : 'transform 0.2s ease-out',
}}
>
{!isLoaded && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="size-8 border-2 border-white/20 border-t-white/80 rounded-full animate-spin" />
</div>
)}
<img
key={imageUrl}
src={imageUrl}
alt={card.name}
className={cn(
'max-w-full max-h-full object-contain rounded-xl select-none transition-opacity duration-300',
isLoaded ? 'opacity-100' : 'opacity-0',
)}
onLoad={() => setIsLoaded(true)}
draggable={false}
/>
</div>
{/* Dot indicators (mobile, small decks) */}
{hasMultiple && cards.length <= 20 && (
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-10 flex items-center gap-1 sm:hidden">
{cards.map((_, i) => (
<div
key={i}
className={cn(
'rounded-full transition-all duration-200',
i === currentIndex ? 'size-2 bg-white' : 'size-1.5 bg-white/40',
)}
/>
))}
</div>
)}
</div>
);
}
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<number | null>(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 (
<div className="mt-2">
{/* Banner image */}
{banner && (
<div className="rounded-2xl overflow-hidden mb-3">
<img
src={banner}
alt={title ?? 'Magic deck'}
className="w-full max-h-[200px] object-cover"
loading="lazy"
onError={(e) => {
(e.currentTarget.parentElement as HTMLElement).style.display = 'none';
}}
/>
</div>
)}
{/* Title */}
{title && (
<div className="flex items-start gap-2 mb-2">
<CardsIcon className="size-4 text-primary mt-0.5 shrink-0" />
<span className="text-[15px] font-semibold leading-snug">{title}</span>
</div>
)}
{/* Commander(s) */}
{commanders.length > 0 && (
<div className="flex items-center gap-2 mb-2">
<Shield className="size-3.5 text-muted-foreground shrink-0" />
<span className="text-xs text-muted-foreground">
Commander{commanders.length > 1 ? 's' : ''}:
</span>
<span className="text-xs font-medium">
{commanders.join(' & ')}
</span>
</div>
)}
{/* Companion */}
{companion && (
<div className="flex items-center gap-2 mb-2">
<Sparkles className="size-3.5 text-muted-foreground shrink-0" />
<span className="text-xs text-muted-foreground">Companion:</span>
<span className="text-xs font-medium">{companion}</span>
</div>
)}
{/* Format badges + card count + sideboard — all on one line */}
<div className="flex flex-wrap items-center gap-1.5 mb-2">
{formatTags.map((tag) => (
<Link
key={tag}
to={`/t/${encodeURIComponent(tag)}`}
onClick={(e) => e.stopPropagation()}
>
<Badge variant="secondary" className="text-[11px] gap-1 font-medium hover:bg-secondary/80 transition-colors">
<Swords className="size-3" />
{FORMAT_LABELS[tag] ?? tag}
</Badge>
</Link>
))}
{archetypeTags.map((tag) => (
<Link
key={tag}
to={`/t/${encodeURIComponent(tag)}`}
onClick={(e) => e.stopPropagation()}
>
<Badge variant="outline" className="text-[11px] font-medium hover:bg-secondary/80 transition-colors">
{ARCHETYPE_LABELS[tag] ?? tag}
</Badge>
</Link>
))}
{otherTags.map((tag) => (
<Link
key={tag}
to={`/t/${encodeURIComponent(tag)}`}
onClick={(e) => e.stopPropagation()}
>
<Badge variant="outline" className="text-[11px] font-medium hover:bg-secondary/80 transition-colors">
{tag}
</Badge>
</Link>
))}
<Badge variant="secondary" className="text-[11px] gap-1 font-medium">
<CardsIcon className="size-3" />
{totalCards} cards
</Badge>
{totalSideboard > 0 && (
<Badge variant="secondary" className="text-[11px] gap-1 font-medium">
{totalSideboard} sideboard
</Badge>
)}
</div>
{/* Card list / visual spoiler */}
{mainDeck.length > 0 && (
<div className="rounded-xl border border-border overflow-hidden" onClick={(e) => e.stopPropagation()}>
{/* View toggle header */}
<div className="flex items-center justify-between px-3 py-1.5 bg-secondary/30 border-b border-border/50">
<span className="text-[11px] font-medium text-muted-foreground">
{visualView ? 'Visual Spoiler' : 'Decklist'}
</span>
<button
onClick={() => setVisualView(!visualView)}
className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground transition-colors"
>
{visualView ? <List className="size-3.5" /> : <Palette className="size-3.5" />}
{visualView ? 'List' : 'Visual'}
</button>
</div>
{visualView ? (
/* Visual spoiler grid */
<div className="max-h-[400px] overflow-y-auto p-2">
<div className="grid grid-cols-4 gap-1.5">
{mainDeck.map((card, i) => (
<CardTile key={`${card.name}-${i}`} card={card} onClick={() => openLightbox(i)} />
))}
</div>
{sideboard.length > 0 && (
<>
<div className="px-1 py-2 mt-1">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Sideboard
</span>
</div>
<div className="grid grid-cols-4 gap-1.5">
{sideboard.map((card, i) => (
<CardTile
key={`sb-${card.name}-${i}`}
card={card}
onClick={() => openLightbox(mainDeck.length + i)}
/>
))}
</div>
</>
)}
</div>
) : (
/* Text decklist */
<div className="max-h-[240px] overflow-y-auto">
{mainDeck.map((card, i) => (
<CardRow key={`${card.name}-${i}`} card={card} onClick={() => openLightbox(i)} />
))}
{/* Sideboard inline */}
{sideboard.length > 0 && (
<>
<div className="px-3 py-1.5 bg-secondary/40 border-y border-border/50">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Sideboard
</span>
</div>
{sideboard.map((card, i) => (
<CardRow
key={`sb-${card.name}-${i}`}
card={card}
onClick={() => openLightbox(mainDeck.length + i)}
/>
))}
</>
)}
</div>
)}
</div>
)}
{/* Card lightbox */}
{lightboxIndex !== null && allCards.length > 0 && (
<CardLightbox
cards={allCards}
currentIndex={lightboxIndex}
onClose={closeLightbox}
onNext={goNext}
onPrev={goPrev}
/>
)}
</div>
);
}
+2
View File
@@ -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<string, React.ReactNode> = {
packs: <PartyPopper className="size-5" />,
streams: <Radio className="size-5" />,
articles: <FileText className="size-5" />,
decks: <CardsIcon className="size-5" />,
};
+207 -2
View File
@@ -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 (
<article
className={cn(
@@ -246,6 +260,16 @@ export function NoteCard({ event, className, repostedBy, compact }: NoteCardProp
<TreasureHeader pubkey={event.pubkey} variant={isGeocache ? 'hid' : 'found'} />
)}
{/* Deck header — "<cards> <name> shared a deck" */}
{isMagicDeck && !repostedBy && (
<DeckHeader pubkey={event.pubkey} />
)}
{/* Stream header — "<radio> <name> is streaming / streamed" */}
{isStream && (
<StreamHeader pubkey={event.pubkey} isLive={getTag(event.tags, 'status') === 'live'} />
)}
{/* Header: avatar + name/handle stacked */}
<div className="flex items-center gap-3">
{author.isLoading ? (
@@ -322,6 +346,10 @@ export function NoteCard({ event, className, repostedBy, compact }: NoteCardProp
<FollowPackContent event={event} />
) : isArticle ? (
<ArticleContent event={event} preview className="mt-2" />
) : isMagicDeck ? (
<MagicDeckContent event={event} />
) : isStream ? (
<StreamContent event={event} />
) : (
<>
<div className="mt-2 break-words overflow-hidden">
@@ -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 (
<div className="mt-2 space-y-2">
{/* Stream player / thumbnail */}
<div className="rounded-xl overflow-hidden border border-border">
{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
<div className="relative" onClick={(e) => e.stopPropagation()}>
<LiveStreamPlayer src={streamingUrl} poster={imageUrl} />
{/* Status + viewer overlay on top of the player */}
<div className="absolute top-2 left-2 z-10 flex items-center gap-2 pointer-events-none">
<Badge variant="outline" className={cn('text-[10px]', statusConfig.className)}>
<div className="size-1.5 bg-white rounded-full animate-pulse mr-1" />
{statusConfig.label}
</Badge>
{currentParticipants && (
<span className="flex items-center gap-1 bg-black/60 text-white text-xs px-2 py-0.5 rounded">
<Users className="size-3" />
{currentParticipants}
</span>
)}
</div>
</div>
) : imageUrl ? (
<div className="relative w-full aspect-video overflow-hidden bg-muted">
<img
src={imageUrl}
alt=""
className="w-full h-full object-cover"
loading="lazy"
onError={(e) => {
(e.currentTarget.parentElement as HTMLElement).style.display = 'none';
}}
/>
<div className="absolute top-2 left-2">
<Badge variant="outline" className={cn('text-[10px]', statusConfig.className)}>
{statusConfig.label}
</Badge>
</div>
{currentParticipants && (
<div className="absolute bottom-2 right-2 flex items-center gap-1 bg-black/60 text-white text-xs px-2 py-0.5 rounded">
<Users className="size-3" />
{currentParticipants}
</div>
)}
</div>
) : (
// No image, no live stream — show a minimal placeholder with status
<div className="flex items-center gap-3 px-3 py-2.5 bg-muted/40">
<Radio className="size-4 text-primary shrink-0" />
<Badge variant="outline" className={cn('text-[10px]', statusConfig.className)}>
{status === 'live' && <div className="size-1.5 bg-white rounded-full animate-pulse mr-1" />}
{statusConfig.label}
</Badge>
{currentParticipants && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Users className="size-3" />
{currentParticipants}
</span>
)}
</div>
)}
</div>
{/* Title + summary — clickable to open stream details */}
<button
type="button"
className="flex items-start gap-2 text-left w-full group"
onClick={(e) => {
e.stopPropagation();
navigate(`/${encodedId}`);
}}
>
<Radio className="size-4 text-primary shrink-0 mt-0.5" />
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-sm leading-snug line-clamp-2 group-hover:underline">
{title}
</h3>
{summary && (
<p className="text-xs text-muted-foreground line-clamp-2 mt-0.5">{summary}</p>
)}
</div>
</button>
</div>
);
}
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 (
<div className="flex items-center gap-3 text-xs text-muted-foreground mb-3 min-w-0">
<div className="w-11 shrink-0 flex justify-end">
<Radio className={cn("size-4 translate-y-px", isLive ? "text-primary" : "text-muted-foreground")} />
</div>
<div className="flex items-center min-w-0">
{author.isLoading ? (
<Skeleton className="h-3 w-20 inline-block" />
) : (
<Link
to={url}
className="font-medium hover:underline mr-1 truncate"
onClick={(e) => e.stopPropagation()}
>
{author.data?.event ? <EmojifiedText tags={author.data.event.tags}>{name}</EmojifiedText> : name}
</Link>
)}
<span className={cn("shrink-0", author.isLoading && 'ml-1')}>
{isLive ? 'is streaming' : 'streamed'}
</span>
</div>
</div>
);
}
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 (
<div className="flex items-center gap-3 text-xs text-muted-foreground mb-3 min-w-0">
<div className="w-11 shrink-0 flex justify-end">
<CardsIcon className="size-4 text-primary translate-y-px" />
</div>
<div className="flex items-center min-w-0">
{author.isLoading ? (
<Skeleton className="h-3 w-20 inline-block" />
) : (
<Link
to={url}
className="font-medium hover:underline mr-1 truncate"
onClick={(e) => e.stopPropagation()}
>
{author.data?.event ? <EmojifiedText tags={author.data.event.tags}>{name}</EmojifiedText> : name}
</Link>
)}
<span className={cn("shrink-0", author.isLoading && 'ml-1')}>shared a deck</span>
</div>
</div>
);
}
function TreasureHeader({ pubkey, variant }: { pubkey: string; variant: 'hid' | 'found' }) {
const author = useAuthor(pubkey);
const name = author.data?.metadata?.name || genUserName(pubkey);
+59
View File
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md p-0 gap-0 rounded-2xl overflow-hidden [&>button]:hidden">
@@ -239,6 +269,14 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
onClick={handleTogglePin}
/>
)}
{isOwnPost && (
<MenuItem
icon={<Trash2 className="size-5" />}
label="Delete post"
onClick={() => setDeleteConfirmOpen(true)}
destructive
/>
)}
{!isOwnPost && (
<MenuItem
icon={<AtSign className="size-5" />}
@@ -280,6 +318,27 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
</Button>
</div>
</DialogContent>
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete post?</AlertDialogTitle>
<AlertDialogDescription>
This will request deletion from relays. Some relays may still keep a copy of the original event. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? 'Deleting...' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Dialog>
);
}
+25
View File
@@ -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<SVGSVGElement, React.SVGProps<SVGSVGElement>>(
({ className, ...props }, ref) => (
<svg
ref={ref}
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 0 24 24"
fill="currentColor"
stroke="none"
className={className}
{...props}
>
<path d="M11.19,2.25C10.93,2.25 10.67,2.31 10.42,2.4L3.06,5.45C2.04,5.87 1.55,7.04 1.97,8.05L6.93,20C7.24,20.77 7.97,21.23 8.74,21.25C9,21.25 9.27,21.22 9.53,21.1L16.9,18.05C17.65,17.74 18.11,17 18.13,16.25C18.14,16 18.09,15.71 18,15.45L13,3.5C12.71,2.73 11.97,2.26 11.19,2.25M14.67,2.25L18.12,10.6V4.25A2,2 0 0,0 16.12,2.25M20.13,3.79V12.82L22.56,6.96C22.97,5.94 22.5,4.78 21.47,4.36M11.19,4.22L16.17,16.24L8.78,19.3L3.8,7.29" />
</svg>
),
);
CardsIcon.displayName = 'CardsIcon';
+4
View File
@@ -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 {
+41
View File
@@ -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'] });
},
});
}
+10
View File
@@ -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',
+5 -1
View File
@@ -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 }) {
<ContentWarningGuard event={event}>
{isArticle ? (
<ArticleContent event={event} className="mt-3" />
) : isMagicDeck ? (
<MagicDeckContent event={event} />
) : isVine || isPoll || isGeocache || isFoundLog || isColor || isFollowPack ? (
<>
{isVine && <VineDetailContent event={event} />}
-1
View File
@@ -570,7 +570,6 @@ export function ProfilePage() {
const { toast } = useToast();
const { muteItems } = useMuteList();
const [activeTab, setActiveTab] = useState<ProfileTab>('posts');
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [followingModalOpen, setFollowingModalOpen] = useState(false);
+2
View File
@@ -50,6 +50,8 @@ export function TestApp({ children }: TestAppProps) {
feedIncludeColors: false,
feedIncludePacks: false,
feedIncludeStreams: false,
showDecks: false,
feedIncludeDecks: false,
},
nip85StatsPubkey: '5f68e85ee174102ca8978eef302129f081f03456c884185d5ec1c1224ab633ea',
nip85OnlyMode: false,