Invalid JSON: Unexpected non-whitespace character after JSON
at line 1, column 13
| "use client"; | |
| import { useState, useRef, useEffect } from 'react'; | |
| import { Button } from './ui/button'; | |
| import { Paperclip, Send, Smile, Mic, X } from 'lucide-react'; | |
| import { CSSTransition } from 'react-transition-group'; | |
| import { VoiceRecorder } from './voice-recorder'; | |
| import type { ReplyTo, Message, Group } 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; | |
| onToggleEmojiPicker: () => void; | |
| editorRef: React.RefObject<HTMLDivElement>; | |
| } | |
| 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> | |
| ); | |
| }; | |
| export function MessageInput({ | |
| sendTextMessage, | |
| onSelectMedia, | |
| onSendAudio, | |
| chatId, | |
| disabled, | |
| lastMessage, | |
| onGetSuggestedReplies, | |
| isGroupChat, | |
| currentGroup, | |
| onToggleEmojiPicker, | |
| editorRef, | |
| }: MessageInputProps) { | |
| const { currentUser } = useAuth(); | |
| const { setUserTyping } = useChatUtils(); | |
| const { replyTo, setReplyTo } = useAppContext(); | |
| const { playSound, t } = useSettings(); | |
| const [isRecording, setIsRecording] = 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); | |
| } | |
| }; | |
| 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); | |
| } | |
| }; | |
| 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 = ''; | |
| }; | |
| 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"> | |
| <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} | |
| data-placeholder={t('typeMessage')} | |
| className="rich-input-editor" | |
| /> | |
| </div> | |
| {editorRef.current && editorRef.current.textContent?.trim() ? ( | |
| <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> | |
| ); | |
| } |