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:
+35
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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" },
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user