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
68 changes: 68 additions & 0 deletions hooks/use-streams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,21 @@ import { readCachedStreams, writeCachedStreams } from '@/lib/streams-cache'
// re-fetch without prop-drilling or global state.
type Listener = () => void
const listeners = new Set<Listener>()

/**
* Signals every mounted {@link useStreams} and {@link useStream} instance to
* immediately re-fetch stream data from the contract.
*
* Call this after any write operation (create, withdraw, cancel, top-up, etc.)
* so that the UI reflects the updated state without waiting for the next
* poll tick. Uses a simple in-module listener set — no global state manager
* or prop-drilling required.
*
* @example
* // Inside a component that just created a stream:
* await contractCreateStream(...)
* invalidateStreams()
*/
export function invalidateStreams() {
listeners.forEach((l) => l())
}
Expand Down Expand Up @@ -52,6 +67,41 @@ interface UseStreamsOptions {
// How long the tab must have been hidden before we show "Refreshing…"
const STALE_THRESHOLD_MS = 3_000

/**
* Fetches and subscribes to all streams associated with the currently
* connected wallet address, split into sent and received categories.
*
* **Return shape:**
* - `sent` — streams where the connected wallet is the sender.
* - `received` — streams where the connected wallet is the recipient.
* - `all` — unfiltered array of every stream returned by the contract.
* - `loading` — `true` while the first (or any subsequent) fetch is in
* flight and no data is available yet.
* - `isRefreshingAfterHidden` — `true` for the duration of the re-fetch that
* fires when the browser tab becomes visible again after being hidden for at
* least 3 seconds. Use this flag to show a non-blocking "Refreshing…"
* indicator without wiping out the existing stream list.
* - `stale` — `true` when `all` is being served from the offline
* {@link readCachedStreams} cache rather than a live network response.
* This can happen when the browser is offline or when a network-level
* fetch fails — FlowStar falls back to the last known data so the user
* sees something instead of an empty dashboard.
* - `lastUpdated` — Unix timestamp (ms) of when the most recent successful
* (or cached) fetch completed, or `null` if no data has been loaded yet.
* - `refetch` — Imperative callback to trigger an immediate re-fetch; also
* called automatically on mount, on address/network change, when
* {@link invalidateStreams} fires, when the tab regains focus, and on
* every poll tick.
*
* **Polling:** Enabled by default every 30 seconds. Polling is paused while
* the tab is hidden and resumes on visibility. Pass `enablePolling: false` or
* a custom `pollInterval` (ms) via `options` to override.
*
* @param options - Optional configuration for the polling behaviour.
* @param options.enablePolling - Whether to poll for updates (default `true`).
* @param options.pollInterval - Polling interval in ms (default `30000`).
* @returns A {@link CategorizedStreams} object.
*/
export function useStreams(options?: UseStreamsOptions): CategorizedStreams {
const { address } = useWallet()
const { network } = useNetwork()
Expand Down Expand Up @@ -248,6 +298,24 @@ export function useStreams(options?: UseStreamsOptions): CategorizedStreams {
}
}

/**
* Fetches a single stream by its contract ID and re-fetches whenever
* {@link invalidateStreams} is called.
*
* Unlike {@link useStreams}, this hook does not poll. It fires one fetch on
* mount and again whenever the stream `id`, the active network, or an
* invalidation signal changes. Use `refetch` to trigger a manual refresh
* after a write.
*
* **Return shape:**
* - `stream` — The {@link StreamData} for the given ID, or `null` while
* loading or if the stream was not found.
* - `loading` — `true` while a fetch is in flight.
* - `refetch` — Imperative callback to trigger an immediate re-fetch.
*
* @param id - The contract stream ID to fetch.
* @returns An object with `stream`, `loading`, and `refetch`.
*/
export function useStream(id: string): {
stream: StreamData | null
loading: boolean
Expand Down
64 changes: 64 additions & 0 deletions lib/address-book.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,32 @@ function writeEntries(entries: AddressBookEntry[]) {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(entries.slice(0, 50)))
}

/**
* Returns all address book entries stored in `localStorage`, sorted by most
* recently used first (`lastUsed` descending).
*
* Safe to call in SSR contexts — returns an empty array when `window` is
* unavailable or when the stored value cannot be parsed.
*
* @returns Array of {@link AddressBookEntry} objects ordered by recency.
*/
export function getAddressBookEntries(): AddressBookEntry[] {
return readEntries().sort((a, b) => b.lastUsed - a.lastUsed)
}

/**
* Adds a new entry to the address book, or replaces an existing entry that
* shares the same `address`.
*
* A unique `id` and a `lastUsed` timestamp (set to `Date.now()`) are
* generated automatically. `label` and `address` are trimmed; an empty or
* missing `federationAddress` is stored as `undefined`. At most 50 entries
* are kept — older duplicates for the same address are removed first.
*
* @param entry - New entry data. `id` and `lastUsed` are generated
* automatically and must not be supplied.
* @returns The fully populated {@link AddressBookEntry} that was persisted.
*/
export function addAddressBookEntry(entry: Omit<AddressBookEntry, 'id' | 'lastUsed'>) {
const entries = readEntries()
const normalized = {
Expand All @@ -44,18 +66,60 @@ export function addAddressBookEntry(entry: Omit<AddressBookEntry, 'id' | 'lastUs
return normalized
}

/**
* Applies a partial update to an existing address book entry identified by
* `id`. Only the fields present in `patch` are changed; all other fields
* remain as-is.
*
* @param id - The unique entry ID to update.
* @param patch - Partial fields to merge into the existing entry. `id` cannot
* be changed.
* @returns The updated {@link AddressBookEntry} if found, or `null` if no
* entry with the given `id` exists.
*/
export function updateAddressBookEntry(id: string, patch: Partial<Omit<AddressBookEntry, 'id'>>) {
const entries = readEntries()
const next = entries.map((entry) => (entry.id === id ? { ...entry, ...patch } : entry))
writeEntries(next)
return next.find((entry) => entry.id === id) ?? null
}

/**
* Permanently removes the address book entry with the given `id` from
* `localStorage`. No-ops silently if no matching entry exists.
*
* @param id - The unique ID of the entry to delete.
*/
export function deleteAddressBookEntry(id: string) {
const entries = readEntries().filter((entry) => entry.id !== id)
writeEntries(entries)
}

/**
* Updates `lastUsed` (and optionally `label` / `federationAddress`) for an
* existing entry that matches `address`, or creates a new entry if none is
* found.
*
* This is the preferred way to record a recently used address — callers do
* not need to know whether the address is already in the book.
*
* - If a matching entry exists: `lastUsed` is set to now; `label` and
* `federationAddress` are overwritten only when non-empty values are
* provided.
* - If no match exists and `address` is non-empty: a new entry is created via
* {@link addAddressBookEntry} with `label` defaulting to
* `"Saved recipient"`.
* - If `address` is blank: returns `null` without modifying storage.
*
* @param address - Stellar G-address to touch.
* @param label - Optional display label to update or use for the
* new entry.
* @param federationAddress - Optional Federation address (e.g.
* `alice*stellarx.com`) associated with this
* account.
* @returns The upserted {@link AddressBookEntry}, or `null` if the address
* was blank.
*/
export function touchAddressBookEntry(address: string, label?: string, federationAddress?: string) {
const entries = readEntries()
const existing = entries.find((entry) => entry.address === address)
Expand Down
47 changes: 47 additions & 0 deletions lib/error-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,34 @@ const ERROR_PATTERNS: Array<{
},
]

/**
* Maps a raw error value thrown during a Soroban transaction (or any other
* operation) to a structured {@link MappedError} with a user-friendly message,
* an actionable suggestion, an {@link ErrorCategory}, and the original error
* string as `details`.
*
* **Matching strategy:**
* The function iterates through `ERROR_PATTERNS` in declaration order. Each
* pattern is either a `RegExp` (tested with `RegExp.test()`) or a plain
* `string` (matched with a case-insensitive `includes()` check). The first
* matching pattern wins. Patterns are ordered so that specific Soroban
* contract error codes (`Error(Contract, #N)`) appear before broader
* catch-all patterns (e.g. "insufficient balance").
*
* If no pattern matches, a generic `"Transaction failed"` fallback is
* returned with `category: "contract"` and the raw message as `details`.
*
* **Known limitation:** The numeric contract error codes (#1–#20) are mapped
* to the `StreamError` enum in `contracts/streaming/src/lib.rs`. If the
* on-chain enum is extended with new codes, this mapping must be updated
* manually (tracked in issues #231 and #287).
*
* @param raw - The thrown value. Accepts an `Error` instance (uses
* `.message`) or any other type (coerced to a string via
* `String()`).
* @returns A {@link MappedError} with `message`, `suggestion`, `category`,
* and `details` (the original raw message).
*/
export function mapError(raw: unknown): MappedError {
const rawMessage = raw instanceof Error ? raw.message : String(raw)

Expand All @@ -316,6 +344,14 @@ export function mapError(raw: unknown): MappedError {
}
}

/**
* Returns a short, human-readable label for an {@link ErrorCategory} suitable
* for display in UI headings or badges (e.g. `"Input error"`,
* `"Network error"`).
*
* @param category - The error category to label.
* @returns A display-ready string for the given category.
*/
export function categoryLabel(category: ErrorCategory): string {
switch (category) {
case 'user':
Expand All @@ -329,6 +365,17 @@ export function categoryLabel(category: ErrorCategory): string {
}
}

/**
* Returns a Tailwind CSS text-colour class pair for the given
* {@link ErrorCategory}, one for light mode and one for dark mode (e.g.
* `"text-amber-600 dark:text-amber-400"`).
*
* Intended to be applied directly to a JSX element's `className` prop so that
* error labels are colour-coded consistently across the UI.
*
* @param category - The error category to colour.
* @returns A Tailwind CSS class string for the matching text colour.
*/
export function categoryColor(category: ErrorCategory): string {
switch (category) {
case 'user':
Expand Down
50 changes: 50 additions & 0 deletions lib/recurring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ export interface RecurringRule {

const STORAGE_KEY = 'flowstar:recurring-streams'

/**
* Reads all recurring stream rules persisted in `localStorage`.
*
* Safe to call in SSR contexts — returns an empty array when `window` is
* unavailable or when the stored value cannot be parsed.
*
* @returns Array of {@link RecurringRule} objects, or `[]` if none are stored.
*/
export function getRecurringRules(): RecurringRule[] {
if (typeof window === 'undefined') return []
try {
Expand All @@ -24,19 +32,47 @@ export function getRecurringRules(): RecurringRule[] {
}
}

/**
* Persists a recurring stream rule to `localStorage`, replacing any existing
* rule for the same `streamId`.
*
* At most 25 rules are kept; the oldest entries are dropped when the list
* exceeds that limit. No-ops in SSR environments.
*
* @param rule - The {@link RecurringRule} to save. An existing rule with the
* same `streamId` is overwritten.
*/
export function saveRecurringRule(rule: RecurringRule): void {
if (typeof window === 'undefined') return
const rules = getRecurringRules().filter((item) => item.streamId !== rule.streamId)
rules.unshift(rule)
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(rules.slice(0, 25)))
}

/**
* Removes the recurring rule associated with the given stream ID from
* `localStorage`. No-ops if no matching rule exists or in SSR environments.
*
* @param streamId - The contract stream ID whose recurring rule should be
* deleted.
*/
export function removeRecurringRule(streamId: string): void {
if (typeof window === 'undefined') return
const rules = getRecurringRules().filter((item) => item.streamId !== streamId)
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(rules))
}

/**
* Returns all recurring rules whose `nextRunAt` timestamp is in the future,
* sorted in ascending order by `nextRunAt` (soonest renewal first).
*
* Rules whose `nextRunAt` is in the past (i.e. overdue or already processed)
* are excluded. Use {@link getRecurringRules} to retrieve the full unfiltered
* list.
*
* @returns Subset of stored rules that are pending renewal, ordered by
* scheduled time.
*/
export function getUpcomingRenewals(): RecurringRule[] {
return getRecurringRules()
.filter((rule) => rule.nextRunAt > Date.now())
Expand Down Expand Up @@ -99,6 +135,20 @@ export function buildNextRunAt(startTime: number, cadence: Exclude<RecurrenceCad
return date.getTime()
}

/**
* Builds a new {@link RecurringRule} preset from an existing stream and
* immediately persists it via {@link saveRecurringRule}.
*
* The `nextRunAt` timestamp is calculated from `Date.now()` using
* {@link buildNextRunAt}, and `lastCreatedAt` is set to the current time.
*
* @param stream - Minimal stream data needed to populate the rule: the
* stream's `id`, `recipient`, `token.symbol`, and
* `depositedAmount`.
* @param cadence - How often the stream should renew: `"weekly"`,
* `"monthly"`, or `"quarterly"`.
* @returns The newly created and saved {@link RecurringRule}.
*/
export function createRenewalPreset(stream: { id: string; recipient: string; token: { symbol: string }; depositedAmount: bigint }, cadence: Exclude<RecurrenceCadence, 'none'>): RecurringRule {
const preset = {
streamId: stream.id,
Expand Down