Browse Source

integrate tombstones in refresh

imwald
Silberengel 1 month ago
parent
commit
6a12ce5326
  1. 2
      src/components/BookmarkList/index.tsx
  2. 18
      src/components/NoteList/index.tsx
  3. 16
      src/components/Profile/ProfileFeedWithPins.tsx
  4. 38
      src/hooks/useProfileTimeline.tsx
  5. 7
      src/lib/deleted-event-key.ts
  6. 10
      src/lib/event.ts
  7. 12
      src/lib/sync-user-deletions.ts
  8. 27
      src/lib/tombstone-events.ts
  9. 23
      src/pages/primary/ExplorePage/index.tsx
  10. 23
      src/pages/primary/MePage/index.tsx
  11. 6
      src/pages/primary/RssPage/index.tsx
  12. 10
      src/pages/primary/SearchPage/index.tsx
  13. 10
      src/pages/secondary/SearchPage/index.tsx
  14. 63
      src/providers/DeletedEventProvider.tsx
  15. 29
      src/services/client.service.ts

2
src/components/BookmarkList/index.tsx

@ -4,6 +4,7 @@ import { getLatestEvent } from '@/lib/event' @@ -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) { @@ -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(
[

18
src/components/NoteList/index.tsx

@ -10,6 +10,7 @@ import { @@ -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( @@ -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( @@ -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

16
src/components/Profile/ProfileFeedWithPins.tsx

@ -6,8 +6,10 @@ import { isReplyNoteEvent } from '@/lib/event' @@ -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() { @@ -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 @@ -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 @@ -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])

38
src/hooks/useProfileTimeline.tsx

@ -1,6 +1,7 @@ @@ -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<string[][]> { @@ -81,7 +82,8 @@ async function getRelayGroups(pubkey: string): Promise<string[][]> {
function postProcessEvents(
rawEvents: Event[],
filterPredicate: ((event: Event) => boolean) | undefined,
limit: number
limit: number,
isEventDeleted: (event: Event) => boolean
) {
const dedupMap = new Map<string, Event>()
rawEvents.forEach((evt) => {
@ -90,7 +92,7 @@ function postProcessEvents( @@ -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({ @@ -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<Event[]>(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({ @@ -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({ @@ -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()

7
src/lib/deleted-event-key.ts

@ -0,0 +1,7 @@ @@ -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
}

10
src/lib/event.ts

@ -190,6 +190,16 @@ export function getReplaceableCoordinateFromEvent(event: Event) { @@ -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<string>): 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)) {

12
src/lib/sync-user-deletions.ts

@ -0,0 +1,12 @@ @@ -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<void> {
if (!pubkey) return
await client.fetchDeletionEvents(buildDeletionRelayUrls(relayList ?? null), pubkey)
}

27
src/lib/tombstone-events.ts

@ -0,0 +1,27 @@ @@ -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)
}

23
src/pages/primary/ExplorePage/index.tsx

@ -11,11 +11,22 @@ import { cn } from '@/lib/utils' @@ -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 { @@ -69,11 +80,17 @@ function normalizeHomeTab(restored: string): TExploreTabs {
const ExplorePage = forwardRef<TPageRef>((_, ref) => {
const { t } = useTranslation()
const { pubkey, relayList } = useNostr()
const [tab, setTab] = useState<TExploreTabs>('explore')
const layoutRef = useRef<TPageRef>(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<TPageRef>((_, ref) => { @@ -81,7 +98,7 @@ const ExplorePage = forwardRef<TPageRef>((_, ref) => {
scrollToTop: (behavior?: ScrollBehavior) => layoutRef.current?.scrollToTop(behavior),
refresh: bumpExploreContent
}),
[]
[bumpExploreContent]
)
// Listen for tab restoration from PageManager

23
src/pages/primary/MePage/index.tsx

@ -9,6 +9,7 @@ import { SimpleUsername } from '@/components/Username' @@ -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 { @@ -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<TPageRef>((_, 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<TPrimaryPageLayoutRef>(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<TPageRef>((_, ref) => { @@ -41,7 +56,7 @@ const MePage = forwardRef<TPageRef>((_, ref) => {
scrollToTop: (behavior?: ScrollBehavior) => layoutRef.current?.scrollToTop(behavior),
refresh: bumpMe
}),
[]
[bumpMe]
)
if (!pubkey) {

6
src/pages/primary/RssPage/index.tsx

@ -4,6 +4,7 @@ import PrimaryPageLayout, { type TPrimaryPageLayoutRef } from '@/layouts/Primary @@ -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' @@ -13,11 +14,12 @@ import { useTranslation } from 'react-i18next'
const RssPage = forwardRef<TPageRef>((_, ref) => {
const { t } = useTranslation()
const { pubkey, rssFeedListEvent } = useNostr()
const { pubkey, relayList, rssFeedListEvent } = useNostr()
const [rssRefreshKey, setRssRefreshKey] = useState(0)
const layoutRef = useRef<TPrimaryPageLayoutRef>(null)
const handleRefresh = useCallback(() => {
void syncUserDeletionTombstones(pubkey, relayList)
let feedUrls: string[] = []
if (pubkey && rssFeedListEvent) {
try {
@ -42,7 +44,7 @@ const RssPage = forwardRef<TPageRef>((_, ref) => { @@ -42,7 +44,7 @@ const RssPage = forwardRef<TPageRef>((_, ref) => {
)
}
setRssRefreshKey((k) => k + 1)
}, [pubkey, rssFeedListEvent])
}, [pubkey, relayList, rssFeedListEvent])
useImperativeHandle(
ref,

10
src/pages/primary/SearchPage/index.tsx

@ -3,7 +3,9 @@ import { RefreshButton } from '@/components/RefreshButton' @@ -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 @@ -11,6 +13,7 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRe
const SearchPage = forwardRef<TPageRef>((_, ref) => {
const { current, display } = usePrimaryPage()
const { pubkey, relayList } = useNostr()
const [input, setInput] = useState('')
const [searchParams, setSearchParams] = useState<TSearchParams | null>(null)
const [resultRefreshKey, setResultRefreshKey] = useState(0)
@ -18,7 +21,12 @@ const SearchPage = forwardRef<TPageRef>((_, ref) => { @@ -18,7 +21,12 @@ const SearchPage = forwardRef<TPageRef>((_, ref) => {
const searchBarRef = useRef<TSearchBarRef>(null)
const layoutRef = useRef<TPrimaryPageLayoutRef>(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,

10
src/pages/secondary/SearchPage/index.tsx

@ -5,7 +5,9 @@ import SearchResult from '@/components/SearchResult' @@ -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 @@ -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) {

63
src/providers/DeletedEventProvider.tsx

@ -1,11 +1,16 @@ @@ -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<TDeletedEventContext | undefined>(undefined)
@ -19,30 +24,52 @@ export const useDeletedEvent = () => { @@ -19,30 +24,52 @@ export const useDeletedEvent = () => {
}
export function DeletedEventProvider({ children }: { children: React.ReactNode }) {
const [deletedEventKeys, setDeletedEventKeys] = useState<Set<string>>(new Set())
const [tombstoneKeys, setTombstoneKeys] = useState<Set<string>>(() => 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 (
<DeletedEventContext.Provider value={{ addDeletedEvent, addDeletedEventId, isEventDeleted }}>
<DeletedEventContext.Provider
value={{ addDeletedEvent, addDeletedEventId, isEventDeleted, tombstoneEpoch }}
>
{children}
</DeletedEventContext.Provider>
)
}
function getKey(event: NostrEvent) {
return isReplaceableEvent(event.kind) ? getReplaceableCoordinateFromEvent(event) : event.id
}

29
src/services/client.service.ts

@ -18,6 +18,7 @@ function filterForRelay(f: Filter, relaySupportsSearch: boolean): Filter { @@ -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 { @@ -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<void> {
@ -1893,11 +1895,38 @@ class ClientService extends EventTarget { @@ -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<void> {
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,

Loading…
Cancel
Save