diff --git a/lib/api/marianumcloud/talk/actions/talk_actions.dart b/lib/api/marianumcloud/talk/actions/talk_actions.dart index acf4d50..391c0f7 100644 --- a/lib/api/marianumcloud/talk/actions/talk_actions.dart +++ b/lib/api/marianumcloud/talk/actions/talk_actions.dart @@ -4,6 +4,7 @@ import 'package:http/http.dart' as http; import '../../../api_params.dart'; import '../../../api_response.dart'; +import '../room/get_room_response.dart'; import '../talk_api.dart'; /// Small POST/DELETE-only Talk endpoints that have no response payload. @@ -105,3 +106,21 @@ class DeleteRoomAvatar extends TalkApi { Map? headers, ) => http.delete(uri, headers: headers); } + +class SetNotificationLevel extends TalkApi { + final String chatToken; + final GetRoomResponseObjectParticipantNotificationLevel level; + + SetNotificationLevel(this.chatToken, this.level) + : super('v4/room/$chatToken/notify', null); + + @override + ApiResponse? assemble(String raw) => null; + + @override + Future request( + Uri uri, + ApiParams? body, + Map? headers, + ) => http.post(uri, headers: headers, body: {'level': '${level.apiValue}'}); +} diff --git a/lib/api/marianumcloud/talk/room/get_room_response.dart b/lib/api/marianumcloud/talk/room/get_room_response.dart index b720c65..5610efa 100644 --- a/lib/api/marianumcloud/talk/room/get_room_response.dart +++ b/lib/api/marianumcloud/talk/room/get_room_response.dart @@ -130,6 +130,12 @@ enum GetRoomResponseObjectParticipantNotificationLevel { neverNotify, } +extension NotificationLevelApiValue + on GetRoomResponseObjectParticipantNotificationLevel { + int get apiValue => + _$GetRoomResponseObjectParticipantNotificationLevelEnumMap[this]!; +} + enum GetRoomResponseObjectMessageActorType { @JsonValue('deleted_users') deletedUsers, diff --git a/lib/api/marianumcloud/talk/room/notification_level.dart b/lib/api/marianumcloud/talk/room/notification_level.dart new file mode 100644 index 0000000..39f1d6a --- /dev/null +++ b/lib/api/marianumcloud/talk/room/notification_level.dart @@ -0,0 +1,27 @@ +import 'get_room_response.dart'; + +typedef NotificationLevel = GetRoomResponseObjectParticipantNotificationLevel; + +/// The levels a user can pick in the UI; [NotificationLevel.defaultLevel] +/// is only ever reported by the server, never chosen. +const selectableNotificationLevels = [ + NotificationLevel.alwaysNotify, + NotificationLevel.notifyOnMention, + NotificationLevel.neverNotify, +]; + +extension RoomNotificationLevel on GetRoomResponseObject { + /// Resolves "default" the way Spreed does: 1:1 chats notify on every + /// message, group chats only on mentions (`default_group_notification`). + NotificationLevel get effectiveNotificationLevel { + if (notificationLevel != NotificationLevel.defaultLevel) { + return notificationLevel; + } + return type == GetRoomResponseObjectConversationType.oneToOne + ? NotificationLevel.alwaysNotify + : NotificationLevel.notifyOnMention; + } + + bool get isMuted => + effectiveNotificationLevel == NotificationLevel.neverNotify; +} diff --git a/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart b/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart index caaa3e8..d9aec94 100644 --- a/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart +++ b/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart @@ -3,6 +3,7 @@ import 'dart:developer'; import 'package:flutter_app_badge/flutter_app_badge.dart'; +import '../../../../../api/marianumcloud/talk/actions/talk_actions.dart'; import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; import '../../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; @@ -126,6 +127,18 @@ class ChatListBloc }); } + Future setNotificationLevel( + String token, + GetRoomResponseObjectParticipantNotificationLevel level, + ) async { + await SetNotificationLevel(token, level).run(); + _mutateRoom(token, (r) { + if (r.notificationLevel == level) return false; + r.notificationLevel = level; + return true; + }); + } + /// Clears unread too — long-poll only feeds this in for an actively-open chat. void applyIncomingMessage(String token, GetChatResponseObject message) { _mutateRoom(token, (r) { diff --git a/lib/view/pages/talk/details/chat_info.dart b/lib/view/pages/talk/details/chat_info.dart index 58a2880..5f07346 100644 --- a/lib/view/pages/talk/details/chat_info.dart +++ b/lib/view/pages/talk/details/chat_info.dart @@ -6,6 +6,7 @@ import '../../../../api/marianumcloud/talk/get_participants/get_participants_cac import '../../../../api/marianumcloud/talk/get_participants/get_participants_response.dart'; import '../../../../api/marianumcloud/talk/get_shared_items/get_shared_items_overview_response.dart'; import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; +import '../../../../api/marianumcloud/talk/room/notification_level.dart'; import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../../../../widget/app_progress_indicator.dart'; import '../../../../widget/async_action_button.dart'; @@ -14,6 +15,7 @@ import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/large_profile_picture_view.dart'; import '../../../../widget/user_avatar.dart'; import '../talk_navigator.dart'; +import '../widgets/notification_level_sheet.dart'; import 'participants_list_view.dart'; import 'shared_items_view.dart'; @@ -29,6 +31,7 @@ class _ChatInfoState extends State { GetParticipantsResponse? participants; GetSharedItemsOverviewResponse? _sharesOverview; late bool _isFavorite; + late NotificationLevel _notificationLevel; int _avatarVersion = 0; bool _avatarBusy = false; @@ -36,6 +39,7 @@ class _ChatInfoState extends State { void initState() { super.initState(); _isFavorite = widget.room.isFavorite; + _notificationLevel = widget.room.effectiveNotificationLevel; GetParticipantsCache( chatToken: widget.room.token, onUpdate: (GetParticipantsResponse data) { @@ -67,6 +71,16 @@ class _ChatInfoState extends State { _refreshList(); } + Future _editNotificationLevel() async { + final level = await showNotificationLevelSheet( + context, + widget.room, + current: _notificationLevel, + ); + if (level == null || !mounted) return; + setState(() => _notificationLevel = level); + } + Future _confirmLeave() async { final closed = await showDialog( context: context, @@ -243,6 +257,11 @@ class _ChatInfoState extends State { title: const Text('Zu Favoriten hinzufügen'), onPressed: _toggleFavorite, ), + NotificationLevelTile( + level: _notificationLevel, + trailing: const Icon(Icons.arrow_right), + onTap: _editNotificationLevel, + ), const Divider(), ListTile( leading: Icon( diff --git a/lib/view/pages/talk/widgets/chat_tile.dart b/lib/view/pages/talk/widgets/chat_tile.dart index 36184cc..a90b129 100644 --- a/lib/view/pages/talk/widgets/chat_tile.dart +++ b/lib/view/pages/talk/widgets/chat_tile.dart @@ -6,6 +6,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../api/marianumcloud/talk/actions/talk_actions.dart'; import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart'; import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; +import '../../../../api/marianumcloud/talk/room/notification_level.dart'; import '../../../../api/marianumcloud/talk/set_read_marker/set_read_marker.dart'; import '../../../../extensions/date_time.dart'; import '../../../../model/account_data.dart'; @@ -22,6 +23,7 @@ import '../../../../widget/details_bottom_sheet.dart'; import '../../../../widget/user_avatar.dart'; import '../data/talk_markdown.dart'; import '../talk_navigator.dart'; +import 'notification_level_sheet.dart'; class ChatTile extends StatefulWidget { final GetRoomResponseObject data; @@ -151,6 +153,15 @@ class _ChatTileState extends State { semanticLabel: A11yLabels.draft, ), ], + if (widget.data.isMuted) ...[ + const SizedBox(width: 5), + Icon( + NotificationLevel.neverNotify.icon, + size: 15, + color: Theme.of(context).colorScheme.onSurfaceVariant, + semanticLabel: A11yLabels.muted, + ), + ], ], ), subtitle: Text( @@ -238,6 +249,17 @@ class _ChatTileState extends State { if (mounted) _refreshList(); }, ), + NotificationLevelTile( + level: widget.data.effectiveNotificationLevel, + onTap: () { + Navigator.of(sheetCtx).pop(); + showNotificationLevelSheet( + context, + widget.data, + current: widget.data.effectiveNotificationLevel, + ); + }, + ), ListTile( leading: const Icon(Icons.delete_outline), title: const Text('Talk-Chat verlassen'), diff --git a/lib/view/pages/talk/widgets/notification_level_sheet.dart b/lib/view/pages/talk/widgets/notification_level_sheet.dart new file mode 100644 index 0000000..a704492 --- /dev/null +++ b/lib/view/pages/talk/widgets/notification_level_sheet.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; +import '../../../../api/marianumcloud/talk/room/notification_level.dart'; +import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; +import '../../../../widget/async_action_button.dart'; +import '../../../../widget/details_bottom_sheet.dart'; + +extension NotificationLevelPresentation on NotificationLevel { + // defaultLevel never reaches the UI (see effectiveNotificationLevel), but + // the switch has to stay exhaustive. + ({String label, String description, IconData icon}) get _presentation => + switch (this) { + NotificationLevel.alwaysNotify || NotificationLevel.defaultLevel => ( + label: 'Alle Nachrichten', + description: 'Bei jeder neuen Nachricht', + icon: Icons.notifications_outlined, + ), + NotificationLevel.notifyOnMention => ( + label: 'Nur Erwähnungen', + description: 'Nur wenn du erwähnt wirst', + icon: Icons.alternate_email, + ), + NotificationLevel.neverNotify => ( + label: 'Aus', + description: 'Keine Benachrichtigungen', + icon: Icons.notifications_off_outlined, + ), + }; + + String get label => _presentation.label; + String get description => _presentation.description; + IconData get icon => _presentation.icon; +} + +/// Lets the user pick the room's Talk notification level. Nextcloud applies +/// it server-side, so no push for the room is sent at all when muted. +/// Returns the new level, or null when nothing changed. +Future showNotificationLevelSheet( + BuildContext context, + GetRoomResponseObject room, { + required NotificationLevel current, +}) async { + final bloc = context.read(); + NotificationLevel? selected; + await showDetailsBottomSheet( + context, + header: ListTile( + leading: const Icon(Icons.notifications_outlined), + title: const Text('Benachrichtigungen'), + subtitle: Text(room.displayName, overflow: TextOverflow.ellipsis), + ), + children: (_) => [ + for (final level in selectableNotificationLevels) + AsyncListTile( + leading: Icon( + level == current + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + ), + title: Text(level.label), + subtitle: Text(level.description), + onPressed: () async { + if (level == current) return; + await bloc.setNotificationLevel(room.token, level); + selected = level; + }, + ), + ], + ); + return selected; +} + +class NotificationLevelTile extends StatelessWidget { + final NotificationLevel level; + final VoidCallback onTap; + final Widget? trailing; + + const NotificationLevelTile({ + required this.level, + required this.onTap, + this.trailing, + super.key, + }); + + @override + Widget build(BuildContext context) => ListTile( + leading: Icon(level.icon), + title: const Text('Benachrichtigungen'), + subtitle: Text(level.label), + trailing: trailing, + onTap: onTap, + ); +} diff --git a/lib/widget/a11y/a11y_labels.dart b/lib/widget/a11y/a11y_labels.dart index 806ce6f..e0af333 100644 --- a/lib/widget/a11y/a11y_labels.dart +++ b/lib/widget/a11y/a11y_labels.dart @@ -14,6 +14,7 @@ abstract final class A11yLabels { static const unread = 'Ungelesen'; static const favorite = 'Favorit'; static const draft = 'Entwurf'; + static const muted = 'Stummgeschaltet'; static const yourReaction = 'deine Reaktion'; // Bilder / Kontakte diff --git a/test/talk/notification_level_test.dart b/test/talk/notification_level_test.dart new file mode 100644 index 0000000..234ab63 --- /dev/null +++ b/test/talk/notification_level_test.dart @@ -0,0 +1,79 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/marianumcloud/talk/chat/get_chat_response.dart'; +import 'package:marianum_mobile/api/marianumcloud/talk/room/get_room_response.dart'; +import 'package:marianum_mobile/api/marianumcloud/talk/room/notification_level.dart'; + +typedef Level = GetRoomResponseObjectParticipantNotificationLevel; +typedef RoomType = GetRoomResponseObjectConversationType; + +GetRoomResponseObject _room(RoomType type, Level level) => + GetRoomResponseObject( + 1, + 't', + type, + 'n', + 'd', + '', + 3, + 0, + 0, + 0, + 0, + 's', + false, + false, + 0, + false, + false, + true, + 0, + false, + level, + 0, + false, + false, + 0, + 0, + GetChatResponseObject( + 1, + 't', + GetRoomResponseObjectMessageActorType.user, + 'a', + 'A', + 0, + '', + GetRoomResponseObjectMessageType.comment, + true, + 'r', + 'm', + null, + null, + null, + null, + false, + ), + null, + null, + null, + ); + +void main() { + test('Standard wird bei 1:1-Chats zu „Alle Nachrichten"', () { + final room = _room(RoomType.oneToOne, Level.defaultLevel); + expect(room.effectiveNotificationLevel, Level.alwaysNotify); + expect(room.isMuted, isFalse); + }); + + test('Standard wird bei Gruppen zu „Nur Erwähnungen"', () { + expect( + _room(RoomType.group, Level.defaultLevel).effectiveNotificationLevel, + Level.notifyOnMention, + ); + }); + + test('explizit gesetzter Level bleibt erhalten', () { + final room = _room(RoomType.oneToOne, Level.neverNotify); + expect(room.effectiveNotificationLevel, Level.neverNotify); + expect(room.isMuted, isTrue); + }); +}