/** * ========================================================================= * PLATEFORME ECO-PANNEAU.FR - VERSION 1.0.0 * Composants : Messagerie Interne Pro (Architecture Classique et Memo) * Gère jusqu'à 20 pièces jointes par message. * ========================================================================= */ const { useState, useEffect, useRef, memo, useCallback } = React; const { MessageSquare, ArrowUp, ArrowDown, Loader, AlertTriangle, Image, FileText, X, ArrowLeft, Download } = window; // ------------------------------------------------------------------------- // UTILITAIRE PDF.JS DYNAMIQUE (Charge une seule fois la librairie) // ------------------------------------------------------------------------- let pdfJsLoaded = false; let pdfJsLoadingPromise = null; const loadPdfJs = () => { if (pdfJsLoaded) { return Promise.resolve(); } if (pdfJsLoadingPromise) { return pdfJsLoadingPromise; } pdfJsLoadingPromise = new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js'; script.onload = () => { window.pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js'; pdfJsLoaded = true; resolve(); }; script.onerror = reject; document.head.appendChild(script); }); return pdfJsLoadingPromise; }; // Composant miniature PDF (Avec File d'attente intelligente en arrière-plan) const PdfThumbnail = memo(({ url }) => { const containerRef = useRef(null); const canvasRef = useRef(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(false); const [isVisible, setIsVisible] = useState(false); // 1. Détection de visibilité et File d'attente d'arrière-plan useEffect(() => { let isMounted = true; let observerDisposed = false; const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) { if (isMounted) setIsVisible(true); observer.disconnect(); observerDisposed = true; } }, { rootMargin: '300px' } ); if (containerRef.current) { observer.observe(containerRef.current); } // Gestionnaire de file d'attente pour charger les PDF hors-champ if (!window.pdfIdleQueue) window.pdfIdleQueue = []; const queueItem = () => { if (isMounted && !observerDisposed) { setIsVisible(true); observer.disconnect(); observerDisposed = true; } }; window.pdfIdleQueue.push(queueItem); // Démarre le processeur de fond s'il n'est pas actif if (!window.pdfIdleProcessorStarted) { window.pdfIdleProcessorStarted = true; const processIdleQueue = () => { if (window.pdfIdleQueue && window.pdfIdleQueue.length > 0) { const task = window.pdfIdleQueue.shift(); task(); } // Exécute 1 rendu caché toutes les 800ms pour ne pas surcharger le CPU setTimeout(processIdleQueue, 800); }; // Attendre 3 secondes (laisser la priorité absolue aux messages visibles) setTimeout(processIdleQueue, 3000); } return () => { isMounted = false; observer.disconnect(); observerDisposed = true; if (window.pdfIdleQueue) { const idx = window.pdfIdleQueue.indexOf(queueItem); if (idx > -1) window.pdfIdleQueue.splice(idx, 1); } }; }, []); // 2. Rendu effectif du Canvas useEffect(() => { if (!isVisible) return; let isMounted = true; let renderTask = null; const renderPage = async () => { try { await loadPdfJs(); if (!isMounted) return; const loadingTask = window.pdfjsLib.getDocument(url); const pdf = await loadingTask.promise; const page = await pdf.getPage(1); const viewport = page.getViewport({ scale: 1 }); const canvas = canvasRef.current; if (!canvas || !isMounted) return; const context = canvas.getContext('2d', { alpha: false }); const scale = 200 / viewport.width; const scaledViewport = page.getViewport({ scale }); canvas.width = Math.floor(scaledViewport.width); canvas.height = Math.floor(scaledViewport.height); renderTask = page.render({ canvasContext: context, viewport: scaledViewport }); await renderTask.promise; if (isMounted) { setLoading(false); } } catch (e) { if (isMounted) { setError(true); setLoading(false); } } }; renderPage(); return () => { isMounted = false; if (renderTask) { renderTask.cancel(); } }; }, [url, isVisible]); return (
{loading && !error && ( )} {error && ( )}
); }); // Composant rendu PDF complet (Multipage) const PdfFullViewer = memo(({ url }) => { const [pdf, setPdf] = useState(null); const [error, setError] = useState(false); useEffect(() => { let isMounted = true; loadPdfJs().then(() => { if (!isMounted) return; const loadingTask = window.pdfjsLib.getDocument(url); loadingTask.promise.then(doc => { if (isMounted) { setPdf(doc); } }).catch(() => { if (isMounted) { setError(true); } }); }).catch(() => { if (isMounted) { setError(true); } }); return () => { isMounted = false; }; }, [url]); if (error) { return (
Impossible de charger le PDF.
); } if (!pdf) { return (
Génération de l'aperçu...
); } return (
{Array.from(new Array(pdf.numPages), (el, index) => ( ))}
); }); const PdfPage = memo(({ pdf, pageNumber }) => { const containerRef = useRef(null); const canvasRef = useRef(null); const [loading, setLoading] = useState(true); const [isVisible, setIsVisible] = useState(false); // File d'attente d'arrière-plan pour les pages du lecteur PDF useEffect(() => { let isMounted = true; let observerDisposed = false; const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) { if (isMounted) setIsVisible(true); observer.disconnect(); observerDisposed = true; } }, { rootMargin: '500px' } ); if (containerRef.current) { observer.observe(containerRef.current); } if (!window.pdfPageIdleQueue) window.pdfPageIdleQueue = []; const queueItem = () => { if (isMounted && !observerDisposed) { setIsVisible(true); observer.disconnect(); observerDisposed = true; } }; window.pdfPageIdleQueue.push(queueItem); if (!window.pdfPageProcessorStarted) { window.pdfPageProcessorStarted = true; const processIdleQueue = () => { if (window.pdfPageIdleQueue && window.pdfPageIdleQueue.length > 0) { const task = window.pdfPageIdleQueue.shift(); task(); } setTimeout(processIdleQueue, 600); // Rend une page toutes les 600ms en cache }; setTimeout(processIdleQueue, 2000); } return () => { isMounted = false; observer.disconnect(); observerDisposed = true; if (window.pdfPageIdleQueue) { const idx = window.pdfPageIdleQueue.indexOf(queueItem); if (idx > -1) window.pdfPageIdleQueue.splice(idx, 1); } }; }, []); useEffect(() => { if (!isVisible) return; let isMounted = true; let renderTask = null; pdf.getPage(pageNumber).then(page => { if (!isMounted) return; const baseViewport = page.getViewport({ scale: 1.0 }); const maxWidth = 768; const pixelRatio = Math.min(window.devicePixelRatio || 1, 2); const targetResolution = maxWidth * pixelRatio; let scale = targetResolution / baseViewport.width; if (scale > 2) scale = 2; if (scale < 0.5) scale = 0.5; const viewport = page.getViewport({ scale }); const canvas = canvasRef.current; if (!canvas) return; const context = canvas.getContext('2d', { alpha: false }); canvas.width = Math.floor(viewport.width); canvas.height = Math.floor(viewport.height); renderTask = page.render({ canvasContext: context, viewport }); renderTask.promise.then(() => { if(isMounted) { setLoading(false); } }).catch(() => { // Erreur silencieuse }); }); return () => { isMounted = false; if (renderTask) { renderTask.cancel(); } }; }, [pdf, pageNumber, isVisible]); return (
{loading && (
)}
); }); // Modale d'avertissement de modération pour les Riverains const ModerationModal = ({ word, onModify, onRequestControl }) => (

Avertissement

À cause de l'utilisation de l'expression "{word}", vous devez modifier votre message.

Vous avez également la possibilité de faire contrôler votre message par le support technique avant envoi au client.
); // Utilitaire de vérification de liste noire const checkForBlacklist = (text, settings) => { if (!settings || !settings.blacklist) { return null; } const words = settings.blacklist.split(',') .map(w => w.trim().toLowerCase()) .filter(w => w); const lowerText = text.toLowerCase(); for (let w of words) { if (lowerText.includes(w)) { return w; } } return null; }; // ------------------------------------------------------------------------- // COMPOSANT 1 : BULLE DE MESSAGE (MÉMORISÉE POUR PERFORMANCES EXTRÊMES) // ------------------------------------------------------------------------- const MessageBubble = memo(({ msg, isMe, displayAuthor }) => { const [viewing, setViewing] = useState(null); const attachments = []; const regex = /\[ATTACHMENT:([a-zA-Z0-9_-]+):(image|pdf)\]/g; let match; while ((match = regex.exec(msg.detail || '')) !== null) { attachments.push({ id: match[1], type: match[2] }); } const textContent = (msg.detail || '').replace(regex, '').trim(); // CORRECTION XSS STORED : On utilise authorType au lieu de author const isHtmlAllowed = msg.authorType === 'Admin' || msg.authorType === 'Système' || msg.authorType === 'Systeme'; return (
{msg.isAlert === 2 && (
En attente de contrôle
)}
{textContent && ( isHtmlAllowed ? (
/g, '>').replace(/"/g, '"').replace(/'/g, ''') }} className="text-sm leading-relaxed whitespace-pre-wrap admin-html" /> ) : (

{textContent}

) )} {attachments.length > 0 && (
{attachments.map((att, i) => ( {att.type === 'image' && (
setViewing(att)} className="cursor-pointer shrink-0"> Pièce jointe
)} {att.type === 'pdf' && (
setViewing(att)} className={`cursor-pointer overflow-hidden rounded-xl border flex flex-col group text-left transition-transform hover:scale-[1.02] shrink-0 ${isMe ? 'border-white/20 bg-emerald-700/50' : 'border-slate-200 bg-white'} w-32`}>
Aperçu PDF
)}
))}
)}
{displayAuthor} • {new Date(String(msg.created_at || '').replace(' ', 'T')).toLocaleString('fr-FR')} {msg.id.toString().startsWith('temp_') && } {viewing && (
e.stopPropagation()}>
Télécharger
{viewing.type === 'image' && ( Aperçu )} {viewing.type === 'pdf' && ( )}
)}
); }, (prevProps, nextProps) => { return prevProps.msg.id === nextProps.msg.id && prevProps.msg.resolved === nextProps.msg.resolved && prevProps.msg.isAlert === nextProps.msg.isAlert; }); // ------------------------------------------------------------------------- // COMPOSANT 2 : CONTENEUR DE TCHAT (ARCHITECTURE CLASSIQUE) // ------------------------------------------------------------------------- const ChatBox = ({ messages, currentUserRole, panneauId, refreshData, onSend, clientName, settings = {} }) => { const containerRef = useRef(null); const textareaRef = useRef(null); const [draft, setDraft] = useState(''); const [sending, setSending] = useState(false); const [selectedFiles, setSelectedFiles] = useState([]); const [fileError, setFileError] = useState(''); const [isAtBottom, setIsAtBottom] = useState(true); const [isDragging, setIsDragging] = useState(false); const [dragCounter, setDragCounter] = useState(0); const [moderationWarning, setModerationWarning] = useState(null); useEffect(() => { if (textareaRef.current) { textareaRef.current.style.height = 'auto'; textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; } }, [draft]); useEffect(() => { const authorRole = currentUserRole === 'admin' ? 'Admin' : 'Client'; const unread = messages.filter(m => !m.resolved && m.author !== authorRole); if (unread.length > 0 && currentUserRole !== 'public' && currentUserRole !== 'riverain') { fetch(window.ECO_CONFIG.apiBaseUrl + 'interactions/read', { method: 'POST', body: JSON.stringify({ panneauId, author: authorRole }) }).then(() => { if(refreshData) { refreshData(true); } }); if (window.Notification && Notification.permission === 'granted' && localStorage.getItem('eco_push_enabled') === 'true') { const latestMsgId = unread[0].id || unread[0].created_at; const lastNotified = sessionStorage.getItem('last_notified_msg_' + panneauId); if (lastNotified !== latestMsgId) { new Notification("eco-panneau.fr", { body: "Nouveau message reçu.", icon: "/favicon.svg" }); sessionStorage.setItem('last_notified_msg_' + panneauId, latestMsgId); } } } }, [messages, panneauId, currentUserRole, refreshData]); const handleScroll = useCallback(() => { if (!containerRef.current) return; const { scrollTop, scrollHeight, clientHeight } = containerRef.current; const isBottom = scrollHeight - scrollTop - clientHeight < 50; setIsAtBottom(isBottom); }, []); const scrollToBottom = useCallback((smooth = true) => { if (containerRef.current) { containerRef.current.scrollTo({ top: containerRef.current.scrollHeight, behavior: smooth ? 'smooth' : 'auto' }); } }, []); useEffect(() => { const timer = setTimeout(() => scrollToBottom(false), 100); return () => clearTimeout(timer); }, [scrollToBottom]); useEffect(() => { if (isAtBottom) { scrollToBottom(true); } }, [messages.length, isAtBottom, scrollToBottom]); const addFiles = (fileList) => { setFileError(''); const validTypes = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf']; const files = Array.from(fileList).filter(f => validTypes.includes(f.type)); if (files.length === 0) { return; } if (selectedFiles.length + files.length > 20) { setFileError("Maximum 20 fichiers autorisés par message."); return; } const validFiles = []; for (let f of files) { if (f.size > 5 * 1024 * 1024) { setFileError("Un ou plusieurs fichiers dépassent 5 Mo."); return; } validFiles.push(f); } setSelectedFiles(prev => [...prev, ...validFiles]); }; const handleFileChange = (e) => { addFiles(e.target.files); e.target.value = null; }; const handlePaste = (e) => { if (e.clipboardData && e.clipboardData.files && e.clipboardData.files.length > 0) { addFiles(e.clipboardData.files); } }; const handleDragEnter = (e) => { e.preventDefault(); e.stopPropagation(); setDragCounter(prev => prev + 1); setIsDragging(true); }; const handleDragLeave = (e) => { e.preventDefault(); e.stopPropagation(); setDragCounter(prev => { const nc = prev - 1; if (nc === 0) { setIsDragging(false); } return nc; }); }; const handleDragOver = (e) => { e.preventDefault(); e.stopPropagation(); }; const handleDrop = (e) => { e.preventDefault(); e.stopPropagation(); setDragCounter(0); setIsDragging(false); if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length > 0) { addFiles(e.dataTransfer.files); } }; const removeFile = (index) => { const newFiles = [...selectedFiles]; newFiles.splice(index, 1); setSelectedFiles(newFiles); }; const executeSend = async (isPendingControl = false) => { setSending(true); let finalDraft = draft.trim(); for (let file of selectedFiles) { const fileType = file.type.includes('pdf') ? 'pdf' : 'image'; try { const isPublic = currentUserRole === 'public' || currentUserRole === 'riverain'; const fileId = await window.uploadFile(file, fileType, null, false, isPublic); finalDraft += `\n[ATTACHMENT:${fileId}:${fileType}]`; } catch(err) { setFileError("Erreur lors de l'upload : " + err.message); setSending(false); return; } } if (onSend) { await onSend(finalDraft, isPendingControl); } setDraft(''); setSelectedFiles([]); setSending(false); setModerationWarning(null); setTimeout(() => scrollToBottom(true), 50); }; const handleInitialSend = (e) => { e.preventDefault(); if (!draft.trim() && selectedFiles.length === 0) { return; } if (currentUserRole === 'public' || currentUserRole === 'riverain') { const word = checkForBlacklist(draft, settings); if (word) { setModerationWarning(word); return; } } executeSend(false); }; return (
{isDragging && (
Déposez vos fichiers ici Images et PDF acceptés (max 20)
)} {moderationWarning && ( setModerationWarning(null)} onRequestControl={() => executeSend(true)} /> )}
{messages.map((msg) => { const isMe = (currentUserRole === 'admin' && msg.author === 'Admin') || (currentUserRole === 'client' && msg.author === 'Client') || (currentUserRole === 'public' && msg.author !== 'Admin' && msg.author !== 'Client' && msg.author !== 'Système' && msg.author !== 'Systeme'); let displayAuthor = msg.author; if (panneauId === 'CONTACT_PUBLIC') { if (msg.author === 'Admin') { displayAuthor = 'Service client'; } else if (msg.author === 'Système' || msg.author === 'Systeme') { displayAuthor = 'Système'; } else { displayAuthor = `Visiteur ${msg.author}`; } } else { if (msg.author === 'Admin') { displayAuthor = 'Support'; } else if (msg.author === 'Client') { displayAuthor = clientName || 'Client'; } else if (msg.author === 'Système' || msg.author === 'Systeme') { displayAuthor = 'Système'; } else { displayAuthor = 'Riverain'; } } return ( ); })} {messages.length === 0 && (
Aucun message.
)}
{!isAtBottom && messages.length > 0 && (
)}
{fileError && (

{fileError}

)} {selectedFiles.length > 0 && (
{selectedFiles.map((f, i) => (
{f.type.includes('pdf') ? : } {f.name}
))}
)}
{!hideAttachments && selectedFiles.length > 0 && (
{selectedFiles.map((f, i) => (
{f.type.includes('pdf') ? : } {f.name}
))}
)} {!hideAlert && ( )}
{!hideAttachments && ( )}
); }; // Injection du module RiverainContactForm avec le message de prévention const _PublicContactFormWrapper = window.PublicContactForm; window.PublicContactForm = (props) => { const [isAlertLocal, setIsAlertLocal] = useState(false); // Interception pour capturer l'état "isAlert" depuis le DOM useEffect(() => { const checkAlert = () => { const checkbox = document.querySelector('input[type="checkbox"]'); if (checkbox && checkbox.checked !== isAlertLocal) { setIsAlertLocal(checkbox.checked); } }; document.addEventListener('change', checkAlert); return () => document.removeEventListener('change', checkAlert); }, [isAlertLocal]); return (
<_PublicContactFormWrapper {...props} /> {isAlertLocal && !props.hideAlert && (
Attention sécurité Ce formulaire ne contacte que le chef de chantier de manière asynchrone. En cas d'accident grave, d'incendie ou de danger immédiat pour les personnes, veuillez contacter les services de secours (112, 15, 17, 18).
)}
); }; // EXPORT GLOBAL AJOUTÉ : PdfFullViewer Object.assign(window, { ChatBox, PublicContactForm, PdfFullViewer }); /* EOF ===== [_socle_messagerie.jsx] =============== */