From 6832914c22ac5bc3616b35937fdb661ad2176de5 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 25 May 2026 20:23:59 +0200 Subject: [PATCH] bug-fixes --- Dockerfile | 3 +- docker-entrypoint.sh | 10 +- package.json | 2 +- src/components/NoteList/index.tsx | 145 +++++------- src/components/ReplyNote/index.tsx | 220 ++++++++---------- src/lib/console-log-buffer.ts | 11 +- src/lib/relay-thread-heat.ts | 5 +- src/pages/primary/NoteListPage/RelaysFeed.tsx | 31 ++- .../primary/SpellsPage/RelayThreadHeatMap.tsx | 37 ++- 9 files changed, 241 insertions(+), 223 deletions(-) diff --git a/Dockerfile b/Dockerfile index 43f47640..413068ac 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,8 +28,7 @@ RUN npm install # Copy the source code to prevent invaliding cache whenever there is a change in the code COPY . . -RUN npm run build \ - && node scripts/write-build-version-json.mjs +RUN npm run build # Step 2: Final container with Nginx and embedded config FROM nginx:alpine diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 587f6ec3..e11688f9 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,9 +1,15 @@ #!/bin/sh # Runtime config for the SPA. NIP-66 monitor runs in a separate cron container; nsec is never sent to the client. # Optional: NIP66_MONITOR_NPUB (npub of the monitor) can be exposed so the relay info page shows who runs the monitor. +HTML=/usr/share/nginx/html +if [ ! -s "$HTML/health.json" ]; then + jq -n --arg t "$(date -Iseconds)" \ + '{status:"ok", name:"imwald", version:"unknown", gitTag:"unknown", gitCommit:"unknown", builtAt:$t}' \ + > "$HTML/health.json" +fi if [ -n "$NIP66_MONITOR_NPUB" ]; then - jq -n --arg npub "$NIP66_MONITOR_NPUB" '{NIP66_MONITOR_NPUB: $npub}' > /usr/share/nginx/html/config.json + jq -n --arg npub "$NIP66_MONITOR_NPUB" '{NIP66_MONITOR_NPUB: $npub}' > "$HTML/config.json" else - echo '{}' > /usr/share/nginx/html/config.json + echo '{}' > "$HTML/config.json" fi exec nginx -g "daemon off;" diff --git a/package.json b/package.json index d4204bda..e2af0d6e 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "docker:editor-tools": "bash scripts/ensure-libretranslate-dirs.sh && docker compose -f docker-compose.dev.yml --profile editor-tools up -d languagetool libretranslate", "docker:local-ancillary": "bash scripts/ensure-libretranslate-dirs.sh && docker compose -f docker-compose.dev.yml --profile editor-tools --profile local-tts build piper-tts-proxy && docker compose -f docker-compose.dev.yml --profile editor-tools --profile local-tts up -d og-proxy languagetool libretranslate piper-wyoming piper-tts-proxy", "piper-tts-proxy": "cross-env NODE_ENV=development tsx services/piper-tts-proxy/http.ts", - "build": "tsc -b && vite build", + "build": "tsc -b && vite build && node scripts/write-build-version-json.mjs", "lint": "eslint .", "knip": "knip", "format": "prettier --write .", diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 0b114692..e43db808 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -911,8 +911,6 @@ const NoteList = forwardRef( const timelineEstablishedCloserRef = useRef<(() => void) | null>(null) /** Bumps on each timeline effect run so Strict Mode / fast remount does not stack subscribeTimeline waves. */ const timelineEffectGenerationRef = useRef(0) - /** Skip closing/reopening the live REQ when effect deps churn but subscription shape is unchanged. */ - const lastTimelineLiveIdentityKeyRef = useRef('') /** Session snapshot was written to state; log once after commit (see feed-paint layout effect). */ const feedPaintSessionPendingRef = useRef(false) /** Relay / one-shot data was written to state; log once after commit. */ @@ -1041,6 +1039,8 @@ const NoteList = forwardRef( useFilterAsIs ] ) + const mapLiveSubRequestsForTimelineRef = useRef(mapLiveSubRequestsForTimeline) + mapLiveSubRequestsForTimelineRef.current = mapLiveSubRequestsForTimeline /** Feed identity for scoping client filter state (timeline key minus unrelated churn where possible). */ const feedClientFilterScopeKey = useMemo( @@ -2021,35 +2021,48 @@ const NoteList = forwardRef( useImperativeHandle(ref, () => ({ scrollToTop, refresh }), [scrollToTop, refresh]) - useEffect(() => { - const timelineLiveIdentityKey = [ - pauseTimelineForPrimaryFreeze ? 'frozen' : 'live', + /** Single key for live timeline REQ identity — effect deps must not exceed this or subscriptions churn. */ + const timelineLiveIdentityKey = useMemo( + () => + [ + pauseTimelineForPrimaryFreeze ? 'frozen' : 'live', + timelineSubscriptionKey, + feedSubscriptionKey ?? '', + sessionSnapshotIdentityKey, + subRequestsKey, + timelineResubscribeKindKey, + seeAllFeedEvents ? '1' : '0', + useFilterAsIs ? '1' : '0', + areAlgoRelays ? '1' : '0', + allowKindlessRelayExplore ? '1' : '0', + clientSideKindFilter ? '1' : '0', + showAllKinds ? '1' : '0', + withKindFilter ? '1' : '0', + feedTimelineScopeKey ?? '', + String(refreshCount), + relayCapabilityReady ? '1' : '0' + ].join('\x1e'), + [ + pauseTimelineForPrimaryFreeze, timelineSubscriptionKey, - feedSubscriptionKey ?? '', + feedSubscriptionKey, sessionSnapshotIdentityKey, subRequestsKey, timelineResubscribeKindKey, - seeAllFeedEvents ? '1' : '0', - useFilterAsIs ? '1' : '0', - areAlgoRelays ? '1' : '0', - allowKindlessRelayExplore ? '1' : '0', - clientSideKindFilter ? '1' : '0', - showAllKinds ? '1' : '0', - withKindFilter ? '1' : '0', - feedTimelineScopeKey ?? '', - String(refreshCount), - relayCapabilityReady ? '1' : '0' - ].join('\x1e') - - if ( - !pauseTimelineForPrimaryFreeze && - lastTimelineLiveIdentityKeyRef.current === timelineLiveIdentityKey && - timelineEstablishedCloserRef.current - ) { - return () => {} - } - lastTimelineLiveIdentityKeyRef.current = timelineLiveIdentityKey + seeAllFeedEvents, + useFilterAsIs, + areAlgoRelays, + allowKindlessRelayExplore, + clientSideKindFilter, + showAllKinds, + withKindFilter, + feedTimelineScopeKey, + refreshCount, + relayCapabilityReady + ] + ) + useEffect(() => { const effectGen = ++timelineEffectGenerationRef.current const timelineEffectStale = () => effectGen !== timelineEffectGenerationRef.current @@ -2108,7 +2121,7 @@ const NoteList = forwardRef( try { const mapped = stripNostrLandAggrFromTimelineSubRequests( feedSubscriptionKey, - mapLiveSubRequestsForTimeline(subRequestsRef.current) + mapLiveSubRequestsForTimelineRef.current(subRequestsRef.current) ) .map((req) => isOfflineRef.current @@ -2201,7 +2214,7 @@ const NoteList = forwardRef( const mappedSubRequests = stripNostrLandAggrFromTimelineSubRequests( feedSubscriptionKey, - mapLiveSubRequestsForTimeline(subRequestsRef.current) + mapLiveSubRequestsForTimelineRef.current(subRequestsRef.current) ) .map((req) => isOfflineRef.current @@ -3457,7 +3470,6 @@ const NoteList = forwardRef( const promise = init() const snapshotKeyForCleanup = sessionSnapshotIdentityKey return () => { - lastTimelineLiveIdentityKeyRef.current = '' effectActive = false if (liveOnNewFlushTimerRef.current != null) { clearTimeout(liveOnNewFlushTimerRef.current) @@ -3490,38 +3502,25 @@ const NoteList = forwardRef( } }) } - }, [ - timelineSubscriptionKey, - feedSubscriptionKey, - sessionSnapshotIdentityKey, - subRequestsKey, - preserveTimelineOnSubRequestsChange, - mergeTimelineWhenSubRequestFiltersMatch, - feedTimelineScopeKey, - refreshCount, - timelineResubscribeKindKey, - seeAllFeedEvents, - useFilterAsIs, - areAlgoRelays, - relayCapabilityReady, - oneShotFetch, - oneShotMergedCap, - revealBatchSize, - oneShotDebugLabel, - oneShotGlobalTimeoutMs, - oneShotEoseTimeoutMs, - oneShotFirstRelayGraceMs, - clientSideKindFilter, - allowKindlessRelayExplore, - showAllKinds, - withKindFilter, - onSingleRelayKindlessEmpty, - mapLiveSubRequestsForTimeline, - progressiveWarmupQuery, - hostPrimaryPageName, - relayAuthoritativeFeedOnly, - pauseTimelineForPrimaryFreeze - ]) + }, [timelineLiveIdentityKey, pauseTimelineForPrimaryFreeze, oneShotFetch]) + + const followingFeedDeltaIdentityKey = useMemo( + () => + [ + followingFeedDeltaSubRequestsKey, + timelineKey ?? '', + feedSubscriptionKey ?? '', + areAlgoRelays ? '1' : '0', + pauseTimelineForPrimaryFreeze ? 'frozen' : 'live' + ].join('\x1e'), + [ + followingFeedDeltaSubRequestsKey, + timelineKey, + feedSubscriptionKey, + areAlgoRelays, + pauseTimelineForPrimaryFreeze + ] + ) useEffect(() => { if (oneShotFetch) return @@ -3542,7 +3541,7 @@ const NoteList = forwardRef( let deltaActive = true const mappedDelta = stripNostrLandAggrFromTimelineSubRequests( feedSubscriptionKey, - mapLiveSubRequestsForTimeline(deltas) + mapLiveSubRequestsForTimelineRef.current(deltas) ) const seeAllNoSpellDelta = seeAllFeedEventsRef.current && !useFilterAsIsRef.current const filterMissingKindsDelta = (f: Filter) => !f.kinds || f.kinds.length === 0 @@ -3745,24 +3744,7 @@ const NoteList = forwardRef( followingFeedDeltaCloserRef.current?.() followingFeedDeltaCloserRef.current = null } - }, [ - followingFeedDeltaSubRequestsKey, - timelineKey, - oneShotFetch, - feedSubscriptionKey, - mapLiveSubRequestsForTimeline, - areAlgoRelays, - allowKindlessRelayExplore, - useFilterAsIs, - clientSideKindFilter, - startLogin, - pubkey, - effectiveShowKinds, - showKind1OPs, - showKind1Replies, - showKind1111, - pauseTimelineForPrimaryFreeze - ]) + }, [followingFeedDeltaIdentityKey, oneShotFetch]) const oneShotDebugPrevLoadingRef = useRef(false) useEffect(() => { @@ -3941,7 +3923,7 @@ const NoteList = forwardRef( if (publicReadFallbackAttemptedRef.current) return const uiStatuses = relayOpTerminalRowsToTimelineRelayUiStatuses(feedSubscribeRelayOutcomes) - const mapped = mapLiveSubRequestsForTimeline(subRequestsRef.current) + const mapped = mapLiveSubRequestsForTimelineRef.current(subRequestsRef.current) if (!mapped.length) return // Skip fallback for d-tag / layered warmup feeds where the live REQ has no NIP-50 `search` @@ -4038,7 +4020,6 @@ const NoteList = forwardRef( progressiveWarmupQuery, feedFullSearchEvents, feedSubscribeRelayOutcomes, - mapLiveSubRequestsForTimeline, effectiveShowKinds, allowKindlessRelayExplore, timelineSubscriptionKey, diff --git a/src/components/ReplyNote/index.tsx b/src/components/ReplyNote/index.tsx index ab435e00..5e245a7d 100644 --- a/src/components/ReplyNote/index.tsx +++ b/src/components/ReplyNote/index.tsx @@ -100,12 +100,10 @@ export default function ReplyNote({ return true }, [showMuted, mutePubkeySet, event, hideContentMentioningMutedUsers]) - return (
{ - // Don't navigate if clicking on interactive elements const target = e.target as HTMLElement if (target.closest('button') || target.closest('[role="button"]') || target.closest('a') || target.closest('[data-parent-note-preview]')) { return @@ -118,26 +116,21 @@ export default function ReplyNote({ }} > -
- -
-
-
-
+
+
+
+ +
+
@@ -151,98 +144,94 @@ export default function ReplyNote({ />
-
- -
- - {webReactionParentUrl ? ( -
- + +
+ + {webReactionParentUrl ? ( +
+ +
+ ) : parentEventId && + event.kind !== kinds.Zap && + event.kind !== ExtendedKind.PAYMENT_NOTIFICATION && + event.kind !== ExtendedKind.ZAP_RECEIPT && + event.kind !== ExtendedKind.MONERO_TIP_DISCLOSURE && + event.kind !== ExtendedKind.MONERO_TIP_RECEIPT ? ( + { + e.stopPropagation() + onClickParent() + }} + /> + ) : null} + {show ? ( + isNip25ReactionKind(event.kind) ? ( +
+ {reactionDisplay.status === 'vote_up' ? ( + + {DISCUSSION_UPVOTE_DISPLAY} + + ) : reactionDisplay.status === 'vote_down' ? ( + + {DISCUSSION_DOWNVOTE_DISPLAY} + + ) : ( + + )} + {reactionDisplay.status !== 'default' && ( + {t(notificationReactionSummaryKey(reactionDisplay))} + )}
- ) : parentEventId && - event.kind !== kinds.Zap && - event.kind !== ExtendedKind.PAYMENT_NOTIFICATION && - event.kind !== ExtendedKind.ZAP_RECEIPT && - event.kind !== ExtendedKind.MONERO_TIP_DISCLOSURE && - event.kind !== ExtendedKind.MONERO_TIP_RECEIPT ? ( - { - e.stopPropagation() - onClickParent() - }} + ) : event.kind === kinds.Zap || event.kind === ExtendedKind.ZAP_RECEIPT ? ( + + ) : event.kind === ExtendedKind.MONERO_TIP_DISCLOSURE || + event.kind === ExtendedKind.MONERO_TIP_RECEIPT ? ( + + ) : event.kind === ExtendedKind.PAYMENT_NOTIFICATION ? ( + + ) : isNip18RepostKind(event.kind) ? null : ( + - ) : null} - {show ? ( - isNip25ReactionKind(event.kind) ? ( -
- {reactionDisplay.status === 'vote_up' ? ( - - {DISCUSSION_UPVOTE_DISPLAY} - - ) : reactionDisplay.status === 'vote_down' ? ( - - {DISCUSSION_DOWNVOTE_DISPLAY} - - ) : ( - - )} - {reactionDisplay.status !== 'default' && ( - {t(notificationReactionSummaryKey(reactionDisplay))} - )} -
- ) : event.kind === kinds.Zap || event.kind === ExtendedKind.ZAP_RECEIPT ? ( - - ) : event.kind === ExtendedKind.MONERO_TIP_DISCLOSURE || - event.kind === ExtendedKind.MONERO_TIP_RECEIPT ? ( - - ) : event.kind === ExtendedKind.PAYMENT_NOTIFICATION ? ( - - ) : isNip18RepostKind(event.kind) ? null : ( - - ) - ) : ( - - )} -
+ ) + ) : ( + + )}
{show && !isNip18RepostKind(event.kind) && ( - <> - - + )}
) @@ -250,19 +239,16 @@ export default function ReplyNote({ export function ReplyNoteSkeleton() { return ( -
- -
-
+
+
+ +
-
-
- -
-
- +
+ +
) } diff --git a/src/lib/console-log-buffer.ts b/src/lib/console-log-buffer.ts index 536f6dc7..b61b3649 100644 --- a/src/lib/console-log-buffer.ts +++ b/src/lib/console-log-buffer.ts @@ -10,8 +10,15 @@ const MAX_ENTRIES = 1000 const buffer: ConsoleLogEntry[] = [] const listeners = new Set<() => void>() let initialized = false +/** Same reference between mutations so `useSyncExternalStore` does not loop (React #185). */ +let snapshot: readonly ConsoleLogEntry[] = buffer + +function refreshSnapshot() { + snapshot = buffer.length === 0 ? buffer : [...buffer] +} function notifyListeners() { + refreshSnapshot() for (const listener of listeners) { listener() } @@ -79,8 +86,8 @@ function captureLog(type: string, ...args: unknown[]) { } /** Ring buffer of recent console output (installed at app startup). */ -export function getConsoleLogBuffer(): ConsoleLogEntry[] { - return [...buffer] +export function getConsoleLogBuffer(): readonly ConsoleLogEntry[] { + return snapshot } export function clearConsoleLogBuffer() { diff --git a/src/lib/relay-thread-heat.ts b/src/lib/relay-thread-heat.ts index b3c3353d..c05fa3fa 100644 --- a/src/lib/relay-thread-heat.ts +++ b/src/lib/relay-thread-heat.ts @@ -18,6 +18,8 @@ export type TRelayThreadHeatBubble = { snippet: string lastActivity: number rootEvent?: Event + /** Thread OP author (for bubble avatar); prefers root note, else top-level kind 1. */ + authorPubkey?: string } /** Undirected link between thread roots (cross-refs, OP-anchor refs, or shared `a`/`A` coordinates). */ @@ -120,7 +122,8 @@ export function buildRelayThreadHeatBubbles( followAuthorsInThread, snippet: collapseRelayThreadHeatSnippet(snippetSource), lastActivity, - rootEvent + rootEvent, + authorPubkey: opForSnippet?.pubkey?.trim().toLowerCase() || rootEvent?.pubkey?.trim().toLowerCase() }) } diff --git a/src/pages/primary/NoteListPage/RelaysFeed.tsx b/src/pages/primary/NoteListPage/RelaysFeed.tsx index 0d1c9312..058d3aea 100644 --- a/src/pages/primary/NoteListPage/RelaysFeed.tsx +++ b/src/pages/primary/NoteListPage/RelaysFeed.tsx @@ -20,6 +20,7 @@ const RelaysFeed = forwardRef< const { relayUrls, replyRelayUrls } = useFeed() const { showKinds } = useKindFilterOrDefaults() const [areAlgoRelays, setAreAlgoRelays] = useState(false) + const [relayCapabilityReady, setRelayCapabilityReady] = useState(false) const relayUrlsKey = useMemo( () => @@ -39,15 +40,19 @@ const RelaysFeed = forwardRef< .join('|'), [replyRelayUrls] ) - const homeFeedSeenOnAllowlistOp = useMemo(() => relayUrls, [relayUrlsKey]) - const homeFeedSeenOnAllowlistReplies = useMemo(() => replyRelayUrls, [replyRelayUrlsKey]) + const stableRelayUrls = useMemo(() => relayUrls, [relayUrlsKey]) + const stableReplyRelayUrls = useMemo(() => replyRelayUrls, [replyRelayUrlsKey]) + const homeFeedSeenOnAllowlistOp = useMemo(() => stableRelayUrls, [relayUrlsKey]) + const homeFeedSeenOnAllowlistReplies = useMemo(() => stableReplyRelayUrls, [replyRelayUrlsKey]) useEffect(() => { if (relayUrls.length === 0) { setAreAlgoRelays(false) + setRelayCapabilityReady(false) return } let cancelled = false + setRelayCapabilityReady(false) const init = async () => { const timeoutPromise = new Promise((_, reject) => { @@ -66,6 +71,8 @@ const RelaysFeed = forwardRef< setAreAlgoRelays(areAlgo) } catch { if (!cancelled) setAreAlgoRelays(false) + } finally { + if (!cancelled) setRelayCapabilityReady(true) } } @@ -82,6 +89,7 @@ const RelaysFeed = forwardRef< if (showKinds.length > 0) return showKinds return fallbackNoteKinds }, [kindsOverride, showKinds, fallbackNoteKinds]) + const defaultKindsKey = useMemo(() => JSON.stringify(defaultKinds), [defaultKinds]) const canRenderFeed = relayUrls.length > 0 @@ -90,24 +98,32 @@ const RelaysFeed = forwardRef< if (!canRenderFeed) return [] return [ { - urls: relayUrls, + urls: stableRelayUrls, filter: { kinds: defaultKinds } } ] - }, [canRenderFeed, relayUrlsKey, relayUrls, defaultKinds]) + }, [canRenderFeed, relayUrlsKey, stableRelayUrls, defaultKindsKey, defaultKinds]) const repliesSubRequests = useMemo(() => { if (!canRenderFeed) return [] return [ { - urls: replyRelayUrls.length > 0 ? replyRelayUrls : relayUrls, + urls: stableReplyRelayUrls.length > 0 ? stableReplyRelayUrls : stableRelayUrls, filter: { kinds: defaultKinds } } ] - }, [canRenderFeed, replyRelayUrlsKey, replyRelayUrls, relayUrlsKey, relayUrls, defaultKinds]) + }, [ + canRenderFeed, + replyRelayUrlsKey, + stableReplyRelayUrls, + relayUrlsKey, + stableRelayUrls, + defaultKindsKey, + defaultKinds + ]) if (!canRenderFeed) { return null @@ -119,12 +135,13 @@ const RelaysFeed = forwardRef< ref={ref} subRequests={subRequests} areAlgoRelays={areAlgoRelays} + relayCapabilityReady={relayCapabilityReady} isMainFeed setSubHeader={setSubHeader} onSubHeaderRefresh={onSubHeaderRefresh} preserveTimelineOnSubRequestsChange repliesSubRequests={repliesSubRequests} - mainFeedGalleryRelayUrls={replyRelayUrls} + mainFeedGalleryRelayUrls={stableReplyRelayUrls} widenMainGalleryRelays={false} feedSubscriptionKey="home-all-favorites" feedTimelineScopeKey="all-favorites" diff --git a/src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx b/src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx index 74bb0822..db76e513 100644 --- a/src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx +++ b/src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx @@ -1,5 +1,6 @@ import { Button } from '@/components/ui/button' import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' +import { SimpleUserAvatar } from '@/components/UserAvatar' import { ExtendedKind } from '@/constants' import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter' import { filterEventsExcludingTombstones } from '@/lib/event' @@ -547,6 +548,9 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props) {layoutRows.map((row) => { const intensity = Math.min(1, row.heat / maxHeat) const size = Math.min(200, Math.max(76, 52 + Math.sqrt(row.heat) * 9)) + const innerPct = 22 + intensity * 48 + const innerSizePx = Math.max(28, Math.round((size * innerPct) / 100)) + const authorPubkey = row.authorPubkey ?? row.rootEvent?.pubkey const statsLine = t('heatMapBubbleStats', { posts: row.postCount, people: row.uniqueAuthors, @@ -577,15 +581,30 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props) onClick={() => navigateToNote(toNote(row.rootId), row.rootEvent)} aria-label={ariaLabel} > - + {authorPubkey ? ( +
+ +
+ ) : ( + + )}