diff --git a/web/src/App.tsx b/web/src/App.tsx index 48066ebd..996d6e9d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,11 +1,44 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; +import { Center, Loader } from "@mantine/core"; import { Login } from "./Login"; import { ChatShell } from "./ChatShell"; +import { api } from "./api"; import type { User } from "./types"; +// shortEndpoint hace legible el endpoint id del operador para mostrarlo como +// handle por defecto cuando no se escribió uno en el login. +function shortEndpoint(ep: string) { + return ep.slice(0, 8); +} + export function App() { const [user, setUser] = useState(null); + const [checking, setChecking] = useState(true); + // Al montar, comprueba si ya hay una sesión viva en el gateway (cookie). Si la + // hay, entra directo; si no (401), muestra el login. + useEffect(() => { + api + .me() + .then((me) => + setUser({ id: me.endpoint, handle: shortEndpoint(me.endpoint) }), + ) + .catch(() => {}) + .finally(() => setChecking(false)); + }, []); + + const logout = () => { + void api.logout().catch(() => {}); + setUser(null); + }; + + if (checking) { + return ( +
+ +
+ ); + } if (!user) return ; - return setUser(null)} />; + return ; } diff --git a/web/src/ChatPanel.tsx b/web/src/ChatPanel.tsx index 99ed24a6..8e669b5d 100644 --- a/web/src/ChatPanel.tsx +++ b/web/src/ChatPanel.tsx @@ -19,7 +19,8 @@ import { IconDotsVertical, IconPaperclip, } from "@tabler/icons-react"; -import type { Message, Room, User } from "./types"; +import { api, streamRoom } from "./api"; +import type { Message, Room } from "./types"; function initials(s: string) { return s.replace(/[^a-z0-9]/gi, "").slice(0, 2).toUpperCase() || "?"; @@ -54,22 +55,30 @@ function MessageRow({ msg }: { msg: Message }) { ); } -export function ChatPanel({ - room, - user, -}: { - room: Room | undefined; - user: User; -}) { +export function ChatPanel({ room }: { room: Room | undefined }) { const [draft, setDraft] = useState(""); - const [extra, setExtra] = useState>({}); + const [messages, setMessages] = useState([]); + const [sendError, setSendError] = useState(null); const viewport = useRef(null); - const msgs = room ? [...room.messages, ...(extra[room.id] ?? [])] : []; + // Abre el stream SSE de la room activa. El gateway entrega historia (rooms + // persistidas) y luego mensajes en vivo, ya descifrados. Dedup por id porque + // un re-render no debe duplicar y el eco del propio envío llega por aquí. + useEffect(() => { + setMessages([]); + setSendError(null); + if (!room) return; + const close = streamRoom(room.id, (m) => { + setMessages((prev) => + prev.some((p) => p.id === m.id) ? prev : [...prev, m], + ); + }); + return close; + }, [room?.id]); useEffect(() => { viewport.current?.scrollTo({ top: viewport.current.scrollHeight }); - }, [room?.id, msgs.length]); + }, [room?.id, messages.length]); if (!room) { return ( @@ -79,18 +88,19 @@ export function ChatPanel({ ); } - const send = () => { + const send = async () => { const body = draft.trim(); if (!body) return; - const msg: Message = { - id: `local-${Date.now()}`, - sender: user.handle, - body, - ts: Date.now(), - mine: true, - }; - setExtra((e) => ({ ...e, [room.id]: [...(e[room.id] ?? []), msg] })); setDraft(""); + setSendError(null); + try { + // No optimista: el mensaje propio vuelve por SSE con su id real (mine:true), + // evitando duplicados. + await api.send(room.id, body); + } catch (e) { + setDraft(body); // restaura el borrador si el envío falló + setSendError(e instanceof Error ? e.message : "No se pudo enviar"); + } }; return ( @@ -126,13 +136,18 @@ export function ChatPanel({ - {msgs.map((m) => ( + {messages.map((m) => ( ))} + {sendError && ( + + {sendError} + + )} @@ -143,14 +158,14 @@ export function ChatPanel({ placeholder={`Mensaje a ${room.name}`} value={draft} onChange={(e) => setDraft(e.currentTarget.value)} - onKeyDown={(e) => e.key === "Enter" && send()} + onKeyDown={(e) => e.key === "Enter" && void send()} /> void send()} disabled={!draft.trim()} > diff --git a/web/src/ChatShell.tsx b/web/src/ChatShell.tsx index fc6ecd4a..550c6527 100644 --- a/web/src/ChatShell.tsx +++ b/web/src/ChatShell.tsx @@ -1,9 +1,9 @@ -import { useState } from "react"; -import { Flex, Box } from "@mantine/core"; +import { useCallback, useEffect, useState } from "react"; +import { Flex, Box, Center, Loader, Stack, Text, Button } from "@mantine/core"; import { Sidebar } from "./Sidebar"; import { ChatPanel } from "./ChatPanel"; -import { MOCK_ROOMS } from "./mock"; -import type { User } from "./types"; +import { api } from "./api"; +import type { Room, User } from "./types"; export function ChatShell({ user, @@ -12,10 +12,59 @@ export function ChatShell({ user: User; onLogout: () => void; }) { - const [rooms] = useState(MOCK_ROOMS); - const [activeId, setActiveId] = useState(rooms[0]?.id ?? ""); + const [rooms, setRooms] = useState([]); + const [activeId, setActiveId] = useState(""); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback(() => { + setLoading(true); + api + .listRooms() + .then((rs) => { + setRooms(rs); + setActiveId((cur) => cur || rs[0]?.id || ""); + setError(null); + }) + .catch((e) => setError(e?.message ?? "No se pudieron cargar las rooms")) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { + load(); + }, [load]); + const active = rooms.find((r) => r.id === activeId); + // El panel derecho muestra el estado de carga/error/empty sin tocar el layout. + let panel = ; + if (loading && rooms.length === 0) { + panel = ( +
+ +
+ ); + } else if (error) { + panel = ( +
+ + + {error} + + + +
+ ); + } else if (rooms.length === 0) { + panel = ( +
+ No perteneces a ninguna room todavía +
+ ); + } + return ( - + {panel} ); diff --git a/web/src/Login.tsx b/web/src/Login.tsx index 30081d2a..731bfe6f 100644 --- a/web/src/Login.tsx +++ b/web/src/Login.tsx @@ -11,15 +11,29 @@ import { Title, } from "@mantine/core"; import { IconShieldLock, IconKey } from "@tabler/icons-react"; +import { api, ApiError } from "./api"; import type { User } from "./types"; export function Login({ onLogin }: { onLogin: (u: User) => void }) { const [handle, setHandle] = useState(""); const [password, setPassword] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); const ready = handle.trim().length > 0 && password.length > 0; - const connect = () => { - const h = handle.trim(); - if (ready) onLogin({ id: h, handle: h }); + const connect = async () => { + if (!ready || busy) return; + setBusy(true); + setError(null); + try { + // La contraseña desbloquea la sesión del gateway (passphrase del operador). + // El handle es solo el nombre a mostrar en esta iteración (wallet = fase 2). + const me = await api.login(password); + const h = handle.trim() || me.endpoint.slice(0, 8); + onLogin({ id: me.endpoint, handle: h }); + } catch (e) { + setError(e instanceof ApiError ? e.message : "No se pudo conectar al gateway"); + setBusy(false); + } }; return ( @@ -52,9 +66,20 @@ export function Login({ onLogin }: { onLogin: (u: User) => void }) { leftSection={} value={password} onChange={(e) => setPassword(e.currentTarget.value)} - onKeyDown={(e) => e.key === "Enter" && connect()} + onKeyDown={(e) => e.key === "Enter" && void connect()} /> - diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 00000000..c8b6633c --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,131 @@ +// La única capa por la que la SPA habla con el bus. Cada llamada va al gateway Go +// bajo /api; el gateway mantiene la sesión `pkg/client` (peer autenticado del +// bus), cifra/descifra por room y traduce a REST/SSE. El navegador nunca firma, +// nunca habla NATS y nunca ve una clave privada: solo guarda una cookie de +// sesión opaca (HttpOnly) que el gateway emite tras el login. +import type { MeInfo, Message, MsgWire, Room, RoomWire } from "./types"; + +export class ApiError extends Error { + status: number; + constructor(message: string, status: number) { + super(message); + this.status = status; + } +} + +async function req(path: string, init?: RequestInit): Promise { + const res = await fetch(path, { + // same-origin envía la cookie de sesión automáticamente (también detrás del + // proxy de vite en dev). + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + ...init, + }); + const text = await res.text(); + let body: unknown = null; + if (text) { + try { + body = JSON.parse(text); + } catch { + body = text; + } + } + if (!res.ok) { + const msg = + body && typeof body === "object" && "error" in body + ? String((body as { error: unknown }).error) + : `HTTP ${res.status}`; + throw new ApiError(msg, res.status); + } + return body as T; +} + +// roomFromWire mapea la fila del gateway al tipo Room que consume la UI. Los +// mensajes NO viven aquí: llegan por stream(). lastMessage/lastTs/unread se +// rellenan de forma neutra para no inventar datos (la cabecera de la sidebar se +// alimentará del stream en una iteración futura). +export function roomFromWire(r: RoomWire): Room { + return { + id: r.id, + name: r.name || r.subject, + encrypted: r.encrypt, + lastMessage: "", + lastTs: 0, + unread: 0, + messages: [], + }; +} + +// messageFromWire mapea un frame descifrado del SSE al tipo Message de la UI. +export function messageFromWire(m: MsgWire): Message { + return { + id: m.id, + sender: m.sender, + body: m.body, + ts: m.ts, + mine: m.mine, + }; +} + +export const api = { + // ---- sesión ------------------------------------------------------------- + // login desbloquea la sesión del gateway con la passphrase del operador. El + // gateway responde con una cookie de sesión; me() comprueba si ya hay una. + login: (passphrase: string) => + req("/api/login", { + method: "POST", + body: JSON.stringify({ passphrase }), + }), + logout: () => req<{ status: string }>("/api/logout", { method: "POST" }), + me: () => req("/api/me"), + + // ---- rooms -------------------------------------------------------------- + listRooms: async (): Promise => { + const wire = await req("/api/rooms"); + return wire.map(roomFromWire); + }, + // createRoom: {subject, encrypted} basta — el gateway deriva la policy + // Matrix-like (cifrada + persistida + firmada) por defecto. + createRoom: async (subject: string, encrypted = true): Promise => { + const r = await req("/api/rooms", { + method: "POST", + body: JSON.stringify({ subject, encrypted }), + }); + return roomFromWire(r); + }, + join: (roomID: string) => + req<{ status: string }>( + `/api/rooms/${encodeURIComponent(roomID)}/join`, + { method: "POST" }, + ), + send: (roomID: string, body: string) => + req<{ status: string }>( + `/api/rooms/${encodeURIComponent(roomID)}/send`, + { method: "POST", body: JSON.stringify({ body }) }, + ), +}; + +// streamRoom abre el SSE de una room y llama onMessage por cada frame descifrado +// (historia primero en rooms persistidas, luego en vivo). Devuelve una función +// de cierre. EventSource manda la cookie de sesión automáticamente y reconecta +// solo si la conexión cae; onError se invoca en cada corte para que la UI pueda +// reflejar el estado. +export function streamRoom( + roomID: string, + onMessage: (m: Message) => void, + onError?: (e: Event) => void, +): () => void { + const es = new EventSource( + `/api/rooms/${encodeURIComponent(roomID)}/stream`, + ); + es.onmessage = (ev) => { + try { + const wire = JSON.parse(ev.data) as MsgWire; + onMessage(messageFromWire(wire)); + } catch { + // frame malformado: se ignora, el stream sigue. + } + }; + if (onError) es.onerror = onError; + return () => es.close(); +} diff --git a/web/src/types.ts b/web/src/types.ts index eaacc026..9b4732d7 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -1,5 +1,5 @@ -// Tipos de dominio de la UI. En la iteración 1 se llenan con datos mock; -// más adelante vendrán del gateway (REST/SSE) que es un peer del bus. +// Tipos de dominio de la UI. Los datos vienen del gateway Go (REST/SSE), que es +// un peer autenticado del bus. El navegador nunca firma ni habla NATS. export interface User { id: string; @@ -8,7 +8,7 @@ export interface User { export interface Message { id: string; - sender: string; // handle + sender: string; // endpoint id del remitente (handle legible es fase 2) body: string; ts: number; // epoch ms mine?: boolean; @@ -23,3 +23,33 @@ export interface Room { unread: number; messages: Message[]; } + +// ---- formas de la API del gateway (wire) --------------------------------- + +// MeInfo es la identidad del operador que el gateway encarna (GET /api/me). +export interface MeInfo { + endpoint: string; + sign_pub: string; +} + +// RoomWire es la fila de room que devuelve el gateway (GET /api/rooms). No trae +// mensajes: estos llegan por SSE (GET /api/rooms/{id}/stream). +export interface RoomWire { + id: string; + subject: string; + name: string; + epoch: number; + encrypt: boolean; + persist: boolean; + sign_msgs: boolean; + role: string; +} + +// MsgWire es un mensaje ya descifrado que el gateway empuja por SSE. +export interface MsgWire { + id: string; + sender: string; + body: string; + ts: number; + mine: boolean; +} diff --git a/web/vite.config.ts b/web/vite.config.ts index ca4ab1f2..3ccfd18a 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -3,5 +3,12 @@ import react from "@vitejs/plugin-react"; export default defineConfig({ plugins: [react()], - server: { host: true, port: 5181 }, + // En dev, /api (REST + SSE) se proxea al gateway Go (cmd/webgw, puerto 8481). + // El proxy hace streaming, así que el SSE de /api/rooms/{id}/stream funciona a + // través de él. En producción el gateway sirve el dist embebido y no hay proxy. + server: { + host: true, + port: 5181, + proxy: { "/api": "http://127.0.0.1:8481" }, + }, });