feat: Live app for NIP-53 broadcast streams and chat (#32)

Adds a Live app (kind 30311 live events + kind 1311 live chat),
scoped to broadcast streams only per the issue discussion — Spaces/
interactive rooms (kind 30312/30313) are a separate, larger effort
tracked in a follow-up issue.

- src/apps/live/index.tsx: sidebar list (live streams first, then
  planned, then ended, newest within each bucket) and a detail pane
  with title/summary/host/status/topics and a link out to the
  `streaming` (or `recording`, once ended) URL.
- src/apps/live/LiveChat.tsx: kind 1311 messages tagged to the stream
  via `a`, with a composer.
- No embedded video player: NIP-53 streams are typically HLS, which
  needs a library (hls.js) to play in-browser — deferred rather than
  pulled in for a first cut. docs/apps.md explains the tradeoff.

Closes #22


Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto

Co-authored-by: highperfocused <highperfocused@pm.me>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mroxso
2026-09-06 18:52:53 +02:00
committed by GitHub
parent d4a83f22b5
commit ae7d27d63e
4 changed files with 429 additions and 2 deletions

View File

@@ -89,7 +89,7 @@ export default function ExampleApp({ setTitle }: AppProps) {
}
```
## The nine apps
## The ten apps
| App | `id` | Params | Notes |
|---|---|---|---|
@@ -99,10 +99,19 @@ export default function ExampleApp({ setTitle }: AppProps) {
| Reader | `articles` | `pubkey?`, `identifier?`, `kind?`, `relays?` | NIP-23 long-form, `react-markdown`, NIP-84 highlights |
| Bookmarks | `bookmarks` | — | NIP-51 kind 10003 list — bookmarked notes and articles |
| Web Bookmarks | `web-bookmarks` | — | NIP-B0 kind 39701 — one addressable event per saved URL |
| Live | `live` | `pubkey?`, `identifier?` | NIP-53 kind 30311 live events + kind 1311 chat |
| Relays | `relays` | — | Connection state, subscription count, measured latency |
| Settings | `settings` | — | Theme, relay list, Blossom servers, account, session |
| About | `about` | — | What this is, the app list, the shortcuts |
### Live only links out to playback, it doesn't embed a player
NIP-53's `streaming` tag is typically an HLS (`.m3u8`) URL, which no browser plays natively
without a library like hls.js. Rather than pull that dependency in for a first cut, the Live
app (`src/apps/live`) shows the stream's metadata and chat and opens `streaming` (or
`recording`, once `status` is `ended`) in a new tab. Spaces/interactive rooms (kind
30312/30313) are a separate, larger effort — see the tracking issue.
### Web bookmarks are one event per URL, not a list
Unlike a NIP-51 list, each NIP-B0 web bookmark (kind 39701) is its own addressable event —

106
src/apps/live/LiveChat.tsx Normal file
View File

@@ -0,0 +1,106 @@
import { useState } from 'react';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { Loader2, Send } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { AppBody, EmptyState } from '@/components/os/AppChrome';
import { AuthorLine } from '@/components/nostr/AuthorLine';
import { NoteContent } from '@/components/nostr/NoteContent';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
const LIVE_CHAT_KIND = 1311;
function useLiveChat(address: string) {
const { nostr } = useNostr();
return useQuery<NostrEvent[]>({
queryKey: ['nostr', 'live-chat', address],
queryFn: async ({ signal }) => {
const events = await nostr.query([{ kinds: [LIVE_CHAT_KIND], '#a': [address], limit: 200 }], {
signal: AbortSignal.any([signal, AbortSignal.timeout(6000)]),
});
return events
.filter((event) => event.content.trim().length > 0)
.sort((a, b) => a.created_at - b.created_at);
},
staleTime: 5_000,
refetchInterval: 15_000,
});
}
/** The chat channel tied to one NIP-53 live event, addressed by its `a` tag. */
export function LiveChat({ address }: { address: string }) {
const { user } = useCurrentUser();
const chat = useLiveChat(address);
const publish = useNostrPublish();
const { toast } = useToast();
const [message, setMessage] = useState('');
const trimmed = message.trim();
const send = async () => {
if (!trimmed) return;
try {
await publish.mutateAsync({ kind: LIVE_CHAT_KIND, content: trimmed, tags: [['a', address]] });
setMessage('');
chat.refetch();
} catch (error) {
toast({
title: 'Could not send',
description: error instanceof Error ? error.message : 'No relay accepted the message.',
variant: 'destructive',
});
}
};
return (
<div className="flex min-h-0 flex-1 flex-col">
<AppBody>
{chat.data && chat.data.length > 0 ? (
<ul className="divide-y divide-border">
{chat.data.map((event) => (
<li key={event.id} className="px-4 py-2.5">
<AuthorLine pubkey={event.pubkey} createdAt={event.created_at} size="sm" />
<div className="mt-1 pl-9">
<NoteContent content={event.content} className="text-[14px]" />
</div>
</li>
))}
</ul>
) : (
<EmptyState title="No chat messages yet" hint={user ? 'Say hello.' : undefined} />
)}
</AppBody>
{user && (
<div className="flex shrink-0 items-end gap-2 border-t border-border p-3">
<Textarea
value={message}
onChange={(event) => setMessage(event.target.value)}
onKeyDown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault();
void send();
}
}}
placeholder="Say something…"
rows={1}
className="min-h-9 flex-1 resize-none py-2 text-[14px]"
/>
<Button size="sm" onClick={send} disabled={!trimmed || publish.isPending} className="shrink-0 gap-1.5">
{publish.isPending ? (
<Loader2 className="size-3.5 animate-spin" aria-hidden />
) : (
<Send className="size-3.5" aria-hidden />
)}
Send
</Button>
</div>
)}
</div>
);
}

302
src/apps/live/index.tsx Normal file
View File

@@ -0,0 +1,302 @@
import { useCallback, useEffect } from 'react';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { ChevronLeft, ExternalLink } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import {
AppBody,
AppLayout,
AppSectionTitle,
AppSidebar,
AppSplit,
AppToolbar,
EmptyState,
} from '@/components/os/AppChrome';
import { AuthorLine } from '@/components/nostr/AuthorLine';
import { LiveChat } from './LiveChat';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { useAuthor } from '@/hooks/useAuthor';
import { useIsMobile } from '@/hooks/useIsMobile';
import { displayName, relativeTime, sanitizeUrl, tagValue, tagValues } from '@/lib/nostrUtils';
import { cn } from '@/lib/utils';
import type { AppParams, AppProps } from '@/os/types';
const LIVE_EVENT_KIND = 30311;
type Status = 'live' | 'planned' | 'ended' | string;
/** A live event is only listable once it has a `d` identifier and a title (NIP-53). */
function isRenderableStream(event: NostrEvent): boolean {
return event.kind === LIVE_EVENT_KIND && Boolean(tagValue(event, 'd')) && Boolean(tagValue(event, 'title'));
}
function statusOf(event: NostrEvent): Status {
return tagValue(event, 'status') ?? 'ended';
}
/** Live first, then planned, then everything else, each bucket newest first. */
function streamOrder(event: NostrEvent): number {
const status = statusOf(event);
if (status === 'live') return 0;
if (status === 'planned') return 1;
return 2;
}
function hostPubkey(event: NostrEvent): string | undefined {
const host = event.tags.find(([name, , , role]) => name === 'p' && role === 'Host');
return host?.[1] ?? tagValue(event, 'p') ?? event.pubkey;
}
function useLiveEvents() {
const { nostr } = useNostr();
return useQuery<NostrEvent[]>({
queryKey: ['nostr', 'live-events'],
queryFn: async ({ signal }) => {
const events = await nostr.query([{ kinds: [LIVE_EVENT_KIND], limit: 60 }], {
signal: AbortSignal.any([signal, AbortSignal.timeout(6000)]),
});
// Addressable events can arrive with stale duplicates; the relay pool
// already dedupes by id, but not by (kind, pubkey, d) — keep the
// newest revision of each stream.
const latest = new Map<string, NostrEvent>();
for (const event of events.filter(isRenderableStream)) {
const address = `${event.pubkey}:${tagValue(event, 'd')}`;
const current = latest.get(address);
if (!current || event.created_at > current.created_at) latest.set(address, event);
}
return [...latest.values()].sort(
(a, b) => streamOrder(a) - streamOrder(b) || b.created_at - a.created_at,
);
},
staleTime: 30_000,
});
}
function useLiveEvent(pubkey: string | undefined, identifier: string | undefined) {
const { nostr } = useNostr();
return useQuery<NostrEvent | null>({
queryKey: ['nostr', 'live-event', pubkey ?? '', identifier ?? ''],
enabled: Boolean(pubkey && identifier),
queryFn: async ({ signal }) => {
const [event] = await nostr.query(
[{ kinds: [LIVE_EVENT_KIND], authors: [pubkey!], '#d': [identifier!], limit: 1 }],
{ signal: AbortSignal.any([signal, AbortSignal.timeout(6000)]) },
);
return event ?? null;
},
staleTime: 15_000,
});
}
export default function LiveApp({ params, setTitle, setParams }: AppProps) {
const isMobile = useIsMobile();
const selected = params.pubkey && params.identifier ? { pubkey: params.pubkey, identifier: params.identifier } : null;
const select = useCallback((next: AppParams | null) => setParams(next ?? {}), [setParams]);
const list = useLiveEvents();
const stream = useLiveEvent(selected?.pubkey, selected?.identifier);
const title = stream.data ? tagValue(stream.data, 'title') : undefined;
useEffect(() => {
setTitle(title ? `Live — ${title}` : 'Live');
}, [title, setTitle]);
const listPane = (
<StreamList
query={list}
selected={selected}
onSelect={(event) => select({ pubkey: event.pubkey, identifier: tagValue(event, 'd')! })}
/>
);
const detailPane = !selected ? (
<EmptyState title="Pick a stream" hint="Choose one from the list to see details and join the chat." />
) : stream.isLoading ? (
<div className="space-y-4 p-6">
<Skeleton className="h-40 w-full" />
<Skeleton className="h-6 w-2/3" />
<Skeleton className="h-4 w-full" />
</div>
) : stream.data ? (
<StreamDetail event={stream.data} />
) : (
<EmptyState title="Stream not found" hint="None of your relays returned this stream." />
);
if (isMobile) {
return (
<AppLayout>
<AppToolbar>
{selected ? (
<button
type="button"
onClick={() => select(null)}
className="-ml-1 flex items-center gap-1 rounded px-1 py-0.5 text-[13px] font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
>
<ChevronLeft className="size-4" aria-hidden />
All streams
</button>
) : (
<span className="text-[13px] font-medium">Live</span>
)}
</AppToolbar>
<AppBody>{selected ? detailPane : listPane}</AppBody>
</AppLayout>
);
}
return (
<AppLayout>
<AppToolbar>
<span className="truncate text-[13px] font-medium">{title ?? 'Live'}</span>
</AppToolbar>
<AppSplit>
<AppSidebar className="p-0">{listPane}</AppSidebar>
<AppBody>{detailPane}</AppBody>
</AppSplit>
</AppLayout>
);
}
function StatusBadge({ status }: { status: Status }) {
if (status === 'live') {
return (
<Badge className="gap-1 border-transparent bg-destructive text-destructive-foreground">
<span className="size-1.5 rounded-full bg-current" aria-hidden />
Live
</Badge>
);
}
if (status === 'planned') {
return <Badge variant="secondary">Planned</Badge>;
}
return <Badge variant="outline">Ended</Badge>;
}
function StreamList({
query,
selected,
onSelect,
}: {
query: ReturnType<typeof useLiveEvents>;
selected: { pubkey: string; identifier: string } | null;
onSelect: (event: NostrEvent) => void;
}) {
if (query.isLoading) {
return (
<div className="space-y-3 p-3">
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton key={index} className="h-12 w-full" />
))}
</div>
);
}
if (!query.data || query.data.length === 0) {
return (
<p className="px-3 py-6 text-center text-xs text-muted-foreground">
No live events on your relays.
</p>
);
}
return (
<>
<AppSectionTitle>Streams</AppSectionTitle>
<ul className="pb-2">
{query.data.map((event) => {
const identifier = tagValue(event, 'd')!;
const active = selected?.pubkey === event.pubkey && selected?.identifier === identifier;
return (
<li key={`${event.pubkey}:${identifier}`}>
<StreamListItem event={event} active={active} onSelect={() => onSelect(event)} />
</li>
);
})}
</ul>
</>
);
}
function StreamListItem({
event,
active,
onSelect,
}: {
event: NostrEvent;
active: boolean;
onSelect: () => void;
}) {
const host = useAuthor(hostPubkey(event));
const title = tagValue(event, 'title') ?? 'Untitled stream';
const status = statusOf(event);
return (
<button
type="button"
onClick={onSelect}
className={cn(
'w-full px-3 py-2 text-left transition-colors',
'focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring',
active ? 'bg-accent text-accent-foreground' : 'hover:bg-muted',
)}
>
<div className="flex items-center gap-1.5">
<span className="line-clamp-1 flex-1 text-[13px] font-medium leading-snug">{title}</span>
<StatusBadge status={status} />
</div>
<span className="mt-0.5 block truncate text-[11px] text-muted-foreground">
{displayName(hostPubkey(event) ?? event.pubkey, host.data?.metadata)} · {relativeTime(event.created_at)}
</span>
</button>
);
}
function StreamDetail({ event }: { event: NostrEvent }) {
const title = tagValue(event, 'title') ?? 'Untitled stream';
const summary = tagValue(event, 'summary');
const image = sanitizeUrl(tagValue(event, 'image'));
const streamingUrl = sanitizeUrl(tagValue(event, 'streaming'));
const recordingUrl = sanitizeUrl(tagValue(event, 'recording'));
const status = statusOf(event);
const topics = tagValues(event, 't');
const address = `${LIVE_EVENT_KIND}:${event.pubkey}:${tagValue(event, 'd') ?? ''}`;
const watchUrl = streamingUrl ?? (status === 'ended' ? recordingUrl : undefined);
return (
<div className="flex h-full min-h-0 flex-col">
<div className="shrink-0 space-y-3 border-b border-border p-5">
{image && <img src={image} alt="" className="aspect-video w-full rounded-lg border border-border object-cover" />}
<div className="flex items-center gap-2">
<StatusBadge status={status} />
{topics.map((topic) => (
<Badge key={topic} variant="outline" className="text-[11px]">
{topic}
</Badge>
))}
</div>
<h1 className="text-xl font-semibold leading-tight tracking-tight">{title}</h1>
{summary && <p className="text-sm text-muted-foreground">{summary}</p>}
<AuthorLine pubkey={hostPubkey(event) ?? event.pubkey} size="sm" />
{watchUrl ? (
<Button asChild className="w-full gap-1.5">
<a href={watchUrl} target="_blank" rel="noopener noreferrer">
<ExternalLink className="size-3.5" aria-hidden />
{status === 'ended' && recordingUrl ? 'Watch the recording' : 'Watch the stream'}
</a>
</Button>
) : (
<p className="text-xs text-muted-foreground">No playback URL was published for this stream.</p>
)}
</div>
<LiveChat address={address} />
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { lazy } from 'react';
import { Activity, Bookmark, BookOpen, FileText, Info, Link2, Rss, Settings, User } from 'lucide-react';
import { Activity, Bookmark, BookOpen, FileText, Info, Link2, Radio, Rss, Settings, User } from 'lucide-react';
import type { AppDefinition } from './types';
/**
@@ -69,6 +69,16 @@ export const APPS: AppDefinition[] = [
defaultSize: { width: 600, height: 660 },
minSize: { width: 340, height: 300 },
},
{
id: 'live',
title: 'Live',
description: 'Live streams happening on Nostr right now',
icon: Radio,
category: 'social',
component: lazy(() => import('@/apps/live')),
defaultSize: { width: 780, height: 720 },
minSize: { width: 360, height: 320 },
},
{
id: 'relays',
title: 'Relays',