From d0ba7c0fd663625e10ad6ef1fca0379971f7950c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 13 May 2026 18:46:34 +0200 Subject: [PATCH] refactored direct chat logic into a shared utility, implemented direct message shortcuts in the participant list and message reactions, and added reaction visibility checks in the message options dialog --- .../pages/talk/data/open_direct_chat.dart | 66 +++++++++++++++++++ lib/view/pages/talk/details/chat_info.dart | 5 +- .../pages/talk/details/message_reactions.dart | 26 ++++---- .../talk/details/participants_list_view.dart | 38 +++++++++-- .../widgets/chat_message_options_dialog.dart | 63 ++---------------- 5 files changed, 121 insertions(+), 77 deletions(-) create mode 100644 lib/view/pages/talk/data/open_direct_chat.dart diff --git a/lib/view/pages/talk/data/open_direct_chat.dart b/lib/view/pages/talk/data/open_direct_chat.dart new file mode 100644 index 0000000..196dd1f --- /dev/null +++ b/lib/view/pages/talk/data/open_direct_chat.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; +import '../../../../routing/app_routes.dart'; +import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; +import '../../../../widget/confirm_dialog.dart'; + +/// Opens an existing 1:1 chat with [actorId] or, after confirmation, creates +/// one. Shared between the group-message context menu and the reactions +/// overview (per-participant chat shortcut). +void openOrCreateDirectChat( + BuildContext context, { + required String actorId, + required String actorDisplayName, +}) { + final chatListBloc = context.read(); + + GetRoomResponseObject? findExisting() { + final rooms = chatListBloc.state.data?.rooms; + if (rooms == null) return null; + for (final room in rooms.data) { + if (room.type == GetRoomResponseObjectConversationType.oneToOne && + room.name == actorId) { + return room; + } + } + return null; + } + + void switchToChat(GetRoomResponseObject room) { + // Pop the previous ChatView first — otherwise it stays in the + // back-stack with a now-mismatched currentToken and renders empty + // on back-swipe. Stop at popups so an open dialog stays alive. + Navigator.of( + context, + ).popUntil((route) => route.isFirst || route is PopupRoute); + AppRoutes.openChatByToken(context, room.token); + } + + final existing = findExisting(); + if (existing != null) { + switchToChat(existing); + return; + } + + ConfirmDialog( + title: 'Privatchat starten?', + content: + 'Es existiert noch kein Privatchat mit $actorDisplayName. ' + 'Soll einer erstellt werden?', + confirmButton: 'Erstellen', + onConfirmAsync: () async { + await chatListBloc.createDirectChat(actorId); + final created = findExisting(); + if (created == null) { + throw Exception( + 'Privatchat konnte nach dem Erstellen nicht gefunden werden.', + ); + } + if (context.mounted) { + switchToChat(created); + } + }, + ).asDialog(context); +} diff --git a/lib/view/pages/talk/details/chat_info.dart b/lib/view/pages/talk/details/chat_info.dart index fad96f5..a4859c2 100644 --- a/lib/view/pages/talk/details/chat_info.dart +++ b/lib/view/pages/talk/details/chat_info.dart @@ -77,7 +77,10 @@ class _ChatInfoState extends State { trailing: const Icon(Icons.arrow_right), onTap: () => TalkNavigator.pushSplitView( context, - ParticipantsListView(participants!), + ParticipantsListView( + participants!, + showDirectMessageAction: isGroup, + ), ), ), ], diff --git a/lib/view/pages/talk/details/message_reactions.dart b/lib/view/pages/talk/details/message_reactions.dart index 6a2a807..e39cc36 100644 --- a/lib/view/pages/talk/details/message_reactions.dart +++ b/lib/view/pages/talk/details/message_reactions.dart @@ -1,4 +1,3 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../../../../api/marianumcloud/talk/get_reactions/get_reactions.dart'; @@ -7,8 +6,8 @@ import '../../../../model/account_data.dart'; import '../../../../widget/centered_leading.dart'; import '../../../../widget/loading_spinner.dart'; import '../../../../widget/placeholder_view.dart'; -import '../../../../widget/unimplemented_dialog.dart'; import '../../../../widget/user_avatar.dart'; +import '../data/open_direct_chat.dart'; class MessageReactions extends StatefulWidget { final String token; @@ -63,25 +62,28 @@ class _MessageReactionsState extends State { leading: CenteredLeading(Text(entry.key)), title: Text('${entry.value.length} mal reagiert'), children: entry.value.map((e) { - var isSelf = AccountData().getUsername() == e.actorId; + final isSelf = AccountData().getUsername() == e.actorId; + final isGuest = + e.actorType == + GetReactionsResponseObjectActorType.guests; return ListTile( leading: UserAvatar(id: e.actorId, isGroup: false), title: Text(e.actorDisplayName), subtitle: isSelf ? const Text('Du') - : e.actorType == - GetReactionsResponseObjectActorType.guests + : isGuest ? const Text('Gast') : null, - trailing: isSelf + trailing: isSelf || isGuest ? null - : Visibility( - visible: kReleaseMode, - child: IconButton( - onPressed: () => - UnimplementedDialog.show(context), - icon: const Icon(Icons.textsms_outlined), + : IconButton( + tooltip: 'Private Nachricht', + onPressed: () => openOrCreateDirectChat( + context, + actorId: e.actorId, + actorDisplayName: e.actorDisplayName, ), + icon: const Icon(Icons.textsms_outlined), ), ); }).toList(), diff --git a/lib/view/pages/talk/details/participants_list_view.dart b/lib/view/pages/talk/details/participants_list_view.dart index b3c4850..3397b0e 100644 --- a/lib/view/pages/talk/details/participants_list_view.dart +++ b/lib/view/pages/talk/details/participants_list_view.dart @@ -2,11 +2,23 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import '../../../../api/marianumcloud/talk/get_participants/get_participants_response.dart'; +import '../../../../model/account_data.dart'; import '../../../../widget/user_avatar.dart'; +import '../data/open_direct_chat.dart'; class ParticipantsListView extends StatelessWidget { final GetParticipantsResponse participantsResponse; - const ParticipantsListView(this.participantsResponse, {super.key}); + + /// Hide the per-participant direct-message shortcut: in a 1:1 chat the + /// only other participant *is* the current chat — the shortcut would + /// just navigate back to where the user already is. + final bool showDirectMessageAction; + + const ParticipantsListView( + this.participantsResponse, { + this.showDirectMessageAction = true, + super.key, + }); @override Widget build(BuildContext context) { @@ -24,6 +36,7 @@ class ParticipantsListView extends StatelessWidget { (participant) => participant.participantType, ); + final selfId = AccountData().getUsername(); return Scaffold( appBar: AppBar(title: const Text('Mitglieder')), body: ListView( @@ -35,15 +48,30 @@ class ParticipantsListView extends StatelessWidget { title: Text(entry.key.prettyName), titleTextStyle: Theme.of(context).textTheme.titleMedium, ), - ...entry.value.map( - (participant) => ListTile( + ...entry.value.map((participant) { + final canDirectMessage = + showDirectMessageAction && + participant.actorType == 'users' && + participant.actorId != selfId; + return ListTile( leading: UserAvatar(id: participant.actorId), title: Text(participant.displayName), subtitle: participant.statusMessage != null ? Text(participant.statusMessage!) : null, - ), - ), + trailing: canDirectMessage + ? IconButton( + tooltip: 'Private Nachricht', + onPressed: () => openOrCreateDirectChat( + context, + actorId: participant.actorId, + actorDisplayName: participant.displayName, + ), + icon: const Icon(Icons.textsms_outlined), + ) + : null, + ); + }), Divider(), ], ), diff --git a/lib/view/pages/talk/widgets/chat_message_options_dialog.dart b/lib/view/pages/talk/widgets/chat_message_options_dialog.dart index 5ac03b9..29b3781 100644 --- a/lib/view/pages/talk/widgets/chat_message_options_dialog.dart +++ b/lib/view/pages/talk/widgets/chat_message_options_dialog.dart @@ -10,13 +10,13 @@ import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../../routing/app_routes.dart'; import '../../../../share_intent/remote_file_ref.dart'; import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; -import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; import '../../../../utils/clipboard_helper.dart'; import '../../../../widget/app_progress_indicator.dart'; import '../../../../widget/async_action_button.dart'; import '../../../../widget/confirm_dialog.dart'; import '../../../../widget/debug/debug_tile.dart'; import '../../../../widget/details_bottom_sheet.dart'; +import '../data/open_direct_chat.dart'; const _commonReactions = ['👍', '👎', '😆', '❤️', '👀']; @@ -40,6 +40,7 @@ void showChatMessageOptionsDialog( final parentContext = context; final canReact = bubbleData.messageType == GetRoomResponseObjectMessageType.comment; + final hasReactions = bubbleData.reactions?.isNotEmpty ?? false; final canDelete = isSender && DateTime.fromMillisecondsSinceEpoch( @@ -66,7 +67,7 @@ void showChatMessageOptionsDialog( Navigator.of(sheetCtx).pop(); }, ), - if (canReact) + if (canReact && hasReactions) ListTile( leading: const Icon(Icons.emoji_emotions_outlined), title: const Text('Reaktionen'), @@ -129,7 +130,7 @@ void showChatMessageOptionsDialog( onTap: () { Navigator.of(sheetCtx).pop(); if (!parentContext.mounted) return; - _openOrCreateDirectChat( + openOrCreateDirectChat( parentContext, actorId: bubbleData.actorId, actorDisplayName: bubbleData.actorDisplayName, @@ -160,62 +161,6 @@ void showChatMessageOptionsDialog( ); } -void _openOrCreateDirectChat( - BuildContext context, { - required String actorId, - required String actorDisplayName, -}) { - final chatListBloc = context.read(); - - GetRoomResponseObject? findExisting() { - final rooms = chatListBloc.state.data?.rooms; - if (rooms == null) return null; - for (final room in rooms.data) { - if (room.type == GetRoomResponseObjectConversationType.oneToOne && - room.name == actorId) { - return room; - } - } - return null; - } - - void switchToChat(GetRoomResponseObject room) { - // Pop the previous ChatView first — otherwise it stays in the - // back-stack with a now-mismatched currentToken and renders empty - // on back-swipe. Stop at popups so an open dialog stays alive. - Navigator.of( - context, - ).popUntil((route) => route.isFirst || route is PopupRoute); - AppRoutes.openChatByToken(context, room.token); - } - - final existing = findExisting(); - if (existing != null) { - switchToChat(existing); - return; - } - - ConfirmDialog( - title: 'Privatchat starten?', - content: - 'Es existiert noch kein Privatchat mit $actorDisplayName. ' - 'Soll einer erstellt werden?', - confirmButton: 'Erstellen', - onConfirmAsync: () async { - await chatListBloc.createDirectChat(actorId); - final created = findExisting(); - if (created == null) { - throw Exception( - 'Privatchat konnte nach dem Erstellen nicht gefunden werden.', - ); - } - if (context.mounted) { - switchToChat(created); - } - }, - ).asDialog(context); -} - class _ReactionsRow extends StatefulWidget { final String chatToken; final int messageId;