Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export function CommandPalette() {
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
const previousActiveElement = useRef<HTMLElement | null>(null);

const close = useCallback(() => {
Expand Down Expand Up @@ -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<HTMLInputElement>) => {
if (e.key === "ArrowDown") {
e.preventDefault();
Expand Down Expand Up @@ -136,6 +160,9 @@ export function CommandPalette() {
<button
key={item.id}
id={`cmdk-${item.id}`}
ref={(el) => {
itemRefs.current[i] = el;
}}
type="button"
role="option"
aria-selected={i === activeIndex}
Expand Down
90 changes: 88 additions & 2 deletions components/TokenSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';

import { useState, useEffect, useRef, useCallback } from 'react';
import { tokenByAddress, networksForAddress, type TokenMeta } from '@/lib/tokens';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { tokenByAddress, getTokens, networksForAddress, type TokenMeta } from '@/lib/tokens';

/**
* Stellar Soroban contract addresses are base32-encoded with the RFC 4648
Expand Down Expand Up @@ -59,6 +59,30 @@ export function TokenSelector({
// address IS a known token — so the message can point the user there (#429).
const [otherNetworks, setOtherNetworks] = useState<Array<'mainnet' | 'testnet'>>([]);

// ── Token search filtering (#653) ─────────────────────────────────────────
// Debounced search across symbol, name, and 56-char contract address so
// custom/imported tokens are findable as the known-token list grows.
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');

useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search), 200);
return () => clearTimeout(t);
}, [search]);

const filteredTokens = useMemo<TokenMeta[]>(() => {
const q = debouncedSearch.trim().toLowerCase();
if (!q) return [];
return getTokens(network).filter(t => {
if (t.symbol.toLowerCase().includes(q)) return true;
if (t.name.toLowerCase().includes(q)) return true;
if (t.address?.toLowerCase().includes(q)) return true;
return false;
});
}, [debouncedSearch, network]);

const showSearchResults = debouncedSearch.trim().length > 0;

// Ref to the AbortController for the current in-flight metadata lookup.
// Replaced on every new lookup so the previous one can be cancelled.
const abortRef = useRef<AbortController | null>(null);
Expand Down Expand Up @@ -203,6 +227,68 @@ export function TokenSelector({
{errorMsg ?? 'Could not resolve token metadata. Please try again.'}
</p>
)}

{/* Token search filter (#653) — debounced filtering across symbol,
name, and 56-char contract address. The result list only renders
while a search query is present so the default view is unchanged. */}
<div className="pt-2">
<label
htmlFor="token-selector-search"
className="block text-xs font-semibold dark:text-white"
>
Search tokens
</label>
<input
id="token-selector-search"
type="search"
value={search}
onChange={e => setSearch(e.target.value)}
disabled={disabled}
placeholder="Search by symbol, name, or address…"
aria-label="Search tokens"
autoComplete="off"
spellCheck={false}
className="input w-full mt-1"
/>
{showSearchResults && (
<div
role="listbox"
aria-label="Matching tokens"
className="mt-1 max-h-48 overflow-y-auto rounded border border-gray-200 dark:border-gray-700"
>
{filteredTokens.length === 0 ? (
<p className="px-3 py-2 text-xs text-gray-400" role="status">
No tokens match “{debouncedSearch.trim()}”.
</p>
) : (
filteredTokens.map(t => {
const isSelected = !!t.address && t.address === value;
return (
<button
key={t.address ?? t.symbol}
type="button"
role="option"
aria-selected={isSelected}
disabled={disabled || !t.address}
onClick={() => {
if (t.address) onChange(t.address);
}}
className="w-full text-left px-3 py-2 text-xs hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-40"
>
<span className="font-semibold">{t.symbol}</span>
<span className="text-gray-500 dark:text-gray-400"> — {t.name}</span>
{t.address && (
<span className="block font-mono text-[10px] text-gray-400 truncate">
{t.address}
</span>
)}
</button>
);
})
)}
</div>
)}
</div>
</div>
);
}
50 changes: 50 additions & 0 deletions components/stream/StreamProgressBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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({
Expand All @@ -43,6 +55,8 @@ export function StreamProgressBar({
status,
pausedAt,
label,
cliffTimestamp,
milestones = [],
}: StreamProgressBarProps) {
const fillRef = useRef<HTMLDivElement>(null);

Expand Down Expand Up @@ -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<typeof m> => m !== null)
: [];

return (
<>
<div
Expand All @@ -156,6 +195,17 @@ export function StreamProgressBar({
})(),
}}
/>
{markers.map(m => (
<div
key={m.key}
title={m.markerLabel}
aria-label={m.markerLabel}
role="img"
data-milestone={m.kind ?? 'custom'}
className="absolute top-0 bottom-0 w-[2px] -translate-x-1/2 bg-white border-x border-black/60 dark:bg-black dark:border-white/70 pointer-events-auto"
style={{ left: `${m.pct.toFixed(2)}%` }}
/>
))}
</div>
</>
);
Expand Down
22 changes: 21 additions & 1 deletion components/stream/WithdrawButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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="<address>"]`
// and emits `optimistic-withdraw-rollback` when a reverted optimistic
// withdrawal restores the balance.
const amountRef = useRef<HTMLDivElement>(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.');
Expand Down Expand Up @@ -146,7 +166,7 @@ export function WithdrawButton({ streamAddress, withdrawable, token, onSuccess }
}

return (
<div className="space-y-2">
<div ref={amountRef as unknown as React.RefObject<HTMLDivElement>} data-withdrawable={streamAddress} className="space-y-2">
{!isEmpty && (
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<span>Protocol fee applies</span>
Expand Down
78 changes: 78 additions & 0 deletions lib/optimistic-updates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -92,18 +93,95 @@ 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) {
qc.setQueryData(key, snapshot);
} 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.
}
}
}