diff --git a/src/hooks/useTransfers.js b/src/hooks/useTransfers.js
index dea1e61..9e3448c 100644
--- a/src/hooks/useTransfers.js
+++ b/src/hooks/useTransfers.js
@@ -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.
@@ -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.
diff --git a/src/pages/SendMoney.jsx b/src/pages/SendMoney.jsx
index c1e03f0..57b3e26 100644
--- a/src/pages/SendMoney.jsx
+++ b/src/pages/SendMoney.jsx
@@ -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();
@@ -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:
@@ -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,
diff --git a/src/pages/Transfers.jsx b/src/pages/Transfers.jsx
index def8104..809b1c1 100644
--- a/src/pages/Transfers.jsx
+++ b/src/pages/Transfers.jsx
@@ -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';
@@ -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
@@ -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();
@@ -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());
@@ -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;
@@ -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) => {
@@ -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) => {
@@ -188,6 +214,11 @@ export default function Transfers() {
setSelectAllAcross(false);
}, []);
+ const handleClearFilters = useCallback(() => {
+ setSearchDraft('');
+ setSearchParams({});
+ }, [setSearchParams]);
+
const renderContent = () => {
if (loading) {
return (
@@ -215,7 +246,7 @@ export default function Transfers() {
}
action={
hasActiveFilters ? (
-
+
) : (
@@ -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"
/>
diff --git a/src/services/api.js b/src/services/api.js
index d8d2797..ce71f6e 100644
--- a/src/services/api.js
+++ b/src/services/api.js
@@ -11,6 +11,13 @@ import {
parseTransferList,
transferContract,
} from './contracts/transfer.js';
+import { DEMO_PUBLIC_KEY } from './wallet.js';
+import {
+ DEFAULT_RESULT_CAP,
+ applyTransferSearch,
+ normalizeTransferQuery,
+ requireActorId,
+} from '../utils/transferSearch.js';
const STORAGE_KEY = 'remitflow.transfers';
@@ -25,6 +32,7 @@ const SEED_TRANSFERS = [
receiveAmount: 294620,
status: 'completed',
createdAt: '2026-05-28T10:15:00Z',
+ actorId: DEMO_PUBLIC_KEY,
},
{
id: 'tx_1002',
@@ -35,6 +43,7 @@ const SEED_TRANSFERS = [
receiveAmount: 9920,
status: 'pending',
createdAt: '2026-06-02T08:42:00Z',
+ actorId: DEMO_PUBLIC_KEY,
},
];
@@ -67,18 +76,57 @@ function reportRejected(rejected) {
}
}
+function abortedError() {
+ if (typeof DOMException === 'function') {
+ return new DOMException(
+ 'The transfer list request was aborted.',
+ 'AbortError',
+ );
+ }
+ const err = new Error('The transfer list request was aborted.');
+ err.name = 'AbortError';
+ return err;
+}
+
/**
- * List all transfers, newest first.
+ * List transfers for one actor, newest first, with optional filters.
+ *
+ * Every call must carry `actorId` so results cannot cross wallet scope.
+ * Pass `signal` to cancel an obsolete in-flight query when filters change.
+ * Results are stably sorted and capped so large histories stay responsive.
*
* One malformed record is dropped and logged so the rest of the list still
* renders. A response where *every* record fails is a schema change, not bad
* data, and is raised so the UI can say so instead of showing "no transfers".
*
- * @returns {Promise} contract-normalised transfers
+ * @param {{
+ * actorId?: string,
+ * search?: string,
+ * status?: string,
+ * range?: string,
+ * limit?: number,
+ * signal?: AbortSignal,
+ * }} [options]
+ * @returns {Promise