@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,179 @@
|
|
|
1
|
+
// components/ui/chat/ChatListHeader.tsx
|
|
2
|
+
"use client";
|
|
3
|
+
|
|
4
|
+
import { useEffect, useRef, useState } from "react";
|
|
5
|
+
import type { Variants } from "framer-motion";
|
|
6
|
+
import { AnimatePresence, motion } from "framer-motion";
|
|
7
|
+
|
|
8
|
+
import { MessageIcon, ChatSearchIcon, ChatXIcon } from "../../icons";
|
|
9
|
+
import { cn } from "../../utils/cn";
|
|
10
|
+
|
|
11
|
+
/* =======================
|
|
12
|
+
Types
|
|
13
|
+
======================= */
|
|
14
|
+
|
|
15
|
+
type Props = {
|
|
16
|
+
className?: string;
|
|
17
|
+
onClose?: () => void;
|
|
18
|
+
onSearchChange?: (value: string) => void;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/* =======================
|
|
22
|
+
Component
|
|
23
|
+
======================= */
|
|
24
|
+
|
|
25
|
+
const ChatListHeader = ({ className, onClose, onSearchChange }: Props) => {
|
|
26
|
+
const [searching, setSearching] = useState(false);
|
|
27
|
+
const [q, setQ] = useState("");
|
|
28
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (searching) {
|
|
32
|
+
const timer = setTimeout(() => inputRef.current?.focus(), 200);
|
|
33
|
+
return () => clearTimeout(timer);
|
|
34
|
+
}
|
|
35
|
+
}, [searching]);
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (!searching) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const onKey = (e: KeyboardEvent) => {
|
|
43
|
+
if (e.key === "Escape") {
|
|
44
|
+
setSearching(false);
|
|
45
|
+
setQ("");
|
|
46
|
+
onSearchChange?.("");
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
window.addEventListener("keydown", onKey);
|
|
51
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
52
|
+
}, [searching, onSearchChange]);
|
|
53
|
+
|
|
54
|
+
// const clearInside = () => {
|
|
55
|
+
// setQ("");
|
|
56
|
+
// onSearchChange?.("");
|
|
57
|
+
// inputRef.current?.focus();
|
|
58
|
+
// };
|
|
59
|
+
|
|
60
|
+
const variants: Variants = {
|
|
61
|
+
inFromRight: {
|
|
62
|
+
opacity: 1,
|
|
63
|
+
x: 0,
|
|
64
|
+
transition: { duration: 0.18, ease: [0.16, 1, 0.3, 1] },
|
|
65
|
+
},
|
|
66
|
+
outToLeft: {
|
|
67
|
+
opacity: 0,
|
|
68
|
+
x: -24,
|
|
69
|
+
transition: { duration: 0.16, ease: [0.4, 0, 1, 1] },
|
|
70
|
+
},
|
|
71
|
+
inFromLeft: {
|
|
72
|
+
opacity: 1,
|
|
73
|
+
x: 0,
|
|
74
|
+
transition: { duration: 0.18, ease: [0.16, 1, 0.3, 1] },
|
|
75
|
+
},
|
|
76
|
+
outToRight: {
|
|
77
|
+
opacity: 0,
|
|
78
|
+
x: 24,
|
|
79
|
+
transition: { duration: 0.16, ease: [0.4, 0, 1, 1] },
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div className={cn("h-[64px] border-b border-[#ededed]", className)}>
|
|
85
|
+
<div className="flex h-full items-center px-[20px]">
|
|
86
|
+
<AnimatePresence initial={false} mode="wait">
|
|
87
|
+
{!searching ? (
|
|
88
|
+
/* =======================
|
|
89
|
+
Normal header
|
|
90
|
+
======================= */
|
|
91
|
+
<motion.div
|
|
92
|
+
key="normal"
|
|
93
|
+
className="flex w-full items-center justify-between"
|
|
94
|
+
initial={{ opacity: 0, x: -24 }}
|
|
95
|
+
animate="inFromLeft"
|
|
96
|
+
exit="outToLeft"
|
|
97
|
+
variants={variants}
|
|
98
|
+
>
|
|
99
|
+
<div className="flex items-center gap-3">
|
|
100
|
+
<div className="flex items-center gap-2 text-[#2c2c2c]">
|
|
101
|
+
<MessageIcon className="h-6 w-6" />
|
|
102
|
+
<span className="text-[22px] font-semibold">Messenger</span>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<div className="flex items-center gap-2">
|
|
107
|
+
<button
|
|
108
|
+
type="button"
|
|
109
|
+
title="Search"
|
|
110
|
+
onClick={() => setSearching(true)}
|
|
111
|
+
className="flex h-9 w-9 items-center justify-center rounded-full hover:bg-black/5"
|
|
112
|
+
>
|
|
113
|
+
<ChatSearchIcon className="h-6 w-6" />
|
|
114
|
+
</button>
|
|
115
|
+
|
|
116
|
+
<button
|
|
117
|
+
type="button"
|
|
118
|
+
title="Close"
|
|
119
|
+
onClick={onClose}
|
|
120
|
+
className="flex h-9 w-9 items-center justify-center rounded-full hover:bg-black/5"
|
|
121
|
+
>
|
|
122
|
+
<ChatXIcon className="h-6 w-6" />
|
|
123
|
+
</button>
|
|
124
|
+
</div>
|
|
125
|
+
</motion.div>
|
|
126
|
+
) : (
|
|
127
|
+
/* =======================
|
|
128
|
+
Search header
|
|
129
|
+
======================= */
|
|
130
|
+
<motion.div
|
|
131
|
+
key="search"
|
|
132
|
+
className="flex w-full items-center gap-3"
|
|
133
|
+
initial={{ opacity: 0, x: 24 }}
|
|
134
|
+
animate="inFromRight"
|
|
135
|
+
exit="outToRight"
|
|
136
|
+
variants={variants}
|
|
137
|
+
>
|
|
138
|
+
<div className="relative flex-1">
|
|
139
|
+
<div className="flex justify-between h-10 w-full items-center gap-1.5 rounded-full border border-[#6A6A6A] bg-white px-[4px]">
|
|
140
|
+
<div className="ms-[12px] flex items-center">
|
|
141
|
+
<span className="mr-2 grid h-6 w-6 shrink-0 place-items-center text-#929292]">
|
|
142
|
+
<ChatSearchIcon className="h-5 w-5" />
|
|
143
|
+
</span>
|
|
144
|
+
<span className="mr-2 h-6 w-px shrink-0 bg-[#e1e1e1]" />
|
|
145
|
+
<input
|
|
146
|
+
ref={inputRef}
|
|
147
|
+
value={q}
|
|
148
|
+
onChange={(e) => {
|
|
149
|
+
setQ(e.target.value);
|
|
150
|
+
onSearchChange?.(e.target.value);
|
|
151
|
+
}}
|
|
152
|
+
placeholder="Search"
|
|
153
|
+
className="h-full w-full flex-1 bg-transparent text-[15px] outline-none placeholder:text-[#9C9C9C]"
|
|
154
|
+
/>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
<button
|
|
158
|
+
type="button"
|
|
159
|
+
title="Close search"
|
|
160
|
+
onClick={() => {
|
|
161
|
+
setSearching(false);
|
|
162
|
+
setQ("");
|
|
163
|
+
onSearchChange?.("");
|
|
164
|
+
}}
|
|
165
|
+
className="flex h-8! w-8! items-center justify-center rounded-full hover:bg-black/5"
|
|
166
|
+
>
|
|
167
|
+
<ChatXIcon className="h-5 w-5" />
|
|
168
|
+
</button>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
</motion.div>
|
|
172
|
+
)}
|
|
173
|
+
</AnimatePresence>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
export default ChatListHeader;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState } from "react";
|
|
4
|
+
|
|
5
|
+
import ChatAddressCard from "./message-items/ChatAddressCard";
|
|
6
|
+
import ChatBubbleAudio from "./message-items/ChatBubbleAudio";
|
|
7
|
+
import ChatBubbleFiles from "./message-items/ChatBubbleFiles";
|
|
8
|
+
import ChatBubbleImages from "./message-items/ChatBubbleImages";
|
|
9
|
+
import ChatBubbleText from "./message-items/ChatBubbleText";
|
|
10
|
+
import ChatBusinessCard from "./message-items/ChatBusinessCard";
|
|
11
|
+
import MessageHoverActions from "./MessageHoverActions";
|
|
12
|
+
import ReplyCard from "./ReplyCard";
|
|
13
|
+
import type { AddressCard, BusinessCard, MessageRef } from "./types";
|
|
14
|
+
import { cn } from "../../utils/cn";
|
|
15
|
+
|
|
16
|
+
/* =======================
|
|
17
|
+
Demo translator
|
|
18
|
+
======================= */
|
|
19
|
+
|
|
20
|
+
const toBanglaDemo = (s: string): string => {
|
|
21
|
+
const map: Record<string, string> = {
|
|
22
|
+
Hi: "হাই",
|
|
23
|
+
"Do you have a freight forwarder in China?": "আপনার কি চীনে কোনো ফ্রেইট ফরওয়ার্ডার আছে?",
|
|
24
|
+
"This conversation is empty. Say hi 👋": "এই কথোপকথনটি খালি । হাই হাই হাই বলুন 👋",
|
|
25
|
+
"Can we schedule a call for tomorrow?": "আমরা কি আগামীকাল একটি কল নির্ধারণ করতে পারি?",
|
|
26
|
+
"Sure, what time suits you?": "অবশ্যই, আপনার জন্য কোন সময়টি সুবিধাজনক?",
|
|
27
|
+
"Welcome to Global Marketplace": "গ্লোবাল মার্কেটপ্লেসে আপনাকে স্বাগতম 🎉",
|
|
28
|
+
"Happy to be here!": "এখানে থাকতে পেরে আনন্দিত!",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
if (map[s]) {
|
|
32
|
+
return map[s];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return `বাংলা ${s}`;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/* =======================
|
|
39
|
+
Types
|
|
40
|
+
======================= */
|
|
41
|
+
|
|
42
|
+
export type ChatAudio = {
|
|
43
|
+
src?: string;
|
|
44
|
+
duration?: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type ChatFile = {
|
|
48
|
+
name: string;
|
|
49
|
+
sizeMB: number;
|
|
50
|
+
ext: string;
|
|
51
|
+
href?: string;
|
|
52
|
+
downloadName?: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type ChatMessageItemProps = {
|
|
56
|
+
id: string;
|
|
57
|
+
mine?: boolean;
|
|
58
|
+
time: string;
|
|
59
|
+
authorInitial?: string;
|
|
60
|
+
avatarBg?: string;
|
|
61
|
+
|
|
62
|
+
text?: string;
|
|
63
|
+
businessCard?: BusinessCard;
|
|
64
|
+
addressCard?: AddressCard;
|
|
65
|
+
images?: string[];
|
|
66
|
+
files?: ChatFile[];
|
|
67
|
+
audio?: ChatAudio;
|
|
68
|
+
|
|
69
|
+
replyTo?: MessageRef;
|
|
70
|
+
showStatus?: boolean;
|
|
71
|
+
status?: string;
|
|
72
|
+
className?: string;
|
|
73
|
+
|
|
74
|
+
onReply?: () => void;
|
|
75
|
+
onTranslate?: () => void;
|
|
76
|
+
|
|
77
|
+
initialSrc?: string;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/* =======================
|
|
81
|
+
Component
|
|
82
|
+
======================= */
|
|
83
|
+
|
|
84
|
+
const ChatMessageItem = ({
|
|
85
|
+
id,
|
|
86
|
+
mine = false,
|
|
87
|
+
time,
|
|
88
|
+
authorInitial = "U",
|
|
89
|
+
avatarBg = "#ffffff",
|
|
90
|
+
text,
|
|
91
|
+
businessCard,
|
|
92
|
+
addressCard,
|
|
93
|
+
images,
|
|
94
|
+
files,
|
|
95
|
+
audio,
|
|
96
|
+
replyTo,
|
|
97
|
+
showStatus = false,
|
|
98
|
+
status = "Seen",
|
|
99
|
+
className,
|
|
100
|
+
onReply,
|
|
101
|
+
onTranslate,
|
|
102
|
+
initialSrc,
|
|
103
|
+
}: ChatMessageItemProps) => {
|
|
104
|
+
const originalText = useMemo(() => text ?? "", [text]);
|
|
105
|
+
const [translated, setTranslated] = useState(false);
|
|
106
|
+
|
|
107
|
+
const displayText = translated ? toBanglaDemo(originalText) : originalText;
|
|
108
|
+
|
|
109
|
+
const handleTranslateClick = () => {
|
|
110
|
+
setTranslated((v) => !v);
|
|
111
|
+
onTranslate?.();
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const isOnline = true;
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<div className={cn("mb-4", className)} data-msg-id={id}>
|
|
118
|
+
<div className={cn("flex items-end gap-3", mine && "justify-end")}>
|
|
119
|
+
{!mine ? (
|
|
120
|
+
<div className={cn(showStatus ? "mb-5" : "mb-0")}>
|
|
121
|
+
{initialSrc ? (
|
|
122
|
+
<div className="relative h-10 w-10 shrink-0 rounded-full border border-[#F1F1F1]">
|
|
123
|
+
<img
|
|
124
|
+
src={initialSrc}
|
|
125
|
+
alt="avatar image"
|
|
126
|
+
className="h-full w-full rounded-full object-cover"
|
|
127
|
+
/>
|
|
128
|
+
|
|
129
|
+
{isOnline ? (
|
|
130
|
+
<span className="absolute bottom-0 right-0 h-[11.25px] w-[11.25px] rounded-full bg-[#328545] ring-1 ring-white" />
|
|
131
|
+
) : null}
|
|
132
|
+
</div>
|
|
133
|
+
) : (
|
|
134
|
+
<div
|
|
135
|
+
className="relative grid h-10 w-10 shrink-0 place-items-center rounded-full border border-[#f1f1f1] font-semibold text-[#2c2c2c]"
|
|
136
|
+
style={{ backgroundColor: avatarBg }}
|
|
137
|
+
>
|
|
138
|
+
{authorInitial}
|
|
139
|
+
|
|
140
|
+
{isOnline ? (
|
|
141
|
+
<span className="absolute bottom-0 right-0 h-[11.25px] w-[11.25px] rounded-full bg-[#328545] ring-1 ring-white" />
|
|
142
|
+
) : null}
|
|
143
|
+
</div>
|
|
144
|
+
)}
|
|
145
|
+
</div>
|
|
146
|
+
) : null}
|
|
147
|
+
|
|
148
|
+
<div className="flex w-full flex-col gap-1">
|
|
149
|
+
<div
|
|
150
|
+
className={cn(
|
|
151
|
+
"text-xs font-light text-[#636363]",
|
|
152
|
+
mine ? "text-right" : "text-left",
|
|
153
|
+
)}
|
|
154
|
+
>
|
|
155
|
+
{time}
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<div className={cn("flex w-full flex-col gap-1", mine ? "items-end" : "items-start")}>
|
|
159
|
+
{/* Reply preview */}
|
|
160
|
+
<div className={cn("flex w-full", mine ? "justify-end" : "justify-start")}>
|
|
161
|
+
{replyTo ? <ReplyCard jumpOnClick refMsg={replyTo} compact className="mb-1" /> : null}
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
{businessCard ? <ChatBusinessCard mine={mine} card={businessCard} /> : null}
|
|
165
|
+
{addressCard ? <ChatAddressCard mine={mine} card={addressCard} /> : null}
|
|
166
|
+
|
|
167
|
+
{files?.length ? (
|
|
168
|
+
<MessageHoverActions mine={mine} onReply={onReply} isItemButton={["replay"]}>
|
|
169
|
+
<ChatBubbleFiles files={files} />
|
|
170
|
+
</MessageHoverActions>
|
|
171
|
+
) : null}
|
|
172
|
+
|
|
173
|
+
{images?.length ? (
|
|
174
|
+
<MessageHoverActions mine={mine} onReply={onReply} isItemButton={["replay"]}>
|
|
175
|
+
<ChatBubbleImages images={images} />
|
|
176
|
+
</MessageHoverActions>
|
|
177
|
+
) : null}
|
|
178
|
+
|
|
179
|
+
{audio ? (
|
|
180
|
+
<MessageHoverActions
|
|
181
|
+
mine={mine}
|
|
182
|
+
onReply={onReply}
|
|
183
|
+
onTranslate={onTranslate}
|
|
184
|
+
isItemButton={["replay", "translate"]}
|
|
185
|
+
>
|
|
186
|
+
<ChatBubbleAudio mine={mine} audio={audio} />
|
|
187
|
+
</MessageHoverActions>
|
|
188
|
+
) : null}
|
|
189
|
+
|
|
190
|
+
{typeof text === "string" && text.length > 0 ? (
|
|
191
|
+
<MessageHoverActions
|
|
192
|
+
mine={mine}
|
|
193
|
+
onReply={onReply}
|
|
194
|
+
onTranslate={handleTranslateClick}
|
|
195
|
+
isItemButton={["replay", "translate"]}
|
|
196
|
+
activeButtons={translated ? ["translate"] : []}
|
|
197
|
+
>
|
|
198
|
+
<ChatBubbleText mine={mine} text={displayText} />
|
|
199
|
+
</MessageHoverActions>
|
|
200
|
+
) : null}
|
|
201
|
+
|
|
202
|
+
{showStatus ? (
|
|
203
|
+
<div className={cn("text-xs text-[#9AA1A9]", mine ? "text-right" : "text-left")}>
|
|
204
|
+
{status}
|
|
205
|
+
</div>
|
|
206
|
+
) : null}
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
export default ChatMessageItem;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import clsx from "clsx";
|
|
3
|
+
import React from "react";
|
|
4
|
+
|
|
5
|
+
type Props = {
|
|
6
|
+
top?: React.ReactNode;
|
|
7
|
+
children: React.ReactNode;
|
|
8
|
+
className?: string;
|
|
9
|
+
/** set true if you want short threads anchored at the bottom */
|
|
10
|
+
bottomAlignWhenShort?: boolean;
|
|
11
|
+
/** when this value changes, we auto-scroll to the bottom */
|
|
12
|
+
scrollKey?: string | number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const ChatScroll: React.FC<Props> = ({
|
|
16
|
+
top,
|
|
17
|
+
children,
|
|
18
|
+
className,
|
|
19
|
+
bottomAlignWhenShort = false,
|
|
20
|
+
scrollKey,
|
|
21
|
+
}) => {
|
|
22
|
+
const ref = React.useRef<HTMLDivElement>(null);
|
|
23
|
+
|
|
24
|
+
const scrollToBottom = React.useCallback(() => {
|
|
25
|
+
const el = ref.current;
|
|
26
|
+
if (!el) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
el.scrollTop = el.scrollHeight;
|
|
30
|
+
}, []);
|
|
31
|
+
|
|
32
|
+
// On mount & when scrollKey changes
|
|
33
|
+
React.useEffect(() => {
|
|
34
|
+
// immediate
|
|
35
|
+
scrollToBottom();
|
|
36
|
+
// nudge after paint/layout (covers images)
|
|
37
|
+
const id = window.setTimeout(scrollToBottom, 0);
|
|
38
|
+
return () => window.clearTimeout(id);
|
|
39
|
+
}, [scrollKey, scrollToBottom]);
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div
|
|
43
|
+
ref={ref}
|
|
44
|
+
data-chat-scroll
|
|
45
|
+
className={clsx(
|
|
46
|
+
"h-full min-h-0 overflow-y-auto bg-white p-4 custom-scroll-hidden",
|
|
47
|
+
className,
|
|
48
|
+
)}
|
|
49
|
+
>
|
|
50
|
+
{/* This wrapper ensures content is at least as tall as the scroll area */}
|
|
51
|
+
<div
|
|
52
|
+
className={clsx(
|
|
53
|
+
"min-h-full flex flex-col",
|
|
54
|
+
bottomAlignWhenShort ? "justify-end" : "justify-start",
|
|
55
|
+
)}
|
|
56
|
+
>
|
|
57
|
+
{top}
|
|
58
|
+
{children}
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export default ChatScroll;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cn } from "../../utils/cn";
|
|
3
|
+
|
|
4
|
+
/* ===========================================================
|
|
5
|
+
ChatSpinner
|
|
6
|
+
Matches the inline-area spinner from the seller app's
|
|
7
|
+
PageLoader (dark dual-ring on white background).
|
|
8
|
+
=========================================================== */
|
|
9
|
+
|
|
10
|
+
type Props = {
|
|
11
|
+
className?: string;
|
|
12
|
+
/** Size in px (default 32) */
|
|
13
|
+
size?: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const ChatSpinner: React.FC<Props> = ({ className, size = 32 }) => (
|
|
17
|
+
<>
|
|
18
|
+
<style>{`
|
|
19
|
+
@keyframes banbox-chat-spin {
|
|
20
|
+
to { transform: rotate(360deg); }
|
|
21
|
+
}
|
|
22
|
+
`}</style>
|
|
23
|
+
<div className={cn("flex items-center justify-center", className)}>
|
|
24
|
+
<svg
|
|
25
|
+
style={{
|
|
26
|
+
width: size,
|
|
27
|
+
height: size,
|
|
28
|
+
animation: "banbox-chat-spin 1.4s linear infinite",
|
|
29
|
+
}}
|
|
30
|
+
viewBox="0 0 36 36"
|
|
31
|
+
fill="none"
|
|
32
|
+
aria-hidden="true"
|
|
33
|
+
>
|
|
34
|
+
<circle cx="18" cy="18" r="15" stroke="#e5e7eb" strokeWidth="3" />
|
|
35
|
+
<circle
|
|
36
|
+
cx="18"
|
|
37
|
+
cy="18"
|
|
38
|
+
r="15"
|
|
39
|
+
stroke="#3d3d3d"
|
|
40
|
+
strokeWidth="3"
|
|
41
|
+
strokeLinecap="round"
|
|
42
|
+
strokeDasharray="22 72"
|
|
43
|
+
/>
|
|
44
|
+
</svg>
|
|
45
|
+
</div>
|
|
46
|
+
</>
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
export default ChatSpinner;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { BlueBadgeIcon } from "../../icons";
|
|
4
|
+
import { cn } from "../../utils/cn";
|
|
5
|
+
|
|
6
|
+
/* =======================
|
|
7
|
+
Types
|
|
8
|
+
======================= */
|
|
9
|
+
|
|
10
|
+
export type ChatThreadStatus =
|
|
11
|
+
| { kind: "seen" }
|
|
12
|
+
| { kind: "delivered" }
|
|
13
|
+
| { kind: "new"; count: number };
|
|
14
|
+
|
|
15
|
+
type Props = {
|
|
16
|
+
active?: boolean;
|
|
17
|
+
pinned?: boolean;
|
|
18
|
+
online?: boolean;
|
|
19
|
+
verified?: boolean;
|
|
20
|
+
|
|
21
|
+
title: string;
|
|
22
|
+
preview: string;
|
|
23
|
+
time: string;
|
|
24
|
+
|
|
25
|
+
status: ChatThreadStatus;
|
|
26
|
+
|
|
27
|
+
avatarText: string;
|
|
28
|
+
avatarSrc?: string;
|
|
29
|
+
size?: number;
|
|
30
|
+
avatarBg?: string;
|
|
31
|
+
className?: string;
|
|
32
|
+
onClick?: () => void;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/* =======================
|
|
36
|
+
Helpers
|
|
37
|
+
======================= */
|
|
38
|
+
|
|
39
|
+
const formatTwoDigits = (value: number): string => {
|
|
40
|
+
return String(Math.max(0, value)).padStart(2, "0");
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/* =======================
|
|
44
|
+
Component
|
|
45
|
+
======================= */
|
|
46
|
+
|
|
47
|
+
const ChatThreadItem = ({
|
|
48
|
+
active = false,
|
|
49
|
+
pinned = false,
|
|
50
|
+
online = false,
|
|
51
|
+
verified = false,
|
|
52
|
+
title,
|
|
53
|
+
preview,
|
|
54
|
+
time,
|
|
55
|
+
status,
|
|
56
|
+
avatarText,
|
|
57
|
+
avatarSrc,
|
|
58
|
+
avatarBg = "#FFE5DA",
|
|
59
|
+
className,
|
|
60
|
+
onClick,
|
|
61
|
+
}: Props) => {
|
|
62
|
+
const statusEl = (() => {
|
|
63
|
+
switch (status.kind) {
|
|
64
|
+
case "seen":
|
|
65
|
+
return <span className="text-[#005694]">Seen</span>;
|
|
66
|
+
|
|
67
|
+
case "delivered":
|
|
68
|
+
return <span className="text-[#929292]">Delivered</span>;
|
|
69
|
+
|
|
70
|
+
case "new":
|
|
71
|
+
return <span className="text-[#EB2127]">New {formatTwoDigits(status.count)}</span>;
|
|
72
|
+
}
|
|
73
|
+
})();
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<button
|
|
77
|
+
type="button"
|
|
78
|
+
onClick={onClick}
|
|
79
|
+
className={cn(
|
|
80
|
+
"relative w-full px-5 py-2 text-left focus:outline-none border-b border-[#f1f1f1]",
|
|
81
|
+
"hover:bg-[#f8f8f8]",
|
|
82
|
+
active && "bg-[#f8f8f8]",
|
|
83
|
+
className,
|
|
84
|
+
)}
|
|
85
|
+
>
|
|
86
|
+
{/* Pinned corner */}
|
|
87
|
+
{pinned ? (
|
|
88
|
+
<span className="absolute right-0 top-0 h-0 w-0 border-l-16 border-t-16 border-l-transparent border-t-[#FFD2BD]" />
|
|
89
|
+
) : null}
|
|
90
|
+
|
|
91
|
+
<div className="flex items-start gap-3">
|
|
92
|
+
<div className="relative shrink-0" style={{ width: 36, height: 36 }}>
|
|
93
|
+
{avatarSrc ? (
|
|
94
|
+
<div className="h-9 w-9 overflow-hidden rounded-[2px] border border-[#f1f1f1]">
|
|
95
|
+
<img src={avatarSrc} alt={title} className="h-full w-full rounded-[2px] object-cover" />
|
|
96
|
+
</div>
|
|
97
|
+
) : (
|
|
98
|
+
<div
|
|
99
|
+
className="grid h-9 w-9 place-items-center rounded-[2px] border border-[#f1f1f1] text-[15px] font-semibold text-[#2c2c2c]"
|
|
100
|
+
style={{ backgroundColor: avatarBg }}
|
|
101
|
+
>
|
|
102
|
+
{avatarText}
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
105
|
+
|
|
106
|
+
<span
|
|
107
|
+
className={cn(
|
|
108
|
+
"absolute rounded-full",
|
|
109
|
+
online ? "bg-[#74A380]" : "bg-[#EB2127]",
|
|
110
|
+
)}
|
|
111
|
+
style={{ bottom: -1.5, right: -1.5, width: 11.25, height: 11.25, border: "1px solid #FFFFFF" }}
|
|
112
|
+
/>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
{/* Content */}
|
|
116
|
+
<div className="min-w-0 flex-1">
|
|
117
|
+
<div className="flex items-center gap-1 text-[14px]">
|
|
118
|
+
<span className="truncate font-medium text-black">{title}</span>
|
|
119
|
+
{verified ? (
|
|
120
|
+
<span>
|
|
121
|
+
<BlueBadgeIcon />
|
|
122
|
+
</span>
|
|
123
|
+
) : null}
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
<div className="truncate text-xs font-normal text-[#2c2c2c]">
|
|
127
|
+
{preview}
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<div className="mt-0.5 flex items-center justify-between text-[12px]">
|
|
131
|
+
<div>{statusEl}</div>
|
|
132
|
+
<span className="font-light text-[#636363] tracking-[0.5px]">{time}</span>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
</button>
|
|
137
|
+
);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export default ChatThreadItem;
|