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
106 changes: 95 additions & 11 deletions src/hooks/useTransfers.js
Original file line number Diff line number Diff line change
@@ -1,28 +1,88 @@

import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { listTransfers, createTransfer } from '../services/api.js';
import { ContractViolationError } from '../services/contracts/schema.js';
import { getUserErrorMessage, normalizeError } from '../services/errors.js';
import { DEMO_PUBLIC_KEY } from '../services/wallet.js';
import {
DEFAULT_RESULT_CAP,
transferQueryScopeKey,
} from '../utils/transferSearch.js';

function isAbortError(err) {
return (
err?.name === 'AbortError' ||
err?.code === 'ERR_CANCELED' ||
err?.code === 'ECONNABORTED'
);
}

/**
* Hook for loading and creating transfers.
*
* List requests always carry an actor scope, honour AbortSignal cancellation,
* and ignore stale responses so a slow reply for an old filter cannot
* overwrite the current result set.
*
* @param {{
* actorId?: string,
* filters?: {search?: string, status?: string, range?: string},
* limit?: number,
* }} [options]
* @returns {{transfers: Array, loading: boolean, error: string|null,
* retryable: boolean, reload: Function|undefined, addTransfer: Function}}
*/
export function useTransfers() {
export function useTransfers(options = {}) {
const actorId = options.actorId || DEMO_PUBLIC_KEY;
const filters = options.filters || {};
const limit = options.limit ?? DEFAULT_RESULT_CAP;

const [transfers, setTransfers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [retryable, setRetryable] = useState(false);

const requestGen = useRef(0);
const abortRef = useRef(null);

const scopeKey = transferQueryScopeKey({
actorId,
search: filters.search,
status: filters.status,
range: filters.range,
limit,
});

const reload = useCallback(async () => {
const gen = ++requestGen.current;
const expectedScope = scopeKey;

if (abortRef.current) {
abortRef.current.abort();
}
const controller = new AbortController();
abortRef.current = controller;

setLoading(true);
setError(null);
setRetryable(false);
try {
const data = await listTransfers();
const data = await listTransfers({
actorId,
search: filters.search,
status: filters.status,
range: filters.range,
limit,
signal: controller.signal,
});
// Drop stale replies: a newer filter/request already superseded this one.
if (gen !== requestGen.current || expectedScope !== scopeKey) {
return;
}
setTransfers(data);
} catch (err) {
if (isAbortError(err) || gen !== requestGen.current) {
return;
}
if (err instanceof ContractViolationError) {
// A schema change, not a flaky request. Retrying will not help, and
// showing an empty list would imply the transfers no longer exist.
Expand All @@ -37,19 +97,43 @@ export function useTransfers() {
setRetryable(normalized.retryable);
}
} finally {
setLoading(false);
if (gen === requestGen.current) {
setLoading(false);
}
}
}, []);
}, [actorId, filters.search, filters.status, filters.range, limit, scopeKey]);

useEffect(() => {
reload();
return () => {
if (abortRef.current) {
abortRef.current.abort();
}
};
}, [reload]);

const addTransfer = useCallback(async (payload) => {
const created = await createTransfer(payload);
setTransfers((prev) => [created, ...prev]);
return created;
}, []);
const addTransfer = useCallback(
async (payload) => {
const created = await createTransfer({
...payload,
actorId: payload?.actorId || actorId,
});
setTransfers((prev) => {
// Prepend only when the new row matches the active filter scope.
const matchesSearch =
!filters.search ||
String(created.recipient ?? '')
.toLowerCase()
.includes(String(filters.search).toLowerCase());
const matchesStatus =
!filters.status || created.status === filters.status;
if (!matchesSearch || !matchesStatus) return prev;
return [created, ...prev].slice(0, limit);
});
return created;
},
[actorId, filters.search, filters.status, limit],
);

// Existing consumers use reload for both pull-to-refresh and the error-state
// retry action. Withhold it only while a non-retryable error is displayed.
Expand Down
8 changes: 5 additions & 3 deletions src/pages/SendMoney.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import './SendMoney.css';
export default function SendMoney() {
const navigate = useNavigate();
const { wallet, isConnected, connect } = useWallet();
const { addTransfer } = useTransfers();
const { addTransfer } = useTransfers({ actorId: wallet?.publicKey });
const { locale } = useApp();
const isOnline = useOnlineStatus();

Expand Down Expand Up @@ -187,8 +187,9 @@ export default function SendMoney() {
submissionLock.current = true;
setSubmitting(true);
try {
if (!isConnected) {
await connect();
const account = isConnected ? wallet : await connect();
if (!account?.publicKey) {
throw new Error('Connect a wallet before sending.');
}

// Rebuild at confirmation time so the committed amounts match the note:
Expand All @@ -210,6 +211,7 @@ export default function SendMoney() {
// can reproduce exactly what was quoted rather than re-deriving it from
// a rate that may since have moved.
const created = await addTransfer({
actorId: account.publicKey,
recipient,
from,
to,
Expand Down
109 changes: 70 additions & 39 deletions src/pages/Transfers.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import Chart from '../components/Chart.jsx';
import { formatMoney, parseDecimal } from '../utils/money.js';
import {
TRANSFER_STATUSES,
normalizeStatus,
} from '../services/contracts/transfer.js';
import { TRANSFER_STATUSES } from '../services/contracts/transfer.js';
import TransferRow from '../components/TransferRow.jsx';
import { TRANSFER_STATUS_LABELS } from '../components/StatusBadge.jsx';
import Skeleton from '../components/Skeleton.jsx';
Expand All @@ -16,9 +13,15 @@ import Pagination from '../components/Pagination.jsx';
import PullToRefresh from '../components/PullToRefresh.jsx';
import SelectionToolbar from '../components/SelectionToolbar.jsx';
import { useTransfers } from '../hooks/useTransfers.js';
import { useDebouncedValue } from '../hooks/useDebouncedValue.js';
import { useOnlineStatus } from '../hooks/useOnlineStatus.js';
import { useApp } from '../context/AppContext.jsx';
import { DATE_RANGE_PRESETS, isWithinDateRange } from '../utils/dateRange.js';
import { DATE_RANGE_PRESETS } from '../utils/dateRange.js';
import { DEMO_PUBLIC_KEY } from '../services/wallet.js';
import {
DEFAULT_RESULT_CAP,
SEARCH_DEBOUNCE_MS,
} from '../utils/transferSearch.js';
import './Transfers.css';

// Derived from the contract so a new lifecycle state cannot be filterable in
Expand All @@ -35,11 +38,13 @@ const PAGE_SIZE = 5;

/**
* Transfers page: lists all transfers with their status.
* Filter state is synced to the URL query string.
* Filter state is synced to the URL query string. Free-text search is
* debounced before it drives the actor-scoped list query so keystrokes do
* not fan out requests; obsolete in-flight queries are aborted by the hook.
*/
export default function Transfers() {
const { transfers, loading, error, reload } = useTransfers();
const { locale } = useApp();
const { locale, wallet } = useApp();
const actorId = wallet?.publicKey || DEMO_PUBLIC_KEY;
const isOnline = useOnlineStatus();
const [searchParams, setSearchParams] = useSearchParams();

Expand All @@ -49,9 +54,48 @@ export default function Transfers() {
const [wasOffline, setWasOffline] = useState(false);
const [syncingAfterReconnect, setSyncingAfterReconnect] = useState(false);

const search = searchParams.get('search') || '';
const status = searchParams.get('status') || '';
const range = searchParams.get('range') || '';
const urlSearch = searchParams.get('search') || '';

// Draft input updates immediately; the debounced value drives URL + query.
const [searchDraft, setSearchDraft] = useState(urlSearch);
const debouncedSearch = useDebouncedValue(searchDraft, SEARCH_DEBOUNCE_MS);

// Keep draft aligned when the URL changes externally (back/forward, clear).
useEffect(() => {
setSearchDraft(urlSearch);
}, [urlSearch]);

// Publish debounced search into the URL so shareable links stay accurate.
useEffect(() => {
setSearchParams(
(prev) => {
const current = prev.get('search') || '';
if (current === debouncedSearch) return prev;
const next = new URLSearchParams(prev);
if (debouncedSearch) next.set('search', debouncedSearch);
else next.delete('search');
return next;
},
{ replace: true },
);
}, [debouncedSearch, setSearchParams]);

const queryFilters = useMemo(
() => ({
search: debouncedSearch,
status,
range,
}),
[debouncedSearch, status, range],
);

const { transfers, loading, error, reload } = useTransfers({
actorId,
filters: queryFilters,
limit: DEFAULT_RESULT_CAP,
});

// Selection state
const [selectedIds, setSelectedIds] = useState(() => new Set());
Expand All @@ -60,39 +104,29 @@ export default function Transfers() {
// Pagination state
const [page, setPage] = useState(1);

// Reset page and selection when filters change
// Reset page and selection when the committed filters change
useEffect(() => {
setPage(1);
setSelectedIds(new Set());
setSelectAllAcross(false);
}, [search, status, range]);
}, [debouncedSearch, status, range]);

// Normalise the query-string status so a legacy or provider spelling in a
// shared/bookmarked URL (?status=settled) still selects the right rows.
const canonicalStatus = normalizeStatus(status);
// Track connectivity so that a reconnect triggers an automatic reload.
// The reload reconciles the true status of transfers that may have been
// created or settled while the connection was down — without resubmitting
// anything.
useEffect(() => {
if (isOnline && wasOffline && !loading) {
if (isOnline && wasOffline && !loading && reload) {
setSyncingAfterReconnect(true);
reload().finally(() => {
Promise.resolve(reload()).finally(() => {
setSyncingAfterReconnect(false);
});
}
setWasOffline(!isOnline);
}, [isOnline, wasOffline, loading, reload]);

const filteredTransfers = useMemo(() => {
return transfers.filter((t) => {
if (status && normalizeStatus(t.status) !== canonicalStatus) return false;
if (search && !t.recipient.toLowerCase().includes(search.toLowerCase()))
return false;
if (!isWithinDateRange(t.createdAt, range)) return false;
return true;
});
}, [transfers, search, status, canonicalStatus, range]);
// API already applies actor scope, filters, stable sort, and the result cap.
const filteredTransfers = transfers;

// Paginated data
const totalPages = Math.ceil(filteredTransfers.length / PAGE_SIZE) || 1;
Expand All @@ -112,17 +146,9 @@ export default function Transfers() {
: selectedIds.size;
const hasMorePages = totalPages > 1;

const handleSearchChange = useCallback(
(e) => {
const value = e.target.value;
setSearchParams((prev) => {
if (value) prev.set('search', value);
else prev.delete('search');
return prev;
});
},
[setSearchParams],
);
const handleSearchChange = useCallback((e) => {
setSearchDraft(e.target.value);
}, []);

const handleStatusChange = useCallback(
(e) => {
Expand All @@ -148,7 +174,7 @@ export default function Transfers() {
[setSearchParams],
);

const hasActiveFilters = Boolean(search || status || range);
const hasActiveFilters = Boolean(debouncedSearch || status || range);

// Selection handlers
const handleToggleSelect = useCallback((id) => {
Expand Down Expand Up @@ -188,6 +214,11 @@ export default function Transfers() {
setSelectAllAcross(false);
}, []);

const handleClearFilters = useCallback(() => {
setSearchDraft('');
setSearchParams({});
}, [setSearchParams]);

const renderContent = () => {
if (loading) {
return (
Expand Down Expand Up @@ -215,7 +246,7 @@ export default function Transfers() {
}
action={
hasActiveFilters ? (
<Button onClick={() => setSearchParams({})}>Clear filters</Button>
<Button onClick={handleClearFilters}>Clear filters</Button>
) : (
<Link to="/send">
<Button>Send your first transfer</Button>
Expand Down Expand Up @@ -288,7 +319,7 @@ export default function Transfers() {
type="search"
className="transfers-filters-search"
placeholder="Search by recipient…"
value={search}
value={searchDraft}
onChange={handleSearchChange}
aria-label="Search transfers by recipient"
/>
Expand Down
Loading