Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
79ec4eef35
  1. 7
      src/components/InviteePicker/index.tsx
  2. 14
      src/components/Note/ZapPoll.tsx
  3. 33
      src/components/PostEditor/PostRelaySelector.tsx
  4. 16
      src/components/Profile/ProfileBadgeDetailDialog.tsx
  5. 4
      src/components/Profile/ProfileHeaderInteractions.tsx
  6. 13
      src/components/Profile/ProfileInteractionsAccordion.tsx
  7. 119
      src/components/ReplyNoteList/index.tsx
  8. 59
      src/hooks/useProfileBadges.tsx
  9. 60
      src/hooks/useProfileFollowPacks.tsx
  10. 73
      src/hooks/useProfileInteractions.tsx
  11. 34
      src/hooks/useProfileRelayUrls.tsx
  12. 43
      src/hooks/useProfileReports.tsx
  13. 2
      src/i18n/locales/de.ts
  14. 2
      src/i18n/locales/en.ts
  15. 5
      src/lib/event.ts
  16. 27
      src/lib/profile-accordion-session-cache.ts
  17. 8
      src/lib/pubkey.ts
  18. 14
      src/pages/primary/SpellsPage/index.tsx
  19. 100
      src/pages/secondary/FollowSetsSettingsPage/index.tsx
  20. 327
      src/providers/FavoriteRelaysProvider.tsx
  21. 7
      src/providers/NostrProvider/index.tsx
  22. 32
      src/services/client.service.ts
  23. 16
      src/services/indexed-db.service.ts

7
src/components/InviteePicker/index.tsx

@ -1,5 +1,6 @@
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { useSearchProfiles } from '@/hooks' import { useSearchProfiles } from '@/hooks'
import { inviteInputToHexPubkey } from '@/lib/pubkey'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { X } from 'lucide-react' import { X } from 'lucide-react'
@ -89,6 +90,12 @@ export function InviteePicker({
type="text" type="text"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} 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…')} placeholder={placeholder ?? t('Search by name or npub…')}
className="mt-1" className="mt-1"
autoComplete="off" autoComplete="off"

14
src/components/Note/ZapPoll.tsx

@ -78,6 +78,18 @@ export default function ZapPoll({
!!meta && !!meta &&
(closed || viewerZapped || event.pubkey === pubkey || tallyRevealed) (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(() => { const satsBounds = useMemo(() => {
if (!meta) return { min: 1, max: undefined as number | undefined } if (!meta) return { min: 1, max: undefined as number | undefined }
return { return {
@ -184,7 +196,7 @@ export default function ZapPoll({
</Button> </Button>
)} )}
<div className="space-y-2"> <div className="space-y-2">
{meta.options.map((opt) => { {optionsDisplayOrder.map((opt) => {
const satsOpt = tally?.satsByOption.get(opt.index) ?? 0 const satsOpt = tally?.satsByOption.get(opt.index) ?? 0
const pct = tally && tally.totalSats > 0 ? (100 * satsOpt) / tally.totalSats : 0 const pct = tally && tally.totalSats > 0 ? (100 * satsOpt) / tally.totalSats : 0
const counts = tally?.receiptCountByOption.get(opt.index) ?? 0 const counts = tally?.receiptCountByOption.get(opt.index) ?? 0

33
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 { NOSTR_URI_FOR_REPLY_PUBKEYS_REGEX } from '@/lib/content-patterns'
import { simplifyUrl, isLocalNetworkUrl, normalizeUrl } from '@/lib/url' import { simplifyUrl, isLocalNetworkUrl, normalizeUrl } from '@/lib/url'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
@ -438,9 +443,17 @@ export default function PostRelaySelector({
<SheetContent side="bottom" className="h-[60vh] p-0"> <SheetContent side="bottom" className="h-[60vh] p-0">
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="p-4 border-b flex items-center justify-between shrink-0 pr-12"> <div className="p-4 border-b flex items-center justify-between shrink-0 pr-12">
<div className="flex flex-col min-w-0 flex-1"> <div className="flex flex-col min-w-0 flex-1 gap-1">
<span className="text-lg font-medium">{t('Select relays')}</span> <span className="text-lg font-medium">{t('Select relays')}</span>
<span className="text-sm text-muted-foreground truncate">{description}</span> <span className="text-sm text-muted-foreground truncate">{description}</span>
{selectedRelayUrls.length >= MAX_PUBLISH_RELAYS && (
<span className="text-xs text-amber-600 dark:text-amber-500">
{t('Publish relay cap hint', {
max: MAX_PUBLISH_RELAYS,
selected: selectedRelayUrls.length
})}
</span>
)}
</div> </div>
</div> </div>
<div className="flex-1 min-h-0 overflow-y-scroll overflow-x-hidden p-4"> <div className="flex-1 min-h-0 overflow-y-scroll overflow-x-hidden p-4">
@ -471,9 +484,19 @@ export default function PostRelaySelector({
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[90vw] max-w-md p-0 max-h-[40vh] flex flex-col overflow-hidden" align="start" side="bottom" sideOffset={8}> <PopoverContent className="w-[90vw] max-w-md p-0 max-h-[40vh] flex flex-col overflow-hidden" align="start" side="bottom" sideOffset={8}>
<div className="p-3 border-b flex items-center justify-between shrink-0"> <div className="p-3 border-b flex flex-col gap-1 shrink-0">
<span className="text-sm font-medium">{t('Select relays')}</span> <div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground truncate ml-2">{description}</span> <span className="text-sm font-medium">{t('Select relays')}</span>
<span className="text-xs text-muted-foreground truncate">{description}</span>
</div>
{selectedRelayUrls.length >= MAX_PUBLISH_RELAYS && (
<span className="text-xs text-amber-600 dark:text-amber-500">
{t('Publish relay cap hint', {
max: MAX_PUBLISH_RELAYS,
selected: selectedRelayUrls.length
})}
</span>
)}
</div> </div>
<div className="max-h-[35vh] min-h-0 overflow-y-scroll overflow-x-hidden p-3"> <div className="max-h-[35vh] min-h-0 overflow-y-scroll overflow-x-hidden p-3">
{content} {content}

16
src/components/Profile/ProfileBadgeDetailDialog.tsx

@ -39,6 +39,11 @@ export default function ProfileBadgeDetailDialog({
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { push } = useSecondaryPage() 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<string[]>([]) const [recipientPubkeys, setRecipientPubkeys] = useState<string[]>([])
const [recipientsLoading, setRecipientsLoading] = useState(false) const [recipientsLoading, setRecipientsLoading] = useState(false)
const [recipientsError, setRecipientsError] = useState(false) const [recipientsError, setRecipientsError] = useState(false)
@ -141,7 +146,7 @@ export default function ProfileBadgeDetailDialog({
<button <button
type="button" type="button"
className="flex w-full items-center gap-2 rounded-md border bg-muted/40 px-2 py-1.5 text-left hover:bg-muted/60" className="flex w-full items-center gap-2 rounded-md border bg-muted/40 px-2 py-1.5 text-left hover:bg-muted/60"
onClick={() => push(toProfile(issuerPubkey))} onClick={() => pushSecondaryAndClose(toProfile(issuerPubkey))}
> >
<UserAvatar userId={issuerPubkey} size="small" className="shrink-0" /> <UserAvatar userId={issuerPubkey} size="small" className="shrink-0" />
<Username userId={issuerPubkey} className="truncate text-sm font-medium" skeletonClassName="h-4" /> <Username userId={issuerPubkey} className="truncate text-sm font-medium" skeletonClassName="h-4" />
@ -165,7 +170,7 @@ export default function ProfileBadgeDetailDialog({
<button <button
type="button" type="button"
className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-left hover:bg-muted/80" className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-left hover:bg-muted/80"
onClick={() => push(toProfile(pk))} onClick={() => pushSecondaryAndClose(toProfile(pk))}
> >
<UserAvatar userId={pk} size="small" className="shrink-0" /> <UserAvatar userId={pk} size="small" className="shrink-0" />
<Username userId={pk} className="truncate text-sm" skeletonClassName="h-4" /> <Username userId={pk} className="truncate text-sm" skeletonClassName="h-4" />
@ -177,7 +182,12 @@ export default function ProfileBadgeDetailDialog({
)} )}
</div> </div>
<Button type="button" variant="secondary" className="w-full" onClick={() => push(toNote(badge.awardId))}> <Button
type="button"
variant="secondary"
className="w-full"
onClick={() => pushSecondaryAndClose(toNote(badge.awardId))}
>
{t('View award')} {t('View award')}
</Button> </Button>
</DialogContent> </DialogContent>

4
src/components/Profile/ProfileHeaderInteractions.tsx

@ -283,14 +283,14 @@ export default function ProfileHeaderInteractions({
return ( return (
<div className="py-2 space-y-3 w-full min-w-0 overflow-visible"> <div className="py-2 space-y-3 w-full min-w-0 overflow-visible">
<Section title={t('Zaps')} isEmpty={displayZaps.length === 0} isLoading={loading}> <Section title={t('Zaps')} isEmpty={displayZaps.length === 0} isLoading={loading}>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 grid-rows-3 gap-1.5"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5 auto-rows-min">
{displayZaps.map((item) => ( {displayZaps.map((item) => (
<ZapBadge key={`zap-${item.pr}`} zap={item} /> <ZapBadge key={`zap-${item.pr}`} zap={item} />
))} ))}
</div> </div>
</Section> </Section>
<Section title={t('Likes')} isEmpty={reactions.length === 0} isLoading={loading}> <Section title={t('Likes')} isEmpty={reactions.length === 0} isLoading={loading}>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 grid-rows-3 gap-1.5"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5 auto-rows-min">
{displayReactions.map((item) => ( {displayReactions.map((item) => (
<ReactionBadge key={`reaction-${item.id}`} event={item} /> <ReactionBadge key={`reaction-${item.id}`} event={item} />
))} ))}

13
src/components/Profile/ProfileInteractionsAccordion.tsx

@ -3,7 +3,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import { ChevronDown } from 'lucide-react' import { ChevronDown } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useEffect } from 'react' import { useEffect, useRef } from 'react'
import { useProfileRelayUrls } from '@/hooks/useProfileRelayUrls' import { useProfileRelayUrls } from '@/hooks/useProfileRelayUrls'
import { useProfileInteractions } from '@/hooks/useProfileInteractions' import { useProfileInteractions } from '@/hooks/useProfileInteractions'
import { useProfileBadges } from '@/hooks/useProfileBadges' import { useProfileBadges } from '@/hooks/useProfileBadges'
@ -36,6 +36,9 @@ function ProfileInteractionsContent({
const { packs, loading: followPacksLoading, refresh: refreshFollowPacks } = useProfileFollowPacks(pubkey, relayUrls) const { packs, loading: followPacksLoading, refresh: refreshFollowPacks } = useProfileFollowPacks(pubkey, relayUrls)
const { reports, loading: reportsLoading, refresh: refreshReports } = useProfileReports(pubkey, viewerPubkey) const { reports, loading: reportsLoading, refresh: refreshReports } = useProfileReports(pubkey, viewerPubkey)
const onRefreshReadyRef = useRef(onRefreshReady)
onRefreshReadyRef.current = onRefreshReady
useEffect(() => { useEffect(() => {
const doRefresh = () => { const doRefresh = () => {
void (async () => { void (async () => {
@ -46,9 +49,11 @@ function ProfileInteractionsContent({
refreshReports() refreshReports()
})() })()
} }
onRefreshReady?.(doRefresh) onRefreshReadyRef.current?.(doRefresh)
return () => { onRefreshReady?.(null) } return () => {
}, [refreshRelayUrls, refresh, refreshBadges, refreshFollowPacks, refreshReports, onRefreshReady]) onRefreshReadyRef.current?.(null)
}
}, [refreshRelayUrls, refresh, refreshBadges, refreshFollowPacks, refreshReports])
return ( return (
<ProfileHeaderInteractions <ProfileHeaderInteractions

119
src/components/ReplyNoteList/index.tsx

@ -18,6 +18,7 @@ import {
isReplyNoteEvent isReplyNoteEvent
} from '@/lib/event' } from '@/lib/event'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { getZapInfoFromEvent } from '@/lib/event-metadata'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { generateBech32IdFromETag } from '@/lib/tag' import { generateBech32IdFromETag } from '@/lib/tag'
@ -56,6 +57,30 @@ type TRootInfo =
const LIMIT = 200 const LIMIT = 200
const SHOW_COUNT = 10 const SHOW_COUNT = 10
function partitionZapReceipts(items: NEvent[]) {
const zaps: NEvent[] = []
const nonZaps: NEvent[] = []
for (const e of items) {
if (e.kind === kinds.Zap) zaps.push(e)
else nonZaps.push(e)
}
return { zaps, nonZaps }
}
/** Zap receipts (9735) at top of reply feeds: largest sats first */
function sortZapReceiptsBySatsDesc(zaps: NEvent[]) {
return [...zaps].sort((a, b) => {
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({ function ReplyNoteList({
index, index,
event, 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) { switch (sort) {
case 'oldest': 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': 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': case 'top':
// Sort by vote score (upvotes - downvotes), then by newest if tied return replyFeedZapsFirst(
return replyEvents.sort((a, b) => { [...nonZaps].sort((a, b) => {
const scoreA = getReplyVoteScore(a) const scoreA = getReplyVoteScore(a)
const scoreB = getReplyVoteScore(b) const scoreB = getReplyVoteScore(b)
if (scoreA !== scoreB) { if (scoreA !== scoreB) {
return scoreB - scoreA // Higher scores first return scoreB - scoreA
} }
return b.created_at - a.created_at // Newest first if tied return b.created_at - a.created_at
}) }),
zaps
)
case 'controversial': case 'controversial':
// Sort by controversy score (min of upvotes and downvotes), then by newest if tied return replyFeedZapsFirst(
return replyEvents.sort((a, b) => { [...nonZaps].sort((a, b) => {
const controversyA = getReplyControversyScore(a) const controversyA = getReplyControversyScore(a)
const controversyB = getReplyControversyScore(b) const controversyB = getReplyControversyScore(b)
if (controversyA !== controversyB) { if (controversyA !== controversyB) {
return controversyB - controversyA // Higher controversy first return controversyB - controversyA
} }
return b.created_at - a.created_at // Newest first if tied return b.created_at - a.created_at
}) }),
zaps
)
case 'most-zapped': case 'most-zapped':
// Sort by total zap amount, then by newest if tied return replyFeedZapsFirst(
return replyEvents.sort((a, b) => { [...nonZaps].sort((a, b) => {
const zapAmountA = getReplyZapAmount(a) const zapAmountA = getReplyZapAmount(a)
const zapAmountB = getReplyZapAmount(b) const zapAmountB = getReplyZapAmount(b)
if (zapAmountA !== zapAmountB) { if (zapAmountA !== zapAmountB) {
return zapAmountB - zapAmountA // Higher zap amounts first return zapAmountB - zapAmountA
} }
return b.created_at - a.created_at // Newest first if tied return b.created_at - a.created_at
}) }),
zaps
)
default: 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, event.id,
@ -257,11 +299,20 @@ function ReplyNoteList({
/** Events that quote the note (from useQuoteEvents) — render with quote styling and without embedded quote. */ /** 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 quoteIdSet = useMemo(() => new Set(quoteEvents.map((e) => e.id)), [quoteEvents])
const mergedFeed = useMemo(() => { 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 if (!showQuotes) return replies
const quoteOnly = quoteEvents.filter((e) => !replyIdSet.has(e.id)) const quoteOnly = quoteEvents.filter((e) => !replyIdSet.has(e.id))
const merged = [...replies, ...quoteOnly] const merged = [...replies, ...quoteOnly]
if (sort === 'oldest') return merged.sort((a, b) => a.created_at - b.created_at) if (sort === 'oldest') return zapsThenTimeSorted(merged, 'asc')
if (sort === 'newest') return merged.sort((a, b) => b.created_at - a.created_at) if (sort === 'newest') return zapsThenTimeSorted(merged, 'desc')
if (sort === 'top' || sort === 'controversial' || sort === 'most-zapped') { if (sort === 'top' || sort === 'controversial' || sort === 'most-zapped') {
const replyIds = new Set(replies.map((r) => r.id)) const replyIds = new Set(replies.map((r) => r.id))
const sortedReplies = [...replies] const sortedReplies = [...replies]
@ -269,7 +320,7 @@ function ReplyNoteList({
const sortedQuotes = [...qo].sort((a, b) => b.created_at - a.created_at) const sortedQuotes = [...qo].sort((a, b) => b.created_at - a.created_at)
return [...sortedReplies, ...sortedQuotes] return [...sortedReplies, ...sortedQuotes]
} }
return merged.sort((a, b) => b.created_at - a.created_at) return zapsThenTimeSorted(merged, 'desc')
}, [replies, quoteEvents, showQuotes, sort, replyIdSet]) }, [replies, quoteEvents, showQuotes, sort, replyIdSet])
const [timelineKey] = useState<string | undefined>(undefined) const [timelineKey] = useState<string | undefined>(undefined)

59
src/hooks/useProfileBadges.tsx

@ -7,7 +7,7 @@ import {
} from '@/lib/fetch-badge-nip58' } from '@/lib/fetch-badge-nip58'
import { import {
profileAccordionGetCachedBadges, profileAccordionGetCachedBadges,
profileAccordionInvalidate, profileAccordionGetCachedRelayUrls,
profileAccordionRelayUrlsKey, profileAccordionRelayUrlsKey,
profileAccordionSetBadges profileAccordionSetBadges
} from '@/lib/profile-accordion-session-cache' } from '@/lib/profile-accordion-session-cache'
@ -55,6 +55,13 @@ function badgeNeedsDefinitionMedia(b: TProfileBadge): boolean {
return !!(parsed && parsed.kind === ExtendedKind.BADGE_DEFINITION) return !!(parsed && parsed.kind === ExtendedKind.BADGE_DEFINITION)
} }
function mergeBadgesByAwardId(seed: TProfileBadge[], fresh: TProfileBadge[]): TProfileBadge[] {
const m = new Map<string, TProfileBadge>()
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<TProfileBadge[]> { async function enrichBadgesFromIndexedDb(badges: TProfileBadge[]): Promise<TProfileBadge[]> {
return Promise.all( return Promise.all(
badges.map(async (b) => { badges.map(async (b) => {
@ -85,6 +92,13 @@ async function enrichBadgesFromIndexedDb(badges: TProfileBadge[]): Promise<TProf
/** Pass relayUrls to share with other profile fetches. */ /** Pass relayUrls to share with other profile fetches. */
export function useProfileBadges(pubkey: string | undefined, relayUrls?: string[]) { export function useProfileBadges(pubkey: string | undefined, relayUrls?: string[]) {
const { blockedRelays } = useFavoriteRelays() 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 [badges, setBadges] = useState<TProfileBadge[]>([]) const [badges, setBadges] = useState<TProfileBadge[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const fetchIdRef = useRef(0) const fetchIdRef = useRef(0)
@ -100,14 +114,22 @@ export function useProfileBadges(pubkey: string | undefined, relayUrls?: string[
return return
} }
const urls = const relayUrlsLatest = relayUrlsRef.current
force || !(relayUrls && relayUrls.length > 0) let urls =
? await buildProfileRelayUrls(pubkey, blockedRelays) relayUrlsLatest && relayUrlsLatest.length > 0
: relayUrls ? relayUrlsLatest
: profileAccordionGetCachedRelayUrls(pubkey) ?? []
if (force || urls.length === 0) {
urls = await buildProfileRelayUrls(pubkey, blockedRelaysRef.current)
}
const relayKey = profileAccordionRelayUrlsKey(urls) const relayKey = profileAccordionRelayUrlsKey(urls)
const seedBadges = profileAccordionGetCachedBadges(pubkey, relayKey)
let deferLoading = !!(force && seedBadges?.length)
if (!force) { if (!force) {
const cached = profileAccordionGetCachedBadges(pubkey, relayKey) const cached = seedBadges
if (cached?.length) { if (cached?.length) {
if (cached.some(badgeNeedsDefinitionMedia)) { if (cached.some(badgeNeedsDefinitionMedia)) {
const enriched = await enrichBadgesFromIndexedDb(cached) const enriched = await enrichBadgesFromIndexedDb(cached)
@ -118,6 +140,7 @@ export function useProfileBadges(pubkey: string | undefined, relayUrls?: string[
setLoading(false) setLoading(false)
return return
} }
deferLoading = false
// Session cache was incomplete and IndexedDB has no definitions — fetch from network below. // Session cache was incomplete and IndexedDB has no definitions — fetch from network below.
} else { } else {
if (myFetchId !== fetchIdRef.current) return 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 if (myFetchId !== fetchIdRef.current) return
setLoading(true) if (!deferLoading) {
setLoading(true)
}
try { try {
const events = await queryService.fetchEvents( 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] const profileBadgesEvent = events.sort((a, b) => b.created_at - a.created_at)[0]
if (!profileBadgesEvent || myFetchId !== fetchIdRef.current) { if (!profileBadgesEvent || myFetchId !== fetchIdRef.current) {
if (myFetchId === fetchIdRef.current) setBadges([]) if (myFetchId === fetchIdRef.current && !seedBadges?.length) setBadges([])
return return
} }
@ -161,7 +190,7 @@ export function useProfileBadges(pubkey: string | undefined, relayUrls?: string[
} }
if (pairs.length === 0) { if (pairs.length === 0) {
setBadges([]) if (!seedBadges?.length) setBadges([])
return return
} }
@ -172,7 +201,7 @@ export function useProfileBadges(pubkey: string | undefined, relayUrls?: string[
return { a, awardId: e } return { a, awardId: e }
} }
const relayPool = mergeNip58BadgeRelayPool(urls, eRelayHint, blockedRelays) const relayPool = mergeNip58BadgeRelayPool(urls, eRelayHint, blockedRelaysRef.current)
const [defEvent, awardEvent] = await Promise.all([ const [defEvent, awardEvent] = await Promise.all([
fetchNip58BadgeDefinition(parsed.pubkey, parsed.d, relayPool), fetchNip58BadgeDefinition(parsed.pubkey, parsed.d, relayPool),
fetchNip58BadgeAward(e, relayPool) fetchNip58BadgeAward(e, relayPool)
@ -212,18 +241,18 @@ export function useProfileBadges(pubkey: string | undefined, relayUrls?: string[
) )
if (myFetchId !== fetchIdRef.current) return if (myFetchId !== fetchIdRef.current) return
setBadges(result) const merged = mergeBadgesByAwardId(seedBadges ?? [], result)
profileAccordionSetBadges(pubkey, relayKey, result) setBadges(merged)
profileAccordionSetBadges(pubkey, relayKey, merged)
} catch { } catch {
if (myFetchId !== fetchIdRef.current) return if (myFetchId !== fetchIdRef.current) return
setBadges([]) if (!seedBadges?.length) setBadges([])
} finally { } finally {
if (myFetchId === fetchIdRef.current) setLoading(false) if (myFetchId === fetchIdRef.current) setLoading(false)
} }
}, [pubkey, blockedRelays, relayUrls]) }, [pubkey, blockedRelaysKey, relayUrlsKey])
const refresh = useCallback(() => { const refresh = useCallback(() => {
if (pubkey) profileAccordionInvalidate(pubkey, 'badges')
void fetchBadges(true) void fetchBadges(true)
}, [pubkey, fetchBadges]) }, [pubkey, fetchBadges])

60
src/hooks/useProfileFollowPacks.tsx

@ -1,7 +1,7 @@
import { ExtendedKind } from '@/constants' import { ExtendedKind, FAST_READ_RELAY_URLS } from '@/constants'
import { import {
profileAccordionGetCachedFollowPacks, profileAccordionGetCachedFollowPacks,
profileAccordionInvalidate, profileAccordionGetCachedRelayUrls,
profileAccordionRelayUrlsKey, profileAccordionRelayUrlsKey,
profileAccordionSetFollowPacks profileAccordionSetFollowPacks
} from '@/lib/profile-accordion-session-cache' } from '@/lib/profile-accordion-session-cache'
@ -27,6 +27,13 @@ export function useProfileFollowPacks(
relayUrls?: string[] relayUrls?: string[]
) { ) {
const { blockedRelays } = useFavoriteRelays() 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<TProfileFollowPack[]>([]) const [packs, setPacks] = useState<TProfileFollowPack[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const fetchIdRef = useRef(0) const fetchIdRef = useRef(0)
@ -42,13 +49,19 @@ export function useProfileFollowPacks(
return return
} }
const urls = const relayUrlsLatest = relayUrlsRef.current
force || !(relayUrls && relayUrls.length > 0) let urls =
? await buildProfileRelayUrls(pubkey, blockedRelays) relayUrlsLatest && relayUrlsLatest.length > 0
: relayUrls ? relayUrlsLatest
const relayKey = profileAccordionRelayUrlsKey(urls) : 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) const cached = profileAccordionGetCachedFollowPacks(pubkey, relayKey)
if (cached) { if (cached) {
if (myFetchId !== fetchIdRef.current) return 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 if (myFetchId !== fetchIdRef.current) return
setLoading(true) if (!seed?.length) {
setLoading(true)
}
try { try {
if (urls.length === 0) {
if (myFetchId === fetchIdRef.current) setPacks([])
return
}
const events = await queryService.fetchEvents( const events = await queryService.fetchEvents(
urls, queryUrls,
[{ '#p': [pubkey], kinds: [ExtendedKind.FOLLOW_PACK], limit: 50 }], [{ '#p': [pubkey], kinds: [ExtendedKind.FOLLOW_PACK], limit: 50 }],
{ eoseTimeout: 2000, globalTimeout: 15000, firstRelayResultGraceMs: false } { eoseTimeout: 2000, globalTimeout: 15000, firstRelayResultGraceMs: false }
) )
if (myFetchId !== fetchIdRef.current) return if (myFetchId !== fetchIdRef.current) return
const result: TProfileFollowPack[] = events.map((evt) => ({ const network: TProfileFollowPack[] = events.map((evt) => ({
event: evt, event: evt,
title: getPackTitle(evt) title: getPackTitle(evt)
})) }))
setPacks(result) const byId = new Map<string, TProfileFollowPack>()
profileAccordionSetFollowPacks(pubkey, relayKey, result) 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 { } catch {
if (myFetchId !== fetchIdRef.current) return if (myFetchId !== fetchIdRef.current) return
setPacks([]) if (!seed?.length) setPacks([])
} finally { } finally {
if (myFetchId === fetchIdRef.current) setLoading(false) if (myFetchId === fetchIdRef.current) setLoading(false)
} }
}, [pubkey, blockedRelays, relayUrls]) }, [pubkey, blockedRelaysKey, relayUrlsKey])
const refresh = useCallback(() => { const refresh = useCallback(() => {
if (pubkey) profileAccordionInvalidate(pubkey, 'followPacks')
void fetchPacks(true) void fetchPacks(true)
}, [pubkey, fetchPacks]) }, [pubkey, fetchPacks])

73
src/hooks/useProfileInteractions.tsx

@ -6,7 +6,7 @@ import { Event, Filter, kinds } from 'nostr-tools'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { import {
profileAccordionGetCachedInteractions, profileAccordionGetCachedInteractions,
profileAccordionInvalidate, profileAccordionGetCachedRelayUrls,
profileAccordionRelayUrlsKey, profileAccordionRelayUrlsKey,
profileAccordionSetInteractions profileAccordionSetInteractions
} from '@/lib/profile-accordion-session-cache' } 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. */ /** 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[]) { export function useProfileInteractions(pubkey: string | undefined, relayUrls?: string[]) {
const { blockedRelays } = useFavoriteRelays() 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<TProfileZap[]>([]) const [zaps, setZaps] = useState<TProfileZap[]>([])
const [reactions, setReactions] = useState<Event[]>([]) const [reactions, setReactions] = useState<Event[]>([])
const [comments, setComments] = useState<Event[]>([]) const [comments, setComments] = useState<Event[]>([])
@ -46,26 +53,45 @@ export function useProfileInteractions(pubkey: string | undefined, relayUrls?: s
return return
} }
const urls = const relayUrlsLatest = relayUrlsRef.current
force || !(relayUrls && relayUrls.length > 0) let urls =
? await buildProfileRelayUrls(pubkey, blockedRelays) relayUrlsLatest && relayUrlsLatest.length > 0
: relayUrls ? relayUrlsLatest
: profileAccordionGetCachedRelayUrls(pubkey) ?? []
if (force || urls.length === 0) {
urls = await buildProfileRelayUrls(pubkey, blockedRelaysRef.current)
}
const relayKey = profileAccordionRelayUrlsKey(urls) const relayKey = profileAccordionRelayUrlsKey(urls)
if (!force) { if (!force) {
const cached = profileAccordionGetCachedInteractions(pubkey, relayKey) const cached = profileAccordionGetCachedInteractions(pubkey, relayKey)
if (cached) { if (cached) {
if (myFetchId !== fetchIdRef.current) return if (myFetchId !== fetchIdRef.current) return
setZaps(cached.zaps) setZaps([...cached.zaps].sort((a, b) => b.amount - a.amount))
setReactions(cached.reactions) setReactions([...cached.reactions].sort((a, b) => b.created_at - a.created_at))
setComments(cached.comments) setComments([...cached.comments].sort((a, b) => b.created_at - a.created_at))
setLoading(false) setLoading(false)
return 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 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 { try {
const profileMetaPromise = replaceableEventService.fetchReplaceableEvent( const profileMetaPromise = replaceableEventService.fetchReplaceableEvent(
@ -75,11 +101,20 @@ export function useProfileInteractions(pubkey: string | undefined, relayUrls?: s
urls urls
) )
const collectedZaps: TProfileZap[] = [] const collectedZaps: TProfileZap[] = seed ? [...seed.zaps] : []
const reactionsByPubkey = new Map<string, Event>() // one reaction per npub, newest kept (profile event only) const reactionsByPubkey = new Map<string, Event>() // one reaction per npub, newest kept (profile event only)
const collectedComments: Event[] = [] if (seed) {
const seenZaps = new Set<string>() for (const e of seed.reactions) {
const seenReactions = new Set<string>() reactionsByPubkey.set(e.pubkey, e)
}
}
const collectedComments: Event[] = seed ? [...seed.comments] : []
const seenZaps = new Set(collectedZaps.map((z) => z.pr))
const seenProfileReactionEventIds = new Set<string>()
if (seed) {
for (const e of seed.reactions) seenProfileReactionEventIds.add(e.id)
}
const seenCommentIds = new Set(collectedComments.map((c) => c.id))
let noteIds: string[] = [] let noteIds: string[] = []
// Phase 1: zaps + profile's recent notes (for comments on those notes) // 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) => { const ingestProfileReaction = (evt: Event) => {
if (!reactionTargetsKind0Profile(evt)) return if (!reactionTargetsKind0Profile(evt)) return
if (hexPubkeysEqual(evt.pubkey, pubkey)) return if (hexPubkeysEqual(evt.pubkey, pubkey)) return
if (seenReactions.has(evt.id)) return if (seenProfileReactionEventIds.has(evt.id)) return
seenReactions.add(evt.id) seenProfileReactionEventIds.add(evt.id)
const existing = reactionsByPubkey.get(evt.pubkey) const existing = reactionsByPubkey.get(evt.pubkey)
if (!existing || evt.created_at > existing.created_at) { if (!existing || evt.created_at > existing.created_at) {
reactionsByPubkey.set(evt.pubkey, evt) reactionsByPubkey.set(evt.pubkey, evt)
@ -158,8 +193,8 @@ export function useProfileInteractions(pubkey: string | undefined, relayUrls?: s
} }
const ingestComment = (evt: Event) => { const ingestComment = (evt: Event) => {
if (hexPubkeysEqual(evt.pubkey, pubkey)) return if (hexPubkeysEqual(evt.pubkey, pubkey)) return
if (seenReactions.has(evt.id)) return if (seenCommentIds.has(evt.id)) return
seenReactions.add(evt.id) seenCommentIds.add(evt.id)
collectedComments.push(evt) collectedComments.push(evt)
flushComments() flushComments()
} }
@ -226,10 +261,10 @@ export function useProfileInteractions(pubkey: string | undefined, relayUrls?: s
} finally { } finally {
if (myFetchId === fetchIdRef.current) setLoading(false) if (myFetchId === fetchIdRef.current) setLoading(false)
} }
}, [pubkey, blockedRelays, relayUrls]) }, [pubkey, blockedRelaysKey, relayUrlsKey])
const refresh = useCallback(() => { 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) void fetchAll(true)
}, [pubkey, fetchAll]) }, [pubkey, fetchAll])

34
src/hooks/useProfileRelayUrls.tsx

@ -1,22 +1,29 @@
import { import {
profileAccordionGetCachedRelayUrls, profileAccordionGetCachedRelayUrls,
profileAccordionInvalidate, profileAccordionRelayUrlsKey,
profileAccordionSetRelayUrls profileAccordionSetRelayUrls
} from '@/lib/profile-accordion-session-cache' } from '@/lib/profile-accordion-session-cache'
import { buildProfileRelayUrls } from '@/lib/profile-relay-urls' 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' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
/** Returns profile relay URLs (outboxes + PROFILE_FETCH). Use for sharing relays across profile fetches. */ /** Returns profile relay URLs (outboxes + PROFILE_FETCH). Use for sharing relays across profile fetches. */
export function useProfileRelayUrls(pubkey: string | undefined, enabled: boolean) { export function useProfileRelayUrls(pubkey: string | undefined, enabled: boolean) {
const { blockedRelays } = useFavoriteRelays() const { blockedRelays } = useFavoriteRelays()
const blockedRelaysRef = useRef(blockedRelays)
blockedRelaysRef.current = blockedRelays
const blockedRelaysKey = profileAccordionRelayUrlsKey(blockedRelays)
const [relayUrls, setRelayUrls] = useState<string[]>([]) const [relayUrls, setRelayUrls] = useState<string[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
/** Stale-while-revalidate: avoid accordion skeleton when refreshing relays but URLs already visible */
const relayUrlsRef = useRef<string[]>([])
relayUrlsRef.current = relayUrls
const fetch = useCallback( const fetch = useCallback(
async (force = false) => { async (force = false) => {
if (!pubkey) { if (!pubkey) {
setRelayUrls([]) setRelayUrls((prev) => (prev.length === 0 ? prev : []))
setLoading(false) setLoading(false)
return 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 { try {
const urls = await buildProfileRelayUrls(pubkey, blockedRelays) const urls = await buildProfileRelayUrls(pubkey, blockedRelaysRef.current)
profileAccordionSetRelayUrls(pubkey, urls) profileAccordionSetRelayUrls(pubkey, urls)
setRelayUrls(urls) setRelayUrls(urls)
} catch { } catch {
setRelayUrls([]) setRelayUrls((prev) => (prev.length === 0 ? prev : []))
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, },
[pubkey, blockedRelays] [pubkey, blockedRelaysKey]
) )
const refresh = useCallback(() => { const refresh = useCallback(() => {
if (pubkey) profileAccordionInvalidate(pubkey, 'relayUrls')
if (!pubkey) return Promise.resolve() if (!pubkey) return Promise.resolve()
/** Do not invalidate: that wipes interactions/badges/follow-packs cache and forces empty refetches */
return fetch(true) return fetch(true)
}, [pubkey, fetch]) }, [pubkey, fetch])
useEffect(() => { useEffect(() => {
if (!pubkey) { if (!pubkey) {
setRelayUrls([]) setRelayUrls((prev) => (prev.length === 0 ? prev : []))
setLoading(false) setLoading(false)
return return
} }
if (!enabled) { if (!enabled) {
const cached = profileAccordionGetCachedRelayUrls(pubkey) const cached = profileAccordionGetCachedRelayUrls(pubkey)
setRelayUrls(cached ?? []) setRelayUrls((prev) => {
if (cached && cached.length > 0) return cached
if (prev.length === 0) return prev
return []
})
setLoading(false) setLoading(false)
return return
} }

43
src/hooks/useProfileReports.tsx

@ -2,7 +2,7 @@ import { ExtendedKind } from '@/constants'
import { buildProfileReportRelayUrls } from '@/lib/profile-report-relay-urls' import { buildProfileReportRelayUrls } from '@/lib/profile-report-relay-urls'
import { import {
profileAccordionGetCachedReports, profileAccordionGetCachedReports,
profileAccordionInvalidate, profileAccordionRelayUrlsKey,
profileAccordionSetReports profileAccordionSetReports
} from '@/lib/profile-accordion-session-cache' } from '@/lib/profile-accordion-session-cache'
import { queryService } from '@/services/client.service' import { queryService } from '@/services/client.service'
@ -18,6 +18,13 @@ export function useProfileReports(
viewerPubkey: string | null | undefined viewerPubkey: string | null | undefined
) { ) {
const { favoriteRelays, blockedRelays } = useFavoriteRelays() 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<Event[]>([]) const [reports, setReports] = useState<Event[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const fetchIdRef = useRef(0) 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 if (myFetchId !== fetchIdRef.current) return
setLoading(true) if (!seed?.length) {
setLoading(true)
}
try { try {
const urls = await buildProfileReportRelayUrls({ const urls = await buildProfileReportRelayUrls({
viewerPubkey: viewer, viewerPubkey: viewer,
favoriteRelays: favoriteRelays ?? [], favoriteRelays: favoriteRelaysRef.current ?? [],
blockedRelays blockedRelays: blockedRelaysRef.current
}) })
if (urls.length === 0) { if (urls.length === 0) {
if (myFetchId === fetchIdRef.current) setReports([]) if (myFetchId === fetchIdRef.current && !seed?.length) setReports([])
return return
} }
@ -66,27 +80,26 @@ export function useProfileReports(
if (myFetchId !== fetchIdRef.current) return if (myFetchId !== fetchIdRef.current) return
const seen = new Set<string>() const byId = new Map<string, Event>()
const deduped: Event[] = [] for (const evt of seed ?? []) byId.set(evt.id, evt)
const seen = new Set<string>(byId.keys())
for (const evt of events) { for (const evt of events) {
if (seen.has(evt.id)) continue if (seen.has(evt.id)) continue
seen.add(evt.id) seen.add(evt.id)
deduped.push(evt) byId.set(evt.id, evt)
} }
deduped.sort((a, b) => b.created_at - a.created_at) const merged = [...byId.values()].sort((a, b) => b.created_at - a.created_at)
setReports(deduped) setReports(merged)
profileAccordionSetReports(profilePubkey, viewer, deduped) profileAccordionSetReports(profilePubkey, viewer, merged)
} catch { } catch {
if (myFetchId !== fetchIdRef.current) return if (myFetchId !== fetchIdRef.current) return
setReports([]) if (!seed?.length) setReports([])
} finally { } finally {
if (myFetchId === fetchIdRef.current) setLoading(false) if (myFetchId === fetchIdRef.current) setLoading(false)
} }
}, [profilePubkey, viewerPubkey, favoriteRelays, blockedRelays]) }, [profilePubkey, viewerPubkey, favoriteRelaysKey, blockedRelaysKey])
const refresh = useCallback(() => { const refresh = useCallback(() => {
const v = viewerPubkey?.trim()
if (profilePubkey && v) profileAccordionInvalidate(profilePubkey, 'reports')
void fetchReports(true) void fetchReports(true)
}, [profilePubkey, viewerPubkey, fetchReports]) }, [profilePubkey, viewerPubkey, fetchReports])

2
src/i18n/locales/de.ts

@ -1374,6 +1374,8 @@ export default {
'Select Media Type': 'Select Media Type', 'Select Media Type': 'Select Media Type',
'Select group...': 'Select group...', 'Select group...': 'Select group...',
'Select relays': 'Select relays', '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 the group where you want to create this discussion.', 'Select the group where you want to create this discussion.',
'Select topic...': 'Select topic...', 'Select topic...': 'Select topic...',

2
src/i18n/locales/en.ts

@ -1429,6 +1429,8 @@ export default {
'Select Media Type': 'Select Media Type', 'Select Media Type': 'Select Media Type',
'Select group...': 'Select group...', 'Select group...': 'Select group...',
'Select relays': 'Select relays', '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 the group where you want to create this discussion.', 'Select the group where you want to create this discussion.',
'Select topic...': 'Select topic...', 'Select topic...': 'Select topic...',

5
src/lib/event.ts

@ -298,6 +298,11 @@ export function isTombstoneKeyForEvent(event: Event, tombstones: Set<string>): b
return false return false
} }
export function filterEventsExcludingTombstones(events: Event[], tombstones: Set<string>): Event[] {
if (tombstones.size === 0) return events
return events.filter((e) => !isTombstoneKeyForEvent(e, tombstones))
}
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)) {

27
src/lib/profile-accordion-session-cache.ts

@ -16,11 +16,15 @@ export type ProfileAccordionInteractionsSnapshot = {
type Entry = { type Entry = {
relayUrls?: string[] 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 relayUrlsKey?: string
interactions?: ProfileAccordionInteractionsSnapshot interactions?: ProfileAccordionInteractionsSnapshot
/** Relay key used for the last interactions fetch (per-slice; avoids races with badges / follow packs) */
interactionsRelayKey?: string
badges?: TProfileBadge[] badges?: TProfileBadge[]
badgesRelayKey?: string
followPacks?: TProfileFollowPack[] followPacks?: TProfileFollowPack[]
followPacksRelayKey?: string
/** viewer hex pubkey → reports */ /** viewer hex pubkey → reports */
reportsByViewer?: Record<string, Event[]> reportsByViewer?: Record<string, Event[]>
} }
@ -51,8 +55,11 @@ export function profileAccordionSetRelayUrls(pubkey: string, urls: string[]): vo
const key = profileAccordionRelayUrlsKey(urls) const key = profileAccordionRelayUrlsKey(urls)
if (e.relayUrlsKey && e.relayUrlsKey !== key) { if (e.relayUrlsKey && e.relayUrlsKey !== key) {
delete e.interactions delete e.interactions
delete e.interactionsRelayKey
delete e.badges delete e.badges
delete e.badgesRelayKey
delete e.followPacks delete e.followPacks
delete e.followPacksRelayKey
} }
e.relayUrls = urls e.relayUrls = urls
e.relayUrlsKey = key e.relayUrlsKey = key
@ -63,7 +70,7 @@ export function profileAccordionGetCachedInteractions(
relayKey: string relayKey: string
): ProfileAccordionInteractionsSnapshot | undefined { ): ProfileAccordionInteractionsSnapshot | undefined {
const e = store.get(pubkey) const e = store.get(pubkey)
if (!e?.interactions || e.relayUrlsKey !== relayKey) return undefined if (!e?.interactions || e.interactionsRelayKey !== relayKey) return undefined
return e.interactions return e.interactions
} }
@ -73,20 +80,20 @@ export function profileAccordionSetInteractions(
data: ProfileAccordionInteractionsSnapshot data: ProfileAccordionInteractionsSnapshot
): void { ): void {
const e = getEntry(pubkey) const e = getEntry(pubkey)
e.relayUrlsKey = relayKey
e.interactions = data e.interactions = data
e.interactionsRelayKey = relayKey
} }
export function profileAccordionGetCachedBadges(pubkey: string, relayKey: string): TProfileBadge[] | undefined { export function profileAccordionGetCachedBadges(pubkey: string, relayKey: string): TProfileBadge[] | undefined {
const e = store.get(pubkey) const e = store.get(pubkey)
if (!e?.badges || e.relayUrlsKey !== relayKey) return undefined if (!e?.badges || e.badgesRelayKey !== relayKey) return undefined
return e.badges return e.badges
} }
export function profileAccordionSetBadges(pubkey: string, relayKey: string, badges: TProfileBadge[]): void { export function profileAccordionSetBadges(pubkey: string, relayKey: string, badges: TProfileBadge[]): void {
const e = getEntry(pubkey) const e = getEntry(pubkey)
e.relayUrlsKey = relayKey
e.badges = badges e.badges = badges
e.badgesRelayKey = relayKey
} }
export function profileAccordionGetCachedFollowPacks( export function profileAccordionGetCachedFollowPacks(
@ -94,7 +101,7 @@ export function profileAccordionGetCachedFollowPacks(
relayKey: string relayKey: string
): TProfileFollowPack[] | undefined { ): TProfileFollowPack[] | undefined {
const e = store.get(pubkey) const e = store.get(pubkey)
if (!e?.followPacks || e.relayUrlsKey !== relayKey) return undefined if (!e?.followPacks || e.followPacksRelayKey !== relayKey) return undefined
return e.followPacks return e.followPacks
} }
@ -104,8 +111,8 @@ export function profileAccordionSetFollowPacks(
packs: TProfileFollowPack[] packs: TProfileFollowPack[]
): void { ): void {
const e = getEntry(pubkey) const e = getEntry(pubkey)
e.relayUrlsKey = relayKey
e.followPacks = packs e.followPacks = packs
e.followPacksRelayKey = relayKey
} }
export function profileAccordionGetCachedReports(profilePubkey: string, viewerPubkey: string): Event[] | undefined { 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.relayUrls
delete e.relayUrlsKey delete e.relayUrlsKey
delete e.interactions delete e.interactions
delete e.interactionsRelayKey
delete e.badges delete e.badges
delete e.badgesRelayKey
delete e.followPacks delete e.followPacks
delete e.followPacksRelayKey
break break
case 'interactions': case 'interactions':
delete e.interactions delete e.interactions
delete e.interactionsRelayKey
break break
case 'badges': case 'badges':
delete e.badges delete e.badges
delete e.badgesRelayKey
break break
case 'followPacks': case 'followPacks':
delete e.followPacks delete e.followPacks
delete e.followPacksRelayKey
break break
case 'reports': case 'reports':
delete e.reportsByViewer delete e.reportsByViewer

8
src/lib/pubkey.ts

@ -81,6 +81,14 @@ export function isValidPubkey(pubkey: string) {
return /^[0-9a-f]{64}$/i.test(pubkey) 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<string, string>({ max: 1000 }) const pubkeyImageCache = new LRUCache<string, string>({ max: 1000 })
// Version identifier to force cache invalidation when algorithm changes // Version identifier to force cache invalidation when algorithm changes

14
src/pages/primary/SpellsPage/index.tsx

@ -48,7 +48,7 @@ import {
FAUX_SPELL_ORDER, FAUX_SPELL_ORDER,
FIRST_RELAY_RESULT_GRACE_MS, FIRST_RELAY_RESULT_GRACE_MS,
} from '@/constants' } from '@/constants'
import { isUserInEventMentions } from '@/lib/event' import { filterEventsExcludingTombstones, isUserInEventMentions } from '@/lib/event'
import { formatPubkey } from '@/lib/pubkey' import { formatPubkey } from '@/lib/pubkey'
import { import {
augmentSubRequestsWithFavoritesFastReadAndInbox, augmentSubRequestsWithFavoritesFastReadAndInbox,
@ -58,6 +58,7 @@ import {
computeKind777SpellFeedSubscriptionKey, computeKind777SpellFeedSubscriptionKey,
computeSpellSubRequestsIdentityKey computeSpellSubRequestsIdentityKey
} from '@/lib/spell-feed-request-identity' } from '@/lib/spell-feed-request-identity'
import { TOMBSTONES_UPDATED_EVENT } from '@/lib/tombstone-events'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import { import {
buildSpellCatalogAuthors, buildSpellCatalogAuthors,
@ -447,7 +448,10 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
{ authors: [pubkey], kinds: [ExtendedKind.FOLLOW_SET], limit: 500 }, { authors: [pubkey], kinds: [ExtendedKind.FOLLOW_SET], limit: 500 },
{ eoseTimeout: 2000, globalTimeout: 15000, firstRelayResultGraceMs: false } { eoseTimeout: 2000, globalTimeout: 15000, firstRelayResultGraceMs: false }
) )
if (!cancelled) setFollowSetListEvents(dedupeFollowSetEventsByD(events)) const tombstones = await indexedDb.getAllTombstones()
if (!cancelled) {
setFollowSetListEvents(dedupeFollowSetEventsByD(filterEventsExcludingTombstones(events, tombstones)))
}
} catch { } catch {
if (!cancelled) setFollowSetListEvents([]) if (!cancelled) setFollowSetListEvents([])
} finally { } finally {
@ -459,6 +463,12 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
} }
}, [pubkey, sortedFavoriteRelaysKey, sortedBlockedRelaysKey, relayMailboxStableKey, followSetManualRefreshKey]) }, [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 * 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. * this IndexedDB read so the feed can subscribe and paint first; the header already reflects the URL.

100
src/pages/secondary/FollowSetsSettingsPage/index.tsx

@ -36,10 +36,13 @@ import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { getRelayUrlsWithFavoritesFastReadAndInbox } from '@/lib/favorites-feed-relays' import { getRelayUrlsWithFavoritesFastReadAndInbox } from '@/lib/favorites-feed-relays'
import { createFollowSetDraftEvent } from '@/lib/draft-event' import { createFollowSetDraftEvent } from '@/lib/draft-event'
import { filterEventsExcludingTombstones } from '@/lib/event'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { TOMBSTONES_UPDATED_EVENT } from '@/lib/tombstone-events'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { queryService } from '@/services/client.service' import { queryService } from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { Pencil, Plus, Trash2, Users } from 'lucide-react' import { Pencil, Plus, Trash2, Users } from 'lucide-react'
@ -101,7 +104,8 @@ const FollowSetsSettingsPage = forwardRef(
{ authors: [pubkey], kinds: [ExtendedKind.FOLLOW_SET], limit: 500 }, { authors: [pubkey], kinds: [ExtendedKind.FOLLOW_SET], limit: 500 },
FOLLOW_SET_FETCH_OPTS FOLLOW_SET_FETCH_OPTS
) )
setLists(dedupeFollowSetEventsByD(events)) const tombstones = await indexedDb.getAllTombstones()
setLists(dedupeFollowSetEventsByD(filterEventsExcludingTombstones(events, tombstones)))
} catch (e) { } catch (e) {
logger.warn('[FollowSetsSettings] Failed to load follow sets', e) logger.warn('[FollowSetsSettings] Failed to load follow sets', e)
toast.error(t('Failed to load follow sets')) toast.error(t('Failed to load follow sets'))
@ -115,6 +119,12 @@ const FollowSetsSettingsPage = forwardRef(
void loadLists() void loadLists()
}, [loadLists]) }, [loadLists])
useEffect(() => {
const onTombstones = () => void loadLists()
window.addEventListener(TOMBSTONES_UPDATED_EVENT, onTombstones)
return () => window.removeEventListener(TOMBSTONES_UPDATED_EVENT, onTombstones)
}, [loadLists])
useEffect(() => { useEffect(() => {
if (!hideTitlebar) { if (!hideTitlebar) {
registerPrimaryPanelRefresh(null) registerPrimaryPanelRefresh(null)
@ -151,55 +161,57 @@ const FollowSetsSettingsPage = forwardRef(
} }
const handleSave = async () => { const handleSave = async () => {
if (!(await checkLogin())) return await checkLogin(async () => {
if (!pubkey) return if (!pubkey) return
let tags: string[][] let tags: string[][]
try { try {
tags = buildFollowSetTags({ tags = buildFollowSetTags({
d: formD, d: formD,
title: formTitle, title: formTitle,
description: formDescription, description: formDescription,
image: formImage, image: formImage,
pubkeys: formPubkeys pubkeys: formPubkeys
}) })
} catch (e) { } catch (e) {
toast.error((e as Error).message) toast.error((e as Error).message)
return return
} }
setSaving(true) setSaving(true)
try { try {
let createdAt = dayjs().unix() let createdAt = dayjs().unix()
if (editing && createdAt === editing.created_at) { if (editing && createdAt === editing.created_at) {
await new Promise((r) => setTimeout(r, 1100)) await new Promise((r) => setTimeout(r, 1100))
createdAt = dayjs().unix() 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 () => { const handleConfirmDelete = async () => {
if (!deleteTarget) return if (!deleteTarget) return
if (!(await checkLogin())) return await checkLogin(async () => {
setDeleting(true) setDeleting(true)
try { try {
await attemptDelete(deleteTarget) await attemptDelete(deleteTarget)
toast.success(t('Follow set deleted')) toast.success(t('Follow set deleted'))
setDeleteTarget(null) setDeleteTarget(null)
await loadLists() await loadLists()
} catch (e) { } catch (e) {
showPublishingError(e instanceof Error ? e : new Error(String(e))) showPublishingError(e instanceof Error ? e : new Error(String(e)))
} finally { } finally {
setDeleting(false) setDeleting(false)
} }
})
} }
return ( return (

327
src/providers/FavoriteRelaysProvider.tsx

@ -9,7 +9,7 @@ import indexedDb from '@/services/indexed-db.service'
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
import { TRelaySet } from '@/types' import { TRelaySet } from '@/types'
import { Event, kinds } from 'nostr-tools' 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 { FavoriteRelaysContext } from './favorite-relays-context'
import { useNostr } from './NostrProvider' import { useNostr } from './NostrProvider'
@ -148,146 +148,201 @@ export function FavoriteRelaysProvider({ children }: { children: React.ReactNode
) )
}, [relaySetEvents, blockedRelays]) }, [relaySetEvents, blockedRelays])
const addFavoriteRelays = async (relayUrls: string[]) => { const addFavoriteRelays = useCallback(
const normalizedUrls = relayUrls async (relayUrls: string[]) => {
.map((relayUrl) => normalizeUrl(relayUrl)) const normalizedUrls = relayUrls
.filter((url) => !!url && !favoriteRelays.includes(url)) .map((relayUrl) => normalizeUrl(relayUrl))
if (!normalizedUrls.length) return .filter((url) => !!url && !favoriteRelays.includes(url))
if (!normalizedUrls.length) return
const draftEvent = createFavoriteRelaysDraftEvent( const draftEvent = createFavoriteRelaysDraftEvent(
[...favoriteRelays, ...normalizedUrls], [...favoriteRelays, ...normalizedUrls],
relaySetEvents relaySetEvents
) )
const newFavoriteRelaysEvent = await publish(draftEvent) const newFavoriteRelaysEvent = await publish(draftEvent)
updateFavoriteRelaysEvent(newFavoriteRelaysEvent) updateFavoriteRelaysEvent(newFavoriteRelaysEvent)
} },
[favoriteRelays, relaySetEvents, publish, updateFavoriteRelaysEvent]
const deleteFavoriteRelays = async (relayUrls: string[]) => { )
const normalizedUrls = relayUrls
.map((relayUrl) => normalizeUrl(relayUrl)) const deleteFavoriteRelays = useCallback(
.filter((url) => !!url && favoriteRelays.includes(url)) async (relayUrls: string[]) => {
if (!normalizedUrls.length) return const normalizedUrls = relayUrls
.map((relayUrl) => normalizeUrl(relayUrl))
const draftEvent = createFavoriteRelaysDraftEvent( .filter((url) => !!url && favoriteRelays.includes(url))
favoriteRelays.filter((url) => !normalizedUrls.includes(url)), if (!normalizedUrls.length) return
relaySetEvents
) const draftEvent = createFavoriteRelaysDraftEvent(
const newFavoriteRelaysEvent = await publish(draftEvent) favoriteRelays.filter((url) => !normalizedUrls.includes(url)),
updateFavoriteRelaysEvent(newFavoriteRelaysEvent) relaySetEvents
} )
const newFavoriteRelaysEvent = await publish(draftEvent)
const createRelaySet = async (relaySetName: string, relayUrls: string[] = []) => { updateFavoriteRelaysEvent(newFavoriteRelaysEvent)
const normalizedUrls = relayUrls },
.map((url) => normalizeUrl(url)) [favoriteRelays, relaySetEvents, publish, updateFavoriteRelaysEvent]
.filter((url) => isWebsocketUrl(url)) )
const id = randomString()
const relaySetDraftEvent = createRelaySetDraftEvent({ const createRelaySet = useCallback(
id, async (relaySetName: string, relayUrls: string[] = []) => {
name: relaySetName, const normalizedUrls = relayUrls
relayUrls: normalizedUrls .map((url) => normalizeUrl(url))
}) .filter((url) => isWebsocketUrl(url))
const newRelaySetEvent = await publish(relaySetDraftEvent) const id = randomString()
await indexedDb.putReplaceableEvent(newRelaySetEvent) const relaySetDraftEvent = createRelaySetDraftEvent({
id,
const favoriteRelaysDraftEvent = createFavoriteRelaysDraftEvent(favoriteRelays, [ name: relaySetName,
...relaySetEvents, relayUrls: normalizedUrls
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 newRelaySetEvent = await publish(relaySetDraftEvent)
} await indexedDb.putReplaceableEvent(newRelaySetEvent)
const reorderFavoriteRelays = async (reorderedRelays: string[]) => { const favoriteRelaysDraftEvent = createFavoriteRelaysDraftEvent(favoriteRelays, [
setFavoriteRelays(reorderedRelays) ...relaySetEvents,
const draftEvent = createFavoriteRelaysDraftEvent(reorderedRelays, relaySetEvents) newRelaySetEvent
const newFavoriteRelaysEvent = await publish(draftEvent) ])
updateFavoriteRelaysEvent(newFavoriteRelaysEvent) const newFavoriteRelaysEvent = await publish(favoriteRelaysDraftEvent)
} updateFavoriteRelaysEvent(newFavoriteRelaysEvent)
},
const addBlockedRelays = async (relayUrls: string[]) => { [favoriteRelays, relaySetEvents, publish, updateFavoriteRelaysEvent]
const normalizedUrls = relayUrls )
.map((relayUrl) => normalizeUrl(relayUrl))
.filter((url) => !!url && !blockedRelays.includes(url)) const addRelaySets = useCallback(
if (!normalizedUrls.length) return async (newRelaySetEvents: Event[]) => {
const newBlockedRelays = [...blockedRelays, ...normalizedUrls] const favoriteRelaysDraftEvent = createFavoriteRelaysDraftEvent(favoriteRelays, [
setBlockedRelays(newBlockedRelays) ...relaySetEvents,
const draftEvent = createBlockedRelaysDraftEvent(newBlockedRelays) ...newRelaySetEvents
const newBlockedRelaysEvent = await publish(draftEvent) ])
updateBlockedRelaysEvent(newBlockedRelaysEvent) const newFavoriteRelaysEvent = await publish(favoriteRelaysDraftEvent)
} updateFavoriteRelaysEvent(newFavoriteRelaysEvent)
},
const deleteBlockedRelays = async (relayUrls: string[]) => { [favoriteRelays, relaySetEvents, publish, updateFavoriteRelaysEvent]
const normalizedUrls = relayUrls.map((relayUrl) => normalizeUrl(relayUrl)).filter(Boolean) )
const newBlockedRelays = blockedRelays.filter((relay) => !normalizedUrls.includes(relay))
setBlockedRelays(newBlockedRelays) const deleteRelaySet = useCallback(
const draftEvent = createBlockedRelaysDraftEvent(newBlockedRelays) async (id: string) => {
const newBlockedRelaysEvent = await publish(draftEvent) const newRelaySetEvents = relaySetEvents.filter((event) => {
updateBlockedRelaysEvent(newBlockedRelaysEvent) return getReplaceableEventIdentifier(event) !== id
} })
if (newRelaySetEvents.length === relaySetEvents.length) return
const reorderRelaySets = async (reorderedSets: TRelaySet[]) => {
setRelaySets(reorderedSets) const previousRelaySetEvents = relaySetEvents
const draftEvent = createFavoriteRelaysDraftEvent( 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, favoriteRelays,
reorderedSets.map((set) => set.aTag) addFavoriteRelays,
) deleteFavoriteRelays,
const newFavoriteRelaysEvent = await publish(draftEvent) reorderFavoriteRelays,
updateFavoriteRelaysEvent(newFavoriteRelaysEvent) blockedRelays,
} addBlockedRelays,
deleteBlockedRelays,
relaySets,
createRelaySet,
addRelaySets,
deleteRelaySet,
updateRelaySet,
reorderRelaySets
}),
[
favoriteRelays,
blockedRelays,
relaySets,
addFavoriteRelays,
deleteFavoriteRelays,
reorderFavoriteRelays,
addBlockedRelays,
deleteBlockedRelays,
createRelaySet,
addRelaySets,
deleteRelaySet,
updateRelaySet,
reorderRelaySets
]
)
return ( return (
<FavoriteRelaysContext.Provider <FavoriteRelaysContext.Provider value={contextValue}>
value={{
favoriteRelays,
addFavoriteRelays,
deleteFavoriteRelays,
reorderFavoriteRelays,
blockedRelays,
addBlockedRelays,
deleteBlockedRelays,
relaySets,
createRelaySet,
addRelaySets,
deleteRelaySet,
updateRelaySet,
reorderRelaySets
}}
>
{children} {children}
</FavoriteRelaysContext.Provider> </FavoriteRelaysContext.Provider>
) )

7
src/providers/NostrProvider/index.tsx

@ -1326,10 +1326,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
} }
const updateFavoriteRelaysEvent = async (favoriteRelaysEvent: Event) => { const updateFavoriteRelaysEvent = async (favoriteRelaysEvent: Event) => {
const newFavoriteRelaysEvent = await indexedDb.putReplaceableEvent(favoriteRelaysEvent) const stored = await indexedDb.putReplaceableEvent(favoriteRelaysEvent)
if (newFavoriteRelaysEvent.id !== favoriteRelaysEvent.id) return /** Always sync UI to IndexedDB winner (same-second updates must not leave stale list + relay sets). */
setFavoriteRelaysEvent(stored)
setFavoriteRelaysEvent(newFavoriteRelaysEvent)
} }
const updateBlockedRelaysEvent = async (blockedRelaysEvent: Event) => { const updateBlockedRelaysEvent = async (blockedRelaysEvent: Event) => {

32
src/services/client.service.ts

@ -761,7 +761,15 @@ class ClientService extends EventTarget {
relays = this.filterPublishingRelays(relays, event) relays = this.filterPublishingRelays(relays, event)
if (specifiedRelayUrls?.length) { if (specifiedRelayUrls?.length) {
const checkedCount = specifiedRelayUrls.length
relays = await this.prioritizePublishUrlList(relays, event, favoriteRelayUrls ?? []) 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 { } else {
relays = dedupeNormalizeRelayUrlsOrdered(relays).slice(0, MAX_PUBLISH_RELAYS) relays = dedupeNormalizeRelayUrlsOrdered(relays).slice(0, MAX_PUBLISH_RELAYS)
} }
@ -943,20 +951,36 @@ class ClientService extends EventTarget {
return true return true
}) })
filtered = Array.from(new Set(filtered)) filtered = Array.from(new Set(filtered))
const countAfterFiltersBeforeCap = filtered.length
filtered = await this.capPublishRelayUrlsForPublish( filtered = await this.capPublishRelayUrlsForPublish(
filtered, filtered,
event, event,
publishExtras?.favoriteRelayUrls ?? [] 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', { logger.debug('[PublishEvent] Starting publishEvent', {
eventId: event.id?.substring(0, 8), eventId: event.id?.substring(0, 8),
kind: event.kind, kind: event.kind,
relayCount: filtered.length, relayCount: uniqueRelayUrls.length,
skippedStrikes: mergedRelayUrls.length - filtered.length relayUrlsPassedInCount: relayUrls.length
}) })
const uniqueRelayUrls = filtered
if (uniqueRelayUrls.length === 0) { if (uniqueRelayUrls.length === 0) {
const emptyBatch = new RelayPublishOpBatch('ClientService.publishEvent', event.id, []) const emptyBatch = new RelayPublishOpBatch('ClientService.publishEvent', event.id, [])
emptyBatch.logBegin() emptyBatch.logBegin()

16
src/services/indexed-db.service.ts

@ -359,11 +359,11 @@ class IndexedDbService {
logger.debug('[IndexedDB] No existing event found', { storeName, key }) logger.debug('[IndexedDB] No existing event found', { storeName, key })
} }
if (oldValue?.value && oldValue.value.created_at >= cleanEvent.created_at) { if (oldValue?.value && oldValue.value.created_at > cleanEvent.created_at) {
logger.debug('[IndexedDB] Keeping existing event (newer or same timestamp)', { logger.debug('[IndexedDB] Keeping existing event (strictly newer timestamp)', {
storeName, storeName,
key, key,
existingEventId: oldValue.value.id existingEventId: oldValue.value.id
}) })
transaction.commit() transaction.commit()
return resolve(oldValue.value) return resolve(oldValue.value)
@ -929,7 +929,7 @@ class IndexedDbService {
const getRequest = store.get(key) const getRequest = store.get(key)
getRequest.onsuccess = () => { getRequest.onsuccess = () => {
const oldValue = getRequest.result as TValue<Event> | undefined const oldValue = getRequest.result as TValue<Event> | 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 // Update master key link even if event is not newer
if (oldValue.masterPublicationKey !== masterKey) { if (oldValue.masterPublicationKey !== masterKey) {
const value = this.formatValue(key, oldValue.value) const value = this.formatValue(key, oldValue.value)
@ -2065,15 +2065,15 @@ class IndexedDbService {
// Ignore errors // Ignore errors
} }
} else if (parts.length >= 2) { } 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 kind = parseInt(parts[0]!, 10)
const pubkey = parts[1]! const pubkey = parts[1]!
const d = parts[2] const d = parts.length > 2 ? parts.slice(2).join(':') : undefined
if (!isNaN(kind)) { if (!isNaN(kind) && /^[0-9a-f]{64}$/i.test(pubkey)) {
try { try {
const storeName = this.getStoreNameByKind(kind) const storeName = this.getStoreNameByKind(kind)
if (storeName) { if (storeName) {
await this.deleteStoreItem(storeName, this.getReplaceableEventKey(pubkey, d)) await this.deleteStoreItem(storeName, this.getReplaceableEventKey(pubkey.toLowerCase(), d))
removed++ removed++
} }
} catch { } catch {

Loading…
Cancel
Save