Skip to content
Open
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
25 changes: 24 additions & 1 deletion src/components/CursorOverlay.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,42 @@
"use client";

import { useEffect, useState } from "react";
import { truncateAddress } from "@stellar-split/sdk";

interface RemoteCursor {
address: string;
field: string;
color: string;
timestamp: number;
displayName?: string;
}

interface Props {
cursors: RemoteCursor[];
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 (
<span
className="transition-opacity duration-500 ease-out"
style={{ opacity: faded ? 0 : 1 }}
>
{cursor.displayName ?? truncateAddress(cursor.address)}
</span>
);
}

export default function CursorOverlay({ cursors, fieldName }: Props) {
const active = cursors.filter((c) => c.field === fieldName);
if (active.length === 0) return null;
Expand All @@ -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)}
<CursorLabel cursor={cursor} />
</div>
))}
</div>
Expand Down
32 changes: 30 additions & 2 deletions src/components/FlowDiagram.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -18,9 +18,30 @@ function formatAmount(a: bigint) {
}

export default function FlowDiagram({ invoice }: FlowDiagramProps) {
const svgRef = useRef<SVGSVGElement>(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("<?xml")
? serialized
: `<?xml version="1.0" encoding="UTF-8"?>\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;

Expand All @@ -36,7 +57,14 @@ export default function FlowDiagram({ invoice }: FlowDiagramProps) {

return (
<div className="w-full overflow-auto">
<svg viewBox={`0 0 ${width} ${height}`} width="100%" height="240" preserveAspectRatio="xMidYMid meet" className="mx-auto">
<button
type="button"
onClick={handleDownloadSvg}
className="mb-2 px-3 py-1.5 rounded-md text-xs font-medium border border-slate-500 text-slate-200 hover:bg-slate-700"
>
Download SVG
</button>
<svg ref={svgRef} viewBox={`0 0 ${width} ${height}`} width="100%" height="240" preserveAspectRatio="xMidYMid meet" className="mx-auto">
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#94a3b8" />
Expand Down
194 changes: 119 additions & 75 deletions src/hooks/useAddressValidation.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;

/**
Expand All @@ -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<AddressValidationResult>({
isValid: false,
isLoading: false,
errorReason: null,
});
const [liveAddress, setLiveAddress] = useState('');
const debouncedLiveAddress = useDebounce(liveAddress, DEBOUNCE_MS);
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
const abortControllerRef = useRef<AbortController | null>(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;
}
Expand All @@ -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 () => {
Expand All @@ -169,6 +212,7 @@ export function useAddressValidation() {

return {
validateAddress,
onAddressChange,
...validationState,
};
}