Browse Source

hide dead live streams in indexeddb

imwald
Silberengel 2 weeks ago
parent
commit
d7f9501911
  1. 39
      src/components/Note/LiveEvent.tsx
  2. 5
      src/i18n/locales/de.ts
  3. 5
      src/i18n/locales/en.ts
  4. 18
      src/lib/live-activities.test.ts
  5. 11
      src/lib/live-activities.ts
  6. 47
      src/providers/LiveActivitiesProvider.tsx
  7. 4
      src/providers/live-activities-context.ts
  8. 24
      src/services/indexed-db.service.ts

39
src/components/Note/LiveEvent.tsx

@ -3,16 +3,18 @@ import { Button } from '@/components/ui/button'
import { createFakeEvent } from '@/lib/event' import { createFakeEvent } from '@/lib/event'
import { getLiveEventMetadataFromEvent } from '@/lib/event-metadata' import { getLiveEventMetadataFromEvent } from '@/lib/event-metadata'
import { import {
liveActivityAddressFromEvent,
liveEventInlinePlaybackFromEvent, liveEventInlinePlaybackFromEvent,
liveEventZapStreamWatchUrl, liveEventZapStreamWatchUrl,
preferredLiveJoinUrlForEvent preferredLiveJoinUrlForEvent
} from '@/lib/live-activities' } from '@/lib/live-activities'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider' import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useLiveActivitiesOptional } from '@/providers/useLiveActivities'
import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider' import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { ExternalLink } from 'lucide-react' import { ExternalLink } from 'lucide-react'
import { useMemo } from 'react' import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import ClientSelect from '../ClientSelect' import ClientSelect from '../ClientSelect'
import Image from '../Image' import Image from '../Image'
@ -21,6 +23,7 @@ import MarkdownArticle from './MarkdownArticle/MarkdownArticle'
export default function LiveEvent({ event, className }: { event: Event; className?: string }) { export default function LiveEvent({ event, className }: { event: Event; className?: string }) {
const { t } = useTranslation() const { t } = useTranslation()
const liveActivities = useLiveActivitiesOptional()
const screenSize = useScreenSizeOptional() const screenSize = useScreenSizeOptional()
const isSmallScreen = screenSize?.isSmallScreen ?? false const isSmallScreen = screenSize?.isSmallScreen ?? false
const contentPolicy = useContentPolicyOptional() const contentPolicy = useContentPolicyOptional()
@ -58,6 +61,15 @@ export default function LiveEvent({ event, className }: { event: Event; classNam
<Badge variant="secondary">{metadata.status}</Badge> <Badge variant="secondary">{metadata.status}</Badge>
)) ))
const liveActivityAddress = useMemo(() => liveActivityAddressFromEvent(event), [event])
const carouselHidden = Boolean(
liveActivityAddress && liveActivities?.carouselHiddenAddresses.has(liveActivityAddress)
)
const onToggleCarouselHidden = useCallback(() => {
if (!liveActivityAddress || !liveActivities) return
void liveActivities.toggleLiveActivityCarouselHidden(liveActivityAddress)
}, [liveActivityAddress, liveActivities])
const titleComponent = ( const titleComponent = (
<div className="text-xl font-semibold break-words min-w-0 sm:line-clamp-1">{metadata.title}</div> <div className="text-xl font-semibold break-words min-w-0 sm:line-clamp-1">{metadata.title}</div>
) )
@ -120,7 +132,30 @@ export default function LiveEvent({ event, className }: { event: Event; classNam
{cover} {cover}
<div className="min-w-0 flex-1 space-y-1"> <div className="min-w-0 flex-1 space-y-1">
{titleComponent} {titleComponent}
{liveStatusComponent} {liveStatusComponent || (liveActivityAddress && liveActivities) ? (
<div className="flex flex-wrap items-center gap-2">
{liveStatusComponent}
{liveActivityAddress && liveActivities ? (
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs shrink-0"
title={
carouselHidden
? t('liveEvent.showInCarouselTitle')
: t('liveEvent.hideFromCarouselTitle')
}
onClick={(e) => {
e.stopPropagation()
onToggleCarouselHidden()
}}
>
{carouselHidden ? t('liveEvent.showInCarousel') : t('liveEvent.hideFromCarousel')}
</Button>
) : null}
</div>
) : null}
{nowPlaying} {nowPlaying}
{summaryComponent} {summaryComponent}
{tagsComponent} {tagsComponent}

5
src/i18n/locales/de.ts

@ -502,6 +502,11 @@ export default {
'liveEvent.zapStreamPlayer': 'Livestream (zap.stream)', 'liveEvent.zapStreamPlayer': 'Livestream (zap.stream)',
'liveEvent.hlsPlaybackUnavailable': 'liveEvent.hlsPlaybackUnavailable':
'Wiedergabe hier fehlgeschlagen (Stream offline, beendet oder blockiert). Die gehostete Watch-Seite kannst du unten trotzdem öffnen.', 'Wiedergabe hier fehlgeschlagen (Stream offline, beendet oder blockiert). Die gehostete Watch-Seite kannst du unten trotzdem öffnen.',
'liveEvent.hideFromCarousel': 'Im Karussell ausblenden',
'liveEvent.showInCarousel': 'Im Karussell anzeigen',
'liveEvent.hideFromCarouselTitle':
'Diesen Stream im Live-Karussell ausblenden (lokal in diesem Browser gespeichert). Erneut klicken, um ihn wieder anzuzeigen.',
'liveEvent.showInCarouselTitle': 'Diesen Stream wieder im Live-Karussell anzeigen.',
'Web page': 'Webseite', 'Web page': 'Webseite',
Open: 'Öffnen', Open: 'Öffnen',
'Sorry! The note cannot be found 😔': 'Entschuldigung! Die Notiz wurde nicht gefunden 😔', 'Sorry! The note cannot be found 😔': 'Entschuldigung! Die Notiz wurde nicht gefunden 😔',

5
src/i18n/locales/en.ts

@ -499,6 +499,11 @@ export default {
'liveEvent.zapStreamPlayer': 'Live stream (zap.stream)', 'liveEvent.zapStreamPlayer': 'Live stream (zap.stream)',
'liveEvent.hlsPlaybackUnavailable': 'liveEvent.hlsPlaybackUnavailable':
'Inline playback failed (the stream may be offline, ended, or blocked). You can still open the hosted watch page below.', 'Inline playback failed (the stream may be offline, ended, or blocked). You can still open the hosted watch page below.',
'liveEvent.hideFromCarousel': 'Hide from carousel',
'liveEvent.showInCarousel': 'Show in carousel',
'liveEvent.hideFromCarouselTitle':
'Hide this stream in the live carousel (saved in this browser on this device). Click again to show it.',
'liveEvent.showInCarouselTitle': 'Show this stream in the live carousel again.',
'Web page': 'Web page', 'Web page': 'Web page',
Open: 'Open', Open: 'Open',
'Sorry! The note cannot be found 😔': 'Sorry! The note cannot be found 😔', 'Sorry! The note cannot be found 😔': 'Sorry! The note cannot be found 😔',

18
src/lib/live-activities.test.ts

@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { import {
filterLiveActivityItemsByReachableMedia, filterLiveActivityItemsByReachableMedia,
liveActivityAddressFromEvent,
liveEventInlinePlaybackFromEvent, liveEventInlinePlaybackFromEvent,
liveEventZapStreamWatchUrl, liveEventZapStreamWatchUrl,
parseLiveActivityEvent, parseLiveActivityEvent,
@ -25,6 +26,23 @@ afterEach(() => {
vi.unstubAllGlobals() vi.unstubAllGlobals()
}) })
describe('liveActivityAddressFromEvent', () => {
it('returns kind:pubkey:d for 30311 with d tag', () => {
const pk = 'a'.repeat(64)
const ev = base(30311, [
['d', 'my-stream'],
['status', 'live'],
['title', 'X']
], pk)
expect(liveActivityAddressFromEvent(ev)).toBe(`30311:${pk}:my-stream`)
})
it('returns null without d tag', () => {
const ev = base(30311, [['status', 'live']])
expect(liveActivityAddressFromEvent(ev)).toBeNull()
})
})
describe('filterLiveActivityItemsByReachableMedia', () => { describe('filterLiveActivityItemsByReachableMedia', () => {
it('removes 30311 when HLS manifest responds 204', async () => { it('removes 30311 when HLS manifest responds 204', async () => {
const pk = 'a'.repeat(64) const pk = 'a'.repeat(64)

11
src/lib/live-activities.ts

@ -36,6 +36,17 @@ export type LiveActivitiesFetchEventsFn = (
/** NIP-53 live streaming (30311), meeting space (30312), meeting (30313). */ /** NIP-53 live streaming (30311), meeting space (30312), meeting (30313). */
export const LIVE_ACTIVITY_KINDS = [30311, 30312, 30313] as const 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).
*/
export function liveActivityAddressFromEvent(ev: Event): string | null {
if (!LIVE_ACTIVITY_KINDS.includes(ev.kind as (typeof LIVE_ACTIVITY_KINDS)[number])) return null
for (const t of ev.tags) {
if (t[0] === 'd' && t[1]?.trim()) return `${ev.kind}:${ev.pubkey}:${t[1].trim()}`
}
return null
}
const LIVE_ACTIVITIES_MAX_ITEMS = 10 const LIVE_ACTIVITIES_MAX_ITEMS = 10
export const LIVE_ACTIVITIES_SLIDE_INTERVAL_MS = 30_000 export const LIVE_ACTIVITIES_SLIDE_INTERVAL_MS = 30_000

47
src/providers/LiveActivitiesProvider.tsx

@ -10,6 +10,7 @@ import {
import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import client from '@/services/client.service' import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
import { registerLiveActivitiesPrewarmCallback } from '@/services/live-activities-prewarm-bridge' import { registerLiveActivitiesPrewarmCallback } from '@/services/live-activities-prewarm-bridge'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@ -30,12 +31,16 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode
const [items, setItems] = useState<TLiveActivityItem[]>([]) const [items, setItems] = useState<TLiveActivityItem[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [carouselHiddenAddresses, setCarouselHiddenAddresses] = useState<ReadonlySet<string>>(() => new Set())
const rawItemsRef = useRef<TLiveActivityItem[]>([])
const hiddenCarouselRef = useRef<Set<string>>(new Set())
const relayRead = useMemo(() => userReadRelaysWithHttp(relayList), [relayList]) const relayRead = useMemo(() => userReadRelaysWithHttp(relayList), [relayList])
const relayWrite = relayList?.write ?? [] const relayWrite = relayList?.write ?? []
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
if (!showLiveActivitiesBanner) { if (!showLiveActivitiesBanner) {
rawItemsRef.current = []
setItems([]) setItems([])
return return
} }
@ -48,6 +53,7 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode
relayListWrite: relayWrite relayListWrite: relayWrite
}) })
if (loggedIn && urls.length === 0) { if (loggedIn && urls.length === 0) {
rawItemsRef.current = []
setItems([]) setItems([])
return return
} }
@ -63,7 +69,8 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode
) )
const merged = mergeLiveActivityEvents(events, followings, parentByAddress) const merged = mergeLiveActivityEvents(events, followings, parentByAddress)
const reachable = await filterLiveActivityItemsByReachableMedia(merged) const reachable = await filterLiveActivityItemsByReachableMedia(merged)
setItems(reachable) rawItemsRef.current = reachable
setItems(reachable.filter((i) => !hiddenCarouselRef.current.has(i.address)))
logger.debug('[LiveActivities] poll done', { logger.debug('[LiveActivities] poll done', {
relayCount: urls.length, relayCount: urls.length,
raw: events.length, raw: events.length,
@ -72,6 +79,7 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode
}) })
} catch (e) { } catch (e) {
logger.warn('[LiveActivities] poll failed', { err: e }) logger.warn('[LiveActivities] poll failed', { err: e })
rawItemsRef.current = []
setItems([]) setItems([])
} finally { } finally {
setLoading(false) setLoading(false)
@ -86,6 +94,33 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode
followings followings
]) ])
const toggleLiveActivityCarouselHidden = useCallback(async (address: string) => {
const next = new Set(hiddenCarouselRef.current)
if (next.has(address)) next.delete(address)
else next.add(address)
hiddenCarouselRef.current = next
setCarouselHiddenAddresses(next)
try {
await indexedDb.setHiddenLiveActivityAddresses([...next])
} catch (e) {
logger.warn('[LiveActivities] persist carousel hide failed', { err: e })
}
setItems(rawItemsRef.current.filter((i) => !next.has(i.address)))
}, [])
useEffect(() => {
let cancelled = false
void indexedDb.getHiddenLiveActivityAddresses().then((s) => {
if (cancelled) return
hiddenCarouselRef.current = s
setCarouselHiddenAddresses(s)
setItems(rawItemsRef.current.filter((i) => !s.has(i.address)))
})
return () => {
cancelled = true
}
}, [])
const refreshRef = useRef(refresh) const refreshRef = useRef(refresh)
refreshRef.current = refresh refreshRef.current = refresh
@ -129,7 +164,15 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode
} }
}, [showLiveActivitiesBanner]) }, [showLiveActivitiesBanner])
const value = useMemo(() => ({ items, loading }), [items, loading]) const value = useMemo(
() => ({
items,
loading,
carouselHiddenAddresses,
toggleLiveActivityCarouselHidden
}),
[items, loading, carouselHiddenAddresses, toggleLiveActivityCarouselHidden]
)
return <LiveActivitiesContext.Provider value={value}>{children}</LiveActivitiesContext.Provider> return <LiveActivitiesContext.Provider value={value}>{children}</LiveActivitiesContext.Provider>
} }

4
src/providers/live-activities-context.ts

@ -4,6 +4,10 @@ import { createContext } from 'react'
export type LiveActivitiesContextValue = { export type LiveActivitiesContextValue = {
items: TLiveActivityItem[] items: TLiveActivityItem[]
loading: boolean loading: boolean
/** NIP-33 `kind:pubkey:d` addresses hidden from the carousel for this browser profile. */
carouselHiddenAddresses: ReadonlySet<string>
/** Toggle carousel visibility; persists to IndexedDB settings. */
toggleLiveActivityCarouselHidden: (address: string) => Promise<void>
} }
export const LiveActivitiesContext = createContext<LiveActivitiesContextValue | undefined>(undefined) export const LiveActivitiesContext = createContext<LiveActivitiesContextValue | undefined>(undefined)

24
src/services/indexed-db.service.ts

@ -2421,6 +2421,8 @@ class IndexedDbService {
/** Settings key for favorite spell event ids (JSON array of strings). */ /** Settings key for favorite spell event ids (JSON array of strings). */
static readonly SPELL_FAVORITE_IDS_KEY = 'spellFavoriteIds' static readonly SPELL_FAVORITE_IDS_KEY = 'spellFavoriteIds'
/** Settings key: JSON array of NIP-33 addresses `kind:pubkey:d` hidden from the live-activities carousel. */
static readonly HIDDEN_LIVE_ACTIVITY_ADDRESSES_KEY = 'hiddenLiveActivityAddresses'
/** /**
* Store a NIP-A7 spell event (kind 777) in IndexedDB by event id. * Store a NIP-A7 spell event (kind 777) in IndexedDB by event id.
@ -2510,6 +2512,28 @@ class IndexedDbService {
await this.setSetting(IndexedDbService.SPELL_FAVORITE_IDS_KEY, JSON.stringify(ids)) await this.setSetting(IndexedDbService.SPELL_FAVORITE_IDS_KEY, JSON.stringify(ids))
} }
/**
* NIP-33 addresses (`kind:pubkey:d`) the user chose to hide from the live-activities carousel (IndexedDB settings).
*/
async getHiddenLiveActivityAddresses(): Promise<Set<string>> {
const raw = await this.getSetting(IndexedDbService.HIDDEN_LIVE_ACTIVITY_ADDRESSES_KEY)
if (!raw?.trim()) return new Set()
try {
const arr = JSON.parse(raw) as unknown
if (!Array.isArray(arr)) return new Set()
return new Set(arr.filter((x): x is string => typeof x === 'string' && x.length > 0))
} catch {
return new Set()
}
}
async setHiddenLiveActivityAddresses(addresses: readonly string[]): Promise<void> {
await this.setSetting(
IndexedDbService.HIDDEN_LIVE_ACTIVITY_ADDRESSES_KEY,
JSON.stringify([...new Set(addresses)])
)
}
/** /**
* Check if an event is tombstoned (deleted) * Check if an event is tombstoned (deleted)
*/ */

Loading…
Cancel
Save