import { reactiveSource, type ReactiveSource, type ReactiveValue } from "octagonal-wheels/dataobject/reactive"; import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; const STATUS_COUNTER_PADDING = "\u2007".repeat(10); export const STATUS_COUNTER_INACTIVE_LINGER_MS = 3_000; export type DisposableReactiveValue = ReactiveValue & { dispose(): void; }; function asDisposableReactiveValue(value: ReactiveSource, dispose: () => void): DisposableReactiveValue { return { get value() { return value.value; }, onChanged(handler) { value.onChanged(handler); }, offChanged(handler) { value.offChanged(handler); }, dispose, }; } /** * Mirrors an activity count while keeping each visible period on screen for a * minimum total lifetime. The delay applies only when the source becomes zero. */ export function createMinimumVisibleActivityCount( source: ReactiveValue, minimumVisibleMs: number ): DisposableReactiveValue { const minimumLifetime = Math.max(0, minimumVisibleMs); const displayed = reactiveSource(Math.max(0, source.value)); let visibleSince = displayed.value > 0 ? Date.now() : undefined; let hideTimer: number | undefined; let disposed = false; const cancelHide = () => { if (hideTimer === undefined) return; compatGlobal.clearTimeout(hideTimer); hideTimer = undefined; }; const hideIfIdle = () => { hideTimer = undefined; if (disposed || Math.max(0, source.value) > 0) return; displayed.value = 0; visibleSince = undefined; }; const update = () => { if (disposed) return; const nextCount = Math.max(0, source.value); cancelHide(); if (nextCount > 0) { if (displayed.value === 0) { visibleSince = Date.now(); } displayed.value = nextCount; return; } if (displayed.value === 0) { visibleSince = undefined; return; } const elapsed = Date.now() - (visibleSince ?? Date.now()); const remaining = Math.max(0, minimumLifetime - elapsed); if (remaining === 0) { hideIfIdle(); } else { hideTimer = compatGlobal.setTimeout(hideIfIdle, remaining); } }; source.onChanged(update); return asDisposableReactiveValue(displayed, () => { if (disposed) return; disposed = true; cancelHide(); source.offChanged(update); }); } /** * Formats a counter with a stable width and briefly retains its zero value so * that the completion of queued work remains visible. */ export function createPaddedCounterLabel( source: ReactiveValue, mark: string, inactiveLingerMs = STATUS_COUNTER_INACTIVE_LINGER_MS ): DisposableReactiveValue { const linger = Math.max(0, inactiveLingerMs); const formatted = reactiveSource(""); let maximumLength = 1; let clearTimer: number | undefined; let disposed = false; const cancelClear = () => { if (clearTimer === undefined) return; compatGlobal.clearTimeout(clearTimer); clearTimer = undefined; }; const format = (count: number) => { const requiredLength = `${Math.abs(count)}`.length + 1; maximumLength = Math.max(maximumLength, requiredLength); return ` ${mark}${`${STATUS_COUNTER_PADDING}${count}`.slice(-maximumLength)}`; }; const update = () => { if (disposed) return; cancelClear(); const count = source.value; formatted.value = format(count); if (count !== 0) return; clearTimer = compatGlobal.setTimeout(() => { clearTimer = undefined; if (disposed) return; formatted.value = ""; maximumLength = 1; }, linger); }; source.onChanged(update); return asDisposableReactiveValue(formatted, () => { if (disposed) return; disposed = true; cancelClear(); source.offChanged(update); }); } /** * Displays the disjoint initial and retry chunk-fetch counts with the same * padding and inactive linger behaviour as the other status counters. */ export function createChunkFetchCounterLabel( source: ReactiveValue<{ initial: number; retrying: number }> ): DisposableReactiveValue { const initialCount = reactiveSource(0); const retryingCount = reactiveSource(0); const initialLabel = createPaddedCounterLabel(initialCount, "🛄"); const retryingLabel = createPaddedCounterLabel(retryingCount, "🔁"); const formatted = reactiveSource(`${initialLabel.value}${retryingLabel.value}`); let updatingCounts = false; let disposed = false; const updateLabel = () => { if (updatingCounts || disposed) return; formatted.value = `${initialLabel.value}${retryingLabel.value}`; }; initialLabel.onChanged(updateLabel); retryingLabel.onChanged(updateLabel); const updateCounts = () => { if (disposed) return; updatingCounts = true; try { initialCount.value = source.value.initial; retryingCount.value = source.value.retrying; } finally { updatingCounts = false; updateLabel(); } }; source.onChanged(updateCounts); updateCounts(); return asDisposableReactiveValue(formatted, () => { if (disposed) return; disposed = true; source.offChanged(updateCounts); initialLabel.offChanged(updateLabel); retryingLabel.offChanged(updateLabel); initialLabel.dispose(); retryingLabel.dispose(); }); }