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 = (
{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)
*/