From 79ec4eef351b90883741ae76fe055e75fde4ed03 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 26 Mar 2026 15:16:23 +0100 Subject: [PATCH] bug-fixes --- src/components/InviteePicker/index.tsx | 7 + src/components/Note/ZapPoll.tsx | 14 +- .../PostEditor/PostRelaySelector.tsx | 33 +- .../Profile/ProfileBadgeDetailDialog.tsx | 16 +- .../Profile/ProfileHeaderInteractions.tsx | 4 +- .../Profile/ProfileInteractionsAccordion.tsx | 13 +- src/components/ReplyNoteList/index.tsx | 119 +++++-- src/hooks/useProfileBadges.tsx | 59 +++- src/hooks/useProfileFollowPacks.tsx | 60 ++-- src/hooks/useProfileInteractions.tsx | 73 +++- src/hooks/useProfileRelayUrls.tsx | 34 +- src/hooks/useProfileReports.tsx | 43 ++- src/i18n/locales/de.ts | 2 + src/i18n/locales/en.ts | 2 + src/lib/event.ts | 5 + src/lib/profile-accordion-session-cache.ts | 27 +- src/lib/pubkey.ts | 8 + src/pages/primary/SpellsPage/index.tsx | 14 +- .../FollowSetsSettingsPage/index.tsx | 100 +++--- src/providers/FavoriteRelaysProvider.tsx | 327 ++++++++++-------- src/providers/NostrProvider/index.tsx | 7 +- src/services/client.service.ts | 32 +- src/services/indexed-db.service.ts | 16 +- 23 files changed, 681 insertions(+), 334 deletions(-) diff --git a/src/components/InviteePicker/index.tsx b/src/components/InviteePicker/index.tsx index 6bf979e0..f8422f8f 100644 --- a/src/components/InviteePicker/index.tsx +++ b/src/components/InviteePicker/index.tsx @@ -1,5 +1,6 @@ import { Input } from '@/components/ui/input' import { useSearchProfiles } from '@/hooks' +import { inviteInputToHexPubkey } from '@/lib/pubkey' import { cn } from '@/lib/utils' import { useNostr } from '@/providers/NostrProvider' import { X } from 'lucide-react' @@ -89,6 +90,12 @@ export function InviteePicker({ type="text" value={search} onChange={(e) => setSearch(e.target.value)} + onKeyDown={(e) => { + if (e.key !== 'Enter') return + e.preventDefault() + const pk = inviteInputToHexPubkey(search) + if (pk) addInvitee(pk) + }} placeholder={placeholder ?? t('Search by name or npub…')} className="mt-1" autoComplete="off" diff --git a/src/components/Note/ZapPoll.tsx b/src/components/Note/ZapPoll.tsx index 5f5a3afe..dde527c3 100644 --- a/src/components/Note/ZapPoll.tsx +++ b/src/components/Note/ZapPoll.tsx @@ -78,6 +78,18 @@ export default function ZapPoll({ !!meta && (closed || viewerZapped || event.pubkey === pubkey || tallyRevealed) + /** When results are visible, list options by total sats (largest first). */ + const optionsDisplayOrder = useMemo(() => { + if (!meta) return [] + if (!showTally || !tally) return meta.options + return [...meta.options].sort((a, b) => { + const sa = tally.satsByOption.get(a.index) ?? 0 + const sb = tally.satsByOption.get(b.index) ?? 0 + if (sb !== sa) return sb - sa + return a.index - b.index + }) + }, [meta, showTally, tally]) + const satsBounds = useMemo(() => { if (!meta) return { min: 1, max: undefined as number | undefined } return { @@ -184,7 +196,7 @@ export default function ZapPoll({ )}
- {meta.options.map((opt) => { + {optionsDisplayOrder.map((opt) => { const satsOpt = tally?.satsByOption.get(opt.index) ?? 0 const pct = tally && tally.totalSats > 0 ? (100 * satsOpt) / tally.totalSats : 0 const counts = tally?.receiptCountByOption.get(opt.index) ?? 0 diff --git a/src/components/PostEditor/PostRelaySelector.tsx b/src/components/PostEditor/PostRelaySelector.tsx index ebe6a390..b15a4753 100644 --- a/src/components/PostEditor/PostRelaySelector.tsx +++ b/src/components/PostEditor/PostRelaySelector.tsx @@ -1,4 +1,9 @@ -import { ExtendedKind, isSocialKindBlockedKind, SOCIAL_KIND_BLOCKED_RELAY_URLS } from '@/constants' +import { + ExtendedKind, + isSocialKindBlockedKind, + MAX_PUBLISH_RELAYS, + SOCIAL_KIND_BLOCKED_RELAY_URLS +} from '@/constants' import { NOSTR_URI_FOR_REPLY_PUBKEYS_REGEX } from '@/lib/content-patterns' import { simplifyUrl, isLocalNetworkUrl, normalizeUrl } from '@/lib/url' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' @@ -438,9 +443,17 @@ export default function PostRelaySelector({
-
+
{t('Select relays')} {description} + {selectedRelayUrls.length >= MAX_PUBLISH_RELAYS && ( + + {t('Publish relay cap hint', { + max: MAX_PUBLISH_RELAYS, + selected: selectedRelayUrls.length + })} + + )}
@@ -471,9 +484,19 @@ export default function PostRelaySelector({ -
- {t('Select relays')} - {description} +
+
+ {t('Select relays')} + {description} +
+ {selectedRelayUrls.length >= MAX_PUBLISH_RELAYS && ( + + {t('Publish relay cap hint', { + max: MAX_PUBLISH_RELAYS, + selected: selectedRelayUrls.length + })} + + )}
{content} diff --git a/src/components/Profile/ProfileBadgeDetailDialog.tsx b/src/components/Profile/ProfileBadgeDetailDialog.tsx index 13d8278e..7e6bf61e 100644 --- a/src/components/Profile/ProfileBadgeDetailDialog.tsx +++ b/src/components/Profile/ProfileBadgeDetailDialog.tsx @@ -39,6 +39,11 @@ export default function ProfileBadgeDetailDialog({ }) { const { t } = useTranslation() const { push } = useSecondaryPage() + /** Secondary panel is below dialog z-index; close modal before navigating. */ + const pushSecondaryAndClose = (path: string) => { + onOpenChange(false) + push(path) + } const [recipientPubkeys, setRecipientPubkeys] = useState([]) const [recipientsLoading, setRecipientsLoading] = useState(false) const [recipientsError, setRecipientsError] = useState(false) @@ -141,7 +146,7 @@ export default function ProfileBadgeDetailDialog({
- diff --git a/src/components/Profile/ProfileHeaderInteractions.tsx b/src/components/Profile/ProfileHeaderInteractions.tsx index c4830d20..caf1835e 100644 --- a/src/components/Profile/ProfileHeaderInteractions.tsx +++ b/src/components/Profile/ProfileHeaderInteractions.tsx @@ -283,14 +283,14 @@ export default function ProfileHeaderInteractions({ return (
-
+
{displayZaps.map((item) => ( ))}
-
+
{displayReactions.map((item) => ( ))} diff --git a/src/components/Profile/ProfileInteractionsAccordion.tsx b/src/components/Profile/ProfileInteractionsAccordion.tsx index ae3d27d6..1b6cf581 100644 --- a/src/components/Profile/ProfileInteractionsAccordion.tsx +++ b/src/components/Profile/ProfileInteractionsAccordion.tsx @@ -3,7 +3,7 @@ import { Skeleton } from '@/components/ui/skeleton' import { ChevronDown } from 'lucide-react' import { useTranslation } from 'react-i18next' import { cn } from '@/lib/utils' -import { useEffect } from 'react' +import { useEffect, useRef } from 'react' import { useProfileRelayUrls } from '@/hooks/useProfileRelayUrls' import { useProfileInteractions } from '@/hooks/useProfileInteractions' import { useProfileBadges } from '@/hooks/useProfileBadges' @@ -36,6 +36,9 @@ function ProfileInteractionsContent({ const { packs, loading: followPacksLoading, refresh: refreshFollowPacks } = useProfileFollowPacks(pubkey, relayUrls) const { reports, loading: reportsLoading, refresh: refreshReports } = useProfileReports(pubkey, viewerPubkey) + const onRefreshReadyRef = useRef(onRefreshReady) + onRefreshReadyRef.current = onRefreshReady + useEffect(() => { const doRefresh = () => { void (async () => { @@ -46,9 +49,11 @@ function ProfileInteractionsContent({ refreshReports() })() } - onRefreshReady?.(doRefresh) - return () => { onRefreshReady?.(null) } - }, [refreshRelayUrls, refresh, refreshBadges, refreshFollowPacks, refreshReports, onRefreshReady]) + onRefreshReadyRef.current?.(doRefresh) + return () => { + onRefreshReadyRef.current?.(null) + } + }, [refreshRelayUrls, refresh, refreshBadges, refreshFollowPacks, refreshReports]) return ( { + const sa = getZapInfoFromEvent(a)?.amount ?? 0 + const sb = getZapInfoFromEvent(b)?.amount ?? 0 + if (sb !== sa) return sb - sa + return b.created_at - a.created_at + }) +} + +function replyFeedZapsFirst(sortedNonZapReplies: NEvent[], zaps: NEvent[]) { + return [...sortZapReceiptsBySatsDesc(zaps), ...sortedNonZapReplies] +} + function ReplyNoteList({ index, event, @@ -205,44 +230,61 @@ function ReplyNoteList({ - // Apply sorting based on the sort parameter + const { zaps, nonZaps } = partitionZapReceipts(replyEvents) + + // Sort notes/comments; zap receipts (9735) are always listed first, largest sats → smallest switch (sort) { case 'oldest': - return replyEvents.sort((a, b) => a.created_at - b.created_at) + return replyFeedZapsFirst( + [...nonZaps].sort((a, b) => a.created_at - b.created_at), + zaps + ) case 'newest': - return replyEvents.sort((a, b) => b.created_at - a.created_at) + return replyFeedZapsFirst( + [...nonZaps].sort((a, b) => b.created_at - a.created_at), + zaps + ) case 'top': - // Sort by vote score (upvotes - downvotes), then by newest if tied - return replyEvents.sort((a, b) => { - const scoreA = getReplyVoteScore(a) - const scoreB = getReplyVoteScore(b) - if (scoreA !== scoreB) { - return scoreB - scoreA // Higher scores first - } - return b.created_at - a.created_at // Newest first if tied - }) + return replyFeedZapsFirst( + [...nonZaps].sort((a, b) => { + const scoreA = getReplyVoteScore(a) + const scoreB = getReplyVoteScore(b) + if (scoreA !== scoreB) { + return scoreB - scoreA + } + return b.created_at - a.created_at + }), + zaps + ) case 'controversial': - // Sort by controversy score (min of upvotes and downvotes), then by newest if tied - return replyEvents.sort((a, b) => { - const controversyA = getReplyControversyScore(a) - const controversyB = getReplyControversyScore(b) - if (controversyA !== controversyB) { - return controversyB - controversyA // Higher controversy first - } - return b.created_at - a.created_at // Newest first if tied - }) + return replyFeedZapsFirst( + [...nonZaps].sort((a, b) => { + const controversyA = getReplyControversyScore(a) + const controversyB = getReplyControversyScore(b) + if (controversyA !== controversyB) { + return controversyB - controversyA + } + return b.created_at - a.created_at + }), + zaps + ) case 'most-zapped': - // Sort by total zap amount, then by newest if tied - return replyEvents.sort((a, b) => { - const zapAmountA = getReplyZapAmount(a) - const zapAmountB = getReplyZapAmount(b) - if (zapAmountA !== zapAmountB) { - return zapAmountB - zapAmountA // Higher zap amounts first - } - return b.created_at - a.created_at // Newest first if tied - }) + return replyFeedZapsFirst( + [...nonZaps].sort((a, b) => { + const zapAmountA = getReplyZapAmount(a) + const zapAmountB = getReplyZapAmount(b) + if (zapAmountA !== zapAmountB) { + return zapAmountB - zapAmountA + } + return b.created_at - a.created_at + }), + zaps + ) default: - return replyEvents.sort((a, b) => b.created_at - a.created_at) + return replyFeedZapsFirst( + [...nonZaps].sort((a, b) => b.created_at - a.created_at), + zaps + ) } }, [ event.id, @@ -257,11 +299,20 @@ function ReplyNoteList({ /** Events that quote the note (from useQuoteEvents) — render with quote styling and without embedded quote. */ const quoteIdSet = useMemo(() => new Set(quoteEvents.map((e) => e.id)), [quoteEvents]) const mergedFeed = useMemo(() => { + /** Quotes + time-sorted feeds must not interleave zap receipts chronologically */ + const zapsThenTimeSorted = (merged: NEvent[], direction: 'asc' | 'desc') => { + const { zaps, nonZaps } = partitionZapReceipts(merged) + const sortedNon = [...nonZaps].sort((a, b) => + direction === 'asc' ? a.created_at - b.created_at : b.created_at - a.created_at + ) + return replyFeedZapsFirst(sortedNon, zaps) + } + if (!showQuotes) return replies const quoteOnly = quoteEvents.filter((e) => !replyIdSet.has(e.id)) const merged = [...replies, ...quoteOnly] - if (sort === 'oldest') return merged.sort((a, b) => a.created_at - b.created_at) - if (sort === 'newest') return merged.sort((a, b) => b.created_at - a.created_at) + if (sort === 'oldest') return zapsThenTimeSorted(merged, 'asc') + if (sort === 'newest') return zapsThenTimeSorted(merged, 'desc') if (sort === 'top' || sort === 'controversial' || sort === 'most-zapped') { const replyIds = new Set(replies.map((r) => r.id)) const sortedReplies = [...replies] @@ -269,7 +320,7 @@ function ReplyNoteList({ const sortedQuotes = [...qo].sort((a, b) => b.created_at - a.created_at) return [...sortedReplies, ...sortedQuotes] } - return merged.sort((a, b) => b.created_at - a.created_at) + return zapsThenTimeSorted(merged, 'desc') }, [replies, quoteEvents, showQuotes, sort, replyIdSet]) const [timelineKey] = useState(undefined) diff --git a/src/hooks/useProfileBadges.tsx b/src/hooks/useProfileBadges.tsx index 25963a37..cd2b180d 100644 --- a/src/hooks/useProfileBadges.tsx +++ b/src/hooks/useProfileBadges.tsx @@ -7,7 +7,7 @@ import { } from '@/lib/fetch-badge-nip58' import { profileAccordionGetCachedBadges, - profileAccordionInvalidate, + profileAccordionGetCachedRelayUrls, profileAccordionRelayUrlsKey, profileAccordionSetBadges } from '@/lib/profile-accordion-session-cache' @@ -55,6 +55,13 @@ function badgeNeedsDefinitionMedia(b: TProfileBadge): boolean { return !!(parsed && parsed.kind === ExtendedKind.BADGE_DEFINITION) } +function mergeBadgesByAwardId(seed: TProfileBadge[], fresh: TProfileBadge[]): TProfileBadge[] { + const m = new Map() + for (const b of seed) m.set(b.awardId, b) + for (const b of fresh) m.set(b.awardId, b) + return [...m.values()] +} + async function enrichBadgesFromIndexedDb(badges: TProfileBadge[]): Promise { return Promise.all( badges.map(async (b) => { @@ -85,6 +92,13 @@ async function enrichBadgesFromIndexedDb(badges: TProfileBadge[]): Promise([]) const [loading, setLoading] = useState(false) const fetchIdRef = useRef(0) @@ -100,14 +114,22 @@ export function useProfileBadges(pubkey: string | undefined, relayUrls?: string[ return } - const urls = - force || !(relayUrls && relayUrls.length > 0) - ? await buildProfileRelayUrls(pubkey, blockedRelays) - : relayUrls + const relayUrlsLatest = relayUrlsRef.current + let urls = + relayUrlsLatest && relayUrlsLatest.length > 0 + ? relayUrlsLatest + : profileAccordionGetCachedRelayUrls(pubkey) ?? [] + + if (force || urls.length === 0) { + urls = await buildProfileRelayUrls(pubkey, blockedRelaysRef.current) + } const relayKey = profileAccordionRelayUrlsKey(urls) + const seedBadges = profileAccordionGetCachedBadges(pubkey, relayKey) + let deferLoading = !!(force && seedBadges?.length) + if (!force) { - const cached = profileAccordionGetCachedBadges(pubkey, relayKey) + const cached = seedBadges if (cached?.length) { if (cached.some(badgeNeedsDefinitionMedia)) { const enriched = await enrichBadgesFromIndexedDb(cached) @@ -118,6 +140,7 @@ export function useProfileBadges(pubkey: string | undefined, relayUrls?: string[ setLoading(false) return } + deferLoading = false // Session cache was incomplete and IndexedDB has no definitions — fetch from network below. } else { if (myFetchId !== fetchIdRef.current) return @@ -128,8 +151,14 @@ export function useProfileBadges(pubkey: string | undefined, relayUrls?: string[ } } + if (force && seedBadges?.length && myFetchId === fetchIdRef.current) { + setBadges(seedBadges) + } + if (myFetchId !== fetchIdRef.current) return - setLoading(true) + if (!deferLoading) { + setLoading(true) + } try { const events = await queryService.fetchEvents( @@ -140,7 +169,7 @@ export function useProfileBadges(pubkey: string | undefined, relayUrls?: string[ const profileBadgesEvent = events.sort((a, b) => b.created_at - a.created_at)[0] if (!profileBadgesEvent || myFetchId !== fetchIdRef.current) { - if (myFetchId === fetchIdRef.current) setBadges([]) + if (myFetchId === fetchIdRef.current && !seedBadges?.length) setBadges([]) return } @@ -161,7 +190,7 @@ export function useProfileBadges(pubkey: string | undefined, relayUrls?: string[ } if (pairs.length === 0) { - setBadges([]) + if (!seedBadges?.length) setBadges([]) return } @@ -172,7 +201,7 @@ export function useProfileBadges(pubkey: string | undefined, relayUrls?: string[ return { a, awardId: e } } - const relayPool = mergeNip58BadgeRelayPool(urls, eRelayHint, blockedRelays) + const relayPool = mergeNip58BadgeRelayPool(urls, eRelayHint, blockedRelaysRef.current) const [defEvent, awardEvent] = await Promise.all([ fetchNip58BadgeDefinition(parsed.pubkey, parsed.d, relayPool), fetchNip58BadgeAward(e, relayPool) @@ -212,18 +241,18 @@ export function useProfileBadges(pubkey: string | undefined, relayUrls?: string[ ) if (myFetchId !== fetchIdRef.current) return - setBadges(result) - profileAccordionSetBadges(pubkey, relayKey, result) + const merged = mergeBadgesByAwardId(seedBadges ?? [], result) + setBadges(merged) + profileAccordionSetBadges(pubkey, relayKey, merged) } catch { if (myFetchId !== fetchIdRef.current) return - setBadges([]) + if (!seedBadges?.length) setBadges([]) } finally { if (myFetchId === fetchIdRef.current) setLoading(false) } - }, [pubkey, blockedRelays, relayUrls]) + }, [pubkey, blockedRelaysKey, relayUrlsKey]) const refresh = useCallback(() => { - if (pubkey) profileAccordionInvalidate(pubkey, 'badges') void fetchBadges(true) }, [pubkey, fetchBadges]) diff --git a/src/hooks/useProfileFollowPacks.tsx b/src/hooks/useProfileFollowPacks.tsx index a9b2a522..716613bc 100644 --- a/src/hooks/useProfileFollowPacks.tsx +++ b/src/hooks/useProfileFollowPacks.tsx @@ -1,7 +1,7 @@ -import { ExtendedKind } from '@/constants' +import { ExtendedKind, FAST_READ_RELAY_URLS } from '@/constants' import { profileAccordionGetCachedFollowPacks, - profileAccordionInvalidate, + profileAccordionGetCachedRelayUrls, profileAccordionRelayUrlsKey, profileAccordionSetFollowPacks } from '@/lib/profile-accordion-session-cache' @@ -27,6 +27,13 @@ export function useProfileFollowPacks( relayUrls?: string[] ) { const { blockedRelays } = useFavoriteRelays() + const blockedRelaysRef = useRef(blockedRelays) + blockedRelaysRef.current = blockedRelays + const relayUrlsRef = useRef(relayUrls) + relayUrlsRef.current = relayUrls + const blockedRelaysKey = profileAccordionRelayUrlsKey(blockedRelays) + const relayUrlsKey = profileAccordionRelayUrlsKey(relayUrls ?? []) + const [packs, setPacks] = useState([]) const [loading, setLoading] = useState(false) const fetchIdRef = useRef(0) @@ -42,13 +49,19 @@ export function useProfileFollowPacks( return } - const urls = - force || !(relayUrls && relayUrls.length > 0) - ? await buildProfileRelayUrls(pubkey, blockedRelays) - : relayUrls - const relayKey = profileAccordionRelayUrlsKey(urls) + const relayUrlsLatest = relayUrlsRef.current + let urls = + relayUrlsLatest && relayUrlsLatest.length > 0 + ? relayUrlsLatest + : profileAccordionGetCachedRelayUrls(pubkey) ?? [] + + if (force || urls.length === 0) { + urls = await buildProfileRelayUrls(pubkey, blockedRelaysRef.current) + } + const queryUrls = urls.length > 0 ? urls : [...FAST_READ_RELAY_URLS] + const relayKey = profileAccordionRelayUrlsKey(queryUrls) - if (!force && urls.length > 0) { + if (!force) { const cached = profileAccordionGetCachedFollowPacks(pubkey, relayKey) if (cached) { if (myFetchId !== fetchIdRef.current) return @@ -58,39 +71,44 @@ export function useProfileFollowPacks( } } + const seed = profileAccordionGetCachedFollowPacks(pubkey, relayKey) + if (seed?.length && myFetchId === fetchIdRef.current) { + setPacks(seed) + } + if (myFetchId !== fetchIdRef.current) return - setLoading(true) + if (!seed?.length) { + setLoading(true) + } try { - if (urls.length === 0) { - if (myFetchId === fetchIdRef.current) setPacks([]) - return - } - const events = await queryService.fetchEvents( - urls, + queryUrls, [{ '#p': [pubkey], kinds: [ExtendedKind.FOLLOW_PACK], limit: 50 }], { eoseTimeout: 2000, globalTimeout: 15000, firstRelayResultGraceMs: false } ) if (myFetchId !== fetchIdRef.current) return - const result: TProfileFollowPack[] = events.map((evt) => ({ + const network: TProfileFollowPack[] = events.map((evt) => ({ event: evt, title: getPackTitle(evt) })) - setPacks(result) - profileAccordionSetFollowPacks(pubkey, relayKey, result) + const byId = new Map() + for (const p of seed ?? []) byId.set(p.event.id, p) + for (const p of network) byId.set(p.event.id, p) + const merged = [...byId.values()].sort((a, b) => b.event.created_at - a.event.created_at) + setPacks(merged) + profileAccordionSetFollowPacks(pubkey, relayKey, merged) } catch { if (myFetchId !== fetchIdRef.current) return - setPacks([]) + if (!seed?.length) setPacks([]) } finally { if (myFetchId === fetchIdRef.current) setLoading(false) } - }, [pubkey, blockedRelays, relayUrls]) + }, [pubkey, blockedRelaysKey, relayUrlsKey]) const refresh = useCallback(() => { - if (pubkey) profileAccordionInvalidate(pubkey, 'followPacks') void fetchPacks(true) }, [pubkey, fetchPacks]) diff --git a/src/hooks/useProfileInteractions.tsx b/src/hooks/useProfileInteractions.tsx index c9f03318..82d7be7e 100644 --- a/src/hooks/useProfileInteractions.tsx +++ b/src/hooks/useProfileInteractions.tsx @@ -6,7 +6,7 @@ import { Event, Filter, kinds } from 'nostr-tools' import { useCallback, useEffect, useRef, useState } from 'react' import { profileAccordionGetCachedInteractions, - profileAccordionInvalidate, + profileAccordionGetCachedRelayUrls, profileAccordionRelayUrlsKey, profileAccordionSetInteractions } from '@/lib/profile-accordion-session-cache' @@ -27,6 +27,13 @@ const NOTE_IDS_FOR_COMMENTS = 50 /** Uses profile owner's outboxes + PROFILE_FETCH_RELAY_URLS. Pass relayUrls to share with other profile fetches. */ export function useProfileInteractions(pubkey: string | undefined, relayUrls?: string[]) { const { blockedRelays } = useFavoriteRelays() + const blockedRelaysRef = useRef(blockedRelays) + blockedRelaysRef.current = blockedRelays + const relayUrlsRef = useRef(relayUrls) + relayUrlsRef.current = relayUrls + const blockedRelaysKey = profileAccordionRelayUrlsKey(blockedRelays) + const relayUrlsKey = profileAccordionRelayUrlsKey(relayUrls ?? []) + const [zaps, setZaps] = useState([]) const [reactions, setReactions] = useState([]) const [comments, setComments] = useState([]) @@ -46,26 +53,45 @@ export function useProfileInteractions(pubkey: string | undefined, relayUrls?: s return } - const urls = - force || !(relayUrls && relayUrls.length > 0) - ? await buildProfileRelayUrls(pubkey, blockedRelays) - : relayUrls + const relayUrlsLatest = relayUrlsRef.current + let urls = + relayUrlsLatest && relayUrlsLatest.length > 0 + ? relayUrlsLatest + : profileAccordionGetCachedRelayUrls(pubkey) ?? [] + + if (force || urls.length === 0) { + urls = await buildProfileRelayUrls(pubkey, blockedRelaysRef.current) + } const relayKey = profileAccordionRelayUrlsKey(urls) if (!force) { const cached = profileAccordionGetCachedInteractions(pubkey, relayKey) if (cached) { if (myFetchId !== fetchIdRef.current) return - setZaps(cached.zaps) - setReactions(cached.reactions) - setComments(cached.comments) + setZaps([...cached.zaps].sort((a, b) => b.amount - a.amount)) + setReactions([...cached.reactions].sort((a, b) => b.created_at - a.created_at)) + setComments([...cached.comments].sort((a, b) => b.created_at - a.created_at)) setLoading(false) return } } + const seed = profileAccordionGetCachedInteractions(pubkey, relayKey) + + if (seed && myFetchId === fetchIdRef.current) { + setZaps([...seed.zaps].sort((a, b) => b.amount - a.amount)) + setReactions([...seed.reactions].sort((a, b) => b.created_at - a.created_at)) + setComments([...seed.comments].sort((a, b) => b.created_at - a.created_at)) + } + if (myFetchId !== fetchIdRef.current) return - setLoading(true) + + const hasVisibleSeed = + !!seed && + (seed.zaps.length > 0 || seed.reactions.length > 0 || seed.comments.length > 0) + if (!hasVisibleSeed) { + setLoading(true) + } try { const profileMetaPromise = replaceableEventService.fetchReplaceableEvent( @@ -75,11 +101,20 @@ export function useProfileInteractions(pubkey: string | undefined, relayUrls?: s urls ) - const collectedZaps: TProfileZap[] = [] + const collectedZaps: TProfileZap[] = seed ? [...seed.zaps] : [] const reactionsByPubkey = new Map() // one reaction per npub, newest kept (profile event only) - const collectedComments: Event[] = [] - const seenZaps = new Set() - const seenReactions = new Set() + if (seed) { + for (const e of seed.reactions) { + reactionsByPubkey.set(e.pubkey, e) + } + } + const collectedComments: Event[] = seed ? [...seed.comments] : [] + const seenZaps = new Set(collectedZaps.map((z) => z.pr)) + const seenProfileReactionEventIds = new Set() + if (seed) { + for (const e of seed.reactions) seenProfileReactionEventIds.add(e.id) + } + const seenCommentIds = new Set(collectedComments.map((c) => c.id)) let noteIds: string[] = [] // Phase 1: zaps + profile's recent notes (for comments on those notes) @@ -148,8 +183,8 @@ export function useProfileInteractions(pubkey: string | undefined, relayUrls?: s const ingestProfileReaction = (evt: Event) => { if (!reactionTargetsKind0Profile(evt)) return if (hexPubkeysEqual(evt.pubkey, pubkey)) return - if (seenReactions.has(evt.id)) return - seenReactions.add(evt.id) + if (seenProfileReactionEventIds.has(evt.id)) return + seenProfileReactionEventIds.add(evt.id) const existing = reactionsByPubkey.get(evt.pubkey) if (!existing || evt.created_at > existing.created_at) { reactionsByPubkey.set(evt.pubkey, evt) @@ -158,8 +193,8 @@ export function useProfileInteractions(pubkey: string | undefined, relayUrls?: s } const ingestComment = (evt: Event) => { if (hexPubkeysEqual(evt.pubkey, pubkey)) return - if (seenReactions.has(evt.id)) return - seenReactions.add(evt.id) + if (seenCommentIds.has(evt.id)) return + seenCommentIds.add(evt.id) collectedComments.push(evt) flushComments() } @@ -226,10 +261,10 @@ export function useProfileInteractions(pubkey: string | undefined, relayUrls?: s } finally { if (myFetchId === fetchIdRef.current) setLoading(false) } - }, [pubkey, blockedRelays, relayUrls]) + }, [pubkey, blockedRelaysKey, relayUrlsKey]) const refresh = useCallback(() => { - if (pubkey) profileAccordionInvalidate(pubkey, 'interactions') + /** Keep session cache so refresh merges new relays/events onto what is already shown */ void fetchAll(true) }, [pubkey, fetchAll]) diff --git a/src/hooks/useProfileRelayUrls.tsx b/src/hooks/useProfileRelayUrls.tsx index aa16cb40..223b6c60 100644 --- a/src/hooks/useProfileRelayUrls.tsx +++ b/src/hooks/useProfileRelayUrls.tsx @@ -1,22 +1,29 @@ import { profileAccordionGetCachedRelayUrls, - profileAccordionInvalidate, + profileAccordionRelayUrlsKey, profileAccordionSetRelayUrls } from '@/lib/profile-accordion-session-cache' import { buildProfileRelayUrls } from '@/lib/profile-relay-urls' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' /** Returns profile relay URLs (outboxes + PROFILE_FETCH). Use for sharing relays across profile fetches. */ export function useProfileRelayUrls(pubkey: string | undefined, enabled: boolean) { const { blockedRelays } = useFavoriteRelays() + const blockedRelaysRef = useRef(blockedRelays) + blockedRelaysRef.current = blockedRelays + const blockedRelaysKey = profileAccordionRelayUrlsKey(blockedRelays) + const [relayUrls, setRelayUrls] = useState([]) const [loading, setLoading] = useState(false) + /** Stale-while-revalidate: avoid accordion skeleton when refreshing relays but URLs already visible */ + const relayUrlsRef = useRef([]) + relayUrlsRef.current = relayUrls const fetch = useCallback( async (force = false) => { if (!pubkey) { - setRelayUrls([]) + setRelayUrls((prev) => (prev.length === 0 ? prev : [])) setLoading(false) return } @@ -30,35 +37,42 @@ export function useProfileRelayUrls(pubkey: string | undefined, enabled: boolean } } - setLoading(true) + const revalidateWithVisibleUrls = force && relayUrlsRef.current.length > 0 + if (!revalidateWithVisibleUrls) { + setLoading(true) + } try { - const urls = await buildProfileRelayUrls(pubkey, blockedRelays) + const urls = await buildProfileRelayUrls(pubkey, blockedRelaysRef.current) profileAccordionSetRelayUrls(pubkey, urls) setRelayUrls(urls) } catch { - setRelayUrls([]) + setRelayUrls((prev) => (prev.length === 0 ? prev : [])) } finally { setLoading(false) } }, - [pubkey, blockedRelays] + [pubkey, blockedRelaysKey] ) const refresh = useCallback(() => { - if (pubkey) profileAccordionInvalidate(pubkey, 'relayUrls') if (!pubkey) return Promise.resolve() + /** Do not invalidate: that wipes interactions/badges/follow-packs cache and forces empty refetches */ return fetch(true) }, [pubkey, fetch]) useEffect(() => { if (!pubkey) { - setRelayUrls([]) + setRelayUrls((prev) => (prev.length === 0 ? prev : [])) setLoading(false) return } if (!enabled) { const cached = profileAccordionGetCachedRelayUrls(pubkey) - setRelayUrls(cached ?? []) + setRelayUrls((prev) => { + if (cached && cached.length > 0) return cached + if (prev.length === 0) return prev + return [] + }) setLoading(false) return } diff --git a/src/hooks/useProfileReports.tsx b/src/hooks/useProfileReports.tsx index 0e1d3164..bfa39c8e 100644 --- a/src/hooks/useProfileReports.tsx +++ b/src/hooks/useProfileReports.tsx @@ -2,7 +2,7 @@ import { ExtendedKind } from '@/constants' import { buildProfileReportRelayUrls } from '@/lib/profile-report-relay-urls' import { profileAccordionGetCachedReports, - profileAccordionInvalidate, + profileAccordionRelayUrlsKey, profileAccordionSetReports } from '@/lib/profile-accordion-session-cache' import { queryService } from '@/services/client.service' @@ -18,6 +18,13 @@ export function useProfileReports( viewerPubkey: string | null | undefined ) { const { favoriteRelays, blockedRelays } = useFavoriteRelays() + const favoriteRelaysRef = useRef(favoriteRelays) + favoriteRelaysRef.current = favoriteRelays + const blockedRelaysRef = useRef(blockedRelays) + blockedRelaysRef.current = blockedRelays + const favoriteRelaysKey = profileAccordionRelayUrlsKey(favoriteRelays ?? []) + const blockedRelaysKey = profileAccordionRelayUrlsKey(blockedRelays) + const [reports, setReports] = useState([]) const [loading, setLoading] = useState(false) const fetchIdRef = useRef(0) @@ -44,17 +51,24 @@ export function useProfileReports( } } + const seed = profileAccordionGetCachedReports(profilePubkey, viewer) + if (seed?.length && myFetchId === fetchIdRef.current) { + setReports(seed) + } + if (myFetchId !== fetchIdRef.current) return - setLoading(true) + if (!seed?.length) { + setLoading(true) + } try { const urls = await buildProfileReportRelayUrls({ viewerPubkey: viewer, - favoriteRelays: favoriteRelays ?? [], - blockedRelays + favoriteRelays: favoriteRelaysRef.current ?? [], + blockedRelays: blockedRelaysRef.current }) if (urls.length === 0) { - if (myFetchId === fetchIdRef.current) setReports([]) + if (myFetchId === fetchIdRef.current && !seed?.length) setReports([]) return } @@ -66,27 +80,26 @@ export function useProfileReports( if (myFetchId !== fetchIdRef.current) return - const seen = new Set() - const deduped: Event[] = [] + const byId = new Map() + for (const evt of seed ?? []) byId.set(evt.id, evt) + const seen = new Set(byId.keys()) for (const evt of events) { if (seen.has(evt.id)) continue seen.add(evt.id) - deduped.push(evt) + byId.set(evt.id, evt) } - deduped.sort((a, b) => b.created_at - a.created_at) - setReports(deduped) - profileAccordionSetReports(profilePubkey, viewer, deduped) + const merged = [...byId.values()].sort((a, b) => b.created_at - a.created_at) + setReports(merged) + profileAccordionSetReports(profilePubkey, viewer, merged) } catch { if (myFetchId !== fetchIdRef.current) return - setReports([]) + if (!seed?.length) setReports([]) } finally { if (myFetchId === fetchIdRef.current) setLoading(false) } - }, [profilePubkey, viewerPubkey, favoriteRelays, blockedRelays]) + }, [profilePubkey, viewerPubkey, favoriteRelaysKey, blockedRelaysKey]) const refresh = useCallback(() => { - const v = viewerPubkey?.trim() - if (profilePubkey && v) profileAccordionInvalidate(profilePubkey, 'reports') void fetchReports(true) }, [profilePubkey, viewerPubkey, fetchReports]) diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index a7fd9c2f..08d109e2 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -1374,6 +1374,8 @@ export default { 'Select Media Type': 'Select Media Type', 'Select group...': 'Select group...', 'Select relays': 'Select relays', + 'Publish relay cap hint': + 'Pro Veröffentlichung werden höchstens {{max}} Relais angesprochen. Deine Outbox-Relais werden zuerst eingereiht, danach Priorität; wegen Fehlern übersprungene Relais entfallen. Du hast {{selected}} gewählt — der Rest wird nicht gesendet. Die genaue Liste steht in der Konsole unter [PublishEvent].', 'Select the group where you want to create this discussion.': 'Select the group where you want to create this discussion.', 'Select topic...': 'Select topic...', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 8acb7947..ecd5067a 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1429,6 +1429,8 @@ export default { 'Select Media Type': 'Select Media Type', 'Select group...': 'Select group...', 'Select relays': 'Select relays', + 'Publish relay cap hint': + 'At most {{max}} relays are contacted per publish. Your outboxes are merged in first, then priority order; session-blocked relays are skipped. You selected {{selected}} — lower-priority checks are not sent. See console [PublishEvent] for the exact list.', 'Select the group where you want to create this discussion.': 'Select the group where you want to create this discussion.', 'Select topic...': 'Select topic...', diff --git a/src/lib/event.ts b/src/lib/event.ts index 603e7d0a..2d4bac50 100644 --- a/src/lib/event.ts +++ b/src/lib/event.ts @@ -298,6 +298,11 @@ export function isTombstoneKeyForEvent(event: Event, tombstones: Set): b return false } +export function filterEventsExcludingTombstones(events: Event[], tombstones: Set): Event[] { + if (tombstones.size === 0) return events + return events.filter((e) => !isTombstoneKeyForEvent(e, tombstones)) +} + export function getNoteBech32Id(event: Event) { const hints = client.getEventHints(event.id).slice(0, 2) if (isReplaceableEvent(event.kind)) { diff --git a/src/lib/profile-accordion-session-cache.ts b/src/lib/profile-accordion-session-cache.ts index 8d49cdf3..8b2eccca 100644 --- a/src/lib/profile-accordion-session-cache.ts +++ b/src/lib/profile-accordion-session-cache.ts @@ -16,11 +16,15 @@ export type ProfileAccordionInteractionsSnapshot = { type Entry = { relayUrls?: string[] - /** Fingerprint of relays used for interaction/badge/pack slices */ + /** Fingerprint of profile relay list from {@link profileAccordionSetRelayUrls} (invalidates slices when it changes) */ relayUrlsKey?: string interactions?: ProfileAccordionInteractionsSnapshot + /** Relay key used for the last interactions fetch (per-slice; avoids races with badges / follow packs) */ + interactionsRelayKey?: string badges?: TProfileBadge[] + badgesRelayKey?: string followPacks?: TProfileFollowPack[] + followPacksRelayKey?: string /** viewer hex pubkey → reports */ reportsByViewer?: Record } @@ -51,8 +55,11 @@ export function profileAccordionSetRelayUrls(pubkey: string, urls: string[]): vo const key = profileAccordionRelayUrlsKey(urls) if (e.relayUrlsKey && e.relayUrlsKey !== key) { delete e.interactions + delete e.interactionsRelayKey delete e.badges + delete e.badgesRelayKey delete e.followPacks + delete e.followPacksRelayKey } e.relayUrls = urls e.relayUrlsKey = key @@ -63,7 +70,7 @@ export function profileAccordionGetCachedInteractions( relayKey: string ): ProfileAccordionInteractionsSnapshot | undefined { const e = store.get(pubkey) - if (!e?.interactions || e.relayUrlsKey !== relayKey) return undefined + if (!e?.interactions || e.interactionsRelayKey !== relayKey) return undefined return e.interactions } @@ -73,20 +80,20 @@ export function profileAccordionSetInteractions( data: ProfileAccordionInteractionsSnapshot ): void { const e = getEntry(pubkey) - e.relayUrlsKey = relayKey e.interactions = data + e.interactionsRelayKey = relayKey } export function profileAccordionGetCachedBadges(pubkey: string, relayKey: string): TProfileBadge[] | undefined { const e = store.get(pubkey) - if (!e?.badges || e.relayUrlsKey !== relayKey) return undefined + if (!e?.badges || e.badgesRelayKey !== relayKey) return undefined return e.badges } export function profileAccordionSetBadges(pubkey: string, relayKey: string, badges: TProfileBadge[]): void { const e = getEntry(pubkey) - e.relayUrlsKey = relayKey e.badges = badges + e.badgesRelayKey = relayKey } export function profileAccordionGetCachedFollowPacks( @@ -94,7 +101,7 @@ export function profileAccordionGetCachedFollowPacks( relayKey: string ): TProfileFollowPack[] | undefined { const e = store.get(pubkey) - if (!e?.followPacks || e.relayUrlsKey !== relayKey) return undefined + if (!e?.followPacks || e.followPacksRelayKey !== relayKey) return undefined return e.followPacks } @@ -104,8 +111,8 @@ export function profileAccordionSetFollowPacks( packs: TProfileFollowPack[] ): void { const e = getEntry(pubkey) - e.relayUrlsKey = relayKey e.followPacks = packs + e.followPacksRelayKey = relayKey } export function profileAccordionGetCachedReports(profilePubkey: string, viewerPubkey: string): Event[] | undefined { @@ -142,17 +149,23 @@ export function profileAccordionInvalidate(pubkey: string, slice: ProfileAccordi delete e.relayUrls delete e.relayUrlsKey delete e.interactions + delete e.interactionsRelayKey delete e.badges + delete e.badgesRelayKey delete e.followPacks + delete e.followPacksRelayKey break case 'interactions': delete e.interactions + delete e.interactionsRelayKey break case 'badges': delete e.badges + delete e.badgesRelayKey break case 'followPacks': delete e.followPacks + delete e.followPacksRelayKey break case 'reports': delete e.reportsByViewer diff --git a/src/lib/pubkey.ts b/src/lib/pubkey.ts index 3e8ad15d..b1a9875f 100644 --- a/src/lib/pubkey.ts +++ b/src/lib/pubkey.ts @@ -81,6 +81,14 @@ export function isValidPubkey(pubkey: string) { return /^[0-9a-f]{64}$/i.test(pubkey) } +/** Hex pubkey from pasted npub / nprofile / hex / `nostr:` URL (e.g. invite lists). */ +export function inviteInputToHexPubkey(raw: string): string | null { + const t = raw.trim().replace(/^nostr:/i, '').trim() + if (!t) return null + const pk = userIdToPubkey(t) + return isValidPubkey(pk) ? pk.toLowerCase() : null +} + const pubkeyImageCache = new LRUCache({ max: 1000 }) // Version identifier to force cache invalidation when algorithm changes diff --git a/src/pages/primary/SpellsPage/index.tsx b/src/pages/primary/SpellsPage/index.tsx index 3bfdaf92..9c5f0f1d 100644 --- a/src/pages/primary/SpellsPage/index.tsx +++ b/src/pages/primary/SpellsPage/index.tsx @@ -48,7 +48,7 @@ import { FAUX_SPELL_ORDER, FIRST_RELAY_RESULT_GRACE_MS, } from '@/constants' -import { isUserInEventMentions } from '@/lib/event' +import { filterEventsExcludingTombstones, isUserInEventMentions } from '@/lib/event' import { formatPubkey } from '@/lib/pubkey' import { augmentSubRequestsWithFavoritesFastReadAndInbox, @@ -58,6 +58,7 @@ import { computeKind777SpellFeedSubscriptionKey, computeSpellSubRequestsIdentityKey } from '@/lib/spell-feed-request-identity' +import { TOMBSTONES_UPDATED_EVENT } from '@/lib/tombstone-events' import { normalizeUrl } from '@/lib/url' import { buildSpellCatalogAuthors, @@ -447,7 +448,10 @@ const SpellsPage = forwardRef(function SpellsPage( { authors: [pubkey], kinds: [ExtendedKind.FOLLOW_SET], limit: 500 }, { eoseTimeout: 2000, globalTimeout: 15000, firstRelayResultGraceMs: false } ) - if (!cancelled) setFollowSetListEvents(dedupeFollowSetEventsByD(events)) + const tombstones = await indexedDb.getAllTombstones() + if (!cancelled) { + setFollowSetListEvents(dedupeFollowSetEventsByD(filterEventsExcludingTombstones(events, tombstones))) + } } catch { if (!cancelled) setFollowSetListEvents([]) } finally { @@ -459,6 +463,12 @@ const SpellsPage = forwardRef(function SpellsPage( } }, [pubkey, sortedFavoriteRelaysKey, sortedBlockedRelaysKey, relayMailboxStableKey, followSetManualRefreshKey]) + useEffect(() => { + const onTombstones = () => setFollowSetManualRefreshKey((k) => k + 1) + window.addEventListener(TOMBSTONES_UPDATED_EVENT, onTombstones) + return () => window.removeEventListener(TOMBSTONES_UPDATED_EVENT, onTombstones) + }, []) + /** * Kind-777 list for the dropdown. When opening with `?spell=…` (faux name, hex id, nevent, etc.), defer * this IndexedDB read so the feed can subscribe and paint first; the header already reflects the URL. diff --git a/src/pages/secondary/FollowSetsSettingsPage/index.tsx b/src/pages/secondary/FollowSetsSettingsPage/index.tsx index f9857686..a389feb4 100644 --- a/src/pages/secondary/FollowSetsSettingsPage/index.tsx +++ b/src/pages/secondary/FollowSetsSettingsPage/index.tsx @@ -36,10 +36,13 @@ import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { getRelayUrlsWithFavoritesFastReadAndInbox } from '@/lib/favorites-feed-relays' import { createFollowSetDraftEvent } from '@/lib/draft-event' +import { filterEventsExcludingTombstones } from '@/lib/event' import logger from '@/lib/logger' +import { TOMBSTONES_UPDATED_EVENT } from '@/lib/tombstone-events' import { useNostr } from '@/providers/NostrProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { queryService } from '@/services/client.service' +import indexedDb from '@/services/indexed-db.service' import dayjs from 'dayjs' import type { Event } from 'nostr-tools' import { Pencil, Plus, Trash2, Users } from 'lucide-react' @@ -101,7 +104,8 @@ const FollowSetsSettingsPage = forwardRef( { authors: [pubkey], kinds: [ExtendedKind.FOLLOW_SET], limit: 500 }, FOLLOW_SET_FETCH_OPTS ) - setLists(dedupeFollowSetEventsByD(events)) + const tombstones = await indexedDb.getAllTombstones() + setLists(dedupeFollowSetEventsByD(filterEventsExcludingTombstones(events, tombstones))) } catch (e) { logger.warn('[FollowSetsSettings] Failed to load follow sets', e) toast.error(t('Failed to load follow sets')) @@ -115,6 +119,12 @@ const FollowSetsSettingsPage = forwardRef( void loadLists() }, [loadLists]) + useEffect(() => { + const onTombstones = () => void loadLists() + window.addEventListener(TOMBSTONES_UPDATED_EVENT, onTombstones) + return () => window.removeEventListener(TOMBSTONES_UPDATED_EVENT, onTombstones) + }, [loadLists]) + useEffect(() => { if (!hideTitlebar) { registerPrimaryPanelRefresh(null) @@ -151,55 +161,57 @@ const FollowSetsSettingsPage = forwardRef( } const handleSave = async () => { - if (!(await checkLogin())) return - if (!pubkey) return - let tags: string[][] - try { - tags = buildFollowSetTags({ - d: formD, - title: formTitle, - description: formDescription, - image: formImage, - pubkeys: formPubkeys - }) - } catch (e) { - toast.error((e as Error).message) - return - } + await checkLogin(async () => { + if (!pubkey) return + let tags: string[][] + try { + tags = buildFollowSetTags({ + d: formD, + title: formTitle, + description: formDescription, + image: formImage, + pubkeys: formPubkeys + }) + } catch (e) { + toast.error((e as Error).message) + return + } - setSaving(true) - try { - let createdAt = dayjs().unix() - if (editing && createdAt === editing.created_at) { - await new Promise((r) => setTimeout(r, 1100)) - createdAt = dayjs().unix() + setSaving(true) + try { + let createdAt = dayjs().unix() + if (editing && createdAt === editing.created_at) { + await new Promise((r) => setTimeout(r, 1100)) + createdAt = dayjs().unix() + } + const draft = createFollowSetDraftEvent(tags, '', createdAt) + await publish(draft) + toast.success(t('Follow set saved')) + closeDialog() + await loadLists() + } catch (e) { + showPublishingError(e instanceof Error ? e : new Error(String(e))) + } finally { + setSaving(false) } - const draft = createFollowSetDraftEvent(tags, '', createdAt) - await publish(draft) - toast.success(t('Follow set saved')) - closeDialog() - await loadLists() - } catch (e) { - showPublishingError(e instanceof Error ? e : new Error(String(e))) - } finally { - setSaving(false) - } + }) } const handleConfirmDelete = async () => { if (!deleteTarget) return - if (!(await checkLogin())) return - setDeleting(true) - try { - await attemptDelete(deleteTarget) - toast.success(t('Follow set deleted')) - setDeleteTarget(null) - await loadLists() - } catch (e) { - showPublishingError(e instanceof Error ? e : new Error(String(e))) - } finally { - setDeleting(false) - } + await checkLogin(async () => { + setDeleting(true) + try { + await attemptDelete(deleteTarget) + toast.success(t('Follow set deleted')) + setDeleteTarget(null) + await loadLists() + } catch (e) { + showPublishingError(e instanceof Error ? e : new Error(String(e))) + } finally { + setDeleting(false) + } + }) } return ( diff --git a/src/providers/FavoriteRelaysProvider.tsx b/src/providers/FavoriteRelaysProvider.tsx index 88a95478..e181e764 100644 --- a/src/providers/FavoriteRelaysProvider.tsx +++ b/src/providers/FavoriteRelaysProvider.tsx @@ -9,7 +9,7 @@ import indexedDb from '@/services/indexed-db.service' import storage from '@/services/local-storage.service' import { TRelaySet } from '@/types' import { Event, kinds } from 'nostr-tools' -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { FavoriteRelaysContext } from './favorite-relays-context' import { useNostr } from './NostrProvider' @@ -148,146 +148,201 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode ) }, [relaySetEvents, blockedRelays]) - const addFavoriteRelays = async (relayUrls: string[]) => { - const normalizedUrls = relayUrls - .map((relayUrl) => normalizeUrl(relayUrl)) - .filter((url) => !!url && !favoriteRelays.includes(url)) - if (!normalizedUrls.length) return + const addFavoriteRelays = useCallback( + async (relayUrls: string[]) => { + const normalizedUrls = relayUrls + .map((relayUrl) => normalizeUrl(relayUrl)) + .filter((url) => !!url && !favoriteRelays.includes(url)) + if (!normalizedUrls.length) return - const draftEvent = createFavoriteRelaysDraftEvent( - [...favoriteRelays, ...normalizedUrls], - relaySetEvents - ) - const newFavoriteRelaysEvent = await publish(draftEvent) - updateFavoriteRelaysEvent(newFavoriteRelaysEvent) - } - - const deleteFavoriteRelays = async (relayUrls: string[]) => { - const normalizedUrls = relayUrls - .map((relayUrl) => normalizeUrl(relayUrl)) - .filter((url) => !!url && favoriteRelays.includes(url)) - if (!normalizedUrls.length) return - - const draftEvent = createFavoriteRelaysDraftEvent( - favoriteRelays.filter((url) => !normalizedUrls.includes(url)), - relaySetEvents - ) - const newFavoriteRelaysEvent = await publish(draftEvent) - updateFavoriteRelaysEvent(newFavoriteRelaysEvent) - } - - const createRelaySet = async (relaySetName: string, relayUrls: string[] = []) => { - const normalizedUrls = relayUrls - .map((url) => normalizeUrl(url)) - .filter((url) => isWebsocketUrl(url)) - const id = randomString() - const relaySetDraftEvent = createRelaySetDraftEvent({ - id, - name: relaySetName, - relayUrls: normalizedUrls - }) - const newRelaySetEvent = await publish(relaySetDraftEvent) - await indexedDb.putReplaceableEvent(newRelaySetEvent) - - const favoriteRelaysDraftEvent = createFavoriteRelaysDraftEvent(favoriteRelays, [ - ...relaySetEvents, - newRelaySetEvent - ]) - const newFavoriteRelaysEvent = await publish(favoriteRelaysDraftEvent) - updateFavoriteRelaysEvent(newFavoriteRelaysEvent) - } - - const addRelaySets = async (newRelaySetEvents: Event[]) => { - const favoriteRelaysDraftEvent = createFavoriteRelaysDraftEvent(favoriteRelays, [ - ...relaySetEvents, - ...newRelaySetEvents - ]) - const newFavoriteRelaysEvent = await publish(favoriteRelaysDraftEvent) - updateFavoriteRelaysEvent(newFavoriteRelaysEvent) - } - - const deleteRelaySet = async (id: string) => { - const newRelaySetEvents = relaySetEvents.filter((event) => { - return getReplaceableEventIdentifier(event) !== id - }) - if (newRelaySetEvents.length === relaySetEvents.length) return - - const draftEvent = createFavoriteRelaysDraftEvent(favoriteRelays, newRelaySetEvents) - const newFavoriteRelaysEvent = await publish(draftEvent) - updateFavoriteRelaysEvent(newFavoriteRelaysEvent) - } - - const updateRelaySet = async (newSet: TRelaySet) => { - const draftEvent = createRelaySetDraftEvent(newSet) - const newRelaySetEvent = await publish(draftEvent) - await indexedDb.putReplaceableEvent(newRelaySetEvent) - - setRelaySetEvents((prev) => { - return prev.map((event) => { - if (getReplaceableEventIdentifier(event) === newSet.id) { - return newRelaySetEvent - } - return event + const draftEvent = createFavoriteRelaysDraftEvent( + [...favoriteRelays, ...normalizedUrls], + relaySetEvents + ) + const newFavoriteRelaysEvent = await publish(draftEvent) + updateFavoriteRelaysEvent(newFavoriteRelaysEvent) + }, + [favoriteRelays, relaySetEvents, publish, updateFavoriteRelaysEvent] + ) + + const deleteFavoriteRelays = useCallback( + async (relayUrls: string[]) => { + const normalizedUrls = relayUrls + .map((relayUrl) => normalizeUrl(relayUrl)) + .filter((url) => !!url && favoriteRelays.includes(url)) + if (!normalizedUrls.length) return + + const draftEvent = createFavoriteRelaysDraftEvent( + favoriteRelays.filter((url) => !normalizedUrls.includes(url)), + relaySetEvents + ) + const newFavoriteRelaysEvent = await publish(draftEvent) + updateFavoriteRelaysEvent(newFavoriteRelaysEvent) + }, + [favoriteRelays, relaySetEvents, publish, updateFavoriteRelaysEvent] + ) + + const createRelaySet = useCallback( + async (relaySetName: string, relayUrls: string[] = []) => { + const normalizedUrls = relayUrls + .map((url) => normalizeUrl(url)) + .filter((url) => isWebsocketUrl(url)) + const id = randomString() + const relaySetDraftEvent = createRelaySetDraftEvent({ + id, + name: relaySetName, + relayUrls: normalizedUrls }) - }) - } - - const reorderFavoriteRelays = async (reorderedRelays: string[]) => { - setFavoriteRelays(reorderedRelays) - const draftEvent = createFavoriteRelaysDraftEvent(reorderedRelays, relaySetEvents) - const newFavoriteRelaysEvent = await publish(draftEvent) - updateFavoriteRelaysEvent(newFavoriteRelaysEvent) - } - - const addBlockedRelays = async (relayUrls: string[]) => { - const normalizedUrls = relayUrls - .map((relayUrl) => normalizeUrl(relayUrl)) - .filter((url) => !!url && !blockedRelays.includes(url)) - if (!normalizedUrls.length) return - const newBlockedRelays = [...blockedRelays, ...normalizedUrls] - setBlockedRelays(newBlockedRelays) - const draftEvent = createBlockedRelaysDraftEvent(newBlockedRelays) - const newBlockedRelaysEvent = await publish(draftEvent) - updateBlockedRelaysEvent(newBlockedRelaysEvent) - } - - const deleteBlockedRelays = async (relayUrls: string[]) => { - const normalizedUrls = relayUrls.map((relayUrl) => normalizeUrl(relayUrl)).filter(Boolean) - const newBlockedRelays = blockedRelays.filter((relay) => !normalizedUrls.includes(relay)) - setBlockedRelays(newBlockedRelays) - const draftEvent = createBlockedRelaysDraftEvent(newBlockedRelays) - const newBlockedRelaysEvent = await publish(draftEvent) - updateBlockedRelaysEvent(newBlockedRelaysEvent) - } - - const reorderRelaySets = async (reorderedSets: TRelaySet[]) => { - setRelaySets(reorderedSets) - const draftEvent = createFavoriteRelaysDraftEvent( + const newRelaySetEvent = await publish(relaySetDraftEvent) + await indexedDb.putReplaceableEvent(newRelaySetEvent) + + const favoriteRelaysDraftEvent = createFavoriteRelaysDraftEvent(favoriteRelays, [ + ...relaySetEvents, + newRelaySetEvent + ]) + const newFavoriteRelaysEvent = await publish(favoriteRelaysDraftEvent) + updateFavoriteRelaysEvent(newFavoriteRelaysEvent) + }, + [favoriteRelays, relaySetEvents, publish, updateFavoriteRelaysEvent] + ) + + const addRelaySets = useCallback( + async (newRelaySetEvents: Event[]) => { + const favoriteRelaysDraftEvent = createFavoriteRelaysDraftEvent(favoriteRelays, [ + ...relaySetEvents, + ...newRelaySetEvents + ]) + const newFavoriteRelaysEvent = await publish(favoriteRelaysDraftEvent) + updateFavoriteRelaysEvent(newFavoriteRelaysEvent) + }, + [favoriteRelays, relaySetEvents, publish, updateFavoriteRelaysEvent] + ) + + const deleteRelaySet = useCallback( + async (id: string) => { + const newRelaySetEvents = relaySetEvents.filter((event) => { + return getReplaceableEventIdentifier(event) !== id + }) + if (newRelaySetEvents.length === relaySetEvents.length) return + + const previousRelaySetEvents = relaySetEvents + setRelaySetEvents(newRelaySetEvents) + + try { + const draftEvent = createFavoriteRelaysDraftEvent(favoriteRelays, newRelaySetEvents) + const newFavoriteRelaysEvent = await publish(draftEvent) + await updateFavoriteRelaysEvent(newFavoriteRelaysEvent) + } catch (e) { + setRelaySetEvents(previousRelaySetEvents) + throw e + } + }, + [favoriteRelays, relaySetEvents, publish, updateFavoriteRelaysEvent] + ) + + const updateRelaySet = useCallback( + async (newSet: TRelaySet) => { + const draftEvent = createRelaySetDraftEvent(newSet) + const newRelaySetEvent = await publish(draftEvent) + await indexedDb.putReplaceableEvent(newRelaySetEvent) + + setRelaySetEvents((prev) => { + return prev.map((event) => { + if (getReplaceableEventIdentifier(event) === newSet.id) { + return newRelaySetEvent + } + return event + }) + }) + }, + [publish] + ) + + const reorderFavoriteRelays = useCallback( + async (reorderedRelays: string[]) => { + setFavoriteRelays(reorderedRelays) + const draftEvent = createFavoriteRelaysDraftEvent(reorderedRelays, relaySetEvents) + const newFavoriteRelaysEvent = await publish(draftEvent) + updateFavoriteRelaysEvent(newFavoriteRelaysEvent) + }, + [relaySetEvents, publish, updateFavoriteRelaysEvent] + ) + + const addBlockedRelays = useCallback( + async (relayUrls: string[]) => { + const normalizedUrls = relayUrls + .map((relayUrl) => normalizeUrl(relayUrl)) + .filter((url) => !!url && !blockedRelays.includes(url)) + if (!normalizedUrls.length) return + const newBlockedRelays = [...blockedRelays, ...normalizedUrls] + setBlockedRelays(newBlockedRelays) + const draftEvent = createBlockedRelaysDraftEvent(newBlockedRelays) + const newBlockedRelaysEvent = await publish(draftEvent) + updateBlockedRelaysEvent(newBlockedRelaysEvent) + }, + [blockedRelays, publish, updateBlockedRelaysEvent] + ) + + const deleteBlockedRelays = useCallback( + async (relayUrls: string[]) => { + const normalizedUrls = relayUrls.map((relayUrl) => normalizeUrl(relayUrl)).filter(Boolean) + const newBlockedRelays = blockedRelays.filter((relay) => !normalizedUrls.includes(relay)) + setBlockedRelays(newBlockedRelays) + const draftEvent = createBlockedRelaysDraftEvent(newBlockedRelays) + const newBlockedRelaysEvent = await publish(draftEvent) + updateBlockedRelaysEvent(newBlockedRelaysEvent) + }, + [blockedRelays, publish, updateBlockedRelaysEvent] + ) + + const reorderRelaySets = useCallback( + async (reorderedSets: TRelaySet[]) => { + setRelaySets(reorderedSets) + const draftEvent = createFavoriteRelaysDraftEvent( + favoriteRelays, + reorderedSets.map((set) => set.aTag) + ) + const newFavoriteRelaysEvent = await publish(draftEvent) + updateFavoriteRelaysEvent(newFavoriteRelaysEvent) + }, + [favoriteRelays, publish, updateFavoriteRelaysEvent] + ) + + const contextValue = useMemo( + () => ({ favoriteRelays, - reorderedSets.map((set) => set.aTag) - ) - const newFavoriteRelaysEvent = await publish(draftEvent) - updateFavoriteRelaysEvent(newFavoriteRelaysEvent) - } + addFavoriteRelays, + deleteFavoriteRelays, + reorderFavoriteRelays, + blockedRelays, + addBlockedRelays, + deleteBlockedRelays, + relaySets, + createRelaySet, + addRelaySets, + deleteRelaySet, + updateRelaySet, + reorderRelaySets + }), + [ + favoriteRelays, + blockedRelays, + relaySets, + addFavoriteRelays, + deleteFavoriteRelays, + reorderFavoriteRelays, + addBlockedRelays, + deleteBlockedRelays, + createRelaySet, + addRelaySets, + deleteRelaySet, + updateRelaySet, + reorderRelaySets + ] + ) return ( - + {children} ) diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 704aa32a..f44033ae 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -1326,10 +1326,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { } const updateFavoriteRelaysEvent = async (favoriteRelaysEvent: Event) => { - const newFavoriteRelaysEvent = await indexedDb.putReplaceableEvent(favoriteRelaysEvent) - if (newFavoriteRelaysEvent.id !== favoriteRelaysEvent.id) return - - setFavoriteRelaysEvent(newFavoriteRelaysEvent) + const stored = await indexedDb.putReplaceableEvent(favoriteRelaysEvent) + /** Always sync UI to IndexedDB winner (same-second updates must not leave stale list + relay sets). */ + setFavoriteRelaysEvent(stored) } const updateBlockedRelaysEvent = async (blockedRelaysEvent: Event) => { diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 24f67e0a..946be013 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -761,7 +761,15 @@ class ClientService extends EventTarget { relays = this.filterPublishingRelays(relays, event) if (specifiedRelayUrls?.length) { + const checkedCount = specifiedRelayUrls.length relays = await this.prioritizePublishUrlList(relays, event, favoriteRelayUrls ?? []) + if (checkedCount > relays.length) { + logger.info('[Publish] Relay picker: checked count exceeds per-publish cap (stage 1)', { + checkedInRelayPicker: checkedCount, + keptAfterOutboxInboxPriorityCap: relays.length, + maxPublishRelays: MAX_PUBLISH_RELAYS + }) + } } else { relays = dedupeNormalizeRelayUrlsOrdered(relays).slice(0, MAX_PUBLISH_RELAYS) } @@ -943,20 +951,36 @@ class ClientService extends EventTarget { return true }) filtered = Array.from(new Set(filtered)) + const countAfterFiltersBeforeCap = filtered.length filtered = await this.capPublishRelayUrlsForPublish( filtered, event, publishExtras?.favoriteRelayUrls ?? [] ) + const uniqueRelayUrls = filtered + + if (relayUrls.length !== uniqueRelayUrls.length || mergedRelayUrls.length !== uniqueRelayUrls.length) { + logger.info('[PublishEvent] Publish target relays (UI selection vs actually contacted)', { + eventId: event.id?.substring(0, 12), + kind: event.kind, + maxPublishRelays: MAX_PUBLISH_RELAYS, + fromPickerOrDetermineCount: relayUrls.length, + afterMergeWithYourOutboxes: mergedRelayUrls.length, + afterReadonlySocialAndStrikeFilter: countAfterFiltersBeforeCap, + finalContactedRelayCount: uniqueRelayUrls.length, + finalRelays: uniqueRelayUrls, + explain: + 'Your NIP-65 write relays are prepended, then the list is de-duplicated, filtered (read-only / social-kind blocks / session strike skips), and capped at maxPublishRelays in outbox→inbox→favorite→fast-write priority. Unchecked relays in the picker are never contacted; checked relays beyond the cap or filtered out are also skipped.' + }) + } + logger.debug('[PublishEvent] Starting publishEvent', { eventId: event.id?.substring(0, 8), kind: event.kind, - relayCount: filtered.length, - skippedStrikes: mergedRelayUrls.length - filtered.length + relayCount: uniqueRelayUrls.length, + relayUrlsPassedInCount: relayUrls.length }) - - const uniqueRelayUrls = filtered if (uniqueRelayUrls.length === 0) { const emptyBatch = new RelayPublishOpBatch('ClientService.publishEvent', event.id, []) emptyBatch.logBegin() diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index f49ee8d5..63f21126 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -359,11 +359,11 @@ class IndexedDbService { logger.debug('[IndexedDB] No existing event found', { storeName, key }) } - if (oldValue?.value && oldValue.value.created_at >= cleanEvent.created_at) { - logger.debug('[IndexedDB] Keeping existing event (newer or same timestamp)', { + if (oldValue?.value && oldValue.value.created_at > cleanEvent.created_at) { + logger.debug('[IndexedDB] Keeping existing event (strictly newer timestamp)', { storeName, key, - existingEventId: oldValue.value.id + existingEventId: oldValue.value.id }) transaction.commit() return resolve(oldValue.value) @@ -929,7 +929,7 @@ class IndexedDbService { const getRequest = store.get(key) getRequest.onsuccess = () => { const oldValue = getRequest.result as TValue | undefined - if (oldValue?.value && oldValue.value.created_at >= cleanEvent.created_at) { + if (oldValue?.value && oldValue.value.created_at > cleanEvent.created_at) { // Update master key link even if event is not newer if (oldValue.masterPublicationKey !== masterKey) { const value = this.formatValue(key, oldValue.value) @@ -2065,15 +2065,15 @@ class IndexedDbService { // Ignore errors } } else if (parts.length >= 2) { - // Replaceable event coordinate format: "kind:pubkey" or "kind:pubkey:d" + // Replaceable coordinate: kind:64-hex-pubkey[:d...] (d may contain ':' per NIP-33) const kind = parseInt(parts[0]!, 10) const pubkey = parts[1]! - const d = parts[2] - if (!isNaN(kind)) { + const d = parts.length > 2 ? parts.slice(2).join(':') : undefined + if (!isNaN(kind) && /^[0-9a-f]{64}$/i.test(pubkey)) { try { const storeName = this.getStoreNameByKind(kind) if (storeName) { - await this.deleteStoreItem(storeName, this.getReplaceableEventKey(pubkey, d)) + await this.deleteStoreItem(storeName, this.getReplaceableEventKey(pubkey.toLowerCase(), d)) removed++ } } catch {