From 89aae228eb896a0e819860decb50f052b9d170a0 Mon Sep 17 00:00:00 2001 From: highperfocused Date: Sun, 6 Sep 2026 13:55:15 +0200 Subject: [PATCH] feat: Live app for NIP-53 broadcast streams and chat 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 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BYiUtZMQeA5RHggQw73wto --- 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 6cc09bf..17a7c40 100644 --- a/docs/apps.md +++ b/docs/apps.md @@ -89,7 +89,7 @@ export default function ExampleApp({ setTitle }: AppProps) { } ``` -## The seven apps +## The eight apps | App | `id` | Params | Notes | |---|---|---|---| @@ -97,10 +97,19 @@ export default function ExampleApp({ setTitle }: AppProps) { | Profile | `profile` | `pubkey`, `relays?` | kind 0 metadata, the author's notes, follow/unfollow | | Note | `notes` | `id`, `relays?` | One note and its replies. **Not** a singleton | | Reader | `articles` | `pubkey?`, `identifier?`, `kind?`, `relays?` | NIP-23 long-form, `react-markdown` | +| 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. + ### Follow lists are a whole-list replacement kind 3 replaces the entire contact list. The follow button therefore reads the current 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 && ( +
+