From 3fe5cdf55d27ee8eb6b41ae1bc8bdb00fa39286a Mon Sep 17 00:00:00 2001 From: ericz Date: Wed, 20 May 2026 23:20:16 +0200 Subject: [PATCH] update to current dev a50c0d0b2dcfb33cf57b553db6b909802262dfe6 --- README.md | 1 + lib/connector/meshcore_connector.dart | 96 ++++++-- lib/connector/meshcore_uuids.dart | 1 + lib/models/channel.dart | 34 +++ lib/models/community.dart | 33 +++ lib/models/radio_settings.dart | 270 ++++++++++++++++++++++ lib/screens/channel_chat_screen.dart | 77 +++++- lib/screens/channels_screen.dart | 84 +++---- lib/screens/chat_screen.dart | 15 +- lib/screens/contacts_screen.dart | 2 + lib/screens/line_of_sight_map_screen.dart | 6 + lib/screens/map_screen.dart | 2 + lib/services/translation_service.dart | 16 +- lib/widgets/quick_switch_bar.dart | 46 +++- 14 files changed, 583 insertions(+), 100 deletions(-) diff --git a/README.md b/README.md index 94d54bac..041ea6e9 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,7 @@ Devices are discovered by scanning for BLE advertisements with known MeshCore de - `WisCore-` - `HT-` - `LowMesh_MC_` + - `NRF52` New device prefixes can be added in `lib/connector/meshcore_uuids.dart`. diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 7eca6e88..0a7056d5 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -302,6 +302,8 @@ class MeshCoreConnector extends ChangeNotifier { final Map _contactUnreadCount = {}; final Map _repeaterBatterySnapshots = {}; bool _unreadStateLoaded = false; + int _cachedContactsUnreadTotal = 0; + int _cachedChannelsUnreadTotal = 0; final Map _pendingRepeaterAcks = {}; String? _activeContactKey; int? _activeChannelIndex; @@ -606,16 +608,42 @@ class MeshCoreConnector extends ChangeNotifier { int getTotalUnreadCount() { if (!_unreadStateLoaded) return 0; - var total = 0; - // Count unread contact messages - for (final contact in _contacts) { - total += getUnreadCountForContact(contact); - } - // Count unread channel messages - for (final channelIndex in _channelMessages.keys) { - total += getUnreadCountForChannelIndex(channelIndex); - } - return total; + return getTotalContactsUnreadCount() + getTotalChannelsUnreadCount(); + } + + int getTotalContactsUnreadCount() { + if (!_unreadStateLoaded) return 0; + return _cachedContactsUnreadTotal; + } + + int getTotalChannelsUnreadCount() { + if (!_unreadStateLoaded) return 0; + return _cachedChannelsUnreadTotal; + } + + /// Recalculates both cached unread totals from scratch. + /// Called when unread state is first loaded. + void _recalculateCachedUnreadTotals() { + _recalculateCachedContactsUnreadTotal(); + _recalculateCachedChannelsUnreadTotal(); + } + + void _recalculateCachedContactsUnreadTotal() { + int total = 0; + _contactUnreadCount.forEach((contactKeyHex, count) { + if (_shouldTrackUnreadForContactKey(contactKeyHex)) { + total += count; + } + }); + _cachedContactsUnreadTotal = total; + } + + void _recalculateCachedChannelsUnreadTotal() { + final allChannels = _channels.isNotEmpty ? _channels : _cachedChannels; + _cachedChannelsUnreadTotal = allChannels.fold( + 0, + (total, ch) => total + ch.unreadCount, + ); } bool isChannelSmazEnabled(int channelIndex) { @@ -649,11 +677,13 @@ class MeshCoreConnector extends ChangeNotifier { ..clear() ..addAll(await _unreadStore.loadContactUnreadCount()); _unreadStateLoaded = true; + _recalculateCachedUnreadTotals(); notifyListeners(); } Future loadCachedChannels() async { _cachedChannels = await _channelStore.loadChannels(); + _recalculateCachedChannelsUnreadTotal(); } void setActiveContact(String? contactKeyHex) { @@ -680,6 +710,8 @@ class MeshCoreConnector extends ChangeNotifier { final previousCount = _contactUnreadCount[contactKeyHex] ?? 0; if (previousCount > 0) { _contactUnreadCount[contactKeyHex] = 0; + _cachedContactsUnreadTotal = (_cachedContactsUnreadTotal - previousCount) + .clamp(0, _cachedContactsUnreadTotal); _appDebugLogService?.info( 'Contact $contactKeyHex marked as read (was $previousCount unread)', tag: 'Unread', @@ -721,6 +753,8 @@ class MeshCoreConnector extends ChangeNotifier { if (channel != null && channel.unreadCount > 0) { final previousCount = channel.unreadCount; channel.unreadCount = 0; + _cachedChannelsUnreadTotal = (_cachedChannelsUnreadTotal - previousCount) + .clamp(0, _cachedChannelsUnreadTotal); _appDebugLogService?.info( 'Channel ${channel.name.isNotEmpty ? channel.name : channelIndex} marked as read (was $previousCount unread)', tag: 'Unread', @@ -3156,6 +3190,9 @@ class MeshCoreConnector extends ChangeNotifier { unawaited(_persistContacts()); _conversations.remove(contact.publicKeyHex); _loadedConversationKeys.remove(contact.publicKeyHex); + final removedCount = _contactUnreadCount[contact.publicKeyHex] ?? 0; + _cachedContactsUnreadTotal = (_cachedContactsUnreadTotal - removedCount) + .clamp(0, _cachedContactsUnreadTotal); _contactUnreadCount.remove(contact.publicKeyHex); _unreadStore.saveContactUnreadCount( Map.from(_contactUnreadCount), @@ -3549,6 +3586,7 @@ class MeshCoreConnector extends ChangeNotifier { // Cache channels for offline use _cachedChannels = List.from(_channels); unawaited(_channelStore.saveChannels(_channels)); + _recalculateCachedChannelsUnreadTotal(); // Apply ordering and notify UI _applyChannelOrder(); @@ -4101,6 +4139,9 @@ class MeshCoreConnector extends ChangeNotifier { _handleDiscovery(contact, frame, noNotify: true, addActive: true); if (contact.type == advTypeRepeater) { + final removedCount = _contactUnreadCount[contact.publicKeyHex] ?? 0; + _cachedContactsUnreadTotal = (_cachedContactsUnreadTotal - removedCount) + .clamp(0, _cachedContactsUnreadTotal); _contactUnreadCount.remove(contact.publicKeyHex); _unreadStore.saveContactUnreadCount( Map.from(_contactUnreadCount), @@ -4191,6 +4232,9 @@ class MeshCoreConnector extends ChangeNotifier { } if (contact.type == advTypeRepeater) { + final removedCount = _contactUnreadCount[contact.publicKeyHex] ?? 0; + _cachedContactsUnreadTotal = (_cachedContactsUnreadTotal - removedCount) + .clamp(0, _cachedContactsUnreadTotal); _contactUnreadCount.remove(contact.publicKeyHex); _unreadStore.saveContactUnreadCount( Map.from(_contactUnreadCount), @@ -4464,17 +4508,13 @@ class MeshCoreConnector extends ChangeNotifier { badgeCount: getTotalUnreadCount(), ); } else if (c?.type == advTypeRoom) { - // Room server messages include a 4-char prefix; strip it for notifications - final bodyText = msg.text.length > 4 - ? msg.text.substring(4) - : msg.text; final resolvedText = (translationResult != null && translationResult.status == MessageTranslationStatus.completed && translationResult.translatedText.trim().isNotEmpty) ? translationResult.translatedText.trim() - : bodyText.trim(); + : msg.text.trim(); await _notificationService.showMessageNotification( contactName: c?.name ?? 'Unknown Room', message: resolvedText, @@ -4522,16 +4562,24 @@ class MeshCoreConnector extends ChangeNotifier { timestampRaw * 1000, ); - if (txtType == 2) { - reader.skipBytes(4); // Skip extra 4 bytes for signed/plain variants + final flags = txtType; + final shiftedType = flags >> 2; + final rawType = flags; + final isSigned = shiftedType == txtTypeSigned || rawType == txtTypeSigned; + final Uint8List? roomAuthorPrefix; + if (isSigned) { + // Room-server pushed posts use signed/plain contact messages where this + // 4-byte "signature" field is actually the original author's pubkey + // prefix. Keep it as metadata; the text starts after these bytes. + roomAuthorPrefix = reader.readBytes(4); + } else { + roomAuthorPrefix = null; } final msgText = reader.readCString(); - final flags = txtType; - final shiftedType = flags >> 2; - final rawType = flags; - final isPlain = shiftedType == txtTypePlain || rawType == txtTypePlain; + final isPlain = + shiftedType == txtTypePlain || rawType == txtTypePlain || isSigned; final isCli = shiftedType == txtTypeCliData || rawType == txtTypeCliData; if (!isPlain && !isCli) { appLogger.warn( @@ -4568,9 +4616,7 @@ class MeshCoreConnector extends ChangeNotifier { status: MessageStatus.delivered, pathLength: pathLength == 0xFF ? 0 : pathLength, pathBytes: Uint8List(0), - fourByteRoomContactKey: msgText.length >= 4 - ? Uint8List.fromList(msgText.substring(0, 4).codeUnits) - : null, + fourByteRoomContactKey: roomAuthorPrefix, ); } catch (e) { appLogger.warn('Error parsing contact direct message: $e'); @@ -5247,6 +5293,7 @@ class MeshCoreConnector extends ChangeNotifier { final channel = _findChannelByIndex(channelIndex); if (channel != null) { channel.unreadCount++; + _cachedChannelsUnreadTotal++; _appDebugLogService?.info( 'Channel ${channel.name.isNotEmpty ? channel.name : channelIndex} unread count incremented to ${channel.unreadCount}', tag: 'Unread', @@ -5291,6 +5338,7 @@ class MeshCoreConnector extends ChangeNotifier { final currentCount = _contactUnreadCount[contactKey] ?? 0; _contactUnreadCount[contactKey] = currentCount + 1; + _cachedContactsUnreadTotal++; _appDebugLogService?.info( 'Contact $contactKey unread count incremented to ${currentCount + 1}', tag: 'Unread', diff --git a/lib/connector/meshcore_uuids.dart b/lib/connector/meshcore_uuids.dart index ae6697b6..084cc424 100644 --- a/lib/connector/meshcore_uuids.dart +++ b/lib/connector/meshcore_uuids.dart @@ -11,5 +11,6 @@ class MeshCoreUuids { "Lilygo", "HT-", "LowMesh_MC_", + "NRF52", ]; } diff --git a/lib/models/channel.dart b/lib/models/channel.dart index 4fdd6270..9baf6302 100644 --- a/lib/models/channel.dart +++ b/lib/models/channel.dart @@ -4,6 +4,9 @@ import 'dart:typed_data'; import 'package:crypto/crypto.dart' as crypto; import '../connector/meshcore_protocol.dart'; +import 'community.dart'; + +enum ChannelType { public, private, hashtag, communityPublic, communityHashtag } class Channel { final int index; @@ -111,5 +114,36 @@ class Channel { return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); } + static bool isCommunityChannel(ChannelType channelType) { + switch (channelType) { + case ChannelType.communityPublic: + case ChannelType.communityHashtag: + return true; + case ChannelType.public: + case ChannelType.private: + case ChannelType.hashtag: + return false; + } + } + + static ChannelType getChannelType( + Channel channel, + CommunityPskIndex communityIndex, + ) { + Community? community = communityIndex.getCommunityForChannel(channel); + if (community != null) { + if (Community.isCommunityPublicChannel(channel, community)) { + return ChannelType.communityPublic; + } + return ChannelType.communityHashtag; + } + if (channel.isPublicChannel) { + return ChannelType.public; + } else if (channel.name.startsWith('#')) { + return ChannelType.hashtag; + } + return ChannelType.private; + } + static const String publicChannelPsk = '8b3387e9c5cdea6ac9e5edbaa115cd72'; } diff --git a/lib/models/community.dart b/lib/models/community.dart index c829f3d1..7261ddf9 100644 --- a/lib/models/community.dart +++ b/lib/models/community.dart @@ -4,6 +4,8 @@ import 'dart:typed_data'; import 'package:crypto/crypto.dart' as crypto; +import 'channel.dart'; + /// Represents a community with a shared secret for deriving channel PSKs. /// /// A Community is a namespace with a shared secret K (32 random bytes), @@ -162,6 +164,12 @@ class Community { return hashtag.replaceFirst(RegExp(r'^#'), '').toLowerCase().trim(); } + /// Returns true if this is the community's public channel + static bool isCommunityPublicChannel(Channel channel, Community community) { + final publicPsk = community.deriveCommunityPublicPsk(); + return channel.pskHex == Channel.formatPskHex(publicPsk); + } + /// Add a hashtag channel to this community's list Community addHashtagChannel(String hashtag) { final normalized = _normalizeCommunityHashtag(hashtag); @@ -237,3 +245,28 @@ class Community { @override int get hashCode => id.hashCode; } + +class CommunityPskIndex { + // Cache of PSK hex -> Community for quick lookup + final Map _pskToCommunity = {}; + + void initialize(List communities) { + _pskToCommunity.clear(); + for (final community in communities) { + // Map the community public channel PSK + final publicPsk = community.deriveCommunityPublicPsk(); + _pskToCommunity[Channel.formatPskHex(publicPsk)] = community; + + // Map all known hashtag channel PSKs + for (final hashtag in community.hashtagChannels) { + final hashtagPsk = community.deriveCommunityHashtagPsk(hashtag); + _pskToCommunity[Channel.formatPskHex(hashtagPsk)] = community; + } + } + } + + /// Returns the community this channel belongs to, or null if not a community channel + Community? getCommunityForChannel(Channel channel) { + return _pskToCommunity[channel.pskHex]; + } +} diff --git a/lib/models/radio_settings.dart b/lib/models/radio_settings.dart index 099d9201..e74cf209 100644 --- a/lib/models/radio_settings.dart +++ b/lib/models/radio_settings.dart @@ -181,6 +181,276 @@ class RadioSettings { txPowerDbm: 14, ), ), + ( + 'Russia Artyom (VVO)', + RadioSettings( + frequencyMHz: 864.281, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_6, + txPowerDbm: 20, + ), + ), + ( + 'Russia Biysk (BSK)', + RadioSettings( + frequencyMHz: 869.000, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_5, + txPowerDbm: 20, + ), + ), + ( + 'Russia Chelyabinsk (CEK)', + RadioSettings( + frequencyMHz: 868.731, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_6, + txPowerDbm: 20, + ), + ), + ( + 'Russia Cherepovets (CEE)', + RadioSettings( + frequencyMHz: 868.570, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf7, + codingRate: LoRaCodingRate.cr4_8, + txPowerDbm: 20, + ), + ), + ( + 'Russia Irkutsk (IKT)', + RadioSettings( + frequencyMHz: 868.731, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf7, + codingRate: LoRaCodingRate.cr4_7, + txPowerDbm: 20, + ), + ), + ( + 'Russia Ivanovo (IWA)', + RadioSettings( + frequencyMHz: 868.731, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_8, + txPowerDbm: 20, + ), + ), + ( + 'Russia Izhevsk (IJK)', + RadioSettings( + frequencyMHz: 868.732, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_8, + txPowerDbm: 20, + ), + ), + ( + 'Russia Kaluga (KLF)', + RadioSettings( + frequencyMHz: 868.731, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf7, + codingRate: LoRaCodingRate.cr4_7, + txPowerDbm: 20, + ), + ), + ( + 'Russia Kazan (KZN)', + RadioSettings( + frequencyMHz: 868.731, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_6, + txPowerDbm: 20, + ), + ), + ( + 'Russia Khabarovsk (KHV)', + RadioSettings( + frequencyMHz: 864.281, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_6, + txPowerDbm: 20, + ), + ), + ( + 'Russia Kirov (KVX)', + RadioSettings( + frequencyMHz: 868.731, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_8, + txPowerDbm: 20, + ), + ), + ( + 'Russia Lipetsk (LPK)', + RadioSettings( + frequencyMHz: 868.950, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf9, + codingRate: LoRaCodingRate.cr4_7, + txPowerDbm: 20, + ), + ), + ( + 'Russia Moscow (MOW)', + RadioSettings( + frequencyMHz: 868.731, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf7, + codingRate: LoRaCodingRate.cr4_7, + txPowerDbm: 20, + ), + ), + ( + 'Russia Nizhny Novgorod (GOJ)', + RadioSettings( + frequencyMHz: 868.731, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_6, + txPowerDbm: 20, + ), + ), + ( + 'Russia Novosibirsk (OVB)', + RadioSettings( + frequencyMHz: 869.000, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf9, + codingRate: LoRaCodingRate.cr4_8, + txPowerDbm: 20, + ), + ), + ( + 'Russia Rostov-on-Don (ROV)', + RadioSettings( + frequencyMHz: 868.731, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf9, + codingRate: LoRaCodingRate.cr4_7, + txPowerDbm: 20, + ), + ), + ( + 'Russia Ryazan (RZN)', + RadioSettings( + frequencyMHz: 868.880, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf9, + codingRate: LoRaCodingRate.cr4_5, + txPowerDbm: 20, + ), + ), + ( + 'Russia Samara (KUF)', + RadioSettings( + frequencyMHz: 864.281, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_7, + txPowerDbm: 20, + ), + ), + ( + 'Russia Saratov (GSV)', + RadioSettings( + frequencyMHz: 864.281, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_7, + txPowerDbm: 20, + ), + ), + ( + 'Russia St. Petersburg (LED)', + RadioSettings( + frequencyMHz: 868.856, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf7, + codingRate: LoRaCodingRate.cr4_7, + txPowerDbm: 20, + ), + ), + ( + 'Russia Tambov (TBW)', + RadioSettings( + frequencyMHz: 868.950, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf10, + codingRate: LoRaCodingRate.cr4_5, + txPowerDbm: 20, + ), + ), + ( + 'Russia Tula (TYA)', + RadioSettings( + frequencyMHz: 868.731, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_7, + txPowerDbm: 20, + ), + ), + ( + 'Russia Tver (KLD)', + RadioSettings( + frequencyMHz: 869.169, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_8, + txPowerDbm: 20, + ), + ), + ( + 'Russia Ufa (UFA)', + RadioSettings( + frequencyMHz: 868.732, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_8, + txPowerDbm: 20, + ), + ), + ( + 'Russia Volgograd (VOG)', + RadioSettings( + frequencyMHz: 869.525, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf7, + codingRate: LoRaCodingRate.cr4_7, + txPowerDbm: 20, + ), + ), + ( + 'Russia Voronezh (VOZ)', + RadioSettings( + frequencyMHz: 868.731, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf8, + codingRate: LoRaCodingRate.cr4_6, + txPowerDbm: 20, + ), + ), + ( + 'Russia Yekaterinburg (SVX)', + RadioSettings( + frequencyMHz: 869.046, + bandwidth: LoRaBandwidth.bw62_5, + spreadingFactor: LoRaSpreadingFactor.sf7, + codingRate: LoRaCodingRate.cr4_7, + txPowerDbm: 20, + ), + ), ( 'Switzerland', RadioSettings( diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index b57bd235..f34bab04 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -9,6 +9,8 @@ import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import '../connector/meshcore_connector.dart'; +import '../models/community.dart'; +import '../storage/community_store.dart'; import '../utils/platform_info.dart'; import '../helpers/chat_scroll_controller.dart'; import '../connector/meshcore_protocol.dart'; @@ -57,8 +59,11 @@ class _ChannelChatScreenState extends State { final ChatScrollController _scrollController = ChatScrollController(); final FocusNode _textFieldFocusNode = FocusNode(); ChannelMessage? _replyingToMessage; + final CommunityStore _communityStore = CommunityStore(); + final CommunityPskIndex _communityIndex = CommunityPskIndex(); final Map _messageKeys = {}; bool _isLoadingOlder = false; + bool _communitiesLoaded = false; MeshCoreConnector? _connector; DateTime? _lastChannelSendAt; @@ -82,6 +87,7 @@ class _ChannelChatScreenState extends State { final idx = widget.channel.index; final unread = widget.initialUnreadCount; final messages = connector.getChannelMessages(widget.channel); + _loadCommunities(); ChannelMessage? anchor; if (unread > 0) { anchor = _findOldestUnreadChannelAnchor(messages, unread); @@ -108,6 +114,19 @@ class _ChannelChatScreenState extends State { }); } + // TODO: Reload communities when returning from another screen + Future _loadCommunities() async { + final connector = context.read(); + _communityStore.setPublicKeyHex = connector.selfPublicKeyHex; + final communities = await _communityStore.loadCommunities(); + if (mounted) { + setState(() { + _communityIndex.initialize(communities); + _communitiesLoaded = true; + }); + } + } + ChannelMessage? _findOldestUnreadChannelAnchor( List messages, int unreadCount, @@ -194,16 +213,63 @@ class _ChannelChatScreenState extends State { ); } + Widget _channelIcon(Channel channel) { + // Determine icon based on channel type + final ChannelType channelType = Channel.getChannelType( + channel, + _communityIndex, + ); + final bool isCommunityChannel = Channel.isCommunityChannel(channelType); + IconData icon; + switch (channelType) { + case ChannelType.communityPublic: + icon = Icons.groups; + case ChannelType.communityHashtag: + icon = Icons.tag; + case ChannelType.public: + icon = Icons.public; + case ChannelType.hashtag: + icon = Icons.tag; + case ChannelType.private: + icon = Icons.lock; + } + return Stack( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: _communitiesLoaded + ? Icon(icon, size: 20) + : SizedBox.square(dimension: 20), + ), + if (isCommunityChannel) + Positioned( + right: 0, + bottom: 0, + child: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: Colors.purple, + shape: BoxShape.circle, + border: Border.all( + color: Theme.of(context).cardColor, + width: 2, + ), + ), + child: const Icon(Icons.people, size: 8, color: Colors.white), + ), + ), + ], + ); + } + @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Row( children: [ - Icon( - widget.channel.isPublicChannel ? Icons.public : Icons.tag, - size: 20, - ), + _channelIcon(widget.channel), const SizedBox(width: 8), Expanded( child: Column( @@ -1321,11 +1387,8 @@ class _ChannelChatScreenState extends State { } void _showMessageActions(ChannelMessage message) { - final settings = context.read().settings; final translationService = context.read(); final canTranslateMessage = - settings.translationEnabled && - !settings.autoTranslateIncomingMessages && translationService.canTranslateIncoming( text: message.text, isCli: false, diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 4613a8ee..8b05b8ce 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -44,11 +44,9 @@ class _ChannelsScreenState extends State with DisconnectNavigationMixin { final TextEditingController _searchController = TextEditingController(); final CommunityStore _communityStore = CommunityStore(); - Timer? _searchDebounce; + final CommunityPskIndex _communityIndex = CommunityPskIndex(); List _communities = []; - - // Cache of PSK hex -> Community for quick lookup - final Map _pskToCommunity = {}; + Timer? _searchDebounce; ChannelMessageStore get _channelMessageStore => ChannelMessageStore(); @@ -71,37 +69,11 @@ class _ChannelsScreenState extends State if (mounted) { setState(() { _communities = communities; - _buildPskCommunityMap(); + _communityIndex.initialize(communities); }); } } - void _buildPskCommunityMap() { - _pskToCommunity.clear(); - for (final community in _communities) { - // Map the community public channel PSK - final publicPsk = community.deriveCommunityPublicPsk(); - _pskToCommunity[Channel.formatPskHex(publicPsk)] = community; - - // Map all known hashtag channel PSKs - for (final hashtag in community.hashtagChannels) { - final hashtagPsk = community.deriveCommunityHashtagPsk(hashtag); - _pskToCommunity[Channel.formatPskHex(hashtagPsk)] = community; - } - } - } - - /// Returns the community this channel belongs to, or null if not a community channel - Community? _getCommunityForChannel(Channel channel) { - return _pskToCommunity[channel.pskHex]; - } - - /// Returns true if this is the community's public channel - bool _isCommunityPublicChannel(Channel channel, Community community) { - final publicPsk = community.deriveCommunityPublicPsk(); - return channel.pskHex == Channel.formatPskHex(publicPsk); - } - @override void dispose() { _searchDebounce?.cancel(); @@ -360,6 +332,8 @@ class _ChannelsScreenState extends State selectedIndex: 1, onDestinationSelected: (index) => _handleQuickSwitch(index, context), + contactsUnreadCount: connector.getTotalContactsUnreadCount(), + channelsUnreadCount: connector.getTotalChannelsUnreadCount(), ), ), ), @@ -375,37 +349,37 @@ class _ChannelsScreenState extends State int? dragIndex, }) { final unreadCount = connector.getUnreadCountForChannel(channel); - final community = _getCommunityForChannel(channel); - final isCommunityChannel = community != null; - final isCommunityPublic = - isCommunityChannel && _isCommunityPublicChannel(channel, community); // Determine icon and colors based on channel type IconData icon; Color iconColor; Color bgColor; - - if (isCommunityChannel) { - // Community channel styling - iconColor = Colors.purple; - bgColor = Colors.purple.withValues(alpha: 0.2); - if (isCommunityPublic) { + final ChannelType channelType = Channel.getChannelType( + channel, + _communityIndex, + ); + final bool isCommunityChannel = Channel.isCommunityChannel(channelType); + switch (channelType) { + case ChannelType.communityPublic: icon = Icons.groups; - } else { + iconColor = Colors.purple; + bgColor = Colors.purple.withValues(alpha: 0.2); + case ChannelType.communityHashtag: icon = Icons.tag; - } - } else if (channel.isPublicChannel) { - icon = Icons.public; - iconColor = Colors.green; - bgColor = Colors.green.withValues(alpha: 0.2); - } else if (channel.name.startsWith('#')) { - icon = Icons.tag; - iconColor = Colors.blue; - bgColor = Colors.blue.withValues(alpha: 0.2); - } else { - icon = Icons.lock; - iconColor = Colors.blue; - bgColor = Colors.blue.withValues(alpha: 0.2); + iconColor = Colors.purple; + bgColor = Colors.purple.withValues(alpha: 0.2); + case ChannelType.public: + icon = Icons.public; + iconColor = Colors.green; + bgColor = Colors.green.withValues(alpha: 0.2); + case ChannelType.hashtag: + icon = Icons.tag; + iconColor = Colors.blue; + bgColor = Colors.blue.withValues(alpha: 0.2); + case ChannelType.private: + icon = Icons.lock; + iconColor = Colors.blue; + bgColor = Colors.blue.withValues(alpha: 0.2); } return Card( diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 07884b96..7c71d597 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -485,6 +485,8 @@ class _ChatScreenState extends State { final message = reversedMessages[messageIndex]; String fourByteHex = ''; if (contact.type == advTypeRoom) { + // Room-server messages carry the original author's 4-byte prefix + // separately from message.text; use it only for resolving the name. contact = _resolveContactFrom4Bytes( connector, message.fourByteRoomContactKey.isEmpty @@ -509,7 +511,6 @@ class _ChatScreenState extends State { ? "${contact.name} [$fourByteHex]" : contact.name, sourceId: widget.contact.publicKeyHex, - isRoomServer: resolvedContact.type == advTypeRoom, textScale: textScale, onTap: () => _openMessagePath(message, contact), onLongPress: () => _showMessageActions(message, contact), @@ -1577,11 +1578,8 @@ class _ChatScreenState extends State { } void _showMessageActions(Message message, Contact contact) { - final settings = context.read().settings; final translationService = context.read(); final canTranslateMessage = - settings.translationEnabled && - !settings.autoTranslateIncomingMessages && translationService.canTranslateIncoming( text: message.text, isCli: message.isCli, @@ -1748,7 +1746,6 @@ class _ChatScreenState extends State { class _MessageBubble extends StatelessWidget { final Message message; final String senderName; - final bool isRoomServer; final VoidCallback? onTap; final VoidCallback? onLongPress; final void Function(Message message, String emoji)? onRetryReaction; @@ -1759,7 +1756,6 @@ class _MessageBubble extends StatelessWidget { required this.message, required this.senderName, required this.sourceId, - required this.isRoomServer, required this.textScale, this.onTap, this.onLongPress, @@ -1785,10 +1781,9 @@ class _MessageBubble extends StatelessWidget { : (isOutgoing ? colorScheme.onPrimary : colorScheme.onSurface); final metaColor = textColor.withValues(alpha: 0.7); const bodyFontSize = 14.0; - String messageText = message.text; - if (isRoomServer && !isOutgoing) { - messageText = message.text.substring(4.clamp(0, message.text.length)); - } + // Do not strip room-server author bytes here: the parser stores them in + // fourByteRoomContactKey, so message.text is safe to render as-is. + final messageText = message.text; final translatedDisplayText = message.translatedText != null && message.translatedText!.trim().isNotEmpty diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index a4cc35ca..d5e1b79a 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -430,6 +430,8 @@ class _ContactsScreenState extends State selectedIndex: 0, onDestinationSelected: (index) => _handleQuickSwitch(index, context), + contactsUnreadCount: connector.getTotalContactsUnreadCount(), + channelsUnreadCount: connector.getTotalChannelsUnreadCount(), ), ), ), diff --git a/lib/screens/line_of_sight_map_screen.dart b/lib/screens/line_of_sight_map_screen.dart index 3ba79d59..f908f5ea 100644 --- a/lib/screens/line_of_sight_map_screen.dart +++ b/lib/screens/line_of_sight_map_screen.dart @@ -539,6 +539,12 @@ class _LineOfSightMapScreenState extends State { child: QuickSwitchBar( selectedIndex: 2, onDestinationSelected: (index) => _handleQuickSwitch(index, context), + contactsUnreadCount: context + .watch() + .getTotalContactsUnreadCount(), + channelsUnreadCount: context + .watch() + .getTotalChannelsUnreadCount(), ), ), ); diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 4ae2fd43..be133240 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -676,6 +676,8 @@ class _MapScreenState extends State { selectedIndex: 2, onDestinationSelected: (index) => _handleQuickSwitch(index, context), + contactsUnreadCount: connector.getTotalContactsUnreadCount(), + channelsUnreadCount: connector.getTotalChannelsUnreadCount(), ), ), floatingActionButton: FloatingActionButton( diff --git a/lib/services/translation_service.dart b/lib/services/translation_service.dart index 4437d1f1..7b1d7f5f 100644 --- a/lib/services/translation_service.dart +++ b/lib/services/translation_service.dart @@ -388,7 +388,9 @@ class TranslationService extends ChangeNotifier { if (targetLanguageCode == null || !_isPlainTextEligible(text)) { return null; } - final detectedLanguageCode = await detectLanguage(text); + final detectedLanguageCode = await detectLanguage( + _stripReplyInfoForDetection(text), + ); if (detectedLanguageCode != null && detectedLanguageCode == targetLanguageCode) { return const TranslationResult( @@ -429,7 +431,9 @@ class TranslationService extends ChangeNotifier { if (targetLanguageCode == null || !_isPlainTextEligible(text)) { return null; } - final detectedLanguageCode = await detectLanguage(text); + final detectedLanguageCode = await detectLanguage( + _stripReplyInfoForDetection(text), + ); if (detectedLanguageCode != null && detectedLanguageCode == targetLanguageCode) { return const TranslationResult( @@ -470,6 +474,14 @@ class TranslationService extends ChangeNotifier { } } + String _stripReplyInfoForDetection(String text) { + final match = RegExp( + r'@\[([^\]]+)\]\s+(.+)$', + dotAll: true, + ).firstMatch(text); + return match?.group(2) ?? text; + } + Future _translateText({ required String text, required String targetLanguageCode, diff --git a/lib/widgets/quick_switch_bar.dart b/lib/widgets/quick_switch_bar.dart index 134091ff..40dcb59a 100644 --- a/lib/widgets/quick_switch_bar.dart +++ b/lib/widgets/quick_switch_bar.dart @@ -6,11 +6,15 @@ import '../l10n/l10n.dart'; class QuickSwitchBar extends StatelessWidget { final int selectedIndex; final ValueChanged onDestinationSelected; + final int contactsUnreadCount; + final int channelsUnreadCount; const QuickSwitchBar({ super.key, required this.selectedIndex, required this.onDestinationSelected, + this.contactsUnreadCount = 0, + this.channelsUnreadCount = 0, }); @override @@ -62,15 +66,30 @@ class QuickSwitchBar extends StatelessWidget { onDestinationSelected: onDestinationSelected, destinations: [ NavigationDestination( - icon: const Icon(Icons.people_outline), + icon: _buildIconWithBadge( + const Icon(Icons.people_outline), + contactsUnreadCount, + ), + selectedIcon: _buildIconWithBadge( + const Icon(Icons.people), + contactsUnreadCount, + ), label: context.l10n.nav_contacts, ), NavigationDestination( - icon: const Icon(Icons.tag), + icon: _buildIconWithBadge( + const Icon(Icons.tag), + channelsUnreadCount, + ), + selectedIcon: _buildIconWithBadge( + const Icon(Icons.tag), + channelsUnreadCount, + ), label: context.l10n.nav_channels, ), NavigationDestination( icon: const Icon(Icons.map_outlined), + selectedIcon: const Icon(Icons.map), label: context.l10n.nav_map, ), ], @@ -81,4 +100,27 @@ class QuickSwitchBar extends StatelessWidget { ), ); } + + Widget _buildIconWithBadge(Icon icon, int count) { + if (count <= 0) return icon; + + return Stack( + clipBehavior: Clip.none, + children: [ + icon, + Positioned( + right: -2, + top: -2, + child: Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Colors.redAccent, + shape: BoxShape.circle, + ), + ), + ), + ], + ); + } }