@banbox/chat 1.0.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/README.md +215 -0
- package/dist/index.cjs +3408 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +556 -0
- package/dist/index.d.ts +556 -0
- package/dist/index.js +3385 -0
- package/dist/index.js.map +1 -0
- package/package.json +83 -0
- package/src/adapter/types.ts +146 -0
- package/src/chat/ChatImagePreviewModal.tsx +194 -0
- package/src/chat/ChatRoot.tsx +67 -0
- package/src/chat/InboxPopup.tsx +312 -0
- package/src/chat/SinglePopup.tsx +240 -0
- package/src/contexts/ChatUIContext.tsx +30 -0
- package/src/contexts/ChatUIProvider.tsx +38 -0
- package/src/contexts/GalleryContext.tsx +40 -0
- package/src/contexts/GalleryProvider.tsx +89 -0
- package/src/hooks/useDisableBodyScroll.ts +16 -0
- package/src/icons/index.tsx +248 -0
- package/src/index.ts +56 -0
- package/src/lottie/typingdotanimation2.json +1 -0
- package/src/modals/chat/ChatConfirmModal.tsx +104 -0
- package/src/modals/chat/ChatTranslateSettingsModal.tsx +180 -0
- package/src/types/index.ts +163 -0
- package/src/ui/Button.tsx +83 -0
- package/src/ui/Portal.tsx +40 -0
- package/src/ui/Select.tsx +74 -0
- package/src/ui/chat/AttachmentPreviewStrip.tsx +166 -0
- package/src/ui/chat/ChatComposerBar.tsx +231 -0
- package/src/ui/chat/ChatFooter.tsx +442 -0
- package/src/ui/chat/ChatHeader.tsx +24 -0
- package/src/ui/chat/ChatIdentity.tsx +145 -0
- package/src/ui/chat/ChatInquiryBar.tsx +57 -0
- package/src/ui/chat/ChatListHeader.tsx +179 -0
- package/src/ui/chat/ChatMessageItem.tsx +214 -0
- package/src/ui/chat/ChatScroll.tsx +64 -0
- package/src/ui/chat/ChatSpinner.tsx +49 -0
- package/src/ui/chat/ChatThreadItem.tsx +140 -0
- package/src/ui/chat/MessageHoverActions.tsx +120 -0
- package/src/ui/chat/ReplyCard.tsx +217 -0
- package/src/ui/chat/TypingIndicator.tsx +93 -0
- package/src/ui/chat/drop-up/BusinessCardDropup.tsx +253 -0
- package/src/ui/chat/drop-up/EmojiDropup.tsx +132 -0
- package/src/ui/chat/message-items/ChatAddressCard.tsx +130 -0
- package/src/ui/chat/message-items/ChatBubbleAudio.tsx +209 -0
- package/src/ui/chat/message-items/ChatBubbleFiles.tsx +80 -0
- package/src/ui/chat/message-items/ChatBubbleImages.tsx +120 -0
- package/src/ui/chat/message-items/ChatBubbleText.tsx +16 -0
- package/src/ui/chat/message-items/ChatBusinessCard.tsx +95 -0
- package/src/ui/chat/scrollToMessage.ts +61 -0
- package/src/ui/chat/types.ts +37 -0
- package/src/utils/cn.ts +6 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// chat/InboxPopup.tsx
|
|
2
|
+
"use client";
|
|
3
|
+
|
|
4
|
+
import { motion } from "framer-motion";
|
|
5
|
+
import React, { useCallback, useEffect, useState } from "react";
|
|
6
|
+
|
|
7
|
+
import ChatConfirmModal from "../modals/chat/ChatConfirmModal";
|
|
8
|
+
import { useChatUI } from "../contexts/ChatUIContext";
|
|
9
|
+
import { useGallery } from "../contexts/GalleryContext";
|
|
10
|
+
import ChatFooter from "../ui/chat/ChatFooter";
|
|
11
|
+
import ChatHeader from "../ui/chat/ChatHeader";
|
|
12
|
+
import ChatIdentity from "../ui/chat/ChatIdentity";
|
|
13
|
+
import ChatInquiryBar from "../ui/chat/ChatInquiryBar";
|
|
14
|
+
import ChatListHeader from "../ui/chat/ChatListHeader";
|
|
15
|
+
import ChatMessageItem from "../ui/chat/ChatMessageItem";
|
|
16
|
+
import ChatScroll from "../ui/chat/ChatScroll";
|
|
17
|
+
import type { ChatThreadStatus } from "../ui/chat/ChatThreadItem";
|
|
18
|
+
import ChatThreadItem from "../ui/chat/ChatThreadItem";
|
|
19
|
+
import TypingIndicator from "../ui/chat/TypingIndicator";
|
|
20
|
+
import ChatSpinner from "../ui/chat/ChatSpinner";
|
|
21
|
+
import ChatImagePreviewModal from "./ChatImagePreviewModal";
|
|
22
|
+
|
|
23
|
+
import type { Thread, Message, MessageRef } from "../types";
|
|
24
|
+
import type { ChatAdapter, ChatUICallbacks } from "../adapter/types";
|
|
25
|
+
|
|
26
|
+
/* =======================
|
|
27
|
+
Props
|
|
28
|
+
======================= */
|
|
29
|
+
export type InboxPopupProps = {
|
|
30
|
+
/** The unified data adapter — provides threads, messages, and send */
|
|
31
|
+
adapter: ChatAdapter;
|
|
32
|
+
/** UI-level callbacks (toast, navigation, kebab menu) */
|
|
33
|
+
uiCallbacks?: ChatUICallbacks;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/* =======================
|
|
37
|
+
Constants
|
|
38
|
+
======================= */
|
|
39
|
+
const avatarBgByInitial: Record<string, string> = {
|
|
40
|
+
K: "#FFE7DB", A: "#FFE5DA", F: "#E8F7FF", B: "#F0EDEB", b: "#F0EDEB",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/* =======================
|
|
44
|
+
Component
|
|
45
|
+
======================= */
|
|
46
|
+
const InboxPopup: React.FC<InboxPopupProps> = ({ adapter, uiCallbacks }) => {
|
|
47
|
+
const { close, selectThread, selectedThreadId, reference } = useChatUI();
|
|
48
|
+
const { isOpen: isGalleryOpen, closeGallery } = useGallery();
|
|
49
|
+
|
|
50
|
+
/* ─── Thread list ─── */
|
|
51
|
+
const [threads, setThreads] = useState<Thread[]>(() => adapter.threads.list(reference));
|
|
52
|
+
const refreshThreads = useCallback(
|
|
53
|
+
() => setThreads(adapter.threads.list(reference)),
|
|
54
|
+
[adapter, reference],
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
// Immediate sync on mount / reference change
|
|
59
|
+
let rafId = 0;
|
|
60
|
+
rafId = requestAnimationFrame(refreshThreads);
|
|
61
|
+
const unsub = adapter.threads.subscribe(refreshThreads);
|
|
62
|
+
return () => { cancelAnimationFrame(rafId); unsub(); };
|
|
63
|
+
}, [adapter, reference, refreshThreads]);
|
|
64
|
+
|
|
65
|
+
/* ─── Active thread & messages ─── */
|
|
66
|
+
const [rev, setRev] = useState(0);
|
|
67
|
+
const [replyTo, setReplyTo] = useState<MessageRef | undefined>(undefined);
|
|
68
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
69
|
+
|
|
70
|
+
const activeId = selectedThreadId ?? threads[0]?.id;
|
|
71
|
+
const activeThread = threads.find((t) => t.id === activeId);
|
|
72
|
+
|
|
73
|
+
const [messages, setMessages] = useState<Message[]>(() =>
|
|
74
|
+
activeId ? adapter.messages.list(activeId) : [],
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// Refresh messages when active thread changes or rev bumps
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (activeId) setMessages(adapter.messages.list(activeId));
|
|
80
|
+
}, [activeId, rev, adapter]);
|
|
81
|
+
|
|
82
|
+
// Subscribe to real-time message updates for the active thread
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (!activeId || !adapter.messages.subscribe) return;
|
|
85
|
+
const unsub = adapter.messages.subscribe(activeId, () => {
|
|
86
|
+
setMessages(adapter.messages.list(activeId));
|
|
87
|
+
});
|
|
88
|
+
return unsub;
|
|
89
|
+
}, [activeId, adapter]);
|
|
90
|
+
|
|
91
|
+
/* ─── Derived UI values ─── */
|
|
92
|
+
const initial = activeThread?.avatarText ?? "U";
|
|
93
|
+
const title = activeThread?.title ?? "Unknown";
|
|
94
|
+
const subtitle = activeThread?.subTitle ?? "";
|
|
95
|
+
const online = Boolean(activeThread?.online);
|
|
96
|
+
const isVerified = Boolean(activeThread?.badge);
|
|
97
|
+
const avatarBg = avatarBgByInitial[initial] ?? "#FFF1EC";
|
|
98
|
+
|
|
99
|
+
const idLabel = activeThread?.orderId ? "Order ID" : activeThread?.inquiryId ? "Inquiry ID" : undefined;
|
|
100
|
+
const idButtonLabel = activeThread?.orderId ? "View Order" : activeThread?.inquiryId ? "View Inquiry" : undefined;
|
|
101
|
+
const idValue = activeThread?.orderId ?? activeThread?.inquiryId ?? undefined;
|
|
102
|
+
|
|
103
|
+
/* ─── Loading state ─── */
|
|
104
|
+
const [showDelete, setShowDelete] = useState(false);
|
|
105
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
106
|
+
const scrollKey = `${activeId}-${messages.length}-${rev}`;
|
|
107
|
+
|
|
108
|
+
const prevActiveIdRef = React.useRef(activeId);
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
if (prevActiveIdRef.current !== activeId) {
|
|
111
|
+
prevActiveIdRef.current = activeId;
|
|
112
|
+
setIsLoading(true);
|
|
113
|
+
const t = setTimeout(() => setIsLoading(false), 300);
|
|
114
|
+
// Mark thread as read when switching to it
|
|
115
|
+
if (activeId) adapter.threads.markRead?.(activeId);
|
|
116
|
+
return () => clearTimeout(t);
|
|
117
|
+
}
|
|
118
|
+
}, [activeId, adapter]);
|
|
119
|
+
|
|
120
|
+
/* ─── Reply helper ─── */
|
|
121
|
+
const toRef = (m: Message): MessageRef => ({
|
|
122
|
+
id: m.id,
|
|
123
|
+
author: typeof m.author === "string" ? m.author : "U",
|
|
124
|
+
time: m.time,
|
|
125
|
+
text: m.text ?? m.content,
|
|
126
|
+
images: m.images,
|
|
127
|
+
files: m.files,
|
|
128
|
+
audio: m.audio,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
/* ─── Delete ─── */
|
|
132
|
+
const handleConfirmDelete = () => {
|
|
133
|
+
if (!activeId) { setShowDelete(false); return; }
|
|
134
|
+
adapter.threads.delete(activeId);
|
|
135
|
+
const nextId = threads.filter((t) => t.id !== activeId)[0]?.id;
|
|
136
|
+
if (nextId) selectThread(nextId);
|
|
137
|
+
setReplyTo(undefined);
|
|
138
|
+
setShowDelete(false);
|
|
139
|
+
uiCallbacks?.showToast?.({
|
|
140
|
+
type: "success",
|
|
141
|
+
title: "Chat Deleted",
|
|
142
|
+
message: "The chat has been deleted successfully.",
|
|
143
|
+
});
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<div className="fixed bottom-4 right-[40px] z-50">
|
|
148
|
+
{/* Backdrop */}
|
|
149
|
+
<motion.button
|
|
150
|
+
aria-label="Close chat"
|
|
151
|
+
onClick={close}
|
|
152
|
+
className="fixed inset-0 bg-black/20"
|
|
153
|
+
initial={{ opacity: 0 }}
|
|
154
|
+
animate={{ opacity: 1 }}
|
|
155
|
+
exit={{ opacity: 0 }}
|
|
156
|
+
transition={{ duration: 0.3 }}
|
|
157
|
+
/>
|
|
158
|
+
|
|
159
|
+
{/* Popup wrapper */}
|
|
160
|
+
<motion.div
|
|
161
|
+
role="dialog"
|
|
162
|
+
aria-modal="true"
|
|
163
|
+
className="relative rounded-[16px]"
|
|
164
|
+
style={{ width: 800, height: 650, boxShadow: "0px 2px 12px 0px rgba(59,51,51,0.1)" }}
|
|
165
|
+
initial={{ x: "100%", opacity: 0 }}
|
|
166
|
+
animate={{ x: 0, opacity: 1 }}
|
|
167
|
+
exit={{ x: "100%", opacity: 0 }}
|
|
168
|
+
transition={{ type: "tween", duration: 0.4, ease: "easeOut" }}
|
|
169
|
+
>
|
|
170
|
+
<div
|
|
171
|
+
className="relative h-full w-full overflow-hidden rounded-[16px]"
|
|
172
|
+
style={{
|
|
173
|
+
border: "2px solid transparent",
|
|
174
|
+
background: "linear-gradient(white, white) padding-box, linear-gradient(236.83deg, rgba(51, 201, 212, 0.3) 0.4%, rgba(39, 83, 251, 0.3) 30.28%, rgba(39, 83, 251, 0.3) 50.2%, rgba(39, 83, 251, 0.3) 65.14%, rgba(235, 67, 255, 0.3) 100%) border-box",
|
|
175
|
+
}}
|
|
176
|
+
>
|
|
177
|
+
<div className="grid h-full min-h-0 grid-cols-[1fr_350px]">
|
|
178
|
+
{/* LEFT — Message area */}
|
|
179
|
+
<div className="flex h-full min-h-0 flex-col border-r border-[#9BBCCF]">
|
|
180
|
+
<div className="h-[64px] shrink-0">
|
|
181
|
+
<ChatHeader
|
|
182
|
+
left={
|
|
183
|
+
activeThread?.avatarSrc ? (
|
|
184
|
+
<ChatIdentity variant="avatar" src={activeThread.avatarSrc} online={online} title={title} subtitle={subtitle} verified={isVerified} subtitleVariant="muted" />
|
|
185
|
+
) : (
|
|
186
|
+
<ChatIdentity variant="initial" initial={initial} bg={avatarBg} online={online} title={title} subtitle={subtitle} verified={isVerified} subtitleVariant="muted" />
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
right={
|
|
190
|
+
uiCallbacks?.renderKebabMenu?.({
|
|
191
|
+
pinned: Boolean(activeThread?.pinned),
|
|
192
|
+
onPinToggle: () => { if (activeId) adapter.threads.pin(activeId, !activeThread?.pinned); },
|
|
193
|
+
onDelete: () => setShowDelete(true),
|
|
194
|
+
}) ?? null
|
|
195
|
+
}
|
|
196
|
+
/>
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
{idValue && (
|
|
200
|
+
<div className="shrink-0">
|
|
201
|
+
<ChatInquiryBar
|
|
202
|
+
id={idValue}
|
|
203
|
+
label={idLabel}
|
|
204
|
+
buttonLabel={idButtonLabel}
|
|
205
|
+
onView={() => {
|
|
206
|
+
const type = activeThread?.orderId ? "order" : "inquiry";
|
|
207
|
+
uiCallbacks?.onNavigate?.({ type, id: idValue });
|
|
208
|
+
}}
|
|
209
|
+
/>
|
|
210
|
+
</div>
|
|
211
|
+
)}
|
|
212
|
+
|
|
213
|
+
<div className="flex-1 min-h-0">
|
|
214
|
+
<div className="relative h-full min-h-0">
|
|
215
|
+
{isLoading ? (
|
|
216
|
+
<ChatSpinner className="h-full min-h-[200px]" />
|
|
217
|
+
) : (
|
|
218
|
+
<ChatScroll className="h-full pb-10" bottomAlignWhenShort={false} scrollKey={scrollKey}>
|
|
219
|
+
{messages.map((m, idx) => {
|
|
220
|
+
const mine = m.author === "you";
|
|
221
|
+
const isLast = idx === messages.length - 1;
|
|
222
|
+
return (
|
|
223
|
+
<ChatMessageItem
|
|
224
|
+
key={m.id}
|
|
225
|
+
id={m.id}
|
|
226
|
+
mine={mine}
|
|
227
|
+
time={m.time ?? ""}
|
|
228
|
+
authorInitial={typeof m.author === "string" ? m.author : "U"}
|
|
229
|
+
avatarBg={avatarBg}
|
|
230
|
+
text={m.text ?? m.content}
|
|
231
|
+
businessCard={m.businessCard as Parameters<typeof ChatMessageItem>[0]["businessCard"]}
|
|
232
|
+
addressCard={m.addressCard as Parameters<typeof ChatMessageItem>[0]["addressCard"]}
|
|
233
|
+
images={m.images}
|
|
234
|
+
files={m.files}
|
|
235
|
+
audio={m.audio}
|
|
236
|
+
replyTo={m.replyTo}
|
|
237
|
+
showStatus={isLast}
|
|
238
|
+
status={activeThread?.status?.kind === "seen" ? "Seen" : "Delivered"}
|
|
239
|
+
onReply={() => setReplyTo(toRef(m))}
|
|
240
|
+
initialSrc={m.avatarSrc}
|
|
241
|
+
/>
|
|
242
|
+
);
|
|
243
|
+
})}
|
|
244
|
+
</ChatScroll>
|
|
245
|
+
)}
|
|
246
|
+
|
|
247
|
+
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-center px-4 pb-2 pt-1 bg-white">
|
|
248
|
+
<TypingIndicator className="pointer-events-auto" />
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
|
|
253
|
+
<div className="shrink-0">
|
|
254
|
+
<ChatFooter
|
|
255
|
+
key={activeId}
|
|
256
|
+
replyTo={replyTo}
|
|
257
|
+
clearReply={() => setReplyTo(undefined)}
|
|
258
|
+
onAfterSend={() => setRev((v) => v + 1)}
|
|
259
|
+
onSend={(payload) => {
|
|
260
|
+
if (activeId) adapter.messages.send(activeId, payload);
|
|
261
|
+
}}
|
|
262
|
+
/>
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
|
|
266
|
+
{/* RIGHT — Thread list */}
|
|
267
|
+
<div className="h-full min-h-0">
|
|
268
|
+
<ChatListHeader onClose={close} onSearchChange={(val) => setSearchQuery(val)} />
|
|
269
|
+
<div className="h-full overflow-y-auto custom-scroll">
|
|
270
|
+
{threads
|
|
271
|
+
.filter((t) => {
|
|
272
|
+
if (!searchQuery.trim()) return true;
|
|
273
|
+
const q = searchQuery.toLowerCase();
|
|
274
|
+
return (
|
|
275
|
+
t.title.toLowerCase().includes(q) ||
|
|
276
|
+
t.last?.toLowerCase().includes(q) ||
|
|
277
|
+
t.orderId?.toLowerCase().includes(q) ||
|
|
278
|
+
t.inquiryId?.toLowerCase().includes(q)
|
|
279
|
+
);
|
|
280
|
+
})
|
|
281
|
+
.map((t) => {
|
|
282
|
+
const status: ChatThreadStatus = t.status ?? (t.unread && t.unread > 0 ? { kind: "new", count: t.unread } : { kind: "seen" });
|
|
283
|
+
return (
|
|
284
|
+
<ChatThreadItem
|
|
285
|
+
key={t.id}
|
|
286
|
+
onClick={() => { setReplyTo(undefined); selectThread(t.id); }}
|
|
287
|
+
active={t.id === activeId}
|
|
288
|
+
pinned={Boolean(t.pinned)}
|
|
289
|
+
online={t.online}
|
|
290
|
+
verified={Boolean(t.badge)}
|
|
291
|
+
title={t.title}
|
|
292
|
+
preview={t.last ?? ""}
|
|
293
|
+
time={t.time ?? ""}
|
|
294
|
+
status={status}
|
|
295
|
+
avatarText={t.avatarText ?? ""}
|
|
296
|
+
avatarSrc={t.avatarSrc}
|
|
297
|
+
/>
|
|
298
|
+
);
|
|
299
|
+
})}
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
</div>
|
|
303
|
+
|
|
304
|
+
<ChatConfirmModal open={showDelete} onClose={() => setShowDelete(false)} onConfirm={handleConfirmDelete} />
|
|
305
|
+
<ChatImagePreviewModal isOpen={isGalleryOpen} onClose={closeGallery} />
|
|
306
|
+
</div>
|
|
307
|
+
</motion.div>
|
|
308
|
+
</div>
|
|
309
|
+
);
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
export default InboxPopup;
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// chat/SinglePopup.tsx
|
|
2
|
+
"use client";
|
|
3
|
+
|
|
4
|
+
import { motion } from "framer-motion";
|
|
5
|
+
import React from "react";
|
|
6
|
+
|
|
7
|
+
import { useChatUI } from "../contexts/ChatUIContext";
|
|
8
|
+
import { ChatXIcon } from "../icons";
|
|
9
|
+
import ChatFooter from "../ui/chat/ChatFooter";
|
|
10
|
+
import ChatHeader from "../ui/chat/ChatHeader";
|
|
11
|
+
import ChatIdentity from "../ui/chat/ChatIdentity";
|
|
12
|
+
import ChatMessageItem from "../ui/chat/ChatMessageItem";
|
|
13
|
+
import ChatScroll from "../ui/chat/ChatScroll";
|
|
14
|
+
import ChatSpinner from "../ui/chat/ChatSpinner";
|
|
15
|
+
import TypingIndicator from "../ui/chat/TypingIndicator";
|
|
16
|
+
|
|
17
|
+
import type { Thread, Message, MessageRef, Reference } from "../types";
|
|
18
|
+
import type { ChatAdapter, ChatUICallbacks } from "../adapter/types";
|
|
19
|
+
|
|
20
|
+
/* ─────────────────────────────────────────────────────────────
|
|
21
|
+
Helpers
|
|
22
|
+
────────────────────────────────────────────────────────────── */
|
|
23
|
+
|
|
24
|
+
function coalesceThreadId(reference: Reference | undefined, threads: Thread[]): string {
|
|
25
|
+
const referenceId = reference?.id;
|
|
26
|
+
if (reference?.kind === "quotation") {
|
|
27
|
+
return threads.find((t) => t.id === "t4")?.id ?? (threads[0]?.id ?? "");
|
|
28
|
+
}
|
|
29
|
+
return (
|
|
30
|
+
(referenceId &&
|
|
31
|
+
(threads.find((t) => t.id === referenceId)?.id ||
|
|
32
|
+
threads.find((t) => t.inquiryId === referenceId)?.id)) ||
|
|
33
|
+
(threads.length ? threads[0].id : "")
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function toRef(m: Message): MessageRef {
|
|
38
|
+
return {
|
|
39
|
+
id: m.id,
|
|
40
|
+
author: m.author,
|
|
41
|
+
time: m.time,
|
|
42
|
+
text: m.text ?? m.content,
|
|
43
|
+
images: m.images,
|
|
44
|
+
files: m.files,
|
|
45
|
+
audio: m.audio,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* ─────────────────────────────────────────────────────────────
|
|
50
|
+
Props
|
|
51
|
+
────────────────────────────────────────────────────────────── */
|
|
52
|
+
|
|
53
|
+
export type SinglePopupProps = {
|
|
54
|
+
/** The unified data adapter */
|
|
55
|
+
adapter: ChatAdapter;
|
|
56
|
+
/** UI-level callbacks (toast, navigation) */
|
|
57
|
+
uiCallbacks?: ChatUICallbacks;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/* ─────────────────────────────────────────────────────────────
|
|
61
|
+
Component
|
|
62
|
+
────────────────────────────────────────────────────────────── */
|
|
63
|
+
|
|
64
|
+
const SinglePopup: React.FC<SinglePopupProps> = ({ adapter, uiCallbacks }) => {
|
|
65
|
+
const { close, reference } = useChatUI();
|
|
66
|
+
|
|
67
|
+
const threads = adapter.threads.list(reference);
|
|
68
|
+
const initialThreadId = React.useMemo(
|
|
69
|
+
() => coalesceThreadId(reference, threads),
|
|
70
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
71
|
+
[reference],
|
|
72
|
+
);
|
|
73
|
+
const [activeId] = React.useState<string>(initialThreadId);
|
|
74
|
+
|
|
75
|
+
const activeThread: Thread | undefined = threads.find((t) => t.id === activeId);
|
|
76
|
+
const isVerified = activeThread?.badge === true;
|
|
77
|
+
|
|
78
|
+
const meta = (reference?.meta ?? {}) as {
|
|
79
|
+
initial?: string;
|
|
80
|
+
title?: string;
|
|
81
|
+
online?: boolean;
|
|
82
|
+
subtitle?: string;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const initial = meta.initial ?? activeThread?.avatarText ?? "A";
|
|
86
|
+
const title = meta.title ?? activeThread?.title ?? "Unknown";
|
|
87
|
+
const online = meta.online ?? activeThread?.online ?? true;
|
|
88
|
+
const subtitle = meta.subtitle ?? "Customer";
|
|
89
|
+
|
|
90
|
+
/* ─── Messages ─── */
|
|
91
|
+
const [messages, setMessages] = React.useState<Message[]>(() =>
|
|
92
|
+
activeId ? adapter.messages.list(activeId) : [],
|
|
93
|
+
);
|
|
94
|
+
const [scrollKey, setScrollKey] = React.useState<number>(0);
|
|
95
|
+
const [replyTo, setReplyTo] = React.useState<MessageRef | undefined>(undefined);
|
|
96
|
+
const [isLoading, setIsLoading] = React.useState(true);
|
|
97
|
+
|
|
98
|
+
// Brief loading flash on initial open
|
|
99
|
+
React.useEffect(() => {
|
|
100
|
+
const t = setTimeout(() => setIsLoading(false), 300);
|
|
101
|
+
return () => clearTimeout(t);
|
|
102
|
+
}, []);
|
|
103
|
+
|
|
104
|
+
// Subscribe to real-time updates
|
|
105
|
+
React.useEffect(() => {
|
|
106
|
+
if (!activeId || !adapter.messages.subscribe) return;
|
|
107
|
+
const unsub = adapter.messages.subscribe(activeId, () => {
|
|
108
|
+
setMessages(adapter.messages.list(activeId));
|
|
109
|
+
setScrollKey(Date.now());
|
|
110
|
+
});
|
|
111
|
+
return unsub;
|
|
112
|
+
}, [activeId, adapter]);
|
|
113
|
+
|
|
114
|
+
const handleAfterSend = React.useCallback(() => {
|
|
115
|
+
setMessages(adapter.messages.list(activeId));
|
|
116
|
+
setScrollKey(Date.now());
|
|
117
|
+
setReplyTo(undefined);
|
|
118
|
+
}, [activeId, adapter]);
|
|
119
|
+
|
|
120
|
+
const statusText = activeThread?.status?.kind === "seen" ? "Seen" : "Delivered";
|
|
121
|
+
|
|
122
|
+
/* ─── Unused callbacks acknowledged ─── */
|
|
123
|
+
void uiCallbacks;
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<div className="fixed bottom-4 right-[40px] z-50">
|
|
127
|
+
{/* Backdrop */}
|
|
128
|
+
<motion.button
|
|
129
|
+
aria-label="Close chat"
|
|
130
|
+
onClick={close}
|
|
131
|
+
className="fixed inset-0 bg-black/20 cursor-auto!"
|
|
132
|
+
initial={{ opacity: 0 }}
|
|
133
|
+
animate={{ opacity: 1 }}
|
|
134
|
+
exit={{ opacity: 0 }}
|
|
135
|
+
transition={{ duration: 0.3 }}
|
|
136
|
+
/>
|
|
137
|
+
|
|
138
|
+
{/* Outer gradient wrapper */}
|
|
139
|
+
<motion.div
|
|
140
|
+
role="dialog"
|
|
141
|
+
aria-modal="true"
|
|
142
|
+
className="relative h-[650px] w-[450px] rounded-[20px] p-[2px]"
|
|
143
|
+
style={{
|
|
144
|
+
boxShadow: "0px 2px 12px 0px #3B33331A",
|
|
145
|
+
background: "linear-gradient(236.83deg, rgba(51, 201, 212, 0.3) 0.4%, rgba(39, 83, 251, 0.3) 30.28%, rgba(39, 83, 251, 0.3) 50.2%, rgba(39, 83, 251, 0.3) 65.14%, rgba(235, 67, 255, 0.3) 100%)",
|
|
146
|
+
}}
|
|
147
|
+
initial={{ x: "100%", opacity: 0 }}
|
|
148
|
+
animate={{ x: 0, opacity: 1 }}
|
|
149
|
+
exit={{ x: "100%", opacity: 0 }}
|
|
150
|
+
transition={{ type: "tween", duration: 0.4, ease: "easeOut" }}
|
|
151
|
+
>
|
|
152
|
+
{/* Inner card */}
|
|
153
|
+
<div className="flex h-full w-full flex-col overflow-hidden rounded-[18px] bg-white">
|
|
154
|
+
{/* Header */}
|
|
155
|
+
<div className="h-[64px] shrink-0">
|
|
156
|
+
<ChatHeader
|
|
157
|
+
left={
|
|
158
|
+
<ChatIdentity
|
|
159
|
+
variant="initial"
|
|
160
|
+
initial={initial}
|
|
161
|
+
bg="#FFE5DA"
|
|
162
|
+
online={online}
|
|
163
|
+
title={title}
|
|
164
|
+
subtitle={subtitle}
|
|
165
|
+
verified={isVerified}
|
|
166
|
+
subtitleVariant="muted"
|
|
167
|
+
/>
|
|
168
|
+
}
|
|
169
|
+
right={
|
|
170
|
+
<button
|
|
171
|
+
type="button"
|
|
172
|
+
onClick={close}
|
|
173
|
+
className="flex h-[34px] w-[34px] items-center justify-center rounded-full bg-white text-black shadow-[0px_2px_4px_0px_#A5A3AE4D] hover:bg-black/5 hover:text-[#ff5301] cursor-pointer"
|
|
174
|
+
>
|
|
175
|
+
<ChatXIcon className="h-6 w-6" />
|
|
176
|
+
</button>
|
|
177
|
+
}
|
|
178
|
+
/>
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
{/* Messages */}
|
|
182
|
+
<div className="relative flex-1 min-h-0">
|
|
183
|
+
{isLoading ? (
|
|
184
|
+
<ChatSpinner className="h-full" />
|
|
185
|
+
) : (
|
|
186
|
+
<ChatScroll className="h-full" bottomAlignWhenShort={false} scrollKey={scrollKey}>
|
|
187
|
+
{messages.map((m, idx) => {
|
|
188
|
+
const mine = m.author === "you";
|
|
189
|
+
const isLast = idx === messages.length - 1;
|
|
190
|
+
return (
|
|
191
|
+
<ChatMessageItem
|
|
192
|
+
key={m.id}
|
|
193
|
+
id={m.id}
|
|
194
|
+
mine={mine}
|
|
195
|
+
time={m.time ?? ""}
|
|
196
|
+
authorInitial={typeof m.author === "string" ? m.author : "U"}
|
|
197
|
+
text={m.text ?? m.content}
|
|
198
|
+
businessCard={m.businessCard as Parameters<typeof ChatMessageItem>[0]["businessCard"]}
|
|
199
|
+
addressCard={m.addressCard as Parameters<typeof ChatMessageItem>[0]["addressCard"]}
|
|
200
|
+
images={m.images}
|
|
201
|
+
files={m.files}
|
|
202
|
+
audio={m.audio}
|
|
203
|
+
replyTo={m.replyTo}
|
|
204
|
+
initialSrc={m.avatarSrc}
|
|
205
|
+
showStatus={isLast}
|
|
206
|
+
status={statusText}
|
|
207
|
+
onReply={() => setReplyTo(toRef(m))}
|
|
208
|
+
/>
|
|
209
|
+
);
|
|
210
|
+
})}
|
|
211
|
+
</ChatScroll>
|
|
212
|
+
)}
|
|
213
|
+
|
|
214
|
+
{/* Typing indicator */}
|
|
215
|
+
<div className="pointer-events-none absolute bottom-0 left-0 w-full bg-white px-4 pb-2">
|
|
216
|
+
<div className="pointer-events-auto flex items-center justify-start">
|
|
217
|
+
<TypingIndicator />
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
|
|
222
|
+
{/* Footer */}
|
|
223
|
+
<div className="shrink-0">
|
|
224
|
+
<ChatFooter
|
|
225
|
+
variant="single"
|
|
226
|
+
replyTo={replyTo}
|
|
227
|
+
clearReply={() => setReplyTo(undefined)}
|
|
228
|
+
onAfterSend={handleAfterSend}
|
|
229
|
+
onSend={(payload) => {
|
|
230
|
+
if (activeId) adapter.messages.send(activeId, payload);
|
|
231
|
+
}}
|
|
232
|
+
/>
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
</motion.div>
|
|
236
|
+
</div>
|
|
237
|
+
);
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
export default SinglePopup;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { createContext, useContext } from "react";
|
|
3
|
+
import type { Reference } from "../types";
|
|
4
|
+
|
|
5
|
+
// Re-export Reference so consumers who import from this context still work.
|
|
6
|
+
export type { Reference };
|
|
7
|
+
|
|
8
|
+
export type ChatVariant = "inbox" | "single";
|
|
9
|
+
|
|
10
|
+
export type ChatUIState = {
|
|
11
|
+
isOpen: boolean;
|
|
12
|
+
variant: ChatVariant;
|
|
13
|
+
reference?: Reference;
|
|
14
|
+
selectedThreadId?: string | null;
|
|
15
|
+
|
|
16
|
+
openInbox: (opts?: { reference?: Reference; threadId?: string }) => void;
|
|
17
|
+
openSingle: (opts?: { reference?: Reference }) => void;
|
|
18
|
+
close: () => void;
|
|
19
|
+
selectThread: (id: string | null) => void;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const ChatUIContext = createContext<ChatUIState | null>(null);
|
|
23
|
+
|
|
24
|
+
export function useChatUI() {
|
|
25
|
+
const ctx = useContext(ChatUIContext);
|
|
26
|
+
if (!ctx) {
|
|
27
|
+
throw new Error("useChatUI must be used within ChatUIProvider");
|
|
28
|
+
}
|
|
29
|
+
return ctx;
|
|
30
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useMemo, useState } from "react";
|
|
4
|
+
import type { ChatUIState, ChatVariant, Reference } from "./ChatUIContext";
|
|
5
|
+
import { ChatUIContext } from "./ChatUIContext";
|
|
6
|
+
|
|
7
|
+
export function ChatUIProvider({ children }: { children: React.ReactNode }) {
|
|
8
|
+
const [isOpen, setOpen] = useState(false);
|
|
9
|
+
const [variant, setVariant] = useState<ChatVariant>("inbox");
|
|
10
|
+
const [reference, setReference] = useState<Reference | undefined>();
|
|
11
|
+
const [selectedThreadId, setSelected] = useState<string | null>(null);
|
|
12
|
+
|
|
13
|
+
const api = useMemo<ChatUIState>(
|
|
14
|
+
() => ({
|
|
15
|
+
isOpen,
|
|
16
|
+
variant,
|
|
17
|
+
reference,
|
|
18
|
+
selectedThreadId,
|
|
19
|
+
openInbox: (opts) => {
|
|
20
|
+
setReference(opts?.reference);
|
|
21
|
+
setSelected(opts?.threadId ?? null);
|
|
22
|
+
setVariant("inbox");
|
|
23
|
+
setOpen(true);
|
|
24
|
+
},
|
|
25
|
+
openSingle: (opts) => {
|
|
26
|
+
setReference(opts?.reference);
|
|
27
|
+
setVariant("single");
|
|
28
|
+
setSelected(null);
|
|
29
|
+
setOpen(true);
|
|
30
|
+
},
|
|
31
|
+
close: () => setOpen(false),
|
|
32
|
+
selectThread: (id) => setSelected(id),
|
|
33
|
+
}),
|
|
34
|
+
[isOpen, variant, reference, selectedThreadId],
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
return <ChatUIContext.Provider value={api}>{children}</ChatUIContext.Provider>;
|
|
38
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext } from "react";
|
|
4
|
+
|
|
5
|
+
// New interface for media items
|
|
6
|
+
export interface GalleryMedia {
|
|
7
|
+
type: "image" | "video" | "pdf";
|
|
8
|
+
url: string;
|
|
9
|
+
altText?: string;
|
|
10
|
+
/** Per-item upload date shown in the gallery preview */
|
|
11
|
+
uploadDate?: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Updated context type
|
|
15
|
+
export interface GalleryContextType {
|
|
16
|
+
images: GalleryMedia[];
|
|
17
|
+
currentIndex: number | null;
|
|
18
|
+
isOpen: boolean;
|
|
19
|
+
uploadDate?: string | null;
|
|
20
|
+
showDots?: boolean;
|
|
21
|
+
setImages: (imgs: GalleryMedia[]) => void;
|
|
22
|
+
setCurrentIndex: (index: number) => void;
|
|
23
|
+
setUploadDate: (date: string | null) => void;
|
|
24
|
+
openGallery: (imgs: GalleryMedia[], startIndex?: number, uploadDate?: string | null, showDots?: boolean) => void;
|
|
25
|
+
closeGallery: () => void;
|
|
26
|
+
next: () => void;
|
|
27
|
+
prev: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const GalleryContext = createContext<GalleryContextType | undefined>(
|
|
31
|
+
undefined,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
export const useGallery = (): GalleryContextType => {
|
|
35
|
+
const context = useContext(GalleryContext);
|
|
36
|
+
if (!context) {
|
|
37
|
+
throw new Error("useGallery must be used within a GalleryProvider");
|
|
38
|
+
}
|
|
39
|
+
return context;
|
|
40
|
+
};
|