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