diff --git a/lib/core/services/push_notification_service.dart b/lib/core/services/push_notification_service.dart index ef451bd..1316a56 100644 --- a/lib/core/services/push_notification_service.dart +++ b/lib/core/services/push_notification_service.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'package:firebase_core/firebase_core.dart'; @@ -27,7 +28,38 @@ class PushNotificationService { bool _initialized = false; /// Callback when user taps a notification — set by the app to navigate. - void Function(String? actionUrl)? onNotificationTap; + /// Receives the full FCM data payload so the app can route by `type` + /// (e.g. 'message' → open chat) instead of only relying on `actionUrl`. + /// + /// When this setter is assigned and a pending tap was buffered (because the + /// terminated-state tap fired before the app UI was ready), the buffered + /// tap is replayed immediately. + void Function(Map data)? _onNotificationTap; + Map? _pendingTap; + + set onNotificationTap(void Function(Map data)? handler) { + _onNotificationTap = handler; + // Replay any buffered tap once a listener is attached + if (handler != null && _pendingTap != null) { + final buffered = _pendingTap!; + _pendingTap = null; + // Defer to next microtask so the caller's initState finishes first + Future.microtask(() => handler(buffered)); + } + } + + void Function(Map data)? get onNotificationTap => + _onNotificationTap; + + void _dispatchTap(Map data) { + final handler = _onNotificationTap; + if (handler != null) { + handler(data); + } else { + // No listener yet — buffer for replay once one attaches + _pendingTap = data; + } + } /// The conversation ID currently being viewed — suppress message push /// notifications for this conversation to avoid self-notifications. @@ -116,7 +148,17 @@ class PushNotificationService { ), onDidReceiveNotificationResponse: (response) { final payload = response.payload; - onNotificationTap?.call(payload); + if (payload == null || payload.isEmpty) { + _dispatchTap(const {}); + return; + } + try { + final data = jsonDecode(payload) as Map; + _dispatchTap(data); + } catch (_) { + // Legacy payloads may be a bare actionUrl string + _dispatchTap({'actionUrl': payload}); + } }, ); } @@ -200,15 +242,16 @@ class PushNotificationService { icon: '@mipmap/ic_launcher', ), ), - payload: message.data['actionUrl'], + // Encode the full FCM data as JSON so the tap handler can route + // by `type` (e.g. open the specific chat for message notifications). + payload: jsonEncode(message.data), ); } } void _onMessageOpenedApp(RemoteMessage message) { debugPrint('[FCM] Notification tap: ${message.data}'); - final actionUrl = message.data['actionUrl'] as String?; - onNotificationTap?.call(actionUrl); + _dispatchTap(Map.from(message.data)); } /// Unregister FCM token from backend (call on logout). diff --git a/lib/core/widgets/app_shell.dart b/lib/core/widgets/app_shell.dart index edf42e7..13cfbc2 100644 --- a/lib/core/widgets/app_shell.dart +++ b/lib/core/widgets/app_shell.dart @@ -20,18 +20,69 @@ class _AppShellState extends State { void initState() { super.initState(); // Wire push notification taps to GoRouter navigation - PushNotificationService().onNotificationTap = (actionUrl) { + PushNotificationService().onNotificationTap = (data) { if (!mounted) return; - if (actionUrl == null || actionUrl.isEmpty) { + debugPrint('[AppShell] Notification tap: $data'); + + // Extract the conversationId from every possible source — FCM payloads + // from different backends/versions may use different keys, and the + // link/actionUrl may carry it as a query parameter instead. + final convId = _extractConversationId(data); + final type = (data['type'] as String?)?.toLowerCase(); + final link = (data['link'] as String?) ?? (data['actionUrl'] as String?); + final looksLikeMessage = type == 'message' || + (link != null && link.contains('/message')); + + if (looksLikeMessage && convId != null && convId.isNotEmpty) { + debugPrint('[AppShell] → /messages/chat/$convId'); + context.go('/messages/chat/$convId'); + return; + } + + if (type == 'connection_request' || type == 'connection_response') { context.go('/notifications'); return; } - // Translate web routes to mobile routes - final mobileRoute = _translateRoute(actionUrl); + + // Fall back to actionUrl translation (web-style URLs) + if (link == null || link.isEmpty) { + context.go('/notifications'); + return; + } + final mobileRoute = _translateRoute(link); + debugPrint('[AppShell] → $mobileRoute (from link=$link)'); context.go(mobileRoute); }; } + /// Try every known source for a conversation id — direct keys, or + /// query parameters on the link/actionUrl. + String? _extractConversationId(Map data) { + // Direct keys (backend sends `conversationId`) + for (final key in const [ + 'conversationId', + 'conversation_id', + 'convId', + 'chatId', + ]) { + final v = data[key]; + if (v is String && v.isNotEmpty) return v; + } + // Query parameter on the link or actionUrl + for (final key in const ['link', 'actionUrl']) { + final raw = data[key]; + if (raw is String && raw.isNotEmpty) { + try { + final uri = Uri.parse(raw); + final qp = uri.queryParameters['conversationId'] ?? + uri.queryParameters['conversation_id']; + if (qp != null && qp.isNotEmpty) return qp; + } catch (_) {} + } + } + return null; + } + /// Translate web-style actionUrl to mobile route. String _translateRoute(String url) { final uri = Uri.parse(url); diff --git a/lib/features/messaging/data/socket_service.dart b/lib/features/messaging/data/socket_service.dart index 4b0efdf..7c8b07c 100644 --- a/lib/features/messaging/data/socket_service.dart +++ b/lib/features/messaging/data/socket_service.dart @@ -59,8 +59,23 @@ class SocketService { return; } + // Already fully connected with the same token — no-op. if (_socket?.connected == true && _currentToken == token) return; + // A socket already exists for this same token but isn't connected yet + // (either in-flight or temporarily disconnected). Don't dispose and + // recreate — that kills the in-flight attempt and causes a race when + // `connect()` is called twice in quick succession (e.g. once from the + // auth provider on app launch and once from chat_screen's + // ensureConnected() after a notification tap). Just kick it to connect. + if (_socket != null && _currentToken == token) { + _intentionalDisconnect = false; + if (_socket!.connected != true) { + _socket!.connect(); + } + return; + } + _currentToken = token; _intentionalDisconnect = false;