14 changed files with 509 additions and 4 deletions
@ -0,0 +1,115 @@
@@ -0,0 +1,115 @@
|
||||
import { LIVE_ACTIVITIES_SLIDE_INTERVAL_MS } from '@/lib/live-activities' |
||||
import { cn } from '@/lib/utils' |
||||
import { useLiveActivitiesOptional } from '@/providers/LiveActivitiesProvider' |
||||
import { useUserPreferences } from '@/providers/UserPreferencesProvider' |
||||
import { ExternalLink } from 'lucide-react' |
||||
import { useEffect, useState } from 'react' |
||||
import { useTranslation } from 'react-i18next' |
||||
|
||||
type TPlacement = 'sidebar' | 'mobile' |
||||
|
||||
export default function LiveActivitiesStrip({ placement }: { placement: TPlacement }) { |
||||
const { t } = useTranslation() |
||||
const { showLiveActivitiesBanner } = useUserPreferences() |
||||
const live = useLiveActivitiesOptional() |
||||
const items = live?.items ?? [] |
||||
|
||||
const [reduceMotion, setReduceMotion] = useState(false) |
||||
useEffect(() => { |
||||
const mq = window.matchMedia('(prefers-reduced-motion: reduce)') |
||||
const apply = () => setReduceMotion(mq.matches) |
||||
apply() |
||||
mq.addEventListener('change', apply) |
||||
return () => mq.removeEventListener('change', apply) |
||||
}, []) |
||||
|
||||
const [slide, setSlide] = useState(0) |
||||
|
||||
useEffect(() => { |
||||
setSlide(0) |
||||
}, [items]) |
||||
|
||||
useEffect(() => { |
||||
if (items.length <= 1 || reduceMotion) return |
||||
const id = window.setInterval(() => { |
||||
setSlide((s) => (s + 1) % items.length) |
||||
}, LIVE_ACTIVITIES_SLIDE_INTERVAL_MS) |
||||
return () => window.clearInterval(id) |
||||
}, [items.length, reduceMotion]) |
||||
|
||||
if (!showLiveActivitiesBanner || items.length === 0) { |
||||
return null |
||||
} |
||||
|
||||
const current = items[slide]! |
||||
|
||||
return ( |
||||
<div |
||||
className={cn( |
||||
placement === 'sidebar' && |
||||
'mb-2 rounded-lg border border-border/80 bg-muted/50 p-2 shadow-sm dark:bg-muted/30', |
||||
placement === 'mobile' && 'w-full shrink-0 border-b border-border/80 bg-muted/50 px-2 py-2 dark:bg-muted/30' |
||||
)} |
||||
role="region" |
||||
aria-label={t('liveActivities.regionLabel')} |
||||
> |
||||
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground xl:text-xs"> |
||||
{t('liveActivities.heading')} |
||||
</div> |
||||
<a |
||||
href={current.joinUrl} |
||||
target="_blank" |
||||
rel="noopener noreferrer" |
||||
className={cn( |
||||
'flex min-w-0 gap-2 rounded-md transition-colors hover:bg-muted/80', |
||||
placement === 'sidebar' && 'flex-col xl:flex-row xl:items-start', |
||||
placement === 'mobile' && 'items-center' |
||||
)} |
||||
> |
||||
{current.imageUrl ? ( |
||||
<img |
||||
src={current.imageUrl} |
||||
alt="" |
||||
className={cn( |
||||
'shrink-0 rounded object-cover', |
||||
placement === 'sidebar' ? 'h-14 w-full xl:h-12 xl:w-12' : 'h-12 w-12' |
||||
)} |
||||
/> |
||||
) : null} |
||||
<div className="min-w-0 flex-1"> |
||||
<div className="flex items-start gap-1"> |
||||
<span className="line-clamp-2 min-w-0 flex-1 text-xs font-medium leading-snug xl:text-sm"> |
||||
{current.title} |
||||
</span> |
||||
<ExternalLink className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" aria-hidden /> |
||||
</div> |
||||
{current.summary ? ( |
||||
<p className="mt-0.5 line-clamp-2 text-[11px] text-muted-foreground xl:text-xs">{current.summary}</p> |
||||
) : null} |
||||
{current.fromFollowedHost ? ( |
||||
<p className="mt-1 text-[10px] text-green-600 dark:text-green-500">{t('liveActivities.fromFollow')}</p> |
||||
) : null} |
||||
</div> |
||||
</a> |
||||
{items.length > 1 ? ( |
||||
<div className="mt-2 flex justify-center gap-1.5"> |
||||
{items.map((item, i) => ( |
||||
<button |
||||
key={item.address} |
||||
type="button" |
||||
aria-label={t('liveActivities.goToSlide', { n: i + 1 })} |
||||
className={cn( |
||||
'size-1.5 rounded-full transition-colors', |
||||
i === slide ? 'bg-primary' : 'bg-muted-foreground/40 hover:bg-muted-foreground/60' |
||||
)} |
||||
onClick={(e) => { |
||||
e.preventDefault() |
||||
setSlide(i) |
||||
}} |
||||
/> |
||||
))} |
||||
</div> |
||||
) : null} |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,163 @@
@@ -0,0 +1,163 @@
|
||||
import { FAST_READ_RELAY_URLS } from '@/constants' |
||||
import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' |
||||
import { |
||||
dedupeNormalizeRelayUrlsOrdered, |
||||
MAX_REQ_RELAY_URLS, |
||||
mergeRelayPriorityLayers, |
||||
relayUrlsLocalsFirst |
||||
} from '@/lib/relay-url-priority' |
||||
import { normalizeAnyRelayUrl } from '@/lib/url' |
||||
import type { Event } from 'nostr-tools' |
||||
|
||||
/** NIP-53 live streaming (30311), meeting space (30312), meeting (30313). */ |
||||
export const LIVE_ACTIVITY_KINDS = [30311, 30312, 30313] as const |
||||
|
||||
export 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 |
||||
} |
||||
|
||||
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 |
||||
} |
||||
|
||||
/** 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 |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* URL for “join this live space” in the browser. NIP-53 `streaming` is often a raw `.m3u8` feed; prefer |
||||
* `service` (access URL), then `r` (e.g. Corny Chat room page), then non-manifest `streaming` / `endpoint`. |
||||
*/ |
||||
function pickHttpsJoinUrl(ev: Event): string | undefined { |
||||
const candidates: Array<string | undefined> = [ |
||||
firstTagValue(ev, 'service'), |
||||
firstTagValue(ev, 'r'), |
||||
firstTagValue(ev, 'streaming'), |
||||
firstTagValue(ev, 'endpoint') |
||||
] |
||||
for (const raw of candidates) { |
||||
if (!raw?.startsWith('https://')) continue |
||||
if (isLikelyRawStreamManifestUrl(raw)) continue |
||||
return raw |
||||
} |
||||
return undefined |
||||
} |
||||
|
||||
export function parseLiveActivityEvent(ev: Event, followSet: Set<string>): TLiveActivityItem | null { |
||||
if (!LIVE_ACTIVITY_KINDS.includes(ev.kind as (typeof LIVE_ACTIVITY_KINDS)[number])) return null |
||||
if (firstTagValue(ev, 'status') !== 'live') return null |
||||
const dTag = firstTagValue(ev, 'd') |
||||
if (!dTag) return null |
||||
const joinUrl = pickHttpsJoinUrl(ev) |
||||
if (!joinUrl) return null |
||||
const title = |
||||
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 = `${ev.kind}:${ev.pubkey}:${dTag}` |
||||
return { |
||||
address, |
||||
kind: ev.kind, |
||||
pubkey: ev.pubkey, |
||||
dTag, |
||||
title, |
||||
summary, |
||||
imageUrl, |
||||
joinUrl, |
||||
updatedAt: ev.created_at, |
||||
fromFollowedHost: followSet.has(ev.pubkey) |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Keep newest event per NIP-33 address (`kind:pubkey:d`), then sort: followed hosts first, then `updatedAt` desc. |
||||
*/ |
||||
export function mergeLiveActivityEvents(events: Event[], followPubkeys: string[]): TLiveActivityItem[] { |
||||
const followSet = new Set(followPubkeys) |
||||
const byAddress = new Map<string, Event>() |
||||
for (const ev of events) { |
||||
const d = firstTagValue(ev, 'd') |
||||
if (!d) continue |
||||
const addr = `${ev.kind}:${ev.pubkey}:${d}` |
||||
const prev = byAddress.get(addr) |
||||
if (!prev || ev.created_at > prev.created_at) { |
||||
byAddress.set(addr, ev) |
||||
} |
||||
} |
||||
const items: TLiveActivityItem[] = [] |
||||
for (const ev of byAddress.values()) { |
||||
const parsed = parseLiveActivityEvent(ev, followSet) |
||||
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[] |
||||
}): string[] { |
||||
const { loggedIn, favoriteRelays, blockedRelays, relayListRead, relayListWrite } = options |
||||
if (loggedIn) { |
||||
const fav = relayUrlsLocalsFirst(getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays)) |
||||
const read = relayUrlsLocalsFirst(relayListRead) |
||||
const write = relayUrlsLocalsFirst(relayListWrite) |
||||
return mergeRelayPriorityLayers([fav, read, write], blockedRelays, MAX_REQ_RELAY_URLS, { |
||||
applySocialKindBlockedFilter: true |
||||
}) |
||||
} |
||||
const fav = relayUrlsLocalsFirst(getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays)) |
||||
const fast = dedupeNormalizeRelayUrlsOrdered( |
||||
FAST_READ_RELAY_URLS.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean) |
||||
) |
||||
return mergeRelayPriorityLayers([fav, fast], blockedRelays, MAX_REQ_RELAY_URLS, { |
||||
applySocialKindBlockedFilter: 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)) |
||||
} |
||||
@ -0,0 +1,137 @@
@@ -0,0 +1,137 @@
|
||||
import { |
||||
buildLiveActivitiesRelayUrls, |
||||
LIVE_ACTIVITY_KINDS, |
||||
mergeLiveActivityEvents, |
||||
msUntilNextQuarterHour, |
||||
type TLiveActivityItem |
||||
} from '@/lib/live-activities' |
||||
import logger from '@/lib/logger' |
||||
import client from '@/services/client.service' |
||||
import { registerLiveActivitiesPrewarmCallback } from '@/services/live-activities-prewarm-bridge' |
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' |
||||
import { useFavoriteRelays } from './FavoriteRelaysProvider' |
||||
import { useFollowList } from './FollowListProvider' |
||||
import { useNostr } from './NostrProvider' |
||||
import { useUserPreferences } from './UserPreferencesProvider' |
||||
|
||||
type TLiveActivitiesContext = { |
||||
items: TLiveActivityItem[] |
||||
loading: boolean |
||||
} |
||||
|
||||
const LiveActivitiesContext = createContext<TLiveActivitiesContext | undefined>(undefined) |
||||
|
||||
export function useLiveActivities(): TLiveActivitiesContext { |
||||
const ctx = useContext(LiveActivitiesContext) |
||||
if (!ctx) { |
||||
throw new Error('useLiveActivities must be used within LiveActivitiesProvider') |
||||
} |
||||
return ctx |
||||
} |
||||
|
||||
export function useLiveActivitiesOptional(): TLiveActivitiesContext | undefined { |
||||
return useContext(LiveActivitiesContext) |
||||
} |
||||
|
||||
export function LiveActivitiesProvider({ children }: { children: React.ReactNode }) { |
||||
const { pubkey, relayList, isInitialized, isAccountSessionHydrating } = useNostr() |
||||
const { favoriteRelays, blockedRelays } = useFavoriteRelays() |
||||
const { followings } = useFollowList() |
||||
const { showLiveActivitiesBanner } = useUserPreferences() |
||||
|
||||
const [items, setItems] = useState<TLiveActivityItem[]>([]) |
||||
const [loading, setLoading] = useState(false) |
||||
|
||||
const relayRead = relayList?.read ?? [] |
||||
const relayWrite = relayList?.write ?? [] |
||||
|
||||
const refresh = useCallback(async () => { |
||||
if (!showLiveActivitiesBanner) { |
||||
setItems([]) |
||||
return |
||||
} |
||||
const loggedIn = Boolean(pubkey) |
||||
const urls = buildLiveActivitiesRelayUrls({ |
||||
loggedIn, |
||||
favoriteRelays, |
||||
blockedRelays, |
||||
relayListRead: relayRead, |
||||
relayListWrite: relayWrite |
||||
}) |
||||
if (loggedIn && urls.length === 0) { |
||||
setItems([]) |
||||
return |
||||
} |
||||
setLoading(true) |
||||
try { |
||||
const events = await client.fetchEvents( |
||||
urls, |
||||
{ kinds: [...LIVE_ACTIVITY_KINDS], limit: 500 }, |
||||
{ eoseTimeout: 6000, globalTimeout: 14_000 } |
||||
) |
||||
const merged = mergeLiveActivityEvents(events, followings) |
||||
setItems(merged) |
||||
logger.debug('[LiveActivities] poll done', { relayCount: urls.length, raw: events.length, merged: merged.length }) |
||||
} catch (e) { |
||||
logger.warn('[LiveActivities] poll failed', { err: e }) |
||||
setItems([]) |
||||
} finally { |
||||
setLoading(false) |
||||
} |
||||
}, [ |
||||
showLiveActivitiesBanner, |
||||
pubkey, |
||||
favoriteRelays, |
||||
blockedRelays, |
||||
relayRead, |
||||
relayWrite, |
||||
followings |
||||
]) |
||||
|
||||
const refreshRef = useRef(refresh) |
||||
refreshRef.current = refresh |
||||
|
||||
useEffect(() => { |
||||
registerLiveActivitiesPrewarmCallback(() => { |
||||
void refreshRef.current() |
||||
}) |
||||
return () => registerLiveActivitiesPrewarmCallback(null) |
||||
}, []) |
||||
|
||||
useEffect(() => { |
||||
if (!showLiveActivitiesBanner) { |
||||
setItems([]) |
||||
return |
||||
} |
||||
if (!isInitialized) return |
||||
if (pubkey && isAccountSessionHydrating) return |
||||
void refresh() |
||||
}, [ |
||||
showLiveActivitiesBanner, |
||||
isInitialized, |
||||
pubkey, |
||||
isAccountSessionHydrating, |
||||
refresh |
||||
]) |
||||
|
||||
useEffect(() => { |
||||
if (!showLiveActivitiesBanner) return |
||||
const id = window.setTimeout(() => { |
||||
void refreshRef.current() |
||||
}, msUntilNextQuarterHour()) |
||||
const interval = window.setInterval( |
||||
() => { |
||||
void refreshRef.current() |
||||
}, |
||||
15 * 60 * 1000 |
||||
) |
||||
return () => { |
||||
window.clearTimeout(id) |
||||
window.clearInterval(interval) |
||||
} |
||||
}, [showLiveActivitiesBanner]) |
||||
|
||||
const value = useMemo(() => ({ items, loading }), [items, loading]) |
||||
|
||||
return <LiveActivitiesContext.Provider value={value}>{children}</LiveActivitiesContext.Provider> |
||||
} |
||||
@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
|
||||
/** |
||||
* Fired when {@link ClientService.runSessionPrewarm} finishes so the live-activities banner can refresh |
||||
* in step with the initial session batch (logged-in or anonymous). |
||||
*/ |
||||
|
||||
let onPrewarmComplete: (() => void) | null = null |
||||
|
||||
export function registerLiveActivitiesPrewarmCallback(fn: (() => void) | null): void { |
||||
onPrewarmComplete = fn |
||||
} |
||||
|
||||
export function notifyLiveActivitiesPrewarmComplete(): void { |
||||
try { |
||||
onPrewarmComplete?.() |
||||
} catch { |
||||
// ignore listener errors
|
||||
} |
||||
} |
||||
Loading…
Reference in new issue