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' @@ -3,16 +3,18 @@ import { Button } from '@/components/ui/button'
import { createFakeEvent } from '@/lib/event'
import { getLiveEventMetadataFromEvent } from '@/lib/event-metadata'
import {
liveActivityAddressFromEvent,
liveEventInlinePlaybackFromEvent,
liveEventZapStreamWatchUrl,
preferredLiveJoinUrlForEvent
} from '@/lib/live-activities'
import { cn } from '@/lib/utils'
import { useContentPolicyOptional } from '@/providers/ContentPolicyProvider'
import { useLiveActivitiesOptional } from '@/providers/useLiveActivities'
import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider'
import { Event, kinds } from 'nostr-tools'
import { ExternalLink } from 'lucide-react'
import { useMemo } from 'react'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import ClientSelect from '../ClientSelect'
import Image from '../Image'
@ -21,6 +23,7 @@ import MarkdownArticle from './MarkdownArticle/MarkdownArticle' @@ -21,6 +23,7 @@ import MarkdownArticle from './MarkdownArticle/MarkdownArticle'
export default function LiveEvent({ event, className }: { event: Event; className?: string }) {
const { t } = useTranslation()
const liveActivities = useLiveActivitiesOptional()
const screenSize = useScreenSizeOptional()
const isSmallScreen = screenSize?.isSmallScreen ?? false
const contentPolicy = useContentPolicyOptional()
@ -58,6 +61,15 @@ export default function LiveEvent({ event, className }: { event: Event; classNam @@ -58,6 +61,15 @@ export default function LiveEvent({ event, className }: { event: Event; classNam
<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 = (
<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 @@ -120,7 +132,30 @@ export default function LiveEvent({ event, className }: { event: Event; classNam
{cover}
<div className="min-w-0 flex-1 space-y-1">
{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}
{summaryComponent}
{tagsComponent}

5
src/i18n/locales/de.ts

@ -502,6 +502,11 @@ export default { @@ -502,6 +502,11 @@ export default {
'liveEvent.zapStreamPlayer': 'Livestream (zap.stream)',
'liveEvent.hlsPlaybackUnavailable':
'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',
Open: 'Öffnen',
'Sorry! The note cannot be found 😔': 'Entschuldigung! Die Notiz wurde nicht gefunden 😔',

5
src/i18n/locales/en.ts

@ -499,6 +499,11 @@ export default { @@ -499,6 +499,11 @@ export default {
'liveEvent.zapStreamPlayer': 'Live stream (zap.stream)',
'liveEvent.hlsPlaybackUnavailable':
'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',
Open: 'Open',
'Sorry! The note cannot be found 😔': 'Sorry! The note cannot be found 😔',

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

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
filterLiveActivityItemsByReachableMedia,
liveActivityAddressFromEvent,
liveEventInlinePlaybackFromEvent,
liveEventZapStreamWatchUrl,
parseLiveActivityEvent,
@ -25,6 +26,23 @@ afterEach(() => { @@ -25,6 +26,23 @@ afterEach(() => {
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', () => {
it('removes 30311 when HLS manifest responds 204', async () => {
const pk = 'a'.repeat(64)

11
src/lib/live-activities.ts

@ -36,6 +36,17 @@ export type LiveActivitiesFetchEventsFn = ( @@ -36,6 +36,17 @@ export type LiveActivitiesFetchEventsFn = (
/** NIP-53 live streaming (30311), meeting space (30312), meeting (30313). */
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
export const LIVE_ACTIVITIES_SLIDE_INTERVAL_MS = 30_000

47
src/providers/LiveActivitiesProvider.tsx

@ -10,6 +10,7 @@ import { @@ -10,6 +10,7 @@ import {
import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays'
import logger from '@/lib/logger'
import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import storage from '@/services/local-storage.service'
import { registerLiveActivitiesPrewarmCallback } from '@/services/live-activities-prewarm-bridge'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@ -30,12 +31,16 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode @@ -30,12 +31,16 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode
const [items, setItems] = useState<TLiveActivityItem[]>([])
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 relayWrite = relayList?.write ?? []
const refresh = useCallback(async () => {
if (!showLiveActivitiesBanner) {
rawItemsRef.current = []
setItems([])
return
}
@ -48,6 +53,7 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode @@ -48,6 +53,7 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode
relayListWrite: relayWrite
})
if (loggedIn && urls.length === 0) {
rawItemsRef.current = []
setItems([])
return
}
@ -63,7 +69,8 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode @@ -63,7 +69,8 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode
)
const merged = mergeLiveActivityEvents(events, followings, parentByAddress)
const reachable = await filterLiveActivityItemsByReachableMedia(merged)
setItems(reachable)
rawItemsRef.current = reachable
setItems(reachable.filter((i) => !hiddenCarouselRef.current.has(i.address)))
logger.debug('[LiveActivities] poll done', {
relayCount: urls.length,
raw: events.length,
@ -72,6 +79,7 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode @@ -72,6 +79,7 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode
})
} catch (e) {
logger.warn('[LiveActivities] poll failed', { err: e })
rawItemsRef.current = []
setItems([])
} finally {
setLoading(false)
@ -86,6 +94,33 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode @@ -86,6 +94,33 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode
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)
refreshRef.current = refresh
@ -129,7 +164,15 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode @@ -129,7 +164,15 @@ export function LiveActivitiesProvider({ children }: { children: React.ReactNode
}
}, [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>
}

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

@ -4,6 +4,10 @@ import { createContext } from 'react' @@ -4,6 +4,10 @@ import { createContext } from 'react'
export type LiveActivitiesContextValue = {
items: TLiveActivityItem[]
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)

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

@ -2421,6 +2421,8 @@ class IndexedDbService { @@ -2421,6 +2421,8 @@ class IndexedDbService {
/** Settings key for favorite spell event ids (JSON array of strings). */
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.
@ -2510,6 +2512,28 @@ class IndexedDbService { @@ -2510,6 +2512,28 @@ class IndexedDbService {
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)
*/

Loading…
Cancel
Save