/** * Browser half of `@local/dsh-balance`. * * Registers one row at the bottom of the sidebar, directly above the account * row (`sidebar.footer.action` precedes `sidebar.settings` in the sidebar * footer), and the row's configuration page (`plugins.row.config`). * * Data comes from `ctx.remote.account.getBalance`; the tooltip's destinations * come from `ctx.remote.account.getState().links`. Every trigger is owned by the * **plugin lifecycle** (`ctx.effect(() => controller.attach())`), never by the * row component, so collapsing the sidebar cannot stop refreshing: * * - a startup read as soon as the plugin activates, retried at 1 s / 2 s / 4 s * (at most three times) until a ready snapshot arrives; * - window focus, throttled to one read per 20 seconds; * - every `api-session/status` transition from running to idle; * - the fallback poll while polling is enabled and the page is visible; * - a click on the row, which always queues a read; * - account stream frames, so a sign-in or sign-out refreshes the value. * * All triggers share one in-flight read plus at most one queued follow-up, and * every client read has an 8 second timeout. Only the startup read retries; * other failures keep the previous value on screen with a warning marker. */ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' import { createPortal } from 'react-dom' import { currencySymbol, mergeWallets, primaryCurrency } from './balance' import { BalanceController, readConfig } from './controller' import { NAMESPACE, PACKAGE_NAME, ROW_CONFIG_KEY, ROW_ID, STYLE_ID, TOOLTIP_ID } from './constants' import { formatAmount } from './format' import { en, zh } from './locales' import { createSettingsPage } from './settings-page' import type { BalanceSettings } from './settings' import { displayLevel, type ThresholdLevel } from './threshold' import { absoluteTime, relativeTime } from './time' import type { ClientContext, Translate } from './types' /** How often the tooltip's relative time is recomputed while open. */ const RELATIVE_TIME_REFRESH_MS = 30000 /** Styles, injected once per application through `ctx.effect`. */ const CSS = '.dshBalanceRow{box-sizing:border-box;display:flex;align-items:center;gap:8px;height:44px;width:calc(100% + 4px);min-width:0;flex:0 0 auto;margin:0 -2px;padding:6px;border:0;border-radius:var(--dsw-radius-md);background:0 0;color:var(--dsw-alias-label-primary);font:inherit;font-size:14px;line-height:18px;cursor:pointer;text-align:left;overflow:hidden}' + '.dshBalanceRow:hover{background:var(--dsw-alias-interactive-bg-hover)}' + '.dshBalanceRow:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:-2px}' + '.dshBalanceIcon{flex:none;display:inline-flex;align-items:center;justify-content:center;color:var(--dsw-alias-label-secondary)}' + '.dshBalanceAmount{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:14px;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-primary)}' + '.dshBalanceAmount[data-level="warn"]{color:#f59e0b}' + '.dshBalanceAmount[data-level="danger"]{color:#ef4444}' + '[data-ds-dark-theme] .dshBalanceAmount[data-level="warn"]{color:#fbbf24}' + '[data-ds-dark-theme] .dshBalanceAmount[data-level="danger"]{color:#f87171}' + '.dshBalanceMuted{color:var(--dsw-alias-label-tertiary)}' + '.dshBalanceDot{flex:none;width:4px;height:4px;border-radius:50%;background:var(--dsw-alias-label-tertiary);opacity:.4}' + '.dshBalanceWarning{flex:none;font-size:11px;line-height:14px;color:var(--dsw-alias-label-tertiary)}' + 'div[class*="footerActions"]{flex-wrap:wrap}' + '.dshBalanceTooltip{position:fixed;z-index:1200;box-sizing:border-box;display:flex;flex-direction:column;gap:6px;min-width:200px;max-width:280px;padding:10px 12px;border-radius:var(--dsw-radius-lg);background:var(--dsw-alias-bg-layer-2);box-shadow:var(--dsw-elevation-prominent);color:var(--dsw-alias-label-primary);font-size:12px;line-height:18px}' + '.dshBalanceTooltipSection{display:flex;flex-direction:column;gap:2px}' + '.dshBalanceTooltipTitle{font-weight:500;color:var(--dsw-alias-label-primary)}' + '.dshBalanceTooltipRow{display:flex;align-items:baseline;justify-content:space-between;gap:16px}' + '.dshBalanceTooltipKey{color:var(--dsw-alias-label-secondary)}' + '.dshBalanceTooltipValue{font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-primary)}' + '.dshBalanceTooltipDivider{height:0;border-top:.5px solid var(--dsw-alias-border-l2)}' + '.dshBalanceTooltipHint{color:var(--dsw-alias-label-tertiary)}' + '.dshBalanceTooltipError{color:var(--dsw-alias-label-tertiary)}' + '.dshBalanceTooltipActions{display:flex;gap:8px}' + '.dshBalanceTooltipButton{box-sizing:border-box;height:26px;padding:0 10px;border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-md);background:0 0;color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;cursor:pointer}' + '.dshBalanceTooltipButton:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}' + '.dshBalanceTooltipButton:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:1px}' + '.dshBalanceTooltipButton:disabled{color:var(--dsw-alias-label-tertiary);border-color:var(--dsw-alias-border-l2);cursor:default}' /** Inject the row stylesheet once; returns its removal cleanup. */ function installStyles(): () => void { if (typeof document === 'undefined') return () => {} const selector = 'style[data-plugin-css="' + STYLE_ID + '"]' if (document.querySelector(selector) !== null) return () => {} const tag = document.createElement('style') tag.dataset.plugin = PACKAGE_NAME tag.dataset.pluginCss = STYLE_ID tag.textContent = CSS document.head.appendChild(tag) return () => { tag.remove() } } /** Observe the live configuration so threshold/interval edits apply immediately. */ function useSettings(ctx: ClientContext): BalanceSettings { const read = useCallback(() => readConfig(ctx), [ctx]) const [settings, setSettings] = useState(read) useEffect(() => { setSettings(read()) let dispose: (() => void) | undefined try { const form = ctx.configForms.get(ROW_ID) const subscription = form?.subscribe?.(() => setSettings(read())) if (typeof subscription === 'function') dispose = subscription } catch { dispose = undefined } return () => { if (dispose !== undefined) { try { dispose() } catch { /* the form is gone with the plugin */ } } } }, [ctx, read]) return settings } /** Inline coin/wallet glyph used at 24px. */ function BalanceIcon(): JSX.Element { return ( ) } /** Build the sidebar row component bound to one controller. */ function createBalanceRow( controller: BalanceController, ctx: ClientContext, ): (props: { wide?: boolean; t?: Translate }) => JSX.Element | null { return function BalanceRow(props: { wide?: boolean; t?: Translate }): JSX.Element | null { const t: Translate = typeof props.t === 'function' ? props.t : (key: string) => key const wide = props.wide !== false const snapshot = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot) const settings = useSettings(ctx) const totals = useMemo(() => mergeWallets(snapshot.wallets), [snapshot.wallets]) const primary = primaryCurrency(totals) const level: ThresholdLevel = primary !== undefined ? displayLevel(primary.currency, primary.total, { warn: settings.warnThresholdCny, danger: settings.dangerThresholdCny, }) : 'normal' const [rowHovered, setRowHovered] = useState(false) const [rowFocused, setRowFocused] = useState(false) const [tooltipHovered, setTooltipHovered] = useState(false) const [tooltipFocused, setTooltipFocused] = useState(false) const [position, setPosition] = useState<{ left: number; top: number } | undefined>(undefined) const [now, setNow] = useState(() => Date.now()) const buttonRef = useRef(null) const tooltipRef = useRef(null) const open = rowHovered || tooltipHovered || rowFocused || tooltipFocused useEffect(() => { if (!wide) { setRowHovered(false) setRowFocused(false) setTooltipHovered(false) setTooltipFocused(false) } }, [wide]) useEffect(() => { if (!open) return setNow(Date.now()) const timer = setInterval(() => setNow(Date.now()), RELATIVE_TIME_REFRESH_MS) return () => clearInterval(timer) }, [open]) useEffect(() => { if (!open) return const onKeyDown = (event: KeyboardEvent): void => { if (event.key !== 'Escape') return setRowHovered(false) setRowFocused(false) setTooltipHovered(false) setTooltipFocused(false) try { buttonRef.current?.focus() } catch { /* focus is best effort */ } } document.addEventListener('keydown', onKeyDown) return () => document.removeEventListener('keydown', onKeyDown) }, [open]) useLayoutEffect(() => { if (!open) { setPosition(undefined) return } const place = (): void => { const row = buttonRef.current const tooltip = tooltipRef.current if (row === null || tooltip === null) return const rect = row.getBoundingClientRect() const height = tooltip.offsetHeight const width = tooltip.offsetWidth const left = Math.max(8, Math.min(rect.left, window.innerWidth - width - 8)) const above = rect.top - height - 8 const top = above >= 8 ? above : Math.min(window.innerHeight - height - 8, rect.bottom + 8) setPosition({ left, top }) } place() window.addEventListener('resize', place) window.addEventListener('scroll', place, true) return () => { window.removeEventListener('resize', place) window.removeEventListener('scroll', place, true) } }, [open, snapshot.wallets, snapshot.fetchedAt, snapshot.error, snapshot.signedOut, totals]) // Bail out after every hook has run, so collapsing the sidebar never // changes the hook order. if (!wide) return null const missingValue = snapshot.wallets === null let text: string if (!missingValue && primary !== undefined) { text = formatAmount(primary.total, currencySymbol(primary.currency)) } else if (snapshot.signedOut) { text = t('row.signedOut') } else if (!missingValue) { // A ready response without any wallet row: nothing to total, nothing to colour. text = '—' } else if (snapshot.error !== null) { text = '—' } else { text = t('row.loading') } const muted = missingValue const showDot = snapshot.refreshing && snapshot.initialized const showWarning = snapshot.error !== null && !missingValue const relativeParts = snapshot.fetchedAt === null ? undefined : relativeTime(snapshot.fetchedAt, now) const relativeText = relativeParts === undefined ? '' : t(relativeParts.key, relativeParts.params) const absoluteText = snapshot.fetchedAt === null ? '' : absoluteTime(snapshot.fetchedAt) const errorText = snapshot.error === null ? '' : snapshot.error === 'failed' ? t('tooltip.failed') : snapshot.error === 'timeout' ? t('tooltip.timeout') : snapshot.error const openExternal = (url: string | undefined): void => { if (url === undefined || url === '') return try { window.open(url, '_blank', 'noopener,noreferrer') } catch { /* popup blocking is not an error worth surfacing here */ } } const tooltip = open && typeof document !== 'undefined' ? createPortal(