feat(messages): ответы на сообщения
This commit is contained in:
@@ -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 {
|
class CachedMessage {
|
||||||
final String id;
|
final String id;
|
||||||
final int accountId;
|
final int accountId;
|
||||||
@@ -367,6 +450,8 @@ class CachedMessage {
|
|||||||
|
|
||||||
bool get isDelayed => delayedTimeToFire != null;
|
bool get isDelayed => delayedTimeToFire != null;
|
||||||
|
|
||||||
|
ReplyInfo? get replyInfo => ReplyInfo.fromPayload(payload);
|
||||||
|
|
||||||
static List<CachedMessage> _decodeRows(List<Map<String, dynamic>> rows) =>
|
static List<CachedMessage> _decodeRows(List<Map<String, dynamic>> rows) =>
|
||||||
rows.map(CachedMessage.fromDbRow).toList();
|
rows.map(CachedMessage.fromDbRow).toList();
|
||||||
|
|
||||||
@@ -556,6 +641,7 @@ class MessagesModule {
|
|||||||
String text, {
|
String text, {
|
||||||
bool notify = true,
|
bool notify = true,
|
||||||
int? scheduledTime,
|
int? scheduledTime,
|
||||||
|
int? replyToMessageId,
|
||||||
}) async {
|
}) async {
|
||||||
final message = <String, dynamic>{
|
final message = <String, dynamic>{
|
||||||
'text': text,
|
'text': text,
|
||||||
@@ -563,6 +649,13 @@ class MessagesModule {
|
|||||||
'elements': [],
|
'elements': [],
|
||||||
'attaches': [],
|
'attaches': [],
|
||||||
};
|
};
|
||||||
|
if (replyToMessageId != null) {
|
||||||
|
message['link'] = {
|
||||||
|
'type': 'REPLY',
|
||||||
|
'chatId': chatId,
|
||||||
|
'messageId': replyToMessageId,
|
||||||
|
};
|
||||||
|
}
|
||||||
if (scheduledTime != null) {
|
if (scheduledTime != null) {
|
||||||
message['delayedAttributes'] = {
|
message['delayedAttributes'] = {
|
||||||
'timeToFire': scheduledTime,
|
'timeToFire': scheduledTime,
|
||||||
|
|||||||
@@ -151,6 +151,9 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
int? _otherSeenTime;
|
int? _otherSeenTime;
|
||||||
int? _participantsCount;
|
int? _participantsCount;
|
||||||
|
|
||||||
|
final ValueNotifier<CachedMessage?> _replyTo = ValueNotifier(null);
|
||||||
|
final ValueNotifier<String?> _highlightMessageId = ValueNotifier(null);
|
||||||
|
|
||||||
bool _prankActive = false;
|
bool _prankActive = false;
|
||||||
String? _prankBubbleId;
|
String? _prankBubbleId;
|
||||||
final GlobalKey _prankBubbleKey = GlobalKey();
|
final GlobalKey _prankBubbleKey = GlobalKey();
|
||||||
@@ -599,6 +602,8 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
_shimmerStartTimer?.cancel();
|
_shimmerStartTimer?.cancel();
|
||||||
_shimmerController.dispose();
|
_shimmerController.dispose();
|
||||||
|
_replyTo.dispose();
|
||||||
|
_highlightMessageId.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1511,6 +1516,26 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
final now = DateTime.now().millisecondsSinceEpoch;
|
final now = DateTime.now().millisecondsSinceEpoch;
|
||||||
final online = api.state == SessionState.online;
|
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(
|
final composed = CachedMessage(
|
||||||
id: tempId,
|
id: tempId,
|
||||||
accountId: _myId,
|
accountId: _myId,
|
||||||
@@ -1519,6 +1544,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
text: text,
|
text: text,
|
||||||
time: now,
|
time: now,
|
||||||
status: online ? 'sending' : 'pending',
|
status: online ? 'sending' : 'pending',
|
||||||
|
payload: replyPayload,
|
||||||
);
|
);
|
||||||
|
|
||||||
_hasText.value = false;
|
_hasText.value = false;
|
||||||
@@ -1553,6 +1579,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
_myId,
|
_myId,
|
||||||
widget.chatId,
|
widget.chatId,
|
||||||
text,
|
text,
|
||||||
|
replyToMessageId: replyId,
|
||||||
);
|
);
|
||||||
|
|
||||||
final index = _messages.indexWhere((m) => m.id == tempId);
|
final index = _messages.indexWhere((m) => m.id == tempId);
|
||||||
@@ -1565,6 +1592,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
text: text,
|
text: text,
|
||||||
time: now,
|
time: now,
|
||||||
status: 'sent',
|
status: 'sent',
|
||||||
|
payload: replyPayload,
|
||||||
);
|
);
|
||||||
_messages[index] = sent;
|
_messages[index] = sent;
|
||||||
_bumpMessages();
|
_bumpMessages();
|
||||||
@@ -1599,6 +1627,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
text: text,
|
text: text,
|
||||||
time: now,
|
time: now,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
|
payload: replyPayload,
|
||||||
);
|
);
|
||||||
_messages[index] = queued;
|
_messages[index] = queued;
|
||||||
_bumpMessages();
|
_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() {
|
List<Object> _buildCombinedItems() {
|
||||||
final key = Object.hash(_messagesRev.value, _messages.length);
|
final key = Object.hash(_messagesRev.value, _messages.length);
|
||||||
final cached = _combinedItemsCache;
|
final cached = _combinedItemsCache;
|
||||||
@@ -2358,6 +2427,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
overrideStatus: _effectiveStatus(message),
|
overrideStatus: _effectiveStatus(message),
|
||||||
reactionsListenable: _reactionNotifierFor(message),
|
reactionsListenable: _reactionNotifierFor(message),
|
||||||
uploadProgress: _photoProgressFor(message),
|
uploadProgress: _photoProgressFor(message),
|
||||||
|
onReplyTap: _jumpToMessage,
|
||||||
);
|
);
|
||||||
|
|
||||||
final pressable = _LongPressBubble(
|
final pressable = _LongPressBubble(
|
||||||
@@ -2367,15 +2437,28 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
onEdit: _canEditMessage(message)
|
onEdit: _canEditMessage(message)
|
||||||
? () => _startEditMessage(message)
|
? () => _startEditMessage(message)
|
||||||
: null,
|
: null,
|
||||||
|
onReply: message.isControl
|
||||||
|
? null
|
||||||
|
: () => _startReply(message),
|
||||||
child: bubble,
|
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;
|
final Widget child;
|
||||||
if (_deletingIds.contains(message.id)) {
|
if (_deletingIds.contains(message.id)) {
|
||||||
child = _DeletingMessageAnimation(
|
child = _DeletingMessageAnimation(
|
||||||
key: ValueKey('del_${message.id}'),
|
key: ValueKey('del_${message.id}'),
|
||||||
onComplete: () => _finalizeDelete(message.id),
|
onComplete: () => _finalizeDelete(message.id),
|
||||||
child: IgnorePointer(child: pressable),
|
child: IgnorePointer(child: swipeable),
|
||||||
);
|
);
|
||||||
} else if (message.id == _lastSentId) {
|
} else if (message.id == _lastSentId) {
|
||||||
child = _SentMessageAnimation(
|
child = _SentMessageAnimation(
|
||||||
@@ -2386,15 +2469,32 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
_bumpMessages();
|
_bumpMessages();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: pressable,
|
child: swipeable,
|
||||||
);
|
);
|
||||||
} else {
|
} 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(
|
final builtItem = RepaintBoundary(
|
||||||
key: ValueKey('msg_${message.id}'),
|
key: ValueKey('msg_${message.id}'),
|
||||||
child: child,
|
child: KeyedSubtree(
|
||||||
|
key: _keyForMessage(message.id),
|
||||||
|
child: highlightable,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return message.id == _prankBubbleId
|
return message.id == _prankBubbleId
|
||||||
? KeyedSubtree(key: _prankBubbleKey, child: builtItem)
|
? KeyedSubtree(key: _prankBubbleKey, child: builtItem)
|
||||||
@@ -2573,11 +2673,18 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: Padding(
|
child: Column(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
|
mainAxisSize: MainAxisSize.min,
|
||||||
child: Row(
|
children: [
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
_buildReplyPreview(cs),
|
||||||
children: [
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12.0,
|
||||||
|
vertical: 8.0,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 200),
|
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) {
|
String _addOptimisticFileMessage(FileAttachment attachment) {
|
||||||
final now = DateTime.now().millisecondsSinceEpoch;
|
final now = DateTime.now().millisecondsSinceEpoch;
|
||||||
final tempId = _nextTempId();
|
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 {
|
class _LongPressBubble extends StatefulWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
final CachedMessage message;
|
final CachedMessage message;
|
||||||
final bool isMe;
|
final bool isMe;
|
||||||
final VoidCallback onDelete;
|
final VoidCallback onDelete;
|
||||||
final VoidCallback? onEdit;
|
final VoidCallback? onEdit;
|
||||||
|
final VoidCallback? onReply;
|
||||||
|
|
||||||
const _LongPressBubble({
|
const _LongPressBubble({
|
||||||
required this.child,
|
required this.child,
|
||||||
@@ -3762,6 +4033,7 @@ class _LongPressBubble extends StatefulWidget {
|
|||||||
required this.isMe,
|
required this.isMe,
|
||||||
required this.onDelete,
|
required this.onDelete,
|
||||||
this.onEdit,
|
this.onEdit,
|
||||||
|
this.onReply,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -3814,6 +4086,7 @@ class _LongPressBubbleState extends State<_LongPressBubble> {
|
|||||||
style: AppMessageActionsStyle.current.value,
|
style: AppMessageActionsStyle.current.value,
|
||||||
onDelete: widget.onDelete,
|
onDelete: widget.onDelete,
|
||||||
onEdit: widget.onEdit,
|
onEdit: widget.onEdit,
|
||||||
|
onReply: widget.onReply,
|
||||||
onDispose: () {
|
onDispose: () {
|
||||||
if (identical(_controller, controller)) {
|
if (identical(_controller, controller)) {
|
||||||
_controller = null;
|
_controller = null;
|
||||||
@@ -3847,6 +4120,7 @@ class _LongPressBubbleState extends State<_LongPressBubble> {
|
|||||||
interaction: MessageActionsInteraction.click,
|
interaction: MessageActionsInteraction.click,
|
||||||
onDelete: widget.onDelete,
|
onDelete: widget.onDelete,
|
||||||
onEdit: widget.onEdit,
|
onEdit: widget.onEdit,
|
||||||
|
onReply: widget.onReply,
|
||||||
onDispose: () {
|
onDispose: () {
|
||||||
if (identical(_controller, controller)) {
|
if (identical(_controller, controller)) {
|
||||||
_controller = null;
|
_controller = null;
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ void showMessageActions({
|
|||||||
required VoidCallback onDispose,
|
required VoidCallback onDispose,
|
||||||
VoidCallback? onDelete,
|
VoidCallback? onDelete,
|
||||||
VoidCallback? onEdit,
|
VoidCallback? onEdit,
|
||||||
|
VoidCallback? onReply,
|
||||||
MessageActionsInteraction interaction = MessageActionsInteraction.dragAndRelease,
|
MessageActionsInteraction interaction = MessageActionsInteraction.dragAndRelease,
|
||||||
}) {
|
}) {
|
||||||
final overlay = Overlay.of(context, rootOverlay: true);
|
final overlay = Overlay.of(context, rootOverlay: true);
|
||||||
@@ -91,6 +92,7 @@ void showMessageActions({
|
|||||||
interaction: interaction,
|
interaction: interaction,
|
||||||
onDelete: onDelete,
|
onDelete: onDelete,
|
||||||
onEdit: onEdit,
|
onEdit: onEdit,
|
||||||
|
onReply: onReply,
|
||||||
onDismiss: () {
|
onDismiss: () {
|
||||||
if (entry.mounted) entry.remove();
|
if (entry.mounted) entry.remove();
|
||||||
onDispose();
|
onDispose();
|
||||||
@@ -112,6 +114,7 @@ class _MessageActionsLayer extends StatefulWidget {
|
|||||||
final VoidCallback onDismiss;
|
final VoidCallback onDismiss;
|
||||||
final VoidCallback? onDelete;
|
final VoidCallback? onDelete;
|
||||||
final VoidCallback? onEdit;
|
final VoidCallback? onEdit;
|
||||||
|
final VoidCallback? onReply;
|
||||||
|
|
||||||
const _MessageActionsLayer({
|
const _MessageActionsLayer({
|
||||||
required this.snapshot,
|
required this.snapshot,
|
||||||
@@ -125,6 +128,7 @@ class _MessageActionsLayer extends StatefulWidget {
|
|||||||
required this.onDismiss,
|
required this.onDismiss,
|
||||||
this.onDelete,
|
this.onDelete,
|
||||||
this.onEdit,
|
this.onEdit,
|
||||||
|
this.onReply,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -291,7 +295,8 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
|
|||||||
if (hasText) _Action(Symbols.content_copy, 'Копировать', _copy),
|
if (hasText) _Action(Symbols.content_copy, 'Копировать', _copy),
|
||||||
if (widget.isMe && widget.onEdit != null)
|
if (widget.isMe && widget.onEdit != null)
|
||||||
_Action(Symbols.edit, 'Изменить', _edit),
|
_Action(Symbols.edit, 'Изменить', _edit),
|
||||||
_Action(Symbols.reply, 'Ответить', () => _stub('Ответ')),
|
if (widget.onReply != null)
|
||||||
|
_Action(Symbols.reply, 'Ответить', _reply),
|
||||||
_Action(Symbols.forward, 'Переслать', () => _stub('Пересылка')),
|
_Action(Symbols.forward, 'Переслать', () => _stub('Пересылка')),
|
||||||
_Action(
|
_Action(
|
||||||
Symbols.delete,
|
Symbols.delete,
|
||||||
@@ -368,6 +373,12 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
|
|||||||
onEdit?.call();
|
onEdit?.call();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _reply() async {
|
||||||
|
final onReply = widget.onReply;
|
||||||
|
await _close();
|
||||||
|
onReply?.call();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _stub(String name) async {
|
Future<void> _stub(String name) async {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
showCustomNotification(context, '$name — пока в разработке');
|
showCustomNotification(context, '$name — пока в разработке');
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ class MessageBubble extends StatelessWidget {
|
|||||||
final String? overrideStatus;
|
final String? overrideStatus;
|
||||||
final ValueListenable<Map<String, dynamic>?>? reactionsListenable;
|
final ValueListenable<Map<String, dynamic>?>? reactionsListenable;
|
||||||
final ValueListenable<List<double>>? uploadProgress;
|
final ValueListenable<List<double>>? uploadProgress;
|
||||||
|
final void Function(String messageId)? onReplyTap;
|
||||||
|
|
||||||
const MessageBubble({
|
const MessageBubble({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -98,6 +99,7 @@ class MessageBubble extends StatelessWidget {
|
|||||||
this.overrideStatus,
|
this.overrideStatus,
|
||||||
this.reactionsListenable,
|
this.reactionsListenable,
|
||||||
this.uploadProgress,
|
this.uploadProgress,
|
||||||
|
this.onReplyTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
bool _computeHasPhotoWithCaption() {
|
bool _computeHasPhotoWithCaption() {
|
||||||
@@ -370,6 +372,20 @@ class MessageBubble extends StatelessWidget {
|
|||||||
final reactionsUnder = _reactionsUnderBubble(contentType);
|
final reactionsUnder = _reactionsUnderBubble(contentType);
|
||||||
final reactionsInside = contentType != MessageType.text && !reactionsUnder;
|
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(
|
return GestureDetector(
|
||||||
onTap: Haptics.tap,
|
onTap: Haptics.tap,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -421,13 +437,15 @@ class MessageBubble extends StatelessWidget {
|
|||||||
padding: padding,
|
padding: padding,
|
||||||
child: child,
|
child: child,
|
||||||
),
|
),
|
||||||
child: reactionsInside
|
child: withReply(
|
||||||
? Column(
|
reactionsInside
|
||||||
mainAxisSize: MainAxisSize.min,
|
? Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [bubbleContent, _reactionsBar(cs)],
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
)
|
children: [bubbleContent, _reactionsBar(cs)],
|
||||||
: bubbleContent,
|
)
|
||||||
|
: bubbleContent,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (reactionsUnder) _reactionsBar(cs),
|
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(
|
Widget _buildForwardedInlineText(
|
||||||
_BubbleCtx ctx,
|
_BubbleCtx ctx,
|
||||||
ForwardedMessageAttachment forwarded,
|
ForwardedMessageAttachment forwarded,
|
||||||
|
|||||||
Reference in New Issue
Block a user