Browse Source

bug-fixes

imwald
Silberengel 2 weeks ago
parent
commit
087bd01ae1
  1. 72
      src/components/AudioPlayer/index.tsx
  2. 20
      src/components/MediaPlayer/index.tsx
  3. 106
      src/components/Note/LiveEvent.tsx
  4. 2
      src/components/Note/index.tsx
  5. 31
      src/components/VideoPlayer/index.tsx
  6. 2
      src/i18n/locales/de.ts
  7. 2
      src/i18n/locales/en.ts
  8. 30
      src/lib/event-metadata.ts
  9. 146
      src/lib/live-activities.test.ts
  10. 160
      src/lib/live-activities.ts
  11. 3
      src/lib/note-renderable-kinds.ts
  12. 11
      src/providers/LiveActivitiesProvider.tsx

72
src/components/AudioPlayer/index.tsx

@ -11,11 +11,13 @@ import logger from '@/lib/logger'
interface AudioPlayerProps { interface AudioPlayerProps {
src: string src: string
className?: string className?: string
/** Optional cover / still (e.g. NIP-53 `image` on live events). */
poster?: string
/** Fires when enough data is buffered to play (e.g. to swap out a blurhash placeholder). */ /** Fires when enough data is buffered to play (e.g. to swap out a blurhash placeholder). */
onReady?: () => void onReady?: () => void
} }
export default function AudioPlayer({ src, className, onReady }: AudioPlayerProps) { export default function AudioPlayer({ src, className, poster, onReady }: AudioPlayerProps) {
const audioRef = useRef<HTMLAudioElement>(null) const audioRef = useRef<HTMLAudioElement>(null)
const [isPlaying, setIsPlaying] = useState(false) const [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0) const [currentTime, setCurrentTime] = useState(0)
@ -107,6 +109,8 @@ export default function AudioPlayer({ src, className, onReady }: AudioPlayerProp
return <ExternalLink url={src} /> return <ExternalLink url={src} />
} }
const cover = poster?.trim()
return ( return (
<MediaErrorBoundary <MediaErrorBoundary
fallback={<ExternalLink url={src} />} fallback={<ExternalLink url={src} />}
@ -118,34 +122,44 @@ export default function AudioPlayer({ src, className, onReady }: AudioPlayerProp
setError(true) setError(true)
}} }}
> >
<div <div className={cn('flex w-full max-w-md flex-col gap-2', className)} onClick={(e) => e.stopPropagation()}>
className={cn( {cover ? (
'flex items-center gap-3 py-2 pl-2 pr-4 border rounded-full max-w-md', <div className="not-prose overflow-hidden rounded-lg border border-border bg-muted shadow-sm">
className <img
)} src={cover}
onClick={(e) => e.stopPropagation()} alt=""
> className="aspect-video w-full max-h-48 object-cover"
<audio ref={audioRef} src={src} preload="metadata" onError={() => setError(true)} /> referrerPolicy="no-referrer"
draggable={false}
{/* Play/Pause Button */} />
<Button size="icon" className="rounded-full shrink-0" onClick={togglePlay}> </div>
{isPlaying ? <Pause fill="currentColor" /> : <Play fill="currentColor" />} ) : null}
</Button> <div
className={cn(
{/* Progress Section */} 'flex w-full items-center gap-3 rounded-full border py-2 pl-2 pr-4',
<div className="flex-1 relative"> !cover && 'max-w-md'
<Slider )}
value={[currentTime]} >
max={duration || 100} <audio ref={audioRef} src={src} preload="metadata" onError={() => setError(true)} />
step={1}
onValueChange={handleSeek} <Button size="icon" className="shrink-0 rounded-full" onClick={togglePlay}>
hideThumb {isPlaying ? <Pause fill="currentColor" /> : <Play fill="currentColor" />}
enableHoverAnimation </Button>
/>
</div> <div className="relative min-w-0 flex-1">
<Slider
<div className="text-sm font-mono text-muted-foreground"> value={[currentTime]}
{formatTime(Math.max(duration - currentTime, 0))} max={duration || 100}
step={1}
onValueChange={handleSeek}
hideThumb
enableHoverAnimation
/>
</div>
<div className="shrink-0 font-mono text-sm text-muted-foreground">
{formatTime(Math.max(duration - currentTime, 0))}
</div>
</div> </div>
</div> </div>
</MediaErrorBoundary> </MediaErrorBoundary>

20
src/components/MediaPlayer/index.tsx

@ -39,7 +39,8 @@ export default function MediaPlayer({
className, className,
mustLoad = false, mustLoad = false,
poster, poster,
blurHash blurHash,
fallbackPageUrl
}: { }: {
src: string src: string
className?: string className?: string
@ -47,6 +48,8 @@ export default function MediaPlayer({
poster?: string poster?: string
/** NIP-94 / imeta blurhash for lazy placeholder when poster is missing */ /** NIP-94 / imeta blurhash for lazy placeholder when poster is missing */
blurHash?: string blurHash?: string
/** Passed to {@link VideoPlayer} when HLS/video playback fails (e.g. NIP-53 zap.stream join URL). */
fallbackPageUrl?: string
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { autoLoadMedia } = useContentPolicy() const { autoLoadMedia } = useContentPolicy()
@ -221,9 +224,20 @@ export default function MediaPlayer({
aria-hidden={!embedPainted} aria-hidden={!embedPainted}
> >
{effectiveMediaType === 'video' ? ( {effectiveMediaType === 'video' ? (
<VideoPlayer src={src} className={className} poster={imagePoster} onReady={onEmbedReady} /> <VideoPlayer
src={src}
className={className}
poster={imagePoster}
onReady={onEmbedReady}
fallbackPageUrl={fallbackPageUrl}
/>
) : ( ) : (
<AudioPlayer src={src} className={className} onReady={onEmbedReady} /> <AudioPlayer
src={src}
className={className}
poster={imagePoster}
onReady={onEmbedReady}
/>
)} )}
</div> </div>
</div> </div>

106
src/components/Note/LiveEvent.tsx

@ -1,5 +1,6 @@
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { createFakeEvent } from '@/lib/event'
import { getLiveEventMetadataFromEvent } from '@/lib/event-metadata' import { getLiveEventMetadataFromEvent } from '@/lib/event-metadata'
import { import {
liveEventInlinePlaybackFromEvent, liveEventInlinePlaybackFromEvent,
@ -8,13 +9,14 @@ import {
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider' import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider'
import { Event } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { ExternalLink } from 'lucide-react' import { ExternalLink } from 'lucide-react'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import ClientSelect from '../ClientSelect' import ClientSelect from '../ClientSelect'
import Image from '../Image' import Image from '../Image'
import MediaPlayer from '../MediaPlayer' import MediaPlayer from '../MediaPlayer'
import MarkdownArticle from './MarkdownArticle/MarkdownArticle'
export default function LiveEvent({ event, className }: { event: Event; className?: string }) { export default function LiveEvent({ event, className }: { event: Event; className?: string }) {
const { t } = useTranslation() const { t } = useTranslation()
@ -25,12 +27,30 @@ export default function LiveEvent({ event, className }: { event: Event; classNam
const metadata = useMemo(() => getLiveEventMetadataFromEvent(event), [event]) const metadata = useMemo(() => getLiveEventMetadataFromEvent(event), [event])
const playback = useMemo(() => liveEventInlinePlaybackFromEvent(event), [event]) const playback = useMemo(() => liveEventInlinePlaybackFromEvent(event), [event])
const joinUrl = useMemo(() => preferredLiveJoinUrlForEvent(event), [event]) const joinUrl = useMemo(() => preferredLiveJoinUrlForEvent(event), [event])
/** Video/HLS: prefer `thumb`, then `image`. Audio: prefer NIP-53 `image`, then `thumb` (still on the player). */
const posterUrl = metadata.thumb ?? metadata.image
const inlinePlayerPoster =
playback?.mode === 'audio' ? metadata.image ?? metadata.thumb : posterUrl
/** Side column only when there is no inline player (artwork for audio/video lives on the player). */
const showSideArtwork = autoLoadMedia && !!posterUrl && !playback
const summaryMarkdownEvent = useMemo(() => {
const s = metadata.summary?.trim()
if (!s) return null
return createFakeEvent({
kind: kinds.ShortTextNote,
pubkey: event.pubkey,
content: s,
created_at: event.created_at
})
}, [metadata.summary, event.pubkey, event.created_at])
const statusKey = metadata.status?.toLowerCase()
const liveStatusComponent = const liveStatusComponent =
metadata.status && statusKey &&
(metadata.status === 'live' ? ( (statusKey === 'live' ? (
<Badge className="bg-green-400 hover:bg-green-400">live</Badge> <Badge className="bg-green-400 hover:bg-green-400">live</Badge>
) : metadata.status === 'ended' ? ( ) : statusKey === 'ended' ? (
<Badge variant="destructive">ended</Badge> <Badge variant="destructive">ended</Badge>
) : ( ) : (
<Badge variant="secondary">{metadata.status}</Badge> <Badge variant="secondary">{metadata.status}</Badge>
@ -45,9 +65,19 @@ export default function LiveEvent({ event, className }: { event: Event; classNam
<p className="text-sm text-muted-foreground mt-1 line-clamp-4">{event.content.trim()}</p> <p className="text-sm text-muted-foreground mt-1 line-clamp-4">{event.content.trim()}</p>
) : null ) : null
const summaryComponent = metadata.summary && ( const summaryComponent = summaryMarkdownEvent ? (
<div className="text-base text-muted-foreground line-clamp-4 mt-1">{metadata.summary}</div> <div
) className="mt-1 min-w-0 w-full text-muted-foreground"
onClick={(e) => e.stopPropagation()}
>
<MarkdownArticle
event={summaryMarkdownEvent}
hideMetadata
lazyMedia={autoLoadMedia}
className="prose-sm max-w-none min-w-0 w-full"
/>
</div>
) : null
const tagsComponent = metadata.tags.length > 0 && ( const tagsComponent = metadata.tags.length > 0 && (
<div className="flex gap-1 flex-wrap mt-2"> <div className="flex gap-1 flex-wrap mt-2">
@ -59,23 +89,34 @@ export default function LiveEvent({ event, className }: { event: Event; classNam
</div> </div>
) )
const cover = const cover = showSideArtwork ? (
metadata.image && autoLoadMedia ? ( <Image
<Image image={{ url: posterUrl!, pubkey: event.pubkey }}
image={{ url: metadata.image, pubkey: event.pubkey }} className={cn(
className={cn( 'bg-muted',
'bg-muted shrink-0', isSmallScreen ? 'w-full aspect-video' : 'aspect-[4/3] xl:aspect-video h-44 w-full max-h-44'
isSmallScreen ? 'w-full aspect-video' : 'aspect-[4/3] xl:aspect-video h-44 w-auto max-w-[min(100%,20rem)]' )}
)} classNames={{
hideIfError // Image’s default wrapper is `w-full`; in a row flex that steals the whole row and collapses the text column.
/> wrapper: cn(
) : null 'shrink-0',
isSmallScreen ? 'w-full' : 'w-[min(100%,20rem)]'
)
}}
hideIfError
/>
) : null
return ( return (
<div className={cn(className, 'space-y-3')}> <div className={cn(className, 'min-w-0 w-full space-y-3')}>
<div className={cn('flex gap-4', isSmallScreen ? 'flex-col' : 'flex-row items-start')}> <div
className={cn(
'flex min-w-0 w-full gap-4',
isSmallScreen ? 'flex-col' : 'flex-row items-start'
)}
>
{cover} {cover}
<div className="flex-1 min-w-0 space-y-1"> <div className="min-w-0 flex-1 space-y-1">
{titleComponent} {titleComponent}
{liveStatusComponent} {liveStatusComponent}
{nowPlaying} {nowPlaying}
@ -85,29 +126,34 @@ export default function LiveEvent({ event, className }: { event: Event; classNam
</div> </div>
{playback ? ( {playback ? (
<div className="w-full max-w-[400px]" onClick={(e) => e.stopPropagation()}> <div className="min-w-0 w-full max-w-[400px]" onClick={(e) => e.stopPropagation()}>
<MediaPlayer src={playback.src} poster={metadata.image} className="w-full" /> <MediaPlayer
src={playback.src}
poster={inlinePlayerPoster}
className="w-full"
fallbackPageUrl={joinUrl ?? undefined}
/>
</div> </div>
) : null} ) : null}
<div <div className="flex min-w-0 w-full flex-col gap-2" onClick={(e) => e.stopPropagation()}>
className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center"
onClick={(e) => e.stopPropagation()}
>
{joinUrl ? ( {joinUrl ? (
<Button variant="secondary" size="sm" className="w-full sm:w-auto shrink-0" asChild> <Button variant="secondary" size="sm" className="h-auto min-h-9 w-full max-w-full justify-center py-2 whitespace-normal" asChild>
<a <a
href={joinUrl} href={joinUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-2" className="inline-flex items-center justify-center gap-2 text-center"
> >
<ExternalLink className="size-4 shrink-0" /> <ExternalLink className="size-4 shrink-0" />
{t('Open in browser')} {t('Open in browser')}
</a> </a>
</Button> </Button>
) : null} ) : null}
<ClientSelect className="w-full sm:w-auto sm:min-w-[12rem] sm:flex-1" event={event} /> <ClientSelect
className="h-auto min-h-9 w-full max-w-full justify-center py-2 whitespace-normal"
event={event}
/>
</div> </div>
</div> </div>
) )

2
src/components/Note/index.tsx

@ -298,7 +298,7 @@ export default function Note({
) )
} else if (event.kind === kinds.LongFormArticle) { } else if (event.kind === kinds.LongFormArticle) {
content = renderEventContent({ hideMetadata: true }) content = renderEventContent({ hideMetadata: true })
} else if (event.kind === kinds.LiveEvent) { } else if (event.kind === kinds.LiveEvent || event.kind === 30312 || event.kind === 30313) {
content = <LiveEvent className="mt-2" event={event} /> content = <LiveEvent className="mt-2" event={event} />
} else if (event.kind === ExtendedKind.GROUP_METADATA) { } else if (event.kind === ExtendedKind.GROUP_METADATA) {
content = <GroupMetadata className="mt-2" event={event} originalNoteId={originalNoteId} /> content = <GroupMetadata className="mt-2" event={event} originalNoteId={originalNoteId} />

31
src/components/VideoPlayer/index.tsx

@ -24,13 +24,16 @@ export default function VideoPlayer({
src, src,
className, className,
poster, poster,
onReady onReady,
fallbackPageUrl
}: { }: {
src: string src: string
className?: string className?: string
poster?: string poster?: string
/** Fires when the first frame is available (e.g. to swap out a blurhash placeholder). */ /** Fires when the first frame is available (e.g. to swap out a blurhash placeholder). */
onReady?: () => void onReady?: () => void
/** When inline playback fails (e.g. empty HLS manifest), link here instead of showing a raw manifest URL. */
fallbackPageUrl?: string
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { autoplay } = useContentPolicy() const { autoplay } = useContentPolicy()
@ -177,6 +180,32 @@ export default function VideoPlayer({
}, [src, onReady, hlsMode]) }, [src, onReady, hlsMode])
if (error) { if (error) {
if (fallbackPageUrl?.trim()) {
return (
<div
className="not-prose w-full space-y-2 rounded-lg border border-border bg-card p-3 shadow-sm"
onClick={(e) => e.stopPropagation()}
>
{poster ? (
<img
src={poster}
alt=""
className="aspect-video w-full max-h-[40vh] rounded-md object-cover"
/>
) : null}
<p className="text-sm leading-snug text-muted-foreground">{t('liveEvent.hlsPlaybackUnavailable')}</p>
<a
href={fallbackPageUrl.trim()}
target="_blank"
rel="noopener noreferrer"
className="inline-flex text-sm font-medium text-green-600 underline-offset-2 hover:underline dark:text-green-400 dark:hover:text-green-300"
onClick={(e) => e.stopPropagation()}
>
{t('Open in browser')}
</a>
</div>
)
}
return <ExternalLink url={src} /> return <ExternalLink url={src} />
} }

2
src/i18n/locales/de.ts

@ -499,6 +499,8 @@ export default {
'Opened by URL — not from your RSS list. Nostr thread is still tied to this link.': 'Opened by URL — not from your RSS list. Nostr thread is still tied to this link.':
'Per URL geöffnet — nicht aus deiner RSS-Liste. Der Nostr-Thread hängt weiter an diesem Link.', 'Per URL geöffnet — nicht aus deiner RSS-Liste. Der Nostr-Thread hängt weiter an diesem Link.',
'Open in browser': 'Im Browser öffnen', 'Open in browser': 'Im Browser öffnen',
'liveEvent.hlsPlaybackUnavailable':
'Wiedergabe hier fehlgeschlagen (Stream offline, beendet oder blockiert). Die gehostete Watch-Seite kannst du unten trotzdem öffnen.',
'Web page': 'Webseite', 'Web page': 'Webseite',
Open: 'Öffnen', Open: 'Öffnen',
'Sorry! The note cannot be found 😔': 'Entschuldigung! Die Notiz wurde nicht gefunden 😔', 'Sorry! The note cannot be found 😔': 'Entschuldigung! Die Notiz wurde nicht gefunden 😔',

2
src/i18n/locales/en.ts

@ -496,6 +496,8 @@ export default {
'Opened by URL — not from your RSS list. Nostr thread is still tied to this link.': 'Opened by URL — not from your RSS list. Nostr thread is still tied to this link.':
'Opened by URL — not from your RSS list. Nostr thread is still tied to this link.', 'Opened by URL — not from your RSS list. Nostr thread is still tied to this link.',
'Open in browser': 'Open in browser', 'Open in browser': 'Open in browser',
'liveEvent.hlsPlaybackUnavailable':
'Inline playback failed (the stream may be offline, ended, or blocked). You can still open the hosted watch page below.',
'Web page': 'Web page', 'Web page': 'Web page',
Open: 'Open', Open: 'Open',
'Sorry! The note cannot be found 😔': 'Sorry! The note cannot be found 😔', 'Sorry! The note cannot be found 😔': 'Sorry! The note cannot be found 😔',

30
src/lib/event-metadata.ts

@ -541,35 +541,41 @@ export function getLongFormArticleMetadataFromEvent(event: Event) {
export function getLiveEventMetadataFromEvent(event: Event) { export function getLiveEventMetadataFromEvent(event: Event) {
let title: string | undefined let title: string | undefined
let room: string | undefined
let summary: string | undefined let summary: string | undefined
let image: string | undefined let image: string | undefined
let thumb: string | undefined
let status: string | undefined let status: string | undefined
const tags = new Set<string>() const tags = new Set<string>()
event.tags.forEach(([tagName, tagValue]) => { event.tags.forEach(([tagName, tagValue]) => {
if (tagName === 'title') { if (tagName === 'title') {
title = tagValue title = tagValue
} else if (tagName === 'room' && tagValue?.trim()) {
room = tagValue.trim()
} else if (tagName === 'summary') { } else if (tagName === 'summary') {
summary = tagValue summary = tagValue
} else if (tagName === 'image') { } else if (tagName === 'image' && tagValue?.trim()) {
image = tagValue image = tagValue.trim()
} else if (tagName === 'status') { } else if (tagName === 'thumb' && tagValue?.trim()) {
status = tagValue thumb = tagValue.trim()
} else if (tagName === 'status' && tagValue?.trim()) {
status = tagValue.trim().toLowerCase()
} else if (tagName === 't' && tagValue && tags.size < 6) { } else if (tagName === 't' && tagValue && tags.size < 6) {
tags.add(tagValue.toLowerCase()) tags.add(tagValue.toLowerCase())
} }
}) })
if (!title) { const dTag = event.tags.find(tagNameEquals('d'))?.[1]
const dTag = event.tags.find(tagNameEquals('d'))?.[1] const dTitle = dTag ? dTagToTitleCase(dTag) : undefined
if (dTag) { /** NIP-53 meeting space (30312) uses `room`; live ticker / meeting (30311/30313) use `title` first. */
title = dTagToTitleCase(dTag) if (event.kind === 30312) {
} else { title = room || title || dTitle || 'no title'
title = 'no title' } else {
} title = title || room || dTitle || 'no title'
} }
return { title, summary, image, status, tags: Array.from(tags) } return { title, summary, image, thumb, status, tags: Array.from(tags) }
} }
export function getGroupMetadataFromEvent(event: Event) { export function getGroupMetadataFromEvent(event: Event) {

146
src/lib/live-activities.test.ts

@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { import {
filterLiveActivityItemsByReachableMedia,
liveEventInlinePlaybackFromEvent, liveEventInlinePlaybackFromEvent,
parseLiveActivityEvent, parseLiveActivityEvent,
preferredLiveJoinUrlForEvent, preferredLiveJoinUrlForEvent,
@ -18,6 +19,74 @@ const base = (kind: number, tags: string[][], pubkey = 'a'.repeat(64)): Event =>
created_at: 1_700_000_000 created_at: 1_700_000_000
}) as Event }) as Event
afterEach(() => {
vi.unstubAllGlobals()
})
describe('filterLiveActivityItemsByReachableMedia', () => {
it('removes 30311 when HLS manifest responds 204', async () => {
const pk = 'a'.repeat(64)
const ev = base(30311, [
['d', 's1'],
['status', 'live'],
['title', 'X'],
['streaming', 'https://example.com/live.m3u8']
], pk)
const item = parseLiveActivityEvent(ev, new Set())
expect(item).not.toBeNull()
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 204,
text: async () => ''
})
)
const out = await filterLiveActivityItemsByReachableMedia([item!])
expect(out).toHaveLength(0)
})
it('keeps 30311 when HLS manifest has body', async () => {
const pk = 'a'.repeat(64)
const ev = base(30311, [
['d', 's1'],
['status', 'live'],
['title', 'X'],
['streaming', 'https://example.com/live.m3u8']
], pk)
const item = parseLiveActivityEvent(ev, new Set())
expect(item).not.toBeNull()
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
text: async () => '#EXTM3U\n'
})
)
const out = await filterLiveActivityItemsByReachableMedia([item!])
expect(out).toHaveLength(1)
})
it('does not fetch for 30312 items', async () => {
const ev = base(30312, [
['d', 'room-1'],
['room', 'Hall'],
['status', 'open'],
['service', 'https://meet.example.com/r/abc']
])
const item = parseLiveActivityEvent(ev, new Set())
expect(item).not.toBeNull()
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
const out = await filterLiveActivityItemsByReachableMedia([item!])
expect(fetchMock).not.toHaveBeenCalled()
expect(out).toHaveLength(1)
})
})
describe('parseLiveActivityEvent (NIP-53)', () => { describe('parseLiveActivityEvent (NIP-53)', () => {
it('accepts 30312 meeting space when status is open (not live)', () => { it('accepts 30312 meeting space when status is open (not live)', () => {
const ev = base(30312, [ const ev = base(30312, [
@ -90,6 +159,43 @@ describe('parseLiveActivityEvent (NIP-53)', () => {
expect(parseLiveActivityEvent(ev, new Set())).toBeNull() expect(parseLiveActivityEvent(ev, new Set())).toBeNull()
}) })
it('30311 Nostr Nests LiveKit uses nostrnests.com naddr, not zap.stream', () => {
const pk = 'f'.repeat(64)
const ev = base(
30311,
[
['d', 'eaf66800-fdaa-4796-b755-e34cec4fd485'],
['status', 'live'],
['title', 'test'],
['service', 'https://nostrnests.com'],
['streaming', 'wss+livekit://nostrnests.com:443']
],
pk
)
const naddr = nip19.naddrEncode({
kind: 30311,
pubkey: pk,
identifier: 'eaf66800-fdaa-4796-b755-e34cec4fd485'
})
const join = `https://nostrnests.com/${naddr}`
expect(parseLiveActivityEvent(ev, new Set())?.joinUrl).toBe(join)
expect(preferredLiveJoinUrlForEvent(ev)).toBe(join)
})
it('accepts 30311 when status is LIVE (case-insensitive)', () => {
const pk = 'a'.repeat(64)
const ev = base(
30311,
[
['d', 's1'],
['status', 'LIVE'],
['streaming', 'https://example.com/live/stream.m3u8']
],
pk
)
expect(parseLiveActivityEvent(ev, new Set())).not.toBeNull()
})
it('uses zap.stream naddr page for 30311 when streaming is only HLS manifest', () => { it('uses zap.stream naddr page for 30311 when streaming is only HLS manifest', () => {
const pk = 'a'.repeat(64) const pk = 'a'.repeat(64)
const ev = base( const ev = base(
@ -218,6 +324,19 @@ describe('liveEventInlinePlaybackFromEvent', () => {
}) })
}) })
it('picks first playable streaming URL when multiple streaming tags exist', () => {
const ev = base(30311, [
['d', 'chill'],
['streaming', 'moq://relay.example:1443/'],
['streaming', 'https://session.example/api/not-a-manifest'],
['streaming', 'https://cdn.example/hls/chill/index.m3u8']
])
expect(liveEventInlinePlaybackFromEvent(ev)).toEqual({
src: 'https://cdn.example/hls/chill/index.m3u8',
mode: 'video'
})
})
it('returns null for non-30311', () => { it('returns null for non-30311', () => {
expect(liveEventInlinePlaybackFromEvent(base(1, [['d', 'x']]))).toBeNull() expect(liveEventInlinePlaybackFromEvent(base(1, [['d', 'x']]))).toBeNull()
}) })
@ -245,6 +364,31 @@ describe('preferredLiveJoinUrlForEvent (Nostr Nests & Corny Chat)', () => {
expect(preferredLiveJoinUrlForEvent(ev)).toBe(`https://nostrnests.com/${naddr}`) expect(preferredLiveJoinUrlForEvent(ev)).toBe(`https://nostrnests.com/${naddr}`)
}) })
it('30312 Nests fork: prefers web origin /naddr over API service URL', () => {
const pk = '3f770d65d3a764a9c5cb503ae123e62ec7598ad035d836e2a810f3877a745b24'
const ev = base(
30312,
[
['d', 'c69dfcf4-627c-4227-bc73-c6fa47f99a13'],
['room', 'TEST'],
['status', 'open'],
['service', 'https://nestsdev.derekross.me/api/v1/nests'],
['streaming', 'wss+livekit://livekit:7880'],
['client', 'nestsdev.derekross.me']
],
pk
)
const naddr = nip19.naddrEncode({
kind: 30312,
pubkey: pk,
identifier: 'c69dfcf4-627c-4227-bc73-c6fa47f99a13'
})
expect(preferredLiveJoinUrlForEvent(ev)).toBe(`https://nestsdev.derekross.me/${naddr}`)
expect(parseLiveActivityEvent(ev, new Set())?.joinUrl).toBe(
`https://nestsdev.derekross.me/${naddr}`
)
})
it('Corny Chat kind 1: prefers r over service when they differ', () => { it('Corny Chat kind 1: prefers r over service when they differ', () => {
const ev = base(1, [ const ev = base(1, [
['L', 'com.cornychat'], ['L', 'com.cornychat'],

160
src/lib/live-activities.ts

@ -172,6 +172,67 @@ function nostrNestsWebUrlForAddressable(ev: Event): string | undefined {
return naddrPageUrlForAddressable(ev, NOSTR_NESTS_WEB_ORIGIN) return naddrPageUrlForAddressable(ev, NOSTR_NESTS_WEB_ORIGIN)
} }
/**
* Self-hosted or dev [Nostr Nests](https://github.com/nostrnests/nests) (LiveKit): `streaming` is
* `wss+livekit://…` and `service` is an HTTPS URL on the web app host (often `…/api/v1/nests`).
* Join URL is `https://<host>/<naddr>` like production nostrnests.com.
*/
function nestsForkLiveKit30312WebOrigin(ev: Event): string | undefined {
if (ev.kind !== 30312) return undefined
const stream = firstTagValue(ev, 'streaming')?.trim() ?? ''
if (!stream.startsWith('wss+livekit://')) return undefined
const svcRaw = firstTagValue(ev, 'service')?.trim()
if (!svcRaw) return undefined
let svcUrl: URL
try {
svcUrl = new URL(svcRaw)
} catch {
return undefined
}
if (svcUrl.protocol !== 'https:') return undefined
const svcHost = svcUrl.hostname.toLowerCase().replace(/^www\./, '')
const pathLower = svcUrl.pathname.toLowerCase()
const clientRaw = firstTagValue(ev, 'client')?.trim()
const clientHost = clientRaw
? clientRaw.split(':')[0].toLowerCase().replace(/^www\./, '')
: ''
const pathSuggestsNests = pathLower.includes('/nests')
const clientMatchesService = Boolean(clientHost && clientHost === svcHost)
if (!pathSuggestsNests && !clientMatchesService) return undefined
return svcUrl.origin
}
/**
* Nostr Nests [publishes](https://github.com/nostrnests/nests) kind 30311 tickers with `wss+livekit://…` and
* `service` on `nostrnests.com`. Those streams are not playable on [zap.stream](https://github.com/v0l/zap.stream);
* open the native nest page instead (same `/:naddr` pattern as kind 30312).
*/
function isNostrNests30311WebJoin(ev: Event): boolean {
if (ev.kind !== 30311) return false
const svc = firstTagValue(ev, 'service')?.trim()
if (svc) {
try {
const h = new URL(svc).hostname.toLowerCase().replace(/^www\./, '')
if (h === 'nostrnests.com') return true
} catch {
/* ignore */
}
}
const stream = firstTagValue(ev, 'streaming')?.trim() ?? ''
if (stream.startsWith('wss+livekit://')) {
const rest = stream.slice('wss+livekit://'.length)
const hostPart = rest.split('/')[0] ?? ''
const host = hostPart.split(':')[0]?.toLowerCase() ?? ''
return host === 'nostrnests.com' || host.endsWith('.nostrnests.com')
}
return false
}
function firstHttpsJoinFromTagNames(ev: Event, names: readonly string[]): string | undefined { function firstHttpsJoinFromTagNames(ev: Event, names: readonly string[]): string | undefined {
for (const name of names) { for (const name of names) {
const raw = firstTagValue(ev, name) const raw = firstTagValue(ev, name)
@ -244,12 +305,14 @@ function isCornyChatKind1Invite(ev: Event): boolean {
/** /**
* URL to open for this activity. * URL to open for this activity.
* **30311 (Nostr Nests + LiveKit):** `service` / `wss+livekit://…` on `nostrnests.com` [nostrnests.com/naddr](https://nostrnests.com/).
* **30311 (Corny Chat):** Prefer [`origin/_/integrations/nostr/naddr…`](https://github.com/vicariousdrama/cornychat) when * **30311 (Corny Chat):** Prefer [`origin/_/integrations/nostr/naddr…`](https://github.com/vicariousdrama/cornychat) when
* `L`/`com.cornychat` is present (instance origin from `r`/`service`, host checked against `l` when tagged). * `L`/`com.cornychat` is present (instance origin from `r`/`service`, host checked against `l` when tagged).
* **30311 (other):** Always use canonical [zap.stream/naddr](https://zap.stream) when `d` is present so we never * **30311 (other):** Always use canonical [zap.stream/naddr](https://zap.stream) when `d` is present so we never
* stick on stale `service`/`r` URLs publishers no longer use. zap.stream loads the same NIP-53 event and * stick on stale `service`/`r` URLs publishers no longer use. zap.stream loads the same NIP-53 event and
* plays `streaming` / etc. Fallbacks only if naddr cannot be built. * plays `streaming` / etc. Fallbacks only if naddr cannot be built.
* **30312 (Nostr Nests official MoQ):** Prefer [nostrnests.com/naddr](https://nostrnests.com/) over `streaming` (MoQ). * **30312 (Nostr Nests official MoQ):** Prefer [nostrnests.com/naddr](https://nostrnests.com/) over `streaming` (MoQ).
* **30312 (Nests fork + LiveKit):** `wss+livekit://…` and HTTPS `service` on the same host as `client` (or `…/nests` in the path) `https://<instance>/<naddr>`.
* **Kind 1 (Corny Chat invite):** Prefer `r` `service` `streaming` per pantry publish shape. * **Kind 1 (Corny Chat invite):** Prefer `r` `service` `streaming` per pantry publish shape.
* **Other 30312 / 30313:** Use tagged https URLs, bare `naddr1`, or (for 30313) parent space URLs via {@link resolveJoinUrl}. * **Other 30312 / 30313:** Use tagged https URLs, bare `naddr1`, or (for 30313) parent space URLs via {@link resolveJoinUrl}.
*/ */
@ -260,6 +323,10 @@ function isCornyChatKind1Invite(ev: Event): boolean {
* Everyone else gets the zap.stream player URL, which resolves the same replaceable event by naddr. * Everyone else gets the zap.stream player URL, which resolves the same replaceable event by naddr.
*/ */
function joinUrlFor30311Ticker(ev: Event): string | undefined { function joinUrlFor30311Ticker(ev: Event): string | undefined {
if (isNostrNests30311WebJoin(ev)) {
const nests = nostrNestsWebUrlForAddressable(ev)
if (nests) return nests
}
if (isCornyChat30311(ev)) { if (isCornyChat30311(ev)) {
const corny = cornyChatNaddrIntegrationUrl(ev) const corny = cornyChatNaddrIntegrationUrl(ev)
if (corny) return corny if (corny) return corny
@ -272,11 +339,18 @@ function joinUrlFor30311Ticker(ev: Event): string | undefined {
* Kind 30312 is the NIP-53 meeting space ticker (Jitsi-style rooms, Nostr Nests, etc.). * Kind 30312 is the NIP-53 meeting space ticker (Jitsi-style rooms, Nostr Nests, etc.).
* [Nostr Nests](https://github.com/nostrnests/nests) official rooms use MoQ (`moq.nostrnests.com` / `moq-auth.nostrnests.com`); * [Nostr Nests](https://github.com/nostrnests/nests) official rooms use MoQ (`moq.nostrnests.com` / `moq-auth.nostrnests.com`);
* `streaming` there is not a normal browser page, so we open [nostrnests.com/naddr](https://nostrnests.com/) instead. * `streaming` there is not a normal browser page, so we open [nostrnests.com/naddr](https://nostrnests.com/) instead.
* Self-hosted LiveKit nests use {@link nestsForkLiveKit30312WebOrigin} for the same `/:naddr` join pattern.
* Other 30312 publishers keep using `service` / `r` / from the generic branch below. * Other 30312 publishers keep using `service` / `r` / from the generic branch below.
*/ */
function joinUrlFor30312Space(ev: Event): string | undefined { function joinUrlFor30312Space(ev: Event): string | undefined {
if (!isNostrNestsOfficialMoq30312(ev)) return undefined if (isNostrNestsOfficialMoq30312(ev)) {
return nostrNestsWebUrlForAddressable(ev) return nostrNestsWebUrlForAddressable(ev)
}
const forkOrigin = nestsForkLiveKit30312WebOrigin(ev)
if (forkOrigin) {
return naddrPageUrlForAddressable(ev, forkOrigin)
}
return undefined
} }
function pickJoinUrl(ev: Event): string | undefined { function pickJoinUrl(ev: Event): string | undefined {
@ -331,7 +405,7 @@ export function preferredLiveJoinUrlForEvent(ev: Event): string | undefined {
* - 30313 meeting in a space: `planned` | `live` | `ended` * - 30313 meeting in a space: `planned` | `live` | `ended`
*/ */
function isActiveLiveActivityStatus(ev: Event): boolean { function isActiveLiveActivityStatus(ev: Event): boolean {
const status = firstTagValue(ev, 'status') const status = firstTagValue(ev, 'status')?.trim().toLowerCase()
if (ev.kind === 30312) { if (ev.kind === 30312) {
return status === 'open' || status === 'private' return status === 'open' || status === 'private'
} }
@ -578,18 +652,88 @@ export type LiveEventInlinePlayback = { src: string; mode: 'audio' | 'video' }
export function liveEventInlinePlaybackFromEvent(ev: Event): LiveEventInlinePlayback | null { export function liveEventInlinePlaybackFromEvent(ev: Event): LiveEventInlinePlayback | null {
if (ev.kind !== 30311) return null if (ev.kind !== 30311) return null
const rUrls = tagValues(ev, 'r').filter(isStreamableHttpUrl) const rUrls = tagValues(ev, 'r').filter(isStreamableHttpUrl)
const streaming = tagValues(ev, 'streaming').find(isStreamableHttpUrl) /** NIP-53 allows several `streaming` tags (e.g. MoQ + HLS); pick the first URL we can actually play in-browser. */
const streamingUrls = tagValues(ev, 'streaming').filter(isStreamableHttpUrl)
for (const u of rUrls) { for (const u of rUrls) {
if (isAudio(u)) return { src: u, mode: 'audio' } if (isAudio(u)) return { src: u, mode: 'audio' }
} }
if (streaming && isAudio(streaming)) return { src: streaming, mode: 'audio' } for (const u of streamingUrls) {
if (isAudio(u)) return { src: u, mode: 'audio' }
}
for (const u of rUrls) { for (const u of rUrls) {
if (isHlsPlaylistUrl(u) || isVideo(u)) return { src: u, mode: 'video' } if (isHlsPlaylistUrl(u) || isVideo(u)) return { src: u, mode: 'video' }
} }
if (streaming && (isHlsPlaylistUrl(streaming) || isVideo(streaming))) { for (const u of streamingUrls) {
return { src: streaming, mode: 'video' } if (isHlsPlaylistUrl(u) || isVideo(u)) return { src: u, mode: 'video' }
} }
return null return null
} }
const LIVE_ACTIVITIES_STREAM_PROBE_MS = 6000
/**
* Best-effort check that the URL used for inline playback returns usable media (not empty manifest, not 404).
* On CORS/network/timeout failure returns true so we do not hide streams we could not verify.
*/
async function isInlinePlaybackUrlReachable(url: string, timeoutMs: number): Promise<boolean> {
const ctrl = new AbortController()
const timer = globalThis.setTimeout(() => ctrl.abort(), timeoutMs)
try {
if (isHlsPlaylistUrl(url)) {
const res = await fetch(url, {
method: 'GET',
mode: 'cors',
signal: ctrl.signal,
cache: 'no-store'
})
if (res.status === 204) return false
if (!res.ok) return false
const text = await res.text()
return text.trim().length > 0
}
let res = await fetch(url, {
method: 'HEAD',
mode: 'cors',
signal: ctrl.signal,
cache: 'no-store'
})
if (res.ok && res.status !== 204) return true
if (res.status === 405 || res.status === 501) {
res = await fetch(url, {
method: 'GET',
mode: 'cors',
signal: ctrl.signal,
cache: 'no-store',
headers: { Range: 'bytes=0-0' }
})
return (res.ok || res.status === 206) && res.status !== 204
}
return false
} catch {
return true
} finally {
globalThis.clearTimeout(timer)
}
}
/**
* Removes kind 30311 live activities from the sidebar/mobile carousel when their inline `r`/`streaming`
* endpoint is clearly dead (e.g. empty HLS manifest). Meetings/spaces (30312/30313) are unchanged.
*/
export async function filterLiveActivityItemsByReachableMedia(
items: TLiveActivityItem[],
timeoutMs = LIVE_ACTIVITIES_STREAM_PROBE_MS
): Promise<TLiveActivityItem[]> {
const checked = await Promise.all(
items.map(async (item) => {
if (item.kind !== 30311) return item
const playback = liveEventInlinePlaybackFromEvent(item.event)
if (!playback) return item
const ok = await isInlinePlaybackUrlReachable(playback.src, timeoutMs)
return ok ? item : null
})
)
return checked.filter((x): x is TLiveActivityItem => x != null)
}

3
src/lib/note-renderable-kinds.ts

@ -9,6 +9,9 @@ const RENDERABLE_NOTE_KINDS = new Set<number>([
ExtendedKind.POLL_RESPONSE, ExtendedKind.POLL_RESPONSE,
kinds.CommunityDefinition, kinds.CommunityDefinition,
kinds.LiveEvent, kinds.LiveEvent,
/** NIP-53 meeting space (30312) and meeting (30313); rendered like kind 30311 in Note. */
30312,
30313,
ExtendedKind.GROUP_METADATA, ExtendedKind.GROUP_METADATA,
ExtendedKind.PUBLIC_MESSAGE, ExtendedKind.PUBLIC_MESSAGE,
ExtendedKind.ZAP_REQUEST, ExtendedKind.ZAP_REQUEST,

11
src/providers/LiveActivitiesProvider.tsx

@ -1,5 +1,6 @@
import { import {
buildLiveActivitiesRelayUrls, buildLiveActivitiesRelayUrls,
filterLiveActivityItemsByReachableMedia,
LIVE_ACTIVITY_KINDS, LIVE_ACTIVITY_KINDS,
mergeLiveActivityEvents, mergeLiveActivityEvents,
msUntilNextQuarterHour, msUntilNextQuarterHour,
@ -61,8 +62,14 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode
client.fetchEvents(u, f, o) client.fetchEvents(u, f, o)
) )
const merged = mergeLiveActivityEvents(events, followings, parentByAddress) const merged = mergeLiveActivityEvents(events, followings, parentByAddress)
setItems(merged) const reachable = await filterLiveActivityItemsByReachableMedia(merged)
logger.debug('[LiveActivities] poll done', { relayCount: urls.length, raw: events.length, merged: merged.length }) setItems(reachable)
logger.debug('[LiveActivities] poll done', {
relayCount: urls.length,
raw: events.length,
merged: merged.length,
afterStreamProbe: reachable.length
})
} catch (e) { } catch (e) {
logger.warn('[LiveActivities] poll failed', { err: e }) logger.warn('[LiveActivities] poll failed', { err: e })
setItems([]) setItems([])

Loading…
Cancel
Save