@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.
Files changed (52) hide show
  1. package/README.md +215 -0
  2. package/dist/index.cjs +3408 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.d.cts +556 -0
  5. package/dist/index.d.ts +556 -0
  6. package/dist/index.js +3385 -0
  7. package/dist/index.js.map +1 -0
  8. package/package.json +83 -0
  9. package/src/adapter/types.ts +146 -0
  10. package/src/chat/ChatImagePreviewModal.tsx +194 -0
  11. package/src/chat/ChatRoot.tsx +67 -0
  12. package/src/chat/InboxPopup.tsx +312 -0
  13. package/src/chat/SinglePopup.tsx +240 -0
  14. package/src/contexts/ChatUIContext.tsx +30 -0
  15. package/src/contexts/ChatUIProvider.tsx +38 -0
  16. package/src/contexts/GalleryContext.tsx +40 -0
  17. package/src/contexts/GalleryProvider.tsx +89 -0
  18. package/src/hooks/useDisableBodyScroll.ts +16 -0
  19. package/src/icons/index.tsx +248 -0
  20. package/src/index.ts +56 -0
  21. package/src/lottie/typingdotanimation2.json +1 -0
  22. package/src/modals/chat/ChatConfirmModal.tsx +104 -0
  23. package/src/modals/chat/ChatTranslateSettingsModal.tsx +180 -0
  24. package/src/types/index.ts +163 -0
  25. package/src/ui/Button.tsx +83 -0
  26. package/src/ui/Portal.tsx +40 -0
  27. package/src/ui/Select.tsx +74 -0
  28. package/src/ui/chat/AttachmentPreviewStrip.tsx +166 -0
  29. package/src/ui/chat/ChatComposerBar.tsx +231 -0
  30. package/src/ui/chat/ChatFooter.tsx +442 -0
  31. package/src/ui/chat/ChatHeader.tsx +24 -0
  32. package/src/ui/chat/ChatIdentity.tsx +145 -0
  33. package/src/ui/chat/ChatInquiryBar.tsx +57 -0
  34. package/src/ui/chat/ChatListHeader.tsx +179 -0
  35. package/src/ui/chat/ChatMessageItem.tsx +214 -0
  36. package/src/ui/chat/ChatScroll.tsx +64 -0
  37. package/src/ui/chat/ChatSpinner.tsx +49 -0
  38. package/src/ui/chat/ChatThreadItem.tsx +140 -0
  39. package/src/ui/chat/MessageHoverActions.tsx +120 -0
  40. package/src/ui/chat/ReplyCard.tsx +217 -0
  41. package/src/ui/chat/TypingIndicator.tsx +93 -0
  42. package/src/ui/chat/drop-up/BusinessCardDropup.tsx +253 -0
  43. package/src/ui/chat/drop-up/EmojiDropup.tsx +132 -0
  44. package/src/ui/chat/message-items/ChatAddressCard.tsx +130 -0
  45. package/src/ui/chat/message-items/ChatBubbleAudio.tsx +209 -0
  46. package/src/ui/chat/message-items/ChatBubbleFiles.tsx +80 -0
  47. package/src/ui/chat/message-items/ChatBubbleImages.tsx +120 -0
  48. package/src/ui/chat/message-items/ChatBubbleText.tsx +16 -0
  49. package/src/ui/chat/message-items/ChatBusinessCard.tsx +95 -0
  50. package/src/ui/chat/scrollToMessage.ts +61 -0
  51. package/src/ui/chat/types.ts +37 -0
  52. 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;