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 <noreply@anthropic.com>
pull/209/head
Strycher 3 weeks ago
parent d63f57a698
commit 00ae5558c7

@ -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<Contact?>().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,

@ -91,6 +91,7 @@ void main() async {
appDebugLogService: appDebugLogService,
backgroundService: backgroundService,
timeoutPredictionService: timeoutPredictionService,
blockService: blockService,
);
await connector.loadContactCache();

@ -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<BlockService>().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: [

@ -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<DiscoveryScreen> {
Widget build(BuildContext context) {
final l10n = context.l10n;
final connector = context.watch<MeshCoreConnector>();
final blockService = context.watch<BlockService>();
final discoveredContacts = connector.discoveredContacts;
final filteredAndSorted = _filterAndSortContacts(
@ -97,6 +100,9 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
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,7 +112,25 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
size: 20,
),
),
title: Text(
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,

@ -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);
}
}
Loading…
Cancel
Save

Powered by TurnKey Linux.