diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 22880718..1efcfbdd 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -102,6 +102,9 @@ "addToStarterPack": "zum Starter-Paket hinzufügen", "blockReport": "Blockieren/Melden", "blockedUsers": "Blockierte Benutzer", + "noBlockedUsers": "Keine blockierten Benutzer", + "addBlockedWord": "Blockiertes Wort hinzufügen", + "noBlockedWords": "Keine blockierten Wörter", "notImplemented": "nicht implementiert", "impersonation": "Identitätsdiebstahl", "spam": "Spam", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 481cdc1a..6a9ace13 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -495,6 +495,18 @@ "@blockedUsers": { "description": "Blocked users page title" }, + "noBlockedUsers": "No blocked users", + "@noBlockedUsers": { + "description": "Empty state text for blocked users list" + }, + "addBlockedWord": "Add blocked word", + "@addBlockedWord": { + "description": "Hint text for adding a blocked word" + }, + "noBlockedWords": "No blocked words", + "@noBlockedWords": { + "description": "Empty state text for blocked words list" + }, "notImplemented": "not implemented", "@notImplemented": { "description": "Message for unimplemented features" diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index d958c9e2..6a070814 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -102,6 +102,9 @@ "addToStarterPack": "agregar al paquete inicial", "blockReport": "Bloquear/Reportar", "blockedUsers": "Usuarios Bloqueados", + "noBlockedUsers": "No hay usuarios bloqueados", + "addBlockedWord": "Agregar palabra bloqueada", + "noBlockedWords": "No hay palabras bloqueadas", "notImplemented": "no implementado", "impersonation": "suplantación de identidad", "spam": "spam", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index b3c67d4e..02fa661b 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -102,6 +102,9 @@ "addToStarterPack": "ajouter au pack de démarrage", "blockReport": "Bloquer/Signaler", "blockedUsers": "Utilisateurs bloqués", + "noBlockedUsers": "Aucun utilisateur bloqué", + "addBlockedWord": "Ajouter un mot bloqué", + "noBlockedWords": "Aucun mot bloqué", "notImplemented": "pas implémenté", "impersonation": "usurpation d'identité", "spam": "spam", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 19d6825b..92abe5a4 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -102,6 +102,9 @@ "addToStarterPack": "スターターパックに追加", "blockReport": "ブロック/報告", "blockedUsers": "ブロック済みユーザー", + "noBlockedUsers": "ブロック済みユーザーはいません", + "addBlockedWord": "ブロックする単語を追加", + "noBlockedWords": "ブロック済み単語はありません", "notImplemented": "実装されていません", "impersonation": "なりすまし", "spam": "スパム", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index d31c9f2d..3515f0fd 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -102,6 +102,9 @@ "addToStarterPack": "adicionar ao pacote inicial", "blockReport": "Bloquear/Denunciar", "blockedUsers": "Usuários Bloqueados", + "noBlockedUsers": "Nenhum usuário bloqueado", + "addBlockedWord": "Adicionar palavra bloqueada", + "noBlockedWords": "Nenhuma palavra bloqueada", "notImplemented": "não implementado", "impersonation": "personificação", "spam": "spam", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 9ef93123..af9bee6a 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -102,6 +102,9 @@ "addToStarterPack": "добавить в стартовый набор", "blockReport": "Блокировать/Пожаловаться", "blockedUsers": "Заблокированные пользователи", + "noBlockedUsers": "Нет заблокированных пользователей", + "addBlockedWord": "Добавить заблокированное слово", + "noBlockedWords": "Нет заблокированных слов", "notImplemented": "не реализовано", "impersonation": "выдача себя за другого", "spam": "спам", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 21408f7f..1e70f7af 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -102,6 +102,9 @@ "addToStarterPack": "เพิ่มไปยังแพ็คเกจเริ่มต้น", "blockReport": "บล็อก/รายงาน", "blockedUsers": "ผู้ใช้ที่ถูกบล็อก", + "noBlockedUsers": "ไม่มีผู้ใช้ที่ถูกบล็อก", + "addBlockedWord": "เพิ่มคำที่บล็อก", + "noBlockedWords": "ไม่มีคำที่บล็อก", "notImplemented": "ยังไม่ได้นำไปใช้", "impersonation": "แอบอ้าง", "spam": "สแปม", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index b6bdc198..62380b9a 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -102,6 +102,9 @@ "addToStarterPack": "添加到新手包", "blockReport": "屏蔽/举报", "blockedUsers": "已屏蔽的用户", + "noBlockedUsers": "没有已屏蔽的用户", + "addBlockedWord": "添加屏蔽词", + "noBlockedWords": "没有屏蔽词", "notImplemented": "未实现", "impersonation": "冒充", "spam": "垃圾信息", diff --git a/lib/presentation_layer/providers/moderation/blocklist_provider.dart b/lib/presentation_layer/providers/moderation/blocklist_provider.dart new file mode 100644 index 00000000..0e80c5ff --- /dev/null +++ b/lib/presentation_layer/providers/moderation/blocklist_provider.dart @@ -0,0 +1,352 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:ndk/ndk.dart' as ndk; +import 'package:riverpod/misc.dart'; + +import '../../../data_layer/data_sources/dart_ndk_source.dart'; +import '../../../data_layer/repositories/nostr_list_repository_impl.dart'; +import '../../../domain_layer/entities/nostr_list.dart'; +import '../../../domain_layer/repositories/nostr_list_repository.dart'; +import '../../../domain_layer/usecases/get_nostr_lists.dart'; +import '../embed_note_cache_provider.dart'; +import '../generic_feed_provider.dart'; +import '../parsed_note_cache_provider.dart'; + +class BlocklistState { + final bool isLoading; + final Set blockedPubkeys; + final Set blockedWords; + final DateTime? syncedListCreatedAt; + + BlocklistState({ + required this.isLoading, + required this.blockedPubkeys, + required this.blockedWords, + required this.syncedListCreatedAt, + }); + + BlocklistState copyWith({ + bool? isLoading, + Set? blockedPubkeys, + Set? blockedWords, + DateTime? syncedListCreatedAt, + bool clearSyncedListCreatedAt = false, + }) { + return BlocklistState( + isLoading: isLoading ?? this.isLoading, + blockedPubkeys: blockedPubkeys ?? this.blockedPubkeys, + blockedWords: blockedWords ?? this.blockedWords, + syncedListCreatedAt: clearSyncedListCreatedAt + ? null + : (syncedListCreatedAt ?? this.syncedListCreatedAt), + ); + } +} + +final blocklistNotifierProvider = + NotifierProvider(BlocklistNotifier.new); + +class BlocklistNotifier extends Notifier { + GetNostrLists? _nostrLists; + bool _pendingSync = false; + + static final List _providersToInvalidateOnBlocklistChange = + [ + embedCacheProvider, + embeddedNoteProvider, + embeddedParsedPostProvider, + parsedNoteCacheStoreProvider, + parsedNoteCacheProvider, + genericFeedStateProvider, + ]; + + @override + BlocklistState build() { + return BlocklistState( + isLoading: false, + blockedPubkeys: {}, + blockedWords: {}, + syncedListCreatedAt: null, + ); + } + + void initializeWithNdk(ndk.Ndk ndkInstance, {bool syncNow = true}) { + if (_nostrLists == null) { + final dartNdkSource = DartNdkSource(ndkInstance); + final NostrListRepository listsRepo = NostrListRepositoryImpl( + dartNdkSource: dartNdkSource, + ); + _nostrLists = GetNostrLists(nostrListRepository: listsRepo); + } + + if (syncNow || _pendingSync) { + _pendingSync = false; + Future.microtask(_syncFromNostr); + } + } + + Future sync() { + if (_nostrLists == null) { + _pendingSync = true; + return Future.value(); + } + return _syncFromNostr(); + } + + Future _syncFromNostr() async { + final nostrLists = _nostrLists; + if (nostrLists == null) { + return; + } + + state = state.copyWith(isLoading: true); + + try { + final list = await nostrLists.getSingleList(kind: NostrList.mute); + if (list == null) { + _setState( + isLoading: false, + blockedPubkeys: {}, + blockedWords: {}, + syncedListCreatedAt: null, + ); + return; + } + _applyList(list: list, isLoading: false); + } catch (_) { + state = state.copyWith(isLoading: false); + } + } + + Future blockPubkey(String pubkey) async { + final nostrLists = _nostrLists; + if (nostrLists == null) { + return; + } + + final value = pubkey.trim(); + if (value.isEmpty || state.blockedPubkeys.contains(value)) { + return; + } + + state = state.copyWith(isLoading: true); + try { + final list = await nostrLists.addElementToList( + tag: NostrList.pubkeyTagKey, + value: value, + kind: NostrList.mute, + ); + _applyList(list: list, isLoading: false); + } catch (_) { + state = state.copyWith(isLoading: false); + } + } + + Future unblockPubkey(String pubkey) async { + final nostrLists = _nostrLists; + if (nostrLists == null) { + return; + } + + final value = pubkey.trim(); + if (value.isEmpty) { + return; + } + + state = state.copyWith(isLoading: true); + try { + final list = await nostrLists.removeElementFromList( + tag: NostrList.pubkeyTagKey, + value: value, + kind: NostrList.mute, + ); + + if (list == null) { + await _syncFromNostr(); + return; + } + + _applyList(list: list, isLoading: false); + } catch (_) { + state = state.copyWith(isLoading: false); + } + } + + Future blockWord(String word) async { + final nostrLists = _nostrLists; + if (nostrLists == null) { + return; + } + + final value = _normalizeWord(word); + if (value.isEmpty || state.blockedWords.contains(value)) { + return; + } + + state = state.copyWith(isLoading: true); + try { + final list = await nostrLists.addElementToList( + tag: NostrList.word, + value: value, + kind: NostrList.mute, + ); + _applyList(list: list, isLoading: false); + } catch (_) { + state = state.copyWith(isLoading: false); + } + } + + Future unblockWord(String word) async { + final nostrLists = _nostrLists; + if (nostrLists == null) { + return; + } + + final value = _normalizeWord(word); + if (value.isEmpty) { + return; + } + + state = state.copyWith(isLoading: true); + try { + final list = await nostrLists.removeElementFromList( + tag: NostrList.word, + value: value, + kind: NostrList.mute, + ); + + if (list == null) { + await _syncFromNostr(); + return; + } + + _applyList(list: list, isLoading: false); + } catch (_) { + state = state.copyWith(isLoading: false); + } + } + + bool isPubkeyBlocked(String pubkey) { + return state.blockedPubkeys.contains(pubkey.trim()); + } + + bool containsBlockedWord(String text) { + final value = text.toLowerCase(); + for (final blockedWord in state.blockedWords) { + if (blockedWord.isNotEmpty && value.contains(blockedWord)) { + return true; + } + } + return false; + } + + void invalidateProviders(List providers) { + for (final provider in providers) { + ref.invalidate(provider); + } + } + + void _applyList({required NostrList list, required bool isLoading}) { + final blockedPubkeys = list.pubKeys + .map((element) => element.value.trim()) + .where((value) => value.isNotEmpty) + .toSet(); + + final blockedWords = list.words + .map((element) => _normalizeWord(element.value)) + .where((value) => value.isNotEmpty) + .toSet(); + + _setState( + isLoading: isLoading, + blockedPubkeys: blockedPubkeys, + blockedWords: blockedWords, + syncedListCreatedAt: DateTime.fromMillisecondsSinceEpoch( + list.createdAt * 1000, + ), + ); + } + + void _setState({ + required bool isLoading, + required Set blockedPubkeys, + required Set blockedWords, + required DateTime? syncedListCreatedAt, + }) { + final hasBlocklistChanged = + state.blockedPubkeys.length != blockedPubkeys.length || + state.blockedWords.length != blockedWords.length || + !state.blockedPubkeys.containsAll(blockedPubkeys) || + !state.blockedWords.containsAll(blockedWords); + + state = state.copyWith( + isLoading: isLoading, + blockedPubkeys: blockedPubkeys, + blockedWords: blockedWords, + syncedListCreatedAt: syncedListCreatedAt, + clearSyncedListCreatedAt: syncedListCreatedAt == null, + ); + + if (hasBlocklistChanged) { + invalidateProviders(_providersToInvalidateOnBlocklistChange); + } + } + + String _normalizeWord(String word) { + return word.trim().toLowerCase(); + } +} + +final blocklistFilterReferenceProvider = Provider((ref) { + final filter = BlocklistEventFilter( + blockedPubkeys: {}, + blockedWords: {}, + ); + + ref.listen(blocklistNotifierProvider, (previous, next) { + filter.update( + blockedPubkeys: next.blockedPubkeys, + blockedWords: next.blockedWords, + ); + }); + + return filter; +}); + +class BlocklistEventFilter implements ndk.EventFilter { + Set _blockedPubkeys; + Set _blockedWords; + + BlocklistEventFilter({ + required Set blockedPubkeys, + required Set blockedWords, + }) : _blockedPubkeys = blockedPubkeys, + _blockedWords = blockedWords; + + void update({ + required Set blockedPubkeys, + required Set blockedWords, + }) { + _blockedPubkeys = blockedPubkeys; + _blockedWords = blockedWords; + } + + @override + bool filter(ndk.Nip01Event event) { + if (_blockedPubkeys.contains(event.pubKey)) { + return false; + } + + if (_blockedWords.isEmpty) { + return true; + } + + final content = event.content.toLowerCase(); + for (final blockedWord in _blockedWords) { + if (blockedWord.isNotEmpty && content.contains(blockedWord)) { + return false; + } + } + + return true; + } +} diff --git a/lib/presentation_layer/providers/ndk_provider.dart b/lib/presentation_layer/providers/ndk_provider.dart index 51c29537..bc9b2592 100644 --- a/lib/presentation_layer/providers/ndk_provider.dart +++ b/lib/presentation_layer/providers/ndk_provider.dart @@ -5,12 +5,14 @@ import 'package:riverpod/riverpod.dart'; import '../../config/default_relays.dart'; import 'db_ndk_provider.dart'; import 'event_verifier.dart'; +import 'moderation/blocklist_provider.dart'; import 'moderation/camelus_bloom_filter_provider.dart'; final ndkProvider = Provider((ref) { final eventVerifier = ref.read(eventVerifierProvider); final db = ref.read(dbNdkProvider); final bloomFilterRef = ref.read(bloomFilterReferenceProvider); + final blocklistFilterRef = ref.read(blocklistFilterReferenceProvider); final NdkConfig ndkConfig = NdkConfig( engine: NdkEngine.JIT, @@ -19,10 +21,11 @@ final ndkProvider = Provider((ref) { bootstrapRelays: camelusBootstrapRelays, logLevel: Logger.logLevels.info, defaultBroadcastConsiderDonePercent: 0.2, - eventOutFilters: [bloomFilterRef], + eventOutFilters: [bloomFilterRef, blocklistFilterRef], ); final ndk = Ndk(ndkConfig); + ref.read(blocklistNotifierProvider.notifier).initializeWithNdk(ndk); return ndk; }); diff --git a/lib/presentation_layer/routes/nostr/blockedUsers/block_page.dart b/lib/presentation_layer/routes/nostr/blockedUsers/block_page.dart index 707e8cb1..957d3086 100644 --- a/lib/presentation_layer/routes/nostr/blockedUsers/block_page.dart +++ b/lib/presentation_layer/routes/nostr/blockedUsers/block_page.dart @@ -1,12 +1,11 @@ -import 'dart:async'; import 'package:camelus/l10n/app_localizations.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import '../../../../domain_layer/entities/nostr_tag.dart'; import '../../../atoms/long_button.dart'; import '../../../providers/metadata_state_provider.dart'; +import '../../../providers/moderation/blocklist_provider.dart'; import '../../../providers/moderation/moderation_provider.dart'; import '../../../providers/ndk_provider.dart'; @@ -21,12 +20,9 @@ class BlockPage extends ConsumerStatefulWidget { } class _BlockPageState extends ConsumerState { - bool isUserBlocked = false; bool requestLoading = false; bool reportToCamelus = true; - List contentTags = []; - final TextEditingController _textController = TextEditingController(); String _reportReason = ""; bool _reportSuccessful = false; @@ -50,11 +46,35 @@ class _BlockPageState extends ConsumerState { } void _blockUser(String pubkey) async { - throw UnimplementedError(); + setState(() { + requestLoading = true; + }); + + try { + await ref.read(blocklistNotifierProvider.notifier).blockPubkey(pubkey); + } finally { + if (mounted) { + setState(() { + requestLoading = false; + }); + } + } } void _unblockUser(String pubkey) async { - throw UnimplementedError(); + setState(() { + requestLoading = true; + }); + + try { + await ref.read(blocklistNotifierProvider.notifier).unblockPubkey(pubkey); + } finally { + if (mounted) { + setState(() { + requestLoading = false; + }); + } + } } void _setReportReason(String reason) { @@ -97,6 +117,10 @@ class _BlockPageState extends ConsumerState { final user = ref .watch(metadataStateProvider(widget.userPubkey)) .userMetadata; + final blocklistState = ref.watch(blocklistNotifierProvider); + final isUserBlocked = blocklistState.blockedPubkeys.contains( + widget.userPubkey, + ); if (_reportSuccessful) { return Scaffold( @@ -177,44 +201,35 @@ class _BlockPageState extends ConsumerState { ], ), const SizedBox(height: 20), - FutureBuilder( - future: Future.delayed(Duration(seconds: 1)), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return SizedBox( - height: 40, - width: MediaQuery.of(context).size.width * 0.75, - child: longButton( - name: AppLocalizations.of(context)!.loading, - loading: true, - onPressed: () {}, - ), - ); - } - - return SizedBox( - height: 40, - width: MediaQuery.of(context).size.width * 0.75, - child: longButton( - name: isUserBlocked - ? AppLocalizations.of(context)!.unblock - : AppLocalizations.of(context)!.block, - inverted: !isUserBlocked, - loading: requestLoading, - onPressed: () { - if (isUserBlocked) { - _unblockUser(widget.userPubkey); - } else { - _blockUser(widget.userPubkey); - } - setState(() { - isUserBlocked = !isUserBlocked; - }); - }, - ), - ); - }, - ), + if (blocklistState.isLoading) + SizedBox( + height: 40, + width: MediaQuery.of(context).size.width * 0.75, + child: longButton( + name: AppLocalizations.of(context)!.loading, + loading: true, + onPressed: () {}, + ), + ) + else + SizedBox( + height: 40, + width: MediaQuery.of(context).size.width * 0.75, + child: longButton( + name: isUserBlocked + ? AppLocalizations.of(context)!.unblock + : AppLocalizations.of(context)!.block, + inverted: !isUserBlocked, + loading: requestLoading, + onPressed: () { + if (isUserBlocked) { + _unblockUser(widget.userPubkey); + } else { + _blockUser(widget.userPubkey); + } + }, + ), + ), const SizedBox(height: 10), Column( children: [ diff --git a/lib/presentation_layer/routes/nostr/blockedUsers/blocked_users.dart b/lib/presentation_layer/routes/nostr/blockedUsers/blocked_users.dart deleted file mode 100644 index d4d79cef..00000000 --- a/lib/presentation_layer/routes/nostr/blockedUsers/blocked_users.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'dart:async'; -import 'package:camelus/domain_layer/entities/nostr_tag.dart'; -import 'package:camelus/l10n/app_localizations.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -class BlockedUsers extends ConsumerStatefulWidget { - const BlockedUsers({super.key}); - - @override - ConsumerState createState() => _BlockedUsersState(); -} - -class _BlockedUsersState extends ConsumerState { - Completer initDone = Completer(); - - List contentTags = []; - - @override - void initState() { - super.initState(); - _initState(); - } - - @override - void dispose() { - super.dispose(); - } - - void _initState() async {} - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: Text(AppLocalizations.of(context)!.blockedUsers)), - body: Text(AppLocalizations.of(context)!.notImplemented), - ); - } -} diff --git a/lib/presentation_layer/routes/nostr/blockedUsers/blocklist_page.dart b/lib/presentation_layer/routes/nostr/blockedUsers/blocklist_page.dart new file mode 100644 index 00000000..fa088998 --- /dev/null +++ b/lib/presentation_layer/routes/nostr/blockedUsers/blocklist_page.dart @@ -0,0 +1,192 @@ +import 'package:camelus/l10n/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:ndk/ndk.dart'; +import 'package:timeago/timeago.dart' as timeago; + +import '../../../providers/metadata_state_provider.dart'; +import '../../../providers/moderation/blocklist_provider.dart'; + +class BlocklistPage extends ConsumerStatefulWidget { + const BlocklistPage({super.key}); + + @override + ConsumerState createState() => _BlockedUsersState(); +} + +class _BlockedUsersState extends ConsumerState { + final TextEditingController _wordController = TextEditingController(); + + String _formatSyncedListCreatedAt(BuildContext context, DateTime createdAt) { + final localCreatedAt = createdAt.toLocal(); + final diff = DateTime.now().difference(localCreatedAt); + if (diff <= const Duration(hours: 24)) { + return timeago.format(localCreatedAt, locale: 'en_short'); + } + + final locale = Localizations.localeOf(context).toLanguageTag(); + return DateFormat.yMMMd(locale).add_Hm().format(localCreatedAt); + } + + @override + void initState() { + super.initState(); + Future.microtask(() { + ref.read(blocklistNotifierProvider.notifier).sync(); + }); + } + + @override + void dispose() { + _wordController.dispose(); + super.dispose(); + } + + Future _addWord() async { + final word = _wordController.text.trim(); + if (word.isEmpty) { + return; + } + + await ref.read(blocklistNotifierProvider.notifier).blockWord(word); + if (mounted) { + _wordController.clear(); + } + } + + @override + Widget build(BuildContext context) { + final blocklistState = ref.watch(blocklistNotifierProvider); + final blocklistNotifier = ref.read(blocklistNotifierProvider.notifier); + final syncedCreatedAt = blocklistState.syncedListCreatedAt; + + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.blockedUsers), + actions: [ + if (syncedCreatedAt != null) + Padding( + padding: const EdgeInsets.only(right: 8), + child: Center( + child: Text( + _formatSyncedListCreatedAt(context, syncedCreatedAt), + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ), + IconButton( + onPressed: blocklistState.isLoading + ? null + : () => blocklistNotifier.sync(), + icon: const Icon(Icons.refresh), + ), + ], + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Row( + children: [ + Expanded( + child: Text( + AppLocalizations.of(context)!.blockedUsers, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), + if (blocklistState.isLoading) + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ], + ), + const SizedBox(height: 12), + if (blocklistState.blockedPubkeys.isEmpty) + Text( + AppLocalizations.of(context)!.noBlockedUsers, + style: TextStyle(color: Theme.of(context).colorScheme.outline), + ) + else + ...blocklistState.blockedPubkeys.map((pubkey) { + final metadata = ref.watch(metadataStateProvider(pubkey)); + final displayName = + metadata.userMetadata?.name ?? metadata.userMetadata?.nip05; + + return ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.person_off), + title: Text(displayName ?? Nip19.encodePubKey(pubkey)), + subtitle: displayName == null + ? null + : Text( + Nip19.encodePubKey(pubkey), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: IconButton( + icon: const Icon(Icons.close), + onPressed: blocklistState.isLoading + ? null + : () => blocklistNotifier.unblockPubkey(pubkey), + ), + ); + }), + const Divider(height: 32), + Text( + AppLocalizations.of(context)!.hideWords, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextField( + controller: _wordController, + enabled: !blocklistState.isLoading, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _addWord(), + decoration: InputDecoration( + hintText: AppLocalizations.of(context)!.addBlockedWord, + border: const OutlineInputBorder(), + isDense: true, + ), + ), + ), + const SizedBox(width: 8), + IconButton( + onPressed: blocklistState.isLoading ? null : _addWord, + icon: const Icon(Icons.add), + ), + ], + ), + const SizedBox(height: 12), + if (blocklistState.blockedWords.isEmpty) + Text( + AppLocalizations.of(context)!.noBlockedWords, + style: TextStyle(color: Theme.of(context).colorScheme.outline), + ) + else + Wrap( + spacing: 8, + runSpacing: 8, + children: blocklistState.blockedWords + .map( + (word) => Chip( + label: Text(word), + onDeleted: blocklistState.isLoading + ? null + : () => blocklistNotifier.unblockWord(word), + ), + ) + .toList(), + ), + ], + ), + ); + } +} diff --git a/lib/presentation_layer/routing/routes.dart b/lib/presentation_layer/routing/routes.dart index 8f414199..a642db62 100644 --- a/lib/presentation_layer/routing/routes.dart +++ b/lib/presentation_layer/routing/routes.dart @@ -18,7 +18,7 @@ import '../layouts/three_colum_layout.dart'; import '../routes/deeplink_reciever_page.dart'; import '../routes/home_page_desktop.dart'; import '../routes/home_page_mobile.dart'; -import '../routes/nostr/blockedUsers/blocked_users.dart'; +import '../routes/nostr/blockedUsers/blocklist_page.dart'; import '../routes/nostr/bookmarks/bookmarks_page.dart'; import '../routes/nostr/event_view/event_view_page.dart'; import '../routes/nostr/onboarding/onboarding.dart'; @@ -232,7 +232,7 @@ final routes = [ ), GoRoute( path: '/blocked-users', - builder: (context, state) => const BlockedUsers(), + builder: (context, state) => const BlocklistPage(), ), GoRoute( path: '/edit-starter-pack',