Browse Source

bug-fixes

imwald
Silberengel 4 weeks ago
parent
commit
14572f8cdf
  1. 97
      src/components/Note/AsciidocArticle/AsciidocArticle.tsx
  2. 316
      src/components/Note/PublicationIndex/PublicationIndex.tsx
  3. 111
      src/components/NoteOptions/useMenuActions.tsx
  4. 2
      src/components/Profile/ProfilePublicationsFeed.tsx
  5. 46
      src/components/SessionRelaysTab/index.tsx
  6. 34
      src/constants.ts
  7. 13
      src/lib/publication-rendered-events.ts
  8. 164
      src/providers/NostrProvider/index.tsx
  9. 13
      src/services/client-events.service.ts
  10. 44
      src/services/client-macro.service.ts
  11. 21
      src/services/client.service.ts

97
src/components/Note/AsciidocArticle/AsciidocArticle.tsx

@ -337,12 +337,14 @@ export default function AsciidocArticle({ @@ -337,12 +337,14 @@ export default function AsciidocArticle({
event,
className,
hideImagesAndInfo = false,
parentImageUrl
parentImageUrl,
footnotesContainerId
}: {
event: Event
className?: string
hideImagesAndInfo?: boolean
parentImageUrl?: string
footnotesContainerId?: string
}) {
const secondaryPage = useSecondaryPageOptional()
const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url })
@ -967,6 +969,7 @@ export default function AsciidocArticle({ @@ -967,6 +969,7 @@ export default function AsciidocArticle({
// Track citations for footnotes and endnotes sections
const citationsRef = useRef<Array<{ id: string; type: string; citationId: string; index: number }>>([])
const citationIndexRef = useRef(0)
const citationAnchorPrefix = useMemo(() => event.id.toLowerCase(), [event.id])
// Post-process rendered HTML to inject React components for nostr: links and handle hashtags
useEffect(() => {
@ -993,7 +996,7 @@ export default function AsciidocArticle({ @@ -993,7 +996,7 @@ export default function AsciidocArticle({
})
}, 0)
}
// Process nostr: mentions - replace placeholders with React components (inline)
const nostrMentions = contentRef.current.querySelectorAll('.nostr-mention-placeholder[data-nostr-mention]')
nostrMentions.forEach((element) => {
@ -1088,6 +1091,11 @@ export default function AsciidocArticle({ @@ -1088,6 +1091,11 @@ export default function AsciidocArticle({
// Process citations - replace placeholders with React components
// First pass: collect all citations and assign indices
const getCitationAnchorId = (index: number) => `citation-${citationAnchorPrefix}-${index}`
const getCitationRefId = (index: number) => `citation-ref-${citationAnchorPrefix}-${index}`
const footnotesSectionId = `footnotes-section-${citationAnchorPrefix}`
const referencesSectionId = `references-section-${citationAnchorPrefix}`
const citationPlaceholders = Array.from(contentRef.current.querySelectorAll('.citation-placeholder[data-citation]'))
console.log('AsciidocArticle: Found citation placeholders', {
count: citationPlaceholders.length,
@ -1110,7 +1118,7 @@ export default function AsciidocArticle({ @@ -1110,7 +1118,7 @@ export default function AsciidocArticle({
const citationIndex = citationIndexRef.current++
citationsRef.current.push({
id: `citation-${citationIndex}`,
id: getCitationAnchorId(citationIndex),
type: citationType,
citationId,
index: citationIndex
@ -1166,13 +1174,13 @@ export default function AsciidocArticle({ @@ -1166,13 +1174,13 @@ export default function AsciidocArticle({
sup.style.display = 'inline'
sup.style.whiteSpace = 'nowrap'
const link = document.createElement('a')
link.href = `#citation-${citation.index}`
link.id = `citation-ref-${citation.index}`
link.href = `#${getCitationAnchorId(citation.index)}`
link.id = getCitationRefId(citation.index)
link.className = 'text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline no-underline'
link.textContent = `[${citationNumber}]`
link.addEventListener('click', (e) => {
e.preventDefault()
const citationElement = document.getElementById(`citation-${citation.index}`)
const citationElement = document.getElementById(getCitationAnchorId(citation.index))
if (citationElement) {
citationElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
@ -1186,13 +1194,13 @@ export default function AsciidocArticle({ @@ -1186,13 +1194,13 @@ export default function AsciidocArticle({
sup.style.display = 'inline'
sup.style.whiteSpace = 'nowrap'
const link = document.createElement('a')
link.href = '#references-section'
link.id = `citation-ref-${citation.index}`
link.href = `#${referencesSectionId}`
link.id = getCitationRefId(citation.index)
link.className = 'text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline no-underline'
link.textContent = `[${citationNumber}]`
link.addEventListener('click', (e) => {
e.preventDefault()
const refSection = document.getElementById('references-section')
const refSection = document.getElementById(referencesSectionId)
if (refSection) {
refSection.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
@ -1237,10 +1245,14 @@ export default function AsciidocArticle({ @@ -1237,10 +1245,14 @@ export default function AsciidocArticle({
}
const parentContainer = contentRef.current.parentElement
const externalReferencesContainer = footnotesContainerId
? document.getElementById(footnotesContainerId)
: null
const referencesTargetContainer = externalReferencesContainer ?? parentContainer
// Check if sections already exist
const existingFootnotes = parentContainer.querySelector('#footnotes-section')
const existingReferences = parentContainer.querySelector('#references-section')
// Footnotes stay at section-level. Endnotes can target publication-level container.
const existingFootnotes = parentContainer.querySelector(`#${footnotesSectionId}`)
const existingReferences = referencesTargetContainer.querySelector(`#${referencesSectionId}`)
// If sections already exist and we have no new citations, preserve existing sections
// This handles the case where useEffect runs again after placeholders are replaced
@ -1273,8 +1285,8 @@ export default function AsciidocArticle({ @@ -1273,8 +1285,8 @@ export default function AsciidocArticle({
// Render footnotes section
if (footnotes.length > 0) {
const footnotesSection = document.createElement('div')
footnotesSection.id = 'footnotes-section'
footnotesSection.className = 'mt-8 pt-4 border-t border-gray-300 dark:border-gray-700'
footnotesSection.id = footnotesSectionId
footnotesSection.className = 'asciidoc-footnotes-section mt-8 pt-4 border-t border-gray-300 dark:border-gray-700'
const h3 = document.createElement('h3')
h3.className = 'text-lg font-semibold mb-4'
@ -1297,14 +1309,14 @@ export default function AsciidocArticle({ @@ -1297,14 +1309,14 @@ export default function AsciidocArticle({
li.appendChild(citationContainer)
const backLink = document.createElement('a')
backLink.href = `#citation-ref-${citation.index}`
backLink.href = `#${getCitationRefId(citation.index)}`
backLink.className = 'text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline text-xs ml-2 inline-flex items-center'
backLink.setAttribute('aria-label', 'Return to citation')
// Use hyperlink icon instead of emoji
backLink.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>'
backLink.addEventListener('click', (e) => {
e.preventDefault()
const refElement = document.getElementById(`citation-ref-${citation.index}`)
const refElement = document.getElementById(getCitationRefId(citation.index))
if (refElement) {
refElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
@ -1349,12 +1361,11 @@ export default function AsciidocArticle({ @@ -1349,12 +1361,11 @@ export default function AsciidocArticle({
})
footnotesSection.appendChild(ol)
// Insert after contentRef div - use insertAdjacentElement for more reliable insertion
// Footnotes always stay at the bottom of this section.
contentRef.current.insertAdjacentElement('afterend', footnotesSection)
// Verify insertion
const insertedFootnotes = parentContainer.querySelector('#footnotes-section')
const insertedFootnotes = parentContainer.querySelector(`#${footnotesSectionId}`)
console.log('AsciidocArticle: Footnotes section created and inserted', {
footnotesCount: footnotes.length,
parentTagName: parentContainer.tagName,
@ -1368,8 +1379,8 @@ export default function AsciidocArticle({ @@ -1368,8 +1379,8 @@ export default function AsciidocArticle({
// Render references section
if (endCitations.length > 0) {
const referencesSection = document.createElement('div')
referencesSection.id = 'references-section'
referencesSection.className = 'mt-8 pt-4 border-t border-gray-300 dark:border-gray-700'
referencesSection.id = referencesSectionId
referencesSection.className = 'asciidoc-references-section mt-8 pt-4 border-t border-gray-300 dark:border-gray-700'
const h3 = document.createElement('h3')
h3.className = 'text-lg font-semibold mb-4'
@ -1383,7 +1394,7 @@ export default function AsciidocArticle({ @@ -1383,7 +1394,7 @@ export default function AsciidocArticle({
endCitations.forEach((citation) => {
const li = document.createElement('li')
li.id = `citation-end-${citation.index}`
li.id = `citation-end-${citationAnchorPrefix}-${citation.index}`
li.className = 'text-sm pl-2'
li.style.display = 'list-item'
@ -1396,13 +1407,13 @@ export default function AsciidocArticle({ @@ -1396,13 +1407,13 @@ export default function AsciidocArticle({
citationWrapper.appendChild(citationContainer)
const backLink = document.createElement('a')
backLink.href = `#citation-ref-${citation.index}`
backLink.href = `#${getCitationRefId(citation.index)}`
backLink.className = 'text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline text-xs ml-2 inline-flex items-center'
backLink.setAttribute('aria-label', 'Return to citation')
backLink.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>'
backLink.addEventListener('click', (e) => {
e.preventDefault()
const refElement = document.getElementById(`citation-ref-${citation.index}`)
const refElement = document.getElementById(getCitationRefId(citation.index))
if (refElement) {
refElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
@ -1441,22 +1452,20 @@ export default function AsciidocArticle({ @@ -1441,22 +1452,20 @@ export default function AsciidocArticle({
})
referencesSection.appendChild(ol)
// Insert after footnotes section if it exists, otherwise after contentRef
const footnotesSection = parentContainer.querySelector('#footnotes-section')
if (footnotesSection) {
// Insert after footnotes section
footnotesSection.insertAdjacentElement('afterend', referencesSection)
const insertedFootnotesSection = parentContainer.querySelector(`#${footnotesSectionId}`)
if (insertedFootnotesSection && !externalReferencesContainer) {
insertedFootnotesSection.insertAdjacentElement('afterend', referencesSection)
} else if (externalReferencesContainer) {
externalReferencesContainer.appendChild(referencesSection)
} else {
// No footnotes section, insert after contentRef
contentRef.current.insertAdjacentElement('afterend', referencesSection)
}
// Verify insertion
const insertedReferences = parentContainer.querySelector('#references-section')
const insertedReferences = referencesTargetContainer.querySelector(`#${referencesSectionId}`)
console.log('AsciidocArticle: References section created and inserted', {
endCitationsCount: endCitations.length,
hasFootnotesSection: !!footnotesSection,
hasFootnotesSection: !!insertedFootnotesSection,
sectionId: referencesSection.id,
isInDOM: !!insertedReferences,
sectionHTML: insertedReferences?.outerHTML?.substring(0, 200)
@ -1721,7 +1730,7 @@ export default function AsciidocArticle({ @@ -1721,7 +1730,7 @@ export default function AsciidocArticle({
// No cleanup needed here - we only clean up disconnected roots above
// Full cleanup happens on component unmount
}, [parsedHtml, isLoading, navigateToHashtag, navigateToRelay])
}, [parsedHtml, isLoading, navigateToHashtag, navigateToRelay, footnotesContainerId, citationAnchorPrefix, event.id])
// Cleanup on component unmount
useEffect(() => {
@ -1876,13 +1885,17 @@ export default function AsciidocArticle({ @@ -1876,13 +1885,17 @@ export default function AsciidocArticle({
}
/* Academic references section formatting */
.asciidoc-content #references-section ol,
.asciidoc-content #footnotes-section ol {
.asciidoc-content #footnotes-section ol,
.asciidoc-references-section ol,
.asciidoc-footnotes-section ol {
list-style: decimal;
padding-left: 1.5rem;
list-style-position: outside;
}
.asciidoc-content #references-section li,
.asciidoc-content #footnotes-section li {
.asciidoc-content #footnotes-section li,
.asciidoc-references-section li,
.asciidoc-footnotes-section li {
padding-left: 0.5rem;
margin-bottom: 0.5rem;
line-height: 1.6;
@ -1890,20 +1903,26 @@ export default function AsciidocArticle({ @@ -1890,20 +1903,26 @@ export default function AsciidocArticle({
}
/* Position backlink at end of first line */
.asciidoc-content #references-section li > div > span > div:first-child,
.asciidoc-content #footnotes-section li > div > span > div:first-child {
.asciidoc-content #footnotes-section li > div > span > div:first-child,
.asciidoc-references-section li > div > span > div:first-child,
.asciidoc-footnotes-section li > div > span > div:first-child {
position: relative;
display: inline-block;
width: 100%;
}
.asciidoc-content #references-section h3,
.asciidoc-content #footnotes-section h3 {
.asciidoc-content #footnotes-section h3,
.asciidoc-references-section h3,
.asciidoc-footnotes-section h3 {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 1rem;
}
/* Blockquote spacing in citations */
.asciidoc-content #references-section blockquote,
.asciidoc-content #footnotes-section blockquote {
.asciidoc-content #footnotes-section blockquote,
.asciidoc-references-section blockquote,
.asciidoc-footnotes-section blockquote {
padding-left: 1.5rem !important;
}
`}</style>

316
src/components/Note/PublicationIndex/PublicationIndex.tsx

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
import { ExtendedKind } from '@/constants'
import { Event, kinds, nip19 } from 'nostr-tools'
import { useEffect, useMemo, useState, useCallback } from 'react'
import { useEffect, useMemo, useState, useCallback, useSyncExternalStore } from 'react'
import { usePublicationSectionLoader } from '@/hooks/usePublicationSectionLoader'
import { parsePublicationATagCoordinate, publicationRefKey } from '@/lib/publication-section-fetch'
import { cn } from '@/lib/utils'
@ -17,7 +17,12 @@ import { extractBookMetadata } from '@/lib/bookstr-parser' @@ -17,7 +17,12 @@ import { extractBookMetadata } from '@/lib/bookstr-parser'
import { dTagToTitleCase } from '@/lib/event-metadata'
import Image from '@/components/Image'
import NoteOptions from '@/components/NoteOptions'
import { upsertRenderedPublicationEvents } from '@/lib/publication-rendered-events'
import {
getRenderedPublicationEventsVersion,
getRenderedPublicationEventsDeep,
subscribeRenderedPublicationEvents,
upsertRenderedPublicationEvents
} from '@/lib/publication-rendered-events'
interface PublicationReference {
coordinate?: string
@ -50,6 +55,9 @@ interface PublicationMetadata { @@ -50,6 +55,9 @@ interface PublicationMetadata {
author?: string
version?: string
type?: string
source?: string
publishedOn?: string
publishedBy?: string
tags: string[]
}
@ -87,12 +95,18 @@ export default function PublicationIndex({ @@ -87,12 +95,18 @@ export default function PublicationIndex({
event,
className,
isNested = false,
parentImageUrl
parentImageUrl,
flattenHierarchy = false,
chapterDepth = 0,
publicationFootnotesContainerId
}: {
event: Event
className?: string
isNested?: boolean
parentImageUrl?: string
flattenHierarchy?: boolean
chapterDepth?: number
publicationFootnotesContainerId?: string
}) {
const secondaryPage = useSecondaryPageOptional()
const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url })
@ -113,6 +127,12 @@ export default function PublicationIndex({ @@ -113,6 +127,12 @@ export default function PublicationIndex({
meta.version = tagValue
} else if (tagName === 'type') {
meta.type = tagValue
} else if (tagName === 'source') {
meta.source = tagValue
} else if (tagName === 'published_on') {
meta.publishedOn = tagValue
} else if (tagName === 'published_by') {
meta.publishedBy = tagValue
} else if (tagName === 't' && tagValue) {
meta.tags.push(tagValue.toLowerCase())
}
@ -130,6 +150,14 @@ export default function PublicationIndex({ @@ -130,6 +150,14 @@ export default function PublicationIndex({
}, [event])
const bookMetadata = useMemo(() => extractBookMetadata(event), [event])
const isBookstrEvent = (event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION_CONTENT) && !!bookMetadata.book
const isTopLevelPublication = !isNested && event.kind === ExtendedKind.PUBLICATION
const forceFlatHierarchy = flattenHierarchy || isBookstrEvent || isTopLevelPublication
const resolvedPublicationFootnotesContainerId = useMemo(
() =>
publicationFootnotesContainerId ??
(isTopLevelPublication ? `publication-footnotes-${event.id}` : undefined),
[publicationFootnotesContainerId, isTopLevelPublication, event.id]
)
const [isRetrying, setIsRetrying] = useState(false)
// Extract references from 'a' tags (addressable events) and 'e' tags (event IDs)
@ -165,6 +193,11 @@ export default function PublicationIndex({ @@ -165,6 +193,11 @@ export default function PublicationIndex({
const { retryKeys, failedKeys, referencesWithEvents } =
usePublicationSectionLoader(event, referencesData)
const renderedEventsVersion = useSyncExternalStore(
subscribeRenderedPublicationEvents,
getRenderedPublicationEventsVersion,
getRenderedPublicationEventsVersion
)
// Helper function to format bookstr titles (remove hyphens, title case)
const formatBookstrTitle = useCallback((title: string, event?: Event): string => {
@ -190,6 +223,20 @@ export default function PublicationIndex({ @@ -190,6 +223,20 @@ export default function PublicationIndex({
const tableOfContents = useMemo<ToCItem[]>(() => {
const toc: ToCItem[] = []
const coordinateOfEvent = (ev: Event): string | null => {
const d = ev.tags.find((tag) => tag[0] === 'd')?.[1]
if (!d) return null
return `${ev.kind}:${ev.pubkey.toLowerCase()}:${d}`
}
const titleFromEvent = (ev: Event): string => {
const titleTag = ev.tags.find((tag) => tag[0] === 'title')?.[1]
if (titleTag) return titleTag
const dTag = ev.tags.find((tag) => tag[0] === 'd')?.[1]
if (dTag) return formatBookstrTitle(dTag, ev)
return 'Untitled'
}
const titleFromIdentifier = (identifier: string, kind?: number) => {
const raw = identifier || 'Untitled'
if (
@ -206,19 +253,26 @@ export default function PublicationIndex({ @@ -206,19 +253,26 @@ export default function PublicationIndex({
return raw
}
const knownByCoordinate = new Map<string, Event>()
for (const ref of referencesWithEvents) {
if (!ref.event) continue
const coord = coordinateOfEvent(ref.event)
if (coord) knownByCoordinate.set(coord, ref.event)
}
for (const ev of getRenderedPublicationEventsDeep(event.id)) {
const coord = coordinateOfEvent(ev)
if (coord && !knownByCoordinate.has(coord)) {
knownByCoordinate.set(coord, ev)
}
}
for (const ref of referencesWithEvents) {
const coord = ref.coordinate || ref.eventId || ''
if (!coord) continue
let title: string
if (ref.event) {
const titleTag = ref.event.tags.find((tag) => tag[0] === 'title')?.[1]
const dTag = ref.event.tags.find((tag) => tag[0] === 'd')?.[1]
let rawTitle: string
if (titleTag) rawTitle = titleTag
else if (dTag) rawTitle = dTag
else rawTitle = 'Untitled'
title = titleTag ? rawTitle : formatBookstrTitle(rawTitle, ref.event)
title = titleFromEvent(ref.event)
} else if (ref.type === 'a' && ref.kind === kinds.ShortTextNote) {
title = 'Note'
} else if (ref.type === 'a' && ref.identifier) {
@ -241,35 +295,30 @@ export default function PublicationIndex({ @@ -241,35 +295,30 @@ export default function PublicationIndex({
// Parse nested references from this publication
for (const tag of ref.event.tags) {
if (tag[0] === 'a' && tag[1]) {
const [kindStr, , identifier] = tag[1].split(':')
const kind = parseInt(kindStr)
const parsed = parsePublicationATagCoordinate(tag[1])
if (!parsed) continue
const kind = parsed.kind
if (
!isNaN(kind) &&
(kind === ExtendedKind.PUBLICATION_CONTENT ||
kind === ExtendedKind.WIKI_ARTICLE ||
kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN ||
kind === kinds.LongFormArticle ||
kind === kinds.ShortTextNote ||
kind === ExtendedKind.PUBLICATION)
kind === ExtendedKind.PUBLICATION_CONTENT ||
kind === ExtendedKind.WIKI_ARTICLE ||
kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN ||
kind === kinds.LongFormArticle ||
kind === kinds.ShortTextNote ||
kind === ExtendedKind.PUBLICATION
) {
// For this simplified version, we'll just extract the title from the coordinate
const rawNestedTitle = identifier || 'Untitled'
// Format for bookstr events (check if kind is bookstr-related)
const nestedTitle =
kind === ExtendedKind.PUBLICATION || kind === ExtendedKind.PUBLICATION_CONTENT
? rawNestedTitle
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ')
: kind === kinds.ShortTextNote
? 'Note'
: rawNestedTitle
const knownNestedEvent = knownByCoordinate.get(parsed.coordinate)
const nestedTitle = knownNestedEvent
? titleFromEvent(knownNestedEvent)
: kind === kinds.ShortTextNote
? 'Note'
: titleFromIdentifier(parsed.identifier, kind)
nestedRefs.push({
title: nestedTitle,
coordinate: tag[1],
kind
coordinate: parsed.coordinate,
kind,
event: knownNestedEvent
})
}
}
@ -284,7 +333,7 @@ export default function PublicationIndex({ @@ -284,7 +333,7 @@ export default function PublicationIndex({
}
return toc
}, [referencesWithEvents, formatBookstrTitle])
}, [referencesWithEvents, formatBookstrTitle, event.id, renderedEventsVersion])
// Scroll to ToC (scroll to top of page)
const scrollToToc = useCallback(() => {
@ -366,62 +415,82 @@ export default function PublicationIndex({ @@ -366,62 +415,82 @@ export default function PublicationIndex({
{!isNested && (
<div className="prose prose-zinc max-w-none dark:prose-invert">
<header className="mb-8 border-b pb-6">
<div className="flex items-start justify-between gap-4 mb-4">
{metadata.title && <h1 className="text-4xl font-bold leading-tight break-words flex-1">{metadata.title}</h1>}
{!metadata.title && isBookstrEvent && (
<div className="flex-1">
<h1 className="text-4xl font-bold leading-tight break-words">
{bookMetadata.book
<div className="mb-6 rounded-xl border border-border/50 bg-muted/20 px-5 py-6 text-center">
<div className="text-[11px] uppercase tracking-[0.22em] text-muted-foreground/80 mb-2">
Publication
</div>
<h1 className="font-serif text-4xl md:text-5xl font-semibold leading-tight tracking-wide break-words">
{metadata.title ||
(isBookstrEvent
? bookMetadata.book
? bookMetadata.book
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ')
: 'Bookstr Publication'}
</h1>
: 'Bookstr Publication'
: 'Untitled Publication')}
</h1>
{metadata.author && (
<div className="mt-3 text-sm text-muted-foreground">
by <span className="font-medium text-foreground/90">{metadata.author}</span>
</div>
)}
</div>
{metadata.summary && (
<blockquote className="border-l-4 border-primary pl-6 italic text-muted-foreground mb-4 text-lg leading-relaxed">
<p className="break-words">{metadata.summary}</p>
</blockquote>
)}
{/* Display image for top-level 30040 publication */}
{metadata.image && (
<div className="mb-4">
<Image
image={{ url: metadata.image, pubkey: event.pubkey }}
className="max-w-[400px] w-full h-auto rounded-lg"
classNames={{
wrapper: 'rounded-lg',
errorPlaceholder: 'aspect-square h-[30vh]'
}}
/>
</div>
)}
<div className="text-sm text-muted-foreground space-y-1">
{metadata.author && (
<div>
<span className="font-semibold">Author:</span> {metadata.author}
{(metadata.type || metadata.version || metadata.publishedOn || metadata.publishedBy) && (
<div className="mt-4 flex flex-wrap justify-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
{metadata.type && <span>Type: {metadata.type}</span>}
{metadata.version && <span>Version: {metadata.version}</span>}
{metadata.publishedOn && <span>Published: {metadata.publishedOn}</span>}
{metadata.publishedBy && <span>Publisher: {metadata.publishedBy}</span>}
</div>
)}
{metadata.tags.length > 0 && (
<div className="mt-4 flex flex-wrap justify-center gap-2">
{metadata.tags.map((tag) => (
<span
key={tag}
className="rounded-full border border-border/60 px-2.5 py-0.5 text-[11px] uppercase tracking-wide text-muted-foreground"
>
{tag}
</span>
))}
</div>
)}
{metadata.version && !isBookstrEvent && (
<div>
<span className="font-semibold">Version:</span> {metadata.version}
{metadata.source && (
<div className="mt-4 text-xs text-muted-foreground">
Source:{' '}
<a
href={metadata.source}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline break-all"
>
{metadata.source}
</a>
</div>
)}
{metadata.type && !isBookstrEvent && (
<div>
<span className="font-semibold">Type:</span> {metadata.type}
{metadata.summary && (
<blockquote className="mt-5 border-l-4 border-primary/70 pl-4 pr-2 italic text-muted-foreground text-left leading-relaxed">
<p className="break-words">{metadata.summary}</p>
</blockquote>
)}
{/* Display image for top-level 30040 publication */}
{metadata.image && (
<div className="mt-5 flex justify-center">
<Image
image={{ url: metadata.image, pubkey: event.pubkey }}
className="max-w-[400px] w-full h-auto rounded-lg"
classNames={{
wrapper: 'rounded-lg',
errorPlaceholder: 'aspect-square h-[30vh]'
}}
/>
</div>
)}
<div className="mt-5 mx-auto h-px w-24 bg-border/70" />
</div>
<div className="text-sm text-muted-foreground space-y-1">
{isBookstrEvent && (
<>
{bookMetadata.type && (
<div>
<span className="font-semibold">Type:</span> {bookMetadata.type}
</div>
)}
{bookMetadata.book && (
<div>
<span className="font-semibold">Book:</span> {bookMetadata.book
@ -455,7 +524,7 @@ export default function PublicationIndex({ @@ -455,7 +524,7 @@ export default function PublicationIndex({
{/* Table of Contents - only show for top-level publications */}
{!isNested && tableOfContents.length > 0 && (
<div id="publication-toc" className="border rounded-lg p-6 bg-muted/30 scroll-mt-24">
<h2 className="text-xl font-semibold mb-4">Table of Contents</h2>
<h2 className="font-serif text-2xl font-semibold tracking-wide mb-4">Table of Contents</h2>
<nav>
<ul className="space-y-2">
{tableOfContents.map((item, index) => (
@ -562,31 +631,84 @@ export default function PublicationIndex({ @@ -562,31 +631,84 @@ export default function PublicationIndex({
const effectiveParentImageUrl = !isNested ? metadata.image : parentImageUrl
if (eventKind === ExtendedKind.PUBLICATION) {
const publicationTitleTag = ref.event.tags.find((tag) => tag[0] === 'title')?.[1]
const publicationDTag = ref.event.tags.find((tag) => tag[0] === 'd')?.[1]
const publicationTitle = publicationTitleTag
? publicationTitleTag
: publicationDTag
? formatBookstrTitle(publicationDTag, ref.event)
: 'Publication'
const publicationDepth = chapterDepth + 1
const sectionTitleClassName =
publicationDepth <= 1
? 'font-serif text-2xl md:text-3xl font-semibold leading-tight tracking-wide break-words'
: publicationDepth === 2
? 'font-serif text-xl md:text-2xl font-medium leading-tight tracking-wide break-words text-muted-foreground'
: 'font-serif text-lg md:text-xl font-medium leading-tight tracking-wide break-words text-muted-foreground'
const useInlinePublicationHeader = forceFlatHierarchy
const publicationContainerClassName = isNested
? forceFlatHierarchy
? 'scroll-mt-24 pt-6 relative'
: 'border-l-4 border-primary pl-6 scroll-mt-24 pt-6 relative'
: 'scroll-mt-24 pt-6 relative'
return (
<div
key={sectionKey || index}
id={sectionId}
className="border-l-4 border-primary pl-6 scroll-mt-24 pt-6 relative"
className={publicationContainerClassName}
>
<div className="absolute top-0 right-0 flex items-center gap-2">
{!isNested && (
<Button
variant="ghost"
size="sm"
className="opacity-70 hover:opacity-100"
onClick={scrollToToc}
title="Back to Table of Contents"
>
<ArrowUp className="h-4 w-4 mr-2" />
ToC
</Button>
)}
<NoteOptions event={ref.event} />
</div>
{useInlinePublicationHeader ? (
<div className="mb-4 rounded-lg border border-border/50 bg-muted/20 px-4 py-3">
<div className="flex items-start justify-end gap-2 mb-2">
<div className="flex items-center gap-2 shrink-0">
{!isNested && (
<Button
variant="ghost"
size="sm"
className="opacity-70 hover:opacity-100"
onClick={scrollToToc}
title="Back to Table of Contents"
>
<ArrowUp className="h-4 w-4 mr-2" />
ToC
</Button>
)}
<NoteOptions event={ref.event} />
</div>
</div>
<div className="text-center">
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground/80 mb-1">
Section
</div>
<h3 className={sectionTitleClassName}>
{publicationTitle}
</h3>
</div>
</div>
) : (
<div className="absolute top-0 right-0 flex items-center gap-2">
{!isNested && (
<Button
variant="ghost"
size="sm"
className="opacity-70 hover:opacity-100"
onClick={scrollToToc}
title="Back to Table of Contents"
>
<ArrowUp className="h-4 w-4 mr-2" />
ToC
</Button>
)}
<NoteOptions event={ref.event} />
</div>
)}
<PublicationIndex
event={ref.event}
isNested={true}
parentImageUrl={effectiveParentImageUrl}
flattenHierarchy={forceFlatHierarchy}
chapterDepth={publicationDepth}
publicationFootnotesContainerId={resolvedPublicationFootnotesContainerId}
/>
</div>
)
@ -618,6 +740,7 @@ export default function PublicationIndex({ @@ -618,6 +740,7 @@ export default function PublicationIndex({
event={ref.event}
hideImagesAndInfo={true}
parentImageUrl={effectiveParentImageUrl}
footnotesContainerId={resolvedPublicationFootnotesContainerId}
/>
</div>
)
@ -651,6 +774,9 @@ export default function PublicationIndex({ @@ -651,6 +774,9 @@ export default function PublicationIndex({
})}
</div>
)}
{isTopLevelPublication && resolvedPublicationFootnotesContainerId && (
<div id={resolvedPublicationFootnotesContainerId} className="mt-10 space-y-8" />
)}
</div>
)
}
@ -671,7 +797,7 @@ function ToCItemComponent({ @@ -671,7 +797,7 @@ function ToCItemComponent({
<li className={cn('list-none', indentClass)}>
<button
onClick={() => onItemClick(item.coordinate)}
className="text-left text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline cursor-pointer"
className="font-serif text-left text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline cursor-pointer tracking-wide"
>
{item.title}
</button>

111
src/components/NoteOptions/useMenuActions.tsx

@ -462,16 +462,14 @@ export function useMenuActions({ @@ -462,16 +462,14 @@ export function useMenuActions({
}, [isArticleType, event, dTag])
const menuActions: MenuAction[] = useMemo(() => {
const rebroadcastEntirePublication = () => {
const rebroadcastEntirePublication = (selectedRelayUrls: string[]) => {
const rootPublication = event
closeDrawer()
const promise = (async () => {
if (rootPublication.kind !== ExtendedKind.PUBLICATION) {
throw new Error(t('This action is only available for publications'))
}
if (allAvailableRelayUrls.length === 0) {
if (selectedRelayUrls.length === 0) {
throw new Error(t('No relays available'))
}
@ -524,7 +522,7 @@ export function useMenuActions({ @@ -524,7 +522,7 @@ export function useMenuActions({
const primaryRelays = await buildPublicationSectionRelayUrls(currentPublication, refs, 40, false)
const fallbackRelays = await buildPublicationSectionRelayUrls(currentPublication, refs, 80, true)
const relays = [...new Set([...primaryRelays, ...fallbackRelays, ...allAvailableRelayUrls])]
const relays = [...new Set([...primaryRelays, ...fallbackRelays, ...selectedRelayUrls])]
const resolved = await batchFetchPublicationSectionEvents(refs, relays)
for (const ev of resolved.values()) {
@ -558,7 +556,7 @@ export function useMenuActions({ @@ -558,7 +556,7 @@ export function useMenuActions({
const batch = uniqueEvents.slice(i, i + BATCH_SIZE)
const batchResults = await Promise.allSettled(
batch.map(async (ev) => {
const result = await client.publishEvent(allAvailableRelayUrls, ev)
const result = await client.publishEvent(selectedRelayUrls, ev)
if (result.successCount > 0) {
acceptedEvents++
acceptedRelayAcks += result.successCount
@ -592,6 +590,102 @@ export function useMenuActions({ @@ -592,6 +590,102 @@ export function useMenuActions({
})
}
const publicationBroadcastSubMenu: SubMenuAction[] = []
if (event.kind === ExtendedKind.PUBLICATION) {
if (allAvailableRelayUrls.length > 0) {
publicationBroadcastSubMenu.push({
label: <div className="text-left">{t('All available relays')} ({allAvailableRelayUrls.length})</div>,
onClick: () => {
closeDrawer()
rebroadcastEntirePublication(allAvailableRelayUrls)
}
})
}
const activeRelayCount =
monitoringListRelayCount !== null
? (monitoringListRelayCount > 0 ? monitoringListRelayCount : allAvailableRelayUrls.length)
: null
publicationBroadcastSubMenu.push({
label: (
<div className="text-left">
{t('All active relays (monitoring list)')}
{activeRelayCount !== null && ` (${activeRelayCount})`}
</div>
),
separator: publicationBroadcastSubMenu.length > 0,
onClick: () => {
closeDrawer()
const promise = (async () => {
let relays = await nip66Service.getPublicLivelyRelayUrls()
const usedMonitoringList = !!relays?.length
if (!relays?.length) relays = allAvailableRelayUrls
if (!relays?.length) throw new Error(t('No relays available'))
rebroadcastEntirePublication(relays)
return usedMonitoringList
})()
// Trigger async relay resolution immediately; rebroadcast handles its own toasts.
void promise.catch((err) => {
toast.error(t('Failed to rebroadcast entire publication: {{error}}', { error: err.message }))
})
}
})
if (pubkey && event.pubkey === pubkey) {
publicationBroadcastSubMenu.push({
label: <div className="text-left">{t('Write relays')}</div>,
separator: publicationBroadcastSubMenu.length > 0,
onClick: async () => {
closeDrawer()
try {
const relays = await client.determineTargetRelays(event)
if (!relays?.length) throw new Error(t('No write relays configured'))
rebroadcastEntirePublication(relays)
} catch (err) {
toast.error(
t('Failed to rebroadcast entire publication: {{error}}', {
error: (err as Error).message
})
)
}
}
})
}
if (relaySets.length) {
publicationBroadcastSubMenu.push(
...relaySets
.filter((set) => set.relayUrls.length)
.map((set, index) => ({
label: <div className="text-left truncate">{set.name}</div>,
separator: index === 0,
onClick: () => {
closeDrawer()
rebroadcastEntirePublication(set.relayUrls)
}
}))
)
}
if (relayUrls.length) {
publicationBroadcastSubMenu.push(
...relayUrls.map((relay, index) => ({
label: (
<div className="flex items-center gap-2 w-full">
<RelayIcon url={relay} />
<div className="flex-1 truncate text-left">{simplifyUrl(relay)}</div>
</div>
),
separator: index === 0,
onClick: () => {
closeDrawer()
rebroadcastEntirePublication([relay])
}
}))
)
}
}
// Export functions for articles
const exportAsMarkdown = () => {
if (!isArticleType) return
@ -921,7 +1015,10 @@ export function useMenuActions({ @@ -921,7 +1015,10 @@ export function useMenuActions({
actions.push({
icon: SatelliteDish,
label: t('Rebroadcast entire publication'),
onClick: rebroadcastEntirePublication,
onClick: isSmallScreen
? () => showSubMenuActions(publicationBroadcastSubMenu, t('Rebroadcast entire publication to ...'))
: undefined,
subMenu: isSmallScreen ? undefined : publicationBroadcastSubMenu,
separator: true
})
}

2
src/components/Profile/ProfilePublicationsFeed.tsx

@ -9,7 +9,7 @@ const ProfilePublicationsFeed = forwardRef<{ refresh: () => void }, { pubkey: st @@ -9,7 +9,7 @@ const ProfilePublicationsFeed = forwardRef<{ refresh: () => void }, { pubkey: st
const [searchQuery, setSearchQuery] = useState('')
const kindsList = useMemo(() => [...PROFILE_PUBLICATIONS_TAB_KINDS], [])
const cacheKey = useMemo(() => `${pubkey}-profile-publications`, [pubkey])
const cacheKey = useMemo(() => `${pubkey}-profile-publications-v2`, [pubkey])
const getKindLabel = (_kindValue: string) => t('articles and publications')

46
src/components/SessionRelaysTab/index.tsx

@ -1,8 +1,10 @@ @@ -1,8 +1,10 @@
import client from '@/services/client.service'
import relayInfoService from '@/services/relay-info.service'
import { useTranslation } from 'react-i18next'
import { useCallback, useEffect, useState } from 'react'
import { RefreshCw, CheckCircle2, XCircle, Zap, RotateCcw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import type { TRelayInfo } from '@/types'
type SessionDebug = {
strikedUrls: string[]
@ -18,6 +20,7 @@ function loadDebug(): SessionDebug { @@ -18,6 +20,7 @@ function loadDebug(): SessionDebug {
export default function SessionRelaysTab() {
const { t } = useTranslation()
const [debug, setDebug] = useState<SessionDebug | null>(null)
const [relayInfoByUrl, setRelayInfoByUrl] = useState<Record<string, TRelayInfo | undefined>>({})
const refresh = useCallback(() => {
setDebug(loadDebug())
@ -27,6 +30,31 @@ export default function SessionRelaysTab() { @@ -27,6 +30,31 @@ export default function SessionRelaysTab() {
refresh()
}, [refresh])
useEffect(() => {
if (debug === null) return
const urls = Array.from(
new Set([
...debug.presetWorking,
...debug.presetStriked,
...debug.strikedUrls,
...debug.scoredRelays.map((r) => r.url)
])
)
if (urls.length === 0) return
let cancelled = false
void relayInfoService.getRelayInfos(urls).then((infos) => {
if (cancelled) return
const next: Record<string, TRelayInfo | undefined> = {}
infos.forEach((info, idx) => {
next[urls[idx]!] = info
})
setRelayInfoByUrl(next)
})
return () => {
cancelled = true
}
}, [debug])
if (debug === null) return null
const clearStrikeForUrl = (url: string) => {
@ -34,15 +62,21 @@ export default function SessionRelaysTab() { @@ -34,15 +62,21 @@ export default function SessionRelaysTab() {
refresh()
}
const formatUrl = (url: string) => {
const formatRelayAddress = (url: string) => {
try {
const u = new URL(url)
return u.hostname || url
return u.host || url // host keeps explicit port when present
} catch {
return url
}
}
const formatRelayLabel = (url: string) => {
const name = relayInfoByUrl[url]?.name?.trim()
if (name) return name
return formatRelayAddress(url)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
@ -69,7 +103,7 @@ export default function SessionRelaysTab() { @@ -69,7 +103,7 @@ export default function SessionRelaysTab() {
) : (
debug.presetWorking.map((url) => (
<li key={url} className="truncate" title={url}>
{formatUrl(url)}
{formatRelayLabel(url)}
</li>
))
)}
@ -91,7 +125,7 @@ export default function SessionRelaysTab() { @@ -91,7 +125,7 @@ export default function SessionRelaysTab() {
debug.presetStriked.map((url) => (
<li key={url} className="flex items-center justify-between gap-2">
<span className="min-w-0 truncate font-mono" title={url}>
{formatUrl(url)}
{formatRelayLabel(url)}
</span>
<Button
type="button"
@ -125,7 +159,7 @@ export default function SessionRelaysTab() { @@ -125,7 +159,7 @@ export default function SessionRelaysTab() {
debug.scoredRelays.map(({ url, successCount, avgLatencyMs }) => (
<li key={url} className="flex justify-between items-center gap-2 font-mono">
<span className="truncate min-w-0" title={url}>
{formatUrl(url)}
{formatRelayLabel(url)}
</span>
<span className="shrink-0 text-muted-foreground text-xs">
{successCount} {t('successes')} · ~{avgLatencyMs} ms
@ -145,7 +179,7 @@ export default function SessionRelaysTab() { @@ -145,7 +179,7 @@ export default function SessionRelaysTab() {
{debug.strikedUrls.map((url) => (
<li key={url} className="flex items-center justify-between gap-2 text-muted-foreground">
<span className="min-w-0 truncate font-mono" title={url}>
{formatUrl(url)}
{formatRelayLabel(url)}
</span>
<Button
type="button"

34
src/constants.ts

@ -228,6 +228,15 @@ export const BOOKSTR_RELAY_URLS = [ @@ -228,6 +228,15 @@ export const BOOKSTR_RELAY_URLS = [
'wss://orly-relay.imwald.eu'
]
/**
* Primary document relay for long-form/wiki/publication kinds:
* 30023, 30818, 30817, 30041, 30040.
*/
export const DOCUMENT_RELAY_URLS = [
'wss://thecitadel.nostr1.com',
'wss://relay.wikifreedia.xyz'
] as const
/**
* Block-list order (applied in sequence when building relay lists):
* 1. READ_ONLY never publish (search mirrors, index relays, NIP-42 read-only aggregators)
@ -498,6 +507,30 @@ export function relayFilterIncludesSocialKindBlockedKind(filter: Filter): boolea @@ -498,6 +507,30 @@ export function relayFilterIncludesSocialKindBlockedKind(filter: Filter): boolea
return arr.some((kind) => SOCIAL_KIND_BLOCKED_KIND_SET.has(kind))
}
/**
* Document/event kinds that should always include {@link DOCUMENT_RELAY_URLS} in read/publish relay candidates.
*/
export const DOCUMENT_RELAY_KINDS: readonly number[] = [
kinds.LongFormArticle, // 30023
ExtendedKind.WIKI_ARTICLE, // 30818
ExtendedKind.WIKI_ARTICLE_MARKDOWN, // 30817
ExtendedKind.PUBLICATION_CONTENT, // 30041
ExtendedKind.PUBLICATION // 30040
]
const DOCUMENT_RELAY_KIND_SET = new Set<number>(DOCUMENT_RELAY_KINDS)
export function isDocumentRelayKind(kind: number): boolean {
return DOCUMENT_RELAY_KIND_SET.has(kind)
}
export function relayFilterIncludesDocumentRelayKind(filter: Filter): boolean {
const k = filter.kinds
if (k === undefined) return false
const arr = Array.isArray(k) ? k : [k]
return arr.some((kind) => DOCUMENT_RELAY_KIND_SET.has(kind))
}
/**
* After dropping {@link SOCIAL_KIND_BLOCKED_RELAY_URLS} from a relay stack: if every URL was removed but the caller
* passed exactly one relay (e.g. a favorite-relay chip), keep it. Blended stacks still omit these relays; a
@ -581,6 +614,7 @@ export const PROFILE_FEED_KINDS = SUPPORTED_KINDS.filter( @@ -581,6 +614,7 @@ export const PROFILE_FEED_KINDS = SUPPORTED_KINDS.filter(
export const PROFILE_PUBLICATIONS_TAB_KINDS: readonly number[] = [
kinds.LongFormArticle,
ExtendedKind.PUBLICATION,
ExtendedKind.PUBLICATION_CONTENT,
ExtendedKind.WIKI_ARTICLE,
ExtendedKind.WIKI_ARTICLE_MARKDOWN
]

13
src/lib/publication-rendered-events.ts

@ -1,6 +1,8 @@ @@ -1,6 +1,8 @@
import type { Event } from 'nostr-tools'
const renderedByPublication = new Map<string, Map<string, Event>>()
let renderedVersion = 0
const listeners = new Set<() => void>()
function normId(id: string): string {
return id.trim().toLowerCase()
@ -17,6 +19,17 @@ export function upsertRenderedPublicationEvents(publicationId: string, events: E @@ -17,6 +19,17 @@ export function upsertRenderedPublicationEvents(publicationId: string, events: E
if (!ev?.id) continue
byId.set(normId(ev.id), ev)
}
renderedVersion += 1
for (const listener of listeners) listener()
}
export function subscribeRenderedPublicationEvents(listener: () => void): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
}
export function getRenderedPublicationEventsVersion(): number {
return renderedVersion
}
export function getRenderedPublicationEvents(publicationId: string): Event[] {

164
src/providers/NostrProvider/index.tsx

@ -800,6 +800,62 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -800,6 +800,62 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
customEmojiService.init(userEmojiListEvent)
}, [userEmojiListEvent])
/**
* If session restore temporarily fell back to read-only (`npub`) while the stored
* account is still `nip-07`, periodically retry reconnecting the extension signer.
*/
useEffect(() => {
if (!account || account.signerType !== 'npub') return
const preferred = storage.getCurrentAccount()
if (!preferred || preferred.signerType !== 'nip-07') return
if (preferred.pubkey !== account.pubkey) return
let cancelled = false
let timer: ReturnType<typeof setTimeout> | null = null
let attempts = 0
const maxAttempts = 6
const schedule = (ms: number) => {
if (cancelled) return
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
void tryRecover()
}, ms)
}
const tryRecover = async () => {
if (cancelled || attempts >= maxAttempts) return
attempts += 1
try {
const nip07Signer = new Nip07Signer()
await nip07Signer.init()
const pubkey = await nip07Signer.getPublicKey()
if (pubkey.toLowerCase() !== preferred.pubkey.toLowerCase()) {
throw new Error('Signer pubkey does not match current account')
}
login(nip07Signer, preferred)
logger.info('[NostrProvider] Recovered NIP-07 signer from read-only fallback', {
pubkeySlice: pubkey.slice(0, 12),
attempts
})
return
} catch (error) {
logger.info('[NostrProvider] NIP-07 recovery retry failed', {
pubkeySlice: preferred.pubkey.slice(0, 12),
attempts,
error: error instanceof Error ? error.message : String(error)
})
}
schedule(Math.min(10_000, attempts * 1_500))
}
schedule(1_200)
return () => {
cancelled = true
if (timer) clearTimeout(timer)
}
}, [account])
const hasNostrLoginHash = () => {
return window.location.hash && window.location.hash.startsWith('#nostr-login')
}
@ -974,79 +1030,113 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -974,79 +1030,113 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
})
return pubkey
}
const currentAccountState = account
let account = storage.findAccount(act)
if (!account) {
let storedAccount = storage.findAccount(act)
if (!storedAccount) {
return null
}
if (account.signerType === 'nsec' || account.signerType === 'browser-nsec') {
if (account.nsec) {
if (storedAccount.signerType === 'nsec' || storedAccount.signerType === 'browser-nsec') {
if (storedAccount.nsec) {
const browserNsecSigner = new NsecSigner()
browserNsecSigner.login(account.nsec)
browserNsecSigner.login(storedAccount.nsec)
// Migrate to nsec
if (account.signerType === 'browser-nsec') {
storage.removeAccount(account)
account = { ...account, signerType: 'nsec' }
storage.addAccount(account)
if (storedAccount.signerType === 'browser-nsec') {
storage.removeAccount(storedAccount)
storedAccount = { ...storedAccount, signerType: 'nsec' }
storage.addAccount(storedAccount)
}
return login(browserNsecSigner, account)
return login(browserNsecSigner, storedAccount)
}
} else if (account.signerType === 'ncryptsec') {
if (account.ncryptsec) {
} else if (storedAccount.signerType === 'ncryptsec') {
if (storedAccount.ncryptsec) {
const password = await askNcryptsecPassword()
if (!password) {
return null
}
let privkey: Uint8Array
try {
privkey = nip49.decrypt(account.ncryptsec, password)
privkey = nip49.decrypt(storedAccount.ncryptsec, password)
} catch (e) {
toast.error(t('Login failed') + ': ' + (e as Error).message)
return null
}
const browserNsecSigner = new NsecSigner()
browserNsecSigner.login(privkey)
return login(browserNsecSigner, account)
return login(browserNsecSigner, storedAccount)
}
} else if (account.signerType === 'nip-07') {
} else if (storedAccount.signerType === 'nip-07') {
try {
const nip07Signer = new Nip07Signer()
await nip07Signer.init()
await nip07Signer.getPublicKey()
return login(nip07Signer, account)
const pubkey = await nip07Signer.getPublicKey()
if (pubkey.toLowerCase() !== storedAccount.pubkey.toLowerCase()) {
throw new Error('Signer pubkey does not match current account')
}
return login(nip07Signer, storedAccount)
} catch (err) {
return fallbackToReadOnlyNpub(account.pubkey, err)
// One short retry avoids transient extension injection races on reload.
try {
await new Promise((resolve) => setTimeout(resolve, 1200))
const retrySigner = new Nip07Signer()
await retrySigner.init()
const retryPubkey = await retrySigner.getPublicKey()
if (retryPubkey.toLowerCase() !== storedAccount.pubkey.toLowerCase()) {
throw new Error('Signer pubkey does not match current account')
}
return login(retrySigner, storedAccount)
} catch (retryErr) {
// If this tab already has a working nip-07 signer for the same account, keep it.
if (
currentAccountState?.pubkey === storedAccount.pubkey &&
currentAccountState.signerType === 'nip-07' &&
signer
) {
try {
const currentPubkey = await signer.getPublicKey()
if (currentPubkey.toLowerCase() === storedAccount.pubkey.toLowerCase()) {
logger.info('[NostrProvider] Keeping existing NIP-07 signer after transient restore failure', {
pubkeySlice: storedAccount.pubkey.slice(0, 12)
})
return storedAccount.pubkey
}
} catch {
// Ignore and fall through to read-only fallback.
}
}
}
return fallbackToReadOnlyNpub(storedAccount.pubkey, err)
}
} else if (account.signerType === 'bunker') {
if (account.bunker && account.bunkerClientSecretKey) {
const bunkerSigner = new BunkerSigner(account.bunkerClientSecretKey)
const pubkey = await bunkerSigner.login(account.bunker, false)
} else if (storedAccount.signerType === 'bunker') {
if (storedAccount.bunker && storedAccount.bunkerClientSecretKey) {
const bunkerSigner = new BunkerSigner(storedAccount.bunkerClientSecretKey)
const pubkey = await bunkerSigner.login(storedAccount.bunker, false)
if (!pubkey) {
storage.removeAccount(account)
storage.removeAccount(storedAccount)
return null
}
if (pubkey !== account.pubkey) {
storage.removeAccount(account)
account = { ...account, pubkey }
storage.addAccount(account)
if (pubkey !== storedAccount.pubkey) {
storage.removeAccount(storedAccount)
storedAccount = { ...storedAccount, pubkey }
storage.addAccount(storedAccount)
}
return login(bunkerSigner, account)
return login(bunkerSigner, storedAccount)
}
} else if (account.signerType === 'npub' && account.npub) {
} else if (storedAccount.signerType === 'npub' && storedAccount.npub) {
const npubSigner = new NpubSigner()
const pubkey = npubSigner.login(account.npub)
const pubkey = npubSigner.login(storedAccount.npub)
if (!pubkey) {
storage.removeAccount(account)
storage.removeAccount(storedAccount)
return null
}
if (pubkey !== account.pubkey) {
storage.removeAccount(account)
account = { ...account, pubkey }
storage.addAccount(account)
if (pubkey !== storedAccount.pubkey) {
storage.removeAccount(storedAccount)
storedAccount = { ...storedAccount, pubkey }
storage.addAccount(storedAccount)
}
return login(npubSigner, account)
return login(npubSigner, storedAccount)
}
storage.removeAccount(account)
storage.removeAccount(storedAccount)
return null
}

13
src/services/client-events.service.ts

@ -382,6 +382,19 @@ export class EventService { @@ -382,6 +382,19 @@ export class EventService {
}
this.notifySessionEventWaiters(id)
queuePersistSeenEvent(cleanEvent as NEvent)
if (
cleanEvent.kind === ExtendedKind.PUBLICATION ||
cleanEvent.kind === ExtendedKind.PUBLICATION_CONTENT
) {
// Keep publication replaceables durable for profile/publication builder cache hits.
void indexedDb.putReplaceableEvent(cleanEvent as NEvent).catch((error) => {
logger.warn('[EventService] Failed to persist publication event to IndexedDB', {
kind: cleanEvent.kind,
eventId: id,
error
})
})
}
}
/** Apply {@link StorageKey.SESSION_EVENT_LRU_MAX} without reload (copies entries into a new LRU). */

44
src/services/client-macro.service.ts

@ -87,20 +87,8 @@ export class MacroService { @@ -87,20 +87,8 @@ export class MacroService {
// Step 4: Save events to cache
if (events.length > 0) {
try {
const eventsByPubkey = new Map<string, NEvent[]>()
for (const event of events) {
if (!eventsByPubkey.has(event.pubkey)) {
eventsByPubkey.set(event.pubkey, [])
}
eventsByPubkey.get(event.pubkey)!.push(event)
}
for (const [pubkey, pubEvents] of eventsByPubkey) {
for (const event of pubEvents) {
await indexedDb.putNonReplaceableEventWithMaster(event, `${ExtendedKind.PUBLICATION}:${pubkey}:`)
}
}
await Promise.allSettled(events.map((event) => this.persistMacroEvent(event)))
logger.info(`fetchMacroEvents[${this.macroType}]: Saved events to cache`, {
count: events.length,
filters
@ -126,16 +114,22 @@ export class MacroService { @@ -126,16 +114,22 @@ export class MacroService {
async getCachedMacroEvents(filters: MacroFilters): Promise<NEvent[]> {
try {
const allCached = await indexedDb.getStoreItems(StoreNames.PUBLICATION_EVENTS)
const cachedEvents: NEvent[] = []
const dedupedByCoordinate = new Map<string, NEvent>()
for (const item of allCached) {
const event = item.value as NEvent | undefined
if (!event) continue
if (this.eventMatchesMacroFilters(event, filters)) {
cachedEvents.push(event)
if (!this.eventMatchesMacroFilters(event, filters)) {
continue
}
const key = this.getMacroEventDedupKey(event)
const existing = dedupedByCoordinate.get(key)
if (!existing || event.created_at > existing.created_at) {
dedupedByCoordinate.set(key, event)
}
}
const cachedEvents = Array.from(dedupedByCoordinate.values())
logger.debug(`getCachedMacroEvents[${this.macroType}]: Found cached events`, {
count: cachedEvents.length,
@ -248,6 +242,22 @@ export class MacroService { @@ -248,6 +242,22 @@ export class MacroService {
return true
}
private async persistMacroEvent(event: NEvent): Promise<void> {
if (event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION_CONTENT) {
await indexedDb.putReplaceableEvent(event)
return
}
await indexedDb.putNonReplaceableEventWithMaster(event, `${ExtendedKind.PUBLICATION}:${event.pubkey}:`)
}
private getMacroEventDedupKey(event: NEvent): string {
const d = event.tags.find((tag) => tag[0] === 'd')?.[1]
if (d) {
return `${event.kind}:${event.pubkey}:${d}`
}
return event.id
}
/**
* Extract macro metadata from event tags
*/

21
src/services/client.service.ts

@ -2,8 +2,11 @@ import { @@ -2,8 +2,11 @@ import {
FAST_READ_RELAY_URLS,
ExtendedKind,
FAST_WRITE_RELAY_URLS,
DOCUMENT_RELAY_URLS,
FIRST_RELAY_RESULT_GRACE_MS,
isDocumentRelayKind,
isSocialKindBlockedKind,
relayFilterIncludesDocumentRelayKind,
relayFilterIncludesSocialKindBlockedKind,
relaysAfterSocialKindBlockedStrip,
SOCIAL_KIND_BLOCKED_RELAY_URLS,
@ -67,6 +70,11 @@ function sanitizeSubscribeFiltersBeforeReq(filter: Filter | Filter[]): Filter[] @@ -67,6 +70,11 @@ function sanitizeSubscribeFiltersBeforeReq(filter: Filter | Filter[]): Filter[]
return asArray.map(sanitizeETagFilterForSubscribe).filter((f): f is Filter => !!f)
}
function withDocumentRelayUrlsForFilters(relays: string[], filters: Filter[]): string[] {
if (!filters.some((f) => relayFilterIncludesDocumentRelayKind(f))) return relays
return dedupeNormalizeRelayUrlsOrdered([...relays, ...DOCUMENT_RELAY_URLS])
}
/** Single key for `pool.seenOn` / query seen-on maps (hex ids are case-insensitive). */
function canonicalSeenOnEventId(eventId: string): string {
const t = eventId.trim()
@ -758,6 +766,9 @@ class ClientService extends EventTarget { @@ -758,6 +766,9 @@ class ClientService extends EventTarget {
let relays: string[]
if (specifiedRelayUrls?.length) {
relays = specifiedRelayUrls
if (isDocumentRelayKind(event.kind)) {
relays = dedupeNormalizeRelayUrlsOrdered([...relays, ...DOCUMENT_RELAY_URLS])
}
} else {
// Kind 777 spells: merged write list (kind 10002 outbox + kind 10432 CACHE_RELAYS) + fast write.
if (event.kind === ExtendedKind.SPELL) {
@ -848,6 +859,9 @@ class ClientService extends EventTarget { @@ -848,6 +859,9 @@ class ClientService extends EventTarget {
} else if (event.kind === ExtendedKind.RSS_FEED_LIST) {
bootstrapExtras.push(...FAST_WRITE_RELAY_URLS, ...PROFILE_FETCH_RELAY_URLS)
}
if (isDocumentRelayKind(event.kind)) {
bootstrapExtras.push(...DOCUMENT_RELAY_URLS)
}
if (
event.kind === kinds.RelayList ||
@ -920,7 +934,9 @@ class ClientService extends EventTarget { @@ -920,7 +934,9 @@ class ClientService extends EventTarget {
// Fallback for all publishing when no relays (e.g. after cache clear or fetch failure).
// Use FAST_WRITE_RELAY_URLS so writes always have known-good write relays.
if (!relays.length) {
relays = [...FAST_WRITE_RELAY_URLS]
relays = isDocumentRelayKind(event.kind)
? dedupeNormalizeRelayUrlsOrdered([...FAST_WRITE_RELAY_URLS, ...DOCUMENT_RELAY_URLS])
: [...FAST_WRITE_RELAY_URLS]
logger.info('[DetermineTargetRelays] Using default write relays (no user/extra relays)', {
count: relays.length
})
@ -1880,6 +1896,8 @@ class ClientService extends EventTarget { @@ -1880,6 +1896,8 @@ class ClientService extends EventTarget {
}
}
relays = withDocumentRelayUrlsForFilters(relays, filters)
const stripSocialBlockedRelays =
SOCIAL_KIND_BLOCKED_RELAY_URLS.length > 0 &&
filters.some((f) => relayFilterIncludesSocialKindBlockedKind(f))
@ -2563,6 +2581,7 @@ class ClientService extends EventTarget { @@ -2563,6 +2581,7 @@ class ClientService extends EventTarget {
let relays = originalDedupedRelays
if (relays.length === 0) relays = [...FAST_READ_RELAY_URLS]
const filters = Array.isArray(filter) ? filter : [filter]
relays = withDocumentRelayUrlsForFilters(relays, filters)
const stripSocialBlockedRelays =
SOCIAL_KIND_BLOCKED_RELAY_URLS.length > 0 &&
filters.some((f) => relayFilterIncludesSocialKindBlockedKind(f))

Loading…
Cancel
Save