@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.
- package/CHANGELOG.md +38 -0
- package/LICENSE +21 -0
- package/README.md +319 -0
- package/bin/openwa.js +11 -0
- package/favicon.ico +0 -0
- package/logo-long.png +0 -0
- package/logo-square.png +0 -0
- package/package.json +69 -0
- package/prisma/schema.prisma +182 -0
- package/server/config.js +29 -0
- package/server/database/client.js +11 -0
- package/server/database/init.js +28 -0
- package/server/express/create-app.js +349 -0
- package/server/express/openapi.js +853 -0
- package/server/index.js +163 -0
- package/server/services/api-key-service.js +131 -0
- package/server/services/auth-service.js +162 -0
- package/server/services/chat-service.js +1014 -0
- package/server/services/session-service.js +81 -0
- package/server/socket/register.js +127 -0
- package/server/utils/avatar.js +34 -0
- package/server/utils/paths.js +29 -0
- package/server/whatsapp/adapters/mock-adapter.js +47 -0
- package/server/whatsapp/adapters/wwebjs-adapter.js +263 -0
- package/server/whatsapp/session-manager.js +356 -0
- package/web/components/AppHead.js +14 -0
- package/web/components/AuthCard.js +170 -0
- package/web/components/BrandLogo.js +11 -0
- package/web/components/ChatWindow.js +875 -0
- package/web/components/ChatWindow.js.tmp +0 -0
- package/web/components/ContactList.js +97 -0
- package/web/components/ContactsPanel.js +90 -0
- package/web/components/EmojiPicker.js +108 -0
- package/web/components/MediaPreviewModal.js +146 -0
- package/web/components/MessageActionMenu.js +155 -0
- package/web/components/SessionSidebar.js +167 -0
- package/web/components/SettingsModal.js +266 -0
- package/web/components/Skeletons.js +73 -0
- package/web/jsconfig.json +10 -0
- package/web/lib/api.js +33 -0
- package/web/lib/socket.js +9 -0
- package/web/pages/_app.js +5 -0
- package/web/pages/dashboard.js +541 -0
- package/web/pages/index.js +62 -0
- package/web/postcss.config.js +10 -0
- package/web/public/favicon.ico +0 -0
- package/web/public/logo-long.png +0 -0
- package/web/public/logo-square.png +0 -0
- package/web/store/useAppStore.js +209 -0
- package/web/styles/globals.css +52 -0
- 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
|
+
}
|