diff --git a/lib/routing/app_routes.dart b/lib/routing/app_routes.dart index ac9042a..804d94e 100644 --- a/lib/routing/app_routes.dart +++ b/lib/routing/app_routes.dart @@ -135,6 +135,18 @@ class AppRoutes { ); } + static void openForwardMessageToChat( + BuildContext context, { + String? text, + RemoteFileRef? file, + }) { + pushScreen( + context, + withNavBar: false, + screen: ShareChatPicker.forMessageForward(text: text, file: file), + ); + } + static void openInternalSaveToFolder( BuildContext context, RemoteFileRef file, diff --git a/lib/view/pages/share_intent/share_chat_picker.dart b/lib/view/pages/share_intent/share_chat_picker.dart index 8233554..d61e620 100644 --- a/lib/view/pages/share_intent/share_chat_picker.dart +++ b/lib/view/pages/share_intent/share_chat_picker.dart @@ -7,6 +7,8 @@ import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; import '../../../api/errors/error_mapper.dart'; import '../../../api/marianumcloud/talk/room/get_room_response.dart'; +import '../../../api/marianumcloud/talk/send_message/send_message.dart'; +import '../../../api/marianumcloud/talk/send_message/send_message_params.dart'; import '../../../api/marianumcloud/talk/share_files_to_chat.dart'; import '../../../api/marianumcloud/webdav/webdav_api.dart'; import '../../../routing/app_routes.dart'; @@ -47,6 +49,23 @@ class ShareChatPicker extends StatelessWidget { onPicked: (ctx, room) => _internalShareFlow(ctx, room, file), ); + /// Forward an existing Talk message (text and/or already-uploaded file + /// attachment) into another chat. The attachment is re-shared via the same + /// FileSharingApi path used for [forInternalShare]; plain text is posted + /// with [SendMessage]. + factory ShareChatPicker.forMessageForward({ + String? text, + RemoteFileRef? file, + }) { + assert( + text != null || file != null, + 'forMessageForward requires either text or file', + ); + return ShareChatPicker._( + onPicked: (ctx, room) => _forwardMessageFlow(ctx, room, text, file), + ); + } + @override Widget build(BuildContext context) { final talkSettings = context.watch().val().talkSettings; @@ -217,6 +236,39 @@ Future _internalShareFlow( _finishWithChat(context, room); } +Future _forwardMessageFlow( + BuildContext context, + GetRoomResponseObject room, + String? text, + RemoteFileRef? file, +) async { + unawaited(_showBlockingSpinner(context)); + try { + if (file != null) { + await shareFilesToChat( + token: room.token, + remoteFilePaths: [file.path], + ); + } + if (text != null && text.isNotEmpty) { + await SendMessage(room.token, SendMessageParams(text)).run(); + } + } catch (e) { + if (context.mounted) Navigator.of(context).pop(); + if (context.mounted) { + InfoDialog.show( + context, + errorToUserMessage(e), + title: 'Fehler', + copyable: true, + ); + } + return; + } + if (!context.mounted) return; + _finishWithChat(context, room); +} + /// Modal progress overlay shown during share-API roundtrips. The dialog is /// popped together with the picker by the subsequent popUntil(isFirst). Future _showBlockingSpinner(BuildContext context) => showDialog( 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 d291d15..1435914 100644 --- a/lib/view/pages/talk/widgets/chat_message_options_dialog.dart +++ b/lib/view/pages/talk/widgets/chat_message_options_dialog.dart @@ -1,5 +1,4 @@ import 'package:emoji_picker_flutter/emoji_picker_flutter.dart' as emojis; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -11,9 +10,11 @@ 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'; @@ -104,13 +105,39 @@ void showChatMessageOptionsDialog( ); }, ), - if (!kReleaseMode && + if (canReact && (bubbleData.message != '{file}' || attachedFile != null)) + ListTile( + leading: const Icon(Icons.forward_outlined), + title: const Text('Weiterleiten'), + onTap: () { + Navigator.of(sheetCtx).pop(); + if (!parentContext.mounted) return; + AppRoutes.openForwardMessageToChat( + parentContext, + text: bubbleData.message == '{file}' ? null : bubbleData.message, + file: attachedFile != null + ? RemoteFileRef.fromTalk(attachedFile) + : null, + ); + }, + ), + if (canReact && !isSender && - chatData.type != GetRoomResponseObjectConversationType.oneToOne) + chatData.type != GetRoomResponseObjectConversationType.oneToOne && + bubbleData.actorType == + GetRoomResponseObjectMessageActorType.user) ListTile( leading: const Icon(Icons.sms_outlined), - title: Text("Private Nachricht an '${bubbleData.actorDisplayName}'"), - onTap: () => Navigator.of(sheetCtx).pop(), + title: Text('Private Nachricht an ${bubbleData.actorDisplayName}'), + onTap: () { + Navigator.of(sheetCtx).pop(); + if (!parentContext.mounted) return; + _openOrCreateDirectChat( + parentContext, + actorId: bubbleData.actorId, + actorDisplayName: bubbleData.actorDisplayName, + ); + }, ), if (canDelete) AsyncListTile( @@ -126,6 +153,60 @@ 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 current ChatView before swapping the global ChatBloc token — + // otherwise the previous group chat stays mounted in the back-stack and + // would render empty after a back-swipe (currentToken no longer matches). + Navigator.of(context).popUntil((route) => route.isFirst); + 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;