Apply .gitignore — stop tracking ignored files
This commit is contained in:
parent
6c8d0ca442
commit
8b5bae04e0
17
.env
17
.env
@ -1,17 +0,0 @@
|
||||
# Phone Configuration
|
||||
PHONE_IP=10.0.0.193
|
||||
ADB_PORT=5555
|
||||
TERMUX_API_PORT=5001
|
||||
|
||||
# Flask Configuration
|
||||
FLASK_ENV=development
|
||||
SECRET_KEY=your-secret-key-here
|
||||
DEFAULT_DELAY_SECONDS=3
|
||||
|
||||
# SMS Campaign coordinates
|
||||
SEND_BUTTON_X=1300
|
||||
SEND_BUTTON_Y=2900
|
||||
|
||||
# Termux API Configuration
|
||||
TERMUX_API_SECRET=termux-sms-campaign-2025
|
||||
PREFER_TERMUX_API=true
|
||||
@ -1,73 +0,0 @@
|
||||
from flask import Flask, jsonify, render_template_string
|
||||
import subprocess
|
||||
import json
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
return render_template_string('''
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>SMS Campaign Manager - Termux</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 40px; background: #f5f5f5; }
|
||||
.container { background: white; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
|
||||
.status { background: #e8f5e8; padding: 20px; border-radius: 8px; margin: 20px 0; }
|
||||
.api-test { background: #f0f8ff; padding: 15px; border-radius: 5px; margin: 10px 0; }
|
||||
a { color: #0066cc; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🚀 SMS Campaign Manager</h1>
|
||||
<h2>Running on Termux!</h2>
|
||||
|
||||
<div class="status">
|
||||
<h3>✅ Flask Server Status: Active</h3>
|
||||
<p><strong>Server IP:</strong> 10.0.0.193:5000</p>
|
||||
<p><strong>Environment:</strong> Termux on Android</p>
|
||||
</div>
|
||||
|
||||
<div class="api-test">
|
||||
<h3>🔋 Termux API Tests</h3>
|
||||
<p><a href="/battery">📱 Battery Status</a></p>
|
||||
<p><a href="/notification">🔔 Send Test Notification</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
''')
|
||||
|
||||
@app.route('/battery')
|
||||
def battery():
|
||||
try:
|
||||
result = subprocess.run(['termux-battery-status'], capture_output=True, text=True)
|
||||
battery_data = json.loads(result.stdout)
|
||||
return f"""
|
||||
<h2>🔋 Battery Status</h2>
|
||||
<pre>{json.dumps(battery_data, indent=2)}</pre>
|
||||
<p><a href='/'>← Back</a></p>
|
||||
"""
|
||||
except Exception as e:
|
||||
return f"<h2>Error</h2><pre>{str(e)}</pre><p><a href='/'>← Back</a></p>"
|
||||
|
||||
@app.route('/notification')
|
||||
def notification():
|
||||
try:
|
||||
subprocess.run(['termux-notification', '--title', 'Flask Test', '--content', 'Hello from SMS Campaign Manager!'], capture_output=True, text=True)
|
||||
return f"""
|
||||
<h2>🔔 Notification Sent!</h2>
|
||||
<p>Check your Android notifications.</p>
|
||||
<p><a href='/'>← Back</a></p>
|
||||
"""
|
||||
except Exception as e:
|
||||
return f"<h2>Error</h2><pre>{str(e)}</pre><p><a href='/'>← Back</a></p>"
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("🚀 Starting SMS Campaign Manager on Termux...")
|
||||
print("📱 Device IP: 10.0.0.193")
|
||||
print("🌐 Access from Ubuntu: http://10.0.0.193:5000")
|
||||
app.run(host='0.0.0.0', port=5000, debug=True)
|
||||
@ -1,437 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Termux SMS API Server
|
||||
Bridges SMS Campaign Manager (Ubuntu) with Termux API (Android)
|
||||
|
||||
This server runs on the Android device in Termux and provides REST API
|
||||
endpoints for the main SMS Campaign Manager to send messages using
|
||||
native Android SMS capabilities instead of ADB automation.
|
||||
"""
|
||||
|
||||
from flask import Flask, request, jsonify
|
||||
import subprocess
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
import hmac
|
||||
import hashlib
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler('/data/data/com.termux/files/home/logs/sms-api.log'),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# Configuration
|
||||
CONFIG = {
|
||||
'SECRET_KEY': os.environ.get('SMS_API_SECRET', 'termux-sms-campaign-2025'),
|
||||
'MAX_MESSAGE_LENGTH': 160,
|
||||
'RATE_LIMIT_DELAY': 2.0, # Seconds between messages
|
||||
'ALLOWED_COMMANDS': [
|
||||
'termux-sms-send',
|
||||
'termux-sms-list',
|
||||
'termux-battery-status',
|
||||
'termux-location',
|
||||
'termux-notification'
|
||||
]
|
||||
}
|
||||
|
||||
class SMSApiServer:
|
||||
"""Main SMS API server class"""
|
||||
|
||||
def __init__(self):
|
||||
self.last_send_time = 0
|
||||
self.message_count = 0
|
||||
self.start_time = time.time()
|
||||
|
||||
def authenticate_request(self, request_data: str, signature: str) -> bool:
|
||||
"""Verify HMAC signature for request authentication"""
|
||||
try:
|
||||
expected_signature = hmac.new(
|
||||
CONFIG['SECRET_KEY'].encode(),
|
||||
request_data.encode(),
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
return hmac.compare_digest(signature, expected_signature)
|
||||
except Exception as e:
|
||||
logger.error(f"Authentication error: {e}")
|
||||
return False
|
||||
|
||||
def execute_termux_command(self, command: List[str]) -> Dict[str, Any]:
|
||||
"""Execute Termux API command with error handling"""
|
||||
if not command or command[0] not in CONFIG['ALLOWED_COMMANDS']:
|
||||
return {'success': False, 'error': 'Command not allowed'}
|
||||
|
||||
try:
|
||||
logger.info(f"Executing: {' '.join(command)}")
|
||||
result = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
return {
|
||||
'success': result.returncode == 0,
|
||||
'stdout': result.stdout.strip(),
|
||||
'stderr': result.stderr.strip(),
|
||||
'return_code': result.returncode
|
||||
}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {'success': False, 'error': 'Command timeout'}
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def rate_limit_check(self) -> bool:
|
||||
"""Check if enough time has passed since last message"""
|
||||
current_time = time.time()
|
||||
if current_time - self.last_send_time < CONFIG['RATE_LIMIT_DELAY']:
|
||||
return False
|
||||
self.last_send_time = current_time
|
||||
return True
|
||||
|
||||
def send_sms(self, phone: str, message: str) -> Dict[str, Any]:
|
||||
"""Send SMS using Termux API"""
|
||||
# Input validation
|
||||
if not phone or not message:
|
||||
return {'success': False, 'error': 'Phone and message required'}
|
||||
|
||||
if len(message) > CONFIG['MAX_MESSAGE_LENGTH']:
|
||||
return {'success': False, 'error': f'Message too long (max {CONFIG["MAX_MESSAGE_LENGTH"]} chars)'}
|
||||
|
||||
# Rate limiting
|
||||
if not self.rate_limit_check():
|
||||
return {'success': False, 'error': 'Rate limit exceeded, please wait'}
|
||||
|
||||
# Execute SMS send command
|
||||
command = ['termux-sms-send', '-n', phone, message]
|
||||
result = self.execute_termux_command(command)
|
||||
|
||||
if result['success']:
|
||||
self.message_count += 1
|
||||
logger.info(f"SMS sent to {phone}: {message[:50]}...")
|
||||
|
||||
# Send confirmation notification
|
||||
self.execute_termux_command([
|
||||
'termux-notification',
|
||||
'--title', 'SMS Sent',
|
||||
'--content', f'Message sent to {phone}'
|
||||
])
|
||||
|
||||
return {
|
||||
'success': result['success'],
|
||||
'error': result.get('error') or result.get('stderr'),
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'phone': phone,
|
||||
'message_length': len(message),
|
||||
'total_sent': self.message_count
|
||||
}
|
||||
|
||||
# Global server instance
|
||||
sms_server = SMSApiServer()
|
||||
|
||||
# API Endpoints
|
||||
# Web interface route
|
||||
@app.route("/")
|
||||
def index():
|
||||
"""Web interface for SMS API Server"""
|
||||
from flask import render_template_string
|
||||
return render_template_string("""
|
||||
<html>
|
||||
<head>
|
||||
<title>SMS API Server - Android</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
margin: 0; padding: 20px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh; color: white; }
|
||||
.container { max-width: 800px; margin: 0 auto; background: rgba(255,255,255,0.1);
|
||||
padding: 30px; border-radius: 15px; backdrop-filter: blur(10px); box-shadow: 0 8px 32px rgba(0,0,0,0.1); }
|
||||
h1 { text-align: center; margin-bottom: 30px; font-size: 2.5em; text-shadow: 2px 2px 4px rgba(0,0,0,0.3); }
|
||||
.status { background: rgba(0,255,0,0.2); padding: 20px; border-radius: 10px; margin: 20px 0;
|
||||
border-left: 5px solid #00ff00; }
|
||||
.endpoint { background: rgba(255,255,255,0.1); padding: 15px; margin: 10px 0; border-radius: 8px;
|
||||
border-left: 3px solid #fff; }
|
||||
.endpoint h3 { margin: 0 0 10px 0; color: #fff; }
|
||||
.endpoint code { background: rgba(0,0,0,0.3); padding: 5px 10px; border-radius: 5px;
|
||||
font-family: "Courier New", monospace; }
|
||||
.endpoint p { margin: 5px 0; opacity: 0.9; }
|
||||
.test-links { text-align: center; margin: 20px 0; }
|
||||
.test-links a { display: inline-block; margin: 5px 10px; padding: 10px 20px;
|
||||
background: rgba(255,255,255,0.2); color: white; text-decoration: none;
|
||||
border-radius: 25px; transition: all 0.3s ease; }
|
||||
.test-links a:hover { background: rgba(255,255,255,0.3); transform: translateY(-2px); }
|
||||
.footer { text-align: center; margin-top: 40px; opacity: 0.7; font-size: 0.9em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header-nav" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; padding-bottom: 20px; border-bottom: 1px solid rgba(255,255,255,0.2);"><div><h1 style="margin: 0; font-size: 2.5em;">📱 SMS API Server</h1><p style="margin: 0; opacity: 0.8; font-size: 1.1em;">Android Termux Interface</p></div><div class="nav-links" style="display: flex; gap: 15px;"><a href="http://10.0.0.190:8080" style="background: rgba(255,255,255,0.2); padding: 8px 16px; border-radius: 20px; color: white; text-decoration: none; font-size: 0.9em; transition: all 0.3s ease;">🏠 Homelab</a><a href="http://10.0.0.193:5000" style="background: rgba(255,255,255,0.2); padding: 8px 16px; border-radius: 20px; color: white; text-decoration: none; font-size: 0.9em; transition: all 0.3s ease;">📊 Monitor</a></div></div>
|
||||
<h2>🚀 Running on Android (Termux)</h2>
|
||||
|
||||
<div class="status">
|
||||
<h3>✅ Server Status: Operational</h3>
|
||||
<p><strong>Device IP:</strong> {{ device_ip }}</p>
|
||||
<p><strong>Port:</strong> 5001</p>
|
||||
<p><strong>Environment:</strong> Termux on Android</p>
|
||||
</div>
|
||||
|
||||
<h3>🔗 API Endpoints</h3>
|
||||
|
||||
<div class="endpoint">
|
||||
<h3>📊 Health Check</h3>
|
||||
<p><code>GET /health</code></p>
|
||||
<p>Returns server status, uptime, and message statistics</p>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<h3>📱 Send SMS</h3>
|
||||
<p><code>POST /api/sms/send</code></p>
|
||||
<p>Send SMS messages with name substitution support</p>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<h3>🔋 Battery Status</h3>
|
||||
<p><code>GET /api/device/battery</code></p>
|
||||
<p>Get real-time Android device battery information</p>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<h3>📍 Location</h3>
|
||||
<p><code>GET /api/device/location</code></p>
|
||||
<p>Get GPS coordinates (with permissions)</p>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<h3>ℹ️ Device Info</h3>
|
||||
<p><code>GET /api/device/info</code></p>
|
||||
<p>System information and device details</p>
|
||||
</div>
|
||||
|
||||
<div class="test-links">
|
||||
<h3>🧪 Quick Tests</h3>
|
||||
<a href="/health">📊 Health Check</a>
|
||||
<a href="/api/device/battery">🔋 Battery</a>
|
||||
<a href="/api/device/info">ℹ️ Device Info</a>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>🏠 Part of SMS Campaign Manager System</p>
|
||||
<p>🖥️ Main Interface: <a href="http://{{ homelab_ip }}:5000" style="color: #fff;">http://{{ homelab_ip }}:5000</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
""", device_ip="10.0.0.193", homelab_ip="10.0.0.190")
|
||||
|
||||
|
||||
|
||||
@app.route('/health', methods=['GET'])
|
||||
def health_check():
|
||||
"""Health check endpoint"""
|
||||
uptime = time.time() - sms_server.start_time
|
||||
|
||||
return jsonify({
|
||||
'status': 'healthy',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'uptime_seconds': int(uptime),
|
||||
'messages_sent': sms_server.message_count,
|
||||
'version': '1.0.0'
|
||||
})
|
||||
|
||||
@app.route('/api/sms/send', methods=['POST'])
|
||||
def send_sms():
|
||||
"""Send SMS message via Termux API"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'success': False, 'error': 'JSON data required'}), 400
|
||||
|
||||
# Extract parameters
|
||||
phone = data.get('phone', '').strip()
|
||||
message = data.get('message', '').strip()
|
||||
name = data.get('name', '')
|
||||
|
||||
# Message template substitution (like existing ui.sh)
|
||||
if name and '{name}' in message:
|
||||
message = message.replace('{name}', name)
|
||||
|
||||
# Send SMS
|
||||
result = sms_server.send_sms(phone, message)
|
||||
|
||||
status_code = 200 if result['success'] else 400
|
||||
return jsonify(result), status_code
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"SMS send error: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/sms/list', methods=['GET'])
|
||||
def list_sms():
|
||||
"""List SMS messages"""
|
||||
try:
|
||||
limit = request.args.get('limit', '10')
|
||||
offset = request.args.get('offset', '0')
|
||||
|
||||
command = ['termux-sms-list', '-l', limit, '-o', offset]
|
||||
result = sms_server.execute_termux_command(command)
|
||||
|
||||
if result['success']:
|
||||
try:
|
||||
sms_data = json.loads(result['stdout']) if result['stdout'] else []
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'messages': sms_data,
|
||||
'count': len(sms_data)
|
||||
})
|
||||
except json.JSONDecodeError:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'messages': result['stdout'],
|
||||
'raw_output': True
|
||||
})
|
||||
else:
|
||||
return jsonify({'success': False, 'error': result['error']}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"SMS list error: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/device/battery', methods=['GET'])
|
||||
def get_battery_status():
|
||||
"""Get device battery status"""
|
||||
try:
|
||||
result = sms_server.execute_termux_command(['termux-battery-status'])
|
||||
|
||||
if result['success']:
|
||||
battery_data = json.loads(result['stdout'])
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'battery': battery_data,
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
else:
|
||||
return jsonify({'success': False, 'error': result['error']}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Battery status error: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/device/location', methods=['GET'])
|
||||
def get_location():
|
||||
"""Get device GPS location"""
|
||||
try:
|
||||
result = sms_server.execute_termux_command(['termux-location'])
|
||||
|
||||
if result['success'] and result['stdout']:
|
||||
try:
|
||||
location_data = json.loads(result['stdout'])
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'location': location_data,
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
except json.JSONDecodeError:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'location': result['stdout'],
|
||||
'raw_output': True
|
||||
})
|
||||
else:
|
||||
return jsonify({'success': False, 'error': 'Location unavailable'}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Location error: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/device/info', methods=['GET'])
|
||||
def get_device_info():
|
||||
"""Get general device information"""
|
||||
try:
|
||||
# Get multiple device stats
|
||||
battery_result = sms_server.execute_termux_command(['termux-battery-status'])
|
||||
|
||||
info = {
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'uptime': time.time() - sms_server.start_time,
|
||||
'messages_sent': sms_server.message_count,
|
||||
'api_version': '1.0.0',
|
||||
'termux_api_available': True
|
||||
}
|
||||
|
||||
if battery_result['success']:
|
||||
try:
|
||||
battery_data = json.loads(battery_result['stdout'])
|
||||
info['battery'] = battery_data
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
return jsonify({'success': True, 'device_info': info})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Device info error: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/campaign/notify', methods=['POST'])
|
||||
def campaign_notification():
|
||||
"""Send notification about campaign status"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
title = data.get('title', 'SMS Campaign')
|
||||
message = data.get('message', 'Campaign update')
|
||||
|
||||
result = sms_server.execute_termux_command([
|
||||
'termux-notification',
|
||||
'--title', title,
|
||||
'--content', message
|
||||
])
|
||||
|
||||
return jsonify({
|
||||
'success': result['success'],
|
||||
'error': result.get('error')
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Notification error: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Create logs directory
|
||||
os.makedirs('/data/data/com.termux/files/home/logs', exist_ok=True)
|
||||
|
||||
logger.info("Starting Termux SMS API Server...")
|
||||
logger.info(f"Available commands: {CONFIG['ALLOWED_COMMANDS']}")
|
||||
|
||||
# Get local IP for display
|
||||
try:
|
||||
ip_result = subprocess.run([
|
||||
'ifconfig', '2>/dev/null', '|', 'grep', '-A1', 'wlan0', '|',
|
||||
'grep', 'inet', '|', 'awk', '{print $2}', '|', 'cut', '-d:', '-f2'
|
||||
], shell=True, capture_output=True, text=True)
|
||||
local_ip = ip_result.stdout.strip() or '10.0.0.193'
|
||||
except:
|
||||
local_ip = '10.0.0.193'
|
||||
|
||||
print(f"""
|
||||
🚀 Termux SMS API Server Starting
|
||||
📱 Device IP: {local_ip}
|
||||
🌐 API Base URL: http://{local_ip}:5001
|
||||
🔗 Health Check: http://{local_ip}:5001/health
|
||||
📞 SMS Endpoint: http://{local_ip}:5001/api/sms/send
|
||||
🔋 Battery API: http://{local_ip}:5001/api/device/battery
|
||||
|
||||
Access from Ubuntu homelab:
|
||||
curl http://{local_ip}:5001/health
|
||||
""")
|
||||
|
||||
app.run(host='0.0.0.0', port=5001, debug=False)
|
||||
BIN
data/campaign.db
BIN
data/campaign.db
Binary file not shown.
14661
logs/campaign.log
14661
logs/campaign.log
File diff suppressed because it is too large
Load Diff
@ -1,4 +0,0 @@
|
||||
phone,message,name
|
||||
7802921731,hi {name} just testing,Reed
|
||||
,,
|
||||
,,
|
||||
|
@ -1,8 +0,0 @@
|
||||
name,phone,message
|
||||
Quin,+1-825-461-6974,
|
||||
Brad,780 975 4537,
|
||||
Robert,7802936842,
|
||||
Ken,7809101334,
|
||||
Nasif,+15875240402,
|
||||
Rebecca,+15873360926,
|
||||
Haley,(438) 938-0733,
|
||||
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,653 +0,0 @@
|
||||
/**
|
||||
* Enhanced Conversation Manager - WhatsApp-style messaging interface
|
||||
* Integrates WebSocket for real-time updates and handles bidirectional SMS
|
||||
*/
|
||||
|
||||
class EnhancedConversationManager {
|
||||
constructor() {
|
||||
// State management
|
||||
this.conversations = [];
|
||||
this.selectedConversation = null;
|
||||
this.messages = [];
|
||||
this.conversationFilter = 'all';
|
||||
this.conversationSearch = '';
|
||||
this.newMessage = '';
|
||||
this.sendingMessage = false;
|
||||
this.hasMoreMessages = false;
|
||||
this.currentPage = 1;
|
||||
this.socket = null;
|
||||
this.isConnected = false;
|
||||
|
||||
// UI elements (will be set by Alpine.js)
|
||||
this.messagesContainer = null;
|
||||
|
||||
// Initialize
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
console.log('🚀 Initializing Enhanced Conversation Manager...');
|
||||
|
||||
try {
|
||||
// Setup WebSocket connection
|
||||
await this.setupWebSocket();
|
||||
|
||||
// Load conversations
|
||||
await this.loadConversations();
|
||||
|
||||
// Setup periodic refresh as fallback
|
||||
this.startPeriodicRefresh();
|
||||
|
||||
console.log('✅ Enhanced Conversation Manager initialized successfully');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to initialize conversation manager:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async setupWebSocket() {
|
||||
// Setup WebSocket connection for real-time updates
|
||||
try {
|
||||
// Connect to WebSocket server
|
||||
this.socket = io({
|
||||
transports: ['websocket', 'polling'],
|
||||
upgrade: true,
|
||||
rememberUpgrade: true
|
||||
});
|
||||
|
||||
// Connection events
|
||||
this.socket.on('connect', () => {
|
||||
console.log('🔌 Connected to conversation service');
|
||||
this.isConnected = true;
|
||||
this.showNotification('Connected to real-time messaging', 'success');
|
||||
});
|
||||
|
||||
this.socket.on('disconnect', (reason) => {
|
||||
console.log('🔌 Disconnected from conversation service:', reason);
|
||||
this.isConnected = false;
|
||||
this.showNotification('Disconnected from real-time messaging', 'warning');
|
||||
});
|
||||
|
||||
this.socket.on('connect_error', (error) => {
|
||||
console.error('🔌 Connection error:', error);
|
||||
this.isConnected = false;
|
||||
});
|
||||
|
||||
// Message events
|
||||
this.socket.on('new_message', (data) => {
|
||||
this.handleNewMessage(data);
|
||||
});
|
||||
|
||||
this.socket.on('message_status_update', (data) => {
|
||||
this.updateMessageStatus(data.message_id, data.status);
|
||||
});
|
||||
|
||||
this.socket.on('conversation_update', (data) => {
|
||||
this.handleConversationUpdate(data);
|
||||
});
|
||||
|
||||
// Sync events
|
||||
this.socket.on('sync_status', (data) => {
|
||||
console.log('📡 Sync status:', data.status, data.details);
|
||||
if (data.status === 'completed') {
|
||||
this.loadConversations();
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to setup WebSocket:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async loadConversations() {
|
||||
//Load conversation list from server//
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
limit: '50',
|
||||
search: this.conversationSearch
|
||||
});
|
||||
|
||||
if (this.conversationFilter === 'starred') {
|
||||
params.append('starred', 'true');
|
||||
} else if (this.conversationFilter !== 'all') {
|
||||
params.append('status', this.conversationFilter);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/conversations/enhanced?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.conversations = data.conversations;
|
||||
this.renderConversationList();
|
||||
} else {
|
||||
console.error('Failed to load conversations:', data.error);
|
||||
this.showNotification('Failed to load conversations', 'error');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading conversations:', error);
|
||||
this.showNotification('Network error loading conversations', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async selectConversation(conversationId) {
|
||||
//Select and load a conversation//
|
||||
try {
|
||||
// Leave previous conversation room
|
||||
if (this.selectedConversation && this.socket) {
|
||||
this.socket.emit('leave_conversation', {
|
||||
conversation_id: this.selectedConversation.id
|
||||
});
|
||||
}
|
||||
|
||||
// Find conversation
|
||||
const conversation = this.conversations.find(c => c.id === conversationId);
|
||||
if (!conversation) {
|
||||
this.showNotification('Conversation not found', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedConversation = conversation;
|
||||
this.messages = [];
|
||||
this.currentPage = 1;
|
||||
this.hasMoreMessages = false;
|
||||
|
||||
// Join new conversation room
|
||||
if (this.socket) {
|
||||
this.socket.emit('join_conversation', {
|
||||
conversation_id: conversationId
|
||||
});
|
||||
}
|
||||
|
||||
// Load messages
|
||||
await this.loadMessages();
|
||||
|
||||
// Mark as read
|
||||
await this.markAsRead(conversationId);
|
||||
|
||||
// Update UI
|
||||
this.renderConversationView();
|
||||
this.scrollToBottom();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error selecting conversation:', error);
|
||||
this.showNotification('Failed to load conversation', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async loadMessages() {
|
||||
//Load messages for current conversation//
|
||||
if (!this.selectedConversation) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/conversations/${this.selectedConversation.id}/messages?page=${this.currentPage}&per_page=50`
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
if (this.currentPage === 1) {
|
||||
this.messages = data.messages;
|
||||
} else {
|
||||
// Prepend older messages for pagination
|
||||
this.messages = [...data.messages, ...this.messages];
|
||||
}
|
||||
|
||||
this.hasMoreMessages = data.has_more;
|
||||
this.renderMessages();
|
||||
|
||||
// Scroll to bottom only on first load
|
||||
if (this.currentPage === 1) {
|
||||
setTimeout(() => this.scrollToBottom(), 100);
|
||||
}
|
||||
|
||||
} else {
|
||||
console.error('Failed to load messages:', data.error);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading messages:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async loadMoreMessages() {
|
||||
//Load more historical messages//
|
||||
if (!this.hasMoreMessages || !this.selectedConversation) return;
|
||||
|
||||
const scrollHeight = this.getMessagesContainer().scrollHeight;
|
||||
|
||||
this.currentPage++;
|
||||
await this.loadMessages();
|
||||
|
||||
// Maintain scroll position after loading older messages
|
||||
setTimeout(() => {
|
||||
const container = this.getMessagesContainer();
|
||||
const newScrollHeight = container.scrollHeight;
|
||||
container.scrollTop = newScrollHeight - scrollHeight;
|
||||
}, 50);
|
||||
}
|
||||
|
||||
async sendMessage() {
|
||||
//Send a new message//
|
||||
if (!this.newMessage.trim() || this.sendingMessage || !this.selectedConversation) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendingMessage = true;
|
||||
const messageText = this.newMessage.trim();
|
||||
this.newMessage = ''; // Clear input immediately
|
||||
|
||||
try {
|
||||
// Send via WebSocket for real-time updates
|
||||
if (this.socket && this.isConnected) {
|
||||
this.socket.emit('send_message', {
|
||||
conversation_id: this.selectedConversation.id,
|
||||
phone: this.selectedConversation.phone,
|
||||
message: messageText
|
||||
});
|
||||
} else {
|
||||
// Fallback to HTTP API
|
||||
const response = await fetch(
|
||||
`/api/conversations/${this.selectedConversation.id}/send`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: messageText })
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Add optimistic message
|
||||
const optimisticMessage = {
|
||||
id: `temp_${Date.now()}`,
|
||||
message: messageText,
|
||||
direction: 'outbound',
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
status: 'pending'
|
||||
};
|
||||
|
||||
this.messages.push(optimisticMessage);
|
||||
this.renderMessages();
|
||||
this.scrollToBottom();
|
||||
} else {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error);
|
||||
this.showNotification(`Failed to send message: ${error.message}`, 'error');
|
||||
|
||||
// Restore message to input on error
|
||||
this.newMessage = messageText;
|
||||
} finally {
|
||||
this.sendingMessage = false;
|
||||
}
|
||||
}
|
||||
|
||||
handleNewMessage(data) {
|
||||
//Handle incoming message from WebSocket//
|
||||
try {
|
||||
// Update current conversation if it matches
|
||||
if (this.selectedConversation && this.selectedConversation.id === data.conversation_id) {
|
||||
// Remove optimistic message if it exists
|
||||
const tempIndex = this.messages.findIndex(m => m.id.toString().startsWith('temp_'));
|
||||
if (tempIndex > -1 && data.direction === 'outbound') {
|
||||
this.messages.splice(tempIndex, 1);
|
||||
}
|
||||
|
||||
// Add the real message
|
||||
this.messages.push(data);
|
||||
this.renderMessages();
|
||||
this.scrollToBottom();
|
||||
|
||||
// Mark as read if window is focused
|
||||
if (document.hasFocus()) {
|
||||
this.markAsRead(data.conversation_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Update conversation list
|
||||
this.updateConversationInList(data);
|
||||
|
||||
// Show notification for new messages
|
||||
if (data.direction === 'inbound') {
|
||||
const conversation = this.conversations.find(c => c.id === data.conversation_id);
|
||||
const senderName = conversation?.display_name || data.phone;
|
||||
|
||||
this.showNotification(`New message from ${senderName}`, 'info');
|
||||
|
||||
// Play notification sound if available
|
||||
this.playNotificationSound();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error handling new message:', error);
|
||||
}
|
||||
}
|
||||
|
||||
updateMessageStatus(messageId, status) {
|
||||
//Update message status in current view//
|
||||
const message = this.messages.find(m => m.id == messageId);
|
||||
if (message) {
|
||||
message.status = status;
|
||||
this.renderMessages();
|
||||
}
|
||||
}
|
||||
|
||||
handleConversationUpdate(data) {
|
||||
//Handle conversation-level updates//
|
||||
const conversation = this.conversations.find(c => c.id === data.conversation_id);
|
||||
if (conversation) {
|
||||
Object.assign(conversation, data);
|
||||
this.renderConversationList();
|
||||
}
|
||||
}
|
||||
|
||||
updateConversationInList(messageData) {
|
||||
//Update conversation in the list with new message info//
|
||||
const conversation = this.conversations.find(c => c.id === messageData.conversation_id);
|
||||
if (conversation) {
|
||||
conversation.last_message = messageData.message;
|
||||
conversation.last_message_time = messageData.timestamp;
|
||||
conversation.last_message_direction = messageData.direction;
|
||||
|
||||
if (messageData.direction === 'inbound') {
|
||||
conversation.unread_count = (conversation.unread_count || 0) + 1;
|
||||
}
|
||||
|
||||
// Move to top of list
|
||||
const index = this.conversations.indexOf(conversation);
|
||||
if (index > 0) {
|
||||
this.conversations.splice(index, 1);
|
||||
this.conversations.unshift(conversation);
|
||||
}
|
||||
|
||||
this.renderConversationList();
|
||||
}
|
||||
}
|
||||
|
||||
async toggleStar(conversationId) {
|
||||
//Toggle conversation starred status//
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/conversations/${conversationId}/star`,
|
||||
{ method: 'PUT' }
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Update local state
|
||||
const conversation = this.conversations.find(c => c.id === conversationId);
|
||||
if (conversation) {
|
||||
conversation.is_starred = data.is_starred;
|
||||
}
|
||||
|
||||
if (this.selectedConversation && this.selectedConversation.id === conversationId) {
|
||||
this.selectedConversation.is_starred = data.is_starred;
|
||||
}
|
||||
|
||||
this.renderConversationList();
|
||||
this.renderConversationView();
|
||||
|
||||
} else {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error toggling star:', error);
|
||||
this.showNotification('Failed to update star status', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async syncConversation(conversationId) {
|
||||
//Manually sync conversation history//
|
||||
try {
|
||||
if (this.socket && this.isConnected) {
|
||||
this.socket.emit('sync_conversation', {
|
||||
conversation_id: conversationId
|
||||
});
|
||||
} else {
|
||||
const response = await fetch(
|
||||
`/api/conversations/${conversationId}/sync`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.showNotification('Syncing conversation history...', 'info');
|
||||
} else {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
}
|
||||
|
||||
// Reload messages after a delay
|
||||
setTimeout(() => {
|
||||
if (this.selectedConversation && this.selectedConversation.id === conversationId) {
|
||||
this.currentPage = 1;
|
||||
this.loadMessages();
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error syncing conversation:', error);
|
||||
this.showNotification('Failed to sync conversation', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async syncAllConversations() {
|
||||
//Sync all conversations with phone//
|
||||
try {
|
||||
const response = await fetch('/api/conversations/sync-all', { method: 'POST' });
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.showNotification('Syncing all conversations...', 'info');
|
||||
|
||||
// Reload conversation list after delay
|
||||
setTimeout(() => {
|
||||
this.loadConversations();
|
||||
}, 5000);
|
||||
} else {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error syncing all conversations:', error);
|
||||
this.showNotification('Failed to sync conversations', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async markAsRead(conversationId) {
|
||||
//Mark conversation as read//
|
||||
try {
|
||||
await fetch(`/api/conversations/${conversationId}/mark-read`, { method: 'PUT' });
|
||||
|
||||
// Update local unread count
|
||||
const conversation = this.conversations.find(c => c.id === conversationId);
|
||||
if (conversation) {
|
||||
conversation.unread_count = 0;
|
||||
this.renderConversationList();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error marking as read:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Computed properties and filters
|
||||
get filteredConversations() {
|
||||
//Get filtered conversation list based on current filters//
|
||||
let filtered = [...this.conversations];
|
||||
|
||||
// Apply status filter
|
||||
if (this.conversationFilter === 'starred') {
|
||||
filtered = filtered.filter(c => c.is_starred);
|
||||
} else if (this.conversationFilter === 'unread') {
|
||||
filtered = filtered.filter(c => (c.unread_count || 0) > 0);
|
||||
} else if (this.conversationFilter !== 'all') {
|
||||
filtered = filtered.filter(c => c.status === this.conversationFilter);
|
||||
}
|
||||
|
||||
// Apply search filter
|
||||
if (this.conversationSearch.trim()) {
|
||||
const search = this.conversationSearch.toLowerCase().trim();
|
||||
filtered = filtered.filter(c =>
|
||||
c.phone.includes(search) ||
|
||||
(c.contact_name && c.contact_name.toLowerCase().includes(search)) ||
|
||||
(c.display_name && c.display_name.toLowerCase().includes(search)) ||
|
||||
(c.last_message && c.last_message.toLowerCase().includes(search))
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
// Utility methods
|
||||
formatPhone(phone) {
|
||||
//Format phone number for display//
|
||||
if (!phone) return '';
|
||||
|
||||
const cleaned = phone.replace(/\D/g, '');
|
||||
if (cleaned.length === 10) {
|
||||
return `(${cleaned.slice(0,3)}) ${cleaned.slice(3,6)}-${cleaned.slice(6)}`;
|
||||
}
|
||||
return phone;
|
||||
}
|
||||
|
||||
formatTime(timestamp) {
|
||||
//Format timestamp for conversation list//
|
||||
if (!timestamp) return '';
|
||||
|
||||
const date = new Date(timestamp * 1000);
|
||||
const now = new Date();
|
||||
const diff = (now - date) / 1000;
|
||||
|
||||
if (diff < 60) return 'now';
|
||||
if (diff < 3600) return `${Math.floor(diff/60)}m`;
|
||||
if (diff < 86400) return `${Math.floor(diff/3600)}h`;
|
||||
if (diff < 604800) return `${Math.floor(diff/86400)}d`;
|
||||
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
formatMessageTime(timestamp) {
|
||||
//Format timestamp for message display//
|
||||
if (!timestamp) return '';
|
||||
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
}
|
||||
|
||||
getMessagesContainer() {
|
||||
//Get messages container element//
|
||||
if (!this.messagesContainer) {
|
||||
this.messagesContainer = document.getElementById('messages-container');
|
||||
}
|
||||
return this.messagesContainer;
|
||||
}
|
||||
|
||||
scrollToBottom() {
|
||||
//Scroll messages to bottom//
|
||||
const container = this.getMessagesContainer();
|
||||
if (container) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
showNotification(message, type = 'info') {
|
||||
//Show toast notification//
|
||||
const colors = {
|
||||
success: 'bg-green-500',
|
||||
error: 'bg-red-500',
|
||||
warning: 'bg-yellow-500',
|
||||
info: 'bg-blue-500'
|
||||
};
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `fixed bottom-4 right-4 ${colors[type]} text-white px-4 py-2 rounded-lg shadow-lg z-50 max-w-sm`;
|
||||
toast.textContent = message;
|
||||
|
||||
document.body.appendChild(toast);
|
||||
|
||||
// Auto-remove after 3 seconds
|
||||
setTimeout(() => {
|
||||
if (toast.parentNode) {
|
||||
toast.remove();
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
playNotificationSound() {
|
||||
//Play notification sound if available//
|
||||
try {
|
||||
// Simple beep using Web Audio API
|
||||
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const gainNode = audioContext.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(audioContext.destination);
|
||||
|
||||
oscillator.frequency.value = 800;
|
||||
oscillator.type = 'sine';
|
||||
|
||||
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2);
|
||||
|
||||
oscillator.start();
|
||||
oscillator.stop(audioContext.currentTime + 0.2);
|
||||
} catch (error) {
|
||||
// Silently fail if audio is not supported
|
||||
}
|
||||
}
|
||||
|
||||
startPeriodicRefresh() {
|
||||
//Start periodic refresh as fallback//
|
||||
setInterval(() => {
|
||||
if (!this.isConnected) {
|
||||
console.log('🔄 Periodic refresh (WebSocket disconnected)');
|
||||
this.loadConversations();
|
||||
|
||||
if (this.selectedConversation) {
|
||||
this.loadMessages();
|
||||
}
|
||||
}
|
||||
}, 30000); // Every 30 seconds
|
||||
}
|
||||
|
||||
// Rendering methods (to be called by Alpine.js)
|
||||
renderConversationList() {
|
||||
// This will trigger Alpine.js reactivity
|
||||
// The actual rendering is handled by the HTML template
|
||||
}
|
||||
|
||||
renderMessages() {
|
||||
// This will trigger Alpine.js reactivity
|
||||
// The actual rendering is handled by the HTML template
|
||||
}
|
||||
|
||||
renderConversationView() {
|
||||
// This will trigger Alpine.js reactivity
|
||||
// The actual rendering is handled by the HTML template
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
destroy() {
|
||||
//Cleanup resources//
|
||||
if (this.socket) {
|
||||
this.socket.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in HTML
|
||||
window.EnhancedConversationManager = EnhancedConversationManager;
|
||||
@ -822,3 +822,431 @@ function campaignApp() {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Enhanced Conversations Data Function
|
||||
function conversationData() {
|
||||
return {
|
||||
// Initialize enhanced conversation manager
|
||||
manager: null,
|
||||
|
||||
// State properties
|
||||
conversations: [],
|
||||
selectedConversation: null,
|
||||
messages: [],
|
||||
conversationFilter: 'all',
|
||||
conversationSearch: '',
|
||||
newMessage: '',
|
||||
sendingMessage: false,
|
||||
hasMoreMessages: false,
|
||||
loadingMessages: false,
|
||||
syncing: false,
|
||||
|
||||
// Computed properties
|
||||
get filteredConversations() {
|
||||
let filtered = this.conversations;
|
||||
|
||||
// Apply search filter
|
||||
if (this.conversationSearch) {
|
||||
const search = this.conversationSearch.toLowerCase();
|
||||
filtered = filtered.filter(conv =>
|
||||
(conv.contact_name && conv.contact_name.toLowerCase().includes(search)) ||
|
||||
conv.phone.includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
// Apply status filter
|
||||
switch (this.conversationFilter) {
|
||||
case 'unread':
|
||||
filtered = filtered.filter(conv => conv.unread_count > 0);
|
||||
break;
|
||||
case 'starred':
|
||||
filtered = filtered.filter(conv => conv.is_starred);
|
||||
break;
|
||||
default:
|
||||
// 'all' - no additional filtering
|
||||
break;
|
||||
}
|
||||
|
||||
return filtered;
|
||||
},
|
||||
|
||||
get unreadCount() {
|
||||
return this.conversations.reduce((total, conv) => total + (conv.unread_count || 0), 0);
|
||||
},
|
||||
|
||||
// Initialize
|
||||
async init() {
|
||||
await this.loadConversations();
|
||||
this.setupWebSocket();
|
||||
},
|
||||
|
||||
// Load conversations from API
|
||||
async loadConversations() {
|
||||
try {
|
||||
const response = await fetch('/api/conversations/enhanced/');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.conversations = data.conversations || [];
|
||||
console.log('Loaded conversations:', this.conversations.length);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading conversations:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Select and load conversation messages
|
||||
async selectConversation(conversationId) {
|
||||
try {
|
||||
this.loadingMessages = true;
|
||||
|
||||
// Find and set selected conversation
|
||||
this.selectedConversation = this.conversations.find(conv => conv.phone === conversationId);
|
||||
|
||||
if (this.selectedConversation) {
|
||||
// Load messages for this conversation
|
||||
await this.loadMessages();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error selecting conversation:', error);
|
||||
} finally {
|
||||
this.loadingMessages = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Load messages for selected conversation
|
||||
async loadMessages() {
|
||||
if (!this.selectedConversation) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/conversations/enhanced/${this.selectedConversation.phone}/messages`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.messages = data.messages || [];
|
||||
this.hasMoreMessages = data.has_more || false;
|
||||
|
||||
// Scroll to bottom after messages load
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.messagesContainer) {
|
||||
this.$refs.messagesContainer.scrollTop = this.$refs.messagesContainer.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading messages:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Send a new message
|
||||
async sendMessage() {
|
||||
if (!this.newMessage.trim() || !this.selectedConversation || this.sendingMessage) return;
|
||||
|
||||
try {
|
||||
this.sendingMessage = true;
|
||||
|
||||
const response = await fetch(`/api/conversations/enhanced/${this.selectedConversation.phone}/send`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: this.newMessage.trim()
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Add optimistic message to UI
|
||||
const newMsg = {
|
||||
id: Date.now(), // Temporary ID
|
||||
message: this.newMessage.trim(),
|
||||
direction: 'outbound',
|
||||
status: 'pending',
|
||||
sent_at: new Date().toISOString(),
|
||||
phone: this.selectedConversation.phone
|
||||
};
|
||||
|
||||
this.messages.push(newMsg);
|
||||
this.newMessage = '';
|
||||
|
||||
// Scroll to bottom
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.messagesContainer) {
|
||||
this.$refs.messagesContainer.scrollTop = this.$refs.messagesContainer.scrollHeight;
|
||||
}
|
||||
});
|
||||
|
||||
// Update conversation in list
|
||||
this.selectedConversation.message_count = (this.selectedConversation.message_count || 0) + 1;
|
||||
this.selectedConversation.last_message_time = Date.now() / 1000;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sending message:', error);
|
||||
} finally {
|
||||
this.sendingMessage = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Filter and search functions
|
||||
setFilter(filter) {
|
||||
this.conversationFilter = filter;
|
||||
},
|
||||
|
||||
searchConversations() {
|
||||
// Reactive filtering happens automatically via computed property
|
||||
},
|
||||
|
||||
// Star/unstar conversation
|
||||
async toggleStar(conversationId) {
|
||||
try {
|
||||
const response = await fetch(`/api/conversations/enhanced/${conversationId}/star`, {
|
||||
method: 'PUT'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Update local state
|
||||
const conv = this.conversations.find(c => c.phone === conversationId);
|
||||
if (conv) {
|
||||
conv.is_starred = !conv.is_starred;
|
||||
}
|
||||
if (this.selectedConversation && this.selectedConversation.phone === conversationId) {
|
||||
this.selectedConversation.is_starred = !this.selectedConversation.is_starred;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling star:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Sync functions
|
||||
async syncConversation(conversationId) {
|
||||
if (this.syncing) return;
|
||||
|
||||
try {
|
||||
this.syncing = true;
|
||||
|
||||
// Trigger sync for specific conversation
|
||||
await fetch(`/api/conversations/enhanced/${conversationId}/sync`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
// Reload messages after sync
|
||||
if (this.selectedConversation && this.selectedConversation.phone === conversationId) {
|
||||
await this.loadMessages();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error syncing conversation:', error);
|
||||
} finally {
|
||||
this.syncing = false;
|
||||
}
|
||||
},
|
||||
|
||||
async syncAllConversations() {
|
||||
if (this.syncing) return;
|
||||
|
||||
try {
|
||||
this.syncing = true;
|
||||
|
||||
// Trigger full sync
|
||||
await fetch('/api/responses/sync', {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
// Reload conversations
|
||||
await this.loadConversations();
|
||||
|
||||
// Reload current conversation messages if any selected
|
||||
if (this.selectedConversation) {
|
||||
await this.loadMessages();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error syncing all conversations:', error);
|
||||
} finally {
|
||||
this.syncing = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Setup WebSocket for real-time updates
|
||||
setupWebSocket() {
|
||||
if (typeof io === 'undefined') {
|
||||
console.warn('Socket.IO not available, real-time updates disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const socket = io();
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('Connected to conversation WebSocket');
|
||||
});
|
||||
|
||||
socket.on('new_message', (data) => {
|
||||
this.handleNewMessage(data);
|
||||
});
|
||||
|
||||
socket.on('message_status_update', (data) => {
|
||||
this.updateMessageStatus(data.message_id, data.status);
|
||||
});
|
||||
|
||||
socket.on('conversation_update', (data) => {
|
||||
this.handleConversationUpdate(data);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error setting up WebSocket:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Handle real-time message updates
|
||||
handleNewMessage(messageData) {
|
||||
// Add to messages if it's for the current conversation
|
||||
if (this.selectedConversation && messageData.phone === this.selectedConversation.phone) {
|
||||
this.messages.push(messageData);
|
||||
|
||||
// Scroll to bottom
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.messagesContainer) {
|
||||
this.$refs.messagesContainer.scrollTop = this.$refs.messagesContainer.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update conversation in list
|
||||
this.updateConversationInList(messageData);
|
||||
},
|
||||
|
||||
updateMessageStatus(messageId, status) {
|
||||
// Update message status in current conversation
|
||||
const message = this.messages.find(m => m.id === messageId);
|
||||
if (message) {
|
||||
message.status = status;
|
||||
}
|
||||
},
|
||||
|
||||
handleConversationUpdate(data) {
|
||||
const conv = this.conversations.find(c => c.phone === data.phone);
|
||||
if (conv) {
|
||||
Object.assign(conv, data);
|
||||
}
|
||||
},
|
||||
|
||||
updateConversationInList(messageData) {
|
||||
let conv = this.conversations.find(c => c.phone === messageData.phone);
|
||||
|
||||
if (!conv) {
|
||||
// Create new conversation if it doesn't exist
|
||||
conv = {
|
||||
phone: messageData.phone,
|
||||
contact_name: messageData.name || messageData.phone,
|
||||
message_count: 1,
|
||||
unread_count: messageData.direction === 'inbound' ? 1 : 0,
|
||||
is_starred: false,
|
||||
last_message_time: messageData.timestamp || Date.now() / 1000
|
||||
};
|
||||
this.conversations.unshift(conv);
|
||||
} else {
|
||||
// Update existing conversation
|
||||
conv.message_count = (conv.message_count || 0) + 1;
|
||||
if (messageData.direction === 'inbound') {
|
||||
conv.unread_count = (conv.unread_count || 0) + 1;
|
||||
}
|
||||
conv.last_message_time = messageData.timestamp || Date.now() / 1000;
|
||||
|
||||
// Move to top of list
|
||||
const index = this.conversations.indexOf(conv);
|
||||
if (index > 0) {
|
||||
this.conversations.splice(index, 1);
|
||||
this.conversations.unshift(conv);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Utility functions
|
||||
getInitials(conversation) {
|
||||
if (!conversation) return '';
|
||||
|
||||
const name = conversation.contact_name || conversation.phone;
|
||||
if (name === conversation.phone) {
|
||||
// For phone numbers, just use the first two digits
|
||||
return name.slice(-4, -2) || name.slice(0, 2) || '??';
|
||||
}
|
||||
|
||||
const words = name.split(' ').filter(word => word.length > 0);
|
||||
if (words.length >= 2) {
|
||||
return (words[0][0] + words[1][0]).toUpperCase();
|
||||
} else if (words.length === 1) {
|
||||
return words[0].slice(0, 2).toUpperCase();
|
||||
}
|
||||
return '??';
|
||||
},
|
||||
|
||||
formatPhone(phone) {
|
||||
if (!phone) return '';
|
||||
|
||||
// Remove any non-digit characters
|
||||
const cleaned = phone.replace(/\D/g, '');
|
||||
|
||||
// Format as (xxx) xxx-xxxx for 10 digit numbers
|
||||
if (cleaned.length === 10) {
|
||||
return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6)}`;
|
||||
}
|
||||
|
||||
return phone;
|
||||
},
|
||||
|
||||
formatTime(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
|
||||
const date = new Date(timestamp * 1000);
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins}m`;
|
||||
if (diffHours < 24) return `${diffHours}h`;
|
||||
if (diffDays < 7) return `${diffDays}d`;
|
||||
|
||||
return date.toLocaleDateString();
|
||||
},
|
||||
|
||||
formatMessageTime(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
|
||||
let date;
|
||||
if (typeof timestamp === 'string') {
|
||||
date = new Date(timestamp);
|
||||
} else {
|
||||
date = new Date(timestamp * 1000);
|
||||
}
|
||||
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
},
|
||||
|
||||
getStatusIcon(status) {
|
||||
switch (status) {
|
||||
case 'pending': return '⏳';
|
||||
case 'sent': return '✓';
|
||||
case 'delivered': return '✓✓';
|
||||
case 'failed': return '❌';
|
||||
default: return '';
|
||||
}
|
||||
},
|
||||
|
||||
getStatusColor(status) {
|
||||
switch (status) {
|
||||
case 'pending': return 'text-yellow-400';
|
||||
case 'sent': return 'text-blue-300';
|
||||
case 'delivered': return 'text-blue-200';
|
||||
case 'failed': return 'text-red-400';
|
||||
default: return 'text-gray-400';
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -250,10 +250,252 @@
|
||||
</div>
|
||||
|
||||
<!-- Conversations Tab -->
|
||||
<div x-show="activeTab === 'conversations'" class="p-6">
|
||||
<div id="conversations-container">
|
||||
<!-- Enhanced conversations component will be loaded here -->
|
||||
<include src="conversations_enhanced_component.html"></include>
|
||||
<div x-show="activeTab === 'conversations'" x-data="conversationData()" class="p-6">
|
||||
<div class="flex h-[calc(100vh-200px)]">
|
||||
|
||||
<!-- Left Panel: Conversation List -->
|
||||
<div class="w-1/3 border-r border-gray-200 overflow-hidden flex flex-col">
|
||||
|
||||
<!-- Search and Controls Header -->
|
||||
<div class="p-4 border-b border-gray-200 bg-white">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<div class="flex-1 relative">
|
||||
<input
|
||||
type="text"
|
||||
x-model="conversationSearch"
|
||||
@input="searchConversations()"
|
||||
placeholder="Search conversations..."
|
||||
class="w-full pl-10 pr-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
>
|
||||
<svg class="absolute left-3 top-2.5 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<button
|
||||
@click="syncAllConversations()"
|
||||
class="p-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
|
||||
title="Sync All Conversations"
|
||||
:disabled="syncing"
|
||||
>
|
||||
<svg class="w-4 h-4" :class="{'animate-spin': syncing}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filter Tabs -->
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
@click="setFilter('all')"
|
||||
:class="conversationFilter === 'all' ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 hover:bg-gray-200'"
|
||||
class="px-3 py-1.5 rounded-full text-xs font-medium transition-colors"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
@click="setFilter('unread')"
|
||||
:class="conversationFilter === 'unread' ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 hover:bg-gray-200'"
|
||||
class="px-3 py-1.5 rounded-full text-xs font-medium transition-colors"
|
||||
>
|
||||
Unread
|
||||
<span x-show="unreadCount > 0" x-text="unreadCount"
|
||||
class="ml-1 bg-red-500 text-white text-xs rounded-full px-1.5 py-0.5"></span>
|
||||
</button>
|
||||
<button
|
||||
@click="setFilter('starred')"
|
||||
:class="conversationFilter === 'starred' ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 hover:bg-gray-200'"
|
||||
class="px-3 py-1.5 rounded-full text-xs font-medium transition-colors"
|
||||
>
|
||||
Starred
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conversation List -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div class="divide-y divide-gray-100">
|
||||
<template x-for="conversation in filteredConversations" :key="conversation.phone">
|
||||
<div
|
||||
@click="selectConversation(conversation.phone)"
|
||||
:class="selectedConversation?.phone === conversation.phone ? 'bg-blue-50 border-r-2 border-blue-500' : 'hover:bg-gray-50'"
|
||||
class="p-4 cursor-pointer transition-colors"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Avatar -->
|
||||
<div class="w-12 h-12 rounded-full bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center flex-shrink-0 text-white font-semibold">
|
||||
<span x-text="getInitials(conversation)"></span>
|
||||
</div>
|
||||
|
||||
<!-- Conversation Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<h4 class="font-semibold text-gray-900 truncate text-sm"
|
||||
x-text="conversation.contact_name || formatPhone(conversation.phone)">
|
||||
</h4>
|
||||
<span class="text-xs text-gray-500" x-text="formatTime(conversation.last_message_time)"></span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-gray-600 truncate"
|
||||
x-text="conversation.message_count + ' messages'"></p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 ml-2">
|
||||
<!-- Star Icon -->
|
||||
<svg x-show="conversation.is_starred" class="w-4 h-4 text-yellow-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
|
||||
</svg>
|
||||
|
||||
<!-- Unread Badge -->
|
||||
<span x-show="conversation.unread_count > 0"
|
||||
x-text="conversation.unread_count"
|
||||
class="bg-red-500 text-white text-xs rounded-full px-2 py-0.5 min-w-[18px] text-center">
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div x-show="filteredConversations.length === 0" class="p-8 text-center">
|
||||
<div class="text-gray-400 mb-2">
|
||||
<svg class="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="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>
|
||||
<p class="text-gray-500">No conversations found</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Panel: Message View -->
|
||||
<div class="flex-1 flex flex-col">
|
||||
<!-- Chat Header -->
|
||||
<div x-show="selectedConversation" class="border-b border-gray-200 p-4 bg-white">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Avatar -->
|
||||
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-blue-400 to-blue-600 flex items-center justify-center text-white font-semibold text-sm">
|
||||
<span x-text="selectedConversation ? getInitials(selectedConversation) : ''"></span>
|
||||
</div>
|
||||
|
||||
<!-- Contact Info -->
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900"
|
||||
x-text="selectedConversation?.contact_name || formatPhone(selectedConversation?.phone)">
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500" x-text="formatPhone(selectedConversation?.phone)"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@click="toggleStar(selectedConversation?.phone)"
|
||||
class="p-2 rounded-full hover:bg-gray-100 transition-colors"
|
||||
:class="selectedConversation?.is_starred ? 'text-yellow-500' : 'text-gray-400'"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="syncConversation(selectedConversation?.phone)"
|
||||
class="p-2 rounded-full hover:bg-gray-100 text-gray-400 transition-colors"
|
||||
:disabled="syncing"
|
||||
>
|
||||
<svg class="w-5 h-5" :class="{'animate-spin': syncing}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages Area -->
|
||||
<div class="flex-1 overflow-y-auto p-4 bg-gray-50" x-ref="messagesContainer">
|
||||
<div x-show="!selectedConversation" class="flex items-center justify-center h-full">
|
||||
<div class="text-center">
|
||||
<div class="text-gray-400 mb-4">
|
||||
<svg class="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="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>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Select a conversation</h3>
|
||||
<p class="text-gray-500">Choose a conversation from the left to start messaging</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message List -->
|
||||
<div x-show="selectedConversation" class="space-y-4">
|
||||
<template x-for="message in messages" :key="message.id">
|
||||
<div :class="message.direction === 'outbound' ? 'flex justify-end' : 'flex justify-start'">
|
||||
<div :class="message.direction === 'outbound'
|
||||
? 'bg-blue-500 text-white max-w-xs lg:max-w-md rounded-l-2xl rounded-br-2xl'
|
||||
: 'bg-white border max-w-xs lg:max-w-md rounded-r-2xl rounded-bl-2xl'"
|
||||
class="px-4 py-2 shadow-sm">
|
||||
<p class="text-sm" x-text="message.message"></p>
|
||||
<div class="flex items-center justify-between mt-1">
|
||||
<span :class="message.direction === 'outbound' ? 'text-blue-100' : 'text-gray-500'"
|
||||
class="text-xs" x-text="formatMessageTime(message.sent_at || message.timestamp)">
|
||||
</span>
|
||||
<span x-show="message.direction === 'outbound'"
|
||||
:class="getStatusColor(message.status)"
|
||||
class="text-xs ml-2" x-text="getStatusIcon(message.status)">
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Load More Messages Button -->
|
||||
<div x-show="hasMoreMessages && !loadingMessages" class="text-center py-2">
|
||||
<button
|
||||
@click="loadMoreMessages()"
|
||||
class="text-blue-500 hover:text-blue-600 text-sm font-medium"
|
||||
>
|
||||
Load older messages
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading Messages -->
|
||||
<div x-show="loadingMessages" class="text-center py-2">
|
||||
<span class="text-gray-500 text-sm">Loading messages...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message Input -->
|
||||
<div x-show="selectedConversation" class="border-t border-gray-200 p-4 bg-white">
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="flex-1">
|
||||
<textarea
|
||||
x-model="newMessage"
|
||||
@keydown.enter.prevent="sendMessage()"
|
||||
@keydown.shift.enter="newMessage += '\n'"
|
||||
placeholder="Type a message..."
|
||||
class="w-full resize-none border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
|
||||
rows="1"
|
||||
x-ref="messageInput"
|
||||
></textarea>
|
||||
</div>
|
||||
<button
|
||||
@click="sendMessage()"
|
||||
:disabled="!newMessage.trim() || sendingMessage"
|
||||
class="p-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -603,6 +845,7 @@
|
||||
<script src="/static/js/dashboard.js?v=2025082505"></script>
|
||||
<script src="/static/js/lists.js?v=2025082505"></script>
|
||||
<script src="/static/js/conversations_enhanced.js?v=2025082505"></script>
|
||||
<script src="/static/js/rcs-gap-detector.js?v=2025082505"></script>
|
||||
<script>
|
||||
// Initialize phone IP from template
|
||||
document.addEventListener('alpine:init', () => {
|
||||
|
||||
@ -1,4 +0,0 @@
|
||||
phone,message,name
|
||||
7802921731,hi {name} just testing,Reed
|
||||
,,
|
||||
,,
|
||||
|
Loading…
x
Reference in New Issue
Block a user