Browse Source

bug-fixes

imwald
Silberengel 4 weeks ago
parent
commit
59cf5dee9f
  1. 6
      src/components/NoteCard/index.tsx
  2. 13
      src/components/PaytoDialog/index.tsx
  3. 6
      src/components/Profile/ProfileReportsFeed.tsx
  4. 101
      src/components/Profile/index.tsx
  5. 153
      src/components/ReportCard/index.tsx
  6. 4
      src/i18n/locales/de.ts
  7. 4
      src/i18n/locales/en.ts
  8. 37
      src/lib/merge-payment-methods.test.ts
  9. 20
      src/lib/merge-payment-methods.ts
  10. 38
      src/lib/nip56-report-display.test.ts
  11. 58
      src/lib/nip56-report-display.ts
  12. 31
      src/lib/payto-paypal-url.test.ts
  13. 104
      src/lib/payto-paypal-url.ts
  14. 10
      src/lib/payto-registry.ts

6
src/components/NoteCard/index.tsx

@ -1,5 +1,6 @@ @@ -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({ @@ -46,6 +47,9 @@ const NoteCard = memo(function NoteCard({
}, [event, filterMutedNotes, mutePubkeySet])
if (shouldHide) return null
if (isNip56ReportEvent(event)) {
return <ReportCard event={event} className={className} />
}
if (isNip18RepostKind(event.kind)) {
return (
<RepostNoteCard

13
src/components/PaytoDialog/index.tsx

@ -9,8 +9,8 @@ import { Button } from '@/components/ui/button' @@ -9,8 +9,8 @@ import { Button } from '@/components/ui/button'
import { Copy } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { getPaytoTypeInfo } from '@/lib/payto'
import { Zap } from 'lucide-react'
import { getPaytoTypeInfo, getPaytoProfileUrl } from '@/lib/payto'
import { Zap, ExternalLink } from 'lucide-react'
export default function PaytoDialog({
open,
@ -29,6 +29,7 @@ export default function PaytoDialog({ @@ -29,6 +29,7 @@ export default function PaytoDialog({
const info = getPaytoTypeInfo(type)
const label = info?.label ?? type
const isLightning = type.toLowerCase() === 'lightning'
const profileUrl = getPaytoProfileUrl(type, authority)
const handleCopy = (text: string, label?: string) => {
navigator.clipboard.writeText(text)
@ -55,6 +56,14 @@ export default function PaytoDialog({ @@ -55,6 +56,14 @@ export default function PaytoDialog({
{authority}
</div>
<div className="flex flex-wrap gap-2">
{profileUrl && (
<Button variant="default" size="sm" asChild className="gap-2">
<a href={profileUrl} target="_blank" rel="noopener noreferrer">
<ExternalLink className="size-4" />
{t('Open on website')}
</a>
</Button>
)}
<Button
variant="secondary"
size="sm"

6
src/components/Profile/ProfileReportsFeed.tsx

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
import NoteCard from '@/components/NoteCard'
import ReportCard from '@/components/ReportCard'
import { Skeleton } from '@/components/ui/skeleton'
import { useProfileReportsEvents } from '@/hooks/useProfileReportsEvents'
import { useProfileReportsRelayBuilder } from '@/hooks/useProfileReportsRelayBuilder'
@ -65,7 +65,7 @@ const ProfileReportsFeed = forwardRef<{ refresh: () => void }, { pubkey: string @@ -65,7 +65,7 @@ const ProfileReportsFeed = forwardRef<{ refresh: () => void }, { pubkey: string
) : (
<div className="space-y-2">
{received.map((event) => (
<NoteCard key={event.id} className="w-full" event={event} filterMutedNotes={false} />
<ReportCard key={event.id} className="w-full" event={event} />
))}
</div>
)}
@ -80,7 +80,7 @@ const ProfileReportsFeed = forwardRef<{ refresh: () => void }, { pubkey: string @@ -80,7 +80,7 @@ const ProfileReportsFeed = forwardRef<{ refresh: () => void }, { pubkey: string
) : (
<div className="space-y-2">
{made.map((event) => (
<NoteCard key={event.id} className="w-full" event={event} filterMutedNotes={false} />
<ReportCard key={event.id} className="w-full" event={event} />
))}
</div>
)}

101
src/components/Profile/index.tsx

@ -25,6 +25,7 @@ import { useSecondaryPage } from '@/PageManager' @@ -25,6 +25,7 @@ import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service'
import { replaceableEventService } from '@/services/client.service'
import { ReplaceableEventService } from '@/services/client-replaceable-events.service'
import {
DropdownMenu,
DropdownMenuContent,
@ -47,6 +48,7 @@ import { @@ -47,6 +48,7 @@ import {
ThumbsUp
} from 'lucide-react'
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
@ -114,8 +116,6 @@ export default function Profile({ @@ -114,8 +116,6 @@ export default function Profile({
const [profileFeedTab, setProfileFeedTab] = useState<
'posts' | 'media' | 'publications' | 'reports' | 'wall' | 'liked'
>('posts')
/** Bumped after profile-view relay sync so payment + kind-0 JSON re-query storage and relays. */
const [authorReplaceablesSyncGen, setAuthorReplaceablesSyncGen] = useState(0)
const profilePubkeyRef = useRef<string | null>(null)
const { profile, isFetching } = useFetchProfile(id)
@ -144,66 +144,51 @@ export default function Profile({ @@ -144,66 +144,51 @@ export default function Profile({
[paymentMethodsByType]
)
// Fetch payment info (kind 10133) for this profile; uses cached replaceable events and IndexedDB
const syncAuthorReplaceablesFromCache = useCallback(async (pubkey: string) => {
try {
const [paymentEvent, metaEvent] = await Promise.all([
client.fetchPaymentInfoEvent(pubkey),
replaceableEventService.fetchReplaceableEvent(pubkey, kinds.Metadata)
])
setPaymentInfo(paymentEvent ? getPaymentInfoFromEvent(paymentEvent) : null)
setProfileEvent(metaEvent ?? undefined)
} catch (error) {
logger.error('Failed to sync author replaceables from cache', { error, pubkey })
}
}, [])
useEffect(() => {
if (!profile?.pubkey) {
setPaymentInfo(null)
setProfileEvent(undefined)
return
}
const fetchPaymentInfo = async () => {
try {
const paymentEvent = await client.fetchPaymentInfoEvent(profile.pubkey)
if (paymentEvent) {
setPaymentInfo(getPaymentInfoFromEvent(paymentEvent))
} else {
setPaymentInfo(null)
}
} catch (error) {
logger.error('Failed to fetch payment info', { error, pubkey: profile.pubkey })
setPaymentInfo(null)
}
}
fetchPaymentInfo()
}, [profile?.pubkey, authorReplaceablesSyncGen])
void syncAuthorReplaceablesFromCache(profile.pubkey)
}, [profile?.pubkey, syncAuthorReplaceablesFromCache])
useEffect(() => {
if (!profile?.pubkey) return
let cancelled = false
void client.refreshAuthorPublishedReplaceablesOnProfileView(profile.pubkey).finally(() => {
if (!cancelled) setAuthorReplaceablesSyncGen((g) => g + 1)
})
return () => {
cancelled = true
}
void client.refreshAuthorPublishedReplaceablesOnProfileView(profile.pubkey)
}, [profile?.pubkey])
// Fetch profile event (kind 0) for republishing and viewing JSON
// Use fetchProfileEvent which does comprehensive search, not fetchReplaceableEvent
useEffect(() => {
if (!profile?.pubkey) {
setProfileEvent(undefined)
return
}
const fetchProfileEventData = async () => {
try {
// Use fetchProfileEvent which includes comprehensive relay search
const event = await replaceableEventService.fetchProfileEvent(profile.pubkey, false)
if (event) {
setProfileEvent(event)
} else {
setProfileEvent(undefined)
}
} catch (error) {
logger.error('Failed to fetch profile event', { error, pubkey: profile.pubkey })
setProfileEvent(undefined)
}
if (!profile?.pubkey) return
const pk = profile.pubkey.toLowerCase()
const onAuthorReplaceablesRefreshed: EventListener = (domEvt) => {
const detailPk = (domEvt as CustomEvent<{ pubkey?: string }>).detail?.pubkey?.toLowerCase()
if (detailPk !== pk) return
void syncAuthorReplaceablesFromCache(profile.pubkey)
}
fetchProfileEventData()
}, [profile?.pubkey, authorReplaceablesSyncGen])
window.addEventListener(
ReplaceableEventService.AUTHOR_REPLACEABLES_REFRESHED_EVENT,
onAuthorReplaceablesRefreshed
)
return () =>
window.removeEventListener(
ReplaceableEventService.AUTHOR_REPLACEABLES_REFRESHED_EVENT,
onAuthorReplaceablesRefreshed
)
}, [profile?.pubkey, syncAuthorReplaceablesFromCache])
const isFollowingYou = useMemo(() => {
// This will be handled by the FollowedBy component
@ -285,9 +270,7 @@ export default function Profile({ @@ -285,9 +270,7 @@ export default function Profile({
likedFeedRef.current?.refresh()
const pk = profilePubkeyRef.current
if (pk) {
void client.refreshAuthorPublishedReplaceablesOnProfileView(pk).finally(() => {
setAuthorReplaceablesSyncGen((g) => g + 1)
})
void client.refreshAuthorPublishedReplaceablesOnProfileView(pk)
}
}
}
@ -296,18 +279,6 @@ export default function Profile({ @@ -296,18 +279,6 @@ export default function Profile({
}
}, [])
useEffect(() => {
if (!profile?.pubkey) return
const forceUpdateCache = async () => {
await Promise.all([
client.forceUpdateRelayListEvent(profile.pubkey),
replaceableEventService.fetchReplaceableEvent(profile.pubkey, kinds.Metadata)
])
}
forceUpdateCache()
}, [profile?.pubkey])
useEffect(() => {
if (!profile?.pubkey) return
setProfileFeedTab('posts')

153
src/components/ReportCard/index.tsx

@ -0,0 +1,153 @@ @@ -0,0 +1,153 @@
import { useSmartNoteNavigation } from '@/PageManager'
import ClientTag from '@/components/ClientTag'
import { FormattedTimestamp } from '@/components/FormattedTimestamp'
import Nip05 from '@/components/Nip05'
import { SimpleUserAvatar } from '@/components/UserAvatar'
import { SimpleUsername } from '@/components/Username'
import { parseNip56Report } from '@/lib/nip56-report-display'
import { toNote, toProfile } from '@/lib/link'
import { cn } from '@/lib/utils'
import client from '@/services/client.service'
import { AlertTriangle, ChevronRight } from 'lucide-react'
import { Event } from 'nostr-tools'
import { memo, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
function ReportTargetLinks({
parsed,
className
}: {
parsed: NonNullable<ReturnType<typeof parseNip56Report>>
className?: string
}) {
const { t } = useTranslation()
const { navigateToNote } = useSmartNoteNavigation()
const hasTargets =
parsed.reportedPubkeys.length > 0 ||
parsed.reportedEventIds.length > 0 ||
parsed.reportedAddresses.length > 0
if (!hasTargets) return null
return (
<ul className={cn('mt-2 space-y-1 text-sm', className)}>
{parsed.reportedPubkeys.map((pk) => (
<li key={`p-${pk}`}>
<a
href={toProfile(pk)}
className="font-medium text-amber-950 underline-offset-2 hover:underline dark:text-amber-50"
onClick={(e) => e.stopPropagation()}
>
{t('Report target profile')}
</a>
</li>
))}
{parsed.reportedEventIds.map((id) => (
<li key={`e-${id}`}>
<button
type="button"
className="font-medium text-amber-950 underline-offset-2 hover:underline dark:text-amber-50"
onClick={(e) => {
e.stopPropagation()
void client.fetchEvent(id).then((ev) => {
if (ev) navigateToNote(toNote(ev), ev)
else navigateToNote(toNote(id))
})
}}
>
{t('Report target note')}
</button>
</li>
))}
{parsed.reportedAddresses.map((a) => (
<li key={`a-${a}`} className="break-all font-mono text-xs text-amber-950/85 dark:text-amber-50/85">
{a}
</li>
))}
</ul>
)
}
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 (
<article
className={cn(
'clickable rounded-lg border px-3 py-3',
'border-amber-600/45 bg-amber-500/[0.07] hover:border-amber-600/60 hover:bg-amber-500/[0.11]',
'dark:border-amber-500/40 dark:bg-amber-500/[0.08] dark:hover:border-amber-400/50 dark:hover:bg-amber-500/[0.12]',
className
)}
onClick={(e) => {
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)
}}
>
<div className="flex items-start gap-2">
<SimpleUserAvatar
userId={event.pubkey}
size="medium"
className="ring-1 ring-amber-600/35 dark:ring-amber-400/35"
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline justify-between gap-x-2 gap-y-0.5">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5">
<SimpleUsername
userId={event.pubkey}
className="truncate text-sm font-semibold text-amber-950 dark:text-amber-50"
skeletonClassName="h-3"
/>
<ClientTag event={event} />
</div>
<div className="flex shrink-0 items-center gap-1 text-xs text-amber-900/75 dark:text-amber-100/70">
<Nip05 pubkey={event.pubkey} append="·" />
<FormattedTimestamp timestamp={event.created_at} short />
</div>
</div>
<p className="mt-1 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-amber-950/90 dark:text-amber-100/90">
<AlertTriangle className="size-3.5 shrink-0 text-amber-600 dark:text-amber-400" aria-hidden />
{t('Report card heading')}
</p>
{parsed.reportType && parsed.reportType !== parsed.reason ? (
<p className="mt-1.5 text-xs font-medium text-amber-950/80 dark:text-amber-50/80">
<span className="text-amber-900/70 dark:text-amber-100/65">{t('Report type label')}: </span>
{parsed.reportType}
</p>
) : null}
{parsed.reason ? (
<p className="mt-1.5 text-sm leading-snug text-amber-950/90 dark:text-amber-50/95">{parsed.reason}</p>
) : null}
<ReportTargetLinks parsed={parsed} />
</div>
<ChevronRight
className="mt-1 size-4 shrink-0 text-amber-700/60 dark:text-amber-300/60"
aria-hidden
/>
</div>
</article>
)
})
ReportCard.displayName = 'ReportCard'
export default ReportCard

4
src/i18n/locales/de.ts

@ -813,6 +813,10 @@ export default { @@ -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",

4
src/i18n/locales/en.ts

@ -850,6 +850,10 @@ export default { @@ -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",

37
src/lib/merge-payment-methods.test.ts

@ -0,0 +1,37 @@ @@ -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')
})
})

20
src/lib/merge-payment-methods.ts

@ -40,6 +40,12 @@ function preferCanonicalLightningAuthority(a: string, b: string): string { @@ -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( @@ -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
}

38
src/lib/nip56-report-display.test.ts

@ -0,0 +1,38 @@ @@ -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')
})
})

58
src/lib/nip56-report-display.ts

@ -0,0 +1,58 @@ @@ -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
}
}

31
src/lib/payto-paypal-url.test.ts

@ -0,0 +1,31 @@ @@ -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')
})
})

104
src/lib/payto-paypal-url.ts

@ -0,0 +1,104 @@ @@ -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
}

10
src/lib/payto-registry.ts

@ -5,6 +5,7 @@ @@ -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 { @@ -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()))
}

Loading…
Cancel
Save