@adens/openwa 0.1.5 → 0.1.6
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/adens-openwa-0.1.5.tgz +0 -0
- package/package.json +2 -1
- package/server/index.js +30 -12
- package/web/.next/BUILD_ID +1 -0
- package/web/.next/build-manifest.json +43 -0
- package/web/.next/cache/.previewinfo +1 -0
- package/web/.next/cache/.rscinfo +1 -0
- package/web/.next/cache/webpack/client-production/0.pack +0 -0
- package/web/.next/cache/webpack/client-production/1.pack +0 -0
- package/web/.next/cache/webpack/client-production/10.pack +0 -0
- package/web/.next/cache/webpack/client-production/11.pack +0 -0
- package/web/.next/cache/webpack/client-production/12.pack +0 -0
- package/web/.next/cache/webpack/client-production/13.pack +0 -0
- package/web/.next/cache/webpack/client-production/14.pack +0 -0
- package/web/.next/cache/webpack/client-production/15.pack +0 -0
- package/web/.next/cache/webpack/client-production/16.pack +0 -0
- package/web/.next/cache/webpack/client-production/17.pack +0 -0
- package/web/.next/cache/webpack/client-production/18.pack +0 -0
- package/web/.next/cache/webpack/client-production/19.pack +0 -0
- package/web/.next/cache/webpack/client-production/2.pack +0 -0
- package/web/.next/cache/webpack/client-production/20.pack +0 -0
- package/web/.next/cache/webpack/client-production/21.pack +0 -0
- package/web/.next/cache/webpack/client-production/22.pack +0 -0
- package/web/.next/cache/webpack/client-production/23.pack +0 -0
- package/web/.next/cache/webpack/client-production/24.pack +0 -0
- package/web/.next/cache/webpack/client-production/25.pack +0 -0
- package/web/.next/cache/webpack/client-production/26.pack +0 -0
- package/web/.next/cache/webpack/client-production/27.pack +0 -0
- package/web/.next/cache/webpack/client-production/3.pack +0 -0
- package/web/.next/cache/webpack/client-production/4.pack +0 -0
- package/web/.next/cache/webpack/client-production/5.pack +0 -0
- package/web/.next/cache/webpack/client-production/6.pack +0 -0
- package/web/.next/cache/webpack/client-production/7.pack +0 -0
- package/web/.next/cache/webpack/client-production/8.pack +0 -0
- package/web/.next/cache/webpack/client-production/9.pack +0 -0
- package/web/.next/cache/webpack/client-production/index.pack +0 -0
- package/web/.next/cache/webpack/client-production/index.pack.old +0 -0
- package/web/.next/cache/webpack/edge-server-production/0.pack +0 -0
- package/web/.next/cache/webpack/edge-server-production/index.pack +0 -0
- package/web/.next/cache/webpack/edge-server-production/index.pack.old +0 -0
- package/web/.next/cache/webpack/server-production/0.pack +0 -0
- package/web/.next/cache/webpack/server-production/1.pack +0 -0
- package/web/.next/cache/webpack/server-production/10.pack +0 -0
- package/web/.next/cache/webpack/server-production/11.pack +0 -0
- package/web/.next/cache/webpack/server-production/12.pack +0 -0
- package/web/.next/cache/webpack/server-production/13.pack +0 -0
- package/web/.next/cache/webpack/server-production/14.pack +0 -0
- package/web/.next/cache/webpack/server-production/15.pack +0 -0
- package/web/.next/cache/webpack/server-production/16.pack +0 -0
- package/web/.next/cache/webpack/server-production/17.pack +0 -0
- package/web/.next/cache/webpack/server-production/18.pack +0 -0
- package/web/.next/cache/webpack/server-production/19.pack +0 -0
- package/web/.next/cache/webpack/server-production/2.pack +0 -0
- package/web/.next/cache/webpack/server-production/20.pack +0 -0
- package/web/.next/cache/webpack/server-production/3.pack +0 -0
- package/web/.next/cache/webpack/server-production/4.pack +0 -0
- package/web/.next/cache/webpack/server-production/5.pack +0 -0
- package/web/.next/cache/webpack/server-production/6.pack +0 -0
- package/web/.next/cache/webpack/server-production/7.pack +0 -0
- package/web/.next/cache/webpack/server-production/8.pack +0 -0
- package/web/.next/cache/webpack/server-production/9.pack +0 -0
- package/web/.next/cache/webpack/server-production/index.pack +0 -0
- package/web/.next/cache/webpack/server-production/index.pack.old +0 -0
- package/web/.next/diagnostics/build-diagnostics.json +6 -0
- package/web/.next/diagnostics/framework.json +1 -0
- package/web/.next/dynamic-css-manifest.json +1 -0
- package/web/.next/export-marker.json +6 -0
- package/web/.next/images-manifest.json +58 -0
- package/web/.next/next-minimal-server.js.nft.json +1 -0
- package/web/.next/next-server.js.nft.json +1 -0
- package/web/.next/package.json +1 -0
- package/web/.next/prerender-manifest.json +11 -0
- package/web/.next/react-loadable-manifest.json +14 -0
- package/web/.next/required-server-files.json +343 -0
- package/web/.next/routes-manifest.json +79 -0
- package/web/.next/server/chunks/276.js +19 -0
- package/web/.next/server/chunks/508.js +1 -0
- package/web/.next/server/chunks/891.js +6 -0
- package/web/.next/server/dynamic-css-manifest.js +1 -0
- package/web/.next/server/functions-config-manifest.json +4 -0
- package/web/.next/server/interception-route-rewrite-manifest.js +1 -0
- package/web/.next/server/middleware-build-manifest.js +1 -0
- package/web/.next/server/middleware-manifest.json +6 -0
- package/web/.next/server/middleware-react-loadable-manifest.js +1 -0
- package/web/.next/server/next-font-manifest.js +1 -0
- package/web/.next/server/next-font-manifest.json +1 -0
- package/web/.next/server/pages/404.html +1 -0
- package/web/.next/server/pages/500.html +1 -0
- package/web/.next/server/pages/_app.js +1 -0
- package/web/.next/server/pages/_app.js.nft.json +1 -0
- package/web/.next/server/pages/_document.js +1 -0
- package/web/.next/server/pages/_document.js.nft.json +1 -0
- package/web/.next/server/pages/_error.js +1 -0
- package/web/.next/server/pages/_error.js.nft.json +1 -0
- package/web/.next/server/pages/dashboard.html +1 -0
- package/web/.next/server/pages/dashboard.js.nft.json +1 -0
- package/web/.next/server/pages/index.html +1 -0
- package/web/.next/server/pages/index.js.nft.json +1 -0
- package/web/.next/server/pages-manifest.json +8 -0
- package/web/.next/server/webpack-runtime.js +1 -0
- package/web/.next/static/FqMH7OdKFqTxzPemOh9_6/_buildManifest.js +1 -0
- package/web/.next/static/FqMH7OdKFqTxzPemOh9_6/_ssgManifest.js +1 -0
- package/web/.next/static/chunks/112-1bb8aeaa7c3fd57f.js +1 -0
- package/web/.next/static/chunks/131.2b0db2ec21a47401.js +1 -0
- package/web/.next/static/chunks/1d0474cf-ced9eb4c00bbcd99.js +1 -0
- package/web/.next/static/chunks/750.caaf5a9ddd46b267.js +1 -0
- package/web/.next/static/chunks/framework-9ad035430eed8b2b.js +1 -0
- package/web/.next/static/chunks/main-a5ca0156a62da1bd.js +1 -0
- package/web/.next/static/chunks/pages/_app-37894446f6ae5afe.js +1 -0
- package/web/.next/static/chunks/pages/_error-8e5b843ec9d413fc.js +1 -0
- package/web/.next/static/chunks/pages/dashboard-ce0d55d3fea9e308.js +1 -0
- package/web/.next/static/chunks/pages/index-8d94234d8de13682.js +1 -0
- package/web/.next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
- package/web/.next/static/chunks/webpack-ea849f90a4c65253.js +1 -0
- package/web/.next/static/css/de4e0852dfebd52f.css +3 -0
- package/web/.next/trace +2 -0
- package/web/.next/types/routes.d.ts +62 -0
- package/web/.next/types/validator.ts +16 -0
- package/web/components/AppHead.js +0 -14
- package/web/components/AuthCard.js +0 -170
- package/web/components/BrandLogo.js +0 -11
- package/web/components/ChatWindow.js +0 -875
- package/web/components/ChatWindow.js.tmp +0 -0
- package/web/components/ContactList.js +0 -97
- package/web/components/ContactsPanel.js +0 -90
- package/web/components/EmojiPicker.js +0 -108
- package/web/components/MediaPreviewModal.js +0 -146
- package/web/components/MessageActionMenu.js +0 -155
- package/web/components/SessionSidebar.js +0 -167
- package/web/components/SettingsModal.js +0 -266
- package/web/components/Skeletons.js +0 -73
- package/web/jsconfig.json +0 -10
- package/web/lib/api.js +0 -33
- package/web/lib/socket.js +0 -9
- package/web/pages/_app.js +0 -5
- package/web/pages/dashboard.js +0 -541
- package/web/pages/index.js +0 -62
- package/web/postcss.config.js +0 -10
- package/web/store/useAppStore.js +0 -209
- package/web/styles/globals.css +0 -52
- package/web/tailwind.config.js +0 -36
|
@@ -1,875 +0,0 @@
|
|
|
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
|
-
});
|