diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..3c7ef49 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,20 @@ +{ + "permissions": { + "allow": [ + "Bash", + "Read", + "Edit", + "Write", + "WebFetch", + "Grep", + "Glob", + "LS", + "MultiEdit", + "NotebookRead", + "NotebookEdit", + "TodoRead", + "TodoWrite", + "WebSearch" + ] + } +} diff --git a/src/app/api/presence/[invoiceId]/route.ts b/src/app/api/presence/[invoiceId]/route.ts new file mode 100644 index 0000000..fc2db7c --- /dev/null +++ b/src/app/api/presence/[invoiceId]/route.ts @@ -0,0 +1,129 @@ +import { NextRequest, NextResponse } from 'next/server'; +import type { CoCreatorPresence, PresenceHeartbeat } from '@/types/presence'; + +// In-memory store for presence data +// In production, this would use Redis or a database +const presenceStore = new Map>(); +const lastHeartbeatStore = new Map>(); + +const INACTIVE_TIMEOUT = 10_000; // 10 seconds + +// Helper to clean up inactive users +function cleanupInactiveUsers(invoiceId: string) { + const roster = presenceStore.get(invoiceId); + const heartbeats = lastHeartbeatStore.get(invoiceId); + + if (!roster || !heartbeats) return; + + const now = Date.now(); + const toDelete: string[] = []; + + heartbeats.forEach((lastHeartbeat, userId) => { + if (now - lastHeartbeat > INACTIVE_TIMEOUT) { + toDelete.push(userId); + } + }); + + toDelete.forEach((userId) => { + roster.delete(userId); + heartbeats.delete(userId); + }); +} + +// Helper to verify co-creator permissions +// In production, this would validate against actual invoice permissions +async function hasCoCreatorPermission(invoiceId: string): Promise { + // For now, allow all requests + // In production: verify user has co-creator role for this invoice + return true; +} + +export async function GET( + request: NextRequest, + { params }: { params: { invoiceId: string } } +) { + try { + const invoiceId = params.invoiceId; + + // Cleanup inactive users + cleanupInactiveUsers(invoiceId); + + // Get current roster + const roster = presenceStore.get(invoiceId) || new Map(); + const active = Array.from(roster.values()); + + return NextResponse.json({ + active, + timestamp: Date.now(), + }); + } catch (error) { + console.error('[Presence API] GET error:', error); + return NextResponse.json( + { error: 'Failed to fetch presence' }, + { status: 500 } + ); + } +} + +export async function POST( + request: NextRequest, + { params }: { params: { invoiceId: string } } +) { + try { + const invoiceId = params.invoiceId; + + // Verify co-creator permissions + const hasPermission = await hasCoCreatorPermission(invoiceId); + if (!hasPermission) { + return NextResponse.json( + { error: 'Insufficient permissions' }, + { status: 403 } + ); + } + + const heartbeat: PresenceHeartbeat = await request.json(); + + // Validate heartbeat + if (!heartbeat.userId || !heartbeat.displayName || !heartbeat.focusedSection) { + return NextResponse.json( + { error: 'Invalid heartbeat data' }, + { status: 400 } + ); + } + + // Initialize invoice roster if needed + if (!presenceStore.has(invoiceId)) { + presenceStore.set(invoiceId, new Map()); + lastHeartbeatStore.set(invoiceId, new Map()); + } + + const roster = presenceStore.get(invoiceId)!; + const heartbeats = lastHeartbeatStore.get(invoiceId)!; + + // Update presence data + const presence: CoCreatorPresence = { + ...heartbeat, + lastSeen: Date.now(), + }; + + roster.set(heartbeat.userId, presence); + heartbeats.set(heartbeat.userId, Date.now()); + + // Cleanup after update + cleanupInactiveUsers(invoiceId); + + // Return updated roster + const active = Array.from(roster.values()); + + return NextResponse.json({ + active, + timestamp: Date.now(), + }); + } catch (error) { + console.error('[Presence API] POST error:', error); + return NextResponse.json( + { error: 'Failed to update presence' }, + { status: 500 } + ); + } +} diff --git a/src/app/invoice/[id]/page.tsx b/src/app/invoice/[id]/page.tsx index 7e7e531..e7bc050 100644 --- a/src/app/invoice/[id]/page.tsx +++ b/src/app/invoice/[id]/page.tsx @@ -57,6 +57,9 @@ import { cancelReminder, setReminder } from "@/lib/reminders"; import { recordCooldown } from "@/lib/cooldown"; import { exportTimelineAsImage } from "@/lib/timelineImageExport"; import type { PaymentChannelState } from "@/components/PaymentChannelPanel"; +import { useInvoicePresence } from "@/hooks/useInvoicePresence"; +import PresenceBar from "@/components/PresenceBar"; +import InvoiceSection from "@/components/InvoiceSection"; const RecipientPieChart = dynamic(() => import("@/components/RecipientPieChart"), { ssr: false }); const InvoiceQR = dynamic(() => import("@/components/InvoiceQR"), { ssr: false }); @@ -99,6 +102,18 @@ export default function InvoiceDetailPage({ params }: Props) { error: streamError, } = useInvoiceStream(id); + // Presence indicators for co-creator awareness + const { + presenceRoster, + currentFocusedSection, + updateFocusedSection, + } = useInvoicePresence({ + invoiceId: id, + userId: publicKey || "anonymous", + displayName: publicKey ? truncateAddress(publicKey) : "Anonymous", + enabled: Boolean(publicKey), + }); + const [payAmount, setPayAmount] = useState(""); const [paying, setPaying] = useState(false); const [txHash, setTxHash] = useState(null); @@ -382,6 +397,13 @@ export default function InvoiceDetailPage({ params }: Props) { /> )} + {/* Presence Bar — shows active co-creators */} + {presenceRoster.length > 0 && ( +
+ +
+ )} + {/* Header */}
@@ -448,8 +470,14 @@ export default function InvoiceDetailPage({ params }: Props) {
- {/* Quick Info Cards */} -
+ {/* Details Section */} + + {/* Quick Info Cards */} +

Creator

@@ -498,8 +526,14 @@ export default function InvoiceDetailPage({ params }: Props) {

Status Timeline

- - {/* Payments */} + + + {/* Payments Section */} +

Payments ({invoice.payments.length}) @@ -533,6 +567,7 @@ export default function InvoiceDetailPage({ params }: Props) {

)} + {invoice.status === "Pending" && (
@@ -582,8 +617,14 @@ export default function InvoiceDetailPage({ params }: Props) {
)} - {/* Recipients */} - + {/* Recipients Section */} + + + {/* Split Calculator */} {invoice.status === "Pending" && } diff --git a/src/components/InvoiceSection.tsx b/src/components/InvoiceSection.tsx new file mode 100644 index 0000000..879a1e1 --- /dev/null +++ b/src/components/InvoiceSection.tsx @@ -0,0 +1,68 @@ +'use client'; + +import React, { useEffect, useRef } from 'react'; +import type { InvoiceSectionFocus } from '@/types/presence'; + +interface InvoiceSectionProps { + sectionId: InvoiceSectionFocus; + onFocusChange: (section: InvoiceSectionFocus) => void; + children: React.ReactNode; + className?: string; +} + +/** + * InvoiceSection component wraps invoice sections and tracks when they enter/exit the viewport. + * Uses IntersectionObserver to detect which section the user is currently viewing. + */ +export default function InvoiceSection({ + sectionId, + onFocusChange, + children, + className = '', +}: InvoiceSectionProps) { + const sectionRef = useRef(null); + const observerRef = useRef(null); + const isInViewRef = useRef(false); + + useEffect(() => { + if (!sectionRef.current) return; + + // Create IntersectionObserver to detect when section is in viewport + observerRef.current = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + // Mark section as in view when it's at least 30% visible + const isNowInView = entry.isIntersecting && entry.intersectionRatio >= 0.3; + + if (isNowInView && !isInViewRef.current) { + // Section just came into view + isInViewRef.current = true; + onFocusChange(sectionId); + } else if (!isNowInView && isInViewRef.current) { + // Section just left view + isInViewRef.current = false; + } + }); + }, + { + threshold: [0.3], // Trigger when 30% of section is visible + rootMargin: '0px', + } + ); + + observerRef.current.observe(sectionRef.current); + + return () => { + if (observerRef.current) { + observerRef.current.disconnect(); + observerRef.current = null; + } + }; + }, [sectionId, onFocusChange]); + + return ( +
+ {children} +
+ ); +} diff --git a/src/components/PresenceBar.tsx b/src/components/PresenceBar.tsx new file mode 100644 index 0000000..d0f41b1 --- /dev/null +++ b/src/components/PresenceBar.tsx @@ -0,0 +1,148 @@ +'use client'; + +import React, { useState } from 'react'; +import type { CoCreatorPresence, InvoiceSectionFocus } from '@/types/presence'; + +const sectionColors: Record = { + details: 'ring-blue-500', + payments: 'ring-green-500', + recipients: 'ring-purple-500', +}; + +const sectionLabels: Record = { + details: 'Details', + payments: 'Payments', + recipients: 'Recipients', +}; + +interface AvatarProps { + presence: CoCreatorPresence; + isCurrentUser: boolean; +} + +function Avatar({ presence, isCurrentUser }: AvatarProps) { + const [showTooltip, setShowTooltip] = useState(false); + const initials = presence.displayName + .split(' ') + .slice(0, 2) + .map((n) => n[0]) + .join('') + .toUpperCase(); + + return ( +
+
setShowTooltip(true)} + onMouseLeave={() => setShowTooltip(false)} + > + {presence.avatarUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {presence.displayName} + ) : ( + initials + )} +
+ + {/* Tooltip */} + {showTooltip && ( +
+
{presence.displayName}
+
+ viewing {sectionLabels[presence.focusedSection]} +
+
+
+
+
+ )} +
+ ); +} + +interface PresenceBarProps { + presenceRoster: CoCreatorPresence[]; + currentUserId: string; +} + +export default function PresenceBar({ presenceRoster, currentUserId }: PresenceBarProps) { + const [showOverflowTooltip, setShowOverflowTooltip] = useState(false); + + if (presenceRoster.length === 0) { + return null; + } + + const visibleCount = 5; // Show up to 5 avatars before "+N" + const displayRoster = presenceRoster.slice(0, visibleCount); + const overflowCount = Math.max(0, presenceRoster.length - visibleCount); + + return ( +
+ Active now: + +
+ {displayRoster.map((presence) => ( + + ))} + + {/* Overflow indicator */} + {overflowCount > 0 && ( +
+
setShowOverflowTooltip(true)} + onMouseLeave={() => setShowOverflowTooltip(false)} + > + +{overflowCount} +
+ + {/* Overflow tooltip */} + {showOverflowTooltip && ( +
+
Also present:
+ {presenceRoster.slice(visibleCount).map((presence) => ( +
+ {presence.displayName} • {sectionLabels[presence.focusedSection]} +
+ ))} +
+
+
+
+ )} +
+ )} +
+
+ ); +} diff --git a/src/hooks/useInvoicePresence.ts b/src/hooks/useInvoicePresence.ts new file mode 100644 index 0000000..e6158bd --- /dev/null +++ b/src/hooks/useInvoicePresence.ts @@ -0,0 +1,151 @@ +'use client'; + +import { useEffect, useRef, useState, useCallback } from 'react'; +import type { CoCreatorPresence, InvoiceSectionFocus, PresenceHeartbeat } from '@/types/presence'; + +interface UseInvoicePresenceOptions { + invoiceId: string; + userId: string; + displayName: string; + enabled?: boolean; +} + +interface UseInvoicePresenceResult { + presenceRoster: CoCreatorPresence[]; + currentFocusedSection: InvoiceSectionFocus; + updateFocusedSection: (section: InvoiceSectionFocus) => void; + isConnected: boolean; + error: string | null; +} + +const HEARTBEAT_INTERVAL = 5_000; // 5 seconds +const INACTIVE_TIMEOUT = 10_000; // 10 seconds + +export function useInvoicePresence( + options: UseInvoicePresenceOptions +): UseInvoicePresenceResult { + const { invoiceId, userId, displayName, enabled = true } = options; + + const [presenceRoster, setPresenceRoster] = useState([]); + const [currentFocusedSection, setCurrentFocusedSection] = useState('details'); + const [isConnected, setIsConnected] = useState(false); + const [error, setError] = useState(null); + + const wsRef = useRef(null); + const heartbeatIntervalRef = useRef | null>(null); + const mountedRef = useRef(true); + const reconnectDelayRef = useRef(1_000); + const lastHeartbeatRef = useRef(0); + + const sendHeartbeat = useCallback(async () => { + if (!enabled || !mountedRef.current) return; + + try { + const heartbeat: PresenceHeartbeat = { + userId, + displayName, + focusedSection: currentFocusedSection, + timestamp: Date.now(), + }; + + // Use Server-Sent Events (SSE) for heartbeat via fetch POST + const response = await fetch(`/api/presence/${invoiceId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(heartbeat), + }); + + if (!response.ok) { + throw new Error(`Heartbeat failed: ${response.status}`); + } + + lastHeartbeatRef.current = Date.now(); + setError(null); + setIsConnected(true); + reconnectDelayRef.current = 1_000; + } catch (err) { + if (!mountedRef.current) return; + const errorMsg = err instanceof Error ? err.message : String(err); + setError(errorMsg); + setIsConnected(false); + } + }, [invoiceId, userId, displayName, currentFocusedSection, enabled]); + + const fetchPresenceRoster = useCallback(async () => { + if (!enabled || !mountedRef.current) return; + + try { + const response = await fetch(`/api/presence/${invoiceId}`); + + if (!response.ok) { + throw new Error(`Fetch roster failed: ${response.status}`); + } + + const data = await response.json(); + if (mountedRef.current && data.active) { + setPresenceRoster(data.active); + setError(null); + } + } catch (err) { + if (!mountedRef.current) return; + // Non-fatal error, don't disconnect + } + }, [invoiceId, enabled]); + + // Main effect: connect and start heartbeat + useEffect(() => { + if (!enabled) return; + + mountedRef.current = true; + + // Send initial heartbeat + sendHeartbeat(); + + // Start heartbeat interval + heartbeatIntervalRef.current = setInterval(() => { + sendHeartbeat(); + }, HEARTBEAT_INTERVAL); + + // Fetch roster periodically + const rosterIntervalRef = setInterval(() => { + fetchPresenceRoster(); + }, HEARTBEAT_INTERVAL); + + return () => { + mountedRef.current = false; + if (heartbeatIntervalRef.current) clearInterval(heartbeatIntervalRef.current); + clearInterval(rosterIntervalRef); + + // Send disconnect heartbeat + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + }; + }, [enabled, sendHeartbeat, fetchPresenceRoster]); + + // Reconnection effect + useEffect(() => { + if (isConnected || !enabled) return; + + const retryTimer = setTimeout(() => { + if (!mountedRef.current) return; + sendHeartbeat(); + reconnectDelayRef.current = Math.min(reconnectDelayRef.current * 2, 30_000); + }, reconnectDelayRef.current); + + return () => clearTimeout(retryTimer); + }, [isConnected, enabled, sendHeartbeat]); + + const updateFocusedSection = useCallback((section: InvoiceSectionFocus) => { + setCurrentFocusedSection(section); + }, []); + + return { + presenceRoster, + currentFocusedSection, + updateFocusedSection, + isConnected, + error, + }; +} diff --git a/src/types/presence.ts b/src/types/presence.ts new file mode 100644 index 0000000..d29a616 --- /dev/null +++ b/src/types/presence.ts @@ -0,0 +1,18 @@ +export type InvoiceSectionFocus = 'details' | 'payments' | 'recipients'; + +export interface PresenceHeartbeat { + userId: string; + displayName: string; + focusedSection: InvoiceSectionFocus; + timestamp: number; +} + +export interface CoCreatorPresence extends PresenceHeartbeat { + avatarUrl?: string; + lastSeen: number; +} + +export interface PresenceRosterUpdate { + active: CoCreatorPresence[]; + timestamp: number; +}