Skip to content
Open
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
7 changes: 5 additions & 2 deletions frontend/src/components/dashboard/dashboard-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import toast from "react-hot-toast";
import {
getDashboardAnalytics,
fetchDashboardData,
useDashboard,
dashboardQueryKey,
type DashboardSnapshot,
type Stream,
} from "@/lib/dashboard";
Expand Down Expand Up @@ -505,6 +507,7 @@ function renderRecentActivity(
// ─── Main Component ───────────────────────────────────────────────────────────

export function DashboardView({ session, onDisconnect }: DashboardViewProps) {
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = React.useState("overview");
const [showWizard, setShowWizard] = React.useState(false);
const [modal, setModal] = React.useState<ModalState>(null);
Expand Down Expand Up @@ -551,7 +554,7 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) {
}
}
}
}, [streamEvents, session.publicKey]);
}, [streamEvents, session.publicKey, queryClient]);

const [streamForm, setStreamForm] =
React.useState<StreamFormValues>(EMPTY_STREAM_FORM);
Expand Down Expand Up @@ -762,7 +765,7 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) {
// ── Optimistic helpers ────────────────────────────────────────────────────

const removeStreamLocally = (streamId: string) => {
setSnapshot((prev) => {
queryClient.setQueryData<DashboardSnapshot | undefined>(dashboardQueryKey(session.publicKey), (prev) => {
if (!prev) return prev;
return {
...prev,
Expand Down
12 changes: 10 additions & 2 deletions frontend/src/components/stream-creation/TokenStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,26 @@ export const TokenStep: React.FC<TokenStepProps> = ({
return (
<div className="space-y-4">
<div>
<h3 className="text-xl font-semibold mb-2">Select Token</h3>
<h3 id="token-group-label" className="text-xl font-semibold mb-2">Select Token</h3>
<p className="text-sm text-slate-400 mb-4">
Choose the token you want to stream to the recipient.
</p>
</div>

<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div
className="grid grid-cols-1 md:grid-cols-3 gap-4"
role="radiogroup"
aria-labelledby="token-group-label"
aria-describedby={error ? "token-error" : undefined}
>
{TOKENS.map((token) => {
const isSelected = value === token.id;
return (
<button
key={token.id}
type="button"
role="radio"
aria-checked={isSelected}
onClick={() => onChange(token.id)}
className={`p-4 rounded-lg border-2 transition-all text-left ${
isSelected
Expand Down Expand Up @@ -68,6 +75,7 @@ export const TokenStep: React.FC<TokenStepProps> = ({

{error && (
<p
id="token-error"
className="mt-2 text-sm text-red-400 flex items-center gap-1"
role="alert"
>
Expand Down
113 changes: 113 additions & 0 deletions frontend/src/hooks/useIncomingStreams.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { renderHook, waitFor, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
import React from "react";
import {
useIncomingStreams,
useWithdrawIncomingStream,
incomingStreamsQueryKey,
} from "./useIncomingStreams";
import { fetchIncomingStreams } from "@/lib/api/streams";
import { withdrawFromStream } from "@/lib/soroban";

vi.mock("@/lib/api/streams", () => ({
fetchIncomingStreams: vi.fn(),
}));

vi.mock("@/lib/soroban", () => ({
withdrawFromStream: vi.fn(),
}));

describe("useIncomingStreams hooks", () => {
let queryClient: QueryClient;

beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
vi.clearAllMocks();
});

afterEach(() => {
queryClient.clear();
});

const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);

describe("incomingStreamsQueryKey", () => {
it("returns correct shape", () => {
expect(incomingStreamsQueryKey("pubkey")).toEqual([
"incoming-streams",
"pubkey",
]);
expect(incomingStreamsQueryKey(null)).toEqual(["incoming-streams", null]);
});
});

describe("useIncomingStreams", () => {
it("stays disabled when publicKey is null/undefined", () => {
const { result, rerender } = renderHook(
(props: { publicKey: string | null | undefined }) =>
useIncomingStreams(props.publicKey),
{ wrapper, initialProps: { publicKey: null } }
);

expect(result.current.isPending).toBe(true);
expect(result.current.fetchStatus).toBe("idle");
expect(fetchIncomingStreams).not.toHaveBeenCalled();

rerender({ publicKey: undefined });
expect(result.current.fetchStatus).toBe("idle");
expect(fetchIncomingStreams).not.toHaveBeenCalled();
});
});

describe("useWithdrawIncomingStream", () => {
it("rejects when session is null", async () => {
const { result } = renderHook(
() => useWithdrawIncomingStream(null, "pubkey"),
{ wrapper }
);

await expect(
result.current.mutateAsync({} as any)
).rejects.toThrow("Please connect your wallet first");
expect(withdrawFromStream).not.toHaveBeenCalled();
});

it("invalidates incomingStreamsQueryKey(publicKey) on success", async () => {
(withdrawFromStream as any).mockResolvedValue({ status: "success" });
(fetchIncomingStreams as any).mockResolvedValue([]);

const { result } = renderHook(
() => useWithdrawIncomingStream({} as any, "pubkey"),
{ wrapper }
);

const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");

await act(async () => {
await result.current.mutateAsync({
id: 1,
streamId: 1,
withdrawn: 0,
deposited: 100,
ratePerSecond: 1,
isPaused: false,
lastUpdateTime: Date.now() / 1000,
} as any);
});

// Wait for pollIndexerForWithdraw to complete and call invalidateQueries
await waitFor(() => {
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: incomingStreamsQueryKey("pubkey"),
});
}, { timeout: 10000 });
});
});
});
41 changes: 40 additions & 1 deletion frontend/src/hooks/useSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import {
useSettings,
STORAGE_KEYS,
formatAmountWithPreference,
DEFAULT_SETTINGS
DEFAULT_SETTINGS,
_resetSharedSettings
} from './useSettings';

const localStorageMock = (() => {
Expand All @@ -29,6 +30,7 @@ describe('useSettings and formatAmountWithPreference', () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.className = '';
_resetSharedSettings();
});

afterEach(() => {
Expand Down Expand Up @@ -119,6 +121,43 @@ describe('useSettings and formatAmountWithPreference', () => {
expect(result.current.amountFormat).toBe('compact');
expect(localStorage.getItem(STORAGE_KEYS.amountFormat)).toBe('compact');
});

it('syncs state across multiple consumers without remount', async () => {
const { result: consumerA } = renderHook(() => useSettings());
const { result: consumerB } = renderHook(() => useSettings());

await waitFor(() => {
expect(consumerA.current.isHydrated).toBe(true);
expect(consumerB.current.isHydrated).toBe(true);
});

act(() => {
consumerA.current.setDecimalPlaces(2);
});

expect(consumerA.current.decimalPlaces).toBe(2);
expect(consumerB.current.decimalPlaces).toBe(2);
});

it('syncs state across tabs using storage event', async () => {
const { result } = renderHook(() => useSettings());

await waitFor(() => expect(result.current.isHydrated).toBe(true));

act(() => {
// Simulate other tab changing local storage
localStorage.setItem(STORAGE_KEYS.theme, 'light');

// Dispatch storage event
const event = new StorageEvent('storage', {
key: STORAGE_KEYS.theme,
newValue: 'light'
});
window.dispatchEvent(event);
});

expect(result.current.theme).toBe('light');
});
});

describe('formatAmountWithPreference', () => {
Expand Down
104 changes: 76 additions & 28 deletions frontend/src/hooks/useSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,39 +28,84 @@ const STORAGE_KEYS = {
decimalPlaces: "flowfi-decimal-places",
};

let sharedSettings: Settings = { ...DEFAULT_SETTINGS };
let sharedIsHydrated = false;
const listeners = new Set<() => void>();

function notifyListeners() {
listeners.forEach((listener) => listener());
}

function loadSettingsFromStorage(): Settings {
if (typeof window === "undefined") return { ...DEFAULT_SETTINGS };
const savedTheme = localStorage.getItem(STORAGE_KEYS.theme) as Theme | null;
const savedCurrency = localStorage.getItem(
STORAGE_KEYS.displayCurrency
) as DisplayCurrency | null;
const savedFormat = localStorage.getItem(
STORAGE_KEYS.amountFormat
) as AmountFormat | null;
const savedDecimals = localStorage.getItem(STORAGE_KEYS.decimalPlaces);

return {
theme: savedTheme || DEFAULT_SETTINGS.theme,
displayCurrency: savedCurrency || DEFAULT_SETTINGS.displayCurrency,
amountFormat: savedFormat || DEFAULT_SETTINGS.amountFormat,
decimalPlaces: savedDecimals
? (parseInt(savedDecimals, 10) as DecimalPlaces)
: DEFAULT_SETTINGS.decimalPlaces,
};
}

if (typeof window !== "undefined") {
window.addEventListener("storage", (e) => {
if (!e.key || Object.values(STORAGE_KEYS).includes(e.key)) {
sharedSettings = loadSettingsFromStorage();
sharedIsHydrated = true;
notifyListeners();
}
});
}

// For testing purposes
export function _resetSharedSettings() {
sharedSettings = { ...DEFAULT_SETTINGS };
sharedIsHydrated = false;
listeners.clear();
}

export function useSettings() {
const [settings, setSettings] = useState<Settings>(DEFAULT_SETTINGS);
const [isHydrated, setIsHydrated] = useState(false);
const [settings, setSettingsState] = useState<Settings>(sharedSettings);
const [isHydrated, setIsHydrated] = useState(sharedIsHydrated);

useEffect(() => {
if (typeof window === "undefined") return;

const savedTheme = localStorage.getItem(STORAGE_KEYS.theme) as Theme | null;
const savedCurrency = localStorage.getItem(
STORAGE_KEYS.displayCurrency
) as DisplayCurrency | null;
const savedFormat = localStorage.getItem(
STORAGE_KEYS.amountFormat
) as AmountFormat | null;
const savedDecimals = localStorage.getItem(STORAGE_KEYS.decimalPlaces);

// Use queueMicrotask to avoid synchronous setState in effect
queueMicrotask(() => {
setSettings({
theme: savedTheme || DEFAULT_SETTINGS.theme,
displayCurrency: savedCurrency || DEFAULT_SETTINGS.displayCurrency,
amountFormat: savedFormat || DEFAULT_SETTINGS.amountFormat,
decimalPlaces: savedDecimals
? (parseInt(savedDecimals, 10) as DecimalPlaces)
: DEFAULT_SETTINGS.decimalPlaces,
const listener = () => {
setSettingsState(sharedSettings);
setIsHydrated(sharedIsHydrated);
};
listeners.add(listener);

if (!sharedIsHydrated && typeof window !== "undefined") {
queueMicrotask(() => {
if (!sharedIsHydrated) {
sharedSettings = loadSettingsFromStorage();
sharedIsHydrated = true;
notifyListeners();
}
});
setIsHydrated(true);
});
} else {
listener();
}

return () => {
listeners.delete(listener);
};
}, []);

const setTheme = useCallback((theme: Theme) => {
setSettings((prev) => ({ ...prev, theme }));
sharedSettings = { ...sharedSettings, theme };
localStorage.setItem(STORAGE_KEYS.theme, theme);
notifyListeners();

// Apply theme immediately
if (theme === "system") {
Expand All @@ -72,18 +117,21 @@ export function useSettings() {
}, []);

const setDisplayCurrency = useCallback((currency: DisplayCurrency) => {
setSettings((prev) => ({ ...prev, displayCurrency: currency }));
sharedSettings = { ...sharedSettings, displayCurrency: currency };
localStorage.setItem(STORAGE_KEYS.displayCurrency, currency);
notifyListeners();
}, []);

const setAmountFormat = useCallback((format: AmountFormat) => {
setSettings((prev) => ({ ...prev, amountFormat: format }));
sharedSettings = { ...sharedSettings, amountFormat: format };
localStorage.setItem(STORAGE_KEYS.amountFormat, format);
notifyListeners();
}, []);

const setDecimalPlaces = useCallback((places: DecimalPlaces) => {
setSettings((prev) => ({ ...prev, decimalPlaces: places }));
sharedSettings = { ...sharedSettings, decimalPlaces: places };
localStorage.setItem(STORAGE_KEYS.decimalPlaces, places.toString());
notifyListeners();
}, []);

return {
Expand Down
Loading