Browse Source

bug-fixes

imwald
Silberengel 3 weeks ago
parent
commit
6832914c22
  1. 3
      Dockerfile
  2. 10
      docker-entrypoint.sh
  3. 2
      package.json
  4. 145
      src/components/NoteList/index.tsx
  5. 220
      src/components/ReplyNote/index.tsx
  6. 11
      src/lib/console-log-buffer.ts
  7. 5
      src/lib/relay-thread-heat.ts
  8. 31
      src/pages/primary/NoteListPage/RelaysFeed.tsx
  9. 37
      src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx

3
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 the source code to prevent invaliding cache whenever there is a change in the code
COPY . . COPY . .
RUN npm run build \ RUN npm run build
&& node scripts/write-build-version-json.mjs
# Step 2: Final container with Nginx and embedded config # Step 2: Final container with Nginx and embedded config
FROM nginx:alpine FROM nginx:alpine

10
docker-entrypoint.sh

@ -1,9 +1,15 @@
#!/bin/sh #!/bin/sh
# Runtime config for the SPA. NIP-66 monitor runs in a separate cron container; nsec is never sent to the client. # 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. # 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 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 else
echo '{}' > /usr/share/nginx/html/config.json echo '{}' > "$HTML/config.json"
fi fi
exec nginx -g "daemon off;" exec nginx -g "daemon off;"

2
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: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", "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", "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 .", "lint": "eslint .",
"knip": "knip", "knip": "knip",
"format": "prettier --write .", "format": "prettier --write .",

145
src/components/NoteList/index.tsx

@ -911,8 +911,6 @@ const NoteList = forwardRef(
const timelineEstablishedCloserRef = useRef<(() => void) | null>(null) const timelineEstablishedCloserRef = useRef<(() => void) | null>(null)
/** Bumps on each timeline effect run so Strict Mode / fast remount does not stack subscribeTimeline waves. */ /** Bumps on each timeline effect run so Strict Mode / fast remount does not stack subscribeTimeline waves. */
const timelineEffectGenerationRef = useRef(0) 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). */ /** Session snapshot was written to state; log once after commit (see feed-paint layout effect). */
const feedPaintSessionPendingRef = useRef(false) const feedPaintSessionPendingRef = useRef(false)
/** Relay / one-shot data was written to state; log once after commit. */ /** Relay / one-shot data was written to state; log once after commit. */
@ -1041,6 +1039,8 @@ const NoteList = forwardRef(
useFilterAsIs useFilterAsIs
] ]
) )
const mapLiveSubRequestsForTimelineRef = useRef(mapLiveSubRequestsForTimeline)
mapLiveSubRequestsForTimelineRef.current = mapLiveSubRequestsForTimeline
/** Feed identity for scoping client filter state (timeline key minus unrelated churn where possible). */ /** Feed identity for scoping client filter state (timeline key minus unrelated churn where possible). */
const feedClientFilterScopeKey = useMemo( const feedClientFilterScopeKey = useMemo(
@ -2021,35 +2021,48 @@ const NoteList = forwardRef(
useImperativeHandle(ref, () => ({ scrollToTop, refresh }), [scrollToTop, refresh]) useImperativeHandle(ref, () => ({ scrollToTop, refresh }), [scrollToTop, refresh])
useEffect(() => { /** Single key for live timeline REQ identity — effect deps must not exceed this or subscriptions churn. */
const timelineLiveIdentityKey = [ const timelineLiveIdentityKey = useMemo(
pauseTimelineForPrimaryFreeze ? 'frozen' : 'live', () =>
[
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, timelineSubscriptionKey,
feedSubscriptionKey ?? '', feedSubscriptionKey,
sessionSnapshotIdentityKey, sessionSnapshotIdentityKey,
subRequestsKey, subRequestsKey,
timelineResubscribeKindKey, timelineResubscribeKindKey,
seeAllFeedEvents ? '1' : '0', seeAllFeedEvents,
useFilterAsIs ? '1' : '0', useFilterAsIs,
areAlgoRelays ? '1' : '0', areAlgoRelays,
allowKindlessRelayExplore ? '1' : '0', allowKindlessRelayExplore,
clientSideKindFilter ? '1' : '0', clientSideKindFilter,
showAllKinds ? '1' : '0', showAllKinds,
withKindFilter ? '1' : '0', withKindFilter,
feedTimelineScopeKey ?? '', feedTimelineScopeKey,
String(refreshCount), refreshCount,
relayCapabilityReady ? '1' : '0' relayCapabilityReady
].join('\x1e') ]
)
if (
!pauseTimelineForPrimaryFreeze &&
lastTimelineLiveIdentityKeyRef.current === timelineLiveIdentityKey &&
timelineEstablishedCloserRef.current
) {
return () => {}
}
lastTimelineLiveIdentityKeyRef.current = timelineLiveIdentityKey
useEffect(() => {
const effectGen = ++timelineEffectGenerationRef.current const effectGen = ++timelineEffectGenerationRef.current
const timelineEffectStale = () => effectGen !== timelineEffectGenerationRef.current const timelineEffectStale = () => effectGen !== timelineEffectGenerationRef.current
@ -2108,7 +2121,7 @@ const NoteList = forwardRef(
try { try {
const mapped = stripNostrLandAggrFromTimelineSubRequests( const mapped = stripNostrLandAggrFromTimelineSubRequests(
feedSubscriptionKey, feedSubscriptionKey,
mapLiveSubRequestsForTimeline(subRequestsRef.current) mapLiveSubRequestsForTimelineRef.current(subRequestsRef.current)
) )
.map((req) => .map((req) =>
isOfflineRef.current isOfflineRef.current
@ -2201,7 +2214,7 @@ const NoteList = forwardRef(
const mappedSubRequests = stripNostrLandAggrFromTimelineSubRequests( const mappedSubRequests = stripNostrLandAggrFromTimelineSubRequests(
feedSubscriptionKey, feedSubscriptionKey,
mapLiveSubRequestsForTimeline(subRequestsRef.current) mapLiveSubRequestsForTimelineRef.current(subRequestsRef.current)
) )
.map((req) => .map((req) =>
isOfflineRef.current isOfflineRef.current
@ -3457,7 +3470,6 @@ const NoteList = forwardRef(
const promise = init() const promise = init()
const snapshotKeyForCleanup = sessionSnapshotIdentityKey const snapshotKeyForCleanup = sessionSnapshotIdentityKey
return () => { return () => {
lastTimelineLiveIdentityKeyRef.current = ''
effectActive = false effectActive = false
if (liveOnNewFlushTimerRef.current != null) { if (liveOnNewFlushTimerRef.current != null) {
clearTimeout(liveOnNewFlushTimerRef.current) clearTimeout(liveOnNewFlushTimerRef.current)
@ -3490,38 +3502,25 @@ const NoteList = forwardRef(
} }
}) })
} }
}, [ }, [timelineLiveIdentityKey, pauseTimelineForPrimaryFreeze, oneShotFetch])
timelineSubscriptionKey,
feedSubscriptionKey, const followingFeedDeltaIdentityKey = useMemo(
sessionSnapshotIdentityKey, () =>
subRequestsKey, [
preserveTimelineOnSubRequestsChange, followingFeedDeltaSubRequestsKey,
mergeTimelineWhenSubRequestFiltersMatch, timelineKey ?? '',
feedTimelineScopeKey, feedSubscriptionKey ?? '',
refreshCount, areAlgoRelays ? '1' : '0',
timelineResubscribeKindKey, pauseTimelineForPrimaryFreeze ? 'frozen' : 'live'
seeAllFeedEvents, ].join('\x1e'),
useFilterAsIs, [
areAlgoRelays, followingFeedDeltaSubRequestsKey,
relayCapabilityReady, timelineKey,
oneShotFetch, feedSubscriptionKey,
oneShotMergedCap, areAlgoRelays,
revealBatchSize, pauseTimelineForPrimaryFreeze
oneShotDebugLabel, ]
oneShotGlobalTimeoutMs, )
oneShotEoseTimeoutMs,
oneShotFirstRelayGraceMs,
clientSideKindFilter,
allowKindlessRelayExplore,
showAllKinds,
withKindFilter,
onSingleRelayKindlessEmpty,
mapLiveSubRequestsForTimeline,
progressiveWarmupQuery,
hostPrimaryPageName,
relayAuthoritativeFeedOnly,
pauseTimelineForPrimaryFreeze
])
useEffect(() => { useEffect(() => {
if (oneShotFetch) return if (oneShotFetch) return
@ -3542,7 +3541,7 @@ const NoteList = forwardRef(
let deltaActive = true let deltaActive = true
const mappedDelta = stripNostrLandAggrFromTimelineSubRequests( const mappedDelta = stripNostrLandAggrFromTimelineSubRequests(
feedSubscriptionKey, feedSubscriptionKey,
mapLiveSubRequestsForTimeline(deltas) mapLiveSubRequestsForTimelineRef.current(deltas)
) )
const seeAllNoSpellDelta = seeAllFeedEventsRef.current && !useFilterAsIsRef.current const seeAllNoSpellDelta = seeAllFeedEventsRef.current && !useFilterAsIsRef.current
const filterMissingKindsDelta = (f: Filter) => !f.kinds || f.kinds.length === 0 const filterMissingKindsDelta = (f: Filter) => !f.kinds || f.kinds.length === 0
@ -3745,24 +3744,7 @@ const NoteList = forwardRef(
followingFeedDeltaCloserRef.current?.() followingFeedDeltaCloserRef.current?.()
followingFeedDeltaCloserRef.current = null followingFeedDeltaCloserRef.current = null
} }
}, [ }, [followingFeedDeltaIdentityKey, oneShotFetch])
followingFeedDeltaSubRequestsKey,
timelineKey,
oneShotFetch,
feedSubscriptionKey,
mapLiveSubRequestsForTimeline,
areAlgoRelays,
allowKindlessRelayExplore,
useFilterAsIs,
clientSideKindFilter,
startLogin,
pubkey,
effectiveShowKinds,
showKind1OPs,
showKind1Replies,
showKind1111,
pauseTimelineForPrimaryFreeze
])
const oneShotDebugPrevLoadingRef = useRef(false) const oneShotDebugPrevLoadingRef = useRef(false)
useEffect(() => { useEffect(() => {
@ -3941,7 +3923,7 @@ const NoteList = forwardRef(
if (publicReadFallbackAttemptedRef.current) return if (publicReadFallbackAttemptedRef.current) return
const uiStatuses = relayOpTerminalRowsToTimelineRelayUiStatuses(feedSubscribeRelayOutcomes) const uiStatuses = relayOpTerminalRowsToTimelineRelayUiStatuses(feedSubscribeRelayOutcomes)
const mapped = mapLiveSubRequestsForTimeline(subRequestsRef.current) const mapped = mapLiveSubRequestsForTimelineRef.current(subRequestsRef.current)
if (!mapped.length) return if (!mapped.length) return
// Skip fallback for d-tag / layered warmup feeds where the live REQ has no NIP-50 `search` // 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, progressiveWarmupQuery,
feedFullSearchEvents, feedFullSearchEvents,
feedSubscribeRelayOutcomes, feedSubscribeRelayOutcomes,
mapLiveSubRequestsForTimeline,
effectiveShowKinds, effectiveShowKinds,
allowKindlessRelayExplore, allowKindlessRelayExplore,
timelineSubscriptionKey, timelineSubscriptionKey,

220
src/components/ReplyNote/index.tsx

@ -100,12 +100,10 @@ export default function ReplyNote({
return true return true
}, [showMuted, mutePubkeySet, event, hideContentMentioningMutedUsers]) }, [showMuted, mutePubkeySet, event, hideContentMentioningMutedUsers])
return ( return (
<div <div
className={`pb-3 border-b transition-colors duration-500 clickable ${highlight ? 'bg-primary/50' : ''}`} className={`clickable border-b pb-3 transition-colors duration-500 ${highlight ? 'bg-primary/50' : ''}`}
onClick={(e) => { onClick={(e) => {
// Don't navigate if clicking on interactive elements
const target = e.target as HTMLElement const target = e.target as HTMLElement
if (target.closest('button') || target.closest('[role="button"]') || target.closest('a') || target.closest('[data-parent-note-preview]')) { if (target.closest('button') || target.closest('[role="button"]') || target.closest('a') || target.closest('[data-parent-note-preview]')) {
return return
@ -118,26 +116,21 @@ export default function ReplyNote({
}} }}
> >
<Collapsible> <Collapsible>
<div className="flex space-x-2 items-start px-4 pt-3"> <div className="px-4 pt-3">
<UserAvatar <div className="flex min-w-0 items-start justify-between gap-2">
userId={headerUserId} <div className="flex min-w-0 flex-1 items-start gap-2">
size="medium" <UserAvatar
className="shrink-0 mt-0.5" userId={headerUserId}
maxFileSizeKb={2048} size="medium"
deferRemoteAvatar={false} className="mt-0.5 shrink-0"
/> maxFileSizeKb={2048}
<div deferRemoteAvatar={false}
className={cn( />
'w-full min-w-0', <div className="min-w-0 flex-1">
isNip25ReactionKind(event.kind) ? 'overflow-visible' : 'overflow-x-hidden' <div className="flex items-center gap-1">
)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 w-0">
<div className="flex gap-1 items-center">
<Username <Username
userId={headerUserId} userId={headerUserId}
className="text-sm font-semibold text-muted-foreground hover:text-foreground truncate" className="truncate text-sm font-semibold text-muted-foreground hover:text-foreground"
skeletonClassName="h-3" skeletonClassName="h-3"
/> />
<ClientTag event={event} /> <ClientTag event={event} />
@ -151,98 +144,94 @@ export default function ReplyNote({
/> />
</div> </div>
</div> </div>
<div className="flex items-center shrink-0">
<NoteOptions event={event} className="shrink-0 [&_svg]:size-5" />
</div>
</div> </div>
<EventPowLabel event={event} className="mt-0.5" /> <NoteOptions event={event} className="shrink-0 [&_svg]:size-5" />
{webReactionParentUrl ? ( </div>
<div className="mt-1.5 not-prose max-w-full" data-parent-note-preview> <EventPowLabel event={event} className="mt-0.5" />
<WebPreview url={webReactionParentUrl} className="w-full" /> {webReactionParentUrl ? (
<div className="not-prose mt-1.5 max-w-full" data-parent-note-preview>
<WebPreview url={webReactionParentUrl} className="w-full" />
</div>
) : 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 ? (
<ParentNotePreview
appearance="subtle"
className="mt-1.5"
eventId={parentEventId}
relayHints={parentFetchRelayHints}
onClick={(e) => {
e.stopPropagation()
onClickParent()
}}
/>
) : null}
{show ? (
isNip25ReactionKind(event.kind) ? (
<div
className={cn(
'mt-2 flex min-h-0 min-w-0 flex-wrap items-end gap-x-2 gap-y-1 overflow-visible pb-1.5',
reactionDisplay.status === 'default'
? 'text-foreground'
: 'text-muted-foreground text-sm'
)}
>
{reactionDisplay.status === 'vote_up' ? (
<span className="text-sm leading-none opacity-90" aria-hidden>
{DISCUSSION_UPVOTE_DISPLAY}
</span>
) : reactionDisplay.status === 'vote_down' ? (
<span className="text-sm leading-none opacity-90" aria-hidden>
{DISCUSSION_DOWNVOTE_DISPLAY}
</span>
) : (
<ReactionEmojiDisplay event={event} variant="thread" maxRawLength={64} />
)}
{reactionDisplay.status !== 'default' && (
<span className="text-sm text-foreground/85">{t(notificationReactionSummaryKey(reactionDisplay))}</span>
)}
</div> </div>
) : parentEventId && ) : event.kind === kinds.Zap || event.kind === ExtendedKind.ZAP_RECEIPT ? (
event.kind !== kinds.Zap && <Zap className="mt-1.5" event={event} variant="thread" />
event.kind !== ExtendedKind.PAYMENT_NOTIFICATION && ) : event.kind === ExtendedKind.MONERO_TIP_DISCLOSURE ||
event.kind !== ExtendedKind.ZAP_RECEIPT && event.kind === ExtendedKind.MONERO_TIP_RECEIPT ? (
event.kind !== ExtendedKind.MONERO_TIP_DISCLOSURE && <MoneroTip className="mt-1.5" event={event} variant="thread" />
event.kind !== ExtendedKind.MONERO_TIP_RECEIPT ? ( ) : event.kind === ExtendedKind.PAYMENT_NOTIFICATION ? (
<ParentNotePreview <Superchat className="mt-1.5" event={event} variant="thread" />
appearance="subtle" ) : isNip18RepostKind(event.kind) ? null : (
className="mt-1.5" <MarkdownArticle
eventId={parentEventId} className="mt-2"
relayHints={parentFetchRelayHints} event={event}
onClick={(e) => { hideMetadata={true}
e.stopPropagation() lazyMedia={false}
onClickParent() duplicateWebPreviewCleanedUrlHints={duplicateWebPreviewCleanedUrlHints}
}}
/> />
) : null} )
{show ? ( ) : (
isNip25ReactionKind(event.kind) ? ( <Button
<div variant="outline"
className={cn( className="mt-2 font-medium text-muted-foreground"
'mt-2 flex min-h-0 min-w-0 flex-wrap items-end gap-x-2 gap-y-1 overflow-visible pb-1.5', onClick={(e) => {
reactionDisplay.status === 'default' e.stopPropagation()
? 'text-foreground' setShowMuted(true)
: 'text-muted-foreground text-sm' }}
)} >
> {t('Temporarily display this reply')}
{reactionDisplay.status === 'vote_up' ? ( </Button>
<span className="text-sm leading-none opacity-90" aria-hidden> )}
{DISCUSSION_UPVOTE_DISPLAY}
</span>
) : reactionDisplay.status === 'vote_down' ? (
<span className="text-sm leading-none opacity-90" aria-hidden>
{DISCUSSION_DOWNVOTE_DISPLAY}
</span>
) : (
<ReactionEmojiDisplay event={event} variant="thread" maxRawLength={64} />
)}
{reactionDisplay.status !== 'default' && (
<span className="text-sm text-foreground/85">{t(notificationReactionSummaryKey(reactionDisplay))}</span>
)}
</div>
) : event.kind === kinds.Zap || event.kind === ExtendedKind.ZAP_RECEIPT ? (
<Zap className="mt-1.5" event={event} variant="thread" />
) : event.kind === ExtendedKind.MONERO_TIP_DISCLOSURE ||
event.kind === ExtendedKind.MONERO_TIP_RECEIPT ? (
<MoneroTip className="mt-1.5" event={event} variant="thread" />
) : event.kind === ExtendedKind.PAYMENT_NOTIFICATION ? (
<Superchat className="mt-1.5" event={event} variant="thread" />
) : isNip18RepostKind(event.kind) ? null : (
<MarkdownArticle
className="mt-2"
event={event}
hideMetadata={true}
lazyMedia={false}
duplicateWebPreviewCleanedUrlHints={duplicateWebPreviewCleanedUrlHints}
/>
)
) : (
<Button
variant="outline"
className="text-muted-foreground font-medium mt-2"
onClick={(e) => {
e.stopPropagation()
setShowMuted(true)
}}
>
{t('Temporarily display this reply')}
</Button>
)}
</div>
</div> </div>
</Collapsible> </Collapsible>
{show && !isNip18RepostKind(event.kind) && ( {show && !isNip18RepostKind(event.kind) && (
<> <NoteStats
<NoteStats className="mt-2 px-4"
className="ml-14 pl-1 mr-4 mt-2" event={event}
event={event} fetchIfNotExisting
fetchIfNotExisting foregroundStats={foregroundStats}
foregroundStats={foregroundStats} useIconOnlyLikeTrigger={isNip25ReactionKind(event.kind)}
useIconOnlyLikeTrigger={isNip25ReactionKind(event.kind)} />
/>
</>
)} )}
</div> </div>
) )
@ -250,19 +239,16 @@ export default function ReplyNote({
export function ReplyNoteSkeleton() { export function ReplyNoteSkeleton() {
return ( return (
<div className="px-4 py-3 flex items-start space-x-2 w-full"> <div className="w-full px-4 py-3">
<Skeleton className="w-9 h-9 rounded-full shrink-0 mt-0.5" /> <div className="flex items-start gap-2">
<div className="w-full"> <Skeleton className="mt-0.5 h-9 w-9 shrink-0 rounded-full" />
<div className="py-1"> <div className="min-w-0 flex-1">
<Skeleton className="h-3 w-16" /> <Skeleton className="h-3 w-16" />
</div> <Skeleton className="mt-2 h-3 w-24" />
<div className="my-1">
<Skeleton className="w-full h-4 my-1 mt-2" />
</div>
<div className="my-1">
<Skeleton className="w-2/3 h-4 my-1" />
</div> </div>
</div> </div>
<Skeleton className="mt-3 h-4 w-full" />
<Skeleton className="mt-2 h-4 w-2/3" />
</div> </div>
) )
} }

11
src/lib/console-log-buffer.ts

@ -10,8 +10,15 @@ const MAX_ENTRIES = 1000
const buffer: ConsoleLogEntry[] = [] const buffer: ConsoleLogEntry[] = []
const listeners = new Set<() => void>() const listeners = new Set<() => void>()
let initialized = false 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() { function notifyListeners() {
refreshSnapshot()
for (const listener of listeners) { for (const listener of listeners) {
listener() listener()
} }
@ -79,8 +86,8 @@ function captureLog(type: string, ...args: unknown[]) {
} }
/** Ring buffer of recent console output (installed at app startup). */ /** Ring buffer of recent console output (installed at app startup). */
export function getConsoleLogBuffer(): ConsoleLogEntry[] { export function getConsoleLogBuffer(): readonly ConsoleLogEntry[] {
return [...buffer] return snapshot
} }
export function clearConsoleLogBuffer() { export function clearConsoleLogBuffer() {

5
src/lib/relay-thread-heat.ts

@ -18,6 +18,8 @@ export type TRelayThreadHeatBubble = {
snippet: string snippet: string
lastActivity: number lastActivity: number
rootEvent?: Event 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). */ /** Undirected link between thread roots (cross-refs, OP-anchor refs, or shared `a`/`A` coordinates). */
@ -120,7 +122,8 @@ export function buildRelayThreadHeatBubbles(
followAuthorsInThread, followAuthorsInThread,
snippet: collapseRelayThreadHeatSnippet(snippetSource), snippet: collapseRelayThreadHeatSnippet(snippetSource),
lastActivity, lastActivity,
rootEvent rootEvent,
authorPubkey: opForSnippet?.pubkey?.trim().toLowerCase() || rootEvent?.pubkey?.trim().toLowerCase()
}) })
} }

31
src/pages/primary/NoteListPage/RelaysFeed.tsx

@ -20,6 +20,7 @@ const RelaysFeed = forwardRef<
const { relayUrls, replyRelayUrls } = useFeed() const { relayUrls, replyRelayUrls } = useFeed()
const { showKinds } = useKindFilterOrDefaults() const { showKinds } = useKindFilterOrDefaults()
const [areAlgoRelays, setAreAlgoRelays] = useState(false) const [areAlgoRelays, setAreAlgoRelays] = useState(false)
const [relayCapabilityReady, setRelayCapabilityReady] = useState(false)
const relayUrlsKey = useMemo( const relayUrlsKey = useMemo(
() => () =>
@ -39,15 +40,19 @@ const RelaysFeed = forwardRef<
.join('|'), .join('|'),
[replyRelayUrls] [replyRelayUrls]
) )
const homeFeedSeenOnAllowlistOp = useMemo(() => relayUrls, [relayUrlsKey]) const stableRelayUrls = useMemo(() => relayUrls, [relayUrlsKey])
const homeFeedSeenOnAllowlistReplies = useMemo(() => replyRelayUrls, [replyRelayUrlsKey]) const stableReplyRelayUrls = useMemo(() => replyRelayUrls, [replyRelayUrlsKey])
const homeFeedSeenOnAllowlistOp = useMemo(() => stableRelayUrls, [relayUrlsKey])
const homeFeedSeenOnAllowlistReplies = useMemo(() => stableReplyRelayUrls, [replyRelayUrlsKey])
useEffect(() => { useEffect(() => {
if (relayUrls.length === 0) { if (relayUrls.length === 0) {
setAreAlgoRelays(false) setAreAlgoRelays(false)
setRelayCapabilityReady(false)
return return
} }
let cancelled = false let cancelled = false
setRelayCapabilityReady(false)
const init = async () => { const init = async () => {
const timeoutPromise = new Promise<never>((_, reject) => { const timeoutPromise = new Promise<never>((_, reject) => {
@ -66,6 +71,8 @@ const RelaysFeed = forwardRef<
setAreAlgoRelays(areAlgo) setAreAlgoRelays(areAlgo)
} catch { } catch {
if (!cancelled) setAreAlgoRelays(false) if (!cancelled) setAreAlgoRelays(false)
} finally {
if (!cancelled) setRelayCapabilityReady(true)
} }
} }
@ -82,6 +89,7 @@ const RelaysFeed = forwardRef<
if (showKinds.length > 0) return showKinds if (showKinds.length > 0) return showKinds
return fallbackNoteKinds return fallbackNoteKinds
}, [kindsOverride, showKinds, fallbackNoteKinds]) }, [kindsOverride, showKinds, fallbackNoteKinds])
const defaultKindsKey = useMemo(() => JSON.stringify(defaultKinds), [defaultKinds])
const canRenderFeed = relayUrls.length > 0 const canRenderFeed = relayUrls.length > 0
@ -90,24 +98,32 @@ const RelaysFeed = forwardRef<
if (!canRenderFeed) return [] if (!canRenderFeed) return []
return [ return [
{ {
urls: relayUrls, urls: stableRelayUrls,
filter: { filter: {
kinds: defaultKinds kinds: defaultKinds
} }
} }
] ]
}, [canRenderFeed, relayUrlsKey, relayUrls, defaultKinds]) }, [canRenderFeed, relayUrlsKey, stableRelayUrls, defaultKindsKey, defaultKinds])
const repliesSubRequests = useMemo(() => { const repliesSubRequests = useMemo(() => {
if (!canRenderFeed) return [] if (!canRenderFeed) return []
return [ return [
{ {
urls: replyRelayUrls.length > 0 ? replyRelayUrls : relayUrls, urls: stableReplyRelayUrls.length > 0 ? stableReplyRelayUrls : stableRelayUrls,
filter: { filter: {
kinds: defaultKinds kinds: defaultKinds
} }
} }
] ]
}, [canRenderFeed, replyRelayUrlsKey, replyRelayUrls, relayUrlsKey, relayUrls, defaultKinds]) }, [
canRenderFeed,
replyRelayUrlsKey,
stableReplyRelayUrls,
relayUrlsKey,
stableRelayUrls,
defaultKindsKey,
defaultKinds
])
if (!canRenderFeed) { if (!canRenderFeed) {
return null return null
@ -119,12 +135,13 @@ const RelaysFeed = forwardRef<
ref={ref} ref={ref}
subRequests={subRequests} subRequests={subRequests}
areAlgoRelays={areAlgoRelays} areAlgoRelays={areAlgoRelays}
relayCapabilityReady={relayCapabilityReady}
isMainFeed isMainFeed
setSubHeader={setSubHeader} setSubHeader={setSubHeader}
onSubHeaderRefresh={onSubHeaderRefresh} onSubHeaderRefresh={onSubHeaderRefresh}
preserveTimelineOnSubRequestsChange preserveTimelineOnSubRequestsChange
repliesSubRequests={repliesSubRequests} repliesSubRequests={repliesSubRequests}
mainFeedGalleryRelayUrls={replyRelayUrls} mainFeedGalleryRelayUrls={stableReplyRelayUrls}
widenMainGalleryRelays={false} widenMainGalleryRelays={false}
feedSubscriptionKey="home-all-favorites" feedSubscriptionKey="home-all-favorites"
feedTimelineScopeKey="all-favorites" feedTimelineScopeKey="all-favorites"

37
src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx

@ -1,5 +1,6 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
import { SimpleUserAvatar } from '@/components/UserAvatar'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter' import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter'
import { filterEventsExcludingTombstones } from '@/lib/event' import { filterEventsExcludingTombstones } from '@/lib/event'
@ -547,6 +548,9 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props)
{layoutRows.map((row) => { {layoutRows.map((row) => {
const intensity = Math.min(1, row.heat / maxHeat) const intensity = Math.min(1, row.heat / maxHeat)
const size = Math.min(200, Math.max(76, 52 + Math.sqrt(row.heat) * 9)) 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', { const statsLine = t('heatMapBubbleStats', {
posts: row.postCount, posts: row.postCount,
people: row.uniqueAuthors, people: row.uniqueAuthors,
@ -577,15 +581,30 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props)
onClick={() => navigateToNote(toNote(row.rootId), row.rootEvent)} onClick={() => navigateToNote(toNote(row.rootId), row.rootEvent)}
aria-label={ariaLabel} aria-label={ariaLabel}
> >
<span {authorPubkey ? (
className="rounded-full bg-primary/25 ring-2 ring-primary/35 transition-[width,height,opacity] group-hover:bg-primary/35" <div
style={{ className="pointer-events-none overflow-hidden rounded-full ring-2 ring-primary/35"
width: `${22 + intensity * 48}%`, style={{ width: innerSizePx, height: innerSizePx }}
height: `${22 + intensity * 48}%`, aria-hidden
opacity: 0.55 + intensity * 0.45 >
}} <SimpleUserAvatar
aria-hidden userId={authorPubkey}
/> deferRemoteAvatar={false}
maxFileSizeKb={500}
className="!size-full max-w-none"
/>
</div>
) : (
<span
className="rounded-full bg-primary/25 ring-2 ring-primary/35 transition-[width,height,opacity] group-hover:bg-primary/35"
style={{
width: `${innerPct}%`,
height: `${innerPct}%`,
opacity: 0.55 + intensity * 0.45
}}
aria-hidden
/>
)}
</button> </button>
</HoverCardTrigger> </HoverCardTrigger>
<HoverCardContent <HoverCardContent

Loading…
Cancel
Save