Files
frontend/src/components/message/MessageInput.tsx

430 lines
14 KiB
TypeScript
Raw Normal View History

'use client';
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, 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(() => {
if (!isTypingRef.current && onTypingStart) {
isTypingRef.current = true;
onTypingStart();
}
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
typingTimeoutRef.current = setTimeout(() => {
if (isTypingRef.current && onTypingStop) {
isTypingRef.current = false;
onTypingStop();
}
}, 2000);
}, [onTypingStart, onTypingStop]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
if (isTypingRef.current && onTypingStop) {
onTypingStop();
}
};
}, [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) {
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
if (isTypingRef.current && onTypingStop) {
isTypingRef.current = false;
onTypingStop();
}
onSend(message.trim());
setMessage('');
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (!isUploading) handleSend();
}
};
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setMessage(e.target.value);
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}
placeholder="Write a Message........"
className="w-full min-h-[120px] resize-none font-serif font-normal text-[14px] leading-[19px] text-[#00293D] placeholder-[#00293D]/50 focus:outline-none"
/>
</div>
{/* Action bar */}
<div className="flex items-center justify-between">
{/* Attachment buttons */}
<div className="flex items-center gap-3">
<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"
width={24}
height={24}
/>
</button>
<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="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 w-[min(360px,calc(100vw-32px))] h-[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={typeof window !== 'undefined' ? Math.min(356, window.innerWidth - 40) : 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={!canSend || isUploading}
className={`px-4 py-1.5 font-serif font-normal text-[14px] leading-[19px] rounded-[15px] transition-colors cursor-pointer ${
canSend && !isUploading
? 'bg-[#e58625] text-[#00293d] hover:bg-[#d47720]'
: 'bg-[#e58625]/50 text-[#00293d]/50 cursor-not-allowed'
}`}
>
{isUploading ? 'Uploading...' : 'Send'}
</button>
</div>
</div>
</div>
);
}