diff --git a/components/CommandPalette.tsx b/components/CommandPalette.tsx index 8bfc97b..4e6aa93 100644 --- a/components/CommandPalette.tsx +++ b/components/CommandPalette.tsx @@ -30,6 +30,8 @@ export function CommandPalette() { const [query, setQuery] = useState(""); const [activeIndex, setActiveIndex] = useState(0); const inputRef = useRef(null); + const listRef = useRef(null); + const itemRefs = useRef>([]); const previousActiveElement = useRef(null); const close = useCallback(() => { @@ -77,6 +79,28 @@ export function CommandPalette() { [close, router], ); + // Clamp the active index when the filtered list shrinks (#651). + useEffect(() => { + setActiveIndex((i) => Math.min(i, Math.max(0, items.length - 1))); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [items.length]); + + // Auto-scroll the active option into view for keyboard navigation (#651). + useEffect(() => { + itemRefs.current[activeIndex]?.scrollIntoView({ block: "nearest" }); + }, [activeIndex, open, query]); + + // Global Escape dismissal so the palette closes even when focus is not + // in the search input (e.g. after mouse hover) (#651). + useEffect(() => { + if (!open) return; + const onEscape = (e: KeyboardEvent) => { + if (e.key === "Escape") close(); + }; + document.addEventListener("keydown", onEscape); + return () => document.removeEventListener("keydown", onEscape); + }, [open, close]); + const onInputKeyDown = (e: React.KeyboardEvent) => { if (e.key === "ArrowDown") { e.preventDefault(); @@ -136,6 +160,9 @@ export function CommandPalette() { + ); + }) + )} + + )} + ); } diff --git a/components/stream/StreamProgressBar.tsx b/components/stream/StreamProgressBar.tsx index 65cf21f..7bf10e8 100644 --- a/components/stream/StreamProgressBar.tsx +++ b/components/stream/StreamProgressBar.tsx @@ -24,6 +24,14 @@ import { useRef, useEffect, useState } from 'react'; +export interface StreamMilestone { /** Unix timestamp (seconds) of the milestone (e.g. cliff unlock, cancellation window). */ + timestamp: number; + /** Tooltip / accessible label for the marker. Defaults to a formatted time. */ + label?: string; + /** Visual variant — cliff and cancellation markers get distinct titles. */ + kind?: 'cliff' | 'cancellation' | 'custom'; +} + interface StreamProgressBarProps { /** Unix timestamp (seconds) when the stream started */ startTime: number; @@ -35,6 +43,10 @@ interface StreamProgressBarProps { pausedAt?: number; /** aria-label for screen readers */ label?: string; + /** Unix timestamp (seconds) when the cliff unlocks — rendered as a tick marker (#652) */ + cliffTimestamp?: number; + /** Explicit milestone markers (cliffs, cancellation windows) rendered as ticks (#652) */ + milestones?: StreamMilestone[]; } export function StreamProgressBar({ @@ -43,6 +55,8 @@ export function StreamProgressBar({ status, pausedAt, label, + cliffTimestamp, + milestones = [], }: StreamProgressBarProps) { const fillRef = useRef(null); @@ -131,6 +145,31 @@ export function StreamProgressBar({ const elapsedSec = totalSec > 0 ? Math.max(0, referenceSec - startTime) : 0; const ariaNow = totalSec > 0 ? Math.min(100, Math.round((elapsedSec / totalSec) * 100)) : 0; + // ── Milestone markers (#652) ──────────────────────────────────────────── + // Merge the cliffTimestamp shorthand with the explicit milestones array, + // then convert each in-range timestamp to a % offset along the bar. + // Out-of-range timestamps and open-ended streams render no markers. + const totalMs = endTime > 0 ? endTime * 1_000 - startTime * 1_000 : 0; + const allMilestones: StreamMilestone[] = [ + ...(typeof cliffTimestamp === 'number' + ? [{ timestamp: cliffTimestamp, kind: 'cliff' as const }] + : []), + ...milestones, + ]; + const markers = totalMs > 0 + ? allMilestones + .map((m, i) => { + const pct = ((m.timestamp - startTime) / (endTime - startTime)) * 100; + if (!Number.isFinite(pct) || pct < 0 || pct > 100) return null; + const kindLabel = m.kind === 'cancellation' ? 'Cancellation window' + : m.kind === 'cliff' ? 'Cliff unlock' + : 'Milestone'; + const markerLabel = m.label ?? `${kindLabel} at ${new Date(m.timestamp * 1_000).toLocaleString()}`; + return { ...m, pct, key: `${m.timestamp}-${i}`, markerLabel }; + }) + .filter((m): m is NonNullable => m !== null) + : []; + return ( <>
+ {markers.map(m => ( +
+ ))}
); diff --git a/components/stream/WithdrawButton.tsx b/components/stream/WithdrawButton.tsx index 987e8d9..b6a5f88 100644 --- a/components/stream/WithdrawButton.tsx +++ b/components/stream/WithdrawButton.tsx @@ -44,6 +44,26 @@ export function WithdrawButton({ streamAddress, withdrawable, token, onSuccess } return () => { mounted.current = false; }; }, []); + // Rollback shake target (#695) — `triggerRollbackShake` in + // lib/optimistic-updates.ts animates `[data-withdrawable="
"]` + // and emits `optimistic-withdraw-rollback` when a reverted optimistic + // withdrawal restores the balance. + const amountRef = useRef(null); + useEffect(() => { + const onRollback = (e: Event) => { + const addr = (e as CustomEvent<{ streamAddress?: string }>).detail?.streamAddress; + if (addr && addr !== streamAddress) return; + const el = amountRef.current; + if (!el) return; + el.classList.remove('optimistic-rollback-shake'); + void el.offsetWidth; + el.classList.add('optimistic-rollback-shake'); + window.setTimeout(() => el.classList.remove('optimistic-rollback-shake'), 450); + }; + window.addEventListener('optimistic-withdraw-rollback', onRollback); + return () => window.removeEventListener('optimistic-withdraw-rollback', onRollback); + }, [streamAddress]); + async function handleWithdraw() { if (!publicKey) { setError('Connect your wallet first.'); @@ -146,7 +166,7 @@ export function WithdrawButton({ streamAddress, withdrawable, token, onSuccess } } return ( -
+
} data-withdrawable={streamAddress} className="space-y-2"> {!isEmpty && (
Protocol fee applies diff --git a/lib/optimistic-updates.ts b/lib/optimistic-updates.ts index 71c800e..6ff28f3 100644 --- a/lib/optimistic-updates.ts +++ b/lib/optimistic-updates.ts @@ -7,6 +7,7 @@ */ import type { QueryClient } from '@tanstack/react-query'; +import toast from 'react-hot-toast'; import { queryKeys } from './query-keys'; import type { StreamInfo } from './stream'; @@ -92,13 +93,78 @@ export function optimisticWithdrawUpdate( return previous; } +export interface RollbackWithdrawOptions { + /** Override the default revert explanation shown in the toast. */ + reason?: string; + /** Skip the toast (e.g. caller already surfaced the error). Defaults to false. */ + silent?: boolean; +} + +const ROLLBACK_SHAKE_STYLE_ID = 'optimistic-rollback-shake-style'; + +/** + * Inject the error-shake keyframes once per document (#695). The animation + * is applied via `triggerRollbackShake` to balance elements so a reverted + * optimistic withdrawal is visually explained instead of silently snapping. + */ +function ensureRollbackShakeStyle(): void { + if (typeof document === 'undefined') return; + if (document.getElementById(ROLLBACK_SHAKE_STYLE_ID)) return; + const style = document.createElement('style'); + style.id = ROLLBACK_SHAKE_STYLE_ID; + style.textContent = [ + '@keyframes optimistic-rollback-shake {', + ' 0%, 100% { transform: translateX(0); }', + ' 20% { transform: translateX(-4px); }', + ' 40% { transform: translateX(4px); }', + ' 60% { transform: translateX(-3px); }', + ' 80% { transform: translateX(3px); }', + '}', + '.optimistic-rollback-shake { animation: optimistic-rollback-shake 0.4s ease; }', + '@media (prefers-reduced-motion: reduce) {', + ' .optimistic-rollback-shake { animation: none; }', + '}', + ].join('\n'); + document.head.appendChild(style); +} + +/** + * Trigger the error-shake animation on withdrawable balance elements (#695). + * Falls back to a document-level CustomEvent (`optimistic-withdraw-rollback`) + * so any mounted balance display can animate even without the data attribute. + */ +export function triggerRollbackShake(streamAddress: string): void { + if (typeof document === 'undefined' || typeof window === 'undefined') return; + ensureRollbackShakeStyle(); + + window.dispatchEvent( + new CustomEvent('optimistic-withdraw-rollback', { detail: { streamAddress } }), + ); + + const selector = `[data-withdrawable="${streamAddress}"]`; + const targets = document.querySelectorAll(selector); + targets.forEach(el => { + el.classList.remove('optimistic-rollback-shake'); + // Force reflow so repeated rollbacks re-trigger the animation. + void (el as HTMLElement).offsetWidth; + el.classList.add('optimistic-rollback-shake'); + window.setTimeout(() => el.classList.remove('optimistic-rollback-shake'), 450); + }); +} + /** * Roll back an optimistic withdraw update. + * + * When a withdrawal transaction reverts, the restored number no longer snaps + * silently: an error-shake animation is triggered and a toast explains the + * revert (#695). Pass `{ silent: true }` if the caller already surfaced the + * failure to avoid a duplicate toast. */ export function rollbackWithdraw( qc: QueryClient, streamAddress: string, snapshot: bigint | undefined, + options?: RollbackWithdrawOptions, ): void { const key = queryKeys.streams.withdrawable(streamAddress); if (snapshot !== undefined) { @@ -106,4 +172,16 @@ export function rollbackWithdraw( } else { qc.removeQueries({ queryKey: key }); } + + triggerRollbackShake(streamAddress); + + if (options?.silent !== true && typeof window !== 'undefined') { + const message = + options?.reason ?? 'Withdrawal reverted — your balance was restored.'; + try { + toast.error(message); + } catch { + // No toaster mounted — the CustomEvent above still lets UI animate. + } + } }