Files
Client/lib/push/chat_thread_store.dart
T

211 lines
7.5 KiB
Dart

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 (`<sender> in <room>`);
/// 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<String, dynamic> toJson() => {
'nid': nid,
'sender': sender,
'text': text,
'timestampMs': timestampMs,
'roomName': ?roomName,
};
factory ThreadMessage.fromJson(Map<String, dynamic> 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<ThreadMessage> appendThreadMessage(
List<ThreadMessage> 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<ThreadMessage> removeThreadNid(List<ThreadMessage> 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` → the user hasn't dismissed/read it, so [message] STACKS onto the
/// existing history.
/// - `false` → the notification is gone (swiped away or the chat was read
/// without our cleanup running), so the thread RESTARTS with only [message].
/// - `null` → the active-notification probe failed or isn't supported; keep
/// stacking defensively — degraded stacking never loses a message.
///
/// Android provides no reliable "notification dismissed" callback, so the
/// visible-state probe at append time is the substitute.
List<ThreadMessage> threadAfterIncoming(
List<ThreadMessage> existing,
ThreadMessage message,
bool? isActive, {
int cap = kChatThreadCap,
}) {
final base = isActive == false ? const <ThreadMessage>[] : existing;
return appendThreadMessage(base, message, cap: cap);
}
/// MessagingStyle header per Android convention: a 1:1 chat gets NO
/// `conversationTitle` — the system then shows the person once as the header
/// and plain texts per line; setting a title would repeat the name on every
/// row. Groups get the ROOM NAME as title (parsed from the subject) plus
/// per-line sender names; when no room name is known, >1 distinct sender
/// still marks the thread as group with the last sender as title fallback.
({String? conversationTitle, bool groupConversation}) conversationHeader(
List<ThreadMessage> 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<List<ThreadMessage>> messages(String chatToken) async {
final data = await _doc(chatToken).get();
final raw = data?['messages'];
if (raw is! List) return const [];
return raw
.whereType<Map<String, dynamic>>()
.map(ThreadMessage.fromJson)
.toList();
}
/// Appends [message] and returns the updated (capped) history.
Future<List<ThreadMessage>> 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<List<ThreadMessage>> 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<List<ThreadMessage>> 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<void> clearChat(String chatToken) => _doc(chatToken).delete();
Future<void> 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/<id>).
await _db.collection(_collection).doc(id.split('/').last).delete();
}
}
DocumentRef _doc(String chatToken) =>
_db.collection(_collection).doc(docIdForToken(chatToken));
Future<void> _write(String chatToken, List<ThreadMessage> messages) =>
_doc(chatToken).set({
'messages': [for (final m in messages) m.toJson()],
});
}