@droppii-org/chat-sdk 0.0.1
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/dist/components/AutoScrollAnchor.d.ts +2 -0
- package/dist/components/AutoScrollAnchor.d.ts.map +1 -0
- package/dist/components/AutoScrollAnchor.jsx +11 -0
- package/dist/components/ChatBubble.d.ts +2 -0
- package/dist/components/ChatBubble.d.ts.map +1 -0
- package/dist/components/ChatBubble.jsx +32 -0
- package/dist/components/ChatHeader.d.ts +8 -0
- package/dist/components/ChatHeader.d.ts.map +1 -0
- package/dist/components/ChatHeader.jsx +72 -0
- package/dist/components/ChatInput.d.ts +4 -0
- package/dist/components/ChatInput.d.ts.map +1 -0
- package/dist/components/ChatInput.jsx +444 -0
- package/dist/components/ChatInputDemo.d.ts +2 -0
- package/dist/components/ChatInputDemo.d.ts.map +1 -0
- package/dist/components/ChatInputDemo.jsx +53 -0
- package/dist/components/ChatInputWithCustomIcon.d.ts +17 -0
- package/dist/components/ChatInputWithCustomIcon.d.ts.map +1 -0
- package/dist/components/ChatInputWithCustomIcon.jsx +167 -0
- package/dist/components/ChatLayout.d.ts +6 -0
- package/dist/components/ChatLayout.d.ts.map +1 -0
- package/dist/components/ChatLayout.jsx +122 -0
- package/dist/components/ConversationItem.d.ts +9 -0
- package/dist/components/ConversationItem.d.ts.map +1 -0
- package/dist/components/ConversationItem.jsx +51 -0
- package/dist/components/ConversationList.d.ts +8 -0
- package/dist/components/ConversationList.d.ts.map +1 -0
- package/dist/components/ConversationList.jsx +22 -0
- package/dist/components/DateDivider.d.ts +7 -0
- package/dist/components/DateDivider.d.ts.map +1 -0
- package/dist/components/DateDivider.jsx +28 -0
- package/dist/components/EmojiPicker.d.ts +4 -0
- package/dist/components/EmojiPicker.d.ts.map +1 -0
- package/dist/components/EmojiPicker.jsx +229 -0
- package/dist/components/ImageLightbox.d.ts +8 -0
- package/dist/components/ImageLightbox.d.ts.map +1 -0
- package/dist/components/ImageLightbox.jsx +16 -0
- package/dist/components/ImagePreviewModal.d.ts +12 -0
- package/dist/components/ImagePreviewModal.d.ts.map +1 -0
- package/dist/components/ImagePreviewModal.jsx +84 -0
- package/dist/components/MessageItem.d.ts +3 -0
- package/dist/components/MessageItem.d.ts.map +1 -0
- package/dist/components/MessageItem.jsx +99 -0
- package/dist/components/MessageItemDemo.d.ts +2 -0
- package/dist/components/MessageItemDemo.d.ts.map +1 -0
- package/dist/components/MessageItemDemo.jsx +179 -0
- package/dist/components/MessageList.d.ts +15 -0
- package/dist/components/MessageList.d.ts.map +1 -0
- package/dist/components/MessageList.jsx +306 -0
- package/dist/components/MessageListDemo.d.ts +2 -0
- package/dist/components/MessageListDemo.d.ts.map +1 -0
- package/dist/components/MessageListDemo.jsx +183 -0
- package/dist/components/StickerPicker.d.ts +4 -0
- package/dist/components/StickerPicker.d.ts.map +1 -0
- package/dist/components/StickerPicker.jsx +106 -0
- package/dist/components/SwipeIndicator.d.ts +9 -0
- package/dist/components/SwipeIndicator.d.ts.map +1 -0
- package/dist/components/SwipeIndicator.jsx +28 -0
- package/dist/components/TextFormattingToolbar.d.ts +4 -0
- package/dist/components/TextFormattingToolbar.d.ts.map +1 -0
- package/dist/components/TextFormattingToolbar.jsx +52 -0
- package/dist/components/TypingIndicator.d.ts +6 -0
- package/dist/components/TypingIndicator.d.ts.map +1 -0
- package/dist/components/TypingIndicator.jsx +27 -0
- package/dist/components/VoiceWaveIcon.d.ts +7 -0
- package/dist/components/VoiceWaveIcon.d.ts.map +1 -0
- package/dist/components/VoiceWaveIcon.jsx +11 -0
- package/dist/context/ChatContext.d.ts +72 -0
- package/dist/context/ChatContext.d.ts.map +1 -0
- package/dist/context/ChatContext.jsx +346 -0
- package/dist/hooks/useChat.d.ts +5 -0
- package/dist/hooks/useChat.d.ts.map +1 -0
- package/dist/hooks/useChat.js +73 -0
- package/dist/hooks/useConversationList.d.ts +5 -0
- package/dist/hooks/useConversationList.d.ts.map +1 -0
- package/dist/hooks/useConversationList.js +9 -0
- package/dist/hooks/useMessages.d.ts +5 -0
- package/dist/hooks/useMessages.d.ts.map +1 -0
- package/dist/hooks/useMessages.js +192 -0
- package/dist/hooks/useSocket.d.ts +7 -0
- package/dist/hooks/useSocket.d.ts.map +1 -0
- package/dist/hooks/useSocket.js +120 -0
- package/dist/hooks/useSwipeGesture.d.ts +11 -0
- package/dist/hooks/useSwipeGesture.d.ts.map +1 -0
- package/dist/hooks/useSwipeGesture.js +54 -0
- package/dist/hooks/useTextSelection.d.ts +13 -0
- package/dist/hooks/useTextSelection.d.ts.map +1 -0
- package/dist/hooks/useTextSelection.js +132 -0
- package/dist/hooks/useTyping.d.ts +7 -0
- package/dist/hooks/useTyping.d.ts.map +1 -0
- package/dist/hooks/useTyping.js +64 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/types/chat.d.ts +39 -0
- package/dist/types/chat.d.ts.map +1 -0
- package/dist/types/chat.js +1 -0
- package/dist/types/index.d.ts +86 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +1 -0
- package/package.json +60 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { X, Search, Clock, Smile, Heart, Hand, Car, Lightbulb, Coffee, Flag } from "lucide-react";
|
|
5
|
+
const emojiCategories = {
|
|
6
|
+
recent: {
|
|
7
|
+
label: "Thường xuyên sử dụng",
|
|
8
|
+
icon: Clock,
|
|
9
|
+
emojis: ["😊", "😂", "❤️", "👍", "😢", "😮", "😡", "🎉", "👏"],
|
|
10
|
+
},
|
|
11
|
+
smileys: {
|
|
12
|
+
label: "Mặt cười & con người",
|
|
13
|
+
icon: Smile,
|
|
14
|
+
emojis: [
|
|
15
|
+
"😀",
|
|
16
|
+
"😃",
|
|
17
|
+
"😄",
|
|
18
|
+
"😁",
|
|
19
|
+
"😆",
|
|
20
|
+
"😅",
|
|
21
|
+
"😂",
|
|
22
|
+
"🤣",
|
|
23
|
+
"😊",
|
|
24
|
+
"😇",
|
|
25
|
+
"🙂",
|
|
26
|
+
"🙃",
|
|
27
|
+
"😉",
|
|
28
|
+
"😌",
|
|
29
|
+
"😍",
|
|
30
|
+
"🥰",
|
|
31
|
+
"😘",
|
|
32
|
+
"😗",
|
|
33
|
+
"😙",
|
|
34
|
+
"😚",
|
|
35
|
+
"😋",
|
|
36
|
+
"😛",
|
|
37
|
+
"😝",
|
|
38
|
+
"😜",
|
|
39
|
+
"🤪",
|
|
40
|
+
"🤨",
|
|
41
|
+
"🧐",
|
|
42
|
+
"🤓",
|
|
43
|
+
"😎",
|
|
44
|
+
"🤩",
|
|
45
|
+
"🥳",
|
|
46
|
+
"😏",
|
|
47
|
+
"😒",
|
|
48
|
+
"😞",
|
|
49
|
+
"😔",
|
|
50
|
+
"😟",
|
|
51
|
+
"😕",
|
|
52
|
+
"🙁",
|
|
53
|
+
"☹️",
|
|
54
|
+
"😣",
|
|
55
|
+
"😖",
|
|
56
|
+
"😫",
|
|
57
|
+
"😩",
|
|
58
|
+
"🥺",
|
|
59
|
+
"😢",
|
|
60
|
+
"😭",
|
|
61
|
+
"😤",
|
|
62
|
+
"😠",
|
|
63
|
+
"😡",
|
|
64
|
+
"🤬",
|
|
65
|
+
"🤯",
|
|
66
|
+
"😳",
|
|
67
|
+
"🥵",
|
|
68
|
+
"🥶",
|
|
69
|
+
"😱",
|
|
70
|
+
"😨",
|
|
71
|
+
"😰",
|
|
72
|
+
"😥",
|
|
73
|
+
"😓",
|
|
74
|
+
"🤗",
|
|
75
|
+
"🤔",
|
|
76
|
+
"🤭",
|
|
77
|
+
"🤫",
|
|
78
|
+
"🤥",
|
|
79
|
+
"😶",
|
|
80
|
+
"😐",
|
|
81
|
+
"😑",
|
|
82
|
+
"😬",
|
|
83
|
+
"🙄",
|
|
84
|
+
"😯",
|
|
85
|
+
"😦",
|
|
86
|
+
"😧",
|
|
87
|
+
"😮",
|
|
88
|
+
"😲",
|
|
89
|
+
"🥱",
|
|
90
|
+
"😴",
|
|
91
|
+
"🤤",
|
|
92
|
+
"😪",
|
|
93
|
+
"😵",
|
|
94
|
+
"🤐",
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
gestures: {
|
|
98
|
+
label: "Cử chỉ",
|
|
99
|
+
icon: Hand,
|
|
100
|
+
emojis: [
|
|
101
|
+
"👍",
|
|
102
|
+
"👎",
|
|
103
|
+
"👌",
|
|
104
|
+
"✌️",
|
|
105
|
+
"🤞",
|
|
106
|
+
"🤟",
|
|
107
|
+
"🤘",
|
|
108
|
+
"🤙",
|
|
109
|
+
"👈",
|
|
110
|
+
"👉",
|
|
111
|
+
"👆",
|
|
112
|
+
"🖕",
|
|
113
|
+
"👇",
|
|
114
|
+
"☝️",
|
|
115
|
+
"👏",
|
|
116
|
+
"🙌",
|
|
117
|
+
"👐",
|
|
118
|
+
"🤲",
|
|
119
|
+
"🤝",
|
|
120
|
+
"🙏",
|
|
121
|
+
],
|
|
122
|
+
},
|
|
123
|
+
hearts: {
|
|
124
|
+
label: "Trái tim",
|
|
125
|
+
icon: Heart,
|
|
126
|
+
emojis: ["❤️", "🧡", "💛", "💚", "💙", "💜", "🖤", "🤍", "🤎", "💔", "❣️", "💕", "💞", "💓", "💗", "💖", "💘", "💝"],
|
|
127
|
+
},
|
|
128
|
+
objects: {
|
|
129
|
+
label: "Đồ vật",
|
|
130
|
+
icon: Coffee,
|
|
131
|
+
emojis: ["📱", "💻", "⌨️", "🖥️", "🖨️", "📷", "📹", "🎥", "📞", "☎️", "📠", "📺", "📻", "🎵", "🎶", "🎤", "🎧", "📢"],
|
|
132
|
+
},
|
|
133
|
+
travel: {
|
|
134
|
+
label: "Du lịch",
|
|
135
|
+
icon: Car,
|
|
136
|
+
emojis: ["🚗", "🚕", "🚙", "🚌", "🚎", "🏎️", "🚓", "🚑", "🚒", "🚐", "🛻", "🚚", "🚛", "🚜", "🏍️", "🛵", "🚲", "🛴"],
|
|
137
|
+
},
|
|
138
|
+
activities: {
|
|
139
|
+
label: "Hoạt động",
|
|
140
|
+
icon: Lightbulb,
|
|
141
|
+
emojis: [
|
|
142
|
+
"⚽",
|
|
143
|
+
"🏀",
|
|
144
|
+
"🏈",
|
|
145
|
+
"⚾",
|
|
146
|
+
"🥎",
|
|
147
|
+
"🎾",
|
|
148
|
+
"🏐",
|
|
149
|
+
"🏉",
|
|
150
|
+
"🥏",
|
|
151
|
+
"🎱",
|
|
152
|
+
"🪀",
|
|
153
|
+
"🏓",
|
|
154
|
+
"🏸",
|
|
155
|
+
"🏒",
|
|
156
|
+
"🏑",
|
|
157
|
+
"🥍",
|
|
158
|
+
"🏏",
|
|
159
|
+
"🪃",
|
|
160
|
+
],
|
|
161
|
+
},
|
|
162
|
+
flags: {
|
|
163
|
+
label: "Cờ",
|
|
164
|
+
icon: Flag,
|
|
165
|
+
emojis: ["🏳️", "🏴", "🏁", "🚩", "🏳️🌈", "🏳️⚧️", "🇺🇳", "🇻🇳", "🇺🇸", "🇬🇧", "🇫🇷", "🇩🇪", "🇯🇵", "🇰🇷", "🇨🇳", "🇮🇳"],
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
export const EmojiPicker = React.forwardRef(({ onEmojiSelect, onClose, isOpen = false, style }, ref) => {
|
|
169
|
+
const [activeCategory, setActiveCategory] = useState("recent");
|
|
170
|
+
const [searchTerm, setSearchTerm] = useState("");
|
|
171
|
+
if (!isOpen)
|
|
172
|
+
return null;
|
|
173
|
+
const handleEmojiClick = (emoji) => {
|
|
174
|
+
onEmojiSelect === null || onEmojiSelect === void 0 ? void 0 : onEmojiSelect(emoji);
|
|
175
|
+
};
|
|
176
|
+
const filteredEmojis = searchTerm
|
|
177
|
+
? emojiCategories[activeCategory].emojis.filter((emoji) =>
|
|
178
|
+
// Simple search - in a real app you'd have emoji names/keywords
|
|
179
|
+
emoji.includes(searchTerm))
|
|
180
|
+
: emojiCategories[activeCategory].emojis;
|
|
181
|
+
return (<div ref={ref} className="absolute bottom-full mb-1 bg-white border border-gray-200 rounded-lg shadow-lg z-50 w-full sm:max-w-xs md:w-80" style={style} // Apply dynamic style here
|
|
182
|
+
>
|
|
183
|
+
<div className="p-2">
|
|
184
|
+
{/* Category Icons Row - Compact */}
|
|
185
|
+
<div className="flex items-center justify-between mb-2 pb-1 border-b border-gray-100">
|
|
186
|
+
{Object.entries(emojiCategories).map(([key, category]) => {
|
|
187
|
+
const Icon = category.icon;
|
|
188
|
+
const isActive = activeCategory === key;
|
|
189
|
+
return (<button key={key} onClick={() => setActiveCategory(key)} className={`
|
|
190
|
+
p-1.5 rounded-full transition-all duration-200 hover:bg-gray-100
|
|
191
|
+
${isActive ? "text-blue-500 bg-blue-50" : "text-gray-500"}
|
|
192
|
+
`} title={category.label}>
|
|
193
|
+
<Icon className="w-4 h-4"/>
|
|
194
|
+
</button>);
|
|
195
|
+
})}
|
|
196
|
+
|
|
197
|
+
{/* Close button */}
|
|
198
|
+
<button onClick={onClose} className="p-1.5 text-gray-400 hover:text-gray-600 rounded-full hover:bg-gray-100">
|
|
199
|
+
<X className="w-4 h-4"/>
|
|
200
|
+
</button>
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
{/* Search Bar - Compact */}
|
|
204
|
+
<div className="relative mb-2">
|
|
205
|
+
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 w-3 h-3 text-gray-400"/>
|
|
206
|
+
<input type="text" placeholder="Tìm kiếm" value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="w-full pl-7 pr-3 py-1.5 text-xs bg-gray-50 border border-gray-200 rounded-md focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-transparent"/>
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
{/* Category Title - Compact */}
|
|
210
|
+
<div className="mb-2">
|
|
211
|
+
<h3 className="text-xs font-medium text-gray-900">{emojiCategories[activeCategory].label}</h3>
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
{/* Emoji Grid - Compact */}
|
|
215
|
+
<div className="grid grid-cols-9 gap-0.5 max-h-48 overflow-y-auto">
|
|
216
|
+
{filteredEmojis.map((emoji, index) => (<button key={index} onClick={() => handleEmojiClick(emoji)} className="p-1.5 text-lg hover:bg-gray-100 rounded transition-all duration-200 hover:scale-110 active:scale-95" title={emoji}>
|
|
217
|
+
{emoji}
|
|
218
|
+
</button>))}
|
|
219
|
+
</div>
|
|
220
|
+
|
|
221
|
+
{/* Empty state */}
|
|
222
|
+
{filteredEmojis.length === 0 && (<div className="text-center py-4 text-gray-500">
|
|
223
|
+
<div className="text-lg mb-1">🔍</div>
|
|
224
|
+
<p className="text-xs">Không tìm thấy emoji nào</p>
|
|
225
|
+
</div>)}
|
|
226
|
+
</div>
|
|
227
|
+
</div>);
|
|
228
|
+
});
|
|
229
|
+
EmojiPicker.displayName = "EmojiPicker";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ImageLightbox.d.ts","sourceRoot":"","sources":["../../src/components/ImageLightbox.tsx"],"names":[],"mappings":"AAIA,UAAU,kBAAkB;IAC1B,GAAG,EAAE,MAAM,CAAA;IACX,GAAG,EAAE,MAAM,CAAA;IACX,OAAO,EAAE,MAAM,IAAI,CAAA;CACpB;AAED,wBAAgB,aAAa,CAAC,EAAE,GAAG,EAAE,GAAG,EAAE,OAAO,EAAE,EAAE,kBAAkB,+BA0BtE"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { X } from "lucide-react";
|
|
3
|
+
import Image from "next/image";
|
|
4
|
+
export function ImageLightbox({ src, alt, onClose }) {
|
|
5
|
+
return (<div className="fixed inset-0 z-[9999] bg-black bg-opacity-80 flex items-center justify-center p-4" onClick={onClose}>
|
|
6
|
+
<button onClick={onClose} className="absolute top-4 right-4 text-white p-2 rounded-full bg-gray-800/50 hover:bg-gray-700/70 transition-colors z-50" aria-label="Close image">
|
|
7
|
+
<X className="w-6 h-6"/>
|
|
8
|
+
</button>
|
|
9
|
+
<div className="relative max-w-full max-h-full" onClick={(e) => e.stopPropagation()}>
|
|
10
|
+
<Image src={src || "/placeholder.svg"} alt={alt} width={1200} // Max width for lightbox image
|
|
11
|
+
height={800} // Max height for lightbox image
|
|
12
|
+
style={{ width: "auto", height: "auto", maxWidth: "90vw", maxHeight: "90vh" }} className="rounded-lg shadow-xl object-contain" priority // Load immediately
|
|
13
|
+
/>
|
|
14
|
+
</div>
|
|
15
|
+
</div>);
|
|
16
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
interface ImagePreviewModalProps {
|
|
2
|
+
images: {
|
|
3
|
+
id: string;
|
|
4
|
+
url: string;
|
|
5
|
+
name?: string;
|
|
6
|
+
}[];
|
|
7
|
+
initialImageId: string;
|
|
8
|
+
onClose: () => void;
|
|
9
|
+
}
|
|
10
|
+
export declare function ImagePreviewModal({ images, initialImageId, onClose }: ImagePreviewModalProps): import("react").JSX.Element | null;
|
|
11
|
+
export {};
|
|
12
|
+
//# sourceMappingURL=ImagePreviewModal.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ImagePreviewModal.d.ts","sourceRoot":"","sources":["../../src/components/ImagePreviewModal.tsx"],"names":[],"mappings":"AAOA,UAAU,sBAAsB;IAC9B,MAAM,EAAE;QACN,EAAE,EAAE,MAAM,CAAA;QACV,GAAG,EAAE,MAAM,CAAA;QACX,IAAI,CAAC,EAAE,MAAM,CAAA;KACd,EAAE,CAAA;IACH,cAAc,EAAE,MAAM,CAAA;IACtB,OAAO,EAAE,MAAM,IAAI,CAAA;CACpB;AAED,wBAAgB,iBAAiB,CAAC,EAAE,MAAM,EAAE,cAAc,EAAE,OAAO,EAAE,EAAE,sBAAsB,sCAmH5F"}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useState, useEffect, useCallback } from "react";
|
|
3
|
+
import Image from "next/image";
|
|
4
|
+
import { X, ChevronLeft, ChevronRight } from "lucide-react";
|
|
5
|
+
export function ImagePreviewModal({ images, initialImageId, onClose }) {
|
|
6
|
+
// Find the initial index based on initialImageId
|
|
7
|
+
const initialIndex = images.findIndex((img) => img.id === initialImageId);
|
|
8
|
+
const [currentImageIndex, setCurrentImageIndex] = useState(initialIndex !== -1 ? initialIndex : 0);
|
|
9
|
+
// Ensure currentImageIndex is valid if initialImageId wasn't found or images array is empty
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
if (initialIndex === -1 && images.length > 0) {
|
|
12
|
+
setCurrentImageIndex(0);
|
|
13
|
+
}
|
|
14
|
+
else if (images.length === 0) {
|
|
15
|
+
onClose(); // Close if no images are provided
|
|
16
|
+
}
|
|
17
|
+
}, [initialImageId, images, initialIndex, onClose]);
|
|
18
|
+
const currentImage = images[currentImageIndex];
|
|
19
|
+
const handlePrev = useCallback(() => {
|
|
20
|
+
setCurrentImageIndex((prevIndex) => Math.max(0, prevIndex - 1));
|
|
21
|
+
}, []);
|
|
22
|
+
const handleNext = useCallback(() => {
|
|
23
|
+
setCurrentImageIndex((prevIndex) => Math.min(images.length - 1, prevIndex + 1));
|
|
24
|
+
}, [images.length]);
|
|
25
|
+
const handleKeyDown = useCallback((event) => {
|
|
26
|
+
if (event.key === "Escape") {
|
|
27
|
+
onClose();
|
|
28
|
+
}
|
|
29
|
+
else if (event.key === "ArrowLeft") {
|
|
30
|
+
handlePrev();
|
|
31
|
+
}
|
|
32
|
+
else if (event.key === "ArrowRight") {
|
|
33
|
+
handleNext();
|
|
34
|
+
}
|
|
35
|
+
}, [onClose, handlePrev, handleNext]);
|
|
36
|
+
// Add and remove keyboard event listener
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
39
|
+
return () => {
|
|
40
|
+
document.removeEventListener("keydown", handleKeyDown);
|
|
41
|
+
};
|
|
42
|
+
}, [handleKeyDown]);
|
|
43
|
+
if (!currentImage) {
|
|
44
|
+
return null; // Or render a loading/error state
|
|
45
|
+
}
|
|
46
|
+
const isFirstImage = currentImageIndex === 0;
|
|
47
|
+
const isLastImage = currentImageIndex === images.length - 1;
|
|
48
|
+
return (<div className="fixed inset-0 z-[9999] bg-black bg-opacity-90 flex items-center justify-center p-4 sm:p-8" onClick={onClose} // Close when clicking on the overlay
|
|
49
|
+
>
|
|
50
|
+
{/* Close Button */}
|
|
51
|
+
<button onClick={onClose} className="absolute top-4 right-4 text-white p-2 rounded-full bg-gray-800/50 hover:bg-gray-700/70 transition-colors z-50" aria-label="Đóng" title="Đóng (Esc)">
|
|
52
|
+
<X className="w-6 h-6"/>
|
|
53
|
+
</button>
|
|
54
|
+
|
|
55
|
+
{/* Image Container */}
|
|
56
|
+
<div className="relative flex items-center justify-center w-full h-full max-w-screen-xl max-h-screen-xl" onClick={(e) => e.stopPropagation()} // Prevent closing when clicking on the image itself
|
|
57
|
+
>
|
|
58
|
+
{/* Previous Button */}
|
|
59
|
+
<button onClick={handlePrev} disabled={isFirstImage} className={`absolute left-2 sm:left-4 p-3 rounded-full bg-gray-800/50 text-white hover:bg-gray-700/70 transition-colors z-40
|
|
60
|
+
${isFirstImage ? "opacity-50 cursor-not-allowed" : ""}`} aria-label="Ảnh trước" title="Ảnh trước (Mũi tên trái)">
|
|
61
|
+
<ChevronLeft className="w-6 h-6 sm:w-8 sm:h-8"/>
|
|
62
|
+
</button>
|
|
63
|
+
|
|
64
|
+
{/* Image Display */}
|
|
65
|
+
<div className="relative w-full h-full flex items-center justify-center">
|
|
66
|
+
<Image src={currentImage.url || "/placeholder.svg"} alt={currentImage.name || "Xem trước hình ảnh"} layout="fill" // Use fill to make it responsive within its parent
|
|
67
|
+
objectFit="contain" // Ensure the image fits within the container without cropping
|
|
68
|
+
className="rounded-lg shadow-xl" priority // Load immediately for better UX
|
|
69
|
+
/>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
{/* Next Button */}
|
|
73
|
+
<button onClick={handleNext} disabled={isLastImage} className={`absolute right-2 sm:right-4 p-3 rounded-full bg-gray-800/50 text-white hover:bg-gray-700/70 transition-colors z-40
|
|
74
|
+
${isLastImage ? "opacity-50 cursor-not-allowed" : ""}`} aria-label="Ảnh tiếp theo" title="Ảnh tiếp theo (Mũi tên phải)">
|
|
75
|
+
<ChevronRight className="w-6 h-6 sm:w-8 sm:h-8"/>
|
|
76
|
+
</button>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
{/* Image Name/Counter (Optional) */}
|
|
80
|
+
<div className="absolute bottom-4 text-white text-sm sm:text-base bg-black/50 px-4 py-2 rounded-full">
|
|
81
|
+
{currentImage.name || "Hình ảnh"} ({currentImageIndex + 1} / {images.length})
|
|
82
|
+
</div>
|
|
83
|
+
</div>);
|
|
84
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"MessageItem.d.ts","sourceRoot":"","sources":["../../src/components/MessageItem.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,gBAAgB,EAAkB,MAAM,UAAU,CAAA;AAIhE,wBAAgB,WAAW,CAAC,EAAE,OAAO,EAAE,SAAS,EAAE,YAAY,EAAE,EAAE,gBAAgB,+BAwLjF"}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useChatContext } from "../context/ChatContext";
|
|
3
|
+
import { FileText, Download } from "lucide-react";
|
|
4
|
+
import Image from "next/image";
|
|
5
|
+
export function MessageItem({ message, isGrouped, onImageClick }) {
|
|
6
|
+
const { state } = useChatContext();
|
|
7
|
+
const isOwnMessage = message.isMine;
|
|
8
|
+
const sender = state.users[message.senderId];
|
|
9
|
+
const formatFileSize = (bytes) => {
|
|
10
|
+
if (bytes === 0)
|
|
11
|
+
return "0 Bytes";
|
|
12
|
+
const k = 1024;
|
|
13
|
+
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
|
14
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
15
|
+
return Number.parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
|
16
|
+
};
|
|
17
|
+
const renderTextMessage = (msg) => {
|
|
18
|
+
const content = msg.text || "";
|
|
19
|
+
// Simple regex to detect URLs and make them clickable
|
|
20
|
+
const linkRegex = /(https?:\/\/[^\s]+)/g;
|
|
21
|
+
const parts = content.split(linkRegex);
|
|
22
|
+
return (<div className={`px-3 py-2 sm:px-4 sm:py-2 rounded-2xl max-w-full break-words ${isOwnMessage ? "bg-blue-500 text-white" : "bg-gray-100 text-gray-900"} ${isGrouped ? (isOwnMessage ? "rounded-tr-md" : "rounded-tl-md") : ""}`}>
|
|
23
|
+
<p className="text-sm sm:text-base whitespace-pre-wrap">
|
|
24
|
+
{parts.map((part, index) => linkRegex.test(part) ? (<a key={index} href={part} target="_blank" rel="noopener noreferrer" className="underline text-blue-200 hover:text-blue-100">
|
|
25
|
+
{part}
|
|
26
|
+
</a>) : (part))}
|
|
27
|
+
</p>
|
|
28
|
+
</div>);
|
|
29
|
+
};
|
|
30
|
+
const renderMediaMessage = (msg) => {
|
|
31
|
+
var _a, _b;
|
|
32
|
+
const imageAttachments = ((_a = msg.attachments) === null || _a === void 0 ? void 0 : _a.filter((att) => att.type === "image")) || [];
|
|
33
|
+
return (<div className={`flex flex-col gap-2 ${isOwnMessage ? "items-end" : "items-start"}`}>
|
|
34
|
+
{msg.text && (<div className={`px-3 py-2 sm:px-4 sm:py-2 rounded-2xl max-w-full break-words ${isOwnMessage ? "bg-blue-500 text-white" : "bg-gray-100 text-gray-900"} ${isGrouped ? (isOwnMessage ? "rounded-tr-md" : "rounded-tl-md") : ""}`}>
|
|
35
|
+
<p className="text-sm sm:text-base whitespace-pre-wrap">{msg.text}</p>
|
|
36
|
+
</div>)}
|
|
37
|
+
<div className={`grid gap-2 ${imageAttachments.length > 1 ? "grid-cols-2" : "grid-cols-1"}`}>
|
|
38
|
+
{(_b = msg.attachments) === null || _b === void 0 ? void 0 : _b.map((attachment, idx) => (<div key={idx} className={`relative rounded-lg overflow-hidden ${isOwnMessage ? "bg-blue-100" : "bg-gray-100"}`}>
|
|
39
|
+
{attachment.type === "image" ? (<>
|
|
40
|
+
<Image src={attachment.url || "/placeholder.svg"} alt={attachment.name || "Attached image"} width={200} height={150} className="w-full h-auto object-cover cursor-pointer" onClick={() => onImageClick === null || onImageClick === void 0 ? void 0 : onImageClick(attachment.id, imageAttachments.map((img) => ({ id: img.id, url: img.url, name: img.name })))}/>
|
|
41
|
+
<div className="absolute inset-0 bg-black/10 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity">
|
|
42
|
+
<span className="text-white text-xs font-medium p-1 rounded bg-black/50">Xem ảnh</span>
|
|
43
|
+
</div>
|
|
44
|
+
</>) : (<a href={attachment.url} download={attachment.name} className="flex items-center p-3 space-x-2 text-gray-800 hover:bg-gray-200 transition-colors">
|
|
45
|
+
<FileText className="w-5 h-5 flex-shrink-0"/>
|
|
46
|
+
<div className="flex-1 min-w-0">
|
|
47
|
+
<span className="block text-sm font-medium truncate">{attachment.name || "File"}</span>
|
|
48
|
+
<span className="block text-xs text-gray-500">
|
|
49
|
+
{attachment.size ? formatFileSize(attachment.size) : "Unknown size"}
|
|
50
|
+
</span>
|
|
51
|
+
</div>
|
|
52
|
+
<Download className="w-4 h-4 flex-shrink-0 text-gray-600"/>
|
|
53
|
+
</a>)}
|
|
54
|
+
</div>))}
|
|
55
|
+
</div>
|
|
56
|
+
</div>);
|
|
57
|
+
};
|
|
58
|
+
const renderPromoMessage = (msg) => {
|
|
59
|
+
if (!msg.promoData)
|
|
60
|
+
return null;
|
|
61
|
+
const { imageUrl, title, description, buttonText, buttonUrl } = msg.promoData;
|
|
62
|
+
return (<div className="w-full max-w-xs mx-auto my-2 bg-white shadow-md rounded-lg overflow-hidden border border-gray-200">
|
|
63
|
+
<div className="relative w-full h-32 bg-gray-200">
|
|
64
|
+
<Image src={imageUrl || "/placeholder.svg"} alt={title} fill style={{ objectFit: "cover" }} className="rounded-t-lg"/>
|
|
65
|
+
</div>
|
|
66
|
+
<div className="p-4">
|
|
67
|
+
<h3 className="text-base font-semibold text-gray-900 mb-1">{title}</h3>
|
|
68
|
+
<p className="text-sm text-gray-600 mb-3">{description}</p>
|
|
69
|
+
<a href={buttonUrl} target="_blank" rel="noopener noreferrer" className="inline-flex items-center justify-center w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md transition-colors">
|
|
70
|
+
{buttonText}
|
|
71
|
+
</a>
|
|
72
|
+
</div>
|
|
73
|
+
</div>);
|
|
74
|
+
};
|
|
75
|
+
return (<div className={`flex ${isOwnMessage ? "justify-end" : "justify-start"} ${isGrouped ? "mt-0.5 sm:mt-1" : "mt-3 sm:mt-4"}`}>
|
|
76
|
+
<div className={`flex items-end space-x-2 max-w-[85%] sm:max-w-xs lg:max-w-md ${isOwnMessage ? "flex-row-reverse space-x-reverse" : ""}`} style={message.type === "promo" ? { maxWidth: "none" } : {}} // Allow promo messages to take full width if needed
|
|
77
|
+
>
|
|
78
|
+
{message.type !== "promo" && !isOwnMessage && !isGrouped && (<img src={(sender === null || sender === void 0 ? void 0 : sender.avatar) || "/placeholder.svg?height=32&width=32&query=user"} alt={(sender === null || sender === void 0 ? void 0 : sender.name) || "User"} className="w-6 h-6 sm:w-8 sm:h-8 rounded-full object-cover flex-shrink-0"/>)}
|
|
79
|
+
|
|
80
|
+
{message.type !== "promo" && !isOwnMessage && isGrouped && (<div className="w-6 sm:w-8 flex-shrink-0"/> // Spacer for grouped messages
|
|
81
|
+
)}
|
|
82
|
+
|
|
83
|
+
<div className={`flex flex-col ${isOwnMessage ? "items-end" : "items-start"}`}>
|
|
84
|
+
{message.type !== "promo" && !isGrouped && !isOwnMessage && (<span className="text-xs text-gray-500 mb-1 px-3">{(sender === null || sender === void 0 ? void 0 : sender.name) || "Unknown User"}</span>)}
|
|
85
|
+
|
|
86
|
+
{message.type === "text" && renderTextMessage(message)}
|
|
87
|
+
{message.type === "media" && renderMediaMessage(message)}
|
|
88
|
+
{message.type === "promo" && renderPromoMessage(message)}
|
|
89
|
+
|
|
90
|
+
{message.type !== "promo" && (<div className={`flex items-center space-x-1 mt-1 px-2 ${isOwnMessage ? "flex-row-reverse space-x-reverse" : ""}`}>
|
|
91
|
+
{/* Status icon logic (from previous implementation) */}
|
|
92
|
+
{isOwnMessage && (<svg className="w-3 h-3 sm:w-4 sm:h-4 text-blue-500" fill="currentColor" viewBox="0 0 24 24">
|
|
93
|
+
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/>
|
|
94
|
+
</svg>)}
|
|
95
|
+
</div>)}
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</div>);
|
|
99
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"MessageItemDemo.d.ts","sourceRoot":"","sources":["../../src/components/MessageItemDemo.tsx"],"names":[],"mappings":"AAMA,wBAAgB,eAAe,gCA2L9B"}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { MessageItem } from "./MessageItem";
|
|
3
|
+
import { DateDivider } from "./DateDivider";
|
|
4
|
+
export function MessageItemDemo() {
|
|
5
|
+
// Demo messages showcasing all message types
|
|
6
|
+
const demoMessages = [
|
|
7
|
+
// Text message from other user
|
|
8
|
+
{
|
|
9
|
+
id: "demo-1",
|
|
10
|
+
senderId: "user-2",
|
|
11
|
+
type: "text",
|
|
12
|
+
text: "Hey! How are you doing today? 😊",
|
|
13
|
+
createdAt: new Date(Date.now() - 3600000).toISOString(),
|
|
14
|
+
isMine: false,
|
|
15
|
+
},
|
|
16
|
+
// Text message from current user
|
|
17
|
+
{
|
|
18
|
+
id: "demo-2",
|
|
19
|
+
senderId: "current-user",
|
|
20
|
+
type: "text",
|
|
21
|
+
text: "I'm doing great! Thanks for asking. Check out this link: https://example.com",
|
|
22
|
+
createdAt: new Date(Date.now() - 3000000).toISOString(),
|
|
23
|
+
isMine: true,
|
|
24
|
+
},
|
|
25
|
+
// Media message with text and image from other user
|
|
26
|
+
{
|
|
27
|
+
id: "demo-3",
|
|
28
|
+
senderId: "user-2",
|
|
29
|
+
type: "media",
|
|
30
|
+
text: "Look at this beautiful sunset I captured! 📸",
|
|
31
|
+
attachments: [
|
|
32
|
+
{
|
|
33
|
+
id: "demo-3-1",
|
|
34
|
+
type: "image",
|
|
35
|
+
url: "/placeholder.svg?height=300&width=400",
|
|
36
|
+
name: "sunset.jpg",
|
|
37
|
+
size: 245760,
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
createdAt: new Date(Date.now() - 2400000).toISOString(),
|
|
41
|
+
isMine: false,
|
|
42
|
+
},
|
|
43
|
+
// Media message with multiple images from current user
|
|
44
|
+
{
|
|
45
|
+
id: "demo-4",
|
|
46
|
+
senderId: "current-user",
|
|
47
|
+
type: "media",
|
|
48
|
+
text: "My vacation photos! 🏖️",
|
|
49
|
+
attachments: [
|
|
50
|
+
{
|
|
51
|
+
id: "demo-4-1",
|
|
52
|
+
type: "image",
|
|
53
|
+
url: "/placeholder.svg?height=200&width=300",
|
|
54
|
+
name: "beach1.jpg",
|
|
55
|
+
size: 180000,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: "demo-4-2",
|
|
59
|
+
type: "image",
|
|
60
|
+
url: "/placeholder.svg?height=200&width=300",
|
|
61
|
+
name: "beach2.jpg",
|
|
62
|
+
size: 195000,
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
createdAt: new Date(Date.now() - 1800000).toISOString(),
|
|
66
|
+
isMine: true,
|
|
67
|
+
},
|
|
68
|
+
// Media message with file from other user
|
|
69
|
+
{
|
|
70
|
+
id: "demo-5",
|
|
71
|
+
senderId: "user-2",
|
|
72
|
+
type: "media",
|
|
73
|
+
text: "Here's the document you requested",
|
|
74
|
+
attachments: [
|
|
75
|
+
{
|
|
76
|
+
id: "demo-5-1",
|
|
77
|
+
type: "file",
|
|
78
|
+
url: "/placeholder.svg?height=200&width=200",
|
|
79
|
+
name: "project-proposal.pdf",
|
|
80
|
+
size: 1024000,
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
createdAt: new Date(Date.now() - 1200000).toISOString(),
|
|
84
|
+
isMine: false,
|
|
85
|
+
},
|
|
86
|
+
// Media message with mixed attachments from current user
|
|
87
|
+
{
|
|
88
|
+
id: "demo-6",
|
|
89
|
+
senderId: "current-user",
|
|
90
|
+
type: "media",
|
|
91
|
+
text: "Screenshot and the related document",
|
|
92
|
+
attachments: [
|
|
93
|
+
{
|
|
94
|
+
id: "demo-6-1",
|
|
95
|
+
type: "image",
|
|
96
|
+
url: "/placeholder.svg?height=400&width=300",
|
|
97
|
+
name: "screenshot.png",
|
|
98
|
+
size: 156000,
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
id: "demo-6-2",
|
|
102
|
+
type: "file",
|
|
103
|
+
url: "/placeholder.svg?height=200&width=200",
|
|
104
|
+
name: "instructions.docx",
|
|
105
|
+
size: 45000,
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
createdAt: new Date(Date.now() - 600000).toISOString(),
|
|
109
|
+
isMine: true,
|
|
110
|
+
},
|
|
111
|
+
// Promotional message (system message)
|
|
112
|
+
{
|
|
113
|
+
id: "demo-7",
|
|
114
|
+
senderId: "system",
|
|
115
|
+
type: "promo",
|
|
116
|
+
promoData: {
|
|
117
|
+
imageUrl: "/placeholder.svg?height=200&width=400",
|
|
118
|
+
title: "Giảm 30% đơn hàng hôm nay!",
|
|
119
|
+
description: "Khuyến mãi đặc biệt chỉ trong hôm nay. Đừng bỏ lỡ cơ hội tiết kiệm!",
|
|
120
|
+
buttonText: "Đặt ngay",
|
|
121
|
+
buttonUrl: "https://example.com/sale",
|
|
122
|
+
},
|
|
123
|
+
createdAt: new Date(Date.now() - 300000).toISOString(),
|
|
124
|
+
isMine: false,
|
|
125
|
+
},
|
|
126
|
+
// Recent text message from other user
|
|
127
|
+
{
|
|
128
|
+
id: "demo-8",
|
|
129
|
+
senderId: "user-2",
|
|
130
|
+
type: "text",
|
|
131
|
+
text: "Thanks for sharing! Those photos are amazing 🤩",
|
|
132
|
+
createdAt: new Date(Date.now() - 60000).toISOString(),
|
|
133
|
+
isMine: false,
|
|
134
|
+
},
|
|
135
|
+
];
|
|
136
|
+
const groupMessagesByDate = () => {
|
|
137
|
+
const groups = [];
|
|
138
|
+
let currentDate = "";
|
|
139
|
+
let currentGroup = [];
|
|
140
|
+
demoMessages.forEach((message) => {
|
|
141
|
+
const messageDate = new Date(message.createdAt).toDateString();
|
|
142
|
+
if (messageDate !== currentDate) {
|
|
143
|
+
if (currentGroup.length > 0) {
|
|
144
|
+
groups.push({ date: currentDate, messages: currentGroup });
|
|
145
|
+
}
|
|
146
|
+
currentDate = messageDate;
|
|
147
|
+
currentGroup = [message];
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
currentGroup.push(message);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
if (currentGroup.length > 0) {
|
|
154
|
+
groups.push({ date: currentDate, messages: currentGroup });
|
|
155
|
+
}
|
|
156
|
+
return groups;
|
|
157
|
+
};
|
|
158
|
+
const messageGroups = groupMessagesByDate();
|
|
159
|
+
return (<div className="max-w-2xl mx-auto bg-white rounded-lg shadow-lg overflow-hidden">
|
|
160
|
+
<div className="bg-gray-50 p-4 border-b">
|
|
161
|
+
<h2 className="text-lg font-semibold text-gray-900">MessageItem Demo</h2>
|
|
162
|
+
<p className="text-sm text-gray-600">Showcasing all message types: text, media, and promotional</p>
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
<div className="h-96 overflow-y-auto p-4 space-y-4">
|
|
166
|
+
{messageGroups.map((group, groupIndex) => (<div key={group.date}>
|
|
167
|
+
<DateDivider date={new Date(group.date)}/>
|
|
168
|
+
<div className="space-y-2">
|
|
169
|
+
{group.messages.map((message, messageIndex) => {
|
|
170
|
+
const prevMessage = messageIndex > 0 ? group.messages[messageIndex - 1] : null;
|
|
171
|
+
const isGrouped = (prevMessage === null || prevMessage === void 0 ? void 0 : prevMessage.senderId) === message.senderId &&
|
|
172
|
+
new Date(message.createdAt).getTime() - new Date(prevMessage.createdAt).getTime() < 300000; // 5 minutes
|
|
173
|
+
return <MessageItem key={message.id} message={message} isGrouped={isGrouped}/>;
|
|
174
|
+
})}
|
|
175
|
+
</div>
|
|
176
|
+
</div>))}
|
|
177
|
+
</div>
|
|
178
|
+
</div>);
|
|
179
|
+
}
|