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. 115
      src/components/NoteList/index.tsx
  5. 52
      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. 23
      src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx

3
Dockerfile

@ -28,8 +28,7 @@ RUN npm install @@ -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

10
docker-entrypoint.sh

@ -1,9 +1,15 @@ @@ -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;"

2
package.json

@ -22,7 +22,7 @@ @@ -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 .",

115
src/components/NoteList/index.tsx

@ -911,8 +911,6 @@ const NoteList = forwardRef( @@ -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( @@ -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,8 +2021,10 @@ const NoteList = forwardRef( @@ -2021,8 +2021,10 @@ const NoteList = forwardRef(
useImperativeHandle(ref, () => ({ scrollToTop, refresh }), [scrollToTop, refresh])
useEffect(() => {
const timelineLiveIdentityKey = [
/** Single key for live timeline REQ identity — effect deps must not exceed this or subscriptions churn. */
const timelineLiveIdentityKey = useMemo(
() =>
[
pauseTimelineForPrimaryFreeze ? 'frozen' : 'live',
timelineSubscriptionKey,
feedSubscriptionKey ?? '',
@ -2039,17 +2041,28 @@ const NoteList = forwardRef( @@ -2039,17 +2041,28 @@ const NoteList = forwardRef(
feedTimelineScopeKey ?? '',
String(refreshCount),
relayCapabilityReady ? '1' : '0'
].join('\x1e')
if (
!pauseTimelineForPrimaryFreeze &&
lastTimelineLiveIdentityKeyRef.current === timelineLiveIdentityKey &&
timelineEstablishedCloserRef.current
) {
return () => {}
}
lastTimelineLiveIdentityKeyRef.current = timelineLiveIdentityKey
].join('\x1e'),
[
pauseTimelineForPrimaryFreeze,
timelineSubscriptionKey,
feedSubscriptionKey,
sessionSnapshotIdentityKey,
subRequestsKey,
timelineResubscribeKindKey,
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( @@ -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( @@ -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( @@ -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( @@ -3490,38 +3502,25 @@ const NoteList = forwardRef(
}
})
}
}, [
timelineSubscriptionKey,
}, [timelineLiveIdentityKey, pauseTimelineForPrimaryFreeze, oneShotFetch])
const followingFeedDeltaIdentityKey = useMemo(
() =>
[
followingFeedDeltaSubRequestsKey,
timelineKey ?? '',
feedSubscriptionKey ?? '',
areAlgoRelays ? '1' : '0',
pauseTimelineForPrimaryFreeze ? 'frozen' : 'live'
].join('\x1e'),
[
followingFeedDeltaSubRequestsKey,
timelineKey,
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
])
]
)
useEffect(() => {
if (oneShotFetch) return
@ -3542,7 +3541,7 @@ const NoteList = forwardRef( @@ -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( @@ -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( @@ -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( @@ -4038,7 +4020,6 @@ const NoteList = forwardRef(
progressiveWarmupQuery,
feedFullSearchEvents,
feedSubscribeRelayOutcomes,
mapLiveSubRequestsForTimeline,
effectiveShowKinds,
allowKindlessRelayExplore,
timelineSubscriptionKey,

52
src/components/ReplyNote/index.tsx

@ -100,12 +100,10 @@ export default function ReplyNote({ @@ -100,12 +100,10 @@ export default function ReplyNote({
return true
}, [showMuted, mutePubkeySet, event, hideContentMentioningMutedUsers])
return (
<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) => {
// 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({ @@ -118,26 +116,21 @@ export default function ReplyNote({
}}
>
<Collapsible>
<div className="flex space-x-2 items-start px-4 pt-3">
<div className="px-4 pt-3">
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="flex min-w-0 flex-1 items-start gap-2">
<UserAvatar
userId={headerUserId}
size="medium"
className="shrink-0 mt-0.5"
className="mt-0.5 shrink-0"
maxFileSizeKb={2048}
deferRemoteAvatar={false}
/>
<div
className={cn(
'w-full min-w-0',
isNip25ReactionKind(event.kind) ? 'overflow-visible' : 'overflow-x-hidden'
)}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 w-0">
<div className="flex gap-1 items-center">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1">
<Username
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"
/>
<ClientTag event={event} />
@ -151,13 +144,12 @@ export default function ReplyNote({ @@ -151,13 +144,12 @@ export default function ReplyNote({
/>
</div>
</div>
<div className="flex items-center shrink-0">
<NoteOptions event={event} className="shrink-0 [&_svg]:size-5" />
</div>
<NoteOptions event={event} className="shrink-0 [&_svg]:size-5" />
</div>
<EventPowLabel event={event} className="mt-0.5" />
{webReactionParentUrl ? (
<div className="mt-1.5 not-prose max-w-full" data-parent-note-preview>
<div className="not-prose mt-1.5 max-w-full" data-parent-note-preview>
<WebPreview url={webReactionParentUrl} className="w-full" />
</div>
) : parentEventId &&
@ -221,7 +213,7 @@ export default function ReplyNote({ @@ -221,7 +213,7 @@ export default function ReplyNote({
) : (
<Button
variant="outline"
className="text-muted-foreground font-medium mt-2"
className="mt-2 font-medium text-muted-foreground"
onClick={(e) => {
e.stopPropagation()
setShowMuted(true)
@ -231,18 +223,15 @@ export default function ReplyNote({ @@ -231,18 +223,15 @@ export default function ReplyNote({
</Button>
)}
</div>
</div>
</Collapsible>
{show && !isNip18RepostKind(event.kind) && (
<>
<NoteStats
className="ml-14 pl-1 mr-4 mt-2"
className="mt-2 px-4"
event={event}
fetchIfNotExisting
foregroundStats={foregroundStats}
useIconOnlyLikeTrigger={isNip25ReactionKind(event.kind)}
/>
</>
)}
</div>
)
@ -250,19 +239,16 @@ export default function ReplyNote({ @@ -250,19 +239,16 @@ export default function ReplyNote({
export function ReplyNoteSkeleton() {
return (
<div className="px-4 py-3 flex items-start space-x-2 w-full">
<Skeleton className="w-9 h-9 rounded-full shrink-0 mt-0.5" />
<div className="w-full">
<div className="py-1">
<div className="w-full px-4 py-3">
<div className="flex items-start gap-2">
<Skeleton className="mt-0.5 h-9 w-9 shrink-0 rounded-full" />
<div className="min-w-0 flex-1">
<Skeleton className="h-3 w-16" />
</div>
<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" />
<Skeleton className="mt-2 h-3 w-24" />
</div>
</div>
<Skeleton className="mt-3 h-4 w-full" />
<Skeleton className="mt-2 h-4 w-2/3" />
</div>
)
}

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

@ -10,8 +10,15 @@ const MAX_ENTRIES = 1000 @@ -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[]) { @@ -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() {

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

@ -18,6 +18,8 @@ export type TRelayThreadHeatBubble = { @@ -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( @@ -120,7 +122,8 @@ export function buildRelayThreadHeatBubbles(
followAuthorsInThread,
snippet: collapseRelayThreadHeatSnippet(snippetSource),
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< @@ -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< @@ -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<never>((_, reject) => {
@ -66,6 +71,8 @@ const RelaysFeed = forwardRef< @@ -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< @@ -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< @@ -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< @@ -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"

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

@ -1,5 +1,6 @@ @@ -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) @@ -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) @@ -577,15 +581,30 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props)
onClick={() => navigateToNote(toNote(row.rootId), row.rootEvent)}
aria-label={ariaLabel}
>
{authorPubkey ? (
<div
className="pointer-events-none overflow-hidden rounded-full ring-2 ring-primary/35"
style={{ width: innerSizePx, height: innerSizePx }}
aria-hidden
>
<SimpleUserAvatar
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: `${22 + intensity * 48}%`,
height: `${22 + intensity * 48}%`,
width: `${innerPct}%`,
height: `${innerPct}%`,
opacity: 0.55 + intensity * 0.45
}}
aria-hidden
/>
)}
</button>
</HoverCardTrigger>
<HoverCardContent

Loading…
Cancel
Save