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. 14
      src/services/indexed-db.service.ts

7
src/components/InviteePicker/index.tsx

@ -1,5 +1,6 @@ @@ -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({ @@ -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"

14
src/components/Note/ZapPoll.tsx

@ -78,6 +78,18 @@ export default function ZapPoll({ @@ -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({ @@ -184,7 +196,7 @@ export default function ZapPoll({
</Button>
)}
<div className="space-y-2">
{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

33
src/components/PostEditor/PostRelaySelector.tsx

@ -1,4 +1,9 @@ @@ -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({ @@ -438,9 +443,17 @@ export default function PostRelaySelector({
<SheetContent side="bottom" className="h-[60vh] p-0">
<div className="flex flex-col h-full">
<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-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 className="flex-1 min-h-0 overflow-y-scroll overflow-x-hidden p-4">
@ -471,9 +484,19 @@ export default function PostRelaySelector({ @@ -471,9 +484,19 @@ export default function PostRelaySelector({
</Button>
</PopoverTrigger>
<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">
<span className="text-sm font-medium">{t('Select relays')}</span>
<span className="text-xs text-muted-foreground truncate ml-2">{description}</span>
<div className="p-3 border-b flex flex-col gap-1 shrink-0">
<div className="flex items-center justify-between gap-2">
<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 className="max-h-[35vh] min-h-0 overflow-y-scroll overflow-x-hidden p-3">
{content}

16
src/components/Profile/ProfileBadgeDetailDialog.tsx

@ -39,6 +39,11 @@ export default function ProfileBadgeDetailDialog({ @@ -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<string[]>([])
const [recipientsLoading, setRecipientsLoading] = useState(false)
const [recipientsError, setRecipientsError] = useState(false)
@ -141,7 +146,7 @@ export default function ProfileBadgeDetailDialog({ @@ -141,7 +146,7 @@ export default function ProfileBadgeDetailDialog({
<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"
onClick={() => push(toProfile(issuerPubkey))}
onClick={() => pushSecondaryAndClose(toProfile(issuerPubkey))}
>
<UserAvatar userId={issuerPubkey} size="small" className="shrink-0" />
<Username userId={issuerPubkey} className="truncate text-sm font-medium" skeletonClassName="h-4" />
@ -165,7 +170,7 @@ export default function ProfileBadgeDetailDialog({ @@ -165,7 +170,7 @@ export default function ProfileBadgeDetailDialog({
<button
type="button"
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" />
<Username userId={pk} className="truncate text-sm" skeletonClassName="h-4" />
@ -177,7 +182,12 @@ export default function ProfileBadgeDetailDialog({ @@ -177,7 +182,12 @@ export default function ProfileBadgeDetailDialog({
)}
</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')}
</Button>
</DialogContent>

4
src/components/Profile/ProfileHeaderInteractions.tsx

@ -283,14 +283,14 @@ export default function ProfileHeaderInteractions({ @@ -283,14 +283,14 @@ export default function ProfileHeaderInteractions({
return (
<div className="py-2 space-y-3 w-full min-w-0 overflow-visible">
<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) => (
<ZapBadge key={`zap-${item.pr}`} zap={item} />
))}
</div>
</Section>
<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) => (
<ReactionBadge key={`reaction-${item.id}`} event={item} />
))}

13
src/components/Profile/ProfileInteractionsAccordion.tsx

@ -3,7 +3,7 @@ import { Skeleton } from '@/components/ui/skeleton' @@ -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({ @@ -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({ @@ -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 (
<ProfileHeaderInteractions

119
src/components/ReplyNoteList/index.tsx

@ -18,6 +18,7 @@ import { @@ -18,6 +18,7 @@ import {
isReplyNoteEvent
} from '@/lib/event'
import logger from '@/lib/logger'
import { getZapInfoFromEvent } from '@/lib/event-metadata'
import { normalizeUrl } from '@/lib/url'
import { toNote } from '@/lib/link'
import { generateBech32IdFromETag } from '@/lib/tag'
@ -56,6 +57,30 @@ type TRootInfo = @@ -56,6 +57,30 @@ type TRootInfo =
const LIMIT = 200
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({
index,
event,
@ -205,44 +230,61 @@ function ReplyNoteList({ @@ -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({ @@ -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({ @@ -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<string | undefined>(undefined)

59
src/hooks/useProfileBadges.tsx

@ -7,7 +7,7 @@ import { @@ -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 { @@ -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<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[]> {
return Promise.all(
badges.map(async (b) => {
@ -85,6 +92,13 @@ async function enrichBadgesFromIndexedDb(badges: TProfileBadge[]): Promise<TProf @@ -85,6 +92,13 @@ async function enrichBadgesFromIndexedDb(badges: TProfileBadge[]): Promise<TProf
/** Pass relayUrls to share with other profile fetches. */
export function useProfileBadges(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 [badges, setBadges] = useState<TProfileBadge[]>([])
const [loading, setLoading] = useState(false)
const fetchIdRef = useRef(0)
@ -100,14 +114,22 @@ export function useProfileBadges(pubkey: string | undefined, relayUrls?: string[ @@ -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[ @@ -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[ @@ -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[ @@ -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[ @@ -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[ @@ -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[ @@ -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])

60
src/hooks/useProfileFollowPacks.tsx

@ -1,7 +1,7 @@ @@ -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( @@ -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<TProfileFollowPack[]>([])
const [loading, setLoading] = useState(false)
const fetchIdRef = useRef(0)
@ -42,13 +49,19 @@ export function useProfileFollowPacks( @@ -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( @@ -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<string, TProfileFollowPack>()
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])

73
src/hooks/useProfileInteractions.tsx

@ -6,7 +6,7 @@ import { Event, Filter, kinds } from 'nostr-tools' @@ -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 @@ -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<TProfileZap[]>([])
const [reactions, setReactions] = useState<Event[]>([])
const [comments, setComments] = useState<Event[]>([])
@ -46,26 +53,45 @@ export function useProfileInteractions(pubkey: string | undefined, relayUrls?: s @@ -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 @@ -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<string, Event>() // one reaction per npub, newest kept (profile event only)
const collectedComments: Event[] = []
const seenZaps = new Set<string>()
const seenReactions = new Set<string>()
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<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[] = []
// Phase 1: zaps + profile's recent notes (for comments on those notes)
@ -148,8 +183,8 @@ export function useProfileInteractions(pubkey: string | undefined, relayUrls?: s @@ -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 @@ -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 @@ -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])

34
src/hooks/useProfileRelayUrls.tsx

@ -1,22 +1,29 @@ @@ -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<string[]>([])
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(
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 @@ -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
}

43
src/hooks/useProfileReports.tsx

@ -2,7 +2,7 @@ import { ExtendedKind } from '@/constants' @@ -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( @@ -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<Event[]>([])
const [loading, setLoading] = useState(false)
const fetchIdRef = useRef(0)
@ -44,17 +51,24 @@ export function useProfileReports( @@ -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( @@ -66,27 +80,26 @@ export function useProfileReports(
if (myFetchId !== fetchIdRef.current) return
const seen = new Set<string>()
const deduped: Event[] = []
const byId = new Map<string, Event>()
for (const evt of seed ?? []) byId.set(evt.id, evt)
const seen = new Set<string>(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])

2
src/i18n/locales/de.ts

@ -1374,6 +1374,8 @@ export default { @@ -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...',

2
src/i18n/locales/en.ts

@ -1429,6 +1429,8 @@ export default { @@ -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...',

5
src/lib/event.ts

@ -298,6 +298,11 @@ export function isTombstoneKeyForEvent(event: Event, tombstones: Set<string>): b @@ -298,6 +298,11 @@ export function isTombstoneKeyForEvent(event: Event, tombstones: Set<string>): b
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) {
const hints = client.getEventHints(event.id).slice(0, 2)
if (isReplaceableEvent(event.kind)) {

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

@ -16,11 +16,15 @@ export type ProfileAccordionInteractionsSnapshot = { @@ -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<string, Event[]>
}
@ -51,8 +55,11 @@ export function profileAccordionSetRelayUrls(pubkey: string, urls: string[]): vo @@ -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( @@ -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( @@ -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( @@ -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( @@ -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 @@ -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

8
src/lib/pubkey.ts

@ -81,6 +81,14 @@ export function isValidPubkey(pubkey: string) { @@ -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<string, string>({ max: 1000 })
// Version identifier to force cache invalidation when algorithm changes

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

@ -48,7 +48,7 @@ import { @@ -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 { @@ -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<TPageRef>(function SpellsPage( @@ -447,7 +448,10 @@ const SpellsPage = forwardRef<TPageRef>(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<TPageRef>(function SpellsPage( @@ -459,6 +463,12 @@ const SpellsPage = forwardRef<TPageRef>(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.

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

@ -36,10 +36,13 @@ import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' @@ -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( @@ -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( @@ -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( @@ -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 (

327
src/providers/FavoriteRelaysProvider.tsx

@ -9,7 +9,7 @@ import indexedDb from '@/services/indexed-db.service' @@ -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 @@ -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 (
<FavoriteRelaysContext.Provider
value={{
favoriteRelays,
addFavoriteRelays,
deleteFavoriteRelays,
reorderFavoriteRelays,
blockedRelays,
addBlockedRelays,
deleteBlockedRelays,
relaySets,
createRelaySet,
addRelaySets,
deleteRelaySet,
updateRelaySet,
reorderRelaySets
}}
>
<FavoriteRelaysContext.Provider value={contextValue}>
{children}
</FavoriteRelaysContext.Provider>
)

7
src/providers/NostrProvider/index.tsx

@ -1326,10 +1326,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -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) => {

32
src/services/client.service.ts

@ -761,7 +761,15 @@ class ClientService extends EventTarget { @@ -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 { @@ -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()

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

@ -359,8 +359,8 @@ class IndexedDbService { @@ -359,8 +359,8 @@ 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
@ -929,7 +929,7 @@ class IndexedDbService { @@ -929,7 +929,7 @@ class IndexedDbService {
const getRequest = store.get(key)
getRequest.onsuccess = () => {
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
if (oldValue.masterPublicationKey !== masterKey) {
const value = this.formatValue(key, oldValue.value)
@ -2065,15 +2065,15 @@ class IndexedDbService { @@ -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 {

Loading…
Cancel
Save