diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index d445fc5..25e7cfe 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -256,6 +256,89 @@ class VideoUploadInfo { }); } +class ReplyInfo { + final String? messageId; + final int senderId; + final String? text; + final int? time; + final List? attachments; + + const ReplyInfo({ + this.messageId, + required this.senderId, + this.text, + this.time, + this.attachments, + }); + + static ReplyInfo? fromPayload(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 msg = link['message']; + if (msg is! Map) { + final mid = link['messageId']; + if (mid == null) return null; + return ReplyInfo(messageId: mid.toString(), senderId: 0); + } + + List? attaches; + final raw = msg['attaches']; + if (raw is List && raw.isNotEmpty) { + attaches = raw + .whereType() + .map((a) => MessageAttachment.fromMap(Map.from(a))) + .toList(); + } + + final sender = msg['sender']; + return ReplyInfo( + messageId: msg['id']?.toString(), + senderId: sender is int + ? sender + : int.tryParse(sender?.toString() ?? '') ?? 0, + text: msg['text']?.toString(), + time: msg['time'] is int ? msg['time'] as int : null, + attachments: attaches, + ); + } + + String previewText() { + final t = text; + if (t != null && t.trim().isNotEmpty) return t; + final a = attachments; + if (a != null && a.isNotEmpty) { + switch (a.first.type) { + case AttachmentType.photo: + return 'Фото'; + case AttachmentType.video: + return 'Видео'; + case AttachmentType.audio: + return 'Голосовое сообщение'; + case AttachmentType.file: + return 'Файл'; + case AttachmentType.sticker: + return 'Стикер'; + case AttachmentType.contact: + return 'Контакт'; + case AttachmentType.location: + return 'Геолокация'; + case AttachmentType.poll: + return 'Опрос'; + case AttachmentType.call: + return 'Звонок'; + case AttachmentType.share: + return 'Ссылка'; + case AttachmentType.control: + return ''; + } + } + return ''; + } +} + class CachedMessage { final String id; final int accountId; @@ -367,6 +450,8 @@ class CachedMessage { bool get isDelayed => delayedTimeToFire != null; + ReplyInfo? get replyInfo => ReplyInfo.fromPayload(payload); + static List _decodeRows(List> rows) => rows.map(CachedMessage.fromDbRow).toList(); @@ -556,6 +641,7 @@ class MessagesModule { String text, { bool notify = true, int? scheduledTime, + int? replyToMessageId, }) async { final message = { 'text': text, @@ -563,6 +649,13 @@ class MessagesModule { 'elements': [], 'attaches': [], }; + if (replyToMessageId != null) { + message['link'] = { + 'type': 'REPLY', + 'chatId': chatId, + 'messageId': replyToMessageId, + }; + } if (scheduledTime != null) { message['delayedAttributes'] = { 'timeToFire': scheduledTime, diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index a463aa0..eed1f9f 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -151,6 +151,9 @@ class _ChatScreenState extends State int? _otherSeenTime; int? _participantsCount; + final ValueNotifier _replyTo = ValueNotifier(null); + final ValueNotifier _highlightMessageId = ValueNotifier(null); + bool _prankActive = false; String? _prankBubbleId; final GlobalKey _prankBubbleKey = GlobalKey(); @@ -599,6 +602,8 @@ class _ChatScreenState extends State _scrollController.dispose(); _shimmerStartTimer?.cancel(); _shimmerController.dispose(); + _replyTo.dispose(); + _highlightMessageId.dispose(); super.dispose(); } @@ -1511,6 +1516,26 @@ class _ChatScreenState extends State final now = DateTime.now().millisecondsSinceEpoch; final online = api.state == SessionState.online; + final reply = _replyTo.value; + final int? replyId = reply == null ? null : int.tryParse(reply.id); + Map? replyPayload; + if (reply != null && replyId != null) { + replyPayload = { + 'link': { + 'type': 'REPLY', + 'chatId': widget.chatId, + 'message': { + 'id': replyId, + 'sender': reply.senderId, + 'text': reply.text, + 'time': reply.time, + 'attaches': reply.payload?['attaches'] ?? const [], + }, + }, + }; + } + _replyTo.value = null; + final composed = CachedMessage( id: tempId, accountId: _myId, @@ -1519,6 +1544,7 @@ class _ChatScreenState extends State text: text, time: now, status: online ? 'sending' : 'pending', + payload: replyPayload, ); _hasText.value = false; @@ -1553,6 +1579,7 @@ class _ChatScreenState extends State _myId, widget.chatId, text, + replyToMessageId: replyId, ); final index = _messages.indexWhere((m) => m.id == tempId); @@ -1565,6 +1592,7 @@ class _ChatScreenState extends State text: text, time: now, status: 'sent', + payload: replyPayload, ); _messages[index] = sent; _bumpMessages(); @@ -1599,6 +1627,7 @@ class _ChatScreenState extends State text: text, time: now, status: 'pending', + payload: replyPayload, ); _messages[index] = queued; _bumpMessages(); @@ -1862,6 +1891,46 @@ class _ChatScreenState extends State }); } + void _startReply(CachedMessage message) { + _replyTo.value = message; + _messageFocusNode.requestFocus(); + } + + void _cancelReply() { + _replyTo.value = null; + } + + void _jumpToMessage(String messageId) { + final index = _messages.indexWhere((m) => m.id == messageId); + if (index == -1) { + showCustomNotification(context, 'Сообщение не загружено'); + return; + } + + final key = _keyForMessage(messageId); + final ctx = key.currentContext; + if (ctx != null) { + Scrollable.ensureVisible( + ctx, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + alignment: 0.4, + ); + } + + _highlightMessageId.value = messageId; + Future.delayed(const Duration(milliseconds: 1400), () { + if (_highlightMessageId.value == messageId) { + _highlightMessageId.value = null; + } + }); + } + + final Map _messageKeys = {}; + + GlobalKey _keyForMessage(String messageId) => + _messageKeys.putIfAbsent(messageId, () => GlobalKey()); + List _buildCombinedItems() { final key = Object.hash(_messagesRev.value, _messages.length); final cached = _combinedItemsCache; @@ -2358,6 +2427,7 @@ class _ChatScreenState extends State overrideStatus: _effectiveStatus(message), reactionsListenable: _reactionNotifierFor(message), uploadProgress: _photoProgressFor(message), + onReplyTap: _jumpToMessage, ); final pressable = _LongPressBubble( @@ -2367,15 +2437,28 @@ class _ChatScreenState extends State onEdit: _canEditMessage(message) ? () => _startEditMessage(message) : null, + onReply: message.isControl + ? null + : () => _startReply(message), child: bubble, ); + final isChannel = + (chat?.type ?? widget.chatType) == 'CHANNEL'; + final swipeable = (message.isControl || isChannel) + ? pressable + : _SwipeToReply( + isMe: isMe, + onReply: () => _startReply(message), + child: pressable, + ); + final Widget child; if (_deletingIds.contains(message.id)) { child = _DeletingMessageAnimation( key: ValueKey('del_${message.id}'), onComplete: () => _finalizeDelete(message.id), - child: IgnorePointer(child: pressable), + child: IgnorePointer(child: swipeable), ); } else if (message.id == _lastSentId) { child = _SentMessageAnimation( @@ -2386,15 +2469,32 @@ class _ChatScreenState extends State _bumpMessages(); } }, - child: pressable, + child: swipeable, ); } else { - child = pressable; + child = swipeable; } + final highlightable = ValueListenableBuilder( + valueListenable: _highlightMessageId, + builder: (context, hl, c) => AnimatedContainer( + duration: const Duration(milliseconds: 250), + color: hl == message.id + ? Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.12) + : Colors.transparent, + child: c, + ), + child: child, + ); + final builtItem = RepaintBoundary( key: ValueKey('msg_${message.id}'), - child: child, + child: KeyedSubtree( + key: _keyForMessage(message.id), + child: highlightable, + ), ); return message.id == _prankBubbleId ? KeyedSubtree(key: _prankBubbleKey, child: builtItem) @@ -2573,11 +2673,18 @@ class _ChatScreenState extends State } return SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildReplyPreview(cs), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12.0, + vertical: 8.0, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ Expanded( child: AnimatedContainer( duration: const Duration(milliseconds: 200), @@ -2763,10 +2870,74 @@ class _ChatScreenState extends State ), ], ), + ), + ], ), ); } + Widget _buildReplyPreview(ColorScheme cs) { + return ValueListenableBuilder( + valueListenable: _replyTo, + builder: (context, reply, _) { + if (reply == null) return const SizedBox.shrink(); + final name = reply.senderId == _myId + ? 'Вы' + : (ContactCache.get(reply.senderId) ?? 'Сообщение'); + final info = ReplyInfo( + senderId: reply.senderId, + text: reply.text, + attachments: reply.attachments, + ); + final preview = info.previewText(); + return Padding( + padding: const EdgeInsets.fromLTRB(16, 6, 8, 2), + child: Row( + children: [ + Icon(Symbols.reply, size: 20, color: cs.primary), + const SizedBox(width: 10), + Container(width: 2, height: 34, color: cs.primary), + const SizedBox(width: 10), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Ответ $name', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.primary, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + if (preview.isNotEmpty) + Text( + preview, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Symbols.close, size: 20), + color: cs.onSurfaceVariant, + onPressed: _cancelReply, + ), + ], + ), + ); + }, + ); + } + String _addOptimisticFileMessage(FileAttachment attachment) { final now = DateTime.now().millisecondsSinceEpoch; final tempId = _nextTempId(); @@ -3749,12 +3920,112 @@ IconData _iconForFilename(String? name) { } } +class _SwipeToReply extends StatefulWidget { + final Widget child; + final bool isMe; + final VoidCallback onReply; + + const _SwipeToReply({ + required this.child, + required this.isMe, + required this.onReply, + }); + + @override + State<_SwipeToReply> createState() => _SwipeToReplyState(); +} + +class _SwipeToReplyState extends State<_SwipeToReply> + with SingleTickerProviderStateMixin { + static const double _maxDrag = 72.0; + static const double _triggerThreshold = 56.0; + + late final AnimationController _springBack; + double _dragX = 0.0; + double _springFrom = 0.0; + bool _triggered = false; + + @override + void initState() { + super.initState(); + _springBack = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 200), + )..addListener(() { + final t = Curves.easeOut.transform(_springBack.value); + setState(() => _dragX = _springFrom * (1 - t)); + }); + } + + @override + void dispose() { + _springBack.dispose(); + super.dispose(); + } + + void _onDragUpdate(DragUpdateDetails d) { + if (_springBack.isAnimating) _springBack.stop(); + var next = _dragX + d.delta.dx; + if (next > 0) next = 0; + if (next < -_maxDrag) next = -_maxDrag; + final wasTriggered = _triggered; + _triggered = next <= -_triggerThreshold; + if (_triggered && !wasTriggered) Haptics.medium(); + setState(() => _dragX = next); + } + + void _onDragEnd(DragEndDetails d) { + if (_triggered) widget.onReply(); + _triggered = false; + _springFrom = _dragX; + _springBack.forward(from: 0); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final progress = (-_dragX / _triggerThreshold).clamp(0.0, 1.0); + return GestureDetector( + behavior: HitTestBehavior.opaque, + onHorizontalDragUpdate: _onDragUpdate, + onHorizontalDragEnd: _onDragEnd, + child: Stack( + alignment: Alignment.centerRight, + children: [ + Positioned( + right: 16, + child: Opacity( + opacity: progress, + child: Transform.scale( + scale: 0.6 + 0.4 * progress, + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: Icon(Symbols.reply, size: 20, color: cs.primary), + ), + ), + ), + ), + Transform.translate( + offset: Offset(_dragX, 0), + child: widget.child, + ), + ], + ), + ); + } +} + class _LongPressBubble extends StatefulWidget { final Widget child; final CachedMessage message; final bool isMe; final VoidCallback onDelete; final VoidCallback? onEdit; + final VoidCallback? onReply; const _LongPressBubble({ required this.child, @@ -3762,6 +4033,7 @@ class _LongPressBubble extends StatefulWidget { required this.isMe, required this.onDelete, this.onEdit, + this.onReply, }); @override @@ -3814,6 +4086,7 @@ class _LongPressBubbleState extends State<_LongPressBubble> { style: AppMessageActionsStyle.current.value, onDelete: widget.onDelete, onEdit: widget.onEdit, + onReply: widget.onReply, onDispose: () { if (identical(_controller, controller)) { _controller = null; @@ -3847,6 +4120,7 @@ class _LongPressBubbleState extends State<_LongPressBubble> { interaction: MessageActionsInteraction.click, onDelete: widget.onDelete, onEdit: widget.onEdit, + onReply: widget.onReply, onDispose: () { if (identical(_controller, controller)) { _controller = null; diff --git a/lib/frontend/widgets/message_actions_overlay.dart b/lib/frontend/widgets/message_actions_overlay.dart index 7bef9ec..034b584 100644 --- a/lib/frontend/widgets/message_actions_overlay.dart +++ b/lib/frontend/widgets/message_actions_overlay.dart @@ -75,6 +75,7 @@ void showMessageActions({ required VoidCallback onDispose, VoidCallback? onDelete, VoidCallback? onEdit, + VoidCallback? onReply, MessageActionsInteraction interaction = MessageActionsInteraction.dragAndRelease, }) { final overlay = Overlay.of(context, rootOverlay: true); @@ -91,6 +92,7 @@ void showMessageActions({ interaction: interaction, onDelete: onDelete, onEdit: onEdit, + onReply: onReply, onDismiss: () { if (entry.mounted) entry.remove(); onDispose(); @@ -112,6 +114,7 @@ class _MessageActionsLayer extends StatefulWidget { final VoidCallback onDismiss; final VoidCallback? onDelete; final VoidCallback? onEdit; + final VoidCallback? onReply; const _MessageActionsLayer({ required this.snapshot, @@ -125,6 +128,7 @@ class _MessageActionsLayer extends StatefulWidget { required this.onDismiss, this.onDelete, this.onEdit, + this.onReply, }); @override @@ -291,7 +295,8 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> if (hasText) _Action(Symbols.content_copy, 'Копировать', _copy), if (widget.isMe && widget.onEdit != null) _Action(Symbols.edit, 'Изменить', _edit), - _Action(Symbols.reply, 'Ответить', () => _stub('Ответ')), + if (widget.onReply != null) + _Action(Symbols.reply, 'Ответить', _reply), _Action(Symbols.forward, 'Переслать', () => _stub('Пересылка')), _Action( Symbols.delete, @@ -368,6 +373,12 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> onEdit?.call(); } + Future _reply() async { + final onReply = widget.onReply; + await _close(); + onReply?.call(); + } + Future _stub(String name) async { if (!mounted) return; showCustomNotification(context, '$name — пока в разработке'); diff --git a/lib/frontend/widgets/message_bubble.dart b/lib/frontend/widgets/message_bubble.dart index 1584335..50850bd 100644 --- a/lib/frontend/widgets/message_bubble.dart +++ b/lib/frontend/widgets/message_bubble.dart @@ -86,6 +86,7 @@ class MessageBubble extends StatelessWidget { final String? overrideStatus; final ValueListenable?>? reactionsListenable; final ValueListenable>? uploadProgress; + final void Function(String messageId)? onReplyTap; const MessageBubble({ super.key, @@ -98,6 +99,7 @@ class MessageBubble extends StatelessWidget { this.overrideStatus, this.reactionsListenable, this.uploadProgress, + this.onReplyTap, }); bool _computeHasPhotoWithCaption() { @@ -370,6 +372,20 @@ class MessageBubble extends StatelessWidget { final reactionsUnder = _reactionsUnderBubble(contentType); final reactionsInside = contentType != MessageType.text && !reactionsUnder; + final reply = message.replyInfo; + Widget withReply(Widget content) { + if (reply == null) return content; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildReplyQuote(context, cs, textColor, reply), + const SizedBox(height: 4), + content, + ], + ); + } + return GestureDetector( onTap: Haptics.tap, child: Padding( @@ -421,13 +437,15 @@ class MessageBubble extends StatelessWidget { padding: padding, child: child, ), - child: reactionsInside - ? Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [bubbleContent, _reactionsBar(cs)], - ) - : bubbleContent, + child: withReply( + reactionsInside + ? Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [bubbleContent, _reactionsBar(cs)], + ) + : bubbleContent, + ), ), if (reactionsUnder) _reactionsBar(cs), ], @@ -703,6 +721,65 @@ class MessageBubble extends StatelessWidget { ); } + Widget _buildReplyQuote( + BuildContext context, + ColorScheme cs, + Color textColor, + ReplyInfo reply, + ) { + final accent = isMe ? cs.onPrimaryContainer : cs.primary; + final name = reply.senderId == myId + ? 'Вы' + : (ContactCache.get(reply.senderId) ?? 'Сообщение'); + final preview = reply.previewText(); + + final quote = Container( + padding: const EdgeInsets.fromLTRB(8, 3, 8, 3), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + color: accent.withValues(alpha: 0.10), + border: Border(left: BorderSide(color: accent, width: 3)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: accent, + fontSize: 12.5, + fontWeight: FontWeight.w600, + ), + ), + if (preview.isNotEmpty) + Text( + preview, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: textColor.withValues(alpha: 0.85), + fontSize: 13, + ), + ), + ], + ), + ); + + final mid = reply.messageId; + final cb = onReplyTap; + if (mid != null && mid != '0' && cb != null) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => cb(mid), + child: quote, + ); + } + return quote; + } + Widget _buildForwardedInlineText( _BubbleCtx ctx, ForwardedMessageAttachment forwarded,