mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-19 23:31:03 +10:00
Refactor unread message tracking and implement channel caching (#126)
* Refactor unread message tracking and implement channel caching * formatted files
This commit is contained in:
@@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user