From 00ae5558c7701a4dc31bde0b1ed802a137b608c6 Mon Sep 17 00:00:00 2001 From: Strycher Date: Wed, 1 Jul 2026 11:47:07 -0400 Subject: [PATCH] feat(#169): suppress blocked DMs/advert notifs + visible blocked marker - Connector: BlockService injected via initialize(); incoming DM from a blocked key dropped (no thread/unread/notify), advert notifications gated at all 3 sites (advert still processed for name self-heal). App-push-layer only. - Contacts + Discovery: blocked entries stay VISIBLE with a red block icon (BlockedBadge) + strikethrough/muted name (not hidden). - New lib/widgets/blocked_badge.dart. Epic A #165 / A2 #169. Design: docs/architecture/block-contract-as-built.md. Co-Authored-By: Claude Opus 4.8 --- lib/connector/meshcore_connector.dart | 24 ++++++++++++++++--- lib/main.dart | 1 + lib/screens/contacts_screen.dart | 25 +++++++++++++++++++- lib/screens/discovery_screen.dart | 34 +++++++++++++++++++++++---- lib/widgets/blocked_badge.dart | 14 +++++++++++ 5 files changed, 89 insertions(+), 9 deletions(-) create mode 100644 lib/widgets/blocked_badge.dart diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 9ff5c26..20baa07 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -29,6 +29,7 @@ import '../services/message_retry_service.dart'; import '../services/path_history_service.dart'; import '../services/mesh_topology_service.dart'; import '../services/app_settings_service.dart'; +import '../services/block_service.dart'; import '../services/background_service.dart'; import '../services/timeout_prediction_service.dart'; import '../services/translation_service.dart'; @@ -367,6 +368,7 @@ class MeshCoreConnector extends ChangeNotifier { PathHistoryService? _pathHistoryService; MeshTopologyService? _topologyService; AppSettingsService? _appSettingsService; + BlockService? _blockService; BackgroundService? _backgroundService; final NotificationService _notificationService = NotificationService(); BleDebugLogService? _bleDebugLogService; @@ -1009,6 +1011,7 @@ class MeshCoreConnector extends ChangeNotifier { AppDebugLogService? appDebugLogService, BackgroundService? backgroundService, TimeoutPredictionService? timeoutPredictionService, + BlockService? blockService, }) { _retryService = retryService; _pathHistoryService = pathHistoryService; @@ -1019,6 +1022,7 @@ class MeshCoreConnector extends ChangeNotifier { _appDebugLogService = appDebugLogService; _backgroundService = backgroundService; _timeoutPredictionService = timeoutPredictionService; + _blockService = blockService; _usbManager.setDebugLogService(_appDebugLogService); _tcpConnector.setDebugLogService(_appDebugLogService); @@ -4754,7 +4758,9 @@ class MeshCoreConnector extends ChangeNotifier { // Show notification for new contact (advertisement) if (isNewContact && _appSettingsService != null) { final settings = _appSettingsService!.settings; - if (settings.notificationsEnabled && settings.notifyOnNewAdvert) { + if (settings.notificationsEnabled && + settings.notifyOnNewAdvert && + !(_blockService?.isBlocked(contact.publicKeyHex) ?? false)) { _notificationService.showAdvertNotification( contactName: contact.name, contactType: contact.typeLabelRaw, @@ -4832,7 +4838,9 @@ class MeshCoreConnector extends ChangeNotifier { // Show notification for new contact (advertisement) if (isNewContact && _appSettingsService != null) { final settings = _appSettingsService!.settings; - if (settings.notificationsEnabled && settings.notifyOnNewAdvert) { + if (settings.notificationsEnabled && + settings.notifyOnNewAdvert && + !(_blockService?.isBlocked(contact.publicKeyHex) ?? false)) { _notificationService.showAdvertNotification( contactName: contact.name, contactType: contact.typeLabelRaw, @@ -5005,6 +5013,14 @@ class MeshCoreConnector extends ChangeNotifier { return; } + // Blocked sender: drop the DM entirely — no thread entry, no unread, no + // notification, no lastMessage bump. Adverts still flow for name self-heal. + if (!message.isOutgoing && + (_blockService?.isBlocked(message.senderKeyHex) ?? false)) { + _handleQueuedMessageReceived(); + return; + } + final contact = _contacts.cast().firstWhere( (c) => c?.publicKeyHex == message!.senderKeyHex, orElse: () => null, @@ -6960,7 +6976,9 @@ class MeshCoreConnector extends ChangeNotifier { // Show notification for new contact (advertisement) if (_appSettingsService != null && !noNotify) { final settings = _appSettingsService!.settings; - if (settings.notificationsEnabled && settings.notifyOnNewAdvert) { + if (settings.notificationsEnabled && + settings.notifyOnNewAdvert && + !(_blockService?.isBlocked(contact.publicKeyHex) ?? false)) { _notificationService.showAdvertNotification( contactName: contact.name, contactType: contact.typeLabelRaw, diff --git a/lib/main.dart b/lib/main.dart index 892ae99..52f3a6f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -91,6 +91,7 @@ void main() async { appDebugLogService: appDebugLogService, backgroundService: backgroundService, timeoutPredictionService: timeoutPredictionService, + blockService: blockService, ); await connector.loadContactCache(); diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index a1b9c97..5deaaaa 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -17,6 +17,7 @@ import '../models/contact.dart'; import '../l10n/contact_localization.dart'; import '../models/contact_group.dart'; import '../services/ui_view_state_service.dart'; +import '../services/block_service.dart'; import '../utils/contact_search.dart'; import '../storage/contact_group_store.dart'; import '../utils/dialog_utils.dart'; @@ -25,6 +26,7 @@ import '../utils/emoji_utils.dart'; import '../utils/route_transitions.dart'; import '../widgets/list_filter_widget.dart'; import '../widgets/empty_state.dart'; +import '../widgets/blocked_badge.dart'; import '../widgets/quick_switch_bar.dart'; import '../widgets/path_selection_dialog.dart'; import '../widgets/repeater_login_dialog.dart'; @@ -1566,6 +1568,9 @@ class _ContactTile extends StatelessWidget { @override Widget build(BuildContext context) { + final isBlocked = context.watch().isBlocked( + contact.publicKeyHex, + ); return GestureDetector( onSecondaryTapUp: PlatformInfo.isDesktop ? (_) => onLongPress() : null, child: ListTile( @@ -1573,7 +1578,25 @@ class _ContactTile extends StatelessWidget { backgroundColor: _getTypeColor(contact.type), child: _buildContactAvatar(contact), ), - title: Text(contact.name, maxLines: 1, overflow: TextOverflow.ellipsis), + title: isBlocked + ? Row( + children: [ + const BlockedBadge(), + const SizedBox(width: 6), + Expanded( + child: Text( + contact.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Theme.of(context).disabledColor, + decoration: TextDecoration.lineThrough, + ), + ), + ), + ], + ) + : Text(contact.name, maxLines: 1, overflow: TextOverflow.ellipsis), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/screens/discovery_screen.dart b/lib/screens/discovery_screen.dart index f9f0e07..4663683 100644 --- a/lib/screens/discovery_screen.dart +++ b/lib/screens/discovery_screen.dart @@ -12,6 +12,8 @@ import '../utils/contact_search.dart'; import '../utils/platform_info.dart'; import '../widgets/app_bar.dart'; import '../widgets/list_filter_widget.dart'; +import '../widgets/blocked_badge.dart'; +import '../services/block_service.dart'; import '../helpers/snack_bar_builder.dart'; enum DiscoverySortOption { lastSeen, name, type } @@ -50,6 +52,7 @@ class _DiscoveryScreenState extends State { Widget build(BuildContext context) { final l10n = context.l10n; final connector = context.watch(); + final blockService = context.watch(); final discoveredContacts = connector.discoveredContacts; final filteredAndSorted = _filterAndSortContacts( @@ -97,6 +100,9 @@ class _DiscoveryScreenState extends State { itemCount: filteredAndSorted.length, itemBuilder: (context, index) { final contact = filteredAndSorted[index]; + final isBlocked = blockService.isBlocked( + contact.publicKeyHex, + ); final tile = ListTile( leading: CircleAvatar( backgroundColor: _getTypeColor(contact.type), @@ -106,11 +112,29 @@ class _DiscoveryScreenState extends State { size: 20, ), ), - title: Text( - contact.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + title: isBlocked + ? Row( + children: [ + const BlockedBadge(), + const SizedBox(width: 6), + Expanded( + child: Text( + contact.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Theme.of(context).disabledColor, + decoration: TextDecoration.lineThrough, + ), + ), + ), + ], + ) + : Text( + contact.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), subtitle: Text( contact.shortPubKeyHex, maxLines: 1, diff --git a/lib/widgets/blocked_badge.dart b/lib/widgets/blocked_badge.dart new file mode 100644 index 0000000..6dca699 --- /dev/null +++ b/lib/widgets/blocked_badge.dart @@ -0,0 +1,14 @@ +import 'package:flutter/material.dart'; + +/// Small red "blocked" indicator (circle-with-slash) shown next to a blocked +/// entry's name in the contacts/discovery lists. Blocked entries stay visible +/// (not hidden) — see `docs/architecture/block-contract-as-built.md`. +class BlockedBadge extends StatelessWidget { + final double size; + const BlockedBadge({super.key, this.size = 16}); + + @override + Widget build(BuildContext context) { + return Icon(Icons.block, size: size, color: Colors.red.shade700); + } +}