From e8850d8965ecd4b52ce7216ef9b2f524da9c165c Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 14 Jun 2026 12:35:33 +0200 Subject: [PATCH] feat(uniweb): crear rooms y chatear desde la UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- web/src/ChatShell.tsx | 34 +++++++++++- web/src/NewRoomModal.tsx | 112 +++++++++++++++++++++++++++++++++++++++ web/src/Sidebar.tsx | 74 +++++++++++++++++++------- 3 files changed, 200 insertions(+), 20 deletions(-) create mode 100644 web/src/NewRoomModal.tsx diff --git a/web/src/ChatShell.tsx b/web/src/ChatShell.tsx index b8595e0..a5e2cfe 100644 --- a/web/src/ChatShell.tsx +++ b/web/src/ChatShell.tsx @@ -1,7 +1,10 @@ import { useCallback, useEffect, useState } from "react"; 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 { ChatPanel } from "./ChatPanel"; +import { NewRoomModal } from "./NewRoomModal"; import { bus } from "./busService"; import type { Room, User } from "./types"; @@ -16,6 +19,16 @@ export function ChatShell({ const [activeId, setActiveId] = useState(""); const [loading, setLoading] = useState(true); const [error, setError] = useState(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(() => { setLoading(true); @@ -60,7 +73,20 @@ export function ChatShell({ } else if (rooms.length === 0) { panel = (
- No perteneces a ninguna room todavía + + Aún no hay conversaciones + + Crea tu primera room cifrada para empezar a chatear. + + +
); } @@ -82,11 +108,17 @@ export function ChatShell({ activeId={activeId} onSelect={setActiveId} onLogout={onLogout} + onNewRoom={modal.open} /> {panel} + ); } diff --git a/web/src/NewRoomModal.tsx b/web/src/NewRoomModal.tsx new file mode 100644 index 0000000..4f005d6 --- /dev/null +++ b/web/src/NewRoomModal.tsx @@ -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(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 ( + + + + Crea una conversación cifrada de extremo a extremo. Tú eres la dueña y + puedes invitar miembros después. + + setSubject(e.currentTarget.value)} + onKeyDown={(e) => e.key === "Enter" && void create()} + leftSection={} + disabled={busy} + /> + {error && ( + } + > + {error} + + )} + + + + + + + ); +} diff --git a/web/src/Sidebar.tsx b/web/src/Sidebar.tsx index 6a21b40..47351d7 100644 --- a/web/src/Sidebar.tsx +++ b/web/src/Sidebar.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { + ActionIcon, Avatar, Badge, Box, @@ -10,6 +11,7 @@ import { Stack, Text, TextInput, + Tooltip, UnstyledButton, } from "@mantine/core"; import { @@ -18,6 +20,7 @@ import { IconDots, IconLock, IconHash, + IconPlus, } from "@tabler/icons-react"; import type { Room, User } from "./types"; @@ -94,12 +97,14 @@ export function Sidebar({ activeId, onSelect, onLogout, + onNewRoom, }: { user: User; rooms: Room[]; activeId: string; onSelect: (id: string) => void; onLogout: () => void; + onNewRoom: () => void; }) { const [q, setQ] = useState(""); const query = q.trim().toLowerCase(); @@ -122,21 +127,35 @@ export function Sidebar({ {user.handle} - - - - - - - - } - onClick={onLogout} + + + - Desconectar - - - + + + + + + + + + + + } + onClick={onLogout} + > + Desconectar + + + + @@ -161,11 +180,28 @@ export function Sidebar({ onClick={() => onSelect(room.id)} /> ))} - {filtered.length === 0 && ( - - Sin resultados - - )} + {filtered.length === 0 && + (query ? ( + + Sin resultados + + ) : ( + + + Aún no tienes ninguna room. + + + + + Crear tu primera room + + + + ))}