From 08cc8593fb50d1bd194af0aeadb84b5200a453fb Mon Sep 17 00:00:00 2001
From: Silberengel
Date: Sat, 15 Nov 2025 08:19:38 +0100
Subject: [PATCH] implement note-taking and citations.
---
src/components/CitationCard/index.tsx | 386 ++++++++++++++++++
src/components/EmbeddedCitation/index.tsx | 54 +++
.../Note/AsciidocArticle/AsciidocArticle.tsx | 40 ++
.../Note/MarkdownArticle/MarkdownArticle.tsx | 183 ++++++++-
src/components/Note/index.tsx | 8 +
src/components/PostEditor/PostContent.tsx | 7 +-
src/components/PostEditor/PostOptions.tsx | 32 +-
src/components/Profile/ProfileNotes.tsx | 188 +++++++++
src/components/Profile/index.tsx | 53 ++-
src/hooks/useProfileNotesTimeline.tsx | 194 +++++++++
.../CacheRelayOnlySetting.tsx | 58 +++
.../secondary/PostSettingsPage/index.tsx | 2 +
12 files changed, 1163 insertions(+), 42 deletions(-)
create mode 100644 src/components/CitationCard/index.tsx
create mode 100644 src/components/EmbeddedCitation/index.tsx
create mode 100644 src/components/Profile/ProfileNotes.tsx
create mode 100644 src/hooks/useProfileNotesTimeline.tsx
create mode 100644 src/pages/secondary/PostSettingsPage/CacheRelayOnlySetting.tsx
diff --git a/src/components/CitationCard/index.tsx b/src/components/CitationCard/index.tsx
new file mode 100644
index 0000000..2420ace
--- /dev/null
+++ b/src/components/CitationCard/index.tsx
@@ -0,0 +1,386 @@
+import { ExtendedKind } from '@/constants'
+import { Event } from 'nostr-tools'
+import { useMemo } from 'react'
+import { useTranslation } from 'react-i18next'
+import { getTagValue } from '@/lib/tag'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { ExternalLink, Book, FileText, Bot } from 'lucide-react'
+
+interface CitationCardProps {
+ event: Event
+ className?: string
+ displayType?: 'end' | 'foot' | 'foot-end' | 'inline' | 'quote' | 'prompt-end' | 'prompt-inline'
+}
+
+export default function CitationCard({ event, className, displayType = 'end' }: CitationCardProps) {
+ const { t } = useTranslation()
+
+ const citationData = useMemo(() => {
+ const title = getTagValue(event, 'title') || ''
+ const author = getTagValue(event, 'author') || ''
+ const publishedOn = getTagValue(event, 'published_on') || ''
+ const accessedOn = getTagValue(event, 'accessed_on') || ''
+ const summary = getTagValue(event, 'summary') || ''
+ const location = getTagValue(event, 'location') || ''
+ const publishedBy = getTagValue(event, 'published_by') || ''
+ const version = getTagValue(event, 'version') || ''
+
+ if (event.kind === ExtendedKind.CITATION_INTERNAL) {
+ const cTag = event.tags.find(tag => tag[0] === 'c')?.[1] || ''
+ const relayHint = event.tags.find(tag => tag[0] === 'c')?.[2] || ''
+ const geohash = getTagValue(event, 'g') || ''
+
+ return {
+ type: 'internal',
+ title,
+ author,
+ publishedOn,
+ accessedOn,
+ summary,
+ location,
+ geohash,
+ cTag,
+ relayHint
+ }
+ } else if (event.kind === ExtendedKind.CITATION_EXTERNAL) {
+ const url = getTagValue(event, 'u') || ''
+ const openTimestamp = getTagValue(event, 'open_timestamp') || ''
+ const geohash = getTagValue(event, 'g') || ''
+
+ return {
+ type: 'external',
+ title,
+ author,
+ url,
+ publishedOn,
+ publishedBy,
+ version,
+ accessedOn,
+ summary,
+ location,
+ geohash,
+ openTimestamp
+ }
+ } else if (event.kind === ExtendedKind.CITATION_HARDCOPY) {
+ const pageRange = getTagValue(event, 'page_range') || ''
+ const chapterTitle = getTagValue(event, 'chapter_title') || ''
+ const editor = getTagValue(event, 'editor') || ''
+ const publishedIn = event.tags.find(tag => tag[0] === 'published_in')?.[1] || ''
+ const volume = event.tags.find(tag => tag[0] === 'published_in')?.[2] || ''
+ const doi = getTagValue(event, 'doi') || ''
+ const geohash = getTagValue(event, 'g') || ''
+
+ return {
+ type: 'hardcopy',
+ title,
+ author,
+ pageRange,
+ chapterTitle,
+ editor,
+ publishedOn,
+ publishedBy,
+ publishedIn,
+ volume,
+ doi,
+ version,
+ accessedOn,
+ summary,
+ location,
+ geohash
+ }
+ } else if (event.kind === ExtendedKind.CITATION_PROMPT) {
+ const llm = getTagValue(event, 'llm') || ''
+ const url = getTagValue(event, 'u') || ''
+
+ return {
+ type: 'prompt',
+ llm,
+ accessedOn,
+ version,
+ summary,
+ url
+ }
+ }
+
+ return null
+ }, [event])
+
+ if (!citationData) {
+ return null
+ }
+
+ const formatDate = (dateStr: string) => {
+ if (!dateStr) return ''
+ try {
+ const date = new Date(dateStr)
+ return date.toLocaleDateString()
+ } catch {
+ return dateStr
+ }
+ }
+
+ const renderCitationContent = () => {
+ if (citationData.type === 'internal') {
+ return (
+
+ {citationData.author && (
+
{citationData.author}
+ )}
+ {citationData.title && (
+
"{citationData.title}"
+ )}
+ {citationData.publishedOn && (
+
{formatDate(citationData.publishedOn)}
+ )}
+ {citationData.cTag && (
+
+ nostr:{citationData.cTag}
+
+ )}
+ {citationData.summary && (
+
{citationData.summary}
+ )}
+ {event.content && (
+
+ {event.content}
+
+ )}
+
+ )
+ } else if (citationData.type === 'external') {
+ return (
+
+ {citationData.author && (
+
{citationData.author}
+ )}
+ {citationData.title && (
+
"{citationData.title}"
+ )}
+ {citationData.publishedBy && (
+
{citationData.publishedBy}
+ )}
+ {citationData.publishedOn && (
+
{formatDate(citationData.publishedOn)}
+ )}
+ {citationData.url && (
+
+ )}
+ {citationData.accessedOn && (
+
+ {t('Accessed on')} {formatDate(citationData.accessedOn)}
+
+ )}
+ {citationData.version && (
+
{t('Version')}: {citationData.version}
+ )}
+ {citationData.summary && (
+
{citationData.summary}
+ )}
+ {event.content && (
+
+ {event.content}
+
+ )}
+
+ )
+ } else if (citationData.type === 'hardcopy') {
+ return (
+
+ {citationData.author && (
+
{citationData.author}
+ )}
+ {citationData.title && (
+
"{citationData.title}"
+ )}
+ {citationData.chapterTitle && (
+
{t('Chapter')}: {citationData.chapterTitle}
+ )}
+ {citationData.editor && (
+
{t('Edited by')} {citationData.editor}
+ )}
+ {citationData.publishedIn && (
+
+ {t('Published in')} {citationData.publishedIn}
+ {citationData.volume && `, ${t('Volume')} ${citationData.volume}`}
+
+ )}
+ {citationData.publishedBy && (
+
{citationData.publishedBy}
+ )}
+ {citationData.publishedOn && (
+
{formatDate(citationData.publishedOn)}
+ )}
+ {citationData.pageRange && (
+
{t('Pages')}: {citationData.pageRange}
+ )}
+ {citationData.doi && (
+
DOI: {citationData.doi}
+ )}
+ {citationData.accessedOn && (
+
+ {t('Accessed on')} {formatDate(citationData.accessedOn)}
+
+ )}
+ {citationData.version && (
+
{t('Version')}: {citationData.version}
+ )}
+ {citationData.summary && (
+
{citationData.summary}
+ )}
+ {event.content && (
+
+ {event.content}
+
+ )}
+
+ )
+ } else if (citationData.type === 'prompt') {
+ return (
+
+ {citationData.llm && (
+
{citationData.llm}
+ )}
+ {citationData.accessedOn && (
+
{t('Accessed on')} {formatDate(citationData.accessedOn)}
+ )}
+ {citationData.version && (
+
{t('Version')}: {citationData.version}
+ )}
+ {citationData.url && (
+
+ )}
+ {citationData.summary && (
+
{citationData.summary}
+ )}
+ {event.content && (
+
+ {event.content}
+
+ )}
+
+ )
+ }
+
+ return null
+ }
+
+ const getIcon = () => {
+ switch (citationData.type) {
+ case 'internal':
+ return
+ case 'external':
+ return
+ case 'hardcopy':
+ return
+ case 'prompt':
+ return
+ default:
+ return
+ }
+ }
+
+ const getTitle = () => {
+ switch (citationData.type) {
+ case 'internal':
+ return t('Internal Citation')
+ case 'external':
+ return t('External Citation')
+ case 'hardcopy':
+ return t('Hardcopy Citation')
+ case 'prompt':
+ return t('Prompt Citation')
+ default:
+ return t('Citation')
+ }
+ }
+
+ // For inline citations, render a compact version
+ if (displayType === 'inline' || displayType === 'prompt-inline') {
+ const inlineText = citationData.type === 'internal' && citationData.author && citationData.publishedOn
+ ? `(${citationData.author}, ${formatDate(citationData.publishedOn)})`
+ : citationData.type === 'prompt' && citationData.llm
+ ? `(${citationData.llm})`
+ : `[${t('Citation')}]`
+
+ return (
+
+ {
+ e.preventDefault()
+ // Scroll to full citation in references section
+ const refSection = document.getElementById('references-section')
+ if (refSection) {
+ refSection.scrollIntoView({ behavior: 'smooth', block: 'start' })
+ }
+ }}
+ >
+ {inlineText}
+
+
+ )
+ }
+
+ // For footnotes (foot-end), render a brief reference
+ if (displayType === 'foot-end') {
+ return (
+
+
+ {citationData.type === 'internal' && citationData.author && citationData.publishedOn
+ ? `${citationData.author}, ${formatDate(citationData.publishedOn)}`
+ : citationData.type === 'external' && citationData.author
+ ? `${citationData.author}`
+ : citationData.type === 'hardcopy' && citationData.author
+ ? `${citationData.author}`
+ : citationData.type === 'prompt' && citationData.llm
+ ? `${citationData.llm}`
+ : t('See reference')}
+
+
+ )
+ }
+
+ // For quotes, render with quote styling
+ if (displayType === 'quote') {
+ return (
+
+
+
+ {getIcon()}
+ {getTitle()}
+
+
+
+ {renderCitationContent()}
+
+
+ )
+ }
+
+ // For endnotes, footnotes, and prompt-end, render full citation
+ return (
+
+
+
+ {getIcon()}
+ {getTitle()}
+
+
+
+ {renderCitationContent()}
+
+
+ )
+}
+
diff --git a/src/components/EmbeddedCitation/index.tsx b/src/components/EmbeddedCitation/index.tsx
new file mode 100644
index 0000000..797848b
--- /dev/null
+++ b/src/components/EmbeddedCitation/index.tsx
@@ -0,0 +1,54 @@
+import { useFetchEvent } from '@/hooks'
+import CitationCard from '@/components/CitationCard'
+import { Skeleton } from '@/components/ui/skeleton'
+import { nip19 } from 'nostr-tools'
+
+interface EmbeddedCitationProps {
+ citationId: string // nevent or note ID
+ displayType?: 'end' | 'foot' | 'foot-end' | 'inline' | 'quote' | 'prompt-end' | 'prompt-inline'
+ className?: string
+}
+
+export default function EmbeddedCitation({ citationId, displayType = 'end', className }: EmbeddedCitationProps) {
+ // Try to decode as bech32 first
+ let eventId: string | null = null
+
+ try {
+ const decoded = nip19.decode(citationId)
+ if (decoded.type === 'nevent') {
+ const data = decoded.data as any
+ eventId = data.id || citationId
+ } else if (decoded.type === 'note') {
+ eventId = decoded.data as string
+ } else {
+ // If it's not a note/nevent, use the original ID
+ eventId = citationId
+ }
+ } catch {
+ // If decoding fails, assume it's already a hex ID
+ eventId = citationId
+ }
+
+ const { event, isLoading } = useFetchEvent(eventId || '')
+
+ if (isLoading) {
+ return (
+
+
+
+ )
+ }
+
+ if (!event) {
+ return (
+
+
+ Citation not found: {citationId.slice(0, 20)}...
+
+
+ )
+ }
+
+ return
+}
+
diff --git a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx
index b78b80b..7559d2a 100644
--- a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx
+++ b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx
@@ -15,6 +15,7 @@ import { createRoot, Root } from 'react-dom/client'
import Lightbox from 'yet-another-react-lightbox'
import Zoom from 'yet-another-react-lightbox/plugins/zoom'
import { EmbeddedNote, EmbeddedMention } from '@/components/Embedded'
+import EmbeddedCitation from '@/components/EmbeddedCitation'
import Wikilink from '@/components/UniversalContent/Wikilink'
import { preprocessAsciidocMediaLinks } from '../MarkdownArticle/preprocessMarkup'
import logger from '@/lib/logger'
@@ -693,6 +694,13 @@ export default function AsciidocArticle({
return ``
})
+ // Handle citation markup: [[citation::type::nevent...]]
+ // AsciiDoc passthrough +++[[citation::type::nevent...]]+++ outputs just [[citation::type::nevent...]] in HTML
+ htmlString = htmlString.replace(/\[\[citation::(end|foot|foot-end|inline|quote|prompt-end|prompt-inline)::([^\]]+)\]\]/g, (_match, citationType, citationId) => {
+ const escapedId = citationId.replace(/"/g, '"').replace(/'/g, ''')
+ return ``
+ })
+
// Handle wikilinks - convert passthrough markers to placeholders
// AsciiDoc passthrough +++WIKILINK:link|display+++ outputs just WIKILINK:link|display in HTML
// Match WIKILINK: followed by any characters (including |) until end of text or HTML tag
@@ -929,6 +937,38 @@ export default function AsciidocArticle({
reactRootsRef.current.set(container, root)
})
+ // Process citations - replace placeholders with React components
+ const citationPlaceholders = contentRef.current.querySelectorAll('.citation-placeholder[data-citation]')
+ citationPlaceholders.forEach((element) => {
+ const citationId = element.getAttribute('data-citation')
+ const citationType = element.getAttribute('data-citation-type') || 'end'
+ if (!citationId) {
+ logger.warn('Citation placeholder found but no citation ID attribute')
+ return
+ }
+
+ // Determine container class based on citation type
+ const isInline = citationType === 'inline' || citationType === 'prompt-inline'
+ const container = document.createElement(isInline ? 'span' : 'div')
+ container.className = isInline ? 'inline' : 'w-full my-2'
+ const parent = element.parentNode
+ if (!parent) {
+ logger.warn('Citation placeholder has no parent node')
+ return
+ }
+ parent.replaceChild(container, element)
+
+ // Use React to render the component
+ const root = createRoot(container)
+ root.render(
+
+ )
+ reactRootsRef.current.set(container, root)
+ })
+
// Process LaTeX math expressions - render with KaTeX
const latexInlinePlaceholders = contentRef.current.querySelectorAll('.latex-inline-placeholder[data-latex-inline]')
latexInlinePlaceholders.forEach((element) => {
diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx
index 8860f59..725d08a 100644
--- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx
+++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx
@@ -16,6 +16,7 @@ import { createPortal } from 'react-dom'
import Lightbox from 'yet-another-react-lightbox'
import Zoom from 'yet-another-react-lightbox/plugins/zoom'
import { EmbeddedNote, EmbeddedMention } from '@/components/Embedded'
+import EmbeddedCitation from '@/components/EmbeddedCitation'
import { preprocessMarkdownMediaLinks } from './preprocessMarkup'
import katex from 'katex'
import 'katex/dist/katex.min.css'
@@ -355,11 +356,12 @@ function parseMarkdownContent(
imageThumbnailMap?: Map
getImageIdentifier?: (url: string) => string | null
}
-): { nodes: React.ReactNode[]; hashtagsInContent: Set; footnotes: Map } {
+): { nodes: React.ReactNode[]; hashtagsInContent: Set; footnotes: Map; citations: Array<{ id: string; type: string; citationId: string }> } {
const { eventPubkey, imageIndexMap, openLightbox, navigateToHashtag, navigateToRelay, videoPosterMap, imageThumbnailMap, getImageIdentifier } = options
const parts: React.ReactNode[] = []
const hashtagsInContent = new Set()
const footnotes = new Map()
+ const citations: Array<{ id: string; type: string; citationId: string }> = []
let lastIndex = 0
// Helper function to check if an index range falls within any block-level pattern
@@ -863,6 +865,34 @@ function parseMarkdownContent(
}
})
+ // Citation markup: [[citation::type::nevent...]]
+ const citationRegex = /\[\[citation::(end|foot|foot-end|inline|quote|prompt-end|prompt-inline)::([^\]]+)\]\]/g
+ const citationMatches = Array.from(content.matchAll(citationRegex))
+ citationMatches.forEach(match => {
+ if (match.index !== undefined) {
+ const start = match.index
+ const end = match.index + match[0].length
+ // Only add if not already covered by other patterns and not in block pattern
+ const isInOther = patterns.some(p =>
+ (p.type === 'markdown-link' || p.type === 'markdown-image-link' || p.type === 'markdown-image' || p.type === 'relay-url' || p.type === 'youtube-url' || p.type === 'nostr') &&
+ start >= p.index &&
+ start < p.end
+ )
+ if (!isInOther && !isWithinBlockPattern(start, end, blockPatterns)) {
+ const citationType = match[1]
+ const citationId = match[2]
+ const citationIndex = citations.length
+ citations.push({ id: `citation-${citationIndex}`, type: citationType, citationId })
+ patterns.push({
+ index: start,
+ end: end,
+ type: 'citation',
+ data: { type: citationType, citationId, index: citationIndex }
+ })
+ }
+ }
+ })
+
// Nostr addresses (nostr:npub1..., nostr:note1..., etc.)
const nostrRegex = /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 nostrMatches = Array.from(content.matchAll(nostrRegex))
@@ -872,7 +902,7 @@ function parseMarkdownContent(
const end = match.index + match[0].length
// Only add if not already covered by other patterns and not in block pattern
const isInOther = patterns.some(p =>
- (p.type === 'markdown-link' || p.type === 'markdown-image-link' || p.type === 'markdown-image' || p.type === 'relay-url' || p.type === 'youtube-url') &&
+ (p.type === 'markdown-link' || p.type === 'markdown-image-link' || p.type === 'markdown-image' || p.type === 'relay-url' || p.type === 'youtube-url' || p.type === 'citation') &&
start >= p.index &&
start < p.end
)
@@ -1725,6 +1755,71 @@ function parseMarkdownContent(
// Footnote not found, just render the reference as-is
parts.push([^{footnoteId}])
}
+ } else if (pattern.type === 'citation') {
+ const { type: citationType, citationId, index: citationIndex } = pattern.data
+ const citationNumber = citationIndex + 1
+
+ if (citationType === 'inline' || citationType === 'prompt-inline') {
+ // Inline citations render as clickable text
+ parts.push(
+
+ )
+ } else if (citationType === 'foot' || citationType === 'foot-end') {
+ // Footnotes render as superscript numbers
+ parts.push(
+
+ {
+ e.preventDefault()
+ const citationElement = document.getElementById(`citation-${citationIndex}`)
+ if (citationElement) {
+ citationElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
+ }
+ }}
+ >
+ [{citationNumber}]
+
+
+ )
+ } else if (citationType === 'quote') {
+ // Quotes render as block-level citation cards
+ parts.push(
+
+
+
+ )
+ } else {
+ // end, prompt-end render as superscript numbers that link to references section
+ parts.push(
+
+ {
+ e.preventDefault()
+ const refSection = document.getElementById('references-section')
+ if (refSection) {
+ refSection.scrollIntoView({ behavior: 'smooth', block: 'start' })
+ }
+ }}
+ >
+ [{citationNumber}]
+
+
+ )
+ }
} else if (pattern.type === 'nostr') {
const bech32Id = pattern.data
// Check if it's a profile type (mentions/handles should be inline)
@@ -1949,7 +2044,7 @@ function parseMarkdownContent(
)
}).filter(Boolean)
- return { nodes: formattedParagraphs, hashtagsInContent, footnotes }
+ return { nodes: formattedParagraphs, hashtagsInContent, footnotes, citations }
}
// Filter out empty spans before wrapping lists
@@ -2131,7 +2226,87 @@ function parseMarkdownContent(
)
}
- return { nodes: wrappedParts, hashtagsInContent, footnotes }
+ // Add citations section (footnotes) at the end if there are any footnotes
+ const footCitations = citations.filter(c => c.type === 'foot' || c.type === 'foot-end')
+ if (footCitations.length > 0) {
+ wrappedParts.push(
+
+ )
+ }
+
+ // Add references section at the end if there are any endnote citations
+ const endCitations = citations.filter(c => c.type === 'end' || c.type === 'prompt-end')
+ if (endCitations.length > 0) {
+ wrappedParts.push(
+
+ )
+ }
+
+ return { nodes: wrappedParts, hashtagsInContent, footnotes, citations }
}
/**
diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx
index 79ce78f..5d40f7d 100644
--- a/src/components/Note/index.tsx
+++ b/src/components/Note/index.tsx
@@ -38,6 +38,7 @@ import UnknownNote from './UnknownNote'
import VideoNote from './VideoNote'
import RelayReview from './RelayReview'
import Zap from './Zap'
+import CitationCard from '@/components/CitationCard'
export default function Note({
event,
@@ -146,6 +147,13 @@ export default function Note({
>
)
+ } else if (
+ event.kind === ExtendedKind.CITATION_INTERNAL ||
+ event.kind === ExtendedKind.CITATION_EXTERNAL ||
+ event.kind === ExtendedKind.CITATION_HARDCOPY ||
+ event.kind === ExtendedKind.CITATION_PROMPT
+ ) {
+ content =
} else if (event.kind === ExtendedKind.POLL) {
content = (
<>
diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx
index 0994054..2734ffb 100644
--- a/src/components/PostEditor/PostContent.tsx
+++ b/src/components/PostEditor/PostContent.tsx
@@ -841,7 +841,7 @@ export default function PostContent({
) : isWikiArticleMarkdown ? (
t('New Wiki Article (Markdown)')
) : isPublicationContent ? (
- t('New Publication Content')
+ t('Take a note')
) : isCitationInternal ? (
t('New Internal Citation')
) : isCitationExternal ? (
@@ -972,7 +972,7 @@ export default function PostContent({
{hasPrivateRelaysAvailable && (
handleArticleToggle('publication')}>
- {t('Publication Content')}
+ {t('Take a note')}
)}
@@ -1162,9 +1162,6 @@ export default function PostContent({
setIsNsfw={setIsNsfw}
minPow={minPow}
setMinPow={setMinPow}
- useCacheOnlyForPrivateNotes={useCacheOnlyForPrivateNotes}
- setUseCacheOnlyForPrivateNotes={setUseCacheOnlyForPrivateNotes}
- hasCacheRelaysAvailable={hasCacheRelaysAvailable}
/>
+
)