| | "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> |
| | ); |
| | } |
| | |