Browse Source

implement note-taking and citations.

imwald
Silberengel 4 months ago
parent
commit
08cc8593fb
  1. 386
      src/components/CitationCard/index.tsx
  2. 54
      src/components/EmbeddedCitation/index.tsx
  3. 40
      src/components/Note/AsciidocArticle/AsciidocArticle.tsx
  4. 183
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  5. 8
      src/components/Note/index.tsx
  6. 7
      src/components/PostEditor/PostContent.tsx
  7. 32
      src/components/PostEditor/PostOptions.tsx
  8. 188
      src/components/Profile/ProfileNotes.tsx
  9. 53
      src/components/Profile/index.tsx
  10. 194
      src/hooks/useProfileNotesTimeline.tsx
  11. 58
      src/pages/secondary/PostSettingsPage/CacheRelayOnlySetting.tsx
  12. 2
      src/pages/secondary/PostSettingsPage/index.tsx

386
src/components/CitationCard/index.tsx

@ -0,0 +1,386 @@ @@ -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 (
<div className="space-y-1 text-sm">
{citationData.author && (
<div className="font-semibold">{citationData.author}</div>
)}
{citationData.title && (
<div className="italic">"{citationData.title}"</div>
)}
{citationData.publishedOn && (
<div className="text-muted-foreground">{formatDate(citationData.publishedOn)}</div>
)}
{citationData.cTag && (
<div className="text-xs text-muted-foreground font-mono break-all">
nostr:{citationData.cTag}
</div>
)}
{citationData.summary && (
<div className="text-muted-foreground mt-2">{citationData.summary}</div>
)}
{event.content && (
<div className="mt-2 p-2 bg-muted/50 rounded text-sm border-l-2 border-primary">
{event.content}
</div>
)}
</div>
)
} else if (citationData.type === 'external') {
return (
<div className="space-y-1 text-sm">
{citationData.author && (
<div className="font-semibold">{citationData.author}</div>
)}
{citationData.title && (
<div className="italic">"{citationData.title}"</div>
)}
{citationData.publishedBy && (
<div>{citationData.publishedBy}</div>
)}
{citationData.publishedOn && (
<div className="text-muted-foreground">{formatDate(citationData.publishedOn)}</div>
)}
{citationData.url && (
<div className="flex items-center gap-1 text-primary hover:underline">
<ExternalLink className="w-3 h-3" />
<a href={citationData.url} target="_blank" rel="noreferrer noopener" className="break-all">
{citationData.url}
</a>
</div>
)}
{citationData.accessedOn && (
<div className="text-xs text-muted-foreground">
{t('Accessed on')} {formatDate(citationData.accessedOn)}
</div>
)}
{citationData.version && (
<div className="text-xs text-muted-foreground">{t('Version')}: {citationData.version}</div>
)}
{citationData.summary && (
<div className="text-muted-foreground mt-2">{citationData.summary}</div>
)}
{event.content && (
<div className="mt-2 p-2 bg-muted/50 rounded text-sm border-l-2 border-primary">
{event.content}
</div>
)}
</div>
)
} else if (citationData.type === 'hardcopy') {
return (
<div className="space-y-1 text-sm">
{citationData.author && (
<div className="font-semibold">{citationData.author}</div>
)}
{citationData.title && (
<div className="italic">"{citationData.title}"</div>
)}
{citationData.chapterTitle && (
<div className="text-muted-foreground">{t('Chapter')}: {citationData.chapterTitle}</div>
)}
{citationData.editor && (
<div>{t('Edited by')} {citationData.editor}</div>
)}
{citationData.publishedIn && (
<div>
{t('Published in')} {citationData.publishedIn}
{citationData.volume && `, ${t('Volume')} ${citationData.volume}`}
</div>
)}
{citationData.publishedBy && (
<div>{citationData.publishedBy}</div>
)}
{citationData.publishedOn && (
<div className="text-muted-foreground">{formatDate(citationData.publishedOn)}</div>
)}
{citationData.pageRange && (
<div className="text-muted-foreground">{t('Pages')}: {citationData.pageRange}</div>
)}
{citationData.doi && (
<div className="text-xs text-muted-foreground">DOI: {citationData.doi}</div>
)}
{citationData.accessedOn && (
<div className="text-xs text-muted-foreground">
{t('Accessed on')} {formatDate(citationData.accessedOn)}
</div>
)}
{citationData.version && (
<div className="text-xs text-muted-foreground">{t('Version')}: {citationData.version}</div>
)}
{citationData.summary && (
<div className="text-muted-foreground mt-2">{citationData.summary}</div>
)}
{event.content && (
<div className="mt-2 p-2 bg-muted/50 rounded text-sm border-l-2 border-primary">
{event.content}
</div>
)}
</div>
)
} else if (citationData.type === 'prompt') {
return (
<div className="space-y-1 text-sm">
{citationData.llm && (
<div className="font-semibold">{citationData.llm}</div>
)}
{citationData.accessedOn && (
<div className="text-muted-foreground">{t('Accessed on')} {formatDate(citationData.accessedOn)}</div>
)}
{citationData.version && (
<div className="text-xs text-muted-foreground">{t('Version')}: {citationData.version}</div>
)}
{citationData.url && (
<div className="flex items-center gap-1 text-primary hover:underline">
<ExternalLink className="w-3 h-3" />
<a href={citationData.url} target="_blank" rel="noreferrer noopener" className="break-all">
{citationData.url}
</a>
</div>
)}
{citationData.summary && (
<div className="text-muted-foreground mt-2">{citationData.summary}</div>
)}
{event.content && (
<div className="mt-2 p-2 bg-muted/50 rounded text-sm border-l-2 border-primary">
{event.content}
</div>
)}
</div>
)
}
return null
}
const getIcon = () => {
switch (citationData.type) {
case 'internal':
return <FileText className="w-4 h-4" />
case 'external':
return <ExternalLink className="w-4 h-4" />
case 'hardcopy':
return <Book className="w-4 h-4" />
case 'prompt':
return <Bot className="w-4 h-4" />
default:
return <FileText className="w-4 h-4" />
}
}
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 (
<span className={className}>
<a
href={`/notes/${event.id}`}
className="text-primary hover:underline"
onClick={(e) => {
e.preventDefault()
// Scroll to full citation in references section
const refSection = document.getElementById('references-section')
if (refSection) {
refSection.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}}
>
{inlineText}
</a>
</span>
)
}
// For footnotes (foot-end), render a brief reference
if (displayType === 'foot-end') {
return (
<div className={className}>
<div className="text-sm text-muted-foreground">
{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')}
</div>
</div>
)
}
// For quotes, render with quote styling
if (displayType === 'quote') {
return (
<Card className={className}>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
{getIcon()}
{getTitle()}
</CardTitle>
</CardHeader>
<CardContent>
{renderCitationContent()}
</CardContent>
</Card>
)
}
// For endnotes, footnotes, and prompt-end, render full citation
return (
<Card className={className}>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
{getIcon()}
{getTitle()}
</CardTitle>
</CardHeader>
<CardContent>
{renderCitationContent()}
</CardContent>
</Card>
)
}

54
src/components/EmbeddedCitation/index.tsx

@ -0,0 +1,54 @@ @@ -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 (
<div className={className}>
<Skeleton className="h-24 w-full" />
</div>
)
}
if (!event) {
return (
<div className={className}>
<div className="text-sm text-muted-foreground p-2 border rounded">
Citation not found: {citationId.slice(0, 20)}...
</div>
</div>
)
}
return <CitationCard event={event} displayType={displayType} className={className} />
}

40
src/components/Note/AsciidocArticle/AsciidocArticle.tsx

@ -15,6 +15,7 @@ import { createRoot, Root } from 'react-dom/client' @@ -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({ @@ -693,6 +694,13 @@ export default function AsciidocArticle({
return `<div data-latex-block="${escaped}" class="latex-block-placeholder my-4"></div>`
})
// 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, '&quot;').replace(/'/g, '&#39;')
return `<div data-citation="${escapedId}" data-citation-type="${citationType}" class="citation-placeholder"></div>`
})
// 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({ @@ -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(
<EmbeddedCitation
citationId={citationId}
displayType={citationType as 'end' | 'foot' | 'foot-end' | 'inline' | 'quote' | 'prompt-end' | 'prompt-inline'}
/>
)
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) => {

183
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -16,6 +16,7 @@ import { createPortal } from 'react-dom' @@ -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( @@ -355,11 +356,12 @@ function parseMarkdownContent(
imageThumbnailMap?: Map<string, string>
getImageIdentifier?: (url: string) => string | null
}
): { nodes: React.ReactNode[]; hashtagsInContent: Set<string>; footnotes: Map<string, string> } {
): { nodes: React.ReactNode[]; hashtagsInContent: Set<string>; footnotes: Map<string, string>; 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<string>()
const footnotes = new Map<string, string>()
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( @@ -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( @@ -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( @@ -1725,6 +1755,71 @@ function parseMarkdownContent(
// Footnote not found, just render the reference as-is
parts.push(<span key={`footnote-ref-${patternIdx}`}>[^{footnoteId}]</span>)
}
} 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(
<EmbeddedCitation
key={`citation-${patternIdx}`}
citationId={citationId}
displayType={citationType as 'inline' | 'prompt-inline'}
className="inline"
/>
)
} else if (citationType === 'foot' || citationType === 'foot-end') {
// Footnotes render as superscript numbers
parts.push(
<sup key={`citation-foot-${patternIdx}`} className="citation-ref">
<a
href={`#citation-${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"
onClick={(e) => {
e.preventDefault()
const citationElement = document.getElementById(`citation-${citationIndex}`)
if (citationElement) {
citationElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}}
>
[{citationNumber}]
</a>
</sup>
)
} else if (citationType === 'quote') {
// Quotes render as block-level citation cards
parts.push(
<div key={`citation-quote-${patternIdx}`} className="w-full my-2">
<EmbeddedCitation
citationId={citationId}
displayType="quote"
/>
</div>
)
} else {
// end, prompt-end render as superscript numbers that link to references section
parts.push(
<sup key={`citation-end-${patternIdx}`} className="citation-ref">
<a
href="#references-section"
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"
onClick={(e) => {
e.preventDefault()
const refSection = document.getElementById('references-section')
if (refSection) {
refSection.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}}
>
[{citationNumber}]
</a>
</sup>
)
}
} 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( @@ -1949,7 +2044,7 @@ function parseMarkdownContent(
</p>
)
}).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( @@ -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(
<div key="citations-footnotes-section" className="mt-8 pt-4 border-t border-gray-300 dark:border-gray-700">
<h3 className="text-lg font-semibold mb-4">Citations</h3>
<ol className="list-decimal list-inside space-y-2">
{footCitations.map((citation, idx) => (
<li
key={`citation-footnote-${idx}`}
id={`citation-${citation.id.replace('citation-', '')}`}
className="text-sm"
>
<span className="font-semibold">[{idx + 1}]:</span>{' '}
<EmbeddedCitation
citationId={citation.citationId}
displayType={citation.type as 'foot' | 'foot-end'}
className="inline-block mt-1"
/>
{' '}
<a
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"
onClick={(e) => {
e.preventDefault()
const refElement = document.getElementById(`citation-ref-${citation.id.replace('citation-', '')}`)
if (refElement) {
refElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}}
>
</a>
</li>
))}
</ol>
</div>
)
}
// 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(
<div key="references-section" id="references-section" className="mt-8 pt-4 border-t border-gray-300 dark:border-gray-700">
<h3 className="text-lg font-semibold mb-4">References</h3>
<ol className="list-decimal list-inside space-y-2">
{endCitations.map((citation, idx) => (
<li
key={`citation-end-${idx}`}
id={`citation-end-${idx}`}
className="text-sm"
>
<span className="font-semibold">[{idx + 1}]:</span>{' '}
<EmbeddedCitation
citationId={citation.citationId}
displayType={citation.type as 'end' | 'prompt-end'}
className="inline-block mt-1"
/>
{' '}
<a
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"
onClick={(e) => {
e.preventDefault()
const refElement = document.getElementById(`citation-ref-${citation.id.replace('citation-', '')}`)
if (refElement) {
refElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}}
>
</a>
</li>
))}
</ol>
</div>
)
}
return { nodes: wrappedParts, hashtagsInContent, footnotes, citations }
}
/**

8
src/components/Note/index.tsx

@ -38,6 +38,7 @@ import UnknownNote from './UnknownNote' @@ -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({ @@ -146,6 +147,13 @@ export default function Note({
<MarkdownArticle className="mt-2" event={event} hideMetadata={true} />
</>
)
} else if (
event.kind === ExtendedKind.CITATION_INTERNAL ||
event.kind === ExtendedKind.CITATION_EXTERNAL ||
event.kind === ExtendedKind.CITATION_HARDCOPY ||
event.kind === ExtendedKind.CITATION_PROMPT
) {
content = <CitationCard className="mt-2" event={event} />
} else if (event.kind === ExtendedKind.POLL) {
content = (
<>

7
src/components/PostEditor/PostContent.tsx

@ -841,7 +841,7 @@ export default function PostContent({ @@ -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({ @@ -972,7 +972,7 @@ export default function PostContent({
</DropdownMenuItem>
{hasPrivateRelaysAvailable && (
<DropdownMenuItem onClick={() => handleArticleToggle('publication')}>
{t('Publication Content')}
{t('Take a note')}
</DropdownMenuItem>
)}
</DropdownMenuContent>
@ -1162,9 +1162,6 @@ export default function PostContent({ @@ -1162,9 +1162,6 @@ export default function PostContent({
setIsNsfw={setIsNsfw}
minPow={minPow}
setMinPow={setMinPow}
useCacheOnlyForPrivateNotes={useCacheOnlyForPrivateNotes}
setUseCacheOnlyForPrivateNotes={setUseCacheOnlyForPrivateNotes}
hasCacheRelaysAvailable={hasCacheRelaysAvailable}
/>
<div className="flex gap-2 items-center justify-around sm:hidden">
<Button

32
src/components/PostEditor/PostOptions.tsx

@ -13,10 +13,7 @@ export default function PostOptions({ @@ -13,10 +13,7 @@ export default function PostOptions({
isNsfw,
setIsNsfw,
minPow,
setMinPow,
useCacheOnlyForPrivateNotes,
setUseCacheOnlyForPrivateNotes,
hasCacheRelaysAvailable
setMinPow
}: {
posting: boolean
show: boolean
@ -26,9 +23,6 @@ export default function PostOptions({ @@ -26,9 +23,6 @@ export default function PostOptions({
setIsNsfw: Dispatch<SetStateAction<boolean>>
minPow: number
setMinPow: Dispatch<SetStateAction<number>>
useCacheOnlyForPrivateNotes?: boolean
setUseCacheOnlyForPrivateNotes?: Dispatch<SetStateAction<boolean>>
hasCacheRelaysAvailable?: boolean
}) {
const { t } = useTranslation()
@ -48,13 +42,6 @@ export default function PostOptions({ @@ -48,13 +42,6 @@ export default function PostOptions({
setIsNsfw(checked)
}
const onUseCacheOnlyChange = (checked: boolean) => {
if (setUseCacheOnlyForPrivateNotes) {
setUseCacheOnlyForPrivateNotes(checked)
window.localStorage.setItem(StorageKey.USE_CACHE_ONLY_FOR_PRIVATE_NOTES, checked.toString())
}
}
return (
<div className="space-y-4">
<div className="space-y-2">
@ -82,23 +69,6 @@ export default function PostOptions({ @@ -82,23 +69,6 @@ export default function PostOptions({
/>
</div>
{hasCacheRelaysAvailable && useCacheOnlyForPrivateNotes !== undefined && setUseCacheOnlyForPrivateNotes && (
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Label htmlFor="use-cache-only">{t('Use cache relay only for citations and publication content')}</Label>
<Switch
id="use-cache-only"
checked={useCacheOnlyForPrivateNotes}
onCheckedChange={onUseCacheOnlyChange}
disabled={posting}
/>
</div>
<div className="text-muted-foreground text-xs">
{t('When enabled, citations and publication content (kind 30041) will only be published to your cache relay, not to outbox relays')}
</div>
</div>
)}
<div className="grid gap-4 pb-4">
<Label>{t('Proof of Work (difficulty {{minPow}})', { minPow })}</Label>
<Slider

188
src/components/Profile/ProfileNotes.tsx

@ -0,0 +1,188 @@ @@ -0,0 +1,188 @@
import { ExtendedKind } from '@/constants'
import { Event } from 'nostr-tools'
import { forwardRef, useMemo, useEffect, useImperativeHandle, useState, useRef } from 'react'
import { useProfileNotesTimeline } from '@/hooks/useProfileNotesTimeline'
import NoteCard from '@/components/NoteCard'
import { Skeleton } from '@/components/ui/skeleton'
const INITIAL_SHOW_COUNT = 25
const LOAD_MORE_COUNT = 25
const NOTES_KIND_LIST = [
ExtendedKind.PUBLICATION_CONTENT, // 30041
ExtendedKind.CITATION_INTERNAL, // 30
ExtendedKind.CITATION_EXTERNAL, // 31
ExtendedKind.CITATION_HARDCOPY, // 32
ExtendedKind.CITATION_PROMPT // 33
]
interface ProfileNotesProps {
pubkey: string
topSpace?: number
searchQuery?: string
kindFilter?: string
onEventsChange?: (events: Event[]) => void
}
const ProfileNotes = forwardRef<{ refresh: () => void; getEvents?: () => Event[] }, ProfileNotesProps>(
({ pubkey, topSpace, searchQuery = '', kindFilter = 'all', onEventsChange }, ref) => {
const cacheKey = useMemo(() => `${pubkey}-notes`, [pubkey])
const [isRefreshing, setIsRefreshing] = useState(false)
const [showCount, setShowCount] = useState(INITIAL_SHOW_COUNT)
const bottomRef = useRef<HTMLDivElement>(null)
const { events: timelineEvents, isLoading, refresh } = useProfileNotesTimeline({
pubkey,
cacheKey,
kinds: NOTES_KIND_LIST,
limit: 200,
filterPredicate: undefined
})
useEffect(() => {
onEventsChange?.(timelineEvents)
}, [timelineEvents, onEventsChange])
useEffect(() => {
if (!isLoading) {
setIsRefreshing(false)
}
}, [isLoading])
useImperativeHandle(
ref,
() => ({
refresh: () => {
setIsRefreshing(true)
refresh()
},
getEvents: () => timelineEvents
}),
[refresh, timelineEvents]
)
const getKindLabel = (kindValue: string) => {
if (!kindValue || kindValue === 'all') return 'notes'
const kindNum = parseInt(kindValue, 10)
if (kindNum === ExtendedKind.PUBLICATION_CONTENT) return 'notes'
if (kindNum === ExtendedKind.CITATION_INTERNAL) return 'internal citations'
if (kindNum === ExtendedKind.CITATION_EXTERNAL) return 'external citations'
if (kindNum === ExtendedKind.CITATION_HARDCOPY) return 'hardcopy citations'
if (kindNum === ExtendedKind.CITATION_PROMPT) return 'prompt citations'
return 'notes'
}
const eventsFilteredByKind = useMemo(() => {
if (kindFilter === 'all') {
return timelineEvents
}
const kindNumber = parseInt(kindFilter, 10)
if (Number.isNaN(kindNumber)) {
return timelineEvents
}
return timelineEvents.filter((event) => event.kind === kindNumber)
}, [timelineEvents, kindFilter])
const filteredEvents = useMemo(() => {
if (!searchQuery.trim()) {
return eventsFilteredByKind
}
const query = searchQuery.toLowerCase().trim()
return eventsFilteredByKind.filter((event) => {
const contentLower = event.content.toLowerCase()
if (contentLower.includes(query)) return true
return event.tags.some((tag) => {
if (tag.length <= 1) return false
const tagValue = tag[1]
return tagValue && tagValue.toLowerCase().includes(query)
})
})
}, [eventsFilteredByKind, searchQuery])
// Reset showCount when filters change
useEffect(() => {
setShowCount(INITIAL_SHOW_COUNT)
}, [searchQuery, kindFilter, pubkey])
// Pagination: slice to showCount for display
const displayedEvents = useMemo(() => {
return filteredEvents.slice(0, showCount)
}, [filteredEvents, showCount])
// IntersectionObserver for infinite scroll
useEffect(() => {
if (!bottomRef.current || displayedEvents.length >= filteredEvents.length) return
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && displayedEvents.length < filteredEvents.length) {
setShowCount((prev) => Math.min(prev + LOAD_MORE_COUNT, filteredEvents.length))
}
},
{ threshold: 0.1 }
)
observer.observe(bottomRef.current)
return () => {
observer.disconnect()
}
}, [displayedEvents.length, filteredEvents.length])
if (!pubkey) {
return (
<div className="flex justify-center items-center py-8">
<div className="text-sm text-muted-foreground">No profile selected</div>
</div>
)
}
if (isLoading && timelineEvents.length === 0) {
return (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
)
}
if (!filteredEvents.length && !isLoading) {
return (
<div className="flex justify-center items-center py-8">
<div className="text-sm text-muted-foreground">
{searchQuery.trim() ? 'No notes match your search' : 'No notes found'}
</div>
</div>
)
}
return (
<div style={{ marginTop: topSpace || 0 }}>
{isRefreshing && (
<div className="px-4 py-2 text-sm text-green-500 text-center">🔄 Refreshing notes...</div>
)}
{(searchQuery.trim() || (kindFilter && kindFilter !== 'all')) && (
<div className="px-4 py-2 text-sm text-muted-foreground">
Showing {displayedEvents.length} of {filteredEvents.length} {getKindLabel(kindFilter)}
</div>
)}
<div className="space-y-2">
{displayedEvents.map((event) => (
<NoteCard key={event.id} className="w-full" event={event} filterMutedNotes={false} />
))}
</div>
{displayedEvents.length < filteredEvents.length && (
<div ref={bottomRef} className="h-10 flex items-center justify-center">
<div className="text-sm text-muted-foreground">Loading more...</div>
</div>
)}
</div>
)
}
)
ProfileNotes.displayName = 'ProfileNotes'
export default ProfileNotes

53
src/components/Profile/index.tsx

@ -44,9 +44,10 @@ import SmartMuteLink from './SmartMuteLink' @@ -44,9 +44,10 @@ import SmartMuteLink from './SmartMuteLink'
import SmartRelays from './SmartRelays'
import ProfileMedia from './ProfileMedia'
import ProfileInteractions from './ProfileInteractions'
import ProfileNotes from './ProfileNotes'
import { toFollowPacks } from '@/lib/link'
type ProfileTabValue = 'posts' | 'pins' | 'bookmarks' | 'interests' | 'articles' | 'media' | 'you'
type ProfileTabValue = 'posts' | 'pins' | 'bookmarks' | 'interests' | 'articles' | 'media' | 'you' | 'notes'
export default function Profile({ id }: { id?: string }) {
const { t } = useTranslation()
@ -58,6 +59,7 @@ export default function Profile({ id }: { id?: string }) { @@ -58,6 +59,7 @@ export default function Profile({ id }: { id?: string }) {
const [articleKindFilter, setArticleKindFilter] = useState<string>('all')
const [postKindFilter, setPostKindFilter] = useState<string>('all')
const [mediaKindFilter, setMediaKindFilter] = useState<string>('all')
const [notesKindFilter, setNotesKindFilter] = useState<string>('all')
// Handle search in articles tab - parse advanced search parameters
const handleArticleSearch = (query: string) => {
@ -137,10 +139,12 @@ export default function Profile({ id }: { id?: string }) { @@ -137,10 +139,12 @@ export default function Profile({ id }: { id?: string }) {
const profileArticlesRef = useRef<{ refresh: () => void; getEvents: () => Event[] }>(null)
const profileMediaRef = useRef<{ refresh: () => void; getEvents: () => Event[] }>(null)
const profileInteractionsRef = useRef<{ refresh: () => void; getEvents?: () => Event[] }>(null)
const profileNotesRef = useRef<{ refresh: () => void; getEvents?: () => Event[] }>(null)
const [articleEvents, setArticleEvents] = useState<Event[]>([])
const [postEvents, setPostEvents] = useState<Event[]>([])
const [mediaEvents, setMediaEvents] = useState<Event[]>([])
const [_interactionEvents, setInteractionEvents] = useState<Event[]>([])
const [notesEvents, setNotesEvents] = useState<Event[]>([])
const isFollowingYou = useMemo(() => {
// This will be handled by the FollowedBy component
@ -162,6 +166,8 @@ export default function Profile({ id }: { id?: string }) { @@ -162,6 +166,8 @@ export default function Profile({ id }: { id?: string }) {
profileMediaRef.current?.refresh()
} else if (activeTab === 'you') {
profileInteractionsRef.current?.refresh()
} else if (activeTab === 'notes') {
profileNotesRef.current?.refresh()
} else {
profileBookmarksRef.current?.refresh()
}
@ -196,6 +202,14 @@ export default function Profile({ id }: { id?: string }) { @@ -196,6 +202,14 @@ export default function Profile({ id }: { id?: string }) {
}
]
// Add "My Notes" tab if viewing own profile
if (isSelf) {
baseTabs.push({
value: 'notes',
label: 'My Notes'
})
}
// Add "You" tab if viewing another user's profile and logged in
if (!isSelf && accountPubkey) {
baseTabs.push({
@ -364,7 +378,7 @@ export default function Profile({ id }: { id?: string }) { @@ -364,7 +378,7 @@ export default function Profile({ id }: { id?: string }) {
<ProfileSearchBar
onSearch={activeTab === 'articles' ? handleArticleSearch : setSearchQuery}
placeholder={`Search ${
activeTab === 'posts' ? 'posts' : activeTab === 'media' ? 'media' : activeTab
activeTab === 'posts' ? 'posts' : activeTab === 'media' ? 'media' : activeTab === 'notes' ? 'notes' : activeTab
}...`}
className="w-64"
/>
@ -445,6 +459,31 @@ export default function Profile({ id }: { id?: string }) { @@ -445,6 +459,31 @@ export default function Profile({ id }: { id?: string }) {
</Select>
)
})()}
{activeTab === 'notes' && (() => {
const allCount = notesEvents.length
const publicationContentCount = notesEvents.filter((event) => event.kind === ExtendedKind.PUBLICATION_CONTENT).length
const internalCitationCount = notesEvents.filter((event) => event.kind === ExtendedKind.CITATION_INTERNAL).length
const externalCitationCount = notesEvents.filter((event) => event.kind === ExtendedKind.CITATION_EXTERNAL).length
const hardcopyCitationCount = notesEvents.filter((event) => event.kind === ExtendedKind.CITATION_HARDCOPY).length
const promptCitationCount = notesEvents.filter((event) => event.kind === ExtendedKind.CITATION_PROMPT).length
return (
<Select value={notesKindFilter} onValueChange={setNotesKindFilter}>
<SelectTrigger className="w-52">
<FileText className="h-4 w-4 mr-2 shrink-0" />
<SelectValue placeholder="Filter notes" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Notes ({allCount})</SelectItem>
<SelectItem value={String(ExtendedKind.PUBLICATION_CONTENT)}>Notes ({publicationContentCount})</SelectItem>
<SelectItem value={String(ExtendedKind.CITATION_INTERNAL)}>Internal Citations ({internalCitationCount})</SelectItem>
<SelectItem value={String(ExtendedKind.CITATION_EXTERNAL)}>External Citations ({externalCitationCount})</SelectItem>
<SelectItem value={String(ExtendedKind.CITATION_HARDCOPY)}>Hardcopy Citations ({hardcopyCitationCount})</SelectItem>
<SelectItem value={String(ExtendedKind.CITATION_PROMPT)}>Prompt Citations ({promptCitationCount})</SelectItem>
</SelectContent>
</Select>
)
})()}
<RetroRefreshButton onClick={handleRefresh} size="sm" className="flex-shrink-0" />
</div>
</div>
@ -486,6 +525,16 @@ export default function Profile({ id }: { id?: string }) { @@ -486,6 +525,16 @@ export default function Profile({ id }: { id?: string }) {
searchQuery={searchQuery}
/>
)}
{activeTab === 'notes' && (
<ProfileNotes
ref={profileNotesRef}
pubkey={pubkey}
topSpace={0}
searchQuery={searchQuery}
kindFilter={notesKindFilter}
onEventsChange={setNotesEvents}
/>
)}
{activeTab === 'you' && accountPubkey && (
<ProfileInteractions
ref={profileInteractionsRef}

194
src/hooks/useProfileNotesTimeline.tsx

@ -0,0 +1,194 @@ @@ -0,0 +1,194 @@
import { useEffect, useMemo, useRef, useState, useCallback } from 'react'
import { Event } from 'nostr-tools'
import client from '@/services/client.service'
import { FAST_READ_RELAY_URLS } from '@/constants'
import { normalizeUrl } from '@/lib/url'
import { getPrivateRelayUrls } from '@/lib/private-relays'
type ProfileNotesTimelineCacheEntry = {
events: Event[]
lastUpdated: number
}
const timelineCache = new Map<string, ProfileNotesTimelineCacheEntry>()
const CACHE_DURATION = 5 * 60 * 1000 // 5 minutes - cache is considered fresh for this long
type UseProfileNotesTimelineOptions = {
pubkey: string
cacheKey: string
kinds: number[]
limit?: number
filterPredicate?: (event: Event) => boolean
}
type UseProfileNotesTimelineResult = {
events: Event[]
isLoading: boolean
refresh: () => void
}
function postProcessEvents(
rawEvents: Event[],
filterPredicate: ((event: Event) => boolean) | undefined,
limit: number
) {
const dedupMap = new Map<string, Event>()
rawEvents.forEach((evt) => {
if (!dedupMap.has(evt.id)) {
dedupMap.set(evt.id, evt)
}
})
let events = Array.from(dedupMap.values())
if (filterPredicate) {
events = events.filter(filterPredicate)
}
events.sort((a, b) => b.created_at - a.created_at)
return events.slice(0, limit)
}
export function useProfileNotesTimeline({
pubkey,
cacheKey,
kinds,
limit = 200,
filterPredicate
}: UseProfileNotesTimelineOptions): UseProfileNotesTimelineResult {
const cachedEntry = useMemo(() => timelineCache.get(cacheKey), [cacheKey])
const [events, setEvents] = useState<Event[]>(cachedEntry?.events ?? [])
const [isLoading, setIsLoading] = useState(!cachedEntry)
const [refreshToken, setRefreshToken] = useState(0)
const subscriptionRef = useRef<() => void>(() => {})
useEffect(() => {
let cancelled = false
const subscribe = async () => {
// Check if we have fresh cached data
const cachedEntry = timelineCache.get(cacheKey)
const cacheAge = cachedEntry ? Date.now() - cachedEntry.lastUpdated : Infinity
const isCacheFresh = cacheAge < CACHE_DURATION
// If cache is fresh, show it immediately and skip subscribing
if (isCacheFresh && cachedEntry) {
setEvents(cachedEntry.events)
setIsLoading(false)
// Still subscribe in background to get updates, but don't show loading
} else {
// Cache is stale or missing - show loading and fetch
setIsLoading(!cachedEntry)
}
try {
// Get private relays (outbox + cache relays) for private notes
const privateRelayUrls = await getPrivateRelayUrls(pubkey)
const normalizedPrivateRelays = Array.from(
new Set(
privateRelayUrls
.map((url) => normalizeUrl(url))
.filter((value): value is string => !!value)
)
)
// Also include fast read relays as fallback
const fastReadRelays = Array.from(
new Set(
FAST_READ_RELAY_URLS.map((url) => normalizeUrl(url) || url)
)
)
// Build relay groups: private relays first, then fast read relays
const relayGroups: string[][] = []
if (normalizedPrivateRelays.length > 0) {
relayGroups.push(normalizedPrivateRelays)
}
if (fastReadRelays.length > 0) {
relayGroups.push(fastReadRelays)
}
if (cancelled) {
return
}
const subRequests = relayGroups
.map((urls) => ({
urls,
filter: {
authors: [pubkey],
kinds,
limit
} as any
}))
.filter((request) => request.urls.length)
if (!subRequests.length) {
timelineCache.set(cacheKey, {
events: [],
lastUpdated: Date.now()
})
setEvents([])
setIsLoading(false)
return
}
const { closer } = await client.subscribeTimeline(
subRequests,
{
onEvents: (fetchedEvents) => {
if (cancelled) return
const processed = postProcessEvents(fetchedEvents as Event[], filterPredicate, limit)
timelineCache.set(cacheKey, {
events: processed,
lastUpdated: Date.now()
})
setEvents(processed)
setIsLoading(false)
},
onNew: (evt) => {
if (cancelled) return
setEvents((prevEvents) => {
const combined = [evt as Event, ...prevEvents]
const processed = postProcessEvents(combined, filterPredicate, limit)
timelineCache.set(cacheKey, {
events: processed,
lastUpdated: Date.now()
})
return processed
})
}
},
{ needSort: true }
)
subscriptionRef.current = () => closer()
} catch (error) {
if (!cancelled) {
setIsLoading(false)
}
}
}
subscribe()
return () => {
cancelled = true
subscriptionRef.current()
subscriptionRef.current = () => {}
}
}, [pubkey, cacheKey, JSON.stringify(kinds), limit, filterPredicate, refreshToken])
const refresh = useCallback(() => {
subscriptionRef.current()
subscriptionRef.current = () => {}
timelineCache.delete(cacheKey)
setIsLoading(true)
setRefreshToken((token) => token + 1)
}, [cacheKey])
return {
events,
isLoading,
refresh
}
}

58
src/pages/secondary/PostSettingsPage/CacheRelayOnlySetting.tsx

@ -0,0 +1,58 @@ @@ -0,0 +1,58 @@
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { StorageKey } from '@/constants'
import { hasCacheRelays } from '@/lib/private-relays'
import { useNostr } from '@/providers/NostrProvider'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function CacheRelayOnlySetting() {
const { t } = useTranslation()
const { pubkey } = useNostr()
const [enabled, setEnabled] = useState(true) // Default ON
const [hasCacheRelaysAvailable, setHasCacheRelaysAvailable] = useState(false)
useEffect(() => {
// Load from localStorage
const stored = window.localStorage.getItem(StorageKey.USE_CACHE_ONLY_FOR_PRIVATE_NOTES)
setEnabled(stored === null ? true : stored === 'true') // Default to true if not set
// Check if user has cache relays
if (pubkey) {
hasCacheRelays(pubkey)
.then(setHasCacheRelaysAvailable)
.catch(() => setHasCacheRelaysAvailable(false))
} else {
setHasCacheRelaysAvailable(false)
}
}, [pubkey])
const handleEnabledChange = (checked: boolean) => {
setEnabled(checked)
window.localStorage.setItem(StorageKey.USE_CACHE_ONLY_FOR_PRIVATE_NOTES, checked.toString())
}
if (!hasCacheRelaysAvailable) {
return null // Don't show if user doesn't have cache relays
}
return (
<div className="space-y-4">
<h3 className="text-lg font-medium">{t('Private Notes')}</h3>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Label htmlFor="cache-relay-only">{t('Use cache relay only for citations and publication content')}</Label>
<Switch
id="cache-relay-only"
checked={enabled}
onCheckedChange={handleEnabledChange}
/>
</div>
<div className="text-muted-foreground text-xs">
{t('When enabled, citations and publication content (kind 30041) will only be published to your cache relay, not to outbox relays')}
</div>
</div>
</div>
)
}

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

@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next' @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
import MediaUploadServiceSetting from './MediaUploadServiceSetting'
import ExpirationSettings from './ExpirationSettings'
import QuietSettings from './QuietSettings'
import CacheRelayOnlySetting from './CacheRelayOnlySetting'
const PostSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => {
const { t } = useTranslation()
@ -20,6 +21,7 @@ const PostSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: @@ -20,6 +21,7 @@ const PostSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?:
<h3 className="text-lg font-medium">{t('Quiet Tags')}</h3>
<QuietSettings />
</div>
<CacheRelayOnlySetting />
</div>
</SecondaryPageLayout>
)

Loading…
Cancel
Save