From 6963bd8f21327866b719394f6031e8bebec23dbf Mon Sep 17 00:00:00 2001 From: ink-mami Date: Mon, 31 Aug 2026 11:14:08 +0000 Subject: [PATCH] feat(hooks,components): live address validation, cursor labels, SVG export - useAddressValidation: add live mode (debounced 300ms) and errorReason enum (too_short | invalid_checksum | wrong_prefix | null) - CursorOverlay: show collaborator display name, fading out after 3s of cursor inactivity - FlowDiagram: add "Download SVG" button to export the diagram Closes: #680 Closes: #671 Closes: #670 Closes: #681 --- src/components/CursorOverlay.tsx | 25 +++- src/components/FlowDiagram.tsx | 32 ++++- src/hooks/useAddressValidation.ts | 194 ++++++++++++++++++------------ 3 files changed, 173 insertions(+), 78 deletions(-) diff --git a/src/components/CursorOverlay.tsx b/src/components/CursorOverlay.tsx index a99bac98..ec82bf0c 100644 --- a/src/components/CursorOverlay.tsx +++ b/src/components/CursorOverlay.tsx @@ -1,5 +1,6 @@ "use client"; +import { useEffect, useState } from "react"; import { truncateAddress } from "@stellar-split/sdk"; interface RemoteCursor { @@ -7,6 +8,7 @@ interface RemoteCursor { field: string; color: string; timestamp: number; + displayName?: string; } interface Props { @@ -14,6 +16,27 @@ interface Props { fieldName: string; } +const LABEL_FADE_MS = 3000; + +function CursorLabel({ cursor }: { cursor: RemoteCursor }) { + const [faded, setFaded] = useState(false); + + useEffect(() => { + setFaded(false); + const timer = setTimeout(() => setFaded(true), LABEL_FADE_MS); + return () => clearTimeout(timer); + }, [cursor.timestamp]); + + return ( + + {cursor.displayName ?? truncateAddress(cursor.address)} + + ); +} + export default function CursorOverlay({ cursors, fieldName }: Props) { const active = cursors.filter((c) => c.field === fieldName); if (active.length === 0) return null; @@ -35,7 +58,7 @@ export default function CursorOverlay({ cursors, fieldName }: Props) { className="w-1.5 h-1.5 rounded-full inline-block" style={{ backgroundColor: cursor.color }} /> - {truncateAddress(cursor.address)} + ))} diff --git a/src/components/FlowDiagram.tsx b/src/components/FlowDiagram.tsx index 1a936d41..7c334988 100644 --- a/src/components/FlowDiagram.tsx +++ b/src/components/FlowDiagram.tsx @@ -1,6 +1,6 @@ "use client" -import React from "react"; +import React, { useRef } from "react"; import type { Payment, Invoice } from "@stellar-split/sdk"; type FlowDiagramProps = { @@ -18,9 +18,30 @@ function formatAmount(a: bigint) { } export default function FlowDiagram({ invoice }: FlowDiagramProps) { + const svgRef = useRef(null); const payers = invoice.payments.map((p: Payment) => ({ address: p.payer, amount: p.amount })); const recipients = invoice.recipients.map((r) => ({ address: r.address, amount: r.amount })); + const handleDownloadSvg = () => { + const svgEl = svgRef.current; + if (!svgEl) return; + + const serialized = new XMLSerializer().serializeToString(svgEl); + const svgString = serialized.startsWith("\n${serialized}`; + + const blob = new Blob([svgString], { type: "image/svg+xml" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "flow-diagram.svg"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }; + const width = 800; const height = 240; @@ -36,7 +57,14 @@ export default function FlowDiagram({ invoice }: FlowDiagramProps) { return (
- + + diff --git a/src/hooks/useAddressValidation.ts b/src/hooks/useAddressValidation.ts index 9296d1d8..0b52ec1a 100644 --- a/src/hooks/useAddressValidation.ts +++ b/src/hooks/useAddressValidation.ts @@ -1,6 +1,9 @@ import { useCallback, useState, useEffect, useRef } from 'react'; import { StrKey } from '@stellar/stellar-sdk'; import { validateFederationAddress, resolveFederationAddress } from '@/lib/stellar'; +import { useDebounce } from '@/hooks/useDebounce'; + +export type AddressValidationErrorReason = 'too_short' | 'invalid_checksum' | 'wrong_prefix' | null; export interface AddressValidationResult { isValid: boolean; @@ -9,12 +12,18 @@ export interface AddressValidationResult { federationAddress?: string; resolvedAddress?: string; error?: string; + errorReason: AddressValidationErrorReason; isLoading: boolean; memoRequired?: boolean; memoId?: string; memoType?: string; } +export interface UseAddressValidationOptions { + /** When true, validation runs automatically (debounced 300ms) via `onAddressChange`. */ + live?: boolean; +} + const DEBOUNCE_MS = 300; /** @@ -27,30 +36,30 @@ const DEBOUNCE_MS = 300; * * Includes 300ms debouncing and cleanup on unmount */ -export function useAddressValidation() { +export function useAddressValidation(options: UseAddressValidationOptions = {}) { + const { live = false } = options; const [validationState, setValidationState] = useState({ isValid: false, isLoading: false, + errorReason: null, }); + const [liveAddress, setLiveAddress] = useState(''); + const debouncedLiveAddress = useDebounce(liveAddress, DEBOUNCE_MS); const debounceTimerRef = useRef(null); const abortControllerRef = useRef(null); - const validateAddress = useCallback(async (address: string) => { + const runValidation = useCallback(async (address: string) => { // Cancel any pending validation requests if (abortControllerRef.current) { abortControllerRef.current.abort(); } abortControllerRef.current = new AbortController(); - // Clear previous timer - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current); - } - if (!address.trim()) { setValidationState({ isValid: false, isLoading: false, + errorReason: null, }); return; } @@ -61,100 +70,134 @@ export function useAddressValidation() { isLoading: true, })); - // Create new debounce timer - const timer = setTimeout(async () => { - try { - let resolvedAddr = address; - let isFederationAddr = false; - let federationMemoId: string | undefined; - let federationMemoType: string | undefined; - - // Check if it's a federation address format (contains *) - if (address.includes('*')) { - isFederationAddr = true; - - // Validate federation address format - const federationParts = address.split('*'); - if (federationParts.length !== 2 || !federationParts[0] || !federationParts[1]) { + try { + let resolvedAddr = address; + let isFederationAddr = false; + let federationMemoId: string | undefined; + let federationMemoType: string | undefined; + + // Check if it's a federation address format (contains *) + if (address.includes('*')) { + isFederationAddr = true; + + // Validate federation address format + const federationParts = address.split('*'); + if (federationParts.length !== 2 || !federationParts[0] || !federationParts[1]) { + setValidationState({ + isValid: false, + isLoading: false, + error: 'Invalid federation address format (use name*domain.com)', + errorReason: null, + }); + return; + } + + try { + const resolved = await resolveFederationAddress(address); + if (!resolved) { setValidationState({ isValid: false, isLoading: false, - error: 'Invalid federation address format (use name*domain.com)', + error: `Federation address could not be resolved. Please verify the address is correct.`, + errorReason: null, }); return; } + resolvedAddr = resolved; + // Try to extract memo info from federation resolution try { - const resolved = await resolveFederationAddress(address); - if (!resolved) { - setValidationState({ - isValid: false, - isLoading: false, - error: `Federation address could not be resolved. Please verify the address is correct.`, - }); - return; + const { Federation } = await import('@stellar/stellar-sdk'); + const federationResult = await Federation.Server.resolve(address); + if (federationResult.memo) { + federationMemoId = federationResult.memo; } - resolvedAddr = resolved; - - // Try to extract memo info from federation resolution - try { - const { Federation } = await import('@stellar/stellar-sdk'); - const federationResult = await Federation.Server.resolve(address); - if (federationResult.memo) { - federationMemoId = federationResult.memo; - } - if (federationResult.memo_type) { - federationMemoType = federationResult.memo_type; - } - } catch { - // Memo extraction failed, continue with address validation + if (federationResult.memo_type) { + federationMemoType = federationResult.memo_type; } - } catch (error) { - setValidationState({ - isValid: false, - isLoading: false, - error: 'Failed to resolve federation address', - }); - return; + } catch { + // Memo extraction failed, continue with address validation } - } - - // Validate G-address format using StrKey - if (!StrKey.isValidEd25519PublicKey(resolvedAddr)) { + } catch (error) { setValidationState({ isValid: false, isLoading: false, - error: 'Invalid Stellar address format (must start with G)', + error: 'Failed to resolve federation address', + errorReason: null, }); return; } + } - // Check if account exists and is funded - const { isFunded, requiresMemo } = await validateFederationAddress(resolvedAddr); - - setValidationState({ - isValid: true, - isFunded, - requiresMemo: requiresMemo || !!federationMemoId, - federationAddress: isFederationAddr ? address : undefined, - resolvedAddress: resolvedAddr, - isLoading: false, - memoRequired: !!federationMemoId, - memoId: federationMemoId, - memoType: federationMemoType, - }); - } catch (error) { + // Validate G-address format using StrKey + if (!StrKey.isValidEd25519PublicKey(resolvedAddr)) { + let errorReason: AddressValidationErrorReason = 'invalid_checksum'; + if (!isFederationAddr) { + if (resolvedAddr.length < 56) { + errorReason = 'too_short'; + } else if (!resolvedAddr.startsWith('G')) { + errorReason = 'wrong_prefix'; + } + } setValidationState({ isValid: false, isLoading: false, - error: error instanceof Error ? error.message : 'Error validating address', + error: 'Invalid Stellar address format (must start with G)', + errorReason, }); + return; } - }, DEBOUNCE_MS); - debounceTimerRef.current = timer; + // Check if account exists and is funded + const { isFunded, requiresMemo } = await validateFederationAddress(resolvedAddr); + + setValidationState({ + isValid: true, + isFunded, + requiresMemo: requiresMemo || !!federationMemoId, + federationAddress: isFederationAddr ? address : undefined, + resolvedAddress: resolvedAddr, + isLoading: false, + errorReason: null, + memoRequired: !!federationMemoId, + memoId: federationMemoId, + memoType: federationMemoType, + }); + } catch (error) { + setValidationState({ + isValid: false, + isLoading: false, + error: error instanceof Error ? error.message : 'Error validating address', + errorReason: null, + }); + } + }, []); + + // Explicit, debounced validation entrypoint (used when `live` is false) + const validateAddress = useCallback( + (address: string) => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + setValidationState((prev) => ({ ...prev, isLoading: true })); + debounceTimerRef.current = setTimeout(() => { + void runValidation(address); + }, DEBOUNCE_MS); + }, + [runValidation] + ); + + // Live mode: track keystrokes and validate the debounced value automatically + const onAddressChange = useCallback((address: string) => { + setLiveAddress(address); }, []); + useEffect(() => { + if (live) { + void runValidation(debouncedLiveAddress); + } + }, [live, debouncedLiveAddress, runValidation]); + // Cleanup on unmount useEffect(() => { return () => { @@ -169,6 +212,7 @@ export function useAddressValidation() { return { validateAddress, + onAddressChange, ...validationState, }; }