looood / src /components /message-input.tsx
looda3131's picture
Clean push without any binary history
cc276cc
"use client";
import { useState, useRef, useEffect } from 'react';
import { Button } from './ui/button';
import { Paperclip, Send, Mic, X, Smile } from 'lucide-react';
import { CSSTransition } from 'react-transition-group';
import { VoiceRecorder } from './voice-recorder';
import type { ReplyTo, Message, Group, UserProfile } from '@/lib/types';
import { useSettings } from '@/contexts/settings-context';
import { useAuth } from '@/contexts/auth-context';
import { useChatUtils } from '@/contexts/chat-utils-context';
import { useAppContext } from '@/contexts/app-context';
import { Skeleton } from './ui/skeleton';
// Helper function to extract plain text and emoji data from the contentEditable div
const getMessageFromDiv = (div: HTMLDivElement): string => {
let message = '';
div.childNodes.forEach(node => {
if (node.nodeType === Node.TEXT_NODE) {
message += node.textContent;
} else if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === 'IMG') {
const imgElement = node as HTMLImageElement;
// Use a specific character or sequence to represent the emoji,
// which can be parsed on the receiving end.
// Here, we use the 'alt' attribute which should contain the native emoji character.
message += imgElement.alt;
}
});
return message;
};
interface MessageInputProps {
chatId: string | null;
disabled?: boolean;
lastMessage: Message | null;
isGroupChat?: boolean;
currentGroup?: Group;
onGetSuggestedReplies: (message: Message) => Promise<string[]>;
sendTextMessage: (text: string, replyTo?: ReplyTo | null) => void;
onSelectMedia: (file: File) => void;
onSendAudio: (data: { file: File, duration: number }) => void;
editorRef: React.RefObject<HTMLDivElement>;
onToggleEmojiPicker: () => void;
isEmojiPickerOpen: boolean;
}
const QuotedMessagePreview = ({ replyTo, onCancel }: { replyTo: ReplyTo | null, onCancel: () => void }) => {
const nodeRef = useRef(null);
return (
<CSSTransition nodeRef={nodeRef} in={!!replyTo} timeout={200} classNames="reply-bar" unmountOnExit>
<div ref={nodeRef} className="bg-muted/70 px-4 pt-2 pb-1 border-b">
<div className="bg-background/50 rounded-lg p-2 flex justify-between items-center border-l-4 border-primary">
{replyTo && (
<>
<div>
<p className="font-bold text-primary text-sm">{replyTo.displayName}</p>
<p className="text-sm text-muted-foreground truncate quoted-message-content">
{replyTo.text || (replyTo.imageKey ? 'Image' : replyTo.videoKey ? 'Video' : 'Voice Message')}
</p>
</div>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onCancel}>
<X className="h-4 w-4" />
</Button>
</>
)}
</div>
</div>
</CSSTransition>
);
};
const PrivateReplyPreview = ({ privateReplyTo, onCancel }: { privateReplyTo: UserProfile | null, onCancel: () => void }) => {
const nodeRef = useRef(null);
return (
<CSSTransition nodeRef={nodeRef} in={!!privateReplyTo} timeout={200} classNames="reply-bar" unmountOnExit>
<div ref={nodeRef} className="bg-primary/10 px-4 pt-2 pb-1 border-b border-primary/20">
<div className="rounded-lg p-2 flex justify-between items-center">
{privateReplyTo && (
<>
<div className="flex items-center gap-2">
{/* <Lock className="h-4 w-4 text-primary" /> */}
<div>
<p className="font-bold text-primary text-sm">Private message to {privateReplyTo.displayName}</p>
</div>
</div>
<Button variant="ghost" size="icon" className="h-7 w-7 text-primary" onClick={onCancel}>
<X className="h-4 w-4" />
</Button>
</>
)}
</div>
</div>
</CSSTransition>
);
};
export function MessageInput({
sendTextMessage,
onSelectMedia,
onSendAudio,
chatId,
disabled,
lastMessage,
onGetSuggestedReplies,
isGroupChat,
currentGroup,
editorRef,
onToggleEmojiPicker,
isEmojiPickerOpen,
}: MessageInputProps) {
const { currentUser } = useAuth();
const { setUserTyping } = useChatUtils();
const { replyTo, setReplyTo, privateReplyTo, setPrivateReplyTo } = useAppContext();
const { playSound, t } = useSettings();
const [isRecording, setIsRecording] = useState(false);
const [hasText, setHasText] = useState(false);
const [suggestedReplies, setSuggestedReplies] = useState<string[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleSend = () => {
if (!editorRef.current) return;
const message = getMessageFromDiv(editorRef.current);
if (message.trim()) {
playSound('send');
sendTextMessage(message, replyTo);
editorRef.current.innerHTML = '';
setReplyTo(null);
setHasText(false);
}
};
useEffect(() => {
const fetchSuggestions = async () => {
if (lastMessage && lastMessage.sender !== currentUser?.uid && lastMessage.text) {
setShowSuggestions(true);
setSuggestedReplies([]); // Clear old suggestions immediately
const replies = await onGetSuggestedReplies(lastMessage);
setSuggestedReplies(replies);
} else {
setShowSuggestions(false);
setSuggestedReplies([]);
}
};
// Only fetch suggestions if the last message changes
if (lastMessage?.id) {
fetchSuggestions();
}
}, [lastMessage?.id, lastMessage?.sender, lastMessage?.text, currentUser?.uid, onGetSuggestedReplies]);
const handleInputChange = (e: React.FormEvent<HTMLDivElement>) => {
if (chatId) {
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
else setUserTyping(chatId, true);
typingTimeoutRef.current = setTimeout(() => {
setUserTyping(chatId, false);
typingTimeoutRef.current = null;
}, 2000);
}
setHasText(!!e.currentTarget.textContent?.trim() || e.currentTarget.getElementsByTagName('img').length > 0);
};
const handleSendSuggestion = (reply: string) => {
sendTextMessage(reply, null);
setShowSuggestions(false);
}
const handleKeyPress = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
handleSend();
}
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
onSelectMedia(file);
}
// Reset the input value to allow selecting the same file again
e.target.value = '';
};
const handleFocus = () => {
if (isEmojiPickerOpen) {
editorRef.current?.blur();
}
};
if (disabled) {
return (
<div className="p-4 border-t text-center text-sm text-muted-foreground">
{t('messagingDisabled')}
</div>
);
}
const sendingMode = currentGroup?.info?.settings?.sendingMode || 'everyone';
const isMuted = isGroupChat && currentGroup?.info.mutedMembers?.[currentUser?.uid || ''];
const canSend = !isGroupChat || (
(sendingMode === 'everyone' && !isMuted) ||
(sendingMode === 'admins' && currentGroup?.admins[currentUser?.uid || '']) ||
(sendingMode === 'owner' && currentGroup?.info.createdBy === currentUser?.uid)
);
if (!canSend) {
return (
<div className="p-4 border-t text-center text-sm text-muted-foreground">
<p>{isMuted ? t('youAreMuted') : t('adminsCanSend')}</p>
</div>
);
}
if (isRecording) {
return <VoiceRecorder onCancel={() => setIsRecording(false)} onSend={onSendAudio} />;
}
return (
<div className="flex flex-col border-t bg-background/80 backdrop-blur-sm">
<PrivateReplyPreview privateReplyTo={privateReplyTo} onCancel={() => setPrivateReplyTo(null)} />
<QuotedMessagePreview replyTo={replyTo} onCancel={() => setReplyTo(null)} />
{showSuggestions && (
<div className="p-2 border-b">
<div className="flex gap-2 overflow-x-auto pb-2">
{suggestedReplies.length > 0 ? (
suggestedReplies.map((reply, i) => (
<Button key={i} variant="outline" size="sm" className="flex-shrink-0" onClick={() => handleSendSuggestion(reply)}>
{reply}
</Button>
))
) : (
Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-9 w-24 rounded-md" />)
)}
<Button variant="ghost" size="icon" className="h-9 w-9 flex-shrink-0" onClick={() => setShowSuggestions(false)}><X className="h-4 w-4"/></Button>
</div>
</div>
)}
<div className="flex items-start gap-2 p-2 md:p-4">
<input type="file" id="file-upload" className="hidden" onChange={handleFileSelect} accept="image/*,video/*" />
<Button asChild variant="ghost" size="icon" title="Attach file">
<label htmlFor="file-upload" className="cursor-pointer">
<Paperclip />
<span className="sr-only">Attach file</span>
</label>
</Button>
<Button variant="ghost" size="icon" title="Add emoji" onClick={onToggleEmojiPicker}>
<Smile />
<span className="sr-only">Add emoji</span>
</Button>
<div className="rich-input-container flex-1">
<div
ref={editorRef}
contentEditable="true"
onInput={handleInputChange}
onKeyDown={handleKeyPress}
onFocus={handleFocus}
data-placeholder={t('typeMessage')}
className="rich-input-editor"
/>
</div>
{hasText ? (
<Button onClick={handleSend} size="icon" className="rounded-full h-10 w-10 flex-shrink-0" title="Send message">
<Send />
<span className="sr-only">Send</span>
</Button>
) : (
<Button onClick={() => { playSound('touch'); setIsRecording(true); }} size="icon" className="rounded-full h-10 w-10 flex-shrink-0" title="Record voice message">
<Mic />
<span className="sr-only">Record</span>
</Button>
)}
</div>
</div>
);
}