@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,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;
|