From 087bd01ae192ef26e84ca09f67002ed0c4f4408d Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 13 Apr 2026 20:05:35 +0200 Subject: [PATCH] bug-fixes --- src/components/AudioPlayer/index.tsx | 72 ++++++---- src/components/MediaPlayer/index.tsx | 20 ++- src/components/Note/LiveEvent.tsx | 106 ++++++++++----- src/components/Note/index.tsx | 2 +- src/components/VideoPlayer/index.tsx | 31 ++++- src/i18n/locales/de.ts | 2 + src/i18n/locales/en.ts | 2 + src/lib/event-metadata.ts | 30 +++-- src/lib/live-activities.test.ts | 146 ++++++++++++++++++++- src/lib/live-activities.ts | 160 +++++++++++++++++++++-- src/lib/note-renderable-kinds.ts | 3 + src/providers/LiveActivitiesProvider.tsx | 11 +- 12 files changed, 498 insertions(+), 87 deletions(-) diff --git a/src/components/AudioPlayer/index.tsx b/src/components/AudioPlayer/index.tsx index 3a8dc54f..3de1c4d8 100644 --- a/src/components/AudioPlayer/index.tsx +++ b/src/components/AudioPlayer/index.tsx @@ -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(null) const [isPlaying, setIsPlaying] = useState(false) const [currentTime, setCurrentTime] = useState(0) @@ -107,6 +109,8 @@ export default function AudioPlayer({ src, className, onReady }: AudioPlayerProp return } + const cover = poster?.trim() + return ( } @@ -118,34 +122,44 @@ export default function AudioPlayer({ src, className, onReady }: AudioPlayerProp setError(true) }} > -
e.stopPropagation()} - > -
diff --git a/src/components/Note/LiveEvent.tsx b/src/components/Note/LiveEvent.tsx index c8bda120..2e9312b8 100644 --- a/src/components/Note/LiveEvent.tsx +++ b/src/components/Note/LiveEvent.tsx @@ -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 { 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 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' ? ( live - ) : metadata.status === 'ended' ? ( + ) : statusKey === 'ended' ? ( ended ) : ( {metadata.status} @@ -45,9 +65,19 @@ export default function LiveEvent({ event, className }: { event: Event; classNam

{event.content.trim()}

) : null - const summaryComponent = metadata.summary && ( -
{metadata.summary}
- ) + const summaryComponent = summaryMarkdownEvent ? ( +
e.stopPropagation()} + > + +
+ ) : null const tagsComponent = metadata.tags.length > 0 && (
@@ -59,23 +89,34 @@ export default function LiveEvent({ event, className }: { event: Event; classNam
) - const cover = - metadata.image && autoLoadMedia ? ( - - ) : null + const cover = showSideArtwork ? ( + + ) : null return ( -
-
+
+
{cover} -
+
{titleComponent} {liveStatusComponent} {nowPlaying} @@ -85,29 +126,34 @@ export default function LiveEvent({ event, className }: { event: Event; classNam
{playback ? ( -
e.stopPropagation()}> - +
e.stopPropagation()}> +
) : null} -
e.stopPropagation()} - > +
e.stopPropagation()}> {joinUrl ? ( - ) : null} - +
) diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index e14e0e00..0df11ce7 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -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 = } else if (event.kind === ExtendedKind.GROUP_METADATA) { content = diff --git a/src/components/VideoPlayer/index.tsx b/src/components/VideoPlayer/index.tsx index 0c4e99bc..13722227 100644 --- a/src/components/VideoPlayer/index.tsx +++ b/src/components/VideoPlayer/index.tsx @@ -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({ }, [src, onReady, hlsMode]) if (error) { + if (fallbackPageUrl?.trim()) { + return ( +
e.stopPropagation()} + > + {poster ? ( + + ) : null} +

{t('liveEvent.hlsPlaybackUnavailable')}

+ e.stopPropagation()} + > + {t('Open in browser')} + +
+ ) + } return } diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 83b2a2ca..6b7ad33a 100644 --- a/src/i18n/locales/de.ts +++ b/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.': '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 😔', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index c92ef636..081af47a 100644 --- a/src/i18n/locales/en.ts +++ b/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.', '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 😔', diff --git a/src/lib/event-metadata.ts b/src/lib/event-metadata.ts index 563fbb0f..88e0bcc5 100644 --- a/src/lib/event-metadata.ts +++ b/src/lib/event-metadata.ts @@ -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() 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) - } else { - title = 'no title' - } + const dTag = event.tags.find(tagNameEquals('d'))?.[1] + 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 = 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) { diff --git a/src/lib/live-activities.test.ts b/src/lib/live-activities.test.ts index a63ccfe7..8d71025f 100644 --- a/src/lib/live-activities.test.ts +++ b/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 { + filterLiveActivityItemsByReachableMedia, liveEventInlinePlaybackFromEvent, parseLiveActivityEvent, preferredLiveJoinUrlForEvent, @@ -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)', () => { 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', () => { }) }) + 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)', () => { 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'], diff --git a/src/lib/live-activities.ts b/src/lib/live-activities.ts index 2d50db77..08ca3431 100644 --- a/src/lib/live-activities.ts +++ b/src/lib/live-activities.ts @@ -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:///` 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 { /** * 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:///`. * **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 { * 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,11 +339,18 @@ 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 - return nostrNestsWebUrlForAddressable(ev) + if (isNostrNestsOfficialMoq30312(ev)) { + return nostrNestsWebUrlForAddressable(ev) + } + const forkOrigin = nestsForkLiveKit30312WebOrigin(ev) + if (forkOrigin) { + return naddrPageUrlForAddressable(ev, forkOrigin) + } + return 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` */ 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' } 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 { + 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 { + 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) +} diff --git a/src/lib/note-renderable-kinds.ts b/src/lib/note-renderable-kinds.ts index 1994487f..11b1078c 100644 --- a/src/lib/note-renderable-kinds.ts +++ b/src/lib/note-renderable-kinds.ts @@ -9,6 +9,9 @@ const RENDERABLE_NOTE_KINDS = new Set([ 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, diff --git a/src/providers/LiveActivitiesProvider.tsx b/src/providers/LiveActivitiesProvider.tsx index 9003db1b..2d89247d 100644 --- a/src/providers/LiveActivitiesProvider.tsx +++ b/src/providers/LiveActivitiesProvider.tsx @@ -1,5 +1,6 @@ import { buildLiveActivitiesRelayUrls, + filterLiveActivityItemsByReachableMedia, LIVE_ACTIVITY_KINDS, mergeLiveActivityEvents, msUntilNextQuarterHour, @@ -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([])