211 lines
7.5 KiB
Dart
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()],
|
|
});
|
|
}
|