mirror of
https://github.com/layer-systems/website.git
synced 2026-09-12 05:33:12 +02:00
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:
11
docs/apps.md
11
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 —
|
||||
|
||||
106
src/apps/live/LiveChat.tsx
Normal file
106
src/apps/live/LiveChat.tsx
Normal 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
302
src/apps/live/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user