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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ All notable changes to the ar.io Console are documented in this file.
first and names the amount it returns. A wallet with no shares sees one line
instead of an empty panel. Approval IDs are no longer shown.

- **Top-up history stays loaded for the session.** Leaving the Account page
and coming back shows it at once, without signing again. It says when it
was loaded and offers Refresh, and after a top-up, share or revoke it says
your balance has changed since. It is never saved to the browser: a reload
or another wallet asks for the signature again.

### Fixed
- **Revoking on the Account page took effect on one click** and revoked
everything shared with that wallet, though the row showed one share. It now
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ what it did. Don't reintroduce a second verification entry point.

**Cache expiry:** ArNS names (24h), owned names (6h), upload status (1h confirmed, 24h finalized)

**Signature-gated reads** (top-up history, `usePaymentHistory`) are cached in TanStack Query for the session, never persisted: keyed by address, wallet type and config mode; `enabled: false` (each request asks the wallet to sign, so only a click fetches); `staleTime`/`gcTime: Infinity`; `retry: false`. `refresh-balance` marks them stale with `invalidateQueries({ refetchType: 'none' })` from `useCreditBalance`, which is always mounted, and the page offers Refresh. TanStack never reports a disabled query as stale (`isStale` checks `enabled` first), so read `getQueryState(key).isInvalidated` instead. `useWalletAccountListener` drops other wallets' entries when the session address changes, and clears the module-level client cache alongside the Ethereum and x402 ones.

### Pages Feature

Pages (`/pages`) is a no-code link-in-bio builder: users pick a template, edit profile/blocks/theme with a live preview, and publish a permanent, self-contained HTML page to Arweave with an optional ArNS domain. Pure-logic modules are node-tested (see the Vitest note in Quick Start).
Expand Down
86 changes: 81 additions & 5 deletions src/components/account/PaymentHistorySection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import {
Inbox,
Download,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { relativeTime } from '@/utils/relativeTime';
import { usePaymentHistory, type PaymentHistoryItem } from '@/hooks/usePaymentHistory';
import {
isCryptoPayment,
Expand Down Expand Up @@ -221,11 +223,75 @@ function PaymentRow({ item }: { item: PaymentHistoryItem }) {
);
}

// ---- freshness ---------------------------------------------------------------

/**
* "Loaded 4m ago · Refresh", or, once a top-up, share or revoke has happened
* since, a nudge to refresh. The history never refetches on its own: each
* request asks the wallet to sign, so the user decides when.
*/
function HistoryFreshness({
loadedAt,
stale,
refreshing,
busy,
onRefresh,
}: {
loadedAt: number;
stale: boolean;
refreshing: boolean;
/** Another fetch (Load more) is running; Refresh would cancel it. */
busy: boolean;
onRefresh: () => void;
}) {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const t = setInterval(() => setNow(Date.now()), 30_000);
return () => clearInterval(t);
}, []);
const age = relativeTime(loadedAt, now) ?? 'just now';

const button = (
<button
type="button"
onClick={onRefresh}
disabled={refreshing || busy}
title="Asks your wallet to sign"
className="inline-flex items-center gap-1 font-medium text-primary hover:underline disabled:cursor-default disabled:no-underline disabled:opacity-60"
>
<RefreshCw className={`h-3.5 w-3.5 ${refreshing ? 'animate-spin' : ''}`} aria-hidden="true" />
{refreshing ? 'Refreshing…' : 'Refresh'}
</button>
);

if (stale && !refreshing) {
return (
<div className="mx-4 mb-3 flex flex-wrap items-center justify-between gap-2 rounded-xl border border-border/20 bg-background px-3 py-2 text-xs text-foreground/80 sm:mx-6">
<span>Your balance has changed since this loaded {age}.</span>
{button}
</div>
);
}
return (
<div className="flex items-center gap-2 px-4 pb-2 text-xs text-foreground/60 sm:px-6" aria-live="polite">
<span>{refreshing ? 'Refreshing…' : `Loaded ${age}`}</span>
{!refreshing && (
<>
<span aria-hidden="true">·</span>
{button}
</>
)}
</div>
);
}

// ---- section -----------------------------------------------------------------

export default function PaymentHistorySection() {
const navigate = useNavigate();
const { payments, hasMore, status, error, load, loadMore } = usePaymentHistory();
const { payments, hasMore, status, error, load, refresh, loadMore, loadedAt, stale } = usePaymentHistory();
// Rows stay on screen while a refresh or "Load more" runs.
const showing = status === 'loaded' || status === 'loadingMore' || status === 'refreshing';

return (
<div className="rounded-2xl border border-border/20 bg-card">
Expand All @@ -238,7 +304,7 @@ export default function PaymentHistorySection() {
<h3 className="text-lg font-extrabold text-foreground">Top-up History</h3>
<p className="text-sm text-foreground/80">Every credit purchase you’ve made</p>
</div>
{(status === 'loaded' || status === 'loadingMore') && payments.length > 0 && (
{showing && payments.length > 0 && (
<div className="ml-auto flex items-center gap-3">
<button
onClick={() => downloadTopupCsv(payments)}
Expand All @@ -252,6 +318,16 @@ export default function PaymentHistorySection() {
)}
</div>

{showing && loadedAt !== undefined && (
<HistoryFreshness
loadedAt={loadedAt}
stale={stale}
refreshing={status === 'refreshing'}
busy={status === 'loadingMore'}
onRefresh={refresh}
/>
)}

{/* Opt-in */}
{status === 'idle' && (
<div className="px-4 pb-6 pt-2 text-center sm:px-6">
Expand Down Expand Up @@ -307,7 +383,7 @@ export default function PaymentHistorySection() {
)}

{/* Empty */}
{(status === 'loaded' || status === 'loadingMore') && payments.length === 0 && (
{showing && payments.length === 0 && (
<div className="px-4 pb-8 pt-2 text-center sm:px-6">
<Inbox className="mx-auto mb-3 h-10 w-10 text-foreground/30" />
<h4 className="mb-1 font-extrabold text-foreground">No top-ups yet</h4>
Expand All @@ -325,7 +401,7 @@ export default function PaymentHistorySection() {

{/* List — bounded height so a long history scrolls inside the card rather
than growing the whole page. */}
{(status === 'loaded' || status === 'loadingMore') && payments.length > 0 && (
{showing && payments.length > 0 && (
<div className="px-4 pb-4 sm:px-6">
{/* pr-2 keeps the scrollbar from crowding the credits/tx column */}
<div className="max-h-[28rem] overflow-y-auto pr-2">
Expand All @@ -340,7 +416,7 @@ export default function PaymentHistorySection() {
<div className="flex justify-center pt-4">
<button
onClick={loadMore}
disabled={status === 'loadingMore'}
disabled={status !== 'loaded'}
className={buttonClass('secondary', 'sm')}
>
{status === 'loadingMore' ? (
Expand Down
34 changes: 3 additions & 31 deletions src/features/arns/purchase/buyTarget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
* `isValidRecordTarget` already knows how to check both.
*/
import { isArweaveTxId } from '../utils';
import { relativeTime } from '@/utils/relativeTime';

/** How the buyer chose the target. */
export type BuyTargetMode =
Expand Down Expand Up @@ -219,37 +220,8 @@ export function describeBuyTarget(
return when ? `${match.label} · ${when}` : match.label;
}

/**
* "2d ago" — coarse on purpose.
*
* `now` is injected rather than read from the clock so this stays pure and
* testable, matching how the rest of this module is written. A timestamp in the
* future (clock skew, a restored backup) returns undefined rather than "in 3
* days", which would read as a bug in the row.
*/
export function relativeTime(
timestamp: number | undefined,
now: number,
): string | undefined {
if (typeof timestamp !== 'number' || !Number.isFinite(timestamp) || timestamp <= 0) {
return undefined;
}
const diff = now - timestamp;
if (diff < 0) return undefined;

const mins = Math.floor(diff / 60_000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 7) return `${days}d ago`;
const weeks = Math.floor(days / 7);
if (weeks < 5) return `${weeks}w ago`;
const months = Math.floor(days / 30);
if (months < 12) return `${months}mo ago`;
return `${Math.floor(days / 365)}y ago`;
}
// Lives in utils so the Account page's payment history can use it too.
export { relativeTime };

/**
* The second line of a picker row: `Site · 2d ago · Gil3ILKW…JGCdA4`.
Expand Down
6 changes: 6 additions & 0 deletions src/hooks/useCreditBalance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useStore } from '../store/useStore';
import { getTurboBalance } from '../utils';
import { PAYMENT_HISTORY_KEY } from './usePaymentHistory';

/**
* Shared React Query hook for the user's Turbo credit balance.
Expand Down Expand Up @@ -62,6 +63,11 @@ export function useCreditBalance() {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ['credit-balance'] });
// Mark the cached top-up history stale too, without fetching: every
// history request asks the wallet to sign, so the Account page offers
// a refresh instead (usePaymentHistory). Lives here because this
// listener is always mounted; a top-up happens on another page.
queryClient.invalidateQueries({ queryKey: [PAYMENT_HISTORY_KEY], refetchType: 'none' });
}, 150);
};
window.addEventListener('refresh-balance', onRefresh);
Expand Down
Loading
Loading