implemented dual Nextcloud push registration with separate general and talk apptypes to ensure reliable Talk notification delivery; introduced stacked MessagingStyle notifications for chat threads with support for circular conversation avatars and disk caching
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
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()],
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user