feat: Implement rich media messaging for images, files, and GIFs, and add message read/delivery status indicators.
This commit is contained in:
@@ -2,32 +2,55 @@
|
||||
|
||||
import Image from 'next/image';
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import data from '@emoji-mart/data';
|
||||
import Picker from '@emoji-mart/react';
|
||||
import { GiphyFetch } from '@giphy/js-fetch-api';
|
||||
import { Grid } from '@giphy/react-components';
|
||||
|
||||
const gf = new GiphyFetch(process.env.NEXT_PUBLIC_GIPHY_API_KEY || '');
|
||||
|
||||
interface AttachedFile {
|
||||
file: File;
|
||||
preview?: string; // Object URL for image preview
|
||||
messageType: 'IMAGE' | 'FILE';
|
||||
}
|
||||
|
||||
interface MessageInputProps {
|
||||
onSend?: (message: string) => void;
|
||||
onSendGif?: (gifUrl: string) => void;
|
||||
onSendFile?: (file: File, messageType: 'IMAGE' | 'FILE') => void;
|
||||
onTypingStart?: () => void;
|
||||
onTypingStop?: () => void;
|
||||
isUploading?: boolean;
|
||||
}
|
||||
|
||||
export function MessageInput({ onSend, onTypingStart, onTypingStop }: MessageInputProps) {
|
||||
export function MessageInput({ onSend, onSendGif, onSendFile, onTypingStart, onTypingStop, isUploading }: MessageInputProps) {
|
||||
const [message, setMessage] = useState('');
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
const [showGifPicker, setShowGifPicker] = useState(false);
|
||||
const [gifSearchQuery, setGifSearchQuery] = useState('');
|
||||
const [attachedFile, setAttachedFile] = useState<AttachedFile | null>(null);
|
||||
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isTypingRef = useRef(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const emojiPickerRef = useRef<HTMLDivElement>(null);
|
||||
const gifPickerRef = useRef<HTMLDivElement>(null);
|
||||
const emojiButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const gifButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Handle typing indicator
|
||||
const handleTyping = useCallback(() => {
|
||||
// Start typing indicator
|
||||
if (!isTypingRef.current && onTypingStart) {
|
||||
isTypingRef.current = true;
|
||||
onTypingStart();
|
||||
}
|
||||
|
||||
// Clear existing timeout
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Set timeout to stop typing indicator after 2 seconds of inactivity
|
||||
typingTimeoutRef.current = setTimeout(() => {
|
||||
if (isTypingRef.current && onTypingStop) {
|
||||
isTypingRef.current = false;
|
||||
@@ -48,9 +71,90 @@ export function MessageInput({ onSend, onTypingStart, onTypingStop }: MessageInp
|
||||
};
|
||||
}, [onTypingStop]);
|
||||
|
||||
// Close pickers on click outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
showEmojiPicker &&
|
||||
emojiPickerRef.current &&
|
||||
!emojiPickerRef.current.contains(e.target as Node) &&
|
||||
emojiButtonRef.current &&
|
||||
!emojiButtonRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setShowEmojiPicker(false);
|
||||
}
|
||||
if (
|
||||
showGifPicker &&
|
||||
gifPickerRef.current &&
|
||||
!gifPickerRef.current.contains(e.target as Node) &&
|
||||
gifButtonRef.current &&
|
||||
!gifButtonRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setShowGifPicker(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [showEmojiPicker, showGifPicker]);
|
||||
|
||||
// Clean up object URL on unmount or when file changes
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (attachedFile?.preview) {
|
||||
URL.revokeObjectURL(attachedFile.preview);
|
||||
}
|
||||
};
|
||||
}, [attachedFile]);
|
||||
|
||||
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const preview = URL.createObjectURL(file);
|
||||
setAttachedFile({ file, preview, messageType: 'IMAGE' });
|
||||
// Reset input so same file can be selected again
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setAttachedFile({ file, messageType: 'FILE' });
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const removeAttachment = () => {
|
||||
if (attachedFile?.preview) {
|
||||
URL.revokeObjectURL(attachedFile.preview);
|
||||
}
|
||||
setAttachedFile(null);
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const handleSend = () => {
|
||||
// Send attached file
|
||||
if (attachedFile && onSendFile) {
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
}
|
||||
if (isTypingRef.current && onTypingStop) {
|
||||
isTypingRef.current = false;
|
||||
onTypingStop();
|
||||
}
|
||||
|
||||
onSendFile(attachedFile.file, attachedFile.messageType);
|
||||
removeAttachment();
|
||||
setMessage('');
|
||||
return;
|
||||
}
|
||||
|
||||
// Send text message
|
||||
if (message.trim() && onSend) {
|
||||
// Stop typing indicator before sending
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
}
|
||||
@@ -67,7 +171,7 @@ export function MessageInput({ onSend, onTypingStart, onTypingStop }: MessageInp
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
if (!isUploading) handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -76,11 +180,122 @@ export function MessageInput({ onSend, onTypingStart, onTypingStop }: MessageInp
|
||||
handleTyping();
|
||||
};
|
||||
|
||||
const handleEmojiSelect = (emoji: { native: string }) => {
|
||||
const textarea = textareaRef.current;
|
||||
if (textarea) {
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const newMessage = message.slice(0, start) + emoji.native + message.slice(end);
|
||||
setMessage(newMessage);
|
||||
// Restore cursor position after emoji
|
||||
setTimeout(() => {
|
||||
textarea.selectionStart = textarea.selectionEnd = start + emoji.native.length;
|
||||
textarea.focus();
|
||||
}, 0);
|
||||
} else {
|
||||
setMessage((prev) => prev + emoji.native);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGifSelect = (gif: { images: { original: { url: string } } }, e: React.SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
const gifUrl = gif.images.original.url;
|
||||
if (onSendGif) {
|
||||
onSendGif(gifUrl);
|
||||
}
|
||||
setShowGifPicker(false);
|
||||
setGifSearchQuery('');
|
||||
};
|
||||
|
||||
const toggleEmojiPicker = () => {
|
||||
setShowEmojiPicker((prev) => !prev);
|
||||
setShowGifPicker(false);
|
||||
};
|
||||
|
||||
const toggleGifPicker = () => {
|
||||
setShowGifPicker((prev) => !prev);
|
||||
setShowEmojiPicker(false);
|
||||
};
|
||||
|
||||
const fetchGifs = useCallback(
|
||||
(offset: number) => {
|
||||
if (gifSearchQuery.trim()) {
|
||||
return gf.search(gifSearchQuery, { offset, limit: 10 });
|
||||
}
|
||||
return gf.trending({ offset, limit: 10 });
|
||||
},
|
||||
[gifSearchQuery]
|
||||
);
|
||||
|
||||
const canSend = message.trim() || attachedFile;
|
||||
|
||||
return (
|
||||
<div className="border border-[#00293d]/10 rounded-[20px] bg-white p-4">
|
||||
{/* Hidden file inputs */}
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
onChange={handleImageSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pdf,.doc,.docx,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Attachment preview area */}
|
||||
{attachedFile && (
|
||||
<div className="border border-[#00293d]/10 rounded-[15px] p-3 mb-3 bg-[#00293d]/[0.02]">
|
||||
<div className="flex items-center gap-3">
|
||||
{attachedFile.messageType === 'IMAGE' && attachedFile.preview ? (
|
||||
<img
|
||||
src={attachedFile.preview}
|
||||
alt="Preview"
|
||||
className="w-[80px] h-[80px] object-cover rounded-[10px]"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-[50px] h-[50px] bg-[#e58625]/10 rounded-[10px] flex items-center justify-center flex-shrink-0">
|
||||
<Image
|
||||
src="/assets/icons/attachment-icon.svg"
|
||||
alt="File"
|
||||
width={22}
|
||||
height={12}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-serif font-normal text-[14px] leading-[19px] text-[#00293D] truncate">
|
||||
{attachedFile.file.name}
|
||||
</p>
|
||||
<p className="font-serif font-normal text-[12px] leading-[16px] text-[#00293D]/50">
|
||||
{formatFileSize(attachedFile.file.size)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={removeAttachment}
|
||||
className="p-1 hover:bg-gray-100 rounded-full transition-colors cursor-pointer flex-shrink-0"
|
||||
title="Remove attachment"
|
||||
>
|
||||
<Image
|
||||
src="/assets/icons/close-icon.svg"
|
||||
alt="Remove"
|
||||
width={20}
|
||||
height={20}
|
||||
className="opacity-50"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text input area */}
|
||||
<div className="border border-[#00293d]/10 rounded-[20px] p-4 mb-3">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={message}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -93,7 +308,11 @@ export function MessageInput({ onSend, onTypingStart, onTypingStop }: MessageInp
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Attachment buttons */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer">
|
||||
<button
|
||||
onClick={() => imageInputRef.current?.click()}
|
||||
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer"
|
||||
title="Attach image"
|
||||
>
|
||||
<Image
|
||||
src="/assets/icons/gallery-icon.svg"
|
||||
alt="Add image"
|
||||
@@ -101,44 +320,107 @@ export function MessageInput({ onSend, onTypingStart, onTypingStop }: MessageInp
|
||||
height={24}
|
||||
/>
|
||||
</button>
|
||||
<button className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer">
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer"
|
||||
title="Attach file"
|
||||
>
|
||||
<Image
|
||||
src="/assets/icons/chain-icon.svg"
|
||||
alt="Add link"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
</button>
|
||||
<button className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer">
|
||||
<Image
|
||||
src="/assets/icons/gif-icon.svg"
|
||||
alt="Add GIF"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
</button>
|
||||
<button className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer">
|
||||
<Image
|
||||
src="/assets/icons/emoji-icon.svg"
|
||||
alt="Add emoji"
|
||||
alt="Attach file"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* GIF button with picker */}
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={gifButtonRef}
|
||||
onClick={toggleGifPicker}
|
||||
className={`p-1.5 rounded-lg transition-colors cursor-pointer ${showGifPicker ? 'bg-[#e58625]/20' : 'hover:bg-gray-100'}`}
|
||||
>
|
||||
<Image
|
||||
src="/assets/icons/gif-icon.svg"
|
||||
alt="Add GIF"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
</button>
|
||||
{showGifPicker && (
|
||||
<div
|
||||
ref={gifPickerRef}
|
||||
className="absolute bottom-full left-0 mb-2 z-50 bg-white border border-[#00293d]/10 rounded-[15px] shadow-lg overflow-hidden"
|
||||
style={{ width: '360px', height: '420px' }}
|
||||
>
|
||||
<div className="p-3 border-b border-[#00293d]/10">
|
||||
<input
|
||||
type="text"
|
||||
value={gifSearchQuery}
|
||||
onChange={(e) => setGifSearchQuery(e.target.value)}
|
||||
placeholder="Search GIFs..."
|
||||
className="w-full px-3 py-2 border border-[#00293d]/10 rounded-[10px] font-serif text-[14px] text-[#00293D] placeholder-[#00293D]/50 focus:outline-none focus:border-[#e58625]"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-y-auto" style={{ height: 'calc(100% - 56px)' }}>
|
||||
<Grid
|
||||
key={gifSearchQuery}
|
||||
fetchGifs={fetchGifs}
|
||||
width={356}
|
||||
columns={2}
|
||||
gutter={6}
|
||||
noLink
|
||||
onGifClick={handleGifSelect}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-2 right-3 bg-white/80 rounded px-1.5 py-0.5">
|
||||
<span className="font-serif text-[10px] text-[#00293D]/40">Powered by GIPHY</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Emoji button with picker */}
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={emojiButtonRef}
|
||||
onClick={toggleEmojiPicker}
|
||||
className={`p-1.5 rounded-lg transition-colors cursor-pointer ${showEmojiPicker ? 'bg-[#e58625]/20' : 'hover:bg-gray-100'}`}
|
||||
>
|
||||
<Image
|
||||
src="/assets/icons/emoji-icon.svg"
|
||||
alt="Add emoji"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
</button>
|
||||
{showEmojiPicker && (
|
||||
<div ref={emojiPickerRef} className="absolute bottom-full left-0 mb-2 z-50">
|
||||
<Picker
|
||||
data={data}
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
theme="light"
|
||||
previewPosition="none"
|
||||
skinTonePosition="search"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Send button and more options */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!message.trim()}
|
||||
disabled={!canSend || isUploading}
|
||||
className={`px-4 py-1.5 font-serif font-normal text-[14px] leading-[19px] rounded-[15px] transition-colors cursor-pointer ${
|
||||
message.trim()
|
||||
canSend && !isUploading
|
||||
? 'bg-[#e58625] text-[#00293d] hover:bg-[#d47720]'
|
||||
: 'bg-[#e58625]/50 text-[#00293d]/50 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
Send
|
||||
{isUploading ? 'Uploading...' : 'Send'}
|
||||
</button>
|
||||
<button className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer">
|
||||
<Image
|
||||
|
||||
Reference in New Issue
Block a user