Refactor unread message tracking and implement channel caching (#126)

* Refactor unread message tracking and implement channel caching

* formatted files
This commit is contained in:
zjs81
2026-02-04 20:34:03 -07:00
committed by GitHub
parent b3645481c7
commit c320378be1
7 changed files with 243 additions and 179 deletions
+157 -104
View File
@@ -24,6 +24,7 @@ import '../services/notification_service.dart';
import '../storage/channel_message_store.dart'; import '../storage/channel_message_store.dart';
import '../storage/channel_order_store.dart'; import '../storage/channel_order_store.dart';
import '../storage/channel_settings_store.dart'; import '../storage/channel_settings_store.dart';
import '../storage/channel_store.dart';
import '../storage/contact_settings_store.dart'; import '../storage/contact_settings_store.dart';
import '../storage/contact_store.dart'; import '../storage/contact_store.dart';
import '../storage/message_store.dart'; import '../storage/message_store.dart';
@@ -139,14 +140,15 @@ class MeshCoreConnector extends ChangeNotifier {
final ChannelSettingsStore _channelSettingsStore = ChannelSettingsStore(); final ChannelSettingsStore _channelSettingsStore = ChannelSettingsStore();
final ContactSettingsStore _contactSettingsStore = ContactSettingsStore(); final ContactSettingsStore _contactSettingsStore = ContactSettingsStore();
final ContactStore _contactStore = ContactStore(); final ContactStore _contactStore = ContactStore();
final ChannelStore _channelStore = ChannelStore();
final UnreadStore _unreadStore = UnreadStore(); final UnreadStore _unreadStore = UnreadStore();
List<Channel> _cachedChannels = [];
final Map<int, bool> _channelSmazEnabled = {}; final Map<int, bool> _channelSmazEnabled = {};
bool _lastSentWasCliCommand = bool _lastSentWasCliCommand =
false; // Track if last sent message was a CLI command false; // Track if last sent message was a CLI command
final Map<String, bool> _contactSmazEnabled = {}; final Map<String, bool> _contactSmazEnabled = {};
final Set<String> _knownContactKeys = {}; final Set<String> _knownContactKeys = {};
final Map<String, int> _contactLastReadMs = {}; final Map<String, int> _contactUnreadCount = {};
final Map<int, int> _channelLastReadMs = {};
bool _unreadStateLoaded = false; bool _unreadStateLoaded = false;
final Map<String, _RepeaterAckContext> _pendingRepeaterAcks = {}; final Map<String, _RepeaterAckContext> _pendingRepeaterAcks = {};
String? _activeContactKey; String? _activeContactKey;
@@ -321,17 +323,7 @@ class MeshCoreConnector extends ChangeNotifier {
int getUnreadCountForContactKey(String contactKeyHex) { int getUnreadCountForContactKey(String contactKeyHex) {
if (!_unreadStateLoaded) return 0; if (!_unreadStateLoaded) return 0;
if (!_shouldTrackUnreadForContactKey(contactKeyHex)) return 0; if (!_shouldTrackUnreadForContactKey(contactKeyHex)) return 0;
final messages = _conversations[contactKeyHex]; return _contactUnreadCount[contactKeyHex] ?? 0;
if (messages == null || messages.isEmpty) return 0;
final lastReadMs = _contactLastReadMs[contactKeyHex] ?? 0;
var count = 0;
for (final message in messages) {
if (message.isOutgoing || message.isCli) continue;
if (message.timestamp.millisecondsSinceEpoch > lastReadMs) {
count++;
}
}
return count;
} }
int getUnreadCountForChannel(Channel channel) { int getUnreadCountForChannel(Channel channel) {
@@ -340,17 +332,7 @@ class MeshCoreConnector extends ChangeNotifier {
int getUnreadCountForChannelIndex(int channelIndex) { int getUnreadCountForChannelIndex(int channelIndex) {
if (!_unreadStateLoaded) return 0; if (!_unreadStateLoaded) return 0;
final messages = _channelMessages[channelIndex]; return _findChannelByIndex(channelIndex)?.unreadCount ?? 0;
if (messages == null || messages.isEmpty) return 0;
final lastReadMs = _channelLastReadMs[channelIndex] ?? 0;
var count = 0;
for (final message in messages) {
if (message.isOutgoing) continue;
if (message.timestamp.millisecondsSinceEpoch > lastReadMs) {
count++;
}
}
return count;
} }
int getTotalUnreadCount() { int getTotalUnreadCount() {
@@ -380,16 +362,17 @@ class MeshCoreConnector extends ChangeNotifier {
} }
Future<void> loadUnreadState() async { Future<void> loadUnreadState() async {
_contactLastReadMs _contactUnreadCount
..clear() ..clear()
..addAll(await _unreadStore.loadContactLastRead()); ..addAll(await _unreadStore.loadContactUnreadCount());
_channelLastReadMs
..clear()
..addAll(await _unreadStore.loadChannelLastRead());
_unreadStateLoaded = true; _unreadStateLoaded = true;
notifyListeners(); notifyListeners();
} }
Future<void> loadCachedChannels() async {
_cachedChannels = await _channelStore.loadChannels();
}
void setActiveContact(String? contactKeyHex) { void setActiveContact(String? contactKeyHex) {
if (contactKeyHex != null && if (contactKeyHex != null &&
!_shouldTrackUnreadForContactKey(contactKeyHex)) { !_shouldTrackUnreadForContactKey(contactKeyHex)) {
@@ -411,17 +394,36 @@ class MeshCoreConnector extends ChangeNotifier {
void markContactRead(String contactKeyHex) { void markContactRead(String contactKeyHex) {
if (!_shouldTrackUnreadForContactKey(contactKeyHex)) return; if (!_shouldTrackUnreadForContactKey(contactKeyHex)) return;
final markMs = _calculateReadTimestampMs( final previousCount = _contactUnreadCount[contactKeyHex] ?? 0;
_conversations[contactKeyHex]?.map((m) => m.timestamp), if (previousCount > 0) {
); _contactUnreadCount[contactKeyHex] = 0;
_setContactLastReadMs(contactKeyHex, markMs); _appDebugLogService?.info(
'Contact $contactKeyHex marked as read (was $previousCount unread)',
tag: 'Unread',
);
_unreadStore.saveContactUnreadCount(
Map<String, int>.from(_contactUnreadCount),
);
notifyListeners();
}
} }
void markChannelRead(int channelIndex) { void markChannelRead(int channelIndex) {
final markMs = _calculateReadTimestampMs( final channel = _findChannelByIndex(channelIndex);
_channelMessages[channelIndex]?.map((m) => m.timestamp), if (channel != null && channel.unreadCount > 0) {
); final previousCount = channel.unreadCount;
_setChannelLastReadMs(channelIndex, markMs); channel.unreadCount = 0;
_appDebugLogService?.info(
'Channel ${channel.name.isNotEmpty ? channel.name : channelIndex} marked as read (was $previousCount unread)',
tag: 'Unread',
);
unawaited(
_channelStore.saveChannels(
_channels.isNotEmpty ? _channels : _cachedChannels,
),
);
notifyListeners();
}
} }
Future<void> setChannelSmazEnabled(int channelIndex, bool enabled) async { Future<void> setChannelSmazEnabled(int channelIndex, bool enabled) async {
@@ -788,6 +790,9 @@ class MeshCoreConnector extends ChangeNotifier {
// Keep device clock aligned on every connection. // Keep device clock aligned on every connection.
await syncTime(); await syncTime();
// Fetch channels so we can track unread counts for incoming messages
unawaited(getChannels());
} catch (e) { } catch (e) {
debugPrint("Connection error: $e"); debugPrint("Connection error: $e");
await disconnect(manual: false); await disconnect(manual: false);
@@ -1341,8 +1346,10 @@ class MeshCoreConnector extends ChangeNotifier {
unawaited(_persistContacts()); unawaited(_persistContacts());
_conversations.remove(contact.publicKeyHex); _conversations.remove(contact.publicKeyHex);
_loadedConversationKeys.remove(contact.publicKeyHex); _loadedConversationKeys.remove(contact.publicKeyHex);
_contactLastReadMs.remove(contact.publicKeyHex); _contactUnreadCount.remove(contact.publicKeyHex);
_unreadStore.saveContactLastRead(Map<String, int>.from(_contactLastReadMs)); _unreadStore.saveContactUnreadCount(
Map<String, int>.from(_contactUnreadCount),
);
_messageStore.clearMessages(contact.publicKeyHex); _messageStore.clearMessages(contact.publicKeyHex);
notifyListeners(); notifyListeners();
} }
@@ -1617,6 +1624,10 @@ class MeshCoreConnector extends ChangeNotifier {
_cleanupChannelSync(completed: true); _cleanupChannelSync(completed: true);
// Cache channels for offline use
_cachedChannels = List<Channel>.from(_channels);
unawaited(_channelStore.saveChannels(_channels));
// Apply ordering and notify UI // Apply ordering and notify UI
_applyChannelOrder(); _applyChannelOrder();
notifyListeners(); notifyListeners();
@@ -1651,8 +1662,6 @@ class MeshCoreConnector extends ChangeNotifier {
// Delete by setting empty name and zero PSK // Delete by setting empty name and zero PSK
await sendFrame(buildSetChannelFrame(index, '', Uint8List(16))); await sendFrame(buildSetChannelFrame(index, '', Uint8List(16)));
_channelLastReadMs.remove(index);
_unreadStore.saveChannelLastRead(Map<int, int>.from(_channelLastReadMs));
// Clear stored messages for this channel // Clear stored messages for this channel
await _channelMessageStore.clearChannelMessages(index); await _channelMessageStore.clearChannelMessages(index);
// Clear in-memory messages for this channel // Clear in-memory messages for this channel
@@ -1930,9 +1939,9 @@ class MeshCoreConnector extends ChangeNotifier {
final contact = Contact.fromFrame(frame); final contact = Contact.fromFrame(frame);
if (contact != null) { if (contact != null) {
if (contact.type == advTypeRepeater) { if (contact.type == advTypeRepeater) {
_contactLastReadMs.remove(contact.publicKeyHex); _contactUnreadCount.remove(contact.publicKeyHex);
_unreadStore.saveContactLastRead( _unreadStore.saveContactUnreadCount(
Map<String, int>.from(_contactLastReadMs), Map<String, int>.from(_contactUnreadCount),
); );
} }
// Check if this is a new contact // Check if this is a new contact
@@ -2157,7 +2166,7 @@ class MeshCoreConnector extends ChangeNotifier {
} }
} }
_addMessage(message.senderKeyHex, message); _addMessage(message.senderKeyHex, message);
_maybeMarkActiveContactRead(message); _maybeIncrementContactUnread(message);
notifyListeners(); notifyListeners();
// Show notification for new incoming message // Show notification for new incoming message
@@ -2348,7 +2357,7 @@ class MeshCoreConnector extends ChangeNotifier {
pathBytes: message.pathBytes, pathBytes: message.pathBytes,
); );
final isNew = _addChannelMessage(message.channelIndex!, message); final isNew = _addChannelMessage(message.channelIndex!, message);
_maybeMarkActiveChannelRead(message); _maybeIncrementChannelUnread(message, isNew: isNew);
notifyListeners(); notifyListeners();
if (isNew) { if (isNew) {
_maybeNotifyChannelMessage(message); _maybeNotifyChannelMessage(message);
@@ -2370,7 +2379,9 @@ class MeshCoreConnector extends ChangeNotifier {
final channelHash = payload[0]; final channelHash = payload[0];
final encrypted = Uint8List.fromList(payload.sublist(1)); final encrypted = Uint8List.fromList(payload.sublist(1));
for (final channel in _channels) { // Use cached channels as fallback if live channels not yet loaded
final channelsToSearch = _channels.isNotEmpty ? _channels : _cachedChannels;
for (final channel in channelsToSearch) {
if (channel.isEmpty) continue; if (channel.isEmpty) continue;
final hash = _computeChannelHash(channel.psk); final hash = _computeChannelHash(channel.psk);
if (hash != channelHash) continue; if (hash != channelHash) continue;
@@ -2409,7 +2420,7 @@ class MeshCoreConnector extends ChangeNotifier {
pathBytes: message.pathBytes, pathBytes: message.pathBytes,
); );
final isNew = _addChannelMessage(channel.index, message); final isNew = _addChannelMessage(channel.index, message);
_maybeMarkActiveChannelRead(message); _maybeIncrementChannelUnread(message, isNew: isNew);
notifyListeners(); notifyListeners();
if (isNew) { if (isNew) {
final label = channel.name.isEmpty final label = channel.name.isEmpty
@@ -2539,6 +2550,15 @@ class MeshCoreConnector extends ChangeNotifier {
'[ChannelSync] Received channel ${channel.index}: ${channel.isEmpty ? "empty" : channel.name}', '[ChannelSync] Received channel ${channel.index}: ${channel.isEmpty ? "empty" : channel.name}',
); );
// Preserve unread count from cached channel
final cachedChannel = _cachedChannels.cast<Channel?>().firstWhere(
(c) => c?.index == channel.index,
orElse: () => null,
);
if (cachedChannel != null) {
channel.unreadCount = cachedChannel.unreadCount;
}
// If we're syncing and this is the channel we're waiting for // If we're syncing and this is the channel we're waiting for
if (_isSyncingChannels && _channelSyncInFlight) { if (_isSyncingChannels && _channelSyncInFlight) {
if (channel.index == _nextChannelIndexToRequest) { if (channel.index == _nextChannelIndexToRequest) {
@@ -2578,6 +2598,8 @@ class MeshCoreConnector extends ChangeNotifier {
(c) => c.index == channel.index, (c) => c.index == channel.index,
); );
if (existingIndex >= 0) { if (existingIndex >= 0) {
// Preserve unread count from existing channel
channel.unreadCount = _channels[existingIndex].unreadCount;
_channels[existingIndex] = channel; _channels[existingIndex] = channel;
} else { } else {
_channels.add(channel); _channels.add(channel);
@@ -2628,67 +2650,98 @@ class MeshCoreConnector extends ChangeNotifier {
return contact.type != advTypeRepeater; return contact.type != advTypeRepeater;
} }
int _calculateReadTimestampMs(Iterable<DateTime>? timestamps) { Channel? _findChannelByIndex(int index) {
var latestMs = 0; return _channels.cast<Channel?>().firstWhere(
if (timestamps != null) { (c) => c?.index == index,
for (final timestamp in timestamps) { orElse: () => null,
final ms = timestamp.millisecondsSinceEpoch; ) ??
if (ms > latestMs) { _cachedChannels.cast<Channel?>().firstWhere(
latestMs = ms; (c) => c?.index == index,
} orElse: () => null,
} );
}
return latestMs;
} }
void _setContactLastReadMs( void _maybeIncrementChannelUnread(
String contactKeyHex, ChannelMessage message, {
int timestampMs, { required bool isNew,
bool notify = true,
}) { }) {
if (!_shouldTrackUnreadForContactKey(contactKeyHex)) return; if (!isNew || message.isOutgoing) {
final existing = _contactLastReadMs[contactKeyHex] ?? 0; _appDebugLogService?.info(
if (timestampMs <= existing) return; 'Skip unread increment: isNew=$isNew, isOutgoing=${message.isOutgoing}',
_contactLastReadMs[contactKeyHex] = timestampMs; tag: 'Unread',
_unreadStore.saveContactLastRead(Map<String, int>.from(_contactLastReadMs)); );
if (notify) { return;
notifyListeners();
} }
}
void _setChannelLastReadMs(
int channelIndex,
int timestampMs, {
bool notify = true,
}) {
final existing = _channelLastReadMs[channelIndex] ?? 0;
if (timestampMs <= existing) return;
_channelLastReadMs[channelIndex] = timestampMs;
_unreadStore.saveChannelLastRead(Map<int, int>.from(_channelLastReadMs));
if (notify) {
notifyListeners();
}
}
void _maybeMarkActiveContactRead(Message message) {
if (message.isOutgoing || message.isCli) return;
if (_activeContactKey != message.senderKeyHex) return;
if (!_shouldTrackUnreadForContactKey(message.senderKeyHex)) return;
_setContactLastReadMs(
message.senderKeyHex,
message.timestamp.millisecondsSinceEpoch,
notify: false,
);
}
void _maybeMarkActiveChannelRead(ChannelMessage message) {
if (message.isOutgoing) return;
final channelIndex = message.channelIndex; final channelIndex = message.channelIndex;
if (channelIndex == null || _activeChannelIndex != channelIndex) return; if (channelIndex == null) {
_setChannelLastReadMs( _appDebugLogService?.info(
channelIndex, 'Skip unread increment: channelIndex is null',
message.timestamp.millisecondsSinceEpoch, tag: 'Unread',
notify: false, );
return;
}
// Don't increment if user is viewing this channel
if (_activeChannelIndex == channelIndex) {
_appDebugLogService?.info(
'Skip unread increment: channel $channelIndex is active',
tag: 'Unread',
);
return;
}
final channel = _findChannelByIndex(channelIndex);
if (channel != null) {
channel.unreadCount++;
_appDebugLogService?.info(
'Channel ${channel.name.isNotEmpty ? channel.name : channelIndex} unread count incremented to ${channel.unreadCount}',
tag: 'Unread',
);
unawaited(
_channelStore.saveChannels(
_channels.isNotEmpty ? _channels : _cachedChannels,
),
);
} else {
_appDebugLogService?.info(
'Channel $channelIndex not found in _channels (${_channels.length}) or _cachedChannels (${_cachedChannels.length})',
tag: 'Unread',
);
}
}
void _maybeIncrementContactUnread(Message message) {
if (message.isOutgoing || message.isCli) {
_appDebugLogService?.info(
'Skip contact unread increment: isOutgoing=${message.isOutgoing}, isCli=${message.isCli}',
tag: 'Unread',
);
return;
}
final contactKey = message.senderKeyHex;
if (!_shouldTrackUnreadForContactKey(contactKey)) {
_appDebugLogService?.info(
'Skip contact unread increment: should not track for $contactKey',
tag: 'Unread',
);
return;
}
// Don't increment if user is viewing this contact
if (_activeContactKey == contactKey) {
_appDebugLogService?.info(
'Skip contact unread increment: contact $contactKey is active',
tag: 'Unread',
);
return;
}
final currentCount = _contactUnreadCount[contactKey] ?? 0;
_contactUnreadCount[contactKey] = currentCount + 1;
_appDebugLogService?.info(
'Contact $contactKey unread count incremented to ${currentCount + 1}',
tag: 'Unread',
);
_unreadStore.saveContactUnreadCount(
Map<String, int>.from(_contactUnreadCount),
); );
} }
+1
View File
@@ -60,6 +60,7 @@ void main() async {
await connector.loadContactCache(); await connector.loadContactCache();
await connector.loadChannelSettings(); await connector.loadChannelSettings();
await connector.loadCachedChannels();
// Load persisted channel messages // Load persisted channel messages
await connector.loadAllChannelMessages(); await connector.loadAllChannelMessages();
+7 -1
View File
@@ -9,8 +9,14 @@ class Channel {
final int index; final int index;
final String name; final String name;
final Uint8List psk; // 16 bytes final Uint8List psk; // 16 bytes
int unreadCount;
Channel({required this.index, required this.name, required this.psk}); Channel({
required this.index,
required this.name,
required this.psk,
this.unreadCount = 0,
});
String get pskHex => _bytesToHex(psk); String get pskHex => _bytesToHex(psk);
+5 -2
View File
@@ -41,6 +41,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final Map<String, GlobalKey> _messageKeys = {}; final Map<String, GlobalKey> _messageKeys = {};
bool _isLoadingOlder = false; bool _isLoadingOlder = false;
MeshCoreConnector? _connector;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -48,7 +50,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
_scrollController.onScrollNearTop = _loadOlderMessages; _scrollController.onScrollNearTop = _loadOlderMessages;
SchedulerBinding.instance.addPostFrameCallback((_) { SchedulerBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
context.read<MeshCoreConnector>().setActiveChannel(widget.channel.index); _connector = context.read<MeshCoreConnector>();
_connector?.setActiveChannel(widget.channel.index);
}); });
} }
@@ -72,7 +75,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
@override @override
void dispose() { void dispose() {
context.read<MeshCoreConnector>().setActiveChannel(null); _connector?.setActiveChannel(null);
_textFieldFocusNode.removeListener(_onTextFieldFocusChange); _textFieldFocusNode.removeListener(_onTextFieldFocusChange);
_textFieldFocusNode.dispose(); _textFieldFocusNode.dispose();
_textController.dispose(); _textController.dispose();
+4 -4
View File
@@ -44,6 +44,7 @@ class _ChatScreenState extends State<ChatScreen> {
final _scrollController = ChatScrollController(); final _scrollController = ChatScrollController();
final _textFieldFocusNode = FocusNode(); final _textFieldFocusNode = FocusNode();
bool _isLoadingOlder = false; bool _isLoadingOlder = false;
MeshCoreConnector? _connector;
@override @override
void initState() { void initState() {
@@ -52,9 +53,8 @@ class _ChatScreenState extends State<ChatScreen> {
_scrollController.onScrollNearTop = _loadOlderMessages; _scrollController.onScrollNearTop = _loadOlderMessages;
SchedulerBinding.instance.addPostFrameCallback((_) { SchedulerBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
context.read<MeshCoreConnector>().setActiveContact( _connector = context.read<MeshCoreConnector>();
widget.contact.publicKeyHex, _connector?.setActiveContact(widget.contact.publicKeyHex);
);
}); });
} }
@@ -78,7 +78,7 @@ class _ChatScreenState extends State<ChatScreen> {
@override @override
void dispose() { void dispose() {
context.read<MeshCoreConnector>().setActiveContact(null); _connector?.setActiveContact(null);
_textFieldFocusNode.removeListener(_onTextFieldFocusChange); _textFieldFocusNode.removeListener(_onTextFieldFocusChange);
_textFieldFocusNode.dispose(); _textFieldFocusNode.dispose();
_textController.dispose(); _textController.dispose();
+50
View File
@@ -0,0 +1,50 @@
import 'dart:convert';
import 'dart:typed_data';
import '../models/channel.dart';
import 'prefs_manager.dart';
class ChannelStore {
static const String _key = 'channels';
Future<List<Channel>> loadChannels() async {
final prefs = PrefsManager.instance;
final jsonStr = prefs.getString(_key);
if (jsonStr == null) return [];
try {
final jsonList = jsonDecode(jsonStr) as List<dynamic>;
return jsonList
.map((entry) => _fromJson(entry as Map<String, dynamic>))
.toList();
} catch (_) {
return [];
}
}
Future<void> saveChannels(List<Channel> channels) async {
final prefs = PrefsManager.instance;
final jsonList = channels.map(_toJson).toList();
await prefs.setString(_key, jsonEncode(jsonList));
}
Map<String, dynamic> _toJson(Channel channel) {
return {
'index': channel.index,
'name': channel.name,
'psk': base64Encode(channel.psk),
'unreadCount': channel.unreadCount,
};
}
Channel _fromJson(Map<String, dynamic> json) {
return Channel(
index: json['index'] as int,
name: json['name'] as String? ?? '',
psk: json['psk'] != null
? Uint8List.fromList(base64Decode(json['psk'] as String))
: Uint8List(16),
unreadCount: json['unreadCount'] as int? ?? 0,
);
}
}
+19 -68
View File
@@ -5,27 +5,23 @@ import 'prefs_manager.dart';
/// Storage for unread message tracking with debounced writes to reduce I/O. /// Storage for unread message tracking with debounced writes to reduce I/O.
class UnreadStore { class UnreadStore {
static const String _contactLastReadKey = 'contact_last_read'; static const String _contactUnreadCountKey = 'contact_unread_count';
static const String _channelLastReadKey = 'channel_last_read';
// Debounce timers to batch rapid writes // Debounce timers to batch rapid writes
Timer? _contactSaveTimer; Timer? _contactUnreadSaveTimer;
Timer? _channelSaveTimer;
static const Duration _saveDebounceDuration = Duration(milliseconds: 500); static const Duration _saveDebounceDuration = Duration(milliseconds: 500);
// Pending write data // Pending write data
Map<String, int>? _pendingContactLastRead; Map<String, int>? _pendingContactUnreadCount;
Map<int, int>? _pendingChannelLastRead;
/// Dispose timers when no longer needed /// Dispose timers when no longer needed
void dispose() { void dispose() {
_contactSaveTimer?.cancel(); _contactUnreadSaveTimer?.cancel();
_channelSaveTimer?.cancel();
} }
Future<Map<String, int>> loadContactLastRead() async { Future<Map<String, int>> loadContactUnreadCount() async {
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final jsonStr = prefs.getString(_contactLastReadKey); final jsonStr = prefs.getString(_contactUnreadCountKey);
if (jsonStr == null) return {}; if (jsonStr == null) return {};
try { try {
@@ -36,75 +32,30 @@ class UnreadStore {
} }
} }
/// Save contact last read timestamps with debouncing. void saveContactUnreadCount(Map<String, int> counts) {
/// Writes are delayed by 500ms and batched to reduce I/O operations. _pendingContactUnreadCount = counts;
void saveContactLastRead(Map<String, int> lastReadMs) {
_pendingContactLastRead = lastReadMs;
// Cancel existing timer _contactUnreadSaveTimer?.cancel();
_contactSaveTimer?.cancel();
// Schedule new write _contactUnreadSaveTimer = Timer(_saveDebounceDuration, () async {
_contactSaveTimer = Timer(_saveDebounceDuration, () async { if (_pendingContactUnreadCount != null) {
if (_pendingContactLastRead != null) { await _flushContactUnreadCount();
await _flushContactLastRead();
} }
}); });
} }
Future<Map<int, int>> loadChannelLastRead() async { Future<void> _flushContactUnreadCount() async {
final prefs = PrefsManager.instance; if (_pendingContactUnreadCount == null) return;
final jsonStr = prefs.getString(_channelLastReadKey);
if (jsonStr == null) return {};
try {
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
return json.map((key, value) => MapEntry(int.parse(key), value as int));
} catch (_) {
return {};
}
}
/// Save channel last read timestamps with debouncing.
/// Writes are delayed by 500ms and batched to reduce I/O operations.
void saveChannelLastRead(Map<int, int> lastReadMs) {
_pendingChannelLastRead = lastReadMs;
_channelSaveTimer?.cancel();
_channelSaveTimer = Timer(_saveDebounceDuration, () async {
if (_pendingChannelLastRead != null) {
await _flushChannelLastRead();
}
});
}
Future<void> _flushContactLastRead() async {
if (_pendingContactLastRead == null) return;
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final jsonStr = jsonEncode(_pendingContactLastRead); final jsonStr = jsonEncode(_pendingContactUnreadCount);
await prefs.setString(_contactLastReadKey, jsonStr); await prefs.setString(_contactUnreadCountKey, jsonStr);
_pendingContactLastRead = null; _pendingContactUnreadCount = null;
}
Future<void> _flushChannelLastRead() async {
if (_pendingChannelLastRead == null) return;
final prefs = PrefsManager.instance;
final asString = _pendingChannelLastRead!.map(
(key, value) => MapEntry(key.toString(), value),
);
final jsonStr = jsonEncode(asString);
await prefs.setString(_channelLastReadKey, jsonStr);
_pendingChannelLastRead = null;
} }
/// Immediately flush pending writes (call before app termination or disposal) /// Immediately flush pending writes (call before app termination or disposal)
Future<void> flush() async { Future<void> flush() async {
_contactSaveTimer?.cancel(); _contactUnreadSaveTimer?.cancel();
_channelSaveTimer?.cancel(); await _flushContactUnreadCount();
await Future.wait([_flushContactLastRead(), _flushChannelLastRead()]);
} }
} }