14 changed files with 509 additions and 4 deletions
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
/** |
||||||
|
* 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