You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
839 lines
30 KiB
839 lines
30 KiB
import { FAST_READ_RELAY_URLS } from '@/constants' |
|
import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' |
|
import { isAudio, isHlsPlaylistUrl, isVideo } from '@/lib/url' |
|
import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' |
|
import { |
|
dedupeNormalizeRelayUrlsOrdered, |
|
MAX_REQ_RELAY_URLS, |
|
relayUrlsLocalsFirst |
|
} from '@/lib/relay-url-priority' |
|
import { normalizeAnyRelayUrl } from '@/lib/url' |
|
import { nip19, type Event, type Filter } from 'nostr-tools' |
|
|
|
/** [zap.stream](https://github.com/v0l/zap.stream) resolves `/:naddr` (NIP-19) for NIP-53 streams — no separate public API needed for “open in player”. */ |
|
const ZAP_STREAM_ORIGIN = 'https://zap.stream' |
|
|
|
/** [Nostr Nests](https://nostrnests.com/) web app loads rooms at `/:naddr` (same pattern as their share modal). */ |
|
const NOSTR_NESTS_WEB_ORIGIN = 'https://nostrnests.com' |
|
|
|
/** |
|
* [Corny Chat](https://github.com/vicariousdrama/cornychat) labels NIP-53 tickers with `L`/`com.cornychat` and serves |
|
* `naddr1…` (and other bech32) at `/_/integrations/nostr/<bech32>` on each instance origin (`ui/server/app.js`). |
|
*/ |
|
const CORNYCHAT_LABEL_NAMESPACE = 'com.cornychat' |
|
|
|
const EMPTY_PARENT_MAP = new Map<string, Event>() |
|
|
|
/** Max extra REQ filters when resolving 30312 parents for 30313 meetings (relay limits). */ |
|
const LIVE_ACTIVITIES_MAX_PARENT_FETCH = 32 |
|
|
|
export type LiveActivitiesFetchEventsFn = ( |
|
urls: string[], |
|
filter: Filter | Filter[], |
|
opts?: { eoseTimeout?: number; globalTimeout?: number; replaceableRace?: boolean; immediateReturn?: boolean } |
|
) => Promise<Event[]> |
|
|
|
/** NIP-53 live streaming (30311), meeting space (30312), meeting (30313). */ |
|
export const LIVE_ACTIVITY_KINDS = [30311, 30312, 30313] as const |
|
|
|
/** |
|
* Stable NIP-33 address `kind:pubkey:d` for a live-activity replaceable event (carousel dedupe / user hide list). |
|
* Must match {@link parseLiveActivityEvent} `address` and {@link dedupeLatestForLiveTicker} keys exactly |
|
* (raw `d` tag value from the event, same as {@link firstTagValue}). |
|
*/ |
|
export function liveActivityAddressFromEvent(ev: Event): string | null { |
|
if (!LIVE_ACTIVITY_KINDS.includes(ev.kind as (typeof LIVE_ACTIVITY_KINDS)[number])) return null |
|
const dTag = firstTagValue(ev, 'd') |
|
if (!dTag) return null |
|
return `${ev.kind}:${ev.pubkey}:${dTag}` |
|
} |
|
|
|
const LIVE_ACTIVITIES_MAX_ITEMS = 10 |
|
|
|
export const LIVE_ACTIVITIES_SLIDE_INTERVAL_MS = 30_000 |
|
|
|
export type TLiveActivityItem = { |
|
address: string |
|
kind: number |
|
pubkey: string |
|
dTag: string |
|
title: string |
|
summary: string |
|
imageUrl: string | undefined |
|
joinUrl: string |
|
updatedAt: number |
|
fromFollowedHost: boolean |
|
/** Full Nostr event (for navigation cache — same payload the strip already loaded). */ |
|
event: Event |
|
} |
|
|
|
function firstTagValue(ev: Event, name: string): string | undefined { |
|
for (const t of ev.tags) { |
|
if (t[0] === name && t[1]) return t[1] |
|
} |
|
return undefined |
|
} |
|
|
|
function parseOptionalUnixTag(ev: Event, name: string): number | undefined { |
|
const v = firstTagValue(ev, name) |
|
if (v === undefined) return undefined |
|
const n = Number.parseInt(v, 10) |
|
if (!Number.isFinite(n)) return undefined |
|
return n |
|
} |
|
|
|
/** True when `ends` is in the past (NIP-53 scheduled window). */ |
|
function isPastScheduledEndsTag(ev: Event, nowSec: number): boolean { |
|
const ends = parseOptionalUnixTag(ev, 'ends') |
|
if (ends === undefined) return false |
|
return nowSec > ends |
|
} |
|
|
|
/** Hide ticker entries that are explicitly ended or past `ends`; `live` is often stale. */ |
|
function isNip53TickerExpired(ev: Event, nowSec: number): boolean { |
|
const st = firstTagValue(ev, 'status')?.toLowerCase() |
|
if (st === 'ended') return true |
|
if (ev.kind === 30311 || ev.kind === 30313) { |
|
if (isPastScheduledEndsTag(ev, nowSec)) return true |
|
} |
|
return false |
|
} |
|
|
|
/** HLS/DASH manifests and similar — opening in a tab usually triggers a download, not a join page. */ |
|
function isLikelyRawStreamManifestUrl(url: string): boolean { |
|
try { |
|
const path = new URL(url).pathname.toLowerCase() |
|
return ( |
|
path.endsWith('.m3u8') || |
|
path.endsWith('.m3u') || |
|
path.endsWith('.mpd') || |
|
path.endsWith('.pls') |
|
) |
|
} catch { |
|
return false |
|
} |
|
} |
|
|
|
function relayHintsFromEvent(ev: Event): string[] | undefined { |
|
const out: string[] = [] |
|
for (const t of ev.tags) { |
|
if (t[0] !== 'relays') continue |
|
for (let i = 1; i < t.length; i++) { |
|
const u = t[i]?.trim() |
|
if (u) out.push(u) |
|
} |
|
} |
|
return out.length > 0 ? out.slice(0, 8) : undefined |
|
} |
|
|
|
/** Bare `naddr1…` or zap.stream path → canonical https URL (matches zap.stream router `/:id`). */ |
|
function normalizeTaggedJoinCandidate(raw: string): string | undefined { |
|
const t = raw.trim() |
|
if (!t) return undefined |
|
if (t.startsWith('naddr1') && t.length >= 16) { |
|
return `${ZAP_STREAM_ORIGIN}/${t}` |
|
} |
|
if (t.startsWith('https://zap.stream/')) return t |
|
if (t.startsWith('http://zap.stream/')) { |
|
return `https://zap.stream/${t.slice('http://zap.stream/'.length)}` |
|
} |
|
if (t.startsWith('https://')) return t |
|
return undefined |
|
} |
|
|
|
function naddrPageUrlForAddressable(ev: Event, origin: string): string | undefined { |
|
const d = firstTagValue(ev, 'd') |
|
if (!d) return undefined |
|
try { |
|
const relays = relayHintsFromEvent(ev) |
|
const naddr = nip19.naddrEncode({ |
|
kind: ev.kind, |
|
pubkey: ev.pubkey, |
|
identifier: d, |
|
relays: relays?.length ? relays : undefined |
|
}) |
|
return `${origin}/${naddr}` |
|
} catch { |
|
return undefined |
|
} |
|
} |
|
|
|
/** NIP-19 naddr for this addressable event → `https://zap.stream/naddr1…` (live player page). */ |
|
function zapStreamUrlForAddressable(ev: Event): string | undefined { |
|
return naddrPageUrlForAddressable(ev, ZAP_STREAM_ORIGIN) |
|
} |
|
|
|
/** |
|
* Canonical [zap.stream](https://zap.stream) watch URL for a kind **30311** ticker (`naddr` from `d` + relay hints). |
|
* Use as {@link MediaPlayer} `fallbackPageUrl` when direct HLS/audio in-app playback fails or is unavailable. |
|
*/ |
|
export function liveEventZapStreamWatchUrl(ev: Event): string | undefined { |
|
if (ev.kind !== 30311) return undefined |
|
return zapStreamUrlForAddressable(ev) |
|
} |
|
|
|
/** |
|
* Official Nostr Nests ([nostrnests/nests](https://github.com/nostrnests/nests)) rooms tag MoQ relay + moq-auth; |
|
* `streaming` is not a browser join URL — prefer the web app naddr route. |
|
*/ |
|
function isNostrNestsOfficialMoq30312(ev: Event): boolean { |
|
if (ev.kind !== 30312) return false |
|
const auth = firstTagValue(ev, 'auth') ?? '' |
|
if (auth.includes('moq-auth.nostrnests.com')) return true |
|
const stream = firstTagValue(ev, 'streaming') ?? '' |
|
try { |
|
const host = new URL(stream).hostname.toLowerCase() |
|
return host === 'moq.nostrnests.com' |
|
} catch { |
|
return stream.includes('moq.nostrnests.com') |
|
} |
|
} |
|
|
|
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 |
|
} |
|
|
|
/** |
|
* `wss+livekit://livekit:7880`-style tags use Docker-internal DNS; browsers cannot reach them while `service` |
|
* points at a public HTTPS origin. Skip join/carousel for that misconfiguration instead of surfacing a dead room. |
|
*/ |
|
function liveKitWssHostPlausibleForNests30312(ev: Event): boolean { |
|
if (ev.kind !== 30312) return true |
|
const stream = firstTagValue(ev, 'streaming')?.trim() ?? '' |
|
if (!stream.startsWith('wss+livekit://')) return true |
|
|
|
const rest = stream.slice('wss+livekit://'.length) |
|
const hostPart = rest.split('/')[0] ?? '' |
|
const streamHost = (hostPart.split(':')[0] ?? '').toLowerCase() |
|
if (!streamHost) return false |
|
|
|
const blocked = new Set([ |
|
'livekit', |
|
'localhost', |
|
'127.0.0.1', |
|
'0.0.0.0', |
|
'host.docker.internal' |
|
]) |
|
if (blocked.has(streamHost)) return false |
|
|
|
const svcRaw = firstTagValue(ev, 'service')?.trim() |
|
let serviceHostname = '' |
|
if (svcRaw) { |
|
try { |
|
serviceHostname = new URL(svcRaw).hostname.toLowerCase().replace(/^www\./, '') |
|
} catch { |
|
return false |
|
} |
|
} |
|
const clientRaw = firstTagValue(ev, 'client')?.trim() |
|
const clientHost = clientRaw ? clientRaw.split(':')[0].toLowerCase().replace(/^www\./, '') : '' |
|
|
|
if (streamHost === serviceHostname) return true |
|
if (clientHost && streamHost === clientHost) return true |
|
if (serviceHostname && streamHost.endsWith(`.${serviceHostname}`)) return true |
|
if (clientHost && streamHost.endsWith(`.${clientHost}`)) return true |
|
|
|
if (!streamHost.includes('.')) return false |
|
|
|
return false |
|
} |
|
|
|
/** |
|
* 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) |
|
if (!raw?.trim()) continue |
|
const url = normalizeTaggedJoinCandidate(raw.trim()) |
|
if (!url?.startsWith('https://')) continue |
|
if (isLikelyRawStreamManifestUrl(url)) continue |
|
return url |
|
} |
|
return undefined |
|
} |
|
|
|
/** NIP-53 30311 live ticker published by [Corny Chat](https://github.com/vicariousdrama/cornychat) (`L` label namespace). */ |
|
function isCornyChat30311(ev: Event): boolean { |
|
if (ev.kind !== 30311) return false |
|
for (const t of ev.tags) { |
|
if (t[0] === 'L' && t[1] === CORNYCHAT_LABEL_NAMESPACE) return true |
|
} |
|
return false |
|
} |
|
|
|
/** |
|
* `l` tag value `jamHost` from Corny pantry (`['l', jamHost, 'com.cornychat']`), when present. |
|
* Used to ensure `r`/`service` room URLs belong to the same instance as the tagged jam host. |
|
*/ |
|
function cornyChatJamHost(ev: Event): string | undefined { |
|
/** Pantry emits both `['l','<jamHost>','com.cornychat']` and `['l','audiospace','com.cornychat']`; prefer a hostname-like value for URL checks. */ |
|
let nonHost: string | undefined |
|
for (const t of ev.tags) { |
|
if (t[0] === 'l' && t[1] && t[2] === CORNYCHAT_LABEL_NAMESPACE) { |
|
const v = t[1].trim().toLowerCase() |
|
if (v.includes('.')) return v |
|
nonHost ??= v |
|
} |
|
} |
|
return nonHost |
|
} |
|
|
|
function cornyPageHostMatchesJamHost(pageHost: string, jamHost: string): boolean { |
|
const h = pageHost.toLowerCase() |
|
const j = jamHost.toLowerCase() |
|
return h === j || h === `www.${j}` |
|
} |
|
|
|
/** [Corny Chat](https://github.com/vicariousdrama/cornychat) kind-1 invites: same room URL on `r` / `service` / `streaming`; prefer `r` (explicit room link). */ |
|
function isCornyChatKind1Invite(ev: Event): boolean { |
|
if (ev.kind !== 1) return false |
|
let hasL = false |
|
let hasAudioServer = false |
|
for (const t of ev.tags) { |
|
if (t[0] === 'L' && t[1] === CORNYCHAT_LABEL_NAMESPACE) hasL = true |
|
if (t[0] === 'audioserver' && t[1]) hasAudioServer = true |
|
} |
|
return hasL || hasAudioServer |
|
} |
|
|
|
/** |
|
* 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 the tagged HTTPS room page on `r` / `service` / `streaming` for “Open in browser” |
|
* (host must match `l` jam host when present). In-app playback uses [zap.stream](https://zap.stream) via {@link liveEventInlinePlaybackFromEvent}. |
|
* **30311 (other):** [zap.stream/naddr…](https://zap.stream) when `d` is present. |
|
* **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}. |
|
*/ |
|
/** |
|
* Kind 30311 is shared by every NIP-53 “live stream” ticker (zap.stream, Corny Chat, etc.). |
|
* Corny-labelled events use [`L`, `com.cornychat`](https://github.com/vicariousdrama/cornychat/blob/main/pantry/nostr/nostr.js). |
|
* In-app playback without direct media tags uses zap.stream; browser join for Corny uses the room URL when valid. |
|
*/ |
|
function joinUrlFor30311Ticker(ev: Event): string | undefined { |
|
if (isNostrNests30311WebJoin(ev)) { |
|
const nests = nostrNestsWebUrlForAddressable(ev) |
|
if (nests) return nests |
|
} |
|
if (isCornyChat30311(ev)) { |
|
const direct = firstHttpsJoinFromTagNames(ev, ['r', 'service', 'streaming']) |
|
if (direct) { |
|
try { |
|
const host = new URL(direct).hostname |
|
const jamHost = cornyChatJamHost(ev) |
|
if (!jamHost || cornyPageHostMatchesJamHost(host, jamHost)) { |
|
return direct |
|
} |
|
} catch { |
|
/* fall through */ |
|
} |
|
} |
|
} |
|
return zapStreamUrlForAddressable(ev) |
|
} |
|
|
|
/** |
|
* 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 nostrNestsWebUrlForAddressable(ev) |
|
} |
|
const forkOrigin = nestsForkLiveKit30312WebOrigin(ev) |
|
if (forkOrigin) { |
|
if (!liveKitWssHostPlausibleForNests30312(ev)) return undefined |
|
return naddrPageUrlForAddressable(ev, forkOrigin) |
|
} |
|
return undefined |
|
} |
|
|
|
function pickJoinUrl(ev: Event): string | undefined { |
|
if (ev.kind === 30311) { |
|
const url = joinUrlFor30311Ticker(ev) |
|
if (url) return url |
|
} |
|
|
|
if (ev.kind === 30312) { |
|
const nests = joinUrlFor30312Space(ev) |
|
if (nests) return nests |
|
const stream = firstTagValue(ev, 'streaming')?.trim() ?? '' |
|
if (stream.startsWith('wss+livekit://') && nestsForkLiveKit30312WebOrigin(ev)) { |
|
return undefined |
|
} |
|
} |
|
|
|
if (isCornyChatKind1Invite(ev)) { |
|
const corny = firstHttpsJoinFromTagNames(ev, ['r', 'service', 'streaming']) |
|
if (corny) return corny |
|
} |
|
|
|
const candidates: Array<string | undefined> = [ |
|
firstTagValue(ev, 'service'), |
|
firstTagValue(ev, 'r'), |
|
firstTagValue(ev, 'streaming'), |
|
firstTagValue(ev, 'endpoint') |
|
] |
|
for (const raw of candidates) { |
|
if (!raw?.trim()) continue |
|
const url = normalizeTaggedJoinCandidate(raw.trim()) |
|
if (!url?.startsWith('https://')) continue |
|
if (isLikelyRawStreamManifestUrl(url)) continue |
|
return url |
|
} |
|
if (ev.kind === 30311) { |
|
const stream = firstTagValue(ev, 'streaming') |
|
if (stream?.startsWith('https://')) return stream.trim() |
|
} |
|
return undefined |
|
} |
|
|
|
/** |
|
* Browser join URL for NIP-53 ticker kinds and known audio-space invites (e.g. Corny Chat 30311 with `L`/`com.cornychat`, |
|
* or kind 1 with `L`/`audioserver`). |
|
* Prefer this over raw tag order when opening rooms from the feed or tooling. |
|
*/ |
|
export function preferredLiveJoinUrlForEvent(ev: Event): string | undefined { |
|
return pickJoinUrl(ev) |
|
} |
|
|
|
/** |
|
* NIP-53 uses different `status` vocabulary per kind: |
|
* - 30311 live stream: `planned` | `live` | `ended` |
|
* - 30312 meeting space: `open` | `private` | `closed` (never `live`) |
|
* - 30313 meeting in a space: `planned` | `live` | `ended` |
|
*/ |
|
function isActiveLiveActivityStatus(ev: Event): boolean { |
|
const status = firstTagValue(ev, 'status')?.trim().toLowerCase() |
|
if (ev.kind === 30312) { |
|
return status === 'open' || status === 'private' |
|
} |
|
if (ev.kind === 30311 || ev.kind === 30313) { |
|
return status === 'live' |
|
} |
|
return false |
|
} |
|
|
|
/** Parse NIP-33 address `kind:hex64pubkey:d` (used in `a` tags and dedupe keys). */ |
|
function parseNip33Address(ref: string): { kind: number; pubkey: string; d: string } | null { |
|
const m = /^(\d+):([0-9a-f]{64}):(.+)$/i.exec(ref.trim()) |
|
if (!m) return null |
|
const kind = Number(m[1]) |
|
if (!Number.isFinite(kind)) return null |
|
return { kind, pubkey: m[2], d: m[3] } |
|
} |
|
|
|
/** Parent meeting space (30312) address from a 30313 event’s `a` tag, if any. */ |
|
function firstParent30312Address(ev: Event): string | null { |
|
for (const t of ev.tags) { |
|
if (t[0] !== 'a' || !t[1]) continue |
|
const p = parseNip33Address(t[1]) |
|
if (p && p.kind === 30312) return `30312:${p.pubkey}:${p.d}` |
|
} |
|
return null |
|
} |
|
|
|
function resolveJoinUrl(ev: Event, parentByAddress: ReadonlyMap<string, Event>): string | undefined { |
|
const direct = pickJoinUrl(ev) |
|
if (direct) return direct |
|
if (ev.kind !== 30313) return undefined |
|
const parentAddr = firstParent30312Address(ev) |
|
if (!parentAddr) return undefined |
|
const parent = parentByAddress.get(parentAddr) |
|
return parent ? pickJoinUrl(parent) : undefined |
|
} |
|
|
|
function dedupeEventsById(events: Event[]): Event[] { |
|
const byId = new Map<string, Event>() |
|
for (const ev of events) { |
|
const prev = byId.get(ev.id) |
|
if (!prev || ev.created_at > prev.created_at) byId.set(ev.id, ev) |
|
} |
|
return [...byId.values()] |
|
} |
|
|
|
function dedupeLatestForLiveTicker(events: Event[]): Map<string, Event> { |
|
const byAddress = new Map<string, Event>() |
|
for (const ev of events) { |
|
const addr = liveActivityAddressFromEvent(ev) |
|
if (!addr) continue |
|
const prev = byAddress.get(addr) |
|
if (!prev || ev.created_at > prev.created_at) { |
|
byAddress.set(addr, ev) |
|
} |
|
} |
|
return byAddress |
|
} |
|
|
|
/** Latest 30312 space event per address from an event list (no network). */ |
|
function parent30312MapFromEvents(events: Event[]): Map<string, Event> { |
|
const m = new Map<string, Event>() |
|
for (const ev of events) { |
|
if (ev.kind !== 30312) continue |
|
const addr = liveActivityAddressFromEvent(ev) |
|
if (!addr) continue |
|
const prev = m.get(addr) |
|
if (!prev || ev.created_at > prev.created_at) m.set(addr, ev) |
|
} |
|
return m |
|
} |
|
|
|
/** |
|
* Fetch kind 30312 parent spaces referenced by kind 30313 meetings that lack their own join URL. |
|
* Merges with any 30312 already present in `events`. |
|
*/ |
|
export async function resolveParentSpacesForLiveActivities( |
|
events: Event[], |
|
relayUrls: string[], |
|
fetchEvents: LiveActivitiesFetchEventsFn |
|
): Promise<Map<string, Event>> { |
|
const parentMap = parent30312MapFromEvents(events) |
|
const latest = dedupeLatestForLiveTicker(events) |
|
const needed: string[] = [] |
|
const seen = new Set<string>() |
|
for (const ev of latest.values()) { |
|
if (ev.kind !== 30313) continue |
|
if (pickJoinUrl(ev)) continue |
|
const pa = firstParent30312Address(ev) |
|
if (!pa || parentMap.has(pa) || seen.has(pa)) continue |
|
seen.add(pa) |
|
needed.push(pa) |
|
} |
|
const slice = needed.slice(0, LIVE_ACTIVITIES_MAX_PARENT_FETCH) |
|
if (slice.length === 0) return parentMap |
|
|
|
const filters: Filter[] = [] |
|
for (const addr of slice) { |
|
const p = parseNip33Address(addr) |
|
if (!p || p.kind !== 30312) continue |
|
filters.push({ kinds: [30312], authors: [p.pubkey], '#d': [p.d], limit: 12 }) |
|
} |
|
if (filters.length === 0) return parentMap |
|
|
|
const fetched = await fetchEvents(relayUrls, filters, { |
|
eoseTimeout: 6000, |
|
globalTimeout: 12_000 |
|
}) |
|
const merged = new Map(parentMap) |
|
for (const ev of fetched) { |
|
if (ev.kind !== 30312) continue |
|
const d = firstTagValue(ev, 'd') |
|
if (!d) continue |
|
const addr = `30312:${ev.pubkey}:${d}` |
|
const prev = merged.get(addr) |
|
if (!prev || ev.created_at > prev.created_at) merged.set(addr, ev) |
|
} |
|
return merged |
|
} |
|
|
|
export function parseLiveActivityEvent( |
|
ev: Event, |
|
followSet: Set<string>, |
|
parentByAddress: ReadonlyMap<string, Event> = EMPTY_PARENT_MAP, |
|
nowSec: number = Math.floor(Date.now() / 1000) |
|
): TLiveActivityItem | null { |
|
if (!LIVE_ACTIVITY_KINDS.includes(ev.kind as (typeof LIVE_ACTIVITY_KINDS)[number])) return null |
|
if (isNip53TickerExpired(ev, nowSec)) return null |
|
if (!isActiveLiveActivityStatus(ev)) return null |
|
const dTag = firstTagValue(ev, 'd') |
|
if (!dTag) return null |
|
const joinUrl = resolveJoinUrl(ev, parentByAddress) |
|
if (!joinUrl) return null |
|
const title = |
|
ev.kind === 30312 |
|
? firstTagValue(ev, 'room')?.trim() || |
|
firstTagValue(ev, 'title')?.trim() || |
|
'Live space' |
|
: firstTagValue(ev, 'title')?.trim() || |
|
firstTagValue(ev, 'room')?.trim() || |
|
'Live' |
|
const summary = firstTagValue(ev, 'summary')?.trim() || '' |
|
const image = firstTagValue(ev, 'image') |
|
const imageUrl = image?.startsWith('https://') ? image : undefined |
|
const address = liveActivityAddressFromEvent(ev) |
|
if (!address) return null |
|
return { |
|
address, |
|
kind: ev.kind, |
|
pubkey: ev.pubkey, |
|
dTag, |
|
title, |
|
summary, |
|
imageUrl, |
|
joinUrl, |
|
updatedAt: ev.created_at, |
|
fromFollowedHost: followSet.has(ev.pubkey), |
|
event: ev |
|
} |
|
} |
|
|
|
/** |
|
* Keep newest event per NIP-33 address (`kind:pubkey:d`), then sort: followed hosts first, then `updatedAt` desc. |
|
* `parentByAddress`: latest 30312 per `30312:pubkey:d` for resolving 30313 join URLs from parent `service`. |
|
*/ |
|
export function mergeLiveActivityEvents( |
|
events: Event[], |
|
followPubkeys: string[], |
|
parentByAddress: ReadonlyMap<string, Event> = EMPTY_PARENT_MAP |
|
): TLiveActivityItem[] { |
|
const followSet = new Set(followPubkeys) |
|
const nowSec = Math.floor(Date.now() / 1000) |
|
const unique = dedupeEventsById(events) |
|
const byAddress = dedupeLatestForLiveTicker(unique) |
|
const items: TLiveActivityItem[] = [] |
|
for (const ev of byAddress.values()) { |
|
const parsed = parseLiveActivityEvent(ev, followSet, parentByAddress, nowSec) |
|
if (parsed) items.push(parsed) |
|
} |
|
items.sort((a, b) => { |
|
if (a.fromFollowedHost !== b.fromFollowedHost) return a.fromFollowedHost ? -1 : 1 |
|
return b.updatedAt - a.updatedAt |
|
}) |
|
return items.slice(0, LIVE_ACTIVITIES_MAX_ITEMS) |
|
} |
|
|
|
export function buildLiveActivitiesRelayUrls(options: { |
|
loggedIn: boolean |
|
favoriteRelays: string[] |
|
blockedRelays: string[] |
|
relayListRead: string[] |
|
relayListWrite: string[] |
|
/** |
|
* When false for a logged-in viewer with their own relay stack, omit {@link FAST_READ_RELAY_URLS} and skip |
|
* {@link DEFAULT_FAVORITE_RELAYS} when favorites are empty. Default true (signed-out / bootstrap). |
|
*/ |
|
includeGlobalFastRead?: boolean |
|
}): string[] { |
|
const { loggedIn, favoriteRelays, blockedRelays, relayListRead, relayListWrite } = options |
|
const includeFast = options.includeGlobalFastRead !== false |
|
const useGlobalFavoriteDefaults = includeFast |
|
if (loggedIn) { |
|
const fav = relayUrlsLocalsFirst( |
|
getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays, useGlobalFavoriteDefaults) |
|
) |
|
const read = relayUrlsLocalsFirst(relayListRead) |
|
const write = relayUrlsLocalsFirst(relayListWrite) |
|
const fast = dedupeNormalizeRelayUrlsOrdered( |
|
FAST_READ_RELAY_URLS.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean) |
|
) |
|
const layers = [ |
|
{ source: 'favorites' as const, urls: fav }, |
|
{ source: 'viewer-read' as const, urls: read }, |
|
{ source: 'viewer-write' as const, urls: write }, |
|
...(includeFast ? [{ source: 'fast-read' as const, urls: fast }] : []) |
|
] |
|
return feedRelayPolicyUrls(layers, { |
|
operation: 'read', |
|
blockedRelays, |
|
maxRelays: MAX_REQ_RELAY_URLS, |
|
applySocialKindBlockedFilter: true, |
|
allowThirdPartyLocalRelays: true |
|
}) |
|
} |
|
const fav = relayUrlsLocalsFirst(getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays, true)) |
|
const fast = dedupeNormalizeRelayUrlsOrdered( |
|
FAST_READ_RELAY_URLS.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean) |
|
) |
|
return feedRelayPolicyUrls( |
|
[ |
|
{ source: 'favorites', urls: fav }, |
|
{ source: 'fast-read', urls: fast } |
|
], |
|
{ |
|
operation: 'read', |
|
blockedRelays, |
|
maxRelays: MAX_REQ_RELAY_URLS, |
|
applySocialKindBlockedFilter: true, |
|
allowThirdPartyLocalRelays: true |
|
} |
|
) |
|
} |
|
|
|
/** Milliseconds until the next wall-clock quarter hour (:00, :15, :30, :45). */ |
|
export function msUntilNextQuarterHour(): number { |
|
const now = new Date() |
|
const m = now.getMinutes() |
|
const s = now.getSeconds() |
|
const ms = now.getMilliseconds() |
|
const minsPastQuarter = m % 15 |
|
const secsUntil = (15 - minsPastQuarter) * 60 - s - ms / 1000 |
|
return Math.max(0, Math.floor(secsUntil * 1000)) |
|
} |
|
|
|
function isStreamableHttpUrl(s: string): boolean { |
|
const t = s.trim() |
|
return t.startsWith('https://') || t.startsWith('http://') |
|
} |
|
|
|
function tagValues(ev: Event, name: string): string[] { |
|
const out: string[] = [] |
|
for (const t of ev.tags) { |
|
if (t[0] === name && t[1]?.trim()) out.push(t[1].trim()) |
|
} |
|
return out |
|
} |
|
|
|
export type LiveEventInlinePlayback = { src: string; mode: 'audio' | 'video' } |
|
|
|
/** |
|
* Pick a URL the in-app {@link MediaPlayer} can use for NIP-53 kind 30311 (live / radio). |
|
* Prefers direct audio (`r` or `streaming`, e.g. Icecast `.mp3`) over HLS manifests, then |
|
* [zap.stream `naddr`](https://zap.stream) when there is no playable URL in tags (except Nostr Nests LiveKit tickers). |
|
*/ |
|
export function liveEventInlinePlaybackFromEvent(ev: Event): LiveEventInlinePlayback | null { |
|
if (ev.kind !== 30311) return null |
|
const rUrls = tagValues(ev, 'r').filter(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' } |
|
} |
|
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' } |
|
} |
|
for (const u of streamingUrls) { |
|
if (isHlsPlaylistUrl(u) || isVideo(u)) return { src: u, mode: 'video' } |
|
} |
|
if (!isNostrNests30311WebJoin(ev)) { |
|
const zapWatch = zapStreamUrlForAddressable(ev) |
|
if (zapWatch) return { src: zapWatch, 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[], |
|
options?: { timeoutMs?: number } |
|
): Promise<TLiveActivityItem[]> { |
|
const timeoutMs = options?.timeoutMs ?? LIVE_ACTIVITIES_STREAM_PROBE_MS |
|
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) |
|
}
|
|
|