@adens/openwa 0.1.0

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/LICENSE +21 -0
  3. package/README.md +319 -0
  4. package/bin/openwa.js +11 -0
  5. package/favicon.ico +0 -0
  6. package/logo-long.png +0 -0
  7. package/logo-square.png +0 -0
  8. package/package.json +69 -0
  9. package/prisma/schema.prisma +182 -0
  10. package/server/config.js +29 -0
  11. package/server/database/client.js +11 -0
  12. package/server/database/init.js +28 -0
  13. package/server/express/create-app.js +349 -0
  14. package/server/express/openapi.js +853 -0
  15. package/server/index.js +163 -0
  16. package/server/services/api-key-service.js +131 -0
  17. package/server/services/auth-service.js +162 -0
  18. package/server/services/chat-service.js +1014 -0
  19. package/server/services/session-service.js +81 -0
  20. package/server/socket/register.js +127 -0
  21. package/server/utils/avatar.js +34 -0
  22. package/server/utils/paths.js +29 -0
  23. package/server/whatsapp/adapters/mock-adapter.js +47 -0
  24. package/server/whatsapp/adapters/wwebjs-adapter.js +263 -0
  25. package/server/whatsapp/session-manager.js +356 -0
  26. package/web/components/AppHead.js +14 -0
  27. package/web/components/AuthCard.js +170 -0
  28. package/web/components/BrandLogo.js +11 -0
  29. package/web/components/ChatWindow.js +875 -0
  30. package/web/components/ChatWindow.js.tmp +0 -0
  31. package/web/components/ContactList.js +97 -0
  32. package/web/components/ContactsPanel.js +90 -0
  33. package/web/components/EmojiPicker.js +108 -0
  34. package/web/components/MediaPreviewModal.js +146 -0
  35. package/web/components/MessageActionMenu.js +155 -0
  36. package/web/components/SessionSidebar.js +167 -0
  37. package/web/components/SettingsModal.js +266 -0
  38. package/web/components/Skeletons.js +73 -0
  39. package/web/jsconfig.json +10 -0
  40. package/web/lib/api.js +33 -0
  41. package/web/lib/socket.js +9 -0
  42. package/web/pages/_app.js +5 -0
  43. package/web/pages/dashboard.js +541 -0
  44. package/web/pages/index.js +62 -0
  45. package/web/postcss.config.js +10 -0
  46. package/web/public/favicon.ico +0 -0
  47. package/web/public/logo-long.png +0 -0
  48. package/web/public/logo-square.png +0 -0
  49. package/web/store/useAppStore.js +209 -0
  50. package/web/styles/globals.css +52 -0
  51. package/web/tailwind.config.js +36 -0
@@ -0,0 +1,875 @@
1
+ import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
2
+ import { getApiBaseUrl } from "@/lib/api";
3
+ import { MessageActionMenu } from "./MessageActionMenu";
4
+ import { MediaPreviewModal } from "./MediaPreviewModal";
5
+ import { EmojiPicker } from "./EmojiPicker";
6
+ import { SendButtonSpinner, MessagesSkeletonList } from "./Skeletons";
7
+ import { MdMoreVert, MdSend, MdEmojiEmotions, MdSearch, MdAdd, MdSettings, MdLogout, MdClose } from "react-icons/md";
8
+
9
+ function formatTime(value) {
10
+ if (!value) {
11
+ return "";
12
+ }
13
+
14
+ return new Intl.DateTimeFormat("id-ID", {
15
+ hour: "2-digit",
16
+ minute: "2-digit"
17
+ }).format(new Date(value));
18
+ }
19
+
20
+ function renderStatus(message) {
21
+ const status = message.statuses?.[message.statuses.length - 1]?.status;
22
+ if (!status || message.direction !== "outbound") {
23
+ return "";
24
+ }
25
+
26
+ return status === "read" ? "Read" : status === "delivered" ? "Delivered" : "Sent";
27
+ }
28
+
29
+ function previewReply(message) {
30
+ if (!message) {
31
+ return "";
32
+ }
33
+
34
+ if (message.body) {
35
+ return message.body;
36
+ }
37
+
38
+ if (message.mediaFile?.originalName) {
39
+ return message.mediaFile.originalName;
40
+ }
41
+
42
+ return "Attachment";
43
+ }
44
+
45
+ function initials(value) {
46
+ return String(value || "?")
47
+ .slice(0, 2)
48
+ .toUpperCase();
49
+ }
50
+
51
+ function ChatAvatar({ src, label }) {
52
+ if (src) {
53
+ return <img src={src} alt={label} className="h-11 w-11 rounded-2xl object-cover" />;
54
+ }
55
+
56
+ return <div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-[#2e2f2f] text-sm font-semibold text-white">{initials(label)}</div>;
57
+ }
58
+
59
+ function renderMediaPreview(message) {
60
+ if (!message.mediaFile) {
61
+ return null;
62
+ }
63
+
64
+ const mediaUrl = `${getApiBaseUrl()}/${message.mediaFile.relativePath}`;
65
+ const mimeType = String(message.mediaFile.mimeType || "");
66
+ const isSticker = message.type === "sticker" || mimeType === "image/webp";
67
+
68
+ if (isSticker) {
69
+ return (
70
+ <a href={mediaUrl} target="_blank" rel="noreferrer" className="mb-2 inline-flex overflow-hidden rounded-2xl bg-transparent">
71
+ <img src={mediaUrl} alt={message.mediaFile.originalName} className="h-36 w-36 object-contain drop-shadow-sm" />
72
+ </a>
73
+ );
74
+ }
75
+
76
+ if (mimeType.startsWith("image/")) {
77
+ return (
78
+ <a href={mediaUrl} target="_blank" rel="noreferrer" className="mb-2 block overflow-hidden rounded-2xl">
79
+ <img src={mediaUrl} alt={message.mediaFile.originalName} className="max-h-[320px] w-full rounded-2xl object-cover" />
80
+ </a>
81
+ );
82
+ }
83
+
84
+ if (mimeType.startsWith("video/")) {
85
+ return (
86
+ <video controls className="mb-2 max-h-[320px] w-full rounded-2xl bg-black">
87
+ <source src={mediaUrl} type={mimeType} />
88
+ </video>
89
+ );
90
+ }
91
+
92
+ if (mimeType.startsWith("audio/")) {
93
+ return <audio controls className="mb-2 w-full"><source src={mediaUrl} type={mimeType} /></audio>;
94
+ }
95
+
96
+ return (
97
+ <a href={mediaUrl} target="_blank" rel="noreferrer" className="mb-2 inline-flex rounded-xl bg-white/10 px-3 py-2 text-sm font-medium text-white underline-offset-2 hover:underline">
98
+ {message.mediaFile.originalName}
99
+ </a>
100
+ );
101
+ }
102
+
103
+ function isImageFile(mimeType) {
104
+ return mimeType && mimeType.startsWith("image/") && mimeType !== "image/webp";
105
+ }
106
+
107
+ function groupConsecutiveImages(messages) {
108
+ const groups = [];
109
+ let currentGroup = null;
110
+ const TWO_MINUTES = 2 * 60 * 1000;
111
+
112
+ for (let i = 0; i < messages.length; i++) {
113
+ const message = messages[i];
114
+ const mimeType = String(message.mediaFile?.mimeType || "");
115
+ const isImage = message.mediaFile && isImageFile(mimeType);
116
+
117
+ if (isImage && currentGroup === null) {
118
+ // Start a new image group
119
+ currentGroup = {
120
+ type: "image-group",
121
+ messages: [message],
122
+ direction: message.direction,
123
+ startTime: new Date(message.createdAt).getTime()
124
+ };
125
+ } else if (
126
+ isImage &&
127
+ currentGroup &&
128
+ currentGroup.type === "image-group" &&
129
+ currentGroup.direction === message.direction &&
130
+ (new Date(message.createdAt).getTime() - currentGroup.startTime) <= TWO_MINUTES
131
+ ) {
132
+ // Add to current group
133
+ currentGroup.messages.push(message);
134
+ } else {
135
+ // Not consecutive, save current group if exists
136
+ if (currentGroup) {
137
+ groups.push(currentGroup);
138
+ currentGroup = null;
139
+ }
140
+ // Add single message
141
+ if (!isImage) {
142
+ groups.push({ type: "single", message });
143
+ }
144
+ }
145
+ }
146
+
147
+ // Don't forget the last group
148
+ if (currentGroup) {
149
+ groups.push(currentGroup);
150
+ }
151
+
152
+ return groups;
153
+ }
154
+
155
+ function renderGridImage(group, onImageClick) {
156
+ const images = group.messages.map(msg => msg.mediaFile).filter(Boolean);
157
+ if (images.length === 0) return null;
158
+
159
+ if (images.length === 1) {
160
+ const img = images[0];
161
+ const mediaUrl = `${getApiBaseUrl()}/${img.relativePath}`;
162
+ return (
163
+ <img
164
+ src={mediaUrl}
165
+ alt={img.originalName}
166
+ className="mb-2 h-24 w-24 cursor-pointer rounded-2xl object-cover"
167
+ onClick={() => onImageClick({
168
+ mediaUrl,
169
+ relativePath: img.relativePath,
170
+ mimeType: img.mimeType,
171
+ originalName: img.originalName,
172
+ isImage: true
173
+ })}
174
+ />
175
+ );
176
+ }
177
+
178
+ return (
179
+ <div className="mb-2 grid grid-cols-2 gap-1">
180
+ {images.map((img, idx) => {
181
+ const mediaUrl = `${getApiBaseUrl()}/${img.relativePath}`;
182
+ return (
183
+ <img
184
+ key={idx}
185
+ src={mediaUrl}
186
+ alt={img.originalName}
187
+ className="h-32 w-32 cursor-pointer rounded-lg object-cover"
188
+ onClick={() => onImageClick({
189
+ mediaUrl,
190
+ relativePath: img.relativePath,
191
+ mimeType: img.mimeType,
192
+ originalName: img.originalName,
193
+ isImage: true
194
+ })}
195
+ />
196
+ );
197
+ })}
198
+ </div>
199
+ );
200
+ }
201
+
202
+ function renderMediaPreviewWithCallback(message, onImageClick) {
203
+ if (!message.mediaFile) {
204
+ return null;
205
+ }
206
+
207
+ const mediaUrl = `${getApiBaseUrl()}/${message.mediaFile.relativePath}`;
208
+ const mimeType = String(message.mediaFile.mimeType || "");
209
+ const isSticker = message.type === "sticker" || mimeType === "image/webp";
210
+
211
+ if (isSticker) {
212
+ return (
213
+ <a href={mediaUrl} target="_blank" rel="noreferrer" className="mb-2 inline-flex overflow-hidden rounded-2xl bg-transparent">
214
+ <img src={mediaUrl} alt={message.mediaFile.originalName} className="h-36 w-36 object-contain drop-shadow-sm" />
215
+ </a>
216
+ );
217
+ }
218
+
219
+ if (isImageFile(mimeType)) {
220
+ return (
221
+ <img
222
+ src={mediaUrl}
223
+ alt={message.mediaFile.originalName}
224
+ className="mb-2 max-h-[320px] w-full cursor-pointer rounded-2xl object-cover"
225
+ onClick={() => onImageClick && onImageClick({
226
+ mediaUrl,
227
+ relativePath: message.mediaFile.relativePath,
228
+ mimeType: message.mediaFile.mimeType,
229
+ originalName: message.mediaFile.originalName,
230
+ isImage: true
231
+ })}
232
+ />
233
+ );
234
+ }
235
+
236
+ if (mimeType.startsWith("video/")) {
237
+ return (
238
+ <video controls className="mb-2 max-h-[320px] w-full rounded-2xl bg-black">
239
+ <source src={mediaUrl} type={mimeType} />
240
+ </video>
241
+ );
242
+ }
243
+
244
+ if (mimeType.startsWith("audio/")) {
245
+ return <audio controls className="mb-2 w-full"><source src={mediaUrl} type={mimeType} /></audio>;
246
+ }
247
+
248
+ return (
249
+ <a href={mediaUrl} target="_blank" rel="noreferrer" className="mb-2 inline-flex rounded-xl bg-white/10 px-3 py-2 text-sm font-medium text-white underline-offset-2 hover:underline">
250
+ {message.mediaFile.originalName}
251
+ </a>
252
+ );
253
+ }
254
+
255
+ export const ChatWindow = forwardRef(function ChatWindow({
256
+ chat,
257
+ messages,
258
+ chats,
259
+ typingState,
260
+ loading,
261
+ messagesLoading,
262
+ loadingOlder,
263
+ hasMoreMessages,
264
+ messageQuery,
265
+ onMessageQueryChange,
266
+ onLoadOlder,
267
+ onSendMessage,
268
+ onSendMedia,
269
+ onTyping,
270
+ onDeleteMessage,
271
+ onForwardMessage,
272
+ onOpenContacts,
273
+ onOpenSettings,
274
+ onLogout
275
+ }, ref) {
276
+ const [draft, setDraft] = useState("");
277
+ const [busy, setBusy] = useState(false);
278
+ const [uploading, setUploading] = useState(false);
279
+ const [replyTo, setReplyTo] = useState(null);
280
+ const [forwardingMessageId, setForwardingMessageId] = useState(null);
281
+ const [forwardTargetChatId, setForwardTargetChatId] = useState("");
282
+ const [searchOpen, setSearchOpen] = useState(Boolean(messageQuery));
283
+ const [searchResultIndex, setSearchResultIndex] = useState(0);
284
+ const [hoveredMessageId, setHoveredMessageId] = useState(null);
285
+ const [activeMenuMessageId, setActiveMenuMessageId] = useState(null);
286
+ const [selectedMediaModal, setSelectedMediaModal] = useState(null);
287
+ const [emojiPickerOpen, setEmojiPickerOpen] = useState(false);
288
+ const [pendingFiles, setPendingFiles] = useState([]);
289
+ const composerRef = useRef(null);
290
+ const searchInputRef = useRef(null);
291
+ const messagesViewportRef = useRef(null);
292
+ const messagesEndRef = useRef(null);
293
+ const pendingOpenChatScrollRef = useRef(false);
294
+ const previousMessagesCountRef = useRef(0);
295
+ const fileInputRef = useRef(null);
296
+ const menuTriggerRef = useRef(null);
297
+ const emojiTriggerRef = useRef(null);
298
+
299
+ const searchResults = useMemo(() => {
300
+ const query = String(messageQuery || "").trim().toLowerCase();
301
+ if (!query) {
302
+ return [];
303
+ }
304
+
305
+ return messages
306
+ .map((message, index) => {
307
+ const matches = [message.body, message.sender, message.replyTo?.body, message.mediaFile?.originalName]
308
+ .filter(Boolean)
309
+ .some((value) => value.toLowerCase().includes(query));
310
+ return matches ? index : -1;
311
+ })
312
+ .filter((index) => index !== -1);
313
+ }, [messageQuery, messages]);
314
+
315
+ const filteredMessages = useMemo(() => {
316
+ if (!messageQuery) {
317
+ return messages;
318
+ }
319
+ return messages;
320
+ }, [messageQuery, messages]);
321
+
322
+ const forwardTargets = chats.filter((item) => item.id !== chat?.id);
323
+
324
+ const handleSubmit = async (event) => {
325
+ event.preventDefault();
326
+ if (pendingFiles.length > 0) {
327
+ await handleSendWithFiles();
328
+ } else if (draft.trim()) {
329
+ await sendDraft();
330
+ }
331
+ };
332
+
333
+ const sendDraft = async () => {
334
+ if (!draft.trim()) {
335
+ return;
336
+ }
337
+
338
+ setBusy(true);
339
+
340
+ try {
341
+ await onSendMessage({ body: draft.trim(), replyToId: replyTo?.id || null });
342
+ setDraft("");
343
+ setReplyTo(null);
344
+ onTyping(false);
345
+ } finally {
346
+ setBusy(false);
347
+ }
348
+ };
349
+
350
+ const handleFile = async (event) => {
351
+ const files = event.target.files;
352
+ if (!files || files.length === 0) {
353
+ return;
354
+ }
355
+
356
+ const newPendingFiles = [];
357
+ for (let file of files) {
358
+ let preview = null;
359
+ const mimeType = file.type || "";
360
+
361
+ if (mimeType.startsWith("image/")) {
362
+ preview = URL.createObjectURL(file);
363
+ } else if (mimeType.startsWith("video/")) {
364
+ preview = URL.createObjectURL(file);
365
+ }
366
+
367
+ newPendingFiles.push({
368
+ file,
369
+ name: file.name,
370
+ size: file.size,
371
+ type: mimeType,
372
+ preview
373
+ });
374
+ }
375
+
376
+ setPendingFiles((current) => [...current, ...newPendingFiles]);
377
+ event.target.value = "";
378
+ };
379
+
380
+ const removePendingFile = (index) => {
381
+ setPendingFiles((current) => {
382
+ const updated = [...current];
383
+ const file = updated[index];
384
+ if (file.preview) {
385
+ URL.revokeObjectURL(file.preview);
386
+ }
387
+ updated.splice(index, 1);
388
+ return updated;
389
+ });
390
+ };
391
+
392
+ const handleSendWithFiles = async () => {
393
+ if (pendingFiles.length === 0) {
394
+ return sendDraft();
395
+ }
396
+
397
+ setBusy(true);
398
+ setUploading(true);
399
+ try {
400
+ for (let i = 0; i < pendingFiles.length; i++) {
401
+ const pendingFile = pendingFiles[i];
402
+ const isLastFile = i === pendingFiles.length - 1;
403
+ const caption = isLastFile ? draft.trim() : "";
404
+
405
+ await onSendMedia({ file: pendingFile.file, caption });
406
+ }
407
+ setDraft("");
408
+ setReplyTo(null);
409
+ setPendingFiles([]);
410
+ } finally {
411
+ setBusy(false);
412
+ setUploading(false);
413
+ }
414
+ };
415
+
416
+ const handleEmojiSelect = (emoji) => {
417
+ const textarea = composerRef.current;
418
+ if (!textarea) return;
419
+
420
+ const start = textarea.selectionStart || 0;
421
+ const end = textarea.selectionEnd || 0;
422
+ const newDraft = draft.slice(0, start) + emoji + draft.slice(end);
423
+ setDraft(newDraft);
424
+
425
+ setTimeout(() => {
426
+ textarea.focus();
427
+ textarea.setSelectionRange(start + emoji.length, start + emoji.length);
428
+ }, 0);
429
+
430
+ setEmojiPickerOpen(false);
431
+ };
432
+
433
+
434
+ const handleForward = async (messageId) => {
435
+ if (!forwardTargetChatId) {
436
+ return;
437
+ }
438
+
439
+ await onForwardMessage(messageId, forwardTargetChatId);
440
+ setForwardingMessageId(null);
441
+ setForwardTargetChatId("");
442
+ };
443
+
444
+ const handleSearchNext = () => {
445
+ if (searchResults.length === 0) return;
446
+ const nextIndex = (searchResultIndex + 1) % searchResults.length;
447
+ setSearchResultIndex(nextIndex);
448
+ scrollToSearchResult(nextIndex);
449
+ };
450
+
451
+ const handleSearchPrev = () => {
452
+ if (searchResults.length === 0) return;
453
+ const prevIndex = (searchResultIndex - 1 + searchResults.length) % searchResults.length;
454
+ setSearchResultIndex(prevIndex);
455
+ scrollToSearchResult(prevIndex);
456
+ };
457
+
458
+ const scrollToSearchResult = (resultIndex) => {
459
+ if (searchResults.length === 0) return;
460
+ const messageIndex = searchResults[resultIndex];
461
+ const messageElement = document.querySelector(`[data-message-id="${messages[messageIndex]?.id}"]`);
462
+ if (messageElement && messagesViewportRef.current) {
463
+ messagesViewportRef.current.scrollTop = messageElement.offsetTop - messagesViewportRef.current.offsetTop;
464
+ }
465
+ };
466
+
467
+ useEffect(() => {
468
+ if (messageQuery && searchResults.length > 0) {
469
+ setSearchResultIndex(0);
470
+ scrollToSearchResult(0);
471
+ }
472
+ }, [messageQuery]);
473
+
474
+ useImperativeHandle(ref, () => ({
475
+ focusComposer() {
476
+ composerRef.current?.focus();
477
+ }
478
+ }), []);
479
+
480
+ useEffect(() => {
481
+ const textarea = composerRef.current;
482
+ if (!textarea) {
483
+ return;
484
+ }
485
+
486
+ textarea.style.height = "0px";
487
+ textarea.style.height = `${Math.min(textarea.scrollHeight, 128)}px`;
488
+ }, [draft]);
489
+
490
+ useEffect(() => {
491
+ if (searchOpen) {
492
+ searchInputRef.current?.focus();
493
+ }
494
+ }, [searchOpen]);
495
+
496
+ // Track when chat is opened to scroll to bottom
497
+ useEffect(() => {
498
+ pendingOpenChatScrollRef.current = true;
499
+ previousMessagesCountRef.current = 0; // Reset count when chat changes
500
+ }, [chat?.id]);
501
+
502
+ // Auto-scroll to bottom when opening a chat or when new messages arrive
503
+ useEffect(() => {
504
+ if (!chat?.id) {
505
+ return;
506
+ }
507
+
508
+ const scrollToBottom = (behavior = "smooth") => {
509
+ if (messagesViewportRef.current) {
510
+ messagesViewportRef.current.scrollTo({
511
+ top: messagesViewportRef.current.scrollHeight,
512
+ behavior
513
+ });
514
+ }
515
+ };
516
+
517
+ // If opening a new chat, always scroll to bottom
518
+ if (pendingOpenChatScrollRef.current) {
519
+ if (!messages.length) {
520
+ scrollToBottom();
521
+ pendingOpenChatScrollRef.current = false;
522
+ return;
523
+ }
524
+
525
+ // Use longer delay for initial load since DOM needs more time to render many messages
526
+ const timeoutId = setTimeout(() => {
527
+ requestAnimationFrame(() => {
528
+ requestAnimationFrame(() => {
529
+ requestAnimationFrame(() => {
530
+ scrollToBottom();
531
+ pendingOpenChatScrollRef.current = false;
532
+ });
533
+ });
534
+ });
535
+ }, 300);
536
+
537
+ return () => clearTimeout(timeoutId);
538
+ }
539
+
540
+ // If new messages arrived (not from loading older messages), scroll to bottom
541
+ const currentCount = messages.length;
542
+ const previousCount = previousMessagesCountRef.current;
543
+ previousMessagesCountRef.current = currentCount;
544
+
545
+ // Only auto-scroll if message count increased (new messages arrived, not prepended old ones)
546
+ // and we're not in the middle of loading older messages
547
+ if (currentCount > previousCount && !messagesLoading) {
548
+ const newMessageCount = currentCount - previousCount;
549
+ // Small delay to ensure DOM is updated
550
+ const timeoutId = setTimeout(() => {
551
+ scrollToBottom("smooth");
552
+ }, 50);
553
+ return () => clearTimeout(timeoutId);
554
+ }
555
+ }, [chat?.id, messages.length, messagesLoading]);
556
+
557
+ if (loading) {
558
+ return <div className="flex flex-1 items-center justify-center text-white/50">Loading dashboard...</div>;
559
+ }
560
+
561
+ if (!chat) {
562
+ return (
563
+ <div className="flex flex-1 items-center justify-center bg-[#161717] px-8 text-center text-white/50">
564
+ <div>
565
+ <p className="text-lg font-medium text-white">No chat selected</p>
566
+ <p className="mt-3 max-w-md text-sm leading-7 text-white/45">Start a new conversation from the contact selector to begin chatting.</p>
567
+ <button type="button" className="mt-5 rounded-full bg-brand-500 px-5 py-3 text-sm font-semibold text-[#10251a]" onClick={onOpenContacts}>
568
+ New chat
569
+ </button>
570
+ </div>
571
+ </div>
572
+ );
573
+ }
574
+
575
+ return (
576
+ <section className="flex min-w-0 flex-1 flex-col bg-[#161717] text-white">
577
+ <header className="flex h-[78px] shrink-0 items-center justify-between gap-4 bg-[#161717] px-6 py-3">
578
+ <div className="flex min-w-0 items-center gap-3">
579
+ <ChatAvatar src={chat.contact.avatarUrl} label={chat.contact.displayName} />
580
+ <div className="min-w-0">
581
+ <h2 className="truncate font-semibold text-white">{chat.contact.displayName}</h2>
582
+ <p className="text-sm text-white/40">{typingState?.isTyping ? `${typingState.name} is typing...` : "WhatsApp chat synced locally"}</p>
583
+ </div>
584
+ </div>
585
+
586
+ <div className="flex items-center gap-2">
587
+ {searchOpen || messageQuery ? (
588
+ <div className="flex items-center gap-2 rounded-[22px] bg-[#2e2f2f] px-4 py-2">
589
+ <input
590
+ ref={searchInputRef}
591
+ className="w-[180px] border-none bg-transparent text-sm text-white outline-none placeholder:text-white/30"
592
+ placeholder="Search messages..."
593
+ value={messageQuery}
594
+ onChange={(event) => onMessageQueryChange(event.target.value)}
595
+ />
596
+ {searchResults.length > 0 && (
597
+ <span className="text-xs text-white/60">
598
+ {searchResultIndex + 1}/{searchResults.length}
599
+ </span>
600
+ )}
601
+ {searchResults.length > 0 && (
602
+ <>
603
+ <button
604
+ type="button"
605
+ title="Previous result"
606
+ aria-label="Previous result"
607
+ className="text-sm leading-none text-white/55 transition hover:text-white"
608
+ onClick={handleSearchPrev}
609
+ >
610
+
611
+ </button>
612
+ <button
613
+ type="button"
614
+ title="Next result"
615
+ aria-label="Next result"
616
+ className="text-sm leading-none text-white/55 transition hover:text-white"
617
+ onClick={handleSearchNext}
618
+ >
619
+
620
+ </button>
621
+ </>
622
+ )}
623
+ <button
624
+ type="button"
625
+ title="Close search"
626
+ aria-label="Close search"
627
+ className="text-sm leading-none text-white/55 transition hover:text-white"
628
+ onClick={() => {
629
+ onMessageQueryChange("");
630
+ setSearchOpen(false);
631
+ }}
632
+ >
633
+ <MdClose className="w-4 h-4" />
634
+ </button>
635
+ </div>
636
+ ) : null}
637
+ <button type="button" title="Search" aria-label="Search" className="flex h-10 w-10 items-center justify-center rounded-full bg-[#2e2f2f] text-base leading-none text-white transition hover:bg-[#3a3b3b]" onClick={() => setSearchOpen(true)}><MdSearch className="w-5 h-5" /></button>
638
+ <button type="button" title="New chat" aria-label="New chat" className="flex h-10 w-10 items-center justify-center rounded-full bg-[#2e2f2f] text-lg leading-none text-white transition hover:bg-[#3a3b3b]" onClick={onOpenContacts}><MdAdd className="w-5 h-5" /></button>
639
+ <button type="button" title="Settings" aria-label="Settings" className="flex h-10 w-10 items-center justify-center rounded-full bg-[#2e2f2f] text-base leading-none text-white transition hover:bg-[#3a3b3b]" onClick={onOpenSettings}><MdSettings className="w-5 h-5" /></button>
640
+ <button type="button" title="Logout" aria-label="Logout" className="flex h-10 w-10 items-center justify-center rounded-full bg-[#2e2f2f] text-base leading-none text-white transition hover:bg-[#3a3b3b]" onClick={onLogout}><MdLogout className="w-5 h-5" /></button>
641
+ </div>
642
+ </header>
643
+
644
+ <div ref={messagesViewportRef} className="flex-1 overflow-y-auto bg-[#161717] px-8 py-5">
645
+ <div className="mb-5 flex justify-center">
646
+ <button
647
+ type="button"
648
+ className="rounded-full bg-[#2e2f2f] px-4 py-2 text-xs font-medium text-white/60 transition hover:text-white disabled:opacity-40"
649
+ onClick={onLoadOlder}
650
+ disabled={!hasMoreMessages || loadingOlder}
651
+ >
652
+ {loadingOlder ? "Loading..." : hasMoreMessages ? "Load older messages" : "All messages loaded"}
653
+ </button>
654
+ </div>
655
+
656
+ {messagesLoading ? (
657
+ <MessagesSkeletonList />
658
+ ) : null}
659
+
660
+ <div className="space-y-3">
661
+ {(() => {
662
+ const groupedMessages = groupConsecutiveImages(messages);
663
+ return groupedMessages.map((group, groupIndex) => {
664
+ if (group.type === "image-group") {
665
+ // Render grouped images
666
+ const firstMessage = group.messages[0];
667
+ const outbound = firstMessage.direction === "outbound";
668
+ // Merge captions from all images in the group
669
+ const captions = group.messages
670
+ .map(msg => msg.body)
671
+ .filter(Boolean)
672
+ .join("\n");
673
+
674
+ return (
675
+ <div key={`group-${groupIndex}`} className={`flex ${outbound ? "justify-end" : "justify-start"}`}>
676
+ <div
677
+ className={`max-w-[72%] rounded-[18px] px-4 py-3 shadow-[0_16px_32px_rgba(0,0,0,0.18)] transition-colors relative ${
678
+ outbound ? "bg-[#144d37]" : "bg-[#2e2f2f]"
679
+ }`}
680
+ >
681
+ {renderGridImage(group, (media) => setSelectedMediaModal(media))}
682
+ {captions ? <p className="mt-3 whitespace-pre-wrap text-sm leading-6 text-white/88">{captions}</p> : null}
683
+ <div className="mt-3 flex items-center justify-end gap-2 text-[11px] text-white/35">
684
+ <span>{formatTime(firstMessage.createdAt)}</span>
685
+ {outbound ? <span>{renderStatus(firstMessage)}</span> : null}
686
+ </div>
687
+ </div>
688
+ </div>
689
+ );
690
+ } else {
691
+ // Render single message
692
+ const message = group.message;
693
+ const outbound = message.direction === "outbound";
694
+ const messageIndexInAll = messages.indexOf(message);
695
+ const isSearchResult = messageQuery && searchResults.includes(messageIndexInAll);
696
+ const isCurrentSearchResult = isSearchResult && searchResults[searchResultIndex] === messageIndexInAll;
697
+
698
+ return (
699
+ <div key={message.id} data-message-id={message.id} className={`flex ${outbound ? "justify-end" : "justify-start"}`}>
700
+ <div
701
+ className={`max-w-[72%] rounded-[18px] px-4 py-3 shadow-[0_16px_32px_rgba(0,0,0,0.18)] transition-colors relative ${
702
+ isCurrentSearchResult
703
+ ? "ring-2 ring-brand-500 " + (outbound ? "bg-[#1a5f41]" : "bg-[#3a4a4a]")
704
+ : isSearchResult
705
+ ? "ring-1 ring-brand-500/50 " + (outbound ? "bg-[#144d37]" : "bg-[#2e2f2f]")
706
+ : outbound ? "bg-[#144d37]" : "bg-[#2e2f2f]"
707
+ }`}
708
+ onMouseEnter={() => setHoveredMessageId(message.id)}
709
+ onMouseLeave={() => setHoveredMessageId(null)}
710
+ >
711
+ {message.replyTo ? (
712
+ <div className="mb-2 rounded-2xl border-l-4 border-brand-500 bg-white/[0.04] px-3 py-2 text-xs text-white/55">
713
+ <span className="font-semibold text-white">{message.replyTo.direction === "outbound" ? "Anda" : chat.contact.displayName}</span>
714
+ <p className="mt-1 truncate">{previewReply(message.replyTo)}</p>
715
+ </div>
716
+ ) : null}
717
+
718
+ {renderMediaPreviewWithCallback(message, (media) => setSelectedMediaModal(media))}
719
+ {message.body ? <p className="whitespace-pre-wrap text-sm leading-6 text-white/88">{message.body}</p> : null}
720
+
721
+ <div className="mt-3 flex items-center justify-end gap-3 text-[11px] text-white/35">
722
+ <span>{formatTime(message.createdAt)}</span>
723
+ {outbound ? <span>{renderStatus(message)}</span> : null}
724
+ </div>
725
+
726
+ {hoveredMessageId === message.id && (
727
+ <div className="absolute right-2 top-2 flex items-center gap-1">
728
+ <button
729
+ type="button"
730
+ ref={menuTriggerRef}
731
+ className="flex h-8 w-8 items-center justify-center rounded-full bg-[#2e2f2f] text-white/60 transition hover:bg-[#3a3b3b] hover:text-white"
732
+ onClick={() => setActiveMenuMessageId((current) => (current === message.id ? null : message.id))}
733
+ title="More options"
734
+ >
735
+ <MdMoreVert className="w-5 h-5" />
736
+ </button>
737
+ <MessageActionMenu
738
+ isOpen={activeMenuMessageId === message.id}
739
+ onClose={() => setActiveMenuMessageId(null)}
740
+ message={message}
741
+ onReply={() => setReplyTo(message)}
742
+ onDelete={() => onDeleteMessage(message.id)}
743
+ onForward={() => {
744
+ setForwardingMessageId((current) => (current === message.id ? null : message.id));
745
+ setForwardTargetChatId("");
746
+ }}
747
+ isOutbound={outbound}
748
+ triggerRef={menuTriggerRef}
749
+ />
750
+ </div>
751
+ )}
752
+
753
+ {forwardingMessageId === message.id ? (
754
+ <div className="mt-3 flex flex-wrap gap-2 rounded-2xl bg-white/[0.04] p-3">
755
+ <select
756
+ className="min-w-[200px] flex-1 rounded-xl border border-white/10 bg-[#0b141a] px-3 py-2 text-sm text-white outline-none"
757
+ value={forwardTargetChatId}
758
+ onChange={(event) => setForwardTargetChatId(event.target.value)}
759
+ >
760
+ <option value="">Select target chat</option>
761
+ {forwardTargets.map((target) => (
762
+ <option key={target.id} value={target.id}>
763
+ {target.contact.displayName}
764
+ </option>
765
+ ))}
766
+ </select>
767
+ <button type="button" className="rounded-xl bg-brand-500 px-4 py-2 text-sm font-semibold text-[#10251a]" onClick={() => handleForward(message.id)}>Send</button>
768
+ </div>
769
+ ) : null}
770
+ </div>
771
+ </div>
772
+ );
773
+ }
774
+ });
775
+ })()}
776
+ <div ref={messagesEndRef} />
777
+ </div>
778
+ </div>
779
+
780
+ <form className="shrink-0 bg-[#161717] px-6 py-3" onSubmit={handleSubmit}>
781
+ {replyTo ? (
782
+ <div className="mb-3 flex items-start justify-between rounded-2xl bg-[#2e2f2f] px-4 py-3">
783
+ <div className="min-w-0">
784
+ <p className="text-xs uppercase tracking-[0.22em] text-brand-100">Reply</p>
785
+ <p className="mt-1 truncate text-sm text-white/55">{previewReply(replyTo)}</p>
786
+ </div>
787
+ <button type="button" className="text-sm text-white/45 hover:text-white" onClick={() => setReplyTo(null)}><MdClose className="w-4 h-4" /></button>
788
+ </div>
789
+ ) : null}
790
+
791
+ {pendingFiles.length > 0 && (
792
+ <div className="mb-3 flex flex-wrap gap-2 rounded-2xl bg-white/[0.04] p-3">
793
+ {pendingFiles.map((file, index) => (
794
+ <div key={index} className="relative">
795
+ {file.preview && file.type.startsWith("image/") ? (
796
+ <img src={file.preview} alt={file.name} className="h-16 w-16 rounded-lg object-cover" />
797
+ ) : file.preview && file.type.startsWith("video/") ? (
798
+ <video src={file.preview} className="h-16 w-16 rounded-lg object-cover" />
799
+ ) : (
800
+ <div className="flex h-16 w-16 items-center justify-center rounded-lg bg-[#2e2f2f] text-sm font-medium text-white/60">
801
+ {file.name.split(".").pop()?.toUpperCase() || "FILE"}
802
+ </div>
803
+ )}
804
+ <button
805
+ type="button"
806
+ onClick={() => removePendingFile(index)}
807
+ className="absolute -right-2 -top-2 flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-xs text-white transition hover:bg-red-600"
808
+ >
809
+ <MdClose className="w-4 h-4" />
810
+ </button>
811
+ </div>
812
+ ))}
813
+ </div>
814
+ )}
815
+
816
+ <div className="flex items-center gap-2 relative">
817
+ <label className="flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-full bg-[#2e2f2f] text-[24px] leading-none text-white/60 transition hover:bg-[#3a3b3b] hover:text-white">
818
+ <MdAdd className="w-5 h-5" />
819
+ <input ref={fileInputRef} type="file" className="hidden" onChange={handleFile} multiple accept="image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.txt" />
820
+ </label>
821
+ <button
822
+ type="button"
823
+ ref={emojiTriggerRef}
824
+ className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#2e2f2f] text-[20px] transition hover:bg-[#3a3b3b]"
825
+ onClick={() => setEmojiPickerOpen(!emojiPickerOpen)}
826
+ title="Emoji"
827
+ >
828
+ <MdEmojiEmotions className="w-5 h-5" />
829
+ </button>
830
+ {emojiPickerOpen && (
831
+ <div className="absolute bottom-full left-0 z-50">
832
+ <EmojiPicker
833
+ isOpen={emojiPickerOpen}
834
+ onClose={() => setEmojiPickerOpen(false)}
835
+ onEmojiSelect={handleEmojiSelect}
836
+ triggerRef={emojiTriggerRef}
837
+ />
838
+ </div>
839
+ )}
840
+ <div className="flex flex-1 items-center rounded-[22px] bg-[#2e2f2f] px-4 py-2">
841
+ <textarea
842
+ ref={composerRef}
843
+ rows={1}
844
+ className="min-h-[20px] w-full resize-none overflow-y-auto border-none bg-transparent px-1 py-0.5 text-sm leading-5 text-white outline-none placeholder:text-white/30 disabled:opacity-60"
845
+ placeholder="Type a message"
846
+ value={draft}
847
+ onChange={(event) => {
848
+ setDraft(event.target.value);
849
+ onTyping(Boolean(event.target.value));
850
+ }}
851
+ onKeyDown={(event) => {
852
+ if (event.key === "Enter" && !event.shiftKey) {
853
+ event.preventDefault();
854
+ if (!busy && !uploading) {
855
+ sendDraft();
856
+ }
857
+ }
858
+ }}
859
+ disabled={busy || uploading}
860
+ />
861
+ </div>
862
+ <button type="submit" className="flex h-10 w-10 items-center justify-center rounded-full bg-brand-500 text-sm font-semibold leading-none text-[#10251a] transition hover:bg-brand-600 disabled:cursor-not-allowed disabled:opacity-60" disabled={busy || uploading}>
863
+ {busy || uploading ? <SendButtonSpinner /> : <MdSend className="w-5 h-5" />}
864
+ </button>
865
+ </div>
866
+ </form>
867
+ {selectedMediaModal && (
868
+ <MediaPreviewModal
869
+ media={selectedMediaModal}
870
+ onClose={() => setSelectedMediaModal(null)}
871
+ />
872
+ )}
873
+ </section>
874
+ );
875
+ });