diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 25e7cfe..3b71979 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -688,6 +688,55 @@ class MessagesModule { return ''; } + /// Пересылает сообщение [messageId] из чата [sourceChatId] в [targetChatId]. + /// + /// Пересылка — это отдельное сообщение без текста и вложений, со ссылкой + /// `link.type = FORWARD`, указывающей на оригинал. Сервер сам подставит + /// тело оригинала в ответе. + Future forwardMessage( + int targetChatId, + int sourceChatId, + int messageId, { + bool notify = true, + }) async { + final message = { + 'isLive': false, + 'detectShare': false, + 'elements': [], + 'attaches': [], + 'cid': DateTime.now().millisecondsSinceEpoch * -1, + 'link': { + 'type': 'FORWARD', + 'chatId': sourceChatId, + 'messageId': messageId, + }, + }; + final payload = { + 'chatId': targetChatId, + 'message': message, + 'notify': notify, + }; + + final response = await _api.sendRequest(Opcode.msgSend, payload); + if (!response.isOk) { + final msg = (response.payload is Map) + ? (response.payload['localizedMessage'] ?? + response.payload['message'] ?? + 'Ошибка пересылки') + : 'Ошибка пересылки'; + throw Exception(msg.toString()); + } + final data = response.payload; + if (data is Map) { + final msgMap = data['message']; + if (msgMap is Map) { + final id = msgMap['id']; + if (id != null) return id.toString(); + } + } + return ''; + } + /// Загружает отложенные (запланированные) сообщения чата. /// /// В отличие от обычной истории, отложенные сообщения не сохраняются diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 7cab827..5bbf361 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -16,6 +16,7 @@ import 'package:komet/core/media/gallery_source.dart'; import 'package:komet/core/utils/format.dart'; import 'package:komet/core/utils/logger.dart'; import 'package:komet/frontend/screens/chats/chat_info_screen.dart'; +import 'package:komet/frontend/screens/chats/forward_picker_screen.dart'; import 'package:komet/frontend/screens/chats/poll_create_screen.dart'; import 'package:komet/frontend/widgets/custom_notification.dart'; import 'package:komet/frontend/widgets/chat_menu_overlay.dart'; @@ -994,7 +995,49 @@ class _ChatScreenState extends State } void _forwardSelected() { - showCustomNotification(context, 'Пересылка — пока в разработке'); + final msgs = _selectedMessages(_selectedIds.value); + _clearSelection(); + unawaited(_forwardMessages(msgs)); + } + + Future _forwardMessages(List msgs) async { + final forwardable = + msgs.where((m) => !m.id.startsWith('temp_')).toList(); + if (forwardable.isEmpty) { + showCustomNotification(context, 'Нечего пересылать'); + return; + } + + final target = await showForwardPicker( + context: context, + accountId: _myId, + messageCount: forwardable.length, + ); + if (target == null || !mounted) return; + + final ordered = [...forwardable]..sort((a, b) => a.time.compareTo(b.time)); + var ok = 0; + for (final m in ordered) { + final mid = int.tryParse(m.id); + if (mid == null) continue; + try { + await messagesModule.forwardMessage(target.chatId, widget.chatId, mid); + ok++; + } catch (_) {} + } + if (!mounted) return; + if (ok == 0) { + Haptics.error(); + showCustomNotification(context, 'Не удалось переслать'); + return; + } + Haptics.send(); + showCustomNotification( + context, + target.chatId == widget.chatId + ? 'Переслано' + : 'Переслано в «${target.name}»', + ); } Widget _buildComposerArea(BuildContext context) { @@ -2950,6 +2993,9 @@ class _ChatScreenState extends State onReply: message.isControl ? null : () => _startReply(message), + onForward: message.isControl + ? null + : () => _forwardMessages([message]), child: bubble, ); @@ -4541,6 +4587,7 @@ class _SelectableMessageRow extends StatefulWidget { final VoidCallback onDelete; final VoidCallback? onEdit; final VoidCallback? onReply; + final VoidCallback? onForward; const _SelectableMessageRow({ required this.child, @@ -4554,6 +4601,7 @@ class _SelectableMessageRow extends StatefulWidget { required this.onDelete, this.onEdit, this.onReply, + this.onForward, }); @override @@ -4600,6 +4648,7 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> { onDelete: widget.onDelete, onEdit: widget.onEdit, onReply: widget.onReply, + onForward: widget.onForward, onDispose: controller.dispose, ); } @@ -4626,6 +4675,7 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> { onDelete: widget.onDelete, onEdit: widget.onEdit, onReply: widget.onReply, + onForward: widget.onForward, onDispose: controller.dispose, ); } diff --git a/lib/frontend/screens/chats/forward_picker_screen.dart b/lib/frontend/screens/chats/forward_picker_screen.dart new file mode 100644 index 0000000..793c2a5 --- /dev/null +++ b/lib/frontend/screens/chats/forward_picker_screen.dart @@ -0,0 +1,239 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import 'package:komet/backend/modules/chats.dart'; +import 'package:komet/backend/modules/messages.dart' show ContactCache; + +class ForwardTarget { + final int chatId; + final String name; + final String imageUrl; + final String chatType; + + const ForwardTarget({ + required this.chatId, + required this.name, + required this.imageUrl, + required this.chatType, + }); +} + +Future showForwardPicker({ + required BuildContext context, + required int accountId, + int messageCount = 1, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + backgroundColor: Colors.transparent, + builder: (_) => + _ForwardPickerSheet(accountId: accountId, messageCount: messageCount), + ); +} + +class _ForwardPickerSheet extends StatefulWidget { + final int accountId; + final int messageCount; + + const _ForwardPickerSheet({ + required this.accountId, + required this.messageCount, + }); + + @override + State<_ForwardPickerSheet> createState() => _ForwardPickerSheetState(); +} + +class _ForwardPickerSheetState extends State<_ForwardPickerSheet> { + final TextEditingController _searchController = TextEditingController(); + List _all = const []; + String _query = ''; + bool _loading = true; + + @override + void initState() { + super.initState(); + _searchController.addListener(() { + setState(() => _query = _searchController.text.trim().toLowerCase()); + }); + _load(); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + Future _load() async { + final chats = await ChatsModule.getChats(widget.accountId); + final targets = []; + for (final chat in chats) { + if (chat.type == 'CHANNEL' && chat.owner != widget.accountId) continue; + targets.add(_targetFor(chat)); + } + targets.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + if (!mounted) return; + setState(() { + _all = targets; + _loading = false; + }); + } + + ForwardTarget _targetFor(CachedChat chat) { + if (chat.id == 0) { + return ForwardTarget( + chatId: 0, + name: 'Избранное', + imageUrl: '', + chatType: chat.type, + ); + } + if (chat.type == 'DIALOG') { + int otherId = widget.accountId; + for (final entry in chat.participants.entries) { + if (entry.key != widget.accountId) { + otherId = entry.key; + break; + } + } + return ForwardTarget( + chatId: chat.id, + name: ContactCache.get(otherId) ?? chat.title ?? 'Пользователь', + imageUrl: ContactCache.getAvatar(otherId) ?? chat.iconUrl ?? '', + chatType: chat.type, + ); + } + return ForwardTarget( + chatId: chat.id, + name: chat.title ?? 'Чат', + imageUrl: chat.iconUrl ?? '', + chatType: chat.type, + ); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final filtered = _query.isEmpty + ? _all + : _all.where((t) => t.name.toLowerCase().contains(_query)).toList(); + + return Padding( + padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), + child: DraggableScrollableSheet( + initialChildSize: 0.6, + minChildSize: 0.4, + maxChildSize: 0.92, + expand: false, + builder: (context, scrollController) { + return Container( + decoration: BoxDecoration( + color: cs.surface, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(20), + ), + ), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + const SizedBox(height: 10), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: cs.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 4), + child: Row( + children: [ + Text( + widget.messageCount > 1 + ? 'Переслать (${widget.messageCount})' + : 'Переслать', + style: TextStyle( + color: cs.onSurface, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Поиск чата', + prefixIcon: const Icon(Symbols.search), + filled: true, + fillColor: cs.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide.none, + ), + contentPadding: EdgeInsets.zero, + ), + ), + ), + Expanded( + child: _loading + ? const Center(child: CircularProgressIndicator()) + : filtered.isEmpty + ? Center( + child: Text( + 'Ничего не найдено', + style: TextStyle(color: cs.onSurfaceVariant), + ), + ) + : ListView.builder( + controller: scrollController, + padding: const EdgeInsets.only(bottom: 8), + itemCount: filtered.length, + itemBuilder: (context, index) { + final t = filtered[index]; + return ListTile( + leading: CircleAvatar( + radius: 22, + backgroundColor: cs.surfaceContainerHighest, + backgroundImage: t.imageUrl.isNotEmpty + ? CachedNetworkImageProvider( + t.imageUrl, + maxWidth: 132, + maxHeight: 132, + ) + : null, + child: t.imageUrl.isEmpty + ? Icon( + t.chatId == 0 + ? Symbols.bookmark + : Symbols.person, + color: cs.onSurfaceVariant, + ) + : null, + ), + title: Text( + t.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onTap: () => Navigator.of(context).pop(t), + ); + }, + ), + ), + ], + ), + ); + }, + ), + ); + } +} diff --git a/lib/frontend/widgets/message_actions_overlay.dart b/lib/frontend/widgets/message_actions_overlay.dart index 1fc9669..5ba6624 100644 --- a/lib/frontend/widgets/message_actions_overlay.dart +++ b/lib/frontend/widgets/message_actions_overlay.dart @@ -76,6 +76,7 @@ void showMessageActions({ VoidCallback? onDelete, VoidCallback? onEdit, VoidCallback? onReply, + VoidCallback? onForward, MessageActionsInteraction interaction = MessageActionsInteraction.dragAndRelease, }) { final overlay = Overlay.of(context, rootOverlay: true); @@ -93,6 +94,7 @@ void showMessageActions({ onDelete: onDelete, onEdit: onEdit, onReply: onReply, + onForward: onForward, onDismiss: () { if (entry.mounted) entry.remove(); onDispose(); @@ -115,6 +117,7 @@ class _MessageActionsLayer extends StatefulWidget { final VoidCallback? onDelete; final VoidCallback? onEdit; final VoidCallback? onReply; + final VoidCallback? onForward; const _MessageActionsLayer({ required this.snapshot, @@ -129,6 +132,7 @@ class _MessageActionsLayer extends StatefulWidget { this.onDelete, this.onEdit, this.onReply, + this.onForward, }); @override @@ -297,7 +301,8 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> _Action(Symbols.edit, 'Изменить', _edit), if (widget.onReply != null) _Action(Symbols.reply, 'Ответить', _reply), - _Action(Symbols.forward, 'Переслать', () => _stub('Пересылка')), + if (widget.onForward != null) + _Action(Symbols.forward, 'Переслать', _forward), _Action( Symbols.delete, 'Удалить', @@ -379,10 +384,10 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> onReply?.call(); } - Future _stub(String name) async { - if (!mounted) return; - showCustomNotification(context, '$name — пока в разработке'); + Future _forward() async { + final onForward = widget.onForward; await _close(); + onForward?.call(); } @override