diff --git a/frontend/src/App.css b/frontend/src/App.css
index b454ab1..d5b78b9 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -118,3 +118,218 @@
padding: 0.25rem 0.5rem;
}
}
+
+/* Print styles for conversation view */
+@media print {
+ /* Hide all UI elements except the conversation content */
+ header,
+ .nav-tabs,
+ .date-filter-container,
+ .conversation-sidebar,
+ button,
+ .btn,
+ input,
+ .form-control,
+ .position-fixed {
+ display: none !important;
+ }
+
+ /* Reset body and root for printing */
+ body,
+ #root {
+ background: white !important;
+ height: auto !important;
+ }
+
+ /* Make message thread container take full page */
+ .message-thread-container {
+ position: static !important;
+ width: 100% !important;
+ height: auto !important;
+ overflow: visible !important;
+ box-shadow: none !important;
+ border: none !important;
+ }
+
+ /* Thread header - compact and print-friendly */
+ .message-thread-container .bg-light {
+ background: white !important;
+ border: none !important;
+ box-shadow: none !important;
+ padding: 0.5rem 0 !important;
+ page-break-after: avoid;
+ }
+
+ .thread-header-title {
+ font-size: 1.25rem !important;
+ color: black !important;
+ margin-bottom: 0.25rem !important;
+ }
+
+ .badge {
+ background-color: white !important;
+ color: black !important;
+ border: 1px solid black !important;
+ font-size: 0.7rem !important;
+ }
+
+ /* Message content area */
+ .message-thread-container .flex-fill.overflow-auto {
+ overflow: visible !important;
+ height: auto !important;
+ background: white !important;
+ padding: 0 !important;
+ }
+
+ /* Message bubbles - print-friendly styling */
+ .card {
+ background: white !important;
+ color: black !important;
+ border: 1px solid #333 !important;
+ box-shadow: none !important;
+ margin-bottom: 0.5rem !important;
+ padding: 0.25rem 0.5rem !important;
+ page-break-inside: avoid;
+ }
+
+ /* Sent messages - distinguish with border style */
+ .bg-primary.text-white {
+ background: white !important;
+ color: black !important;
+ border: 1px solid #333 !important;
+ border-left: 3px solid #333 !important;
+ }
+
+ /* Received messages */
+ .bg-white {
+ background: white !important;
+ border: 1px solid #999 !important;
+ }
+
+ /* Message body text */
+ .card-body {
+ padding: 0.25rem 0.5rem !important;
+ }
+
+ /* Message timestamp */
+ .text-white-50,
+ .text-muted {
+ color: #666 !important;
+ }
+
+ /* SVG icons in messages */
+ svg {
+ color: #666 !important;
+ stroke: #666 !important;
+ }
+
+ /* Call log items */
+ .bg-light.text-dark.border {
+ background: white !important;
+ border: 1px solid #666 !important;
+ color: black !important;
+ }
+
+ /* Images and media - ensure they're visible and sized appropriately */
+ img {
+ max-width: 100% !important;
+ height: auto !important;
+ page-break-inside: avoid;
+ display: block !important;
+ margin: 0.25rem 0 !important;
+ border: 1px solid #ccc !important;
+ }
+
+ /* Video elements - show poster/thumbnail */
+ video {
+ max-width: 100% !important;
+ height: auto !important;
+ page-break-inside: avoid;
+ display: block !important;
+ margin: 0.25rem 0 !important;
+ border: 1px solid #ccc !important;
+ }
+
+ /* LazyMedia wrapper */
+ .lazy-media-wrapper,
+ .img-fluid {
+ max-width: 100% !important;
+ page-break-inside: avoid;
+ }
+
+ /* Ensure message container doesn't break across pages */
+ .d-flex.justify-content-start,
+ .d-flex.justify-content-end {
+ page-break-inside: avoid;
+ margin-bottom: 0.5rem;
+ }
+
+ /* Audio players - show as a simple text indicator */
+ audio {
+ display: none !important;
+ }
+
+ audio::after {
+ content: "[Audio attachment]";
+ display: block;
+ font-style: italic;
+ color: #666;
+ }
+
+ /* Flex layout adjustments for print */
+ .d-flex {
+ display: block !important;
+ }
+
+ .justify-content-end {
+ text-align: right !important;
+ }
+
+ .justify-content-start {
+ text-align: left !important;
+ }
+
+ .flex-fill {
+ flex: none !important;
+ }
+
+ /* Remove shadows and gradients */
+ .shadow,
+ .shadow-sm {
+ box-shadow: none !important;
+ }
+
+ .bg-gradient {
+ background-image: none !important;
+ }
+
+ /* Sender label for group conversations */
+ .small.text-muted.mb-1 {
+ color: #666 !important;
+ font-size: 0.7rem !important;
+ margin-bottom: 0.1rem !important;
+ }
+
+ /* Phone numbers in header */
+ .thread-header-title + .small.text-muted {
+ color: #666 !important;
+ font-size: 0.8rem !important;
+ }
+
+ /* Compact spacing for print */
+ .gap-1,
+ .gap-2,
+ .gap-3 {
+ gap: 0.25rem !important;
+ }
+
+ /* Page breaks */
+ .message-thread-container > div > div {
+ page-break-inside: avoid;
+ }
+
+ /* Ensure all content is visible */
+ * {
+ overflow: visible !important;
+ }
+}
diff --git a/frontend/src/components/LazyMedia.jsx b/frontend/src/components/LazyMedia.jsx
index 56680fa..180c428 100644
--- a/frontend/src/components/LazyMedia.jsx
+++ b/frontend/src/components/LazyMedia.jsx
@@ -41,10 +41,21 @@ function LazyMedia({ messageId, mediaType, className, alt = "MMS attachment" })
observerRef.current.observe(imgRef.current)
}
+ // Load media before printing to ensure all images are available
+ const handleBeforePrint = () => {
+ if (!hasLoadedRef.current) {
+ hasLoadedRef.current = true
+ loadMedia()
+ }
+ }
+
+ window.addEventListener('beforeprint', handleBeforePrint)
+
return () => {
if (observerRef.current) {
observerRef.current.disconnect()
}
+ window.removeEventListener('beforeprint', handleBeforePrint)
}
}, [messageId])
diff --git a/frontend/src/components/MessageThread.jsx b/frontend/src/components/MessageThread.jsx
index 893910b..35043e3 100644
--- a/frontend/src/components/MessageThread.jsx
+++ b/frontend/src/components/MessageThread.jsx
@@ -11,7 +11,9 @@ function MessageThread({ conversation, startDate, endDate }) {
const [items, setItems] = useState([])
const [loading, setLoading] = useState(false)
const [highlightedMessageId, setHighlightedMessageId] = useState(null)
+ const [isPreprintingMedia, setIsPreprintingMedia] = useState(false)
const messageRefs = useRef({})
+ const printTriggeredRef = useRef(false)
useEffect(() => {
if (conversation) {
@@ -181,6 +183,46 @@ function MessageThread({ conversation, startDate, endDate }) {
}
}, [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 {
@@ -200,6 +242,18 @@ function MessageThread({ conversation, startDate, endDate }) {
}
}
+ 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')
}
@@ -349,6 +403,25 @@ function MessageThread({ conversation, startDate, endDate }) {
return (
+ {/* Print preparation overlay */}
+ {isPreprintingMedia && (
+
+
+
+ Loading media...
+
+
Preparing conversation for printing...
+
Loading all images and media
+
+
+ )}
+
{/* Thread Header */}
@@ -385,12 +458,24 @@ function MessageThread({ conversation, startDate, endDate }) {
)
})()}
-
+
{items.length} {isCallLog ? 'call' : 'message'}{items.length !== 1 ? 's' : ''}
+
diff --git a/frontend/src/components/PrintView.css b/frontend/src/components/PrintView.css
new file mode 100644
index 0000000..6fc0aa4
--- /dev/null
+++ b/frontend/src/components/PrintView.css
@@ -0,0 +1,218 @@
+/* Print View Styles */
+
+.print-view {
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 20px;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+}
+
+.print-loading {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: 100vh;
+ gap: 1rem;
+}
+
+.print-loading-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: white;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 9999;
+}
+
+.print-loading-content {
+ text-align: center;
+}
+
+.print-header {
+ margin-bottom: 2rem;
+ border-bottom: 2px solid #dee2e6;
+ padding-bottom: 1rem;
+}
+
+.print-header h1 {
+ font-size: 24px;
+ margin-bottom: 0.5rem;
+ color: #212529;
+}
+
+.print-address {
+ color: #6c757d;
+ font-size: 14px;
+ margin-bottom: 0.25rem;
+}
+
+.print-meta {
+ color: #6c757d;
+ font-size: 12px;
+ margin: 0;
+}
+
+.print-messages {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.print-message {
+ display: flex;
+ margin-bottom: 8px;
+}
+
+.print-message.sent {
+ justify-content: flex-end;
+}
+
+.print-message.received {
+ justify-content: flex-start;
+}
+
+.print-message-bubble {
+ max-width: 70%;
+ padding: 10px 14px;
+ border-radius: 12px;
+ word-wrap: break-word;
+}
+
+.print-message.sent .print-message-bubble {
+ background-color: #f8f9fa;
+ border: 1px solid #333;
+}
+
+.print-message.received .print-message-bubble {
+ background-color: #fff;
+ border: 1px solid #ccc;
+}
+
+.print-message-body {
+ margin-bottom: 6px;
+ white-space: pre-wrap;
+ font-size: 14px;
+ line-height: 1.4;
+ color: #212529;
+}
+
+.print-message-media {
+ margin-bottom: 6px;
+}
+
+.print-message-media img {
+ max-width: 100%;
+ height: auto;
+ display: block;
+ border-radius: 8px;
+}
+
+.print-message-media video {
+ max-width: 100%;
+ height: auto;
+ display: block;
+ border-radius: 8px;
+ background: #000;
+}
+
+.print-media-placeholder {
+ padding: 20px;
+ background: #f8f9fa;
+ border-radius: 8px;
+ text-align: center;
+ color: #6c757d;
+}
+
+.print-message-time {
+ font-size: 11px;
+ color: #6c757d;
+ margin-top: 4px;
+ text-align: right;
+}
+
+/* Call-specific styles */
+.print-call {
+ justify-content: center;
+}
+
+.print-call .print-message-bubble {
+ background-color: #f8f9fa;
+ border: 1px solid #dee2e6;
+ text-align: center;
+}
+
+.print-call-info {
+ font-size: 14px;
+ font-weight: 500;
+ color: #495057;
+ margin-bottom: 4px;
+}
+
+.print-call-icon {
+ font-size: 16px;
+ margin-right: 4px;
+}
+
+.print-call-label {
+ font-weight: 600;
+}
+
+.print-call-duration {
+ color: #6c757d;
+ font-weight: normal;
+}
+
+/* Print-specific styles */
+@media print {
+ .print-loading-overlay {
+ display: none !important;
+ }
+
+ .print-view {
+ max-width: none;
+ padding: 0;
+ }
+
+ .print-header {
+ page-break-after: avoid;
+ }
+
+ .print-message {
+ page-break-inside: avoid;
+ }
+
+ .print-message-bubble {
+ page-break-inside: avoid;
+ }
+
+ @page {
+ margin: 0.5in;
+ }
+
+ /* Ensure good contrast for printing */
+ .print-message.sent .print-message-bubble {
+ background-color: #f0f0f0;
+ border: 1px solid #000;
+ }
+
+ .print-message.received .print-message-bubble {
+ background-color: #fff;
+ border: 1px solid #666;
+ }
+
+ .print-message-body {
+ color: #000;
+ }
+}
+
+/* Screen-only: hide scrollbars during loading */
+@media screen {
+ body:has(.print-loading-overlay) {
+ overflow: hidden;
+ }
+}
diff --git a/frontend/src/components/PrintView.jsx b/frontend/src/components/PrintView.jsx
new file mode 100644
index 0000000..2735a23
--- /dev/null
+++ b/frontend/src/components/PrintView.jsx
@@ -0,0 +1,327 @@
+import { useState, useEffect, useRef } from 'react'
+import { useParams, useSearchParams } from 'react-router-dom'
+import axios from 'axios'
+import { format } from 'date-fns'
+import './PrintView.css'
+
+const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081/api'
+
+function PrintView() {
+ const { address } = useParams()
+ const [searchParams] = useSearchParams()
+ const [messages, setMessages] = useState([])
+ const [conversation, setConversation] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [mediaLoaded, setMediaLoaded] = useState(false)
+ const [loadedCount, setLoadedCount] = useState(0)
+ const [totalMedia, setTotalMedia] = useState(0)
+ const printTriggeredRef = useRef(false)
+
+ useEffect(() => {
+ const startDate = searchParams.get('start')
+ const endDate = searchParams.get('end')
+
+ console.log('URL params:', { address, startDate, endDate })
+
+ if (!address) {
+ console.error('No address provided')
+ return
+ }
+
+ fetchConversation(address, startDate, endDate)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+
+ const fetchConversation = async (address, startDate, endDate) => {
+ try {
+ setLoading(true)
+ const params = { address, type: 'conversation' }
+ if (startDate) params.start = startDate
+ if (endDate) params.end = endDate
+
+ console.log('Fetching conversation with params:', params)
+ // Use /messages endpoint with type=conversation to get all types (SMS, MMS, calls)
+ const response = await axios.get(`${API_BASE}/messages`, { params })
+ const items = response.data || []
+
+ console.log('Received items:', items.length)
+ if (items.length > 0) {
+ console.log('First item:', items[0])
+ console.log('First item body:', items[0].body)
+ console.log('First item date:', items[0].date)
+ console.log('First item type:', items[0].type)
+ }
+
+ console.log('Setting messages state with items:', items)
+ setMessages(items)
+ console.log('Messages state set')
+
+ // Get contact name from any message in the list (they should all have the same contact_name)
+ const contactName = items.find(item => item.contact_name)?.contact_name || address
+ console.log('Contact name:', contactName)
+
+ setConversation({
+ address,
+ contactName
+ })
+
+ // Count total media items - need to check nested message for media_type
+ const mediaCount = items.filter(item => {
+ const msg = item.message || item
+ return msg.media_type
+ }).length
+ setTotalMedia(mediaCount)
+ console.log('Total media items:', mediaCount)
+
+ setLoading(false)
+
+ // Wait for all media to load before triggering print
+ if (mediaCount > 0) {
+ waitForAllMedia()
+ } else {
+ // No media, trigger print after short delay
+ setTimeout(() => {
+ if (!printTriggeredRef.current) {
+ printTriggeredRef.current = true
+ setMediaLoaded(true)
+ window.print()
+ }
+ }, 500)
+ }
+ } catch (error) {
+ console.error('Error fetching conversation:', error)
+ setLoading(false)
+ }
+ }
+
+ const waitForAllMedia = () => {
+ const checkInterval = setInterval(() => {
+ const images = document.querySelectorAll('.print-message-media img')
+ const videos = document.querySelectorAll('.print-message-media video')
+ const allMedia = [...images, ...videos]
+
+ if (allMedia.length === 0) return
+
+ const loaded = allMedia.filter(el => {
+ if (el.tagName === 'IMG') {
+ return el.complete && el.naturalHeight !== 0
+ } else if (el.tagName === 'VIDEO') {
+ return el.readyState >= 2
+ }
+ return false
+ })
+
+ setLoadedCount(loaded.length)
+
+ // All media loaded
+ if (loaded.length === allMedia.length) {
+ clearInterval(checkInterval)
+ clearTimeout(timeoutId)
+ if (!printTriggeredRef.current) {
+ printTriggeredRef.current = true
+ setMediaLoaded(true)
+ // Give browser a moment to render everything
+ setTimeout(() => {
+ window.print()
+ }, 500)
+ }
+ }
+ }, 100)
+
+ // Timeout after 60 seconds
+ const timeoutId = setTimeout(() => {
+ clearInterval(checkInterval)
+ if (!printTriggeredRef.current) {
+ printTriggeredRef.current = true
+ setMediaLoaded(true)
+ window.print()
+ }
+ }, 60000)
+ }
+
+ const formatDate = (dateString) => {
+ if (!dateString) return ''
+ try {
+ const date = new Date(dateString)
+ return format(date, 'MMM d, yyyy h:mm a')
+ } catch (e) {
+ return ''
+ }
+ }
+
+ const formatDuration = (seconds) => {
+ const mins = Math.floor(seconds / 60)
+ const secs = seconds % 60
+ return `${mins}:${secs.toString().padStart(2, '0')}`
+ }
+
+ const getCallTypeInfo = (type) => {
+ switch (type) {
+ case 1: return { label: 'Incoming', icon: '↓' }
+ case 2: return { label: 'Outgoing', icon: '↑' }
+ case 3: return { label: 'Missed', icon: '✕' }
+ case 4: return { label: 'Voicemail', icon: '⊙' }
+ case 5: return { label: 'Rejected', icon: '✕' }
+ case 6: return { label: 'Refused', icon: '✕' }
+ default: return { label: 'Call', icon: '☎' }
+ }
+ }
+
+ const renderMessage = (item) => {
+ console.log('Rendering item:', {
+ itemType: item.type,
+ hasMessage: !!item.message,
+ fullItem: item
+ })
+
+ // Check if this is a call at the Activity level
+ if (item.type === 'call' && item.call) {
+ // For calls in Activity items, the call data is nested in item.call
+ const call = item.call
+ const typeInfo = getCallTypeInfo(call.type)
+ console.log('Rendering call:', {
+ callId: call.id,
+ callType: call.type,
+ duration: call.duration,
+ typeInfo
+ })
+
+ return (
+
+
+
+ {typeInfo.icon}
+ {typeInfo.label} Call
+ {call.duration > 0 && (
+ • {formatDuration(call.duration)}
+ )}
+
+
{formatDate(call.date)}
+
+
+ )
+ }
+
+ // For messages, extract from nested message object
+ const message = item.message || item
+ console.log('Rendering message:', {
+ messageId: message.id,
+ body: message.body,
+ hasBody: !!(message.body && message.body !== ''),
+ hasMedia: !!message.media_type
+ })
+
+ // Regular message rendering
+ const isSent = message.type === 2
+ const messageClass = isSent ? 'print-message sent' : 'print-message received'
+
+ // Check if body has actual content (not null, not undefined, not empty string)
+ const hasBody = message.body != null && message.body !== ''
+
+ return (
+
+
+ {hasBody && (
+
{message.body}
+ )}
+ {message.media_type && (
+
+ {message.media_type.startsWith('image/') && (
+

console.log(`Image ${message.id} loaded`)}
+ onError={(e) => console.log(`Image ${message.id} failed to load:`, e)}
+ />
+ )}
+ {message.media_type.startsWith('video/') && (
+
+ )}
+ {!hasBody && !message.media_type && (
+
+ (Empty message)
+
+ )}
+
{formatDate(message.date)}
+
+
+ )
+ }
+
+ if (loading) {
+ return (
+
+
+ Loading...
+
+
Loading conversation...
+
+ )
+ }
+
+ return (
+
+ {!mediaLoaded && totalMedia > 0 && (
+
+
+
+ Loading...
+
+
Preparing PDF...
+
Loading media: {loadedCount} of {totalMedia}
+
+
+
+ )}
+
+
+
Conversation with {conversation?.contactName}
+
{conversation?.address}
+
+ {messages.length} items
+ {' • '}
+ Exported on {format(new Date(), 'MMMM d, yyyy')}
+
+
+
+
+ {(() => {
+ console.log('About to render messages. Count:', messages.length)
+ console.log('Messages array:', messages)
+ console.log('Is array?', Array.isArray(messages))
+ return messages.length > 0 ? (
+ messages.map((msg, index) => {
+ console.log(`Message ${index}:`, msg)
+ return renderMessage(msg)
+ })
+ ) : (
+
No messages to display
+ )
+ })()}
+
+
+ )
+}
+
+export default PrintView
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
index bf06d3e..dca4301 100644
--- a/frontend/src/main.jsx
+++ b/frontend/src/main.jsx
@@ -7,6 +7,7 @@ import './index.css'
import { AuthProvider } from './contexts/AuthContext'
import App from './App.jsx'
import Login from './components/Login.jsx'
+import PrintView from './components/PrintView.jsx'
import ProtectedRoute from './components/ProtectedRoute.jsx'
// Configure axios to include credentials with all requests
@@ -18,6 +19,11 @@ createRoot(document.getElementById('root')).render(
} />
+
+
+
+ } />