diff --git a/package-lock.json b/package-lock.json index 7bbbd80b..1585d9c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "jumble-imwald", - "version": "21.3.2", + "version": "21.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jumble-imwald", - "version": "21.3.2", + "version": "21.4.0", "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", diff --git a/package.json b/package.json index b7172e93..92cadbbd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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", "private": true, "type": "module", diff --git a/src/components/Explore/ExploreRelayReviews.tsx b/src/components/Explore/ExploreRelayReviews.tsx index 61baf618..fecafdfd 100644 --- a/src/components/Explore/ExploreRelayReviews.tsx +++ b/src/components/Explore/ExploreRelayReviews.tsx @@ -8,6 +8,7 @@ import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpel import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useNostr } from '@/providers/NostrProvider' import client from '@/services/client.service' +import indexedDb, { StoreNames } from '@/services/indexed-db.service' import type { Event } from 'nostr-tools' import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -32,6 +33,28 @@ function dedupeRelayReviewsNewestFirst(events: Event[]): Event[] { return out } +async function loadCachedRelayReviews(limit: number): Promise { + 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() { const { t } = useTranslation() const { favoriteRelays, blockedRelays } = useFavoriteRelays() @@ -70,6 +93,10 @@ export default function ExploreRelayReviews() { setShowCount(SHOW_COUNT) void (async () => { + const cached = await loadCachedRelayReviews(REVIEW_QUERY_LIMIT) + if (!cancelled && fetchGenRef.current === gen && cached.length > 0) { + setEvents(cached) + } try { const raw = await client.fetchEvents( relayUrls, diff --git a/src/components/HelpAndAccountMenu.tsx b/src/components/HelpAndAccountMenu.tsx index 0944bd01..0eb50f56 100644 --- a/src/components/HelpAndAccountMenu.tsx +++ b/src/components/HelpAndAccountMenu.tsx @@ -16,6 +16,7 @@ import { Skeleton } from '@/components/ui/skeleton' import { formatPubkey, formatNpub, generateImageByPubkey, pubkeyToNpub } from '@/lib/pubkey' import { cn } from '@/lib/utils' import { usePrimaryPage } from '@/contexts/primary-page-context' +import { useFetchProfile } from '@/hooks/useFetchProfile' import { useNostr } from '@/providers/NostrProvider' import { ArrowDownUp, LogIn, LogOut, Settings, User, UserRound } from 'lucide-react' import { useMemo, useState, type ReactNode } from 'react' @@ -67,6 +68,7 @@ function SidebarAccountMenu({ const { account, profile } = useNostr() const { current, display } = usePrimaryPage() const pubkey = account?.pubkey + const { profile: fetchedProfile } = useFetchProfile(pubkey) const active = useMemo(() => current === 'profile' && display, [display, current]) if (!pubkey) return null @@ -74,7 +76,8 @@ function SidebarAccountMenu({ const defaultAvatar = generateImageByPubkey(pubkey) const npub = pubkeyToNpub(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 ( @@ -114,11 +117,14 @@ function TitlebarAccountMenu({ onLogoutClick: () => void }) { 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 defaultAvatar = useMemo( - () => (profile?.pubkey ? generateImageByPubkey(profile.pubkey) : ''), - [profile] + () => (resolvedProfile?.pubkey ? generateImageByPubkey(resolvedProfile.pubkey) : ''), + [resolvedProfile] ) const active = useMemo(() => current === 'profile' && display, [display, current]) @@ -132,9 +138,9 @@ function TitlebarAccountMenu({ title={t('Account menu')} aria-label={t('Account menu')} > - {profile ? ( + {resolvedProfile ? ( - + diff --git a/src/components/ImageWithLightbox/index.tsx b/src/components/ImageWithLightbox/index.tsx index 2deb3b13..556db32f 100644 --- a/src/components/ImageWithLightbox/index.tsx +++ b/src/components/ImageWithLightbox/index.tsx @@ -72,7 +72,13 @@ export default function ImageWithLightbox({ /> {index >= 0 && createPortal( -
e.stopPropagation()}> +
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onTouchStart={(e) => e.stopPropagation()} + > publicationFootnotesContainerId ?? @@ -159,6 +164,8 @@ export default function PublicationIndex({ [publicationFootnotesContainerId, isTopLevelPublication, event.id] ) const [isRetrying, setIsRetrying] = useState(false) + const [sectionLoadCount, setSectionLoadCount] = useState(initialSectionLoadCount) + const lazyLoadSentinelRef = useRef(null) // Extract references from 'a' tags (addressable events) and 'e' tags (event IDs) const referencesData = useMemo(() => { @@ -191,8 +198,8 @@ export default function PublicationIndex({ return refs }, [event]) - const { retryKeys, failedKeys, referencesWithEvents } = - usePublicationSectionLoader(event, referencesData) + const { requestKeys, retryKeys, failedKeys, referencesWithEvents } = + usePublicationSectionLoader(event, referencesData, { autoLoad: false }) const renderedEventsVersion = useSyncExternalStore( subscribeRenderedPublicationEvents, getRenderedPublicationEventsVersion, @@ -369,10 +376,29 @@ export default function PublicationIndex({ // Scroll to section const scrollToSection = (coordinate: string) => { - const element = document.getElementById(`section-${coordinate.replace(/:/g, '-')}`) - if (element) { - element.scrollIntoView({ behavior: 'smooth', block: 'start' }) + const targetId = `section-${coordinate.replace(/:/g, '-')}` + const sectionIndex = referencesWithEvents.findIndex( + (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) }, [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(() => { setIsRetrying(true) const keys = @@ -408,6 +469,19 @@ export default function PublicationIndex({ window.setTimeout(() => setIsRetrying(false), 600) }, [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 (
@@ -436,11 +510,25 @@ export default function PublicationIndex({
)} {(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.type ? { label: 'Type', value: metadata.type } : null, + metadata.version ? { label: 'Version', value: metadata.version } : null, + 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) => ( +
+ {index > 0 && ( + + · + + )} + {item.label} + {item.value} +
+ ))}
)} {metadata.tags.length > 0 && ( @@ -468,24 +556,28 @@ export default function PublicationIndex({
)} - {metadata.summary && ( -
-

{metadata.summary}

-
- )} {/* Display image for top-level 30040 publication */} {metadata.image && (
-
)} + {metadata.summary && ( +
+
+ Summary +
+

+ {metadata.summary} +

+
+ )}
@@ -520,6 +612,24 @@ export default function PublicationIndex({
)} + {isNested && (showNestedImagePreview || showNestedSummaryPreview) && ( +
+ {showNestedImagePreview && metadata.image && ( +
+ +
+ )} + {showNestedSummaryPreview && metadata.summary && ( +

+ {metadata.summary} +

+ )} +
+ )} {/* Table of Contents - only show for top-level publications */} {!isNested && tableOfContents.length > 0 && ( @@ -571,7 +681,7 @@ export default function PublicationIndex({
) : (
- {referencesWithEvents.map((ref, index) => { + {visibleReferences.map((ref, index) => { const sectionKey = publicationRefKey(ref) const coordinate = ref.coordinate || ref.eventId || '' const sectionId = `section-${coordinate.replace(/:/g, '-')}` @@ -629,6 +739,18 @@ export default function PublicationIndex({ const eventKind = ref.event?.kind ?? ref.kind ?? 0 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) { const publicationTitleTag = ref.event.tags.find((tag) => tag[0] === 'title')?.[1] @@ -706,6 +828,7 @@ export default function PublicationIndex({ event={ref.event} isNested={true} parentImageUrl={effectiveParentImageUrl} + parentSummary={effectiveParentSummary} flattenHierarchy={forceFlatHierarchy} chapterDepth={publicationDepth} publicationFootnotesContainerId={resolvedPublicationFootnotesContainerId} @@ -736,6 +859,24 @@ export default function PublicationIndex({ )}
+ {(showSectionImagePreview || showSectionSummaryPreview) && ( +
+ {showSectionImagePreview && sectionImageTag && ( +
+ +
+ )} + {showSectionSummaryPreview && sectionSummaryTag && ( +

+ {sectionSummaryTag} +

+ )} +
+ )} ) })} + {sectionLoadCount < referencesWithEvents.length && ( +
+ )}
)} {isTopLevelPublication && resolvedPublicationFootnotesContainerId && ( diff --git a/src/components/Profile/ProfilePublicationsFeed.tsx b/src/components/Profile/ProfilePublicationsFeed.tsx index 221a5923..9c3c2786 100644 --- a/src/components/Profile/ProfilePublicationsFeed.tsx +++ b/src/components/Profile/ProfilePublicationsFeed.tsx @@ -1,4 +1,5 @@ import ProfileSearchBar from '@/components/ui/ProfileSearchBar' +import { ExtendedKind } from '@/constants' import { PROFILE_PUBLICATIONS_TAB_KINDS } from '@/constants' import { forwardRef, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -9,7 +10,11 @@ const ProfilePublicationsFeed = forwardRef<{ refresh: () => void }, { pubkey: st const [searchQuery, setSearchQuery] = useState('') 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') @@ -30,6 +35,7 @@ const ProfilePublicationsFeed = forwardRef<{ refresh: () => void }, { pubkey: st kindFilter="all" kinds={kindsList} cacheKey={cacheKey} + filterPredicate={visiblePublicationFilter} getKindLabel={getKindLabel} refreshLabel={t('Refreshing articles...')} emptyLabel={t('No articles or publications found')} diff --git a/src/components/SessionRelaysTab/index.tsx b/src/components/SessionRelaysTab/index.tsx index da3e4da2..697e79f8 100644 --- a/src/components/SessionRelaysTab/index.tsx +++ b/src/components/SessionRelaysTab/index.tsx @@ -1,10 +1,12 @@ import client from '@/services/client.service' import relayInfoService from '@/services/relay-info.service' +import { isHttpRelayUrl } from '@/lib/url' 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 { Button } from '@/components/ui/button' import type { TRelayInfo } from '@/types' +import { useNostr } from '@/providers/NostrProvider' type SessionDebug = { strikedUrls: string[] @@ -19,6 +21,7 @@ function loadDebug(): SessionDebug { export default function SessionRelaysTab() { const { t } = useTranslation() + const { httpRelayListEvent } = useNostr() const [debug, setDebug] = useState(null) const [relayInfoByUrl, setRelayInfoByUrl] = useState>({}) @@ -55,8 +58,6 @@ export default function SessionRelaysTab() { } }, [debug]) - if (debug === null) return null - const clearStrikeForUrl = (url: string) => { client.clearSessionRelayStrikeForUrl(url) refresh() @@ -77,6 +78,40 @@ export default function SessionRelaysTab() { return formatRelayAddress(url) } + const configuredHttpRelayAddresses = useMemo(() => { + const out = new Set() + 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 }) => ( + + + {formatRelayLabel(url)} + + {isHttpRelayEntry(url) ? ( + + HTTP + + ) : null} + + ) + return (
@@ -102,8 +137,8 @@ export default function SessionRelaysTab() {
  • {t('None')}
  • ) : ( debug.presetWorking.map((url) => ( -
  • - {formatRelayLabel(url)} +
  • +
  • )) )} @@ -124,9 +159,7 @@ export default function SessionRelaysTab() { ) : ( debug.presetStriked.map((url) => (
  • - - {formatRelayLabel(url)} - +