Browse Source

more article stuff:

everything is Asciidoc
wikilinks added
imwald
Silberengel 5 months ago
parent
commit
dd3b6c324b
  1. 166
      package-lock.json
  2. 3
      package.json
  3. 267
      src/components/Note/Article/index.tsx
  4. 85
      src/components/UniversalContent/HighlightSourcePreview.tsx
  5. 95
      src/components/UniversalContent/ParsedContent.tsx
  6. 132
      src/components/UniversalContent/TableOfContents.tsx
  7. 60
      src/components/UniversalContent/Wikilink.tsx
  8. 47
      src/components/UniversalContent/WikilinkProcessor.tsx
  9. 9
      src/components/ui/collapsible.tsx
  10. 332
      src/services/content-parser.service.ts

166
package-lock.json generated

@ -19,6 +19,7 @@ @@ -19,6 +19,7 @@
"@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-hover-card": "^1.1.4",
@ -2601,6 +2602,171 @@ @@ -2601,6 +2602,171 @@
}
}
},
"node_modules/@radix-ui/react-collapsible": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz",
"integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-id": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-presence": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collection": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.1.tgz",

3
package.json

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
{
"name": "jumble-imwald",
"version": "10.15",
"version": "11.0",
"description": "A user-friendly Nostr client focused on relay feed browsing and relay discovery, forked from Jumble",
"private": true,
"type": "module",
@ -29,6 +29,7 @@ @@ -29,6 +29,7 @@
"@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-hover-card": "^1.1.4",

267
src/components/Note/Article/index.tsx

@ -1,21 +1,16 @@ @@ -1,21 +1,16 @@
import { useSecondaryPage } from '@/PageManager'
import ImageWithLightbox from '@/components/ImageWithLightbox'
import ImageGallery from '@/components/ImageGallery'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNoteList } from '@/lib/link'
import { ExternalLink } from 'lucide-react'
import { ChevronDown, ChevronRight } from 'lucide-react'
import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import rehypeKatex from 'rehype-katex'
import NostrNode from '../LongFormArticle/NostrNode'
import { remarkNostr } from '../LongFormArticle/remarkNostr'
import { Components } from '../LongFormArticle/types'
import { useMemo, useState, useEffect, useRef } from 'react'
import { useEventFieldParser } from '@/hooks/useContentParser'
import WebPreview from '../../WebPreview'
import 'katex/dist/katex.min.css'
import HighlightSourcePreview from '../../UniversalContent/HighlightSourcePreview'
import TableOfContents from '../../UniversalContent/TableOfContents'
import { Button } from '@/components/ui/button'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
export default function Article({
event,
@ -26,6 +21,7 @@ export default function Article({ @@ -26,6 +21,7 @@ export default function Article({
}) {
const { push } = useSecondaryPage()
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
const [isInfoOpen, setIsInfoOpen] = useState(false)
// Use the comprehensive content parser
const { parsedContent, isLoading, error } = useEventFieldParser(event, 'content', {
@ -33,48 +29,70 @@ export default function Article({ @@ -33,48 +29,70 @@ export default function Article({
enableSyntaxHighlighting: true
})
const components = useMemo(
() =>
({
nostr: ({ rawText, bech32Id }) => <NostrNode rawText={rawText} bech32Id={bech32Id} />,
a: ({ href, children, ...props }) => {
if (href?.startsWith('nostr:')) {
return <NostrNode rawText={href} bech32Id={href.slice(6)} />
const contentRef = useRef<HTMLDivElement>(null)
// Handle wikilink clicks
useEffect(() => {
if (!contentRef.current) return
const handleWikilinkClick = (event: MouseEvent) => {
const target = event.target as HTMLElement
if (target.classList.contains('wikilink')) {
event.preventDefault()
const dTag = target.getAttribute('data-dtag')
const displayText = target.getAttribute('data-display')
if (dTag && displayText) {
// Create a simple dropdown menu
const existingDropdown = document.querySelector('.wikilink-dropdown')
if (existingDropdown) {
existingDropdown.remove()
}
return (
<a
href={href}
target="_blank"
rel="noreferrer noopener"
className="break-words inline-flex items-baseline gap-1"
{...props}
>
{children}
<ExternalLink className="size-3" />
</a>
)
},
p: (props) => {
// Check if paragraph contains only an image
if (props.children && typeof props.children === 'string' && props.children.match(/^!\[.*\]\(.*\)$/)) {
return <div {...props} />
const dropdown = document.createElement('div')
dropdown.className = 'wikilink-dropdown fixed bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg z-50 p-2'
dropdown.style.left = `${event.pageX}px`
dropdown.style.top = `${event.pageY + 10}px`
const wikistrButton = document.createElement('button')
wikistrButton.className = 'w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2'
wikistrButton.innerHTML = '<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>View on Wikistr'
wikistrButton.onclick = () => {
window.open(`https://wikistr.imwald.eu/${dTag}`, '_blank', 'noopener,noreferrer')
dropdown.remove()
}
return <p {...props} className="break-words" />
},
div: (props) => <div {...props} className="break-words" />,
code: (props) => <code {...props} className="break-words whitespace-pre-wrap" />,
img: (props) => (
<ImageWithLightbox
image={{ url: props.src || '', pubkey: event.pubkey }}
className="max-h-[80vh] sm:max-h-[50vh] object-contain my-0 max-w-[400px]"
classNames={{
wrapper: 'w-fit max-w-[400px]'
}}
/>
)
}) as Components,
[event.pubkey]
)
const alexandriaButton = document.createElement('button')
alexandriaButton.className = 'w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2'
alexandriaButton.innerHTML = '<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>View on Alexandria'
alexandriaButton.onclick = () => {
window.open(`https://next-alexandria.gitcitadel.eu/events?d=${dTag}`, '_blank', 'noopener,noreferrer')
dropdown.remove()
}
dropdown.appendChild(wikistrButton)
dropdown.appendChild(alexandriaButton)
document.body.appendChild(dropdown)
// Close dropdown when clicking outside
const closeDropdown = (e: MouseEvent) => {
if (!dropdown.contains(e.target as Node)) {
dropdown.remove()
document.removeEventListener('click', closeDropdown)
}
}
setTimeout(() => document.addEventListener('click', closeDropdown), 0)
}
}
}
contentRef.current.addEventListener('click', handleWikilinkClick)
return () => {
contentRef.current?.removeEventListener('click', handleWikilinkClick)
}
}, [parsedContent])
if (isLoading) {
return (
@ -117,26 +135,14 @@ export default function Article({ @@ -117,26 +135,14 @@ export default function Article({
)}
{/* Render content based on markup type */}
{parsedContent.markupType === 'asciidoc' ? (
// AsciiDoc content (already processed to HTML)
<div dangerouslySetInnerHTML={{ __html: parsedContent.html }} />
) : (
// Markdown content (let react-markdown handle it)
<Markdown
remarkPlugins={[remarkGfm, remarkMath, remarkNostr]}
rehypePlugins={[rehypeKatex]}
urlTransform={(url) => {
if (url.startsWith('nostr:')) {
return url.slice(6) // Remove 'nostr:' prefix for rendering
}
return url
}}
components={components}
>
{event.content}
</Markdown>
)}
{/* Table of Contents */}
<TableOfContents
content={parsedContent.html}
className="mb-6"
/>
{/* Render AsciiDoc content (everything is now processed as AsciiDoc) */}
<div ref={contentRef} dangerouslySetInnerHTML={{ __html: parsedContent.html }} />
{/* Hashtags */}
{parsedContent.hashtags.length > 0 && (
@ -157,55 +163,84 @@ export default function Article({ @@ -157,55 +163,84 @@ export default function Article({
</div>
)}
{/* Media thumbnails */}
{parsedContent.media.length > 0 && (
<div className="mt-4 p-4 bg-muted rounded-lg">
<h4 className="text-sm font-semibold mb-3">Images in this article:</h4>
<div className="grid grid-cols-8 sm:grid-cols-12 md:grid-cols-16 gap-1">
{parsedContent.media.map((media, index) => (
<div key={index} className="aspect-square">
<ImageWithLightbox
image={media}
className="w-full h-full object-cover rounded cursor-pointer hover:opacity-80 transition-opacity"
classNames={{
wrapper: 'w-full h-full'
}}
/>
{/* Collapsible Article Info */}
{(parsedContent.media.length > 0 || parsedContent.links.length > 0 || parsedContent.nostrLinks.length > 0 || parsedContent.highlightSources.length > 0) && (
<Collapsible open={isInfoOpen} onOpenChange={setIsInfoOpen} className="mt-4">
<CollapsibleTrigger asChild>
<Button variant="outline" className="w-full justify-between">
<span>Article Info</span>
{isInfoOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 mt-2">
{/* Media thumbnails */}
{parsedContent.media.length > 0 && (
<div className="p-4 bg-muted rounded-lg">
<h4 className="text-sm font-semibold mb-3">Images in this article:</h4>
<div className="grid grid-cols-8 sm:grid-cols-12 md:grid-cols-16 gap-1">
{parsedContent.media.map((media, index) => (
<div key={index} className="aspect-square">
<ImageWithLightbox
image={media}
className="w-full h-full object-cover rounded cursor-pointer hover:opacity-80 transition-opacity"
classNames={{
wrapper: 'w-full h-full'
}}
/>
</div>
))}
</div>
</div>
))}
</div>
</div>
)}
)}
{/* Links summary with OpenGraph previews */}
{parsedContent.links.length > 0 && (
<div className="mt-4 p-4 bg-muted rounded-lg">
<h4 className="text-sm font-semibold mb-3">Links in this article:</h4>
<div className="space-y-3">
{parsedContent.links.map((link, index) => (
<WebPreview
key={index}
url={link.url}
className="w-full"
/>
))}
</div>
</div>
)}
{/* Links summary with OpenGraph previews */}
{parsedContent.links.length > 0 && (
<div className="p-4 bg-muted rounded-lg">
<h4 className="text-sm font-semibold mb-3">Links in this article:</h4>
<div className="space-y-3">
{parsedContent.links.map((link, index) => (
<WebPreview
key={index}
url={link.url}
className="w-full"
/>
))}
</div>
</div>
)}
{/* Nostr links summary */}
{parsedContent.nostrLinks.length > 0 && (
<div className="mt-4 p-4 bg-muted rounded-lg">
<h4 className="text-sm font-semibold mb-2">Nostr references:</h4>
<div className="space-y-1">
{parsedContent.nostrLinks.map((link, index) => (
<div key={index} className="text-sm">
<span className="font-mono text-blue-600">{link.type}:</span>{' '}
<span className="font-mono">{link.id}</span>
{/* Nostr links summary */}
{parsedContent.nostrLinks.length > 0 && (
<div className="p-4 bg-muted rounded-lg">
<h4 className="text-sm font-semibold mb-2">Nostr references:</h4>
<div className="space-y-1">
{parsedContent.nostrLinks.map((link, index) => (
<div key={index} className="text-sm">
<span className="font-mono text-blue-600">{link.type}:</span>{' '}
<span className="font-mono">{link.id}</span>
</div>
))}
</div>
</div>
))}
</div>
</div>
)}
{/* Highlight sources */}
{parsedContent.highlightSources.length > 0 && (
<div className="p-4 bg-muted rounded-lg">
<h4 className="text-sm font-semibold mb-3">Highlight sources:</h4>
<div className="space-y-3">
{parsedContent.highlightSources.map((source, index) => (
<HighlightSourcePreview
key={index}
source={source}
className="w-full"
/>
))}
</div>
</div>
)}
</CollapsibleContent>
</Collapsible>
)}
</div>
)

85
src/components/UniversalContent/HighlightSourcePreview.tsx

@ -0,0 +1,85 @@ @@ -0,0 +1,85 @@
/**
* Component to display highlight sources (e/a tags or URLs) as embedded events or OpenGraph previews
*/
import { useMemo } from 'react'
import { nip19 } from 'nostr-tools'
import WebPreview from '../WebPreview'
import { EmbeddedNote } from '../Embedded/EmbeddedNote'
import { ExternalLink } from 'lucide-react'
interface HighlightSourcePreviewProps {
source: {
type: 'event' | 'addressable' | 'url'
value: string
bech32: string
}
className?: string
}
export default function HighlightSourcePreview({ source, className }: HighlightSourcePreviewProps) {
const alexandriaUrl = useMemo(() => {
if (source.type === 'url') {
return source.value
}
return `https://next-alexandria.gitcitadel.eu/events?id=${source.bech32}`
}, [source])
if (source.type === 'event') {
// For events, try to decode and show as embedded note
try {
const decoded = nip19.decode(source.bech32)
if (decoded.type === 'nevent' || decoded.type === 'note') {
return (
<div className={className}>
<EmbeddedNote noteId={source.value} className="w-full" />
</div>
)
}
} catch (error) {
console.warn('Failed to decode nostr event:', error)
}
}
if (source.type === 'addressable') {
// For addressable events, try to decode and show as embedded note
try {
const decoded = nip19.decode(source.bech32)
if (decoded.type === 'naddr') {
return (
<div className={className}>
<EmbeddedNote noteId={source.bech32} className="w-full" />
</div>
)
}
} catch (error) {
console.warn('Failed to decode nostr addressable event:', error)
}
}
// Fallback: show as Alexandria link or WebPreview for URLs
if (source.type === 'url') {
return (
<div className={className}>
<WebPreview url={source.value} className="w-full" />
</div>
)
}
// For nostr events that couldn't be embedded, show as Alexandria link
return (
<div className={`p-3 border rounded-lg bg-muted/50 ${className}`}>
<a
href={alexandriaUrl}
target="_blank"
rel="noreferrer noopener"
className="flex items-center gap-2 text-blue-600 hover:text-blue-800 break-words"
>
<span className="font-mono text-sm">
{source.type === 'event' ? 'nevent' : 'naddr'}: {source.value.slice(0, 20)}...
</span>
<ExternalLink className="w-3 h-3 flex-shrink-0" />
</a>
</div>
)
}

95
src/components/UniversalContent/ParsedContent.tsx

@ -5,18 +5,9 @@ @@ -5,18 +5,9 @@
import { useEventFieldParser } from '@/hooks/useContentParser'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import rehypeKatex from 'rehype-katex'
import { Components } from '../Note/LongFormArticle/types'
import NostrNode from '../Note/LongFormArticle/NostrNode'
import ImageWithLightbox from '../ImageWithLightbox'
import ImageGallery from '../ImageGallery'
import { ExternalLink } from 'lucide-react'
import WebPreview from '../WebPreview'
import 'katex/dist/katex.min.css'
import HighlightSourcePreview from './HighlightSourcePreview'
interface ParsedContentProps {
event: Event
@ -28,7 +19,7 @@ interface ParsedContentProps { @@ -28,7 +19,7 @@ interface ParsedContentProps {
showLinks?: boolean
showHashtags?: boolean
showNostrLinks?: boolean
maxImageWidth?: string
showHighlightSources?: boolean
}
export default function ParsedContent({
@ -41,55 +32,13 @@ export default function ParsedContent({ @@ -41,55 +32,13 @@ export default function ParsedContent({
showLinks = false,
showHashtags = false,
showNostrLinks = false,
maxImageWidth = '400px'
showHighlightSources = false,
}: ParsedContentProps) {
const { parsedContent, isLoading, error } = useEventFieldParser(event, field, {
enableMath,
enableSyntaxHighlighting
})
const components = useMemo(
() =>
({
nostr: ({ rawText, bech32Id }) => <NostrNode rawText={rawText} bech32Id={bech32Id} />,
a: ({ href, children, ...props }) => {
if (href?.startsWith('nostr:')) {
return <NostrNode rawText={href} bech32Id={href.slice(6)} />
}
return (
<a
href={href}
target="_blank"
rel="noreferrer noopener"
className="break-words inline-flex items-baseline gap-1"
{...props}
>
{children}
<ExternalLink className="size-3" />
</a>
)
},
p: (props) => {
// Check if paragraph contains only an image
if (props.children && typeof props.children === 'string' && props.children.match(/^!\[.*\]\(.*\)$/)) {
return <div {...props} />
}
return <p {...props} className="break-words" />
},
div: (props) => <div {...props} className="break-words" />,
code: (props) => <code {...props} className="break-words whitespace-pre-wrap" />,
img: (props) => (
<ImageWithLightbox
image={{ url: props.src || '', pubkey: event.pubkey }}
className={`max-h-[80vh] sm:max-h-[50vh] object-contain my-0`}
classNames={{
wrapper: 'w-fit'
}}
/>
)
}) as Components,
[event.pubkey, maxImageWidth]
)
if (isLoading) {
return (
@ -118,26 +67,8 @@ export default function ParsedContent({ @@ -118,26 +67,8 @@ export default function ParsedContent({
return (
<div className={`${parsedContent.cssClasses} ${className}`}>
{/* Render content based on markup type */}
{parsedContent.markupType === 'asciidoc' ? (
// AsciiDoc content (already processed to HTML)
<div dangerouslySetInnerHTML={{ __html: parsedContent.html }} />
) : (
// Markdown content (let react-markdown handle it)
<Markdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
urlTransform={(url) => {
if (url.startsWith('nostr:')) {
return url.slice(6) // Remove 'nostr:' prefix for rendering
}
return url
}}
components={components}
>
{field === 'content' ? event.content : event.tags?.find(tag => tag[0] === field)?.[1] || ''}
</Markdown>
)}
{/* Render AsciiDoc content (everything is now processed as AsciiDoc) */}
<div dangerouslySetInnerHTML={{ __html: parsedContent.html }} />
{/* Media thumbnails */}
{showMedia && parsedContent.media.length > 0 && (
@ -204,6 +135,22 @@ export default function ParsedContent({ @@ -204,6 +135,22 @@ export default function ParsedContent({
</div>
</div>
)}
{/* Highlight sources */}
{showHighlightSources && parsedContent.highlightSources.length > 0 && (
<div className="mt-4 p-4 bg-muted rounded-lg">
<h4 className="text-sm font-semibold mb-3">Highlight sources:</h4>
<div className="space-y-3">
{parsedContent.highlightSources.map((source, index) => (
<HighlightSourcePreview
key={index}
source={source}
className="w-full"
/>
))}
</div>
</div>
)}
</div>
)
}

132
src/components/UniversalContent/TableOfContents.tsx

@ -0,0 +1,132 @@ @@ -0,0 +1,132 @@
/**
* Compact Table of Contents component for articles
*/
import { useEffect, useState } from 'react'
import { ChevronDown, ChevronRight, Hash } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
interface TocItem {
id: string
text: string
level: number
}
interface TableOfContentsProps {
content: string
className?: string
}
export default function TableOfContents({ content, className }: TableOfContentsProps) {
const [isOpen, setIsOpen] = useState(false)
const [tocItems, setTocItems] = useState<TocItem[]>([])
useEffect(() => {
// Parse content for headings
const parser = new DOMParser()
const doc = parser.parseFromString(content, 'text/html')
const headings = doc.querySelectorAll('h1, h2, h3, h4, h5, h6')
const items: TocItem[] = []
headings.forEach((heading, index) => {
const level = parseInt(heading.tagName.charAt(1))
const text = heading.textContent?.trim() || ''
if (text) {
// Use existing ID if available, otherwise generate one
const existingId = heading.id
const id = existingId || `heading-${index}-${text.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`
items.push({
id,
text,
level
})
}
})
setTocItems(items)
}, [content])
if (tocItems.length === 0) {
return null
}
const scrollToHeading = (item: TocItem) => {
// Try to find the element in the actual DOM
const element = document.getElementById(item.id)
if (element) {
element.scrollIntoView({
behavior: 'smooth',
block: 'start',
inline: 'nearest'
})
setIsOpen(false)
} else {
// Fallback: try to find by text content
const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6')
for (const heading of headings) {
if (heading.textContent?.trim() === item.text) {
heading.scrollIntoView({
behavior: 'smooth',
block: 'start',
inline: 'nearest'
})
setIsOpen(false)
break
}
}
}
}
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen} className={className}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="sm"
className="w-full justify-between h-8 px-2 text-xs text-muted-foreground hover:text-foreground"
>
<div className="flex items-center gap-1">
<Hash className="h-3 w-3" />
<span>Table of Contents ({tocItems.length})</span>
</div>
{isOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-1">
<div className="bg-muted/30 rounded-md p-2 text-xs">
<div className="space-y-1 max-h-48 overflow-y-auto">
{tocItems.map((item) => (
<button
key={item.id}
onClick={() => scrollToHeading(item)}
className={`block w-full text-left hover:text-foreground transition-colors ${
item.level === 1 ? 'font-medium' : ''
} ${
item.level === 2 ? 'ml-2' : ''
} ${
item.level === 3 ? 'ml-4' : ''
} ${
item.level === 4 ? 'ml-6' : ''
} ${
item.level === 5 ? 'ml-8' : ''
} ${
item.level === 6 ? 'ml-10' : ''
}`}
style={{
fontSize: `${Math.max(0.75, 0.9 - (item.level - 1) * 0.05)}rem`,
lineHeight: '1.2'
}}
>
{item.text}
</button>
))}
</div>
</div>
</CollapsibleContent>
</Collapsible>
)
}

60
src/components/UniversalContent/Wikilink.tsx

@ -0,0 +1,60 @@ @@ -0,0 +1,60 @@
import { useState } from 'react'
import { ExternalLink, ChevronDown, ChevronRight } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
interface WikilinkProps {
dTag: string
displayText: string
className?: string
}
export default function Wikilink({ dTag, displayText, className }: WikilinkProps) {
const [isOpen, setIsOpen] = useState(false)
const handleWikistrClick = () => {
const url = `https://wikistr.imwald.eu/${dTag}`
window.open(url, '_blank', 'noopener,noreferrer')
}
const handleAlexandriaClick = () => {
const url = `https://next-alexandria.gitcitadel.eu/events?d=${dTag}`
window.open(url, '_blank', 'noopener,noreferrer')
}
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen} className={className}>
<CollapsibleTrigger asChild>
<Button
variant="link"
className="p-0 h-auto text-blue-600 hover:text-blue-800 hover:underline inline-flex items-center gap-1"
>
<span>{displayText}</span>
{isOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-1">
<div className="bg-muted/30 rounded-md p-2 text-xs space-y-1">
<Button
variant="ghost"
size="sm"
className="w-full justify-start text-xs h-6"
onClick={handleWikistrClick}
>
<ExternalLink className="h-3 w-3 mr-1" />
View on Wikistr
</Button>
<Button
variant="ghost"
size="sm"
className="w-full justify-start text-xs h-6"
onClick={handleAlexandriaClick}
>
<ExternalLink className="h-3 w-3 mr-1" />
View on Alexandria
</Button>
</div>
</CollapsibleContent>
</Collapsible>
)
}

47
src/components/UniversalContent/WikilinkProcessor.tsx

@ -0,0 +1,47 @@ @@ -0,0 +1,47 @@
import { useEffect, useRef } from 'react'
import Wikilink from './Wikilink'
interface WikilinkProcessorProps {
htmlContent: string
className?: string
}
export default function WikilinkProcessor({ htmlContent, className }: WikilinkProcessorProps) {
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!containerRef.current) return
// Find all wikilink spans and replace them with Wikilink components
const wikilinkSpans = containerRef.current.querySelectorAll('span.wikilink')
wikilinkSpans.forEach((span) => {
const dTag = span.getAttribute('data-dtag')
const displayText = span.getAttribute('data-display')
if (dTag && displayText) {
// Create a container for the Wikilink component
const container = document.createElement('div')
container.className = 'inline-block'
// Replace the span with the container
span.parentNode?.replaceChild(container, span)
// Render the Wikilink component into the container
// We'll use React's createRoot for this
import('react-dom/client').then(({ createRoot }) => {
const root = createRoot(container)
root.render(<Wikilink dTag={dTag} displayText={displayText} />)
})
}
})
}, [htmlContent])
return (
<div
ref={containerRef}
dangerouslySetInnerHTML={{ __html: htmlContent }}
className={className}
/>
)
}

9
src/components/ui/collapsible.tsx

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible'
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

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

@ -4,7 +4,7 @@ @@ -4,7 +4,7 @@
*/
import { detectMarkupType, getMarkupClasses, MarkupType } from '@/lib/markup-detection'
import { Event } from 'nostr-tools'
import { Event, nip19 } from 'nostr-tools'
import { getImetaInfosFromEvent } from '@/lib/event'
import { URL_REGEX } from '@/constants'
import { TImetaInfo } from '@/types'
@ -18,6 +18,7 @@ export interface ParsedContent { @@ -18,6 +18,7 @@ export interface ParsedContent {
links: Array<{ url: string; text: string; isExternal: boolean }>
hashtags: string[]
nostrLinks: Array<{ type: 'npub' | 'nprofile' | 'nevent' | 'naddr' | 'note'; id: string; text: string }>
highlightSources: Array<{ type: 'event' | 'addressable' | 'url'; value: string; bech32: string }>
}
export interface ParseOptions {
@ -68,54 +69,38 @@ class ContentParserService { @@ -68,54 +69,38 @@ class ContentParserService {
const cssClasses = getMarkupClasses(markupType)
// Extract all content elements
const media = this.extractAllMedia(content, event)
const links = this.extractLinks(content)
const hashtags = this.extractHashtags(content)
const nostrLinks = this.extractNostrLinks(content)
const media = this.extractAllMedia(content, event)
const links = this.extractLinks(content)
const hashtags = this.extractHashtags(content)
const nostrLinks = this.extractNostrLinks(content)
const highlightSources = event ? this.extractHighlightSources(event) : []
// Check for LaTeX math
const hasMath = enableMath && this.hasMathContent(content)
let html = ''
let processedContent = content
try {
switch (markupType) {
case 'asciidoc':
html = await this.parseAsciidoc(content, { enableMath, enableSyntaxHighlighting })
break
case 'advanced-markdown':
processedContent = this.preprocessAdvancedMarkdown(content)
html = await this.parseAdvancedMarkdown(processedContent, { enableMath, enableSyntaxHighlighting })
break
case 'basic-markdown':
processedContent = this.preprocessBasicMarkdown(content)
html = await this.parseBasicMarkdown(processedContent)
break
case 'plain-text':
default:
html = this.parsePlainText(content)
break
}
// Convert everything to AsciiDoc format and process as AsciiDoc
const asciidocContent = this.convertToAsciidoc(content, markupType)
html = await this.parseAsciidoc(asciidocContent, { enableMath, enableSyntaxHighlighting })
} catch (error) {
console.error('Content parsing error:', error)
// Fallback to plain text
html = this.parsePlainText(content)
}
return {
html,
markupType,
cssClasses,
hasMath,
media,
links,
hashtags,
nostrLinks
}
return {
html,
markupType: 'asciidoc',
cssClasses,
hasMath,
media,
links,
hashtags,
nostrLinks,
highlightSources
}
}
/**
@ -143,8 +128,11 @@ class ContentParserService { @@ -143,8 +128,11 @@ class ContentParserService {
const htmlString = typeof result === 'string' ? result : result.toString()
// Process wikilinks in the HTML output
const processedHtml = this.processWikilinksInHtml(htmlString)
// Clean up any leftover markdown syntax
return this.cleanupMarkdown(htmlString)
return this.cleanupMarkdown(processedHtml)
} catch (error) {
console.error('AsciiDoc parsing error:', error)
return this.parsePlainText(content)
@ -152,141 +140,149 @@ class ContentParserService { @@ -152,141 +140,149 @@ class ContentParserService {
}
/**
* Parse advanced Markdown content
* Convert content to AsciiDoc format based on markup type
*/
private async parseAdvancedMarkdown(content: string, _options: { enableMath: boolean; enableSyntaxHighlighting: boolean }): Promise<string> {
// This will be handled by react-markdown with plugins
// Return the processed content for react-markdown to handle
return content
private convertToAsciidoc(content: string, markupType: string): string {
let asciidoc = ''
switch (markupType) {
case 'asciidoc':
asciidoc = content
break
case 'advanced-markdown':
case 'basic-markdown':
asciidoc = this.convertMarkdownToAsciidoc(content)
break
case 'plain-text':
default:
asciidoc = this.convertPlainTextToAsciidoc(content)
break
}
// Process wikilinks for all content types
return this.processWikilinks(asciidoc)
}
/**
* Parse basic Markdown content
* Convert Markdown to AsciiDoc format
*/
private parseBasicMarkdown(content: string): string {
// Basic markdown processing
let processed = content
private convertMarkdownToAsciidoc(content: string): string {
let asciidoc = content
// Convert headers
asciidoc = asciidoc.replace(/^#{6}\s+(.+)$/gm, '====== $1 ======')
asciidoc = asciidoc.replace(/^#{5}\s+(.+)$/gm, '===== $1 =====')
asciidoc = asciidoc.replace(/^#{4}\s+(.+)$/gm, '==== $1 ====')
asciidoc = asciidoc.replace(/^#{3}\s+(.+)$/gm, '=== $1 ===')
asciidoc = asciidoc.replace(/^#{2}\s+(.+)$/gm, '== $1 ==')
asciidoc = asciidoc.replace(/^#{1}\s+(.+)$/gm, '= $1 =')
// Convert emphasis
asciidoc = asciidoc.replace(/\*\*(.+?)\*\*/g, '*$1*') // Bold
asciidoc = asciidoc.replace(/\*(.+?)\*/g, '_$1_') // Italic
asciidoc = asciidoc.replace(/~~(.+?)~~/g, '[line-through]#$1#') // Strikethrough
// Convert code
asciidoc = asciidoc.replace(/```(\w+)?\n([\s\S]*?)```/g, (_match, lang, code) => {
return `[source${lang ? ',' + lang : ''}]\n----\n${code.trim()}\n----`
})
asciidoc = asciidoc.replace(/`([^`]+)`/g, '`$1`') // Inline code
// Headers
processed = processed.replace(/^### (.*$)/gim, '<h3>$1</h3>')
processed = processed.replace(/^## (.*$)/gim, '<h2>$1</h2>')
processed = processed.replace(/^# (.*$)/gim, '<h1>$1</h1>')
// Convert blockquotes
asciidoc = asciidoc.replace(/^>\s+(.+)$/gm, '____\n$1\n____')
// Bold and italic
processed = processed.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
processed = processed.replace(/\*(.*?)\*/g, '<em>$1</em>')
processed = processed.replace(/_(.*?)_/g, '<em>$1</em>')
processed = processed.replace(/~(.*?)~/g, '<del>$1</del>')
// Convert lists
asciidoc = asciidoc.replace(/^(\s*)\*\s+(.+)$/gm, '$1* $2') // Unordered lists
asciidoc = asciidoc.replace(/^(\s*)\d+\.\s+(.+)$/gm, '$1. $2') // Ordered lists
// Links and images
processed = this.processLinks(processed)
processed = this.processImages(processed)
// Convert links
asciidoc = asciidoc.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1[$2]')
// Lists
processed = this.processLists(processed)
// Convert images
asciidoc = asciidoc.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, 'image::$2[$1]')
// Blockquotes
processed = processed.replace(/^> (.*$)/gim, '<blockquote>$1</blockquote>')
// Convert tables (basic support)
asciidoc = asciidoc.replace(/^\|(.+)\|$/gm, '|$1|')
// Line breaks
processed = processed.replace(/\n\n/g, '</p><p>')
processed = `<p>${processed}</p>`
// Convert horizontal rules
asciidoc = asciidoc.replace(/^---$/gm, '\'\'\'')
return processed
return asciidoc
}
/**
* Parse plain text content
* Process wikilinks in content (both standard and bookstr macro)
*/
private parsePlainText(content: string): string {
// Convert line breaks to HTML
return content
.replace(/\n\n/g, '</p><p>')
.replace(/\n/g, '<br>')
.replace(/^/, '<p>')
.replace(/$/, '</p>')
}
private processWikilinks(content: string): string {
let processed = content
/**
* Preprocess advanced Markdown content
*/
private preprocessAdvancedMarkdown(content: string): string {
// Handle wikilinks: [[NIP-54]] -> [NIP-54](https://next-alexandria.gitcitadel.eu/publication?d=nip-54)
content = content.replace(/\[\[([^\]]+)\]\]/g, (_match, text) => {
const slug = text.toLowerCase().replace(/\s+/g, '-')
return `[${text}](https://next-alexandria.gitcitadel.eu/publication?d=${slug})`
// Process bookstr macro wikilinks: [[book:...]] where ... can be any book type and reference
processed = processed.replace(/\[\[book:([^\]]+)\]\]/g, (_match, bookContent) => {
const cleanContent = bookContent.trim()
const dTag = this.normalizeDtag(cleanContent)
return `wikilink:${dTag}[${cleanContent}]`
})
// Handle hashtags: #hashtag -> [#hashtag](/hashtag/hashtag)
content = content.replace(/#([a-zA-Z0-9_]+)/g, (_match, tag) => {
return `[#${tag}](/hashtag/${tag})`
// Process standard wikilinks: [[Target Page]] or [[target page|see this]]
processed = processed.replace(/\[\[([^|\]]+)(?:\|([^\]]+))?\]\]/g, (_match, target, displayText) => {
const cleanTarget = target.trim()
const cleanDisplay = displayText ? displayText.trim() : cleanTarget
const dTag = this.normalizeDtag(cleanTarget)
return `wikilink:${dTag}[${cleanDisplay}]`
})
return content
return processed
}
/**
* Preprocess basic Markdown content
* Normalize text to d-tag format (lowercase, non-letters to dashes)
*/
private preprocessBasicMarkdown(content: string): string {
// Handle hashtags
content = content.replace(/#([a-zA-Z0-9_]+)/g, (_match, tag) => {
return `[#${tag}](/hashtag/${tag})`
})
// Handle emoji shortcodes
content = content.replace(/:([a-zA-Z0-9_]+):/g, (_match, _emoji) => {
// This would need an emoji mapping - for now just return as-is
return _match
})
return content
private normalizeDtag(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
/**
* Process markdown links
* Process wikilinks in HTML output
*/
private processLinks(content: string): string {
return content.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => {
// Check if it's already an HTML link
if (content.includes(`href="${url}"`)) {
return match
}
// Handle nostr: prefixes
if (url.startsWith('nostr:')) {
return `<span class="nostr-link" data-nostr="${url}">${text}</span>`
}
return `<a href="${url}" target="_blank" rel="noreferrer noopener" class="break-words inline-flex items-baseline gap-1">${text} <svg class="size-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg></a>`
private processWikilinksInHtml(html: string): string {
// Convert wikilink:dtag[display] format to HTML with data attributes
return html.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>`
})
}
/**
* Process markdown images
* Convert plain text to AsciiDoc format
*/
private processImages(content: string): string {
return content.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, url) => {
const altText = alt || ''
return `<img src="${url}" alt="${altText}" class="max-w-[400px] object-contain my-0" />`
})
private convertPlainTextToAsciidoc(content: string): string {
// Convert line breaks to AsciiDoc format
return content
.replace(/\n\n/g, '\n\n')
.replace(/\n/g, ' +\n')
}
/**
* Process markdown lists
* Parse plain text content
*/
private processLists(content: string): string {
// Unordered lists
content = content.replace(/^[\s]*\* (.+)$/gm, '<li>$1</li>')
content = content.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
// Ordered lists
content = content.replace(/^[\s]*\d+\. (.+)$/gm, '<li>$1</li>')
content = content.replace(/(<li>.*<\/li>)/s, '<ol>$1</ol>')
private parsePlainText(content: string): string {
// Convert line breaks to HTML
return content
.replace(/\n\n/g, '</p><p>')
.replace(/\n/g, '<br>')
.replace(/^/, '<p>')
.replace(/$/, '</p>')
}
/**
* Clean up leftover markdown syntax after AsciiDoc processing
*/
@ -562,6 +558,77 @@ class ContentParserService { @@ -562,6 +558,77 @@ class ContentParserService {
return url.startsWith('nostr:') || this.getNostrType(url) !== null
}
/**
* Extract highlight sources from event tags
*/
private extractHighlightSources(event: Event): Array<{ type: 'event' | 'addressable' | 'url'; value: string; bech32: string }> {
const sources: Array<{ type: 'event' | 'addressable' | 'url'; value: string; bech32: string }> = []
// Check for 'source' marker first (highest priority)
let sourceTag: string[] | undefined
for (const tag of event.tags) {
if (tag[2] === 'source' || tag[3] === 'source') {
sourceTag = tag
break
}
}
// If no 'source' marker found, process tags in priority order: e > a > r
if (!sourceTag) {
for (const tag of event.tags) {
// Give 'e' tags highest priority
if (tag[0] === 'e') {
sourceTag = tag
continue
}
// Give 'a' tags second priority (but don't override 'e' tags)
if (tag[0] === 'a' && (!sourceTag || sourceTag[0] !== 'e')) {
sourceTag = tag
continue
}
// Give 'r' tags lowest priority
if (tag[0] === 'r' && (!sourceTag || sourceTag[0] === 'r')) {
sourceTag = tag
continue
}
}
}
// Process the selected source tag
if (sourceTag) {
if (sourceTag[0] === 'e') {
sources.push({
type: 'event',
value: sourceTag[1],
bech32: nip19.noteEncode(sourceTag[1])
})
} else if (sourceTag[0] === 'a') {
const [kind, pubkey, identifier] = sourceTag[1].split(':')
const relay = sourceTag[2]
sources.push({
type: 'addressable',
value: sourceTag[1],
bech32: nip19.naddrEncode({
kind: parseInt(kind),
pubkey,
identifier: identifier || '',
relays: relay ? [relay] : []
})
})
} else if (sourceTag[0] === 'r') {
sources.push({
type: 'url',
value: sourceTag[1],
bech32: sourceTag[1]
})
}
}
return sources
}
/**
* Get Nostr identifier type
*/
@ -605,7 +672,8 @@ class ContentParserService { @@ -605,7 +672,8 @@ class ContentParserService {
media: [],
links: [],
hashtags: [],
nostrLinks: []
nostrLinks: [],
highlightSources: []
}
}

Loading…
Cancel
Save