Browse Source

render a banner for live events

imwald
Silberengel 1 month ago
parent
commit
cd898c1e53
  1. 5
      src/App.tsx
  2. 6
      src/PageManager.tsx
  3. 115
      src/components/LiveActivitiesStrip.tsx
  4. 4
      src/components/Sidebar/index.tsx
  5. 2
      src/constants.ts
  6. 7
      src/i18n/locales/de.ts
  7. 7
      src/i18n/locales/en.ts
  8. 163
      src/lib/live-activities.ts
  9. 15
      src/pages/secondary/GeneralSettingsPage/index.tsx
  10. 137
      src/providers/LiveActivitiesProvider.tsx
  11. 15
      src/providers/UserPreferencesProvider.tsx
  12. 3
      src/services/client.service.ts
  13. 18
      src/services/live-activities-prewarm-bridge.ts
  14. 16
      src/services/local-storage.service.ts

5
src/App.tsx

@ -21,6 +21,7 @@ import { NostrProvider } from '@/providers/NostrProvider' @@ -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 { @@ -52,7 +53,9 @@ export default function App(): JSX.Element {
<MediaUploadServiceProvider>
<KindFilterProvider>
<UserPreferencesProvider>
<PageManager />
<LiveActivitiesProvider>
<PageManager />
</LiveActivitiesProvider>
<ReadAloudPlayerModal />
<PublishSuccessSubtleIndicator />
<Toaster />

6
src/PageManager.tsx

@ -5,6 +5,7 @@ import logger from '@/lib/logger' @@ -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 }) { @@ -1848,9 +1849,11 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
}}
>
<NoteDrawerContext.Provider value={{ openDrawer, closeDrawer, isDrawerOpen: drawerOpen, drawerNoteId, drawerInitialEvent }}>
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<LiveActivitiesStrip placement="mobile" />
{primaryNoteView ? (
// Show primary note view with back button on mobile
<div className="flex flex-col h-full w-full">
<div className="flex min-h-0 flex-1 flex-col h-full w-full">
<div className="flex justify-center py-1 border-b">
<span className="text-green-600 dark:text-green-500 font-semibold text-sm">
Imwald
@ -1917,6 +1920,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1917,6 +1920,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
))}
</>
)}
</div>
{drawerNoteId && (
<NoteDrawer
open={drawerOpen}

115
src/components/LiveActivitiesStrip.tsx

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

4
src/components/Sidebar/index.tsx

@ -14,6 +14,7 @@ import SpellsButton from './SpellsButton' @@ -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() { @@ -31,6 +32,9 @@ export default function PrimaryPageSidebar() {
</div>
</div>
</div>
<div className="max-xl:hidden w-full min-w-0 px-1">
<LiveActivitiesStrip placement="sidebar" />
</div>
<HomeButton />
<FeedButton />
<DiscussionsButton />

2
src/constants.ts

@ -160,6 +160,8 @@ export const StorageKey = { @@ -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

7
src/i18n/locales/de.ts

@ -559,6 +559,13 @@ export default { @@ -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.',

7
src/i18n/locales/en.ts

@ -581,6 +581,13 @@ export default { @@ -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.',

163
src/lib/live-activities.ts

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

15
src/pages/secondary/GeneralSettingsPage/index.tsx

@ -46,7 +46,9 @@ const GeneralSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index @@ -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 @@ -171,6 +173,17 @@ const GeneralSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index
</Label>
<Switch id="autoplay" checked={autoplay} onCheckedChange={setAutoplay} />
</SettingItem>
<SettingItem>
<Label htmlFor="live-activities-banner" className="text-base font-normal">
<div>{t('liveActivities.settingsToggle')}</div>
<div className="text-muted-foreground">{t('liveActivities.settingsHint')}</div>
</Label>
<Switch
id="live-activities-banner"
checked={showLiveActivitiesBanner}
onCheckedChange={updateShowLiveActivitiesBanner}
/>
</SettingItem>
<SettingItem>
<Label htmlFor="add-random-relays" className="text-base font-normal">
<div>{t('Add random relays to every publish')}</div>

137
src/providers/LiveActivitiesProvider.tsx

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

15
src/providers/UserPreferencesProvider.tsx

@ -9,6 +9,8 @@ type TUserPreferencesContext = { @@ -9,6 +9,8 @@ type TUserPreferencesContext = {
updateShowRecommendedRelaysPanel: (show: boolean) => void
addRandomRelaysToPublish: boolean
updateAddRandomRelaysToPublish: (value: boolean) => void
showLiveActivitiesBanner: boolean
updateShowLiveActivitiesBanner: (value: boolean) => void
}
const UserPreferencesContext = createContext<TUserPreferencesContext | undefined>(undefined)
@ -33,6 +35,10 @@ export function UserPreferencesProvider({ children }: { children: React.ReactNod @@ -33,6 +35,10 @@ export function UserPreferencesProvider({ children }: { children: React.ReactNod
storage.getAddRandomRelaysToPublish()
)
const [showLiveActivitiesBanner, setShowLiveActivitiesBanner] = useState(
storage.getShowLiveActivitiesBanner()
)
// DEPRECATED: Mobile panel forcing removed - double-panel functionality disabled
const updateNotificationListStyle = (style: TNotificationStyle) => {
@ -50,6 +56,11 @@ export function UserPreferencesProvider({ children }: { children: React.ReactNod @@ -50,6 +56,11 @@ export function UserPreferencesProvider({ children }: { children: React.ReactNod
storage.setAddRandomRelaysToPublish(value)
}
const updateShowLiveActivitiesBanner = (value: boolean) => {
setShowLiveActivitiesBanner(value)
storage.setShowLiveActivitiesBanner(value)
}
return (
<UserPreferencesContext.Provider
value={{
@ -58,7 +69,9 @@ export function UserPreferencesProvider({ children }: { children: React.ReactNod @@ -58,7 +69,9 @@ export function UserPreferencesProvider({ children }: { children: React.ReactNod
showRecommendedRelaysPanel,
updateShowRecommendedRelaysPanel,
addRandomRelaysToPublish,
updateAddRandomRelaysToPublish
updateAddRandomRelaysToPublish,
showLiveActivitiesBanner,
updateShowLiveActivitiesBanner
}}
>
{children}

3
src/services/client.service.ts

@ -73,6 +73,7 @@ import { @@ -73,6 +73,7 @@ import {
} from 'nostr-tools'
import { AbstractRelay } from 'nostr-tools/abstract-relay'
import indexedDb from './indexed-db.service'
import { notifyLiveActivitiesPrewarmComplete } from './live-activities-prewarm-bridge'
import nip66Service from './nip66.service'
import { patchRelayNoticeForFetchFailures } from '@/services/relay-notice-strike'
import {
@ -264,6 +265,7 @@ class ClientService extends EventTarget { @@ -264,6 +265,7 @@ class ClientService extends EventTarget {
}
if (tasks.length === 0) {
notifyLiveActivitiesPrewarmComplete()
return
}
@ -276,6 +278,7 @@ class ClientService extends EventTarget { @@ -276,6 +278,7 @@ class ClientService extends EventTarget {
ms: typeof performance !== 'undefined' ? Math.round(performance.now() - t0) : undefined,
results: results.map((r) => r.status)
})
notifyLiveActivitiesPrewarmComplete()
}
// Update signer in query service when it changes

18
src/services/live-activities-prewarm-bridge.ts

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

16
src/services/local-storage.service.ts

@ -59,6 +59,7 @@ const SETTINGS_KEYS = [ @@ -59,6 +59,7 @@ const SETTINGS_KEYS = [
StorageKey.SHOW_RECOMMENDED_RELAYS_PANEL,
StorageKey.ADD_RANDOM_RELAYS_TO_PUBLISH,
StorageKey.SHOW_PUBLISH_SUCCESS_TOASTS,
StorageKey.SHOW_LIVE_ACTIVITIES_BANNER,
StorageKey.DEFAULT_EXPIRATION_ENABLED,
StorageKey.DEFAULT_EXPIRATION_MONTHS,
StorageKey.DEFAULT_QUIET_ENABLED,
@ -114,6 +115,7 @@ class LocalStorageService { @@ -114,6 +115,7 @@ class LocalStorageService {
private panelMode: 'single' | 'double' = 'single'
private addRandomRelaysToPublish: boolean = false
private showPublishSuccessToasts: boolean = true
private showLiveActivitiesBanner: boolean = true
constructor() {
if (!LocalStorageService.instance) {
@ -408,6 +410,9 @@ class LocalStorageService { @@ -408,6 +410,9 @@ class LocalStorageService {
const showPublishSuccessStr = window.localStorage.getItem(StorageKey.SHOW_PUBLISH_SUCCESS_TOASTS)
this.showPublishSuccessToasts = showPublishSuccessStr !== 'false'
const showLiveActivitiesStr = window.localStorage.getItem(StorageKey.SHOW_LIVE_ACTIVITIES_BANNER)
this.showLiveActivitiesBanner = showLiveActivitiesStr !== 'false'
// Clean up deprecated data
window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
window.localStorage.removeItem(StorageKey.ACCOUNT_FOLLOW_LIST_EVENT_MAP)
@ -515,6 +520,8 @@ class LocalStorageService { @@ -515,6 +520,8 @@ class LocalStorageService {
this.addRandomRelaysToPublish = get(StorageKey.ADD_RANDOM_RELAYS_TO_PUBLISH) === 'true'
const showPublishSuccessStr = get(StorageKey.SHOW_PUBLISH_SUCCESS_TOASTS)
if (showPublishSuccessStr != null) this.showPublishSuccessToasts = showPublishSuccessStr !== 'false'
const showLiveActivitiesStr = get(StorageKey.SHOW_LIVE_ACTIVITIES_BANNER)
if (showLiveActivitiesStr != null) this.showLiveActivitiesBanner = showLiveActivitiesStr !== 'false'
const showKindsStr = get(StorageKey.SHOW_KINDS)
if (showKindsStr != null) this.showKinds = JSON.parse(showKindsStr) as number[]
const showKind1OPsStr = get(StorageKey.SHOW_KIND_1_OPs)
@ -808,6 +815,15 @@ class LocalStorageService { @@ -808,6 +815,15 @@ class LocalStorageService {
this.persistSetting(StorageKey.ADD_RANDOM_RELAYS_TO_PUBLISH, value.toString())
}
getShowLiveActivitiesBanner(): boolean {
return this.showLiveActivitiesBanner
}
setShowLiveActivitiesBanner(value: boolean) {
this.showLiveActivitiesBanner = value
this.persistSetting(StorageKey.SHOW_LIVE_ACTIVITIES_BANNER, value ? 'true' : 'false')
}
getShowKinds() {
return this.showKinds
}

Loading…
Cancel
Save