Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -80,17 +80,14 @@ class _HazeBotAdminAppState extends State<HazeBotAdminApp>

if (state == AppLifecycleState.paused ||
state == AppLifecycleState.inactive) {
// App going to background - disconnect WebSocket
// ✅ FIX: Web should NOT disconnect on inactive (tab switch)
// Only mobile needs disconnect (app minimized)
// Web should NOT disconnect on inactive (tab switch)
// Only mobile disconnects when app goes to background
if (!kIsWeb) {
debugPrint('📱 App paused/inactive - disconnecting WebSocket');
discordAuthService.wsService.disconnect();
}
} else if (state == AppLifecycleState.resumed) {
// App coming to foreground - reconnect if authenticated
// Reconnect if authenticated
if (discordAuthService.isAuthenticated) {
debugPrint('📱 App resumed - reconnecting WebSocket');
final baseUrl = dotenv.env['API_BASE_URL'] ?? '';
if (baseUrl.isNotEmpty) {
discordAuthService.wsService.connect(baseUrl);
Expand Down
63 changes: 5 additions & 58 deletions lib/services/websocket_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,7 @@ class WebSocketService {

/// Initialize WebSocket connection
void connect(String baseUrl) {
print('🔌 WebSocket connect() called with baseUrl: $baseUrl');

if (_socket != null && _socket!.connected) {
print('🔌 WebSocket already connected - skipping');
return;
}

Expand All @@ -30,20 +27,13 @@ class WebSocketService {

if (kIsWeb) {
// WEB: Use current origin (admin.haze.pro)
// WebSocket connects to same domain as the web app
wsUrl = Uri.base.origin; // Gets https://admin.haze.pro
print('🌐 WEB: Using current origin for WebSocket: $wsUrl');
wsUrl = Uri.base.origin;
} else {
// MOBILE: Direct URL - remove trailing /api if present
// baseUrl is like: https://api.haze.pro/api
// We need: https://api.haze.pro
wsUrl = baseUrl.endsWith('/api')
? baseUrl.substring(0, baseUrl.length - 4)
: baseUrl;
print('📱 MOBILE: Using direct API URL for WebSocket: $wsUrl');
}

print('🔌 Connecting to WebSocket: $wsUrl');

_socket = IO.io(
wsUrl,
Expand All @@ -55,13 +45,10 @@ class WebSocketService {
);

_socket!.onConnect((_) {
print('✅ WebSocket connected');
_isConnected = true;
print('✅ WebSocket connection established');
});

_socket!.onDisconnect((_) {
print('❌ WebSocket disconnected');
_isConnected = false;
});

Expand All @@ -71,16 +58,14 @@ class WebSocketService {
});

_socket!.on('connected', (data) {
print('📡 Server confirmed connection: $data');
// Server confirmed connection
});

_socket!.on('ticket_update', (data) {
print('📨 Received ticket update: $data');
_handleTicketUpdate(data);
});

_socket!.on('message_history', (data) {
print('📜 Received message history: ${data}');
_handleMessageHistory(data);
});

Expand Down Expand Up @@ -120,13 +105,8 @@ class WebSocketService {
/// Disconnect WebSocket
void disconnect() {
if (_socket != null) {
print('🔌 Disconnecting WebSocket');

// ✅ CRITICAL: Leave all joined tickets BEFORE disconnecting
// This ensures backend receives leave_ticket events and clears active_ticket_viewers
// Leave all joined tickets BEFORE disconnecting
if (_joinedTickets.isNotEmpty) {
print(
'🧹 Leaving ${_joinedTickets.length} ticket room(s) before disconnect...');
for (var entry in _joinedTickets.entries) {
final ticketId = entry.key;
final userId = entry.value;
Expand All @@ -137,8 +117,6 @@ class WebSocketService {
}

_socket!.emit('leave_ticket', data);
print(
'🎫 Left ticket room: $ticketId${userId != null ? " (user: $userId)" : ""}');
}
_joinedTickets.clear();
}
Expand All @@ -157,53 +135,33 @@ class WebSocketService {
/// Join a ticket room to receive updates
/// [userId] - Discord user ID to suppress push notifications for this user
void joinTicket(String ticketId, {String? userId}) {
// ✅ FIX: Use _isConnected (same as waitForConnection) instead of _socket!.connected
// This avoids race condition where onConnect fired but socket.io internal state not yet updated
if (_socket == null || !_isConnected) {
print('⚠️ Cannot join ticket: WebSocket not connected');
return;
}

print(
'🎫 Joining ticket room: $ticketId${userId != null ? " (user: $userId)" : ""}');

final data = {'ticket_id': ticketId};
if (userId != null) {
data['user_id'] = userId; // ✅ Send user_id to suppress push notifications
data['user_id'] = userId;
}

// ✅ Track this ticket as joined (for cleanup on disconnect)
_joinedTickets[ticketId] = userId;

_socket!.emit('join_ticket', data);

_socket!.once('joined_ticket', (data) {
print('✅ Joined ticket room: $data');
});
}

/// Leave a ticket room
/// [userId] - Discord user ID to re-enable push notifications for this user
void leaveTicket(String ticketId, {String? userId}) {
// ✅ FIX: Use _isConnected for consistency
if (_socket == null || !_isConnected) {
return;
}

print(
'🎫 Leaving ticket room: $ticketId${userId != null ? " (user: $userId)" : ""}');

final data = {'ticket_id': ticketId};
if (userId != null) {
data['user_id'] =
userId; // ✅ Send user_id to re-enable push notifications
data['user_id'] = userId;
}

_socket!.emit('leave_ticket', data);

// ✅ Remove from joined tickets tracking
_joinedTickets.remove(ticketId);

_ticketListeners.remove(ticketId);
}

Expand All @@ -214,7 +172,6 @@ class WebSocketService {
_ticketListeners[ticketId] = [];
}
_ticketListeners[ticketId]!.add(callback);
print('👂 Added listener for ticket: $ticketId');
}

/// Remove ticket update listener
Expand All @@ -236,24 +193,18 @@ class WebSocketService {
final eventType = updateData['event_type'] as String?;

if (ticketId == null || eventType == null) {
print('⚠️ Invalid ticket update data: $updateData');
return;
}

print('📡 Processing $eventType for ticket $ticketId');

// Notify all listeners for this ticket
if (_ticketListeners.containsKey(ticketId)) {
print('✅ Found ${_ticketListeners[ticketId]!.length} listener(s) for ticket $ticketId');
for (final listener in _ticketListeners[ticketId]!) {
try {
listener(updateData);
} catch (e) {
print('❌ Error in ticket listener: $e');
}
}
} else {
print('⚠️ No listeners found for ticket $ticketId! Available tickets: ${_ticketListeners.keys.toList()}');
}
} catch (e) {
print('❌ Error handling ticket update: $e');
Expand All @@ -268,13 +219,9 @@ class WebSocketService {
final messages = historyData['messages'] as List<dynamic>?;

if (ticketId == null || messages == null) {
print('⚠️ Invalid message history data: $historyData');
return;
}

print(
'📜 Processing message history for ticket $ticketId: ${messages.length} messages');

// Convert to proper format and notify listeners
final updateData = {
'ticket_id': ticketId,
Expand Down
57 changes: 5 additions & 52 deletions lib/widgets/ticket_chat_widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -141,36 +141,23 @@ class _TicketChatWidgetState extends State<TicketChatWidget>

@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// ✅ Guard: Check if authService is initialized
if (_authService == null) return;

// ✅ CRITICAL: Leave ticket room when app goes to background
// This ensures push notifications are re-enabled when user is not actively viewing
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.inactive) {
debugPrint(
'📱 App paused/inactive - leaving ticket room to re-enable push notifications');

// Track if WebSocket is disconnected during pause (we might miss messages)
_wasDisconnectedWhilePaused = !_authService!.wsService.isConnected;

_authService!.wsService.leaveTicket(
widget.ticket.ticketId,
userId: _currentUserDiscordId,
);
} else if (state == AppLifecycleState.resumed) {
debugPrint(
'📱 App resumed - rejoining ticket room to suppress push notifications');

// ✅ If WebSocket was disconnected, reload messages from API to catch missed ones
// Reload messages if we were disconnected
if (_wasDisconnectedWhilePaused) {
debugPrint(
'📱 WebSocket was disconnected during pause - reloading messages to catch up');
_loadMessages();
_wasDisconnectedWhilePaused = false;
}

// ✅ FIX: Wait for WebSocket connection before joining ticket room
_rejoinTicketAfterReconnect();
}
}
Expand All @@ -180,22 +167,18 @@ class _TicketChatWidgetState extends State<TicketChatWidget>
final connected = await _authService!.wsService.waitForConnection();

if (connected) {
debugPrint('✅ WebSocket ready - joining ticket room');
_authService!.wsService.joinTicket(
widget.ticket.ticketId,
userId: _currentUserDiscordId,
);
} else {
debugPrint('❌ WebSocket connection timeout - retrying in 2s...');
// Retry once after 2 seconds
await Future.delayed(const Duration(seconds: 2));
if (_authService!.wsService.isConnected) {
_authService!.wsService.joinTicket(
widget.ticket.ticketId,
userId: _currentUserDiscordId,
);
} else {
debugPrint('❌ Failed to rejoin ticket room - WebSocket not connected');
}
}
}
Expand Down Expand Up @@ -334,17 +317,12 @@ class _TicketChatWidgetState extends State<TicketChatWidget>

// Listen for new messages and history
wsService.onTicketUpdate(widget.ticket.ticketId, (data) {
debugPrint('🎯 TicketChatWidget received update: ${data['event_type']}');
final eventType = data['event_type'] as String?;

if (eventType == 'new_message') {
final messageData = data['data'] as Map<String, dynamic>?;
debugPrint('📦 Message data: ${messageData != null ? "VALID" : "NULL"}');
if (messageData != null) {
debugPrint('✅ Calling _handleNewMessage()');
_handleNewMessage(messageData);
} else {
debugPrint('❌ messageData is NULL!');
}
} else if (eventType == 'message_history') {
final messagesData = data['data'] as List<dynamic>?;
Expand All @@ -360,22 +338,18 @@ class _TicketChatWidgetState extends State<TicketChatWidget>
final connected = await _authService!.wsService.waitForConnection();

if (connected) {
debugPrint('✅ WebSocket ready - joining ticket room (initial)');
_authService!.wsService.joinTicket(
widget.ticket.ticketId,
userId: _currentUserDiscordId,
);
} else {
debugPrint('⚠️ WebSocket connection timeout on initial join - retrying...');
// Retry once after 2 seconds
await Future.delayed(const Duration(seconds: 2));
if (_authService!.wsService.isConnected) {
_authService!.wsService.joinTicket(
widget.ticket.ticketId,
userId: _currentUserDiscordId,
);
} else {
debugPrint('❌ Failed to join ticket room - WebSocket not connected');
}
}
}
Expand Down Expand Up @@ -446,40 +420,26 @@ class _TicketChatWidgetState extends State<TicketChatWidget>
}

void _handleNewMessage(Map<String, dynamic> messageData) {
debugPrint('🔍 _handleNewMessage called, mounted=$mounted');
if (!mounted) {
debugPrint('❌ Widget not mounted, skipping');
return;
}
if (!mounted) return;

// ✅ FIX: Use display_content if available (for messages sent via app)
// Backend sends both 'content' (formatted for Discord) and 'display_content' (original)
final displayContent = messageData['display_content'] as String? ??
messageData['content'] as String;

// Create message with cleaned content for display
final cleanedMessageData = Map<String, dynamic>.from(messageData);
cleanedMessageData['content'] = displayContent;

final newMessage = TicketMessage.fromJson(cleanedMessageData);
debugPrint('📝 New message parsed: ${newMessage.id} from ${newMessage.authorName}');

// ✅ FIX: Check if message already exists (prevent duplicates)
// Check if message already exists (prevent duplicates)
if (_seenMessageIds.contains(newMessage.id)) {
debugPrint('⚠️ Duplicate message ignored: ${newMessage.id}');
return;
}

// ✅ FIX: Skip own messages from WebSocket (already added optimistically)
// This prevents duplicate messages when user sends a message
// Skip own messages from WebSocket (already added optimistically)
if (_currentUserDiscordId != null &&
newMessage.authorId == _currentUserDiscordId) {
debugPrint(
'⏭️ Skipping own message from WebSocket: ${newMessage.id} (already added optimistically)');
return;
}

debugPrint('✅ Message passes all checks, adding to list');


final oldCount = _messages.length;
Expand All @@ -498,16 +458,9 @@ class _TicketChatWidgetState extends State<TicketChatWidget>
// ✅ Update cache (fire-and-forget)
_cacheService.appendMessage(widget.ticket.ticketId, newMessage);

// ✅ IMPROVED: Smart scroll behavior based on user position
// - If user is at bottom: auto-scroll to new message
// - If user scrolled up: don't interrupt their reading
// Smart scroll behavior based on user position
if (_isUserAtBottom) {
debugPrint('📩 New message arrived, user at bottom → auto-scrolling');
_scrollToBottom(animate: true);
} else {
debugPrint(
'📩 New message arrived, user scrolled up → not auto-scrolling');
// Optional: Could show a "New messages" badge here
}
}

Expand Down
Loading