diff --git a/app/profile/page.tsx b/app/profile/page.tsx index 22f82ce..ae60c6e 100644 --- a/app/profile/page.tsx +++ b/app/profile/page.tsx @@ -1,12 +1,21 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { useWallet } from "@/contexts/WalletContext"; import { useSelectedNetwork } from "@/hooks/useSelectedNetwork"; import { CopyHashButton } from "@/components/ui/CopyHashButton"; import { ProfileSkeleton } from "@/components/ProfileSkeleton"; import { isValidStellarPublicKey } from "@/lib/stellar-address"; +import { CashflowChart } from "@/components/profile/CashflowChart"; +import { + computeCashflow, + CASHFLOW_WINDOW_DAYS, + type CashflowStream, +} from "@/lib/profile-analytics"; +import { streamsBySender, streamsByRecipient } from "@/lib/factory"; +import { getStreamAddress, getStreamInfo } from "@/lib/stream"; +import { fromStroops } from "@/lib/format"; type ConnectionState = | { status: "loading" } @@ -24,6 +33,108 @@ function useConnectionState(): ConnectionState { return { status: "disconnected" }; } +/** + * Aggregated incoming vs outgoing streaming volume over the trailing 30 days + * (#690). Resolves the user's stream IDs in both directions, estimates + * per-day volume as rate × overlap, and renders totals + a stacked bar chart. + */ +function CashflowSection({ publicKey }: { publicKey: string }) { + const [streams, setStreams] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let active = true; + setLoading(true); + (async () => { + try { + const [senderIds, recipientIds] = await Promise.all([ + streamsBySender(publicKey, publicKey, 0, 50).catch(() => [] as bigint[]), + streamsByRecipient(publicKey, publicKey, 0, 50).catch(() => [] as bigint[]), + ]); + const jobs: { id: bigint; direction: "incoming" | "outgoing" }[] = [ + ...senderIds.map((id) => ({ id, direction: "outgoing" as const })), + ...recipientIds.map((id) => ({ id, direction: "incoming" as const })), + ]; + const settled = await Promise.allSettled( + jobs.map(async (job) => { + const addr = await getStreamAddress(publicKey, job.id); + if (!addr) return null; + const info = await getStreamInfo(publicKey, addr); + return { info, direction: job.direction } as CashflowStream; + }), + ); + if (!active) return; + setStreams( + settled.flatMap((r) => + r.status === "fulfilled" && r.value ? [r.value] : [], + ), + ); + } catch { + if (active) setStreams([]); + } finally { + if (active) setLoading(false); + } + })(); + return () => { + active = false; + }; + }, [publicKey]); + + const summary = useMemo( + () => + computeCashflow( + streams, + Math.floor(Date.now() / 1000), + CASHFLOW_WINDOW_DAYS, + ), + [streams], + ); + + return ( +
+

+ Cash Flow — past {CASHFLOW_WINDOW_DAYS} days +

+

+ Estimated streaming income vs expenditure from active stream rates. +

+ {loading ? ( +
+ Loading cash flow… +
+ ) : ( + <> +
+
+

Income

+

+ {fromStroops(summary.totalIncoming)} +

+
+
+

Spent

+

+ {fromStroops(summary.totalOutgoing)} +

+
+
+

Net

+

= 0n ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"}`} + data-testid="cashflow-net" + > + {summary.net >= 0n ? "+" : "−"} + {fromStroops(summary.net >= 0n ? summary.net : -summary.net)} +

+
+
+ + + )} +
+ ); +} + export default function ProfilePage() { const connection = useConnectionState(); const { disconnect } = useWallet(); @@ -125,6 +236,8 @@ export default function ProfilePage() { + +

Quick Links diff --git a/app/settings/page.tsx b/app/settings/page.tsx index ae58346..827259c 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -5,6 +5,15 @@ import { useTheme } from "next-themes"; import { NetworkName, NETWORKS } from "@/lib/network-config"; import { saveSelectedNetwork } from "@/lib/network-storage"; import { useOnboarding } from "@/hooks/useOnboarding"; +import { + clearCustomRpcUrl, + getCustomRpcUrl, + isValidRpcUrl, + pingRpcEndpoint, + saveCustomRpcUrl, + type RpcPingResult, +} from "@/lib/custom-rpc"; +import { resetServer } from "@/lib/soroban"; import { type TimeFormat, type RefreshIntervalSeconds, @@ -73,6 +82,18 @@ export default function SettingsPage() { const [saved, setSaved] = useState(false); const didMount = useRef(false); + // #691 — custom RPC endpoint override (local sandbox / private node). + const [customRpc, setCustomRpc] = useState(""); + const [savedCustomRpc, setSavedCustomRpc] = useState(null); + const [ping, setPing] = useState(null); + const [pinging, setPinging] = useState(false); + const [rpcMessage, setRpcMessage] = useState(null); + + useEffect(() => { + setSavedCustomRpc(getCustomRpcUrl()); + setCustomRpc(getCustomRpcUrl() ?? ""); + }, []); + useEffect(() => { if (!didMount.current) { didMount.current = true; @@ -113,6 +134,59 @@ export default function SettingsPage() { setTimeout(() => setSaved(false), 2000); }, [setTheme]); + const handleTestPing = useCallback(async () => { + setRpcMessage(null); + setPing(null); + if (!isValidRpcUrl(customRpc)) { + setPing({ ok: false, latencyMs: null, error: "Enter a valid http(s) RPC URL." }); + return; + } + setPinging(true); + try { + const result = await pingRpcEndpoint(customRpc.trim()); + setPing(result); + } finally { + setPinging(false); + } + }, [customRpc]); + + const handleSaveCustomRpc = useCallback(async () => { + setRpcMessage(null); + const url = customRpc.trim(); + if (!isValidRpcUrl(url)) { + setPing({ ok: false, latencyMs: null, error: "Enter a valid http(s) RPC URL." }); + return; + } + // Validate latency before saving — ping first, save only on success. + setPinging(true); + try { + const result = await pingRpcEndpoint(url); + setPing(result); + if (!result.ok) return; + saveCustomRpcUrl(url); + resetServer(); + setSavedCustomRpc(url); + setRpcMessage(`Custom RPC saved (${result.latencyMs} ms).`); + } catch (e) { + setPing({ + ok: false, + latencyMs: null, + error: e instanceof Error ? e.message : "Could not save the RPC endpoint.", + }); + } finally { + setPinging(false); + } + }, [customRpc]); + + const handleClearCustomRpc = useCallback(() => { + clearCustomRpcUrl(); + resetServer(); + setSavedCustomRpc(null); + setCustomRpc(""); + setPing(null); + setRpcMessage("Custom RPC cleared — using the preset network endpoint."); + }, []); + return (
@@ -173,6 +247,78 @@ export default function SettingsPage() { ))}
+ + {/* Custom RPC endpoint override (#691) */} +
+ Custom RPC endpoint +

+ Override the preset endpoint with a local sandbox or private node + (e.g. QuickNode, Triton). Ping it first — saving requires a + successful response. +

+
+ { + setCustomRpc(e.target.value); + setPing(null); + setRpcMessage(null); + }} + placeholder="https://my-private-rpc.example.com" + aria-label="Custom RPC endpoint" + className="w-full bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded px-3 py-1.5 text-sm font-mono" + /> +
+ + + {savedCustomRpc && ( + + )} +
+ {ping && ( +

+ {ping.ok + ? `Reachable — ${ping.latencyMs} ms.` + : `Unreachable${ping.latencyMs !== null ? ` (${ping.latencyMs} ms)` : ""}: ${ping.error ?? "no response"}`} +

+ )} + {rpcMessage && ( +

+ {rpcMessage} +

+ )} + {savedCustomRpc && ( +

+ Active override: {savedCustomRpc} +

+ )} +
+
{/* Currency & Slippage */} diff --git a/app/stream/[id]/page.tsx b/app/stream/[id]/page.tsx index d344101..9446ba1 100644 --- a/app/stream/[id]/page.tsx +++ b/app/stream/[id]/page.tsx @@ -3,11 +3,12 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useParams } from "next/navigation"; import Link from "next/link"; -import { ArrowLeft, FileText } from "lucide-react"; +import { ArrowLeft, FileText, Share2 } from "lucide-react"; import { Badge } from "@/components/ui/Badge"; import { Card } from "@/components/ui/Card"; import { CopyAddress } from "@/components/ui/CopyAddress"; +import { QrCodeModal } from "@/components/ui/QrCodeModal"; import { RateTicker } from "@/components/stream/RateTicker"; import { StreamTimeline } from "@/components/stream/StreamTimeline"; import { StreamFlowChart } from "@/components/stream/StreamFlowChart"; @@ -68,6 +69,8 @@ export default function StreamPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [status, setStatus] = useState("active"); + // #688 — shareable claim-link QR modal state. + const [shareOpen, setShareOpen] = useState(false); // Use stream note hook — only available after streamAddress is resolved const noteHook = useStreamNote(streamAddress || ""); @@ -255,6 +258,14 @@ export default function StreamPage() { tokenByAddress(info.token, "testnet")?.symbol ?? truncateAddress(info.token); + // #688 — direct stream claim URL for QR sharing. Built from the current + // origin on the client so a scanned code opens this exact stream page + // (claim/monitor) on the recipient's device. + const claimUrl = + typeof window !== "undefined" + ? `${window.location.origin}/stream/${id}` + : `/stream/${id}`; + return (
+ {/* Share stream claim link via QR code (#688) */} + {/* Add stream end date to an external calendar (#566) */} {info.endTime > 0 && ( )} + + {/* Share stream claim-link QR modal (#688) */} + {shareOpen && ( + setShareOpen(false)} + /> + )}
); } diff --git a/app/streams/compare/page.tsx b/app/streams/compare/page.tsx index 65dbe93..f44af68 100644 --- a/app/streams/compare/page.tsx +++ b/app/streams/compare/page.tsx @@ -176,16 +176,28 @@ function CompareView() {
); } else { + // #689 — responsive multi-column grid: 2 columns fit comfortably, 3–4 + // columns scroll horizontally on small screens and sit side-by-side on + // lg+ (max-w-6xl). Each stream gets an equal min-width column so 3 or 4 + // schedules remain comparable without squashing. + const columnCount = columns.length; body = ( -
- +
+
= 4 ? 860 : columnCount >= 3 ? 720 : 520 }} + > - {columns.map((col) => ( - ))} @@ -194,11 +206,11 @@ function CompareView() { {ROWS.map((row) => ( - {columns.map((col) => ( -
+ Metric + Stream #{col.id}
+ {row.label} + {col.kind === 'error' ? ( row === ROWS[0] ? {col.message} : — ) : ( @@ -218,11 +230,17 @@ function CompareView() { } return ( -
+
All streams -

Compare streams

+

Compare streams

+ {columns.length >= 2 && ( +

+ Comparing {columns.length} of up to {MAX_COMPARE} streams side-by-side +

+ )} + {columns.length < 2 &&
} {overLimit && connected && publicKey && (
You can compare up to {MAX_COMPARE} streams at a time. Showing the first {MAX_COMPARE} of the{' '} diff --git a/components/profile/CashflowChart.tsx b/components/profile/CashflowChart.tsx new file mode 100644 index 0000000..7b14072 --- /dev/null +++ b/components/profile/CashflowChart.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useMemo } from "react"; +import { fromStroops } from "@/lib/format"; +import type { DailyFlow } from "@/lib/profile-analytics"; + +interface CashflowChartProps { + daily: DailyFlow[]; +} + +/** + * Stacked bar chart of daily streaming income (bottom, dark) vs expenditure + * (top, gray) over the trailing 30-day window (#690). Pure CSS/divs — no + * chart dependency. Bars scale to the busiest day in the window. + */ +export function CashflowChart({ daily }: CashflowChartProps) { + const max = useMemo(() => { + let m = 0n; + for (const d of daily) { + const total = d.incoming + d.outgoing; + if (total > m) m = total; + } + return m > 0n ? m : 1n; + }, [daily]); + + const hasData = daily.some((d) => d.incoming > 0n || d.outgoing > 0n); + if (!hasData) { + return ( +
+

No streaming activity yet

+

+ Income vs expenditure will appear here once you send or receive streams. +

+
+ ); + } + + return ( +
+
+ {daily.map((d) => { + const total = d.incoming + d.outgoing; + const totalPct = total > 0n ? Number((total * 1000n) / max) / 10 : 0; + const incomingPct = + total > 0n ? (Number((d.incoming * 1000n) / total) / 10) * (totalPct / 100) * 100 : 0; + const outgoingPct = totalPct - incomingPct; + return ( +
+
0n ? 4 : 0)}%` }} + > + {outgoingPct > 0 && ( +
+ )} + {incomingPct > 0 && ( +
0n ? 2 : 0, + }} + /> + )} +
+
+ ); + })} +
+
+ {daily[0]?.label} + {daily[daily.length - 1]?.label} +
+
+ + + + +
+
+ ); +} diff --git a/components/ui/QrCodeModal.tsx b/components/ui/QrCodeModal.tsx index dbccf19..a1f0ab8 100644 --- a/components/ui/QrCodeModal.tsx +++ b/components/ui/QrCodeModal.tsx @@ -5,8 +5,15 @@ import qrcode from "qrcode-generator"; import { Modal } from "@/components/ui/Modal"; interface QrCodeModalProps { - /** The address encoded into the QR code. */ - address: string; + /** The address encoded into the QR code (legacy prop, kept for backwards compat). */ + address?: string; + /** + * Generic payload encoded into the QR code (e.g. a stream claim URL). + * Takes precedence over `address` when both are provided (#688). + */ + value?: string; + /** Modal title. Defaults to "Scan address" for address codes. */ + title?: string; onClose: () => void; } @@ -21,11 +28,14 @@ const CELL_SIZE = 4; * `createSvgTag` HTML-string output, so it needs no `dangerouslySetInnerHTML` * and can follow the app's light/dark theme. */ -export function QrCodeModal({ address, onClose }: QrCodeModalProps) { +export function QrCodeModal({ address, value, title, onClose }: QrCodeModalProps) { + // #688 — generic share payload (stream claim URL) with legacy address fallback. + const payload = value ?? address ?? ""; + const modalTitle = title ?? "Scan address"; const modules = useMemo(() => { // Type number 0 = auto-select the smallest size that fits the data. const qr = qrcode(0, "M"); - qr.addData(address); + qr.addData(payload); qr.make(); const count = qr.getModuleCount(); const cells: boolean[][] = []; @@ -37,12 +47,12 @@ export function QrCodeModal({ address, onClose }: QrCodeModalProps) { cells.push(line); } return cells; - }, [address]); + }, [payload]); const size = modules.length * CELL_SIZE; return ( - +
@@ -71,7 +81,7 @@ export function QrCodeModal({ address, onClose }: QrCodeModalProps) {

- {address} + {payload}

diff --git a/lib/custom-rpc.ts b/lib/custom-rpc.ts new file mode 100644 index 0000000..739054f --- /dev/null +++ b/lib/custom-rpc.ts @@ -0,0 +1,108 @@ +/** + * Custom Soroban RPC endpoint override (#691). + * + * Developers running a local sandbox (`http://localhost:8000`) or a private + * QuickNode/Triton node can override the preset network RPC URL from the + * Settings page. The override is stored in localStorage and takes precedence + * in `lib/env.ts#getRpcUrl` until cleared. + */ + +export const CUSTOM_RPC_STORAGE_KEY = "conduit:custom-rpc"; + +export function isValidRpcUrl(url: string): boolean { + const trimmed = url.trim(); + if (!trimmed) return false; + try { + const parsed = new URL(trimmed); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + +/** Return the saved custom RPC override, or null when unset/invalid. */ +export function getCustomRpcUrl(): string | null { + if (typeof window === "undefined") return null; + try { + const raw = localStorage.getItem(CUSTOM_RPC_STORAGE_KEY); + if (!raw) return null; + const trimmed = raw.trim(); + return trimmed && isValidRpcUrl(trimmed) ? trimmed : null; + } catch { + return null; + } +} + +export function saveCustomRpcUrl(url: string): void { + const trimmed = url.trim(); + if (!isValidRpcUrl(trimmed)) { + throw new Error("Enter a valid http(s) RPC URL."); + } + try { + localStorage.setItem(CUSTOM_RPC_STORAGE_KEY, trimmed); + } catch { + // Storage can be unavailable in private browsing or embedded webviews. + } +} + +export function clearCustomRpcUrl(): void { + try { + localStorage.removeItem(CUSTOM_RPC_STORAGE_KEY); + } catch { + /* ignore */ + } +} + +export interface RpcPingResult { + ok: boolean; + latencyMs: number | null; + error?: string; +} + +/** + * Validate an RPC endpoint by POSTing a lightweight `getHealth` JSON-RPC call + * and measuring round-trip latency. Never throws — failures are returned as + * `{ ok: false }` so the Settings UI can render them inline. + */ +export async function pingRpcEndpoint( + url: string, + timeoutMs = 10_000, +): Promise { + const trimmed = url.trim(); + if (!isValidRpcUrl(trimmed)) { + return { ok: false, latencyMs: null, error: "Enter a valid http(s) RPC URL." }; + } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + const started = typeof performance !== "undefined" ? performance.now() : Date.now(); + try { + const res = await fetch(trimmed, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getHealth" }), + signal: controller.signal, + }); + const ended = typeof performance !== "undefined" ? performance.now() : Date.now(); + const latencyMs = Math.round(ended - started); + if (!res.ok) { + return { ok: false, latencyMs, error: `HTTP ${res.status} from RPC endpoint.` }; + } + // A well-formed JSON-RPC response (even an error payload) proves the + // endpoint is reachable and speaking RPC. Only a transport failure fails. + await res.text().catch(() => ""); + return { ok: true, latencyMs }; + } catch (e) { + const ended = typeof performance !== "undefined" ? performance.now() : Date.now(); + const latencyMs = Math.round(ended - started); + if (e instanceof Error && e.name === "AbortError") { + return { ok: false, latencyMs: null, error: `Timed out after ${timeoutMs} ms.` }; + } + return { + ok: false, + latencyMs: null, + error: e instanceof Error ? e.message : "Could not reach the RPC endpoint.", + }; + } finally { + clearTimeout(timer); + } +} diff --git a/lib/env.ts b/lib/env.ts index 468113b..afbf588 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -13,6 +13,7 @@ */ import { loadSelectedNetwork } from './network-storage'; +import { CUSTOM_RPC_STORAGE_KEY, isValidRpcUrl } from './custom-rpc'; function required(name: string): string { const value = process.env[name]; @@ -27,11 +28,18 @@ function required(name: string): string { /** * Return the RPC URL for the currently selected network. * - * If the user has selected a known network (testnet/mainnet/local) from - * settings, its well-known RPC URL is used. Otherwise falls back to - * NEXT_PUBLIC_SOROBAN_RPC_URL so a custom/deployed URL can still be set. + * Precedence: an explicit custom override saved from Settings (#691) wins, + * then NEXT_PUBLIC_SOROBAN_RPC_URL, then the selected preset's well-known URL. */ export function getRpcUrl(): string { + try { + if (typeof window !== 'undefined') { + const custom = window.localStorage.getItem(CUSTOM_RPC_STORAGE_KEY); + if (custom && isValidRpcUrl(custom)) return custom.trim(); + } + } catch { + /* storage unavailable — fall through to presets */ + } const selected = loadSelectedNetwork(); return process.env['NEXT_PUBLIC_SOROBAN_RPC_URL'] || selected.rpcUrl; } diff --git a/lib/profile-analytics.ts b/lib/profile-analytics.ts new file mode 100644 index 0000000..c675bd5 --- /dev/null +++ b/lib/profile-analytics.ts @@ -0,0 +1,92 @@ +import type { StreamInfo } from "@/lib/stream"; + +export const CASHFLOW_WINDOW_DAYS = 30; +const DAY_S = 86_400; + +export interface CashflowStream { + info: StreamInfo; + /** "incoming" = user is recipient, "outgoing" = user is sender */ + direction: "incoming" | "outgoing"; +} + +export interface DailyFlow { + /** Unix seconds at UTC midnight starting this bucket */ + dayStart: number; + /** Short label, e.g. "Sep 12" */ + label: string; + incoming: bigint; + outgoing: bigint; +} + +export interface CashflowSummary { + totalIncoming: bigint; + totalOutgoing: bigint; + net: bigint; + daily: DailyFlow[]; +} + +/** Floor a unix timestamp to UTC midnight. */ +export function startOfUtcDay(ts: number): number { + return Math.floor(ts / DAY_S) * DAY_S; +} + +/** + * Aggregate per-day streamed volume over the trailing `days` window ending at + * `nowSeconds` (#690). + * + * Volume for a stream on a given day is `ratePerSecond × overlapped seconds` + * between the stream's [startTime, effectiveEnd] interval and the day bucket, + * where effectiveEnd is `min(endTime || now, now)`. Cancelled/ended streams + * naturally contribute zero after they stop. Pause skew is intentionally + * ignored — analytics are an estimate, not settlement accounting. + */ +export function computeCashflow( + streams: CashflowStream[], + nowSeconds: number, + days: number = CASHFLOW_WINDOW_DAYS, +): CashflowSummary { + const todayStart = startOfUtcDay(nowSeconds); + const windowStart = todayStart - (days - 1) * DAY_S; + + const daily: DailyFlow[] = Array.from({ length: days }, (_, i) => { + const dayStart = windowStart + i * DAY_S; + return { + dayStart, + label: new Date(dayStart * 1000).toLocaleDateString("en-US", { + timeZone: "UTC", + month: "short", + day: "numeric", + }), + incoming: 0n, + outgoing: 0n, + }; + }); + + let totalIncoming = 0n; + let totalOutgoing = 0n; + + for (const { info, direction } of streams) { + if (info.ratePerSecond <= 0n) continue; + const streamStart = info.startTime; + const streamEnd = info.endTime > 0 ? Math.min(info.endTime, nowSeconds) : nowSeconds; + if (streamEnd <= streamStart) continue; + // Skip streams that ended before the window or start in the future. + if (streamEnd <= windowStart || streamStart >= todayStart + DAY_S) continue; + + for (const bucket of daily) { + const bucketEnd = bucket.dayStart + DAY_S; + const overlap = Math.min(streamEnd, bucketEnd) - Math.max(streamStart, bucket.dayStart); + if (overlap <= 0) continue; + const volume = info.ratePerSecond * BigInt(Math.floor(overlap)); + if (direction === "incoming") { + bucket.incoming += volume; + totalIncoming += volume; + } else { + bucket.outgoing += volume; + totalOutgoing += volume; + } + } + } + + return { totalIncoming, totalOutgoing, net: totalIncoming - totalOutgoing, daily }; +}