mirror of
https://github.com/temetro/temetro.git
synced 2026-08-17 13:57:48 +00:00
feat: real-time staff messaging over Socket.io
Add conversations/participants/messages tables, a participant-scoped REST API (/api/conversations) and a Socket.io server (session-authenticated handshake; per-user + per-conversation rooms) sharing the HTTP port. New messages broadcast live and create per-recipient notifications. Also lands the notifications table + service + routes (used by the message flow). The Messages page is rewritten: live threads, unread state, and a compose dialog to start a conversation with a clinic member. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { Mail, SendHorizonal } from "lucide-react";
|
||||
import { type FormEvent, useMemo, useState } from "react";
|
||||
import { Mail, Plus, SendHorizonal } from "lucide-react";
|
||||
import {
|
||||
type FormEvent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogPanel,
|
||||
DialogPopup,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
@@ -13,132 +27,129 @@ import {
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import {
|
||||
type ConversationMessage,
|
||||
type ConversationSummary,
|
||||
type Participant,
|
||||
createConversation,
|
||||
getMessages,
|
||||
listClinicMembers,
|
||||
listConversations,
|
||||
} from "@/lib/messages";
|
||||
import { getSocket } from "@/lib/socket";
|
||||
import { notify } from "@/lib/toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// All conversations here are mock/placeholder data — there is no messaging
|
||||
// backend. They illustrate the inbox + chat-thread timeline layout.
|
||||
// Up to two-letter initials from a display name.
|
||||
function initials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return "?";
|
||||
if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase();
|
||||
return (parts[0]![0]! + parts.at(-1)![0]!).toUpperCase();
|
||||
}
|
||||
|
||||
type ChatMessage = {
|
||||
id: string;
|
||||
direction: "in" | "out";
|
||||
text: string;
|
||||
time: string;
|
||||
};
|
||||
|
||||
type Conversation = {
|
||||
id: string;
|
||||
name: string;
|
||||
initials: string;
|
||||
role: string;
|
||||
unread: boolean;
|
||||
messages: ChatMessage[];
|
||||
};
|
||||
|
||||
const seed: Conversation[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Dr. Stein",
|
||||
initials: "DS",
|
||||
role: "Endocrinology",
|
||||
unread: true,
|
||||
messages: [
|
||||
{
|
||||
id: "1a",
|
||||
direction: "in",
|
||||
text: "The lab results for Amina Yusuf are back — lipid panel and HbA1c are both in range.",
|
||||
time: "10:24",
|
||||
},
|
||||
{
|
||||
id: "1b",
|
||||
direction: "in",
|
||||
text: "Want me to adjust her plan before the follow-up?",
|
||||
time: "10:25",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Dr. Okafor",
|
||||
initials: "DO",
|
||||
role: "Family medicine",
|
||||
unread: true,
|
||||
messages: [
|
||||
{
|
||||
id: "2a",
|
||||
direction: "out",
|
||||
text: "Sent over Daniel Mensah's intake notes — can you take a look?",
|
||||
time: "08:50",
|
||||
},
|
||||
{
|
||||
id: "2b",
|
||||
direction: "in",
|
||||
text: "Thanks! Added him to tomorrow at 10:00. Were his prior records imported?",
|
||||
time: "09:12",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "Care team",
|
||||
initials: "CT",
|
||||
role: "Clinic-wide",
|
||||
unread: false,
|
||||
messages: [
|
||||
{
|
||||
id: "3a",
|
||||
direction: "in",
|
||||
text: "Seasonal vaccine stock has been replenished — slots are open all week.",
|
||||
time: "Yesterday",
|
||||
},
|
||||
{
|
||||
id: "3b",
|
||||
direction: "out",
|
||||
text: "Great, I'll direct eligible patients to the front desk.",
|
||||
time: "Yesterday",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "Reception",
|
||||
initials: "RC",
|
||||
role: "Front desk",
|
||||
unread: false,
|
||||
messages: [
|
||||
{
|
||||
id: "4a",
|
||||
direction: "in",
|
||||
text: "Two Friday afternoon appointments were moved to next Monday at the patients' request.",
|
||||
time: "Mon",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const now = () =>
|
||||
new Date().toLocaleTimeString("en-US", {
|
||||
// ISO timestamp -> "10:24" (24h).
|
||||
function formatTime(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function MessagesView() {
|
||||
const [conversations, setConversations] = useState<Conversation[]>(seed);
|
||||
const { data: session } = authClient.useSession();
|
||||
const myId = session?.user?.id ?? "";
|
||||
|
||||
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<ConversationMessage[]>([]);
|
||||
const [showUnreadOnly, setShowUnreadOnly] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [composeOpen, setComposeOpen] = useState(false);
|
||||
const [members, setMembers] = useState<Participant[]>([]);
|
||||
|
||||
// Refs so the socket handler (registered once) reads current values.
|
||||
const selectedIdRef = useRef<string | null>(null);
|
||||
const myIdRef = useRef<string>("");
|
||||
myIdRef.current = myId;
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Initial conversation load.
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
listConversations()
|
||||
.then((data) => {
|
||||
if (active) setConversations(data);
|
||||
})
|
||||
.catch(() => {
|
||||
/* api-client redirects on 401 */
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Realtime: append to the open thread and keep the inbox fresh.
|
||||
useEffect(() => {
|
||||
const socket = getSocket();
|
||||
const onMessageNew = (msg: ConversationMessage) => {
|
||||
setConversations((prev) => {
|
||||
const existing = prev.find((c) => c.id === msg.conversationId);
|
||||
if (!existing) {
|
||||
listConversations().then(setConversations).catch(() => {});
|
||||
return prev;
|
||||
}
|
||||
const isSelected = selectedIdRef.current === msg.conversationId;
|
||||
const updated: ConversationSummary = {
|
||||
...existing,
|
||||
lastMessage: msg,
|
||||
updatedAt: msg.createdAt,
|
||||
unread: msg.senderId !== myIdRef.current && !isSelected,
|
||||
};
|
||||
return [updated, ...prev.filter((c) => c.id !== msg.conversationId)];
|
||||
});
|
||||
|
||||
if (selectedIdRef.current === msg.conversationId) {
|
||||
setMessages((prev) =>
|
||||
prev.some((m) => m.id === msg.id) ? prev : [...prev, msg],
|
||||
);
|
||||
socket.emit("message:read", msg.conversationId);
|
||||
}
|
||||
};
|
||||
socket.on("message:new", onMessageNew);
|
||||
return () => {
|
||||
socket.off("message:new", onMessageNew);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-scroll the open thread to the latest message.
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
|
||||
}, [messages]);
|
||||
|
||||
const unreadCount = conversations.filter((c) => c.unread).length;
|
||||
const selected = conversations.find((c) => c.id === selectedId) ?? null;
|
||||
|
||||
const visible = useMemo(
|
||||
() => (showUnreadOnly ? conversations.filter((c) => c.unread) : conversations),
|
||||
() =>
|
||||
showUnreadOnly ? conversations.filter((c) => c.unread) : conversations,
|
||||
[conversations, showUnreadOnly],
|
||||
);
|
||||
|
||||
const open = (id: string) => {
|
||||
setSelectedId(id);
|
||||
selectedIdRef.current = id;
|
||||
setDraft("");
|
||||
setMessages([]);
|
||||
getMessages(id)
|
||||
.then(setMessages)
|
||||
.catch(() => {});
|
||||
const socket = getSocket();
|
||||
socket.emit("conversation:join", id);
|
||||
socket.emit("message:read", id);
|
||||
setConversations((prev) =>
|
||||
prev.map((c) => (c.id === id ? { ...c, unread: false } : c)),
|
||||
);
|
||||
@@ -148,46 +159,68 @@ export function MessagesView() {
|
||||
event.preventDefault();
|
||||
const text = draft.trim();
|
||||
if (!(text && selected)) return;
|
||||
const message: ChatMessage = {
|
||||
id: `${selected.id}-${Date.now()}`,
|
||||
direction: "out",
|
||||
text,
|
||||
time: now(),
|
||||
};
|
||||
setConversations((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === selected.id
|
||||
? { ...c, messages: [...c.messages, message] }
|
||||
: c,
|
||||
),
|
||||
);
|
||||
getSocket().emit("message:send", {
|
||||
conversationId: selected.id,
|
||||
body: text,
|
||||
});
|
||||
setDraft("");
|
||||
};
|
||||
|
||||
const openCompose = () => {
|
||||
setComposeOpen(true);
|
||||
listClinicMembers()
|
||||
.then(setMembers)
|
||||
.catch(() => setMembers([]));
|
||||
};
|
||||
|
||||
const startConversation = async (memberId: string) => {
|
||||
try {
|
||||
const conv = await createConversation({ participantIds: [memberId] });
|
||||
setConversations((prev) =>
|
||||
prev.some((c) => c.id === conv.id) ? prev : [conv, ...prev],
|
||||
);
|
||||
setComposeOpen(false);
|
||||
open(conv.id);
|
||||
} catch {
|
||||
notify.error("Couldn't start conversation", "Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full gap-4 p-4">
|
||||
{/* Left: conversation list */}
|
||||
<aside className="flex w-72 shrink-0 flex-col overflow-hidden rounded-2xl border bg-card/30">
|
||||
<div className="flex items-center justify-between gap-2 border-border border-b px-4 py-3">
|
||||
<h1 className="font-semibold text-base tracking-tight">Inbox</h1>
|
||||
<Button
|
||||
aria-pressed={showUnreadOnly}
|
||||
onClick={() => setShowUnreadOnly((v) => !v)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={showUnreadOnly ? "secondary" : "ghost"}
|
||||
>
|
||||
Unread · {unreadCount}
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
aria-pressed={showUnreadOnly}
|
||||
onClick={() => setShowUnreadOnly((v) => !v)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={showUnreadOnly ? "secondary" : "ghost"}
|
||||
>
|
||||
Unread · {unreadCount}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="New message"
|
||||
onClick={openCompose}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto p-2">
|
||||
{visible.length === 0 ? (
|
||||
<p className="px-2 py-1.5 text-muted-foreground text-sm">
|
||||
No unread messages.
|
||||
{showUnreadOnly ? "No unread messages." : "No conversations yet."}
|
||||
</p>
|
||||
) : (
|
||||
visible.map((c) => {
|
||||
const last = c.messages.at(-1);
|
||||
const last = c.lastMessage;
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
@@ -213,12 +246,12 @@ export function MessagesView() {
|
||||
{c.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-muted-foreground text-xs">
|
||||
{last?.time}
|
||||
{last ? formatTime(last.createdAt) : ""}
|
||||
</span>
|
||||
</div>
|
||||
<span className="w-full truncate text-muted-foreground text-xs">
|
||||
{last?.direction === "out" && "You: "}
|
||||
{last?.text}
|
||||
{last?.senderId === myId && "You: "}
|
||||
{last?.body}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
@@ -233,43 +266,56 @@ export function MessagesView() {
|
||||
<div className="flex h-full flex-col gap-4">
|
||||
<div className="flex items-center gap-3 rounded-2xl border bg-card/30 p-4">
|
||||
<Avatar className="size-9">
|
||||
<AvatarFallback>{selected.initials}</AvatarFallback>
|
||||
<AvatarFallback>{initials(selected.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate font-medium text-foreground text-sm">
|
||||
{selected.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{selected.role}
|
||||
{selected.isGroup
|
||||
? `${selected.participants.length} people`
|
||||
: "Direct message"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto rounded-2xl border bg-card/30 p-4">
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-y-auto rounded-2xl border bg-card/30 p-4"
|
||||
ref={scrollRef}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{selected.messages.map((m) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-1",
|
||||
m.direction === "out" ? "items-end" : "items-start",
|
||||
)}
|
||||
key={m.id}
|
||||
>
|
||||
{messages.map((m) => {
|
||||
const out = m.senderId === myId;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[75%] rounded-2xl px-3 py-2 text-sm",
|
||||
m.direction === "out"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-foreground",
|
||||
"flex flex-col gap-1",
|
||||
out ? "items-end" : "items-start",
|
||||
)}
|
||||
key={m.id}
|
||||
>
|
||||
{m.text}
|
||||
{selected.isGroup && !out && (
|
||||
<span className="px-1 text-muted-foreground text-[11px]">
|
||||
{m.senderName}
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[75%] rounded-2xl px-3 py-2 text-sm",
|
||||
out
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-foreground",
|
||||
)}
|
||||
>
|
||||
{m.body}
|
||||
</div>
|
||||
<span className="px-1 text-muted-foreground text-[11px]">
|
||||
{formatTime(m.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="px-1 text-muted-foreground text-[11px]">
|
||||
{m.time}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -303,13 +349,49 @@ export function MessagesView() {
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No conversation selected</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Choose a conversation from the inbox to read and reply.
|
||||
Choose a conversation from the inbox, or start a new one.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Compose: pick a clinic member to message */}
|
||||
<Dialog onOpenChange={setComposeOpen} open={composeOpen}>
|
||||
<DialogPopup className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New message</DialogTitle>
|
||||
<DialogDescription>
|
||||
Start a conversation with a member of your clinic.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogPanel className="flex flex-col gap-1">
|
||||
{members.length === 0 ? (
|
||||
<p className="px-1 py-4 text-center text-muted-foreground text-sm">
|
||||
No other clinic members yet. Invite colleagues from Settings →
|
||||
Care team.
|
||||
</p>
|
||||
) : (
|
||||
members.map((m) => (
|
||||
<button
|
||||
className="flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left transition-colors hover:bg-accent"
|
||||
key={m.id}
|
||||
onClick={() => startConversation(m.id)}
|
||||
type="button"
|
||||
>
|
||||
<Avatar className="size-8">
|
||||
<AvatarFallback>{initials(m.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate text-foreground text-sm">
|
||||
{m.name}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</DialogPanel>
|
||||
</DialogPopup>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { apiFetch } from "@/lib/api-client";
|
||||
|
||||
// Messaging shapes. Mirror the backend `src/types/messaging.ts`.
|
||||
export type Participant = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type ConversationMessage = {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
senderId: string;
|
||||
senderName: string;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type ConversationSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
isGroup: boolean;
|
||||
participants: Participant[];
|
||||
lastMessage: ConversationMessage | null;
|
||||
unread: boolean;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export function listConversations(): Promise<ConversationSummary[]> {
|
||||
return apiFetch<ConversationSummary[]>("/api/conversations");
|
||||
}
|
||||
|
||||
export function listClinicMembers(): Promise<Participant[]> {
|
||||
return apiFetch<Participant[]>("/api/conversations/members");
|
||||
}
|
||||
|
||||
export function createConversation(input: {
|
||||
participantIds: string[];
|
||||
name?: string | null;
|
||||
}): Promise<ConversationSummary> {
|
||||
return apiFetch<ConversationSummary>("/api/conversations", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function getMessages(
|
||||
conversationId: string,
|
||||
): Promise<ConversationMessage[]> {
|
||||
return apiFetch<ConversationMessage[]>(
|
||||
`/api/conversations/${conversationId}/messages`,
|
||||
);
|
||||
}
|
||||
|
||||
// REST fallback for sending (the realtime path is the socket "message:send").
|
||||
export function sendMessageRest(
|
||||
conversationId: string,
|
||||
body: string,
|
||||
): Promise<ConversationMessage> {
|
||||
return apiFetch<ConversationMessage>(
|
||||
`/api/conversations/${conversationId}/messages`,
|
||||
{ method: "POST", body: JSON.stringify({ body }) },
|
||||
);
|
||||
}
|
||||
|
||||
export function markConversationRead(conversationId: string): Promise<void> {
|
||||
return apiFetch<void>(`/api/conversations/${conversationId}/read`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { io, type Socket } from "socket.io-client";
|
||||
|
||||
import { API_BASE_URL } from "@/lib/api-client";
|
||||
|
||||
// A single shared Socket.io connection to the backend, authenticated by the
|
||||
// Better Auth session cookie (withCredentials). Used by messaging and
|
||||
// notifications. Created lazily on first use in the browser.
|
||||
let socket: Socket | null = null;
|
||||
|
||||
export function getSocket(): Socket {
|
||||
if (!socket) {
|
||||
socket = io(API_BASE_URL, {
|
||||
withCredentials: true,
|
||||
transports: ["websocket", "polling"],
|
||||
});
|
||||
}
|
||||
return socket;
|
||||
}
|
||||
Generated
+86
@@ -44,6 +44,7 @@
|
||||
"react-jsx-parser": "^2.4.1",
|
||||
"shadcn": "^4.8.3",
|
||||
"shiki": "^4.1.0",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"streamdown": "^2.5.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tokenlens": "^1.3.1",
|
||||
@@ -2836,6 +2837,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@socket.io/component-emitter": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
@@ -6971,6 +6978,28 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-client": {
|
||||
"version": "6.6.5",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.5.tgz",
|
||||
"integrity": "sha512-QCwxUDULPlXv8F6tqMMKx5dNkTe6OaBYRMPYeXKBlyOoKvAmE0ac6pW7fFhSscJ/5SI7666/U/B+MElbsrJlIg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-parser": "~5.2.1",
|
||||
"ws": "~8.20.1",
|
||||
"xmlhttprequest-ssl": "~2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-parser": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.22.1",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.1.tgz",
|
||||
@@ -13800,6 +13829,34 @@
|
||||
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/socket.io-client": {
|
||||
"version": "4.8.3",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
|
||||
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-client": "~6.6.1",
|
||||
"socket.io-parser": "~4.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser": {
|
||||
"version": "4.2.6",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
||||
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
@@ -15260,6 +15317,27 @@
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.20.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
|
||||
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/wsl-utils": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz",
|
||||
@@ -15276,6 +15354,14 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlhttprequest-ssl": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"react-jsx-parser": "^2.4.1",
|
||||
"shadcn": "^4.8.3",
|
||||
"shiki": "^4.1.0",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"streamdown": "^2.5.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tokenlens": "^1.3.1",
|
||||
|
||||
Reference in New Issue
Block a user