Initial commit
This commit is contained in:
@@ -0,0 +1,437 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import axios from 'axios'
|
||||
import LazyMedia from './LazyMedia'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081/api'
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
function Activity({ startDate, endDate }) {
|
||||
const [activities, setActivities] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [offset, setOffset] = useState(0)
|
||||
const observerTarget = useRef(null)
|
||||
const scrollContainerRef = useRef(null)
|
||||
|
||||
// Reset when date range changes
|
||||
useEffect(() => {
|
||||
setActivities([])
|
||||
setOffset(0)
|
||||
setHasMore(true)
|
||||
fetchActivity(0, false)
|
||||
}, [startDate, endDate])
|
||||
|
||||
const fetchActivity = async (currentOffset, append = false) => {
|
||||
if (append) {
|
||||
setLoadingMore(true)
|
||||
} else {
|
||||
setLoading(true)
|
||||
}
|
||||
|
||||
try {
|
||||
const params = {
|
||||
limit: PAGE_SIZE,
|
||||
offset: currentOffset
|
||||
}
|
||||
if (startDate) params.start = startDate.toISOString()
|
||||
if (endDate) params.end = endDate.toISOString()
|
||||
|
||||
const response = await axios.get(`${API_BASE}/activity`, { params })
|
||||
const newActivities = response.data || []
|
||||
|
||||
// If we got fewer items than the page size, we've reached the end
|
||||
if (newActivities.length < PAGE_SIZE) {
|
||||
setHasMore(false)
|
||||
}
|
||||
|
||||
if (append) {
|
||||
setActivities(prev => [...prev, ...newActivities])
|
||||
} else {
|
||||
setActivities(newActivities)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching activity:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
console.log('loadMore called:', { loadingMore, hasMore, offset })
|
||||
if (!loadingMore && hasMore) {
|
||||
const newOffset = offset + PAGE_SIZE
|
||||
setOffset(newOffset)
|
||||
fetchActivity(newOffset, true)
|
||||
}
|
||||
}, [offset, loadingMore, hasMore])
|
||||
|
||||
// Set up intersection observer for infinite scroll
|
||||
useEffect(() => {
|
||||
// Make sure both refs are available
|
||||
if (!scrollContainerRef.current || !observerTarget.current) {
|
||||
console.log('Refs not ready:', { scroll: !!scrollContainerRef.current, target: !!observerTarget.current })
|
||||
return
|
||||
}
|
||||
|
||||
console.log('Setting up IntersectionObserver', { hasMore, loadingMore, activitiesCount: activities.length })
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
console.log('Observer callback fired', {
|
||||
isIntersecting: entries[0].isIntersecting,
|
||||
hasMore,
|
||||
loadingMore
|
||||
})
|
||||
if (entries[0].isIntersecting && hasMore && !loadingMore) {
|
||||
console.log('Intersection detected, loading more...')
|
||||
loadMore()
|
||||
}
|
||||
},
|
||||
{
|
||||
root: scrollContainerRef.current,
|
||||
rootMargin: '100px',
|
||||
threshold: 0.1
|
||||
}
|
||||
)
|
||||
|
||||
observer.observe(observerTarget.current)
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [loadMore, hasMore, loadingMore, activities])
|
||||
|
||||
const formatCallType = (type) => {
|
||||
switch (type) {
|
||||
case 1: return { label: 'Incoming call', icon: '📞', color: 'success' }
|
||||
case 2: return { label: 'Outgoing call', icon: '📱', color: 'primary' }
|
||||
case 3: return { label: 'Missed call', icon: '📵', color: 'danger' }
|
||||
case 4: return { label: 'Voicemail', icon: '🎙️', color: 'info' }
|
||||
case 5: return { label: 'Rejected call', icon: '🚫', color: 'warning' }
|
||||
case 6: return { label: 'Refused call', icon: '❌', color: 'danger' }
|
||||
default: return { label: 'Call', icon: '📞', color: 'secondary' }
|
||||
}
|
||||
}
|
||||
|
||||
const formatDuration = (seconds) => {
|
||||
if (seconds < 60) return `${seconds}s`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${minutes}m ${secs}s`
|
||||
}
|
||||
|
||||
const formatPhoneNumber = (phoneNumber) => {
|
||||
if (!phoneNumber) return ''
|
||||
|
||||
// Handle comma-separated numbers (group conversations)
|
||||
if (phoneNumber.includes(',')) {
|
||||
const numbers = phoneNumber.split(',').map(n => n.trim())
|
||||
return numbers.map(n => formatSinglePhoneNumber(n)).join(', ')
|
||||
}
|
||||
|
||||
return formatSinglePhoneNumber(phoneNumber)
|
||||
}
|
||||
|
||||
const formatSinglePhoneNumber = (phoneNumber) => {
|
||||
if (!phoneNumber) return ''
|
||||
|
||||
// Remove any non-numeric characters except leading +
|
||||
let cleaned = phoneNumber.replace(/[^\d+]/g, '')
|
||||
|
||||
// Handle +1 prefix (US numbers)
|
||||
if (cleaned.startsWith('+1') && cleaned.length === 12) {
|
||||
// Format as +1 (XXX) XXX-XXXX
|
||||
return `+1 (${cleaned.slice(2, 5)}) ${cleaned.slice(5, 8)}-${cleaned.slice(8)}`
|
||||
}
|
||||
|
||||
// Handle numbers with + country code
|
||||
if (cleaned.startsWith('+')) {
|
||||
return cleaned // Return international numbers as-is
|
||||
}
|
||||
|
||||
// Handle 11-digit numbers starting with 1 (US numbers without +)
|
||||
if (cleaned.length === 11 && cleaned.startsWith('1')) {
|
||||
return `+1 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7)}`
|
||||
}
|
||||
|
||||
// Handle 10-digit US numbers
|
||||
if (cleaned.length === 10) {
|
||||
return `+1 (${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6)}`
|
||||
}
|
||||
|
||||
// Return as-is if format doesn't match
|
||||
return phoneNumber
|
||||
}
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diffMs = now - date
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||
|
||||
const timeStr = date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
})
|
||||
|
||||
if (diffDays === 0) return `Today at ${timeStr}`
|
||||
if (diffDays === 1) return `Yesterday at ${timeStr}`
|
||||
if (diffDays < 7) return date.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
})
|
||||
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
})
|
||||
}
|
||||
|
||||
const getMessageTypeLabel = (type) => {
|
||||
switch (type) {
|
||||
case 1: return { label: 'Received', color: 'primary' }
|
||||
case 2: return { label: 'Sent', color: 'success' }
|
||||
case 3: return { label: 'Draft', color: 'secondary' }
|
||||
case 4: return { label: 'Outbox', color: 'warning' }
|
||||
case 5: return { label: 'Failed', color: 'danger' }
|
||||
case 6: return { label: 'Queued', color: 'info' }
|
||||
default: return { label: 'Message', color: 'secondary' }
|
||||
}
|
||||
}
|
||||
|
||||
const shouldDisplaySubject = (subject) => {
|
||||
if (!subject) return false
|
||||
// Filter out protocol buffer/RCS subjects
|
||||
if (subject.startsWith('proto:')) return false
|
||||
return true
|
||||
}
|
||||
|
||||
// Get sender display name for a message in group conversations
|
||||
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
|
||||
if (!senderPhone && message.addresses && message.addresses.length > 0) {
|
||||
// Use the first address as the sender
|
||||
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 (loading) {
|
||||
return (
|
||||
<div className="h-100 d-flex align-items-center justify-content-center">
|
||||
<div className="text-center">
|
||||
<div className="spinner-border text-primary mb-3" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<p className="text-muted">Loading activity...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (activities.length === 0) {
|
||||
return (
|
||||
<div className="h-100 d-flex align-items-center justify-content-center">
|
||||
<div className="text-center">
|
||||
<svg style={{width: '4rem', height: '4rem'}} className="text-muted mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p className="text-muted">No activity found</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-100 d-flex flex-column">
|
||||
<div className="bg-light border-bottom p-3">
|
||||
<h2 className="h5 mb-0 d-flex align-items-center gap-2">
|
||||
<svg style={{width: '1.25rem', height: '1.25rem'}} className="text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Activity Timeline
|
||||
<span className="badge bg-primary ms-auto">{activities.length} items</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div ref={scrollContainerRef} className="flex-fill overflow-auto p-3">
|
||||
<div className="container-fluid">
|
||||
{activities.map((activity, index) => {
|
||||
if (activity.type === 'message' && activity.message) {
|
||||
const msg = activity.message
|
||||
const msgType = getMessageTypeLabel(msg.type)
|
||||
|
||||
// For MMS with multiple recipients, use the addresses array; otherwise use the single address
|
||||
let displayAddress
|
||||
if (msg.addresses && msg.addresses.length > 0) {
|
||||
// Format each address and join with commas
|
||||
displayAddress = msg.addresses.map(addr => formatPhoneNumber(addr)).join(', ')
|
||||
} else {
|
||||
// Fall back to the single address field
|
||||
displayAddress = formatPhoneNumber(activity.address)
|
||||
}
|
||||
|
||||
const displayName = activity.contact_name || displayAddress
|
||||
|
||||
// Check if this is a group conversation
|
||||
const isGroupConversation = msg.addresses && msg.addresses.length > 1
|
||||
const isSent = msg.type === 2
|
||||
const showSenderLabel = isGroupConversation && !isSent
|
||||
|
||||
// Debug logging for ALL messages to understand what we're receiving
|
||||
console.log('Message received:', {
|
||||
id: msg.id,
|
||||
addresses: msg.addresses,
|
||||
addressesType: typeof msg.addresses,
|
||||
addressesLength: msg.addresses?.length,
|
||||
sender: msg.sender,
|
||||
address: msg.address,
|
||||
type: msg.type,
|
||||
isSent,
|
||||
isGroupConversation,
|
||||
showSenderLabel,
|
||||
body: msg.body?.substring(0, 30)
|
||||
})
|
||||
|
||||
return (
|
||||
<div key={`msg-${msg.id}`} className="card mb-2 shadow-sm" style={{padding: '0.5rem'}}>
|
||||
<div className="card-body py-2 px-3">
|
||||
<div className="d-flex justify-content-between align-items-start mb-1">
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<svg style={{width: '1.25rem', height: '1.25rem'}} className="text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
<div>
|
||||
<h6 className="mb-0">
|
||||
{displayName}
|
||||
</h6>
|
||||
<small className="text-muted">{displayAddress}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-end">
|
||||
<span className={`badge bg-${msgType.color}`}>{msgType.label}</span>
|
||||
<br />
|
||||
<small className="text-muted">{formatDate(activity.date)}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sender label for received messages in group conversations */}
|
||||
{showSenderLabel && (
|
||||
<div className="mb-1 ps-2">
|
||||
<small className="text-muted fw-semibold">
|
||||
From: {getSenderDisplayName(msg)}
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldDisplaySubject(msg.subject) && (
|
||||
<div className="mb-1">
|
||||
<strong>Subject:</strong> {msg.subject}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{msg.body && (
|
||||
<p className="card-text mb-1">{msg.body}</p>
|
||||
)}
|
||||
|
||||
{msg.media_type && (
|
||||
<LazyMedia
|
||||
messageId={msg.id}
|
||||
mediaType={msg.media_type}
|
||||
className="mt-1"
|
||||
alt="MMS attachment"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
} else if (activity.type === 'call' && activity.call) {
|
||||
const call = activity.call
|
||||
const callType = formatCallType(call.type)
|
||||
const formattedAddress = formatPhoneNumber(activity.address)
|
||||
const displayName = activity.contact_name || formattedAddress
|
||||
|
||||
return (
|
||||
<div key={`call-${call.id}`} className="card mb-2 shadow-sm border-start border-4" style={{borderLeftColor: `var(--bs-${callType.color})`}}>
|
||||
<div className="card-body py-2 px-3">
|
||||
<div className="d-flex justify-content-between align-items-start">
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<div style={{fontSize: '1.25rem'}}>{callType.icon}</div>
|
||||
<div>
|
||||
<h6 className="mb-0">
|
||||
{displayName}
|
||||
</h6>
|
||||
<small className="text-muted">{formattedAddress}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-end">
|
||||
<span className={`badge bg-${callType.color}`}>{callType.label}</span>
|
||||
<br />
|
||||
<small className="text-muted">{formatDate(activity.date)}</small>
|
||||
</div>
|
||||
</div>
|
||||
{call.duration > 0 && (
|
||||
<div className="mt-1">
|
||||
<small className="text-muted">
|
||||
<svg style={{width: '1rem', height: '1rem'}} className="me-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Duration: {formatDuration(call.duration)}
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
|
||||
{/* Infinite scroll trigger */}
|
||||
<div ref={observerTarget} style={{ height: '20px' }} />
|
||||
|
||||
{/* Loading more indicator */}
|
||||
{loadingMore && (
|
||||
<div className="text-center py-3">
|
||||
<div className="spinner-border spinner-border-sm text-primary" role="status">
|
||||
<span className="visually-hidden">Loading more...</span>
|
||||
</div>
|
||||
<p className="small text-muted mt-2 mb-0">Loading more activities...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* End of results indicator */}
|
||||
{!hasMore && activities.length > 0 && (
|
||||
<div className="text-center py-3">
|
||||
<small className="text-muted">No more activities to load</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Activity
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useState } from 'react'
|
||||
import { Modal, Button, Form, Alert } from 'react-bootstrap'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
function ChangePasswordModal({ onClose, onSuccess }) {
|
||||
const { changePassword } = useAuth()
|
||||
const [oldPassword, setOldPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
|
||||
// Validate fields
|
||||
if (!oldPassword || !newPassword || !confirmPassword) {
|
||||
setError('All fields are required')
|
||||
return
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('New passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
setError('New password must be at least 6 characters')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const result = await changePassword(oldPassword, newPassword, confirmPassword)
|
||||
|
||||
if (result.success) {
|
||||
if (onSuccess) {
|
||||
onSuccess()
|
||||
}
|
||||
onClose()
|
||||
} else {
|
||||
setError(result.error || 'Failed to change password')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to change password. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal show={true} onHide={onClose} centered backdrop="static" keyboard={!loading}>
|
||||
<Modal.Header closeButton={!loading}>
|
||||
<Modal.Title className="h4 fw-bold">Change Password</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{error && (
|
||||
<Alert variant="danger" dismissible onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Current Password</Form.Label>
|
||||
<Form.Control
|
||||
type="password"
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>New Password</Form.Label>
|
||||
<Form.Control
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
<Form.Text className="text-muted">
|
||||
Must be at least 6 characters
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Confirm New Password</Form.Label>
|
||||
<Form.Control
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={onClose} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || !oldPassword || !newPassword || !confirmPassword}
|
||||
>
|
||||
{loading ? 'Changing...' : 'Change Password'}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChangePasswordModal
|
||||
@@ -0,0 +1,170 @@
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
|
||||
function ConversationList({ conversations, selectedConversation, onSelectConversation, loading }) {
|
||||
const formatDate = (date) => {
|
||||
try {
|
||||
return formatDistanceToNow(new Date(date), { addSuffix: true })
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const truncateMessage = (message, maxLength = 50) => {
|
||||
if (!message) return ''
|
||||
if (message.length <= maxLength) return message
|
||||
return message.substring(0, maxLength).trim() + '...'
|
||||
}
|
||||
|
||||
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 shouldDisplaySubject = (subject) => {
|
||||
if (!subject) return false
|
||||
// Filter out protocol buffer/RCS subjects
|
||||
if (subject.startsWith('proto:')) return false
|
||||
return true
|
||||
}
|
||||
|
||||
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 getConversationIcon = (type) => {
|
||||
if (type === 'call') {
|
||||
return (
|
||||
<svg style={{width: '1.25rem', height: '1.25rem'}} className="text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<svg style={{width: '1.25rem', height: '1.25rem'}} className="text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="d-flex align-items-center justify-content-center h-100">
|
||||
<div className="text-center">
|
||||
<div className="spinner-border text-primary mb-3" role="status" style={{width: '3rem', height: '3rem'}}>
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<p className="text-muted fw-medium">Loading conversations...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (conversations.length === 0) {
|
||||
return (
|
||||
<div className="d-flex align-items-center justify-content-center h-100 text-muted p-4">
|
||||
<div className="text-center">
|
||||
<svg style={{width: '4rem', height: '4rem'}} className="mx-auto mb-3 text-secondary opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
<p className="fw-medium text-dark">No conversations found</p>
|
||||
<p className="small mt-2">Upload a backup file to get started</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="list-group list-group-flush">
|
||||
{conversations.map((conv, index) => {
|
||||
const isSelected = selectedConversation &&
|
||||
selectedConversation.address === conv.address &&
|
||||
selectedConversation.type === conv.type
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${conv.type}-${conv.address}-${index}`}
|
||||
onClick={() => onSelectConversation(conv)}
|
||||
className={`list-group-item list-group-item-action ${
|
||||
isSelected ? 'active' : ''
|
||||
}`}
|
||||
style={{cursor: 'pointer'}}
|
||||
>
|
||||
<div className="d-flex align-items-start gap-2">
|
||||
<div className="flex-shrink-0 mt-1 p-2 rounded-circle bg-white shadow-sm">
|
||||
{getConversationIcon(conv.type)}
|
||||
</div>
|
||||
<div className="flex-fill min-w-0" style={{overflow: 'hidden'}}>
|
||||
<div className="d-flex justify-content-between align-items-baseline mb-1 gap-2">
|
||||
<h6 className="fw-semibold mb-0 text-truncate" style={{flex: '1 1 auto', minWidth: 0}}>
|
||||
{getDisplayName(conv)}
|
||||
</h6>
|
||||
<small className="text-nowrap flex-shrink-0" style={{fontSize: '0.75rem'}}>
|
||||
{formatDate(conv.last_date)}
|
||||
</small>
|
||||
</div>
|
||||
<p className="small mb-1 text-muted" style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: '0.85rem'
|
||||
}}>
|
||||
{truncateMessage(conv.last_message, 50)}
|
||||
</p>
|
||||
<div className="d-flex align-items-center gap-1">
|
||||
<span className="badge bg-secondary" style={{fontSize: '0.7rem'}}>
|
||||
{conv.message_count} {conv.type === 'call' ? 'call' : 'message'}{conv.message_count !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ConversationList
|
||||
@@ -0,0 +1,67 @@
|
||||
import DatePicker from 'react-datepicker'
|
||||
import 'react-datepicker/dist/react-datepicker.css'
|
||||
|
||||
function DateFilter({ startDate, endDate, minDate, maxDate, onStartDateChange, onEndDateChange }) {
|
||||
const clearDates = () => {
|
||||
onStartDateChange(null)
|
||||
onEndDateChange(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-2 bg-light">
|
||||
<div className="d-flex align-items-center gap-4 flex-wrap">
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<svg style={{width: '1.25rem', height: '1.25rem'}} className="text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<label className="small fw-semibold mb-0">From:</label>
|
||||
<DatePicker
|
||||
selected={startDate}
|
||||
onChange={onStartDateChange}
|
||||
selectsStart
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
minDate={minDate}
|
||||
maxDate={maxDate}
|
||||
dateFormat="MMM d, yyyy"
|
||||
className="form-control form-control-sm"
|
||||
placeholderText="Select start date"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<svg style={{width: '1.25rem', height: '1.25rem'}} className="text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<label className="small fw-semibold mb-0">To:</label>
|
||||
<DatePicker
|
||||
selected={endDate}
|
||||
onChange={onEndDateChange}
|
||||
selectsEnd
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
minDate={startDate || minDate}
|
||||
maxDate={maxDate}
|
||||
dateFormat="MMM d, yyyy"
|
||||
className="form-control form-control-sm"
|
||||
placeholderText="Select end date"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(startDate || endDate) && (
|
||||
<button
|
||||
onClick={clearDates}
|
||||
className="btn btn-sm btn-outline-primary d-flex align-items-center gap-2"
|
||||
>
|
||||
<svg style={{width: '1rem', height: '1rem'}} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Clear dates
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DateFilter
|
||||
@@ -0,0 +1,332 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import axios from 'axios'
|
||||
import VCardPreview from './VCardPreview'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081/api'
|
||||
|
||||
function LazyMedia({ messageId, mediaType, className, alt = "MMS attachment" }) {
|
||||
const [src, setSrc] = useState(null)
|
||||
const [vcfData, setVcfData] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const imgRef = useRef(null)
|
||||
const videoRef = useRef(null)
|
||||
const observerRef = useRef(null)
|
||||
const hasLoadedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Reset loaded state when messageId changes
|
||||
hasLoadedRef.current = false
|
||||
|
||||
// Set up Intersection Observer for lazy loading
|
||||
observerRef.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting && !hasLoadedRef.current) {
|
||||
hasLoadedRef.current = true
|
||||
loadMedia()
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
// Only load images below viewport (not above) to prevent scroll jump
|
||||
// rootMargin: top right bottom left
|
||||
rootMargin: '50px 0px 200px 0px'
|
||||
}
|
||||
)
|
||||
|
||||
if (imgRef.current) {
|
||||
observerRef.current.observe(imgRef.current)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect()
|
||||
}
|
||||
}
|
||||
}, [messageId])
|
||||
|
||||
const loadMedia = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Check if this is a VCF file - fetch as text instead of blob
|
||||
const isVCard = mediaType === 'text/x-vcard' ||
|
||||
mediaType === 'text/vcard' ||
|
||||
mediaType === 'text/directory'
|
||||
|
||||
if (isVCard) {
|
||||
// Fetch VCF as text
|
||||
const response = await axios.get(`${API_BASE}/media`, {
|
||||
params: { id: messageId },
|
||||
responseType: 'text'
|
||||
})
|
||||
setVcfData(response.data)
|
||||
} else {
|
||||
// Fetch other media as blob
|
||||
const response = await axios.get(`${API_BASE}/media`, {
|
||||
params: { id: messageId },
|
||||
responseType: 'blob'
|
||||
})
|
||||
|
||||
const blob = response.data
|
||||
const objectUrl = URL.createObjectURL(blob)
|
||||
setSrc(objectUrl)
|
||||
}
|
||||
|
||||
// Stop observing once loaded - we don't need to track this element anymore
|
||||
if (observerRef.current && imgRef.current) {
|
||||
observerRef.current.unobserve(imgRef.current)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load media:', err)
|
||||
setError(true)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup object URL when component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (src) {
|
||||
URL.revokeObjectURL(src)
|
||||
}
|
||||
}
|
||||
}, [src])
|
||||
|
||||
// Handle ESC key to close modal
|
||||
useEffect(() => {
|
||||
const handleEscape = (e) => {
|
||||
if (e.key === 'Escape' && showModal) {
|
||||
setShowModal(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (showModal) {
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
// Prevent body scroll when modal is open
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
document.body.style.overflow = 'unset'
|
||||
}
|
||||
}, [showModal])
|
||||
|
||||
// Pause original video when modal opens
|
||||
useEffect(() => {
|
||||
if (showModal && videoRef.current) {
|
||||
videoRef.current.pause()
|
||||
}
|
||||
}, [showModal])
|
||||
|
||||
if (!mediaType) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isImage = mediaType.startsWith('image/')
|
||||
const isVideo = mediaType.startsWith('video/')
|
||||
const isVCard = mediaType === 'text/x-vcard' ||
|
||||
mediaType === 'text/vcard' ||
|
||||
mediaType === 'text/directory'
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={imgRef} className={className}>
|
||||
{/* Placeholder shown before loading or while loading */}
|
||||
{!src && !vcfData && !error && (
|
||||
<div
|
||||
className="bg-light rounded d-flex align-items-center justify-content-center position-relative overflow-hidden"
|
||||
style={{
|
||||
width: '100%',
|
||||
aspectRatio: isVideo ? '16/9' : '3/4', // Common phone camera ratio
|
||||
minHeight: isVideo ? '200px' : '300px', // Larger to prevent layout shift
|
||||
maxHeight: '400px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
backgroundImage: 'linear-gradient(45deg, #e9ecef 25%, transparent 25%, transparent 75%, #e9ecef 75%, #e9ecef), linear-gradient(45deg, #e9ecef 25%, transparent 25%, transparent 75%, #e9ecef 75%, #e9ecef)',
|
||||
backgroundSize: '20px 20px',
|
||||
backgroundPosition: '0 0, 10px 10px'
|
||||
}}
|
||||
>
|
||||
<div className="text-center">
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="spinner-border spinner-border-sm text-secondary mb-2" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<div className="small text-muted">Loading {isImage ? 'image' : isVideo ? 'video' : isVCard ? 'contact' : 'media'}...</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-muted d-flex flex-column align-items-center">
|
||||
{isImage && (
|
||||
<svg style={{width: '2.5rem', height: '2.5rem'}} className="mb-2 text-secondary opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)}
|
||||
{isVideo && (
|
||||
<svg style={{width: '2.5rem', height: '2.5rem'}} className="mb-2 text-secondary opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)}
|
||||
{isVCard && (
|
||||
<svg style={{width: '2.5rem', height: '2.5rem'}} className="mb-2 text-secondary opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
)}
|
||||
{!isImage && !isVideo && !isVCard && (
|
||||
<svg style={{width: '2.5rem', height: '2.5rem'}} className="mb-2 text-secondary opacity-50" 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>
|
||||
)}
|
||||
<small className="text-muted">
|
||||
{isImage ? 'Image' : isVideo ? 'Video' : isVCard ? 'Contact' : 'Attachment'}
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-warning mb-0 small">
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<svg style={{width: '1.25rem', height: '1.25rem'}} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
Failed to load media
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(src || vcfData) && !loading && !error && (
|
||||
<>
|
||||
{isImage && src && (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="img-fluid rounded shadow"
|
||||
loading="lazy"
|
||||
onClick={() => setShowModal(true)}
|
||||
style={{
|
||||
display: 'block',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '400px',
|
||||
objectFit: 'contain',
|
||||
animation: 'fadeIn 0.3s ease-in',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isVideo && src && (
|
||||
<video
|
||||
ref={videoRef}
|
||||
controls
|
||||
className="img-fluid rounded shadow"
|
||||
src={src}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
if (videoRef.current) {
|
||||
videoRef.current.pause()
|
||||
}
|
||||
setShowModal(true)
|
||||
}}
|
||||
style={{
|
||||
display: 'block',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '400px',
|
||||
objectFit: 'contain',
|
||||
animation: 'fadeIn 0.3s ease-in',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isVCard && vcfData && (
|
||||
<VCardPreview vcfText={vcfData} messageId={messageId} />
|
||||
)}
|
||||
{!isImage && !isVideo && !isVCard && (
|
||||
<div className="small p-2 rounded bg-light d-flex align-items-center gap-1">
|
||||
<svg style={{width: '1rem', height: '1rem'}} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
|
||||
</svg>
|
||||
Attachment: {mediaType}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Full-screen modal */}
|
||||
{showModal && (isImage || isVideo) && src && (
|
||||
<div
|
||||
className="position-fixed top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center"
|
||||
style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||
zIndex: 9999,
|
||||
padding: '2rem'
|
||||
}}
|
||||
onClick={() => setShowModal(false)}
|
||||
>
|
||||
{/* Close button */}
|
||||
<button
|
||||
className="btn btn-light position-absolute top-0 end-0 m-3"
|
||||
onClick={() => setShowModal(false)}
|
||||
style={{
|
||||
zIndex: 10000,
|
||||
borderRadius: '50%',
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<svg style={{width: '1.5rem', height: '1.5rem'}} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Media content - stop propagation to prevent closing when clicking on media */}
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="d-flex align-items-center justify-content-center"
|
||||
style={{
|
||||
maxWidth: '95vw',
|
||||
maxHeight: '95vh'
|
||||
}}
|
||||
>
|
||||
{isImage && (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="rounded shadow-lg"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '95vh',
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isVideo && (
|
||||
<video
|
||||
controls
|
||||
autoPlay
|
||||
className="rounded shadow-lg"
|
||||
src={src}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '95vh',
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default LazyMedia
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useState } from 'react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
function Login() {
|
||||
const [isLogin, setIsLogin] = useState(true)
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { login, register } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
// Validation
|
||||
if (!username.trim() || !password) {
|
||||
setError('Username and password are required')
|
||||
return
|
||||
}
|
||||
|
||||
if (!isLogin) {
|
||||
if (username.trim().length < 3) {
|
||||
setError('Username must be at least 3 characters')
|
||||
return
|
||||
}
|
||||
if (password.length < 6) {
|
||||
setError('Password must be at least 6 characters')
|
||||
return
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const result = isLogin
|
||||
? await login(username.trim(), password)
|
||||
: await register(username.trim(), password)
|
||||
|
||||
if (result.success) {
|
||||
navigate('/')
|
||||
} else {
|
||||
setError(result.error || 'Authentication failed')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('An unexpected error occurred')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleMode = () => {
|
||||
setIsLogin(!isLogin)
|
||||
setError('')
|
||||
setPassword('')
|
||||
setConfirmPassword('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-vh-100 d-flex align-items-center justify-content-center bg-light">
|
||||
<div className="container">
|
||||
<div className="row justify-content-center">
|
||||
<div className="col-md-6 col-lg-4">
|
||||
<div className="card shadow">
|
||||
<div className="card-body p-4">
|
||||
<div className="text-center mb-4">
|
||||
<h2 className="h4 mb-2">
|
||||
<svg style={{width: '2rem', height: '2rem'}} className="text-primary me-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
SMS Backup Viewer
|
||||
</h2>
|
||||
<p className="text-muted mb-0">
|
||||
{isLogin ? 'Sign in to your account' : 'Create a new account'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="username" className="form-label">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
autoComplete="username"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="password" className="form-label">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-control"
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete={isLogin ? 'current-password' : 'new-password'}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isLogin && (
|
||||
<div className="mb-3">
|
||||
<label htmlFor="confirmPassword" className="form-label">
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-control"
|
||||
id="confirmPassword"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary w-100 mb-3"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
||||
{isLogin ? 'Signing in...' : 'Creating account...'}
|
||||
</>
|
||||
) : (
|
||||
<>{isLogin ? 'Sign In' : 'Create Account'}</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link text-decoration-none"
|
||||
onClick={toggleMode}
|
||||
disabled={loading}
|
||||
>
|
||||
{isLogin
|
||||
? "Don't have an account? Sign up"
|
||||
: 'Already have an account? Sign in'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Login
|
||||
@@ -0,0 +1,542 @@
|
||||
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'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081/api'
|
||||
|
||||
function MessageThread({ conversation, startDate, endDate }) {
|
||||
const location = useLocation()
|
||||
const [items, setItems] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [highlightedMessageId, setHighlightedMessageId] = useState(null)
|
||||
const messageRefs = useRef({})
|
||||
|
||||
useEffect(() => {
|
||||
if (conversation) {
|
||||
fetchItems()
|
||||
} 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 media = [...images, ...videos]
|
||||
|
||||
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 media = [...images, ...videos]
|
||||
|
||||
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])
|
||||
|
||||
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 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 (
|
||||
<div className="d-flex align-items-center justify-content-center h-100 text-muted">
|
||||
<div className="text-center">
|
||||
<svg style={{width: '5rem', height: '5rem'}} className="mx-auto mb-3 text-secondary opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
<p className="h5 text-dark">Select a conversation</p>
|
||||
<p className="small mt-2">Choose a conversation from the list to view messages</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="d-flex align-items-center justify-content-center h-100">
|
||||
<div className="text-center">
|
||||
<div className="spinner-border text-primary mb-3" role="status" style={{width: '3rem', height: '3rem'}}>
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<p className="text-muted fw-medium">Loading messages...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isCallLog = conversation.type === 'call'
|
||||
|
||||
return (
|
||||
<div className="d-flex flex-column h-100">
|
||||
{/* Thread Header */}
|
||||
<div className="bg-light border-bottom p-4 shadow-sm">
|
||||
<div className="d-flex align-items-center gap-3">
|
||||
<div className="p-3 rounded-circle bg-primary bg-gradient shadow">
|
||||
{isCallLog ? (
|
||||
<svg style={{width: '1.5rem', height: '1.5rem'}} className="text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg style={{width: '1.5rem', height: '1.5rem'}} className="text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="h4 fw-bold mb-1">
|
||||
{getDisplayName(conversation)}
|
||||
</h2>
|
||||
{/* 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 && (
|
||||
<div className="small text-muted mb-2">
|
||||
{addresses.map((addr, idx) => (
|
||||
<span key={idx}>
|
||||
{formatPhoneNumber(addr)}
|
||||
{idx < addresses.length - 1 ? ', ' : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
<div>
|
||||
<span className="badge bg-primary">
|
||||
{items.length} {isCallLog ? 'call' : 'message'}{items.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-fill overflow-auto p-4 bg-light">
|
||||
{isCallLog ? (
|
||||
// Call Log View
|
||||
<div className="d-flex flex-column gap-3">
|
||||
{items.map((call) => {
|
||||
const typeInfo = getCallTypeInfo(call.type)
|
||||
return (
|
||||
<div
|
||||
key={call.id}
|
||||
className="card shadow-sm border-2"
|
||||
>
|
||||
<div className="card-body">
|
||||
<div className="d-flex align-items-center justify-content-between">
|
||||
<div className="d-flex align-items-center gap-3">
|
||||
<div className={`p-3 rounded-circle ${typeInfo.bgColor} bg-opacity-10`}>
|
||||
<span className={`fs-4 ${typeInfo.color}`}>
|
||||
{typeInfo.icon}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className={`fw-semibold ${typeInfo.color}`}>
|
||||
{typeInfo.label} Call
|
||||
</div>
|
||||
<div className="small text-muted mt-1 d-flex align-items-center gap-1">
|
||||
<svg style={{width: '1rem', height: '1rem'}} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{formatTime(call.date)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-end">
|
||||
<div className="h5 fw-bold mb-0">
|
||||
{formatDuration(call.duration)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
// Unified Message and Call View
|
||||
<div className="d-flex flex-column gap-1">
|
||||
{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 (
|
||||
<div key={`call-${call.id}`} className="d-flex justify-content-center my-1">
|
||||
<div className="badge bg-light text-dark border px-3 py-2 d-flex align-items-center gap-2" style={{fontSize: '0.75rem'}}>
|
||||
<span className={typeInfo.color} style={{fontSize: '1rem'}}>{typeInfo.icon}</span>
|
||||
<span className={`fw-semibold ${typeInfo.color}`}>{typeInfo.label} call</span>
|
||||
<span className="text-muted">·</span>
|
||||
<span className="text-muted">{formatTime(call.date)}</span>
|
||||
{call.duration > 0 && (
|
||||
<>
|
||||
<span className="text-muted">·</span>
|
||||
<span className="text-muted">{formatDuration(call.duration)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Message rendering
|
||||
if (!message) return null
|
||||
|
||||
const isSent = message.type === 2
|
||||
const isHighlighted = highlightedMessageId === String(message.id)
|
||||
const showSenderLabel = isGroupConversation && !isSent
|
||||
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`d-flex ${isSent ? 'justify-content-end' : 'justify-content-start'}`}
|
||||
>
|
||||
<div style={{ maxWidth: '70%' }}>
|
||||
{/* Sender label for received messages in group conversations */}
|
||||
{showSenderLabel && (
|
||||
<div className="small text-muted mb-1 ms-2" style={{ fontSize: '0.7rem' }}>
|
||||
{getSenderDisplayName(message)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={(el) => (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'
|
||||
}}
|
||||
>
|
||||
<div className="card-body py-1 px-2">
|
||||
{message.body && (
|
||||
<div style={{whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: '0.875rem', lineHeight: '1.3'}}>
|
||||
{message.body}
|
||||
</div>
|
||||
)}
|
||||
{message.media_type && (
|
||||
<LazyMedia
|
||||
messageId={message.id}
|
||||
mediaType={message.media_type}
|
||||
className="mt-1"
|
||||
alt="MMS attachment"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`mt-1 d-flex align-items-center gap-1 ${
|
||||
isSent ? 'text-white-50' : 'text-muted'
|
||||
}`}
|
||||
style={{fontSize: '0.75rem'}}
|
||||
>
|
||||
<svg style={{width: '0.7rem', height: '0.7rem'}} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{formatTime(message.date)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MessageThread
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
function ProtectedRoute({ children }) {
|
||||
const { isAuthenticated, loading } = useAuth()
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-vh-100 d-flex align-items-center justify-content-center">
|
||||
<div className="text-center">
|
||||
<div className="spinner-border text-primary mb-3" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<p className="text-muted">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
|
||||
export default ProtectedRoute
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import axios from 'axios'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081/api'
|
||||
|
||||
function Search({ searchQuery, setSearchQuery, results, setResults, loading, setLoading, searched, setSearched, scrollPosition, setScrollPosition }) {
|
||||
const navigate = useNavigate()
|
||||
const scrollContainerRef = useRef(null)
|
||||
|
||||
const handleSearch = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!searchQuery.trim()) return
|
||||
|
||||
setLoading(true)
|
||||
setSearched(true)
|
||||
|
||||
try {
|
||||
const response = await axios.get(`${API_BASE}/search`, {
|
||||
params: { q: searchQuery, limit: 1000 }
|
||||
})
|
||||
setResults(response.data || [])
|
||||
} catch (error) {
|
||||
console.error('Error searching:', error)
|
||||
setResults([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResultClick = (result) => {
|
||||
// Navigate to the conversation with the message ID as a query parameter
|
||||
navigate(`/conversation/${encodeURIComponent(result.address)}?messageId=${result.message_id}`)
|
||||
}
|
||||
|
||||
const formatPhoneNumber = (phoneNumber) => {
|
||||
if (!phoneNumber) return ''
|
||||
|
||||
// Handle comma-separated numbers (group conversations)
|
||||
if (phoneNumber.includes(',')) {
|
||||
const numbers = phoneNumber.split(',').map(n => n.trim())
|
||||
return numbers.map(n => formatSinglePhoneNumber(n)).join(', ')
|
||||
}
|
||||
|
||||
return formatSinglePhoneNumber(phoneNumber)
|
||||
}
|
||||
|
||||
const formatSinglePhoneNumber = (phoneNumber) => {
|
||||
if (!phoneNumber) return ''
|
||||
|
||||
// Remove +1 prefix if present
|
||||
const cleaned = phoneNumber.replace(/^\+1/, '')
|
||||
|
||||
// Format as (XXX) XXX-XXXX
|
||||
if (cleaned.length === 10) {
|
||||
return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6)}`
|
||||
}
|
||||
|
||||
return phoneNumber
|
||||
}
|
||||
|
||||
// Restore scroll position when component mounts or results change
|
||||
useEffect(() => {
|
||||
if (scrollContainerRef.current && scrollPosition > 0) {
|
||||
scrollContainerRef.current.scrollTop = scrollPosition
|
||||
}
|
||||
}, [scrollPosition])
|
||||
|
||||
// Save scroll position when user scrolls
|
||||
const handleScroll = (e) => {
|
||||
if (e.target.scrollTop !== scrollPosition) {
|
||||
setScrollPosition(e.target.scrollTop)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-100 d-flex flex-column">
|
||||
{/* Header */}
|
||||
<div className="bg-light border-bottom p-3">
|
||||
<h2 className="h5 mb-3 d-flex align-items-center gap-2">
|
||||
<svg style={{width: '1.25rem', height: '1.25rem'}} className="text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
Search Messages
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleSearch}>
|
||||
<div className="input-group">
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Search message contents..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={loading || !searchQuery.trim()}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
||||
Searching...
|
||||
</>
|
||||
) : (
|
||||
'Search'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{searched && !loading && (
|
||||
<div className="mt-2 small text-muted">
|
||||
{results.length > 0 ? (
|
||||
<>
|
||||
Found <strong>{results.length.toLocaleString()}</strong> result{results.length !== 1 ? 's' : ''}
|
||||
{results.length >= 1000 && ' (limited to first 1000)'}
|
||||
</>
|
||||
) : (
|
||||
'No results found'
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div ref={scrollContainerRef} onScroll={handleScroll} className="flex-fill overflow-auto p-3">
|
||||
{!searched ? (
|
||||
<div className="text-center text-muted py-5">
|
||||
<svg style={{width: '4rem', height: '4rem'}} className="mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<p className="lead">Search for messages</p>
|
||||
<p className="small">Enter a search term to find messages across all conversations</p>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="text-center py-5">
|
||||
<div className="spinner-border text-primary mb-3" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<p className="text-muted">Searching...</p>
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="text-center text-muted py-5">
|
||||
<svg style={{width: '4rem', height: '4rem'}} className="mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p className="lead">No results found</p>
|
||||
<p className="small">Try a different search term</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="row g-2">
|
||||
{results.map((result) => (
|
||||
<div key={result.message_id} className="col-12">
|
||||
<div
|
||||
className="card h-100 shadow-sm"
|
||||
style={{ cursor: 'pointer', transition: 'all 0.2s' }}
|
||||
onClick={() => handleResultClick(result)}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.boxShadow = '0 0.5rem 1rem rgba(0,0,0,0.15)'
|
||||
e.currentTarget.style.transform = 'translateY(-2px)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.boxShadow = ''
|
||||
e.currentTarget.style.transform = ''
|
||||
}}
|
||||
>
|
||||
<div className="card-body">
|
||||
<div className="d-flex justify-content-between align-items-start mb-2">
|
||||
<div className="flex-fill">
|
||||
<h6 className="card-title mb-1 fw-bold">
|
||||
{result.contact_name || formatPhoneNumber(result.address) || 'Unknown'}
|
||||
</h6>
|
||||
{result.contact_name && (
|
||||
<div className="small text-muted">
|
||||
{formatPhoneNumber(result.address)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<small className="text-muted text-nowrap ms-2">
|
||||
{format(new Date(result.date), 'MMM d, yyyy')}
|
||||
</small>
|
||||
</div>
|
||||
<div
|
||||
className="card-text small text-muted"
|
||||
dangerouslySetInnerHTML={{ __html: result.snippet }}
|
||||
style={{
|
||||
wordBreak: 'break-word',
|
||||
lineHeight: '1.5'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Search
|
||||
@@ -0,0 +1,358 @@
|
||||
import { useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import { Modal, Button, Form, Alert, Spinner, ProgressBar } from 'react-bootstrap'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8081/api'
|
||||
|
||||
function Upload({ onClose, onSuccess }) {
|
||||
const [files, setFiles] = useState([])
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [success, setSuccess] = useState(null)
|
||||
const [progress, setProgress] = useState(null)
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
const [currentStep, setCurrentStep] = useState(1) // 1 = upload, 2 = processing
|
||||
const [currentFileIndex, setCurrentFileIndex] = useState(0)
|
||||
const [totalFiles, setTotalFiles] = useState(0)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
const selectedFiles = Array.from(e.target.files)
|
||||
setFiles(selectedFiles)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
}
|
||||
|
||||
const handleDragEnter = (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!uploading) {
|
||||
setIsDragging(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragLeave = (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
// Only set dragging to false if we're leaving the drop zone itself
|
||||
if (e.currentTarget === e.target) {
|
||||
setIsDragging(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragOver = (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
|
||||
if (uploading) return
|
||||
|
||||
const droppedFiles = Array.from(e.dataTransfer.files)
|
||||
// Filter to only accept XML files
|
||||
const xmlFiles = droppedFiles.filter(file => file.name.toLowerCase().endsWith('.xml'))
|
||||
|
||||
if (xmlFiles.length === 0) {
|
||||
setError('Please drop only XML files')
|
||||
return
|
||||
}
|
||||
|
||||
if (xmlFiles.length < droppedFiles.length) {
|
||||
setError(`Only ${xmlFiles.length} of ${droppedFiles.length} files are XML files. Non-XML files were ignored.`)
|
||||
}
|
||||
|
||||
setFiles(xmlFiles)
|
||||
setSuccess(null)
|
||||
if (xmlFiles.length === droppedFiles.length) {
|
||||
setError(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (files.length === 0) {
|
||||
setError('Please select at least one file')
|
||||
return
|
||||
}
|
||||
|
||||
setUploading(true)
|
||||
setError(null)
|
||||
setProgress(null)
|
||||
setTotalFiles(files.length)
|
||||
setCurrentFileIndex(0)
|
||||
|
||||
try {
|
||||
// Process each file sequentially
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
setCurrentFileIndex(i + 1)
|
||||
|
||||
await uploadSingleFile(file)
|
||||
|
||||
// If this was the last file, show success and close
|
||||
if (i === files.length - 1) {
|
||||
setSuccess(`Successfully imported all ${files.length} file${files.length !== 1 ? 's' : ''}`)
|
||||
setTimeout(() => {
|
||||
setUploading(false)
|
||||
onSuccess()
|
||||
}, 1500)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Upload error:', err)
|
||||
if (err.code === 'ECONNABORTED') {
|
||||
setError('Upload timeout. The file may be too large.')
|
||||
} else {
|
||||
setError(err.response?.data?.error || err.message || 'Upload failed')
|
||||
}
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const uploadSingleFile = async (file) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
setUploadProgress(0)
|
||||
setCurrentStep(1)
|
||||
|
||||
// Step 1: Upload file to server
|
||||
const response = await axios.post(`${API_BASE}/upload`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
timeout: 300000, // 5 minute timeout for file upload
|
||||
onUploadProgress: (progressEvent) => {
|
||||
// This tracks the HTTP upload progress (file transfer to disk)
|
||||
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total)
|
||||
setUploadProgress(percentCompleted) // Show actual upload progress 0-100%
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.error || 'Upload failed')
|
||||
}
|
||||
|
||||
// File uploaded successfully, move to step 2
|
||||
setUploadProgress(0) // Reset for processing step
|
||||
setCurrentStep(2)
|
||||
|
||||
// Wait for processing to complete
|
||||
await waitForProcessingComplete()
|
||||
}
|
||||
|
||||
const waitForProcessingComplete = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const checkProgress = setInterval(async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API_BASE}/progress`)
|
||||
const data = response.data
|
||||
|
||||
if (!data || data.status === 'no_upload') {
|
||||
clearInterval(checkProgress)
|
||||
// Reject instead of resolve so the error is caught by handleUpload
|
||||
reject(new Error('Processing status unavailable'))
|
||||
return
|
||||
}
|
||||
|
||||
setProgress(data)
|
||||
|
||||
// Calculate processing progress (0-100% for step 2)
|
||||
const total = data.total_messages || 1
|
||||
const processed = data.processed_messages || 0
|
||||
const processingPercent = Math.min(Math.round((processed / total) * 100), 100)
|
||||
setUploadProgress(processingPercent)
|
||||
|
||||
// Check if completed
|
||||
if (data.status === 'completed') {
|
||||
clearInterval(checkProgress)
|
||||
setUploadProgress(100)
|
||||
// Just resolve - don't call onSuccess() here since we're processing multiple files
|
||||
// The main handleUpload() function will handle success after all files are done
|
||||
resolve()
|
||||
} else if (data.status === 'error') {
|
||||
clearInterval(checkProgress)
|
||||
// Reject instead of resolve so the error is caught by handleUpload
|
||||
reject(new Error(data.error_message || 'Processing failed'))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error checking progress:', err)
|
||||
}
|
||||
}, 500) // Check every 500ms for more responsive updates
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal show={true} onHide={onClose} centered backdrop="static" keyboard={!uploading}>
|
||||
<Modal.Header closeButton={!uploading}>
|
||||
<Modal.Title className="h4 fw-bold">Upload Backup</Modal.Title>
|
||||
</Modal.Header>
|
||||
|
||||
<Modal.Body>
|
||||
<div className="mb-3">
|
||||
<div className="d-flex align-items-center gap-2 text-muted mb-3">
|
||||
<svg style={{width: '1.25rem', height: '1.25rem'}} className="text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<small>Select or drag and drop one or more XML files from SMS Backup & Restore app</small>
|
||||
</div>
|
||||
|
||||
<Form.Group>
|
||||
<div
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
style={{
|
||||
border: isDragging ? '2px dashed #0d6efd' : '2px dashed #dee2e6',
|
||||
borderRadius: '0.375rem',
|
||||
padding: '2rem 1rem',
|
||||
textAlign: 'center',
|
||||
backgroundColor: isDragging ? '#f0f7ff' : '#f8f9fa',
|
||||
transition: 'all 0.2s ease',
|
||||
cursor: uploading ? 'not-allowed' : 'pointer',
|
||||
opacity: uploading ? 0.6 : 1
|
||||
}}
|
||||
>
|
||||
<div className="mb-3">
|
||||
<svg
|
||||
style={{width: '3rem', height: '3rem'}}
|
||||
className={isDragging ? "text-primary" : "text-muted"}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className={isDragging ? "text-primary fw-semibold" : "text-muted"}>
|
||||
{isDragging ? (
|
||||
<div>Drop XML files here</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="mb-2">Drag and drop XML files here</div>
|
||||
<div className="text-muted small">or</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Form.Control
|
||||
type="file"
|
||||
accept=".xml"
|
||||
onChange={handleFileChange}
|
||||
disabled={uploading}
|
||||
multiple
|
||||
style={{
|
||||
maxWidth: '250px',
|
||||
margin: '0 auto'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{files.length > 0 && !uploading && (
|
||||
<div className="mt-3">
|
||||
<Form.Text className="text-success d-flex align-items-center gap-1">
|
||||
<svg style={{width: '1rem', height: '1rem'}} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{files.length} file{files.length !== 1 ? 's' : ''} selected
|
||||
</Form.Text>
|
||||
<div className="mt-2" style={{maxHeight: '150px', overflowY: 'auto'}}>
|
||||
{files.map((file, index) => (
|
||||
<div key={index} className="small text-muted">
|
||||
{index + 1}. {file.name} ({(file.size / (1024 * 1024)).toFixed(2)} MB)
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Form.Group>
|
||||
</div>
|
||||
|
||||
{uploading && (
|
||||
<div className="mb-3">
|
||||
{totalFiles > 1 && (
|
||||
<div className="mb-2">
|
||||
<small className="text-muted fw-semibold">
|
||||
Processing file {currentFileIndex} of {totalFiles}
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
<div className="d-flex justify-content-between align-items-center mb-2">
|
||||
<small className="text-muted fw-semibold">
|
||||
Step {currentStep} of 2: {currentStep === 1 ? 'Uploading file' : 'Processing messages'}
|
||||
</small>
|
||||
<small className="text-muted fw-bold">{uploadProgress}%</small>
|
||||
</div>
|
||||
<ProgressBar
|
||||
now={uploadProgress}
|
||||
variant={uploadProgress === 100 && currentStep === 2 ? "success" : "primary"}
|
||||
striped={!(uploadProgress === 100 && currentStep === 2)}
|
||||
animated={!(uploadProgress === 100 && currentStep === 2)}
|
||||
/>
|
||||
{currentStep === 1 && files[currentFileIndex - 1] && (
|
||||
<small className="text-muted mt-2 d-block">
|
||||
Uploading {files[currentFileIndex - 1].name} ({(files[currentFileIndex - 1].size / (1024 * 1024)).toFixed(2)} MB) to server...
|
||||
</small>
|
||||
)}
|
||||
{currentStep === 2 && progress && (
|
||||
<small className="text-muted mt-2 d-block">
|
||||
{progress.processed_messages?.toLocaleString() || 0} / {progress.total_messages?.toLocaleString() || '?'} messages imported
|
||||
{progress.processed_calls > 0 && `, ${progress.processed_calls?.toLocaleString()} calls`}
|
||||
</small>
|
||||
)}
|
||||
{currentStep === 2 && !progress && (
|
||||
<small className="text-muted mt-2 d-block">
|
||||
Starting import process...
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert variant="danger" className="d-flex align-items-center gap-2">
|
||||
<svg style={{width: '1.25rem', height: '1.25rem'}} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<Alert variant="success" className="d-flex align-items-center gap-2">
|
||||
<svg style={{width: '1.25rem', height: '1.25rem'}} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{success}
|
||||
</Alert>
|
||||
)}
|
||||
</Modal.Body>
|
||||
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={onClose} disabled={uploading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleUpload} disabled={uploading || files.length === 0}>
|
||||
{uploading ? (
|
||||
<>
|
||||
<Spinner
|
||||
as="span"
|
||||
animation="border"
|
||||
size="sm"
|
||||
role="status"
|
||||
aria-hidden="true"
|
||||
className="me-2"
|
||||
/>
|
||||
Uploading...
|
||||
</>
|
||||
) : 'Upload'}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default Upload
|
||||
@@ -0,0 +1,239 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { parseVCard, formatAddress, formatBirthday } from '../utils/vcfParser'
|
||||
|
||||
/**
|
||||
* VCardPreview component for displaying vCard (contact) files
|
||||
*/
|
||||
function VCardPreview({ vcfText, messageId }) {
|
||||
const [contact, setContact] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
const loadVCard = () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
if (!vcfText) {
|
||||
throw new Error('No VCF data provided')
|
||||
}
|
||||
|
||||
const parsedContact = parseVCard(vcfText)
|
||||
setContact(parsedContact)
|
||||
} catch (err) {
|
||||
console.error('Error loading vCard:', err)
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (vcfText) {
|
||||
loadVCard()
|
||||
}
|
||||
}, [vcfText])
|
||||
|
||||
const handleDownload = () => {
|
||||
try {
|
||||
// Create blob from VCF text
|
||||
const blob = new Blob([vcfText], { type: 'text/vcard' })
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${contact?.name || 'contact'}.vcf`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
document.body.removeChild(a)
|
||||
} catch (err) {
|
||||
console.error('Error downloading vCard:', err)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="card shadow-sm" style={{ maxWidth: '400px' }}>
|
||||
<div className="card-body text-center">
|
||||
<div className="spinner-border spinner-border-sm text-primary" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<p className="text-muted small mb-0 mt-2">Loading contact...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="card shadow-sm border-danger" style={{ maxWidth: '400px' }}>
|
||||
<div className="card-body">
|
||||
<div className="text-danger small">
|
||||
<svg className="bi me-1" width="16" height="16" fill="currentColor">
|
||||
<use xlinkHref="#exclamation-triangle-fill" />
|
||||
</svg>
|
||||
Error loading contact: {error}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!contact) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card shadow-sm" style={{ maxWidth: '400px' }}>
|
||||
<div className="card-body">
|
||||
{/* Header with photo and name */}
|
||||
<div className="d-flex align-items-center mb-3">
|
||||
{contact.photo ? (
|
||||
<img
|
||||
src={contact.photo}
|
||||
alt={contact.name}
|
||||
className="rounded-circle me-3"
|
||||
style={{ width: '64px', height: '64px', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="rounded-circle bg-primary text-white d-flex align-items-center justify-content-center me-3"
|
||||
style={{ width: '64px', height: '64px', fontSize: '24px', fontWeight: 'bold' }}
|
||||
>
|
||||
{contact.name ? contact.name.charAt(0).toUpperCase() : '?'}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-grow-1">
|
||||
<h5 className="card-title mb-1">{contact.name || contact.formattedName || 'Unknown Contact'}</h5>
|
||||
{contact.title && <p className="text-muted small mb-0">{contact.title}</p>}
|
||||
{contact.organization && <p className="text-muted small mb-0">{contact.organization}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Details */}
|
||||
<div className="vcard-details">
|
||||
{/* Phone Numbers */}
|
||||
{contact.phoneNumbers.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="small text-muted fw-bold mb-1">
|
||||
<svg className="bi me-1" width="14" height="14" fill="currentColor">
|
||||
<path d="M3.654 1.328a.678.678 0 0 0-1.015-.063L1.605 2.3c-.483.484-.661 1.169-.45 1.77a17.568 17.568 0 0 0 4.168 6.608 17.569 17.569 0 0 0 6.608 4.168c.601.211 1.286.033 1.77-.45l1.034-1.034a.678.678 0 0 0-.063-1.015l-2.307-1.794a.678.678 0 0 0-.58-.122l-2.19.547a1.745 1.745 0 0 1-1.657-.459L5.482 8.062a1.745 1.745 0 0 1-.46-1.657l.548-2.19a.678.678 0 0 0-.122-.58L3.654 1.328zM1.884.511a1.745 1.745 0 0 1 2.612.163L6.29 2.98c.329.423.445.974.315 1.494l-.547 2.19a.678.678 0 0 0 .178.643l2.457 2.457a.678.678 0 0 0 .644.178l2.189-.547a1.745 1.745 0 0 1 1.494.315l2.306 1.794c.829.645.905 1.87.163 2.611l-1.034 1.034c-.74.74-1.846 1.065-2.877.702a18.634 18.634 0 0 1-7.01-4.42 18.634 18.634 0 0 1-4.42-7.009c-.362-1.03-.037-2.137.703-2.877L1.885.511z"/>
|
||||
</svg>
|
||||
Phone
|
||||
</div>
|
||||
{contact.phoneNumbers.map((phone, index) => (
|
||||
<div key={index} className="small mb-1">
|
||||
<span className="text-muted">{phone.type}:</span>{' '}
|
||||
<a href={`tel:${phone.number}`} className="text-decoration-none">
|
||||
{phone.number}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email Addresses */}
|
||||
{contact.emails.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="small text-muted fw-bold mb-1">
|
||||
<svg className="bi me-1" width="14" height="14" fill="currentColor">
|
||||
<path d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V4Zm2-1a1 1 0 0 0-1 1v.217l7 4.2 7-4.2V4a1 1 0 0 0-1-1H2Zm13 2.383-4.708 2.825L15 11.105V5.383Zm-.034 6.876-5.64-3.471L8 9.583l-1.326-.795-5.64 3.47A1 1 0 0 0 2 13h12a1 1 0 0 0 .966-.741ZM1 11.105l4.708-2.897L1 5.383v5.722Z"/>
|
||||
</svg>
|
||||
Email
|
||||
</div>
|
||||
{contact.emails.map((email, index) => (
|
||||
<div key={index} className="small mb-1">
|
||||
<span className="text-muted">{email.type}:</span>{' '}
|
||||
<a href={`mailto:${email.address}`} className="text-decoration-none">
|
||||
{email.address}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Addresses */}
|
||||
{contact.addresses.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="small text-muted fw-bold mb-1">
|
||||
<svg className="bi me-1" width="14" height="14" fill="currentColor">
|
||||
<path d="M8.707 1.5a1 1 0 0 0-1.414 0L.646 8.146a.5.5 0 0 0 .708.708L2 8.207V13.5A1.5 1.5 0 0 0 3.5 15h9a1.5 1.5 0 0 0 1.5-1.5V8.207l.646.647a.5.5 0 0 0 .708-.708L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.707 1.5ZM13 7.207V13.5a.5.5 0 0 1-.5.5h-9a.5.5 0 0 1-.5-.5V7.207l5-5 5 5Z"/>
|
||||
</svg>
|
||||
Address
|
||||
</div>
|
||||
{contact.addresses.map((addr, index) => {
|
||||
const formatted = formatAddress(addr)
|
||||
return formatted ? (
|
||||
<div key={index} className="small mb-1">
|
||||
<span className="text-muted">{addr.type}:</span> {formatted}
|
||||
</div>
|
||||
) : null
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Birthday */}
|
||||
{contact.birthday && (
|
||||
<div className="mb-3">
|
||||
<div className="small text-muted fw-bold mb-1">
|
||||
<svg className="bi me-1" width="14" height="14" fill="currentColor">
|
||||
<path d="M4 .5a.5.5 0 0 0-1 0V1H2a2 2 0 0 0-2 2v1h16V3a2 2 0 0 0-2-2h-1V.5a.5.5 0 0 0-1 0V1H4V.5zM16 14V5H0v9a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2zm-3.5-7h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5z"/>
|
||||
</svg>
|
||||
Birthday
|
||||
</div>
|
||||
<div className="small">{formatBirthday(contact.birthday)}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* URL */}
|
||||
{contact.url && (
|
||||
<div className="mb-3">
|
||||
<div className="small text-muted fw-bold mb-1">
|
||||
<svg className="bi me-1" width="14" height="14" fill="currentColor">
|
||||
<path d="M4.715 6.542 3.343 7.914a3 3 0 1 0 4.243 4.243l1.828-1.829A3 3 0 0 0 8.586 5.5L8 6.086a1.002 1.002 0 0 0-.154.199 2 2 0 0 1 .861 3.337L6.88 11.45a2 2 0 1 1-2.83-2.83l.793-.792a4.018 4.018 0 0 1-.128-1.287z"/>
|
||||
<path d="M6.586 4.672A3 3 0 0 0 7.414 9.5l.775-.776a2 2 0 0 1-.896-3.346L9.12 3.55a2 2 0 1 1 2.83 2.83l-.793.792c.112.42.155.855.128 1.287l1.372-1.372a3 3 0 1 0-4.243-4.243L6.586 4.672z"/>
|
||||
</svg>
|
||||
Website
|
||||
</div>
|
||||
<div className="small">
|
||||
<a href={contact.url} target="_blank" rel="noopener noreferrer" className="text-decoration-none">
|
||||
{contact.url}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note */}
|
||||
{contact.note && (
|
||||
<div className="mb-3">
|
||||
<div className="small text-muted fw-bold mb-1">
|
||||
<svg className="bi me-1" width="14" height="14" fill="currentColor">
|
||||
<path d="M5 0h8a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2 2 2 0 0 1-2 2H3a2 2 0 0 1-2-2h1a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1H1a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v9a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1H3a2 2 0 0 1 2-2z"/>
|
||||
<path d="M1 6v-.5a.5.5 0 0 1 1 0V6h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1H1zm0 3v-.5a.5.5 0 0 1 1 0V9h.5a.5.5 0 0 1 0 1h-2a.5.5 0 0 1 0-1H1zm0 2.5v.5H.5a.5.5 0 0 0 0 1h2a.5.5 0 0 0 0-1H2v-.5a.5.5 0 0 0-1 0z"/>
|
||||
</svg>
|
||||
Note
|
||||
</div>
|
||||
<div className="small text-muted">{contact.note}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Download Button */}
|
||||
<div className="d-grid">
|
||||
<button
|
||||
className="btn btn-sm btn-outline-primary"
|
||||
onClick={handleDownload}
|
||||
>
|
||||
<svg className="bi me-1" width="14" height="14" fill="currentColor">
|
||||
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
|
||||
<path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/>
|
||||
</svg>
|
||||
Download Contact
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VCardPreview
|
||||
Reference in New Issue
Block a user