diff --git a/.cursor/rules/no-framer-motion.mdc b/.cursor/rules/no-framer-motion.mdc new file mode 100644 index 00000000..f8464382 --- /dev/null +++ b/.cursor/rules/no-framer-motion.mdc @@ -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. diff --git a/eslint.config.js b/eslint.config.js index f0f9c2a1..e00d1389 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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', diff --git a/src/PageManager.tsx b/src/PageManager.tsx index db94ae30..4acaecd0 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -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() { 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(), + 'profile-badges' + ) + } else { + pushSecondaryPage(url) + } + } + + return { navigateToProfileBadgesList } +} + export function useSmartNotificationThreadFollowListNavigation() { const { setPrimaryNoteView } = usePrimaryNoteView() const { push: pushSecondaryPage } = useSecondaryPage() diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index 7d8e826a..1c8f1f1a 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -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({ 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({ event.kind === ExtendedKind.ZAP_RECEIPT) && ( {!embedded && !searchListPreview ? : null} diff --git a/src/components/NoteOptions/index.tsx b/src/components/NoteOptions/index.tsx index 11c673df..ad34629a 100644 --- a/src/components/NoteOptions/index.tsx +++ b/src/components/NoteOptions/index.tsx @@ -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({ onOpenEditOrClone: (mode) => { setEditCloneMode(mode) setEditCloneOpen(true) - } + }, + pinned }) const trigger = useMemo( diff --git a/src/components/NoteOptions/useMenuActions.tsx b/src/components/NoteOptions/useMenuActions.tsx index 54aa4e05..96d00f16 100644 --- a/src/components/NoteOptions/useMenuActions.tsx +++ b/src/components/NoteOptions/useMenuActions.tsx @@ -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({ 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({ } }, []) - // 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({ 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 diff --git a/src/components/PaytoDialog/index.tsx b/src/components/PaytoDialog/index.tsx index c50d18d4..7ed3feb9 100644 --- a/src/components/PaytoDialog/index.tsx +++ b/src/components/PaytoDialog/index.tsx @@ -50,7 +50,7 @@ export default function PaytoDialog({ : t('Payment address – copy to use in your wallet or app')} -
+
{authority}
diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index 6ed0df29..c834369a 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -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 = { 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 | null, @@ -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( 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({ 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() for (const method of mergedPaymentMethods) { const key = method.displayType || method.type @@ -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({ 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({ ) : null}
-
+
{username}
{isFollowingYou && ( @@ -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 && ( -
+
Payment Methods
{paymentMethodsByType.map((group, groupIdx) => ( diff --git a/src/components/ProfileEditor/PaymentMethodRow.tsx b/src/components/ProfileEditor/PaymentMethodRow.tsx index 165fa373..4544a031 100644 --- a/src/components/ProfileEditor/PaymentMethodRow.tsx +++ b/src/components/ProfileEditor/PaymentMethodRow.tsx @@ -11,6 +11,8 @@ import { getCanonicalPaytoType, getPaytoAuthorityFieldHelp, getPaytoEditorTypeLabel, + isPaytoEditorCustomType, + PAYTO_EDITOR_OTHER_OPTION, paytoEditorSelectTypes } from '@/lib/payto' import { Trash2 } from 'lucide-react' @@ -26,27 +28,72 @@ 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 (
- + {isCustomType ? ( +
+ 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' })} + /> +

+ {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())}> +
+ +
{t('Profile badges list')}
+
+ +
+ ) : null} {pubkey ? ( navigateToInterestList(toInterestsList())}>
diff --git a/src/pages/secondary/ProfileBadgesListPage/index.tsx b/src/pages/secondary/ProfileBadgesListPage/index.tsx new file mode 100644 index 00000000..bff7e629 --- /dev/null +++ b/src/pages/secondary/ProfileBadgesListPage/index.tsx @@ -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() + + 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 ( +
+ {imageUrl ? ( + + ) : ( +
+ +
+ )} +
+
{label}
+
{entry.definitionCoordinate}
+
e: {entry.awardEventId}
+
+ +
+ ) +} + +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(null) + const [legacyListEvent, setLegacyListEvent] = useState(null) + const [entries, setEntries] = useState([]) + 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(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 + } + + return ( + + void loadLists()} /> + + + + + + openJson()}> + + {t('View JSON')} + + setCleanConfirmOpen(true)} + > + + {t('Clean list')} + + + +
+ ) + } + displayScrollToTopButton + > + setJsonOpen(false)} /> + + + + {t('Clean this list?')} + {t('Clean list confirm')} + + + {t('Cancel')} + { + e.preventDefault() + void handleCleanList() + }} + > + {cleaning ? t('loading...') : t('Clean list')} + + + + + +
+

{t('Profile badges list intro')}

+ + {showMigrate && ( +
+

{t('Profile badges migrate hint')}

+ +
+ )} + + {entries.length === 0 ? ( +

+ {t('No profile badges on your list')} +

+ ) : ( +
+ {entries.map((entry) => ( + handleRemoveEntry(entry)} + /> + ))} +
+ )} + +
+ +
+ setNewDefinitionA(e.target.value)} + placeholder={t('Badge definition (a tag), e.g. 30009:pubkey:bravery')} + className="font-mono text-sm" + /> + setNewAwardE(e.target.value)} + placeholder={t('Badge award event id (e tag)')} + className="font-mono text-sm" + /> +
+ +
+ + +
+ + ) + } +) + +ProfileBadgesListPage.displayName = 'ProfileBadgesListPage' +export default ProfileBadgesListPage diff --git a/src/pages/secondary/ProfileEditorPage/index.tsx b/src/pages/secondary/ProfileEditorPage/index.tsx index fc63e524..0799286b 100644 --- a/src/pages/secondary/ProfileEditorPage/index.tsx +++ b/src/pages/secondary/ProfileEditorPage/index.tsx @@ -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) => { 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) => { {paymentInfoEvent ? t('Edit payment info') : t('Add payment info')}
- - - - {t('Raw payment info event')} - - - {paymentInfoEvent ? ( - <> -
- -
-                      {paymentInfoEvent.content || '{}'}
-                    
-
-
- -
-                      {JSON.stringify(paymentInfoEvent.tags ?? [], null, 2)}
-                    
-
- - ) : ( -

- {t('No payment info event yet. Click "Add payment info" to create one.')} -

- )} -
-
@@ -813,7 +789,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { {t('Edit payment info')} (kind 10133) -
+

@@ -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 diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 3fb8161c..26282339 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -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 }) { 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 }) { } } + 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)', { diff --git a/src/routes.tsx b/src/routes.tsx index dd6078f0..d600c2a2 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -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 = [ { 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) } diff --git a/src/services/client-replaceable-events.service.ts b/src/services/client-replaceable-events.service.ts index d1b14281..78ec1fe0 100644 --- a/src/services/client-replaceable-events.service.ts +++ b/src/services/client-replaceable-events.service.ts @@ -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' 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 { async forceRefreshProfileAndPaymentInfoCache(pubkey: string): Promise { 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 { */ 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 { const pk = pubkey.trim().toLowerCase() if (!/^[0-9a-f]{64}$/.test(pk)) return @@ -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 { } ) + 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() for (const e of events) { if (shouldDropEventOnIngest(e)) continue @@ -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 { diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index 0d43f578..4a3fbd05 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -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 = 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 = 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 { 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 { 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 { 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