From ae7d27d63ea30c13b893fa1a7cccc3776f8ea397 Mon Sep 17 00:00:00 2001 From: mroxso <24775431+mroxso@users.noreply.github.com> Date: Sun, 6 Sep 2026 18:52:53 +0200 Subject: [PATCH] feat: Live app for NIP-53 broadcast streams and chat (#32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Claude Sonnet 5 --- docs/apps.md | 11 +- src/apps/live/LiveChat.tsx | 106 +++++++++++++ src/apps/live/index.tsx | 302 +++++++++++++++++++++++++++++++++++++ src/os/registry.ts | 12 +- 4 files changed, 429 insertions(+), 2 deletions(-) create mode 100644 src/apps/live/LiveChat.tsx create mode 100644 src/apps/live/index.tsx diff --git a/docs/apps.md b/docs/apps.md index f17a0fb..18205e8 100644 --- a/docs/apps.md +++ b/docs/apps.md @@ -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 — diff --git a/src/apps/live/LiveChat.tsx b/src/apps/live/LiveChat.tsx new file mode 100644 index 0000000..256ad7d --- /dev/null +++ b/src/apps/live/LiveChat.tsx @@ -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({ + 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 ( +
+ + {chat.data && chat.data.length > 0 ? ( +
    + {chat.data.map((event) => ( +
  • + +
    + +
    +
  • + ))} +
+ ) : ( + + )} +
+ + {user && ( +
+