diff --git a/eslint.config.js b/eslint.config.js index 86aaa04..46ada6e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -102,7 +102,7 @@ export default tseslint.config( ], // Disable the base ESLint rule as @typescript-eslint/no-unused-vars handles it better - "no-unused-vars": "warn", + "no-unused-vars": "off", // Prevent unused imports (catches imports that are never used) "no-unused-private-class-members": "error" diff --git a/src/components/screens-component/chat-screen/components/bubbles/chat-types.ts b/src/components/screens-component/chat-screen/components/bubbles/chat-types.ts index b89b34c..875e6bf 100644 --- a/src/components/screens-component/chat-screen/components/bubbles/chat-types.ts +++ b/src/components/screens-component/chat-screen/components/bubbles/chat-types.ts @@ -44,4 +44,10 @@ export type AudioMessage = MessageBase & { duration: number; }; -export type ChatMessage = TextMessage | CardMessage | QuickRepliesMessage | SystemMessage | AudioMessage; +export type ImageMessage = MessageBase & { + type: "image"; + imageUrl: string; + caption?: string; +}; + +export type ChatMessage = TextMessage | CardMessage | QuickRepliesMessage | SystemMessage | AudioMessage | ImageMessage; diff --git a/src/components/screens-component/chat-screen/components/bubbles/index.tsx b/src/components/screens-component/chat-screen/components/bubbles/index.tsx index aeb50ca..7385c0c 100644 --- a/src/components/screens-component/chat-screen/components/bubbles/index.tsx +++ b/src/components/screens-component/chat-screen/components/bubbles/index.tsx @@ -5,9 +5,7 @@ import { SystemBubble } from "./system-bubble"; import { TextBubble } from "./text-bubble"; import { MessageChrome } from "../message-chrome"; -/* eslint-disable no-unused-vars */ type BubbleProps = { message: ChatMessage; onQuickReply?: (payload: string) => void }; -/* eslint-enable no-unused-vars */ export function Bubble({ message }: BubbleProps) { @@ -45,6 +43,21 @@ export function Bubble({ message }: BubbleProps) { ); + case "image": + return ( + +
+ {message.caption + {message.caption && ( +

{message.caption}

+ )} +
+
+ ); default: return null; } diff --git a/src/components/screens-component/chat-screen/components/bubbles/quick-replies-bubble.tsx b/src/components/screens-component/chat-screen/components/bubbles/quick-replies-bubble.tsx index 671220e..567962e 100644 --- a/src/components/screens-component/chat-screen/components/bubbles/quick-replies-bubble.tsx +++ b/src/components/screens-component/chat-screen/components/bubbles/quick-replies-bubble.tsx @@ -1,12 +1,10 @@ import { Button } from "@/components/ui/button"; import { QuickRepliesMessage } from "./chat-types"; -/* eslint-disable no-unused-vars */ type QuickRepliesBubbleProps = { message: QuickRepliesMessage; onQuickReply?: (payload: string) => void; }; -/* eslint-enable no-unused-vars */ export function QuickRepliesBubble(props: QuickRepliesBubbleProps) { const { message, onQuickReply } = props; diff --git a/src/components/screens-component/chat-screen/components/chat-input.tsx b/src/components/screens-component/chat-screen/components/chat-input.tsx index 2a6c186..b725071 100644 --- a/src/components/screens-component/chat-screen/components/chat-input.tsx +++ b/src/components/screens-component/chat-screen/components/chat-input.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { Mic, X } from "lucide-react"; +import { Mic, X, Camera, ImageIcon } from "lucide-react"; import Lottie from "lottie-react"; import loadingAnim from "@/assets/Loading.json"; import sendIcon from "@/assets/send.svg"; @@ -7,17 +7,30 @@ import activeSend from "@/assets/activeSend.svg"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; import { RecordingControls } from "./recording-controls"; import { Suggestions } from "./suggestions"; import type { Suggestion } from "../api/suggestions-api"; import { useLanguage } from "@/components/LanguageProvider"; import { useAuth } from "@/contexts/AuthContext"; +import { useChatStore } from "@/hooks/store/chat"; import { environment } from "@/lib/config/environment"; +const MAX_CLIENT_IMAGE_SIZE_BYTES = 10 * 1024 * 1024; +const ACCEPTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/jpg"]); + export type ChatInputPayload = { text: string; files: File[]; + mode?: "chat" | "pest_api"; voice?: Blob | null; duration?: number; }; @@ -35,7 +48,6 @@ export type ChatInputProps = { footerNote?: string; isListening?: boolean; isTranscribing?: boolean; - isAssistantTyping?: boolean; suggestions?: Suggestion[]; onSuggestionClick?: (text: string) => void; }; @@ -53,14 +65,16 @@ export function ChatInput({ footerNote, isListening, isTranscribing, - isAssistantTyping, suggestions = [], onSuggestionClick }: ChatInputProps) { const { t } = useLanguage(); const { user } = useAuth(); + const setToast = useChatStore((s) => s.setToast); const isUnauthenticated = !user; - const [files, setFiles] = useState([]); + const [isPestDialogOpen, setIsPestDialogOpen] = useState(false); + const [pestImage, setPestImage] = useState(null); + const [pestImagePreview, setPestImagePreview] = useState(null); const [voice, setVoice] = useState(null); const [recordingState, setRecordingState] = useState<"idle" | "recording" | "paused">("idle"); const [recordingDuration, setRecordingDuration] = useState(0); @@ -70,12 +84,14 @@ export function ChatInput({ const chunksRef = useRef([]); const timerRef = useRef(null); - const fileInputRef = useRef(null); + const pestCameraInputRef = useRef(null); + const pestGalleryInputRef = useRef(null); const taRef = useRef(null); - const canSend = useMemo(() => value.trim().length > 0 || files.length > 0 || !!voice, [value, files, voice]); - const isLoading = isTranscribing || isAssistantTyping; - const maxLength = environment.chatMessageMaxLength; + const canSend = useMemo(() => value.trim().length > 0 || !!voice, [value, voice]); + const isLoading = isTranscribing || Boolean(disabled); + const isPestSubmitDisabled = disabled || isLoading || isUnauthenticated || !pestImage; + const maxLength = environment.chatMessageMaxLength ?? 4000; const charCount = value.length; const isNearLimit = charCount >= maxLength * 0.8; const isAtLimit = charCount >= maxLength; @@ -87,13 +103,28 @@ export function ChatInput({ if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") { mediaRecorderRef.current.stream.getTracks().forEach((t) => t.stop()); } + if (pestImagePreview) { + URL.revokeObjectURL(pestImagePreview); + } }; - }, []); + }, [pestImagePreview]); useEffect(() => { onTypingChange?.(value.trim().length > 0); - }, [value]); + }, [onTypingChange, value]); + + useEffect(() => { + if (!isPestDialogOpen) { + setPestImage(null); + setPestImagePreview((currentPreview) => { + if (currentPreview) { + URL.revokeObjectURL(currentPreview); + } + return null; + }); + } + }, [isPestDialogOpen]); async function startRecording() { try { @@ -192,25 +223,83 @@ export function ChatInput({ }, 50); } - function onFilesPicked(e: React.ChangeEvent) { - const list = e.target.files ? Array.from(e.target.files) : []; - if (list.length) setFiles((prev) => [...prev, ...list]); - e.target.value = ""; + function openPestApiPicker() { + pestCameraInputRef.current?.click(); } - function removeFile(idx: number) { - setFiles((prev) => prev.filter((_, i) => i !== idx)); + function openPestGalleryPicker() { + pestGalleryInputRef.current?.click(); } function clearVoice() { setVoice(null); } + function setPestFile(file: File) { + setPestImage(file); + setPestImagePreview((currentPreview) => { + if (currentPreview) { + URL.revokeObjectURL(currentPreview); + } + return URL.createObjectURL(file); + }); + } + + function onPestFilePicked(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + if (!validateSelectedImage(file)) { + e.target.value = ""; + return; + } + setPestFile(file); + e.target.value = ""; + } + + function validateSelectedImage(file: File) { + const fileType = file.type.toLowerCase(); + const validType = ACCEPTED_IMAGE_TYPES.has(fileType); + if (!validType) { + setToast({ + message: t("imageUpload.invalidFormat") as string, + type: "error" + }); + return false; + } + + if (file.size > MAX_CLIENT_IMAGE_SIZE_BYTES) { + setToast({ + message: t("imageUpload.imageTooLarge") as string, + type: "error" + }); + return false; + } + + return true; + } + + function removePestFile() { + setPestImage(null); + setPestImagePreview((currentPreview) => { + if (currentPreview) { + URL.revokeObjectURL(currentPreview); + } + return null; + }); + } + + function submitPestImage() { + if (!pestImage || isPestSubmitDisabled) return; + + const selectedImage = pestImage; + setIsPestDialogOpen(false); + onSend({ text: "", files: [selectedImage], mode: "pest_api" }); + } + function submit() { if (!canSend || disabled || isLoading) return; - onSend({ text: value.trim(), files, voice }); + onSend({ text: value.trim(), files: [], voice, mode: "chat" }); onValueChange(""); - setFiles([]); setVoice(null); } @@ -241,6 +330,103 @@ export function ChatInput({ return (
+ + + +
+ {t("pestApi.title") as string} + + {t("pestApi.description") as string} + +
+
+
+
+ {t("pestApi.note") as string} +
+ +
+

+ {t("pestApi.photoLabel") as string} +

+ {pestImagePreview ? ( +
+ {pestImage?.name + +
+ ) : ( +
+
+ + +
+

+ {t("imageUpload.imageFormatHint") as string} +

+
+ )} +
+ + + +
+ + + + +
+
@@ -249,83 +435,67 @@ export function ChatInput({ onSuggestionClick={(text) => onSuggestionClick?.(text)} className="mb-2" /> - {/* Attachment/voice preview row */} - {(files.length > 0 || voice) && ( + {/* Voice preview row */} + {voice && (
- {files.map((f, idx) => ( - - {f.name} - - - ))} - - {voice && ( - - Voice message - - - )} + + Voice message + +
)} -
-
- {micHint && !value.trim() ? ( -
-
- {t("chatMicHint")} -
-
- +
+
+ {micHint && !value.trim() ? ( +
+
+ {t("chatMicHint")} +
- ) : null} - -
+ +
+ ) : null} + +
)} -