Browse Source

bug-fixes

imwald
Silberengel 2 weeks ago
parent
commit
087bd01ae1
  1. 32
      src/components/AudioPlayer/index.tsx
  2. 20
      src/components/MediaPlayer/index.tsx
  3. 94
      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. 26
      src/lib/event-metadata.ts
  9. 146
      src/lib/live-activities.test.ts
  10. 158
      src/lib/live-activities.ts
  11. 3
      src/lib/note-renderable-kinds.ts
  12. 11
      src/providers/LiveActivitiesProvider.tsx

32
src/components/AudioPlayer/index.tsx

@ -11,11 +11,13 @@ import logger from '@/lib/logger' @@ -11,11 +11,13 @@ import logger from '@/lib/logger'
interface AudioPlayerProps {
src: 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). */
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 [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
@ -107,6 +109,8 @@ export default function AudioPlayer({ src, className, onReady }: AudioPlayerProp @@ -107,6 +109,8 @@ export default function AudioPlayer({ src, className, onReady }: AudioPlayerProp
return <ExternalLink url={src} />
}
const cover = poster?.trim()
return (
<MediaErrorBoundary
fallback={<ExternalLink url={src} />}
@ -118,22 +122,31 @@ export default function AudioPlayer({ src, className, onReady }: AudioPlayerProp @@ -118,22 +122,31 @@ export default function AudioPlayer({ src, className, onReady }: AudioPlayerProp
setError(true)
}}
>
<div className={cn('flex w-full max-w-md flex-col gap-2', className)} onClick={(e) => e.stopPropagation()}>
{cover ? (
<div className="not-prose overflow-hidden rounded-lg border border-border bg-muted shadow-sm">
<img
src={cover}
alt=""
className="aspect-video w-full max-h-48 object-cover"
referrerPolicy="no-referrer"
draggable={false}
/>
</div>
) : null}
<div
className={cn(
'flex items-center gap-3 py-2 pl-2 pr-4 border rounded-full max-w-md',
className
'flex w-full items-center gap-3 rounded-full border py-2 pl-2 pr-4',
!cover && 'max-w-md'
)}
onClick={(e) => e.stopPropagation()}
>
<audio ref={audioRef} src={src} preload="metadata" onError={() => setError(true)} />
{/* Play/Pause Button */}
<Button size="icon" className="rounded-full shrink-0" onClick={togglePlay}>
<Button size="icon" className="shrink-0 rounded-full" onClick={togglePlay}>
{isPlaying ? <Pause fill="currentColor" /> : <Play fill="currentColor" />}
</Button>
{/* Progress Section */}
<div className="flex-1 relative">
<div className="relative min-w-0 flex-1">
<Slider
value={[currentTime]}
max={duration || 100}
@ -144,10 +157,11 @@ export default function AudioPlayer({ src, className, onReady }: AudioPlayerProp @@ -144,10 +157,11 @@ export default function AudioPlayer({ src, className, onReady }: AudioPlayerProp
/>
</div>
<div className="text-sm font-mono text-muted-foreground">
<div className="shrink-0 font-mono text-sm text-muted-foreground">
{formatTime(Math.max(duration - currentTime, 0))}
</div>
</div>
</div>
</MediaErrorBoundary>
)
}

20
src/components/MediaPlayer/index.tsx

@ -39,7 +39,8 @@ export default function MediaPlayer({ @@ -39,7 +39,8 @@ export default function MediaPlayer({
className,
mustLoad = false,
poster,
blurHash
blurHash,
fallbackPageUrl
}: {
src: string
className?: string
@ -47,6 +48,8 @@ export default function MediaPlayer({ @@ -47,6 +48,8 @@ export default function MediaPlayer({
poster?: string
/** NIP-94 / imeta blurhash for lazy placeholder when poster is missing */
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 { autoLoadMedia } = useContentPolicy()
@ -221,9 +224,20 @@ export default function MediaPlayer({ @@ -221,9 +224,20 @@ export default function MediaPlayer({
aria-hidden={!embedPainted}
>
{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>

94
src/components/Note/LiveEvent.tsx

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { createFakeEvent } from '@/lib/event'
import { getLiveEventMetadataFromEvent } from '@/lib/event-metadata'
import {
liveEventInlinePlaybackFromEvent,
@ -8,13 +9,14 @@ import { @@ -8,13 +9,14 @@ import {
import { cn } from '@/lib/utils'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider'
import { Event } from 'nostr-tools'
import { Event, kinds } from 'nostr-tools'
import { ExternalLink } from 'lucide-react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import ClientSelect from '../ClientSelect'
import Image from '../Image'
import MediaPlayer from '../MediaPlayer'
import MarkdownArticle from './MarkdownArticle/MarkdownArticle'
export default function LiveEvent({ event, className }: { event: Event; className?: string }) {
const { t } = useTranslation()
@ -25,12 +27,30 @@ export default function LiveEvent({ event, className }: { event: Event; classNam @@ -25,12 +27,30 @@ export default function LiveEvent({ event, className }: { event: Event; classNam
const metadata = useMemo(() => getLiveEventMetadataFromEvent(event), [event])
const playback = useMemo(() => liveEventInlinePlaybackFromEvent(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 =
metadata.status &&
(metadata.status === 'live' ? (
statusKey &&
(statusKey === 'live' ? (
<Badge className="bg-green-400 hover:bg-green-400">live</Badge>
) : metadata.status === 'ended' ? (
) : statusKey === 'ended' ? (
<Badge variant="destructive">ended</Badge>
) : (
<Badge variant="secondary">{metadata.status}</Badge>
@ -45,9 +65,19 @@ export default function LiveEvent({ event, className }: { event: Event; classNam @@ -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>
) : null
const summaryComponent = metadata.summary && (
<div className="text-base text-muted-foreground line-clamp-4 mt-1">{metadata.summary}</div>
)
const summaryComponent = summaryMarkdownEvent ? (
<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 && (
<div className="flex gap-1 flex-wrap mt-2">
@ -59,23 +89,34 @@ export default function LiveEvent({ event, className }: { event: Event; classNam @@ -59,23 +89,34 @@ export default function LiveEvent({ event, className }: { event: Event; classNam
</div>
)
const cover =
metadata.image && autoLoadMedia ? (
const cover = showSideArtwork ? (
<Image
image={{ url: metadata.image, pubkey: event.pubkey }}
image={{ url: posterUrl!, pubkey: event.pubkey }}
className={cn(
'bg-muted shrink-0',
isSmallScreen ? 'w-full aspect-video' : 'aspect-[4/3] xl:aspect-video h-44 w-auto max-w-[min(100%,20rem)]'
'bg-muted',
isSmallScreen ? 'w-full aspect-video' : 'aspect-[4/3] xl:aspect-video h-44 w-full max-h-44'
)}
classNames={{
// Image’s default wrapper is `w-full`; in a row flex that steals the whole row and collapses the text column.
wrapper: cn(
'shrink-0',
isSmallScreen ? 'w-full' : 'w-[min(100%,20rem)]'
)
}}
hideIfError
/>
) : null
return (
<div className={cn(className, 'space-y-3')}>
<div className={cn('flex gap-4', isSmallScreen ? 'flex-col' : 'flex-row items-start')}>
<div className={cn(className, 'min-w-0 w-full space-y-3')}>
<div
className={cn(
'flex min-w-0 w-full gap-4',
isSmallScreen ? 'flex-col' : 'flex-row items-start'
)}
>
{cover}
<div className="flex-1 min-w-0 space-y-1">
<div className="min-w-0 flex-1 space-y-1">
{titleComponent}
{liveStatusComponent}
{nowPlaying}
@ -85,29 +126,34 @@ export default function LiveEvent({ event, className }: { event: Event; classNam @@ -85,29 +126,34 @@ export default function LiveEvent({ event, className }: { event: Event; classNam
</div>
{playback ? (
<div className="w-full max-w-[400px]" onClick={(e) => e.stopPropagation()}>
<MediaPlayer src={playback.src} poster={metadata.image} className="w-full" />
<div className="min-w-0 w-full max-w-[400px]" onClick={(e) => e.stopPropagation()}>
<MediaPlayer
src={playback.src}
poster={inlinePlayerPoster}
className="w-full"
fallbackPageUrl={joinUrl ?? undefined}
/>
</div>
) : null}
<div
className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center"
onClick={(e) => e.stopPropagation()}
>
<div className="flex min-w-0 w-full flex-col gap-2" onClick={(e) => e.stopPropagation()}>
{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
href={joinUrl}
target="_blank"
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" />
{t('Open in browser')}
</a>
</Button>
) : 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>
)

2
src/components/Note/index.tsx

@ -298,7 +298,7 @@ export default function Note({ @@ -298,7 +298,7 @@ export default function Note({
)
} else if (event.kind === kinds.LongFormArticle) {
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} />
} else if (event.kind === ExtendedKind.GROUP_METADATA) {
content = <GroupMetadata className="mt-2" event={event} originalNoteId={originalNoteId} />

31
src/components/VideoPlayer/index.tsx

@ -24,13 +24,16 @@ export default function VideoPlayer({ @@ -24,13 +24,16 @@ export default function VideoPlayer({
src,
className,
poster,
onReady
onReady,
fallbackPageUrl
}: {
src: string
className?: string
poster?: string
/** Fires when the first frame is available (e.g. to swap out a blurhash placeholder). */
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 { autoplay } = useContentPolicy()
@ -177,6 +180,32 @@ export default function VideoPlayer({ @@ -177,6 +180,32 @@ export default function VideoPlayer({
}, [src, onReady, hlsMode])
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} />
}

2
src/i18n/locales/de.ts

@ -499,6 +499,8 @@ export default { @@ -499,6 +499,8 @@ export default {
'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.',
'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',
Open: 'Öffnen',
'Sorry! The note cannot be found 😔': 'Entschuldigung! Die Notiz wurde nicht gefunden 😔',

2
src/i18n/locales/en.ts

@ -496,6 +496,8 @@ export default { @@ -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.',
'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',
Open: 'Open',
'Sorry! The note cannot be found 😔': 'Sorry! The note cannot be found 😔',

26
src/lib/event-metadata.ts

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

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

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
filterLiveActivityItemsByReachableMedia,
liveEventInlinePlaybackFromEvent,
parseLiveActivityEvent,
preferredLiveJoinUrlForEvent,
@ -18,6 +19,74 @@ const base = (kind: number, tags: string[][], pubkey = 'a'.repeat(64)): Event => @@ -18,6 +19,74 @@ const base = (kind: number, tags: string[][], pubkey = 'a'.repeat(64)): Event =>
created_at: 1_700_000_000
}) 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)', () => {
it('accepts 30312 meeting space when status is open (not live)', () => {
const ev = base(30312, [
@ -90,6 +159,43 @@ describe('parseLiveActivityEvent (NIP-53)', () => { @@ -90,6 +159,43 @@ describe('parseLiveActivityEvent (NIP-53)', () => {
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', () => {
const pk = 'a'.repeat(64)
const ev = base(
@ -218,6 +324,19 @@ describe('liveEventInlinePlaybackFromEvent', () => { @@ -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', () => {
expect(liveEventInlinePlaybackFromEvent(base(1, [['d', 'x']]))).toBeNull()
})
@ -245,6 +364,31 @@ describe('preferredLiveJoinUrlForEvent (Nostr Nests & Corny Chat)', () => { @@ -245,6 +364,31 @@ describe('preferredLiveJoinUrlForEvent (Nostr Nests & Corny Chat)', () => {
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', () => {
const ev = base(1, [
['L', 'com.cornychat'],

158
src/lib/live-activities.ts

@ -172,6 +172,67 @@ function nostrNestsWebUrlForAddressable(ev: Event): string | undefined { @@ -172,6 +172,67 @@ function nostrNestsWebUrlForAddressable(ev: Event): string | undefined {
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 {
for (const name of names) {
const raw = firstTagValue(ev, name)
@ -244,12 +305,14 @@ function isCornyChatKind1Invite(ev: Event): boolean { @@ -244,12 +305,14 @@ function isCornyChatKind1Invite(ev: Event): boolean {
/**
* 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
* `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
* 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.
* **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.
* **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 { @@ -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.
*/
function joinUrlFor30311Ticker(ev: Event): string | undefined {
if (isNostrNests30311WebJoin(ev)) {
const nests = nostrNestsWebUrlForAddressable(ev)
if (nests) return nests
}
if (isCornyChat30311(ev)) {
const corny = cornyChatNaddrIntegrationUrl(ev)
if (corny) return corny
@ -272,12 +339,19 @@ function joinUrlFor30311Ticker(ev: Event): string | undefined { @@ -272,12 +339,19 @@ function joinUrlFor30311Ticker(ev: Event): string | undefined {
* 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`);
* `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.
*/
function joinUrlFor30312Space(ev: Event): string | undefined {
if (!isNostrNestsOfficialMoq30312(ev)) return undefined
if (isNostrNestsOfficialMoq30312(ev)) {
return nostrNestsWebUrlForAddressable(ev)
}
const forkOrigin = nestsForkLiveKit30312WebOrigin(ev)
if (forkOrigin) {
return naddrPageUrlForAddressable(ev, forkOrigin)
}
return undefined
}
function pickJoinUrl(ev: Event): string | undefined {
if (ev.kind === 30311) {
@ -331,7 +405,7 @@ export function preferredLiveJoinUrlForEvent(ev: Event): string | undefined { @@ -331,7 +405,7 @@ export function preferredLiveJoinUrlForEvent(ev: Event): string | undefined {
* - 30313 meeting in a space: `planned` | `live` | `ended`
*/
function isActiveLiveActivityStatus(ev: Event): boolean {
const status = firstTagValue(ev, 'status')
const status = firstTagValue(ev, 'status')?.trim().toLowerCase()
if (ev.kind === 30312) {
return status === 'open' || status === 'private'
}
@ -578,18 +652,88 @@ export type LiveEventInlinePlayback = { src: string; mode: 'audio' | 'video' } @@ -578,18 +652,88 @@ export type LiveEventInlinePlayback = { src: string; mode: 'audio' | 'video' }
export function liveEventInlinePlaybackFromEvent(ev: Event): LiveEventInlinePlayback | null {
if (ev.kind !== 30311) return null
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) {
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) {
if (isHlsPlaylistUrl(u) || isVideo(u)) return { src: u, mode: 'video' }
}
if (streaming && (isHlsPlaylistUrl(streaming) || isVideo(streaming))) {
return { src: streaming, mode: 'video' }
for (const u of streamingUrls) {
if (isHlsPlaylistUrl(u) || isVideo(u)) return { src: u, mode: 'video' }
}
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>([ @@ -9,6 +9,9 @@ const RENDERABLE_NOTE_KINDS = new Set<number>([
ExtendedKind.POLL_RESPONSE,
kinds.CommunityDefinition,
kinds.LiveEvent,
/** NIP-53 meeting space (30312) and meeting (30313); rendered like kind 30311 in Note. */
30312,
30313,
ExtendedKind.GROUP_METADATA,
ExtendedKind.PUBLIC_MESSAGE,
ExtendedKind.ZAP_REQUEST,

11
src/providers/LiveActivitiesProvider.tsx

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

Loading…
Cancel
Save