Files
unibus/web/src/ChatPanel.tsx
T
agent caf005f04b feat(web): frontend v1 — login (handle+contraseña), sidebar rooms+buscador, chat estilo Element
SPA React 19 + Vite + Mantine v9 en modo oscuro (acento índigo), datos mock para
iterar el diseño antes de cablear el gateway. Login con identidad + contraseña
(la contraseña desbloqueará la identidad Ed25519 cifrada en el dispositivo).
Sidebar: avatar de usuario, buscador (rooms/usuarios/mensajes) y lista de rooms
con candado E2E / hash cleartext / badges de no leídos. Panel de chat estilo
Element (avatar+nombre+hora+texto) con composer interactivo.
2026-06-07 17:57:50 +02:00

162 lines
4.2 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import {
ActionIcon,
Avatar,
Box,
Center,
Divider,
Group,
ScrollArea,
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import {
IconSend,
IconLock,
IconHash,
IconDotsVertical,
IconPaperclip,
} from "@tabler/icons-react";
import type { Message, Room, User } from "./types";
function initials(s: string) {
return s.replace(/[^a-z0-9]/gi, "").slice(0, 2).toUpperCase() || "?";
}
function timeShort(ts: number) {
const d = new Date(ts);
return `${String(d.getHours()).padStart(2, "0")}:${String(
d.getMinutes(),
).padStart(2, "0")}`;
}
function MessageRow({ msg }: { msg: Message }) {
return (
<Group align="flex-start" gap="sm" wrap="nowrap">
<Avatar radius="xl" size={36} color={msg.mine ? "brand" : "gray"}>
{initials(msg.sender)}
</Avatar>
<Box style={{ minWidth: 0 }}>
<Group gap={8} align="baseline">
<Text size="sm" fw={600} c={msg.mine ? "brand.4" : undefined}>
{msg.sender}
</Text>
<Text size="xs" c="dimmed">
{timeShort(msg.ts)}
</Text>
</Group>
<Text size="sm" style={{ wordBreak: "break-word" }}>
{msg.body}
</Text>
</Box>
</Group>
);
}
export function ChatPanel({
room,
user,
}: {
room: Room | undefined;
user: User;
}) {
const [draft, setDraft] = useState("");
const [extra, setExtra] = useState<Record<string, Message[]>>({});
const viewport = useRef<HTMLDivElement>(null);
const msgs = room ? [...room.messages, ...(extra[room.id] ?? [])] : [];
useEffect(() => {
viewport.current?.scrollTo({ top: viewport.current.scrollHeight });
}, [room?.id, msgs.length]);
if (!room) {
return (
<Center h="100%">
<Text c="dimmed">Selecciona una conversación</Text>
</Center>
);
}
const send = () => {
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("");
};
return (
<Stack h="100vh" gap={0}>
<Group justify="space-between" px="md" py="xs" wrap="nowrap">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<Avatar radius="md" size={38} color="brand">
{initials(room.name)}
</Avatar>
<Box style={{ minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text fw={650} truncate>
{room.name}
</Text>
{room.encrypted ? (
<Tooltip label="Cifrada de extremo a extremo">
<IconLock size={14} style={{ opacity: 0.6 }} />
</Tooltip>
) : (
<IconHash size={14} style={{ opacity: 0.6 }} />
)}
</Group>
<Text size="xs" c="dimmed">
{room.encrypted ? "cifrada · E2E" : "abierta · cleartext"}
</Text>
</Box>
</Group>
<ActionIcon variant="subtle" color="gray">
<IconDotsVertical size={18} />
</ActionIcon>
</Group>
<Divider color="dark.4" />
<ScrollArea style={{ flex: 1 }} viewportRef={viewport}>
<Stack gap="lg" p="md">
{msgs.map((m) => (
<MessageRow key={m.id} msg={m} />
))}
</Stack>
</ScrollArea>
<Divider color="dark.4" />
<Group p="sm" gap="xs" wrap="nowrap">
<ActionIcon variant="subtle" color="gray" size="lg">
<IconPaperclip size={18} />
</ActionIcon>
<TextInput
style={{ flex: 1 }}
radius="xl"
placeholder={`Mensaje a ${room.name}`}
value={draft}
onChange={(e) => setDraft(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && send()}
/>
<ActionIcon
size="lg"
radius="xl"
variant="filled"
color="brand"
onClick={send}
disabled={!draft.trim()}
>
<IconSend size={18} />
</ActionIcon>
</Group>
</Stack>
);
}