diff --git a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx index a71868a8..6fb7d18a 100644 --- a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx +++ b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx @@ -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({ // Track citations for footnotes and endnotes sections const citationsRef = useRef>([]) 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({ }) }, 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({ // 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({ 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({ 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({ 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({ } 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({ // 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({ 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 = '' 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({ }) 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({ // 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({ 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({ 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 = '' 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({ }) 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({ // 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({ } /* 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({ } /* 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; } `} diff --git a/src/components/Note/PublicationIndex/PublicationIndex.tsx b/src/components/Note/PublicationIndex/PublicationIndex.tsx index bff7f235..bf4471ea 100644 --- a/src/components/Note/PublicationIndex/PublicationIndex.tsx +++ b/src/components/Note/PublicationIndex/PublicationIndex.tsx @@ -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' 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 { author?: string version?: string type?: string + source?: string + publishedOn?: string + publishedBy?: string tags: string[] } @@ -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({ 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({ }, [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({ 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({ const tableOfContents = useMemo(() => { 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({ return raw } + const knownByCoordinate = new Map() + 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({ // 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({ } 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({ {!isNested && (
-
- {metadata.title &&

{metadata.title}

} - {!metadata.title && isBookstrEvent && ( -
-

- {bookMetadata.book +
+
+ Publication +
+

+ {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'} -

+ : 'Bookstr Publication' + : 'Untitled Publication')} +

+ {metadata.author && ( +
+ by {metadata.author}
)} -
- {metadata.summary && ( -
-

{metadata.summary}

-
- )} - {/* Display image for top-level 30040 publication */} - {metadata.image && ( -
- -
- )} -
- {metadata.author && ( -
- Author: {metadata.author} + {(metadata.type || metadata.version || metadata.publishedOn || metadata.publishedBy) && ( +
+ {metadata.type && Type: {metadata.type}} + {metadata.version && Version: {metadata.version}} + {metadata.publishedOn && Published: {metadata.publishedOn}} + {metadata.publishedBy && Publisher: {metadata.publishedBy}} +
+ )} + {metadata.tags.length > 0 && ( +
+ {metadata.tags.map((tag) => ( + + {tag} + + ))}
)} - {metadata.version && !isBookstrEvent && ( -
- Version: {metadata.version} + {metadata.source && ( +
+ Source:{' '} + + {metadata.source} +
)} - {metadata.type && !isBookstrEvent && ( -
- Type: {metadata.type} + {metadata.summary && ( +
+

{metadata.summary}

+
+ )} + {/* Display image for top-level 30040 publication */} + {metadata.image && ( +
+
)} +
+
+
{isBookstrEvent && ( <> - {bookMetadata.type && ( -
- Type: {bookMetadata.type} -
- )} {bookMetadata.book && (
Book: {bookMetadata.book @@ -455,7 +524,7 @@ export default function PublicationIndex({ {/* Table of Contents - only show for top-level publications */} {!isNested && tableOfContents.length > 0 && (
-

Table of Contents

+

Table of Contents

) @@ -651,6 +774,9 @@ export default function PublicationIndex({ })}
)} + {isTopLevelPublication && resolvedPublicationFootnotesContainerId && ( +
+ )}
) } @@ -671,7 +797,7 @@ function ToCItemComponent({
  • diff --git a/src/components/NoteOptions/useMenuActions.tsx b/src/components/NoteOptions/useMenuActions.tsx index 334bd14a..01d54523 100644 --- a/src/components/NoteOptions/useMenuActions.tsx +++ b/src/components/NoteOptions/useMenuActions.tsx @@ -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({ 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({ 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({ }) } + const publicationBroadcastSubMenu: SubMenuAction[] = [] + if (event.kind === ExtendedKind.PUBLICATION) { + if (allAvailableRelayUrls.length > 0) { + publicationBroadcastSubMenu.push({ + label:
    {t('All available relays')} ({allAvailableRelayUrls.length})
    , + onClick: () => { + closeDrawer() + rebroadcastEntirePublication(allAvailableRelayUrls) + } + }) + } + + const activeRelayCount = + monitoringListRelayCount !== null + ? (monitoringListRelayCount > 0 ? monitoringListRelayCount : allAvailableRelayUrls.length) + : null + publicationBroadcastSubMenu.push({ + label: ( +
    + {t('All active relays (monitoring list)')} + {activeRelayCount !== null && ` (${activeRelayCount})`} +
    + ), + 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:
    {t('Write relays')}
    , + 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:
    {set.name}
    , + separator: index === 0, + onClick: () => { + closeDrawer() + rebroadcastEntirePublication(set.relayUrls) + } + })) + ) + } + + if (relayUrls.length) { + publicationBroadcastSubMenu.push( + ...relayUrls.map((relay, index) => ({ + label: ( +
    + +
    {simplifyUrl(relay)}
    +
    + ), + separator: index === 0, + onClick: () => { + closeDrawer() + rebroadcastEntirePublication([relay]) + } + })) + ) + } + } + // Export functions for articles const exportAsMarkdown = () => { if (!isArticleType) return @@ -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 }) } diff --git a/src/components/Profile/ProfilePublicationsFeed.tsx b/src/components/Profile/ProfilePublicationsFeed.tsx index 2641463d..221a5923 100644 --- a/src/components/Profile/ProfilePublicationsFeed.tsx +++ b/src/components/Profile/ProfilePublicationsFeed.tsx @@ -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') diff --git a/src/components/SessionRelaysTab/index.tsx b/src/components/SessionRelaysTab/index.tsx index 8dc77cf0..da3e4da2 100644 --- a/src/components/SessionRelaysTab/index.tsx +++ b/src/components/SessionRelaysTab/index.tsx @@ -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 { export default function SessionRelaysTab() { const { t } = useTranslation() const [debug, setDebug] = useState(null) + const [relayInfoByUrl, setRelayInfoByUrl] = useState>({}) const refresh = useCallback(() => { setDebug(loadDebug()) @@ -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 = {} + 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() { 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 (
    @@ -69,7 +103,7 @@ export default function SessionRelaysTab() { ) : ( debug.presetWorking.map((url) => (
  • - {formatUrl(url)} + {formatRelayLabel(url)}
  • )) )} @@ -91,7 +125,7 @@ export default function SessionRelaysTab() { debug.presetStriked.map((url) => (
  • - {formatUrl(url)} + {formatRelayLabel(url)}