1 Commits

Author SHA1 Message Date
egutierrez e8850d8965 feat(uniweb): crear rooms y chatear desde la UI
Añade un control "Nueva room" en el header del sidebar (botón "+") y CTAs
en los estados vacíos del sidebar y del panel. Abren un modal que pide el
asunto, crea la room con bus.createRoom contra el bus real, la inserta en
la lista (dedup por id, sin recargar) y la activa.

- NewRoomModal: modal de Mantine con loading, manejo de SessionError/Error
  en español, crear con Enter o botón, formulario limpio en cada apertura.
- ChatShell: estado del modal con useDisclosure, handleRoomCreated centraliza
  inserción + selección, empty state del panel rediseñado con botón crear.
- Sidebar: prop onNewRoom, botón "+" con tooltip, empty state distingue
  "sin resultados" de "sin rooms" (con CTA crear primera room).

No toca la capa de datos (busService.ts ni web/src/bus/): usa los métodos de
bus tal como están. Verificado end-to-end contra el cluster real: crear room
desde la UI, enviar mensaje y verlo aparecer por la suscripción.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:35:33 +02:00
4 changed files with 205 additions and 40 deletions
+33 -1
View File
@@ -1,7 +1,10 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Flex, Box, Center, Loader, Stack, Text, Button } from "@mantine/core"; import { Flex, Box, Center, Loader, Stack, Text, Button } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconPlus } from "@tabler/icons-react";
import { Sidebar } from "./Sidebar"; import { Sidebar } from "./Sidebar";
import { ChatPanel } from "./ChatPanel"; import { ChatPanel } from "./ChatPanel";
import { NewRoomModal } from "./NewRoomModal";
import { bus } from "./busService"; import { bus } from "./busService";
import type { Room, User } from "./types"; import type { Room, User } from "./types";
@@ -16,6 +19,16 @@ export function ChatShell({
const [activeId, setActiveId] = useState<string>(""); const [activeId, setActiveId] = useState<string>("");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [modalOpen, modal] = useDisclosure(false);
// Inserta la room recién creada al principio de la lista y la activa, sin
// recargar todo. Evita duplicar si el id ya estaba presente.
const handleRoomCreated = useCallback((room: Room) => {
setRooms((prev) =>
prev.some((r) => r.id === room.id) ? prev : [room, ...prev],
);
setActiveId(room.id);
}, []);
const load = useCallback(() => { const load = useCallback(() => {
setLoading(true); setLoading(true);
@@ -60,7 +73,20 @@ export function ChatShell({
} else if (rooms.length === 0) { } else if (rooms.length === 0) {
panel = ( panel = (
<Center h="100%"> <Center h="100%">
<Text c="dimmed">No perteneces a ninguna room todavía</Text> <Stack align="center" gap="sm" maw={320} ta="center">
<Text fw={600}>Aún no hay conversaciones</Text>
<Text c="dimmed" size="sm">
Crea tu primera room cifrada para empezar a chatear.
</Text>
<Button
color="brand"
leftSection={<IconPlus size={16} />}
onClick={modal.open}
mt="xs"
>
Crear room
</Button>
</Stack>
</Center> </Center>
); );
} }
@@ -82,11 +108,17 @@ export function ChatShell({
activeId={activeId} activeId={activeId}
onSelect={setActiveId} onSelect={setActiveId}
onLogout={onLogout} onLogout={onLogout}
onNewRoom={modal.open}
/> />
</Box> </Box>
<Box flex={1} h="100%" bg="dark.7" style={{ minWidth: 0 }}> <Box flex={1} h="100%" bg="dark.7" style={{ minWidth: 0 }}>
{panel} {panel}
</Box> </Box>
<NewRoomModal
opened={modalOpen}
onClose={modal.close}
onCreated={handleRoomCreated}
/>
</Flex> </Flex>
); );
} }
+112
View File
@@ -0,0 +1,112 @@
import { useEffect, useState } from "react";
import {
Alert,
Button,
Group,
Modal,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { IconAlertCircle, IconLock, IconPlus } from "@tabler/icons-react";
import { bus, SessionError } from "./busService";
import type { Room } from "./types";
// NewRoomModal pide el asunto de una room nueva y la crea contra el bus. La room
// que devuelve `bus.createRoom` (cifrada + firmada, propiedad del usuario) se
// entrega al padre vía `onCreated` para insertarla en la lista sin recargar.
export function NewRoomModal({
opened,
onClose,
onCreated,
}: {
opened: boolean;
onClose: () => void;
onCreated: (room: Room) => void;
}) {
const [subject, setSubject] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// Cada vez que se abre el modal partimos de un formulario limpio.
useEffect(() => {
if (opened) {
setSubject("");
setBusy(false);
setError(null);
}
}, [opened]);
const create = async () => {
const name = subject.trim();
if (!name || busy) return;
setBusy(true);
setError(null);
try {
const room = await bus.createRoom(name);
onCreated(room);
onClose();
} catch (e) {
const msg =
e instanceof SessionError
? "Tu sesión con el bus expiró. Vuelve a iniciar sesión."
: e instanceof Error
? e.message
: "No se pudo crear la room";
setError(msg);
} finally {
setBusy(false);
}
};
return (
<Modal
opened={opened}
onClose={onClose}
title="Nueva room"
centered
radius="md"
overlayProps={{ blur: 2 }}
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Crea una conversación cifrada de extremo a extremo. eres la dueña y
puedes invitar miembros después.
</Text>
<TextInput
label="Asunto"
placeholder="ej. equipo-infra, anuncios, 1-a-1 con ana…"
data-autofocus
value={subject}
onChange={(e) => setSubject(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && void create()}
leftSection={<IconLock size={16} />}
disabled={busy}
/>
{error && (
<Alert
color="red"
variant="light"
icon={<IconAlertCircle size={16} />}
>
{error}
</Alert>
)}
<Group justify="flex-end" gap="sm">
<Button variant="subtle" color="gray" onClick={onClose} disabled={busy}>
Cancelar
</Button>
<Button
color="brand"
leftSection={<IconPlus size={16} />}
onClick={() => void create()}
loading={busy}
disabled={!subject.trim()}
>
Crear room
</Button>
</Group>
</Stack>
</Modal>
);
}
+55 -19
View File
@@ -1,5 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { import {
ActionIcon,
Avatar, Avatar,
Badge, Badge,
Box, Box,
@@ -10,6 +11,7 @@ import {
Stack, Stack,
Text, Text,
TextInput, TextInput,
Tooltip,
UnstyledButton, UnstyledButton,
} from "@mantine/core"; } from "@mantine/core";
import { import {
@@ -18,6 +20,7 @@ import {
IconDots, IconDots,
IconLock, IconLock,
IconHash, IconHash,
IconPlus,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import type { Room, User } from "./types"; import type { Room, User } from "./types";
@@ -94,12 +97,14 @@ export function Sidebar({
activeId, activeId,
onSelect, onSelect,
onLogout, onLogout,
onNewRoom,
}: { }: {
user: User; user: User;
rooms: Room[]; rooms: Room[];
activeId: string; activeId: string;
onSelect: (id: string) => void; onSelect: (id: string) => void;
onLogout: () => void; onLogout: () => void;
onNewRoom: () => void;
}) { }) {
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const query = q.trim().toLowerCase(); const query = q.trim().toLowerCase();
@@ -122,21 +127,35 @@ export function Sidebar({
{user.handle} {user.handle}
</Text> </Text>
</Group> </Group>
<Menu position="bottom-end" withinPortal> <Group gap={4} wrap="nowrap">
<Menu.Target> <Tooltip label="Nueva room" position="bottom" withArrow>
<UnstyledButton c="dimmed"> <ActionIcon
<IconDots size={18} /> variant="subtle"
</UnstyledButton> color="brand"
</Menu.Target> size="lg"
<Menu.Dropdown> radius="md"
<Menu.Item onClick={onNewRoom}
leftSection={<IconLogout size={15} />} aria-label="Crear nueva room"
onClick={onLogout}
> >
Desconectar <IconPlus size={20} />
</Menu.Item> </ActionIcon>
</Menu.Dropdown> </Tooltip>
</Menu> <Menu position="bottom-end" withinPortal>
<Menu.Target>
<UnstyledButton c="dimmed">
<IconDots size={18} />
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconLogout size={15} />}
onClick={onLogout}
>
Desconectar
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Group> </Group>
<Box px="sm" pb="sm"> <Box px="sm" pb="sm">
@@ -161,11 +180,28 @@ export function Sidebar({
onClick={() => onSelect(room.id)} onClick={() => onSelect(room.id)}
/> />
))} ))}
{filtered.length === 0 && ( {filtered.length === 0 &&
<Text c="dimmed" size="sm" ta="center" mt="md"> (query ? (
Sin resultados <Text c="dimmed" size="sm" ta="center" mt="md">
</Text> Sin resultados
)} </Text>
) : (
<Stack align="center" gap="xs" mt="xl" px="md">
<Text c="dimmed" size="sm" ta="center">
Aún no tienes ninguna room.
</Text>
<UnstyledButton
onClick={onNewRoom}
c="brand.4"
style={{ fontSize: "var(--mantine-font-size-sm)", fontWeight: 600 }}
>
<Group gap={4} wrap="nowrap">
<IconPlus size={15} />
Crear tu primera room
</Group>
</UnstyledButton>
</Stack>
))}
</Stack> </Stack>
</ScrollArea> </ScrollArea>
</Stack> </Stack>
+5 -20
View File
@@ -24,26 +24,11 @@ import {
import type { WalletIdentity } from "./wallet/derive"; import type { WalletIdentity } from "./wallet/derive";
import type { MeInfo, Message, Room, User } from "./types"; import type { MeInfo, Message, Room, User } from "./types";
// Bus endpoints. The SPA is served same-origin behind a reverse proxy (Caddy): // Bus endpoints. A browser cannot open a raw TCP NATS socket, so the data plane is
// both planes are reached through this page's OWN origin, so there is no CORS and // reached over WebSocket; the control plane is the signed HTTPS API. Both default to
// the cluster node IPs stay hidden behind the proxy. The control plane is the // a cluster node and can be overridden at build time (VITE_BUS_HTTP / VITE_BUS_WS).
// signed HTTPS API under the relative path /api; the data plane is NATS over const BUS_HTTP = import.meta.env.VITE_BUS_HTTP ?? "https://51.91.100.142:8470";
// WebSocket under /nats (a browser cannot open a raw TCP NATS socket). Both can const BUS_WS = import.meta.env.VITE_BUS_WS ?? "wss://51.91.100.142:8480";
// still be overridden at build time (VITE_BUS_HTTP / VITE_BUS_WS) for a dev setup
// that points straight at a cluster node.
const BUS_HTTP = import.meta.env.VITE_BUS_HTTP ?? "/api";
const BUS_WS = import.meta.env.VITE_BUS_WS ?? defaultBusWS();
// defaultBusWS derives the data-plane WebSocket URL from the page origin: the same
// host and port as the SPA, the wss/ws scheme mirroring https/http, path /nats. A
// browser WebSocket needs an absolute ws(s) URL, so this is computed from location
// rather than left relative. Returns "" where window is absent (SSR/tests), where
// the build-time override is expected instead.
function defaultBusWS(): string {
if (typeof window === "undefined") return "";
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${window.location.host}/nats`;
}
export class SessionError extends Error {} export class SessionError extends Error {}