From 242213c9c5519bd43b0b6519f85c727ac971cd36 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Fri, 15 May 2026 14:34:39 +0200 Subject: [PATCH] follow and mute events, cont. --- .../NotificationThreadWatchButtons/index.tsx | 73 ++-- src/constants.ts | 1 - src/index.css | 3 +- src/lib/notification-thread-watch.ts | 42 +- src/lib/personal-list-mutations.ts | 9 +- .../primary/SpellsPage/useSpellsPageFeed.ts | 5 +- .../NotificationThreadWatchProvider.tsx | 390 ++++++++++-------- 7 files changed, 287 insertions(+), 236 deletions(-) diff --git a/src/components/NotificationThreadWatchButtons/index.tsx b/src/components/NotificationThreadWatchButtons/index.tsx index 612f1ea9..fa408b7b 100644 --- a/src/components/NotificationThreadWatchButtons/index.tsx +++ b/src/components/NotificationThreadWatchButtons/index.tsx @@ -6,62 +6,65 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { useNostr } from '@/providers/NostrProvider' -import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey' export default function NotificationThreadWatchButtons({ event }: { event: Event }) { const { t } = useTranslation() - const { pubkey } = useNostr() + const { pubkey, checkLogin } = useNostr() const watch = useNotificationThreadWatchOptional() const [busy, setBusy] = useState<'follow' | 'mute' | null>(null) + // Show for your own notes too (e.g. notifications feed): you may still want follow/mute on that anchor. if (!watch || !pubkey) return null - if (hexPubkeysEqual(event.pubkey, normalizeHexPubkey(pubkey))) return null const followed = watch.isFollowedForNotifications(event) const muted = watch.isMutedForNotifications(event) - const onFollow = async (e: React.MouseEvent) => { + const onFollow = (e: React.MouseEvent) => { e.stopPropagation() - setBusy('follow') - try { - if (followed) { - const ok = await watch.unfollowThreadForNotifications(event) - if (ok) { - toast.success(t('Unfollowed thread notifications')) + void checkLogin(async () => { + setBusy('follow') + try { + if (followed) { + const ok = await watch.unfollowThreadForNotifications(event) + if (ok) { + toast.success(t('Unfollowed thread notifications')) + } else { + toast.error(t('Thread notification list update failed')) + } } else { - toast.error(t('Thread notification list update failed')) + await watch.followThreadForNotifications(event) + toast.success(t('Following thread for notifications')) } - } else { - await watch.followThreadForNotifications(event) - toast.success(t('Following thread for notifications')) + } catch (err) { + toast.error(t('Thread notification list update failed') + ': ' + (err as Error).message) + } finally { + setBusy(null) } - } catch (err) { - toast.error(t('Thread notification list update failed') + ': ' + (err as Error).message) - } finally { - setBusy(null) - } + }) } - const onMute = async (e: React.MouseEvent) => { + const onMute = (e: React.MouseEvent) => { e.stopPropagation() - setBusy('mute') - try { - if (muted) { - const ok = await watch.unmuteThreadForNotifications(event) - if (ok) { - toast.success(t('Unmuted thread notifications')) + void checkLogin(async () => { + setBusy('mute') + try { + if (muted) { + const ok = await watch.unmuteThreadForNotifications(event) + if (ok) { + toast.success(t('Unmuted thread notifications')) + } else { + toast.error(t('Thread notification list update failed')) + } } else { - toast.error(t('Thread notification list update failed')) + await watch.muteThreadForNotifications(event) + toast.success(t('Muted thread for notifications')) } - } else { - await watch.muteThreadForNotifications(event) - toast.success(t('Muted thread for notifications')) + } catch (err) { + toast.error(t('Thread notification list update failed') + ': ' + (err as Error).message) + } finally { + setBusy(null) } - } catch (err) { - toast.error(t('Thread notification list update failed') + ': ' + (err as Error).message) - } finally { - setBusy(null) - } + }) } return ( diff --git a/src/constants.ts b/src/constants.ts index c198521a..e6571136 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -446,7 +446,6 @@ export const SOCIAL_KIND_BLOCKED_RELAY_URLS = [ */ export const E_TAG_FILTER_BLOCKED_RELAY_URLS = [ 'wss://nostr.v0l.io', - 'wss://nostr.sovbit.host' ] // Optimized relay list for read operations (includes aggregator) diff --git a/src/index.css b/src/index.css index dcff5b69..825ba904 100644 --- a/src/index.css +++ b/src/index.css @@ -210,7 +210,8 @@ --muted-foreground: 140 8% 72%; --accent: 150 14% 18%; --accent-foreground: 100 10% 95%; - --destructive: 0 62.8% 30.6%; + /* Was ~31% L — too dark on popovers/menus; use ~60% L for readable red labels (Report, Mute, …). */ + --destructive: 0 72% 60%; --destructive-foreground: 0 0% 98%; --border: 150 12% 22%; --input: 150 12% 18%; diff --git a/src/lib/notification-thread-watch.ts b/src/lib/notification-thread-watch.ts index 232c3c66..c08ac667 100644 --- a/src/lib/notification-thread-watch.ts +++ b/src/lib/notification-thread-watch.ts @@ -1,8 +1,10 @@ import { ExtendedKind } from '@/constants' import { getParentEventHexId, + getReplaceableCoordinateFromEvent, getRootEventHexId, isNip18RepostKind, + isReplaceableEvent, isReplyNoteEvent, normalizeReplaceableCoordinateString, resolveDeclaredThreadRootEventHex @@ -86,39 +88,17 @@ export function threadWatchMatchesRefs( return false } -function threadWatchListTagMatchesEvent(tag: string[], event: Event): boolean { - const k = tag[0] - if ((k === 'e' || k === 'E') && tag[1] && /^[0-9a-f]{64}$/i.test(tag[1])) { - const id = tag[1].toLowerCase() - const refs: TThreadWatchListRefs = { eHexLower: new Set([id]), aCoordLower: new Set() } - return threadWatchMatchesRefs(event, refs) - } - if ((k === 'a' || k === 'A') && tag[1]) { - const n = normalizeReplaceableCoordinateString(tag[1]) - if (!n) return false - const refs: TThreadWatchListRefs = { eHexLower: new Set(), aCoordLower: new Set([n]) } - return threadWatchMatchesRefs(event, refs) - } - return false -} - /** - * Drops every `e` / `a` ref that applies to `event` (same rules as {@link threadWatchMatchesRefs}), - * so toggling off works when the list stores a thread root id but the UI row is a reply (or vice versa). + * True if the list contains this **exact** event (`e` = {@link Event.id}, or `a` = replaceable coordinate). + * Use for per-note bell UI and for writing list updates. For “any reply in this thread”, use {@link threadWatchMatchesRefs}. */ -export function listTagsAfterRemovingThreadWatchMatches( - listTags: string[][], - event: Event -): string[][] | null { - let changed = false - const next = listTags.filter((t) => { - if (threadWatchListTagMatchesEvent(t, event)) { - changed = true - return false - } - return true - }) - return changed ? next : null +export function eventHasExactNotificationThreadWatchRef(event: Event, refs: TThreadWatchListRefs): boolean { + if (!refs.eHexLower.size && !refs.aCoordLower.size) return false + if (isReplaceableEvent(event.kind)) { + const n = normalizeReplaceableCoordinateString(getReplaceableCoordinateFromEvent(event)) + return !!n && refs.aCoordLower.has(n) + } + return refs.eHexLower.has(event.id.toLowerCase()) } /** Replies, reactions, reposts, zaps-on-note, comments, poll votes, highlights — not plain top-level notes. */ diff --git a/src/lib/personal-list-mutations.ts b/src/lib/personal-list-mutations.ts index 8269e305..0df11727 100644 --- a/src/lib/personal-list-mutations.ts +++ b/src/lib/personal-list-mutations.ts @@ -1,3 +1,4 @@ +import { normalizeReplaceableCoordinateString } from '@/lib/event' import { nip19 } from 'nostr-tools' /** Decoded target for one bookmark/pin list entry (NIP-19 nevent/note or naddr). */ @@ -32,8 +33,12 @@ export function bookmarkListTagsAfterRemovingRef( ): string[][] | null { if (!ref.eIdLower && !ref.aCoordLower) return null const next = tags.filter((tag) => { - if (ref.eIdLower && tag[0] === 'e' && tag[1]?.toLowerCase() === ref.eIdLower) return false - if (ref.aCoordLower && tag[0] === 'a' && tag[1]?.toLowerCase() === ref.aCoordLower) return false + const k = tag[0] + if (ref.eIdLower && (k === 'e' || k === 'E') && tag[1]?.toLowerCase() === ref.eIdLower) return false + if (ref.aCoordLower && (k === 'a' || k === 'A') && tag[1]) { + const n = normalizeReplaceableCoordinateString(tag[1]) + if (n === ref.aCoordLower) return false + } return true }) return next.length === tags.length ? null : next diff --git a/src/pages/primary/SpellsPage/useSpellsPageFeed.ts b/src/pages/primary/SpellsPage/useSpellsPageFeed.ts index aa618d26..68a4d7ff 100644 --- a/src/pages/primary/SpellsPage/useSpellsPageFeed.ts +++ b/src/pages/primary/SpellsPage/useSpellsPageFeed.ts @@ -579,6 +579,9 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) { const followRefs = parseThreadWatchListRefs(notificationEventsIFollowListEvent ?? null) const mutedRefs = parseThreadWatchListRefs(notificationEventsIMutedListEvent ?? null) + // Never list your own authored events in this account's notifications (`#p` REQ still returns self-replies, self-`#p`, etc.). + if (hexPubkeysEqual(evt.pubkey, pk)) return true + if ( threadWatchMatchesRefs(evt, mutedRefs) && isNotificationThreadInteractionEvent(evt) @@ -588,8 +591,6 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) { if (isUserInEventMentions(evt, pk)) return false - if (hexPubkeysEqual(evt.pubkey, pk)) return false - if ( threadWatchMatchesRefs(evt, followRefs) && isNotificationThreadInteractionEvent(evt) diff --git a/src/providers/NotificationThreadWatchProvider.tsx b/src/providers/NotificationThreadWatchProvider.tsx index 66290d39..c33b61cb 100644 --- a/src/providers/NotificationThreadWatchProvider.tsx +++ b/src/providers/NotificationThreadWatchProvider.tsx @@ -8,15 +8,23 @@ import { } from '@/lib/personal-list-mutations' import { fetchLatestReplaceableListEvent } from '@/lib/replaceable-list-latest' import { - listTagsAfterRemovingThreadWatchMatches, - parseThreadWatchListRefs, - threadWatchMatchesRefs + eventHasExactNotificationThreadWatchRef, + parseThreadWatchListRefs } from '@/lib/notification-thread-watch' import logger from '@/lib/logger' import { ExtendedKind } from '@/constants' import indexedDb from '@/services/indexed-db.service' import type { Event } from 'nostr-tools' -import { useCallback, useContext, useEffect, useMemo, useState, createContext, type ReactNode } from 'react' +import { + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + createContext, + type ReactNode +} from 'react' import { useNostr } from '@/providers/NostrProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' @@ -79,10 +87,15 @@ function mergeTagsPreservingMeta(baseTags: string[][], refTags: string[][]): str } export function NotificationThreadWatchProvider({ children }: { children: ReactNode }) { - const { pubkey: accountPubkey, publish } = useNostr() + const { pubkey: accountPubkey, publish, signEvent } = useNostr() const { favoriteRelays, blockedRelays } = useFavoriteRelays() const [eventsIFollowListEvent, setEventsIFollowListEvent] = useState(null) const [eventsIMutedListEvent, setEventsIMutedListEvent] = useState(null) + /** Same as state, updated during render so async handlers never read a stale list before effects run. */ + const eventsIFollowListEventRef = useRef(null) + const eventsIMutedListEventRef = useRef(null) + eventsIFollowListEventRef.current = eventsIFollowListEvent + eventsIMutedListEventRef.current = eventsIMutedListEvent const buildComprehensiveRelayList = useCallback(async () => { if (!accountPubkey) return [] as string[] @@ -134,14 +147,10 @@ export function NotificationThreadWatchProvider({ children }: { children: ReactN } const f = pick(remoteFollow, idbFollow ?? undefined) const m = pick(remoteMuted, idbMuted ?? undefined) - if (f) { - await indexedDb.putReplaceableEvent(f) - setEventsIFollowListEvent(f) - } - if (m) { - await indexedDb.putReplaceableEvent(m) - setEventsIMutedListEvent(m) - } + if (f) await indexedDb.putReplaceableEvent(f) + if (m) await indexedDb.putReplaceableEvent(m) + setEventsIFollowListEvent(f ?? null) + setEventsIMutedListEvent(m ?? null) }, [accountPubkey, buildComprehensiveRelayList, hydrateFromStorage]) useEffect(() => { @@ -174,17 +183,17 @@ export function NotificationThreadWatchProvider({ children }: { children: ReactN ) const isFollowedForNotifications = useCallback( - (event: Event) => threadWatchMatchesRefs(event, followRefs), + (event: Event) => eventHasExactNotificationThreadWatchRef(event, followRefs), [followRefs] ) const isMutedForNotifications = useCallback( - (event: Event) => threadWatchMatchesRefs(event, mutedRefs), + (event: Event) => eventHasExactNotificationThreadWatchRef(event, mutedRefs), [mutedRefs] ) - const publishList = useCallback( + const relayPublishList = useCallback( async (kind: number, nextTags: string[][], content: string) => { - if (!accountPubkey) return + if (!accountPubkey) throw new Error('Not logged in') const comprehensiveRelays = await buildComprehensiveRelayList() const draft = createReplaceablePersonalListDraftEvent(kind, nextTags, content) const ev = await publish(draft, { specifiedRelayUrls: comprehensiveRelays }) @@ -194,213 +203,266 @@ export function NotificationThreadWatchProvider({ children }: { children: ReactN } else { setEventsIMutedListEvent(stored) } + return stored }, [accountPubkey, buildComprehensiveRelayList, publish] ) + const signApplyListLocal = useCallback( + async (kind: number, nextTags: string[][], content: string): Promise => { + const draft = createReplaceablePersonalListDraftEvent(kind, nextTags, content) + const ev = await signEvent(draft) + if (kind === ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST) { + setEventsIFollowListEvent(ev) + } else { + setEventsIMutedListEvent(ev) + } + void indexedDb.putReplaceableEvent(ev).catch((err) => + logger.warn('NotificationThreadWatchProvider: optimistic IndexedDB write failed', { err }) + ) + return ev + }, + [signEvent] + ) + + const restoreListSnapshots = useCallback(async (snapFollow: Event | null, snapMuted: Event | null) => { + setEventsIFollowListEvent(snapFollow) + setEventsIMutedListEvent(snapMuted) + try { + await Promise.all([ + snapFollow ? indexedDb.putReplaceableEvent(snapFollow) : Promise.resolve(), + snapMuted ? indexedDb.putReplaceableEvent(snapMuted) : Promise.resolve() + ]) + } catch (err) { + logger.warn('NotificationThreadWatchProvider: rollback IndexedDB write failed', { err }) + } + }, []) + const followThreadForNotifications = useCallback( async (event: Event) => { if (!accountPubkey) return - const comprehensiveRelays = await buildComprehensiveRelayList() + const prevF = eventsIFollowListEventRef.current + const prevM = eventsIMutedListEventRef.current + const snapF = prevF + const snapM = prevM + const refTag = isReplaceableEvent(event.kind) ? buildATag(event) : buildETag(event.id, event.pubkey) - let followEv = - (await fetchLatestReplaceableListEvent( - accountPubkey, - ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST, - comprehensiveRelays - )) ?? null - if (!followEv) { - followEv = - (await indexedDb.getReplaceableEvent( - accountPubkey.trim().toLowerCase(), - ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST - )) ?? null - } - let mutedEv = - (await fetchLatestReplaceableListEvent( - accountPubkey, - ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST, - comprehensiveRelays - )) ?? null - if (!mutedEv) { - mutedEv = - (await indexedDb.getReplaceableEvent( - accountPubkey.trim().toLowerCase(), - ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST - )) ?? null - } + const ref = refKeyForEvent(event) - const mutedStripped = mutedEv ? listTagsAfterRemovingThreadWatchMatches(mutedEv.tags, event) : null - if (mutedStripped) { - await publishList(ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST, mutedStripped, mutedEv.content) - } + const mutedStripped = prevM ? listTagsWithoutRef(prevM.tags, ref) : null - const curTags = followEv?.tags ?? [] - const curFollowRefs = parseThreadWatchListRefs(followEv) - if (threadWatchMatchesRefs(event, curFollowRefs)) { + const curFollowRefs = parseThreadWatchListRefs(prevF) + if (eventHasExactNotificationThreadWatchRef(event, curFollowRefs)) { + if (prevF) { + try { + await indexedDb.putReplaceableEvent(prevF) + } catch { + /* ignore */ + } + setEventsIFollowListEvent(prevF) + } return } - const next = mergeTagsPreservingMeta(curTags, [refTag]) - await publishList(ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST, next, followEv?.content ?? '') - logger.component('NotificationThreadWatchProvider', 'follow thread for notifications', { - kind: event.kind - }) + + const nextFollowTags = mergeTagsPreservingMeta(prevF?.tags ?? [], [refTag]) + const followContent = prevF?.content ?? '' + + try { + if (mutedStripped && prevM) { + await signApplyListLocal( + ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST, + mutedStripped, + prevM.content + ) + } + await signApplyListLocal( + ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST, + nextFollowTags, + followContent + ) + + if (mutedStripped && prevM) { + await relayPublishList( + ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST, + mutedStripped, + prevM.content + ) + } + await relayPublishList( + ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST, + nextFollowTags, + followContent + ) + logger.component('NotificationThreadWatchProvider', 'follow thread for notifications', { + kind: event.kind + }) + } catch (e) { + await restoreListSnapshots(snapF, snapM) + throw e + } }, - [accountPubkey, buildComprehensiveRelayList, publishList] + [accountPubkey, relayPublishList, restoreListSnapshots, signApplyListLocal] ) const muteThreadForNotifications = useCallback( async (event: Event) => { if (!accountPubkey) return - const comprehensiveRelays = await buildComprehensiveRelayList() + const prevF = eventsIFollowListEventRef.current + const prevM = eventsIMutedListEventRef.current + const snapF = prevF + const snapM = prevM + const refTag = isReplaceableEvent(event.kind) ? buildATag(event) : buildETag(event.id, event.pubkey) - let mutedEv = - (await fetchLatestReplaceableListEvent( - accountPubkey, - ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST, - comprehensiveRelays - )) ?? null - if (!mutedEv) { - mutedEv = - (await indexedDb.getReplaceableEvent( - accountPubkey.trim().toLowerCase(), - ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST - )) ?? null - } - let followEv = - (await fetchLatestReplaceableListEvent( - accountPubkey, - ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST, - comprehensiveRelays - )) ?? null - if (!followEv) { - followEv = - (await indexedDb.getReplaceableEvent( - accountPubkey.trim().toLowerCase(), - ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST - )) ?? null - } + const ref = refKeyForEvent(event) - const followStripped = followEv ? listTagsAfterRemovingThreadWatchMatches(followEv.tags, event) : null - if (followStripped) { - await publishList(ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST, followStripped, followEv.content) - } + const followStripped = prevF ? listTagsWithoutRef(prevF.tags, ref) : null - const curTags = mutedEv?.tags ?? [] - const curMutedRefs = parseThreadWatchListRefs(mutedEv) - if (threadWatchMatchesRefs(event, curMutedRefs)) { + const curMutedRefs = parseThreadWatchListRefs(prevM) + if (eventHasExactNotificationThreadWatchRef(event, curMutedRefs)) { + if (prevM) { + try { + await indexedDb.putReplaceableEvent(prevM) + } catch { + /* ignore */ + } + setEventsIMutedListEvent(prevM) + } return } - const next = mergeTagsPreservingMeta(curTags, [refTag]) - await publishList(ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST, next, mutedEv?.content ?? '') + + const nextMutedTags = mergeTagsPreservingMeta(prevM?.tags ?? [], [refTag]) + const mutedContent = prevM?.content ?? '' + + try { + if (followStripped && prevF) { + await signApplyListLocal( + ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST, + followStripped, + prevF.content + ) + } + await signApplyListLocal( + ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST, + nextMutedTags, + mutedContent + ) + + if (followStripped && prevF) { + await relayPublishList( + ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST, + followStripped, + prevF.content + ) + } + await relayPublishList( + ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST, + nextMutedTags, + mutedContent + ) + } catch (e) { + await restoreListSnapshots(snapF, snapM) + throw e + } }, - [accountPubkey, buildComprehensiveRelayList, publishList] + [accountPubkey, relayPublishList, restoreListSnapshots, signApplyListLocal] ) const unfollowThreadForNotifications = useCallback( async (event: Event): Promise => { if (!accountPubkey) return false - const comprehensiveRelays = await buildComprehensiveRelayList() - let followEv = - (await fetchLatestReplaceableListEvent( - accountPubkey, - ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST, - comprehensiveRelays - )) ?? null - if (!followEv) { - followEv = - (await indexedDb.getReplaceableEvent( - accountPubkey.trim().toLowerCase(), - ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST - )) ?? null - } - if (!followEv) return false - const next = listTagsAfterRemovingThreadWatchMatches(followEv.tags, event) + const pk = accountPubkey.trim().toLowerCase() + let prevF = + eventsIFollowListEventRef.current ?? + (await indexedDb.getReplaceableEvent(pk, ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST)) + if (!prevF) return false + const next = listTagsWithoutRef(prevF.tags, refKeyForEvent(event)) if (!next) return false - await publishList(ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST, next, followEv.content) - return true + const snapF = prevF + const snapM = eventsIMutedListEventRef.current + try { + await signApplyListLocal(ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST, next, prevF.content) + await relayPublishList(ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST, next, prevF.content) + return true + } catch (e) { + await restoreListSnapshots(snapF, snapM) + throw e + } }, - [accountPubkey, buildComprehensiveRelayList, publishList] + [accountPubkey, relayPublishList, restoreListSnapshots, signApplyListLocal] ) const unmuteThreadForNotifications = useCallback( async (event: Event): Promise => { if (!accountPubkey) return false - const comprehensiveRelays = await buildComprehensiveRelayList() - let mutedEv = - (await fetchLatestReplaceableListEvent( - accountPubkey, - ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST, - comprehensiveRelays - )) ?? null - if (!mutedEv) { - mutedEv = - (await indexedDb.getReplaceableEvent( - accountPubkey.trim().toLowerCase(), - ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST - )) ?? null - } - if (!mutedEv) return false - const next = listTagsAfterRemovingThreadWatchMatches(mutedEv.tags, event) + const pk = accountPubkey.trim().toLowerCase() + let prevM = + eventsIMutedListEventRef.current ?? + (await indexedDb.getReplaceableEvent(pk, ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST)) + if (!prevM) return false + const next = listTagsWithoutRef(prevM.tags, refKeyForEvent(event)) if (!next) return false - await publishList(ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST, next, mutedEv.content) - return true + const snapF = eventsIFollowListEventRef.current + const snapM = prevM + try { + await signApplyListLocal(ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST, next, prevM.content) + await relayPublishList(ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST, next, prevM.content) + return true + } catch (e) { + await restoreListSnapshots(snapF, snapM) + throw e + } }, - [accountPubkey, buildComprehensiveRelayList, publishList] + [accountPubkey, relayPublishList, restoreListSnapshots, signApplyListLocal] ) const removeFollowRefByBech32 = useCallback( async (bech32Id: string): Promise => { const ref = decodePersonalListBech32Ref(bech32Id) if (!ref || !accountPubkey) return false - const comprehensiveRelays = await buildComprehensiveRelayList() + const pk = accountPubkey.trim().toLowerCase() let followEv = - (await fetchLatestReplaceableListEvent( - accountPubkey, - ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST, - comprehensiveRelays - )) ?? null - if (!followEv) { - followEv = - (await indexedDb.getReplaceableEvent( - accountPubkey.trim().toLowerCase(), - ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST - )) ?? null - } + eventsIFollowListEventRef.current ?? + (await indexedDb.getReplaceableEvent(pk, ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST)) if (!followEv) return false const next = listTagsWithoutRef(followEv.tags, ref) if (!next) return false - await publishList(ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST, next, followEv.content) - return true + const snapF = followEv + const snapM = eventsIMutedListEventRef.current + try { + await signApplyListLocal(ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST, next, followEv.content) + await relayPublishList(ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST, next, followEv.content) + return true + } catch (e) { + await restoreListSnapshots(snapF, snapM) + throw e + } }, - [accountPubkey, buildComprehensiveRelayList, publishList] + [accountPubkey, relayPublishList, restoreListSnapshots, signApplyListLocal] ) const removeMuteRefByBech32 = useCallback( async (bech32Id: string): Promise => { const ref = decodePersonalListBech32Ref(bech32Id) if (!ref || !accountPubkey) return false - const comprehensiveRelays = await buildComprehensiveRelayList() + const pk = accountPubkey.trim().toLowerCase() let mutedEv = - (await fetchLatestReplaceableListEvent( - accountPubkey, - ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST, - comprehensiveRelays - )) ?? null - if (!mutedEv) { - mutedEv = - (await indexedDb.getReplaceableEvent( - accountPubkey.trim().toLowerCase(), - ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST - )) ?? null - } + eventsIMutedListEventRef.current ?? + (await indexedDb.getReplaceableEvent(pk, ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST)) if (!mutedEv) return false const next = listTagsWithoutRef(mutedEv.tags, ref) if (!next) return false - await publishList(ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST, next, mutedEv.content) - return true + const snapF = eventsIFollowListEventRef.current + const snapM = mutedEv + try { + await signApplyListLocal(ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST, next, mutedEv.content) + await relayPublishList(ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST, next, mutedEv.content) + return true + } catch (e) { + await restoreListSnapshots(snapF, snapM) + throw e + } }, - [accountPubkey, buildComprehensiveRelayList, publishList] + [accountPubkey, relayPublishList, restoreListSnapshots, signApplyListLocal] ) const value = useMemo(