Skip to content

Commit 0637152

Browse files
feat: Real-Time Invoice Collaboration with Live Cursors (#451)
Implements real-time collaborative editing layer for invoice pages using Server-Sent Events (SSE) for cross-user cursor and presence sharing. Adds: - app/api/collab/[invoiceId]/route.ts - SSE broker endpoint for broadcasting cursor and presence events between connected clients - hooks/useInvoiceCollaboration.ts - React hook managing SSE connection, exponential backoff reconnection, cursor/presence state, and permission gating - components/CursorOverlay.tsx - Renders colored per-user cursor indicators above focused form fields - components/PresencePill.tsx - Shows colored avatars for currently editing collaborators - components/ReconnectionBanner.tsx - Dismissible banner indicating connection loss with automatic reconnect Modifies: - app/invoice/[id]/page.tsx - Integrates collaboration hook, cursor overlays on payment fields, presence indicators, and permission-gated socket opening - app/invoice/new/page.tsx - Integrates collaboration hook and cursor overlays on key form fields (token, deadline, amount) - components/CoCreatorPanel.tsx - Exports canUserEditInvoice() utility for permission checks across the app Key behaviors: - Co-creators with edit/admin permission see each other's cursor position within 200ms of field focus - Users without write permission cannot open a collaboration socket - On WebSocket disconnect, a reconnection banner appears with exponential backoff (1s → 30s) - Presence indicators are removed within 5s of tab close - Cursor positions stale after 5s TTL closes #398 Co-authored-by: isaac4real-art <isaac4real-art@users.noreply.github.com> Co-authored-by: Emmanuel Chukwunyere <emmanuelanalaba@gmail.com>
1 parent 0dbc453 commit 0637152

8 files changed

Lines changed: 589 additions & 1 deletion

File tree

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { NextRequest } from "next/server";
2+
3+
interface CursorEvent {
4+
type: "cursor";
5+
address: string;
6+
field: string;
7+
timestamp: number;
8+
}
9+
10+
interface PresenceEvent {
11+
type: "presence";
12+
address: string;
13+
online: boolean;
14+
timestamp: number;
15+
}
16+
17+
type CollabEvent = CursorEvent | PresenceEvent;
18+
19+
interface SseClient {
20+
id: string;
21+
controller: ReadableStreamDefaultController;
22+
encoder: TextEncoder;
23+
address: string | null;
24+
}
25+
26+
const clients = new Map<string, SseClient[]>();
27+
28+
function broadcast(invoiceId: string, event: CollabEvent) {
29+
const invoiceClients = clients.get(invoiceId);
30+
if (!invoiceClients) return;
31+
const data = `data: ${JSON.stringify(event)}\n\n`;
32+
const encoded = new TextEncoder().encode(data);
33+
for (const client of invoiceClients) {
34+
try {
35+
client.controller.enqueue(encoded);
36+
} catch {
37+
// client disconnected
38+
}
39+
}
40+
}
41+
42+
function prune(invoiceId: string) {
43+
const invoiceClients = clients.get(invoiceId);
44+
if (!invoiceClients) return;
45+
const now = Date.now();
46+
const active = invoiceClients.filter(
47+
(c) => now - c.id.split("-").map(Number)[1] < 60_000
48+
);
49+
if (active.length === 0) {
50+
clients.delete(invoiceId);
51+
} else {
52+
clients.set(invoiceId, active);
53+
}
54+
}
55+
56+
export async function GET(
57+
_request: NextRequest,
58+
{ params }: { params: { invoiceId: string } }
59+
) {
60+
const { invoiceId } = params;
61+
62+
const stream = new ReadableStream({
63+
start(controller) {
64+
const client: SseClient = {
65+
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
66+
controller,
67+
encoder: new TextEncoder(),
68+
address: null,
69+
};
70+
71+
if (!clients.has(invoiceId)) {
72+
clients.set(invoiceId, []);
73+
}
74+
clients.get(invoiceId)!.push(client);
75+
76+
controller.enqueue(
77+
new TextEncoder().encode(`data: ${JSON.stringify({ type: "connected", clientId: client.id })}\n\n`)
78+
);
79+
80+
prune(invoiceId);
81+
},
82+
cancel() {
83+
const invoiceClients = clients.get(invoiceId);
84+
if (invoiceClients) {
85+
const remaining = invoiceClients.filter(
86+
(c) => !c.controller.locked
87+
);
88+
if (remaining.length === 0) {
89+
clients.delete(invoiceId);
90+
broadcast(invoiceId, {
91+
type: "presence",
92+
address: "all",
93+
online: false,
94+
timestamp: Date.now(),
95+
});
96+
} else {
97+
clients.set(invoiceId, remaining);
98+
}
99+
}
100+
},
101+
});
102+
103+
return new Response(stream, {
104+
headers: {
105+
"Content-Type": "text/event-stream",
106+
"Cache-Control": "no-cache, no-transform",
107+
Connection: "keep-alive",
108+
},
109+
});
110+
}
111+
112+
export async function POST(
113+
request: NextRequest,
114+
{ params }: { params: { invoiceId: string } }
115+
) {
116+
const { invoiceId } = params;
117+
const body = (await request.json()) as { address?: string; field?: string; online?: boolean };
118+
119+
if (!body.address) {
120+
return Response.json({ error: "address is required" }, { status: 400 });
121+
}
122+
123+
if (body.field !== undefined) {
124+
broadcast(invoiceId, {
125+
type: "cursor",
126+
address: body.address,
127+
field: body.field,
128+
timestamp: Date.now(),
129+
});
130+
}
131+
132+
if (body.online !== undefined) {
133+
broadcast(invoiceId, {
134+
type: "presence",
135+
address: body.address,
136+
online: body.online,
137+
timestamp: Date.now(),
138+
});
139+
}
140+
141+
return Response.json({ ok: true });
142+
}

src/app/invoice/[id]/page.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ export default function InvoiceDetailPage({ params }: Props) {
130130
const {
131131
invoice: streamInvoice,
132132
latestEvent,
133-
isConnected,
133+
isConnected: streamConnected,
134134
error: streamError,
135135
} = useInvoiceStream(id);
136136

@@ -460,6 +460,12 @@ export default function InvoiceDetailPage({ params }: Props) {
460460
</div>
461461
)}
462462

463+
{/* Reconnecting indicator */}
464+
<ReconnectionBanner
465+
show={showReconnecting}
466+
isConnected={streamConnected && collabConnected}
467+
/>
468+
463469
{/* Release Banner */}
464470
{showReleaseBanner && (
465471
<ReleaseBanner
@@ -709,10 +715,17 @@ export default function InvoiceDetailPage({ params }: Props) {
709715
placeholder="Amount in USDC"
710716
value={payAmount}
711717
onChange={(e) => setPayAmount(e.target.value)}
718+
onFocus={() => setFocusedField("pay-amount-freighter")}
719+
onBlur={() => {
720+
if (focusedField === "pay-amount-freighter") {
721+
emitFieldBlur();
722+
}
723+
}}
712724
required
713725
aria-label="Amount in USDC"
714726
className="bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
715727
/>
728+
<CursorOverlay cursors={remoteCursors} fieldName="pay-amount-freighter" />
716729
{error && <p className="text-red-400 text-sm">{error}</p>}
717730
{txHash && (
718731
<p className="text-green-400 text-sm">
@@ -847,9 +860,16 @@ export default function InvoiceDetailPage({ params }: Props) {
847860
placeholder="0.00"
848861
value={payAmount}
849862
onChange={(e) => setPayAmount(e.target.value)}
863+
onFocus={() => setFocusedField("pay-amount")}
864+
onBlur={() => {
865+
if (focusedField === "pay-amount") {
866+
emitFieldBlur();
867+
}
868+
}}
850869
required
851870
className="w-full min-h-11 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
852871
/>
872+
<CursorOverlay cursors={remoteCursors} fieldName="pay-amount" />
853873
</div>
854874
{paymentError && (
855875
<p role="alert" className="text-red-400 text-sm">{paymentError}</p>

src/app/invoice/new/page.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ import {
3535
} from "@/hooks/useSplitCalculator";
3636
import InstallmentPlanBuilder from "@/components/invoice/InstallmentPlanBuilder";
3737

38+
import { useInvoiceCollaboration } from "@/hooks/useInvoiceCollaboration";
39+
import CursorOverlay from "@/components/CursorOverlay";
40+
import PresencePill from "@/components/PresencePill";
41+
import ReconnectionBanner from "@/components/ReconnectionBanner";
42+
3843
const RecipientForm = dynamic(() => import("@/components/RecipientForm"), { ssr: false });
3944
const TemplateManager = dynamic(() => import("@/components/TemplateManager"), { ssr: false });
4045
const TxImportPanel = dynamic(() => import("@/components/invoice/TxImportPanel"), { ssr: false });
@@ -291,6 +296,7 @@ function NewInvoiceForm() {
291296
}
292297
}, [searchParams, addToast]);
293298

299+
const [publicKey, setPublicKey] = useState<string | null>(null);
294300
const [error, setError] = useState<string | null>(null);
295301
const [txModal, setTxModal] = useState<{ txHash: string; invoiceId: string } | null>(null);
296302
const [equalSplit, setEqualSplit] = useState(false);
@@ -299,6 +305,10 @@ function NewInvoiceForm() {
299305
const [autofilled, setAutofilled] = useState(false);
300306
const [stepErrors, setStepErrors] = useState<Record<number, string | null>>({});
301307

308+
useEffect(() => {
309+
getFreighterPublicKey().then(setPublicKey).catch(() => null);
310+
}, []);
311+
302312
useEffect(() => {
303313
if (fromId || sessionStorage.getItem("invoiceTemplate") || searchParams.get("address")) return;
304314

@@ -582,10 +592,15 @@ function NewInvoiceForm() {
582592
type="text"
583593
value={token}
584594
onChange={(e) => setToken(e.target.value)}
595+
onFocus={() => setFocusedField("token-address")}
596+
onBlur={() => {
597+
if (focusedField === "token-address") emitFieldBlur();
598+
}}
585599
required
586600
placeholder="C..."
587601
className="w-full min-h-11 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-sm text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
588602
/>
603+
<CursorOverlay cursors={remoteCursors} fieldName="token-address" />
589604
</ChangedField>
590605
</div>
591606

@@ -629,9 +644,14 @@ function NewInvoiceForm() {
629644
max={365}
630645
value={deadlineDays}
631646
onChange={(e) => setDeadlineDays(Number(e.target.value))}
647+
onFocus={() => setFocusedField("deadline-days")}
648+
onBlur={() => {
649+
if (focusedField === "deadline-days") emitFieldBlur();
650+
}}
632651
required
633652
className="w-full min-h-11 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-sm text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
634653
/>
654+
<CursorOverlay cursors={remoteCursors} fieldName="deadline-days" />
635655
<DeadlineSuggester
636656
totalAmount={
637657
equalSplit
@@ -731,9 +751,14 @@ function NewInvoiceForm() {
731751
min="0.0000001"
732752
value={totalAmount}
733753
onChange={(e) => setTotalAmount(e.target.value)}
754+
onFocus={() => setFocusedField("total-amount")}
755+
onBlur={() => {
756+
if (focusedField === "total-amount") emitFieldBlur();
757+
}}
734758
required
735759
className="w-full min-h-11 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-sm text-gray-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
736760
/>
761+
<CursorOverlay cursors={remoteCursors} fieldName="total-amount" />
737762
{perRecipientAmount && (
738763
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
739764
{perRecipientAmount} {t("invoiceNew.perRecipient")}
@@ -887,6 +912,14 @@ function NewInvoiceForm() {
887912

888913
return (
889914
<main className="max-w-xl mx-auto w-full px-4 sm:px-6 py-16 overflow-x-hidden">
915+
{/* Collaboration presence */}
916+
{publicKey && <PresencePill presences={remotePresence} currentAddress={publicKey} />}
917+
918+
<ReconnectionBanner
919+
show={!collabConnected && !!publicKey}
920+
isConnected={collabConnected}
921+
/>
922+
890923
<div
891924
aria-live="polite"
892925
className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 pointer-events-none"

src/components/CoCreatorPanel.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,16 @@ export function loadPermissions(invoiceId: string): CoCreatorEntry[] {
2828
}
2929
}
3030

31+
export function canUserEditInvoice(
32+
invoice: { creator: string; id: string; coCreators?: string[] },
33+
address: string,
34+
): boolean {
35+
if (address === invoice.creator) return true;
36+
const permissions = loadPermissions(invoice.id);
37+
const entry = permissions.find((e) => e.address === address);
38+
return entry?.permissionLevel === "edit" || entry?.permissionLevel === "admin";
39+
}
40+
3141
function savePermissions(invoiceId: string, entries: CoCreatorEntry[]): void {
3242
if (typeof window === "undefined") return;
3343
localStorage.setItem(storageKey(invoiceId), JSON.stringify(entries));

src/components/CursorOverlay.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"use client";
2+
3+
import { truncateAddress } from "@stellar-split/sdk";
4+
5+
interface RemoteCursor {
6+
address: string;
7+
field: string;
8+
color: string;
9+
timestamp: number;
10+
}
11+
12+
interface Props {
13+
cursors: RemoteCursor[];
14+
fieldName: string;
15+
}
16+
17+
export default function CursorOverlay({ cursors, fieldName }: Props) {
18+
const active = cursors.filter((c) => c.field === fieldName);
19+
if (active.length === 0) return null;
20+
21+
return (
22+
<div className="flex items-center gap-1.5 mt-1" aria-live="polite">
23+
{active.map((cursor) => (
24+
<div
25+
key={cursor.address}
26+
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium"
27+
style={{
28+
backgroundColor: `${cursor.color}20`,
29+
color: cursor.color,
30+
border: `1px solid ${cursor.color}40`,
31+
}}
32+
title={cursor.address}
33+
>
34+
<span
35+
className="w-1.5 h-1.5 rounded-full inline-block"
36+
style={{ backgroundColor: cursor.color }}
37+
/>
38+
{truncateAddress(cursor.address)}
39+
</div>
40+
))}
41+
</div>
42+
);
43+
}

src/components/PresencePill.tsx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"use client";
2+
3+
import { truncateAddress } from "@stellar-split/sdk";
4+
5+
interface RemotePresence {
6+
address: string;
7+
online: boolean;
8+
color: string;
9+
}
10+
11+
interface Props {
12+
presences: RemotePresence[];
13+
currentAddress: string | null;
14+
}
15+
16+
export default function PresencePill({ presences, currentAddress }: Props) {
17+
const online = presences.filter((p) => p.online && p.address !== currentAddress);
18+
if (online.length === 0) return null;
19+
20+
return (
21+
<div className="flex items-center gap-2 mb-4">
22+
<span className="text-xs text-gray-400">Editing:</span>
23+
<div className="flex items-center gap-1">
24+
{online.slice(0, 5).map((p) => (
25+
<div
26+
key={p.address}
27+
className="w-7 h-7 rounded-full flex items-center justify-center text-[10px] font-bold text-white ring-2"
28+
style={{
29+
backgroundColor: p.color,
30+
ringColor: p.color,
31+
}}
32+
title={p.address}
33+
>
34+
{truncateAddress(p.address).slice(0, 2).toUpperCase()}
35+
</div>
36+
))}
37+
{online.length > 5 && (
38+
<div className="w-7 h-7 rounded-full flex items-center justify-center text-[10px] font-bold bg-gray-700 text-gray-300">
39+
+{online.length - 5}
40+
</div>
41+
)}
42+
</div>
43+
</div>
44+
);
45+
}

0 commit comments

Comments
 (0)