Browse Source

bug-fix

imwald
Silberengel 4 weeks ago
parent
commit
4aac303469
  1. 2
      src/components/CalendarEventContent/index.tsx
  2. 4
      src/components/CalendarEventNip52StructuredMeta.tsx
  3. 6
      src/components/CitationCard/index.tsx
  4. 8
      src/components/Content/index.tsx
  5. 6
      src/components/ContentPreview/Content.tsx
  6. 2
      src/components/Embedded/EmbeddedHashtag.tsx
  7. 3
      src/components/Embedded/EmbeddedNormalUrl.tsx
  8. 3
      src/components/ExternalLink/index.tsx
  9. 2
      src/components/FavoriteRelaysActiveStrip/RelayPulseActiveNpubsSheet.tsx
  10. 2
      src/components/Image/index.tsx
  11. 2
      src/components/KeyboardShortcutsHelp/index.tsx
  12. 4
      src/components/Nip05/index.tsx
  13. 4
      src/components/Nip05List/index.tsx
  14. 19
      src/components/Note/AsciidocArticle/AsciidocArticle.tsx
  15. 2
      src/components/Note/GitRepublicEventCard.tsx
  16. 2
      src/components/Note/Highlight/index.tsx
  17. 60
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  18. 2
      src/components/Note/index.tsx
  19. 93
      src/components/NoteStats/ZapButton.tsx
  20. 14
      src/components/PaymentMethodsSection/index.tsx
  21. 12
      src/components/PaytoLink/index.tsx
  22. 42
      src/components/Profile/index.tsx
  23. 16
      src/components/ProfileAbout/index.tsx
  24. 2
      src/components/RelayInfo/index.tsx
  25. 2
      src/components/RelayStatusDisplay/index.tsx
  26. 2
      src/components/RssFeedItem/index.tsx
  27. 2
      src/components/StandardRssFeedUrlRow/index.tsx
  28. 2
      src/components/UniversalContent/Wikilink.tsx
  29. 2
      src/components/VideoPlayer/index.tsx
  30. 10
      src/components/WebPreview/index.tsx
  31. 14
      src/hooks/useRecipientAlternativePayments.ts
  32. 14
      src/index.css
  33. 10
      src/lib/lightning.ts
  34. 14
      src/lib/link-styles.ts
  35. 89
      src/lib/merge-payment-methods.test.ts
  36. 41
      src/lib/merge-payment-methods.ts
  37. 7
      src/lib/payto-registry.ts
  38. 1
      src/lib/payto.ts
  39. 6
      src/pages/primary/CalendarPrimaryPage.tsx
  40. 4
      src/pages/secondary/PersonalListsSettingsPage/index.tsx
  41. 2
      src/pages/secondary/PostSettingsPage/BlossomServerListSetting.tsx
  42. 6
      src/services/content-parser.service.ts
  43. 5
      tailwind.config.js

2
src/components/CalendarEventContent/index.tsx

@ -283,7 +283,7 @@ export default function CalendarEventContent({
href={url} href={url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs font-medium text-primary underline-offset-2 hover:underline" className="inline-flex items-center gap-1 text-xs font-medium text-primary underline-offset-2 hover:text-foreground hover:underline transition-colors"
> >
<ExternalLink className="size-3 shrink-0 opacity-80" aria-hidden /> <ExternalLink className="size-3 shrink-0 opacity-80" aria-hidden />
{t('Open link')} {t('Open link')}

4
src/components/CalendarEventNip52StructuredMeta.tsx

@ -51,7 +51,7 @@ export function CalendarEventNip52StructuredMeta({
if (!hasLocations && !summaryTrim && !hasGeo) return null if (!hasLocations && !summaryTrim && !hasGeo) return null
const linkClass = const linkClass =
'inline-flex shrink-0 items-center gap-1 text-xs font-medium text-primary underline-offset-2 hover:underline' 'inline-flex shrink-0 items-center gap-1 text-xs font-medium text-primary underline-offset-2 hover:text-foreground hover:underline transition-colors'
return ( return (
<div className="min-w-0 space-y-2.5 border-t border-border/50 pt-2"> <div className="min-w-0 space-y-2.5 border-t border-border/50 pt-2">
@ -183,7 +183,7 @@ export function CalendarEventNip52StructuredMeta({
href={r.value} href={r.value}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex min-w-0 max-w-full items-start gap-1 break-all text-xs font-medium text-primary underline-offset-2 hover:underline" className="inline-flex min-w-0 max-w-full items-start gap-1 break-all text-xs font-medium text-primary underline-offset-2 hover:text-foreground hover:underline transition-colors"
> >
<Link2 className="mt-0.5 size-3 shrink-0 opacity-80" aria-hidden /> <Link2 className="mt-0.5 size-3 shrink-0 opacity-80" aria-hidden />
<span>{r.value}</span> <span>{r.value}</span>

6
src/components/CitationCard/index.tsx

@ -390,7 +390,7 @@ export default function CitationCard({ event, className, displayType = 'end', ci
<div className="text-muted-foreground">{formatDate(citationData.publishedOn)}</div> <div className="text-muted-foreground">{formatDate(citationData.publishedOn)}</div>
)} )}
{citationData.url && ( {citationData.url && (
<div className="flex items-center gap-1 text-primary hover:underline"> <div className="flex items-center gap-1 text-primary hover:text-foreground hover:underline underline-offset-2 transition-colors">
<ExternalLink className="w-3 h-3" /> <ExternalLink className="w-3 h-3" />
<a href={citationData.url} target="_blank" rel="noreferrer noopener" className="break-all"> <a href={citationData.url} target="_blank" rel="noreferrer noopener" className="break-all">
{citationData.url} {citationData.url}
@ -526,7 +526,7 @@ export default function CitationCard({ event, className, displayType = 'end', ci
<div className="text-xs text-muted-foreground">{t('Version')}: {citationData.version}</div> <div className="text-xs text-muted-foreground">{t('Version')}: {citationData.version}</div>
)} )}
{citationData.url && ( {citationData.url && (
<div className="flex items-center gap-1 text-primary hover:underline"> <div className="flex items-center gap-1 text-primary hover:text-foreground hover:underline underline-offset-2 transition-colors">
<ExternalLink className="w-3 h-3" /> <ExternalLink className="w-3 h-3" />
<a href={citationData.url} target="_blank" rel="noreferrer noopener" className="break-all"> <a href={citationData.url} target="_blank" rel="noreferrer noopener" className="break-all">
{citationData.url} {citationData.url}
@ -572,7 +572,7 @@ export default function CitationCard({ event, className, displayType = 'end', ci
<span className={className}> <span className={className}>
<a <a
href={`/notes/${event.id}`} href={`/notes/${event.id}`}
className="text-primary hover:underline" className="text-primary hover:text-foreground hover:underline underline-offset-2 transition-colors"
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
// Scroll to full citation in references section // Scroll to full citation in references section

8
src/components/Content/index.tsx

@ -65,7 +65,7 @@ function renderRedirectText(text: string, key: number) {
{prefix} {prefix}
Read{' '} Read{' '}
<a <a
className="text-primary hover:underline" className="text-primary hover:text-foreground hover:underline underline-offset-2 transition-colors"
href={href} href={href}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
@ -618,11 +618,7 @@ export default function Content({
} }
if (node.type === 'payto') { if (node.type === 'payto') {
return ( return (
<PaytoLink <PaytoLink key={index} paytoUri={node.data} />
key={index}
paytoUri={node.data}
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words"
/>
) )
} }
if (node.type === 'websocket-url') { if (node.type === 'websocket-url') {

6
src/components/ContentPreview/Content.tsx

@ -42,11 +42,7 @@ export default function Content({
} }
if (node.type === 'payto') { if (node.type === 'payto') {
return ( return (
<PaytoLink <PaytoLink key={index} paytoUri={node.data} />
key={index}
paytoUri={node.data}
className="text-green-600 dark:text-green-400 hover:underline break-words"
/>
) )
} }
if (node.type === 'emoji') { if (node.type === 'emoji') {

2
src/components/Embedded/EmbeddedHashtag.tsx

@ -13,7 +13,7 @@ export function EmbeddedHashtag({ hashtag }: { hashtag: string }) {
return ( return (
<button <button
className="text-primary hover:underline cursor-pointer" className="text-primary hover:text-foreground hover:underline underline-offset-2 transition-colors cursor-pointer"
onClick={handleClick} onClick={handleClick}
> >
{hashtag} {hashtag}

3
src/components/Embedded/EmbeddedNormalUrl.tsx

@ -1,3 +1,4 @@
import { URI_LINK_CLASS } from '@/lib/link-styles'
import { cleanUrl } from '@/lib/url' import { cleanUrl } from '@/lib/url'
import React from 'react' import React from 'react'
@ -8,7 +9,7 @@ export function EmbeddedNormalUrl({ url, children }: { url: string; children?: R
// Render all URLs as green text links (like hashtags) - WebPreview cards shown at bottom // Render all URLs as green text links (like hashtags) - WebPreview cards shown at bottom
return ( return (
<a <a
className="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline" className={URI_LINK_CLASS}
href={cleanedUrl} href={cleanedUrl}
target="_blank" target="_blank"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}

3
src/components/ExternalLink/index.tsx

@ -1,3 +1,4 @@
import { URI_LINK_CLASS } from '@/lib/link-styles'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { cleanUrl } from '@/lib/url' import { cleanUrl } from '@/lib/url'
@ -5,7 +6,7 @@ export default function ExternalLink({ url, className }: { url: string; classNam
const cleanedUrl = cleanUrl(url) const cleanedUrl = cleanUrl(url)
return ( return (
<a <a
className={cn('text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline', className)} className={cn(URI_LINK_CLASS, className)}
href={cleanedUrl} href={cleanedUrl}
target="_blank" target="_blank"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}

2
src/components/FavoriteRelaysActiveStrip/RelayPulseActiveNpubsSheet.tsx

@ -52,7 +52,7 @@ function CompactProfileCard({ event }: { event: Event }) {
<li key={id} className="truncate font-mono"> <li key={id} className="truncate font-mono">
<SecondaryPageLink <SecondaryPageLink
to={profileUrl} to={profileUrl}
className="text-primary hover:underline" className="text-primary hover:text-foreground hover:underline underline-offset-2 transition-colors"
onClick={closeDrawer} onClick={closeDrawer}
> >
{id} {id}

2
src/components/Image/index.tsx

@ -361,7 +361,7 @@ export default function Image({
href={openLinkHref} href={openLinkHref}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-sm text-primary underline-offset-4 hover:underline break-all max-w-full" className="text-sm text-primary underline-offset-4 hover:text-foreground hover:underline transition-colors break-all max-w-full"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{t('Open image link')} {t('Open image link')}

2
src/components/KeyboardShortcutsHelp/index.tsx

@ -176,7 +176,7 @@ function ReadmeOverviewPanel({ className }: { className?: string }) {
<div <div
className={cn( className={cn(
'min-w-0 pt-1 text-sm prose prose-sm dark:prose-invert max-w-none', 'min-w-0 pt-1 text-sm prose prose-sm dark:prose-invert max-w-none',
'[&_a]:text-green-600 [&_a]:dark:text-green-400 hover:[&_a]:underline', '[&_a]:text-link-uri [&_a]:no-underline hover:[&_a]:text-primary hover:[&_a]:underline',
'[&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded', '[&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded',
'[&_pre]:bg-muted [&_pre]:p-3 [&_pre]:rounded-lg [&_pre]:overflow-x-auto', '[&_pre]:bg-muted [&_pre]:p-3 [&_pre]:rounded-lg [&_pre]:overflow-x-auto',
'[&_img]:max-w-full [&_img]:h-auto [&_img]:rounded-md', '[&_img]:max-w-full [&_img]:h-auto [&_img]:rounded-md',

4
src/components/Nip05/index.tsx

@ -29,7 +29,7 @@ export default function Nip05({ pubkey, append }: { pubkey: string; append?: str
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{nip05Name !== '_' ? ( {nip05Name !== '_' ? (
<span className="text-sm text-muted-foreground truncate">@{nip05Name}</span> <span className="text-sm text-muted-foreground truncate shrink-0">{nip05Name}</span>
) : null} ) : null}
{nip05IsVerified ? ( {nip05IsVerified ? (
<Favicon <Favicon
@ -42,7 +42,7 @@ export default function Nip05({ pubkey, append }: { pubkey: string; append?: str
)} )}
<SecondaryPageLink <SecondaryPageLink
to={toNoteList({ domain: nip05Domain })} to={toNoteList({ domain: nip05Domain })}
className={`hover:underline truncate text-sm ${nip05IsVerified ? 'text-primary' : 'text-muted-foreground'}`} className={`truncate text-sm hover:text-foreground hover:underline underline-offset-2 transition-colors ${nip05IsVerified ? 'text-primary' : 'text-muted-foreground'}`}
> >
{nip05Domain} {nip05Domain}
</SecondaryPageLink> </SecondaryPageLink>

4
src/components/Nip05List/index.tsx

@ -102,7 +102,7 @@ export default function Nip05List({ nip05List, pubkey }: { nip05List: string[];
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{nip05Name !== '_' ? ( {nip05Name !== '_' ? (
<span className="text-sm text-muted-foreground truncate">@{nip05Name}</span> <span className="text-sm text-muted-foreground truncate shrink-0">{nip05Name}@</span>
) : null} ) : null}
{isVerified ? ( {isVerified ? (
<Favicon <Favicon
@ -115,7 +115,7 @@ export default function Nip05List({ nip05List, pubkey }: { nip05List: string[];
)} )}
<SecondaryPageLink <SecondaryPageLink
to={toNoteList({ domain: nip05Domain })} to={toNoteList({ domain: nip05Domain })}
className={`hover:underline truncate text-sm ${isVerified ? 'text-primary' : 'text-muted-foreground'}`} className={`truncate text-sm hover:text-foreground hover:underline underline-offset-2 transition-colors ${isVerified ? 'text-primary' : 'text-muted-foreground'}`}
> >
{nip05Domain} {nip05Domain}
</SecondaryPageLink> </SecondaryPageLink>

19
src/components/Note/AsciidocArticle/AsciidocArticle.tsx

@ -27,6 +27,7 @@ import { EmbeddedNote, EmbeddedMention } from '@/components/Embedded'
import EmbeddedCitation from '@/components/EmbeddedCitation' import EmbeddedCitation from '@/components/EmbeddedCitation'
import { parsePaytoUri } from '@/lib/payto' import { parsePaytoUri } from '@/lib/payto'
import PaytoLink from '@/components/PaytoLink' import PaytoLink from '@/components/PaytoLink'
import { URI_LINK_CLASS, URI_LINK_INLINE_HTML_CLASS } from '@/lib/link-styles'
import { DeletedEventProvider } from '@/providers/DeletedEventProvider' import { DeletedEventProvider } from '@/providers/DeletedEventProvider'
import { ReplyProvider } from '@/providers/ReplyProvider' import { ReplyProvider } from '@/providers/ReplyProvider'
import Wikilink from '@/components/UniversalContent/Wikilink' import Wikilink from '@/components/UniversalContent/Wikilink'
@ -923,7 +924,7 @@ export default function AsciidocArticle({
// Check if the href is a relay URL // Check if the href is a relay URL
else if (isWebsocketUrl(href)) { else if (isWebsocketUrl(href)) {
const relayPath = `/relays/${encodeURIComponent(href)}` const relayPath = `/relays/${encodeURIComponent(href)}`
replacement = `<a href="${relayPath}" class="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words cursor-pointer" data-relay-url="${href}" data-original-text="${linkText.replace(/"/g, '&quot;')}">${linkText}</a>` replacement = `<a href="${relayPath}" class="${URI_LINK_INLINE_HTML_CLASS} cursor-pointer" data-relay-url="${href}" data-original-text="${linkText.replace(/"/g, '&quot;')}">${linkText}</a>`
} }
htmlString = htmlString.substring(0, linkMatches[i].index) + replacement + htmlString.substring(linkMatches[i].index + match.length) htmlString = htmlString.substring(0, linkMatches[i].index) + replacement + htmlString.substring(linkMatches[i].index + match.length)
@ -946,7 +947,7 @@ export default function AsciidocArticle({
// Only replace if not already in a tag (basic check) // Only replace if not already in a tag (basic check)
if (!match.includes('<') && !match.includes('>') && isWebsocketUrl(match)) { if (!match.includes('<') && !match.includes('>') && isWebsocketUrl(match)) {
const relayPath = `/relays/${encodeURIComponent(match)}` const relayPath = `/relays/${encodeURIComponent(match)}`
return `<a href="${relayPath}" class="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words cursor-pointer" data-relay-url="${match}" data-original-text="${match.replace(/"/g, '&quot;')}">${match}</a>` return `<a href="${relayPath}" class="${URI_LINK_INLINE_HTML_CLASS} cursor-pointer" data-relay-url="${match}" data-original-text="${match.replace(/"/g, '&quot;')}">${match}</a>`
} }
return match return match
}) })
@ -961,7 +962,7 @@ export default function AsciidocArticle({
if (isImage(rawUrl) || isVideo(rawUrl) || isAudio(rawUrl)) return rawUrl if (isImage(rawUrl) || isVideo(rawUrl) || isAudio(rawUrl)) return rawUrl
const cleanedUrl = cleanUrl(rawUrl) const cleanedUrl = cleanUrl(rawUrl)
if (!cleanedUrl) return rawUrl if (!cleanedUrl) return rawUrl
return `<a href="${cleanedUrl}" class="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words" target="_blank" rel="noopener noreferrer">${rawUrl}</a>` return `<a href="${cleanedUrl}" class="${URI_LINK_INLINE_HTML_CLASS}" target="_blank" rel="noopener noreferrer">${rawUrl}</a>`
}) })
return `>${replacedText}<` return `>${replacedText}<`
}) })
@ -1102,7 +1103,7 @@ export default function AsciidocArticle({
parent.replaceChild(container, element) parent.replaceChild(container, element)
try { try {
const root = createRoot(container) const root = createRoot(container)
root.render(<PaytoLink paytoUri={decoded} className="text-primary hover:underline break-words" />) root.render(<PaytoLink paytoUri={decoded} />)
reactRootsRef.current.set(container, root) reactRootsRef.current.set(container, root)
} catch (error) { } catch (error) {
logger.error('Failed to render payto link', { paytoUri: decoded, error }) logger.error('Failed to render payto link', { paytoUri: decoded, error })
@ -1198,7 +1199,7 @@ export default function AsciidocArticle({
const link = document.createElement('a') const link = document.createElement('a')
link.href = `#${getCitationAnchorId(citation.index)}` link.href = `#${getCitationAnchorId(citation.index)}`
link.id = getCitationRefId(citation.index) link.id = getCitationRefId(citation.index)
link.className = 'text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline no-underline' link.className = `${URI_LINK_CLASS} no-underline`
link.textContent = `[${citationNumber}]` link.textContent = `[${citationNumber}]`
link.addEventListener('click', (e) => { link.addEventListener('click', (e) => {
e.preventDefault() e.preventDefault()
@ -1218,7 +1219,7 @@ export default function AsciidocArticle({
const link = document.createElement('a') const link = document.createElement('a')
link.href = `#${referencesSectionId}` link.href = `#${referencesSectionId}`
link.id = getCitationRefId(citation.index) link.id = getCitationRefId(citation.index)
link.className = 'text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline no-underline' link.className = `${URI_LINK_CLASS} no-underline`
link.textContent = `[${citationNumber}]` link.textContent = `[${citationNumber}]`
link.addEventListener('click', (e) => { link.addEventListener('click', (e) => {
e.preventDefault() e.preventDefault()
@ -1332,7 +1333,7 @@ export default function AsciidocArticle({
const backLink = document.createElement('a') const backLink = document.createElement('a')
backLink.href = `#${getCitationRefId(citation.index)}` backLink.href = `#${getCitationRefId(citation.index)}`
backLink.className = 'text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline text-xs ml-2 inline-flex items-center' backLink.className = `text-xs ml-2 inline-flex items-center ${URI_LINK_CLASS}`
backLink.setAttribute('aria-label', 'Return to citation') backLink.setAttribute('aria-label', 'Return to citation')
// Use hyperlink icon instead of emoji // Use hyperlink icon instead of emoji
backLink.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>' backLink.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>'
@ -1430,7 +1431,7 @@ export default function AsciidocArticle({
const backLink = document.createElement('a') const backLink = document.createElement('a')
backLink.href = `#${getCitationRefId(citation.index)}` backLink.href = `#${getCitationRefId(citation.index)}`
backLink.className = 'text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline text-xs ml-2 inline-flex items-center' backLink.className = `text-xs ml-2 inline-flex items-center ${URI_LINK_CLASS}`
backLink.setAttribute('aria-label', 'Return to citation') backLink.setAttribute('aria-label', 'Return to citation')
backLink.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>' backLink.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>'
backLink.addEventListener('click', (e) => { backLink.addEventListener('click', (e) => {
@ -1697,7 +1698,7 @@ export default function AsciidocArticle({
// Create hashtag link // Create hashtag link
const link = document.createElement('a') const link = document.createElement('a')
link.href = `/notes?t=${match[1].toLowerCase()}` link.href = `/notes?t=${match[1].toLowerCase()}`
link.className = 'inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline cursor-pointer' link.className = `${URI_LINK_INLINE_HTML_CLASS} cursor-pointer`
link.textContent = `#${match[1]}` link.textContent = `#${match[1]}`
link.addEventListener('click', (e) => { link.addEventListener('click', (e) => {
e.stopPropagation() e.stopPropagation()

2
src/components/Note/GitRepublicEventCard.tsx

@ -145,7 +145,7 @@ export default function GitRepublicEventCard({
href={webUrl} href={webUrl}
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
className="inline-flex max-w-full items-center gap-1 text-xs font-medium text-primary hover:underline" className="inline-flex max-w-full items-center gap-1 text-xs font-medium text-primary hover:text-foreground hover:underline underline-offset-2 transition-colors"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<ExternalLink className="size-3 shrink-0" aria-hidden /> <ExternalLink className="size-3 shrink-0" aria-hidden />

2
src/components/Note/Highlight/index.tsx

@ -90,7 +90,7 @@ function HighlightAuthorCard({
A{' '} A{' '}
<button <button
onClick={handleNoteClick} onClick={handleNoteClick}
className="text-primary hover:text-primary/80 hover:underline font-medium cursor-pointer" className="text-primary hover:text-foreground hover:underline underline-offset-2 transition-colors font-medium cursor-pointer"
> >
note note
</button> </button>

60
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -25,6 +25,7 @@ import {
} from '@/lib/url' } from '@/lib/url'
import { getHttpUrlFromITags, getImetaInfosFromEvent } from '@/lib/event' import { getHttpUrlFromITags, getImetaInfosFromEvent } from '@/lib/event'
import { canonicalizeRssArticleUrl } from '@/lib/rss-article' import { canonicalizeRssArticleUrl } from '@/lib/rss-article'
import { URI_LINK_CLASS } from '@/lib/link-styles'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import Emoji, { EMOJI_IMG_INLINE_CLASS } from '@/components/Emoji' import Emoji, { EMOJI_IMG_INLINE_CLASS } from '@/components/Emoji'
@ -2188,7 +2189,7 @@ function parseMarkdownContentLegacy(
<a <a
key={`link-${patternIdx}`} key={`link-${patternIdx}`}
href={url} href={url}
className="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words" className={cn('inline', URI_LINK_CLASS)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
@ -2202,7 +2203,7 @@ function parseMarkdownContentLegacy(
<a <a
key={`link-${patternIdx}`} key={`link-${patternIdx}`}
href={url} href={url}
className="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words" className={cn('inline', URI_LINK_CLASS)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
@ -2236,7 +2237,7 @@ function parseMarkdownContentLegacy(
<a <a
key={`link-${patternIdx}`} key={`link-${patternIdx}`}
href={url} href={url}
className="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words" className={cn('inline', URI_LINK_CLASS)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
@ -2248,7 +2249,7 @@ function parseMarkdownContentLegacy(
<a <a
key={`link-${patternIdx}`} key={`link-${patternIdx}`}
href={url} href={url}
className="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words" className={cn('inline', URI_LINK_CLASS)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
@ -2283,7 +2284,7 @@ function parseMarkdownContentLegacy(
<a <a
key={`relay-${patternIdx}`} key={`relay-${patternIdx}`}
href={relayPath} href={relayPath}
className="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words cursor-pointer" className={cn('inline cursor-pointer', URI_LINK_CLASS)}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
e.preventDefault() e.preventDefault()
@ -2300,7 +2301,7 @@ function parseMarkdownContentLegacy(
<a <a
key={`link-${patternIdx}`} key={`link-${patternIdx}`}
href={url} href={url}
className="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words" className={cn('inline', URI_LINK_CLASS)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
@ -2347,7 +2348,7 @@ function parseMarkdownContentLegacy(
<a <a
key={`relay-${patternIdx}`} key={`relay-${patternIdx}`}
href={relayPath} href={relayPath}
className="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words cursor-pointer" className={cn('inline cursor-pointer', URI_LINK_CLASS)}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
e.preventDefault() e.preventDefault()
@ -2547,7 +2548,7 @@ function parseMarkdownContentLegacy(
<a <a
href={`#footnote-${footnoteId}`} href={`#footnote-${footnoteId}`}
id={`footnote-ref-${footnoteId}`} id={`footnote-ref-${footnoteId}`}
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline no-underline" className={cn(URI_LINK_CLASS, 'no-underline')}
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
const footnoteElement = document.getElementById(`footnote-${footnoteId}`) const footnoteElement = document.getElementById(`footnote-${footnoteId}`)
@ -2585,7 +2586,7 @@ function parseMarkdownContentLegacy(
<a <a
href={`#citation-${citationIndex}`} href={`#citation-${citationIndex}`}
id={`citation-ref-${citationIndex}`} id={`citation-ref-${citationIndex}`}
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline no-underline" className={cn(URI_LINK_CLASS, 'no-underline')}
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
const citationElement = document.getElementById(`citation-${citationIndex}`) const citationElement = document.getElementById(`citation-${citationIndex}`)
@ -2615,7 +2616,7 @@ function parseMarkdownContentLegacy(
<a <a
href="#references-section" href="#references-section"
id={`citation-ref-${citationIndex}`} id={`citation-ref-${citationIndex}`}
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline no-underline" className={cn(URI_LINK_CLASS, 'no-underline')}
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
const refSection = document.getElementById('references-section') const refSection = document.getElementById('references-section')
@ -2673,7 +2674,7 @@ function parseMarkdownContentLegacy(
<a <a
key={`hashtag-${patternIdx}`} key={`hashtag-${patternIdx}`}
href={`/notes?t=${tagLower}`} href={`/notes?t=${tagLower}`}
className="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline cursor-pointer whitespace-nowrap" className={cn('inline cursor-pointer whitespace-nowrap', URI_LINK_CLASS)}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
e.preventDefault() e.preventDefault()
@ -3041,7 +3042,7 @@ function parseMarkdownContentLegacy(
{' '} {' '}
<a <a
href={`#footnote-ref-${id}`} href={`#footnote-ref-${id}`}
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline text-xs" className={cn('text-xs', URI_LINK_CLASS)}
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
const refElement = document.getElementById(`footnote-ref-${id}`) const refElement = document.getElementById(`footnote-ref-${id}`)
@ -3082,7 +3083,7 @@ function parseMarkdownContentLegacy(
</span> </span>
<a <a
href={`#citation-ref-${citation.id.replace('citation-', '')}`} href={`#citation-ref-${citation.id.replace('citation-', '')}`}
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline text-xs ml-2 inline-flex items-center absolute right-0 top-0" className={cn('text-xs ml-2 inline-flex items-center absolute right-0 top-0', URI_LINK_CLASS)}
aria-label="Return to citation" aria-label="Return to citation"
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
@ -3126,7 +3127,7 @@ function parseMarkdownContentLegacy(
</span> </span>
<a <a
href={`#citation-ref-${citation.id.replace('citation-', '')}`} href={`#citation-ref-${citation.id.replace('citation-', '')}`}
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline text-xs ml-2 inline-flex items-center absolute right-0 top-0" className={cn('text-xs ml-2 inline-flex items-center absolute right-0 top-0', URI_LINK_CLASS)}
aria-label="Return to citation" aria-label="Return to citation"
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
@ -3415,8 +3416,9 @@ function parseMarkdownContentMarked(
const cleaned = cleanUrl(href) const cleaned = cleanUrl(href)
const linkTip = markdownTokenTitle(token) const linkTip = markdownTokenTitle(token)
const linkVisual = cn( const linkVisual = cn(
'text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words', URI_LINK_CLASS,
linkTip && 'cursor-help underline decoration-dotted decoration-current/70 underline-offset-2' linkTip &&
'cursor-help no-underline hover:underline decoration-dotted decoration-current/70 underline-offset-2'
) )
if (href.startsWith('payto://')) { if (href.startsWith('payto://')) {
const children = stripNestedAnchorsFromNodes( const children = stripNestedAnchorsFromNodes(
@ -3711,7 +3713,7 @@ function parseMarkdownContentMarked(
<a <a
key={`${key}-relay`} key={`${key}-relay`}
href={`/relays/${encodeURIComponent(paragraphText)}`} href={`/relays/${encodeURIComponent(paragraphText)}`}
className="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words" className={cn('inline', URI_LINK_CLASS)}
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
navigateToRelay(paragraphText) navigateToRelay(paragraphText)
@ -3742,7 +3744,7 @@ function parseMarkdownContentMarked(
<a <a
key={`${key}-line-relay-${lineIdx}`} key={`${key}-line-relay-${lineIdx}`}
href={`/relays/${encodeURIComponent(line)}`} href={`/relays/${encodeURIComponent(line)}`}
className="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words" className={cn('inline', URI_LINK_CLASS)}
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
navigateToRelay(line) navigateToRelay(line)
@ -3817,7 +3819,7 @@ function parseMarkdownContentMarked(
href={cleaned} href={cleaned}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words" className={cn('inline', URI_LINK_CLASS)}
> >
{cleaned} {cleaned}
</a> </a>
@ -3990,7 +3992,7 @@ function parseMarkdownContentMarked(
href={cleaned} href={cleaned}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words" className={cn('inline', URI_LINK_CLASS)}
> >
{cleaned} {cleaned}
</a> </a>
@ -4672,7 +4674,7 @@ function parseMarkdownContentMarked(
<span>{parseInlineMarkdown(text, `footnote-${id}`, footnotes, emojiInfos, navigateToHashtag, emojiLightbox)}</span>{' '} <span>{parseInlineMarkdown(text, `footnote-${id}`, footnotes, emojiInfos, navigateToHashtag, emojiLightbox)}</span>{' '}
<a <a
href={`#footnote-ref-${id}`} href={`#footnote-ref-${id}`}
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline text-xs" className={cn('text-xs', URI_LINK_CLASS)}
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
const refElement = document.getElementById(`footnote-ref-${id}`) const refElement = document.getElementById(`footnote-ref-${id}`)
@ -4789,7 +4791,7 @@ function parseInlineMarkdown(
<PaytoLink <PaytoLink
key={`${tokenKey}-payto-link`} key={`${tokenKey}-payto-link`}
paytoUri={href} paytoUri={href}
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words" className={URI_LINK_CLASS}
> >
{children} {children}
</PaytoLink> </PaytoLink>
@ -4799,7 +4801,7 @@ function parseInlineMarkdown(
<a <a
key={`${tokenKey}-link`} key={`${tokenKey}-link`}
href={href} href={href}
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words" className={URI_LINK_CLASS}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
@ -5076,7 +5078,7 @@ function parseInlineMarkdownLegacy(
const { text, url } = pattern.data const { text, url } = pattern.data
if (url.startsWith('payto://')) { if (url.startsWith('payto://')) {
parts.push( parts.push(
<PaytoLink key={`${keyPrefix}-payto-link-${i}`} paytoUri={url} className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words"> <PaytoLink key={`${keyPrefix}-payto-link-${i}`} paytoUri={url} className={URI_LINK_CLASS}>
{parseInlineMarkdownLegacy(text, `${keyPrefix}-link-${i}`, _footnotes, emojiInfos, undefined, emojiLightbox)} {parseInlineMarkdownLegacy(text, `${keyPrefix}-link-${i}`, _footnotes, emojiInfos, undefined, emojiLightbox)}
</PaytoLink> </PaytoLink>
) )
@ -5093,7 +5095,7 @@ function parseInlineMarkdownLegacy(
<a <a
key={`${keyPrefix}-link-${i}`} key={`${keyPrefix}-link-${i}`}
href={url} href={url}
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words" className={URI_LINK_CLASS}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
@ -5109,7 +5111,7 @@ function parseInlineMarkdownLegacy(
<a <a
key={`${keyPrefix}-hashtag-${i}`} key={`${keyPrefix}-hashtag-${i}`}
href={`/notes?t=${tagLower}`} href={`/notes?t=${tagLower}`}
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words" className={URI_LINK_CLASS}
onClick={(e) => { onClick={(e) => {
if (!navigateToHashtag) return if (!navigateToHashtag) return
e.stopPropagation() e.stopPropagation()
@ -5127,7 +5129,7 @@ function parseInlineMarkdownLegacy(
<a <a
href={`#footnote-${footnoteId}`} href={`#footnote-${footnoteId}`}
id={`footnote-ref-${footnoteId}`} id={`footnote-ref-${footnoteId}`}
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline text-xs" className={cn('text-xs', URI_LINK_CLASS)}
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
const footnoteElement = document.getElementById(`footnote-${footnoteId}`) const footnoteElement = document.getElementById(`footnote-${footnoteId}`)
@ -5150,7 +5152,7 @@ function parseInlineMarkdownLegacy(
<a <a
key={`${keyPrefix}-relay-${i}`} key={`${keyPrefix}-relay-${i}`}
href={relayPath} href={relayPath}
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words" className={URI_LINK_CLASS}
> >
{url} {url}
</a> </a>
@ -5175,7 +5177,7 @@ function parseInlineMarkdownLegacy(
<PaytoLink <PaytoLink
key={`${keyPrefix}-payto-${i}`} key={`${keyPrefix}-payto-${i}`}
paytoUri={payto.raw} paytoUri={payto.raw}
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words" className={URI_LINK_CLASS}
/> />
) )
} else if (pattern.type === 'emoji') { } else if (pattern.type === 'emoji') {

2
src/components/Note/index.tsx

@ -442,7 +442,7 @@ export default function Note({
href={href} href={href}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-sm text-primary underline-offset-4 hover:underline break-all" className="text-sm text-primary underline-offset-4 hover:text-foreground hover:underline transition-colors break-all"
> >
{href} {href}
</a> </a>

93
src/components/NoteStats/ZapButton.tsx

@ -1,18 +1,22 @@
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import { getLightningAddressFromProfile } from '@/lib/lightning' import {
buildOrderedZapLightningAddresses,
recipientHasAnyPaymentOptions
} from '@/lib/merge-payment-methods'
import { getPaymentInfoFromEvent, getProfileFromEvent } from '@/lib/event-metadata'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useNoteFeedProfileContext } from '@/providers/NoteFeedProfileContext'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useZap } from '@/providers/ZapProvider' import { useZap } from '@/providers/ZapProvider'
import { replaceableEventService } from '@/services/client.service' import client, { replaceableEventService } from '@/services/client.service'
import { getProfileFromEvent } from '@/lib/event-metadata' import type { TProfile } from '@/types'
import { kinds } from 'nostr-tools' import { kinds, type Event } from 'nostr-tools'
import lightning from '@/services/lightning.service' import lightning from '@/services/lightning.service'
import noteStatsService from '@/services/note-stats.service' import noteStatsService from '@/services/note-stats.service'
import type { TNoteStats } from '@/services/note-stats.service' import type { TNoteStats } from '@/services/note-stats.service'
import { Zap } from 'lucide-react' import { Zap } from 'lucide-react'
import { Event } from 'nostr-tools' import { MouseEvent, TouchEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { MouseEvent, TouchEvent, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import ZapDialog from '../ZapDialog' import ZapDialog from '../ZapDialog'
@ -40,25 +44,79 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
} }
}, [noteStats, pubkey]) }, [noteStats, pubkey])
const showZapAmount = !hideCount && (statsLoaded || (zapAmount ?? 0) > 0) const showZapAmount = !hideCount && (statsLoaded || (zapAmount ?? 0) > 0)
const authorPubkey = event.pubkey.toLowerCase()
const isSelf = !!pubkey && pubkey.toLowerCase() === authorPubkey
const feedProfiles = useNoteFeedProfileContext()
const feedProfile = feedProfiles?.profiles.get(authorPubkey)
const feedProfileRef = useRef(feedProfile)
feedProfileRef.current = feedProfile
const [disable, setDisable] = useState(true) const [disable, setDisable] = useState(true)
const [canLightningZap, setCanLightningZap] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null) const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const isLongPressRef = useRef(false) const isLongPressRef = useRef(false)
const applyTipAvailability = useCallback(
(
profile: TProfile | null,
profileEvent: Event | null | undefined,
paymentInfo: ReturnType<typeof getPaymentInfoFromEvent> | null
) => {
const canTip = recipientHasAnyPaymentOptions(paymentInfo, profile, profileEvent ?? null)
setDisable(!canTip)
setCanLightningZap(
buildOrderedZapLightningAddresses({ profileEvent, paymentInfo }).length > 0
)
},
[]
)
/** Re-enable when the feed batch loads a real profile (not a placeholder row). */
useEffect(() => { useEffect(() => {
if (isSelf) return
if (!feedProfile || feedProfile.batchPlaceholder) return
applyTipAvailability(feedProfile, null, null)
}, [isSelf, feedProfile, feedProfiles?.version, applyTipAvailability])
useEffect(() => {
if (isSelf) {
setDisable(true)
setCanLightningZap(false)
return
}
setDisable(true) setDisable(true)
setCanLightningZap(false)
let cancelled = false let cancelled = false
replaceableEventService.fetchReplaceableEvent(event.pubkey, kinds.Metadata).then((profileEvent) => {
void Promise.allSettled([
replaceableEventService.fetchReplaceableEvent(authorPubkey, kinds.Metadata),
client.fetchPaymentInfoEvent(authorPubkey),
replaceableEventService.getProfileFromIndexedDB(authorPubkey)
]).then(([profileRes, paymentRes, idbRes]) => {
if (cancelled) return if (cancelled) return
const profile = profileEvent ? getProfileFromEvent(profileEvent) : undefined
if (!profile) return const profileEvent =
if (pubkey === profile.pubkey) return profileRes.status === 'fulfilled' ? profileRes.value : undefined
const lightningAddress = getLightningAddressFromProfile(profile) const paymentEvent =
if (lightningAddress) setDisable(false) paymentRes.status === 'fulfilled' ? paymentRes.value : undefined
const idbProfile = idbRes.status === 'fulfilled' ? idbRes.value : undefined
const cachedFeed = feedProfileRef.current
const profile =
(profileEvent ? getProfileFromEvent(profileEvent) : null) ??
(cachedFeed && !cachedFeed.batchPlaceholder ? cachedFeed : null) ??
idbProfile ??
null
const paymentInfo = paymentEvent ? getPaymentInfoFromEvent(paymentEvent) : null
applyTipAvailability(profile, profileEvent ?? null, paymentInfo)
}) })
return () => { return () => {
cancelled = true cancelled = true
} }
}, [event.pubkey, pubkey]) }, [authorPubkey, isSelf, applyTipAvailability])
const handleZap = async () => { const handleZap = async () => {
try { try {
@ -143,7 +201,14 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
setZapping(true) setZapping(true)
}) })
} else if (!isLongPressRef.current) { } else if (!isLongPressRef.current) {
checkLogin(() => handleZap()) if (canLightningZap) {
checkLogin(() => handleZap())
} else {
checkLogin(() => {
setOpenZapDialog(true)
setZapping(true)
})
}
} }
isLongPressRef.current = false isLongPressRef.current = false
} }

14
src/components/PaymentMethodsSection/index.tsx

@ -1,6 +1,7 @@
import PaytoLink from '@/components/PaytoLink' import PaytoLink from '@/components/PaytoLink'
import type { PaymentMethodGroup } from '@/lib/merge-payment-methods' import type { PaymentMethodGroup } from '@/lib/merge-payment-methods'
import { isLightningPaytoType } from '@/lib/payto' import { PRIMARY_LINK_HOVER_CLASS } from '@/lib/link-styles'
import { isZappableLightningPaytoType } from '@/lib/payto'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Copy } from 'lucide-react' import { Copy } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -33,10 +34,7 @@ export default function PaymentMethodsSection({
{title ?? t('Payment Methods')} {title ?? t('Payment Methods')}
</div> </div>
{headerHelpText ? ( {headerHelpText ? (
<p <p className="mb-3 text-xs leading-snug text-muted-foreground" role="note">
className="mb-3 rounded-md border border-amber-500/45 bg-amber-500/15 px-3 py-2.5 text-sm font-semibold leading-snug text-foreground"
role="note"
>
{headerHelpText} {headerHelpText}
</p> </p>
) : null} ) : null}
@ -62,13 +60,13 @@ export default function PaymentMethodsSection({
type={method.type} type={method.type}
authority={method.authority} authority={method.authority}
paytoUri={method.payto} paytoUri={method.payto}
pubkey={isLightningPaytoType(method.type) ? recipientPubkey : undefined} pubkey={isZappableLightningPaytoType(method.type) ? recipientPubkey : undefined}
onOpenZap={ onOpenZap={
isLightningPaytoType(method.type) && onOpenZap isZappableLightningPaytoType(method.type) && onOpenZap
? (_pk, authority) => onOpenZap(authority) ? (_pk, authority) => onOpenZap(authority)
: undefined : undefined
} }
className="hover:underline break-all min-w-0 text-primary flex-1" className={cn(PRIMARY_LINK_HOVER_CLASS, 'break-all min-w-0 flex-1')}
> >
{method.authority} {method.authority}
</PaytoLink> </PaytoLink>

12
src/components/PaytoLink/index.tsx

@ -10,10 +10,12 @@ import {
getPaytoLogoPath, getPaytoLogoPath,
getPaytoProfileUrl, getPaytoProfileUrl,
isKnownPaytoType, isKnownPaytoType,
isLightningPaytoType isLightningPaytoType,
isZappableLightningPaytoType
} from '@/lib/payto' } from '@/lib/payto'
import PaytoDialog from '@/components/PaytoDialog' import PaytoDialog from '@/components/PaytoDialog'
import { HelpCircle } from 'lucide-react' import { HelpCircle } from 'lucide-react'
import { PRIMARY_LINK_HOVER_CLASS, URI_LINK_CLASS } from '@/lib/link-styles'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
export default function PaytoLink({ export default function PaytoLink({
@ -58,7 +60,7 @@ export default function PaytoLink({
const info = getPaytoTypeInfo(type) const info = getPaytoTypeInfo(type)
const known = isKnownPaytoType(type) const known = isKnownPaytoType(type)
const isLightning = isLightningPaytoType(type) const isLightning = isLightningPaytoType(type)
const canZap = isLightning && !!pubkey && !!onOpenZap const canZap = isZappableLightningPaytoType(type) && !!pubkey && !!onOpenZap
const handleClick = (e: React.MouseEvent) => { const handleClick = (e: React.MouseEvent) => {
e.preventDefault() e.preventDefault()
@ -112,7 +114,8 @@ export default function PaytoLink({
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={cn( className={cn(
'text-primary hover:underline cursor-pointer text-left break-words inline-flex items-center gap-1.5', URI_LINK_CLASS,
'cursor-pointer text-left inline-flex items-center gap-1.5',
className className
)} )}
title={ title={
@ -133,7 +136,8 @@ export default function PaytoLink({
type="button" type="button"
onClick={handleClick} onClick={handleClick}
className={cn( className={cn(
'text-primary hover:underline cursor-pointer text-left break-words inline-flex items-center gap-1.5', URI_LINK_CLASS,
'cursor-pointer text-left inline-flex items-center gap-1.5',
className className
)} )}
title={ title={

42
src/components/Profile/index.tsx

@ -86,12 +86,13 @@ import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants'
import { nip66Service } from '@/services/nip66.service' import { nip66Service } from '@/services/nip66.service'
import PaymentMethodsSection from '@/components/PaymentMethodsSection' import PaymentMethodsSection from '@/components/PaymentMethodsSection'
import { import {
getAlternativePaymentMethods,
groupPaymentMethodsByDisplayType, groupPaymentMethodsByDisplayType,
mergePaymentMethods, mergePaymentMethods,
recipientHasAnyPaymentOptions,
sortMergedPaymentMethods sortMergedPaymentMethods
} from '@/lib/merge-payment-methods' } from '@/lib/merge-payment-methods'
import { isLightningPaytoType } from '@/lib/payto' import { PRIMARY_LINK_HOVER_CLASS } from '@/lib/link-styles'
import { cn } from '@/lib/utils'
export default function Profile({ export default function Profile({
id, id,
@ -139,8 +140,8 @@ export default function Profile({
const { relaySets, favoriteRelays } = useFavoriteRelays() const { relaySets, favoriteRelays } = useFavoriteRelays()
const mergedPaymentMethods = useMemo( const mergedPaymentMethods = useMemo(
() => sortMergedPaymentMethods(mergePaymentMethods(paymentInfo, profile ?? null)), () => sortMergedPaymentMethods(mergePaymentMethods(paymentInfo, profile ?? null, profileEvent)),
[paymentInfo, profile] [paymentInfo, profile, profileEvent]
) )
const paymentMethodsByType = useMemo( const paymentMethodsByType = useMemo(
@ -148,11 +149,10 @@ export default function Profile({
[mergedPaymentMethods] [mergedPaymentMethods]
) )
const hasTipDialog = useMemo(() => { const hasTipDialog = useMemo(
const merged = sortMergedPaymentMethods(mergePaymentMethods(paymentInfo, profile ?? null)) () => recipientHasAnyPaymentOptions(paymentInfo, profile ?? null, profileEvent),
if (merged.some((m) => isLightningPaytoType(m.type))) return true [paymentInfo, profile, profileEvent]
return getAlternativePaymentMethods(merged).length > 0 )
}, [paymentInfo, profile])
const syncAuthorReplaceablesFromCache = useCallback(async (pubkey: string) => { const syncAuthorReplaceablesFromCache = useCallback(async (pubkey: string) => {
try { try {
@ -563,13 +563,16 @@ export default function Profile({
/> />
{/* Display websites - show first one prominently, others below */} {/* Display websites - show first one prominently, others below */}
{website && ( {website && (
<div className="flex gap-1 items-center text-primary mt-2 truncate select-text"> <div className="group flex gap-1 items-center mt-2 truncate select-text">
<Link size={14} className="shrink-0" /> <Link
size={14}
className={cn('shrink-0 text-primary transition-colors', 'group-hover:text-foreground')}
/>
<a <a
href={website} href={website}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="hover:underline truncate flex-1 max-w-fit w-0" className={cn(PRIMARY_LINK_HOVER_CLASS, 'truncate flex-1 max-w-fit w-0')}
> >
{website} {website}
</a> </a>
@ -578,13 +581,22 @@ export default function Profile({
{websiteList && websiteList.length > 1 && ( {websiteList && websiteList.length > 1 && (
<div className="flex flex-col gap-1 mt-1"> <div className="flex flex-col gap-1 mt-1">
{websiteList.slice(1).map((url: string, idx: number) => ( {websiteList.slice(1).map((url: string, idx: number) => (
<div key={idx} className="flex gap-1 items-center text-primary truncate select-text"> <div
<Link size={12} className="shrink-0" /> key={idx}
className="group flex gap-1 items-center truncate select-text"
>
<Link
size={12}
className={cn(
'shrink-0 text-primary transition-colors',
'group-hover:text-foreground'
)}
/>
<a <a
href={url} href={url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="hover:underline truncate text-sm" className={cn(PRIMARY_LINK_HOVER_CLASS, 'truncate text-sm')}
> >
{url} {url}
</a> </a>

16
src/components/ProfileAbout/index.tsx

@ -8,6 +8,8 @@ import {
} from '@/lib/content-parser' } from '@/lib/content-parser'
import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content' import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content'
import PaytoLink from '@/components/PaytoLink' import PaytoLink from '@/components/PaytoLink'
import { URI_LINK_CLASS } from '@/lib/link-styles'
import { cn } from '@/lib/utils'
import { marked } from 'marked' import { marked } from 'marked'
import { import {
EmbeddedHashtag, EmbeddedHashtag,
@ -53,11 +55,7 @@ export default function ProfileAbout({ about, className }: { about?: string; cla
} }
if (node.type === 'payto') { if (node.type === 'payto') {
return ( return (
<PaytoLink <PaytoLink key={`${keyPrefix}-payto-${index}`} paytoUri={node.data} />
key={`${keyPrefix}-payto-${index}`}
paytoUri={node.data}
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words"
/>
) )
} }
if (node.type === 'hashtag') { if (node.type === 'hashtag') {
@ -120,11 +118,7 @@ export default function ProfileAbout({ about, className }: { about?: string; cla
const label = String(token.text ?? href) const label = String(token.text ?? href)
if (href.startsWith('payto://')) { if (href.startsWith('payto://')) {
out.push( out.push(
<PaytoLink <PaytoLink key={`${key}-payto-link`} paytoUri={href}>
key={`${key}-payto-link`}
paytoUri={href}
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words"
>
{label} {label}
</PaytoLink> </PaytoLink>
) )
@ -135,7 +129,7 @@ export default function ProfileAbout({ about, className }: { about?: string; cla
href={href} href={href}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words" className={URI_LINK_CLASS}
> >
{label} {label}
</a> </a>

2
src/components/RelayInfo/index.tsx

@ -96,7 +96,7 @@ export default function RelayInfo({ url, className }: { url: string; className?:
<a <a
href={normalizeHttpUrl(relayInfo.url)} href={normalizeHttpUrl(relayInfo.url)}
target="_blank" target="_blank"
className="hover:underline text-primary select-text truncate block" className="text-primary hover:text-foreground hover:underline underline-offset-2 transition-colors select-text truncate block"
> >
{normalizeHttpUrl(relayInfo.url)} {normalizeHttpUrl(relayInfo.url)}
</a> </a>

2
src/components/RelayStatusDisplay/index.tsx

@ -71,7 +71,7 @@ function renderTextWithLinks(text: string): React.ReactNode {
href={url} href={url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 hover:underline break-all" className="text-blue-600 dark:text-blue-400 hover:text-foreground hover:underline underline-offset-2 transition-colors break-all"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{url} {url}

2
src/components/RssFeedItem/index.tsx

@ -914,7 +914,7 @@ export default function RssFeedItem({
href={item.link} href={item.link}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1 min-w-0 truncate" className="text-primary hover:text-foreground hover:underline underline-offset-2 transition-colors inline-flex items-center gap-1 min-w-0 truncate"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<span className="truncate">{t('Read full article')}</span> <span className="truncate">{t('Read full article')}</span>

2
src/components/StandardRssFeedUrlRow/index.tsx

@ -68,7 +68,7 @@ export default function StandardRssFeedUrlRow({ feedUrl, className, actions }: P
href={feedUrl} href={feedUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="block break-all text-xs text-primary hover:underline" className="block break-all text-xs text-primary hover:text-foreground hover:underline underline-offset-2 transition-colors"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{feedUrl} {feedUrl}

2
src/components/UniversalContent/Wikilink.tsx

@ -22,7 +22,7 @@ export default function Wikilink({ dTag, displayText, className }: WikilinkProps
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<Button <Button
variant="link" variant="link"
className="p-0 h-auto text-blue-600 hover:text-blue-800 hover:underline inline-flex items-center gap-1" className="p-0 h-auto text-blue-600 hover:text-foreground hover:underline underline-offset-2 transition-colors inline-flex items-center gap-1"
> >
<span>{displayText}</span> <span>{displayText}</span>
{isOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />} {isOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}

2
src/components/VideoPlayer/index.tsx

@ -198,7 +198,7 @@ export default function VideoPlayer({
href={fallbackPageUrl.trim()} href={fallbackPageUrl.trim()}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex text-sm font-medium text-green-600 underline-offset-2 hover:underline dark:text-green-400 dark:hover:text-green-300" className="inline-flex text-sm font-medium text-link-uri no-underline underline-offset-2 hover:text-primary hover:underline transition-colors"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{t('Open in browser')} {t('Open in browser')}

10
src/components/WebPreview/index.tsx

@ -616,7 +616,7 @@ export default function WebPreview({ url, className }: { url: string; className?
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="text-xs text-muted-foreground truncate block hover:underline break-all" className="text-xs text-muted-foreground truncate block hover:text-foreground hover:underline underline-offset-2 transition-colors break-all"
> >
{truncatedUrl} {truncatedUrl}
</a> </a>
@ -685,7 +685,7 @@ export default function WebPreview({ url, className }: { url: string; className?
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="text-xs text-muted-foreground truncate block hover:underline break-all" className="text-xs text-muted-foreground truncate block hover:text-foreground hover:underline underline-offset-2 transition-colors break-all"
> >
{truncatedUrl} {truncatedUrl}
</a> </a>
@ -738,7 +738,7 @@ export default function WebPreview({ url, className }: { url: string; className?
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="text-xs text-muted-foreground break-all line-clamp-2 block hover:underline" className="text-xs text-muted-foreground break-all line-clamp-2 block hover:text-foreground hover:underline underline-offset-2 transition-colors"
> >
{cleanedUrl} {cleanedUrl}
</a> </a>
@ -780,7 +780,7 @@ export default function WebPreview({ url, className }: { url: string; className?
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="text-xs text-muted-foreground truncate block hover:underline break-all" className="text-xs text-muted-foreground truncate block hover:text-foreground hover:underline underline-offset-2 transition-colors break-all"
> >
{url} {url}
</a> </a>
@ -832,7 +832,7 @@ export default function WebPreview({ url, className }: { url: string; className?
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="text-xs text-muted-foreground truncate block hover:underline break-all" className="text-xs text-muted-foreground truncate block hover:text-foreground hover:underline underline-offset-2 transition-colors break-all"
> >
{url} {url}
</a> </a>

14
src/hooks/useRecipientAlternativePayments.ts

@ -2,6 +2,7 @@ import {
getAlternativePaymentMethods, getAlternativePaymentMethods,
groupPaymentMethodsByDisplayType, groupPaymentMethodsByDisplayType,
mergePaymentMethods, mergePaymentMethods,
recipientHasAnyPaymentOptions,
sortMergedPaymentMethods, sortMergedPaymentMethods,
type PaymentMethodGroup type PaymentMethodGroup
} from '@/lib/merge-payment-methods' } from '@/lib/merge-payment-methods'
@ -17,6 +18,8 @@ export type RecipientZapPaymentData = {
profile: TProfile | null profile: TProfile | null
profileEvent: Event | null profileEvent: Event | null
alternativeGroups: PaymentMethodGroup[] alternativeGroups: PaymentMethodGroup[]
/** Any payto / Lightning target on kind 0 or 10133 — used to enable zap UI. */
canReceiveTip: boolean
} }
/** Kind 10133 + profile payto targets except the Lightning address used for zapping. */ /** Kind 10133 + profile payto targets except the Lightning address used for zapping. */
@ -59,14 +62,19 @@ export function useRecipientZapPaymentData(
} }
}, [recipientPubkey, enabled]) }, [recipientPubkey, enabled])
const canReceiveTip = useMemo(
() => recipientHasAnyPaymentOptions(paymentInfo, profile, profileEvent),
[paymentInfo, profile, profileEvent]
)
const alternativeGroups = useMemo(() => { const alternativeGroups = useMemo(() => {
if (!recipientPubkey) return [] if (!recipientPubkey) return []
const merged = sortMergedPaymentMethods(mergePaymentMethods(paymentInfo, profile)) const merged = sortMergedPaymentMethods(mergePaymentMethods(paymentInfo, profile, profileEvent))
const alts = getAlternativePaymentMethods(merged) const alts = getAlternativePaymentMethods(merged)
return groupPaymentMethodsByDisplayType(alts) return groupPaymentMethodsByDisplayType(alts)
}, [recipientPubkey, paymentInfo, profile]) }, [recipientPubkey, paymentInfo, profile, profileEvent])
return { paymentInfo, profile, profileEvent, alternativeGroups } return { paymentInfo, profile, profileEvent, alternativeGroups, canReceiveTip }
} }
/** @deprecated Use {@link useRecipientZapPaymentData} */ /** @deprecated Use {@link useRecipientZapPaymentData} */

14
src/index.css

@ -183,6 +183,8 @@
--sidebar-border: 130 18% 82%; --sidebar-border: 130 18% 82%;
--brand-wordmark: 155 32% 22%; --brand-wordmark: 155 32% 22%;
--prose-link: 152 38% 28%; --prose-link: 152 38% 28%;
/* Lighter forest green for inline URIs (http, payto, …); hover uses --primary. */
--uri-link: 154 32% 42%;
--highlight: 152 45% 36%; --highlight: 152 45% 36%;
--chart-1: 12 76% 61%; --chart-1: 12 76% 61%;
--chart-2: 173 58% 39%; --chart-2: 173 58% 39%;
@ -221,6 +223,7 @@
--sidebar-border: 150 14% 20%; --sidebar-border: 150 14% 20%;
--brand-wordmark: 145 28% 91%; --brand-wordmark: 145 28% 91%;
--prose-link: 145 35% 72%; --prose-link: 145 35% 72%;
--uri-link: 145 28% 68%;
--highlight: 150 40% 48%; --highlight: 150 40% 48%;
--chart-1: 220 70% 50%; --chart-1: 220 70% 50%;
--chart-2: 160 60% 45%; --chart-2: 160 60% 45%;
@ -230,12 +233,15 @@
} }
:where(.prose) a { :where(.prose) a {
color: hsl(var(--prose-link)); color: hsl(var(--uri-link));
text-decoration: none;
text-underline-offset: 2px; text-underline-offset: 2px;
transition: color 0.15s ease;
} }
:where(.prose) a:hover { :where(.prose) a:hover {
color: hsl(var(--primary)); color: hsl(var(--primary));
text-decoration: underline;
} }
.dark input[type='datetime-local']::-webkit-calendar-picker-indicator { .dark input[type='datetime-local']::-webkit-calendar-picker-indicator {
@ -475,15 +481,15 @@
} }
.asciidoc-content .footnote a { .asciidoc-content .footnote a {
@apply text-primary hover:underline; @apply text-link-uri no-underline hover:text-primary hover:underline underline-offset-2 transition-colors;
} }
.asciidoc-content .footnote-backref { .asciidoc-content .footnote-backref {
@apply ml-1 text-primary hover:underline; @apply ml-1 text-link-uri no-underline hover:text-primary hover:underline underline-offset-2 transition-colors;
} }
.asciidoc-content .footnoteref { .asciidoc-content .footnoteref {
@apply text-primary hover:underline; @apply text-link-uri no-underline hover:text-primary hover:underline underline-offset-2 transition-colors;
vertical-align: super; vertical-align: super;
font-size: 0.75em; font-size: 0.75em;
text-decoration: none; text-decoration: none;

10
src/lib/lightning.ts

@ -14,6 +14,12 @@ export function formatAmount(amount: number) {
} }
export function getLightningAddressFromProfile(profile: TProfile) { export function getLightningAddressFromProfile(profile: TProfile) {
if (profile.lightningAddress?.trim()) return profile.lightningAddress.trim()
if (profile.lightningAddressList?.length) {
const first = profile.lightningAddressList.find((a) => a?.trim())
if (first) return first.trim()
}
// Some clients have incorrectly filled in the positions for lud06 and lud16 // Some clients have incorrectly filled in the positions for lud06 and lud16
const { lud16: a, lud06: b } = profile const { lud16: a, lud06: b } = profile
let lud16: string | undefined let lud16: string | undefined
@ -22,11 +28,13 @@ export function getLightningAddressFromProfile(profile: TProfile) {
lud16 = a lud16 = a
} else if (b && isEmail(b)) { } else if (b && isEmail(b)) {
lud16 = b lud16 = b
} else if (a?.trim() && a.includes('.')) {
lud16 = a.trim()
} else if (b && b.startsWith('lnurl')) { } else if (b && b.startsWith('lnurl')) {
lud06 = b lud06 = b
} else if (a && a.startsWith('lnurl')) { } else if (a && a.startsWith('lnurl')) {
lud06 = a lud06 = a
} }
return lud16 || lud06 || profile.lightningAddress || undefined return lud16 || lud06 || undefined
} }

14
src/lib/link-styles.ts

@ -0,0 +1,14 @@
/** Hover: forest primary + underline (rest state stays lighter green, no underline). */
const URI_LINK_HOVER =
'hover:text-primary hover:underline underline-offset-2 transition-colors'
/** http(s), payto://, and inline URIs in notes, bios, and previews. */
export const URI_LINK_CLASS =
`text-link-uri no-underline ${URI_LINK_HOVER} break-words`
/** Primary-tinted URIs (profile websites, payment rows using theme primary). */
export const PRIMARY_LINK_HOVER_CLASS =
`text-link-uri no-underline ${URI_LINK_HOVER} break-words`
/** For HTML template literals (asciidoc pipeline, etc.). */
export const URI_LINK_INLINE_HTML_CLASS = `inline ${URI_LINK_CLASS}`

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

@ -1,9 +1,14 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { isLightningPaytoType } from '@/lib/payto-registry' import { isLightningPaytoType, isZappableLightningPaytoType } from '@/lib/payto-registry'
import { getProfileFromEvent } from '@/lib/event-metadata'
import { kinds, type Event } from 'nostr-tools'
import { import {
buildOrderedZapLightningAddresses,
getAlternativePaymentMethods,
prepareZapDialogAlternativePayments, prepareZapDialogAlternativePayments,
mergePaymentMethods, mergePaymentMethods,
normalizeLightningAuthority normalizeLightningAuthority,
recipientHasAnyPaymentOptions
} from './merge-payment-methods' } from './merge-payment-methods'
describe('normalizeLightningAuthority', () => { describe('normalizeLightningAuthority', () => {
@ -49,6 +54,86 @@ describe('isLightningPaytoType', () => {
}) })
}) })
describe('isZappableLightningPaytoType', () => {
it('is LUD-16 lightning only', () => {
expect(isZappableLightningPaytoType('lightning')).toBe(true)
expect(isZappableLightningPaytoType('bip353')).toBe(false)
})
})
describe('getAlternativePaymentMethods', () => {
it('includes BIP-353 in zap dialog other payments', () => {
const merged = mergePaymentMethods(
{
methods: [
{
type: 'lightning',
authority: 'zap@example.com',
payto: 'payto://lightning/zap@example.com',
displayType: 'Lightning Network'
},
{
type: 'bip353',
authority: 'dns@example.com',
payto: 'payto://bip353/dns@example.com',
displayType: 'DNS Payment Instructions (BIP-353)'
}
]
},
null
)
const alts = getAlternativePaymentMethods(merged)
expect(alts.some((m) => m.type === 'bip353')).toBe(true)
expect(alts.some((m) => m.type === 'lightning')).toBe(false)
})
})
describe('buildOrderedZapLightningAddresses', () => {
it('excludes BIP-353 from zap selector (payment options only)', () => {
const addrs = buildOrderedZapLightningAddresses({
profileEvent: null,
paymentInfo: {
methods: [
{
type: 'bip353',
authority: 'user@example.com',
payto: 'payto://bip353/user@example.com',
displayType: 'DNS Payment Instructions (BIP-353)'
},
{
type: 'lightning',
authority: 'zap@example.com',
payto: 'payto://lightning/zap@example.com',
displayType: 'Lightning Network'
}
]
}
})
expect(addrs).toEqual(['zap@example.com'])
})
it('includes lud16 from kind 0 JSON when not in tags', () => {
const profileEvent = {
kind: kinds.Metadata,
pubkey: 'aa'.repeat(32),
created_at: 1,
tags: [] as string[][],
content: JSON.stringify({ lud16: 'user@example.com' }),
id: 'bb'.repeat(64),
sig: 'cc'.repeat(128)
} as Event
const addrs = buildOrderedZapLightningAddresses({
profileEvent,
paymentInfo: null
})
expect(addrs).toEqual(['user@example.com'])
expect(recipientHasAnyPaymentOptions(null, getProfileFromEvent(profileEvent), profileEvent)).toBe(
true
)
})
})
describe('prepareZapDialogAlternativePayments', () => { describe('prepareZapDialogAlternativePayments', () => {
const groups = [ const groups = [
{ {

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

@ -1,10 +1,11 @@
import { getPaymentInfoFromEvent } from '@/lib/event-metadata' import { getPaymentInfoFromEvent, getProfileFromEvent } from '@/lib/event-metadata'
import { import {
buildPaytoUri, buildPaytoUri,
getCanonicalPaytoType, getCanonicalPaytoType,
getPaytoEditorTypeLabel, getPaytoEditorTypeLabel,
getPaytoTypeInfo, getPaytoTypeInfo,
isLightningPaytoType isLightningPaytoType,
isZappableLightningPaytoType
} from '@/lib/payto' } from '@/lib/payto'
import { normalizePaypalAuthority } from '@/lib/payto-paypal-url' import { normalizePaypalAuthority } from '@/lib/payto-paypal-url'
import type { TProfile } from '@/types' import type { TProfile } from '@/types'
@ -132,7 +133,8 @@ export function paytoPaymentSortRank(type: string): number {
/** Merge payment methods from kind 10133 and profile (kind 0: JSON + tags), normalized and deduplicated. */ /** Merge payment methods from kind 10133 and profile (kind 0: JSON + tags), normalized and deduplicated. */
export function mergePaymentMethods( export function mergePaymentMethods(
paymentInfo: ReturnType<typeof getPaymentInfoFromEvent> | null, paymentInfo: ReturnType<typeof getPaymentInfoFromEvent> | null,
profile: TProfile | null profile: TProfile | null,
profileEvent?: Event | null
): MergedPaymentMethod[] { ): MergedPaymentMethod[] {
const seen = new Map<string, MergedPaymentMethod>() const seen = new Map<string, MergedPaymentMethod>()
const out: MergedPaymentMethod[] = [] const out: MergedPaymentMethod[] = []
@ -232,9 +234,27 @@ export function mergePaymentMethods(
add(type, authority, paymentInfo.payto, type === 'lightning' ? 'Lightning Network' : paymentInfo.type || 'Payment') add(type, authority, paymentInfo.payto, type === 'lightning' ? 'Lightning Network' : paymentInfo.type || 'Payment')
} }
if (profileEvent?.kind === kinds.Metadata) {
for (const tag of profileEvent.tags) {
if (tag[0] === 'payto' && tag[1] && tag[2]) {
const type = String(tag[1]).toLowerCase()
add(type, String(tag[2]), buildPaytoUri(getCanonicalPaytoType(type), String(tag[2])))
}
}
}
return out return out
} }
/** True when the recipient has any payto / Lightning target (kind 0 or 10133). */
export function recipientHasAnyPaymentOptions(
paymentInfo: ReturnType<typeof getPaymentInfoFromEvent> | null,
profile: TProfile | null,
profileEvent?: Event | null
): boolean {
return mergePaymentMethods(paymentInfo, profile, profileEvent).length > 0
}
export function sortMergedPaymentMethods(methods: MergedPaymentMethod[]): MergedPaymentMethod[] { export function sortMergedPaymentMethods(methods: MergedPaymentMethod[]): MergedPaymentMethod[] {
return [...methods].sort((a, b) => paytoPaymentSortRank(a.type) - paytoPaymentSortRank(b.type)) return [...methods].sort((a, b) => paytoPaymentSortRank(a.type) - paytoPaymentSortRank(b.type))
} }
@ -277,20 +297,25 @@ export function buildOrderedZapLightningAddresses(opts: {
} }
const ev = opts.profileEvent const ev = opts.profileEvent
const profile = ev?.kind === kinds.Metadata ? getProfileFromEvent(ev) : null
if (ev?.kind === kinds.Metadata) { if (ev?.kind === kinds.Metadata) {
for (const tag of ev.tags) { for (const tag of ev.tags) {
if (tag[0] === 'lud16' && tag[1]) add(tag[1]) if (tag[0] === 'lud16' && tag[1]) add(tag[1])
if (tag[0] === 'lud06' && tag[1]) add(tag[1])
} }
for (const tag of ev.tags) { for (const tag of ev.tags) {
if (tag[0] === 'w' && tag[1] && tag[2] && String(tag[3]).toLowerCase() === 'lightning') { if (tag[0] !== 'w' || !tag[1] || !tag[2]) continue
if (tag[3] && String(tag[3]).toLowerCase() === 'lightning') {
add(tag[2])
} else if (!tag[3] && String(tag[1]).toLowerCase() === 'lightning') {
add(tag[2]) add(tag[2])
} }
} }
} }
const paymentMethods = mergePaymentMethods(opts.paymentInfo, null) const paymentMethods = mergePaymentMethods(opts.paymentInfo, profile, ev)
for (const m of paymentMethods) { for (const m of paymentMethods) {
if (isLightningPaytoType(m.type)) add(m.authority) if (isZappableLightningPaytoType(m.type)) add(m.authority)
} }
return prioritizeZapLightningAddress(out, opts.preferredAddress ?? undefined) return prioritizeZapLightningAddress(out, opts.preferredAddress ?? undefined)
@ -308,7 +333,7 @@ export function prioritizeZapLightningAddress(candidates: string[], preferred?:
return [candidates[idx], ...rest] return [candidates[idx], ...rest]
} }
/** Non-Lightning payto targets for zap dialog “other payment methods” (Lightning has its own selector). */ /** Non-zap payto targets for zap dialog “other payment methods” (LUD-16 uses the Lightning selector). */
export function getAlternativePaymentMethods(methods: MergedPaymentMethod[]): MergedPaymentMethod[] { export function getAlternativePaymentMethods(methods: MergedPaymentMethod[]): MergedPaymentMethod[] {
return methods.filter((m) => !isLightningPaytoType(m.type)) return methods.filter((m) => !isZappableLightningPaytoType(m.type))
} }

7
src/lib/payto-registry.ts

@ -120,8 +120,13 @@ export function getPaytoIconChar(type: string): string | null {
return getPaytoTypeRecord(type)?.symbol ?? null return getPaytoTypeRecord(type)?.symbol ?? null
} }
/** LUD-16 lightning and BIP-353 DNS payment instructions (not on-chain Bitcoin). */ /** LUD-16 / LNURL lightning and BIP-353 DNS instructions — payment UI, not on-chain Bitcoin. */
export function isLightningPaytoType(type: string): boolean { export function isLightningPaytoType(type: string): boolean {
const canonical = getCanonicalPaytoType(type) const canonical = getCanonicalPaytoType(type)
return canonical === 'lightning' || canonical === 'bip353' return canonical === 'lightning' || canonical === 'bip353'
} }
/** Lightning targets that support zaps (LUD-16 / LNURL only; BIP-353 is pay/copy, not zappable). */
export function isZappableLightningPaytoType(type: string): boolean {
return getCanonicalPaytoType(type) === 'lightning'
}

1
src/lib/payto.ts

@ -16,6 +16,7 @@ export {
getPaytoTypeInfo, getPaytoTypeInfo,
isKnownPaytoType, isKnownPaytoType,
isLightningPaytoType, isLightningPaytoType,
isZappableLightningPaytoType,
isPaytoEditorCustomType, isPaytoEditorCustomType,
paytoEditorSelectTypes, paytoEditorSelectTypes,
PAYTO_EDITOR_OTHER_OPTION, PAYTO_EDITOR_OTHER_OPTION,

6
src/pages/primary/CalendarPrimaryPage.tsx

@ -510,7 +510,7 @@ const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(funct
{excess > 0 ? ( {excess > 0 ? (
<button <button
type="button" type="button"
className="mt-0.5 w-full shrink-0 rounded-md py-1 text-center text-[11px] font-semibold text-primary underline-offset-2 hover:bg-muted/50 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="mt-0.5 w-full shrink-0 rounded-md py-1 text-center text-[11px] font-semibold text-primary underline-offset-2 hover:bg-muted/50 hover:text-foreground hover:underline transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={t('calendarPageMoreEventsAria', { count: excess })} aria-label={t('calendarPageMoreEventsAria', { count: excess })}
onClick={() => openDayEventsPanel(day)} onClick={() => openDayEventsPanel(day)}
> >
@ -566,7 +566,7 @@ const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(funct
onClick={() => navigateToNote(toNote(ev), ev)} onClick={() => navigateToNote(toNote(ev), ev)}
className={cn( className={cn(
'flex w-full min-w-0 items-center gap-0.5 rounded px-0.5 py-px text-left text-[9px] font-medium leading-tight text-primary underline-offset-2', 'flex w-full min-w-0 items-center gap-0.5 rounded px-0.5 py-px text-left text-[9px] font-medium leading-tight text-primary underline-offset-2',
'hover:bg-muted/80 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring md:text-[10px]' 'hover:bg-muted/80 hover:text-foreground hover:underline transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring md:text-[10px]'
)} )}
title={title} title={title}
> >
@ -585,7 +585,7 @@ const CalendarPrimaryPage = forwardRef<TPageRef, CalendarPrimaryPageProps>(funct
{excess > 0 ? ( {excess > 0 ? (
<button <button
type="button" type="button"
className="mt-0.5 w-full shrink-0 rounded py-0.5 text-center text-[9px] font-semibold text-primary underline-offset-2 hover:bg-muted/50 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring md:text-[10px]" className="mt-0.5 w-full shrink-0 rounded py-0.5 text-center text-[9px] font-semibold text-primary underline-offset-2 hover:bg-muted/50 hover:text-foreground hover:underline transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring md:text-[10px]"
aria-label={t('calendarPageMoreEventsAria', { count: excess })} aria-label={t('calendarPageMoreEventsAria', { count: excess })}
onClick={() => openDayEventsPanel(day)} onClick={() => openDayEventsPanel(day)}
> >

4
src/pages/secondary/PersonalListsSettingsPage/index.tsx

@ -184,7 +184,7 @@ const PersonalListsSettingsPage = forwardRef(
{t('Personal lists bookmarks spell hint')}{' '} {t('Personal lists bookmarks spell hint')}{' '}
<button <button
type="button" type="button"
className="text-primary underline-offset-4 hover:underline" className="text-primary underline-offset-4 hover:text-foreground hover:underline transition-colors"
onClick={() => navigatePrimary('spells', { spell: 'bookmarks' })} onClick={() => navigatePrimary('spells', { spell: 'bookmarks' })}
> >
{t('Bookmarks spell')} {t('Bookmarks spell')}
@ -197,7 +197,7 @@ const PersonalListsSettingsPage = forwardRef(
{t('Personal lists interests spell hint')}{' '} {t('Personal lists interests spell hint')}{' '}
<button <button
type="button" type="button"
className="text-primary underline-offset-4 hover:underline" className="text-primary underline-offset-4 hover:text-foreground hover:underline transition-colors"
onClick={() => navigatePrimary('spells', { spell: 'interests' })} onClick={() => navigatePrimary('spells', { spell: 'interests' })}
> >
{t('Interests spell')} {t('Interests spell')}

2
src/pages/secondary/PostSettingsPage/BlossomServerListSetting.tsx

@ -134,7 +134,7 @@ export default function BlossomServerListSetting() {
href={url} href={url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="truncate hover:underline" className="truncate hover:text-foreground hover:underline underline-offset-2 transition-colors"
> >
{url} {url}
</a> </a>

6
src/services/content-parser.service.ts

@ -563,12 +563,12 @@ class ContentParserService {
// Convert hashtag links to HTML with green styling // Convert hashtag links to HTML with green styling
processed = processed.replace(/hashtag:([^[]+)\[([^\]]+)\]/g, (_match, normalizedHashtag, displayText) => { processed = processed.replace(/hashtag:([^[]+)\[([^\]]+)\]/g, (_match, normalizedHashtag, displayText) => {
return `<a href="/notes?t=${normalizedHashtag}" class="hashtag-link text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline">${displayText}</a>` return `<a href="/notes?t=${normalizedHashtag}" class="hashtag-link text-link-uri no-underline hover:text-primary hover:underline underline-offset-2 transition-colors">${displayText}</a>`
}) })
// Convert wikilink:dtag[display] format to HTML with data attributes // Convert wikilink:dtag[display] format to HTML with data attributes
processed = processed.replace(/wikilink:([^[]+)\[([^\]]+)\]/g, (_match, dTag, displayText) => { processed = processed.replace(/wikilink:([^[]+)\[([^\]]+)\]/g, (_match, dTag, displayText) => {
return `<span class="wikilink cursor-pointer text-blue-600 hover:text-blue-800 hover:underline border-b border-dotted border-blue-300" data-dtag="${dTag}" data-display="${displayText}">${displayText}</span>` return `<span class="wikilink cursor-pointer text-blue-600 hover:text-foreground hover:underline underline-offset-2 transition-colors border-b border-dotted border-blue-300" data-dtag="${dTag}" data-display="${displayText}">${displayText}</span>`
}) })
// Convert nostr: links to proper embedded components // Convert nostr: links to proper embedded components
@ -583,7 +583,7 @@ class ContentParserService {
return `<span class="user-handle" data-pubkey="${bech32Id}">@${displayText}</span>` return `<span class="user-handle" data-pubkey="${bech32Id}">@${displayText}</span>`
} else { } else {
// Fallback to regular link // Fallback to regular link
return `<a href="nostr:${bech32Id}" class="nostr-link text-blue-600 hover:text-blue-800 hover:underline" data-nostr-type="${nostrType}" data-bech32="${bech32Id}">${displayText}</a>` return `<a href="nostr:${bech32Id}" class="nostr-link text-blue-600 hover:text-foreground hover:underline underline-offset-2 transition-colors" data-nostr-type="${nostrType}" data-bech32="${bech32Id}">${displayText}</a>`
} }
}) })

5
tailwind.config.js

@ -64,7 +64,10 @@ export default {
4: 'hsl(var(--chart-4))', 4: 'hsl(var(--chart-4))',
5: 'hsl(var(--chart-5))' 5: 'hsl(var(--chart-5))'
}, },
highlight: 'hsl(var(--highlight))' highlight: 'hsl(var(--highlight))',
link: {
uri: 'hsl(var(--uri-link))'
}
} }
} }
}, },

Loading…
Cancel
Save