@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,15 @@
|
|
|
1
|
+
import type { Message } from "../types";
|
|
2
|
+
interface MessageListProps {
|
|
3
|
+
messages: Message[];
|
|
4
|
+
isLoadingMore?: boolean;
|
|
5
|
+
onLoadMore?: () => void;
|
|
6
|
+
hasMore?: boolean;
|
|
7
|
+
currentUserId: string;
|
|
8
|
+
conversationId?: string;
|
|
9
|
+
className?: string;
|
|
10
|
+
onSwipeBack?: () => void;
|
|
11
|
+
}
|
|
12
|
+
export declare function MessageList({ messages, // Add default empty array
|
|
13
|
+
isLoadingMore, onLoadMore, hasMore, currentUserId, conversationId, className, onSwipeBack, }: MessageListProps): import("react").JSX.Element;
|
|
14
|
+
export {};
|
|
15
|
+
//# sourceMappingURL=MessageList.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"MessageList.d.ts","sourceRoot":"","sources":["../../src/components/MessageList.tsx"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAkB,OAAO,EAAE,MAAM,UAAU,CAAA;AAGvD,UAAU,gBAAgB;IACxB,QAAQ,EAAE,OAAO,EAAE,CAAA;IACnB,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,UAAU,CAAC,EAAE,MAAM,IAAI,CAAA;IACvB,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,aAAa,EAAE,MAAM,CAAA;IACrB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,WAAW,CAAC,EAAE,MAAM,IAAI,CAAA;CACzB;AAED,wBAAgB,WAAW,CAAC,EAC1B,QAAa,EAAE,0BAA0B;AACzC,aAAqB,EACrB,UAAU,EACV,OAAe,EACf,aAAa,EACb,cAAc,EACd,SAAc,EACd,WAAW,GACZ,EAAE,gBAAgB,+BA8WlB"}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useRef, useState, useCallback } from "react";
|
|
3
|
+
import { MessageItem } from "./MessageItem";
|
|
4
|
+
import { DateDivider } from "./DateDivider";
|
|
5
|
+
import { TypingIndicator } from "./TypingIndicator";
|
|
6
|
+
// import { AutoScrollAnchor } from "./AutoScrollAnchor" // Removed
|
|
7
|
+
import { useSwipeGesture } from "../hooks/useSwipeGesture";
|
|
8
|
+
import { useChatContext } from "../context/ChatContext";
|
|
9
|
+
import { ImagePreviewModal } from "./ImagePreviewModal"; // Import the new modal
|
|
10
|
+
export function MessageList({ messages = [], // Add default empty array
|
|
11
|
+
isLoadingMore = false, onLoadMore, hasMore = false, currentUserId, conversationId, className = "", onSwipeBack, }) {
|
|
12
|
+
const { state } = useChatContext();
|
|
13
|
+
const scrollRef = useRef(null);
|
|
14
|
+
const shouldScrollToBottomRef = useRef(true); // New ref to control auto-scrolling
|
|
15
|
+
const lastMessageCountRef = useRef((messages === null || messages === void 0 ? void 0 : messages.length) || 0); // Add null check
|
|
16
|
+
const [showSwipeHint, setShowSwipeHint] = useState(false);
|
|
17
|
+
const [showScrollToBottomButton, setShowScrollToBottomButton] = useState(false); // State for button visibility
|
|
18
|
+
// State for ImagePreviewModal
|
|
19
|
+
const [isImagePreviewModalOpen, setIsImagePreviewModalOpen] = useState(false);
|
|
20
|
+
const [previewImages, setPreviewImages] = useState([]);
|
|
21
|
+
const [initialPreviewImageId, setInitialPreviewImageId] = useState("");
|
|
22
|
+
// Convert internal Message format to DisplayMessage format
|
|
23
|
+
const convertToDisplayMessages = useCallback(() => {
|
|
24
|
+
if (!messages || !Array.isArray(messages))
|
|
25
|
+
return []; // Add safety check
|
|
26
|
+
return messages.map((msg) => {
|
|
27
|
+
const isCurrentUser = msg.senderId === currentUserId;
|
|
28
|
+
// Handle different message types
|
|
29
|
+
if (msg.attachments && msg.attachments.length > 0) {
|
|
30
|
+
return {
|
|
31
|
+
id: msg.id,
|
|
32
|
+
senderId: msg.senderId,
|
|
33
|
+
type: "media",
|
|
34
|
+
text: msg.content || undefined,
|
|
35
|
+
attachments: msg.attachments.map((att) => ({
|
|
36
|
+
id: att.id, // Ensure ID is passed
|
|
37
|
+
type: att.type.startsWith("image/") ? "image" : "file",
|
|
38
|
+
url: att.url,
|
|
39
|
+
name: att.name,
|
|
40
|
+
size: att.size,
|
|
41
|
+
})),
|
|
42
|
+
createdAt: msg.timestamp.toISOString(),
|
|
43
|
+
isMine: isCurrentUser,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
// Promotional message type (assuming senderId 'system' or similar for demo)
|
|
47
|
+
if (msg.type === "promo" && msg.promoData) {
|
|
48
|
+
return {
|
|
49
|
+
id: msg.id,
|
|
50
|
+
senderId: msg.senderId,
|
|
51
|
+
type: "promo",
|
|
52
|
+
promoData: msg.promoData,
|
|
53
|
+
createdAt: msg.timestamp.toISOString(),
|
|
54
|
+
isMine: isCurrentUser, // Can be false for system messages
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
// Regular text message
|
|
58
|
+
return {
|
|
59
|
+
id: msg.id,
|
|
60
|
+
senderId: msg.senderId,
|
|
61
|
+
type: "text",
|
|
62
|
+
text: msg.content,
|
|
63
|
+
createdAt: msg.timestamp.toISOString(),
|
|
64
|
+
isMine: isCurrentUser,
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
}, [messages, currentUserId]);
|
|
68
|
+
const displayMessages = convertToDisplayMessages();
|
|
69
|
+
// Swipe gesture for going back (secondary swipe area)
|
|
70
|
+
const messageSwipeRef = useSwipeGesture({
|
|
71
|
+
onSwipeRight: () => {
|
|
72
|
+
if (window.innerWidth < 768) {
|
|
73
|
+
setShowSwipeHint(true);
|
|
74
|
+
setTimeout(() => setShowSwipeHint(false), 1500);
|
|
75
|
+
onSwipeBack === null || onSwipeBack === void 0 ? void 0 : onSwipeBack();
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
threshold: 80,
|
|
79
|
+
restraint: 120,
|
|
80
|
+
allowedTime: 400,
|
|
81
|
+
enabled: true,
|
|
82
|
+
});
|
|
83
|
+
// Auto-scroll to bottom logic
|
|
84
|
+
const scrollToBottom = useCallback((force = false) => {
|
|
85
|
+
if (scrollRef.current) {
|
|
86
|
+
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
87
|
+
if (force) {
|
|
88
|
+
shouldScrollToBottomRef.current = true; // If forced, ensure auto-scroll is re-enabled
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}, []);
|
|
92
|
+
// Handle scroll events to manage auto-scroll behavior and button visibility
|
|
93
|
+
const handleScroll = useCallback(() => {
|
|
94
|
+
if (!scrollRef.current)
|
|
95
|
+
return;
|
|
96
|
+
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
|
|
97
|
+
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
|
|
98
|
+
const SCROLL_UP_THRESHOLD = 200; // If user scrolls up more than 200px from bottom, disable auto-scroll
|
|
99
|
+
const SCROLL_DOWN_THRESHOLD = 5; // If user scrolls within 5px of bottom, re-enable auto-scroll
|
|
100
|
+
if (distanceFromBottom > SCROLL_UP_THRESHOLD) {
|
|
101
|
+
shouldScrollToBottomRef.current = false;
|
|
102
|
+
}
|
|
103
|
+
else if (distanceFromBottom <= SCROLL_DOWN_THRESHOLD) {
|
|
104
|
+
shouldScrollToBottomRef.current = true;
|
|
105
|
+
}
|
|
106
|
+
// Show button if not at bottom AND auto-scroll is disabled
|
|
107
|
+
setShowScrollToBottomButton(distanceFromBottom > SCROLL_DOWN_THRESHOLD && !shouldScrollToBottomRef.current);
|
|
108
|
+
}, []);
|
|
109
|
+
// Handle new messages
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
const currentMessageCount = (messages === null || messages === void 0 ? void 0 : messages.length) || 0;
|
|
112
|
+
const previousMessageCount = lastMessageCountRef.current;
|
|
113
|
+
if (currentMessageCount > previousMessageCount) {
|
|
114
|
+
const newMessages = messages.slice(previousMessageCount);
|
|
115
|
+
const hasNewMessageFromCurrentUser = newMessages.some((msg) => msg.senderId === currentUserId);
|
|
116
|
+
// If current user sent a message, always scroll to bottom
|
|
117
|
+
// If another user sent a message, only scroll if shouldScrollToBottomRef is true (user is already at bottom)
|
|
118
|
+
if (hasNewMessageFromCurrentUser) {
|
|
119
|
+
setTimeout(() => scrollToBottom(true), 50); // Force scroll for own messages
|
|
120
|
+
}
|
|
121
|
+
else if (shouldScrollToBottomRef.current) {
|
|
122
|
+
setTimeout(() => scrollToBottom(), 50); // Scroll if auto-scroll is enabled for others' messages
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
lastMessageCountRef.current = currentMessageCount;
|
|
126
|
+
}, [messages, currentUserId, scrollToBottom]); // scrollToBottom is a dependency because it's called here
|
|
127
|
+
// Attach and detach scroll listener
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
const currentScrollRef = scrollRef.current;
|
|
130
|
+
if (currentScrollRef) {
|
|
131
|
+
currentScrollRef.addEventListener("scroll", handleScroll);
|
|
132
|
+
// Initial check for button visibility
|
|
133
|
+
handleScroll();
|
|
134
|
+
}
|
|
135
|
+
return () => {
|
|
136
|
+
if (currentScrollRef) {
|
|
137
|
+
currentScrollRef.removeEventListener("scroll", handleScroll);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
}, [handleScroll]); // Re-attach if handleScroll changes (due to useCallback dependencies)
|
|
141
|
+
// Preserve scroll position when loading more messages
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
if (isLoadingMore)
|
|
144
|
+
return;
|
|
145
|
+
const scrollContainer = scrollRef.current;
|
|
146
|
+
if (!scrollContainer)
|
|
147
|
+
return;
|
|
148
|
+
// Save current scroll position
|
|
149
|
+
const previousScrollHeight = scrollContainer.scrollHeight;
|
|
150
|
+
const previousScrollTop = scrollContainer.scrollTop;
|
|
151
|
+
// After new messages are loaded, adjust scroll position
|
|
152
|
+
const adjustScrollPosition = () => {
|
|
153
|
+
const newScrollHeight = scrollContainer.scrollHeight;
|
|
154
|
+
const heightDifference = newScrollHeight - previousScrollHeight;
|
|
155
|
+
if (heightDifference > 0) {
|
|
156
|
+
scrollContainer.scrollTop = previousScrollTop + heightDifference;
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
// Use setTimeout to ensure DOM has updated
|
|
160
|
+
setTimeout(adjustScrollPosition, 0);
|
|
161
|
+
}, [isLoadingMore]);
|
|
162
|
+
// Show swipe hint on first load for mobile users
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
const hasSeenHint = localStorage.getItem("chat-swipe-hint-seen");
|
|
165
|
+
if (!hasSeenHint && window.innerWidth < 768) {
|
|
166
|
+
setTimeout(() => {
|
|
167
|
+
setShowSwipeHint(true);
|
|
168
|
+
setTimeout(() => {
|
|
169
|
+
setShowSwipeHint(false);
|
|
170
|
+
localStorage.setItem("chat-swipe-hint-seen", "true");
|
|
171
|
+
}, 3000);
|
|
172
|
+
}, 1000);
|
|
173
|
+
}
|
|
174
|
+
}, []);
|
|
175
|
+
// Group messages by date
|
|
176
|
+
const groupMessagesByDate = useCallback(() => {
|
|
177
|
+
const groups = [];
|
|
178
|
+
let currentDate = "";
|
|
179
|
+
let currentGroup = [];
|
|
180
|
+
displayMessages.forEach((message) => {
|
|
181
|
+
const messageDate = new Date(message.createdAt).toDateString();
|
|
182
|
+
if (messageDate !== currentDate) {
|
|
183
|
+
if (currentGroup.length > 0) {
|
|
184
|
+
groups.push({ date: currentDate, messages: currentGroup });
|
|
185
|
+
}
|
|
186
|
+
currentDate = messageDate;
|
|
187
|
+
currentGroup = [message];
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
currentGroup.push(message);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
if (currentGroup.length > 0) {
|
|
194
|
+
groups.push({ date: currentDate, messages: currentGroup });
|
|
195
|
+
}
|
|
196
|
+
return groups;
|
|
197
|
+
}, [displayMessages]);
|
|
198
|
+
const messageGroups = groupMessagesByDate();
|
|
199
|
+
// Format date labels in Vietnamese
|
|
200
|
+
const formatDateLabel = (date) => {
|
|
201
|
+
const now = new Date();
|
|
202
|
+
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
203
|
+
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
|
|
204
|
+
const messageDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
|
205
|
+
if (messageDate.getTime() === today.getTime()) {
|
|
206
|
+
return "Hรดm nay";
|
|
207
|
+
}
|
|
208
|
+
else if (messageDate.getTime() === yesterday.getTime()) {
|
|
209
|
+
return "Hรดm qua";
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
return date.toLocaleDateString("vi-VN", {
|
|
213
|
+
weekday: "long",
|
|
214
|
+
year: "numeric",
|
|
215
|
+
month: "long",
|
|
216
|
+
day: "numeric",
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
// Handler for image clicks from MessageItem
|
|
221
|
+
const handleImageClick = useCallback((imageId, images) => {
|
|
222
|
+
setPreviewImages(images);
|
|
223
|
+
setInitialPreviewImageId(imageId);
|
|
224
|
+
setIsImagePreviewModalOpen(true);
|
|
225
|
+
}, []);
|
|
226
|
+
if (!messages || messages.length === 0) {
|
|
227
|
+
return (<div className={`flex items-center justify-center h-full ${className}`}>
|
|
228
|
+
<div className="text-center text-gray-500">
|
|
229
|
+
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 rounded-full flex items-center justify-center">
|
|
230
|
+
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
231
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
|
|
232
|
+
</svg>
|
|
233
|
+
</div>
|
|
234
|
+
<p className="text-sm">Chฦฐa cรณ tin nhแบฏn nร o</p>
|
|
235
|
+
<p className="text-xs text-gray-400 mt-1">Hรฃy bแบฏt ฤแบงu cuแปc trรฒ chuyแปn!</p>
|
|
236
|
+
</div>
|
|
237
|
+
</div>);
|
|
238
|
+
}
|
|
239
|
+
return (<div className={`relative h-full ${className}`}>
|
|
240
|
+
{/* Swipe hint overlay */}
|
|
241
|
+
{showSwipeHint && (<div className="absolute top-4 left-1/2 transform -translate-x-1/2 z-20 md:hidden">
|
|
242
|
+
<div className="bg-black bg-opacity-80 text-white text-sm px-4 py-2 rounded-full flex items-center space-x-2 animate-pulse">
|
|
243
|
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
244
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7"/>
|
|
245
|
+
</svg>
|
|
246
|
+
<span>Vuแปt phแบฃi ฤแป quay lแบกi</span>
|
|
247
|
+
</div>
|
|
248
|
+
</div>)}
|
|
249
|
+
|
|
250
|
+
{/* Scroll to bottom button */}
|
|
251
|
+
{showScrollToBottomButton && (<div className="absolute bottom-20 right-4 z-10">
|
|
252
|
+
<button onClick={() => scrollToBottom(true)} className="bg-blue-500 text-white p-3 rounded-full shadow-lg hover:bg-blue-600 transition-colors" aria-label="Scroll to bottom">
|
|
253
|
+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
254
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3"/>
|
|
255
|
+
</svg>
|
|
256
|
+
</button>
|
|
257
|
+
</div>)}
|
|
258
|
+
|
|
259
|
+
{/* Scrollable message container */}
|
|
260
|
+
<div ref={(el) => {
|
|
261
|
+
scrollRef.current = el;
|
|
262
|
+
if (messageSwipeRef.current !== el) {
|
|
263
|
+
messageSwipeRef.current = el;
|
|
264
|
+
}
|
|
265
|
+
}} className="h-full overflow-y-auto p-3 sm:p-4" style={{
|
|
266
|
+
WebkitOverflowScrolling: "touch", // Smooth scrolling on iOS
|
|
267
|
+
}} onScroll={handleScroll}>
|
|
268
|
+
{/* Loading more indicator */}
|
|
269
|
+
{isLoadingMore && (<div className="flex justify-center py-4">
|
|
270
|
+
<div className="flex items-center space-x-2 text-gray-500">
|
|
271
|
+
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500"></div>
|
|
272
|
+
<span className="text-sm">ฤang tแบฃi thรชm tin nhแบฏn...</span>
|
|
273
|
+
</div>
|
|
274
|
+
</div>)}
|
|
275
|
+
|
|
276
|
+
{/* Load more button */}
|
|
277
|
+
{hasMore && !isLoadingMore && (<div className="flex justify-center py-4">
|
|
278
|
+
<button onClick={onLoadMore} className="text-blue-500 hover:text-blue-600 text-sm font-medium">
|
|
279
|
+
Tแบฃi thรชm tin nhแบฏn
|
|
280
|
+
</button>
|
|
281
|
+
</div>)}
|
|
282
|
+
|
|
283
|
+
{/* Message groups */}
|
|
284
|
+
<div className="space-y-3 sm:space-y-4">
|
|
285
|
+
{messageGroups.map((group, groupIndex) => (<div key={group.date}>
|
|
286
|
+
<DateDivider date={new Date(group.date)} customLabel={formatDateLabel(new Date(group.date))}/>
|
|
287
|
+
<div className="space-y-1 sm:space-y-2">
|
|
288
|
+
{group.messages.map((message, messageIndex) => {
|
|
289
|
+
const prevMessage = messageIndex > 0 ? group.messages[messageIndex - 1] : null;
|
|
290
|
+
const isGrouped = (prevMessage === null || prevMessage === void 0 ? void 0 : prevMessage.senderId) === message.senderId &&
|
|
291
|
+
new Date(message.createdAt).getTime() - new Date(prevMessage.createdAt).getTime() < 300000; // 5 minutes
|
|
292
|
+
return (<MessageItem key={message.id} message={message} isGrouped={isGrouped} onImageClick={handleImageClick} // Pass the handler down
|
|
293
|
+
/>);
|
|
294
|
+
})}
|
|
295
|
+
</div>
|
|
296
|
+
</div>))}
|
|
297
|
+
</div>
|
|
298
|
+
|
|
299
|
+
{/* Typing indicator */}
|
|
300
|
+
{conversationId && <TypingIndicator conversationId={conversationId}/>}
|
|
301
|
+
</div>
|
|
302
|
+
|
|
303
|
+
{/* Image Preview Modal */}
|
|
304
|
+
{isImagePreviewModalOpen && (<ImagePreviewModal images={previewImages} initialImageId={initialPreviewImageId} onClose={() => setIsImagePreviewModalOpen(false)}/>)}
|
|
305
|
+
</div>);
|
|
306
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"MessageListDemo.d.ts","sourceRoot":"","sources":["../../src/components/MessageListDemo.tsx"],"names":[],"mappings":"AAMA,wBAAgB,eAAe,gCAmN9B"}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { MessageList } from "./MessageList";
|
|
4
|
+
export function MessageListDemo() {
|
|
5
|
+
const [messages, setMessages] = useState([
|
|
6
|
+
// Yesterday's messages
|
|
7
|
+
{
|
|
8
|
+
id: "demo-old-1",
|
|
9
|
+
conversationId: "demo-conv",
|
|
10
|
+
senderId: "user-2",
|
|
11
|
+
content: "Hey! How was your weekend?",
|
|
12
|
+
type: "text",
|
|
13
|
+
timestamp: new Date(Date.now() - 86400000 - 3600000), // Yesterday
|
|
14
|
+
status: "read",
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
id: "demo-old-2",
|
|
18
|
+
conversationId: "demo-conv",
|
|
19
|
+
senderId: "current-user",
|
|
20
|
+
content: "It was great! Went hiking ๐๏ธ",
|
|
21
|
+
type: "text",
|
|
22
|
+
timestamp: new Date(Date.now() - 86400000 - 3000000), // Yesterday
|
|
23
|
+
status: "read",
|
|
24
|
+
},
|
|
25
|
+
// Today's messages
|
|
26
|
+
{
|
|
27
|
+
id: "demo-1",
|
|
28
|
+
conversationId: "demo-conv",
|
|
29
|
+
senderId: "user-2",
|
|
30
|
+
content: "Good morning! How are you today? ๐",
|
|
31
|
+
type: "text",
|
|
32
|
+
timestamp: new Date(Date.now() - 3600000), // 1 hour ago
|
|
33
|
+
status: "read",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: "demo-2",
|
|
37
|
+
conversationId: "demo-conv",
|
|
38
|
+
senderId: "current-user",
|
|
39
|
+
content: "Morning! I'm doing great, thanks for asking!",
|
|
40
|
+
type: "text",
|
|
41
|
+
timestamp: new Date(Date.now() - 3000000), // 50 minutes ago
|
|
42
|
+
status: "read",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: "demo-3",
|
|
46
|
+
conversationId: "demo-conv",
|
|
47
|
+
senderId: "user-2",
|
|
48
|
+
content: "Check out these photos from my trip!",
|
|
49
|
+
type: "image",
|
|
50
|
+
timestamp: new Date(Date.now() - 2400000), // 40 minutes ago
|
|
51
|
+
status: "read",
|
|
52
|
+
attachments: [
|
|
53
|
+
{
|
|
54
|
+
id: "att-1",
|
|
55
|
+
name: "trip1.jpg",
|
|
56
|
+
url: "/placeholder.svg?height=300&width=400",
|
|
57
|
+
type: "image/jpeg",
|
|
58
|
+
size: 245760,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: "att-2",
|
|
62
|
+
name: "trip2.jpg",
|
|
63
|
+
url: "/placeholder.svg?height=300&width=400",
|
|
64
|
+
type: "image/jpeg",
|
|
65
|
+
size: 198432,
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: "demo-4",
|
|
71
|
+
conversationId: "demo-conv",
|
|
72
|
+
senderId: "current-user",
|
|
73
|
+
content: "Wow, those are amazing! ๐",
|
|
74
|
+
type: "text",
|
|
75
|
+
timestamp: new Date(Date.now() - 1800000), // 30 minutes ago
|
|
76
|
+
status: "read",
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: "demo-5",
|
|
80
|
+
conversationId: "demo-conv",
|
|
81
|
+
senderId: "user-2",
|
|
82
|
+
content: "Here's the document you requested",
|
|
83
|
+
type: "file",
|
|
84
|
+
timestamp: new Date(Date.now() - 1200000), // 20 minutes ago
|
|
85
|
+
status: "read",
|
|
86
|
+
attachments: [
|
|
87
|
+
{
|
|
88
|
+
id: "att-3",
|
|
89
|
+
name: "report.pdf",
|
|
90
|
+
url: "/placeholder.svg?height=200&width=200",
|
|
91
|
+
type: "application/pdf",
|
|
92
|
+
size: 1024000,
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: "demo-6",
|
|
98
|
+
conversationId: "demo-conv",
|
|
99
|
+
senderId: "current-user",
|
|
100
|
+
content: "Perfect, thanks! I'll review it this afternoon.",
|
|
101
|
+
type: "text",
|
|
102
|
+
timestamp: new Date(Date.now() - 600000), // 10 minutes ago
|
|
103
|
+
status: "read",
|
|
104
|
+
},
|
|
105
|
+
]);
|
|
106
|
+
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
|
107
|
+
const [hasMore, setHasMore] = useState(true);
|
|
108
|
+
const handleLoadMore = () => {
|
|
109
|
+
setIsLoadingMore(true);
|
|
110
|
+
// Simulate loading more messages
|
|
111
|
+
setTimeout(() => {
|
|
112
|
+
const olderMessages = [
|
|
113
|
+
{
|
|
114
|
+
id: `older-${Date.now()}-1`,
|
|
115
|
+
conversationId: "demo-conv",
|
|
116
|
+
senderId: "user-2",
|
|
117
|
+
content: "Hey, did you see the news today?",
|
|
118
|
+
type: "text",
|
|
119
|
+
timestamp: new Date(Date.now() - 172800000), // 2 days ago
|
|
120
|
+
status: "read",
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
id: `older-${Date.now()}-2`,
|
|
124
|
+
conversationId: "demo-conv",
|
|
125
|
+
senderId: "current-user",
|
|
126
|
+
content: "Yes! It's quite interesting.",
|
|
127
|
+
type: "text",
|
|
128
|
+
timestamp: new Date(Date.now() - 172800000 + 300000), // 2 days ago + 5 min
|
|
129
|
+
status: "read",
|
|
130
|
+
},
|
|
131
|
+
];
|
|
132
|
+
setMessages((prev) => [...olderMessages, ...prev]);
|
|
133
|
+
setIsLoadingMore(false);
|
|
134
|
+
// Simulate no more messages after a few loads
|
|
135
|
+
if (messages.length > 15) {
|
|
136
|
+
setHasMore(false);
|
|
137
|
+
}
|
|
138
|
+
}, 1500);
|
|
139
|
+
};
|
|
140
|
+
const handleSendMessage = (content) => {
|
|
141
|
+
const newMessage = {
|
|
142
|
+
id: `new-${Date.now()}`,
|
|
143
|
+
conversationId: "demo-conv",
|
|
144
|
+
senderId: "current-user",
|
|
145
|
+
content,
|
|
146
|
+
type: "text",
|
|
147
|
+
timestamp: new Date(),
|
|
148
|
+
status: "sending",
|
|
149
|
+
};
|
|
150
|
+
setMessages((prev) => [...prev, newMessage]);
|
|
151
|
+
// Simulate message status update
|
|
152
|
+
setTimeout(() => {
|
|
153
|
+
setMessages((prev) => prev.map((msg) => (msg.id === newMessage.id ? Object.assign(Object.assign({}, msg), { status: "delivered" }) : msg)));
|
|
154
|
+
}, 1000);
|
|
155
|
+
};
|
|
156
|
+
return (<div className="max-w-4xl mx-auto bg-white rounded-lg shadow-lg overflow-hidden h-[600px] flex flex-col">
|
|
157
|
+
<div className="bg-gray-50 p-4 border-b flex-shrink-0">
|
|
158
|
+
<h2 className="text-lg font-semibold text-gray-900">MessageList Demo</h2>
|
|
159
|
+
<p className="text-sm text-gray-600">Features: Auto-scroll, lazy loading, date grouping, sender grouping</p>
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<div className="flex-1 overflow-hidden">
|
|
163
|
+
<MessageList messages={messages} isLoadingMore={isLoadingMore} onLoadMore={handleLoadMore} hasMore={hasMore} currentUserId="current-user" conversationId="demo-conv"/>
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
{/* Simple input for testing */}
|
|
167
|
+
<div className="border-t p-4 flex-shrink-0">
|
|
168
|
+
<form onSubmit={(e) => {
|
|
169
|
+
e.preventDefault();
|
|
170
|
+
const input = e.currentTarget.elements.namedItem("message");
|
|
171
|
+
if (input.value.trim()) {
|
|
172
|
+
handleSendMessage(input.value.trim());
|
|
173
|
+
input.value = "";
|
|
174
|
+
}
|
|
175
|
+
}} className="flex space-x-2">
|
|
176
|
+
<input name="message" type="text" placeholder="Nhแบญp tin nhแบฏn ฤแป test auto-scroll..." className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"/>
|
|
177
|
+
<button type="submit" className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">
|
|
178
|
+
Gแปญi
|
|
179
|
+
</button>
|
|
180
|
+
</form>
|
|
181
|
+
</div>
|
|
182
|
+
</div>);
|
|
183
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"StickerPicker.d.ts","sourceRoot":"","sources":["../../src/components/StickerPicker.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,MAAM,OAAO,CAAA;AAezB,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAA;AA6CvD,eAAO,MAAM,aAAa,wGA8FzB,CAAA"}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { X, Search, Heart, Smile, Star, Zap, Coffee, Music, Gamepad2Icon as GameController2, Sparkles, } from "lucide-react";
|
|
5
|
+
const stickerCategories = {
|
|
6
|
+
popular: {
|
|
7
|
+
label: "Phแป biแบฟn",
|
|
8
|
+
icon: Star,
|
|
9
|
+
stickers: ["๐ฅ", "๐ฏ", "โจ", "โญ", "๐", "๐", "๐ซ", "๐", "โก"],
|
|
10
|
+
},
|
|
11
|
+
emotions: {
|
|
12
|
+
label: "Cแบฃm xรบc",
|
|
13
|
+
icon: Heart,
|
|
14
|
+
stickers: ["โค๏ธ", "๐", "๐", "๐", "๐", "๐", "๐", "๐", "โฅ๏ธ", "๐", "โฃ๏ธ", "๐", "๐", "๐", "๐น"],
|
|
15
|
+
},
|
|
16
|
+
reactions: {
|
|
17
|
+
label: "Phแบฃn แปฉng",
|
|
18
|
+
icon: Smile,
|
|
19
|
+
stickers: ["๐", "๐", "๐", "๐", "๐", "โ๏ธ", "๐ค", "๐ค", "๐ค", "๐", "โ", "๐ค", "๐ค", "๐", "๐ค"],
|
|
20
|
+
},
|
|
21
|
+
fun: {
|
|
22
|
+
label: "Vui nhแปn",
|
|
23
|
+
icon: Zap,
|
|
24
|
+
stickers: ["๐ฏ", "๐ช", "๐จ", "๐ญ", "๐ช", "๐ก", "๐ข", "๐ ", "๐ณ", "๐ฎ", "๐น๏ธ", "๐ฒ", "๐", "๐ด", "๐"],
|
|
25
|
+
},
|
|
26
|
+
food: {
|
|
27
|
+
label: "ฤแป ฤn",
|
|
28
|
+
icon: Coffee,
|
|
29
|
+
stickers: ["๐", "๐", "๐", "๐ญ", "๐ฅช", "๐ฎ", "๐ฏ", "๐ฅ", "๐ง", "๐ฅ", "๐ณ", "๐ฅ", "๐ฒ", "๐ฅ", "๐ฟ"],
|
|
30
|
+
},
|
|
31
|
+
activities: {
|
|
32
|
+
label: "Hoแบกt ฤแปng",
|
|
33
|
+
icon: GameController2,
|
|
34
|
+
stickers: ["โฝ", "๐", "๐", "โพ", "๐ฅ", "๐พ", "๐", "๐", "๐ฅ", "๐ฑ", "๐ช", "๐", "๐ธ", "๐", "๐"],
|
|
35
|
+
},
|
|
36
|
+
nature: {
|
|
37
|
+
label: "Thiรชn nhiรชn",
|
|
38
|
+
icon: Sparkles,
|
|
39
|
+
stickers: ["๐ธ", "๐บ", "๐ป", "๐ท", "๐น", "๐ฅ", "๐พ", "๐ฟ", "โ๏ธ", "๐", "๐", "๐ฑ", "๐ฒ", "๐ณ", "๐ด"],
|
|
40
|
+
},
|
|
41
|
+
music: {
|
|
42
|
+
label: "รm nhแบกc",
|
|
43
|
+
icon: Music,
|
|
44
|
+
stickers: ["๐ต", "๐ถ", "๐ผ", "๐น", "๐ฅ", "๐ท", "๐บ", "๐ธ", "๐ช", "๐ป", "๐ค", "๐ง", "๐ป", "๐๏ธ", "๐๏ธ"],
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
export const StickerPicker = React.forwardRef(({ onStickerSelect, onClose, isOpen = false, style }, ref) => {
|
|
48
|
+
const [activeCategory, setActiveCategory] = useState("popular");
|
|
49
|
+
const [searchTerm, setSearchTerm] = useState("");
|
|
50
|
+
if (!isOpen)
|
|
51
|
+
return null;
|
|
52
|
+
const handleStickerClick = (sticker) => {
|
|
53
|
+
onStickerSelect === null || onStickerSelect === void 0 ? void 0 : onStickerSelect(sticker);
|
|
54
|
+
};
|
|
55
|
+
const filteredStickers = searchTerm
|
|
56
|
+
? stickerCategories[activeCategory].stickers.filter((sticker) => sticker.includes(searchTerm))
|
|
57
|
+
: stickerCategories[activeCategory].stickers;
|
|
58
|
+
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
|
|
59
|
+
>
|
|
60
|
+
<div className="p-2">
|
|
61
|
+
{/* Category Icons Row - Compact */}
|
|
62
|
+
<div className="flex items-center justify-between mb-2 pb-1 border-b border-gray-100">
|
|
63
|
+
{Object.entries(stickerCategories).map(([key, category]) => {
|
|
64
|
+
const Icon = category.icon;
|
|
65
|
+
const isActive = activeCategory === key;
|
|
66
|
+
return (<button key={key} onClick={() => setActiveCategory(key)} className={`
|
|
67
|
+
p-1.5 rounded-full transition-all duration-200 hover:bg-gray-100
|
|
68
|
+
${isActive ? "text-purple-500 bg-purple-50" : "text-gray-500"}
|
|
69
|
+
`} title={category.label}>
|
|
70
|
+
<Icon className="w-4 h-4"/>
|
|
71
|
+
</button>);
|
|
72
|
+
})}
|
|
73
|
+
|
|
74
|
+
{/* Close button */}
|
|
75
|
+
<button onClick={onClose} className="p-1.5 text-gray-400 hover:text-gray-600 rounded-full hover:bg-gray-100">
|
|
76
|
+
<X className="w-4 h-4"/>
|
|
77
|
+
</button>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
{/* Search Bar - Compact */}
|
|
81
|
+
<div className="relative mb-2">
|
|
82
|
+
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 w-3 h-3 text-gray-400"/>
|
|
83
|
+
<input type="text" placeholder="Tรฌm sticker" 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-purple-500 focus:border-transparent"/>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
{/* Category Title - Compact */}
|
|
87
|
+
<div className="mb-2">
|
|
88
|
+
<h3 className="text-xs font-medium text-gray-900">{stickerCategories[activeCategory].label}</h3>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
{/* Sticker Grid - Compact */}
|
|
92
|
+
<div className="grid grid-cols-6 gap-1 max-h-48 overflow-y-auto">
|
|
93
|
+
{filteredStickers.map((sticker, index) => (<button key={index} onClick={() => handleStickerClick(sticker)} className="p-2 text-xl hover:bg-gray-100 rounded transition-all duration-200 hover:scale-110 active:scale-95 border border-gray-100 hover:border-gray-200" title={sticker}>
|
|
94
|
+
{sticker}
|
|
95
|
+
</button>))}
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
{/* Empty state */}
|
|
99
|
+
{filteredStickers.length === 0 && (<div className="text-center py-4 text-gray-500">
|
|
100
|
+
<div className="text-lg mb-1">๐</div>
|
|
101
|
+
<p className="text-xs">Khรดng tรฌm thแบฅy sticker nร o</p>
|
|
102
|
+
</div>)}
|
|
103
|
+
</div>
|
|
104
|
+
</div>);
|
|
105
|
+
});
|
|
106
|
+
StickerPicker.displayName = "StickerPicker";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
interface SwipeIndicatorProps {
|
|
2
|
+
show: boolean;
|
|
3
|
+
direction: "left" | "right";
|
|
4
|
+
text: string;
|
|
5
|
+
className?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function SwipeIndicator({ show, direction, text, className }: SwipeIndicatorProps): import("react").JSX.Element | null;
|
|
8
|
+
export {};
|
|
9
|
+
//# sourceMappingURL=SwipeIndicator.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SwipeIndicator.d.ts","sourceRoot":"","sources":["../../src/components/SwipeIndicator.tsx"],"names":[],"mappings":"AAIA,UAAU,mBAAmB;IAC3B,IAAI,EAAE,OAAO,CAAA;IACb,SAAS,EAAE,MAAM,GAAG,OAAO,CAAA;IAC3B,IAAI,EAAE,MAAM,CAAA;IACZ,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,wBAAgB,cAAc,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,SAAc,EAAE,EAAE,mBAAmB,sCAmC5F"}
|