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

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)
}