diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 05135f8..e381a5d 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -75,6 +75,7 @@ class ContactCache { } return _nameCache[id]; } + static String? getAvatar(int id) => _avatarCache[id]; static Set? getOptions(int id) => _optionsCache[id]; static bool isOfficial(int id) => @@ -764,6 +765,7 @@ class MessagesModule { bool notify = true, int? scheduledTime, int? replyToMessageId, + int? replySourceChatId, List> elements = const [], }) async { final message = { @@ -775,7 +777,7 @@ class MessagesModule { if (replyToMessageId != null) { message['link'] = { 'type': 'REPLY', - 'chatId': chatId, + 'chatId': replySourceChatId ?? chatId, 'messageId': replyToMessageId, }; } @@ -790,6 +792,23 @@ class MessagesModule { return _sendAndExtractMessageId(payload, 'Ошибка отправки'); } + Future sendControlMessage( + int chatId, + Map control, { + bool notify = true, + }) { + final payload = { + 'chatId': chatId, + 'message': { + 'cid': DateTime.now().millisecondsSinceEpoch * -1, + 'text': '', + 'attaches': [control], + }, + 'notify': notify, + }; + return _api.sendRequest(Opcode.msgSend, payload); + } + Future _sendAndExtractMessageId( Map payload, String defaultError, @@ -1190,7 +1209,11 @@ class MessagesModule { ) async { final accountId = await TokenStorage.getActiveAccountId(); if (accountId == null) return; - final existing = await AppDatabase.loadMessage(accountId, chatId, messageId); + final existing = await AppDatabase.loadMessage( + accountId, + chatId, + messageId, + ); if (existing == null) return; Map payloadMap; diff --git a/lib/backend/modules/outbox.dart b/lib/backend/modules/outbox.dart index ea2712e..4f451e9 100644 --- a/lib/backend/modules/outbox.dart +++ b/lib/backend/modules/outbox.dart @@ -47,6 +47,7 @@ class OutboxService { final payload = pending.payload; final replyToMessageId = _replyIdFromPayload(payload); + final replySourceChatId = _replySourceChatIdFromPayload(payload); final elements = _elementsFromPayload(payload); try { @@ -55,6 +56,7 @@ class OutboxService { pending.chatId, text, replyToMessageId: replyToMessageId, + replySourceChatId: replySourceChatId, elements: elements, ); final sent = CachedMessage( @@ -114,6 +116,17 @@ class OutboxService { return null; } + int? _replySourceChatIdFromPayload(Map? payload) { + if (payload == null) return null; + final link = payload['link']; + if (link is! Map) return null; + if ((link['type'] as String?)?.toUpperCase() != 'REPLY') return null; + final chatId = link['chatId']; + if (chatId is int) return chatId; + if (chatId != null) return int.tryParse(chatId.toString()); + return null; + } + List> _elementsFromPayload( Map? payload, ) { diff --git a/lib/frontend/commands/command_registry.dart b/lib/frontend/commands/command_registry.dart index ca54a7d..612b5d0 100644 --- a/lib/frontend/commands/command_registry.dart +++ b/lib/frontend/commands/command_registry.dart @@ -2,6 +2,7 @@ import 'anim_command.dart'; import 'crush_command.dart'; import 'epsh_files_command.dart'; import 'info_command.dart'; +import 'send_control_command.dart'; import 'slash_command.dart'; import 'watching_command.dart'; @@ -21,6 +22,11 @@ const List kSlashCommands = [ run: runCrush, hidden: true, ), + SlashCommand( + '/sendControlPayload', + 'CONTROL в текущий чат: [event | json]', + run: runSendControl, + ), ]; SlashCommand? findSlashCommand(String text) { diff --git a/lib/frontend/commands/send_control_command.dart b/lib/frontend/commands/send_control_command.dart new file mode 100644 index 0000000..45a36d0 --- /dev/null +++ b/lib/frontend/commands/send_control_command.dart @@ -0,0 +1,47 @@ +import 'dart:convert'; + +import '../../core/protocol/packet.dart'; +import 'slash_command.dart'; + +Future runSendControl(CommandContext ctx) async { + final rest = ctx.args.trim(); + + Map control; + if (rest.startsWith('{')) { + try { + final decoded = jsonDecode(rest); + if (decoded is! Map) { + ctx.notify('JSON должен быть объектом'); + return; + } + control = Map.from(decoded); + } catch (e) { + ctx.notify('Кривой JSON: $e'); + return; + } + } else { + control = {'event': rest.isEmpty ? 'test' : rest}; + } + control['_type'] = 'CONTROL'; + + try { + final Packet packet = await ctx.messages.sendControlMessage( + ctx.chatId, + control, + ); + if (packet.isOk) { + final data = packet.payload; + final msg = data is Map ? data['message'] : null; + final id = msg is Map ? msg['id'] : null; + ctx.notify('Принято ✅${id != null ? ' · msgId=$id' : ''}'); + } else { + final p = packet.payload; + final err = p is Map + ? (p['localizedMessage'] ?? p['message'] ?? p).toString() + : p.toString(); + ctx.notify('Отклонено ❌: $err'); + } + } catch (e) { + ctx.notify('Ошибка: $e'); + } +} diff --git a/lib/frontend/screens/chats/chat/view/composer_input.dart b/lib/frontend/screens/chats/chat/view/composer_input.dart index b6bda97..7603a5d 100644 --- a/lib/frontend/screens/chats/chat/view/composer_input.dart +++ b/lib/frontend/screens/chats/chat/view/composer_input.dart @@ -42,6 +42,7 @@ class ComposerInputBar extends StatelessWidget { required this.onOpenAttachScheduled, required this.onSendHistory, required this.onCancelReply, + this.onPickReplyChat, required this.formatElapsed, required this.contextMenuBuilder, required this.isMuted, @@ -77,6 +78,7 @@ class ComposerInputBar extends StatelessWidget { final VoidCallback onOpenAttachScheduled; final Future Function(FileHistoryEntry entry) onSendHistory; final VoidCallback onCancelReply; + final VoidCallback? onPickReplyChat; final String Function(int ms) formatElapsed; final Widget Function(BuildContext, EditableTextState) contextMenuBuilder; final bool isMuted; @@ -380,7 +382,9 @@ class ComposerInputBar extends StatelessWidget { valueListenable: note.videoNoteMode, builder: (context, videoMode, _) { final sendMode = - hasText || locked || forceSend; + hasText || + locked || + forceSend; final pill = _actionSurface( color: _flat ? Colors.transparent @@ -553,6 +557,16 @@ class ComposerInputBar extends StatelessWidget { ); } + Widget _replyIconButton(ColorScheme cs) { + final icon = Icon(Symbols.reply, size: 20, color: cs.primary); + if (onPickReplyChat == null) return icon; + return InkWell( + onTap: onPickReplyChat, + customBorder: const CircleBorder(), + child: Padding(padding: const EdgeInsets.all(4), child: icon), + ); + } + Widget _replyPreview(ColorScheme cs) { return ValueListenableBuilder( valueListenable: replyTo, @@ -571,7 +585,7 @@ class ComposerInputBar extends StatelessWidget { padding: const EdgeInsets.fromLTRB(16, 6, 8, 2), child: Row( children: [ - Icon(Symbols.reply, size: 20, color: cs.primary), + _replyIconButton(cs), const SizedBox(width: 10), Container(width: 2, height: 34, color: cs.primary), const SizedBox(width: 10), diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index bb3b873..9f55e54 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -194,6 +194,13 @@ class ForwardRequest { const ForwardRequest({required this.sourceChatId, required this.optimistic}); } +class ReplyRequest { + final int sourceChatId; + final CachedMessage message; + + const ReplyRequest({required this.sourceChatId, required this.message}); +} + class ChatScreen extends StatefulWidget { final int chatId; final String name; @@ -202,6 +209,7 @@ class ChatScreen extends StatefulWidget { final bool embedded; final VoidCallback? onClose; final ForwardRequest? forwardRequest; + final ReplyRequest? replyRequest; final String? initialMessageId; final int? initialMessageTime; final String? commentPostId; @@ -216,6 +224,7 @@ class ChatScreen extends StatefulWidget { this.embedded = false, this.onClose, this.forwardRequest, + this.replyRequest, this.initialMessageId, this.initialMessageTime, this.commentPostId, @@ -420,6 +429,8 @@ class _ChatScreenState extends State int? _participantsCount; final ValueNotifier _replyTo = ValueNotifier(null); + static const bool _crossChatReplySupported = false; + int? _replySourceChatId; final ValueNotifier _highlightMessageId = ValueNotifier(null); Timer? _highlightTimer; final ValueNotifier _jumpCacheExtent = ValueNotifier(null); @@ -605,6 +616,13 @@ class _ChatScreenState extends State chatId: widget.chatId, isMounted: () => mounted, ); + final incomingReply = widget.replyRequest; + if (incomingReply != null) { + _replyTo.value = incomingReply.message; + _replySourceChatId = incomingReply.sourceChatId == widget.chatId + ? null + : incomingReply.sourceChatId; + } _pushSub = api.pushStream .where( (p) => @@ -2363,35 +2381,38 @@ class _ChatScreenState extends State bottomSafe: _stickers.anim.value == 0, chatType: _commentsMode ? 'CHAT' : widget.chatType, chrome: _effectiveChrome, - style: AppComposerStyle.current.value, - background: AppComposerBackground.current.value, - backdropKey: _pillBackdrop, - attachAnim: _attachAnim, - replyTo: _replyTo, - myId: _myId, - hasText: _hasText, - uploadStatus: _uploadStatus, - messageController: _messageController, - messageFocusNode: _messageFocusNode, - voiceRec: _voiceRec, - note: _note, - onToggleStickerPanel: _toggleStickerPanel, - onSendText: _sendMessage, - onScheduleMessage: _scheduleMessage, - onOpenAttach: _openAttachmentSheet, - onOpenAttachScheduled: _openAttachmentSheetScheduled, - onSendHistory: _sendHistoryFile, - onCancelReply: _cancelReply, - formatElapsed: formatVoiceElapsed, - contextMenuBuilder: (ctx, state) => - _formatContextMenu(_messageController, ctx, state), - isMuted: chat?.isMuted ?? false, - onToggleMute: _toggleChatMute, - channelSubscribed: !_previewChat, - channelSubscribing: _subscribing, - onSubscribe: _subscribeChannel, - showStickerButton: !_commentsMode, - showAttachButton: !_commentsMode, + style: AppComposerStyle.current.value, + background: AppComposerBackground.current.value, + backdropKey: _pillBackdrop, + attachAnim: _attachAnim, + replyTo: _replyTo, + myId: _myId, + hasText: _hasText, + uploadStatus: _uploadStatus, + messageController: _messageController, + messageFocusNode: _messageFocusNode, + voiceRec: _voiceRec, + note: _note, + onToggleStickerPanel: _toggleStickerPanel, + onSendText: _sendMessage, + onScheduleMessage: _scheduleMessage, + onOpenAttach: _openAttachmentSheet, + onOpenAttachScheduled: _openAttachmentSheetScheduled, + onSendHistory: _sendHistoryFile, + onCancelReply: _cancelReply, + onPickReplyChat: _commentsMode || !_crossChatReplySupported + ? null + : () => unawaited(_pickReplyChat()), + formatElapsed: formatVoiceElapsed, + contextMenuBuilder: (ctx, state) => + _formatContextMenu(_messageController, ctx, state), + isMuted: chat?.isMuted ?? false, + onToggleMute: _toggleChatMute, + channelSubscribed: !_previewChat, + channelSubscribing: _subscribing, + onSubscribe: _subscribeChannel, + showStickerButton: !_commentsMode, + showAttachButton: !_commentsMode, forceSend: _commentsMode, hintText: _commentsMode ? 'Комментарий' : 'Message', ), @@ -3452,12 +3473,13 @@ class _ChatScreenState extends State final reply = _replyTo.value; final int? replyId = reply == null ? null : int.tryParse(reply.id); + final int? replySourceChatId = replyId == null ? null : _replySourceChatId; Map? replyPayload; if (reply != null && replyId != null) { replyPayload = { 'link': { 'type': 'REPLY', - 'chatId': widget.chatId, + 'chatId': replySourceChatId ?? widget.chatId, 'message': { 'id': replyId, 'sender': reply.senderId, @@ -3469,6 +3491,7 @@ class _ChatScreenState extends State }; } _replyTo.value = null; + _replySourceChatId = null; final elements = _trimmedElements(content.elements, rawText, text); final Map? composedPayload = @@ -3535,6 +3558,7 @@ class _ChatScreenState extends State widget.chatId, text, replyToMessageId: replyId, + replySourceChatId: replySourceChatId, elements: elements, ); @@ -3579,6 +3603,20 @@ class _ChatScreenState extends State ); } } catch (e) { + if (replySourceChatId != null) { + logger.w('Cross-chat reply rejected: $e'); + final index = _messages.indexWhere((m) => m.id == tempId); + if (index != -1 && mounted) { + _messages.removeAt(index); + _bumpMessages(); + } + unawaited(AppDatabase.deleteMessage(_myId, widget.chatId, tempId)); + if (mounted) { + Haptics.error(); + showCustomNotification(context, e.toString()); + } + return; + } final index = _messages.indexWhere((m) => m.id == tempId); if (index != -1 && mounted) { final queued = CachedMessage( @@ -4046,11 +4084,45 @@ class _ChatScreenState extends State void _startReply(CachedMessage message) { _replyTo.value = message; + _replySourceChatId = null; _messageFocusNode.requestFocus(); } void _cancelReply() { _replyTo.value = null; + _replySourceChatId = null; + } + + Future _pickReplyChat() async { + final reply = _replyTo.value; + if (reply == null) return; + if (reply.id.startsWith('temp_')) { + showCustomNotification(context, 'Сообщение ещё не отправлено'); + return; + } + + final sourceChatId = _replySourceChatId ?? widget.chatId; + final target = await openForwardScreen(context: context); + if (target == null || !mounted) return; + + if (target.chatId == widget.chatId) { + _replySourceChatId = sourceChatId == widget.chatId ? null : sourceChatId; + _messageFocusNode.requestFocus(); + return; + } + + await chats.ensureChatCached(api, _myId, target.chatId); + if (!mounted) return; + pushSwipeable( + context, + (_) => ChatScreen( + chatId: target.chatId, + name: target.name, + imageUrl: target.imageUrl, + chatType: target.chatType, + replyRequest: ReplyRequest(sourceChatId: sourceChatId, message: reply), + ), + ); } void _openSenderProfile(int senderId) {