@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,132 @@
1
+ "use client";
2
+
3
+ import clsx from "clsx";
4
+ import React, { useEffect } from "react";
5
+ import { createPortal } from "react-dom";
6
+
7
+ type Props = {
8
+ open: boolean;
9
+ onClose: () => void;
10
+ onSelect: (emoji: string) => void;
11
+ /** Button ref to position against */
12
+ anchorRef?: React.RefObject<HTMLElement | null>;
13
+ className?: string;
14
+ };
15
+
16
+ const EMOJIS =
17
+ "๐Ÿ˜€ ๐Ÿ˜ ๐Ÿ˜‚ ๐Ÿคฃ ๐Ÿ˜ƒ ๐Ÿ˜„ ๐Ÿ˜… ๐Ÿ˜Š ๐Ÿ˜‡ ๐Ÿ™‚ ๐Ÿ™ƒ ๐Ÿ˜‰ ๐Ÿ˜Œ ๐Ÿ˜ ๐Ÿฅฐ ๐Ÿ˜˜ ๐Ÿ˜— ๐Ÿ˜™ ๐Ÿ˜š ๐Ÿ˜‹ ๐Ÿ˜œ ๐Ÿ˜ ๐Ÿ˜› ๐Ÿซข ๐Ÿคซ ๐Ÿคญ ๐Ÿค— ๐Ÿค” ๐Ÿคจ ๐Ÿ˜ ๐Ÿ˜‘ ๐Ÿ˜ถ ๐Ÿ™„ ๐Ÿ˜ฏ ๐Ÿ˜ช ๐Ÿ˜ด ๐Ÿ˜Œ ๐Ÿคค ๐Ÿ˜ฎโ€๐Ÿ’จ ๐Ÿ˜ฎ ๐Ÿ˜ฒ ๐Ÿคฏ ๐Ÿฅด ๐Ÿฅฑ ๐Ÿ˜ฌ ๐Ÿ˜ ๐Ÿคฅ ๐Ÿ˜• ๐Ÿ˜Ÿ ๐Ÿ™ โ˜น๏ธ ๐Ÿ˜ข ๐Ÿ˜ญ".split(
18
+ " ",
19
+ );
20
+
21
+ const WIDTH = 300; // fixed width (your ask)
22
+ const GAP_Y = 1; // space above button
23
+ const GAP_X = 1; // space right of button
24
+ const PADDING = 1; // viewport padding
25
+
26
+ const EmojiDropup: React.FC<Props> = ({ open, onClose, onSelect, anchorRef, className }) => {
27
+ const panelRef = React.useRef<HTMLDivElement | null>(null);
28
+ const [pos, setPos] = React.useState<{ left: number; top: number } | null>(null);
29
+
30
+ const place = React.useCallback(() => {
31
+ const anchor = anchorRef?.current;
32
+ const panel = panelRef.current;
33
+ if (!anchor || !panel) {
34
+ return;
35
+ }
36
+
37
+ // desired: to the RIGHT of button, and ABOVE it
38
+ const ar = anchor.getBoundingClientRect();
39
+ const ph = panel.offsetHeight + 10;
40
+
41
+ let left = ar.right + GAP_X - 45; // right side of button
42
+ let top = ar.top - GAP_Y - ph; // above the button
43
+
44
+ // clamp horizontally and vertically
45
+ left = Math.min(left, window.innerWidth - PADDING - WIDTH);
46
+ left = Math.max(left, PADDING);
47
+ top = Math.max(top, PADDING);
48
+
49
+ // set arrow X offset from panel's left edge so it points near the button
50
+ const arrowLeft = Math.max(12, Math.min(24, Math.round(Math.min(ar.right + GAP_X - left, 40))));
51
+ panel.style.setProperty("--arrow-left", `${arrowLeft}px`);
52
+
53
+ setPos({ left: Math.round(left), top: Math.round(top) });
54
+ }, [anchorRef]);
55
+
56
+ useEffect(() => {
57
+ if (!open) {
58
+ return;
59
+ }
60
+ place();
61
+ const onScroll = () => place();
62
+ const onResize = () => place();
63
+ window.addEventListener("scroll", onScroll, true);
64
+ window.addEventListener("resize", onResize);
65
+ return () => {
66
+ window.removeEventListener("scroll", onScroll, true);
67
+ window.removeEventListener("resize", onResize);
68
+ };
69
+ }, [open, place]);
70
+
71
+ // close on outside + Esc
72
+ useEffect(() => {
73
+ if (!open) {
74
+ return;
75
+ }
76
+ const onDoc = (e: MouseEvent) => {
77
+ if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
78
+ onClose();
79
+ }
80
+ };
81
+ const onEsc = (e: KeyboardEvent) => e.key === "Escape" && onClose();
82
+ document.addEventListener("mousedown", onDoc);
83
+ document.addEventListener("keydown", onEsc);
84
+ return () => {
85
+ document.removeEventListener("mousedown", onDoc);
86
+ document.removeEventListener("keydown", onEsc);
87
+ };
88
+ }, [open, onClose]);
89
+
90
+ if (!open) {
91
+ return null;
92
+ }
93
+
94
+ const body = (
95
+ <div
96
+ ref={panelRef}
97
+ role="dialog"
98
+ aria-label="Emoji picker"
99
+ className={clsx("emoji-dropup", className)}
100
+ style={{
101
+ zIndex: "9999",
102
+ width: WIDTH,
103
+ left: pos?.left ?? -9999,
104
+ top: pos?.top ?? -9999,
105
+ maxHeight: Math.max(140, window.innerHeight - 2 * PADDING), // dynamic height
106
+ }}
107
+ >
108
+ <div className="emoji-dropup__scroll">
109
+ <div className="grid grid-cols-7 gap-0">
110
+ {EMOJIS.map((e, i) => (
111
+ <button
112
+ key={i}
113
+ type="button"
114
+ className="emoji-dropup__item"
115
+ onClick={() => {
116
+ onSelect(e);
117
+ onClose();
118
+ }}
119
+ aria-label={`Insert ${e}`}
120
+ >
121
+ {e}
122
+ </button>
123
+ ))}
124
+ </div>
125
+ </div>
126
+ </div>
127
+ );
128
+
129
+ return createPortal(body, document.body);
130
+ };
131
+
132
+ export default EmojiDropup;
@@ -0,0 +1,130 @@
1
+ // components/ui/chat/message-items/ChatAddressCard.tsx
2
+ "use client";
3
+
4
+ import clsx from "clsx";
5
+ import React from "react";
6
+
7
+ import { ChatPhoneCallIcon, BadgeHomeIcon, MapPinIcon } from "../../../icons";
8
+ import { BadgeOfficeIcon } from "../../../icons";
9
+ import { cn } from "../../../utils/cn";
10
+ import type { AddressCard } from "../types";
11
+
12
+ type Props = {
13
+ mine?: boolean;
14
+ card: AddressCard;
15
+ className?: string;
16
+ };
17
+
18
+ type RowProps = {
19
+ icon: React.ReactNode;
20
+ label: string;
21
+ value?: string | null;
22
+ highlight?: boolean;
23
+ };
24
+
25
+ const Row: React.FC<RowProps> = ({ icon, label, value, highlight }) => {
26
+ if (value === null || value === undefined) {
27
+ return null;
28
+ }
29
+
30
+ const trimmed = String(value).trim();
31
+ if (!trimmed) {
32
+ return null;
33
+ }
34
+
35
+ const display = trimmed.length > 0 ? trimmed : "N/A";
36
+
37
+ return (
38
+ <div className="grid grid-cols-[18px_120px_1fr] items-start gap-1 text-xs">
39
+ <span className="pt-[2px] text-black">{icon}</span>
40
+ <strong className="truncate font-medium text-black">{label}</strong>
41
+ <span className={cn(highlight ? "text-black" : "text-[#747474]")}>{display}</span>
42
+ </div>
43
+ );
44
+ };
45
+
46
+ const ChatAddressCard: React.FC<Props> = ({ mine, card, className }) => {
47
+ const {
48
+ name,
49
+ label,
50
+ businessName,
51
+ mobileNumber,
52
+ country,
53
+ district,
54
+ policeStation,
55
+ postalCode,
56
+ addressLine,
57
+ landMark,
58
+ } = card;
59
+
60
+ // Build combined address:
61
+ // Landmark, Address line, Police Station, District, Postal Code, Country
62
+ const addressParts: string[] = [];
63
+
64
+ if (landMark?.trim()) {
65
+ addressParts.push(landMark.trim());
66
+ }
67
+ if (addressLine?.trim()) {
68
+ addressParts.push(addressLine.trim());
69
+ }
70
+ if (policeStation?.trim()) {
71
+ addressParts.push(policeStation.trim());
72
+ }
73
+ if (district?.trim()) {
74
+ addressParts.push(district.trim());
75
+ }
76
+ if (postalCode?.trim()) {
77
+ addressParts.push(postalCode.trim());
78
+ }
79
+ if (country?.trim()) {
80
+ addressParts.push(country.trim());
81
+ }
82
+
83
+ const combinedAddress = addressParts.length > 0 ? `${addressParts.join(", ")}.` : undefined;
84
+
85
+ const badge = label ? (
86
+ <span className="inline-flex h-[21px] items-center gap-1 rounded-xs bg-[#FFDBCF] px-2 py-[2px] text-[10px] text-[#ff5301]">
87
+ {label.toLowerCase() === "office" ? (
88
+ <BadgeOfficeIcon className="h-3 w-3" />
89
+ ) : (
90
+ <BadgeHomeIcon className="h-3 w-3" />
91
+ )}
92
+ {label}
93
+ </span>
94
+ ) : null;
95
+
96
+ return (
97
+ <div
98
+ className={clsx(
99
+ "w-[340px] rounded-md bg-[#f8f8f8] p-2",
100
+ mine ? "ml-auto" : "mr-auto",
101
+ className,
102
+ )}
103
+ >
104
+ {/* Header */}
105
+ <div className="mb-2 flex items-center gap-2">
106
+ <h3 className="text-xl font-semibold text-black">{name || "Recipient"}</h3>
107
+ {badge}
108
+ </div>
109
+
110
+ {/* Fields */}
111
+ <div className="space-y-[2px]">
112
+ <Row
113
+ highlight
114
+ icon={<BadgeOfficeIcon className="h-[14px] w-[14px]" />}
115
+ label="Business Name"
116
+ value={businessName ?? undefined}
117
+ />
118
+ <Row
119
+ highlight
120
+ icon={<ChatPhoneCallIcon className="h-[14px] w-[14px]" />}
121
+ label="Mobile Number"
122
+ value={mobileNumber ?? undefined}
123
+ />
124
+ <Row icon={<MapPinIcon className="h-4 w-4" />} label="Address" value={combinedAddress} />
125
+ </div>
126
+ </div>
127
+ );
128
+ };
129
+
130
+ export default ChatAddressCard;
@@ -0,0 +1,209 @@
1
+ "use client";
2
+
3
+ import clsx from "clsx";
4
+ import React from "react";
5
+
6
+ export type ChatAudio = {
7
+ /** Local or remote audio file (e.g. "/chat/audio/sample.mp3") */
8
+ src?: string;
9
+ /** Optional display-only duration; will be auto-derived if omitted */
10
+ duration?: string;
11
+ };
12
+
13
+ const formatSec = (s: number) => {
14
+ if (!isFinite(s) || s < 0) {
15
+ return "0:00";
16
+ }
17
+ const m = Math.floor(s / 60);
18
+ const ss = Math.floor(s % 60)
19
+ .toString()
20
+ .padStart(2, "0");
21
+ return `${m}:${ss}`;
22
+ };
23
+
24
+ const PlayIcon: React.FC<{ className?: string }> = ({ className }) => (
25
+ <svg viewBox="0 0 20 20" className={className} fill="currentColor" aria-hidden>
26
+ <path d="M6 4.5v11l9-5.5-9-5.5Z" />
27
+ </svg>
28
+ );
29
+
30
+ const PauseIcon: React.FC<{ className?: string }> = ({ className }) => (
31
+ <svg viewBox="0 0 20 20" className={className} fill="currentColor" aria-hidden>
32
+ <path d="M5 4h4v12H5V4zm6 0h4v12h-4V4z" />
33
+ </svg>
34
+ );
35
+
36
+ /** Registry so only one audio plays at a time */
37
+ const audioRegistry = new Set<HTMLAudioElement>();
38
+
39
+ const ChatBubbleAudio: React.FC<{ mine: boolean; audio: ChatAudio }> = ({ mine, audio }) => {
40
+ const ref = React.useRef<HTMLAudioElement>(null);
41
+ const trackRef = React.useRef<HTMLDivElement>(null);
42
+
43
+ const [playing, setPlaying] = React.useState(false);
44
+ const [progress, setProgress] = React.useState(0); // 0..100
45
+ const [durSec, setDurSec] = React.useState<number>(0);
46
+ const [remain, setRemain] = React.useState<string>(audio.duration ?? "0:00");
47
+
48
+ // Register/unregister this audio element
49
+ React.useEffect(() => {
50
+ const a = ref.current;
51
+ if (!a) {
52
+ return;
53
+ }
54
+ audioRegistry.add(a);
55
+ const onPause = () => setPlaying(false);
56
+ const onPlay = () => setPlaying(true);
57
+ a.addEventListener("pause", onPause);
58
+ a.addEventListener("play", onPlay);
59
+ return () => {
60
+ a.removeEventListener("pause", onPause);
61
+ a.removeEventListener("play", onPlay);
62
+ audioRegistry.delete(a);
63
+ };
64
+ }, []);
65
+
66
+ const pauseOthers = (current: HTMLAudioElement) => {
67
+ audioRegistry.forEach((a) => {
68
+ if (a !== current && !a.paused) {
69
+ a.pause(); // their 'pause' listener updates their local state
70
+ }
71
+ });
72
+ };
73
+
74
+ const applyProgressToAudio = (pct: number) => {
75
+ const a = ref.current;
76
+ if (!a || !isFinite(a.duration) || a.duration <= 0) {
77
+ return;
78
+ }
79
+ const clamped = Math.min(100, Math.max(0, pct));
80
+ a.currentTime = (clamped / 100) * a.duration;
81
+ setProgress(clamped);
82
+ setRemain(formatSec(a.duration - a.currentTime));
83
+ };
84
+
85
+ const toggle = () => {
86
+ const a = ref.current;
87
+ if (!a) {
88
+ return;
89
+ }
90
+ if (a.paused) {
91
+ pauseOthers(a);
92
+ a.play();
93
+ // setPlaying will be driven by 'play' event too; keep optimistic:
94
+ setPlaying(true);
95
+ } else {
96
+ a.pause();
97
+ setPlaying(false);
98
+ }
99
+ };
100
+
101
+ const onTime = () => {
102
+ const a = ref.current;
103
+ if (!a) {
104
+ return;
105
+ }
106
+ const pct = (a.currentTime / (a.duration || 1)) * 100;
107
+ setProgress(isFinite(pct) ? pct : 0);
108
+ setRemain(formatSec((a.duration || 0) - a.currentTime));
109
+ };
110
+
111
+ const onLoaded = () => {
112
+ const a = ref.current;
113
+ if (!a) {
114
+ return;
115
+ }
116
+ setDurSec(a.duration || 0);
117
+ setRemain(formatSec(a.duration || 0));
118
+ };
119
+
120
+ // --- custom slider (no borders), draggable ---------------------------------
121
+ const pctFromClientX = (clientX: number) => {
122
+ const el = trackRef.current;
123
+ if (!el) {
124
+ return progress;
125
+ }
126
+ const rect = el.getBoundingClientRect();
127
+ const x = clientX - rect.left;
128
+ const pct = (x / rect.width) * 100;
129
+ return Math.min(100, Math.max(0, pct));
130
+ };
131
+
132
+ const handleTrackClick = (e: React.MouseEvent<HTMLDivElement>) => {
133
+ applyProgressToAudio(pctFromClientX(e.clientX));
134
+ };
135
+
136
+ const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
137
+ (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
138
+ applyProgressToAudio(pctFromClientX(e.clientX));
139
+ };
140
+
141
+ const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
142
+ if ((e.currentTarget as HTMLDivElement).hasPointerCapture(e.pointerId)) {
143
+ applyProgressToAudio(pctFromClientX(e.clientX));
144
+ }
145
+ };
146
+
147
+ return (
148
+ <div className="h-9 max-w-[260px] rounded-lg border border-[#E5E5E5] bg-white px-[3px] py-[3px]">
149
+ <div className="flex items-center gap-3">
150
+ {/* Play/Pause pill */}
151
+ <button
152
+ type="button"
153
+ aria-label={playing ? "Pause" : "Play"}
154
+ onClick={toggle}
155
+ className={clsx(
156
+ "grid h-7 w-[34px] place-items-center rounded-md transition-colors",
157
+ mine ? "bg-[#F1F1F1] text-[#00486F]" : "bg-[#F1F1F1] text-[#00486F]",
158
+ )}
159
+ >
160
+ {playing ? <PauseIcon className="h-4 w-4" /> : <PlayIcon className="h-4 w-4" />}
161
+ </button>
162
+
163
+ {/* Track (custom, borderless, draggable) */}
164
+ <div
165
+ ref={trackRef}
166
+ onClick={handleTrackClick}
167
+ onPointerDown={handlePointerDown}
168
+ onPointerMove={handlePointerMove}
169
+ className="relative h-[4px] w-[186px] cursor-pointer select-none rounded-full bg-[#BDBDBD]"
170
+ aria-label="Seek"
171
+ role="slider"
172
+ aria-valuemin={0}
173
+ aria-valuemax={100}
174
+ aria-valuenow={Math.round(progress)}
175
+ >
176
+ {/* filled portion */}
177
+ <div
178
+ className="absolute left-0 top-0 h-full rounded-full bg-[#747474]"
179
+ style={{ width: `${progress}%` }}
180
+ />
181
+ {/* knob */}
182
+ <div
183
+ className="absolute top-1/2 -translate-y-1/2 h-3 w-3 rounded-full bg-[#747474]"
184
+ style={{ left: `calc(${progress}% - 6px)` }}
185
+ />
186
+ </div>
187
+
188
+ {/* Countdown (remaining) */}
189
+ <span className="pe-[4px] text-xs font-normal text-[#747474]">
190
+ {remain || (durSec ? formatSec(durSec) : "0:00")}
191
+ </span>
192
+ </div>
193
+
194
+ {audio.src ? (
195
+ <audio
196
+ ref={ref}
197
+ src={audio.src}
198
+ preload="metadata"
199
+ onTimeUpdate={onTime}
200
+ onLoadedMetadata={onLoaded}
201
+ onEnded={() => setPlaying(false)}
202
+ className="hidden"
203
+ />
204
+ ) : null}
205
+ </div>
206
+ );
207
+ };
208
+
209
+ export default ChatBubbleAudio;
@@ -0,0 +1,80 @@
1
+ import clsx from "clsx";
2
+ import React from "react";
3
+ import { FileDownloadIcon, FileIcon } from "../../../icons";
4
+
5
+ export type ChatFile = {
6
+ /** Visible filename */
7
+ name: string;
8
+ /** File size in MB (display only) */
9
+ sizeMB: number;
10
+ /** File extension, e.g. "pdf" | "pptx" | "docx" */
11
+ ext: string;
12
+ /** Local or remote path to the file (e.g. "/chat/files/spec.pdf") */
13
+ href?: string;
14
+ /** Optional: force browser download */
15
+ downloadName?: string;
16
+ };
17
+
18
+ const extColor = (ext: string) => {
19
+ const e = ext.toLowerCase();
20
+ if (e === "pdf") {
21
+ return "text-[#D93025]";
22
+ }
23
+ if (e === "ppt" || e === "pptx") {
24
+ return "text-[#E69138]";
25
+ }
26
+ if (e === "doc" || e === "docx") {
27
+ return "text-[#2B579A]";
28
+ }
29
+ return "text-[#6B7280]";
30
+ };
31
+
32
+ const FileChip: React.FC<{ file: ChatFile }> = ({ file }) => (
33
+ <div className="flex items-center justify-between gap-3 rounded-sm border border-[#e1e1e1] bg-white px-3 py-2">
34
+ <div className="flex min-w-0 items-center gap-2">
35
+ <div className="min-w-0">
36
+ <div className="flex items-center gap-1">
37
+ <FileIcon className={clsx("h-[18px] w-[18px]", extColor(file.ext))} />{" "}
38
+ <div className="truncate text-xs font-normal text-black">{file.name}</div>
39
+ </div>
40
+ <div className="flex items-center gap-2 text-[10px] text-[#636363] mt-2">
41
+ <span>{file.sizeMB.toFixed(1)} MB</span>
42
+ <span className="h-3 w-px bg-[#e1e1e1]" />
43
+ <span className="uppercase">{file.ext}</span>
44
+ </div>
45
+ </div>
46
+ </div>
47
+ <div className="flex items-center gap-3">
48
+ <span className="h-[41px] w-px bg-[#cacaca]" />
49
+ {file.href ? (
50
+ <a
51
+ href={file.href}
52
+ download={file.downloadName}
53
+ target="_blank"
54
+ rel="noopener noreferrer"
55
+ className="flex h-7 w-[28px] items-center justify-center rounded-full text-black hover:text-[#ff5301] shadow-[0px_2px_4px_0px_#A5A3AE4D]"
56
+ title={file.downloadName}
57
+ aria-label={file.downloadName}
58
+ >
59
+ <FileDownloadIcon className="h-5 w-5" />
60
+ </a>
61
+ ) : (
62
+ <span className="flex h-7 w-[28px] items-center justify-center rounded-full text-[#9ca3af]">
63
+ <FileDownloadIcon className="h-5 w-5" />
64
+ </span>
65
+ )}
66
+ </div>
67
+ </div>
68
+ );
69
+
70
+ const ChatBubbleFiles: React.FC<{ files: ChatFile[] }> = ({ files }) => {
71
+ return (
72
+ <div className="flex max-w-[260px] flex-col gap-1">
73
+ {files.map((f, i) => (
74
+ <FileChip key={i} file={f} />
75
+ ))}
76
+ </div>
77
+ );
78
+ };
79
+
80
+ export default ChatBubbleFiles;
@@ -0,0 +1,120 @@
1
+ // components/ui/chat/message-items/ChatBubbleImages.tsx
2
+ "use client";
3
+
4
+ import React from "react";
5
+
6
+ import { useGallery } from "../../../contexts/GalleryContext";
7
+
8
+ type ImgTileProps = {
9
+ src: string;
10
+ w: number;
11
+ h: number;
12
+ overlayText?: string;
13
+ onClick?: () => void;
14
+ };
15
+
16
+ const ImgTile: React.FC<ImgTileProps> = ({ src, w, h, overlayText, onClick }) => {
17
+ return (
18
+ <button
19
+ type="button"
20
+ onClick={onClick}
21
+ className="relative cursor-zoom-in overflow-hidden rounded-sm border border-[#EFEFEF] bg-[#F5F7FA]"
22
+ style={{ width: w, height: h }}
23
+ aria-label="Open image"
24
+ >
25
+ <img
26
+ src={src}
27
+ alt=""
28
+ width={w}
29
+ height={h}
30
+ className="h-full w-full object-cover"
31
+ loading="lazy"
32
+ />
33
+
34
+ {overlayText ? (
35
+ <div className="absolute inset-0 grid place-items-center bg-black/35">
36
+ <span className="text-4xl font-semibold text-white">+{overlayText}</span>
37
+ </div>
38
+ ) : null}
39
+ </button>
40
+ );
41
+ };
42
+
43
+ type Props = {
44
+ images: string[];
45
+ };
46
+
47
+ const ChatBubbleImages: React.FC<Props> = ({ images }) => {
48
+ const { openGallery } = useGallery();
49
+
50
+ const openAt = (index: number) => {
51
+ openGallery(
52
+ images.map((url) => ({
53
+ type: "image",
54
+ url,
55
+ altText: "Chat image",
56
+ })),
57
+ index,
58
+ );
59
+ };
60
+
61
+ const count = images.length;
62
+
63
+ // 1 image โ†’ 260 ร— 174
64
+ if (count === 1) {
65
+ return <ImgTile src={images[0]} w={260} h={174} onClick={() => openAt(0)} />;
66
+ }
67
+
68
+ // 2 images โ†’ 2 columns, 125 ร— 83
69
+ if (count === 2) {
70
+ return (
71
+ <div className="grid grid-cols-2 gap-1">
72
+ {images.map((src, i) => (
73
+ <ImgTile key={src + i} src={src} w={125} h={83} onClick={() => openAt(i)} />
74
+ ))}
75
+ </div>
76
+ );
77
+ }
78
+
79
+ // 3 images โ†’ asymmetric layout
80
+ if (count === 3) {
81
+ const [a, b, c] = images;
82
+
83
+ return (
84
+ <div className="grid grid-cols-[125px_125px] gap-1">
85
+ {/* Left column */}
86
+ <div className="flex flex-col gap-2">
87
+ <div style={{ width: 125, height: 83 }} />
88
+ <ImgTile src={a} w={125} h={83} onClick={() => openAt(0)} />
89
+ </div>
90
+
91
+ {/* Right column */}
92
+ <div className="flex flex-col gap-2">
93
+ <ImgTile src={b} w={125} h={83} onClick={() => openAt(1)} />
94
+ <ImgTile src={c} w={125} h={83} onClick={() => openAt(2)} />
95
+ </div>
96
+ </div>
97
+ );
98
+ }
99
+
100
+ // 4+ images โ†’ 2ร—2 grid, overlay on last tile
101
+ const visible = images.slice(0, 4);
102
+ const extraCount = count - 4;
103
+
104
+ return (
105
+ <div className="grid grid-cols-2 gap-1">
106
+ {visible.map((src, i) => (
107
+ <ImgTile
108
+ key={src + i}
109
+ src={src}
110
+ w={125}
111
+ h={83}
112
+ overlayText={i === 3 && extraCount > 0 ? String(extraCount) : undefined}
113
+ onClick={() => openAt(i)}
114
+ />
115
+ ))}
116
+ </div>
117
+ );
118
+ };
119
+
120
+ export default ChatBubbleImages;
@@ -0,0 +1,16 @@
1
+ // ChatBubbleText.tsx
2
+ import clsx from "clsx";
3
+ import React from "react";
4
+
5
+ const ChatBubbleText: React.FC<{ mine: boolean; text: string }> = ({ mine, text }) => {
6
+ const base =
7
+ "max-w-[289px] rounded-sm border border-[#f1f1f1] px-4 py-2.5 text-xs font-normal";
8
+ const color = mine ? "bg-[#f8f8f8] border-[#f1f1f1]" : "bg-white border-[#EFEFEF]";
9
+ const corner = mine ? "rounded-tr-[6px]" : "rounded-tl-md";
10
+ // key bit: preserve line breaks + spaces and still wrap long words
11
+ const textFormatting = "whitespace-pre-wrap break-words";
12
+
13
+ return <div className={clsx(base, color, corner, textFormatting)}>{text}</div>;
14
+ };
15
+
16
+ export default ChatBubbleText;