Browse Source

fix feed-switching of relays on the home feed

fixing badge rendering
imwald
Silberengel 1 month ago
parent
commit
e5e7ba1687
  1. 4
      package-lock.json
  2. 2
      package.json
  3. 42
      src/components/ContentPreview/FollowPackPreview.tsx
  4. 6
      src/components/NormalFeed/index.tsx
  5. 23
      src/components/NoteList/index.tsx
  6. 10
      src/components/Profile/ProfileBadgeDetailDialog.tsx
  7. 1
      src/components/Profile/ProfileHeaderInteractions.tsx
  8. 2
      src/components/Sidebar/NotificationButton.tsx
  9. 3
      src/constants.ts
  10. 162
      src/hooks/useProfileBadges.tsx
  11. 1
      src/i18n/locales/en.ts
  12. 27
      src/lib/badge-definition-media.ts
  13. 75
      src/lib/fetch-badge-nip58.ts
  14. 16
      src/pages/primary/NoteListPage/RelaysFeed.tsx

4
package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "jumble-imwald", "name": "jumble-imwald",
"version": "20.0.0", "version": "20.0.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "jumble-imwald", "name": "jumble-imwald",
"version": "20.0.0", "version": "20.0.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "jumble-imwald", "name": "jumble-imwald",
"version": "20.0.0", "version": "20.0.1",
"description": "A user-friendly Nostr client focused on relay feed browsing and relay discovery, forked from Jumble", "description": "A user-friendly Nostr client focused on relay feed browsing and relay discovery, forked from Jumble",
"private": true, "private": true,
"type": "module", "type": "module",

42
src/components/ContentPreview/FollowPackPreview.tsx

@ -1,3 +1,5 @@
import { resolveHttpMediaUrl } from '@/lib/badge-definition-media'
import { getImetaInfosFromEvent } from '@/lib/event'
import { getPubkeysFromPTags } from '@/lib/tag' import { getPubkeysFromPTags } from '@/lib/tag'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -6,13 +8,28 @@ import { useMuteList } from '@/contexts/mute-list-context'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { Users } from 'lucide-react' import { Users } from 'lucide-react'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import UserAvatar, { SimpleUserAvatar } from '@/components/UserAvatar' import UserAvatar, { SimpleUserAvatar } from '@/components/UserAvatar'
import Username from '@/components/Username' import Username from '@/components/Username'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
/** NIP-style `image` tags on kind 39089; falls back to first NIP-94 `imeta` URL. */
function followPackBannerUrlFromEvent(event: Event): string | undefined {
for (const t of event.tags) {
if (t[0] === 'image' && t[1]) {
const u = resolveHttpMediaUrl(t[1])
if (u) return u
}
}
for (const im of getImetaInfosFromEvent(event)) {
const u = resolveHttpMediaUrl(im.url)
if (u) return u
}
return undefined
}
export default function FollowPackPreview({ export default function FollowPackPreview({
event, event,
className className
@ -26,8 +43,14 @@ export default function FollowPackPreview({
const followings = followList?.followings ?? [] const followings = followList?.followings ?? []
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [bannerFailed, setBannerFailed] = useState(false)
const packPubkeys = useMemo(() => getPubkeysFromPTags(event.tags), [event.tags]) const packPubkeys = useMemo(() => getPubkeysFromPTags(event.tags), [event.tags])
const bannerUrl = useMemo(() => followPackBannerUrlFromEvent(event), [event])
useEffect(() => {
setBannerFailed(false)
}, [event.id])
const getPackTitle = (pack: Event): string => { const getPackTitle = (pack: Event): string => {
const titleTag = pack.tags.find((tag) => tag[0] === 'title' || tag[0] === 'name') const titleTag = pack.tags.find((tag) => tag[0] === 'title' || tag[0] === 'name')
@ -87,7 +110,21 @@ export default function FollowPackPreview({
) )
return ( return (
<div className={cn('rounded-lg border bg-muted/30 p-3', className)}> <div className={cn('overflow-hidden rounded-lg border bg-muted/30', className)}>
{bannerUrl && !bannerFailed ? (
<div className="relative w-full max-h-52 overflow-hidden bg-muted">
<img
src={bannerUrl}
alt={title}
className="h-auto w-full max-h-52 object-cover object-center"
loading="lazy"
decoding="async"
referrerPolicy="no-referrer"
onError={() => setBannerFailed(true)}
/>
</div>
) : null}
<div className="p-3">
<div className="mb-2 space-y-1"> <div className="mb-2 space-y-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1"> <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-sm text-muted-foreground">[{t('Follow Pack')}]</span> <span className="text-sm text-muted-foreground">[{t('Follow Pack')}]</span>
@ -153,6 +190,7 @@ export default function FollowPackPreview({
)} )}
</Button> </Button>
)} )}
</div>
</div> </div>
) )
} }

6
src/components/NormalFeed/index.tsx

@ -24,6 +24,8 @@ const NormalFeed = forwardRef<TNoteListRef, {
*/ */
preserveTimelineOnSubRequestsChange?: boolean preserveTimelineOnSubRequestsChange?: boolean
mergeTimelineWhenSubRequestFiltersMatch?: boolean mergeTimelineWhenSubRequestFiltersMatch?: boolean
/** Home favorite-relays chip scope; see {@link NoteList} `feedTimelineScopeKey`. */
feedTimelineScopeKey?: string
}>(function NormalFeed( }>(function NormalFeed(
{ {
subRequests, subRequests,
@ -33,7 +35,8 @@ const NormalFeed = forwardRef<TNoteListRef, {
setSubHeader, setSubHeader,
onSubHeaderRefresh, onSubHeaderRefresh,
preserveTimelineOnSubRequestsChange = false, preserveTimelineOnSubRequestsChange = false,
mergeTimelineWhenSubRequestFiltersMatch = false mergeTimelineWhenSubRequestFiltersMatch = false,
feedTimelineScopeKey
}, },
ref ref
) { ) {
@ -119,6 +122,7 @@ const NormalFeed = forwardRef<TNoteListRef, {
relayCapabilityReady={relayCapabilityReady} relayCapabilityReady={relayCapabilityReady}
preserveTimelineOnSubRequestsChange={preserveTimelineOnSubRequestsChange} preserveTimelineOnSubRequestsChange={preserveTimelineOnSubRequestsChange}
mergeTimelineWhenSubRequestFiltersMatch={mergeTimelineWhenSubRequestFiltersMatch} mergeTimelineWhenSubRequestFiltersMatch={mergeTimelineWhenSubRequestFiltersMatch}
feedTimelineScopeKey={feedTimelineScopeKey}
/> />
</div> </div>
</> </>

23
src/components/NoteList/index.tsx

@ -111,6 +111,12 @@ const NoteList = forwardRef(
* avoid a loading reset. * avoid a loading reset.
*/ */
mergeTimelineWhenSubRequestFiltersMatch = false, mergeTimelineWhenSubRequestFiltersMatch = false,
/**
* When set with {@link preserveTimelineOnSubRequestsChange}: home relay chip / feed mode identity.
* If this string changes (e.g. single relay all favorites), the timeline is cleared even when the new
* relay URL set is a strict superset of the old one (which would otherwise keep stale rows).
*/
feedTimelineScopeKey,
/** /**
* Spells / one-shot feeds: when the initial fetch finishes with zero rows, show explicit empty copy * Spells / one-shot feeds: when the initial fetch finishes with zero rows, show explicit empty copy
* (see list footer). Does not end loading early loading stays until EOSE, first events, or safety timeouts. * (see list footer). Does not end loading early loading stays until EOSE, first events, or safety timeouts.
@ -174,6 +180,7 @@ const NoteList = forwardRef(
feedSubscriptionKey?: string feedSubscriptionKey?: string
preserveTimelineOnSubRequestsChange?: boolean preserveTimelineOnSubRequestsChange?: boolean
mergeTimelineWhenSubRequestFiltersMatch?: boolean mergeTimelineWhenSubRequestFiltersMatch?: boolean
feedTimelineScopeKey?: string
/** When set (e.g. spells), use explicit empty-feed copy after load completes with no rows. */ /** When set (e.g. spells), use explicit empty-feed copy after load completes with no rows. */
spellFetchTimeoutMs?: number spellFetchTimeoutMs?: number
spellFeedInstrumentToken?: number spellFeedInstrumentToken?: number
@ -257,6 +264,7 @@ const NoteList = forwardRef(
const timelineSubscriptionKey = feedSubscriptionKey ?? subRequestsKey const timelineSubscriptionKey = feedSubscriptionKey ?? subRequestsKey
const prevSubRequestsKeyForTimelineRef = useRef<string | null>(null) const prevSubRequestsKeyForTimelineRef = useRef<string | null>(null)
const feedTimelineScopePrevRef = useRef<string | undefined>(undefined)
/** Detect pull-to-refresh so preserve-mode feeds still clear; unrelated dep changes must not clear. */ /** Detect pull-to-refresh so preserve-mode feeds still clear; unrelated dep changes must not clear. */
const timelineEffectLastRefreshCountRef = useRef(refreshCount) const timelineEffectLastRefreshCountRef = useRef(refreshCount)
@ -641,9 +649,23 @@ const NoteList = forwardRef(
if (userPulledRefresh) { if (userPulledRefresh) {
timelineEffectLastRefreshCountRef.current = refreshCount timelineEffectLastRefreshCountRef.current = refreshCount
} }
const prevFeedScope = feedTimelineScopePrevRef.current
const feedScopeKey = feedTimelineScopeKey
const feedScopeChanged =
feedScopeKey !== undefined &&
prevFeedScope !== undefined &&
prevFeedScope !== feedScopeKey
if (feedScopeKey !== undefined) {
feedTimelineScopePrevRef.current = feedScopeKey
} else {
feedTimelineScopePrevRef.current = undefined
}
const keepExistingTimelineEvents = const keepExistingTimelineEvents =
preserveTimelineOnSubRequestsChange && preserveTimelineOnSubRequestsChange &&
!userPulledRefresh && !userPulledRefresh &&
!feedScopeChanged &&
(prevSubKey === subRequestsKey || (prevSubKey === subRequestsKey ||
isRelayUrlStrictSupersetIdentityKey(prevSubKey, subRequestsKey) || isRelayUrlStrictSupersetIdentityKey(prevSubKey, subRequestsKey) ||
(mergeTimelineWhenSubRequestFiltersMatch && (mergeTimelineWhenSubRequestFiltersMatch &&
@ -1037,6 +1059,7 @@ const NoteList = forwardRef(
subRequestsKey, subRequestsKey,
preserveTimelineOnSubRequestsChange, preserveTimelineOnSubRequestsChange,
mergeTimelineWhenSubRequestFiltersMatch, mergeTimelineWhenSubRequestFiltersMatch,
feedTimelineScopeKey,
refreshCount, refreshCount,
showKindsKey, showKindsKey,
showKind1OPs, showKind1OPs,

10
src/components/Profile/ProfileBadgeDetailDialog.tsx

@ -125,6 +125,16 @@ export default function ProfileBadgeDetailDialog({
</p> </p>
) : null} ) : null}
{badge.awardCreatedAt != null ? (
<p className="text-xs text-muted-foreground">
{t('Awarded on', { defaultValue: 'Awarded on' })}{' '}
{new Date(badge.awardCreatedAt * 1000).toLocaleString(undefined, {
dateStyle: 'medium',
timeStyle: 'short'
})}
</p>
) : null}
{issuerPubkey ? ( {issuerPubkey ? (
<div className="space-y-1"> <div className="space-y-1">
<div className="text-xs font-medium text-muted-foreground">{t('Issued by')}</div> <div className="text-xs font-medium text-muted-foreground">{t('Issued by')}</div>

1
src/components/Profile/ProfileHeaderInteractions.tsx

@ -184,7 +184,6 @@ function BadgeItem({
alt="" alt=""
className="size-full rounded-lg object-cover" className="size-full rounded-lg object-cover"
loading="lazy" loading="lazy"
referrerPolicy="no-referrer"
onError={(e) => { onError={(e) => {
e.currentTarget.style.visibility = 'hidden' e.currentTarget.style.visibility = 'hidden'
const fallback = e.currentTarget.nextElementSibling as HTMLElement | null const fallback = e.currentTarget.nextElementSibling as HTMLElement | null

2
src/components/Sidebar/NotificationButton.tsx

@ -12,7 +12,7 @@ export default function NotificationButton() {
return ( return (
<SidebarItem <SidebarItem
title="notifications" title="Notifications"
onClick={() => checkLogin(() => navigate('spells', { spell: 'notifications' }))} onClick={() => checkLogin(() => navigate('spells', { spell: 'notifications' }))}
active={ active={
display && display &&

3
src/constants.ts

@ -206,7 +206,8 @@ export const SOCIAL_KIND_BLOCKED_RELAY_URLS = [
'wss://relay.noswhere.com', 'wss://relay.noswhere.com',
'wss://aggr.nostr.land', 'wss://aggr.nostr.land',
'wss://search.nos.today', 'wss://search.nos.today',
'wss://trending.nostr.wine' 'wss://trending.nostr.wine',
'wss://sendit.nosflare.com'
] ]
/** Relays that reject #e (and similar) tag filters; skip for reply/quote/stats fetches. */ /** Relays that reject #e (and similar) tag filters; skip for reply/quote/stats fetches. */

162
src/hooks/useProfileBadges.tsx

@ -1,12 +1,18 @@
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { extractBadgeDefinitionMedia } from '@/lib/badge-definition-media' import { extractBadgeDefinitionMedia } from '@/lib/badge-definition-media'
import {
fetchNip58BadgeAward,
fetchNip58BadgeDefinition,
mergeNip58BadgeRelayPool
} from '@/lib/fetch-badge-nip58'
import { import {
profileAccordionGetCachedBadges, profileAccordionGetCachedBadges,
profileAccordionInvalidate, profileAccordionInvalidate,
profileAccordionRelayUrlsKey, profileAccordionRelayUrlsKey,
profileAccordionSetBadges profileAccordionSetBadges
} from '@/lib/profile-accordion-session-cache' } from '@/lib/profile-accordion-session-cache'
import { queryService, replaceableEventService } from '@/services/client.service' import { queryService } from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { tagNameEquals } from '@/lib/tag' import { tagNameEquals } from '@/lib/tag'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
@ -25,6 +31,8 @@ export type TProfileBadge = {
thumb?: string thumb?: string
/** From badge definition (NIP-58) */ /** From badge definition (NIP-58) */
description?: string description?: string
/** Kind 8 award `created_at` when loaded */
awardCreatedAt?: number
} }
/** Parse a-tag "30009:pubkey:d" into { kind, pubkey, d } */ /** Parse a-tag "30009:pubkey:d" into { kind, pubkey, d } */
@ -33,7 +41,44 @@ function parseATag(aTag: string): { kind: number; pubkey: string; d: string } |
if (parts.length < 3) return null if (parts.length < 3) return null
const kind = parseInt(parts[0], 10) const kind = parseInt(parts[0], 10)
if (isNaN(kind)) return null if (isNaN(kind)) return null
return { kind, pubkey: parts[1], d: parts[2] } const pk = parts[1]
if (!/^[0-9a-fA-F]{64}$/.test(pk)) return null
const d = parts.slice(2).join(':')
if (!d) return null
return { kind, pubkey: pk.toLowerCase(), d }
}
/** True when we should re-resolve the badge definition (missing media but coordinate looks like kind 30009). */
function badgeNeedsDefinitionMedia(b: TProfileBadge): boolean {
if (b.thumb || b.image) return false
const parsed = parseATag(b.a)
return !!(parsed && parsed.kind === ExtendedKind.BADGE_DEFINITION)
}
async function enrichBadgesFromIndexedDb(badges: TProfileBadge[]): Promise<TProfileBadge[]> {
return Promise.all(
badges.map(async (b) => {
if (b.thumb || b.image) return b
const parsed = parseATag(b.a)
if (!parsed || parsed.kind !== ExtendedKind.BADGE_DEFINITION) return b
try {
const def = await indexedDb.getReplaceableEvent(parsed.pubkey, parsed.kind, parsed.d)
if (!def) return b
const name = def.tags.find(tagNameEquals('name'))?.[1]
const description = def.tags.find(tagNameEquals('description'))?.[1]
const media = extractBadgeDefinitionMedia(def)
return {
...b,
name: name ?? b.name ?? parsed.d,
image: media.image,
thumb: media.thumb ?? media.image,
description: description ?? b.description
}
} catch {
return b
}
})
)
} }
/** NIP-58: Fetches profile badges (kind 30008) and resolves badge definitions (kind 30009). */ /** NIP-58: Fetches profile badges (kind 30008) and resolves badge definitions (kind 30009). */
@ -63,11 +108,23 @@ export function useProfileBadges(pubkey: string | undefined, relayUrls?: string[
if (!force) { if (!force) {
const cached = profileAccordionGetCachedBadges(pubkey, relayKey) const cached = profileAccordionGetCachedBadges(pubkey, relayKey)
if (cached) { if (cached?.length) {
if (myFetchId !== fetchIdRef.current) return if (cached.some(badgeNeedsDefinitionMedia)) {
setBadges(cached) const enriched = await enrichBadgesFromIndexedDb(cached)
setLoading(false) if (!enriched.some(badgeNeedsDefinitionMedia)) {
return if (myFetchId !== fetchIdRef.current) return
setBadges(enriched)
profileAccordionSetBadges(pubkey, relayKey, enriched)
setLoading(false)
return
}
// Session cache was incomplete and IndexedDB has no definitions — fetch from network below.
} else {
if (myFetchId !== fetchIdRef.current) return
setBadges(cached)
setLoading(false)
return
}
} }
} }
@ -88,12 +145,18 @@ export function useProfileBadges(pubkey: string | undefined, relayUrls?: string[
} }
const tags = profileBadgesEvent.tags const tags = profileBadgesEvent.tags
const pairs: { a: string; e: string }[] = [] const pairs: { a: string; e: string; eRelayHint?: string }[] = []
for (let i = 0; i < tags.length - 1; i++) { for (let i = 0; i < tags.length - 1; i++) {
const [tagNameA, aVal] = tags[i] const ta = tags[i]
const [tagNameE, eVal] = tags[i + 1] const te = tags[i + 1]
if (tagNameA === 'a' && tagNameE === 'e' && aVal && eVal && /^[a-f0-9]{64}$/i.test(eVal)) { if (
pairs.push({ a: aVal, e: eVal }) ta[0] === 'a' &&
te[0] === 'e' &&
ta[1] &&
te[1] &&
/^[a-f0-9]{64}$/i.test(te[1])
) {
pairs.push({ a: ta[1], e: te[1], eRelayHint: te[2] })
} }
} }
@ -102,38 +165,51 @@ export function useProfileBadges(pubkey: string | undefined, relayUrls?: string[
return return
} }
const result: TProfileBadge[] = [] const result: TProfileBadge[] = await Promise.all(
for (const { a, e } of pairs) { pairs.map(async ({ a, e, eRelayHint }) => {
const parsed = parseATag(a) const parsed = parseATag(a)
if (!parsed || parsed.kind !== ExtendedKind.BADGE_DEFINITION) { if (!parsed || parsed.kind !== ExtendedKind.BADGE_DEFINITION) {
result.push({ a, awardId: e }) return { a, awardId: e }
continue }
}
const relayPool = mergeNip58BadgeRelayPool(urls, eRelayHint, blockedRelays)
const defEvent = await replaceableEventService.fetchReplaceableEvent( const [defEvent, awardEvent] = await Promise.all([
parsed.pubkey, fetchNip58BadgeDefinition(parsed.pubkey, parsed.d, relayPool),
parsed.kind, fetchNip58BadgeAward(e, relayPool)
parsed.d ])
)
const awardATag = awardEvent?.tags.find(tagNameEquals('a'))?.[1]
if (!defEvent) { const awardMatchesDefinition = !awardEvent || awardATag === a
result.push({ a, awardId: e }) const awardCreatedAt =
continue awardMatchesDefinition && awardEvent ? awardEvent.created_at : undefined
}
if (defEvent) {
const name = defEvent.tags.find(tagNameEquals('name'))?.[1] try {
const description = defEvent.tags.find(tagNameEquals('description'))?.[1] await indexedDb.putReplaceableEvent(defEvent)
const media = extractBadgeDefinitionMedia(defEvent) } catch {
// ignore ingest failures (tombstone / validation)
result.push({ }
a, }
awardId: e,
name: name ?? parsed.d, if (!defEvent) {
image: media.image, return { a, awardId: e, awardCreatedAt }
thumb: media.thumb ?? media.image, }
description
const name = defEvent.tags.find(tagNameEquals('name'))?.[1]
const description = defEvent.tags.find(tagNameEquals('description'))?.[1]
const media = extractBadgeDefinitionMedia(defEvent)
return {
a,
awardId: e,
name: name ?? parsed.d,
image: media.image,
thumb: media.thumb ?? media.image,
description,
awardCreatedAt
}
}) })
} )
if (myFetchId !== fetchIdRef.current) return if (myFetchId !== fetchIdRef.current) return
setBadges(result) setBadges(result)

1
src/i18n/locales/en.ts

@ -511,6 +511,7 @@ export default {
'No other recipients found': 'No other recipients found', 'No other recipients found': 'No other recipients found',
'Recipients could not be loaded': 'Recipients could not be loaded', 'Recipients could not be loaded': 'Recipients could not be loaded',
'View award': 'View award', 'View award': 'View award',
'Awarded on': 'Awarded on',
'Please log in to follow': 'Please log in to follow', 'Please log in to follow': 'Please log in to follow',
'Following All': 'Following All', 'Following All': 'Following All',
'Followed {{count}} users': 'Followed {{count}} users', 'Followed {{count}} users': 'Followed {{count}} users',

27
src/lib/badge-definition-media.ts

@ -15,6 +15,23 @@ export function resolveHttpMediaUrl(raw: string | undefined): string | undefined
} }
} }
/** NIP-58 allows multiple `thumb` tags; prefer a medium size for grid tiles when dimensions are tagged. */
function pickThumbFromDefinitionTags(defEvent: Event): string | undefined {
const thumbTags = defEvent.tags.filter(tagNameEquals('thumb'))
if (thumbTags.length === 0) return undefined
const preferredDims = ['256x256', '512x512', '128x128', '64x64', '32x32', '16x16', '1024x1024']
for (const dim of preferredDims) {
const row = thumbTags.find((t) => t[2] === dim)
const u = row && resolveHttpMediaUrl(row[1])
if (u) return u
}
for (const t of thumbTags) {
const u = resolveHttpMediaUrl(t[1])
if (u) return u
}
return undefined
}
/** Resolve `image` / `thumb` / `imeta` URLs from a NIP-58 badge definition (kind 30009). */ /** Resolve `image` / `thumb` / `imeta` URLs from a NIP-58 badge definition (kind 30009). */
export function extractBadgeDefinitionMedia(defEvent: Event | undefined): { export function extractBadgeDefinitionMedia(defEvent: Event | undefined): {
image?: string image?: string
@ -22,14 +39,14 @@ export function extractBadgeDefinitionMedia(defEvent: Event | undefined): {
} { } {
if (!defEvent) return {} if (!defEvent) return {}
const tagImage = defEvent.tags.find(tagNameEquals('image'))?.[1] const tagImage = defEvent.tags.find(tagNameEquals('image'))?.[1]
const tagThumb = defEvent.tags.find(tagNameEquals('thumb'))?.[1] const tagThumb = pickThumbFromDefinitionTags(defEvent)
const imetaUrls = getImetaInfosFromEvent(defEvent) const imetaUrls = getImetaInfosFromEvent(defEvent)
.map((i) => i.url) .map((i) => i.url)
.filter(Boolean) as string[] .filter(Boolean) as string[]
const orderedThumb = [tagThumb, tagImage, ...imetaUrls].map(resolveHttpMediaUrl).find(Boolean) const imageResolved = [tagImage, ...imetaUrls].map(resolveHttpMediaUrl).find(Boolean)
const orderedImage = [tagImage, tagThumb, ...imetaUrls].map(resolveHttpMediaUrl).find(Boolean) const thumbResolved = [tagThumb, tagImage, ...imetaUrls].map(resolveHttpMediaUrl).find(Boolean)
return { return {
thumb: orderedThumb ?? orderedImage, thumb: thumbResolved ?? imageResolved,
image: orderedImage ?? orderedThumb image: imageResolved ?? thumbResolved
} }
} }

75
src/lib/fetch-badge-nip58.ts

@ -0,0 +1,75 @@
import { ExtendedKind, FAST_READ_RELAY_URLS, PROFILE_FETCH_RELAY_URLS } from '@/constants'
import { normalizeUrl, isWebsocketUrl } from '@/lib/url'
import { queryService } from '@/services/client.service'
import type { Event } from 'nostr-tools'
const BADGE_AWARD_KIND = 8
function addRelayUrl(out: Set<string>, raw: string | undefined, blocked: Set<string>) {
if (!raw?.trim()) return
const n = normalizeUrl(raw.trim()) || raw.trim()
if (!n || !isWebsocketUrl(n) || blocked.has(n)) return
out.add(n)
}
/**
* Relay pool for NIP-58 definition + award fetches: profile mirrors, optional `e`-tag hint from kind 30008,
* then app profile/fast-read fallbacks. Issuer definitions often live off default fast read relays only.
*/
export function mergeNip58BadgeRelayPool(
profileRelayUrls: string[],
awardRelayHint: string | undefined,
blockedRelays: string[]
): string[] {
const blocked = new Set(blockedRelays.map((u) => normalizeUrl(u) || u).filter(Boolean))
const out = new Set<string>()
for (const u of profileRelayUrls) addRelayUrl(out, u, blocked)
addRelayUrl(out, awardRelayHint, blocked)
for (const u of PROFILE_FETCH_RELAY_URLS) addRelayUrl(out, u, blocked)
for (const u of FAST_READ_RELAY_URLS) addRelayUrl(out, u, blocked)
return [...out]
}
export async function fetchNip58BadgeDefinition(
issuerPubkey: string,
dTag: string,
relayUrls: string[]
): Promise<Event | undefined> {
if (!relayUrls.length) return undefined
const hexPk = issuerPubkey.toLowerCase()
const events = await queryService.fetchEvents(
relayUrls,
{
authors: [hexPk],
kinds: [ExtendedKind.BADGE_DEFINITION],
'#d': [dTag]
},
{
replaceableRace: true,
eoseTimeout: 4000,
globalTimeout: 22_000,
firstRelayResultGraceMs: false
}
)
const match = events.filter((e) => {
if (e.pubkey.toLowerCase() !== hexPk) return false
const d = e.tags.find((t) => t[0] === 'd')?.[1]
return d === dTag
})
return match.sort((a, b) => b.created_at - a.created_at)[0]
}
export async function fetchNip58BadgeAward(awardId: string, relayUrls: string[]): Promise<Event | undefined> {
if (!relayUrls.length || !/^[a-f0-9]{64}$/i.test(awardId)) return undefined
const events = await queryService.fetchEvents(
relayUrls,
{ ids: [awardId.toLowerCase()], kinds: [BADGE_AWARD_KIND] },
{
immediateReturn: true,
eoseTimeout: 4000,
globalTimeout: 18_000,
firstRelayResultGraceMs: false
}
)
return events.find((e) => e.id.toLowerCase() === awardId.toLowerCase())
}

16
src/pages/primary/NoteListPage/RelaysFeed.tsx

@ -81,6 +81,17 @@ const RelaysFeed = forwardRef<
feedInfo.feedType === 'all-favorites') && feedInfo.feedType === 'all-favorites') &&
relayUrls.length > 0 relayUrls.length > 0
/** Distinguishes home relay chips so we do not keep the previous timeline on single→all-favorites (strict superset). */
const feedTimelineScopeKey = useMemo(() => {
if (feedInfo.feedType === 'all-favorites') return 'all-favorites'
if (feedInfo.feedType === 'relays') return `relays:${feedInfo.id ?? ''}`
if (feedInfo.feedType === 'relay') {
const id = feedInfo.id ? normalizeUrl(feedInfo.id) || feedInfo.id : ''
return `relay:${id}`
}
return undefined
}, [feedInfo.feedType, feedInfo.id])
// Hooks must run every render — never place useMemo after conditional returns. // Hooks must run every render — never place useMemo after conditional returns.
const subRequests = useMemo(() => { const subRequests = useMemo(() => {
if (!canRenderFeed) return [] if (!canRenderFeed) return []
@ -98,6 +109,9 @@ const RelaysFeed = forwardRef<
return null return null
} }
// preserveTimeline: merge when relay list grows (e.g. all-favorites list fills in). Do not use
// mergeTimelineWhenSubRequestFiltersMatch here — same kinds + different URLs would keep the old
// timeline when switching home feed chips (all-favorites ↔ set ↔ single relay).
return ( return (
<NormalFeed <NormalFeed
ref={ref} ref={ref}
@ -108,7 +122,7 @@ const RelaysFeed = forwardRef<
setSubHeader={setSubHeader} setSubHeader={setSubHeader}
onSubHeaderRefresh={onSubHeaderRefresh} onSubHeaderRefresh={onSubHeaderRefresh}
preserveTimelineOnSubRequestsChange preserveTimelineOnSubRequestsChange
mergeTimelineWhenSubRequestFiltersMatch feedTimelineScopeKey={feedTimelineScopeKey}
/> />
) )
}) })

Loading…
Cancel
Save