d33ca6278a
Cliente web sobre el gateway (REST + SSE). El navegador no habla NATS ni cripto: el peer Go del gateway lo hace. - Pantalla de conexión: gateway URL + identidad (persistidas en localStorage). - Navbar: crear room (con toggle de cifrado E2E), unirse por id, lista de rooms. - Centro: mensajes en vivo por SSE, burbujas con autor y hora, composer. - Lateral: miembros (rol owner), invitar por peer conectado, expulsar (owner). - Mantine v9 (createTheme + MantineProvider), @tabler/icons-react, layout con AppShell/Stack/Group; sin Tailwind ni CSS manual. React 19 (peer dep de v9). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
154 lines
4.5 KiB
TypeScript
154 lines
4.5 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import {
|
|
Stack,
|
|
Group,
|
|
Text,
|
|
Badge,
|
|
Paper,
|
|
ScrollArea,
|
|
TextInput,
|
|
ActionIcon,
|
|
Center,
|
|
ThemeIcon,
|
|
Box,
|
|
CopyButton,
|
|
Tooltip,
|
|
} from "@mantine/core";
|
|
import {
|
|
IconLock,
|
|
IconHash,
|
|
IconSend,
|
|
IconMessages,
|
|
IconCopy,
|
|
IconCheck,
|
|
} from "@tabler/icons-react";
|
|
import type { Message, Room } from "../types";
|
|
|
|
interface Props {
|
|
room: Room | null;
|
|
messages: Message[];
|
|
myEndpoint: string;
|
|
nameFor: (endpoint: string) => string;
|
|
onPublish: (text: string) => void;
|
|
}
|
|
|
|
// formatTime renders a message timestamp as HH:mm:ss in 24h European style.
|
|
function formatTime(ts: number): string {
|
|
return new Date(ts).toLocaleTimeString("es-ES", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
});
|
|
}
|
|
|
|
// MessagePane is the center column: the active room's live message log plus the
|
|
// composer. Own messages align right; others align left and show the sender.
|
|
export function MessagePane({ room, messages, myEndpoint, nameFor, onPublish }: Props) {
|
|
const [text, setText] = useState("");
|
|
const viewport = useRef<HTMLDivElement>(null);
|
|
|
|
// Auto-scroll to the newest message.
|
|
useEffect(() => {
|
|
viewport.current?.scrollTo({ top: viewport.current.scrollHeight, behavior: "smooth" });
|
|
}, [messages.length]);
|
|
|
|
if (!room) {
|
|
return (
|
|
<Center h="100%">
|
|
<Stack align="center" gap="xs">
|
|
<ThemeIcon size={64} radius="xl" variant="light" color="gray">
|
|
<IconMessages size={34} />
|
|
</ThemeIcon>
|
|
<Text c="dimmed">Elige o crea una room para empezar a chatear</Text>
|
|
</Stack>
|
|
</Center>
|
|
);
|
|
}
|
|
|
|
const send = () => {
|
|
const t = text.trim();
|
|
if (t) {
|
|
onPublish(t);
|
|
setText("");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Stack gap={0} h="100%">
|
|
<Group justify="space-between" px="md" py="sm" wrap="nowrap" style={{ borderBottom: "1px solid var(--mantine-color-default-border)" }}>
|
|
<Group gap="xs" wrap="nowrap">
|
|
{room.encrypt ? <IconLock size={18} /> : <IconHash size={18} />}
|
|
<Text fw={600}>{room.subject}</Text>
|
|
{room.encrypt && (
|
|
<Badge size="sm" color="teal" variant="light">
|
|
cifrada E2E
|
|
</Badge>
|
|
)}
|
|
</Group>
|
|
<CopyButton value={room.room_id}>
|
|
{({ copied, copy }) => (
|
|
<Tooltip label={copied ? "¡copiado!" : "copiar room id"} withArrow>
|
|
<ActionIcon variant="subtle" color="gray" onClick={copy}>
|
|
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
)}
|
|
</CopyButton>
|
|
</Group>
|
|
|
|
<ScrollArea style={{ flex: 1 }} viewportRef={viewport} p="md">
|
|
<Stack gap="sm">
|
|
{messages.length === 0 && (
|
|
<Text c="dimmed" ta="center" py="xl" size="sm">
|
|
No hay mensajes todavía.
|
|
</Text>
|
|
)}
|
|
{messages.map((m) => {
|
|
const mine = m.sender === myEndpoint;
|
|
return (
|
|
<Box
|
|
key={m.id}
|
|
style={{ display: "flex", justifyContent: mine ? "flex-end" : "flex-start" }}
|
|
>
|
|
<Paper
|
|
withBorder
|
|
shadow="xs"
|
|
radius="md"
|
|
p="xs"
|
|
bg={mine ? "violet.9" : undefined}
|
|
maw="75%"
|
|
>
|
|
{!mine && (
|
|
<Text size="xs" fw={700} c="violet.4">
|
|
{nameFor(m.sender)}
|
|
</Text>
|
|
)}
|
|
<Text size="sm" style={{ wordBreak: "break-word", whiteSpace: "pre-wrap" }}>
|
|
{m.text}
|
|
</Text>
|
|
<Text size="9px" c="dimmed" ta="right" mt={2}>
|
|
{formatTime(m.ts)}
|
|
</Text>
|
|
</Paper>
|
|
</Box>
|
|
);
|
|
})}
|
|
</Stack>
|
|
</ScrollArea>
|
|
|
|
<Group p="md" gap="xs" wrap="nowrap" style={{ borderTop: "1px solid var(--mantine-color-default-border)" }}>
|
|
<TextInput
|
|
style={{ flex: 1 }}
|
|
placeholder={`Mensaje a ${room.subject}…`}
|
|
value={text}
|
|
onChange={(e) => setText(e.currentTarget.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && send()}
|
|
/>
|
|
<ActionIcon size="lg" onClick={send} disabled={!text.trim()}>
|
|
<IconSend size={18} />
|
|
</ActionIcon>
|
|
</Group>
|
|
</Stack>
|
|
);
|
|
}
|