feat: Implement room tags

This commit is contained in:
Christian Kußowski 2026-04-01 08:24:59 +02:00
commit 101578bc9d
No known key found for this signature in database
GPG key ID: E067ECD60F1A0652
3 changed files with 165 additions and 38 deletions

View file

@ -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"
}

View file

@ -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<ChatList>
StreamSubscription? _intentFileStreamSubscription;
late ActiveFilter activeFilter;
String? activeTag;
String? _activeSpaceId;
String? get activeSpaceId => _activeSpaceId;
@ -141,6 +144,8 @@ class ChatListController extends State<ChatList>
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<ChatList>
}
}
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<ChatList>
);
});
_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<ChatList>
void dispose() {
_intentDataStreamSubscription?.cancel();
_intentFileStreamSubscription?.cancel();
_onRoomTagUpdate?.cancel();
scrollController.removeListener(_onScroll);
super.dispose();
}
@ -622,6 +651,30 @@ class ChatListController extends State<ChatList>
],
),
),
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<ChatList>
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<String?>(
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<String, int> 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<void> dismissStatusList() async {
final result = await showOkCancelAlertDialog(
title: L10n.of(context).hidePresences,
@ -858,11 +970,17 @@ class ChatListController extends State<ChatList>
}
}
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,

View file

@ -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)