@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
File without changes
@@ -0,0 +1,97 @@
1
+ import { BrandLogo } from "@/components/BrandLogo";
2
+ import { ConversationsSkeletonList } from "@/components/Skeletons";
3
+
4
+ function formatTime(value) {
5
+ if (!value) {
6
+ return "";
7
+ }
8
+
9
+ return new Intl.DateTimeFormat("en-US", {
10
+ hour: "2-digit",
11
+ minute: "2-digit"
12
+ }).format(new Date(value));
13
+ }
14
+
15
+ function initials(name) {
16
+ return String(name || "?")
17
+ .split(" ")
18
+ .map((part) => part[0])
19
+ .join("")
20
+ .slice(0, 2)
21
+ .toUpperCase();
22
+ }
23
+
24
+ function Avatar({ src, label }) {
25
+ if (src) {
26
+ return <img src={src} alt={label} className="h-12 w-12 shrink-0 rounded-2xl object-cover" />;
27
+ }
28
+
29
+ return <div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-[#2e2f2f] text-sm font-semibold text-white">{initials(label)}</div>;
30
+ }
31
+
32
+ export function ContactList({ chats, activeChatId, onSelectChat, currentUser, loading, query, onQueryChange }) {
33
+ const normalizedQuery = String(query || "").trim().toLowerCase();
34
+ const filteredChats = chats.filter((chat) =>
35
+ !normalizedQuery
36
+ || [chat.contact.displayName, chat.contact.lastMessagePreview, chat.title]
37
+ .filter(Boolean)
38
+ .some((value) => value.toLowerCase().includes(normalizedQuery))
39
+ );
40
+
41
+ return (
42
+ <aside className="flex h-full w-[360px] shrink-0 flex-col bg-[#161717]">
43
+ <div className="px-5 py-5">
44
+ <div className="flex items-center justify-center">
45
+ <div className="h-12 flex items-center">
46
+ <BrandLogo variant="long" alt="OpenWA" className="h-full" />
47
+ </div>
48
+ </div>
49
+
50
+ <div className="mt-4 rounded-[22px] bg-[#2e2f2f] px-3 py-2">
51
+ <input
52
+ className="w-full border-none bg-transparent text-sm text-white outline-none placeholder:text-white/30"
53
+ placeholder="Search conversation"
54
+ value={query}
55
+ onChange={(event) => onQueryChange(event.target.value)}
56
+ />
57
+ </div>
58
+ </div>
59
+
60
+ <div className="flex-1 overflow-y-auto px-3 py-3">
61
+ {loading ? <ConversationsSkeletonList /> : null}
62
+ {!loading && filteredChats.length === 0 ? <p className="px-3 py-4 text-sm leading-6 text-white/40">No synced conversations yet. Connect your device to load chats.</p> : null}
63
+
64
+ <div className="space-y-2">
65
+ {filteredChats.map((chat) => (
66
+ <button
67
+ key={chat.id}
68
+ type="button"
69
+ className={`flex w-full items-start gap-3 rounded-[16px] px-4 py-3 text-left transition ${
70
+ chat.id === activeChatId
71
+ ? "bg-[#2e2f2f]"
72
+ : "bg-transparent hover:bg-white/[0.05]"
73
+ }`}
74
+ onClick={() => onSelectChat(chat.id)}
75
+ >
76
+ <Avatar src={chat.contact.avatarUrl} label={chat.contact.displayName} />
77
+ <div className="min-w-0 flex-1">
78
+ <div className="flex items-center justify-between gap-3">
79
+ <h3 className="truncate font-medium text-white">{chat.contact.displayName}</h3>
80
+ <span className="shrink-0 text-[11px] text-white/35">{formatTime(chat.contact.lastMessageAt || chat.updatedAt)}</span>
81
+ </div>
82
+ <div className="mt-1 flex items-center justify-between gap-3">
83
+ <p className="truncate text-sm text-white/42">{chat.contact.lastMessagePreview || "No messages yet"}</p>
84
+ {chat.contact.unreadCount ? (
85
+ <span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-brand-500 px-1.5 text-[11px] font-bold text-[#10251a]">
86
+ {chat.contact.unreadCount}
87
+ </span>
88
+ ) : null}
89
+ </div>
90
+ </div>
91
+ </button>
92
+ ))}
93
+ </div>
94
+ </div>
95
+ </aside>
96
+ );
97
+ }
@@ -0,0 +1,90 @@
1
+ import { MdClose } from "react-icons/md";
2
+
3
+ function formatSubtitle(contact) {
4
+ if (contact.lastMessagePreview) {
5
+ return contact.lastMessagePreview;
6
+ }
7
+
8
+ return contact.externalId.replace(/@.+$/, "");
9
+ }
10
+
11
+ function initials(name) {
12
+ return String(name || "?")
13
+ .split(" ")
14
+ .map((part) => part[0])
15
+ .join("")
16
+ .slice(0, 2)
17
+ .toUpperCase();
18
+ }
19
+
20
+ function ContactAvatar({ src, label }) {
21
+ if (src) {
22
+ return <img src={src} alt={label} className="h-11 w-11 rounded-2xl object-cover" />;
23
+ }
24
+
25
+ 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>;
26
+ }
27
+
28
+ export function ContactsPanel({ contacts, loading, open, query, onQueryChange, onStartChat, onClose, startingContactId }) {
29
+ const normalizedQuery = String(query || "").trim().toLowerCase();
30
+ const filteredContacts = contacts.filter((contact) =>
31
+ !normalizedQuery
32
+ || [contact.displayName, contact.externalId, contact.lastMessagePreview]
33
+ .filter(Boolean)
34
+ .some((value) => value.toLowerCase().includes(normalizedQuery))
35
+ );
36
+
37
+ return (
38
+ <aside className={`flex h-full shrink-0 overflow-hidden bg-[#161717] transition-[width,opacity] duration-300 ${open ? "w-[320px] opacity-100" : "w-0 opacity-0"}`}>
39
+ <div className={`flex h-full w-[320px] min-w-[320px] flex-col transition-transform duration-300 ${open ? "translate-x-0" : "translate-x-8"}`}>
40
+ <div className="px-5 py-5">
41
+ <div className="flex items-start justify-between gap-3">
42
+ <div>
43
+ <p className="text-[11px] uppercase tracking-[0.24em] text-white/35">Contacts</p>
44
+ <h2 className="mt-2 text-lg font-semibold text-white">Start new message</h2>
45
+ </div>
46
+ <button type="button" className="flex h-8 w-8 items-center justify-center rounded-full bg-[#2e2f2f] text-xs leading-none text-white/60 transition hover:bg-[#3a3b3b] hover:text-white" onClick={onClose} title="Close contacts" aria-label="Close contacts">
47
+ <MdClose className="w-4 h-4" />
48
+ </button>
49
+ </div>
50
+ <div className="mt-4 rounded-[22px] bg-[#2e2f2f] px-3 py-2">
51
+ <input
52
+ className="w-full border-none bg-transparent text-sm text-white outline-none placeholder:text-white/30"
53
+ placeholder="Search contacts"
54
+ value={query}
55
+ onChange={(event) => onQueryChange(event.target.value)}
56
+ />
57
+ </div>
58
+ </div>
59
+
60
+ <div className="flex-1 overflow-y-auto px-3 py-3">
61
+ {loading ? <p className="px-3 py-4 text-sm text-white/45">Loading contacts...</p> : null}
62
+ {!loading && filteredContacts.length === 0 ? <p className="px-3 py-4 text-sm leading-6 text-white/40">No synced contacts yet. Connect your device and wait for WhatsApp sync to complete.</p> : null}
63
+
64
+ <div className="space-y-2">
65
+ {filteredContacts.map((contact) => (
66
+ <button
67
+ key={contact.id}
68
+ type="button"
69
+ className="flex w-full items-center gap-3 rounded-[16px] bg-transparent px-3 py-3 text-left transition hover:bg-white/[0.05]"
70
+ onClick={() => onStartChat(contact.id)}
71
+ >
72
+ <ContactAvatar src={contact.avatarUrl} label={contact.displayName} />
73
+ <div className="min-w-0 flex-1">
74
+ <div className="flex items-center justify-between gap-3">
75
+ <h3 className="truncate text-sm font-medium text-white">{contact.displayName}</h3>
76
+ {contact.hasChat ? <span className="rounded-full bg-[#144d37] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-white">Chat</span> : null}
77
+ </div>
78
+ <p className="mt-1 truncate text-xs text-white/40">{formatSubtitle(contact)}</p>
79
+ </div>
80
+ <div className="shrink-0 text-xs text-white/35">
81
+ {startingContactId === contact.id ? "..." : "+"}
82
+ </div>
83
+ </button>
84
+ ))}
85
+ </div>
86
+ </div>
87
+ </div>
88
+ </aside>
89
+ );
90
+ }
@@ -0,0 +1,108 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { MdClose } from "react-icons/md";
3
+
4
+ const EMOJI_CATEGORIES = {
5
+ smileys: {
6
+ label: "😊",
7
+ emojis: ["😀", "😃", "😄", "😁", "😆", "😅", "🤣", "😂", "🙂", "🙃", "😉", "😊", "😇", "🥰", "😍", "🤩", "😘", "😗", "😚", "😙", "🥲", "😋", "😛", "😜", "🤪", "😌", "😔", "😑", "😐", "😐", "😑", "😒", "🙁", "☹️", "😲", "😞", "😖", "😢", "😭", "😤", "😠", "😡", "🤬", "😈", "👿", "💀", "☠️", "💩", "🤡", "👹", "👺", "👻", "👽", "👾", "🤖"]
8
+ },
9
+ gestures: {
10
+ label: "👋",
11
+ emojis: ["👋", "🤚", "🖐️", "✋", "🖖", "👌", "🤌", "🤏", "✌️", "🤞", "🫰", "🤟", "🤘", "🤙", "👍", "👎", "✊", "👊", "🤛", "🤜", "👏", "🙌", "👐", "🤲", "🤝", "🤜", "🤛", "🫱", "🫲", "💅", "👂", "👃", "🧠", "🫀", "🫁", "🦷", "🦴", "👀", "👁️", "👅", "👄"]
12
+ },
13
+ hearts: {
14
+ label: "❤️",
15
+ emojis: ["❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "🤍", "🤎", "💔", "💕", "💞", "💓", "💗", "💖", "💘", "💝", "💟", "💌", "💤", "💋"]
16
+ },
17
+ hand: {
18
+ label: "👌",
19
+ emojis: ["👌", "🤌", "🤏", "✌️", "🤞", "🫰", "🤟", "🤘", "🤙", "👍", "👎", "✊", "👊", "🤛", "🤜", "👏", "🙌", "👐", "🤲", "🤝"]
20
+ },
21
+ animals: {
22
+ label: "🐶",
23
+ emojis: ["🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🐯", "🦁", "🐮", "🐷", "🐸", "🐵", "🙈", "🙉", "🙊", "🐒", "🐔", "🐧", "🐦", "🐤", "🐣", "🐥", "🦆", "🦅", "🦉", "🦇", "🐺", "🐗", "🐴", "🦄", "🐝", "🪱", "🐛", "🦋", "🐌", "🐞", "🐜", "🪰", "🦟"]
24
+ },
25
+ food: {
26
+ label: "🍎",
27
+ emojis: ["🍎", "🍊", "🍋", "🍌", "🍉", "🍇", "🍓", "🫐", "🍈", "🍒", "🍑", "🥭", "🍍", "🥥", "🥑", "🍆", "🍅", "🌶️", "🌽", "🥒", "🥬", "🥦", "🧄", "🧅", "🍄", "🥜", "🌰", "🍞", "🥐", "🥖", "🥨", "🥯", "🥞", "🧇", "🥚", "🍳", "🧈", "🥞", "🥓", "🥔", "🍟", "🍗", "🍖", "🌭", "🍔", "🍟", "🍕"]
28
+ },
29
+ travel: {
30
+ label: "🎡",
31
+ emojis: ["✈️", "🛫", "🛬", "🛩️", "💺", "🛰️", "🚁", "🚂", "🚃", "🚄", "🚅", "🚆", "🚇", "🚈", "🚉", "🚊", "🚝", "🚞", "🚋", "🚌", "🚍", "🚎", "🚐", "🚑", "🚒", "🚓", "🚔", "🚕", "🚖", "🚗", "🚘", "🚙", "🚚", "🚛", "🚜", "🏎️", "🏍️", "🛵", "🦯", "🦽", "🦼", "🛺", "🚲", "🛴", "🛹", "🛼", "🛞", "🚏", "⛽"]
32
+ },
33
+ activities: {
34
+ label: "⚽",
35
+ emojis: ["⚽", "⚾", "🥎", "🎾", "🏀", "🏐", "🏈", "🏉", "🥏", "🎳", "🏓", "🏸", "🏒", "🏑", "🥍", "🏏", "🥅", "⛳", "⛸️", "🎣", "🎽", "🎿", "⛷️", "🏂", "🪂", "🤼", "🤸", "⛹️", "🤺", "🤾", "🏌️", "🏇", "🧘", "🏄", "🏊", "🤽", "🚣", "🧗", "🚴", "🚵", "🤹", "🎪"]
36
+ }
37
+ };
38
+
39
+ export function EmojiPicker({ isOpen, onClose, onEmojiSelect, triggerRef }) {
40
+ const pickerRef = useRef(null);
41
+ const scrollRef = useRef(null);
42
+
43
+ useEffect(() => {
44
+ function handleClickOutside(event) {
45
+ if (pickerRef.current && !pickerRef.current.contains(event.target) &&
46
+ triggerRef?.current && !triggerRef.current.contains(event.target)) {
47
+ onClose();
48
+ }
49
+ }
50
+
51
+ if (isOpen) {
52
+ document.addEventListener("mousedown", handleClickOutside);
53
+ return () => document.removeEventListener("mousedown", handleClickOutside);
54
+ }
55
+ }, [isOpen, onClose, triggerRef]);
56
+
57
+ if (!isOpen) return null;
58
+
59
+ return (
60
+ <div
61
+ ref={pickerRef}
62
+ className="relative mb-2 w-80 rounded-2xl border border-white/10 bg-[#1a1b1b] shadow-[0_16px_32px_rgba(0,0,0,0.4)]"
63
+ >
64
+ <div className="flex h-12 items-center border-b border-white/10 px-3">
65
+ <h3 className="flex-1 text-sm font-semibold text-white">Emoji</h3>
66
+ <button
67
+ type="button"
68
+ onClick={onClose}
69
+ className="flex h-6 w-6 items-center justify-center rounded-full text-white/60 transition hover:bg-white/10 hover:text-white"
70
+ title="Close emoji picker"
71
+ >
72
+ <MdClose className="w-4 h-4" />
73
+ </button>
74
+ </div>
75
+
76
+ <div
77
+ ref={scrollRef}
78
+ className="max-h-64 overflow-y-auto p-3"
79
+ >
80
+ <div className="space-y-3">
81
+ {Object.entries(EMOJI_CATEGORIES).map(([key, category]) => (
82
+ <div key={key}>
83
+ <div className="mb-2 text-xs font-semibold text-white/50">
84
+ {category.label}
85
+ </div>
86
+ <div className="grid grid-cols-8 gap-2">
87
+ {category.emojis.map((emoji, idx) => (
88
+ <button
89
+ key={`${key}-${idx}`}
90
+ type="button"
91
+ onClick={() => {
92
+ onEmojiSelect(emoji);
93
+ onClose();
94
+ }}
95
+ className="flex h-8 w-8 items-center justify-center rounded-lg text-lg transition hover:bg-white/10"
96
+ title={emoji}
97
+ >
98
+ {emoji}
99
+ </button>
100
+ ))}
101
+ </div>
102
+ </div>
103
+ ))}
104
+ </div>
105
+ </div>
106
+ </div>
107
+ );
108
+ }
@@ -0,0 +1,146 @@
1
+ import { useEffect, useState } from "react";
2
+ import { MdClose, MdDownload, MdAdd, MdRemove } from "react-icons/md";
3
+
4
+ export function MediaPreviewModal({ media, onClose }) {
5
+ const [zoom, setZoom] = useState(1);
6
+
7
+ useEffect(() => {
8
+ const handleKeyDown = (event) => {
9
+ if (event.key === "Escape") {
10
+ onClose();
11
+ }
12
+ if (event.key === "+" || event.key === "=") {
13
+ event.preventDefault();
14
+ setZoom((current) => Math.min(current + 0.2, 3));
15
+ }
16
+ if (event.key === "-") {
17
+ event.preventDefault();
18
+ setZoom((current) => Math.max(current - 0.2, 0.5));
19
+ }
20
+ };
21
+
22
+ window.addEventListener("keydown", handleKeyDown);
23
+ return () => window.removeEventListener("keydown", handleKeyDown);
24
+ }, [onClose]);
25
+
26
+ if (!media) {
27
+ return null;
28
+ }
29
+
30
+ const mediaUrl = media.mediaUrl || (media.relativePath ? `${getApiBaseUrl?.()}/` + media.relativePath : "");
31
+ const mimeType = media.mimeType || "";
32
+ const originalName = media.originalName || "media";
33
+ const isImage = media.isImage !== undefined ? media.isImage : mimeType.startsWith("image/") && mimeType !== "image/webp";
34
+
35
+ const handleDownload = async () => {
36
+ try {
37
+ const response = await fetch(mediaUrl);
38
+ const blob = await response.blob();
39
+ const url = window.URL.createObjectURL(blob);
40
+ const link = document.createElement("a");
41
+ link.href = url;
42
+ link.download = originalName;
43
+ document.body.appendChild(link);
44
+ link.click();
45
+ document.body.removeChild(link);
46
+ window.URL.revokeObjectURL(url);
47
+ } catch (error) {
48
+ console.error("Download failed:", error);
49
+ alert("Download failed: " + error.message);
50
+ }
51
+ };
52
+
53
+ return (
54
+ <div
55
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
56
+ onClick={onClose}
57
+ >
58
+ <div
59
+ className="relative max-h-[90vh] max-w-[90vw] rounded-2xl bg-black shadow-2xl"
60
+ onClick={(event) => event.stopPropagation()}
61
+ >
62
+ {/* Close button */}
63
+ <button
64
+ type="button"
65
+ onClick={onClose}
66
+ className="absolute right-6 top-6 z-10 rounded-full bg-black/60 w-10 h-10 flex items-center justify-center transition hover:bg-black/80 text-white"
67
+ title="Close (Esc)"
68
+ >
69
+ <MdClose className="w-5 h-5" />
70
+ </button>
71
+
72
+ {/* Download button */}
73
+ <button
74
+ type="button"
75
+ onClick={handleDownload}
76
+ className="absolute bottom-6 right-6 z-10 rounded-lg bg-black/60 px-4 py-2 text-sm font-medium text-white transition hover:bg-black/80 flex items-center gap-2"
77
+ title="Download"
78
+ >
79
+ <MdDownload className="w-4 h-4" />
80
+ Download
81
+ </button>
82
+
83
+ {/* Media content */}
84
+ <div className="flex items-center justify-center overflow-auto p-4">
85
+ {isImage ? (
86
+ <img
87
+ src={mediaUrl}
88
+ alt={originalName}
89
+ className="max-h-[calc(90vh-80px)] max-w-[calc(90vw-32px)] rounded-xl object-contain"
90
+ style={{
91
+ transform: `scale(${zoom})`,
92
+ transition: "transform 0.2s ease-in-out"
93
+ }}
94
+ />
95
+ ) : mimeType.startsWith("video/") ? (
96
+ <video
97
+ controls
98
+ autoPlay
99
+ className="max-h-[calc(90vh-80px)] max-w-[calc(90vw-32px)] rounded-xl"
100
+ >
101
+ <source src={mediaUrl} type={mimeType} />
102
+ </video>
103
+ ) : mimeType.startsWith("audio/") ? (
104
+ <audio controls autoPlay className="w-full max-w-[500px]">
105
+ <source src={mediaUrl} type={mimeType} />
106
+ </audio>
107
+ ) : null}
108
+ </div>
109
+
110
+ {/* Zoom controls (only for images) */}
111
+ {isImage && (
112
+ <div className="absolute bottom-6 left-6 flex items-center gap-2 rounded-lg bg-black/60 px-3 py-2">
113
+ <button
114
+ type="button"
115
+ onClick={() => setZoom((current) => Math.max(current - 0.2, 0.5))}
116
+ className="rounded p-1 transition hover:bg-black/80 text-white flex items-center justify-center"
117
+ title="Zoom out"
118
+ >
119
+ <MdRemove className="w-4 h-4" />
120
+ </button>
121
+ <span className="min-w-[40px] text-center text-sm text-white">
122
+ {Math.round(zoom * 100)}%
123
+ </span>
124
+ <button
125
+ type="button"
126
+ onClick={() => setZoom((current) => Math.min(current + 0.2, 3))}
127
+ className="rounded p-1 transition hover:bg-black/80 text-white flex items-center justify-center"
128
+ title="Zoom in"
129
+ >
130
+ <MdAdd className="w-4 h-4" />
131
+ </button>
132
+ <div className="h-4 w-px bg-white/20" />
133
+ <button
134
+ type="button"
135
+ onClick={() => setZoom(1)}
136
+ className="px-2 py-1 text-xs transition hover:bg-black/80 text-white"
137
+ title="Reset zoom"
138
+ >
139
+ Reset
140
+ </button>
141
+ </div>
142
+ )}
143
+ </div>
144
+ </div>
145
+ );
146
+ }
@@ -0,0 +1,155 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { MdReply, MdDelete, MdShare } from "react-icons/md";
3
+
4
+ export function MessageActionMenu({
5
+ isOpen,
6
+ onClose,
7
+ message,
8
+ onReply,
9
+ onDelete,
10
+ onForward,
11
+ isOutbound,
12
+ triggerRef
13
+ }) {
14
+ const menuRef = useRef(null);
15
+ const [position, setPosition] = useState({ top: 0, left: 0, transformOrigin: "top-right" });
16
+
17
+ useEffect(() => {
18
+ function handleClickOutside(event) {
19
+ if (menuRef.current && !menuRef.current.contains(event.target) &&
20
+ triggerRef?.current && !triggerRef.current.contains(event.target)) {
21
+ onClose();
22
+ }
23
+ }
24
+
25
+ if (isOpen) {
26
+ document.addEventListener("mousedown", handleClickOutside);
27
+ return () => document.removeEventListener("mousedown", handleClickOutside);
28
+ }
29
+ }, [isOpen, onClose, triggerRef]);
30
+
31
+ useEffect(() => {
32
+ if (!isOpen || !triggerRef?.current || !menuRef?.current) return;
33
+
34
+ const calculatePosition = () => {
35
+ const trigger = triggerRef.current;
36
+ const menu = menuRef.current;
37
+ const triggerRect = trigger.getBoundingClientRect();
38
+ const menuRect = menu.getBoundingClientRect();
39
+
40
+ // Get viewport dimensions
41
+ const viewportWidth = window.innerWidth;
42
+ const viewportHeight = window.innerHeight;
43
+ const menuWidth = 224; // w-56 = 14rem = 224px
44
+ const menuHeight = menuRect.height || 180;
45
+ const gap = 8;
46
+
47
+ let top = 0;
48
+ let left = 0;
49
+ let transformOrigin = "top-right";
50
+
51
+ // Calculate horizontal position
52
+ const spaceRight = viewportWidth - triggerRect.right;
53
+ const spaceLeft = triggerRect.left;
54
+
55
+ if (spaceRight >= menuWidth + gap) {
56
+ // Show on the right
57
+ left = triggerRect.width + gap;
58
+ transformOrigin = "top-left";
59
+ } else if (spaceLeft >= menuWidth + gap) {
60
+ // Show on the left
61
+ left = -(menuWidth + gap);
62
+ transformOrigin = "top-right";
63
+ } else {
64
+ // Not enough space, show to the right anyway but may overlap
65
+ left = triggerRect.width + gap;
66
+ transformOrigin = "top-left";
67
+ }
68
+
69
+ // Calculate vertical position
70
+ const spaceBottom = viewportHeight - triggerRect.bottom;
71
+ const spaceTop = triggerRect.top;
72
+
73
+ if (spaceBottom >= menuHeight + gap) {
74
+ // Show below the button
75
+ top = triggerRect.height + gap;
76
+ } else if (spaceTop >= menuHeight + gap) {
77
+ // Show above the button
78
+ top = -(menuHeight + gap);
79
+ } else {
80
+ // Not enough space, show below anyway
81
+ top = triggerRect.height + gap;
82
+ }
83
+
84
+ setPosition({ top, left, transformOrigin });
85
+ };
86
+
87
+ calculatePosition();
88
+ window.addEventListener("resize", calculatePosition);
89
+ return () => window.removeEventListener("resize", calculatePosition);
90
+ }, [isOpen, triggerRef]);
91
+
92
+ if (!isOpen) return null;
93
+
94
+ return (
95
+ <div
96
+ ref={menuRef}
97
+ style={{
98
+ position: "absolute",
99
+ top: `${position.top}px`,
100
+ left: `${position.left}px`,
101
+ transformOrigin: position.transformOrigin,
102
+ }}
103
+ className="z-50 w-56 rounded-2xl border border-white/10 bg-[#1a1b1b] shadow-[0_16px_32px_rgba(0,0,0,0.4)]"
104
+ >
105
+ <button
106
+ type="button"
107
+ onClick={onClose}
108
+ className="flex w-full items-center gap-3 border-b border-white/10 px-4 py-3 text-sm font-medium text-white transition hover:bg-white/5"
109
+ >
110
+ <span>←</span>
111
+ <span>Back</span>
112
+ </button>
113
+
114
+ <div className="space-y-0">
115
+ <button
116
+ type="button"
117
+ onClick={() => {
118
+ onReply();
119
+ onClose();
120
+ }}
121
+ className="flex w-full items-center gap-3 px-4 py-3 text-sm text-white/80 transition hover:bg-white/5 hover:text-white"
122
+ >
123
+ <span><MdReply className="w-4 h-4" /></span>
124
+ <span>Reply</span>
125
+ </button>
126
+
127
+ {isOutbound && (
128
+ <button
129
+ type="button"
130
+ onClick={() => {
131
+ onDelete();
132
+ onClose();
133
+ }}
134
+ className="flex w-full items-center gap-3 px-4 py-3 text-sm text-white/80 transition hover:bg-red-500/10 hover:text-red-300"
135
+ >
136
+ <span><MdDelete className="w-4 h-4" /></span>
137
+ <span>Delete</span>
138
+ </button>
139
+ )}
140
+
141
+ <button
142
+ type="button"
143
+ onClick={() => {
144
+ onForward();
145
+ onClose();
146
+ }}
147
+ className="flex w-full items-center gap-3 px-4 py-3 text-sm text-white/80 transition hover:bg-white/5 hover:text-white"
148
+ >
149
+ <span><MdShare className="w-4 h-4" /></span>
150
+ <span>Forward</span>
151
+ </button>
152
+ </div>
153
+ </div>
154
+ );
155
+ }