Browse Source

bug-fixes

imwald
Silberengel 4 weeks ago
parent
commit
c104dfd3f7
  1. 4
      package-lock.json
  2. 2
      package.json
  3. 27
      src/components/Explore/ExploreRelayReviews.tsx
  4. 18
      src/components/HelpAndAccountMenu.tsx
  5. 8
      src/components/ImageWithLightbox/index.tsx
  6. 186
      src/components/Note/PublicationIndex/PublicationIndex.tsx
  7. 8
      src/components/Profile/ProfilePublicationsFeed.tsx
  8. 57
      src/components/SessionRelaysTab/index.tsx
  9. 10
      src/hooks/usePublicationSectionLoader.ts
  10. 16
      src/lib/relay-list-builder.ts
  11. 4
      src/services/client.service.ts

4
package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "jumble-imwald", "name": "jumble-imwald",
"version": "21.3.2", "version": "21.4.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "jumble-imwald", "name": "jumble-imwald",
"version": "21.3.2", "version": "21.4.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "jumble-imwald", "name": "jumble-imwald",
"version": "21.3.2", "version": "21.4.0",
"description": "A user-friendly Nostr client focused on relay feed browsing and relay discovery, forked from Jumble", "description": "A user-friendly Nostr client focused on relay feed browsing and relay discovery, forked from Jumble",
"private": true, "private": true,
"type": "module", "type": "module",

27
src/components/Explore/ExploreRelayReviews.tsx

@ -8,6 +8,7 @@ import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpel
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import indexedDb, { StoreNames } from '@/services/indexed-db.service'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -32,6 +33,28 @@ function dedupeRelayReviewsNewestFirst(events: Event[]): Event[] {
return out return out
} }
async function loadCachedRelayReviews(limit: number): Promise<Event[]> {
const fromSession = client
.getSessionEventsMatchingSearch('', Math.max(limit * 2, 200), [ExtendedKind.RELAY_REVIEW])
.filter((e) => e.kind === ExtendedKind.RELAY_REVIEW && !!getRelayUrlFromRelayReviewEvent(e))
if (fromSession.length >= limit) {
return dedupeRelayReviewsNewestFirst(fromSession).slice(0, limit)
}
try {
const archiveRows = await indexedDb.getStoreItems(StoreNames.EVENT_ARCHIVE)
const fromArchive = archiveRows
.map((row) => row?.value as Event | undefined)
.filter(
(e): e is Event =>
!!e && e.kind === ExtendedKind.RELAY_REVIEW && !!getRelayUrlFromRelayReviewEvent(e)
)
return dedupeRelayReviewsNewestFirst([...fromSession, ...fromArchive]).slice(0, limit)
} catch {
return dedupeRelayReviewsNewestFirst(fromSession).slice(0, limit)
}
}
export default function ExploreRelayReviews() { export default function ExploreRelayReviews() {
const { t } = useTranslation() const { t } = useTranslation()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
@ -70,6 +93,10 @@ export default function ExploreRelayReviews() {
setShowCount(SHOW_COUNT) setShowCount(SHOW_COUNT)
void (async () => { void (async () => {
const cached = await loadCachedRelayReviews(REVIEW_QUERY_LIMIT)
if (!cancelled && fetchGenRef.current === gen && cached.length > 0) {
setEvents(cached)
}
try { try {
const raw = await client.fetchEvents( const raw = await client.fetchEvents(
relayUrls, relayUrls,

18
src/components/HelpAndAccountMenu.tsx

@ -16,6 +16,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import { formatPubkey, formatNpub, generateImageByPubkey, pubkeyToNpub } from '@/lib/pubkey' import { formatPubkey, formatNpub, generateImageByPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { usePrimaryPage } from '@/contexts/primary-page-context' import { usePrimaryPage } from '@/contexts/primary-page-context'
import { useFetchProfile } from '@/hooks/useFetchProfile'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { ArrowDownUp, LogIn, LogOut, Settings, User, UserRound } from 'lucide-react' import { ArrowDownUp, LogIn, LogOut, Settings, User, UserRound } from 'lucide-react'
import { useMemo, useState, type ReactNode } from 'react' import { useMemo, useState, type ReactNode } from 'react'
@ -67,6 +68,7 @@ function SidebarAccountMenu({
const { account, profile } = useNostr() const { account, profile } = useNostr()
const { current, display } = usePrimaryPage() const { current, display } = usePrimaryPage()
const pubkey = account?.pubkey const pubkey = account?.pubkey
const { profile: fetchedProfile } = useFetchProfile(pubkey)
const active = useMemo(() => current === 'profile' && display, [display, current]) const active = useMemo(() => current === 'profile' && display, [display, current])
if (!pubkey) return null if (!pubkey) return null
@ -74,7 +76,8 @@ function SidebarAccountMenu({
const defaultAvatar = generateImageByPubkey(pubkey) const defaultAvatar = generateImageByPubkey(pubkey)
const npub = pubkeyToNpub(pubkey) const npub = pubkeyToNpub(pubkey)
const fallbackUsername = npub ? formatNpub(npub) : formatPubkey(pubkey) const fallbackUsername = npub ? formatNpub(npub) : formatPubkey(pubkey)
const { username, avatar } = profile || { username: fallbackUsername, avatar: defaultAvatar } const resolvedProfile = fetchedProfile ?? profile
const { username, avatar } = resolvedProfile || { username: fallbackUsername, avatar: defaultAvatar }
return ( return (
<DropdownMenu> <DropdownMenu>
@ -114,11 +117,14 @@ function TitlebarAccountMenu({
onLogoutClick: () => void onLogoutClick: () => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { profile } = useNostr() const { account, profile } = useNostr()
const pubkey = account?.pubkey
const { profile: fetchedProfile } = useFetchProfile(pubkey)
const resolvedProfile = fetchedProfile ?? profile
const { current, display } = usePrimaryPage() const { current, display } = usePrimaryPage()
const defaultAvatar = useMemo( const defaultAvatar = useMemo(
() => (profile?.pubkey ? generateImageByPubkey(profile.pubkey) : ''), () => (resolvedProfile?.pubkey ? generateImageByPubkey(resolvedProfile.pubkey) : ''),
[profile] [resolvedProfile]
) )
const active = useMemo(() => current === 'profile' && display, [display, current]) const active = useMemo(() => current === 'profile' && display, [display, current])
@ -132,9 +138,9 @@ function TitlebarAccountMenu({
title={t('Account menu')} title={t('Account menu')}
aria-label={t('Account menu')} aria-label={t('Account menu')}
> >
{profile ? ( {resolvedProfile ? (
<Avatar className={cn('w-6 h-6', active ? 'ring-primary ring-1' : '')}> <Avatar className={cn('w-6 h-6', active ? 'ring-primary ring-1' : '')}>
<AvatarImage src={profile.avatar} className="object-cover object-center" /> <AvatarImage src={resolvedProfile.avatar} className="object-cover object-center" />
<AvatarFallback> <AvatarFallback>
<img src={defaultAvatar} alt="" /> <img src={defaultAvatar} alt="" />
</AvatarFallback> </AvatarFallback>

8
src/components/ImageWithLightbox/index.tsx

@ -72,7 +72,13 @@ export default function ImageWithLightbox({
/> />
{index >= 0 && {index >= 0 &&
createPortal( createPortal(
<div onClick={(e) => e.stopPropagation()}> <div
data-lightbox-overlay
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
>
<Lightbox <Lightbox
index={index} index={index}
slides={[{ slides={[{

186
src/components/Note/PublicationIndex/PublicationIndex.tsx

@ -1,6 +1,6 @@
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { Event, kinds, nip19 } from 'nostr-tools' import { Event, kinds, nip19 } from 'nostr-tools'
import { useEffect, useMemo, useState, useCallback, useSyncExternalStore } from 'react' import { useEffect, useMemo, useState, useCallback, useSyncExternalStore, useRef } from 'react'
import { usePublicationSectionLoader } from '@/hooks/usePublicationSectionLoader' import { usePublicationSectionLoader } from '@/hooks/usePublicationSectionLoader'
import { parsePublicationATagCoordinate, publicationRefKey } from '@/lib/publication-section-fetch' import { parsePublicationATagCoordinate, publicationRefKey } from '@/lib/publication-section-fetch'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -15,7 +15,7 @@ import indexedDb from '@/services/indexed-db.service'
import { useSecondaryPageOptional } from '@/PageManager' import { useSecondaryPageOptional } from '@/PageManager'
import { extractBookMetadata } from '@/lib/bookstr-parser' import { extractBookMetadata } from '@/lib/bookstr-parser'
import { dTagToTitleCase } from '@/lib/event-metadata' import { dTagToTitleCase } from '@/lib/event-metadata'
import Image from '@/components/Image' import ImageWithLightbox from '@/components/ImageWithLightbox'
import NoteOptions from '@/components/NoteOptions' import NoteOptions from '@/components/NoteOptions'
import { import {
getRenderedPublicationEventsVersion, getRenderedPublicationEventsVersion,
@ -96,6 +96,7 @@ export default function PublicationIndex({
className, className,
isNested = false, isNested = false,
parentImageUrl, parentImageUrl,
parentSummary,
flattenHierarchy = false, flattenHierarchy = false,
chapterDepth = 0, chapterDepth = 0,
publicationFootnotesContainerId publicationFootnotesContainerId
@ -104,6 +105,7 @@ export default function PublicationIndex({
className?: string className?: string
isNested?: boolean isNested?: boolean
parentImageUrl?: string parentImageUrl?: string
parentSummary?: string
flattenHierarchy?: boolean flattenHierarchy?: boolean
chapterDepth?: number chapterDepth?: number
publicationFootnotesContainerId?: string publicationFootnotesContainerId?: string
@ -152,6 +154,9 @@ export default function PublicationIndex({
const isBookstrEvent = (event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION_CONTENT) && !!bookMetadata.book const isBookstrEvent = (event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION_CONTENT) && !!bookMetadata.book
const isTopLevelPublication = !isNested && event.kind === ExtendedKind.PUBLICATION const isTopLevelPublication = !isNested && event.kind === ExtendedKind.PUBLICATION
const forceFlatHierarchy = flattenHierarchy || isBookstrEvent || isTopLevelPublication const forceFlatHierarchy = flattenHierarchy || isBookstrEvent || isTopLevelPublication
const initialSectionLoadCount = isNested ? 1 : 3
const sectionLoadStep = isNested ? 1 : 3
const effectiveParentSummary = metadata.summary || parentSummary
const resolvedPublicationFootnotesContainerId = useMemo( const resolvedPublicationFootnotesContainerId = useMemo(
() => () =>
publicationFootnotesContainerId ?? publicationFootnotesContainerId ??
@ -159,6 +164,8 @@ export default function PublicationIndex({
[publicationFootnotesContainerId, isTopLevelPublication, event.id] [publicationFootnotesContainerId, isTopLevelPublication, event.id]
) )
const [isRetrying, setIsRetrying] = useState(false) const [isRetrying, setIsRetrying] = useState(false)
const [sectionLoadCount, setSectionLoadCount] = useState(initialSectionLoadCount)
const lazyLoadSentinelRef = useRef<HTMLDivElement | null>(null)
// Extract references from 'a' tags (addressable events) and 'e' tags (event IDs) // Extract references from 'a' tags (addressable events) and 'e' tags (event IDs)
const referencesData = useMemo(() => { const referencesData = useMemo(() => {
@ -191,8 +198,8 @@ export default function PublicationIndex({
return refs return refs
}, [event]) }, [event])
const { retryKeys, failedKeys, referencesWithEvents } = const { requestKeys, retryKeys, failedKeys, referencesWithEvents } =
usePublicationSectionLoader(event, referencesData) usePublicationSectionLoader(event, referencesData, { autoLoad: false })
const renderedEventsVersion = useSyncExternalStore( const renderedEventsVersion = useSyncExternalStore(
subscribeRenderedPublicationEvents, subscribeRenderedPublicationEvents,
getRenderedPublicationEventsVersion, getRenderedPublicationEventsVersion,
@ -369,10 +376,29 @@ export default function PublicationIndex({
// Scroll to section // Scroll to section
const scrollToSection = (coordinate: string) => { const scrollToSection = (coordinate: string) => {
const element = document.getElementById(`section-${coordinate.replace(/:/g, '-')}`) const targetId = `section-${coordinate.replace(/:/g, '-')}`
if (element) { const sectionIndex = referencesWithEvents.findIndex(
element.scrollIntoView({ behavior: 'smooth', block: 'start' }) (ref) => (ref.coordinate || ref.eventId || '') === coordinate
)
if (sectionIndex >= 0) {
setSectionLoadCount((prev) => Math.max(prev, sectionIndex + 1))
const key = publicationRefKey(referencesWithEvents[sectionIndex] || {})
if (key) requestKeys([key])
} }
let attempts = 0
const tryScroll = () => {
const element = document.getElementById(targetId)
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' })
return
}
if (attempts < 8) {
attempts += 1
window.setTimeout(tryScroll, 80)
}
}
tryScroll()
} }
@ -398,6 +424,41 @@ export default function PublicationIndex({
return () => clearTimeout(t) return () => clearTimeout(t)
}, [referencesWithEvents, event]) }, [referencesWithEvents, event])
useEffect(() => {
setSectionLoadCount(initialSectionLoadCount)
}, [event.id, initialSectionLoadCount])
useEffect(() => {
const keysToRequest = referencesWithEvents
.slice(0, sectionLoadCount)
.filter((ref) => ref.loadStatus === 'idle')
.map((ref) => publicationRefKey(ref))
.filter(Boolean)
if (keysToRequest.length > 0) {
requestKeys(keysToRequest)
}
}, [referencesWithEvents, requestKeys, sectionLoadCount])
useEffect(() => {
const sentinel = lazyLoadSentinelRef.current
if (!sentinel) return
if (sectionLoadCount >= referencesWithEvents.length) return
const observer = new IntersectionObserver(
(entries) => {
if (!entries[0]?.isIntersecting) return
setSectionLoadCount((prev) => Math.min(prev + sectionLoadStep, referencesWithEvents.length))
},
{ rootMargin: '220px 0px' }
)
observer.observe(sentinel)
return () => observer.disconnect()
}, [referencesWithEvents.length, sectionLoadCount, sectionLoadStep])
const visibleReferences = useMemo(
() => referencesWithEvents.slice(0, sectionLoadCount),
[referencesWithEvents, sectionLoadCount]
)
const handleManualRetry = useCallback(() => { const handleManualRetry = useCallback(() => {
setIsRetrying(true) setIsRetrying(true)
const keys = const keys =
@ -408,6 +469,19 @@ export default function PublicationIndex({
window.setTimeout(() => setIsRetrying(false), 600) window.setTimeout(() => setIsRetrying(false), 600)
}, [failedKeys, referencesData, retryKeys]) }, [failedKeys, referencesData, retryKeys])
const normalizedParentImage = (parentImageUrl || '').trim()
const normalizedOwnImage = (metadata.image || '').trim()
const normalizedParentSummary = (parentSummary || '').trim()
const normalizedOwnSummary = (metadata.summary || '').trim()
const showNestedImagePreview =
isNested &&
!!normalizedOwnImage &&
normalizedOwnImage !== normalizedParentImage
const showNestedSummaryPreview =
isNested &&
!!normalizedOwnSummary &&
normalizedOwnSummary !== normalizedParentSummary
return ( return (
<div className={cn('space-y-6', className)}> <div className={cn('space-y-6', className)}>
@ -436,11 +510,25 @@ export default function PublicationIndex({
</div> </div>
)} )}
{(metadata.type || metadata.version || metadata.publishedOn || metadata.publishedBy) && ( {(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"> <div className="mt-4 flex flex-wrap items-center justify-center gap-y-1 text-xs text-muted-foreground">
{metadata.type && <span>Type: {metadata.type}</span>} {[
{metadata.version && <span>Version: {metadata.version}</span>} metadata.type ? { label: 'Type', value: metadata.type } : null,
{metadata.publishedOn && <span>Published: {metadata.publishedOn}</span>} metadata.version ? { label: 'Version', value: metadata.version } : null,
{metadata.publishedBy && <span>Publisher: {metadata.publishedBy}</span>} metadata.publishedOn ? { label: 'Published', value: metadata.publishedOn } : null,
metadata.publishedBy ? { label: 'Publisher', value: metadata.publishedBy } : null
]
.filter((item): item is { label: string; value: string } => !!item)
.map((item, index) => (
<div key={item.label} className="inline-flex items-center">
{index > 0 && (
<span aria-hidden className="mx-2 text-muted-foreground/50">
·
</span>
)}
<span className="uppercase tracking-[0.14em] text-muted-foreground/80">{item.label}</span>
<span className="ml-1.5 text-foreground/85">{item.value}</span>
</div>
))}
</div> </div>
)} )}
{metadata.tags.length > 0 && ( {metadata.tags.length > 0 && (
@ -468,24 +556,28 @@ export default function PublicationIndex({
</a> </a>
</div> </div>
)} )}
{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 */} {/* Display image for top-level 30040 publication */}
{metadata.image && ( {metadata.image && (
<div className="mt-5 flex justify-center"> <div className="mt-5 flex justify-center">
<Image <ImageWithLightbox
image={{ url: metadata.image, pubkey: event.pubkey }} image={{ url: metadata.image, pubkey: event.pubkey }}
className="max-w-[400px] w-full h-auto rounded-lg" className="max-w-[400px] w-full h-auto rounded-lg"
classNames={{ classNames={{
wrapper: 'rounded-lg', wrapper: 'rounded-lg'
errorPlaceholder: 'aspect-square h-[30vh]'
}} }}
/> />
</div> </div>
)} )}
{metadata.summary && (
<div className="mt-6 mx-auto max-w-2xl text-center">
<div className="mb-2 text-[10px] uppercase tracking-[0.2em] text-muted-foreground/75">
Summary
</div>
<p className="break-words text-sm md:text-base leading-relaxed italic text-muted-foreground">
{metadata.summary}
</p>
</div>
)}
<div className="mt-5 mx-auto h-px w-24 bg-border/70" /> <div className="mt-5 mx-auto h-px w-24 bg-border/70" />
</div> </div>
<div className="text-sm text-muted-foreground space-y-1"> <div className="text-sm text-muted-foreground space-y-1">
@ -520,6 +612,24 @@ export default function PublicationIndex({
</header> </header>
</div> </div>
)} )}
{isNested && (showNestedImagePreview || showNestedSummaryPreview) && (
<div className="rounded-lg border border-border/50 bg-muted/15 px-4 py-4">
{showNestedImagePreview && metadata.image && (
<div className="mb-3 flex justify-center">
<ImageWithLightbox
image={{ url: metadata.image, pubkey: event.pubkey }}
className="max-w-[260px] w-full h-auto rounded-lg"
classNames={{ wrapper: 'rounded-lg' }}
/>
</div>
)}
{showNestedSummaryPreview && metadata.summary && (
<p className="mx-auto max-w-2xl text-sm italic leading-relaxed text-muted-foreground text-center break-words">
{metadata.summary}
</p>
)}
</div>
)}
{/* Table of Contents - only show for top-level publications */} {/* Table of Contents - only show for top-level publications */}
{!isNested && tableOfContents.length > 0 && ( {!isNested && tableOfContents.length > 0 && (
@ -571,7 +681,7 @@ export default function PublicationIndex({
</div> </div>
) : ( ) : (
<div className="space-y-8"> <div className="space-y-8">
{referencesWithEvents.map((ref, index) => { {visibleReferences.map((ref, index) => {
const sectionKey = publicationRefKey(ref) const sectionKey = publicationRefKey(ref)
const coordinate = ref.coordinate || ref.eventId || '' const coordinate = ref.coordinate || ref.eventId || ''
const sectionId = `section-${coordinate.replace(/:/g, '-')}` const sectionId = `section-${coordinate.replace(/:/g, '-')}`
@ -629,6 +739,18 @@ export default function PublicationIndex({
const eventKind = ref.event?.kind ?? ref.kind ?? 0 const eventKind = ref.event?.kind ?? ref.kind ?? 0
const effectiveParentImageUrl = !isNested ? metadata.image : parentImageUrl const effectiveParentImageUrl = !isNested ? metadata.image : parentImageUrl
const sectionSummaryTag = ref.event.tags.find((tag) => tag[0] === 'summary')?.[1]
const sectionImageTag = ref.event.tags.find((tag) => tag[0] === 'image')?.[1]
const normalizedParentSummaryForSection = (effectiveParentSummary || '').trim()
const normalizedSectionSummary = (sectionSummaryTag || '').trim()
const normalizedParentImageForSection = (effectiveParentImageUrl || '').trim()
const normalizedSectionImage = (sectionImageTag || '').trim()
const showSectionSummaryPreview =
!!normalizedSectionSummary &&
normalizedSectionSummary !== normalizedParentSummaryForSection
const showSectionImagePreview =
!!normalizedSectionImage &&
normalizedSectionImage !== normalizedParentImageForSection
if (eventKind === ExtendedKind.PUBLICATION) { if (eventKind === ExtendedKind.PUBLICATION) {
const publicationTitleTag = ref.event.tags.find((tag) => tag[0] === 'title')?.[1] const publicationTitleTag = ref.event.tags.find((tag) => tag[0] === 'title')?.[1]
@ -706,6 +828,7 @@ export default function PublicationIndex({
event={ref.event} event={ref.event}
isNested={true} isNested={true}
parentImageUrl={effectiveParentImageUrl} parentImageUrl={effectiveParentImageUrl}
parentSummary={effectiveParentSummary}
flattenHierarchy={forceFlatHierarchy} flattenHierarchy={forceFlatHierarchy}
chapterDepth={publicationDepth} chapterDepth={publicationDepth}
publicationFootnotesContainerId={resolvedPublicationFootnotesContainerId} publicationFootnotesContainerId={resolvedPublicationFootnotesContainerId}
@ -736,6 +859,24 @@ export default function PublicationIndex({
)} )}
<NoteOptions event={ref.event} /> <NoteOptions event={ref.event} />
</div> </div>
{(showSectionImagePreview || showSectionSummaryPreview) && (
<div className="mb-4 rounded-lg border border-border/50 bg-muted/15 px-4 py-4">
{showSectionImagePreview && sectionImageTag && (
<div className="mb-3 flex justify-center">
<ImageWithLightbox
image={{ url: sectionImageTag, pubkey: ref.event.pubkey }}
className="max-w-[260px] w-full h-auto rounded-lg"
classNames={{ wrapper: 'rounded-lg' }}
/>
</div>
)}
{showSectionSummaryPreview && sectionSummaryTag && (
<p className="mx-auto max-w-2xl text-sm italic leading-relaxed text-muted-foreground text-center break-words">
{sectionSummaryTag}
</p>
)}
</div>
)}
<AsciidocArticle <AsciidocArticle
event={ref.event} event={ref.event}
hideImagesAndInfo={true} hideImagesAndInfo={true}
@ -772,6 +913,9 @@ export default function PublicationIndex({
</div> </div>
) )
})} })}
{sectionLoadCount < referencesWithEvents.length && (
<div ref={lazyLoadSentinelRef} className="h-8" aria-hidden />
)}
</div> </div>
)} )}
{isTopLevelPublication && resolvedPublicationFootnotesContainerId && ( {isTopLevelPublication && resolvedPublicationFootnotesContainerId && (

8
src/components/Profile/ProfilePublicationsFeed.tsx

@ -1,4 +1,5 @@
import ProfileSearchBar from '@/components/ui/ProfileSearchBar' import ProfileSearchBar from '@/components/ui/ProfileSearchBar'
import { ExtendedKind } from '@/constants'
import { PROFILE_PUBLICATIONS_TAB_KINDS } from '@/constants' import { PROFILE_PUBLICATIONS_TAB_KINDS } from '@/constants'
import { forwardRef, useMemo, useState } from 'react' import { forwardRef, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -9,7 +10,11 @@ const ProfilePublicationsFeed = forwardRef<{ refresh: () => void }, { pubkey: st
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const kindsList = useMemo(() => [...PROFILE_PUBLICATIONS_TAB_KINDS], []) const kindsList = useMemo(() => [...PROFILE_PUBLICATIONS_TAB_KINDS], [])
const cacheKey = useMemo(() => `${pubkey}-profile-publications-v2`, [pubkey]) const cacheKey = useMemo(() => `${pubkey}-profile-publications-v3`, [pubkey])
const visiblePublicationFilter = useMemo(
() => (event: { kind: number }) => event.kind !== ExtendedKind.PUBLICATION_CONTENT,
[]
)
const getKindLabel = (_kindValue: string) => t('articles and publications') const getKindLabel = (_kindValue: string) => t('articles and publications')
@ -30,6 +35,7 @@ const ProfilePublicationsFeed = forwardRef<{ refresh: () => void }, { pubkey: st
kindFilter="all" kindFilter="all"
kinds={kindsList} kinds={kindsList}
cacheKey={cacheKey} cacheKey={cacheKey}
filterPredicate={visiblePublicationFilter}
getKindLabel={getKindLabel} getKindLabel={getKindLabel}
refreshLabel={t('Refreshing articles...')} refreshLabel={t('Refreshing articles...')}
emptyLabel={t('No articles or publications found')} emptyLabel={t('No articles or publications found')}

57
src/components/SessionRelaysTab/index.tsx

@ -1,10 +1,12 @@
import client from '@/services/client.service' import client from '@/services/client.service'
import relayInfoService from '@/services/relay-info.service' import relayInfoService from '@/services/relay-info.service'
import { isHttpRelayUrl } from '@/lib/url'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { RefreshCw, CheckCircle2, XCircle, Zap, RotateCcw } from 'lucide-react' import { RefreshCw, CheckCircle2, XCircle, Zap, RotateCcw } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import type { TRelayInfo } from '@/types' import type { TRelayInfo } from '@/types'
import { useNostr } from '@/providers/NostrProvider'
type SessionDebug = { type SessionDebug = {
strikedUrls: string[] strikedUrls: string[]
@ -19,6 +21,7 @@ function loadDebug(): SessionDebug {
export default function SessionRelaysTab() { export default function SessionRelaysTab() {
const { t } = useTranslation() const { t } = useTranslation()
const { httpRelayListEvent } = useNostr()
const [debug, setDebug] = useState<SessionDebug | null>(null) const [debug, setDebug] = useState<SessionDebug | null>(null)
const [relayInfoByUrl, setRelayInfoByUrl] = useState<Record<string, TRelayInfo | undefined>>({}) const [relayInfoByUrl, setRelayInfoByUrl] = useState<Record<string, TRelayInfo | undefined>>({})
@ -55,8 +58,6 @@ export default function SessionRelaysTab() {
} }
}, [debug]) }, [debug])
if (debug === null) return null
const clearStrikeForUrl = (url: string) => { const clearStrikeForUrl = (url: string) => {
client.clearSessionRelayStrikeForUrl(url) client.clearSessionRelayStrikeForUrl(url)
refresh() refresh()
@ -77,6 +78,40 @@ export default function SessionRelaysTab() {
return formatRelayAddress(url) return formatRelayAddress(url)
} }
const configuredHttpRelayAddresses = useMemo(() => {
const out = new Set<string>()
if (!httpRelayListEvent) return out
for (const tag of httpRelayListEvent.tags) {
if (tag[0] !== 'r' || !tag[1]) continue
const raw = tag[1].trim()
if (!isHttpRelayUrl(raw)) continue
out.add(formatRelayAddress(raw).toLowerCase())
}
return out
}, [httpRelayListEvent])
const isHttpRelayEntry = (url: string): boolean => {
if (isHttpRelayUrl(url)) return true
const infoUrl = relayInfoByUrl[url]?.url
if (infoUrl && isHttpRelayUrl(infoUrl)) return true
return configuredHttpRelayAddresses.has(formatRelayAddress(url).toLowerCase())
}
if (debug === null) return null
const RelayNameWithTransport = ({ url, mono = true }: { url: string; mono?: boolean }) => (
<span className="min-w-0 inline-flex max-w-full items-center gap-1.5">
<span className={`min-w-0 truncate ${mono ? 'font-mono' : ''}`} title={url}>
{formatRelayLabel(url)}
</span>
{isHttpRelayEntry(url) ? (
<span className="shrink-0 rounded border border-border/70 bg-muted px-1.5 py-0.5 text-[10px] font-semibold tracking-wide text-muted-foreground">
HTTP
</span>
) : null}
</span>
)
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -102,8 +137,8 @@ export default function SessionRelaysTab() {
<li className="text-muted-foreground">{t('None')}</li> <li className="text-muted-foreground">{t('None')}</li>
) : ( ) : (
debug.presetWorking.map((url) => ( debug.presetWorking.map((url) => (
<li key={url} className="truncate" title={url}> <li key={url} className="truncate">
{formatRelayLabel(url)} <RelayNameWithTransport url={url} />
</li> </li>
)) ))
)} )}
@ -124,9 +159,7 @@ export default function SessionRelaysTab() {
) : ( ) : (
debug.presetStriked.map((url) => ( debug.presetStriked.map((url) => (
<li key={url} className="flex items-center justify-between gap-2"> <li key={url} className="flex items-center justify-between gap-2">
<span className="min-w-0 truncate font-mono" title={url}> <RelayNameWithTransport url={url} />
{formatRelayLabel(url)}
</span>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@ -158,9 +191,7 @@ export default function SessionRelaysTab() {
) : ( ) : (
debug.scoredRelays.map(({ url, successCount, avgLatencyMs }) => ( debug.scoredRelays.map(({ url, successCount, avgLatencyMs }) => (
<li key={url} className="flex justify-between items-center gap-2 font-mono"> <li key={url} className="flex justify-between items-center gap-2 font-mono">
<span className="truncate min-w-0" title={url}> <RelayNameWithTransport url={url} />
{formatRelayLabel(url)}
</span>
<span className="shrink-0 text-muted-foreground text-xs"> <span className="shrink-0 text-muted-foreground text-xs">
{successCount} {t('successes')} · ~{avgLatencyMs} ms {successCount} {t('successes')} · ~{avgLatencyMs} ms
</span> </span>
@ -178,9 +209,7 @@ export default function SessionRelaysTab() {
<ul className="rounded-lg border bg-muted/30 p-3 space-y-2 text-sm"> <ul className="rounded-lg border bg-muted/30 p-3 space-y-2 text-sm">
{debug.strikedUrls.map((url) => ( {debug.strikedUrls.map((url) => (
<li key={url} className="flex items-center justify-between gap-2 text-muted-foreground"> <li key={url} className="flex items-center justify-between gap-2 text-muted-foreground">
<span className="min-w-0 truncate font-mono" title={url}> <RelayNameWithTransport url={url} />
{formatRelayLabel(url)}
</span>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"

10
src/hooks/usePublicationSectionLoader.ts

@ -60,7 +60,11 @@ function dedupeRelayUrls(urls: string[]): string[] {
return out return out
} }
export function usePublicationSectionLoader(indexEvent: Event, refs: PublicationSectionRef[]) { export function usePublicationSectionLoader(
indexEvent: Event,
refs: PublicationSectionRef[],
options?: { autoLoad?: boolean }
) {
const indexId = indexEvent.id const indexId = indexEvent.id
const refsSignature = useMemo(() => signatureOfRefs(refs), [refs]) const refsSignature = useMemo(() => signatureOfRefs(refs), [refs])
const [relayUrls, setRelayUrls] = useState<string[]>([]) const [relayUrls, setRelayUrls] = useState<string[]>([])
@ -68,6 +72,7 @@ export function usePublicationSectionLoader(indexEvent: Event, refs: Publication
const [rows, setRows] = useState<Row[]>([]) const [rows, setRows] = useState<Row[]>([])
const inflightKeysRef = useRef<Set<string>>(new Set()) const inflightKeysRef = useRef<Set<string>>(new Set())
const autoLoadedSignatureRef = useRef<string | null>(null) const autoLoadedSignatureRef = useRef<string | null>(null)
const autoLoad = options?.autoLoad ?? true
useEffect(() => { useEffect(() => {
const cached = indexCache.get(indexId) ?? { loaded: new Map(), failed: new Set() } const cached = indexCache.get(indexId) ?? { loaded: new Map(), failed: new Set() }
@ -355,6 +360,7 @@ export function usePublicationSectionLoader(indexEvent: Event, refs: Publication
) )
useEffect(() => { useEffect(() => {
if (!autoLoad) return
if (relayUrls.length === 0) return if (relayUrls.length === 0) return
const sig = `${indexId}:${refsSignature}` const sig = `${indexId}:${refsSignature}`
if (autoLoadedSignatureRef.current === sig) return if (autoLoadedSignatureRef.current === sig) return
@ -365,7 +371,7 @@ export function usePublicationSectionLoader(indexEvent: Event, refs: Publication
logger.info('[PublicationSection] flush_start', { keys: idleKeys, relayCount: relayUrls.length }) logger.info('[PublicationSection] flush_start', { keys: idleKeys, relayCount: relayUrls.length })
} }
requestKeys(idleKeys) requestKeys(idleKeys)
}, [indexId, refsSignature, relayUrls, rows, requestKeys]) }, [autoLoad, indexId, refsSignature, relayUrls, rows, requestKeys])
const referencesWithEvents = useMemo( const referencesWithEvents = useMemo(
() => () =>

16
src/lib/relay-list-builder.ts

@ -10,7 +10,7 @@
*/ */
import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS, PROFILE_FETCH_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants' import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS, PROFILE_FETCH_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants'
import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' import { isHttpRelayUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import { getCacheRelayUrls } from './private-relays' import { getCacheRelayUrls } from './private-relays'
import client from '@/services/client.service' import client from '@/services/client.service'
import logger from '@/lib/logger' import logger from '@/lib/logger'
@ -20,6 +20,7 @@ function dedupeNormalizedRelayUrls(urls: string[]): string[] {
const seen = new Set<string>() const seen = new Set<string>()
const out: string[] = [] const out: string[] = []
for (const u of urls) { for (const u of urls) {
if (isHttpRelayUrl(u)) continue
const n = normalizeAnyRelayUrl(u) || u.trim() const n = normalizeAnyRelayUrl(u) || u.trim()
if (!n || seen.has(n)) continue if (!n || seen.has(n)) continue
seen.add(n) seen.add(n)
@ -95,6 +96,8 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio
const addRelay = (url: string | undefined) => { const addRelay = (url: string | undefined) => {
if (!url) return if (!url) return
// This builder feeds WebSocket REQ/publish lists; keep HTTP relays separate.
if (isHttpRelayUrl(url)) return
const normalized = normalizeAnyRelayUrl(url) const normalized = normalizeAnyRelayUrl(url)
if (!normalized) return if (!normalized) return
// Filter blocked (case-insensitive comparison) // Filter blocked (case-insensitive comparison)
@ -128,13 +131,11 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio
if (authorRelayList) { if (authorRelayList) {
const authorOutboxes = [ const authorOutboxes = [
...(authorRelayList.httpWrite || []).slice(0, 10),
...(authorRelayList.write || []).slice(0, 10) ...(authorRelayList.write || []).slice(0, 10)
] ]
authorOutboxes.forEach(addRelay) authorOutboxes.forEach(addRelay)
const authorInboxes = [ const authorInboxes = [
...(authorRelayList.httpRead || []).slice(0, 10),
...(authorRelayList.read || []).slice(0, 10) ...(authorRelayList.read || []).slice(0, 10)
] ]
authorInboxes.forEach(addRelay) authorInboxes.forEach(addRelay)
@ -167,11 +168,9 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio
if (userRelayList) { if (userRelayList) {
const userRead = [ const userRead = [
...(userRelayList.httpRead || []).slice(0, 10),
...(userRelayList.read || []).slice(0, 10) ...(userRelayList.read || []).slice(0, 10)
] ]
const userWrite = [ const userWrite = [
...(userRelayList.httpWrite || []).slice(0, 10),
...(userRelayList.write || []).slice(0, 10) ...(userRelayList.write || []).slice(0, 10)
] ]
userRead.forEach(addRelay) userRead.forEach(addRelay)
@ -225,7 +224,6 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio
if (userRelayList) { if (userRelayList) {
const userInboxes = [ const userInboxes = [
...(userRelayList.httpRead || []).slice(0, 10),
...(userRelayList.read || []).slice(0, 10) ...(userRelayList.read || []).slice(0, 10)
] ]
userInboxes.forEach(addRelay) userInboxes.forEach(addRelay)
@ -342,6 +340,7 @@ export async function buildPollResultsReadRelayUrls(options: {
const pushLayer = (urls: string[]) => { const pushLayer = (urls: string[]) => {
for (const raw of urls) { for (const raw of urls) {
if (isHttpRelayUrl(raw)) continue
const normalized = normalizeUrl(raw) || raw?.trim() const normalized = normalizeUrl(raw) || raw?.trim()
if (!normalized || normalizedBlocked.has(normalized.toLowerCase())) continue if (!normalized || normalizedBlocked.has(normalized.toLowerCase())) continue
if (seenNorm.has(normalized)) continue if (seenNorm.has(normalized)) continue
@ -438,6 +437,7 @@ export async function buildReplyWriteRelayList(
const addRelay = (url: string | undefined) => { const addRelay = (url: string | undefined) => {
if (!url) return if (!url) return
if (isHttpRelayUrl(url)) return
const normalized = normalizeAnyRelayUrl(url) const normalized = normalizeAnyRelayUrl(url)
if (!normalized) return if (!normalized) return
// Filter blocked (case-insensitive comparison) // Filter blocked (case-insensitive comparison)
@ -457,13 +457,11 @@ export async function buildReplyWriteRelayList(
if (opRelayList) { if (opRelayList) {
const opOutboxes = [ const opOutboxes = [
...(opRelayList.httpWrite || []).slice(0, 10),
...(opRelayList.write || []).slice(0, 10) ...(opRelayList.write || []).slice(0, 10)
] ]
opOutboxes.forEach(addRelay) opOutboxes.forEach(addRelay)
const opInboxes = [ const opInboxes = [
...(opRelayList.httpRead || []).slice(0, 10),
...(opRelayList.read || []).slice(0, 10) ...(opRelayList.read || []).slice(0, 10)
] ]
opInboxes.forEach(addRelay) opInboxes.forEach(addRelay)
@ -485,7 +483,6 @@ export async function buildReplyWriteRelayList(
if (replyToRelayList) { if (replyToRelayList) {
const replyToInboxes = [ const replyToInboxes = [
...(replyToRelayList.httpRead || []).slice(0, 10),
...(replyToRelayList.read || []).slice(0, 10) ...(replyToRelayList.read || []).slice(0, 10)
] ]
replyToInboxes.forEach(addRelay) replyToInboxes.forEach(addRelay)
@ -507,7 +504,6 @@ export async function buildReplyWriteRelayList(
if (userRelayList) { if (userRelayList) {
const userOutboxes = [ const userOutboxes = [
...(userRelayList.httpWrite || []).slice(0, 10),
...(userRelayList.write || []).slice(0, 10) ...(userRelayList.write || []).slice(0, 10)
] ]
userOutboxes.forEach(addRelay) userOutboxes.forEach(addRelay)

4
src/services/client.service.ts

@ -1880,7 +1880,7 @@ class ClientService extends EventTarget {
relayReqLog?: { groupId?: string; onBatchEnd?: (rows: RelayOpTerminalRow[]) => void } relayReqLog?: { groupId?: string; onBatchEnd?: (rows: RelayOpTerminalRow[]) => void }
) { ) {
const originalDedupedRelays = Array.from(new Set(urls)) const originalDedupedRelays = Array.from(new Set(urls))
let relays = originalDedupedRelays let relays = originalDedupedRelays.filter((url) => !isHttpRelayUrl(url))
const filters = sanitizeSubscribeFiltersBeforeReq(filter) const filters = sanitizeSubscribeFiltersBeforeReq(filter)
if (filters.length === 0) { if (filters.length === 0) {
logger.debug('[relay-req] batch_skip', { logger.debug('[relay-req] batch_skip', {
@ -2578,7 +2578,7 @@ class ClientService extends EventTarget {
} = {} } = {}
) { ) {
const originalDedupedRelays = Array.from(new Set(urls)) const originalDedupedRelays = Array.from(new Set(urls))
let relays = originalDedupedRelays let relays = originalDedupedRelays.filter((url) => !isHttpRelayUrl(url))
if (relays.length === 0) relays = [...FAST_READ_RELAY_URLS] if (relays.length === 0) relays = [...FAST_READ_RELAY_URLS]
const filters = Array.isArray(filter) ? filter : [filter] const filters = Array.isArray(filter) ? filter : [filter]
relays = withDocumentRelayUrlsForFilters(relays, filters) relays = withDocumentRelayUrlsForFilters(relays, filters)

Loading…
Cancel
Save