From 101578bc9d393478a01a77baa29588efc71deae0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Ku=C3=9Fowski?= Date: Wed, 1 Apr 2026 08:24:59 +0200 Subject: [PATCH] feat: Implement room tags --- lib/l10n/intl_en.arb | 6 +- lib/pages/chat_list/chat_list.dart | 136 ++++++++++++++++++++++-- lib/pages/chat_list/chat_list_body.dart | 61 ++++++----- 3 files changed, 165 insertions(+), 38 deletions(-) diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 159750d1..75a720c2 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -2798,5 +2798,9 @@ "joinVoiceCall": "Join voice call", "joinVideoCall": "Join video call", "live": "Live", - "playSoundOnNotification": "Play sound on notification" + "playSoundOnNotification": "Play sound on notification", + "addTag": "Add tag", + "removeTag": "Remove tag", + "tagName": "Tag name", + "createNewTag": "Create new tag" } \ No newline at end of file diff --git a/lib/pages/chat_list/chat_list.dart b/lib/pages/chat_list/chat_list.dart index fe5dd7e9..23ca65fe 100644 --- a/lib/pages/chat_list/chat_list.dart +++ b/lib/pages/chat_list/chat_list.dart @@ -30,7 +30,7 @@ import '../../config/setting_keys.dart'; import '../../utils/url_launcher.dart'; import '../../widgets/matrix.dart'; -enum ActiveFilter { allChats, messages, groups, unread, spaces } +enum ActiveFilter { allChats, messages, groups, unread, spaces, tag } extension LocalizedActiveFilter on ActiveFilter { String toLocalizedString(BuildContext context) { @@ -45,6 +45,8 @@ extension LocalizedActiveFilter on ActiveFilter { return L10n.of(context).groups; case ActiveFilter.spaces: return L10n.of(context).spaces; + case ActiveFilter.tag: + throw 'Tags should not directly be displayed!'; } } } @@ -73,6 +75,7 @@ class ChatListController extends State StreamSubscription? _intentFileStreamSubscription; late ActiveFilter activeFilter; + String? activeTag; String? _activeSpaceId; String? get activeSpaceId => _activeSpaceId; @@ -141,6 +144,8 @@ class ChatListController extends State return (room) => room.isUnreadOrInvited; case ActiveFilter.spaces: return (room) => room.isSpace; + case ActiveFilter.tag: + return (room) => room.tags.keys.contains(activeTag); } } @@ -358,13 +363,10 @@ class ChatListController extends State } } + StreamSubscription? _onRoomTagUpdate; + @override void initState() { - activeFilter = - ActiveFilter.values.singleWhereOrNull( - (filter) => AppSettings.chatFilter.value == filter.name, - ) ?? - ActiveFilter.allChats; _initReceiveSharingIntent(); _activeSpaceId = widget.activeSpace; @@ -387,6 +389,32 @@ class ChatListController extends State ); }); + _updateRoomTags(); + _onRoomTagUpdate = Matrix.of(context).client.onSync.stream + .where( + (syncUpdate) => + syncUpdate.rooms?.join?.values.any( + (roomUpdate) => + roomUpdate.accountData?.any( + (accountData) => accountData.type == 'm.tag', + ) ?? + false, + ) ?? + false, + ) + .listen(_updateRoomTags); + + if (roomTags.containsKey(AppSettings.chatFilter.value)) { + activeFilter = ActiveFilter.tag; + activeTag = AppSettings.chatFilter.value; + } else { + activeFilter = + ActiveFilter.values.singleWhereOrNull( + (filter) => AppSettings.chatFilter.value == filter.name, + ) ?? + ActiveFilter.allChats; + } + super.initState(); } @@ -394,6 +422,7 @@ class ChatListController extends State void dispose() { _intentDataStreamSubscription?.cancel(); _intentFileStreamSubscription?.cancel(); + _onRoomTagUpdate?.cancel(); scrollController.removeListener(_onScroll); super.dispose(); } @@ -622,6 +651,30 @@ class ChatListController extends State ], ), ), + if (activeTag == null) + PopupMenuItem( + value: ChatContextAction.addTag, + child: Row( + mainAxisSize: .min, + children: [ + Icon(Icons.bookmark_add_outlined), + const SizedBox(width: 12), + Text(L10n.of(context).addTag), + ], + ), + ) + else + PopupMenuItem( + value: ChatContextAction.removeTag, + child: Row( + mainAxisSize: .min, + children: [ + Icon(Icons.bookmark_remove_outlined), + const SizedBox(width: 12), + Text(L10n.of(context).removeTag), + ], + ), + ), if (spacesWithPowerLevels.isNotEmpty) PopupMenuItem( value: ChatContextAction.addToSpace, @@ -762,9 +815,68 @@ class ChatListController extends State future: () => room.setLowPriority(!room.isLowPriority), ); return; + case ChatContextAction.addTag: + final existingTags = List.of(roomTags.keys); + existingTags.removeWhere(room.tags.containsKey); + String? tag; + if (existingTags.isNotEmpty) { + tag = await showModalActionPopup( + context: context, + actions: [ + ...existingTags.map((tag) { + final displayTag = tag.replaceFirst('u.', ''); + return AdaptiveModalAction( + label: displayTag, + value: displayTag, + ); + }), + AdaptiveModalAction( + label: L10n.of(context).createNewTag, + value: null, + ), + ], + ); + if (!mounted) return; + } + tag ??= await showTextInputDialog( + context: context, + title: L10n.of(context).addTag, + hintText: L10n.of(context).tagName, + ); + final newTag = tag; + if (!mounted) return; + if (newTag == null) return; + await showFutureLoadingDialog( + context: context, + future: () => room.addTag('u.$newTag'), + ); + return; + case ChatContextAction.removeTag: + await showFutureLoadingDialog( + context: context, + future: () => room.removeTag(activeTag!), + ); + return; } } + Map roomTags = {}; + + void _updateRoomTags([_]) { + roomTags.clear(); + for (final room in Matrix.of(context).client.rooms) { + for (final tag in room.tags.keys) { + if (tag.startsWith('u.')) roomTags[tag] = (roomTags[tag] ?? 0) + 1; + } + } + setState(() { + if (activeTag != null && !roomTags.keys.contains(activeTag)) { + activeTag = null; + activeFilter = ActiveFilter.allChats; + } + }); + } + Future dismissStatusList() async { final result = await showOkCancelAlertDialog( title: L10n.of(context).hidePresences, @@ -858,11 +970,17 @@ class ChatListController extends State } } - void setActiveFilter(ActiveFilter filter) { + void setActiveFilter(ActiveFilter filter, String? tag) { + if (filter == ActiveFilter.tag && tag == null) { + throw ('Must set a tag when setting filter to tags!'); + } setState(() { + activeTag = tag; activeFilter = filter; }); - AppSettings.chatFilter.setItem(filter.name); + AppSettings.chatFilter.setItem( + filter == ActiveFilter.tag ? tag! : filter.name, + ); } void setActiveClient(Client client) { @@ -978,6 +1096,8 @@ enum ChatContextAction { goToSpace, favorite, lowPriority, + addTag, + removeTag, markUnread, mute, leave, diff --git a/lib/pages/chat_list/chat_list_body.dart b/lib/pages/chat_list/chat_list_body.dart index 8489128f..e9de7e2f 100644 --- a/lib/pages/chat_list/chat_list_body.dart +++ b/lib/pages/chat_list/chat_list_body.dart @@ -134,37 +134,40 @@ class ChatListViewBody extends StatelessWidget { padding: const EdgeInsets.all(12.0), shrinkWrap: true, scrollDirection: Axis.horizontal, - children: - [ - ActiveFilter.allChats, - - if (spaces.isNotEmpty && - !AppSettings - .displayNavigationRail - .value && - !FluffyThemes.isColumnMode(context)) - ActiveFilter.spaces, - ActiveFilter.unread, - ActiveFilter.groups, - ActiveFilter.messages, - ] - .map( - (filter) => Padding( - padding: const EdgeInsets.symmetric( - horizontal: 4.0, - ), - child: FilterChip( - selected: - filter == controller.activeFilter, - onSelected: (_) => - controller.setActiveFilter(filter), - label: Text( - filter.toLocalizedString(context), - ), + children: [ + ...ActiveFilter.values + .where((filter) => filter != ActiveFilter.tag) + .map( + (filter) => Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4.0, + ), + child: FilterChip( + selected: filter == controller.activeFilter, + onSelected: (_) => controller + .setActiveFilter(filter, null), + label: Text( + filter.toLocalizedString(context), ), ), - ) - .toList(), + ), + ), + ...controller.roomTags.entries.map( + (entry) => Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4.0, + ), + child: FilterChip( + selected: entry.key == controller.activeTag, + onSelected: (_) => controller.setActiveFilter( + ActiveFilter.tag, + entry.key, + ), + label: Text(entry.key.replaceFirst('u.', '')), + ), + ), + ), + ], ), ), if (controller.isSearchMode)