create-stackflow 1.0.5 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,661 @@
1
+ /** WhatsApp-style chat templates (chat requests + 1:1 messaging + Socket.IO). */
2
+
3
+ export function chatBackendFiles(context, e, { im, imn, esm }) {
4
+ if (!context.socketio) return {};
5
+
6
+ const relImport = (names, path) => imn(names, path, true);
7
+ const defaultExport = (expr) => (esm ? `export default ${expr};` : `module.exports = ${expr};`);
8
+
9
+ return {
10
+ [`src/models/chatRequestModel.${e}`]: `${im("mongoose", "mongoose")}
11
+
12
+ const chatRequestSchema = new mongoose.Schema({
13
+ from: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true },
14
+ to: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true },
15
+ status: { type: String, enum: ["pending", "accepted", "rejected"], default: "pending" }
16
+ }, { timestamps: true });
17
+
18
+ chatRequestSchema.index({ from: 1, to: 1 }, { unique: true });
19
+
20
+ ${esm ? "export const ChatRequest = mongoose.model(\"ChatRequest\", chatRequestSchema);" : "const ChatRequest = mongoose.model(\"ChatRequest\", chatRequestSchema);\nmodule.exports = { ChatRequest };"}
21
+ `,
22
+ [`src/models/conversationModel.${e}`]: `${im("mongoose", "mongoose")}
23
+
24
+ const conversationSchema = new mongoose.Schema({
25
+ participants: [{ type: mongoose.Schema.Types.ObjectId, ref: "User", required: true }],
26
+ lastMessageAt: { type: Date, default: Date.now }
27
+ }, { timestamps: true });
28
+
29
+ conversationSchema.index({ participants: 1 });
30
+
31
+ ${esm ? "export const Conversation = mongoose.model(\"Conversation\", conversationSchema);" : "const Conversation = mongoose.model(\"Conversation\", conversationSchema);\nmodule.exports = { Conversation };"}
32
+ `,
33
+ [`src/models/messageModel.${e}`]: `${im("mongoose", "mongoose")}
34
+
35
+ const messageSchema = new mongoose.Schema({
36
+ conversation: { type: mongoose.Schema.Types.ObjectId, ref: "Conversation", required: true },
37
+ sender: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true },
38
+ text: { type: String, required: true, trim: true }
39
+ }, { timestamps: true });
40
+
41
+ messageSchema.index({ conversation: 1, createdAt: -1 });
42
+
43
+ ${esm ? "export const Message = mongoose.model(\"Message\", messageSchema);" : "const Message = mongoose.model(\"Message\", messageSchema);\nmodule.exports = { Message };"}
44
+ `,
45
+ [`src/controllers/chatController.${e}`]: `${relImport("ChatRequest", "../models/chatRequestModel")}
46
+ ${relImport("Conversation", "../models/conversationModel")}
47
+ ${relImport("Message", "../models/messageModel")}
48
+ ${relImport("User", "../models/userModel")}
49
+
50
+ function publicUser(user) {
51
+ return { id: user._id, name: user.name, email: user.email };
52
+ }
53
+
54
+ ${esm ? "export" : ""} async function searchUsers(req, res, next) {
55
+ try {
56
+ const q = String(req.query.q || "").trim();
57
+ if (!q) return res.json({ data: [] });
58
+ const users = await User.find({
59
+ _id: { $ne: req.user._id },
60
+ $or: [{ name: new RegExp(q, "i") }, { email: new RegExp(q, "i") }]
61
+ }).select("name email").limit(20);
62
+ res.json({ data: users.map(publicUser) });
63
+ } catch (error) {
64
+ next(error);
65
+ }
66
+ }
67
+
68
+ ${esm ? "export" : ""} async function sendChatRequest(req, res, next) {
69
+ try {
70
+ const { toUserId } = req.body;
71
+ if (!toUserId) return res.status(400).json({ message: "toUserId is required" });
72
+ if (String(toUserId) === String(req.user._id)) {
73
+ return res.status(400).json({ message: "Cannot send request to yourself" });
74
+ }
75
+ const target = await User.findById(toUserId);
76
+ if (!target) return res.status(404).json({ message: "User not found" });
77
+
78
+ const existing = await ChatRequest.findOne({
79
+ $or: [
80
+ { from: req.user._id, to: toUserId },
81
+ { from: toUserId, to: req.user._id }
82
+ ]
83
+ });
84
+ if (existing?.status === "accepted") {
85
+ return res.status(409).json({ message: "You can already chat with this user" });
86
+ }
87
+ if (existing?.status === "pending") {
88
+ return res.status(409).json({ message: "Chat request already pending" });
89
+ }
90
+ if (existing?.status === "rejected") {
91
+ existing.status = "pending";
92
+ existing.from = req.user._id;
93
+ existing.to = toUserId;
94
+ await existing.save();
95
+ return res.status(201).json({ data: existing });
96
+ }
97
+
98
+ const request = await ChatRequest.create({ from: req.user._id, to: toUserId });
99
+ res.status(201).json({ data: request });
100
+ } catch (error) {
101
+ next(error);
102
+ }
103
+ }
104
+
105
+ ${esm ? "export" : ""} async function listIncomingRequests(req, res, next) {
106
+ try {
107
+ const requests = await ChatRequest.find({ to: req.user._id, status: "pending" })
108
+ .populate("from", "name email")
109
+ .sort({ createdAt: -1 });
110
+ res.json({ data: requests });
111
+ } catch (error) {
112
+ next(error);
113
+ }
114
+ }
115
+
116
+ ${esm ? "export" : ""} async function listSentRequests(req, res, next) {
117
+ try {
118
+ const requests = await ChatRequest.find({ from: req.user._id, status: "pending" })
119
+ .populate("to", "name email")
120
+ .sort({ createdAt: -1 });
121
+ res.json({ data: requests });
122
+ } catch (error) {
123
+ next(error);
124
+ }
125
+ }
126
+
127
+ ${esm ? "export" : ""} async function respondToRequest(req, res, next) {
128
+ try {
129
+ const { action } = req.body;
130
+ const request = await ChatRequest.findOne({ _id: req.params.id, to: req.user._id, status: "pending" });
131
+ if (!request) return res.status(404).json({ message: "Request not found" });
132
+
133
+ if (action === "reject") {
134
+ request.status = "rejected";
135
+ await request.save();
136
+ return res.json({ data: request });
137
+ }
138
+ if (action !== "accept") return res.status(400).json({ message: "action must be accept or reject" });
139
+
140
+ request.status = "accepted";
141
+ await request.save();
142
+
143
+ let conversation = await Conversation.findOne({
144
+ participants: { $all: [request.from, request.to], $size: 2 }
145
+ });
146
+ if (!conversation) {
147
+ conversation = await Conversation.create({
148
+ participants: [request.from, request.to]
149
+ });
150
+ }
151
+
152
+ res.json({ data: { request, conversationId: conversation._id } });
153
+ } catch (error) {
154
+ next(error);
155
+ }
156
+ }
157
+
158
+ ${esm ? "export" : ""} async function listConversations(req, res, next) {
159
+ try {
160
+ const conversations = await Conversation.find({ participants: req.user._id })
161
+ .populate("participants", "name email")
162
+ .sort({ lastMessageAt: -1 });
163
+
164
+ const enriched = await Promise.all(conversations.map(async (conversation) => {
165
+ const lastMessage = await Message.findOne({ conversation: conversation._id })
166
+ .sort({ createdAt: -1 })
167
+ .populate("sender", "name");
168
+ const peer = conversation.participants.find(
169
+ (p) => String(p._id) !== String(req.user._id)
170
+ );
171
+ return {
172
+ _id: conversation._id,
173
+ peer: peer ? publicUser(peer) : null,
174
+ lastMessage: lastMessage
175
+ ? { text: lastMessage.text, senderId: lastMessage.sender._id, createdAt: lastMessage.createdAt }
176
+ : null
177
+ };
178
+ }));
179
+
180
+ res.json({ data: enriched });
181
+ } catch (error) {
182
+ next(error);
183
+ }
184
+ }
185
+
186
+ ${esm ? "export" : ""} async function listMessages(req, res, next) {
187
+ try {
188
+ const conversation = await Conversation.findOne({
189
+ _id: req.params.conversationId,
190
+ participants: req.user._id
191
+ });
192
+ if (!conversation) return res.status(404).json({ message: "Conversation not found" });
193
+
194
+ const messages = await Message.find({ conversation: conversation._id })
195
+ .populate("sender", "name email")
196
+ .sort({ createdAt: 1 })
197
+ .limit(200);
198
+
199
+ res.json({ data: messages });
200
+ } catch (error) {
201
+ next(error);
202
+ }
203
+ }
204
+ ${esm ? "" : `
205
+ module.exports = {
206
+ searchUsers,
207
+ sendChatRequest,
208
+ listIncomingRequests,
209
+ listSentRequests,
210
+ respondToRequest,
211
+ listConversations,
212
+ listMessages
213
+ };`}
214
+ `,
215
+ [`src/routes/chatRoute.${e}`]: `${imn("Router", "express")}
216
+ ${relImport("protect", "../middleware/authMiddleware")}
217
+ ${relImport(
218
+ "searchUsers, sendChatRequest, listIncomingRequests, listSentRequests, respondToRequest, listConversations, listMessages",
219
+ "../controllers/chatController"
220
+ )}
221
+
222
+ const router = Router();
223
+
224
+ router.use(protect);
225
+ router.get("/users/search", searchUsers);
226
+ router.post("/requests", sendChatRequest);
227
+ router.get("/requests/incoming", listIncomingRequests);
228
+ router.get("/requests/sent", listSentRequests);
229
+ router.patch("/requests/:id", respondToRequest);
230
+ router.get("/conversations", listConversations);
231
+ router.get("/conversations/:conversationId/messages", listMessages);
232
+
233
+ ${defaultExport("router")}
234
+ `,
235
+ [`src/socket/chatSocket.${e}`]: `${im("jwt", "jsonwebtoken")}
236
+ ${relImport("Conversation", "../models/conversationModel")}
237
+ ${relImport("Message", "../models/messageModel")}
238
+ ${relImport("User", "../models/userModel")}
239
+
240
+ function getToken(socket) {
241
+ const auth = socket.handshake.auth?.token;
242
+ if (auth) return auth;
243
+ const header = socket.handshake.headers.authorization;
244
+ if (header?.startsWith("Bearer ")) return header.split(" ")[1];
245
+ return null;
246
+ }
247
+
248
+ ${esm ? "export" : ""} function setupChatSocket(io) {
249
+ io.use(async (socket, next) => {
250
+ try {
251
+ const token = getToken(socket);
252
+ if (!token) return next(new Error("Authentication required"));
253
+ const decoded = jwt.verify(token, process.env.JWT_SECRET || "dev_secret");
254
+ const userId = typeof decoded === "object" && "userId" in decoded ? decoded.userId : null;
255
+ const user = await User.findById(userId).select("-password");
256
+ if (!user) return next(new Error("Invalid user"));
257
+ socket.data.user = user;
258
+ next();
259
+ } catch {
260
+ next(new Error("Invalid token"));
261
+ }
262
+ });
263
+
264
+ io.on("connection", (socket) => {
265
+ socket.join(\`user:\${socket.data.user._id}\`);
266
+
267
+ socket.on("conversation:join", async ({ conversationId }) => {
268
+ const conversation = await Conversation.findOne({
269
+ _id: conversationId,
270
+ participants: socket.data.user._id
271
+ });
272
+ if (!conversation) return;
273
+ socket.join(\`conversation:\${conversationId}\`);
274
+ });
275
+
276
+ socket.on("message:send", async ({ conversationId, text }) => {
277
+ const trimmed = String(text || "").trim();
278
+ if (!trimmed) return;
279
+
280
+ const conversation = await Conversation.findOne({
281
+ _id: conversationId,
282
+ participants: socket.data.user._id
283
+ });
284
+ if (!conversation) return;
285
+
286
+ const message = await Message.create({
287
+ conversation: conversationId,
288
+ sender: socket.data.user._id,
289
+ text: trimmed
290
+ });
291
+
292
+ conversation.lastMessageAt = new Date();
293
+ await conversation.save();
294
+
295
+ const payload = {
296
+ _id: message._id,
297
+ conversationId,
298
+ text: message.text,
299
+ createdAt: message.createdAt,
300
+ sender: {
301
+ id: socket.data.user._id,
302
+ name: socket.data.user.name,
303
+ email: socket.data.user.email
304
+ }
305
+ };
306
+
307
+ io.to(\`conversation:\${conversationId}\`).emit("message:new", payload);
308
+
309
+ const peerId = conversation.participants.find(
310
+ (id) => String(id) !== String(socket.data.user._id)
311
+ );
312
+ if (peerId) {
313
+ io.to(\`user:\${peerId}\`).emit("conversation:updated", {
314
+ conversationId,
315
+ lastMessage: { text: trimmed, createdAt: message.createdAt }
316
+ });
317
+ }
318
+ });
319
+ });
320
+ }
321
+ ${esm ? "" : "\nmodule.exports = { setupChatSocket };"}
322
+ `,
323
+ };
324
+ }
325
+
326
+ export function patchMainRouteForChat(mainRouteContent, context, e) {
327
+ if (!context.socketio) return mainRouteContent;
328
+ const chatImport = `import chatRoutes from "./chatRoute.js";`;
329
+ if (mainRouteContent.includes("chatRoutes")) return mainRouteContent;
330
+ return mainRouteContent
331
+ .replace(
332
+ `import todoRoutes from "./todoRoute.js";`,
333
+ `import todoRoutes from "./todoRoute.js";\n${chatImport}`,
334
+ )
335
+ .replace(
336
+ `router.use("/todos", todoRoutes);`,
337
+ `router.use("/todos", todoRoutes);\nrouter.use("/chat", chatRoutes);`,
338
+ );
339
+ }
340
+
341
+ export function chatServerSocketBlock(context, clientUrl, { imn }) {
342
+ if (!context.socketio) return "";
343
+ return `
344
+ const io = new Server(server, {
345
+ cors: {
346
+ origin: process.env.CLIENT_URL || "${clientUrl}",
347
+ credentials: true
348
+ }
349
+ });
350
+ ${imn("setupChatSocket", "./socket/chatSocket", true)}
351
+ setupChatSocket(io);
352
+ `;
353
+ }
354
+
355
+ export function chatNextPages(context, x) {
356
+ if (!context.socketio || context.frontend !== "next") return {};
357
+
358
+ const page = `"use client";
359
+ import { Chat } from "../../pages/Chat";
360
+
361
+ export default function ChatPage() {
362
+ return <Chat />;
363
+ }
364
+ `;
365
+
366
+ return {
367
+ [`app/chat/page.${x}`]: page,
368
+ [`app/chat/[conversationId]/page.${x}`]: page,
369
+ };
370
+ }
371
+
372
+ export function chatFrontendFiles(context, x) {
373
+ if (!context.socketio) return {};
374
+
375
+ const isTs = context.language === "typescript";
376
+ const ext = isTs ? "ts" : "js";
377
+ const apiBase = context.frontend === "next"
378
+ ? "process.env.NEXT_PUBLIC_API_URL"
379
+ : "import.meta.env.VITE_API_URL";
380
+ const socketUrl = context.frontend === "next"
381
+ ? "process.env.NEXT_PUBLIC_SOCKET_URL"
382
+ : "import.meta.env.VITE_SOCKET_URL";
383
+
384
+ return {
385
+ [`lib/socket.${ext}`]: `import { io } from "socket.io-client";
386
+
387
+ const SOCKET_URL = ${socketUrl} || "http://localhost:5000";
388
+
389
+ export function createChatSocket() {
390
+ const token = typeof window !== "undefined" ? localStorage.getItem("stackflow_token") : null;
391
+ return io(SOCKET_URL, {
392
+ auth: { token },
393
+ transports: ["websocket", "polling"]
394
+ });
395
+ }
396
+ `,
397
+ [`features/chat/chatService.${ext}`]: `import { api } from "../../lib/api";
398
+
399
+ export const chatService = {
400
+ searchUsers: (q) => api.get("/chat/users/search", { params: { q } }).then((r) => r.data.data),
401
+ sendRequest: (toUserId) => api.post("/chat/requests", { toUserId }).then((r) => r.data.data),
402
+ incomingRequests: () => api.get("/chat/requests/incoming").then((r) => r.data.data),
403
+ sentRequests: () => api.get("/chat/requests/sent").then((r) => r.data.data),
404
+ respondRequest: (id, action) => api.patch(\`/chat/requests/\${id}\`, { action }).then((r) => r.data.data),
405
+ conversations: () => api.get("/chat/conversations").then((r) => r.data.data),
406
+ messages: (conversationId) =>
407
+ api.get(\`/chat/conversations/\${conversationId}/messages\`).then((r) => r.data.data)
408
+ };
409
+ `,
410
+ [`pages/Chat.${x}`]: chatPageComponent(context),
411
+ };
412
+ }
413
+
414
+ function chatPageComponent(context) {
415
+ const next = context.frontend === "next";
416
+ const navImport = next
417
+ ? `import { useRouter, useParams } from "next/navigation";`
418
+ : `import { Link, useNavigate, useParams } from "react-router-dom";`;
419
+ const navHook = next ? "const router = useRouter();" : "const navigate = useNavigate();";
420
+ const logoutNav = next ? "router.push(\"/login\");" : "navigate(\"/login\");";
421
+ const authImport = context.state === "zustand"
422
+ ? `import { useAuthStore } from "${next ? "../store/auth" : "../store/auth"}";`
423
+ : context.state === "redux-toolkit"
424
+ ? `import { useDispatch } from "react-redux";\nimport { logout as reduxLogout } from "${next ? "../redux/slices/authSlice" : "../redux/slices/authSlice"}";`
425
+ : context.state === "context-api"
426
+ ? `import { useAuthContext } from "${next ? "../store/auth" : "../store/auth"}";`
427
+ : "";
428
+
429
+ return `"use client";
430
+ import { useEffect, useMemo, useRef, useState } from "react";
431
+ ${navImport}
432
+ import { Check, LogOut, MessageCircle, Search, Send, UserPlus, X } from "lucide-react";
433
+ import { toast } from "sonner";
434
+ import { Button } from "${next ? "../components/ui/button" : "../components/ui/button"}";
435
+ import { Input } from "${next ? "../components/ui/input" : "../components/ui/input"}";
436
+ import { chatService } from "${next ? "../features/chat/chatService" : "../features/chat/chatService"}";
437
+ import { authService } from "${next ? "../features/auth/authService" : "../features/auth/authService"}";
438
+ import { createChatSocket } from "${next ? "../lib/socket" : "../lib/socket"}";
439
+ ${authImport}
440
+
441
+ export function Chat() {
442
+ ${navHook}
443
+ const params = useParams();
444
+ const activeConversationId = params?.conversationId || null;
445
+ ${context.state === "redux-toolkit" ? "const dispatch = useDispatch();" : ""}
446
+ const logoutStore = ${
447
+ context.state === "zustand"
448
+ ? "useAuthStore((s) => s.logout)"
449
+ : context.state === "redux-toolkit"
450
+ ? "() => dispatch(reduxLogout())"
451
+ : context.state === "context-api"
452
+ ? "useAuthContext().logout"
453
+ : "() => localStorage.removeItem(\"stackflow_token\")"
454
+ };
455
+
456
+ const [tab, setTab] = useState("chats");
457
+ const [query, setQuery] = useState("");
458
+ const [searchResults, setSearchResults] = useState([]);
459
+ const [incoming, setIncoming] = useState([]);
460
+ const [sent, setSent] = useState([]);
461
+ const [conversations, setConversations] = useState([]);
462
+ const [messages, setMessages] = useState([]);
463
+ const [draft, setDraft] = useState("");
464
+ const [myId, setMyId] = useState("");
465
+ const socketRef = useRef(null);
466
+
467
+ useEffect(() => {
468
+ authService.me().then((data) => setMyId(data.user.id)).catch(() => {});
469
+ }, []);
470
+
471
+ const activeConversation = useMemo(
472
+ () => conversations.find((c) => String(c._id) === String(activeConversationId)),
473
+ [conversations, activeConversationId]
474
+ );
475
+
476
+ async function refreshSidebar() {
477
+ const [conv, inc, out] = await Promise.all([
478
+ chatService.conversations(),
479
+ chatService.incomingRequests(),
480
+ chatService.sentRequests()
481
+ ]);
482
+ setConversations(conv);
483
+ setIncoming(inc);
484
+ setSent(out);
485
+ }
486
+
487
+ useEffect(() => {
488
+ refreshSidebar().catch(() => toast.error("Could not load chats"));
489
+ }, []);
490
+
491
+ useEffect(() => {
492
+ const socket = createChatSocket();
493
+ socketRef.current = socket;
494
+ socket.on("message:new", (message) => {
495
+ if (String(message.conversationId) === String(activeConversationId)) {
496
+ setMessages((prev) => [...prev, message]);
497
+ }
498
+ refreshSidebar();
499
+ });
500
+ socket.on("conversation:updated", () => refreshSidebar());
501
+ return () => socket.disconnect();
502
+ }, [activeConversationId]);
503
+
504
+ useEffect(() => {
505
+ if (!activeConversationId) {
506
+ setMessages([]);
507
+ return;
508
+ }
509
+ chatService.messages(activeConversationId).then(setMessages).catch(() => toast.error("Could not load messages"));
510
+ socketRef.current?.emit("conversation:join", { conversationId: activeConversationId });
511
+ }, [activeConversationId]);
512
+
513
+ async function onSearchUsers(value) {
514
+ setQuery(value);
515
+ if (!value.trim()) return setSearchResults([]);
516
+ const users = await chatService.searchUsers(value);
517
+ setSearchResults(users);
518
+ }
519
+
520
+ async function sendRequest(toUserId) {
521
+ await chatService.sendRequest(toUserId);
522
+ toast.success("Chat request sent");
523
+ setQuery("");
524
+ setSearchResults([]);
525
+ refreshSidebar();
526
+ }
527
+
528
+ async function respondRequest(id, action) {
529
+ const result = await chatService.respondRequest(id, action);
530
+ toast.success(action === "accept" ? "Request accepted" : "Request rejected");
531
+ await refreshSidebar();
532
+ if (action === "accept" && result?.conversationId) {
533
+ ${next ? "router.push(`/chat/${result.conversationId}`);" : "navigate(`/chat/${result.conversationId}`);"}
534
+ }
535
+ }
536
+
537
+ function openConversation(id) {
538
+ ${next ? "router.push(`/chat/${id}`);" : "navigate(`/chat/${id}`);"}
539
+ }
540
+
541
+ async function sendMessage(event) {
542
+ event.preventDefault();
543
+ const text = draft.trim();
544
+ if (!text || !activeConversationId) return;
545
+ socketRef.current?.emit("message:send", { conversationId: activeConversationId, text });
546
+ setDraft("");
547
+ }
548
+
549
+ function logout() {
550
+ logoutStore();
551
+ ${logoutNav}
552
+ }
553
+
554
+ return (
555
+ <main className="flex h-screen bg-slate-100 text-slate-950 dark:bg-slate-950 dark:text-white">
556
+ <aside className="flex w-full max-w-md flex-col border-r border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
557
+ <header className="flex items-center justify-between border-b border-slate-200 px-4 py-3 dark:border-slate-800">
558
+ <div className="flex items-center gap-2">
559
+ <MessageCircle size={20} />
560
+ <h1 className="font-semibold">StackFlow Chat</h1>
561
+ </div>
562
+ <Button variant="ghost" onClick={logout} aria-label="Logout"><LogOut size={18} /></Button>
563
+ </header>
564
+
565
+ <div className="border-b border-slate-200 p-3 dark:border-slate-800">
566
+ <div className="relative">
567
+ <Search className="absolute left-3 top-2.5 text-slate-400" size={16} />
568
+ <Input className="pl-9" placeholder="Search users by name or email" value={query} onChange={(e) => onSearchUsers(e.target.value)} />
569
+ </div>
570
+ {searchResults.length > 0 && (
571
+ <div className="mt-2 max-h-40 overflow-auto rounded-md border border-slate-200 dark:border-slate-700">
572
+ {searchResults.map((user) => (
573
+ <button key={user.id} type="button" className="flex w-full items-center justify-between px-3 py-2 text-left text-sm hover:bg-slate-50 dark:hover:bg-slate-800" onClick={() => sendRequest(user.id)}>
574
+ <span>{user.name} <span className="text-slate-500">({user.email})</span></span>
575
+ <UserPlus size={16} />
576
+ </button>
577
+ ))}
578
+ </div>
579
+ )}
580
+ </div>
581
+
582
+ <div className="flex border-b border-slate-200 dark:border-slate-800">
583
+ <button type="button" className={\`flex-1 py-2 text-sm \${tab === "chats" ? "border-b-2 border-cyan-600 font-medium" : "text-slate-500"}\`} onClick={() => setTab("chats")}>Chats</button>
584
+ <button type="button" className={\`flex-1 py-2 text-sm \${tab === "requests" ? "border-b-2 border-cyan-600 font-medium" : "text-slate-500"}\`} onClick={() => setTab("requests")}>
585
+ Requests {incoming.length ? \`(\${incoming.length})\` : ""}
586
+ </button>
587
+ </div>
588
+
589
+ <div className="flex-1 overflow-auto">
590
+ {tab === "chats" && conversations.map((conversation) => (
591
+ <button key={conversation._id} type="button" onClick={() => openConversation(conversation._id)} className={\`flex w-full flex-col gap-1 border-b border-slate-100 px-4 py-3 text-left hover:bg-slate-50 dark:border-slate-800 dark:hover:bg-slate-800 \${String(conversation._id) === String(activeConversationId) ? "bg-slate-50 dark:bg-slate-800" : ""}\`}>
592
+ <p className="font-medium">{conversation.peer?.name || "Chat"}</p>
593
+ <p className="truncate text-xs text-slate-500">{conversation.lastMessage?.text || "No messages yet"}</p>
594
+ </button>
595
+ ))}
596
+ {tab === "chats" && !conversations.length && <p className="p-4 text-sm text-slate-500">No chats yet. Send a request to start.</p>}
597
+
598
+ {tab === "requests" && (
599
+ <div className="space-y-4 p-3">
600
+ <section>
601
+ <h3 className="mb-2 text-xs font-semibold uppercase text-slate-500">Incoming</h3>
602
+ {incoming.map((request) => (
603
+ <div key={request._id} className="mb-2 flex items-center justify-between rounded-md border border-slate-200 p-2 dark:border-slate-700">
604
+ <span className="text-sm">{request.from?.name || "User"}</span>
605
+ <div className="flex gap-1">
606
+ <Button variant="ghost" onClick={() => respondRequest(request._id, "accept")} aria-label="Accept"><Check size={16} /></Button>
607
+ <Button variant="ghost" onClick={() => respondRequest(request._id, "reject")} aria-label="Reject"><X size={16} /></Button>
608
+ </div>
609
+ </div>
610
+ ))}
611
+ {!incoming.length && <p className="text-xs text-slate-500">No incoming requests</p>}
612
+ </section>
613
+ <section>
614
+ <h3 className="mb-2 text-xs font-semibold uppercase text-slate-500">Sent</h3>
615
+ {sent.map((request) => (
616
+ <div key={request._id} className="mb-2 rounded-md border border-slate-200 p-2 text-sm dark:border-slate-700">
617
+ {request.to?.name || "User"} — pending
618
+ </div>
619
+ ))}
620
+ {!sent.length && <p className="text-xs text-slate-500">No sent requests</p>}
621
+ </section>
622
+ </div>
623
+ )}
624
+ </div>
625
+ </aside>
626
+
627
+ <section className="flex flex-1 flex-col bg-[#efeae2] dark:bg-slate-900">
628
+ {activeConversation ? (
629
+ <>
630
+ <header className="border-b border-slate-200 bg-white px-4 py-3 dark:border-slate-800 dark:bg-slate-900">
631
+ <p className="font-semibold">{activeConversation.peer?.name}</p>
632
+ <p className="text-xs text-slate-500">{activeConversation.peer?.email}</p>
633
+ </header>
634
+ <div className="flex-1 space-y-2 overflow-auto p-4">
635
+ {messages.map((message) => {
636
+ const mine = String(message.sender?.id || message.sender?._id) === String(myId);
637
+ return (
638
+ <div key={message._id} className={\`flex \${mine ? "justify-end" : "justify-start"}\`}>
639
+ <div className={\`max-w-[70%] rounded-lg px-3 py-2 text-sm \${mine ? "bg-cyan-600 text-white" : "bg-white dark:bg-slate-800"}\`}>
640
+ {message.text}
641
+ </div>
642
+ </div>
643
+ );
644
+ })}
645
+ </div>
646
+ <form onSubmit={sendMessage} className="flex gap-2 border-t border-slate-200 bg-white p-3 dark:border-slate-800 dark:bg-slate-900">
647
+ <Input value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="Type a message" />
648
+ <Button type="submit" aria-label="Send"><Send size={18} /></Button>
649
+ </form>
650
+ </>
651
+ ) : (
652
+ <div className="grid flex-1 place-items-center text-slate-500">
653
+ <p>Select a chat or accept a request to start messaging</p>
654
+ </div>
655
+ )}
656
+ </section>
657
+ </main>
658
+ );
659
+ }
660
+ `;
661
+ }