diff --git a/src/components/BookmarkList/index.tsx b/src/components/BookmarkList/index.tsx index c5bc66d8..7a980a1c 100644 --- a/src/components/BookmarkList/index.tsx +++ b/src/components/BookmarkList/index.tsx @@ -4,6 +4,7 @@ import { getLatestEvent } from '@/lib/event' import { generateBech32IdFromATag, generateBech32IdFromETag } from '@/lib/tag' import { normalizeUrl } from '@/lib/url' import { useNostr } from '@/providers/NostrProvider' +import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions' import { queryService } from '@/services/client.service' import { kinds } from 'nostr-tools' import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' @@ -38,6 +39,7 @@ const BookmarkList = forwardRef(function BookmarkList(_, ref) { () => ({ refresh: async () => { if (!pubkey) return + await syncUserDeletionTombstones(pubkey, relayList) const urls = Array.from( new Set( [ diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 3965ff27..22003155 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -10,6 +10,7 @@ import { } from '@/lib/event' import { shouldFilterEvent } from '@/lib/event-filtering' import { stableSpellFeedFilterKey } from '@/lib/spell-feed-request-identity' +import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions' import { normalizeUrl } from '@/lib/url' import { getZapInfoFromEvent } from '@/lib/event-metadata' import { isTouchDevice } from '@/lib/utils' @@ -103,7 +104,7 @@ const NoteList = forwardRef( ref ) => { const { t } = useTranslation() - const { startLogin, pubkey } = useNostr() + const { startLogin, pubkey, relayList } = useNostr() const { isUserTrusted } = useUserTrust() const { mutePubkeySet } = useMuteList() const { hideContentMentioningMutedUsers } = useContentPolicy() @@ -364,20 +365,23 @@ const NoteList = forwardRef( return () => window.clearTimeout(handle) }, [filteredEvents, events, showCount]) - const scrollToTop = (behavior: ScrollBehavior = 'instant') => { + const scrollToTop = useCallback((behavior: ScrollBehavior = 'instant') => { setTimeout(() => { topRef.current?.scrollIntoView({ behavior, block: 'start' }) }, 20) - } + }, []) - const refresh = () => { + const refresh = useCallback(() => { scrollToTop() setTimeout(() => { - setRefreshCount((count) => count + 1) + void (async () => { + await syncUserDeletionTombstones(pubkey, relayList) + setRefreshCount((count) => count + 1) + })() }, 500) - } + }, [pubkey, relayList, scrollToTop]) - useImperativeHandle(ref, () => ({ scrollToTop, refresh }), []) + useImperativeHandle(ref, () => ({ scrollToTop, refresh }), [scrollToTop, refresh]) useEffect(() => { const currentSubRequests = subRequestsRef.current diff --git a/src/components/Profile/ProfileFeedWithPins.tsx b/src/components/Profile/ProfileFeedWithPins.tsx index c9eabe44..104c87b2 100644 --- a/src/components/Profile/ProfileFeedWithPins.tsx +++ b/src/components/Profile/ProfileFeedWithPins.tsx @@ -6,8 +6,10 @@ import { isReplyNoteEvent } from '@/lib/event' import { getZapInfoFromEvent } from '@/lib/event-metadata' import { useProfilePins } from '@/hooks/useProfilePins' import { useProfileTimeline } from '@/hooks/useProfileTimeline' +import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useKindFilter } from '@/providers/KindFilterProvider' import { useZap } from '@/providers/ZapProvider' +import client from '@/services/client.service' import storage from '@/services/local-storage.service' import { Event, kinds } from 'nostr-tools' import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' @@ -36,6 +38,7 @@ function useHideRepliesLikeMainFeed() { const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string }>(({ pubkey }, ref) => { const { t } = useTranslation() + const { isEventDeleted } = useDeletedEvent() const { zapReplyThreshold } = useZap() const { showKinds, showKind1OPs, showKind1Replies, showKind1111 } = useKindFilter() const hideReplies = useHideRepliesLikeMainFeed() @@ -103,8 +106,14 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string [searchQuery] ) - const filteredPins = useMemo(() => applySearch(pinEvents), [pinEvents, applySearch]) - const filteredRest = useMemo(() => applySearch(restTimeline), [restTimeline, applySearch]) + const filteredPins = useMemo( + () => applySearch(pinEvents).filter((e) => !isEventDeleted(e)), + [pinEvents, applySearch, isEventDeleted] + ) + const filteredRest = useMemo( + () => applySearch(restTimeline).filter((e) => !isEventDeleted(e)), + [restTimeline, applySearch, isEventDeleted] + ) const mergedDisplay = useMemo(() => [...filteredPins, ...filteredRest], [filteredPins, filteredRest]) @@ -124,7 +133,8 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string setIsRefreshing(true) refreshPins() refreshTimeline() - }, [refreshPins, refreshTimeline]) + void client.fetchDeletionEventsForPubkey(pubkey) + }, [refreshPins, refreshTimeline, pubkey]) useImperativeHandle(ref, () => ({ refresh: refreshAll }), [refreshAll]) diff --git a/src/hooks/useProfileTimeline.tsx b/src/hooks/useProfileTimeline.tsx index f2abbe4c..6c67745f 100644 --- a/src/hooks/useProfileTimeline.tsx +++ b/src/hooks/useProfileTimeline.tsx @@ -1,6 +1,7 @@ +import { useDeletedEvent } from '@/providers/DeletedEventProvider' +import client from '@/services/client.service' import { useEffect, useMemo, useRef, useState, useCallback } from 'react' import { Event } from 'nostr-tools' -import client from '@/services/client.service' import { CALENDAR_EVENT_KINDS, ExtendedKind, FAST_READ_RELAY_URLS } from '@/constants' import { normalizeUrl } from '@/lib/url' @@ -81,7 +82,8 @@ async function getRelayGroups(pubkey: string): Promise { function postProcessEvents( rawEvents: Event[], filterPredicate: ((event: Event) => boolean) | undefined, - limit: number + limit: number, + isEventDeleted: (event: Event) => boolean ) { const dedupMap = new Map() rawEvents.forEach((evt) => { @@ -90,7 +92,7 @@ function postProcessEvents( } }) - let events = Array.from(dedupMap.values()) + let events = Array.from(dedupMap.values()).filter((e) => !isEventDeleted(e)) if (filterPredicate) { events = events.filter(filterPredicate) } @@ -105,12 +107,28 @@ export function useProfileTimeline({ limit = 200, filterPredicate }: UseProfileTimelineOptions): UseProfileTimelineResult { + const { isEventDeleted, tombstoneEpoch } = useDeletedEvent() + const isEventDeletedRef = useRef(isEventDeleted) + isEventDeletedRef.current = isEventDeleted + const cachedEntry = useMemo(() => timelineCache.get(cacheKey), [cacheKey]) const [events, setEvents] = useState(cachedEntry?.events ?? []) const [isLoading, setIsLoading] = useState(!cachedEntry) const [refreshToken, setRefreshToken] = useState(0) const subscriptionRef = useRef<() => void>(() => {}) + useEffect(() => { + setEvents((prev) => { + const next = prev.filter((e) => !isEventDeletedRef.current(e)) + if (next.length === prev.length) return prev + const cached = timelineCache.get(cacheKey) + if (cached) { + timelineCache.set(cacheKey, { events: next, lastUpdated: cached.lastUpdated }) + } + return next + }) + }, [tombstoneEpoch, cacheKey]) + useEffect(() => { let cancelled = false @@ -178,7 +196,12 @@ export function useProfileTimeline({ { onEvents: (fetchedEvents) => { if (cancelled) return - const processed = postProcessEvents(fetchedEvents as Event[], filterPredicate, limit) + const processed = postProcessEvents( + fetchedEvents as Event[], + filterPredicate, + limit, + isEventDeletedRef.current + ) timelineCache.set(cacheKey, { events: processed, lastUpdated: Date.now() @@ -190,7 +213,12 @@ export function useProfileTimeline({ if (cancelled) return setEvents((prevEvents) => { const combined = [evt as Event, ...prevEvents] - const processed = postProcessEvents(combined, filterPredicate, limit) + const processed = postProcessEvents( + combined, + filterPredicate, + limit, + isEventDeletedRef.current + ) timelineCache.set(cacheKey, { events: processed, lastUpdated: Date.now() diff --git a/src/lib/deleted-event-key.ts b/src/lib/deleted-event-key.ts new file mode 100644 index 00000000..d1acf358 --- /dev/null +++ b/src/lib/deleted-event-key.ts @@ -0,0 +1,7 @@ +import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' +import { NostrEvent } from 'nostr-tools' + +/** Key used when optimistically marking an event deleted in UI (matches tombstone / filter lookup). */ +export function getKeyForDeletedLookup(event: NostrEvent): string { + return isReplaceableEvent(event.kind) ? getReplaceableCoordinateFromEvent(event) : event.id +} diff --git a/src/lib/event.ts b/src/lib/event.ts index ae03fa02..1b057c90 100644 --- a/src/lib/event.ts +++ b/src/lib/event.ts @@ -190,6 +190,16 @@ export function getReplaceableCoordinateFromEvent(event: Event) { return getReplaceableCoordinate(event.kind, event.pubkey, d) } +/** Whether an event matches a tombstone key from IndexedDB (e-tag id, a-tag coordinate, or k-tag kind:pubkey). */ +export function isTombstoneKeyForEvent(event: Event, tombstones: Set): boolean { + if (tombstones.has(event.id)) return true + if (isReplaceableEvent(event.kind)) { + if (tombstones.has(getReplaceableCoordinateFromEvent(event))) return true + if (tombstones.has(`${event.kind}:${event.pubkey}`)) return true + } + return false +} + export function getNoteBech32Id(event: Event) { const hints = client.getEventHints(event.id).slice(0, 2) if (isReplaceableEvent(event.kind)) { diff --git a/src/lib/sync-user-deletions.ts b/src/lib/sync-user-deletions.ts new file mode 100644 index 00000000..f173a1c2 --- /dev/null +++ b/src/lib/sync-user-deletions.ts @@ -0,0 +1,12 @@ +import { buildDeletionRelayUrls } from '@/lib/tombstone-events' +import client from '@/services/client.service' +import type { TRelayList } from '@/types' + +/** Re-fetch the current user's kind-5 events, update IndexedDB tombstones, and notify UI (via tombstonesUpdated). */ +export async function syncUserDeletionTombstones( + pubkey: string | undefined | null, + relayList: TRelayList | null | undefined +): Promise { + if (!pubkey) return + await client.fetchDeletionEvents(buildDeletionRelayUrls(relayList ?? null), pubkey) +} diff --git a/src/lib/tombstone-events.ts b/src/lib/tombstone-events.ts new file mode 100644 index 00000000..a6b027e3 --- /dev/null +++ b/src/lib/tombstone-events.ts @@ -0,0 +1,27 @@ +import { PROFILE_FETCH_RELAY_URLS } from '@/constants' +import { normalizeUrl } from '@/lib/url' +import type { TRelayList } from '@/types' + +/** Dispatched after tombstones in IndexedDB change (kind-5 sync or local apply). */ +export const TOMBSTONES_UPDATED_EVENT = 'jumble:tombstonesUpdated' + +export function dispatchTombstonesUpdated(): void { + if (typeof window === 'undefined') return + window.dispatchEvent(new CustomEvent(TOMBSTONES_UPDATED_EVENT)) +} + +/** Relay set for querying the current user's kind-5 events (aligned with login sync). */ +export function buildDeletionRelayUrls(relayList: TRelayList | null | undefined): string[] { + if (!relayList?.read?.length && !relayList?.write?.length) { + return Array.from( + new Set(PROFILE_FETCH_RELAY_URLS.map((url) => normalizeUrl(url) || url).filter(Boolean)) + ).slice(0, 20) + } + return Array.from( + new Set([ + ...relayList.write.map((url: string) => normalizeUrl(url) || url), + ...relayList.read.slice(0, 8).map((url: string) => normalizeUrl(url) || url), + ...PROFILE_FETCH_RELAY_URLS.map((url: string) => normalizeUrl(url) || url) + ]) + ).slice(0, 20) +} diff --git a/src/pages/primary/ExplorePage/index.tsx b/src/pages/primary/ExplorePage/index.tsx index edd5d5f5..4141a250 100644 --- a/src/pages/primary/ExplorePage/index.tsx +++ b/src/pages/primary/ExplorePage/index.tsx @@ -11,11 +11,22 @@ import { cn } from '@/lib/utils' import { isWebsocketUrl, normalizeUrl, simplifyUrl } from '@/lib/url' import { RefreshButton } from '@/components/RefreshButton' import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' +import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions' import { useSmartRelayNavigation } from '@/PageManager' +import { useNostr } from '@/providers/NostrProvider' import nip66Service from '@/services/nip66.service' import { TPageRef } from '@/types' import { ArrowRight, Compass, Plus } from 'lucide-react' -import { forwardRef, FormEvent, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' +import { + forwardRef, + FormEvent, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState +} from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -69,11 +80,17 @@ function normalizeHomeTab(restored: string): TExploreTabs { const ExplorePage = forwardRef((_, ref) => { const { t } = useTranslation() + const { pubkey, relayList } = useNostr() const [tab, setTab] = useState('explore') const layoutRef = useRef(null) const [contentRefreshKey, setContentRefreshKey] = useState(0) - const bumpExploreContent = () => setContentRefreshKey((k) => k + 1) + const bumpExploreContent = useCallback(() => { + void (async () => { + await syncUserDeletionTombstones(pubkey, relayList) + setContentRefreshKey((k) => k + 1) + })() + }, [pubkey, relayList]) useImperativeHandle( ref, @@ -81,7 +98,7 @@ const ExplorePage = forwardRef((_, ref) => { scrollToTop: (behavior?: ScrollBehavior) => layoutRef.current?.scrollToTop(behavior), refresh: bumpExploreContent }), - [] + [bumpExploreContent] ) // Listen for tab restoration from PageManager diff --git a/src/pages/primary/MePage/index.tsx b/src/pages/primary/MePage/index.tsx index e133f672..d57ed55d 100644 --- a/src/pages/primary/MePage/index.tsx +++ b/src/pages/primary/MePage/index.tsx @@ -9,6 +9,7 @@ import { SimpleUsername } from '@/components/Username' import { RefreshButton } from '@/components/RefreshButton' import PrimaryPageLayout, { type TPrimaryPageLayoutRef } from '@/layouts/PrimaryPageLayout' import { toProfile, toRelaySettings, toWallet } from '@/lib/link' +import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions' import { cn } from '@/lib/utils' import { useSecondaryPage } from '@/PageManager' import { useNostr } from '@/providers/NostrProvider' @@ -21,19 +22,33 @@ import { Wallet } from 'lucide-react' import { TPageRef } from '@/types' -import { forwardRef, HTMLProps, useImperativeHandle, useRef, useState, type KeyboardEvent, type MouseEvent } from 'react' +import { + forwardRef, + HTMLProps, + useCallback, + useImperativeHandle, + useRef, + useState, + type KeyboardEvent, + type MouseEvent +} from 'react' import { useTranslation } from 'react-i18next' const MePage = forwardRef((_, ref) => { const { t } = useTranslation() const { push } = useSecondaryPage() - const { pubkey } = useNostr() + const { pubkey, relayList } = useNostr() const [loginDialogOpen, setLoginDialogOpen] = useState(false) const [logoutDialogOpen, setLogoutDialogOpen] = useState(false) const layoutRef = useRef(null) const [contentKey, setContentKey] = useState(0) - const bumpMe = () => setContentKey((k) => k + 1) + const bumpMe = useCallback(() => { + void (async () => { + await syncUserDeletionTombstones(pubkey, relayList) + setContentKey((k) => k + 1) + })() + }, [pubkey, relayList]) useImperativeHandle( ref, @@ -41,7 +56,7 @@ const MePage = forwardRef((_, ref) => { scrollToTop: (behavior?: ScrollBehavior) => layoutRef.current?.scrollToTop(behavior), refresh: bumpMe }), - [] + [bumpMe] ) if (!pubkey) { diff --git a/src/pages/primary/RssPage/index.tsx b/src/pages/primary/RssPage/index.tsx index e8b9f0b2..cfefb695 100644 --- a/src/pages/primary/RssPage/index.tsx +++ b/src/pages/primary/RssPage/index.tsx @@ -4,6 +4,7 @@ import PrimaryPageLayout, { type TPrimaryPageLayoutRef } from '@/layouts/Primary import { Button } from '@/components/ui/button' import { DEFAULT_RSS_FEEDS } from '@/constants' import logger from '@/lib/logger' +import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions' import { useNostr } from '@/providers/NostrProvider' import rssFeedService from '@/services/rss-feed.service' import { Rss, Search } from 'lucide-react' @@ -13,11 +14,12 @@ import { useTranslation } from 'react-i18next' const RssPage = forwardRef((_, ref) => { const { t } = useTranslation() - const { pubkey, rssFeedListEvent } = useNostr() + const { pubkey, relayList, rssFeedListEvent } = useNostr() const [rssRefreshKey, setRssRefreshKey] = useState(0) const layoutRef = useRef(null) const handleRefresh = useCallback(() => { + void syncUserDeletionTombstones(pubkey, relayList) let feedUrls: string[] = [] if (pubkey && rssFeedListEvent) { try { @@ -42,7 +44,7 @@ const RssPage = forwardRef((_, ref) => { ) } setRssRefreshKey((k) => k + 1) - }, [pubkey, rssFeedListEvent]) + }, [pubkey, relayList, rssFeedListEvent]) useImperativeHandle( ref, diff --git a/src/pages/primary/SearchPage/index.tsx b/src/pages/primary/SearchPage/index.tsx index 9c55b9c0..c8aacd93 100644 --- a/src/pages/primary/SearchPage/index.tsx +++ b/src/pages/primary/SearchPage/index.tsx @@ -3,7 +3,9 @@ import { RefreshButton } from '@/components/RefreshButton' import SearchBar, { TSearchBarRef } from '@/components/SearchBar' import SearchResult from '@/components/SearchResult' import PrimaryPageLayout, { TPrimaryPageLayoutRef } from '@/layouts/PrimaryPageLayout' +import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions' import { usePrimaryPage } from '@/PageManager' +import { useNostr } from '@/providers/NostrProvider' import { TPageRef, TSearchParams } from '@/types' import { BookOpen } from 'lucide-react' import { Button } from '@/components/ui/button' @@ -11,6 +13,7 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRe const SearchPage = forwardRef((_, ref) => { const { current, display } = usePrimaryPage() + const { pubkey, relayList } = useNostr() const [input, setInput] = useState('') const [searchParams, setSearchParams] = useState(null) const [resultRefreshKey, setResultRefreshKey] = useState(0) @@ -18,7 +21,12 @@ const SearchPage = forwardRef((_, ref) => { const searchBarRef = useRef(null) const layoutRef = useRef(null) - const bumpResults = useCallback(() => setResultRefreshKey((k) => k + 1), []) + const bumpResults = useCallback(() => { + void (async () => { + await syncUserDeletionTombstones(pubkey, relayList) + setResultRefreshKey((k) => k + 1) + })() + }, [pubkey, relayList]) useImperativeHandle( ref, diff --git a/src/pages/secondary/SearchPage/index.tsx b/src/pages/secondary/SearchPage/index.tsx index 0e7e2261..df816128 100644 --- a/src/pages/secondary/SearchPage/index.tsx +++ b/src/pages/secondary/SearchPage/index.tsx @@ -5,7 +5,9 @@ import SearchResult from '@/components/SearchResult' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { toSearch } from '@/lib/link' import { parseAdvancedSearch } from '@/lib/search-parser' +import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions' import { usePrimaryNoteView, useSecondaryPage } from '@/PageManager' +import { useNostr } from '@/providers/NostrProvider' import { TSearchParams } from '@/types' import { BookOpen } from 'lucide-react' import { Button } from '@/components/ui/button' @@ -14,8 +16,14 @@ import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'r const SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const { push } = useSecondaryPage() + const { pubkey, relayList } = useNostr() const [resultRefreshKey, setResultRefreshKey] = useState(0) - const bumpResults = useCallback(() => setResultRefreshKey((k) => k + 1), []) + const bumpResults = useCallback(() => { + void (async () => { + await syncUserDeletionTombstones(pubkey, relayList) + setResultRefreshKey((k) => k + 1) + })() + }, [pubkey, relayList]) useEffect(() => { if (!hideTitlebar) { diff --git a/src/providers/DeletedEventProvider.tsx b/src/providers/DeletedEventProvider.tsx index f17a9a5d..032e977c 100644 --- a/src/providers/DeletedEventProvider.tsx +++ b/src/providers/DeletedEventProvider.tsx @@ -1,11 +1,16 @@ -import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' +import { getKeyForDeletedLookup } from '@/lib/deleted-event-key' +import { isTombstoneKeyForEvent } from '@/lib/event' +import { TOMBSTONES_UPDATED_EVENT } from '@/lib/tombstone-events' +import indexedDb from '@/services/indexed-db.service' import { NostrEvent } from 'nostr-tools' -import { createContext, useCallback, useContext, useState } from 'react' +import { createContext, useCallback, useContext, useEffect, useState } from 'react' type TDeletedEventContext = { addDeletedEvent: (event: NostrEvent) => void addDeletedEventId: (eventId: string) => void isEventDeleted: (event: NostrEvent) => boolean + /** Bumps when tombstones are reloaded from IndexedDB (for list re-filtering). */ + tombstoneEpoch: number } const DeletedEventContext = createContext(undefined) @@ -19,30 +24,52 @@ export const useDeletedEvent = () => { } export function DeletedEventProvider({ children }: { children: React.ReactNode }) { - const [deletedEventKeys, setDeletedEventKeys] = useState>(new Set()) + const [tombstoneKeys, setTombstoneKeys] = useState>(() => new Set()) + const [tombstoneEpoch, setTombstoneEpoch] = useState(0) + + const hydrateFromIndexedDb = useCallback(async () => { + try { + const keys = await indexedDb.getAllTombstones() + setTombstoneKeys(keys) + setTombstoneEpoch((e) => e + 1) + } catch { + /* ignore */ + } + }, []) + + useEffect(() => { + void hydrateFromIndexedDb() + }, [hydrateFromIndexedDb]) + + useEffect(() => { + const onUpdate = () => { + void hydrateFromIndexedDb() + } + window.addEventListener(TOMBSTONES_UPDATED_EVENT, onUpdate) + return () => window.removeEventListener(TOMBSTONES_UPDATED_EVENT, onUpdate) + }, [hydrateFromIndexedDb]) const isEventDeleted = useCallback( - (event: NostrEvent) => { - return deletedEventKeys.has(getKey(event)) - }, - [deletedEventKeys] + (event: NostrEvent) => isTombstoneKeyForEvent(event, tombstoneKeys), + [tombstoneKeys] ) - const addDeletedEvent = (event: NostrEvent) => { - setDeletedEventKeys((prev) => new Set(prev).add(getKey(event))) - } + const addDeletedEvent = useCallback((event: NostrEvent) => { + const key = getKeyForDeletedLookup(event) + setTombstoneKeys((prev) => new Set(prev).add(key)) + setTombstoneEpoch((e) => e + 1) + }, []) - const addDeletedEventId = (eventId: string) => { - setDeletedEventKeys((prev) => new Set(prev).add(eventId)) - } + const addDeletedEventId = useCallback((eventId: string) => { + setTombstoneKeys((prev) => new Set(prev).add(eventId)) + setTombstoneEpoch((e) => e + 1) + }, []) return ( - + {children} ) } - -function getKey(event: NostrEvent) { - return isReplaceableEvent(event.kind) ? getReplaceableCoordinateFromEvent(event) : event.id -} diff --git a/src/services/client.service.ts b/src/services/client.service.ts index cf1789a9..2a48ff15 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -18,6 +18,7 @@ function filterForRelay(f: Filter, relaySupportsSearch: boolean): Filter { } import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata' import logger from '@/lib/logger' +import { dispatchTombstonesUpdated } from '@/lib/tombstone-events' import { isValidPubkey, pubkeyToNpub } from '@/lib/pubkey' import { getPubkeysFromPTags, tagNameEquals } from '@/lib/tag' import { isLocalNetworkUrl, normalizeUrl, simplifyUrl } from '@/lib/url' @@ -1835,6 +1836,7 @@ class ClientService extends EventTarget { if (removed > 0) { logger.info('[ClientService] Removed tombstoned events from cache', { count: removed }) } + dispatchTombstonesUpdated() } private async addTombstoneEntriesFromDeletionEvent(deletionEvent: NEvent): Promise { @@ -1893,11 +1895,38 @@ class ClientService extends EventTarget { if (removed > 0) { logger.info('[ClientService] Removed tombstoned events from cache', { count: removed }) } + dispatchTombstonesUpdated() } catch (error) { logger.warn('[ClientService] Failed to fetch deletion events', { error }) } } + /** + * Fetch kind-5 events for a profile pubkey (e.g. on profile feed refresh) so their deletes apply to tombstones + UI. + */ + async fetchDeletionEventsForPubkey(profilePubkey: string): Promise { + if (!profilePubkey) return + try { + const [relayList, favoriteRelays] = await Promise.all([ + this.fetchRelayList(profilePubkey).catch(() => ({ read: [] as string[], write: [] as string[] })), + this.fetchFavoriteRelays(profilePubkey).catch(() => [] as string[]) + ]) + const urls = Array.from( + new Set( + [ + ...relayList.write.map((url: string) => normalizeUrl(url) || url), + ...relayList.read.slice(0, 8).map((url: string) => normalizeUrl(url) || url), + ...favoriteRelays.map((url: string) => normalizeUrl(url) || url), + ...FAST_READ_RELAY_URLS.map((url: string) => normalizeUrl(url) || url) + ].filter(Boolean) + ) + ).slice(0, 24) + await this.fetchDeletionEvents(urls.length > 0 ? urls : undefined, profilePubkey) + } catch (error) { + logger.warn('[ClientService] fetchDeletionEventsForPubkey failed', { error }) + } + } + async searchNpubsForMention( query: string, limit: number = 100,