Initial print/export support

This commit is contained in:
lowcarbdev
2025-11-29 21:49:50 -07:00
parent c542c9aebb
commit cc164ef230
6 changed files with 863 additions and 1 deletions
+215
View File
@@ -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;
}
}
+11
View File
@@ -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])
+86 -1
View File
@@ -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 (
<div className="d-flex flex-column h-100">
{/* Print preparation overlay */}
{isPreprintingMedia && (
<div
className="position-fixed top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.95)',
zIndex: 10000
}}
>
<div className="text-center">
<div className="spinner-border text-primary mb-3" role="status" style={{width: '3rem', height: '3rem'}}>
<span className="visually-hidden">Loading media...</span>
</div>
<p className="h5 text-dark">Preparing conversation for printing...</p>
<p className="text-muted small">Loading all images and media</p>
</div>
</div>
)}
{/* Thread Header */}
<div className="bg-light border-bottom p-2 p-md-4 shadow-sm">
<div className="d-flex align-items-center gap-2 gap-md-3">
@@ -385,12 +458,24 @@ function MessageThread({ conversation, startDate, endDate }) {
</div>
)
})()}
<div>
<div className="d-flex align-items-center gap-2">
<span className="badge bg-primary" style={{fontSize: '0.7rem'}}>
{items.length} {isCallLog ? 'call' : 'message'}{items.length !== 1 ? 's' : ''}
</span>
</div>
</div>
<div>
<button
onClick={handleExportPDF}
className="btn btn-sm btn-outline-primary d-flex align-items-center gap-1"
title="Export as PDF"
>
<svg style={{width: '1rem', height: '1rem'}} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<span className="d-none d-md-inline">Export PDF</span>
</button>
</div>
</div>
</div>
+218
View File
@@ -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;
}
}
+327
View File
@@ -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 (
<div key={call.id} className="print-message print-call">
<div className="print-message-bubble">
<div className="print-call-info">
<span className="print-call-icon">{typeInfo.icon}</span>
<span className="print-call-label">{typeInfo.label} Call</span>
{call.duration > 0 && (
<span className="print-call-duration"> {formatDuration(call.duration)}</span>
)}
</div>
<div className="print-message-time">{formatDate(call.date)}</div>
</div>
</div>
)
}
// 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 (
<div key={message.id} className={messageClass}>
<div className="print-message-bubble">
{hasBody && (
<div className="print-message-body">{message.body}</div>
)}
{message.media_type && (
<div className="print-message-media">
{message.media_type.startsWith('image/') && (
<img
src={`${API_BASE}/media?id=${message.id}`}
alt="Message attachment"
onLoad={() => console.log(`Image ${message.id} loaded`)}
onError={(e) => console.log(`Image ${message.id} failed to load:`, e)}
/>
)}
{message.media_type.startsWith('video/') && (
<video
src={`${API_BASE}/media?id=${message.id}`}
controls
onLoadedData={() => console.log(`Video ${message.id} loaded`)}
onError={(e) => console.log(`Video ${message.id} failed to load:`, e)}
/>
)}
{message.media_type.startsWith('audio/') && (
<div className="print-media-placeholder">
🎵 Audio attachment
</div>
)}
</div>
)}
{!hasBody && !message.media_type && (
<div className="print-message-body" style={{color: '#999', fontStyle: 'italic'}}>
(Empty message)
</div>
)}
<div className="print-message-time">{formatDate(message.date)}</div>
</div>
</div>
)
}
if (loading) {
return (
<div className="print-loading">
<div className="spinner-border" role="status">
<span className="visually-hidden">Loading...</span>
</div>
<p>Loading conversation...</p>
</div>
)
}
return (
<div className="print-view">
{!mediaLoaded && totalMedia > 0 && (
<div className="print-loading-overlay">
<div className="print-loading-content">
<div className="spinner-border mb-3" role="status">
<span className="visually-hidden">Loading...</span>
</div>
<h4>Preparing PDF...</h4>
<p>Loading media: {loadedCount} of {totalMedia}</p>
<div className="progress" style={{ width: '300px' }}>
<div
className="progress-bar"
role="progressbar"
style={{ width: `${(loadedCount / totalMedia) * 100}%` }}
aria-valuenow={loadedCount}
aria-valuemin="0"
aria-valuemax={totalMedia}
/>
</div>
</div>
</div>
)}
<div className="print-header">
<h1>Conversation with {conversation?.contactName}</h1>
<p className="print-address">{conversation?.address}</p>
<p className="print-meta">
{messages.length} items
{' • '}
Exported on {format(new Date(), 'MMMM d, yyyy')}
</p>
</div>
<div className="print-messages">
{(() => {
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)
})
) : (
<p className="text-center text-muted">No messages to display</p>
)
})()}
</div>
</div>
)
}
export default PrintView
+6
View File
@@ -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(
<AuthProvider>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/conversation/:address/print" element={
<ProtectedRoute>
<PrintView />
</ProtectedRoute>
} />
<Route path="/*" element={
<ProtectedRoute>
<App />