Browse Source

edit badges in settings

payto helpers
bug-fixes
imwald
Silberengel 4 weeks ago
parent
commit
efa58a24f8
  1. 17
      .cursor/rules/no-framer-motion.mdc
  2. 29
      eslint.config.js
  3. 21
      src/PageManager.tsx
  4. 6
      src/components/Note/index.tsx
  5. 1
      src/components/NoteCard/MainNoteCard.tsx
  6. 8
      src/components/NoteOptions/index.tsx
  7. 18
      src/components/NoteOptions/useMenuActions.tsx
  8. 2
      src/components/PaytoDialog/index.tsx
  9. 30
      src/components/Profile/index.tsx
  10. 59
      src/components/ProfileEditor/PaymentMethodRow.tsx
  11. 25
      src/constants.ts
  12. 44
      src/data/payto-types.json
  13. 18
      src/hooks/useProfileWall.tsx
  14. 26
      src/i18n/locales/de.ts
  15. 26
      src/i18n/locales/en.ts
  16. 31
      src/lib/event-metadata.ts
  17. 1
      src/lib/link.ts
  18. 70
      src/lib/nip58-profile-badges-list.test.ts
  19. 118
      src/lib/nip58-profile-badges-list.ts
  20. 46
      src/lib/payto-editor-hints.test.ts
  21. 20
      src/lib/payto-registry.ts
  22. 2
      src/lib/payto.ts
  23. 60
      src/lib/replaceable-list-latest.ts
  24. 14
      src/pages/secondary/PersonalListsSettingsPage/index.tsx
  25. 403
      src/pages/secondary/ProfileBadgesListPage/index.tsx
  26. 49
      src/pages/secondary/ProfileEditorPage/index.tsx
  27. 19
      src/providers/NostrProvider/index.tsx
  28. 2
      src/routes.tsx
  29. 55
      src/services/client-replaceable-events.service.ts
  30. 11
      src/services/indexed-db.service.ts

17
.cursor/rules/no-framer-motion.mdc

@ -0,0 +1,17 @@ @@ -0,0 +1,17 @@
---
description: Do not use Framer Motion or Motion for React animations
alwaysApply: true
---
# No Framer Motion
This project does **not** use Framer Motion or the `motion` package.
- Never add `framer-motion`, `motion`, or `motion/react` to `package.json`.
- Never import from `framer-motion`, `motion`, or `motion/react`.
- Never use `motion.div`, `motion.span`, `motion.button`, or other `motion.*` components.
- Never use `AnimatePresence`, `useAnimation`, `useMotionValue`, or related APIs.
Use plain HTML elements (`motion`-free elements) with **Tailwind** and **CSS** instead: `transition-*`, `animate-*`, `motion-reduce:*`, etc.
If you need enter/exit effects, prefer CSS transitions, conditional render, or existing UI patterns in the codebase — not animation libraries.

29
eslint.config.js

@ -19,6 +19,35 @@ export default tseslint.config( @@ -19,6 +19,35 @@ export default tseslint.config(
},
rules: {
...reactHooks.configs.recommended.rules,
'no-restricted-imports': [
'error',
{
paths: [
{
name: 'framer-motion',
message:
'Framer Motion is not used in this project. Use plain elements with Tailwind/CSS transitions.'
},
{
name: 'motion',
message:
'The Motion package is not used in this project. Use plain elements with Tailwind/CSS transitions.'
},
{
name: 'motion/react',
message:
'The Motion package is not used in this project. Use plain elements with Tailwind/CSS transitions.'
}
],
patterns: [
{
group: ['framer-motion/*', 'motion/*'],
message:
'Framer Motion / Motion is not used in this project. Use plain elements with Tailwind/CSS transitions.'
}
]
}
],
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'@typescript-eslint/explicit-function-return-type': 'off',
'react/prop-types': 'off',

21
src/PageManager.tsx

@ -113,6 +113,7 @@ const PrimaryNotificationThreadMuteListPageLazy = lazy(() => @@ -113,6 +113,7 @@ const PrimaryNotificationThreadMuteListPageLazy = lazy(() =>
}))
)
const PrimaryPinListPageLazy = lazy(() => import('@/pages/secondary/PinListPage'))
const PrimaryProfileBadgesListPageLazy = lazy(() => import('@/pages/secondary/ProfileBadgesListPage'))
const PrimaryInterestListPageLazy = lazy(() => import('@/pages/secondary/InterestListPage'))
const PrimaryUserEmojiListPageLazy = lazy(() => import('@/pages/secondary/UserEmojiListPage'))
const PrimaryOthersRelaySettingsPageLazy = lazy(() => import('@/pages/secondary/OthersRelaySettingsPage'))
@ -856,6 +857,26 @@ export function useSmartPinListNavigation() { @@ -856,6 +857,26 @@ export function useSmartPinListNavigation() {
return { navigateToPinList }
}
export function useSmartProfileBadgesListNavigation() {
const { setPrimaryNoteView } = usePrimaryNoteView()
const { push: pushSecondaryPage } = useSecondaryPage()
const { isSmallScreen } = useScreenSize()
const navigateToProfileBadgesList = (url: string) => {
if (isSmallScreen) {
window.history.pushState(null, '', url)
setPrimaryNoteView(
suspensePrimaryPage(<PrimaryProfileBadgesListPageLazy index={0} hideTitlebar={true} />),
'profile-badges'
)
} else {
pushSecondaryPage(url)
}
}
return { navigateToProfileBadgesList }
}
export function useSmartNotificationThreadFollowListNavigation() {
const { setPrimaryNoteView } = usePrimaryNoteView()
const { push: pushSecondaryPage } = useSecondaryPage()

6
src/components/Note/index.tsx

@ -226,7 +226,8 @@ export default function Note({ @@ -226,7 +226,8 @@ export default function Note({
fullCalendarInvite,
zapPollVoteHighlightOption,
nip84HighlightEvents,
deferAuthorAvatar = false
deferAuthorAvatar = false,
pinned = false
}: {
event: Event
originalNoteId?: string
@ -236,6 +237,8 @@ export default function Note({ @@ -236,6 +237,8 @@ export default function Note({
showFull?: boolean
disableClick?: boolean
embedded?: boolean
/** Passed to note menu when this row is already shown as pinned. */
pinned?: boolean
/** When viewing a kind-24 invite, use this to replace the embedded calendar with the full card (RSVP) in content */
fullCalendarInvite?: { event: Event; naddr: string }
/** Profile: highlight option when this row is from a zap vote receipt. */
@ -731,6 +734,7 @@ export default function Note({ @@ -731,6 +734,7 @@ export default function Note({
event.kind === ExtendedKind.ZAP_RECEIPT) && (
<NoteOptions
event={event}
pinned={pinned}
className={cn(
'py-1 shrink-0',
size === 'small' ? '[&_svg]:size-4' : '[&_svg]:size-5'

1
src/components/NoteCard/MainNoteCard.tsx

@ -124,6 +124,7 @@ export default function MainNoteCard({ @@ -124,6 +124,7 @@ export default function MainNoteCard({
zapPollVoteHighlightOption={zapPollVoteHighlightOption}
showFull={showFull}
deferAuthorAvatar={deferAuthorAvatar}
pinned={pinned}
/>
</Collapsible>
{!embedded && !searchListPreview ? <NoteBoostBadges event={event} className={`mt-2 ${notePadX}`} /> : null}

8
src/components/NoteOptions/index.tsx

@ -21,10 +21,13 @@ export default function NoteOptions({ @@ -21,10 +21,13 @@ export default function NoteOptions({
onOpenPublicMessage,
initialPublicMessageTo,
onOpenCallInvite,
initialDefaultContent
initialDefaultContent,
pinned = false
}: {
event: Event
className?: string
/** Note is shown in a pinned section (profile pins, etc.). */
pinned?: boolean
initialHighlightData?: HighlightData
highlightDefaultContent?: string
isPostEditorOpen?: boolean
@ -83,7 +86,8 @@ export default function NoteOptions({ @@ -83,7 +86,8 @@ export default function NoteOptions({
onOpenEditOrClone: (mode) => {
setEditCloneMode(mode)
setEditCloneOpen(true)
}
},
pinned
})
const trigger = useMemo(

18
src/components/NoteOptions/useMenuActions.tsx

@ -116,6 +116,8 @@ interface UseMenuActionsProps { @@ -116,6 +116,8 @@ interface UseMenuActionsProps {
onOpenCallInvite?: (url: string) => void
/** Opens edit/clone dialog (signed-in accounts only, not read-only npub). */
onOpenEditOrClone?: (mode: TEditOrCloneMode) => void
/** When the feed already marks this note pinned (e.g. profile pin section). */
pinned?: boolean
}
export function useMenuActions({
@ -128,6 +130,7 @@ export function useMenuActions({ @@ -128,6 +130,7 @@ export function useMenuActions({
onOpenPublicMessage,
onOpenCallInvite,
onOpenEditOrClone,
pinned: pinnedInFeed = false
}: UseMenuActionsProps) {
const { t } = useTranslation()
// Use useContext directly to avoid error if provider is not available
@ -198,8 +201,8 @@ export function useMenuActions({ @@ -198,8 +201,8 @@ export function useMenuActions({
}
}, [])
// Check if event is pinned
const [isPinned, setIsPinned] = useState(false)
// Check if event is pinned (feed hint avoids "Pin note" on rows already shown as pinned)
const [isPinned, setIsPinned] = useState(pinnedInFeed)
// Keep refs so the effect can read the latest relay lists without making them
// part of the dependency array. Including live array references as deps causes
@ -226,21 +229,18 @@ export function useMenuActions({ @@ -226,21 +229,18 @@ export function useMenuActions({
new Set(allRelays.map(url => normalizeAnyRelayUrl(url)).filter((url): url is string => !!url))
)
const pinListEvent = await fetchNewestPinListForPubkey(pubkey, comprehensiveRelays)
if (pinListEvent) {
setIsPinned(isEventInPinList(pinListEvent, event))
} else {
setIsPinned(false)
}
const inList = pinListEvent ? isEventInPinList(pinListEvent, event) : false
setIsPinned(inList || pinnedInFeed)
} catch (error) {
logger.component('PinStatus', 'Error checking pin status', { error: (error as Error).message })
setIsPinned(false)
setIsPinned(pinnedInFeed)
}
}
checkIfPinned()
// Only re-run when the user or the specific event changes, not on relay list
// reference churn (relay arrays are read via refs above).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pubkey, event.id])
}, [pubkey, event.id, pinnedInFeed])
const handlePinNote = async () => {
if (!pubkey) return

2
src/components/PaytoDialog/index.tsx

@ -50,7 +50,7 @@ export default function PaytoDialog({ @@ -50,7 +50,7 @@ export default function PaytoDialog({
: t('Payment address – copy to use in your wallet or app')}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-3 pb-2">
<div className="rounded-md bg-muted px-3 py-2 font-mono text-sm break-all select-text">
{authority}
</div>

30
src/components/Profile/index.tsx

@ -85,7 +85,7 @@ import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' @@ -85,7 +85,7 @@ import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants'
import { nip66Service } from '@/services/nip66.service'
import { buildPaytoUri } from '@/lib/payto'
import { buildPaytoUri, getCanonicalPaytoType, getPaytoEditorTypeLabel, getPaytoTypeInfo } from '@/lib/payto'
import type { TProfile } from '@/types'
/**
@ -126,6 +126,14 @@ type MergedPaymentMethod = { @@ -126,6 +126,14 @@ type MergedPaymentMethod = {
maxAmount?: number
}
/** Bitcoin-layer first, then on-chain Bitcoin family, then everything else. */
function paytoPaymentSortRank(type: string): number {
const category = getPaytoTypeInfo(type)?.category
if (category === 'bitcoin-layer') return 0
if (category === 'bitcoin') return 1
return 2
}
/** Merge payment methods from kind 10133 and profile (kind 0: JSON + tags), normalized and deduplicated */
function mergePaymentMethods(
paymentInfo: ReturnType<typeof getPaymentInfoFromEvent> | null,
@ -136,7 +144,7 @@ function mergePaymentMethods( @@ -136,7 +144,7 @@ function mergePaymentMethods(
const add = (type: string, authority: string, payto?: string, displayType?: string, extra?: { currency?: string; minAmount?: number; maxAmount?: number }) => {
if (!authority?.trim()) return
const normType = type.toLowerCase()
const normType = getCanonicalPaytoType(type)
const key = `${normType}:${normalizePaymentAuthority(normType, authority)}`
const existing = seen.get(key)
if (existing) {
@ -150,7 +158,7 @@ function mergePaymentMethods( @@ -150,7 +158,7 @@ function mergePaymentMethods(
type: normType,
authority: authority.trim(),
payto: payto || (normType && authority ? `payto://${normType}/${authority.trim()}` : undefined),
displayType: displayType || (normType === 'lightning' ? 'Lightning Network' : normType === 'bitcoin' ? 'Bitcoin' : type || 'Payment'),
displayType: displayType || getPaytoEditorTypeLabel(normType),
...extra
}
seen.set(key, entry)
@ -270,17 +278,11 @@ export default function Profile({ @@ -270,17 +278,11 @@ export default function Profile({
const mergedPaymentMethods = useMemo(() => {
const list = mergePaymentMethods(paymentInfo, profile ?? null)
return [...list].sort((a, b) => {
const rank = (type: string) =>
type === 'lightning' || type === 'liquid' || type === 'lbtc' ? 0 : type === 'bitcoin' ? 1 : 2
return rank(a.type) - rank(b.type)
})
return [...list].sort((a, b) => paytoPaymentSortRank(a.type) - paytoPaymentSortRank(b.type))
}, [paymentInfo, profile])
/** Group payment methods by displayType so same-type addresses render under one heading */
const paymentMethodsByType = useMemo(() => {
const rank = (type: string) =>
type === 'lightning' || type === 'liquid' || type === 'lbtc' ? 0 : type === 'bitcoin' ? 1 : 2
const groups = new Map<string, MergedPaymentMethod[]>()
for (const method of mergedPaymentMethods) {
const key = method.displayType || method.type
@ -292,7 +294,7 @@ export default function Profile({ @@ -292,7 +294,7 @@ export default function Profile({
const arrB = groups.get(b)
const typeA = arrA?.[0]?.type ?? ''
const typeB = arrB?.[0]?.type ?? ''
return rank(typeA) - rank(typeB)
return paytoPaymentSortRank(typeA) - paytoPaymentSortRank(typeB)
})
return order.map((key) => ({ displayType: key, methods: groups.get(key) ?? [] }))
}, [mergedPaymentMethods])
@ -433,6 +435,8 @@ export default function Profile({ @@ -433,6 +435,8 @@ export default function Profile({
postsFeedRef.current?.refresh()
mediaFeedRef.current?.refresh()
publicationsFeedRef.current?.refresh()
reportsFeedRef.current?.refresh()
wallFeedRef.current?.refresh()
likedFeedRef.current?.refresh()
const pk = profilePubkeyRef.current
if (pk) {
@ -689,7 +693,7 @@ export default function Profile({ @@ -689,7 +693,7 @@ export default function Profile({
</>
) : null}
</div>
<div className="pt-2 md:pl-56">
<div className="pt-2 pb-4 md:pl-56">
<div className="flex flex-wrap gap-2 items-center min-w-0">
<div className="text-xl font-semibold truncate select-text max-w-full">{username}</div>
{isFollowingYou && (
@ -744,7 +748,7 @@ export default function Profile({ @@ -744,7 +748,7 @@ export default function Profile({
)}
{/* Payment methods: merged from kind 10133 + profile lightning, deduplicated – use PaytoLink for consistent behavior */}
{paymentMethodsByType.length > 0 && (
<div className="mt-2 p-2 border rounded-lg bg-muted/50 min-w-0 overflow-hidden">
<div className="mt-2 mb-4 p-3 pb-4 border rounded-lg bg-muted/50 min-w-0">
<div className="text-xs font-semibold text-muted-foreground mb-2">Payment Methods</div>
<div className="space-y-3 min-w-0">
{paymentMethodsByType.map((group, groupIdx) => (

59
src/components/ProfileEditor/PaymentMethodRow.tsx

@ -11,6 +11,8 @@ import { @@ -11,6 +11,8 @@ import {
getCanonicalPaytoType,
getPaytoAuthorityFieldHelp,
getPaytoEditorTypeLabel,
isPaytoEditorCustomType,
PAYTO_EDITOR_OTHER_OPTION,
paytoEditorSelectTypes
} from '@/lib/payto'
import { Trash2 } from 'lucide-react'
@ -26,15 +28,57 @@ type PaymentMethodRowProps = { @@ -26,15 +28,57 @@ type PaymentMethodRowProps = {
export default function PaymentMethodRow({ row, onChange, onRemove }: PaymentMethodRowProps) {
const { t } = useTranslation()
const selectTypes = paytoEditorSelectTypes(row.type)
const canonicalType = getCanonicalPaytoType(row.type || 'lightning')
const fieldHelp = getPaytoAuthorityFieldHelp(canonicalType)
const selectTypes = paytoEditorSelectTypes()
const isCustomType = isPaytoEditorCustomType(row.type)
const canonicalType =
isCustomType && row.type !== PAYTO_EDITOR_OTHER_OPTION
? getCanonicalPaytoType(row.type)
: isCustomType
? 'other'
: getCanonicalPaytoType(row.type || 'lightning')
const fieldHelp = getPaytoAuthorityFieldHelp(isCustomType ? '' : canonicalType)
const selectValue = isCustomType ? PAYTO_EDITOR_OTHER_OPTION : canonicalType
const customTypeInputValue = row.type === PAYTO_EDITOR_OTHER_OPTION ? '' : row.type
return (
<div className="flex gap-2 items-start">
{isCustomType ? (
<div className="w-[11.5rem] shrink-0 space-y-1">
<Input
value={customTypeInputValue}
onChange={(e) => onChange({ ...row, type: e.target.value })}
placeholder={t('paytoEditor.customTypePlaceholder', {
defaultValue: 'Custom type (e.g. mycoin)'
})}
className="text-sm font-medium"
aria-label={t('paytoEditor.customTypeLabel', { defaultValue: 'Custom payment type' })}
/>
<p className="text-xs text-muted-foreground leading-snug">
{t('paytoEditor.customTypeHint', {
defaultValue:
'This is for custom options not in the list. Use lowercase letters, numbers, and hyphens in the type name.'
})}
</p>
<Button
type="button"
variant="link"
size="sm"
className="h-auto p-0 text-xs text-muted-foreground"
onClick={() => onChange({ ...row, type: 'lightning' })}
>
{t('paytoEditor.choosePresetType', { defaultValue: 'Choose from list' })}
</Button>
</div>
) : (
<Select
value={canonicalType}
onValueChange={(type) => onChange({ ...row, type })}
value={selectValue}
onValueChange={(type) => {
if (type === PAYTO_EDITOR_OTHER_OPTION) {
onChange({ ...row, type: PAYTO_EDITOR_OTHER_OPTION })
} else {
onChange({ ...row, type })
}
}}
>
<SelectTrigger className="w-[11.5rem] shrink-0 font-medium text-sm">
<SelectValue placeholder={t('Payment type')} />
@ -42,11 +86,14 @@ export default function PaymentMethodRow({ row, onChange, onRemove }: PaymentMet @@ -42,11 +86,14 @@ export default function PaymentMethodRow({ row, onChange, onRemove }: PaymentMet
<SelectContent className="max-h-[min(20rem,70vh)]">
{selectTypes.map((type) => (
<SelectItem key={type} value={type}>
{getPaytoEditorTypeLabel(type)}
{type === PAYTO_EDITOR_OTHER_OPTION
? t('paytoEditor.other', { defaultValue: 'Other' })
: getPaytoEditorTypeLabel(type)}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<div className="flex-1 min-w-0 space-y-1">
<Input

25
src/constants.ts

@ -608,6 +608,31 @@ export function isAuthorProfileMetadataPublishKind(kind: number): boolean { @@ -608,6 +608,31 @@ export function isAuthorProfileMetadataPublishKind(kind: number): boolean {
return kind === kinds.Metadata || kind === ExtendedKind.PAYMENT_INFO
}
/**
* Author-published replaceables refetched on profile-view refresh, profile editor Refresh cache,
* settings Refresh cache, and {@link ReplaceableEventService.refreshAuthorPublishedReplaceablesFromRelays}.
*/
export const AUTHOR_PROFILE_VIEW_REPLACEABLE_KINDS: readonly number[] = [
kinds.Metadata,
kinds.Contacts,
kinds.RelayList,
kinds.Mutelist,
kinds.BookmarkList,
10001, // NIP-51 pin list
10015, // interests
ExtendedKind.PROFILE_BADGES_LIST,
ExtendedKind.EVENTS_I_FOLLOW_NOTIFICATIONS_LIST,
ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST,
ExtendedKind.FAVORITE_RELAYS,
ExtendedKind.BLOCKED_RELAYS,
ExtendedKind.BLOSSOM_SERVER_LIST,
ExtendedKind.PAYMENT_INFO,
kinds.UserEmojiList,
ExtendedKind.CACHE_RELAYS,
ExtendedKind.HTTP_RELAY_LIST,
ExtendedKind.RSS_FEED_LIST
]
/**
* Relay-local experiment: event `id` is the standard Nostr hash, but `sig` is empty.
* Not verifiable on the public relay network; relays that accept writes should require NIP-42 AUTH first.

44
src/data/payto-types.json

@ -6,9 +6,11 @@ @@ -6,9 +6,11 @@
"editorOrder": [
"lightning",
"bitcoin",
"bolt12",
"bip353",
"bip352",
"liquid",
"lbtc",
"sats",
"ethereum",
"monero",
"litecoin",
@ -40,6 +42,10 @@ @@ -40,6 +42,10 @@
},
"aliases": {
"btc": "bitcoin",
"sats": "lightning",
"bolt-12": "bolt12",
"bip-353": "bip353",
"bip-352": "bip352",
"doge": "dogecoin",
"eth": "ethereum",
"xmr": "monero",
@ -58,6 +64,33 @@ @@ -58,6 +64,33 @@
"hint": "On-chain Bitcoin address (Bech32 bc1… preferred)"
}
},
"bolt12": {
"label": "Bolt 12",
"symbol": "₿",
"category": "bitcoin",
"authority": {
"placeholder": "lno1…",
"hint": "BOLT-12 offer (static offer string, e.g. lno1…)"
}
},
"bip353": {
"label": "BIP-353",
"symbol": "₿",
"category": "bitcoin",
"authority": {
"placeholder": "₿user@example.com",
"hint": "BIP-353 DNS payment address (human-readable name@domain)"
}
},
"bip352": {
"label": "BIP-352",
"symbol": "₿",
"category": "bitcoin",
"authority": {
"placeholder": "sp1…",
"hint": "BIP-352 silent payment address (sp1…)"
}
},
"liquid": {
"label": "Liquid",
"symbol": "⛓",
@ -78,15 +111,6 @@ @@ -78,15 +111,6 @@
"hint": "Liquid Bitcoin (L-BTC) receiving address"
}
},
"sats": {
"label": "Satoshis",
"symbol": "丰",
"category": "bitcoin",
"authority": {
"placeholder": "bc1q… or lightning address",
"hint": "Satoshis payment target (same formats as Bitcoin / Lightning)"
}
},
"lightning": {
"label": "Lightning Network",
"symbol": "⚡",

18
src/hooks/useProfileWall.tsx

@ -16,6 +16,7 @@ import { normalizeAnyRelayUrl } from '@/lib/url' @@ -16,6 +16,7 @@ import { normalizeAnyRelayUrl } from '@/lib/url'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import client, { replaceableEventService } from '@/services/client.service'
import { ReplaceableEventService } from '@/services/client-replaceable-events.service'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Event, kinds, type Filter } from 'nostr-tools'
@ -185,5 +186,22 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine @@ -185,5 +186,22 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine
setRefreshToken((t) => t + 1)
}, [cacheKey])
useEffect(() => {
const onAuthorReplaceablesRefreshed: EventListener = (domEvt) => {
const pk = (domEvt as CustomEvent<{ pubkey?: string }>).detail?.pubkey?.toLowerCase()
if (!pk || pk !== normalizeHexPubkey(pubkey)) return
refresh()
}
window.addEventListener(
ReplaceableEventService.AUTHOR_REPLACEABLES_REFRESHED_EVENT,
onAuthorReplaceablesRefreshed
)
return () =>
window.removeEventListener(
ReplaceableEventService.AUTHOR_REPLACEABLES_REFRESHED_EVENT,
onAuthorReplaceablesRefreshed
)
}, [pubkey, refresh])
return { badges, comments, isLoading, refresh }
}

26
src/i18n/locales/de.ts

@ -156,6 +156,12 @@ export default { @@ -156,6 +156,12 @@ export default {
"Payment type": "Zahlungsart",
"paytoEditor.intro":
"Zahlungsart wählen, dann Adresse oder Benutzername wie in der Hinweiszeile darunter eintragen.",
"paytoEditor.other": "Sonstiges",
"paytoEditor.customTypeLabel": "Eigener Zahlungstyp",
"paytoEditor.customTypePlaceholder": "Eigener Typ (z. B. mycoin)",
"paytoEditor.customTypeHint":
"Für eigene Optionen, die nicht in der Liste stehen. Im Typ nur Kleinbuchstaben, Ziffern und Bindestriche.",
"paytoEditor.choosePresetType": "Aus Liste wählen",
"NIP-A3 payto tags: type (e.g. lightning) and authority (e.g. user@domain.com).": "NIP-A3 payto-Tags: Typ (z. B. lightning) und Authority (z. B. user@domain.com).",
"Type (e.g. lightning)": "Type (e.g. lightning)",
"Authority (e.g. user@domain.com)": "Authority (e.g. user@domain.com)",
@ -1768,12 +1774,30 @@ export default { @@ -1768,12 +1774,30 @@ export default {
"RSS Feed Settings": "RSS Feed Settings",
"Follow sets": "Folgenlisten",
"Personal Lists": "Personal Lists",
"Personal lists hub intro": "Open mute list, following, bookmarks list, pinned notes, interest topics (kind 10015), your NIP-30 user emoji list (kind 10030), and emoji set packs (kind 30030) on their own pages. Follow sets are below. Web page bookmarks (NIP-B0, kind 39701) are separate—save from an article’s panel or use the Bookmarks spell for a mixed feed.",
"Personal lists hub intro": "Open mute list, following, bookmarks list, pinned notes, profile badges (kind 10008), interest topics (kind 10015), your NIP-30 user emoji list (kind 10030), and emoji set packs (kind 30030) on their own pages. Follow sets are below. Web page bookmarks (NIP-B0, kind 39701) are separate—save from an article’s panel or use the Bookmarks spell for a mixed feed.",
"Mute list": "Mute list",
"Following list": "Following list",
"Bookmarks list": "Bookmarks list",
"Pinned notes list": "Pinned notes list",
"Interests list": "Interests list",
"Profile badges list": "Profil-Abzeichenliste",
"Profile badges list intro":
"NIP-58-Abzeichen auf deiner Profil-Pinnwand: aufeinanderfolgende `a`- (Abzeichendefinition) und `e`-Tags (Abzeichenvergabe) auf Kind 10008. Veröffentlichen, wenn du fertig bist.",
"Profile badges migrate hint":
"Du hast noch eine veraltete Profil-Abzeichenliste (Kind 30008, `d=profile_badges`). Einträge nach Kind 10008 kopieren — das alte Event wird nicht gelöscht.",
"Migrate from kind 30008": "Von Kind 30008 migrieren",
"No profile badges on your list": "Noch keine Profil-Abzeichen in deiner Liste.",
"Profile badges list updated": "Profil-Abzeichenliste veröffentlicht",
"Migrated profile badges to kind 10008": "Profil-Abzeichen nach Kind 10008 migriert",
"No badges found in deprecated list": "In der veralteten Liste wurden keine Abzeichen gefunden",
"Profile badges need both definition (a) and award (e)":
"Abzeichendefinition (a) und Vergabe-Event-ID (e) eingeben.",
"Award must be a 64-character hex event id": "Die Vergabe muss eine 64-stellige Hex-Event-ID sein",
"Add badge": "Abzeichen hinzufügen",
"Badge definition (a tag), e.g. 30009:pubkey:bravery":
"Abzeichendefinition (a-Tag), z. B. 30009:pubkey:bravery",
"Badge award event id (e tag)": "Event-ID der Abzeichenvergabe (e-Tag)",
"Publish profile badges list": "Profil-Abzeichenliste veröffentlichen",
"User emoji list": "User emoji list (kind 10030)",
"Emoji sets": "Emoji sets (kind 30030)",
"User emoji list title": "{{username}}'s emoji list",

26
src/i18n/locales/en.ts

@ -161,6 +161,12 @@ export default { @@ -161,6 +161,12 @@ export default {
"Payment type": "Payment type",
"paytoEditor.intro":
"Choose a payment type, then enter the address or username shown in the hint below each field.",
"paytoEditor.other": "Other",
"paytoEditor.customTypeLabel": "Custom payment type",
"paytoEditor.customTypePlaceholder": "Custom type (e.g. mycoin)",
"paytoEditor.customTypeHint":
"This is for custom options not in the list. Use lowercase letters, numbers, and hyphens in the type name.",
"paytoEditor.choosePresetType": "Choose from list",
"NIP-A3 payto tags: type (e.g. lightning) and authority (e.g. user@domain.com).": "NIP-A3 payto tags: type (e.g. lightning) and authority (e.g. user@domain.com).",
"Type (e.g. lightning)": "Type (e.g. lightning)",
"Authority (e.g. user@domain.com)": "Authority (e.g. user@domain.com)",
@ -1815,7 +1821,7 @@ export default { @@ -1815,7 +1821,7 @@ export default {
"RSS Feed Settings": "RSS Feed Settings",
"Follow sets": "Follow sets",
"Personal Lists": "Personal Lists",
"Personal lists hub intro": "Open mute list, following, bookmarks list, thread notification follow/mute lists (kinds 19130 / 19132), pinned notes, interest topics (kind 10015), your NIP-30 user emoji list (kind 10030), and emoji set packs (kind 30030) on their own pages. Follow sets are below. Web page bookmarks (NIP-B0, kind 39701) are separate—save from an article’s panel or use the Bookmarks spell for a mixed feed.",
"Personal lists hub intro": "Open mute list, following, bookmarks list, thread notification follow/mute lists (kinds 19130 / 19132), pinned notes, profile badges (kind 10008), interest topics (kind 10015), your NIP-30 user emoji list (kind 10030), and emoji set packs (kind 30030) on their own pages. Follow sets are below. Web page bookmarks (NIP-B0, kind 39701) are separate—save from an article’s panel or use the Bookmarks spell for a mixed feed.",
"Mute list": "Mute list",
"Following list": "Following list",
"Bookmarks list": "Bookmarks list",
@ -1823,6 +1829,24 @@ export default { @@ -1823,6 +1829,24 @@ export default {
"Notification thread mute list": "Thread notifications (mute)",
"Pinned notes list": "Pinned notes list",
"Interests list": "Interests list",
"Profile badges list": "Profile badges list",
"Profile badges list intro":
"NIP-58 badges shown on your profile wall: consecutive `a` (badge definition) and `e` (badge award) tag pairs on kind 10008. Publish when you are done editing.",
"Profile badges migrate hint":
"You still have a deprecated kind 30008 profile badges list (`d=profile_badges`). Copy its entries to kind 10008 — the old event is not deleted.",
"Migrate from kind 30008": "Migrate from kind 30008",
"No profile badges on your list": "No profile badges on your list yet.",
"Profile badges list updated": "Profile badges list published",
"Migrated profile badges to kind 10008": "Profile badges migrated to kind 10008",
"No badges found in deprecated list": "No badges found in the deprecated list",
"Profile badges need both definition (a) and award (e)":
"Enter both a badge definition coordinate and an award event id.",
"Award must be a 64-character hex event id": "Award must be a 64-character hex event id",
"Add badge": "Add badge",
"Badge definition (a tag), e.g. 30009:pubkey:bravery":
"Badge definition (a tag), e.g. 30009:pubkey:bravery",
"Badge award event id (e tag)": "Badge award event id (e tag)",
"Publish profile badges list": "Publish profile badges list",
"User emoji list": "User emoji list (kind 10030)",
"Emoji sets": "Emoji sets (kind 30030)",
"User emoji list title": "{{username}}'s emoji list",

31
src/lib/event-metadata.ts

@ -9,6 +9,7 @@ import { generateBech32IdFromATag, generateBech32IdFromETag, getImetaInfoFromIme @@ -9,6 +9,7 @@ import { generateBech32IdFromATag, generateBech32IdFromETag, getImetaInfoFromIme
import { isHttpRelayUrl, isWebsocketUrl, normalizeAnyRelayUrl, normalizeHttpRelayUrl, normalizeHttpUrl, normalizeUrl } from './url'
import { isTorBrowser } from './utils'
import logger from '@/lib/logger'
import { getCanonicalPaytoType, getPaytoEditorTypeLabel } from '@/lib/payto-registry'
const emptyHttpRelayListFields = {
httpRead: [] as string[],
@ -386,7 +387,7 @@ export function getPaymentInfoFromEvent(event: Event): TPaymentInfo | null { @@ -386,7 +387,7 @@ export function getPaymentInfoFromEvent(event: Event): TPaymentInfo | null {
// Parse each payto tag according to NIP-A3 spec
paytoTags.forEach((tag) => {
const type = tag[1]?.toLowerCase() || 'lightning' // Normalize to lowercase per spec
const type = getCanonicalPaytoType(tag[1]?.toLowerCase() || 'lightning')
const authority = tag[2] || ''
const extra = tag.slice(3) // Optional extra fields
@ -397,16 +398,7 @@ export function getPaymentInfoFromEvent(event: Event): TPaymentInfo | null { @@ -397,16 +398,7 @@ export function getPaymentInfoFromEvent(event: Event): TPaymentInfo | null {
type,
authority,
payto: paytoUri,
// Map common types to display names
displayType: type === 'lightning' ? 'Lightning Network' :
type === 'bitcoin' ? 'Bitcoin' :
type === 'ethereum' ? 'Ethereum' :
type === 'monero' ? 'Monero' :
type === 'nano' ? 'Nano' :
type === 'cashme' ? 'Cash App' :
type === 'revolut' ? 'Revolut' :
type === 'venmo' ? 'Venmo' :
type.charAt(0).toUpperCase() + type.slice(1),
displayType: getPaytoEditorTypeLabel(type),
...(extra.length > 0 && { extra })
}
methods.push(method)
@ -414,10 +406,19 @@ export function getPaymentInfoFromEvent(event: Event): TPaymentInfo | null { @@ -414,10 +406,19 @@ export function getPaymentInfoFromEvent(event: Event): TPaymentInfo | null {
// If we have methods in JSON but no tags, use JSON methods
if (methods.length === 0 && paymentInfo.methods && Array.isArray(paymentInfo.methods)) {
methods.push(...paymentInfo.methods.map((m: any) => ({
methods.push(
...paymentInfo.methods.map((m: any) => {
const type = getCanonicalPaytoType((m.type || 'lightning').toLowerCase())
const authority = m.authority || m.address || ''
return {
...m,
payto: m.payto || (m.type && m.authority ? `payto://${m.type}/${m.authority}` : undefined)
})))
type,
authority,
displayType: m.displayType || getPaytoEditorTypeLabel(type),
payto: m.payto || (type && authority ? `payto://${type}/${authority}` : undefined)
}
})
)
}
// If we have payto at root level in JSON but no methods array
@ -426,7 +427,7 @@ export function getPaymentInfoFromEvent(event: Event): TPaymentInfo | null { @@ -426,7 +427,7 @@ export function getPaymentInfoFromEvent(event: Event): TPaymentInfo | null {
payto: paymentInfo.payto,
type: paymentInfo.type || 'lightning',
authority: paymentInfo.authority,
displayType: paymentInfo.type === 'lightning' ? 'Lightning Network' : paymentInfo.type || 'Payment'
displayType: getPaytoEditorTypeLabel(paymentInfo.type || 'lightning')
})
}

1
src/lib/link.ts

@ -141,6 +141,7 @@ export const toNotificationThreadFollowList = () => '/notification-thread-follow @@ -141,6 +141,7 @@ export const toNotificationThreadFollowList = () => '/notification-thread-follow
export const toNotificationThreadMuteList = () => '/notification-thread-mute'
export const toPinsList = () => '/pins'
export const toProfileBadgesList = () => '/profile-badges'
export const toInterestsList = () => '/interests'
export const toUserEmojiList = () => '/user-emojis'

70
src/lib/nip58-profile-badges-list.test.ts

@ -0,0 +1,70 @@ @@ -0,0 +1,70 @@
import { ExtendedKind } from '@/constants'
import { LEGACY_PROFILE_BADGES_D_TAG } from '@/lib/nip58-profile-badges'
import { shouldOfferProfileBadgesMigration } from '@/lib/nip58-profile-badges-list'
import type { Event } from 'nostr-tools'
import { describe, expect, it } from 'vitest'
function listEvent(
kind: number,
created_at: number,
pairs: Array<[string, string]>,
d?: string
): Event {
const tags: string[][] = []
if (d !== undefined) tags.push(['d', d])
for (const [a, e] of pairs) {
tags.push(['a', a])
tags.push(['e', e])
}
return {
id: 'id',
pubkey: 'aa'.repeat(32),
created_at,
kind,
tags,
content: '',
sig: 'sig'
}
}
describe('shouldOfferProfileBadgesMigration', () => {
const legacy = listEvent(
ExtendedKind.PROFILE_BADGES,
100,
[['30009:aa:bravery', 'bb'.repeat(32)]],
LEGACY_PROFILE_BADGES_D_TAG
)
it('offers when only legacy list has entries', () => {
expect(shouldOfferProfileBadgesMigration(null, legacy)).toBe(true)
})
it('offers when kind 10008 is empty but legacy has entries', () => {
const empty = listEvent(ExtendedKind.PROFILE_BADGES_LIST, 200, [])
expect(shouldOfferProfileBadgesMigration(empty, legacy)).toBe(true)
})
it('offers when legacy is newer than current', () => {
const current = listEvent(ExtendedKind.PROFILE_BADGES_LIST, 50, [
['30009:aa:other', 'cc'.repeat(32)]
])
expect(shouldOfferProfileBadgesMigration(current, legacy)).toBe(true)
})
it('does not offer when current is up to date', () => {
const current = listEvent(ExtendedKind.PROFILE_BADGES_LIST, 200, [
['30009:aa:bravery', 'bb'.repeat(32)]
])
expect(shouldOfferProfileBadgesMigration(current, legacy)).toBe(false)
})
it('does not offer without legacy entries', () => {
const emptyLegacy = listEvent(
ExtendedKind.PROFILE_BADGES,
100,
[],
LEGACY_PROFILE_BADGES_D_TAG
)
expect(shouldOfferProfileBadgesMigration(null, emptyLegacy)).toBe(false)
})
})

118
src/lib/nip58-profile-badges-list.ts

@ -0,0 +1,118 @@ @@ -0,0 +1,118 @@
import {
ExtendedKind,
METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS,
METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS
} from '@/constants'
import {
isNip58ProfileBadgesListEvent,
LEGACY_PROFILE_BADGES_D_TAG,
parseProfileBadgeEntries,
type ProfileBadgeEntry
} from '@/lib/nip58-profile-badges'
import { normalizeHexPubkey } from '@/lib/pubkey'
import { fetchLatestReplaceableListEvent } from '@/lib/replaceable-list-latest'
import { normalizeAnyRelayUrl } from '@/lib/url'
import client, { replaceableEventService } from '@/services/client.service'
import type { Event } from 'nostr-tools'
export function profileBadgeEntriesToTags(entries: ProfileBadgeEntry[]): string[][] {
const tags: string[][] = []
for (const entry of entries) {
tags.push(['a', entry.definitionCoordinate])
tags.push(['e', entry.awardEventId])
}
return tags
}
export function profileBadgeListTagsAfterRemovingEntry(
tags: string[][],
entry: ProfileBadgeEntry
): string[][] | null {
const parsed = parseProfileBadgeEntries({ kind: ExtendedKind.PROFILE_BADGES_LIST, tags } as Event)
const next = parsed.filter(
(row) =>
!(
row.definitionCoordinate === entry.definitionCoordinate &&
row.awardEventId === entry.awardEventId
)
)
if (next.length === parsed.length) return null
return profileBadgeEntriesToTags(next)
}
export async function fetchProfileBadgesListEvent(
pubkeyHex: string,
relayUrls: string[]
): Promise<Event | undefined> {
const pk = normalizeHexPubkey(pubkeyHex)
let cached: Event | undefined
try {
cached =
(await replaceableEventService.fetchReplaceableEvent(pk, ExtendedKind.PROFILE_BADGES_LIST)) ??
undefined
} catch {
cached = undefined
}
const fromRelays = relayUrls.length
? await fetchLatestReplaceableListEvent(pk, ExtendedKind.PROFILE_BADGES_LIST, relayUrls)
: undefined
if (!cached) return fromRelays
if (!fromRelays) return cached
return fromRelays.created_at >= cached.created_at ? fromRelays : cached
}
/** Deprecated NIP-58 profile badges (kind 30008, d=profile_badges). */
export async function fetchLegacyProfileBadgesListEvent(
pubkeyHex: string,
relayUrls: string[]
): Promise<Event | undefined> {
const pk = normalizeHexPubkey(pubkeyHex)
let cached: Event | undefined
try {
cached =
(await replaceableEventService.fetchReplaceableEvent(
pk,
ExtendedKind.PROFILE_BADGES,
LEGACY_PROFILE_BADGES_D_TAG
)) ?? undefined
} catch {
cached = undefined
}
const allUrls = [...new Set(relayUrls.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean))]
if (!allUrls.length) return cached
const rows = await client.fetchEvents(
allUrls,
{
authors: [pk],
kinds: [ExtendedKind.PROFILE_BADGES],
'#d': [LEGACY_PROFILE_BADGES_D_TAG],
limit: 20
},
{
replaceableRace: true,
eoseTimeout: METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS,
globalTimeout: METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS
}
)
const legacyRows = rows.filter(isNip58ProfileBadgesListEvent)
if (!legacyRows.length) return cached
const newest = legacyRows.reduce((best, e) => (e.created_at > best.created_at ? e : best))
if (!cached) return newest
return newest.created_at >= cached.created_at ? newest : cached
}
export function shouldOfferProfileBadgesMigration(
currentList: Event | null | undefined,
legacyList: Event | null | undefined
): boolean {
if (!legacyList || !isNip58ProfileBadgesListEvent(legacyList)) return false
const legacyEntries = parseProfileBadgeEntries(legacyList)
if (legacyEntries.length === 0) return false
if (!currentList || currentList.kind !== ExtendedKind.PROFILE_BADGES_LIST) return true
const currentEntries = parseProfileBadgeEntries(currentList)
if (currentEntries.length === 0) return true
return legacyList.created_at > currentList.created_at
}

46
src/lib/payto-editor-hints.test.ts

@ -1,5 +1,13 @@ @@ -1,5 +1,13 @@
import { describe, expect, it } from 'vitest'
import { getPaytoAuthorityFieldHelp, getPaytoLogoPath, paytoEditorSelectTypes } from './payto-registry'
import {
getCanonicalPaytoType,
getPaytoAuthorityFieldHelp,
getPaytoLogoPath,
getPaytoTypeInfo,
isPaytoEditorCustomType,
PAYTO_EDITOR_OTHER_OPTION,
paytoEditorSelectTypes
} from './payto-registry'
describe('getPaytoAuthorityFieldHelp', () => {
it('returns lightning-specific hint', () => {
@ -15,10 +23,40 @@ describe('getPaytoAuthorityFieldHelp', () => { @@ -15,10 +23,40 @@ describe('getPaytoAuthorityFieldHelp', () => {
})
describe('paytoEditorSelectTypes', () => {
it('appends custom type not in curated list', () => {
const types = paytoEditorSelectTypes('custom-coin')
it('ends with Other option', () => {
const types = paytoEditorSelectTypes()
expect(types[0]).toBe('lightning')
expect(types).toContain('custom-coin')
expect(types.at(-1)).toBe(PAYTO_EDITOR_OTHER_OPTION)
expect(types).not.toContain('custom-coin')
expect(types).not.toContain('sats')
expect(types).toContain('bolt12')
expect(types).toContain('bip353')
expect(types).toContain('bip352')
})
})
describe('payto aliases', () => {
it('maps sats to lightning (bitcoin-layer category)', () => {
expect(getCanonicalPaytoType('sats')).toBe('lightning')
expect(getPaytoTypeInfo('sats')?.category).toBe('bitcoin-layer')
expect(getPaytoAuthorityFieldHelp('sats').hint.toLowerCase()).toContain('lud')
})
})
describe('bitcoin family types', () => {
it('uses bitcoin symbol and category for bolt12 and BIPs', () => {
const help = getPaytoAuthorityFieldHelp('bolt12')
expect(help.placeholder).toContain('lno1')
expect(getPaytoAuthorityFieldHelp('bip353').placeholder).toContain('@')
expect(getPaytoAuthorityFieldHelp('bip352').placeholder).toContain('sp1')
})
})
describe('isPaytoEditorCustomType', () => {
it('treats unknown types as custom', () => {
expect(isPaytoEditorCustomType('custom-coin')).toBe(true)
expect(isPaytoEditorCustomType(PAYTO_EDITOR_OTHER_OPTION)).toBe(true)
expect(isPaytoEditorCustomType('lightning')).toBe(false)
})
})

20
src/lib/payto-registry.ts

@ -34,6 +34,9 @@ const catalog = paytoTypesCatalog as PaytoTypesCatalogJson @@ -34,6 +34,9 @@ const catalog = paytoTypesCatalog as PaytoTypesCatalogJson
export const PAYTO_EDITOR_TYPE_ORDER: readonly string[] = catalog.editorOrder
/** Select value: opens free-text payto type field (not published as this literal). */
export const PAYTO_EDITOR_OTHER_OPTION = '__other__'
const GENERIC_AUTHORITY_HELP: PaytoAuthorityHelp = catalog.genericAuthorityHelp
const PAYTO_TYPE_ALIASES: Record<string, string> = catalog.aliases
@ -77,13 +80,16 @@ export function getPaytoEditorTypeLabel(type: string): string { @@ -77,13 +80,16 @@ export function getPaytoEditorTypeLabel(type: string): string {
return getPaytoTypeInfo(type)?.label ?? getCanonicalPaytoType(type)
}
/** Dropdown options: catalog order plus the row's type when not listed. */
export function paytoEditorSelectTypes(currentType: string): string[] {
const key = getCanonicalPaytoType(currentType)
const ordered = new Set(PAYTO_EDITOR_TYPE_ORDER)
const out = [...PAYTO_EDITOR_TYPE_ORDER]
if (key && !ordered.has(key)) out.push(key)
return out
/** True when the row uses a custom payto type (Other selected or unknown type from JSON). */
export function isPaytoEditorCustomType(type: string): boolean {
const trimmed = type.trim()
if (!trimmed || trimmed === PAYTO_EDITOR_OTHER_OPTION) return true
return !isKnownPaytoType(trimmed)
}
/** Dropdown options: catalog presets plus “Other”. */
export function paytoEditorSelectTypes(): string[] {
return [...PAYTO_EDITOR_TYPE_ORDER, PAYTO_EDITOR_OTHER_OPTION]
}
/** Bundled asset URL for `<img src>` (resolved from catalog `logoAssetPath`). */

2
src/lib/payto.ts

@ -16,7 +16,9 @@ export { @@ -16,7 +16,9 @@ export {
getPaytoTypeInfo,
isKnownPaytoType,
isLightningPaytoType,
isPaytoEditorCustomType,
paytoEditorSelectTypes,
PAYTO_EDITOR_OTHER_OPTION,
PAYTO_EDITOR_TYPE_ORDER,
PAYTO_KNOWN_TYPES,
type PaytoAuthorityHelp,

60
src/lib/replaceable-list-latest.ts

@ -1,13 +1,41 @@ @@ -1,13 +1,41 @@
import { METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS, METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS } from '@/constants'
import { ExtendedKind, METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS, METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS } from '@/constants'
import { normalizeHexPubkey } from '@/lib/pubkey'
import { normalizeAnyRelayUrl } from '@/lib/url'
import client from '@/services/client.service'
import client, { eventService } from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import type { TPersonalListBech32Ref } from '@/lib/personal-list-mutations'
import type { Event } from 'nostr-tools'
import { kinds, type Event } from 'nostr-tools'
function isSlowReplaceableListKind(kind: number): boolean {
return (
kind === kinds.Metadata ||
kind === 10001 ||
kind === ExtendedKind.PAYMENT_INFO ||
kind === kinds.Contacts ||
kind === kinds.RelayList ||
kind === kinds.Mutelist ||
kind === kinds.BookmarkList ||
kind === ExtendedKind.PROFILE_BADGES_LIST
)
}
function replaceableListFetchQueryOpts(kind: number) {
const slow = isSlowReplaceableListKind(kind)
return {
replaceableRace: !slow,
eoseTimeout: slow ? METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS : 100,
globalTimeout: slow ? METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS : 2000
}
}
function newestReplaceableEvent(candidates: Event[]): Event | undefined {
if (!candidates.length) return undefined
return candidates.reduce((best, e) => (e.created_at > best.created_at ? e : best))
}
/**
* REQ across relays with {@link replaceableRace}, then keep the newest `created_at` row for this author+kind.
* Use before appending to pin / bookmark / follow / mute / interest lists so merges dont drop remote state.
* REQ across relays, then keep the newest `created_at` row for this author+kind.
* Slow replaceables (pins, contacts, ) wait for EOSE instead of {@link replaceableRace} so mirrors arent missed.
*/
export async function fetchLatestReplaceableListEvent(
pubkeyHex: string,
@ -18,15 +46,13 @@ export async function fetchLatestReplaceableListEvent( @@ -18,15 +46,13 @@ export async function fetchLatestReplaceableListEvent(
const allUrls = [...new Set(relayUrls.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean))]
if (!allUrls.length) return undefined
// client.fetchEvents() handles both HTTP index relays and WebSocket relays internally.
const rows = await client.fetchEvents(allUrls, { authors: [pk], kinds: [kind], limit: 80 }, {
replaceableRace: true,
eoseTimeout: METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS,
globalTimeout: METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS
})
const rows = await client.fetchEvents(
allUrls,
{ authors: [pk], kinds: [kind], limit: 80 },
replaceableListFetchQueryOpts(kind)
)
if (!rows.length) return undefined
return rows.reduce((best, e) => (e.created_at > best.created_at ? e : best))
return newestReplaceableEvent(rows)
}
/**
@ -39,15 +65,17 @@ export async function fetchNewestPinListForPubkey( @@ -39,15 +65,17 @@ export async function fetchNewestPinListForPubkey(
relayUrls: string[]
): Promise<Event | undefined> {
const pk = normalizeHexPubkey(pubkeyHex)
const diskPin = await indexedDb.getReplaceableEvent(pk, 10001).catch(() => undefined)
const sessionPins = eventService.listSessionEventsAuthoredBy(pk, { kinds: [10001], limit: 8 })
const [fromRelays, fromService] = await Promise.all([
relayUrls.length
? fetchLatestReplaceableListEvent(pk, 10001, relayUrls)
: Promise.resolve(undefined),
client.fetchPinListEvent(pk).catch(() => undefined)
])
if (!fromRelays) return fromService
if (!fromService) return fromRelays
return fromService.created_at >= fromRelays.created_at ? fromService : fromRelays
return newestReplaceableEvent(
[fromRelays, fromService, diskPin, ...sessionPins].filter((e): e is Event => !!e)
)
}
/** Whether this event is referenced by the pin list via `e` (hex id) or `a` (NIP-33 coordinate). */

14
src/pages/secondary/PersonalListsSettingsPage/index.tsx

@ -11,6 +11,7 @@ import { @@ -11,6 +11,7 @@ import {
useSmartNotificationThreadFollowListNavigation,
useSmartNotificationThreadMuteListNavigation,
useSmartPinListNavigation,
useSmartProfileBadgesListNavigation,
useSmartSettingsNavigation,
useSmartUserEmojiListNavigation
} from '@/PageManager'
@ -24,10 +25,11 @@ import { @@ -24,10 +25,11 @@ import {
toNotificationThreadFollowList,
toNotificationThreadMuteList,
toPinsList,
toProfileBadgesList,
toUserEmojiList
} from '@/lib/link'
import { useNostr } from '@/providers/NostrProvider'
import { Bookmark, Bell, BellOff, ChevronRight, Hash, Pin, Smile, Sticker, Users, VolumeX } from 'lucide-react'
import { Award, Bookmark, Bell, BellOff, ChevronRight, Hash, Pin, Smile, Sticker, Users, VolumeX } from 'lucide-react'
import { forwardRef, HTMLProps, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -46,6 +48,7 @@ const PersonalListsSettingsPage = forwardRef( @@ -46,6 +48,7 @@ const PersonalListsSettingsPage = forwardRef(
const { navigateToNotificationThreadFollowList } = useSmartNotificationThreadFollowListNavigation()
const { navigateToNotificationThreadMuteList } = useSmartNotificationThreadMuteListNavigation()
const { navigateToPinList } = useSmartPinListNavigation()
const { navigateToProfileBadgesList } = useSmartProfileBadgesListNavigation()
const { navigateToInterestList } = useSmartInterestListNavigation()
const { navigateToUserEmojiList } = useSmartUserEmojiListNavigation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
@ -134,6 +137,15 @@ const PersonalListsSettingsPage = forwardRef( @@ -134,6 +137,15 @@ const PersonalListsSettingsPage = forwardRef(
<ChevronRight />
</SettingRow>
) : null}
{pubkey ? (
<SettingRow className="clickable" onClick={() => navigateToProfileBadgesList(toProfileBadgesList())}>
<div className="flex items-center gap-3">
<Award />
<div>{t('Profile badges list')}</div>
</div>
<ChevronRight />
</SettingRow>
) : null}
{pubkey ? (
<SettingRow className="clickable" onClick={() => navigateToInterestList(toInterestsList())}>
<div className="flex items-center gap-3">

403
src/pages/secondary/ProfileBadgesListPage/index.tsx

@ -0,0 +1,403 @@ @@ -0,0 +1,403 @@
import JsonViewDialog from '@/components/JsonViewDialog'
import { RefreshButton } from '@/components/RefreshButton'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { ExtendedKind } from '@/constants'
import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls'
import { createReplaceablePersonalListDraftEvent } from '@/lib/draft-event'
import { extractBadgeDefinitionMedia } from '@/lib/badge-definition-media'
import { parseAddressableCoordinate, parseProfileBadgeEntries } from '@/lib/nip58-profile-badges'
import {
fetchLegacyProfileBadgesListEvent,
fetchProfileBadgesListEvent,
profileBadgeEntriesToTags,
shouldOfferProfileBadgesMigration
} from '@/lib/nip58-profile-badges-list'
import type { ProfileBadgeEntry } from '@/lib/nip58-profile-badges'
import { showPublishingError } from '@/lib/publishing-feedback'
import { useNostr } from '@/providers/NostrProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { replaceableEventService } from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import dayjs from 'dayjs'
import { Award, Code, Eraser, MoreVertical, Trash2 } from 'lucide-react'
import type { Event } from 'nostr-tools'
import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import NotFoundPage from '../NotFoundPage'
function BadgeEntryRow({
entry,
onRemove
}: {
entry: ProfileBadgeEntry
onRemove: () => void
}) {
const [label, setLabel] = useState(entry.definitionCoordinate)
const [imageUrl, setImageUrl] = useState<string | undefined>()
useEffect(() => {
let cancelled = false
const parsed = parseAddressableCoordinate(entry.definitionCoordinate)
if (!parsed || parsed.kind !== ExtendedKind.BADGE_DEFINITION) {
setLabel(entry.definitionCoordinate)
return
}
void replaceableEventService
.fetchReplaceableEvent(parsed.pubkey, parsed.kind, parsed.d)
.then((def) => {
if (cancelled || !def) return
const name = def.tags.find((t) => t[0] === 'name')?.[1]?.trim() || parsed.d
setLabel(name)
setImageUrl(extractBadgeDefinitionMedia(def).thumb ?? extractBadgeDefinitionMedia(def).image)
})
.catch(() => {})
return () => {
cancelled = true
}
}, [entry.definitionCoordinate])
return (
<div className="flex items-center gap-3 rounded-lg border border-border px-3 py-2">
{imageUrl ? (
<img src={imageUrl} alt="" className="h-10 w-10 shrink-0 rounded-md object-cover" />
) : (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-muted">
<Award className="h-5 w-5 text-muted-foreground" aria-hidden />
</div>
)}
<div className="min-w-0 flex-1 space-y-0.5">
<div className="truncate text-sm font-medium">{label}</div>
<div className="truncate font-mono text-xs text-muted-foreground">{entry.definitionCoordinate}</div>
<div className="truncate font-mono text-xs text-muted-foreground">e: {entry.awardEventId}</div>
</div>
<Button type="button" variant="ghost" size="icon" onClick={onRemove} aria-label="Remove">
<Trash2 className="h-4 w-4" />
</Button>
</div>
)
}
const ProfileBadgesListPage = forwardRef(
({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => {
const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const { profile, pubkey, publish, checkLogin } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const [listEvent, setListEvent] = useState<Event | null>(null)
const [legacyListEvent, setLegacyListEvent] = useState<Event | null>(null)
const [entries, setEntries] = useState<ProfileBadgeEntry[]>([])
const [newDefinitionA, setNewDefinitionA] = useState('')
const [newAwardE, setNewAwardE] = useState('')
const [publishing, setPublishing] = useState(false)
const [migrating, setMigrating] = useState(false)
const [jsonOpen, setJsonOpen] = useState(false)
const [jsonPayload, setJsonPayload] = useState<unknown>(null)
const [cleanConfirmOpen, setCleanConfirmOpen] = useState(false)
const [cleaning, setCleaning] = useState(false)
const showMigrate = useMemo(
() => shouldOfferProfileBadgesMigration(listEvent, legacyListEvent),
[listEvent, legacyListEvent]
)
const loadLists = useCallback(async () => {
if (!pubkey) {
setListEvent(null)
setLegacyListEvent(null)
setEntries([])
return
}
const relays = await buildAccountListRelayUrlsForMerge({
accountPubkey: pubkey,
favoriteRelays: favoriteRelays ?? [],
blockedRelays
})
const [current, legacy] = await Promise.all([
fetchProfileBadgesListEvent(pubkey, relays),
fetchLegacyProfileBadgesListEvent(pubkey, relays)
])
setListEvent(current ?? null)
setLegacyListEvent(legacy ?? null)
const active = current ?? null
setEntries(parseProfileBadgeEntries(active ?? undefined))
if (current) {
try {
await indexedDb.putReplaceableEvent(current)
} catch {
/* ignore */
}
}
}, [pubkey, favoriteRelays, blockedRelays])
useEffect(() => {
void loadLists()
}, [loadLists])
useEffect(() => {
if (!hideTitlebar) {
registerPrimaryPanelRefresh(null)
return
}
registerPrimaryPanelRefresh(() => {
void loadLists()
})
return () => registerPrimaryPanelRefresh(null)
}, [hideTitlebar, registerPrimaryPanelRefresh, loadLists])
const publishEntries = useCallback(
async (nextEntries: ProfileBadgeEntry[], successMessage: string) => {
if (!pubkey) return
setPublishing(true)
try {
if (dayjs().unix() === listEvent?.created_at) {
await new Promise((resolve) => setTimeout(resolve, 1000))
}
const relays = await buildAccountListRelayUrlsForMerge({
accountPubkey: pubkey,
favoriteRelays: favoriteRelays ?? [],
blockedRelays
})
const draft = createReplaceablePersonalListDraftEvent(
ExtendedKind.PROFILE_BADGES_LIST,
profileBadgeEntriesToTags(nextEntries),
''
)
const published = await publish(draft, { specifiedRelayUrls: relays })
await indexedDb.putReplaceableEvent(published)
setListEvent(published)
setEntries(nextEntries)
toast.success(successMessage)
} catch (e) {
showPublishingError(e instanceof Error ? e : new Error(String(e)))
} finally {
setPublishing(false)
}
},
[pubkey, listEvent?.created_at, favoriteRelays, blockedRelays, publish, t]
)
const handleSave = useCallback(() => {
checkLogin(() => void publishEntries(entries, t('Profile badges list updated')))
}, [checkLogin, publishEntries, entries, t])
const handleAddEntry = useCallback(() => {
const a = newDefinitionA.trim()
const e = newAwardE.trim()
if (!a || !e) {
toast.error(t('Profile badges need both definition (a) and award (e)'))
return
}
if (!/^[0-9a-f]{64}$/i.test(e)) {
toast.error(t('Award must be a 64-character hex event id'))
return
}
const next: ProfileBadgeEntry[] = [...entries, { definitionCoordinate: a, awardEventId: e.toLowerCase() }]
setEntries(next)
setNewDefinitionA('')
setNewAwardE('')
}, [newDefinitionA, newAwardE, entries, t])
const handleRemoveEntry = useCallback((entry: ProfileBadgeEntry) => {
setEntries((prev) =>
prev.filter(
(row) =>
!(
row.definitionCoordinate === entry.definitionCoordinate &&
row.awardEventId === entry.awardEventId
)
)
)
}, [])
const handleMigrate = useCallback(() => {
if (!legacyListEvent || migrating) return
checkLogin(async () => {
setMigrating(true)
try {
const migrated = parseProfileBadgeEntries(legacyListEvent)
if (migrated.length === 0) {
toast.error(t('No badges found in deprecated list'))
return
}
await publishEntries(migrated, t('Migrated profile badges to kind 10008'))
} finally {
setMigrating(false)
}
})
}, [legacyListEvent, migrating, checkLogin, publishEntries, t])
const handleCleanList = useCallback(async () => {
if (!pubkey || cleaning) return
setCleaning(true)
try {
await publishEntries([], t('List cleaned'))
} finally {
setCleaning(false)
setCleanConfirmOpen(false)
}
}, [pubkey, cleaning, publishEntries, t])
const openJson = useCallback(() => {
setJsonPayload({
profileBadgesListKind10008: listEvent ?? null,
deprecatedKind30008ProfileBadges: legacyListEvent ?? null,
derivedEntries: entries,
note: 'NIP-58 profile badges: consecutive `a` (badge definition) and `e` (badge award) tag pairs on kind 10008.'
})
setJsonOpen(true)
}, [listEvent, legacyListEvent, entries])
if (!profile || !pubkey) {
return <NotFoundPage />
}
return (
<SecondaryPageLayout
ref={ref}
index={index}
title={hideTitlebar ? undefined : t('Profile badges list')}
hideBackButton={hideTitlebar}
controls={
hideTitlebar ? undefined : (
<div className="flex items-center gap-0">
<RefreshButton onClick={() => void loadLists()} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t('More options')}>
<MoreVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openJson()}>
<Code className="mr-2 size-4" />
{t('View JSON')}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setCleanConfirmOpen(true)}
>
<Eraser className="mr-2 size-4" />
{t('Clean list')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
displayScrollToTopButton
>
<JsonViewDialog value={jsonPayload} isOpen={jsonOpen} onClose={() => setJsonOpen(false)} />
<AlertDialog open={cleanConfirmOpen} onOpenChange={setCleanConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('Clean this list?')}</AlertDialogTitle>
<AlertDialogDescription>{t('Clean list confirm')}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={cleaning}>{t('Cancel')}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={cleaning}
onClick={(e) => {
e.preventDefault()
void handleCleanList()
}}
>
{cleaning ? t('loading...') : t('Clean list')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<div className="space-y-4 px-4 pt-2 pb-8">
<p className="text-sm text-muted-foreground">{t('Profile badges list intro')}</p>
{showMigrate && (
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-3 space-y-2">
<p className="text-sm">{t('Profile badges migrate hint')}</p>
<Button
type="button"
variant="secondary"
size="sm"
disabled={migrating || publishing}
onClick={() => handleMigrate()}
>
{migrating ? t('loading...') : t('Migrate from kind 30008')}
</Button>
</div>
)}
{entries.length === 0 ? (
<p className="text-center text-sm text-muted-foreground py-4">
{t('No profile badges on your list')}
</p>
) : (
<div className="space-y-2">
{entries.map((entry) => (
<BadgeEntryRow
key={`${entry.definitionCoordinate}:${entry.awardEventId}`}
entry={entry}
onRemove={() => handleRemoveEntry(entry)}
/>
))}
</div>
)}
<div className="rounded-lg border border-border p-4 space-y-3">
<Label className="text-sm font-medium">{t('Add badge')}</Label>
<div className="space-y-2">
<Input
value={newDefinitionA}
onChange={(e) => setNewDefinitionA(e.target.value)}
placeholder={t('Badge definition (a tag), e.g. 30009:pubkey:bravery')}
className="font-mono text-sm"
/>
<Input
value={newAwardE}
onChange={(e) => setNewAwardE(e.target.value)}
placeholder={t('Badge award event id (e tag)')}
className="font-mono text-sm"
/>
</div>
<Button type="button" variant="outline" size="sm" onClick={handleAddEntry}>
{t('Add to list')}
</Button>
</div>
<Button
type="button"
className="w-full"
disabled={publishing || migrating}
onClick={handleSave}
>
{publishing ? t('Publishing...') : t('Publish profile badges list')}
</Button>
</div>
</SecondaryPageLayout>
)
}
)
ProfileBadgesListPage.displayName = 'ProfileBadgesListPage'
export default ProfileBadgesListPage

49
src/pages/secondary/ProfileEditorPage/index.tsx

@ -35,6 +35,7 @@ import { @@ -35,6 +35,7 @@ import {
import { canUseNostrBuildThumb, toNostrBuildThumbUrl } from '@/lib/nostr-build'
import { isVideo } from '@/lib/url'
import PaymentMethodRow from '@/components/ProfileEditor/PaymentMethodRow'
import { PAYTO_EDITOR_OTHER_OPTION } from '@/lib/payto'
import { ChevronDown, Fingerprint, Pencil, Plus, RefreshCw, Trash2, Upload } from 'lucide-react'
import type { Event } from 'nostr-tools'
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'
@ -238,8 +239,11 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -238,8 +239,11 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
const savePaymentInfo = useCallback(async () => {
if (savingPaymentInfoRef.current) return
const tags: string[][] = paymentInfoEditMethods
.filter((m) => m.authority.trim())
.map((m) => ['payto', (m.type.trim() || 'lightning').toLowerCase(), m.authority.trim()])
.filter((m) => {
const type = m.type.trim()
return m.authority.trim() && type && type !== PAYTO_EDITOR_OTHER_OPTION
})
.map((m) => ['payto', m.type.trim().toLowerCase(), m.authority.trim()])
savingPaymentInfoRef.current = true
setSavingPaymentInfo(true)
try {
@ -734,34 +738,6 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -734,34 +738,6 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
{paymentInfoEvent ? t('Edit payment info') : t('Add payment info')}
</Button>
</div>
<Collapsible>
<CollapsibleTrigger className="flex items-center gap-2 text-sm text-muted-foreground mt-1">
<ChevronDown className="h-4 w-4 transition-transform [[data-state=open]_&]:rotate-180" />
{t('Raw payment info event')}
</CollapsibleTrigger>
<CollapsibleContent className="pt-2 space-y-2">
{paymentInfoEvent ? (
<>
<div>
<Label className="text-muted-foreground text-xs">{t('Content (JSON)')}</Label>
<pre className="mt-1 p-3 rounded-md bg-muted text-xs overflow-auto max-h-48 break-all whitespace-pre-wrap">
{paymentInfoEvent.content || '{}'}
</pre>
</div>
<div>
<Label className="text-muted-foreground text-xs">{t('Tags')}</Label>
<pre className="mt-1 p-3 rounded-md bg-muted text-xs overflow-auto max-h-48">
{JSON.stringify(paymentInfoEvent.tags ?? [], null, 2)}
</pre>
</div>
</>
) : (
<p className="text-sm text-muted-foreground">
{t('No payment info event yet. Click "Add payment info" to create one.')}
</p>
)}
</CollapsibleContent>
</Collapsible>
</Item>
</div>
@ -813,7 +789,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -813,7 +789,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
<DialogHeader>
<DialogTitle>{t('Edit payment info')} (kind 10133)</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-auto space-y-4">
<div className="flex-1 overflow-auto space-y-4 pb-6">
<Item>
<Label className="text-muted-foreground">{t('Payment methods')}</Label>
<p className="text-xs text-muted-foreground">
@ -883,12 +859,11 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -883,12 +859,11 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
createPaymentInfoDraftEvent(
paymentInfoEditContent.trim() || '{}',
paymentInfoEditMethods
.filter((m) => m.authority.trim())
.map((m) => [
'payto',
(m.type.trim() || 'lightning').toLowerCase(),
m.authority.trim()
])
.filter((m) => {
const type = m.type.trim()
return m.authority.trim() && type && type !== PAYTO_EDITOR_OTHER_OPTION
})
.map((m) => ['payto', m.type.trim().toLowerCase(), m.authority.trim()])
),
null,
2

19
src/providers/NostrProvider/index.tsx

@ -6,6 +6,7 @@ import { @@ -6,6 +6,7 @@ import {
ACCOUNT_SESSION_NETWORK_HYDRATE_MIN_INTERVAL_MS,
DEFAULT_FAVORITE_RELAYS,
FAST_READ_RELAY_URLS,
AUTHOR_PROFILE_VIEW_REPLACEABLE_KINDS,
ExtendedKind,
PROFILE_RELAY_URLS,
SEARCHABLE_RELAY_URLS,
@ -553,17 +554,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -553,17 +554,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const fetchRelays = Array.from(new Set(normalizedRelays)).slice(0, 16)
const events = await queryService.fetchEvents(fetchRelays, [
{
kinds: [
kinds.Metadata,
kinds.Contacts,
kinds.Mutelist,
kinds.BookmarkList,
INTEREST_LIST_KIND,
ExtendedKind.FAVORITE_RELAYS,
ExtendedKind.BLOCKED_RELAYS,
ExtendedKind.BLOSSOM_SERVER_LIST,
kinds.UserEmojiList
],
kinds: [...AUTHOR_PROFILE_VIEW_REPLACEABLE_KINDS],
authors: [account.pubkey]
}
], hydrateFetchOpts)
@ -787,6 +778,12 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -787,6 +778,12 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}
}
await replaceableEventService
.refreshAuthorPublishedReplaceablesFromRelays(account.pubkey)
.catch((err) => {
logger.debug('[NostrProvider] Author replaceables refresh after hydrate failed', { error: err })
})
storage.setAccountNetworkHydrateAt(account.pubkey, Date.now())
void client.runSessionPrewarm({ pubkey: account.pubkey, signal: controller.signal })
logger.debug('[NostrProvider] Account session hydrate: core relay/profile merge finished; client prewarm started (parallel)', {

2
src/routes.tsx

@ -24,6 +24,7 @@ const NotificationThreadMuteListPageLazy = lazy(() => @@ -24,6 +24,7 @@ const NotificationThreadMuteListPageLazy = lazy(() =>
}))
)
const PinListPageLazy = lazy(() => import('./pages/secondary/PinListPage'))
const ProfileBadgesListPageLazy = lazy(() => import('./pages/secondary/ProfileBadgesListPage'))
const InterestListPageLazy = lazy(() => import('./pages/secondary/InterestListPage'))
const NoteListPageLazy = lazy(() => import('./pages/secondary/NoteListPage'))
const NotePageLazy = lazy(() => import('./pages/secondary/NotePage'))
@ -104,6 +105,7 @@ const ROUTES = [ @@ -104,6 +105,7 @@ const ROUTES = [
{ path: '/notification-thread-follow', element: SR(NotificationThreadFollowListPageLazy) },
{ path: '/notification-thread-mute', element: SR(NotificationThreadMuteListPageLazy) },
{ path: '/pins', element: SR(PinListPageLazy) },
{ path: '/profile-badges', element: SR(ProfileBadgesListPageLazy) },
{ path: '/interests', element: SR(InterestListPageLazy) },
{ path: '/user-emojis', element: SR(UserEmojiListPageLazy) },
{ path: '/follow-packs', element: SR(FollowPacksRedirectLazy) }

55
src/services/client-replaceable-events.service.ts

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
import {
AUTHOR_PROFILE_VIEW_REPLACEABLE_KINDS,
ExtendedKind,
FAST_READ_RELAY_URLS,
FEED_PROFILE_BATCH_FETCH_TIMEOUT_MS,
@ -15,6 +16,7 @@ import type { Event as NEvent, Filter } from 'nostr-tools' @@ -15,6 +16,7 @@ import type { Event as NEvent, Filter } from 'nostr-tools'
import DataLoader from 'dataloader'
import { isHttpRelayUrl, isWebsocketUrl, normalizeAnyRelayUrl, normalizeHttpUrl, normalizeUrl } from '@/lib/url'
import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata'
import { LEGACY_PROFILE_BADGES_D_TAG } from '@/lib/nip58-profile-badges'
import { formatPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib/pubkey'
import { getPubkeysFromPTags, getServersFromServerTags } from '@/lib/tag'
import { TProfile } from '@/types'
@ -1358,8 +1360,14 @@ export class ReplaceableEventService { @@ -1358,8 +1360,14 @@ export class ReplaceableEventService {
async forceRefreshProfileAndPaymentInfoCache(pubkey: string): Promise<void> {
const pk = pubkey.trim().toLowerCase()
if (!/^[0-9a-f]{64}$/.test(pk)) return
this.replaceableEventFromBigRelaysDataloader.clear({ pubkey: pk, kind: kinds.Metadata })
this.replaceableEventFromBigRelaysDataloader.clear({ pubkey: pk, kind: ExtendedKind.PAYMENT_INFO })
for (const kind of AUTHOR_PROFILE_VIEW_REPLACEABLE_KINDS) {
this.replaceableEventFromBigRelaysDataloader.clear({ pubkey: pk, kind })
}
this.replaceableEventDataLoader.clear({
pubkey: pk,
kind: ExtendedKind.PROFILE_BADGES,
d: LEGACY_PROFILE_BADGES_D_TAG
})
await this.refreshAuthorPublishedReplaceablesFromRelays(pk)
}
@ -1370,24 +1378,6 @@ export class ReplaceableEventService { @@ -1370,24 +1378,6 @@ export class ReplaceableEventService {
*/
static readonly AUTHOR_REPLACEABLES_REFRESHED_EVENT = 'jumble:author-replaceables-refreshed' as const
private static readonly PROFILE_VIEW_AUTHOR_REPLACEABLE_KINDS: readonly number[] = [
kinds.Metadata,
kinds.Contacts,
kinds.RelayList,
kinds.Mutelist,
kinds.BookmarkList,
10001, // pins (NIP-51)
10015, // interests
ExtendedKind.FAVORITE_RELAYS,
ExtendedKind.BLOCKED_RELAYS,
ExtendedKind.BLOSSOM_SERVER_LIST,
ExtendedKind.PAYMENT_INFO,
kinds.UserEmojiList,
ExtendedKind.CACHE_RELAYS,
ExtendedKind.HTTP_RELAY_LIST,
ExtendedKind.RSS_FEED_LIST
]
async refreshAuthorPublishedReplaceablesFromRelays(pubkey: string): Promise<void> {
const pk = pubkey.trim().toLowerCase()
if (!/^[0-9a-f]{64}$/.test(pk)) return
@ -1428,7 +1418,7 @@ export class ReplaceableEventService { @@ -1428,7 +1418,7 @@ export class ReplaceableEventService {
const events = await this.queryService.query(
relayUrls,
{ authors: [pk], kinds: [...ReplaceableEventService.PROFILE_VIEW_AUTHOR_REPLACEABLE_KINDS] },
{ authors: [pk], kinds: [...AUTHOR_PROFILE_VIEW_REPLACEABLE_KINDS] },
undefined,
{
replaceableRace: false,
@ -1437,6 +1427,22 @@ export class ReplaceableEventService { @@ -1437,6 +1427,22 @@ export class ReplaceableEventService {
}
)
const legacyProfileBadgeRows = await this.queryService.query(
relayUrls,
{
authors: [pk],
kinds: [ExtendedKind.PROFILE_BADGES],
'#d': [LEGACY_PROFILE_BADGES_D_TAG],
limit: 10
},
undefined,
{
replaceableRace: false,
eoseTimeout: METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS,
globalTimeout: METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS
}
)
const bestByKind = new Map<number, NEvent>()
for (const e of events) {
if (shouldDropEventOnIngest(e)) continue
@ -1446,8 +1452,13 @@ export class ReplaceableEventService { @@ -1446,8 +1452,13 @@ export class ReplaceableEventService {
}
}
const legacyProfileBadges = legacyProfileBadgeRows.filter(shouldDropEventOnIngest).reduce<
NEvent | undefined
>((best, e) => (!best || e.created_at > best.created_at ? e : best), undefined)
await Promise.allSettled(
Array.from(bestByKind.values()).map(async (ev) => {
[...Array.from(bestByKind.values()), ...(legacyProfileBadges ? [legacyProfileBadges] : [])].map(
async (ev) => {
try {
await indexedDb.putReplaceableEvent(ev)
} catch {

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

@ -95,6 +95,8 @@ export const StoreNames = { @@ -95,6 +95,8 @@ export const StoreNames = {
/** Imwald kind 19132: thread roots to hide interaction notifications for. */
NOTIFICATION_THREAD_MUTE_EVENTS: 'notificationThreadMuteEvents',
PIN_LIST_EVENTS: 'pinListEvents',
/** NIP-58 profile badges display list (kind 10008). */
PROFILE_BADGES_LIST_EVENTS: 'profileBadgesListEvents',
BLOSSOM_SERVER_LIST_EVENTS: 'blossomServerListEvents',
INTEREST_LIST_EVENTS: 'interestListEvents',
MUTE_DECRYPTED_TAGS: 'muteDecryptedTags',
@ -188,6 +190,7 @@ const REPLACEABLE_METADATA_EVENT_STORES: ReadonlySet<string> = new Set([ @@ -188,6 +190,7 @@ const REPLACEABLE_METADATA_EVENT_STORES: ReadonlySet<string> = new Set([
StoreNames.NOTIFICATION_THREAD_FOLLOW_EVENTS,
StoreNames.NOTIFICATION_THREAD_MUTE_EVENTS,
StoreNames.PIN_LIST_EVENTS,
StoreNames.PROFILE_BADGES_LIST_EVENTS,
StoreNames.INTEREST_LIST_EVENTS,
StoreNames.BLOSSOM_SERVER_LIST_EVENTS,
StoreNames.USER_EMOJI_LIST_EVENTS,
@ -211,7 +214,7 @@ const FULL_TEXT_NOTE_SEARCH_STORES: ReadonlySet<string> = new Set([ @@ -211,7 +214,7 @@ const FULL_TEXT_NOTE_SEARCH_STORES: ReadonlySet<string> = new Set([
const ARCHIVE_CALENDAR_PURGE_SETTING_KEY = 'archiveCalendarPurgedV37'
/** Schema version we expect. When adding stores or migrations, bump this. */
const DB_VERSION = 37
const DB_VERSION = 38
/** Max age for profile and payment info cache before we refetch (5 min). */
const PROFILE_AND_PAYMENT_CACHE_MAX_AGE_MS = 5 * 60 * 1000
@ -371,6 +374,9 @@ class IndexedDbService { @@ -371,6 +374,9 @@ class IndexedDbService {
if (!db.objectStoreNames.contains(StoreNames.PIN_LIST_EVENTS)) {
db.createObjectStore(StoreNames.PIN_LIST_EVENTS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.PROFILE_BADGES_LIST_EVENTS)) {
db.createObjectStore(StoreNames.PROFILE_BADGES_LIST_EVENTS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.INTEREST_LIST_EVENTS)) {
db.createObjectStore(StoreNames.INTEREST_LIST_EVENTS, { keyPath: 'key' })
}
@ -1133,6 +1139,8 @@ class IndexedDbService { @@ -1133,6 +1139,8 @@ class IndexedDbService {
return StoreNames.NOTIFICATION_THREAD_MUTE_EVENTS
case 10001: // Pin list
return StoreNames.PIN_LIST_EVENTS
case ExtendedKind.PROFILE_BADGES_LIST:
return StoreNames.PROFILE_BADGES_LIST_EVENTS
case 10015: // Interest list
return StoreNames.INTEREST_LIST_EVENTS
case ExtendedKind.BLOSSOM_SERVER_LIST:
@ -2248,6 +2256,7 @@ class IndexedDbService { @@ -2248,6 +2256,7 @@ class IndexedDbService {
if (storeName === StoreNames.NOTIFICATION_THREAD_MUTE_EVENTS)
return ExtendedKind.EVENTS_I_MUTED_NOTIFICATIONS_LIST
if (storeName === StoreNames.PIN_LIST_EVENTS) return 10001
if (storeName === StoreNames.PROFILE_BADGES_LIST_EVENTS) return ExtendedKind.PROFILE_BADGES_LIST
if (storeName === StoreNames.INTEREST_LIST_EVENTS) return 10015
if (storeName === StoreNames.BLOSSOM_SERVER_LIST_EVENTS) return ExtendedKind.BLOSSOM_SERVER_LIST
if (storeName === StoreNames.RELAY_SETS) return kinds.Relaysets

Loading…
Cancel
Save