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", "joinVoiceCall": "Join voice call",
"joinVideoCall": "Join video call", "joinVideoCall": "Join video call",
"live": "Live", "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 '../../utils/url_launcher.dart';
import '../../widgets/matrix.dart'; import '../../widgets/matrix.dart';
enum ActiveFilter { allChats, messages, groups, unread, spaces } enum ActiveFilter { allChats, messages, groups, unread, spaces, tag }
extension LocalizedActiveFilter on ActiveFilter { extension LocalizedActiveFilter on ActiveFilter {
String toLocalizedString(BuildContext context) { String toLocalizedString(BuildContext context) {
@ -45,6 +45,8 @@ extension LocalizedActiveFilter on ActiveFilter {
return L10n.of(context).groups; return L10n.of(context).groups;
case ActiveFilter.spaces: case ActiveFilter.spaces:
return L10n.of(context).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; StreamSubscription? _intentFileStreamSubscription;
late ActiveFilter activeFilter; late ActiveFilter activeFilter;
String? activeTag;
String? _activeSpaceId; String? _activeSpaceId;
String? get activeSpaceId => _activeSpaceId; String? get activeSpaceId => _activeSpaceId;
@ -141,6 +144,8 @@ class ChatListController extends State<ChatList>
return (room) => room.isUnreadOrInvited; return (room) => room.isUnreadOrInvited;
case ActiveFilter.spaces: case ActiveFilter.spaces:
return (room) => room.isSpace; 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 @override
void initState() { void initState() {
activeFilter =
ActiveFilter.values.singleWhereOrNull(
(filter) => AppSettings.chatFilter.value == filter.name,
) ??
ActiveFilter.allChats;
_initReceiveSharingIntent(); _initReceiveSharingIntent();
_activeSpaceId = widget.activeSpace; _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(); super.initState();
} }
@ -394,6 +422,7 @@ class ChatListController extends State<ChatList>
void dispose() { void dispose() {
_intentDataStreamSubscription?.cancel(); _intentDataStreamSubscription?.cancel();
_intentFileStreamSubscription?.cancel(); _intentFileStreamSubscription?.cancel();
_onRoomTagUpdate?.cancel();
scrollController.removeListener(_onScroll); scrollController.removeListener(_onScroll);
super.dispose(); 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) if (spacesWithPowerLevels.isNotEmpty)
PopupMenuItem( PopupMenuItem(
value: ChatContextAction.addToSpace, value: ChatContextAction.addToSpace,
@ -762,9 +815,68 @@ class ChatListController extends State<ChatList>
future: () => room.setLowPriority(!room.isLowPriority), future: () => room.setLowPriority(!room.isLowPriority),
); );
return; 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 { Future<void> dismissStatusList() async {
final result = await showOkCancelAlertDialog( final result = await showOkCancelAlertDialog(
title: L10n.of(context).hidePresences, 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(() { setState(() {
activeTag = tag;
activeFilter = filter; activeFilter = filter;
}); });
AppSettings.chatFilter.setItem(filter.name); AppSettings.chatFilter.setItem(
filter == ActiveFilter.tag ? tag! : filter.name,
);
} }
void setActiveClient(Client client) { void setActiveClient(Client client) {
@ -978,6 +1096,8 @@ enum ChatContextAction {
goToSpace, goToSpace,
favorite, favorite,
lowPriority, lowPriority,
addTag,
removeTag,
markUnread, markUnread,
mute, mute,
leave, leave,

View file

@ -134,37 +134,40 @@ class ChatListViewBody extends StatelessWidget {
padding: const EdgeInsets.all(12.0), padding: const EdgeInsets.all(12.0),
shrinkWrap: true, shrinkWrap: true,
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
children: children: [
[ ...ActiveFilter.values
ActiveFilter.allChats, .where((filter) => filter != ActiveFilter.tag)
.map(
if (spaces.isNotEmpty && (filter) => Padding(
!AppSettings padding: const EdgeInsets.symmetric(
.displayNavigationRail horizontal: 4.0,
.value && ),
!FluffyThemes.isColumnMode(context)) child: FilterChip(
ActiveFilter.spaces, selected: filter == controller.activeFilter,
ActiveFilter.unread, onSelected: (_) => controller
ActiveFilter.groups, .setActiveFilter(filter, null),
ActiveFilter.messages, label: Text(
] filter.toLocalizedString(context),
.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),
),
), ),
), ),
) ),
.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) if (controller.isSearchMode)