14 changed files with 506 additions and 79 deletions
@ -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 |
||||
@ -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') |
||||
}) |
||||
}) |
||||
@ -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') |
||||
}) |
||||
}) |
||||
@ -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 |
||||
} |
||||
} |
||||
@ -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') |
||||
}) |
||||
}) |
||||
@ -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 |
||||
} |
||||
Loading…
Reference in new issue