@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,166 @@
1
+ "use client";
2
+
3
+ import type { FC } from "react";
4
+ import { FileIcon, ChatXIcon } from "../../icons";
5
+ import { cn } from "../../utils/cn";
6
+
7
+ /* =======================
8
+ Helpers
9
+ ======================= */
10
+
11
+ const extColor = (ext: string): string => {
12
+ const e = ext.toLowerCase();
13
+
14
+ if (e === "pdf") {
15
+ return "text-[#D93025]";
16
+ }
17
+ if (e === "ppt" || e === "pptx") {
18
+ return "text-[#E69138]";
19
+ }
20
+ if (e === "doc" || e === "docx") {
21
+ return "text-[#2B579A]";
22
+ }
23
+
24
+ return "text-[#6B7280]";
25
+ };
26
+
27
+ /* =======================
28
+ Types
29
+ ======================= */
30
+
31
+ export type FilePreview = {
32
+ name: string;
33
+ sizeMB: number;
34
+ ext: string;
35
+ href: string;
36
+ downloadName?: string;
37
+ };
38
+
39
+ type FilePreviewChipProps = {
40
+ name: string;
41
+ sizeMB: number;
42
+ ext: string;
43
+ onRemove: () => void;
44
+ };
45
+
46
+ type ImageThumbProps = {
47
+ url: string;
48
+ onRemove: () => void;
49
+ };
50
+
51
+ type AttachmentPreviewStripProps = {
52
+ imgPreviews: string[];
53
+ filePreviews: FilePreview[];
54
+ onRemoveImage: (index: number) => void;
55
+ onRemoveFile: (index: number) => void;
56
+ };
57
+
58
+ /* =======================
59
+ Sub Components
60
+ ======================= */
61
+
62
+ export const FilePreviewChip: FC<FilePreviewChipProps> = ({ name, sizeMB, ext, onRemove }) => {
63
+ return (
64
+ <div
65
+ className={cn(
66
+ "mr-2 inline-flex h-[65px] max-w-[185px] items-center gap-3 whitespace-nowrap rounded-sm",
67
+ "border border-[#e1e1e1] bg-white px-3 py-2",
68
+ )}
69
+ >
70
+ <div className="flex min-w-0 items-center gap-2">
71
+ <div className="min-w-0">
72
+ <div className="flex items-center gap-1">
73
+ <FileIcon className={cn("h-[18px] w-[18px]", extColor(ext))} />
74
+ <div className="truncate text-[13px] font-normal text-black">{name}</div>
75
+ </div>
76
+
77
+ <div className="mt-2 flex items-center gap-2 text-xs text-[#636363]">
78
+ <span>{sizeMB.toFixed(1)} MB</span>
79
+ <span className="h-3 w-px bg-[#e1e1e1]" />
80
+ <span className="uppercase">{ext}</span>
81
+ </div>
82
+ </div>
83
+ </div>
84
+
85
+ <button
86
+ type="button"
87
+ onClick={onRemove}
88
+ title="Remove"
89
+ aria-label="Remove file"
90
+ className={cn(
91
+ "grid h-8 w-8 place-items-center rounded-full bg-white text-[#3D3D3D]",
92
+ "shadow-[0px_2px_4px_0px_#A5A3AE4D] hover:bg-black/5",
93
+ )}
94
+ >
95
+ <ChatXIcon className="h-[18px] w-[18px]" />
96
+ </button>
97
+ </div>
98
+ );
99
+ };
100
+
101
+ const ImageThumb: FC<ImageThumbProps> = ({ url, onRemove }) => {
102
+ return (
103
+ <div
104
+ className={cn(
105
+ "relative mr-2 inline-block h-[65px] w-[65px] rounded-sm",
106
+ "border border-[#e1e1e1] bg-[#F7F7F7]",
107
+ )}
108
+ >
109
+ <img src={url} alt="" className="h-full w-full rounded-sm object-cover" loading="lazy" />
110
+
111
+ <button
112
+ type="button"
113
+ onClick={onRemove}
114
+ aria-label="Remove image"
115
+ title="Remove image"
116
+ className={cn(
117
+ "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",
118
+ "rounded-full bg-black/30 text-white",
119
+ "shadow-[0px_2px_4px_0px_#A5A3AE4D]",
120
+ )}
121
+ >
122
+ <ChatXIcon className="h-4 w-4 text-white" />
123
+ </button>
124
+ </div>
125
+ );
126
+ };
127
+
128
+ /* =======================
129
+ Component
130
+ ======================= */
131
+
132
+ /** Single-row, horizontally scrollable preview strip */
133
+ const AttachmentPreviewStrip: FC<AttachmentPreviewStripProps> = ({
134
+ imgPreviews,
135
+ filePreviews,
136
+ onRemoveFile,
137
+ onRemoveImage,
138
+ }) => {
139
+ const has = imgPreviews.length > 0 || filePreviews.length > 0;
140
+
141
+ if (!has) {
142
+ return null;
143
+ }
144
+
145
+ return (
146
+ <div className={cn("mx-auto mb-2 max-w-[410px] overflow-x-auto custom-scroll")}>
147
+ <div className="flex items-start whitespace-nowrap">
148
+ {filePreviews.map((file, index) => (
149
+ <FilePreviewChip
150
+ key={`${file.name}-${index}`}
151
+ name={file.name}
152
+ sizeMB={file.sizeMB}
153
+ ext={file.ext}
154
+ onRemove={() => onRemoveFile(index)}
155
+ />
156
+ ))}
157
+
158
+ {imgPreviews.map((url, index) => (
159
+ <ImageThumb key={`${url}-${index}`} url={url} onRemove={() => onRemoveImage(index)} />
160
+ ))}
161
+ </div>
162
+ </div>
163
+ );
164
+ };
165
+
166
+ export default AttachmentPreviewStrip;
@@ -0,0 +1,231 @@
1
+ "use client";
2
+
3
+ import { useRef, useState } from "react";
4
+
5
+ import { ArrowSendAngleIcon, ArrowSendIcon, RecordMicIcon, ChatXIcon } from "../../icons";
6
+ import { cn } from "../../utils/cn";
7
+
8
+ /* =======================
9
+ Types
10
+ ======================= */
11
+
12
+ type ChatComposerBarProps = {
13
+ recording: boolean;
14
+ seconds: number;
15
+
16
+ // typing
17
+ isTyping: boolean;
18
+ textRef: React.RefObject<HTMLTextAreaElement | null>;
19
+ text: string;
20
+ onTextChange: (v: string) => void;
21
+ onAutoGrow: () => void;
22
+
23
+ // attachments
24
+ hasAttachments: boolean;
25
+ canSendArrow: boolean;
26
+
27
+ // actions
28
+ startRecording: () => void;
29
+ stopRecording: (send: boolean) => void;
30
+ sendText: () => void;
31
+ sendAttachments: () => void;
32
+
33
+ // util
34
+ fmtTime: (s: number) => string;
35
+ };
36
+
37
+ /* =======================
38
+ Gradients
39
+ ======================= */
40
+
41
+ const idleGradient =
42
+ "linear-gradient(90.85deg, rgba(51, 201, 212, 0.5) 0%, rgba(39, 83, 251, 0.5) 29.98%, rgba(39, 83, 251, 0.5) 49.97%, rgba(39, 83, 251, 0.5) 64.96%, rgba(235, 67, 255, 0.5) 99.94%)";
43
+
44
+ const activeGradient =
45
+ "linear-gradient(90.85deg, #33C9D4 0%, #2753FB 29.98%, #2753FB 49.97%, #2753FB 64.96%, #EB43FF 99.94%)";
46
+
47
+ /* =======================
48
+ Component
49
+ ======================= */
50
+
51
+ const ChatComposerBar = ({
52
+ recording,
53
+ seconds,
54
+ isTyping,
55
+ textRef,
56
+ text,
57
+ onTextChange,
58
+ onAutoGrow,
59
+ hasAttachments,
60
+ canSendArrow,
61
+ startRecording,
62
+ stopRecording,
63
+ sendText,
64
+ sendAttachments,
65
+ fmtTime,
66
+ }: ChatComposerBarProps) => {
67
+ const composingRef = useRef(false);
68
+ const [isFocused, setIsFocused] = useState(false);
69
+
70
+ const isActiveBorder = isFocused || recording;
71
+
72
+ if (!recording) {
73
+ return (
74
+ <div className="flex w-full items-stretch gap-2">
75
+ <div
76
+ className="w-full rounded-sm p-px transition-[background] duration-200"
77
+ style={{
78
+ background: isActiveBorder ? activeGradient : idleGradient,
79
+ }}
80
+ >
81
+ <div className="flex min-h-[50px] w-full items-center justify-between rounded-[3px] bg-white">
82
+ <div className="flex w-full items-center justify-between p-[3px]">
83
+ {!isTyping ? (
84
+ <button
85
+ type="button"
86
+ onClick={startRecording}
87
+ className="grid h-[46px] w-[46px] place-items-center rounded-xs bg-[#f8f8f8] text-[#ff5301] hover:brightness-95"
88
+ title="Record voice"
89
+ aria-label="Record voice"
90
+ >
91
+ <RecordMicIcon className="h-6 w-6" />
92
+ </button>
93
+ ) : null}
94
+
95
+ <textarea
96
+ ref={textRef}
97
+ rows={1}
98
+ autoFocus
99
+ placeholder="Type a message"
100
+ className="custom-scroll-hidden max-h-[200px] flex-1 resize-none bg-transparent px-3 py-2 outline-none placeholder:text-[#777]"
101
+ value={text}
102
+ onChange={(e) => onTextChange(e.target.value)}
103
+ onInput={onAutoGrow}
104
+ onFocus={() => setIsFocused(true)}
105
+ onBlur={() => setIsFocused(false)}
106
+ onCompositionStart={() => {
107
+ composingRef.current = true;
108
+ }}
109
+ onCompositionEnd={() => {
110
+ composingRef.current = false;
111
+ onAutoGrow();
112
+ }}
113
+ onKeyDown={(e) => {
114
+ if (e.key === "Enter" && e.shiftKey) {
115
+ return;
116
+ }
117
+ if (composingRef.current) {
118
+ return;
119
+ }
120
+ if (e.key === "Enter") {
121
+ e.preventDefault();
122
+ if (text.length > 0) {
123
+ sendText();
124
+ }
125
+ }
126
+ }}
127
+ />
128
+ </div>
129
+
130
+ {!canSendArrow ? (
131
+ <div className="grid h-full w-px place-items-center bg-[#E7E7E7]" />
132
+ ) : null}
133
+
134
+ <div className="px-2">
135
+ {isTyping ? (
136
+ <div className="flex items-center">
137
+ <div className="h-10 w-px border-l border-[#CCCCCC]" />
138
+ <button
139
+ type="button"
140
+ onClick={sendText}
141
+ className={cn(
142
+ "ms-1 grid h-[40px] w-[40px] place-items-center rounded-full",
143
+ "text-[#ff5301] hover:bg-[#f8f8f8]",
144
+ )}
145
+ title={hasAttachments ? "Send attachments" : "Send"}
146
+ aria-label="Send"
147
+ >
148
+ <ArrowSendAngleIcon className="h-6 w-6" />
149
+ </button>
150
+ </div>
151
+ ) : (
152
+ <div className="flex items-center">
153
+ <div className="h-10 w-px border-l border-[#CCCCCC]" />
154
+ <button
155
+ type="button"
156
+ onClick={sendAttachments}
157
+ disabled={!hasAttachments}
158
+ className={cn(
159
+ "ms-1 grid h-[40px] w-[40px] place-items-center rounded-full hover:bg-[#f8f8f8]",
160
+ hasAttachments ? "text-[#ff5301]" : "text-[#B9C3D4]",
161
+ )}
162
+ title={hasAttachments ? "Send attachments" : "Send"}
163
+ aria-label="Send"
164
+ >
165
+ <ArrowSendIcon className="h-6 w-6" />
166
+ </button>
167
+ </div>
168
+ )}
169
+ </div>
170
+ </div>
171
+ </div>
172
+ </div>
173
+ );
174
+ }
175
+
176
+ /* =======================
177
+ Recording state UI
178
+ ======================= */
179
+
180
+ return (
181
+ <div className="flex w-full items-stretch gap-2">
182
+ <div className="w-full rounded-sm p-px" style={{ background: activeGradient }}>
183
+ <div className="flex h-[52px] w-full items-center justify-between rounded-[3px] bg-white">
184
+ <button
185
+ type="button"
186
+ className="ms-[3px] grid h-[46px] w-[46px] place-items-center rounded-xs bg-[#f8f8f8] text-[#ff5301] hover:brightness-95"
187
+ aria-label="Recording"
188
+ title="Recording"
189
+ >
190
+ <RecordMicIcon
191
+ className={cn(
192
+ "h-6 w-6",
193
+ seconds % 2 === 0
194
+ ? "text-[#929292]"
195
+ : "text-[#ff5301]",
196
+ )}
197
+ />
198
+ </button>
199
+
200
+ <div className="px-3 text-[13px]">{fmtTime(seconds)}</div>
201
+
202
+ <div className="ml-auto flex items-center gap-3 pr-2">
203
+ <button
204
+ type="button"
205
+ onClick={() => stopRecording(false)}
206
+ className="grid h-8 w-8 place-items-center rounded-full text-[#3D3D3D] hover:bg-black/5"
207
+ title="Discard"
208
+ aria-label="Discard recording"
209
+ >
210
+ <ChatXIcon className="h-5 w-5" />
211
+ </button>
212
+
213
+ <div className="h-6 w-px bg-[#E7E7E7]" />
214
+
215
+ <button
216
+ type="button"
217
+ onClick={() => stopRecording(true)}
218
+ className="grid h-10 w-[40px] place-items-center rounded-full text-[#ff5301]"
219
+ title={hasAttachments ? "Send attachments" : "Send"}
220
+ aria-label="Send"
221
+ >
222
+ <ArrowSendAngleIcon className="h-6 w-6" />
223
+ </button>
224
+ </div>
225
+ </div>
226
+ </div>
227
+ </div>
228
+ );
229
+ };
230
+
231
+ export default ChatComposerBar;