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 && ( +
+