import { useState, useEffect, useRef } from 'react' import { useLocation } from 'react-router-dom' import axios from 'axios' import { format } from 'date-fns' import LazyMedia from './LazyMedia' import MediaGrid from './MediaGrid' const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8085/api' function MessageThread({ conversation, startDate, endDate }) { const location = useLocation() const [items, setItems] = useState([]) const [loading, setLoading] = useState(false) const [highlightedMessageId, setHighlightedMessageId] = useState(null) const [isPreprintingMedia, setIsPreprintingMedia] = useState(false) const [showMediaOnly, setShowMediaOnly] = useState(false) const messageRefs = useRef({}) const printTriggeredRef = useRef(false) useEffect(() => { if (conversation) { fetchItems() setShowMediaOnly(false) // Reset to message view when conversation changes } else { setItems([]) } }, [conversation, startDate, endDate]) // Scroll to specific message if messageId is in URL useEffect(() => { if (items.length > 0) { const params = new URLSearchParams(location.search) const messageId = params.get('messageId') if (messageId) { setHighlightedMessageId(messageId) if (messageRefs.current[messageId]) { const element = messageRefs.current[messageId] // Function to wait for media in an element to load const waitForMediaInElement = (elem) => { const images = Array.from(elem.querySelectorAll('img')) const videos = Array.from(elem.querySelectorAll('video')) const audios = Array.from(elem.querySelectorAll('audio')) const media = [...images, ...videos, ...audios] if (media.length === 0) { return Promise.resolve() } const mediaPromises = media.map(mediaElement => { if (mediaElement.complete || mediaElement.readyState >= 2) { return Promise.resolve() } return new Promise((resolve) => { mediaElement.addEventListener('load', resolve, { once: true }) mediaElement.addEventListener('loadeddata', resolve, { once: true }) mediaElement.addEventListener('error', resolve, { once: true }) setTimeout(resolve, 3000) }) }) return Promise.all(mediaPromises) } // Function to perform the scroll const scrollToElement = () => { element.scrollIntoView({ behavior: 'smooth', block: 'center' }) } // Multi-stage scroll approach: // 1. Initial scroll to get element near viewport (triggers lazy loading) // 2. Wait for lazy-loaded media // 3. Final scroll to correct position setTimeout(() => { // First scroll - instant to trigger lazy loading element.scrollIntoView({ behavior: 'instant', block: 'center' }) // Wait a bit for lazy loading to trigger setTimeout(() => { // Wait for media to load waitForMediaInElement(element).then(() => { // Final smooth scroll to correct position scrollToElement() // Re-scroll after a delay to handle any late-loading media setTimeout(scrollToElement, 500) setTimeout(scrollToElement, 1500) }) }, 200) }, 100) } } else { setHighlightedMessageId(null) } } }, [items, location.search]) // Automatically scroll to the last message when opening a conversation useEffect(() => { if (items.length > 0) { const params = new URLSearchParams(location.search) const messageId = params.get('messageId') // Only auto-scroll if there's no specific messageId in the URL if (!messageId) { // Find the last message (not a call) to scroll to const lastItem = items[items.length - 1] let lastMessageId = null // Handle ActivityItem format vs direct Message format if (lastItem.type === 'message' && lastItem.message) { lastMessageId = lastItem.message.id } else if (lastItem.type === 'call') { // If last item is a call, find the last message before it for (let i = items.length - 1; i >= 0; i--) { if (items[i].type === 'message' && items[i].message) { lastMessageId = items[i].message.id break } } } else if (lastItem.id) { // Direct message format lastMessageId = lastItem.id } if (lastMessageId && messageRefs.current[lastMessageId]) { const element = messageRefs.current[lastMessageId] // Function to wait for media in an element to load const waitForMediaInElement = (elem) => { const images = Array.from(elem.querySelectorAll('img')) const videos = Array.from(elem.querySelectorAll('video')) const audios = Array.from(elem.querySelectorAll('audio')) const media = [...images, ...videos, ...audios] if (media.length === 0) { return Promise.resolve() } const mediaPromises = media.map(mediaElement => { if (mediaElement.complete || mediaElement.readyState >= 2) { return Promise.resolve() } return new Promise((resolve) => { mediaElement.addEventListener('load', resolve, { once: true }) mediaElement.addEventListener('loadeddata', resolve, { once: true }) mediaElement.addEventListener('error', resolve, { once: true }) setTimeout(resolve, 3000) }) }) return Promise.all(mediaPromises) } // Function to perform the scroll const scrollToElement = () => { element.scrollIntoView({ behavior: 'instant', block: 'end' }) } // Scroll to last message after a short delay to ensure rendering is complete setTimeout(() => { // First scroll to trigger lazy loading if needed scrollToElement() // Wait for media to load, then scroll again setTimeout(() => { waitForMediaInElement(element).then(() => { scrollToElement() // Re-scroll after a delay to handle any late-loading media setTimeout(scrollToElement, 300) }) }, 100) }, 100) } } } }, [items, location.search]) // Handle print: load all media before showing print dialog useEffect(() => { const handleBeforePrint = (e) => { // If we're already loading media for print, let it proceed if (printTriggeredRef.current) { return } // Prevent default print dialog e.preventDefault() printTriggeredRef.current = true setIsPreprintingMedia(true) // Trigger beforeprint event on all LazyMedia components to load them const printEvent = new Event('beforeprint') window.dispatchEvent(printEvent) // Wait a bit for all media to start loading, then open print dialog setTimeout(() => { setIsPreprintingMedia(false) printTriggeredRef.current = false window.print() }, 1500) // Give media 1.5 seconds to load } const handleKeyDown = (e) => { // Intercept Ctrl+P / Cmd+P if ((e.ctrlKey || e.metaKey) && e.key === 'p') { e.preventDefault() handleBeforePrint(e) } } window.addEventListener('keydown', handleKeyDown) return () => { window.removeEventListener('keydown', handleKeyDown) } }, []) const fetchItems = async () => { setLoading(true) try { const params = { address: conversation.address, type: conversation.type } if (startDate) params.start = startDate.toISOString() if (endDate) params.end = endDate.toISOString() const response = await axios.get(`${API_BASE}/messages`, { params }) setItems(response.data || []) } catch (error) { console.error('Error fetching items:', error) } finally { setLoading(false) } } const handleExportPDF = () => { // Build URL parameters for print view const params = new URLSearchParams() if (startDate) params.set('start', startDate.toISOString()) if (endDate) params.set('end', endDate.toISOString()) // Open print view in new window const queryString = params.toString() const printUrl = `/conversation/${encodeURIComponent(conversation.address)}/print${queryString ? '?' + queryString : ''}` window.open(printUrl, '_blank', 'width=1024,height=768') } const formatTime = (date) => { return format(new Date(date), 'MMM d, yyyy h:mm a') } const formatDuration = (seconds) => { const mins = Math.floor(seconds / 60) const secs = seconds % 60 return `${mins}:${secs.toString().padStart(2, '0')}` } const formatPhoneNumber = (number) => { if (!number) return 'Unknown' // Handle comma-separated numbers (group conversations) if (number.includes(',')) { const numbers = number.split(',').map(n => n.trim()) return numbers.map(n => formatSinglePhoneNumber(n)).join(', ') } return formatSinglePhoneNumber(number) } const formatSinglePhoneNumber = (number) => { if (!number) return 'Unknown' // Remove all non-digit characters const cleaned = number.replace(/\D/g, '') // Handle 11-digit numbers (e.g., +1 country code) if (cleaned.length === 11 && cleaned.startsWith('1')) { return `+1 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7)}` } // Handle 10-digit numbers (US format) if (cleaned.length === 10) { return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6)}` } // Handle other formats - try to format with spaces if (cleaned.length > 10) { // International format: +XX XXX XXX XXXX return `+${cleaned.slice(0, cleaned.length - 10)} ${cleaned.slice(cleaned.length - 10, cleaned.length - 7)} ${cleaned.slice(cleaned.length - 7, cleaned.length - 4)} ${cleaned.slice(cleaned.length - 4)}` } // Return original if we can't format it nicely return number } const getDisplayName = (conv) => { // If we have a valid subject, use it when contact_name is empty, "(Unknown)", or looks like an 8-digit number if (conv.subject && shouldDisplaySubject(conv.subject)) { if (!conv.contact_name || conv.contact_name === '(Unknown)' || /^\d{8}$/.test(conv.contact_name)) { return conv.subject } } // If contact_name is empty, null, or "(Unknown)", use formatted phone number if (!conv.contact_name || conv.contact_name === '(Unknown)') { return formatPhoneNumber(conv.address) } return conv.contact_name } const shouldDisplaySubject = (subject) => { if (!subject) return false // Filter out protocol buffer/RCS subjects if (subject.startsWith('proto:')) return false return true } const getCallTypeInfo = (type) => { switch (type) { case 1: return { label: 'Incoming', color: 'text-success', bgColor: 'bg-success', icon: '↓' } case 2: return { label: 'Outgoing', color: 'text-primary', bgColor: 'bg-primary', icon: '↑' } case 3: return { label: 'Missed', color: 'text-danger', bgColor: 'bg-danger', icon: '✕' } case 4: return { label: 'Voicemail', color: 'text-info', bgColor: 'bg-info', icon: '⊙' } case 5: return { label: 'Rejected', color: 'text-warning', bgColor: 'bg-warning', icon: '✕' } case 6: return { label: 'Refused', color: 'text-secondary', bgColor: 'bg-secondary', icon: '✕' } default: return { label: 'Call', color: 'text-secondary', bgColor: 'bg-secondary', icon: '○' } } } // Check if conversation is a group conversation // Handle both ActivityItem format (items[0].message) and direct Message format (items[0]) const isGroupConversation = items.length > 0 && (() => { const firstItem = items[0] // ActivityItem format: check message.addresses if (firstItem.type === 'message' && firstItem.message) { return firstItem.message.addresses && firstItem.message.addresses.length > 1 } // Direct Message format: check addresses directly return firstItem.addresses && firstItem.addresses.length > 1 })() // Get sender display name for a message const getSenderDisplayName = (message) => { // For received messages, use the sender field if available let senderPhone = message.sender // If sender is empty, try to extract from addresses array // (exclude any number that might be "me" - this is a received message so sender is someone else) if (!senderPhone && message.addresses && message.addresses.length > 0) { // For now, use the first address as the sender // In the future, we could exclude the current user's number senderPhone = message.addresses[0] } // If sender contains comma-separated numbers (shouldn't happen, but handle it), // extract only the first one if (senderPhone && senderPhone.includes(',')) { senderPhone = senderPhone.split(',')[0].trim() } if (!senderPhone) return 'Unknown' // Format as a single phone number (not as a group) return formatSinglePhoneNumber(senderPhone) } if (!conversation) { return (

Select a conversation

Choose a conversation from the list to view messages

) } if (loading) { return (
Loading...

Loading messages...

) } const isCallLog = conversation.type === 'call' return (
{/* Print preparation overlay */} {isPreprintingMedia && (
Loading media...

Preparing conversation for printing...

Loading all images and media

)} {/* Thread Header */}
{isCallLog ? ( ) : ( )}

{getDisplayName(conversation)}

{/* Display phone numbers for conversations with addresses */} {!isCallLog && items.length > 0 && (() => { const firstItem = items[0] // Get addresses from either ActivityItem.message or direct Message const addresses = (firstItem.type === 'message' && firstItem.message) ? firstItem.message.addresses : firstItem.addresses return addresses && addresses.length > 0 && (
{addresses.map((addr, idx) => ( {formatPhoneNumber(addr)} {idx < addresses.length - 1 ? ', ' : ''} ))}
) })()}
{items.length} {isCallLog ? 'call' : 'message'}{items.length !== 1 ? 's' : ''}
{/* Content */}
{showMediaOnly && !isCallLog ? ( // Media Grid View ) : isCallLog ? ( // Call Log View
{items.map((call) => { const typeInfo = getCallTypeInfo(call.type) return (
{typeInfo.icon}
{typeInfo.label} Call
{formatTime(call.date)}
{formatDuration(call.duration)}
) })}
) : ( // Unified Message and Call View
{items.map((item) => { // Check if this is an ActivityItem (has type field) or a direct Message const isActivityItem = item.type === 'message' || item.type === 'call' const isCall = isActivityItem && item.type === 'call' const message = isActivityItem ? item.message : item const call = isActivityItem ? item.call : null if (isCall && call) { // Compact call representation - inline with messages const typeInfo = getCallTypeInfo(call.type) return (
{typeInfo.icon} {typeInfo.label} call · {formatTime(call.date)} {call.duration > 0 && ( <> · {formatDuration(call.duration)} )}
) } // Message rendering if (!message) return null const isSent = message.type === 2 const isHighlighted = highlightedMessageId === String(message.id) const showSenderLabel = isGroupConversation && !isSent return (
{/* Sender label for received messages in group conversations */} {showSenderLabel && (
{getSenderDisplayName(message)}
)}
(messageRefs.current[message.id] = el)} className={`card shadow-sm ${ isSent ? 'bg-primary text-white' : 'bg-white' } ${ isHighlighted ? 'border-warning border-3' : 'border-2' }`} style={{ padding: '0.5em', position: 'relative' }} >
{message.body && (
{message.body}
)} {message.media_type && ( )}
{formatTime(message.date)}
) })}
)}
) } export default MessageThread