From cd898c1e5398cfb12dd8886a6f5d596990f1ad0d Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sun, 29 Mar 2026 19:42:23 +0200 Subject: [PATCH] render a banner for live events --- src/App.tsx | 5 +- src/PageManager.tsx | 6 +- src/components/LiveActivitiesStrip.tsx | 115 ++++++++++++ src/components/Sidebar/index.tsx | 4 + src/constants.ts | 2 + src/i18n/locales/de.ts | 7 + src/i18n/locales/en.ts | 7 + src/lib/live-activities.ts | 163 ++++++++++++++++++ .../secondary/GeneralSettingsPage/index.tsx | 15 +- src/providers/LiveActivitiesProvider.tsx | 137 +++++++++++++++ src/providers/UserPreferencesProvider.tsx | 15 +- src/services/client.service.ts | 3 + .../live-activities-prewarm-bridge.ts | 18 ++ src/services/local-storage.service.ts | 16 ++ 14 files changed, 509 insertions(+), 4 deletions(-) create mode 100644 src/components/LiveActivitiesStrip.tsx create mode 100644 src/lib/live-activities.ts create mode 100644 src/providers/LiveActivitiesProvider.tsx create mode 100644 src/services/live-activities-prewarm-bridge.ts diff --git a/src/App.tsx b/src/App.tsx index f580206e..cd083170 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,7 @@ import { NostrProvider } from '@/providers/NostrProvider' import { ReplyProvider } from '@/providers/ReplyProvider' import { ScreenSizeProvider } from '@/providers/ScreenSizeProvider' import { ThemeProvider } from '@/providers/ThemeProvider' +import { LiveActivitiesProvider } from '@/providers/LiveActivitiesProvider' import { UserPreferencesProvider } from '@/providers/UserPreferencesProvider' import { UserTrustProvider } from '@/providers/UserTrustProvider' import { ZapProvider } from '@/providers/ZapProvider' @@ -52,7 +53,9 @@ export default function App(): JSX.Element { - + + + diff --git a/src/PageManager.tsx b/src/PageManager.tsx index 921dc1a4..b37dea78 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -5,6 +5,7 @@ import logger from '@/lib/logger' import { ChevronLeft } from 'lucide-react' import { NavigationService } from '@/services/navigation.service' // Page imports needed for primary note view +import LiveActivitiesStrip from '@/components/LiveActivitiesStrip' import NoteDrawer from '@/components/NoteDrawer' import SecondaryProfilePage from '@/pages/secondary/ProfilePage' import storage from '@/services/local-storage.service' @@ -1848,9 +1849,11 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { }} > +
+ {primaryNoteView ? ( // Show primary note view with back button on mobile -
+
Imwald @@ -1917,6 +1920,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { ))} )} +
{drawerNoteId && ( { + 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 ( +
+
+ {t('liveActivities.heading')} +
+ + {current.imageUrl ? ( + + ) : null} +
+
+ + {current.title} + + +
+ {current.summary ? ( +

{current.summary}

+ ) : null} + {current.fromFollowedHost ? ( +

{t('liveActivities.fromFollow')}

+ ) : null} +
+
+ {items.length > 1 ? ( +
+ {items.map((item, i) => ( +
+ ) : null} +
+ ) +} diff --git a/src/components/Sidebar/index.tsx b/src/components/Sidebar/index.tsx index 2e74d813..8179ffb1 100644 --- a/src/components/Sidebar/index.tsx +++ b/src/components/Sidebar/index.tsx @@ -14,6 +14,7 @@ import SpellsButton from './SpellsButton' import { FavoriteRelaysActiveStripSidebar } from '@/components/FavoriteRelaysActiveStrip' import PaneModeToggle from './PaneModeToggle' import DownloadDesktopSidebarButton from './DownloadDesktopSidebarButton' +import LiveActivitiesStrip from '@/components/LiveActivitiesStrip' export default function PrimaryPageSidebar() { const { isSmallScreen } = useScreenSize() @@ -31,6 +32,9 @@ export default function PrimaryPageSidebar() {
+
+ +
diff --git a/src/constants.ts b/src/constants.ts index 10f71d98..4784f3ad 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -160,6 +160,8 @@ export const StorageKey = { ADD_RANDOM_RELAYS_TO_PUBLISH: 'addRandomRelaysToPublish', /** When not `'false'`, show green Sonner toasts after successful publishes (default on). */ SHOW_PUBLISH_SUCCESS_TOASTS: 'showPublishSuccessToasts', + /** When not `'false'`, show NIP-53 live activity banner (default on). */ + SHOW_LIVE_ACTIVITIES_BANNER: 'showLiveActivitiesBanner', /** Temporary draft cache: new notes and replies. Persisted after 30s idle; restored on refresh; cleared on logout/switch. */ POST_EDITOR_DRAFT: 'postEditorDraft', MEDIA_UPLOAD_SERVICE: 'mediaUploadService', // deprecated diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 154761b1..d6b156bf 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -559,6 +559,13 @@ export default { Autoplay: 'Automatische Wiedergabe', 'Enable video autoplay on this device': 'Aktiviere die automatische Video-Wiedergabe auf diesem Gerät', + 'liveActivities.heading': 'Jetzt live', + 'liveActivities.regionLabel': 'Live-Räume und Streams', + 'liveActivities.fromFollow': 'Von jemandem, dem du folgst', + 'liveActivities.goToSlide': 'Live-Eintrag {{n}} anzeigen', + 'liveActivities.settingsToggle': 'Banner für Live-Aktivitäten', + 'liveActivities.settingsHint': + 'Zeigt NIP-53-Live-Räume (Audio/Video) von deinen Relays. Aktualisierung zur Viertelstunde und nach dem ersten Session-Warm-up.', 'Add random relays to every publish': 'Zufällige Relays in der Publish-Liste', 'Add random relays to every publish description': 'Fügt {{n}} zufällige öffentliche Relays aus der NIP-66-Liveliness-Liste hinzu (bevorzugt solche, deren Monitor eine Write-RTT gemeldet hat). Bei AN standardmäßig ausgewählt; bei AUS in der Liste, aber nicht angehakt.', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 8ec04163..aaf29806 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -581,6 +581,13 @@ export default { General: 'General', Autoplay: 'Autoplay', 'Enable video autoplay on this device': 'Enable video autoplay on this device', + 'liveActivities.heading': 'Live now', + 'liveActivities.regionLabel': 'Live spaces and streams', + 'liveActivities.fromFollow': 'From someone you follow', + 'liveActivities.goToSlide': 'Show live item {{n}}', + 'liveActivities.settingsToggle': 'Live activities banner', + 'liveActivities.settingsHint': + 'Shows NIP-53 live rooms (audio/video spaces) from your relays. Updates on a quarter-hour schedule and when the app finishes its initial session warm-up.', 'Add random relays to every publish': 'Random relays in publish list', 'Add random relays to every publish description': 'Adds {{n}} random public relays from the NIP-66 lively list (preferring monitors that reported a write RTT) to the publish relay list. When ON, they are selected by default; when OFF, they appear in the list but are unchecked so you can optionally include them.', diff --git a/src/lib/live-activities.ts b/src/lib/live-activities.ts new file mode 100644 index 00000000..c443def7 --- /dev/null +++ b/src/lib/live-activities.ts @@ -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 = [ + 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): 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() + 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)) +} diff --git a/src/pages/secondary/GeneralSettingsPage/index.tsx b/src/pages/secondary/GeneralSettingsPage/index.tsx index 323ea8a8..7143d452 100644 --- a/src/pages/secondary/GeneralSettingsPage/index.tsx +++ b/src/pages/secondary/GeneralSettingsPage/index.tsx @@ -46,7 +46,9 @@ const GeneralSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index notificationListStyle, updateNotificationListStyle, addRandomRelaysToPublish, - updateAddRandomRelaysToPublish + updateAddRandomRelaysToPublish, + showLiveActivitiesBanner, + updateShowLiveActivitiesBanner } = useUserPreferences() const handleLanguageChange = (value: TLanguage) => { @@ -171,6 +173,17 @@ const GeneralSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index + + + +