{author.isLoading ? (
@@ -322,6 +346,10 @@ export function NoteCard({ event, className, repostedBy, compact }: NoteCardProp
@@ -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 */}
+
{
+ e.stopPropagation();
+ navigate(`/${encodedId}`);
+ }}
+ >
+
+
+
+ {title}
+
+ {summary && (
+
{summary}
+ )}
+
+
+
+ );
+}
+
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) {
);
}
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