import 'dart:convert'; import 'package:localstore/localstore.dart'; /// Maximum messages kept per chat thread — enough for a messenger-style /// notification, small enough to stay cheap in the background isolate. const int kChatThreadCap = 15; /// Canonical per-chat notification tag (kept in sync with /// `NotificationTasks.chatTag`, which delegates here). String chatNotificationTag(String chatToken) => 'talk_$chatToken'; /// Deterministic non-negative 31-bit notification id per chat, so every /// message of the same conversation updates ONE stacked notification instead /// of adding a new card per push. int stableChatNotificationId(String chatToken) { var hash = 0; for (final unit in chatToken.codeUnits) { hash = (hash * 31 + unit) & 0x7fffffff; } return hash; } /// One message inside a chat's notification history. class ThreadMessage { final int nid; final String sender; final String text; final int timestampMs; /// Room name parsed from a group-chat subject (` in `); /// null for 1:1 chats and for history entries written before this field /// existed (tolerated on read — they simply carry no room information). final String? roomName; const ThreadMessage({ required this.nid, required this.sender, required this.text, required this.timestampMs, this.roomName, }); Map toJson() => { 'nid': nid, 'sender': sender, 'text': text, 'timestampMs': timestampMs, 'roomName': ?roomName, }; factory ThreadMessage.fromJson(Map json) => ThreadMessage( nid: (json['nid'] as num).toInt(), sender: json['sender'] as String? ?? '', text: json['text'] as String? ?? '', timestampMs: (json['timestampMs'] as num?)?.toInt() ?? 0, roomName: json['roomName'] as String?, ); } /// Appends [message] to [messages], replacing an entry with the same nid /// (redelivered push) and capping the history at [cap] (oldest dropped). /// Pure so the stacking behavior is unit-testable. List appendThreadMessage( List messages, ThreadMessage message, { int cap = kChatThreadCap, }) { final result = [...messages.where((m) => m.nid != message.nid), message]; return result.length > cap ? result.sublist(result.length - cap) : result; } /// Removes the message with [nid]; the caller cancels the notification when /// the returned list is empty and re-renders it silently otherwise. List removeThreadNid(List messages, int nid) => messages.where((m) => m.nid != nid).toList(); /// History for the next notification after [message] arrives, given whether /// the chat's previous notification is still on screen ([isActive]): /// `true` → stacks onto existing history; `false` → notification gone /// (dismissed/read), thread restarts with only [message]; `null` → probe /// failed/unsupported, keep stacking (degraded stacking never loses a message). /// /// Android has no reliable "notification dismissed" callback, so the /// visible-state probe at append time is the substitute. List threadAfterIncoming( List existing, ThreadMessage message, bool? isActive, { int cap = kChatThreadCap, }) { final base = isActive == false ? const [] : existing; return appendThreadMessage(base, message, cap: cap); } /// MessagingStyle header per Android convention: a 1:1 chat gets NO /// `conversationTitle` (setting one would repeat the person's name on every /// row); groups use the room name as title, falling back to the last sender /// when there is >1 distinct sender but no room name is known. ({String? conversationTitle, bool groupConversation}) conversationHeader( List messages, ) { final roomName = messages .lastWhere( (m) => m.roomName?.isNotEmpty ?? false, orElse: () => const ThreadMessage(nid: 0, sender: '', text: '', timestampMs: 0), ) .roomName; if (roomName != null && roomName.isNotEmpty) { return (conversationTitle: roomName, groupConversation: true); } final distinctSenders = messages.map((m) => m.sender).toSet().length; if (distinctSenders <= 1) { return (conversationTitle: null, groupConversation: false); } return (conversationTitle: messages.last.sender, groupConversation: true); } /// Persists the per-chat message history via [Localstore] so both the /// foreground and the FCM background isolate build the same stacked /// notification. Complements [NidStore], which keeps the nid → chat mapping /// needed to resolve delete-pushes. class ChatThreadStore { static const _collection = 'push_chat_threads'; final Localstore _db; ChatThreadStore({Localstore? db}) : _db = db ?? Localstore.instance; /// Localstore doc ids become file names — tokens are normally URL-safe and /// used verbatim, anything else is base64url-encoded. static String docIdForToken(String chatToken) => RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(chatToken) ? chatToken : base64Url.encode(utf8.encode(chatToken)).replaceAll('=', ''); Future> messages(String chatToken) async { final data = await _doc(chatToken).get(); final raw = data?['messages']; if (raw is! List) return const []; return raw .whereType>() .map(ThreadMessage.fromJson) .toList(); } /// Appends [message] and returns the updated (capped) history. Future> append( String chatToken, ThreadMessage message, ) async { final updated = appendThreadMessage(await messages(chatToken), message); await _write(chatToken, updated); return updated; } /// Appends [message], but restarts the thread first when the chat's previous /// notification is no longer active ([isActive] == false — dismissed/read). /// See [threadAfterIncoming] for the tri-state semantics. Future> appendConsideringActive( String chatToken, ThreadMessage message, bool? isActive, ) async { final updated = threadAfterIncoming( await messages(chatToken), message, isActive, ); await _write(chatToken, updated); return updated; } /// Removes the message with [nid] and returns the remaining history. Future> removeNid(String chatToken, int nid) async { final remaining = removeThreadNid(await messages(chatToken), nid); if (remaining.isEmpty) { await clearChat(chatToken); } else { await _write(chatToken, remaining); } return remaining; } Future clearChat(String chatToken) => _doc(chatToken).delete(); Future clearAll() async { final docs = await _db.collection(_collection).get(); if (docs == null) return; for (final id in docs.keys) { // Localstore keys are full document paths (/push_chat_threads/). await _db.collection(_collection).doc(id.split('/').last).delete(); } } DocumentRef _doc(String chatToken) => _db.collection(_collection).doc(docIdForToken(chatToken)); Future _write(String chatToken, List messages) => _doc(chatToken).set({ 'messages': [for (final m in messages) m.toJson()], }); }