14 changed files with 506 additions and 79 deletions
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
/** |
||||||
|
* 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