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'
import { generateBech32IdFromATag, generateBech32IdFromETag } from '@/lib/tag' import { generateBech32IdFromATag, generateBech32IdFromETag } from '@/lib/tag'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions'
import { queryService } from '@/services/client.service' import { queryService } from '@/services/client.service'
import { kinds } from 'nostr-tools' import { kinds } from 'nostr-tools'
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
@ -38,6 +39,7 @@ const BookmarkList = forwardRef(function BookmarkList(_, ref) {
() => ({ () => ({
refresh: async () => { refresh: async () => {
if (!pubkey) return if (!pubkey) return
await syncUserDeletionTombstones(pubkey, relayList)
const urls = Array.from( const urls = Array.from(
new Set( new Set(
[ [

18
src/components/NoteList/index.tsx

@ -10,6 +10,7 @@ import {
} from '@/lib/event' } from '@/lib/event'
import { shouldFilterEvent } from '@/lib/event-filtering' import { shouldFilterEvent } from '@/lib/event-filtering'
import { stableSpellFeedFilterKey } from '@/lib/spell-feed-request-identity' import { stableSpellFeedFilterKey } from '@/lib/spell-feed-request-identity'
import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import { getZapInfoFromEvent } from '@/lib/event-metadata' import { getZapInfoFromEvent } from '@/lib/event-metadata'
import { isTouchDevice } from '@/lib/utils' import { isTouchDevice } from '@/lib/utils'
@ -103,7 +104,7 @@ const NoteList = forwardRef(
ref ref
) => { ) => {
const { t } = useTranslation() const { t } = useTranslation()
const { startLogin, pubkey } = useNostr() const { startLogin, pubkey, relayList } = useNostr()
const { isUserTrusted } = useUserTrust() const { isUserTrusted } = useUserTrust()
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
@ -364,20 +365,23 @@ const NoteList = forwardRef(
return () => window.clearTimeout(handle) return () => window.clearTimeout(handle)
}, [filteredEvents, events, showCount]) }, [filteredEvents, events, showCount])
const scrollToTop = (behavior: ScrollBehavior = 'instant') => { const scrollToTop = useCallback((behavior: ScrollBehavior = 'instant') => {
setTimeout(() => { setTimeout(() => {
topRef.current?.scrollIntoView({ behavior, block: 'start' }) topRef.current?.scrollIntoView({ behavior, block: 'start' })
}, 20) }, 20)
} }, [])
const refresh = () => { const refresh = useCallback(() => {
scrollToTop() scrollToTop()
setTimeout(() => { setTimeout(() => {
setRefreshCount((count) => count + 1) void (async () => {
await syncUserDeletionTombstones(pubkey, relayList)
setRefreshCount((count) => count + 1)
})()
}, 500) }, 500)
} }, [pubkey, relayList, scrollToTop])
useImperativeHandle(ref, () => ({ scrollToTop, refresh }), []) useImperativeHandle(ref, () => ({ scrollToTop, refresh }), [scrollToTop, refresh])
useEffect(() => { useEffect(() => {
const currentSubRequests = subRequestsRef.current const currentSubRequests = subRequestsRef.current

16
src/components/Profile/ProfileFeedWithPins.tsx

@ -6,8 +6,10 @@ import { isReplyNoteEvent } from '@/lib/event'
import { getZapInfoFromEvent } from '@/lib/event-metadata' import { getZapInfoFromEvent } from '@/lib/event-metadata'
import { useProfilePins } from '@/hooks/useProfilePins' import { useProfilePins } from '@/hooks/useProfilePins'
import { useProfileTimeline } from '@/hooks/useProfileTimeline' import { useProfileTimeline } from '@/hooks/useProfileTimeline'
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { useKindFilter } from '@/providers/KindFilterProvider' import { useKindFilter } from '@/providers/KindFilterProvider'
import { useZap } from '@/providers/ZapProvider' import { useZap } from '@/providers/ZapProvider'
import client from '@/services/client.service'
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' 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 ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string }>(({ pubkey }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { isEventDeleted } = useDeletedEvent()
const { zapReplyThreshold } = useZap() const { zapReplyThreshold } = useZap()
const { showKinds, showKind1OPs, showKind1Replies, showKind1111 } = useKindFilter() const { showKinds, showKind1OPs, showKind1Replies, showKind1111 } = useKindFilter()
const hideReplies = useHideRepliesLikeMainFeed() const hideReplies = useHideRepliesLikeMainFeed()
@ -103,8 +106,14 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string
[searchQuery] [searchQuery]
) )
const filteredPins = useMemo(() => applySearch(pinEvents), [pinEvents, applySearch]) const filteredPins = useMemo(
const filteredRest = useMemo(() => applySearch(restTimeline), [restTimeline, applySearch]) () => 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]) const mergedDisplay = useMemo(() => [...filteredPins, ...filteredRest], [filteredPins, filteredRest])
@ -124,7 +133,8 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string
setIsRefreshing(true) setIsRefreshing(true)
refreshPins() refreshPins()
refreshTimeline() refreshTimeline()
}, [refreshPins, refreshTimeline]) void client.fetchDeletionEventsForPubkey(pubkey)
}, [refreshPins, refreshTimeline, pubkey])
useImperativeHandle(ref, () => ({ refresh: refreshAll }), [refreshAll]) useImperativeHandle(ref, () => ({ refresh: refreshAll }), [refreshAll])

38
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 { useEffect, useMemo, useRef, useState, useCallback } from 'react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import client from '@/services/client.service'
import { CALENDAR_EVENT_KINDS, ExtendedKind, FAST_READ_RELAY_URLS } from '@/constants' import { CALENDAR_EVENT_KINDS, ExtendedKind, FAST_READ_RELAY_URLS } from '@/constants'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
@ -81,7 +82,8 @@ async function getRelayGroups(pubkey: string): Promise<string[][]> {
function postProcessEvents( function postProcessEvents(
rawEvents: Event[], rawEvents: Event[],
filterPredicate: ((event: Event) => boolean) | undefined, filterPredicate: ((event: Event) => boolean) | undefined,
limit: number limit: number,
isEventDeleted: (event: Event) => boolean
) { ) {
const dedupMap = new Map<string, Event>() const dedupMap = new Map<string, Event>()
rawEvents.forEach((evt) => { 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) { if (filterPredicate) {
events = events.filter(filterPredicate) events = events.filter(filterPredicate)
} }
@ -105,12 +107,28 @@ export function useProfileTimeline({
limit = 200, limit = 200,
filterPredicate filterPredicate
}: UseProfileTimelineOptions): UseProfileTimelineResult { }: UseProfileTimelineOptions): UseProfileTimelineResult {
const { isEventDeleted, tombstoneEpoch } = useDeletedEvent()
const isEventDeletedRef = useRef(isEventDeleted)
isEventDeletedRef.current = isEventDeleted
const cachedEntry = useMemo(() => timelineCache.get(cacheKey), [cacheKey]) const cachedEntry = useMemo(() => timelineCache.get(cacheKey), [cacheKey])
const [events, setEvents] = useState<Event[]>(cachedEntry?.events ?? []) const [events, setEvents] = useState<Event[]>(cachedEntry?.events ?? [])
const [isLoading, setIsLoading] = useState(!cachedEntry) const [isLoading, setIsLoading] = useState(!cachedEntry)
const [refreshToken, setRefreshToken] = useState(0) const [refreshToken, setRefreshToken] = useState(0)
const subscriptionRef = useRef<() => void>(() => {}) 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(() => { useEffect(() => {
let cancelled = false let cancelled = false
@ -178,7 +196,12 @@ export function useProfileTimeline({
{ {
onEvents: (fetchedEvents) => { onEvents: (fetchedEvents) => {
if (cancelled) return if (cancelled) return
const processed = postProcessEvents(fetchedEvents as Event[], filterPredicate, limit) const processed = postProcessEvents(
fetchedEvents as Event[],
filterPredicate,
limit,
isEventDeletedRef.current
)
timelineCache.set(cacheKey, { timelineCache.set(cacheKey, {
events: processed, events: processed,
lastUpdated: Date.now() lastUpdated: Date.now()
@ -190,7 +213,12 @@ export function useProfileTimeline({
if (cancelled) return if (cancelled) return
setEvents((prevEvents) => { setEvents((prevEvents) => {
const combined = [evt as Event, ...prevEvents] const combined = [evt as Event, ...prevEvents]
const processed = postProcessEvents(combined, filterPredicate, limit) const processed = postProcessEvents(
combined,
filterPredicate,
limit,
isEventDeletedRef.current
)
timelineCache.set(cacheKey, { timelineCache.set(cacheKey, {
events: processed, events: processed,
lastUpdated: Date.now() lastUpdated: Date.now()

7
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
}

10
src/lib/event.ts

@ -190,6 +190,16 @@ export function getReplaceableCoordinateFromEvent(event: Event) {
return getReplaceableCoordinate(event.kind, event.pubkey, d) 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) { export function getNoteBech32Id(event: Event) {
const hints = client.getEventHints(event.id).slice(0, 2) const hints = client.getEventHints(event.id).slice(0, 2)
if (isReplaceableEvent(event.kind)) { if (isReplaceableEvent(event.kind)) {

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

27
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)
}

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

@ -11,11 +11,22 @@ import { cn } from '@/lib/utils'
import { isWebsocketUrl, normalizeUrl, simplifyUrl } from '@/lib/url' import { isWebsocketUrl, normalizeUrl, simplifyUrl } from '@/lib/url'
import { RefreshButton } from '@/components/RefreshButton' import { RefreshButton } from '@/components/RefreshButton'
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions'
import { useSmartRelayNavigation } from '@/PageManager' import { useSmartRelayNavigation } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import nip66Service from '@/services/nip66.service' import nip66Service from '@/services/nip66.service'
import { TPageRef } from '@/types' import { TPageRef } from '@/types'
import { ArrowRight, Compass, Plus } from 'lucide-react' 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 { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
@ -69,11 +80,17 @@ function normalizeHomeTab(restored: string): TExploreTabs {
const ExplorePage = forwardRef<TPageRef>((_, ref) => { const ExplorePage = forwardRef<TPageRef>((_, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey, relayList } = useNostr()
const [tab, setTab] = useState<TExploreTabs>('explore') const [tab, setTab] = useState<TExploreTabs>('explore')
const layoutRef = useRef<TPageRef>(null) const layoutRef = useRef<TPageRef>(null)
const [contentRefreshKey, setContentRefreshKey] = useState(0) 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( useImperativeHandle(
ref, ref,
@ -81,7 +98,7 @@ const ExplorePage = forwardRef<TPageRef>((_, ref) => {
scrollToTop: (behavior?: ScrollBehavior) => layoutRef.current?.scrollToTop(behavior), scrollToTop: (behavior?: ScrollBehavior) => layoutRef.current?.scrollToTop(behavior),
refresh: bumpExploreContent refresh: bumpExploreContent
}), }),
[] [bumpExploreContent]
) )
// Listen for tab restoration from PageManager // Listen for tab restoration from PageManager

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

@ -9,6 +9,7 @@ import { SimpleUsername } from '@/components/Username'
import { RefreshButton } from '@/components/RefreshButton' import { RefreshButton } from '@/components/RefreshButton'
import PrimaryPageLayout, { type TPrimaryPageLayoutRef } from '@/layouts/PrimaryPageLayout' import PrimaryPageLayout, { type TPrimaryPageLayoutRef } from '@/layouts/PrimaryPageLayout'
import { toProfile, toRelaySettings, toWallet } from '@/lib/link' import { toProfile, toRelaySettings, toWallet } from '@/lib/link'
import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
@ -21,19 +22,33 @@ import {
Wallet Wallet
} from 'lucide-react' } from 'lucide-react'
import { TPageRef } from '@/types' 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' import { useTranslation } from 'react-i18next'
const MePage = forwardRef<TPageRef>((_, ref) => { const MePage = forwardRef<TPageRef>((_, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { pubkey } = useNostr() const { pubkey, relayList } = useNostr()
const [loginDialogOpen, setLoginDialogOpen] = useState(false) const [loginDialogOpen, setLoginDialogOpen] = useState(false)
const [logoutDialogOpen, setLogoutDialogOpen] = useState(false) const [logoutDialogOpen, setLogoutDialogOpen] = useState(false)
const layoutRef = useRef<TPrimaryPageLayoutRef>(null) const layoutRef = useRef<TPrimaryPageLayoutRef>(null)
const [contentKey, setContentKey] = useState(0) 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( useImperativeHandle(
ref, ref,
@ -41,7 +56,7 @@ const MePage = forwardRef<TPageRef>((_, ref) => {
scrollToTop: (behavior?: ScrollBehavior) => layoutRef.current?.scrollToTop(behavior), scrollToTop: (behavior?: ScrollBehavior) => layoutRef.current?.scrollToTop(behavior),
refresh: bumpMe refresh: bumpMe
}), }),
[] [bumpMe]
) )
if (!pubkey) { if (!pubkey) {

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

@ -4,6 +4,7 @@ import PrimaryPageLayout, { type TPrimaryPageLayoutRef } from '@/layouts/Primary
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { DEFAULT_RSS_FEEDS } from '@/constants' import { DEFAULT_RSS_FEEDS } from '@/constants'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import rssFeedService from '@/services/rss-feed.service' import rssFeedService from '@/services/rss-feed.service'
import { Rss, Search } from 'lucide-react' import { Rss, Search } from 'lucide-react'
@ -13,11 +14,12 @@ import { useTranslation } from 'react-i18next'
const RssPage = forwardRef<TPageRef>((_, ref) => { const RssPage = forwardRef<TPageRef>((_, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey, rssFeedListEvent } = useNostr() const { pubkey, relayList, rssFeedListEvent } = useNostr()
const [rssRefreshKey, setRssRefreshKey] = useState(0) const [rssRefreshKey, setRssRefreshKey] = useState(0)
const layoutRef = useRef<TPrimaryPageLayoutRef>(null) const layoutRef = useRef<TPrimaryPageLayoutRef>(null)
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
void syncUserDeletionTombstones(pubkey, relayList)
let feedUrls: string[] = [] let feedUrls: string[] = []
if (pubkey && rssFeedListEvent) { if (pubkey && rssFeedListEvent) {
try { try {
@ -42,7 +44,7 @@ const RssPage = forwardRef<TPageRef>((_, ref) => {
) )
} }
setRssRefreshKey((k) => k + 1) setRssRefreshKey((k) => k + 1)
}, [pubkey, rssFeedListEvent]) }, [pubkey, relayList, rssFeedListEvent])
useImperativeHandle( useImperativeHandle(
ref, ref,

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

@ -3,7 +3,9 @@ import { RefreshButton } from '@/components/RefreshButton'
import SearchBar, { TSearchBarRef } from '@/components/SearchBar' import SearchBar, { TSearchBarRef } from '@/components/SearchBar'
import SearchResult from '@/components/SearchResult' import SearchResult from '@/components/SearchResult'
import PrimaryPageLayout, { TPrimaryPageLayoutRef } from '@/layouts/PrimaryPageLayout' import PrimaryPageLayout, { TPrimaryPageLayoutRef } from '@/layouts/PrimaryPageLayout'
import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions'
import { usePrimaryPage } from '@/PageManager' import { usePrimaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import { TPageRef, TSearchParams } from '@/types' import { TPageRef, TSearchParams } from '@/types'
import { BookOpen } from 'lucide-react' import { BookOpen } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@ -11,6 +13,7 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRe
const SearchPage = forwardRef<TPageRef>((_, ref) => { const SearchPage = forwardRef<TPageRef>((_, ref) => {
const { current, display } = usePrimaryPage() const { current, display } = usePrimaryPage()
const { pubkey, relayList } = useNostr()
const [input, setInput] = useState('') const [input, setInput] = useState('')
const [searchParams, setSearchParams] = useState<TSearchParams | null>(null) const [searchParams, setSearchParams] = useState<TSearchParams | null>(null)
const [resultRefreshKey, setResultRefreshKey] = useState(0) const [resultRefreshKey, setResultRefreshKey] = useState(0)
@ -18,7 +21,12 @@ const SearchPage = forwardRef<TPageRef>((_, ref) => {
const searchBarRef = useRef<TSearchBarRef>(null) const searchBarRef = useRef<TSearchBarRef>(null)
const layoutRef = useRef<TPrimaryPageLayoutRef>(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( useImperativeHandle(
ref, ref,

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

@ -5,7 +5,9 @@ import SearchResult from '@/components/SearchResult'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { toSearch } from '@/lib/link' import { toSearch } from '@/lib/link'
import { parseAdvancedSearch } from '@/lib/search-parser' import { parseAdvancedSearch } from '@/lib/search-parser'
import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions'
import { usePrimaryNoteView, useSecondaryPage } from '@/PageManager' import { usePrimaryNoteView, useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import { TSearchParams } from '@/types' import { TSearchParams } from '@/types'
import { BookOpen } from 'lucide-react' import { BookOpen } from 'lucide-react'
import { Button } from '@/components/ui/button' 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 SearchPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => {
const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { pubkey, relayList } = useNostr()
const [resultRefreshKey, setResultRefreshKey] = useState(0) 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(() => { useEffect(() => {
if (!hideTitlebar) { if (!hideTitlebar) {

63
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 { NostrEvent } from 'nostr-tools'
import { createContext, useCallback, useContext, useState } from 'react' import { createContext, useCallback, useContext, useEffect, useState } from 'react'
type TDeletedEventContext = { type TDeletedEventContext = {
addDeletedEvent: (event: NostrEvent) => void addDeletedEvent: (event: NostrEvent) => void
addDeletedEventId: (eventId: string) => void addDeletedEventId: (eventId: string) => void
isEventDeleted: (event: NostrEvent) => boolean isEventDeleted: (event: NostrEvent) => boolean
/** Bumps when tombstones are reloaded from IndexedDB (for list re-filtering). */
tombstoneEpoch: number
} }
const DeletedEventContext = createContext<TDeletedEventContext | undefined>(undefined) const DeletedEventContext = createContext<TDeletedEventContext | undefined>(undefined)
@ -19,30 +24,52 @@ export const useDeletedEvent = () => {
} }
export function DeletedEventProvider({ children }: { children: React.ReactNode }) { 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( const isEventDeleted = useCallback(
(event: NostrEvent) => { (event: NostrEvent) => isTombstoneKeyForEvent(event, tombstoneKeys),
return deletedEventKeys.has(getKey(event)) [tombstoneKeys]
},
[deletedEventKeys]
) )
const addDeletedEvent = (event: NostrEvent) => { const addDeletedEvent = useCallback((event: NostrEvent) => {
setDeletedEventKeys((prev) => new Set(prev).add(getKey(event))) const key = getKeyForDeletedLookup(event)
} setTombstoneKeys((prev) => new Set(prev).add(key))
setTombstoneEpoch((e) => e + 1)
}, [])
const addDeletedEventId = (eventId: string) => { const addDeletedEventId = useCallback((eventId: string) => {
setDeletedEventKeys((prev) => new Set(prev).add(eventId)) setTombstoneKeys((prev) => new Set(prev).add(eventId))
} setTombstoneEpoch((e) => e + 1)
}, [])
return ( return (
<DeletedEventContext.Provider value={{ addDeletedEvent, addDeletedEventId, isEventDeleted }}> <DeletedEventContext.Provider
value={{ addDeletedEvent, addDeletedEventId, isEventDeleted, tombstoneEpoch }}
>
{children} {children}
</DeletedEventContext.Provider> </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 {
} }
import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata' import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { dispatchTombstonesUpdated } from '@/lib/tombstone-events'
import { isValidPubkey, pubkeyToNpub } from '@/lib/pubkey' import { isValidPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { getPubkeysFromPTags, tagNameEquals } from '@/lib/tag' import { getPubkeysFromPTags, tagNameEquals } from '@/lib/tag'
import { isLocalNetworkUrl, normalizeUrl, simplifyUrl } from '@/lib/url' import { isLocalNetworkUrl, normalizeUrl, simplifyUrl } from '@/lib/url'
@ -1835,6 +1836,7 @@ class ClientService extends EventTarget {
if (removed > 0) { if (removed > 0) {
logger.info('[ClientService] Removed tombstoned events from cache', { count: removed }) logger.info('[ClientService] Removed tombstoned events from cache', { count: removed })
} }
dispatchTombstonesUpdated()
} }
private async addTombstoneEntriesFromDeletionEvent(deletionEvent: NEvent): Promise<void> { private async addTombstoneEntriesFromDeletionEvent(deletionEvent: NEvent): Promise<void> {
@ -1893,11 +1895,38 @@ class ClientService extends EventTarget {
if (removed > 0) { if (removed > 0) {
logger.info('[ClientService] Removed tombstoned events from cache', { count: removed }) logger.info('[ClientService] Removed tombstoned events from cache', { count: removed })
} }
dispatchTombstonesUpdated()
} catch (error) { } catch (error) {
logger.warn('[ClientService] Failed to fetch deletion events', { 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( async searchNpubsForMention(
query: string, query: string,
limit: number = 100, limit: number = 100,

Loading…
Cancel
Save