feat(web): wire the SPA to the live bus via the gateway (drop mock)

Replace the mock data source with a real data layer that talks to the webgw
gateway over REST + SSE. The UI components keep their look and props; only
where the data comes from changed.

- src/api.ts: the single repository layer. fetch wrappers (same-origin cookie)
  for login/logout/me and rooms list/create/join/send, plus streamRoom() which
  opens an EventSource and yields each decrypted message. Wire->UI mappers
  (roomFromWire, messageFromWire).
- src/types.ts: add the gateway wire shapes (MeInfo, RoomWire, MsgWire) next to
  the existing UI types.
- App.tsx: probe /api/me on mount to resume an existing session; otherwise show
  Login. Logout calls the gateway.
- Login.tsx: the password field now unlocks the gateway session (operator
  passphrase); shows a basic error and a loading state. Wallet-per-browser is
  phase 2.
- ChatShell.tsx: load rooms from /api/rooms with loading / empty / error states;
  same Flex layout.
- ChatPanel.tsx: stream messages over SSE for the active room (dedup by id),
  composer sends through the gateway; no optimistic insert (the peer's own echo
  returns over SSE with the real frame id).
- vite.config.ts: dev proxy /api (REST + SSE) -> the gateway on :8481.

mock.ts is left untouched (no longer imported) to avoid churn with the parallel
styling work on master.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
agent
2026-06-07 21:14:19 +02:00
parent fb8a03cf0c
commit 5ea8fa1c20
7 changed files with 331 additions and 41 deletions
+35 -2
View File
@@ -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<User | null>(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 (
<Center h="100vh" bg="dark.9">
<Loader color="brand" />
</Center>
);
}
if (!user) return <Login onLogin={setUser} />;
return <ChatShell user={user} onLogout={() => setUser(null)} />;
return <ChatShell user={user} onLogout={logout} />;
}
+38 -23
View File
@@ -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<Record<string, Message[]>>({});
const [messages, setMessages] = useState<Message[]>([]);
const [sendError, setSendError] = useState<string | null>(null);
const viewport = useRef<HTMLDivElement>(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({
<ScrollArea style={{ flex: 1 }} viewportRef={viewport}>
<Stack gap="lg" p="md">
{msgs.map((m) => (
{messages.map((m) => (
<MessageRow key={m.id} msg={m} />
))}
</Stack>
</ScrollArea>
<Divider color="dark.4" />
{sendError && (
<Text c="red" size="xs" px="sm" pt={4}>
{sendError}
</Text>
)}
<Group p="sm" gap="xs" wrap="nowrap">
<ActionIcon variant="subtle" color="gray" size="lg">
<IconPaperclip size={18} />
@@ -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()}
/>
<ActionIcon
size="lg"
radius="xl"
variant="filled"
color="brand"
onClick={send}
onClick={() => void send()}
disabled={!draft.trim()}
>
<IconSend size={18} />
+56 -7
View File
@@ -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<string>(rooms[0]?.id ?? "");
const [rooms, setRooms] = useState<Room[]>([]);
const [activeId, setActiveId] = useState<string>("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 = <ChatPanel room={active} />;
if (loading && rooms.length === 0) {
panel = (
<Center h="100%">
<Loader color="brand" />
</Center>
);
} else if (error) {
panel = (
<Center h="100%">
<Stack align="center" gap="sm">
<Text c="red" size="sm">
{error}
</Text>
<Button variant="light" color="brand" onClick={load}>
Reintentar
</Button>
</Stack>
</Center>
);
} else if (rooms.length === 0) {
panel = (
<Center h="100%">
<Text c="dimmed">No perteneces a ninguna room todavía</Text>
</Center>
);
}
return (
<Flex h="100vh" w="100vw" style={{ overflow: "hidden" }}>
<Box
@@ -36,7 +85,7 @@ export function ChatShell({
/>
</Box>
<Box flex={1} h="100%" bg="dark.7" style={{ minWidth: 0 }}>
<ChatPanel room={active} user={user} />
{panel}
</Box>
</Flex>
);
+30 -5
View File
@@ -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<string | null>(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={<IconKey size={16} />}
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && connect()}
onKeyDown={(e) => e.key === "Enter" && void connect()}
/>
<Button w="100%" size="md" onClick={connect} disabled={!ready}>
{error && (
<Text c="red" size="sm" ta="center">
{error}
</Text>
)}
<Button
w="100%"
size="md"
onClick={() => void connect()}
disabled={!ready}
loading={busy}
>
Conectar
</Button>
</Stack>
+131
View File
@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
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<MeInfo>("/api/login", {
method: "POST",
body: JSON.stringify({ passphrase }),
}),
logout: () => req<{ status: string }>("/api/logout", { method: "POST" }),
me: () => req<MeInfo>("/api/me"),
// ---- rooms --------------------------------------------------------------
listRooms: async (): Promise<Room[]> => {
const wire = await req<RoomWire[]>("/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<Room> => {
const r = await req<RoomWire>("/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();
}
+33 -3
View File
@@ -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;
}
+8 -1
View File
@@ -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" },
},
});