| "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'; |
|
|
| |
| 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; |
| |
| |
| |
| 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([]); |
| const replies = await onGetSuggestedReplies(lastMessage); |
| setSuggestedReplies(replies); |
| } else { |
| setShowSuggestions(false); |
| setSuggestedReplies([]); |
| } |
| }; |
|
|
| |
| 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); |
| } |
| |
| 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> |
| ); |
| } |
| |