13 changed files with 1759 additions and 75 deletions
@ -0,0 +1,70 @@ |
|||||||
|
import { randomString } from '@/lib/random' |
||||||
|
import { cn } from '@/lib/utils' |
||||||
|
import modalManager from '@/services/modal-manager.service' |
||||||
|
import { TImageInfo } from '@/types' |
||||||
|
import { useEffect, useMemo, useState } from 'react' |
||||||
|
import { createPortal } from 'react-dom' |
||||||
|
import Lightbox from 'yet-another-react-lightbox' |
||||||
|
import Zoom from 'yet-another-react-lightbox/plugins/zoom' |
||||||
|
import Image from '../Image' |
||||||
|
|
||||||
|
export default function ImageWithLightbox({ |
||||||
|
image, |
||||||
|
className |
||||||
|
}: { |
||||||
|
image: TImageInfo |
||||||
|
className?: string |
||||||
|
}) { |
||||||
|
const id = useMemo(() => `image-with-lightbox-${randomString()}`, []) |
||||||
|
const [index, setIndex] = useState(-1) |
||||||
|
useEffect(() => { |
||||||
|
if (index >= 0) { |
||||||
|
modalManager.register(id, () => { |
||||||
|
setIndex(-1) |
||||||
|
}) |
||||||
|
} else { |
||||||
|
modalManager.unregister(id) |
||||||
|
} |
||||||
|
}, [index]) |
||||||
|
|
||||||
|
const handlePhotoClick = (event: React.MouseEvent) => { |
||||||
|
event.stopPropagation() |
||||||
|
event.preventDefault() |
||||||
|
setIndex(0) |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="w-fit max-w-full"> |
||||||
|
<Image |
||||||
|
key={0} |
||||||
|
className={cn('rounded-lg max-h-[80vh] sm:max-h-[50vh] border cursor-zoom-in', className)} |
||||||
|
classNames={{ |
||||||
|
errorPlaceholder: 'aspect-square h-[30vh]' |
||||||
|
}} |
||||||
|
image={image} |
||||||
|
onClick={(e) => handlePhotoClick(e)} |
||||||
|
/> |
||||||
|
{index >= 0 && |
||||||
|
createPortal( |
||||||
|
<div onClick={(e) => e.stopPropagation()}> |
||||||
|
<Lightbox |
||||||
|
index={index} |
||||||
|
slides={[{ src: image.url }]} |
||||||
|
plugins={[Zoom]} |
||||||
|
open={index >= 0} |
||||||
|
close={() => setIndex(-1)} |
||||||
|
controller={{ |
||||||
|
closeOnBackdropClick: true, |
||||||
|
closeOnPullUp: true, |
||||||
|
closeOnPullDown: true |
||||||
|
}} |
||||||
|
styles={{ |
||||||
|
toolbar: { paddingTop: '2.25rem' } |
||||||
|
}} |
||||||
|
/> |
||||||
|
</div>, |
||||||
|
document.body |
||||||
|
)} |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,30 @@ |
|||||||
|
import { EmbeddedMention, EmbeddedNote } from '@/components/Embedded' |
||||||
|
import { nip19 } from 'nostr-tools' |
||||||
|
import { ComponentProps, useMemo } from 'react' |
||||||
|
import { Components } from './types' |
||||||
|
|
||||||
|
export default function NostrNode({ rawText, bech32Id }: ComponentProps<Components['nostr']>) { |
||||||
|
const { type, id } = useMemo(() => { |
||||||
|
if (!bech32Id) return { type: 'invalid', id: '' } |
||||||
|
console.log('NostrLink bech32Id:', bech32Id) |
||||||
|
try { |
||||||
|
const { type } = nip19.decode(bech32Id) |
||||||
|
if (type === 'npub') { |
||||||
|
return { type: 'mention', id: bech32Id } |
||||||
|
} |
||||||
|
if (type === 'nevent' || type === 'naddr' || type === 'note') { |
||||||
|
return { type: 'note', id: bech32Id } |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.error('Invalid bech32 ID:', bech32Id, error) |
||||||
|
} |
||||||
|
return { type: 'invalid', id: '' } |
||||||
|
}, [bech32Id]) |
||||||
|
|
||||||
|
if (type === 'invalid') return rawText |
||||||
|
|
||||||
|
if (type === 'mention') { |
||||||
|
return <EmbeddedMention userId={id} className="not-prose" /> |
||||||
|
} |
||||||
|
return <EmbeddedNote noteId={id} className="not-prose" /> |
||||||
|
} |
||||||
@ -0,0 +1,60 @@ |
|||||||
|
import ImageWithLightbox from '@/components/ImageWithLightbox' |
||||||
|
import { Badge } from '@/components/ui/badge' |
||||||
|
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' |
||||||
|
import { Event } from 'nostr-tools' |
||||||
|
import { useMemo } from 'react' |
||||||
|
import Markdown from 'react-markdown' |
||||||
|
import remarkGfm from 'remark-gfm' |
||||||
|
import NostrNode from './NostrNode' |
||||||
|
import { remarkNostr } from './remarkNostr' |
||||||
|
import { Components } from './types' |
||||||
|
|
||||||
|
export default function LongFormArticle({ |
||||||
|
event, |
||||||
|
className |
||||||
|
}: { |
||||||
|
event: Event |
||||||
|
className?: string |
||||||
|
}) { |
||||||
|
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className={`prose prose-zinc max-w-none dark:prose-invert ${className || ''}`}> |
||||||
|
<h1>{metadata.title}</h1> |
||||||
|
{metadata.summary && ( |
||||||
|
<blockquote> |
||||||
|
<p>{metadata.summary}</p> |
||||||
|
</blockquote> |
||||||
|
)} |
||||||
|
{metadata.tags.length > 0 && ( |
||||||
|
<div className="flex gap-1 flex-wrap"> |
||||||
|
{metadata.tags.map((tag) => ( |
||||||
|
<Badge key={tag} variant="secondary"> |
||||||
|
{tag} |
||||||
|
</Badge> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
)} |
||||||
|
{metadata.image && ( |
||||||
|
<ImageWithLightbox |
||||||
|
image={{ url: metadata.image, pubkey: event.pubkey }} |
||||||
|
className="w-full aspect-[3/1] object-cover rounded-lg" |
||||||
|
/> |
||||||
|
)} |
||||||
|
<Markdown |
||||||
|
remarkPlugins={[remarkGfm, remarkNostr]} |
||||||
|
components={ |
||||||
|
{ |
||||||
|
nostr: (props) => <NostrNode {...props} />, |
||||||
|
img: ({ src, ...props }) => ( |
||||||
|
<ImageWithLightbox image={{ url: src ?? '', pubkey: event.pubkey }} {...props} /> |
||||||
|
), |
||||||
|
a: (props) => <a {...props} target="_blank" rel="noreferrer noopener" /> |
||||||
|
} as Components |
||||||
|
} |
||||||
|
> |
||||||
|
{event.content} |
||||||
|
</Markdown> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,90 @@ |
|||||||
|
import type { PhrasingContent, Root, Text } from 'mdast' |
||||||
|
import type { Plugin } from 'unified' |
||||||
|
import { visit } from 'unist-util-visit' |
||||||
|
import { NostrNode } from './types' |
||||||
|
|
||||||
|
const NOSTR_REGEX = |
||||||
|
/nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+|note1[a-z0-9]{58}|nevent1[a-z0-9]+|naddr1[a-z0-9]+)/g |
||||||
|
const NOSTR_REFERENCE_REGEX = |
||||||
|
/\[[^\]]+\]\[(nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+|note1[a-z0-9]{58}|nevent1[a-z0-9]+|naddr1[a-z0-9]+))\]/g |
||||||
|
|
||||||
|
export const remarkNostr: Plugin<[], Root> = () => { |
||||||
|
return (tree) => { |
||||||
|
visit(tree, 'text', (node: Text, index, parent) => { |
||||||
|
if (!parent || typeof index !== 'number') return |
||||||
|
|
||||||
|
const text = node.value |
||||||
|
|
||||||
|
// First, handle reference-style nostr links [text][nostr:...]
|
||||||
|
const refMatches = Array.from(text.matchAll(NOSTR_REFERENCE_REGEX)) |
||||||
|
// Then, handle direct nostr links that are not part of reference links
|
||||||
|
const directMatches = Array.from(text.matchAll(NOSTR_REGEX)).filter((directMatch) => { |
||||||
|
return !refMatches.some( |
||||||
|
(refMatch) => |
||||||
|
directMatch.index! >= refMatch.index! && |
||||||
|
directMatch.index! < refMatch.index! + refMatch[0].length |
||||||
|
) |
||||||
|
}) |
||||||
|
|
||||||
|
// Combine and sort matches by position
|
||||||
|
const allMatches = [ |
||||||
|
...refMatches.map((match) => ({ |
||||||
|
...match, |
||||||
|
type: 'reference' as const, |
||||||
|
bech32Id: match[2], |
||||||
|
rawText: match[0] |
||||||
|
})), |
||||||
|
...directMatches.map((match) => ({ |
||||||
|
...match, |
||||||
|
type: 'direct' as const, |
||||||
|
bech32Id: match[1], |
||||||
|
rawText: match[0] |
||||||
|
})) |
||||||
|
].sort((a, b) => a.index! - b.index!) |
||||||
|
|
||||||
|
if (allMatches.length === 0) return |
||||||
|
|
||||||
|
const children: (Text | NostrNode)[] = [] |
||||||
|
let lastIndex = 0 |
||||||
|
|
||||||
|
allMatches.forEach((match) => { |
||||||
|
const matchStart = match.index! |
||||||
|
const matchEnd = matchStart + match[0].length |
||||||
|
|
||||||
|
// Add text before the match
|
||||||
|
if (matchStart > lastIndex) { |
||||||
|
children.push({ |
||||||
|
type: 'text', |
||||||
|
value: text.slice(lastIndex, matchStart) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
// Create custom nostr node with type information
|
||||||
|
const nostrNode: NostrNode = { |
||||||
|
type: 'nostr', |
||||||
|
data: { |
||||||
|
hName: 'nostr', |
||||||
|
hProperties: { |
||||||
|
bech32Id: match.bech32Id, |
||||||
|
rawText: match.rawText |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
children.push(nostrNode) |
||||||
|
|
||||||
|
lastIndex = matchEnd |
||||||
|
}) |
||||||
|
|
||||||
|
// Add remaining text after the last match
|
||||||
|
if (lastIndex < text.length) { |
||||||
|
children.push({ |
||||||
|
type: 'text', |
||||||
|
value: text.slice(lastIndex) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
// Type assertion to tell TypeScript these are valid AST nodes
|
||||||
|
parent.children.splice(index, 1, ...(children as PhrasingContent[])) |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,19 @@ |
|||||||
|
import { ComponentProps } from 'react' |
||||||
|
import type { Components as RmComponents } from 'react-markdown' |
||||||
|
import type { Data, Node } from 'unist' |
||||||
|
|
||||||
|
// Extend the Components interface to include your custom component
|
||||||
|
export interface Components extends RmComponents { |
||||||
|
nostr: React.ComponentType<{ |
||||||
|
rawText: string |
||||||
|
bech32Id?: string |
||||||
|
}> |
||||||
|
} |
||||||
|
|
||||||
|
export interface NostrNode extends Node { |
||||||
|
type: 'nostr' |
||||||
|
data: Data & { |
||||||
|
hName: string |
||||||
|
hProperties: ComponentProps<Components['nostr']> |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue