+ {t('paytoEditor.customTypeHint', {
+ defaultValue:
+ 'This is for custom options not in the list. Use lowercase letters, numbers, and hyphens in the type name.'
+ })}
+
+
+
+ ) : (
+
+ )}
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 }
}
diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts
index 2816d555..45bfaeea 100644
--- a/src/i18n/locales/de.ts
+++ b/src/i18n/locales/de.ts
@@ -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 {
"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",
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index d653fdfc..7f287149 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -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 {
"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 {
"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",
diff --git a/src/lib/event-metadata.ts b/src/lib/event-metadata.ts
index c64814bd..a2cfea88 100644
--- a/src/lib/event-metadata.ts
+++ b/src/lib/event-metadata.ts
@@ -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 {
// 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 {
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 {
// 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) => ({
- ...m,
- payto: m.payto || (m.type && m.authority ? `payto://${m.type}/${m.authority}` : undefined)
- })))
+ methods.push(
+ ...paymentInfo.methods.map((m: any) => {
+ const type = getCanonicalPaytoType((m.type || 'lightning').toLowerCase())
+ const authority = m.authority || m.address || ''
+ return {
+ ...m,
+ 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 {
payto: paymentInfo.payto,
type: paymentInfo.type || 'lightning',
authority: paymentInfo.authority,
- displayType: paymentInfo.type === 'lightning' ? 'Lightning Network' : paymentInfo.type || 'Payment'
+ displayType: getPaytoEditorTypeLabel(paymentInfo.type || 'lightning')
})
}
diff --git a/src/lib/link.ts b/src/lib/link.ts
index 4ebfeb3b..74aae5ff 100644
--- a/src/lib/link.ts
+++ b/src/lib/link.ts
@@ -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'
diff --git a/src/lib/nip58-profile-badges-list.test.ts b/src/lib/nip58-profile-badges-list.test.ts
new file mode 100644
index 00000000..e64f2c03
--- /dev/null
+++ b/src/lib/nip58-profile-badges-list.test.ts
@@ -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)
+ })
+})
diff --git a/src/lib/nip58-profile-badges-list.ts b/src/lib/nip58-profile-badges-list.ts
new file mode 100644
index 00000000..a9db514f
--- /dev/null
+++ b/src/lib/nip58-profile-badges-list.ts
@@ -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 {
+ 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 {
+ 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
+}
diff --git a/src/lib/payto-editor-hints.test.ts b/src/lib/payto-editor-hints.test.ts
index 5fd602b9..7bd3ea7e 100644
--- a/src/lib/payto-editor-hints.test.ts
+++ b/src/lib/payto-editor-hints.test.ts
@@ -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', () => {
})
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)
})
})
diff --git a/src/lib/payto-registry.ts b/src/lib/payto-registry.ts
index ce4f3812..3b0e9449 100644
--- a/src/lib/payto-registry.ts
+++ b/src/lib/payto-registry.ts
@@ -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 = catalog.aliases
@@ -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 `` (resolved from catalog `logoAssetPath`). */
diff --git a/src/lib/payto.ts b/src/lib/payto.ts
index 49c744c1..29910afd 100644
--- a/src/lib/payto.ts
+++ b/src/lib/payto.ts
@@ -16,7 +16,9 @@ export {
getPaytoTypeInfo,
isKnownPaytoType,
isLightningPaytoType,
+ isPaytoEditorCustomType,
paytoEditorSelectTypes,
+ PAYTO_EDITOR_OTHER_OPTION,
PAYTO_EDITOR_TYPE_ORDER,
PAYTO_KNOWN_TYPES,
type PaytoAuthorityHelp,
diff --git a/src/lib/replaceable-list-latest.ts b/src/lib/replaceable-list-latest.ts
index d23cf0fd..49875b55 100644
--- a/src/lib/replaceable-list-latest.ts
+++ b/src/lib/replaceable-list-latest.ts
@@ -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 don’t 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 aren’t missed.
*/
export async function fetchLatestReplaceableListEvent(
pubkeyHex: string,
@@ -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(
relayUrls: string[]
): Promise {
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). */
diff --git a/src/pages/secondary/PersonalListsSettingsPage/index.tsx b/src/pages/secondary/PersonalListsSettingsPage/index.tsx
index e3290d1e..17cf8117 100644
--- a/src/pages/secondary/PersonalListsSettingsPage/index.tsx
+++ b/src/pages/secondary/PersonalListsSettingsPage/index.tsx
@@ -11,6 +11,7 @@ import {
useSmartNotificationThreadFollowListNavigation,
useSmartNotificationThreadMuteListNavigation,
useSmartPinListNavigation,
+ useSmartProfileBadgesListNavigation,
useSmartSettingsNavigation,
useSmartUserEmojiListNavigation
} from '@/PageManager'
@@ -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(
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(
) : null}
+ {pubkey ? (
+ navigateToProfileBadgesList(toProfileBadgesList())}>
+