From 59cf5dee9f413cbf184b87c4e48ef0680a4a38d4 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Tue, 19 May 2026 13:15:24 +0200 Subject: [PATCH] bug-fixes --- src/components/NoteCard/index.tsx | 6 +- src/components/PaytoDialog/index.tsx | 13 +- src/components/Profile/ProfileReportsFeed.tsx | 6 +- src/components/Profile/index.tsx | 101 +++++------- src/components/ReportCard/index.tsx | 153 ++++++++++++++++++ src/i18n/locales/de.ts | 4 + src/i18n/locales/en.ts | 4 + src/lib/merge-payment-methods.test.ts | 37 +++++ src/lib/merge-payment-methods.ts | 20 ++- src/lib/nip56-report-display.test.ts | 38 +++++ src/lib/nip56-report-display.ts | 58 +++++++ src/lib/payto-paypal-url.test.ts | 31 ++++ src/lib/payto-paypal-url.ts | 104 ++++++++++++ src/lib/payto-registry.ts | 10 +- 14 files changed, 506 insertions(+), 79 deletions(-) create mode 100644 src/components/ReportCard/index.tsx create mode 100644 src/lib/merge-payment-methods.test.ts create mode 100644 src/lib/nip56-report-display.test.ts create mode 100644 src/lib/nip56-report-display.ts create mode 100644 src/lib/payto-paypal-url.test.ts create mode 100644 src/lib/payto-paypal-url.ts diff --git a/src/components/NoteCard/index.tsx b/src/components/NoteCard/index.tsx index 5ff3de4c..a3ed6cd8 100644 --- a/src/components/NoteCard/index.tsx +++ b/src/components/NoteCard/index.tsx @@ -1,5 +1,6 @@ import { Skeleton } from '@/components/ui/skeleton' -import { isMentioningMutedUsers, isNip18RepostKind } from '@/lib/event' +import { isMentioningMutedUsers, isNip18RepostKind, isNip56ReportEvent } from '@/lib/event' +import ReportCard from '@/components/ReportCard' import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useMuteList } from '@/contexts/mute-list-context' import { muteSetHas } from '@/lib/mute-set' @@ -46,6 +47,9 @@ const NoteCard = memo(function NoteCard({ }, [event, filterMutedNotes, mutePubkeySet]) if (shouldHide) return null + if (isNip56ReportEvent(event)) { + return + } if (isNip18RepostKind(event.kind)) { return ( { navigator.clipboard.writeText(text) @@ -55,6 +56,14 @@ export default function PaytoDialog({ {authority}
+ {profileUrl && ( + + )} + + ))} + {parsed.reportedAddresses.map((a) => ( +
  • + {a} +
  • + ))} + + ) +} + +const ReportCard = memo(function ReportCard({ + event, + className +}: { + event: Event + className?: string +}) { + const { t } = useTranslation() + const { navigateToNote } = useSmartNoteNavigation() + const parsed = useMemo(() => parseNip56Report(event), [event]) + + if (!parsed) return null + + return ( +
    { + const target = e.target as HTMLElement + if ( + target.closest('button') || + target.closest('[role="button"]') || + target.closest('a') + ) { + return + } + client.addEventToCache(event) + navigateToNote(toNote(event), event) + }} + > +
    + +
    +
    +
    + + +
    +
    + + +
    +
    +

    + + {t('Report card heading')} +

    + {parsed.reportType && parsed.reportType !== parsed.reason ? ( +

    + {t('Report type label')}: + {parsed.reportType} +

    + ) : null} + {parsed.reason ? ( +

    {parsed.reason}

    + ) : null} + +
    + +
    +
    + ) +}) + +ReportCard.displayName = 'ReportCard' + +export default ReportCard diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 45bfaeea..855b25bd 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -813,6 +813,10 @@ export default { "Reports made": "Abgegebene Meldungen", "No reports received": "Keine erhaltenen Meldungen", "No reports made": "Keine abgegebenen Meldungen", + "Report card heading": "Moderationsmeldung", + "Report type label": "Art", + "Report target profile": "Gemeldetes Profil", + "Report target note": "Gemeldete Notiz", "Wall": "Pinnwand", "Refreshing wall...": "Pinnwand wird aktualisiert…", "No wall comments yet": "Noch keine Pinnwand-Kommentare", diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 4537b671..72f90426 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -850,6 +850,10 @@ export default { "Reports made": "Reports made", "No reports received": "No reports received", "No reports made": "No reports made", + "Report card heading": "Moderation report", + "Report type label": "Type", + "Report target profile": "Reported profile", + "Report target note": "Reported note", "Wall": "Wall", "Refreshing wall...": "Refreshing wall...", "No wall comments yet": "No wall comments yet", diff --git a/src/lib/merge-payment-methods.test.ts b/src/lib/merge-payment-methods.test.ts new file mode 100644 index 00000000..cd20d8e7 --- /dev/null +++ b/src/lib/merge-payment-methods.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { mergePaymentMethods, normalizeLightningAuthority } from './merge-payment-methods' + +describe('normalizeLightningAuthority', () => { + it('maps dot variant to user@domain', () => { + expect(normalizeLightningAuthority('User.Name@Example.COM')).toBe('user.name@example.com') + expect(normalizeLightningAuthority('user.name@example.com')).toBe('user.name@example.com') + }) +}) + +describe('mergePaymentMethods lightning dedup', () => { + it('keeps payto URI in sync when authority is canonicalized on merge', () => { + const methods = mergePaymentMethods( + { + methods: [ + { + type: 'lightning', + authority: 'user.domain', + payto: 'payto://lightning/user.domain', + displayType: 'Lightning Network' + }, + { + type: 'lightning', + authority: 'user@domain', + payto: 'payto://lightning/user@domain', + displayType: 'Lightning Network' + } + ] + }, + null + ) + + expect(methods).toHaveLength(1) + expect(methods[0].authority).toBe('user@domain') + expect(methods[0].payto).toBe('payto://lightning/user%40domain') + }) +}) diff --git a/src/lib/merge-payment-methods.ts b/src/lib/merge-payment-methods.ts index c314463c..3782f31e 100644 --- a/src/lib/merge-payment-methods.ts +++ b/src/lib/merge-payment-methods.ts @@ -40,6 +40,12 @@ function preferCanonicalLightningAuthority(a: string, b: string): string { return a } +/** Canonical LUD-16 authority (user@domain) for display and payto:// URIs. */ +function resolveLightningAuthority(a: string, b?: string): string { + const preferred = b !== undefined ? preferCanonicalLightningAuthority(a, b) : a + return normalizeLightningAuthority(preferred) || preferred.trim() +} + /** Bitcoin-layer first, then on-chain Bitcoin family, then everything else. */ export function paytoPaymentSortRank(type: string): number { const category = getPaytoTypeInfo(type)?.category @@ -69,18 +75,18 @@ export function mergePaymentMethods( const existing = seen.get(key) if (existing) { if (normType === 'lightning') { - existing.authority = preferCanonicalLightningAuthority(existing.authority, authority.trim()) - existing.payto = - existing.payto || - payto || - (normType && authority ? `payto://${normType}/${existing.authority}` : undefined) + existing.authority = resolveLightningAuthority(existing.authority, authority.trim()) + existing.payto = buildPaytoUri(normType, existing.authority) } return } + const trimmedAuthority = authority.trim() + const resolvedAuthority = + normType === 'lightning' ? resolveLightningAuthority(trimmedAuthority) : trimmedAuthority const entry: MergedPaymentMethod = { type: normType, - authority: authority.trim(), - payto: payto || (normType && authority ? `payto://${normType}/${authority.trim()}` : undefined), + authority: resolvedAuthority, + payto: payto || (normType && resolvedAuthority ? buildPaytoUri(normType, resolvedAuthority) : undefined), displayType: displayType || getPaytoEditorTypeLabel(normType), ...extra } diff --git a/src/lib/nip56-report-display.test.ts b/src/lib/nip56-report-display.test.ts new file mode 100644 index 00000000..394291a1 --- /dev/null +++ b/src/lib/nip56-report-display.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { kinds } from 'nostr-tools' +import { parseNip56Report } from './nip56-report-display' + +describe('parseNip56Report', () => { + it('reads reason from e tag relay field', () => { + const event = { + kind: kinds.Report, + pubkey: 'aa'.repeat(32), + id: 'bb'.repeat(32), + sig: 'cc'.repeat(32), + created_at: 1, + tags: [ + ['p', 'dd'.repeat(32)], + ['e', 'ee'.repeat(32), 'spam'] + ], + content: '' + } + const parsed = parseNip56Report(event) + expect(parsed?.reason).toBe('spam') + expect(parsed?.reportedEventIds).toEqual(['ee'.repeat(32)]) + }) + + it('prefers content over report tag', () => { + const event = { + kind: kinds.Report, + pubkey: 'aa'.repeat(32), + id: 'bb'.repeat(32), + sig: 'cc'.repeat(32), + created_at: 1, + tags: [['report', 'nudity']], + content: 'explicit content' + } + const parsed = parseNip56Report(event) + expect(parsed?.reportType).toBe('nudity') + expect(parsed?.reason).toBe('explicit content') + }) +}) diff --git a/src/lib/nip56-report-display.ts b/src/lib/nip56-report-display.ts new file mode 100644 index 00000000..7cc30e5a --- /dev/null +++ b/src/lib/nip56-report-display.ts @@ -0,0 +1,58 @@ +import { isNip56ReportEvent } from '@/lib/event' +import { Event } from 'nostr-tools' + +export type ParsedNip56Report = { + reportType: string | null + reason: string | null + reportedPubkeys: string[] + reportedEventIds: string[] + reportedAddresses: string[] +} + +/** Extract NIP-56 report targets and human-readable reason from tags and content. */ +export function parseNip56Report(event: Event): ParsedNip56Report | null { + if (!isNip56ReportEvent(event)) return null + + const reportType = + event.tags.find((t) => t[0] === 'report' || t[0] === 'Report')?.[1]?.trim() || null + + const reportedPubkeys: string[] = [] + const reportedEventIds: string[] = [] + const reportedAddresses: string[] = [] + const tagReasons: string[] = [] + + for (const t of event.tags) { + const key = t[0] + const val = typeof t[1] === 'string' ? t[1].trim() : '' + const relayOrReason = typeof t[2] === 'string' ? t[2].trim() : '' + if (!val && !relayOrReason) continue + + if (key === 'p' || key === 'P') { + if (val) reportedPubkeys.push(val) + if (relayOrReason && !relayOrReason.startsWith('wss://') && !relayOrReason.startsWith('ws://')) { + tagReasons.push(relayOrReason) + } + } else if (key === 'e' || key === 'E') { + if (val) reportedEventIds.push(val) + if (relayOrReason && !relayOrReason.startsWith('wss://') && !relayOrReason.startsWith('ws://')) { + tagReasons.push(relayOrReason) + } + } else if (key === 'a' || key === 'A') { + if (val) reportedAddresses.push(val) + if (relayOrReason && !relayOrReason.startsWith('wss://') && !relayOrReason.startsWith('ws://')) { + tagReasons.push(relayOrReason) + } + } + } + + const content = event.content.trim() + const reason = content || tagReasons[0] || reportType || null + + return { + reportType, + reason, + reportedPubkeys, + reportedEventIds, + reportedAddresses + } +} diff --git a/src/lib/payto-paypal-url.test.ts b/src/lib/payto-paypal-url.test.ts new file mode 100644 index 00000000..aeb5296f --- /dev/null +++ b/src/lib/payto-paypal-url.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { resolvePaypalPaymentUrl } from './payto-paypal-url' + +describe('resolvePaypalPaymentUrl', () => { + it('maps paypal.com/paypalme slug to paypal.me', () => { + expect(resolvePaypalPaymentUrl('https://www.paypal.com/paypalme/2rizmo%40gmail.com')).toBe( + 'https://paypal.me/2rizmo@gmail.com' + ) + }) + + it('passes through donate links', () => { + const donate = 'https://www.paypal.com/donate/?hosted_button_id=T32KCSU8EZTBL' + expect(resolvePaypalPaymentUrl(donate)).toBe(donate) + }) + + it('unwraps YouTube redirect q= PayPal URL', () => { + const yt = + 'https://www.youtube.com/redirect?event=channel_description&redir_token=abc&q=https%3A%2F%2Fwww.paypal.com%2Fdonate%2F%3Fhosted_button_id%3DT32KCSU8EZTBL' + expect(resolvePaypalPaymentUrl(yt)).toBe( + 'https://www.paypal.com/donate/?hosted_button_id=T32KCSU8EZTBL' + ) + }) + + it('builds paypal.me from bare username', () => { + expect(resolvePaypalPaymentUrl('somecreator')).toBe('https://paypal.me/somecreator') + }) + + it('normalizes paypal.me path', () => { + expect(resolvePaypalPaymentUrl('https://paypal.me/foo')).toBe('https://paypal.me/foo') + }) +}) diff --git a/src/lib/payto-paypal-url.ts b/src/lib/payto-paypal-url.ts new file mode 100644 index 00000000..0673c884 --- /dev/null +++ b/src/lib/payto-paypal-url.ts @@ -0,0 +1,104 @@ +/** + * Resolve PayPal payment targets to a browser-openable https URL. + * Handles PayPal.Me slugs, paypal.com/paypalme/… paths, donate links, and YouTube redirect wrappers. + */ + +const PAYPAL_HOSTS = new Set(['paypal.com', 'www.paypal.com', 'paypal.me', 'www.paypal.me']) + +function isPaypalHostname(hostname: string): boolean { + return PAYPAL_HOSTS.has(hostname.toLowerCase()) +} + +/** Decode once; leave valid path characters (e.g. @ in PayPal.Me) unescaped for display URLs. */ +function decodeAuthoritySegment(segment: string): string { + try { + return decodeURIComponent(segment.replace(/\+/g, ' ')) + } catch { + return segment + } +} + +function extractNestedUrlFromYoutubeRedirect(input: string): string | null { + let u: URL + try { + u = new URL(input.trim()) + } catch { + return null + } + const host = u.hostname.toLowerCase() + if (!host.includes('youtube.com') && !host.includes('youtu.be')) return null + + for (const key of ['q', 'u', 'url']) { + const raw = u.searchParams.get(key) + if (!raw?.trim()) continue + try { + const nested = decodeURIComponent(raw.trim()) + if (/^https?:\/\//i.test(nested) || nested.toLowerCase().includes('paypal')) { + return nested + } + } catch { + continue + } + } + return null +} + +function normalizePaypalComOrMeUrl(u: URL): string { + const host = u.hostname.toLowerCase().replace(/^www\./, '') + + if (host === 'paypal.me') { + const slug = u.pathname.replace(/^\/+/, '').split('/')[0] + if (slug) return `https://paypal.me/${decodeAuthoritySegment(slug)}` + return u.origin + } + + const meMatch = u.pathname.match(/\/paypalme\/([^/?#]+)/i) + if (meMatch?.[1]) { + return `https://paypal.me/${decodeAuthoritySegment(meMatch[1])}` + } + + // Donate / hosted button / payment links — open as published + return u.toString() +} + +function extractPaypalMeSlugFromText(input: string): string | null { + let s = input.trim() + if (!s) return null + + s = s.replace(/^payto:\/\/paypal\//i, '') + + if (/^https?:\/\//i.test(s)) return null + + s = s + .replace(/^www\./i, '') + .replace(/^paypal\.me\//i, '') + .replace(/^paypal\.com\/paypalme\//i, '') + + if (!s || s.includes('/') || s.includes('?') || s.includes('#')) return null + return decodeAuthoritySegment(s) +} + +/** + * Turn a payto PayPal authority (username, email slug, or full URL) into an https URL for the browser. + */ +export function resolvePaypalPaymentUrl(authority: string): string | null { + const trimmed = authority.trim() + if (!trimmed) return null + + const fromYoutube = extractNestedUrlFromYoutubeRedirect(trimmed) + if (fromYoutube) return resolvePaypalPaymentUrl(fromYoutube) + + if (/^https?:\/\//i.test(trimmed)) { + try { + const u = new URL(trimmed) + if (isPaypalHostname(u.hostname)) return normalizePaypalComOrMeUrl(u) + } catch { + return null + } + } + + const slug = extractPaypalMeSlugFromText(trimmed) + if (slug) return `https://paypal.me/${slug}` + + return null +} diff --git a/src/lib/payto-registry.ts b/src/lib/payto-registry.ts index 3b0e9449..8df8eaef 100644 --- a/src/lib/payto-registry.ts +++ b/src/lib/payto-registry.ts @@ -5,6 +5,7 @@ import paytoTypesCatalog from '@/data/payto-types.json' import { resolvePaytoLogoAssetPath } from '@/lib/payto-logos' +import { resolvePaypalPaymentUrl } from '@/lib/payto-paypal-url' export type PaytoCategory = 'bitcoin' | 'bitcoin-layer' | 'crypto' | 'stablecoin' | 'fiat' | 'tip' @@ -103,8 +104,15 @@ export function getPaytoLogoUrl(type: string): string | null { } export function getPaytoProfileUrl(type: string, authority: string): string | null { + if (!authority.trim()) return null + + const canonical = getCanonicalPaytoType(type) + if (canonical === 'paypal') { + return resolvePaypalPaymentUrl(authority) + } + const template = getPaytoTypeRecord(type)?.profileUrlTemplate - if (!template || !authority.trim()) return null + if (!template) return null return template.replace('{authority}', encodeURIComponent(authority.trim())) }