From d7f9501911394f9dcdb9715e5c4171896d06dc32 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 15 Apr 2026 11:14:40 +0200 Subject: [PATCH] hide dead live streams in indexeddb --- src/components/Note/LiveEvent.tsx | 39 +++++++++++++++++++- src/i18n/locales/de.ts | 5 +++ src/i18n/locales/en.ts | 5 +++ src/lib/live-activities.test.ts | 18 +++++++++ src/lib/live-activities.ts | 11 ++++++ src/providers/LiveActivitiesProvider.tsx | 47 +++++++++++++++++++++++- src/providers/live-activities-context.ts | 4 ++ src/services/indexed-db.service.ts | 24 ++++++++++++ 8 files changed, 149 insertions(+), 4 deletions(-) diff --git a/src/components/Note/LiveEvent.tsx b/src/components/Note/LiveEvent.tsx index 9dc314f8..14e5de5a 100644 --- a/src/components/Note/LiveEvent.tsx +++ b/src/components/Note/LiveEvent.tsx @@ -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' 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 {metadata.status} )) + 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 = (
{metadata.title}
) @@ -120,7 +132,30 @@ export default function LiveEvent({ event, className }: { event: Event; classNam {cover}
{titleComponent} - {liveStatusComponent} + {liveStatusComponent || (liveActivityAddress && liveActivities) ? ( +
+ {liveStatusComponent} + {liveActivityAddress && liveActivities ? ( + + ) : null} +
+ ) : null} {nowPlaying} {summaryComponent} {tagsComponent} diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 2346e697..8360f7ef 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -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 😔', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 3bd56edd..41ea3c67 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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 😔', diff --git a/src/lib/live-activities.test.ts b/src/lib/live-activities.test.ts index ffd1d516..e1ba512c 100644 --- a/src/lib/live-activities.test.ts +++ b/src/lib/live-activities.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { filterLiveActivityItemsByReachableMedia, + liveActivityAddressFromEvent, liveEventInlinePlaybackFromEvent, liveEventZapStreamWatchUrl, parseLiveActivityEvent, @@ -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) diff --git a/src/lib/live-activities.ts b/src/lib/live-activities.ts index bad6d461..07af075f 100644 --- a/src/lib/live-activities.ts +++ b/src/lib/live-activities.ts @@ -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 diff --git a/src/providers/LiveActivitiesProvider.tsx b/src/providers/LiveActivitiesProvider.tsx index 2d89247d..96035762 100644 --- a/src/providers/LiveActivitiesProvider.tsx +++ b/src/providers/LiveActivitiesProvider.tsx @@ -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 const [items, setItems] = useState([]) const [loading, setLoading] = useState(false) + const [carouselHiddenAddresses, setCarouselHiddenAddresses] = useState>(() => new Set()) + const rawItemsRef = useRef([]) + const hiddenCarouselRef = useRef>(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 relayListWrite: relayWrite }) if (loggedIn && urls.length === 0) { + rawItemsRef.current = [] setItems([]) return } @@ -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 }) } 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 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 } }, [showLiveActivitiesBanner]) - const value = useMemo(() => ({ items, loading }), [items, loading]) + const value = useMemo( + () => ({ + items, + loading, + carouselHiddenAddresses, + toggleLiveActivityCarouselHidden + }), + [items, loading, carouselHiddenAddresses, toggleLiveActivityCarouselHidden] + ) return {children} } diff --git a/src/providers/live-activities-context.ts b/src/providers/live-activities-context.ts index 0d90907d..87c5ca2b 100644 --- a/src/providers/live-activities-context.ts +++ b/src/providers/live-activities-context.ts @@ -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 + /** Toggle carousel visibility; persists to IndexedDB settings. */ + toggleLiveActivityCarouselHidden: (address: string) => Promise } export const LiveActivitiesContext = createContext(undefined) diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index 919d873e..20a8ae1a 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -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 { 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> { + 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 { + await this.setSetting( + IndexedDbService.HIDDEN_LIVE_ACTIVITY_ADDRESSES_KEY, + JSON.stringify([...new Set(addresses)]) + ) + } + /** * Check if an event is tombstoned (deleted) */