@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
package/package.json
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@banbox/chat",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Banbox Chat UI components — reusable across all Banbox React/Next.js projects",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "UNLICENSED",
|
|
7
|
+
"private": false,
|
|
8
|
+
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"require": {
|
|
16
|
+
"types": "./dist/index.d.cts",
|
|
17
|
+
"default": "./dist/index.cjs"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
"main": "./dist/index.cjs",
|
|
23
|
+
"module": "./dist/index.js",
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
|
|
26
|
+
"sideEffects": false,
|
|
27
|
+
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"src",
|
|
31
|
+
"README.md"
|
|
32
|
+
],
|
|
33
|
+
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsup",
|
|
36
|
+
"build:watch": "tsup --watch",
|
|
37
|
+
"dev": "tsup --watch",
|
|
38
|
+
"typecheck": "tsc --noEmit",
|
|
39
|
+
"prepublishOnly": "npm run typecheck && npm run build"
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"registry": "https://registry.npmjs.org",
|
|
44
|
+
"access": "public"
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
"repository": {
|
|
48
|
+
"type": "git",
|
|
49
|
+
"url": "git+https://github.com/OCEANGET/banbox-chat.git"
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
"bugs": {
|
|
53
|
+
"url": "https://github.com/OCEANGET/banbox-chat/issues"
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
"homepage": "https://github.com/OCEANGET/banbox-chat#readme",
|
|
57
|
+
|
|
58
|
+
"keywords": ["react", "nextjs", "chat", "ui", "banbox", "typescript"],
|
|
59
|
+
|
|
60
|
+
"peerDependencies": {
|
|
61
|
+
"react": ">=18",
|
|
62
|
+
"react-dom": ">=18",
|
|
63
|
+
"framer-motion": ">=10"
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
"peerDependenciesMeta": {
|
|
67
|
+
"framer-motion": {
|
|
68
|
+
"optional": false
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
"dependencies": {
|
|
73
|
+
"clsx": "^2.1.1",
|
|
74
|
+
"tailwind-merge": "^3.6.0"
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
"devDependencies": {
|
|
78
|
+
"@types/react": "^19.2.16",
|
|
79
|
+
"@types/react-dom": "^19.2.3",
|
|
80
|
+
"tsup": "^8.5.0",
|
|
81
|
+
"typescript": "~6.0.3"
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// @banbox/chat — Adapter Interfaces
|
|
2
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
3
|
+
// This file defines the contract between the chat package and the host app.
|
|
4
|
+
//
|
|
5
|
+
// The host app implements ChatAdapter with its own data source
|
|
6
|
+
// (demo data, REST API, WebSocket, etc.) and passes it to <ChatRoot />.
|
|
7
|
+
//
|
|
8
|
+
// To switch from demo → real API:
|
|
9
|
+
// const adapter = createApiAdapter({ baseUrl: "/api", token });
|
|
10
|
+
// <ChatRoot adapter={adapter} ... />
|
|
11
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
import type React from "react";
|
|
14
|
+
import type { Thread, Message, SendPayload, Reference } from "../types";
|
|
15
|
+
|
|
16
|
+
/** Options for the kebab menu (⋮) rendered by the host app */
|
|
17
|
+
export type KebabMenuOpts = {
|
|
18
|
+
pinned: boolean;
|
|
19
|
+
onPinToggle: () => void;
|
|
20
|
+
onDelete: () => void;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Options for a toast notification triggered by the package */
|
|
24
|
+
export type ToastOpts = {
|
|
25
|
+
type?: "success" | "error" | "info" | "warning";
|
|
26
|
+
title: string;
|
|
27
|
+
message?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The unified data adapter.
|
|
32
|
+
*
|
|
33
|
+
* Implement this interface in your host app and pass it to <ChatRoot adapter={...} />.
|
|
34
|
+
*
|
|
35
|
+
* ### Demo implementation (local state):
|
|
36
|
+
* ```ts
|
|
37
|
+
* const adapter: ChatAdapter = {
|
|
38
|
+
* threads: { list: getThreads, subscribe: subscribeThreads, pin, delete: deleteThread },
|
|
39
|
+
* messages: { list: getMessages, send: handleSend },
|
|
40
|
+
* };
|
|
41
|
+
* ```
|
|
42
|
+
*
|
|
43
|
+
* ### Real API implementation:
|
|
44
|
+
* ```ts
|
|
45
|
+
* const adapter: ChatAdapter = {
|
|
46
|
+
* threads: {
|
|
47
|
+
* list: (ref) => cachedThreads, // from polling/socket
|
|
48
|
+
* subscribe: (cb) => socket.on("threads", cb),
|
|
49
|
+
* pin: (id, p) => api.patch(`/threads/${id}`, { pinned: p }),
|
|
50
|
+
* delete: (id) => api.delete(`/threads/${id}`),
|
|
51
|
+
* },
|
|
52
|
+
* messages: {
|
|
53
|
+
* list: (tid) => cachedMessages[tid] ?? [],
|
|
54
|
+
* subscribe: (tid, cb) => socket.on(`messages:${tid}`, cb),
|
|
55
|
+
* send: (tid, payload) => api.post(`/threads/${tid}/messages`, payload),
|
|
56
|
+
* },
|
|
57
|
+
* };
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export interface ChatAdapter {
|
|
61
|
+
threads: {
|
|
62
|
+
/**
|
|
63
|
+
* Returns the current list of threads.
|
|
64
|
+
* Called synchronously; keep a local cache if using async data.
|
|
65
|
+
*/
|
|
66
|
+
list: (reference?: Reference) => Thread[];
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Subscribe to thread list changes.
|
|
70
|
+
* Call the callback whenever threads change (new msg, pin, delete, etc.)
|
|
71
|
+
* Returns an unsubscribe function.
|
|
72
|
+
*
|
|
73
|
+
* For WebSocket: `(cb) => socket.on("threads:update", cb)`
|
|
74
|
+
*/
|
|
75
|
+
subscribe: (cb: () => void) => () => void;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Pin or unpin a thread.
|
|
79
|
+
* @param id Thread ID
|
|
80
|
+
* @param pinned True to pin, false to unpin
|
|
81
|
+
*/
|
|
82
|
+
pin: (id: string, pinned: boolean) => Promise<void> | void;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Delete a thread permanently.
|
|
86
|
+
*/
|
|
87
|
+
delete: (id: string) => Promise<void> | void;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Mark a thread as read (optional).
|
|
91
|
+
* Called when the user opens a thread.
|
|
92
|
+
*/
|
|
93
|
+
markRead?: (id: string) => Promise<void> | void;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
messages: {
|
|
97
|
+
/**
|
|
98
|
+
* Returns the current messages for a thread.
|
|
99
|
+
* Called synchronously; keep a local cache if using async data.
|
|
100
|
+
*/
|
|
101
|
+
list: (threadId: string) => Message[];
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Subscribe to message changes for a specific thread.
|
|
105
|
+
* Returns an unsubscribe function.
|
|
106
|
+
*
|
|
107
|
+
* For WebSocket: `(tid, cb) => socket.on(`messages:${tid}`, cb)`
|
|
108
|
+
* For demo: optional (UI re-renders via onAfterSend)
|
|
109
|
+
*/
|
|
110
|
+
subscribe?: (threadId: string, cb: () => void) => () => void;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Send a message.
|
|
114
|
+
* The payload is a discriminated union covering all message types.
|
|
115
|
+
* Mutate your local cache + emit to server here.
|
|
116
|
+
*/
|
|
117
|
+
send: (threadId: string, payload: SendPayload) => Promise<void> | void;
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* UI-level callbacks — side-effects that the host app controls.
|
|
123
|
+
*
|
|
124
|
+
* These are NOT data operations; they are UI actions that the package
|
|
125
|
+
* delegates back to the host app (so the package stays decoupled from
|
|
126
|
+
* the host app's routing, notification, and menu systems).
|
|
127
|
+
*/
|
|
128
|
+
export interface ChatUICallbacks {
|
|
129
|
+
/**
|
|
130
|
+
* Show a toast notification.
|
|
131
|
+
* Use your app's existing toast system (react-toastify, sonner, etc.)
|
|
132
|
+
*/
|
|
133
|
+
showToast?: (opts: ToastOpts) => void;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Navigate to an order or inquiry page.
|
|
137
|
+
* Called when the user clicks "View Order" / "View Inquiry" in the chat.
|
|
138
|
+
*/
|
|
139
|
+
onNavigate?: (route: { type: "order" | "inquiry"; id: string }) => void;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Render the kebab (⋮) menu in the chat header.
|
|
143
|
+
* Return your app's custom dropdown component here.
|
|
144
|
+
*/
|
|
145
|
+
renderKebabMenu?: (opts: KebabMenuOpts) => React.ReactNode;
|
|
146
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// components/modals/ChatImagePreviewModal.tsx
|
|
2
|
+
"use client";
|
|
3
|
+
|
|
4
|
+
import { useState, useEffect, useCallback } from "react";
|
|
5
|
+
import type { FC, JSX } from "react";
|
|
6
|
+
import { AnimatePresence, motion } from "framer-motion";
|
|
7
|
+
import { useGallery } from "../contexts/GalleryContext";
|
|
8
|
+
import Portal from "../ui/Portal";
|
|
9
|
+
|
|
10
|
+
/* =======================
|
|
11
|
+
Types
|
|
12
|
+
======================= */
|
|
13
|
+
interface ChatImagePreviewModalProps {
|
|
14
|
+
isOpen: boolean;
|
|
15
|
+
onClose: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/* =======================
|
|
19
|
+
Slide direction variants
|
|
20
|
+
======================= */
|
|
21
|
+
const slideVariants = {
|
|
22
|
+
enter: (dir: number) => ({
|
|
23
|
+
x: dir > 0 ? 300 : -300,
|
|
24
|
+
opacity: 0,
|
|
25
|
+
}),
|
|
26
|
+
center: { x: 0, opacity: 1 },
|
|
27
|
+
exit: (dir: number) => ({
|
|
28
|
+
x: dir > 0 ? -300 : 300,
|
|
29
|
+
opacity: 0,
|
|
30
|
+
}),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/* =======================
|
|
34
|
+
Component
|
|
35
|
+
======================= */
|
|
36
|
+
const ChatImagePreviewModal: FC<ChatImagePreviewModalProps> = ({
|
|
37
|
+
isOpen,
|
|
38
|
+
onClose,
|
|
39
|
+
}): JSX.Element => {
|
|
40
|
+
const { images, currentIndex, setCurrentIndex } = useGallery();
|
|
41
|
+
|
|
42
|
+
const current = currentIndex ?? 0;
|
|
43
|
+
const total = images?.length ?? 0;
|
|
44
|
+
const hasPrev = current > 0;
|
|
45
|
+
const hasNext = current < total - 1;
|
|
46
|
+
|
|
47
|
+
/* Track slide direction for animation */
|
|
48
|
+
const [direction, setDirection] = useState(0);
|
|
49
|
+
|
|
50
|
+
const goPrev = useCallback(() => {
|
|
51
|
+
if (current > 0) {
|
|
52
|
+
setDirection(-1);
|
|
53
|
+
setCurrentIndex(current - 1);
|
|
54
|
+
}
|
|
55
|
+
}, [current, setCurrentIndex]);
|
|
56
|
+
|
|
57
|
+
const goNext = useCallback(() => {
|
|
58
|
+
if (images && current < images.length - 1) {
|
|
59
|
+
setDirection(1);
|
|
60
|
+
setCurrentIndex(current + 1);
|
|
61
|
+
}
|
|
62
|
+
}, [current, images, setCurrentIndex]);
|
|
63
|
+
|
|
64
|
+
/* Keyboard navigation */
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!isOpen) return;
|
|
67
|
+
const handleKey = (e: KeyboardEvent) => {
|
|
68
|
+
if (e.key === "Escape") onClose();
|
|
69
|
+
if (e.key === "ArrowLeft") goPrev();
|
|
70
|
+
if (e.key === "ArrowRight") goNext();
|
|
71
|
+
};
|
|
72
|
+
window.addEventListener("keydown", handleKey);
|
|
73
|
+
return () => window.removeEventListener("keydown", handleKey);
|
|
74
|
+
}, [isOpen, onClose, goPrev, goNext]);
|
|
75
|
+
|
|
76
|
+
const currentImage = images?.[current];
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<Portal>
|
|
80
|
+
<AnimatePresence>
|
|
81
|
+
{isOpen && total > 0 && (
|
|
82
|
+
<motion.div
|
|
83
|
+
className="fixed inset-0 z-999 flex items-center justify-center"
|
|
84
|
+
initial={{ opacity: 0, backgroundColor: "rgba(0,0,0,0)" }}
|
|
85
|
+
animate={{ opacity: 1, backgroundColor: "rgba(0,0,0,0.55)" }}
|
|
86
|
+
exit={{ opacity: 0, backgroundColor: "rgba(0,0,0,0)" }}
|
|
87
|
+
transition={{ duration: 0.25, ease: "easeInOut" }}
|
|
88
|
+
onClick={onClose}
|
|
89
|
+
>
|
|
90
|
+
{/* Content wrapper — prevents overlay click-through */}
|
|
91
|
+
<motion.div
|
|
92
|
+
className="relative flex items-center justify-center"
|
|
93
|
+
initial={{ opacity: 0, scale: 0.92 }}
|
|
94
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
95
|
+
exit={{ opacity: 0, scale: 0.92 }}
|
|
96
|
+
transition={{ duration: 0.25, ease: "easeInOut" }}
|
|
97
|
+
onClick={(e) => e.stopPropagation()}
|
|
98
|
+
>
|
|
99
|
+
{/* Fixed 850×850 frame */}
|
|
100
|
+
<div
|
|
101
|
+
className="relative flex items-center justify-center overflow-hidden rounded-[6px] bg-white"
|
|
102
|
+
style={{ width: 850, height: 850 }}
|
|
103
|
+
>
|
|
104
|
+
{/* Sliding image */}
|
|
105
|
+
<AnimatePresence mode="wait" initial={false} custom={direction}>
|
|
106
|
+
<motion.img
|
|
107
|
+
key={current}
|
|
108
|
+
custom={direction}
|
|
109
|
+
variants={slideVariants}
|
|
110
|
+
initial="enter"
|
|
111
|
+
animate="center"
|
|
112
|
+
exit="exit"
|
|
113
|
+
transition={{ duration: 0.3, ease: "easeInOut" }}
|
|
114
|
+
src={currentImage?.url}
|
|
115
|
+
alt={currentImage?.altText ?? `Image ${current + 1}`}
|
|
116
|
+
className="w-full rounded-[6px] object-contain"
|
|
117
|
+
draggable={false}
|
|
118
|
+
/>
|
|
119
|
+
</AnimatePresence>
|
|
120
|
+
|
|
121
|
+
{/* Left arrow — always visible, disabled when no prev */}
|
|
122
|
+
<button
|
|
123
|
+
type="button"
|
|
124
|
+
onClick={goPrev}
|
|
125
|
+
disabled={!hasPrev}
|
|
126
|
+
className={`absolute left-0 top-1/2 -translate-y-1/2 flex h-[100px] items-center rounded-tr-[3px] rounded-br-[3px] p-[7px] backdrop-blur-[2px] shadow-[3px_0px_6px_0px_rgba(0,0,0,0.1)] transition-opacity ${hasPrev ? "cursor-pointer opacity-100" : "cursor-default opacity-30"}`}
|
|
127
|
+
style={{ backgroundColor: "rgba(255,255,255,0.7)" }}
|
|
128
|
+
aria-label="Previous image"
|
|
129
|
+
>
|
|
130
|
+
<svg width="42" height="42" viewBox="0 0 24 24" fill="none">
|
|
131
|
+
<path
|
|
132
|
+
d="M15 18L9 12L15 6"
|
|
133
|
+
stroke="#2C2C2C"
|
|
134
|
+
strokeWidth="2"
|
|
135
|
+
strokeLinecap="round"
|
|
136
|
+
strokeLinejoin="round"
|
|
137
|
+
/>
|
|
138
|
+
</svg>
|
|
139
|
+
</button>
|
|
140
|
+
|
|
141
|
+
{/* Right arrow — always visible, disabled when no next */}
|
|
142
|
+
<button
|
|
143
|
+
type="button"
|
|
144
|
+
onClick={goNext}
|
|
145
|
+
disabled={!hasNext}
|
|
146
|
+
className={`absolute right-0 top-1/2 -translate-y-1/2 flex h-[100px] items-center rounded-tl-[3px] rounded-bl-[3px] p-[7px] backdrop-blur-[2px] shadow-[-3px_0px_6px_0px_rgba(0,0,0,0.1)] transition-opacity ${hasNext ? "cursor-pointer opacity-100" : "cursor-default opacity-30"}`}
|
|
147
|
+
style={{ backgroundColor: "rgba(255,255,255,0.7)" }}
|
|
148
|
+
aria-label="Next image"
|
|
149
|
+
>
|
|
150
|
+
<svg width="42" height="42" viewBox="0 0 24 24" fill="none">
|
|
151
|
+
<path
|
|
152
|
+
d="M9 18L15 12L9 6"
|
|
153
|
+
stroke="#2C2C2C"
|
|
154
|
+
strokeWidth="2"
|
|
155
|
+
strokeLinecap="round"
|
|
156
|
+
strokeLinejoin="round"
|
|
157
|
+
/>
|
|
158
|
+
</svg>
|
|
159
|
+
</button>
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
{/* Close button — top-right outside the image */}
|
|
163
|
+
<button
|
|
164
|
+
type="button"
|
|
165
|
+
onClick={onClose}
|
|
166
|
+
className="absolute top-[-4px] right-[-63px] flex h-[36px] w-[36px] cursor-pointer items-center justify-center rounded-full bg-white shadow-[0_2px_8px_rgba(0,0,0,0.15)] transition-colors hover:bg-[#f1f1f1]"
|
|
167
|
+
aria-label="Close preview"
|
|
168
|
+
>
|
|
169
|
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
|
170
|
+
<path
|
|
171
|
+
d="M18 6L6 18"
|
|
172
|
+
stroke="black"
|
|
173
|
+
strokeWidth="2"
|
|
174
|
+
strokeLinecap="round"
|
|
175
|
+
strokeLinejoin="round"
|
|
176
|
+
/>
|
|
177
|
+
<path
|
|
178
|
+
d="M6 6L18 18"
|
|
179
|
+
stroke="black"
|
|
180
|
+
strokeWidth="2"
|
|
181
|
+
strokeLinecap="round"
|
|
182
|
+
strokeLinejoin="round"
|
|
183
|
+
/>
|
|
184
|
+
</svg>
|
|
185
|
+
</button>
|
|
186
|
+
</motion.div>
|
|
187
|
+
</motion.div>
|
|
188
|
+
)}
|
|
189
|
+
</AnimatePresence>
|
|
190
|
+
</Portal>
|
|
191
|
+
);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
export default ChatImagePreviewModal;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// chat/ChatRoot.tsx
|
|
2
|
+
"use client";
|
|
3
|
+
import { AnimatePresence } from "framer-motion";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
5
|
+
import { useChatUI } from "../contexts/ChatUIContext";
|
|
6
|
+
import { useDisableBodyScroll } from "../hooks/useDisableBodyScroll";
|
|
7
|
+
import { GalleryProvider } from "../contexts/GalleryProvider";
|
|
8
|
+
import type { ChatAdapter, ChatUICallbacks } from "../adapter/types";
|
|
9
|
+
import InboxPopup from "./InboxPopup";
|
|
10
|
+
import SinglePopup from "./SinglePopup";
|
|
11
|
+
|
|
12
|
+
export type ChatRootProps = {
|
|
13
|
+
/**
|
|
14
|
+
* The unified data adapter — provides all threads, messages, and send logic.
|
|
15
|
+
*
|
|
16
|
+
* Implement this in your host app:
|
|
17
|
+
* ```ts
|
|
18
|
+
* const adapter = createDemoChatAdapter(); // or createApiChatAdapter(...)
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
adapter: ChatAdapter;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Optional UI callbacks — controls toast notifications, navigation,
|
|
25
|
+
* and the kebab (⋮) menu renderer.
|
|
26
|
+
*
|
|
27
|
+
* These delegate UI side-effects back to the host app so the package
|
|
28
|
+
* stays decoupled from the host's routing and notification systems.
|
|
29
|
+
*/
|
|
30
|
+
uiCallbacks?: ChatUICallbacks;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export default function ChatRoot({ adapter, uiCallbacks }: ChatRootProps) {
|
|
34
|
+
const { isOpen, variant } = useChatUI();
|
|
35
|
+
|
|
36
|
+
// Lock page scroll whenever the chat is open
|
|
37
|
+
useDisableBodyScroll(isOpen);
|
|
38
|
+
|
|
39
|
+
if (typeof window === "undefined") {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return createPortal(
|
|
44
|
+
// GalleryProvider is scoped to the chat only.
|
|
45
|
+
// It is completely separate from the host app's own gallery context.
|
|
46
|
+
<GalleryProvider>
|
|
47
|
+
<AnimatePresence mode="wait">
|
|
48
|
+
{isOpen && (
|
|
49
|
+
variant === "inbox" ? (
|
|
50
|
+
<InboxPopup
|
|
51
|
+
key="inbox"
|
|
52
|
+
adapter={adapter}
|
|
53
|
+
uiCallbacks={uiCallbacks}
|
|
54
|
+
/>
|
|
55
|
+
) : (
|
|
56
|
+
<SinglePopup
|
|
57
|
+
key="single"
|
|
58
|
+
adapter={adapter}
|
|
59
|
+
uiCallbacks={uiCallbacks}
|
|
60
|
+
/>
|
|
61
|
+
)
|
|
62
|
+
)}
|
|
63
|
+
</AnimatePresence>
|
|
64
|
+
</GalleryProvider>,
|
|
65
|
+
document.body,
|
|
66
|
+
);
|
|
67
|
+
}
|