feat(messages): ответы на сообщения

This commit is contained in:
klockky
2026-06-22 17:25:17 +03:00
parent 4f11203064
commit 4aee2a4fb1
4 changed files with 472 additions and 17 deletions
+93
View File
@@ -256,6 +256,89 @@ class VideoUploadInfo {
});
}
class ReplyInfo {
final String? messageId;
final int senderId;
final String? text;
final int? time;
final List<MessageAttachment>? attachments;
const ReplyInfo({
this.messageId,
required this.senderId,
this.text,
this.time,
this.attachments,
});
static ReplyInfo? fromPayload(Map<String, dynamic>? 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<MessageAttachment>? attaches;
final raw = msg['attaches'];
if (raw is List && raw.isNotEmpty) {
attaches = raw
.whereType<Map>()
.map((a) => MessageAttachment.fromMap(Map<String, dynamic>.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<CachedMessage> _decodeRows(List<Map<String, dynamic>> rows) =>
rows.map(CachedMessage.fromDbRow).toList();
@@ -556,6 +641,7 @@ class MessagesModule {
String text, {
bool notify = true,
int? scheduledTime,
int? replyToMessageId,
}) async {
final message = <String, dynamic>{
'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,
+283 -9
View File
@@ -151,6 +151,9 @@ class _ChatScreenState extends State<ChatScreen>
int? _otherSeenTime;
int? _participantsCount;
final ValueNotifier<CachedMessage?> _replyTo = ValueNotifier(null);
final ValueNotifier<String?> _highlightMessageId = ValueNotifier(null);
bool _prankActive = false;
String? _prankBubbleId;
final GlobalKey _prankBubbleKey = GlobalKey();
@@ -599,6 +602,8 @@ class _ChatScreenState extends State<ChatScreen>
_scrollController.dispose();
_shimmerStartTimer?.cancel();
_shimmerController.dispose();
_replyTo.dispose();
_highlightMessageId.dispose();
super.dispose();
}
@@ -1511,6 +1516,26 @@ class _ChatScreenState extends State<ChatScreen>
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<String, dynamic>? 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<ChatScreen>
text: text,
time: now,
status: online ? 'sending' : 'pending',
payload: replyPayload,
);
_hasText.value = false;
@@ -1553,6 +1579,7 @@ class _ChatScreenState extends State<ChatScreen>
_myId,
widget.chatId,
text,
replyToMessageId: replyId,
);
final index = _messages.indexWhere((m) => m.id == tempId);
@@ -1565,6 +1592,7 @@ class _ChatScreenState extends State<ChatScreen>
text: text,
time: now,
status: 'sent',
payload: replyPayload,
);
_messages[index] = sent;
_bumpMessages();
@@ -1599,6 +1627,7 @@ class _ChatScreenState extends State<ChatScreen>
text: text,
time: now,
status: 'pending',
payload: replyPayload,
);
_messages[index] = queued;
_bumpMessages();
@@ -1862,6 +1891,46 @@ class _ChatScreenState extends State<ChatScreen>
});
}
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<String, GlobalKey> _messageKeys = {};
GlobalKey _keyForMessage(String messageId) =>
_messageKeys.putIfAbsent(messageId, () => GlobalKey());
List<Object> _buildCombinedItems() {
final key = Object.hash(_messagesRev.value, _messages.length);
final cached = _combinedItemsCache;
@@ -2358,6 +2427,7 @@ class _ChatScreenState extends State<ChatScreen>
overrideStatus: _effectiveStatus(message),
reactionsListenable: _reactionNotifierFor(message),
uploadProgress: _photoProgressFor(message),
onReplyTap: _jumpToMessage,
);
final pressable = _LongPressBubble(
@@ -2367,15 +2437,28 @@ class _ChatScreenState extends State<ChatScreen>
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<ChatScreen>
_bumpMessages();
}
},
child: pressable,
child: swipeable,
);
} else {
child = pressable;
child = swipeable;
}
final highlightable = ValueListenableBuilder<String?>(
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<ChatScreen>
}
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<ChatScreen>
),
],
),
),
],
),
);
}
Widget _buildReplyPreview(ColorScheme cs) {
return ValueListenableBuilder<CachedMessage?>(
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;
@@ -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<void> _reply() async {
final onReply = widget.onReply;
await _close();
onReply?.call();
}
Future<void> _stub(String name) async {
if (!mounted) return;
showCustomNotification(context, '$name — пока в разработке');
+84 -7
View File
@@ -86,6 +86,7 @@ class MessageBubble extends StatelessWidget {
final String? overrideStatus;
final ValueListenable<Map<String, dynamic>?>? reactionsListenable;
final ValueListenable<List<double>>? 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,