@banbox/chat 1.0.17 → 1.0.19

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@banbox/chat",
3
- "version": "1.0.17",
3
+ "version": "1.0.19",
4
4
  "description": "Banbox Chat UI components — reusable across all Banbox React/Next.js projects",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -18,14 +18,17 @@ interface ChatImagePreviewModalProps {
18
18
  /* =======================
19
19
  Slide direction variants
20
20
  ======================= */
21
+ // Percentage-based so the slide distance always matches the container width exactly.
22
+ // Both exit and enter run simultaneously (no mode="wait"), giving a natural
23
+ // cross-slide feel without the jerk caused by a sequential pause.
21
24
  const slideVariants = {
22
25
  enter: (dir: number) => ({
23
- x: dir > 0 ? 300 : -300,
26
+ x: dir > 0 ? "100%" : "-100%",
24
27
  opacity: 0,
25
28
  }),
26
29
  center: { x: 0, opacity: 1 },
27
30
  exit: (dir: number) => ({
28
- x: dir > 0 ? -300 : 300,
31
+ x: dir > 0 ? "-100%" : "100%",
29
32
  opacity: 0,
30
33
  }),
31
34
  };
@@ -80,7 +83,7 @@ const ChatImagePreviewModal: FC<ChatImagePreviewModalProps> = ({
80
83
  <AnimatePresence>
81
84
  {isOpen && total > 0 && (
82
85
  <motion.div
83
- className="fixed inset-0 z-999 flex items-center justify-center"
86
+ className="fixed inset-0 z-10010 flex items-center justify-center"
84
87
  initial={{ opacity: 0, backgroundColor: "rgba(0,0,0,0)" }}
85
88
  animate={{ opacity: 1, backgroundColor: "rgba(0,0,0,0.55)" }}
86
89
  exit={{ opacity: 0, backgroundColor: "rgba(0,0,0,0)" }}
@@ -96,13 +99,13 @@ const ChatImagePreviewModal: FC<ChatImagePreviewModalProps> = ({
96
99
  transition={{ duration: 0.25, ease: "easeInOut" }}
97
100
  onClick={(e) => e.stopPropagation()}
98
101
  >
99
- {/* Fixed 850×850 frame */}
102
+ {/* Fixed 953×679 frame — image adapts dynamically inside */}
100
103
  <div
101
104
  className="relative flex items-center justify-center overflow-hidden rounded-[6px] bg-white"
102
- style={{ width: 850, height: 850 }}
105
+ style={{ width: 953, height: 679 }}
103
106
  >
104
- {/* Sliding image */}
105
- <AnimatePresence mode="wait" initial={false} custom={direction}>
107
+ {/* Sliding image — both exit and enter animate together (no mode="wait") */}
108
+ <AnimatePresence initial={false} custom={direction}>
106
109
  <motion.img
107
110
  key={current}
108
111
  custom={direction}
@@ -110,53 +113,58 @@ const ChatImagePreviewModal: FC<ChatImagePreviewModalProps> = ({
110
113
  initial="enter"
111
114
  animate="center"
112
115
  exit="exit"
113
- transition={{ duration: 0.3, ease: "easeInOut" }}
116
+ transition={{ duration: 0.35, ease: [0.25, 0.46, 0.45, 0.94] }}
114
117
  src={currentImage?.url}
115
118
  alt={currentImage?.altText ?? `Image ${current + 1}`}
116
- className="w-full rounded-[6px] object-contain"
119
+ className="absolute inset-0 w-full h-full rounded-[6px] object-contain"
117
120
  draggable={false}
118
121
  />
119
122
  </AnimatePresence>
120
123
 
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>
124
+ {/* Arrowsonly shown when there are multiple images */}
125
+ {total > 1 && (
126
+ <>
127
+ {/* Left arrow */}
128
+ <button
129
+ type="button"
130
+ onClick={goPrev}
131
+ disabled={!hasPrev}
132
+ 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-all duration-200 ${hasPrev ? "cursor-pointer opacity-100 hover:bg-white hover:shadow-[3px_0px_10px_0px_rgba(0,0,0,0.18)]" : "cursor-default opacity-30"}`}
133
+ style={{ backgroundColor: "rgba(255,255,255,0.7)" }}
134
+ aria-label="Previous image"
135
+ >
136
+ <svg width="42" height="42" viewBox="0 0 24 24" fill="none">
137
+ <path
138
+ d="M15 18L9 12L15 6"
139
+ stroke="#2C2C2C"
140
+ strokeWidth="2"
141
+ strokeLinecap="round"
142
+ strokeLinejoin="round"
143
+ />
144
+ </svg>
145
+ </button>
140
146
 
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>
147
+ {/* Right arrow */}
148
+ <button
149
+ type="button"
150
+ onClick={goNext}
151
+ disabled={!hasNext}
152
+ 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-all duration-200 ${hasNext ? "cursor-pointer opacity-100 hover:bg-white hover:shadow-[-3px_0px_10px_0px_rgba(0,0,0,0.18)]" : "cursor-default opacity-30"}`}
153
+ style={{ backgroundColor: "rgba(255,255,255,0.7)" }}
154
+ aria-label="Next image"
155
+ >
156
+ <svg width="42" height="42" viewBox="0 0 24 24" fill="none">
157
+ <path
158
+ d="M9 18L15 12L9 6"
159
+ stroke="#2C2C2C"
160
+ strokeWidth="2"
161
+ strokeLinecap="round"
162
+ strokeLinejoin="round"
163
+ />
164
+ </svg>
165
+ </button>
166
+ </>
167
+ )}
160
168
  </div>
161
169
 
162
170
  {/* Close button — top-right outside the image */}
@@ -22,9 +22,10 @@ import type { ChatAdapter, ChatUICallbacks } from "../adapter/types";
22
22
  import type { Message, MessageRef, Reference, Thread } from "../types";
23
23
  import type { ChatTheme } from "./InboxPopup";
24
24
 
25
-
26
-
27
- function coalesceThreadId(reference: Reference | undefined, threads: Thread[]): string {
25
+ function coalesceThreadId(
26
+ reference: Reference | undefined,
27
+ threads: Thread[],
28
+ ): string {
28
29
  if (!reference?.id) return threads[0]?.id ?? "";
29
30
  const refId = reference.id;
30
31
  // Priority: exact thread.id match → orderId match → inquiryId match → first thread
@@ -51,16 +52,34 @@ function toRef(m: Message): MessageRef {
51
52
 
52
53
  /** Maps the first letter of a name to a deterministic background colour. */
53
54
  const avatarBgByInitial: Record<string, string> = {
54
- a: "#FFE4E4", b: "#E4F0FF", c: "#E4FFE9", d: "#FFF4E4", e: "#F4E4FF",
55
- f: "#FFE4F4", g: "#E4FFFF", h: "#FFFFE4", i: "#E4E4FF", j: "#FFE9E4",
56
- k: "#E4FFE4", l: "#FFE4EA", m: "#E8E4FF", n: "#E4F8FF", o: "#FFF0E4",
57
- p: "#F0FFE4", q: "#FFE4F8", r: "#E4FFEC", s: "#FFEEE4", t: "#E4EAFF",
58
- u: "#F8FFE4", v: "#FFE4EE", w: "#E4FFFA", x: "#FFF8E4", y: "#EAE4FF",
55
+ a: "#FFE4E4",
56
+ b: "#E4F0FF",
57
+ c: "#E4FFE9",
58
+ d: "#FFF4E4",
59
+ e: "#F4E4FF",
60
+ f: "#FFE4F4",
61
+ g: "#E4FFFF",
62
+ h: "#FFFFE4",
63
+ i: "#E4E4FF",
64
+ j: "#FFE9E4",
65
+ k: "#E4FFE4",
66
+ l: "#FFE4EA",
67
+ m: "#E8E4FF",
68
+ n: "#E4F8FF",
69
+ o: "#FFF0E4",
70
+ p: "#F0FFE4",
71
+ q: "#FFE4F8",
72
+ r: "#E4FFEC",
73
+ s: "#FFEEE4",
74
+ t: "#E4EAFF",
75
+ u: "#F8FFE4",
76
+ v: "#FFE4EE",
77
+ w: "#E4FFFA",
78
+ x: "#FFF8E4",
79
+ y: "#EAE4FF",
59
80
  z: "#E4FFF0",
60
81
  };
61
82
 
62
-
63
-
64
83
  export type SinglePopupProps = {
65
84
  adapter: ChatAdapter;
66
85
  uiCallbacks?: ChatUICallbacks;
@@ -75,12 +94,19 @@ export type SinglePopupProps = {
75
94
  /* ══════════════════════════════════════════════════
76
95
  Component
77
96
  ══════════════════════════════════════════════════ */
78
- const SinglePopup: React.FC<SinglePopupProps> = ({ adapter, uiCallbacks, theme, footerActions }) => {
97
+ const SinglePopup: React.FC<SinglePopupProps> = ({
98
+ adapter,
99
+ uiCallbacks,
100
+ theme,
101
+ footerActions,
102
+ }) => {
79
103
  const { close, reference } = useChatUI();
80
104
  const { isOpen: isGalleryOpen, closeGallery } = useGallery();
81
105
 
82
106
  // ── Threads — subscribed so real-API updates (new msg, pin, delete) are reflected
83
- const [threads, setThreads] = React.useState<Thread[]>(() => adapter.threads.list(reference));
107
+ const [threads, setThreads] = React.useState<Thread[]>(() =>
108
+ adapter.threads.list(reference),
109
+ );
84
110
  React.useEffect(() => {
85
111
  // Refresh once on mount (covers any gap between render and subscribe)
86
112
  setThreads(adapter.threads.list(reference));
@@ -97,7 +123,9 @@ const SinglePopup: React.FC<SinglePopupProps> = ({ adapter, uiCallbacks, theme,
97
123
  );
98
124
  const [activeId] = React.useState<string>(initialThreadId);
99
125
 
100
- const activeThread: Thread | undefined = threads.find((t) => t.id === activeId);
126
+ const activeThread: Thread | undefined = threads.find(
127
+ (t) => t.id === activeId,
128
+ );
101
129
  const isVerified = activeThread?.badge === true;
102
130
 
103
131
  const meta = (reference?.meta ?? {}) as {
@@ -114,13 +142,11 @@ const SinglePopup: React.FC<SinglePopupProps> = ({ adapter, uiCallbacks, theme,
114
142
  const avatarSrc: string | undefined =
115
143
  meta.avatarSrc ?? activeThread?.avatarSrc;
116
144
 
117
- const initial = (
145
+ const initial =
118
146
  meta.initial ??
119
147
  activeThread?.avatarText ??
120
- (activeThread?.title ?? meta.title ?? "?").charAt(0).toUpperCase()
121
- );
122
- const avatarBg =
123
- avatarBgByInitial[initial.toLowerCase()] ?? "#E4F0FF";
148
+ (activeThread?.title ?? meta.title ?? "?").charAt(0).toUpperCase();
149
+ const avatarBg = avatarBgByInitial[initial.toLowerCase()] ?? "#E4F0FF";
124
150
 
125
151
  const title = meta.title ?? activeThread?.title ?? "Unknown";
126
152
  const online = meta.online ?? activeThread?.online ?? false;
@@ -130,7 +156,9 @@ const SinglePopup: React.FC<SinglePopupProps> = ({ adapter, uiCallbacks, theme,
130
156
  activeId ? adapter.messages.list(activeId) : [],
131
157
  );
132
158
  const [scrollKey, setScrollKey] = React.useState<number>(Date.now());
133
- const [replyTo, setReplyTo] = React.useState<MessageRef | undefined>(undefined);
159
+ const [replyTo, setReplyTo] = React.useState<MessageRef | undefined>(
160
+ undefined,
161
+ );
134
162
  const [showDelete, setShowDelete] = React.useState(false);
135
163
 
136
164
  React.useEffect(() => {
@@ -149,7 +177,10 @@ const SinglePopup: React.FC<SinglePopupProps> = ({ adapter, uiCallbacks, theme,
149
177
  }, [activeId, adapter]);
150
178
 
151
179
  const handleConfirmDelete = React.useCallback(() => {
152
- if (!activeId) { setShowDelete(false); return; }
180
+ if (!activeId) {
181
+ setShowDelete(false);
182
+ return;
183
+ }
153
184
  adapter.threads.delete(activeId);
154
185
  uiCallbacks?.showToast?.({
155
186
  type: "success",
@@ -190,9 +221,12 @@ const SinglePopup: React.FC<SinglePopupProps> = ({ adapter, uiCallbacks, theme,
190
221
  exit={{ x: "110%" }}
191
222
  transition={{ type: "tween", duration: 0.3, ease: "easeOut" }}
192
223
  >
193
- {/* Inner white card */}
224
+ {/* Inner white card — `relative` is required so that ChatTranslateSettingsModal's
225
+ `absolute inset-0` overlay is contained here rather than escaping to an outer
226
+ positioned ancestor. Combined with `overflow-hidden` this clips the backdrop
227
+ to the rounded corners of the popup. */}
194
228
  <div
195
- className="flex h-full w-full flex-col overflow-hidden rounded-[18px] bg-white"
229
+ className="relative flex h-full w-full flex-col overflow-hidden rounded-[18px] bg-white"
196
230
  style={{ overscrollBehavior: "contain" }}
197
231
  >
198
232
  {/* Header — 64px */}
@@ -250,29 +284,45 @@ const SinglePopup: React.FC<SinglePopupProps> = ({ adapter, uiCallbacks, theme,
250
284
  id={m.id}
251
285
  mine={mine}
252
286
  time={m.time ?? ""}
253
- authorInitial={typeof m.author === "string" ? m.author : "U"}
287
+ authorInitial={
288
+ typeof m.author === "string" ? m.author : "U"
289
+ }
254
290
  text={m.text ?? m.content}
255
- businessCard={m.businessCard as Parameters<typeof ChatMessageItem>[0]["businessCard"]}
256
- addressCard={m.addressCard as Parameters<typeof ChatMessageItem>[0]["addressCard"]}
291
+ businessCard={
292
+ m.businessCard as Parameters<
293
+ typeof ChatMessageItem
294
+ >[0]["businessCard"]
295
+ }
296
+ addressCard={
297
+ m.addressCard as Parameters<
298
+ typeof ChatMessageItem
299
+ >[0]["addressCard"]
300
+ }
257
301
  images={m.images}
258
302
  files={m.files}
259
303
  audio={m.audio}
260
304
  replyTo={m.replyTo}
261
305
  initialSrc={m.avatarSrc}
262
306
  showStatus={isLast}
263
- status={activeThread?.status?.kind === "seen" ? "Seen" : "Delivered"}
307
+ status={
308
+ activeThread?.status?.kind === "seen"
309
+ ? "Seen"
310
+ : "Delivered"
311
+ }
264
312
  onReply={() => setReplyTo(toRef(m))}
265
313
  />
266
314
  );
267
315
  })}
268
-
269
- {/* Typing indicator */}
270
- <div className="flex items-center justify-start">
271
- <TypingIndicator />
272
- </div>
273
316
  </ChatScroll>
274
317
  </div>
275
318
 
319
+ {/* Typing indicator — sticky above footer */}
320
+ <div className="shrink-0">
321
+ <div className="flex items-center justify-start py-2 px-[16px]">
322
+ <TypingIndicator />
323
+ </div>
324
+ </div>
325
+
276
326
  {/* Footer */}
277
327
  <div className="shrink-0">
278
328
  <ChatFooter
@@ -97,7 +97,7 @@ const ChatTranslateSettingsModal: React.FC<Props> = ({
97
97
  }}
98
98
  >
99
99
  {/* Semi-transparent backdrop over the chat panel */}
100
- <div className="absolute inset-0 bg-black/30 rounded-[inherit]" />
100
+ <div className="absolute inset-0 bg-black/30 rounded-[inherit] overflow-hidden" />
101
101
 
102
102
  {/* Modal card */}
103
103
  <div
@@ -107,7 +107,7 @@ const ChatTranslateSettingsModal: React.FC<Props> = ({
107
107
  onClick={(e) => e.stopPropagation()}
108
108
  className={clsx(
109
109
  "relative w-[460px] max-w-[95%]",
110
- "overflow-clip rounded-[6px] bg-white shadow-[0px_2px_12px_0px_rgba(59,51,51,0.1)]",
110
+ "rounded-[6px] bg-white shadow-[0px_2px_12px_0px_rgba(59,51,51,0.1)]",
111
111
  className,
112
112
  )}
113
113
  >
@@ -24,7 +24,7 @@ export const FilePreviewChip: React.FC<{
24
24
  ext: string;
25
25
  onRemove: () => void;
26
26
  }> = ({ name, sizeMB, ext, onRemove }) => (
27
- <div className="mr-2 inline-flex items-center gap-3 whitespace-nowrap rounded-sm border border-[#e1e1e1] bg-white px-3 py-2 h-[65px] max-w-[185px]">
27
+ <div className="mr-2 inline-flex items-center gap-2 whitespace-nowrap rounded-sm border border-[#e1e1e1] bg-white px-3 py-2 h-[65px] max-w-[185px]">
28
28
  <div className="flex min-w-0 items-center gap-2">
29
29
  <div className="min-w-0">
30
30
  <div className="flex items-center gap-1">
@@ -38,25 +38,30 @@ export const FilePreviewChip: React.FC<{
38
38
  </div>
39
39
  </div>
40
40
  </div>
41
- <button
42
- type="button"
43
- onClick={onRemove}
44
- className="grid h-8 w-8 place-items-center rounded-full bg-white text-[#3D3D3D] shadow-[0px_2px_4px_0px_#A5A3AE4D] hover:bg-black/5"
45
- title="Remove"
46
- aria-label="Remove file"
47
- >
48
- <ChatXIcon className="h-[18px] w-[18px]" />
49
- </button>
41
+ <span>
42
+ <button
43
+ type="button"
44
+ onClick={onRemove}
45
+ className="grid h-[24px] w-[24px] cursor-pointer place-items-center rounded-full bg-white text-[#3D3D3D] shadow-[0px_2px_4px_0px_#A5A3AE4D] hover:bg-black/5"
46
+ title="Remove"
47
+ aria-label="Remove file"
48
+ >
49
+ <ChatXIcon className="h-[18px] w-[18px]" />
50
+ </button>
51
+ </span>
50
52
  </div>
51
53
  );
52
54
 
53
- const ImageThumb: React.FC<{ url: string; onRemove: () => void }> = ({ url, onRemove }) => (
55
+ const ImageThumb: React.FC<{ url: string; onRemove: () => void }> = ({
56
+ url,
57
+ onRemove,
58
+ }) => (
54
59
  <div className="relative mr-2 inline-block h-[65px] w-[65px] rounded-sm border border-[#e1e1e1] bg-[#F7F7F7]">
55
60
  <img src={url} alt="" className="h-full w-full object-cover rounded-sm" />
56
61
  <button
57
62
  type="button"
58
63
  onClick={onRemove}
59
- className="absolute left-1/2 top-1/2 z-10 grid h-6 w-6 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full bg-black/30 text-[#3D3D3D] shadow-[0px_2px_4px_0px_#A5A3AE4D]"
64
+ className="absolute left-1/2 top-1/2 z-10 grid h-6 w-6 -translate-x-1/2 -translate-y-1/2 cursor-pointer place-items-center rounded-full bg-black/30 text-[#3D3D3D] shadow-[0px_2px_4px_0px_#A5A3AE4D]"
60
65
  aria-label="Remove image"
61
66
  title="Remove image"
62
67
  >
@@ -3,7 +3,12 @@
3
3
 
4
4
  import clsx from "clsx";
5
5
  import React, { useRef, useState } from "react";
6
- import { ArrowSendAngleIcon, ArrowSendIcon, RecordMicIcon, ChatXIcon } from "../icons";
6
+ import {
7
+ ArrowSendAngleIcon,
8
+ ArrowSendIcon,
9
+ RecordMicIcon,
10
+ ChatXIcon,
11
+ } from "../icons";
7
12
 
8
13
  type Props = {
9
14
  recording: boolean;
@@ -63,18 +68,18 @@ const ChatComposerBar: React.FC<Props> = ({
63
68
  return (
64
69
  <div className="flex w-full items-stretch gap-2">
65
70
  <div
66
- className="w-full rounded-sm p-px transition-[background] duration-200"
71
+ className="w-full rounded-[6px] p-px transition-[background] duration-200"
67
72
  style={{
68
73
  background: isActiveBorder ? activeGradient : idleGradient,
69
74
  }}
70
75
  >
71
- <div className="flex min-h-[50px] w-full items-center justify-between rounded-[3px] bg-white">
76
+ <div className="flex min-h-[50px] w-full items-center justify-between rounded-[5px] bg-white">
72
77
  <div className="flex w-full items-center justify-between p-[3px]">
73
78
  {!isTyping ? (
74
79
  <button
75
80
  type="button"
76
81
  onClick={startRecording}
77
- className="grid h-[44px] w-[44px] place-items-center rounded-xs bg-[#f8f8f8] text-[#ff5301] hover:brightness-95"
82
+ className="grid h-[44px] w-[44px] cursor-pointer place-items-center rounded-xs bg-[#f8f8f8] text-[#ff5301] hover:brightness-95"
78
83
  title="Record voice"
79
84
  aria-label="Record voice"
80
85
  >
@@ -117,7 +122,9 @@ const ChatComposerBar: React.FC<Props> = ({
117
122
  />
118
123
  </div>
119
124
 
120
- {!canSendArrow && <div className="grid h-full w-px place-items-center bg-[#E7E7E7]" />}
125
+ {!canSendArrow && (
126
+ <div className="grid h-full w-px place-items-center bg-[#E7E7E7]" />
127
+ )}
121
128
 
122
129
  <div className="px-2">
123
130
  {isTyping ? (
@@ -127,7 +134,7 @@ const ChatComposerBar: React.FC<Props> = ({
127
134
  type="button"
128
135
  onClick={sendText}
129
136
  className={clsx(
130
- "ms-1 grid h-[40px] w-[40px] place-items-center rounded-full text-[#ff5301] hover:bg-[#f8f8f8]",
137
+ "ms-1 grid h-[40px] w-[40px] cursor-pointer place-items-center rounded-full text-[#ff5301] hover:bg-[#f8f8f8]",
131
138
  )}
132
139
  title={hasAttachments ? "Send attachments" : "Send"}
133
140
  aria-label="Send"
@@ -144,7 +151,7 @@ const ChatComposerBar: React.FC<Props> = ({
144
151
  disabled={!hasAttachments}
145
152
  className={clsx(
146
153
  "ms-1 grid h-[40px] w-[40px] place-items-center rounded-full hover:bg-[#f8f8f8]",
147
- hasAttachments ? "text-[#ff5301]" : "text-[#B9C3D4]",
154
+ hasAttachments ? "text-[#ff5301] cursor-pointer" : "text-[#B9C3D4] cursor-not-allowed",
148
155
  )}
149
156
  title={hasAttachments ? "Send attachments" : "Send"}
150
157
  aria-label="Send"
@@ -163,7 +170,10 @@ const ChatComposerBar: React.FC<Props> = ({
163
170
  // Recording state UI
164
171
  return (
165
172
  <div className="flex w-full items-stretch gap-2">
166
- <div className="w-full rounded-sm p-px" style={{ background: activeGradient }}>
173
+ <div
174
+ className="w-full rounded-sm p-px"
175
+ style={{ background: activeGradient }}
176
+ >
167
177
  <div className="flex min-h-[50px] w-full items-center justify-between rounded-[3px] bg-white">
168
178
  <button
169
179
  type="button"
@@ -174,9 +184,7 @@ const ChatComposerBar: React.FC<Props> = ({
174
184
  <RecordMicIcon
175
185
  className={clsx(
176
186
  "h-6 w-6",
177
- seconds % 2 === 0
178
- ? "text-[#929292]"
179
- : "text-[#ff5301]",
187
+ seconds % 2 === 0 ? "text-[#929292]" : "text-[#ff5301]",
180
188
  )}
181
189
  />
182
190
  </button>
@@ -187,7 +195,7 @@ const ChatComposerBar: React.FC<Props> = ({
187
195
  <button
188
196
  type="button"
189
197
  onClick={() => stopRecording(false)}
190
- className="grid h-8 w-8 place-items-center rounded-full text-[#3D3D3D] hover:bg-black/5"
198
+ className="grid h-8 w-8 cursor-pointer place-items-center rounded-full text-[#3D3D3D] hover:bg-black/5"
191
199
  title="Discard"
192
200
  aria-label="Discard recording"
193
201
  >
@@ -199,7 +207,7 @@ const ChatComposerBar: React.FC<Props> = ({
199
207
  <button
200
208
  type="button"
201
209
  onClick={() => stopRecording(true)}
202
- className="grid h-10 w-[40px] place-items-center rounded-full text-[#ff5301]"
210
+ className="grid h-10 w-[40px] cursor-pointer place-items-center rounded-full text-[#ff5301]"
203
211
  title="Send"
204
212
  aria-label="Send"
205
213
  >
@@ -225,13 +225,23 @@ const ChatFooter: React.FC<Props> = ({
225
225
  const previewFilesToPayload = (files: PreviewFile[]) =>
226
226
  files.map((f) => ({ name: f.name, sizeMB: f.sizeMB, ext: f.ext, href: f.href, downloadName: f.downloadName }));
227
227
 
228
- const clearAttachments = () => {
228
+ // Used by the preview strip's ✕ / remove buttons — safe to revoke because
229
+ // the user is cancelling the attachment (it will never appear in a message).
230
+ const cancelAttachments = () => {
229
231
  imgPreviews.forEach((u) => u.startsWith("blob:") && URL.revokeObjectURL(u));
230
232
  filePreviews.forEach((f) => f.href?.startsWith("blob:") && URL.revokeObjectURL(f.href));
231
233
  setImgPreviews([]);
232
234
  setFilePreviews([]);
233
235
  };
234
236
 
237
+ // Used after a successful send — do NOT revoke blobs here.
238
+ // The sent message object already holds these blob URLs and ChatMessageItem
239
+ // needs them to render the image. Revoking immediately makes <img src> fail.
240
+ const dismissAttachments = () => {
241
+ setImgPreviews([]);
242
+ setFilePreviews([]);
243
+ };
244
+
235
245
  /* ─────── Send handlers — all delegate to the single onSend prop ─────── */
236
246
 
237
247
  const sendText = async () => {
@@ -248,7 +258,7 @@ const ChatFooter: React.FC<Props> = ({
248
258
  images: imgPreviews,
249
259
  replyTo,
250
260
  });
251
- clearAttachments();
261
+ dismissAttachments(); // ← do NOT revoke — blob URLs are now in the message
252
262
  } else {
253
263
  onSend({ type: "text", text: t, replyTo });
254
264
  }
@@ -268,7 +278,7 @@ const ChatFooter: React.FC<Props> = ({
268
278
  files: previewFilesToPayload(filePreviews),
269
279
  replyTo,
270
280
  });
271
- clearAttachments();
281
+ dismissAttachments(); // ← do NOT revoke — blob URLs are now in the message
272
282
  clearReply?.();
273
283
  onAfterSend?.();
274
284
  };
@@ -368,7 +378,7 @@ const ChatFooter: React.FC<Props> = ({
368
378
  return (
369
379
  <span key={a.key} className="relative inline-flex">
370
380
  <Tooltip text={a.title}>
371
- <button ref={emojiBtnRef} type="button" onClick={() => setShowEmoji((v) => !v)} className="flex h-6 w-6 items-center justify-center rounded-full text-[#0F0F0F] hover:bg-[#F4F6F8]">
381
+ <button ref={emojiBtnRef} type="button" onClick={() => setShowEmoji(true)} className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-full text-[#0F0F0F] hover:bg-[#F4F6F8]">
372
382
  <span>{a.icon}</span>
373
383
  </button>
374
384
  </Tooltip>
@@ -378,7 +388,7 @@ const ChatFooter: React.FC<Props> = ({
378
388
  if (isTranslate) {
379
389
  return (
380
390
  <Tooltip key={a.key} text={a.title}>
381
- <button type="button" onClick={() => setShowTranslate(true)} className="flex h-6 w-6 items-center justify-center rounded-full text-[#0F0F0F] hover:bg-[#F4F6F8]">
391
+ <button type="button" onClick={() => setShowTranslate(true)} className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-full text-[#0F0F0F] hover:bg-[#F4F6F8]">
382
392
  <span>{a.icon}</span>
383
393
  </button>
384
394
  </Tooltip>
@@ -388,7 +398,7 @@ const ChatFooter: React.FC<Props> = ({
388
398
  return (
389
399
  <span key={a.key} className="relative inline-flex">
390
400
  <Tooltip text={a.title}>
391
- <button ref={bizBtnRef} type="button" onClick={() => setShowBiz((v) => !v)} className="flex h-6 w-6 items-center justify-center rounded-full text-[#0F0F0F] hover:bg-[#F4F6F8]">
401
+ <button ref={bizBtnRef} type="button" onClick={() => setShowBiz((v) => !v)} className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-full text-[#0F0F0F] hover:bg-[#F4F6F8]">
392
402
  <span>{a.icon}</span>
393
403
  </button>
394
404
  </Tooltip>
@@ -399,7 +409,7 @@ const ChatFooter: React.FC<Props> = ({
399
409
  return (
400
410
  <span key={a.key} className="relative inline-flex">
401
411
  <Tooltip text={a.title}>
402
- <button ref={addrBtnRef} type="button" onClick={() => setShowAddress(true)} className="flex h-6 w-6 items-center justify-center rounded-full text-[#0F0F0F] hover:bg-[#F4F6F8]">
412
+ <button ref={addrBtnRef} type="button" onClick={() => setShowAddress(true)} className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-full text-[#0F0F0F] hover:bg-[#F4F6F8]">
403
413
  <span>{a.icon}</span>
404
414
  </button>
405
415
  </Tooltip>
@@ -408,7 +418,7 @@ const ChatFooter: React.FC<Props> = ({
408
418
  }
409
419
  return (
410
420
  <Tooltip key={a.key} text={a.title}>
411
- <button type="button" onClick={isAttach ? () => fileInputRef.current?.click() : a.onClick} className="flex h-6 w-6 items-center justify-center rounded-full text-[#0F0F0F] hover:bg-[#F4F6F8]">
421
+ <button type="button" onClick={isAttach ? () => fileInputRef.current?.click() : a.onClick} className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-full text-[#0F0F0F] hover:bg-[#F4F6F8]">
412
422
  <span>{a.icon}</span>
413
423
  </button>
414
424
  </Tooltip>
@@ -432,9 +442,9 @@ const ChatFooter: React.FC<Props> = ({
432
442
  </div>
433
443
 
434
444
  {micError && (
435
- <div className="mb-2 flex items-start gap-2 rounded-sm bg-[#f8f8f8] px-2 py-1.5 text-xs text-[#ff5301]">
436
- <span><ChatInfoIcon className="mt-0.5 h-3.5 w-3.5" /></span>
437
- <span>{micError}</span>
445
+ <div className="mb-2 flex items-center gap-2 rounded-sm bg-[#f8f8f8] px-2 py-1.5 text-xs text-[#ff5301]">
446
+ <span><ChatInfoIcon className="h-3.5 w-3.5 shrink-0" /></span>
447
+ <span className="flex-1">{micError}</span>
438
448
  <span className="pointer flex cursor-pointer items-center justify-center rounded-full p-1 hover:bg-black/10" onClick={() => setMicError("")}>
439
449
  <ChatXIcon className="h-3.5 w-3.5" />
440
450
  </span>
@@ -33,7 +33,7 @@ const ChatInquiryBar: React.FC<Props> = ({ id, onView, className, label, buttonL
33
33
  <button
34
34
  type="button"
35
35
  onClick={onView}
36
- className="group relative inline-flex w-fit items-center justify-end text-xs font-medium text-black"
36
+ className="group relative inline-flex w-fit cursor-pointer items-center justify-end text-xs font-medium text-black"
37
37
  >
38
38
  <span className="flex items-center transition-opacity duration-300 ease-in-out group-hover:opacity-0">
39
39
  <span>{buttonLabel}</span>
@@ -63,7 +63,7 @@ const ChatThreadItem: React.FC<Props> = ({
63
63
  <button
64
64
  onClick={onClick}
65
65
  className={clsx(
66
- "relative w-full text-left px-5 py-2 hover:bg-[#f8f8f8] focus:outline-none h-[75px]",
66
+ "relative w-full cursor-pointer text-left px-5 py-2 hover:bg-[#f8f8f8] focus:outline-none h-[75px]",
67
67
  active && "bg-[#f8f8f8]",
68
68
  className,
69
69
  )}