Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e12894099f |
+1
-33
@@ -1,10 +1,7 @@
|
|||||||
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";
|
||||||
|
|
||||||
@@ -19,16 +16,6 @@ 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);
|
||||||
@@ -73,20 +60,7 @@ export function ChatShell({
|
|||||||
} else if (rooms.length === 0) {
|
} else if (rooms.length === 0) {
|
||||||
panel = (
|
panel = (
|
||||||
<Center h="100%">
|
<Center h="100%">
|
||||||
<Stack align="center" gap="sm" maw={320} ta="center">
|
<Text c="dimmed">No perteneces a ninguna room todavía</Text>
|
||||||
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -108,17 +82,11 @@ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
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. Tú 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+2
-38
@@ -1,6 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
|
||||||
Avatar,
|
Avatar,
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
@@ -11,7 +10,6 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Tooltip,
|
|
||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
@@ -20,7 +18,6 @@ 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";
|
||||||
|
|
||||||
@@ -97,14 +94,12 @@ 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();
|
||||||
@@ -127,19 +122,6 @@ export function Sidebar({
|
|||||||
{user.handle}
|
{user.handle}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap={4} wrap="nowrap">
|
|
||||||
<Tooltip label="Nueva room" position="bottom" withArrow>
|
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
color="brand"
|
|
||||||
size="lg"
|
|
||||||
radius="md"
|
|
||||||
onClick={onNewRoom}
|
|
||||||
aria-label="Crear nueva room"
|
|
||||||
>
|
|
||||||
<IconPlus size={20} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
<Menu position="bottom-end" withinPortal>
|
<Menu position="bottom-end" withinPortal>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<UnstyledButton c="dimmed">
|
<UnstyledButton c="dimmed">
|
||||||
@@ -156,7 +138,6 @@ export function Sidebar({
|
|||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Box px="sm" pb="sm">
|
<Box px="sm" pb="sm">
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -180,28 +161,11 @@ export function Sidebar({
|
|||||||
onClick={() => onSelect(room.id)}
|
onClick={() => onSelect(room.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{filtered.length === 0 &&
|
{filtered.length === 0 && (
|
||||||
(query ? (
|
|
||||||
<Text c="dimmed" size="sm" ta="center" mt="md">
|
<Text c="dimmed" size="sm" ta="center" mt="md">
|
||||||
Sin resultados
|
Sin resultados
|
||||||
</Text>
|
</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>
|
||||||
|
|||||||
+20
-5
@@ -24,11 +24,26 @@ 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. A browser cannot open a raw TCP NATS socket, so the data plane is
|
// Bus endpoints. The SPA is served same-origin behind a reverse proxy (Caddy):
|
||||||
// reached over WebSocket; the control plane is the signed HTTPS API. Both default to
|
// both planes are reached through this page's OWN origin, so there is no CORS and
|
||||||
// a cluster node and can be overridden at build time (VITE_BUS_HTTP / VITE_BUS_WS).
|
// the cluster node IPs stay hidden behind the proxy. The control plane is the
|
||||||
const BUS_HTTP = import.meta.env.VITE_BUS_HTTP ?? "https://51.91.100.142:8470";
|
// signed HTTPS API under the relative path /api; the data plane is NATS over
|
||||||
const BUS_WS = import.meta.env.VITE_BUS_WS ?? "wss://51.91.100.142:8480";
|
// WebSocket under /nats (a browser cannot open a raw TCP NATS socket). Both can
|
||||||
|
// 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 {}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user