feat(social): local blocklist — hide blocked authors' offers, chats and incoming messages, with unblock management
This commit is contained in:
parent
5d25f65f76
commit
11b242edbf
13 changed files with 399 additions and 4 deletions
109
apps/app_seeds/lib/ui/blocked_people.dart
Normal file
109
apps/app_seeds/lib/ui/blocked_people.dart
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../di/injector.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../services/profile_cache.dart';
|
||||
import '../services/social_settings.dart';
|
||||
import 'peer_avatar.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
/// Management list for the local blocklist: who is blocked, with one-tap
|
||||
/// unblock. Opened from the market's sharing setup.
|
||||
class BlockedPeopleSheet extends StatefulWidget {
|
||||
const BlockedPeopleSheet({required this.settings, this.profileCache, super.key});
|
||||
|
||||
final SocialSettings settings;
|
||||
|
||||
/// Resolves peers' display names; falls back to the app-wide cache when not
|
||||
/// injected (tests pass one explicitly or leave names as short keys).
|
||||
final ProfileCache? profileCache;
|
||||
|
||||
@override
|
||||
State<BlockedPeopleSheet> createState() => _BlockedPeopleSheetState();
|
||||
}
|
||||
|
||||
class _BlockedPeopleSheetState extends State<BlockedPeopleSheet> {
|
||||
List<String>? _blocked;
|
||||
final Map<String, String> _names = {};
|
||||
|
||||
ProfileCache? get _cache =>
|
||||
widget.profileCache ??
|
||||
(getIt.isRegistered<ProfileCache>() ? getIt<ProfileCache>() : null);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final blocked = (await widget.settings.blockedPubkeys()).toList()..sort();
|
||||
final cache = _cache;
|
||||
if (cache != null) {
|
||||
for (final p in blocked) {
|
||||
final name = await cache.name(p);
|
||||
if (name != null) _names[p] = name;
|
||||
}
|
||||
}
|
||||
if (mounted) setState(() => _blocked = blocked);
|
||||
}
|
||||
|
||||
Future<void> _unblock(String pubkey) async {
|
||||
await widget.settings.unblock(pubkey);
|
||||
await _load();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.t;
|
||||
final blocked = _blocked;
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
t.block.manageTitle,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
color: seedOnSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (blocked == null)
|
||||
const Center(child: CircularProgressIndicator())
|
||||
else if (blocked.isEmpty)
|
||||
Text(
|
||||
t.block.manageEmpty,
|
||||
style: const TextStyle(color: seedMuted),
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: blocked.length,
|
||||
itemBuilder: (context, i) {
|
||||
final pubkey = blocked[i];
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: PeerAvatar(
|
||||
pubkey: pubkey,
|
||||
name: _names[pubkey],
|
||||
),
|
||||
title: Text(_names[pubkey] ?? shortPubkey(pubkey)),
|
||||
trailing: TextButton(
|
||||
onPressed: () => _unblock(pubkey),
|
||||
child: Text(t.block.unblock),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import '../services/message_store.dart';
|
|||
import '../services/profile_cache.dart';
|
||||
import 'peer_avatar.dart';
|
||||
import '../services/social_connection.dart';
|
||||
import '../services/social_settings.dart';
|
||||
import 'theme.dart';
|
||||
import 'unread_badge.dart';
|
||||
|
||||
|
|
@ -20,6 +21,7 @@ class ChatListScreen extends StatefulWidget {
|
|||
this.connection,
|
||||
this.profileCache,
|
||||
this.inbox,
|
||||
this.settings,
|
||||
super.key,
|
||||
});
|
||||
|
||||
|
|
@ -32,6 +34,10 @@ class ChatListScreen extends StatefulWidget {
|
|||
/// App-wide inbox listener; the list reloads whenever it reports a change.
|
||||
final InboxService? inbox;
|
||||
|
||||
/// Social settings holding the local blocklist; blocked peers' conversations
|
||||
/// are hidden. Null in tests → nothing filtered.
|
||||
final SocialSettings? settings;
|
||||
|
||||
@override
|
||||
State<ChatListScreen> createState() => _ChatListScreenState();
|
||||
}
|
||||
|
|
@ -56,7 +62,11 @@ class _ChatListScreenState extends State<ChatListScreen> {
|
|||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final items = await widget.store.conversations();
|
||||
var items = await widget.store.conversations();
|
||||
final blocked = await widget.settings?.blockedPubkeys();
|
||||
if (blocked != null && blocked.isNotEmpty) {
|
||||
items = items.where((c) => !blocked.contains(c.peerPubkey)).toList();
|
||||
}
|
||||
final cache = widget.profileCache;
|
||||
if (cache != null) {
|
||||
for (final c in items) {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import '../services/profile_cache.dart';
|
|||
import '../services/profile_store.dart';
|
||||
import '../services/social_connection.dart';
|
||||
import '../services/social_service.dart';
|
||||
import '../services/social_settings.dart';
|
||||
import '../services/unread_service.dart';
|
||||
import '../state/messages_cubit.dart';
|
||||
import '../state/peer_rating_cubit.dart';
|
||||
|
|
@ -36,6 +37,7 @@ class ChatScreen extends StatefulWidget {
|
|||
this.messageStore,
|
||||
this.profileCache,
|
||||
this.onboarding,
|
||||
this.settings,
|
||||
super.key,
|
||||
});
|
||||
|
||||
|
|
@ -57,6 +59,11 @@ class ChatScreen extends StatefulWidget {
|
|||
/// entering the market). Null in tests → no gate.
|
||||
final OnboardingStore? onboarding;
|
||||
|
||||
/// Social settings holding the local blocklist; falls back to the app-wide
|
||||
/// instance so the block action also works when not injected. Null in
|
||||
/// tests → the block menu is hidden.
|
||||
final SocialSettings? settings;
|
||||
|
||||
@override
|
||||
State<ChatScreen> createState() => _ChatScreenState();
|
||||
}
|
||||
|
|
@ -191,6 +198,42 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
SocialSettings? get _settings =>
|
||||
widget.settings ??
|
||||
(getIt.isRegistered<SocialSettings>() ? getIt<SocialSettings>() : null);
|
||||
|
||||
/// Blocks this peer after confirmation and leaves the chat; their offers,
|
||||
/// conversation and future messages disappear (all locally).
|
||||
Future<void> _blockPeer() async {
|
||||
final settings = _settings;
|
||||
if (settings == null) return;
|
||||
final t = context.t;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(t.block.confirmTitle),
|
||||
content: Text(t.block.confirmBody),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(t.block.confirm),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
await settings.block(widget.peerPubkey);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(content: Text(t.block.blockedToast)));
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
Future<void> _send() async {
|
||||
final cubit = _messages;
|
||||
if (cubit == null || _input.text.trim().isEmpty) return;
|
||||
|
|
@ -244,6 +287,19 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
tooltip: t.chat.payG1,
|
||||
onPressed: _payG1,
|
||||
),
|
||||
if (_settings != null)
|
||||
PopupMenuButton<String>(
|
||||
key: const Key('chat.menu'),
|
||||
onSelected: (value) {
|
||||
if (value == 'block') _blockPeer();
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'block',
|
||||
child: Text(t.block.action),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _loading || messages == null || trust == null || rating == null
|
||||
|
|
|
|||
|
|
@ -5,11 +5,13 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter/services.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../di/injector.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../services/profile_cache.dart';
|
||||
import '../services/saved_offers_store.dart';
|
||||
import '../services/social_connection.dart';
|
||||
import '../services/social_service.dart';
|
||||
import '../services/social_settings.dart';
|
||||
import '../state/peer_rating_cubit.dart';
|
||||
import 'market_widgets.dart';
|
||||
import 'peer_avatar.dart';
|
||||
|
|
@ -29,6 +31,7 @@ class MarketOfferDetailScreen extends StatefulWidget {
|
|||
this.profileCache,
|
||||
this.selfPubkey,
|
||||
this.savedOffers,
|
||||
this.settings,
|
||||
super.key,
|
||||
});
|
||||
|
||||
|
|
@ -51,6 +54,10 @@ class MarketOfferDetailScreen extends StatefulWidget {
|
|||
/// null in tests (circle weighting then stays off).
|
||||
final String? selfPubkey;
|
||||
|
||||
/// Social settings holding the local blocklist; falls back to the app-wide
|
||||
/// instance. Null in tests → the block action is hidden.
|
||||
final SocialSettings? settings;
|
||||
|
||||
@override
|
||||
State<MarketOfferDetailScreen> createState() =>
|
||||
_MarketOfferDetailScreenState();
|
||||
|
|
@ -161,6 +168,42 @@ class _MarketOfferDetailScreenState extends State<MarketOfferDetailScreen> {
|
|||
|
||||
// No dispose of the session — it belongs to the shared connection.
|
||||
|
||||
SocialSettings? get _settings =>
|
||||
widget.settings ??
|
||||
(getIt.isRegistered<SocialSettings>() ? getIt<SocialSettings>() : null);
|
||||
|
||||
/// Blocks the offer's author after confirmation and goes back to the market
|
||||
/// (which reloads the blocklist and drops their offers).
|
||||
Future<void> _blockAuthor() async {
|
||||
final settings = _settings;
|
||||
if (settings == null) return;
|
||||
final t = context.t;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(t.block.confirmTitle),
|
||||
content: Text(t.block.confirmBody),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(t.block.confirm),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
await settings.block(widget.offer.authorPubkeyHex);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(content: Text(t.block.blockedToast)));
|
||||
if (context.canPop()) context.pop();
|
||||
}
|
||||
|
||||
Future<void> _copyId() async {
|
||||
final t = context.t;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
|
|
@ -188,6 +231,19 @@ class _MarketOfferDetailScreenState extends State<MarketOfferDetailScreen> {
|
|||
tooltip: saved ? t.favorites.remove : t.favorites.save,
|
||||
onPressed: _toggleSaved,
|
||||
),
|
||||
if (!widget.mine && _settings != null)
|
||||
PopupMenuButton<String>(
|
||||
key: const Key('offer.menu'),
|
||||
onSelected: (value) {
|
||||
if (value == 'block') _blockAuthor();
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'block',
|
||||
child: Text(t.block.action),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import '../services/social_service.dart';
|
|||
import '../services/social_settings.dart';
|
||||
import '../services/onboarding_store.dart';
|
||||
import '../state/offers_cubit.dart';
|
||||
import 'blocked_people.dart';
|
||||
import 'edge_fade.dart';
|
||||
import 'filter_chips.dart';
|
||||
import 'market_gate.dart';
|
||||
|
|
@ -92,6 +93,7 @@ class _MarketScreenState extends State<MarketScreen> {
|
|||
setState(() => _loading = true);
|
||||
final cubit =
|
||||
await createOffersCubit(widget.connection, repository: repo);
|
||||
cubit.setBlockedAuthors(await widget.settings.blockedPubkeys());
|
||||
final area = await widget.settings.areaGeohash();
|
||||
if (!mounted) {
|
||||
await cubit.close();
|
||||
|
|
@ -123,6 +125,12 @@ class _MarketScreenState extends State<MarketScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Re-reads the blocklist (the offer detail can block an author) so their
|
||||
/// offers drop out of the visible list at once.
|
||||
Future<void> _reloadBlocked() async {
|
||||
_cubit?.setBlockedAuthors(await widget.settings.blockedPubkeys());
|
||||
}
|
||||
|
||||
Future<void> _openConfig() async {
|
||||
final changed = await showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
|
|
@ -227,6 +235,7 @@ class _MarketScreenState extends State<MarketScreen> {
|
|||
onRetry: _init,
|
||||
hasArea: _hasArea,
|
||||
selfPubkey: widget.social.publicKeyHex,
|
||||
onOfferClosed: _reloadBlocked,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -239,6 +248,7 @@ class MarketBody extends StatelessWidget {
|
|||
this.onRetry,
|
||||
this.hasArea = true,
|
||||
this.selfPubkey,
|
||||
this.onOfferClosed,
|
||||
super.key,
|
||||
});
|
||||
|
||||
|
|
@ -253,6 +263,10 @@ class MarketBody extends StatelessWidget {
|
|||
/// This user's own pubkey, so we don't offer to message our own listings.
|
||||
final String? selfPubkey;
|
||||
|
||||
/// Called after coming back from an offer's detail (which can block its
|
||||
/// author), so the visible list refreshes against the blocklist.
|
||||
final Future<void> Function()? onOfferClosed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.t;
|
||||
|
|
@ -371,8 +385,10 @@ class MarketBody extends StatelessWidget {
|
|||
return _OfferCard(
|
||||
offer: o,
|
||||
mine: mine,
|
||||
onTap: () =>
|
||||
context.push('/market/offer', extra: o),
|
||||
onTap: () async {
|
||||
await context.push('/market/offer', extra: o);
|
||||
await onOfferClosed?.call();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
@ -826,6 +842,19 @@ class _ConfigSheetState extends State<_ConfigSheet> {
|
|||
helperMaxLines: 2,
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
key: const Key('market.blockedPeople'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.block, color: seedMuted),
|
||||
title: Text(t.block.manageTitle),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) =>
|
||||
BlockedPeopleSheet(settings: widget.settings),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue