feat: панель перессылки
This commit is contained in:
@@ -28,6 +28,7 @@ class ComposerInputBar extends StatelessWidget {
|
|||||||
this.backdropKey,
|
this.backdropKey,
|
||||||
required this.attachAnim,
|
required this.attachAnim,
|
||||||
required this.replyTo,
|
required this.replyTo,
|
||||||
|
required this.forwardMessages,
|
||||||
required this.myId,
|
required this.myId,
|
||||||
required this.hasText,
|
required this.hasText,
|
||||||
required this.uploadStatus,
|
required this.uploadStatus,
|
||||||
@@ -42,6 +43,7 @@ class ComposerInputBar extends StatelessWidget {
|
|||||||
required this.onOpenAttachScheduled,
|
required this.onOpenAttachScheduled,
|
||||||
required this.onSendHistory,
|
required this.onSendHistory,
|
||||||
required this.onCancelReply,
|
required this.onCancelReply,
|
||||||
|
required this.onCancelForward,
|
||||||
this.onPickReplyChat,
|
this.onPickReplyChat,
|
||||||
required this.formatElapsed,
|
required this.formatElapsed,
|
||||||
required this.contextMenuBuilder,
|
required this.contextMenuBuilder,
|
||||||
@@ -64,6 +66,7 @@ class ComposerInputBar extends StatelessWidget {
|
|||||||
final BackdropKey? backdropKey;
|
final BackdropKey? backdropKey;
|
||||||
final Animation<double> attachAnim;
|
final Animation<double> attachAnim;
|
||||||
final ValueListenable<CachedMessage?> replyTo;
|
final ValueListenable<CachedMessage?> replyTo;
|
||||||
|
final ValueListenable<List<CachedMessage>> forwardMessages;
|
||||||
final int myId;
|
final int myId;
|
||||||
final ValueListenable<bool> hasText;
|
final ValueListenable<bool> hasText;
|
||||||
final ValueListenable<UploadStatus> uploadStatus;
|
final ValueListenable<UploadStatus> uploadStatus;
|
||||||
@@ -78,6 +81,7 @@ class ComposerInputBar extends StatelessWidget {
|
|||||||
final VoidCallback onOpenAttachScheduled;
|
final VoidCallback onOpenAttachScheduled;
|
||||||
final Future<void> Function(FileHistoryEntry entry) onSendHistory;
|
final Future<void> Function(FileHistoryEntry entry) onSendHistory;
|
||||||
final VoidCallback onCancelReply;
|
final VoidCallback onCancelReply;
|
||||||
|
final VoidCallback onCancelForward;
|
||||||
final VoidCallback? onPickReplyChat;
|
final VoidCallback? onPickReplyChat;
|
||||||
final String Function(int ms) formatElapsed;
|
final String Function(int ms) formatElapsed;
|
||||||
final Widget Function(BuildContext, EditableTextState) contextMenuBuilder;
|
final Widget Function(BuildContext, EditableTextState) contextMenuBuilder;
|
||||||
@@ -94,10 +98,18 @@ class ComposerInputBar extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
return ValueListenableBuilder<List<CachedMessage>>(
|
||||||
|
valueListenable: forwardMessages,
|
||||||
|
builder: (context, forwards, _) => _build(context, forwards),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _build(BuildContext context, List<CachedMessage> forwards) {
|
||||||
final cs = Theme.of(context).colorScheme;
|
final cs = Theme.of(context).colorScheme;
|
||||||
final mutedIcon = cs.onSurfaceVariant.withValues(alpha: 0.85);
|
final mutedIcon = cs.onSurfaceVariant.withValues(alpha: 0.85);
|
||||||
|
final hasForward = forwards.isNotEmpty;
|
||||||
|
|
||||||
if (chatType == "CHANNEL") {
|
if (chatType == "CHANNEL" && !hasForward) {
|
||||||
if (!channelSubscribed) {
|
if (!channelSubscribed) {
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -180,7 +192,7 @@ class ComposerInputBar extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
_replyPreview(cs),
|
_messagePreview(cs, forwards),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 12.0,
|
horizontal: 12.0,
|
||||||
@@ -242,7 +254,11 @@ class ComposerInputBar extends StatelessWidget {
|
|||||||
!HardwareKeyboard
|
!HardwareKeyboard
|
||||||
.instance
|
.instance
|
||||||
.isShiftPressed) {
|
.isShiftPressed) {
|
||||||
if (hasText.value) onSendText();
|
if (hasText.value ||
|
||||||
|
hasForward ||
|
||||||
|
forceSend) {
|
||||||
|
onSendText();
|
||||||
|
}
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
return KeyEventResult.ignored;
|
return KeyEventResult.ignored;
|
||||||
@@ -383,6 +399,7 @@ class ComposerInputBar extends StatelessWidget {
|
|||||||
builder: (context, videoMode, _) {
|
builder: (context, videoMode, _) {
|
||||||
final sendMode =
|
final sendMode =
|
||||||
hasText ||
|
hasText ||
|
||||||
|
hasForward ||
|
||||||
locked ||
|
locked ||
|
||||||
forceSend;
|
forceSend;
|
||||||
final pill = _actionSurface(
|
final pill = _actionSurface(
|
||||||
@@ -395,7 +412,10 @@ class ComposerInputBar extends StatelessWidget {
|
|||||||
: _frost
|
: _frost
|
||||||
? AppFrost.inputTint(cs)
|
? AppFrost.inputTint(cs)
|
||||||
: cs.surfaceContainerHighest,
|
: cs.surfaceContainerHighest,
|
||||||
onTap: (hasText || forceSend)
|
onTap:
|
||||||
|
(hasText ||
|
||||||
|
hasForward ||
|
||||||
|
forceSend)
|
||||||
? onSendText
|
? onSendText
|
||||||
: locked
|
: locked
|
||||||
? () => voiceRec.stop(
|
? () => voiceRec.stop(
|
||||||
@@ -403,7 +423,9 @@ class ComposerInputBar extends StatelessWidget {
|
|||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
onLongPress:
|
onLongPress:
|
||||||
(hasText && !forceSend)
|
(hasText &&
|
||||||
|
!forceSend &&
|
||||||
|
!hasForward)
|
||||||
? onScheduleMessage
|
? onScheduleMessage
|
||||||
: null,
|
: null,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
@@ -567,6 +589,81 @@ class ComposerInputBar extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _messagePreview(ColorScheme cs, List<CachedMessage> forwards) {
|
||||||
|
if (forwards.isNotEmpty) return _forwardPreview(cs, forwards);
|
||||||
|
return _replyPreview(cs);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _forwardPreview(ColorScheme cs, List<CachedMessage> messages) {
|
||||||
|
final first = messages.first;
|
||||||
|
final senderName = ContactCache.get(first.senderId);
|
||||||
|
final info = ReplyInfo(
|
||||||
|
senderId: first.senderId,
|
||||||
|
text: first.text,
|
||||||
|
attachments: first.attachments,
|
||||||
|
);
|
||||||
|
final preview = info.previewText();
|
||||||
|
final title = messages.length == 1
|
||||||
|
? first.senderId == myId
|
||||||
|
? 'Пересылка от вас'
|
||||||
|
: senderName == null
|
||||||
|
? 'Пересылка сообщения'
|
||||||
|
: 'Пересылка от $senderName'
|
||||||
|
: 'Пересылка: ${_forwardCount(messages.length)}';
|
||||||
|
final row = Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 6, 8, 2),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Symbols.forward, 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(
|
||||||
|
title,
|
||||||
|
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: onCancelForward,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return _previewSurface(cs, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _forwardCount(int count) {
|
||||||
|
final last = count % 10;
|
||||||
|
final lastTwo = count % 100;
|
||||||
|
if (last == 1 && lastTwo != 11) return '$count сообщение';
|
||||||
|
if (last >= 2 && last <= 4 && (lastTwo < 12 || lastTwo > 14)) {
|
||||||
|
return '$count сообщения';
|
||||||
|
}
|
||||||
|
return '$count сообщений';
|
||||||
|
}
|
||||||
|
|
||||||
Widget _replyPreview(ColorScheme cs) {
|
Widget _replyPreview(ColorScheme cs) {
|
||||||
return ValueListenableBuilder<CachedMessage?>(
|
return ValueListenableBuilder<CachedMessage?>(
|
||||||
valueListenable: replyTo,
|
valueListenable: replyTo,
|
||||||
@@ -625,19 +722,23 @@ class ComposerInputBar extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (_flat && _translucent) return row;
|
return _previewSurface(cs, row);
|
||||||
if (!_translucent && chrome != ChatChromeStyle.transparent) return row;
|
|
||||||
return GlassSurface(
|
|
||||||
liquid: _liquid,
|
|
||||||
frostTint: AppFrost.panelTint(cs),
|
|
||||||
border: Border(top: AppFrost.hairline(cs)),
|
|
||||||
backdropKey: backdropKey,
|
|
||||||
child: row,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _previewSurface(ColorScheme cs, Widget child) {
|
||||||
|
if (_flat && _translucent) return child;
|
||||||
|
if (!_translucent && chrome != ChatChromeStyle.transparent) return child;
|
||||||
|
return GlassSurface(
|
||||||
|
liquid: _liquid,
|
||||||
|
frostTint: AppFrost.panelTint(cs),
|
||||||
|
border: Border(top: AppFrost.hairline(cs)),
|
||||||
|
backdropKey: backdropKey,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _recordingButtonVisual({
|
Widget _recordingButtonVisual({
|
||||||
required Widget pill,
|
required Widget pill,
|
||||||
required ColorScheme cs,
|
required ColorScheme cs,
|
||||||
|
|||||||
@@ -198,9 +198,26 @@ class _MeasureSizeState extends State<_MeasureSize> {
|
|||||||
|
|
||||||
class ForwardRequest {
|
class ForwardRequest {
|
||||||
final int sourceChatId;
|
final int sourceChatId;
|
||||||
final List<CachedMessage> optimistic;
|
final String sourceChatName;
|
||||||
|
final String sourceChatIconUrl;
|
||||||
|
final String sourceChatType;
|
||||||
|
final List<CachedMessage> messages;
|
||||||
|
|
||||||
const ForwardRequest({required this.sourceChatId, required this.optimistic});
|
ForwardRequest({
|
||||||
|
required this.sourceChatId,
|
||||||
|
required this.sourceChatName,
|
||||||
|
required this.sourceChatIconUrl,
|
||||||
|
required this.sourceChatType,
|
||||||
|
required List<CachedMessage> messages,
|
||||||
|
}) : messages = List.unmodifiable(messages);
|
||||||
|
|
||||||
|
ForwardRequest withMessages(List<CachedMessage> value) => ForwardRequest(
|
||||||
|
sourceChatId: sourceChatId,
|
||||||
|
sourceChatName: sourceChatName,
|
||||||
|
sourceChatIconUrl: sourceChatIconUrl,
|
||||||
|
sourceChatType: sourceChatType,
|
||||||
|
messages: value,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class ReplyRequest {
|
class ReplyRequest {
|
||||||
@@ -459,8 +476,13 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
int? _participantsCount;
|
int? _participantsCount;
|
||||||
|
|
||||||
final ValueNotifier<CachedMessage?> _replyTo = ValueNotifier(null);
|
final ValueNotifier<CachedMessage?> _replyTo = ValueNotifier(null);
|
||||||
|
final ValueNotifier<List<CachedMessage>> _pendingForwards = ValueNotifier(
|
||||||
|
const [],
|
||||||
|
);
|
||||||
static const bool _crossChatReplySupported = false;
|
static const bool _crossChatReplySupported = false;
|
||||||
int? _replySourceChatId;
|
int? _replySourceChatId;
|
||||||
|
ForwardRequest? _forwardRequest;
|
||||||
|
bool _forwardSending = false;
|
||||||
final ValueNotifier<String?> _highlightMessageId = ValueNotifier(null);
|
final ValueNotifier<String?> _highlightMessageId = ValueNotifier(null);
|
||||||
Timer? _highlightTimer;
|
Timer? _highlightTimer;
|
||||||
final ValueNotifier<double?> _jumpCacheExtent = ValueNotifier<double?>(null);
|
final ValueNotifier<double?> _jumpCacheExtent = ValueNotifier<double?>(null);
|
||||||
@@ -493,8 +515,6 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
bool _previewChat = false;
|
bool _previewChat = false;
|
||||||
bool _subscribing = false;
|
bool _subscribing = false;
|
||||||
String? _channelLink;
|
String? _channelLink;
|
||||||
bool _forwardRequestDone = false;
|
|
||||||
|
|
||||||
final ChatController _chatController = ChatController();
|
final ChatController _chatController = ChatController();
|
||||||
|
|
||||||
List<CachedMessage> get _messages => _chatController.messages;
|
List<CachedMessage> get _messages => _chatController.messages;
|
||||||
@@ -667,6 +687,11 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
? null
|
? null
|
||||||
: incomingReply.sourceChatId;
|
: incomingReply.sourceChatId;
|
||||||
}
|
}
|
||||||
|
final incomingForward = widget.forwardRequest;
|
||||||
|
if (incomingForward != null) {
|
||||||
|
_forwardRequest = incomingForward;
|
||||||
|
_pendingForwards.value = incomingForward.messages;
|
||||||
|
}
|
||||||
_pushSub = api.pushStream
|
_pushSub = api.pushStream
|
||||||
.where(
|
.where(
|
||||||
(p) =>
|
(p) =>
|
||||||
@@ -718,11 +743,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
reverseCurve: Curves.easeIn,
|
reverseCurve: Curves.easeIn,
|
||||||
);
|
);
|
||||||
|
|
||||||
unawaited(
|
unawaited(_fastPreloadCache());
|
||||||
_fastPreloadCache().then((_) {
|
|
||||||
if (mounted) unawaited(_runForwardRequest());
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
unawaited(_loadParticipantsCount());
|
unawaited(_loadParticipantsCount());
|
||||||
WidgetsBinding.instance.addPostFrameCallback(_onFirstFrameRendered);
|
WidgetsBinding.instance.addPostFrameCallback(_onFirstFrameRendered);
|
||||||
}
|
}
|
||||||
@@ -1935,6 +1956,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
_shimmerStartTimer?.cancel();
|
_shimmerStartTimer?.cancel();
|
||||||
_shimmerController.dispose();
|
_shimmerController.dispose();
|
||||||
_replyTo.dispose();
|
_replyTo.dispose();
|
||||||
|
_pendingForwards.dispose();
|
||||||
_highlightTimer?.cancel();
|
_highlightTimer?.cancel();
|
||||||
_highlightMessageId.dispose();
|
_highlightMessageId.dispose();
|
||||||
_goToMessageSettleTimer?.cancel();
|
_goToMessageSettleTimer?.cancel();
|
||||||
@@ -2213,7 +2235,9 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _forwardMessages(List<CachedMessage> msgs) async {
|
Future<void> _forwardMessages(List<CachedMessage> msgs) async {
|
||||||
final forwardable = msgs.where((m) => !m.id.startsWith('temp_')).toList();
|
final forwardable = msgs
|
||||||
|
.where((message) => int.tryParse(message.id) != null)
|
||||||
|
.toList();
|
||||||
if (forwardable.isEmpty) {
|
if (forwardable.isEmpty) {
|
||||||
showCustomNotification(context, 'Нечего пересылать');
|
showCustomNotification(context, 'Нечего пересылать');
|
||||||
return;
|
return;
|
||||||
@@ -2225,21 +2249,20 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
);
|
);
|
||||||
if (target == null || !mounted) return;
|
if (target == null || !mounted) return;
|
||||||
|
|
||||||
if (api.state != SessionState.online) {
|
|
||||||
showCustomNotification(context, 'Нет соединения');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final ordered = [...forwardable]..sort((a, b) => a.time.compareTo(b.time));
|
final ordered = [...forwardable]..sort((a, b) => a.time.compareTo(b.time));
|
||||||
|
final request = ForwardRequest(
|
||||||
|
sourceChatId: widget.chatId,
|
||||||
|
sourceChatName: widget.name,
|
||||||
|
sourceChatIconUrl: widget.imageUrl,
|
||||||
|
sourceChatType: widget.chatType,
|
||||||
|
messages: ordered,
|
||||||
|
);
|
||||||
|
|
||||||
if (target.chatId == widget.chatId) {
|
if (target.chatId == widget.chatId) {
|
||||||
await _forwardIntoCurrentChat(ordered);
|
_setForwardRequest(request);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final optimistic = await _seedForwardsToChat(target, ordered);
|
|
||||||
if (!mounted) return;
|
|
||||||
Haptics.send();
|
|
||||||
pushSwipeable(
|
pushSwipeable(
|
||||||
context,
|
context,
|
||||||
(_) => ChatScreen(
|
(_) => ChatScreen(
|
||||||
@@ -2247,164 +2270,127 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
name: target.name,
|
name: target.name,
|
||||||
imageUrl: target.imageUrl,
|
imageUrl: target.imageUrl,
|
||||||
chatType: target.chatType,
|
chatType: target.chatType,
|
||||||
forwardRequest: ForwardRequest(
|
forwardRequest: request,
|
||||||
sourceChatId: widget.chatId,
|
|
||||||
optimistic: optimistic,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _forwardIntoCurrentChat(List<CachedMessage> sources) async {
|
void _setForwardRequest(ForwardRequest request) {
|
||||||
final now = DateTime.now().millisecondsSinceEpoch;
|
_cancelReply();
|
||||||
final optimistic = <CachedMessage>[];
|
_forwardRequest = request;
|
||||||
var i = 0;
|
_pendingForwards.value = request.messages;
|
||||||
for (final src in sources) {
|
}
|
||||||
final msg = MessagesModule.buildForwardMessage(
|
|
||||||
|
void _cancelForward() {
|
||||||
|
_forwardRequest = null;
|
||||||
|
_pendingForwards.value = const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _sendForwardRequest() async {
|
||||||
|
var request = _forwardRequest;
|
||||||
|
if (request == null) return true;
|
||||||
|
if (api.state != SessionState.online) {
|
||||||
|
showCustomNotification(context, 'Нет соединения');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Haptics.send();
|
||||||
|
while (request != null && request.messages.isNotEmpty) {
|
||||||
|
if (!identical(_forwardRequest, request)) return false;
|
||||||
|
final source = request.messages.first;
|
||||||
|
final optimistic = MessagesModule.buildForwardMessage(
|
||||||
myId: _myId,
|
myId: _myId,
|
||||||
targetChatId: widget.chatId,
|
targetChatId: widget.chatId,
|
||||||
sourceChatId: widget.chatId,
|
sourceChatId: request.sourceChatId,
|
||||||
source: src,
|
source: source,
|
||||||
tempId: _nextTempId(),
|
tempId: _nextTempId(),
|
||||||
time: now + i,
|
time: DateTime.now().millisecondsSinceEpoch,
|
||||||
status: 'sending',
|
status: 'sending',
|
||||||
sourceChatName: widget.name,
|
sourceChatName: request.sourceChatName,
|
||||||
sourceChatIconUrl: widget.imageUrl,
|
sourceChatIconUrl: request.sourceChatIconUrl,
|
||||||
sourceChatType: widget.chatType,
|
sourceChatType: request.sourceChatType,
|
||||||
);
|
);
|
||||||
optimistic.add(msg);
|
_messages.add(optimistic);
|
||||||
_messages.add(msg);
|
_bumpMessages();
|
||||||
unawaited(_persistOutgoing(msg));
|
_scrollToBottom();
|
||||||
i++;
|
await _syncForwardOutgoing(optimistic);
|
||||||
}
|
final sent = await _sendOneForward(optimistic, request.sourceChatId);
|
||||||
_bumpMessages();
|
if (!sent || !mounted) return false;
|
||||||
Haptics.send();
|
if (!identical(_forwardRequest, request)) return false;
|
||||||
_scrollToBottom();
|
final remaining = request.messages.skip(1).toList(growable: false);
|
||||||
if (optimistic.isNotEmpty) {
|
if (remaining.isEmpty) {
|
||||||
final last = optimistic.last;
|
_cancelForward();
|
||||||
unawaited(
|
return true;
|
||||||
chats.applyOutgoing(
|
}
|
||||||
_myId,
|
request = request.withMessages(remaining);
|
||||||
widget.chatId,
|
_forwardRequest = request;
|
||||||
messageId: last.id,
|
_pendingForwards.value = request.messages;
|
||||||
time: last.time,
|
|
||||||
text: MessagesModule.forwardPreviewText(last),
|
|
||||||
status: 'sending',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (final opt in optimistic) {
|
|
||||||
await _sendOneForward(opt, widget.chatId);
|
|
||||||
}
|
}
|
||||||
|
_cancelForward();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<CachedMessage>> _seedForwardsToChat(
|
Future<bool> _sendOneForward(
|
||||||
ForwardTarget target,
|
|
||||||
List<CachedMessage> sources,
|
|
||||||
) async {
|
|
||||||
await chats.ensureChatCached(api, _myId, target.chatId);
|
|
||||||
final now = DateTime.now().millisecondsSinceEpoch;
|
|
||||||
final optimistic = <CachedMessage>[];
|
|
||||||
var i = 0;
|
|
||||||
for (final src in sources) {
|
|
||||||
final msg = MessagesModule.buildForwardMessage(
|
|
||||||
myId: _myId,
|
|
||||||
targetChatId: target.chatId,
|
|
||||||
sourceChatId: widget.chatId,
|
|
||||||
source: src,
|
|
||||||
tempId: _nextTempId(),
|
|
||||||
time: now + i,
|
|
||||||
status: 'sending',
|
|
||||||
sourceChatName: widget.name,
|
|
||||||
sourceChatIconUrl: widget.imageUrl,
|
|
||||||
sourceChatType: widget.chatType,
|
|
||||||
);
|
|
||||||
optimistic.add(msg);
|
|
||||||
await AppDatabase.saveMessages([msg.toDbRow()]);
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
final cached = MessageSessionCache.get(_myId, target.chatId);
|
|
||||||
if (cached != null) {
|
|
||||||
MessageSessionCache.save(_myId, target.chatId, [
|
|
||||||
...cached.messages,
|
|
||||||
...optimistic,
|
|
||||||
], reachedStart: cached.reachedStart);
|
|
||||||
}
|
|
||||||
if (optimistic.isNotEmpty) {
|
|
||||||
final last = optimistic.last;
|
|
||||||
unawaited(
|
|
||||||
chats.applyOutgoing(
|
|
||||||
_myId,
|
|
||||||
target.chatId,
|
|
||||||
messageId: last.id,
|
|
||||||
time: last.time,
|
|
||||||
text: MessagesModule.forwardPreviewText(last),
|
|
||||||
status: 'sending',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return optimistic;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _runForwardRequest() async {
|
|
||||||
final req = widget.forwardRequest;
|
|
||||||
if (req == null || _forwardRequestDone) return;
|
|
||||||
_forwardRequestDone = true;
|
|
||||||
for (final opt in req.optimistic) {
|
|
||||||
await _sendOneForward(opt, req.sourceChatId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _sendOneForward(
|
|
||||||
CachedMessage optimistic,
|
CachedMessage optimistic,
|
||||||
int sourceChatId,
|
int sourceChatId,
|
||||||
) async {
|
) async {
|
||||||
final link = optimistic.payload?['link'];
|
final link = optimistic.payload?['link'];
|
||||||
final rawWireId = link is Map ? link['messageId'] : null;
|
final rawWireId = link is Map ? link['messageId'] : null;
|
||||||
final wireId = rawWireId is int ? rawWireId : null;
|
final wireId = rawWireId is int ? rawWireId : null;
|
||||||
if (wireId == null) return;
|
if (wireId == null) return false;
|
||||||
try {
|
try {
|
||||||
final realId = await messagesModule.forwardMessage(
|
final realId = await messagesModule.forwardMessage(
|
||||||
widget.chatId,
|
widget.chatId,
|
||||||
sourceChatId,
|
sourceChatId,
|
||||||
wireId,
|
wireId,
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
|
||||||
final sent = MessagesModule.reidentifyMessage(
|
final sent = MessagesModule.reidentifyMessage(
|
||||||
optimistic,
|
optimistic,
|
||||||
realId.isNotEmpty ? realId : optimistic.id,
|
realId.isNotEmpty ? realId : optimistic.id,
|
||||||
status: 'sent',
|
status: 'sent',
|
||||||
);
|
);
|
||||||
final index = _messages.indexWhere((m) => m.id == optimistic.id);
|
if (mounted) {
|
||||||
if (index != -1) {
|
final index = _messages.indexWhere((m) => m.id == optimistic.id);
|
||||||
_messages[index] = sent;
|
if (index != -1) {
|
||||||
_bumpMessages();
|
_messages[index] = sent;
|
||||||
|
_bumpMessages();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
unawaited(_persistOutgoing(sent, removeId: optimistic.id));
|
await _syncForwardOutgoing(sent, removeId: optimistic.id);
|
||||||
unawaited(
|
return true;
|
||||||
chats.applyOutgoing(
|
|
||||||
_myId,
|
|
||||||
widget.chatId,
|
|
||||||
messageId: sent.id,
|
|
||||||
time: sent.time,
|
|
||||||
text: MessagesModule.forwardPreviewText(sent),
|
|
||||||
status: 'sent',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
final index = _messages.indexWhere((m) => m.id == optimistic.id);
|
final index = _messages.indexWhere((m) => m.id == optimistic.id);
|
||||||
if (index != -1 && mounted) {
|
if (index != -1 && mounted) {
|
||||||
_messages.removeAt(index);
|
_messages.removeAt(index);
|
||||||
_bumpMessages();
|
_bumpMessages();
|
||||||
}
|
}
|
||||||
unawaited(AppDatabase.deleteMessage(_myId, widget.chatId, optimistic.id));
|
try {
|
||||||
|
await AppDatabase.deleteMessage(_myId, widget.chatId, optimistic.id);
|
||||||
|
} catch (_) {}
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
Haptics.error();
|
Haptics.error();
|
||||||
showCustomNotification(context, 'Не удалось переслать');
|
showCustomNotification(context, 'Не удалось переслать');
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _syncForwardOutgoing(
|
||||||
|
CachedMessage message, {
|
||||||
|
String? removeId,
|
||||||
|
}) async {
|
||||||
|
await _persistOutgoing(message, removeId: removeId);
|
||||||
|
try {
|
||||||
|
await chats.applyOutgoing(
|
||||||
|
_myId,
|
||||||
|
widget.chatId,
|
||||||
|
messageId: message.id,
|
||||||
|
time: message.time,
|
||||||
|
text: MessagesModule.forwardPreviewText(message),
|
||||||
|
status: message.status ?? 'sending',
|
||||||
|
);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildComposerArea(BuildContext context) {
|
Widget _buildComposerArea(BuildContext context) {
|
||||||
final cs = Theme.of(context).colorScheme;
|
final cs = Theme.of(context).colorScheme;
|
||||||
final content = Column(
|
final content = Column(
|
||||||
@@ -2472,6 +2458,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
backdropKey: _pillBackdrop,
|
backdropKey: _pillBackdrop,
|
||||||
attachAnim: _attachAnim,
|
attachAnim: _attachAnim,
|
||||||
replyTo: _replyTo,
|
replyTo: _replyTo,
|
||||||
|
forwardMessages: _pendingForwards,
|
||||||
myId: _myId,
|
myId: _myId,
|
||||||
hasText: _hasText,
|
hasText: _hasText,
|
||||||
uploadStatus: _uploadStatus,
|
uploadStatus: _uploadStatus,
|
||||||
@@ -2486,6 +2473,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
onOpenAttachScheduled: _openAttachmentSheetScheduled,
|
onOpenAttachScheduled: _openAttachmentSheetScheduled,
|
||||||
onSendHistory: _sendHistoryFile,
|
onSendHistory: _sendHistoryFile,
|
||||||
onCancelReply: _cancelReply,
|
onCancelReply: _cancelReply,
|
||||||
|
onCancelForward: _cancelForward,
|
||||||
onPickReplyChat: _commentsMode || !_crossChatReplySupported
|
onPickReplyChat: _commentsMode || !_crossChatReplySupported
|
||||||
? null
|
? null
|
||||||
: () => unawaited(_pickReplyChat()),
|
: () => unawaited(_pickReplyChat()),
|
||||||
@@ -3609,6 +3597,23 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _sendMessage() async {
|
Future<void> _sendMessage() async {
|
||||||
|
if (_forwardRequest == null) {
|
||||||
|
await _sendTextMessage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_forwardSending || _myId == 0) return;
|
||||||
|
_forwardSending = true;
|
||||||
|
try {
|
||||||
|
final forwarded = await _sendForwardRequest();
|
||||||
|
if (!forwarded || !mounted) return;
|
||||||
|
if (_messageController.text.trim().isEmpty) return;
|
||||||
|
await _sendTextMessage();
|
||||||
|
} finally {
|
||||||
|
_forwardSending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _sendTextMessage() async {
|
||||||
final content = _messageController.buildContent();
|
final content = _messageController.buildContent();
|
||||||
final rawText = content.text;
|
final rawText = content.text;
|
||||||
final text = rawText.trim();
|
final text = rawText.trim();
|
||||||
@@ -4260,6 +4265,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _startReply(CachedMessage message) {
|
void _startReply(CachedMessage message) {
|
||||||
|
_cancelForward();
|
||||||
_replyTo.value = message;
|
_replyTo.value = message;
|
||||||
_replySourceChatId = null;
|
_replySourceChatId = null;
|
||||||
_messageFocusNode.requestFocus();
|
_messageFocusNode.requestFocus();
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:komet/backend/modules/messages.dart';
|
||||||
|
import 'package:komet/core/config/app_chat_chrome.dart';
|
||||||
|
import 'package:komet/core/config/app_composer_background.dart';
|
||||||
|
import 'package:komet/core/config/app_composer_style.dart';
|
||||||
|
import 'package:komet/frontend/screens/chats/chat/upload_status.dart';
|
||||||
|
import 'package:komet/frontend/screens/chats/chat/video_note_controller.dart';
|
||||||
|
import 'package:komet/frontend/screens/chats/chat/view/composer_input.dart';
|
||||||
|
import 'package:komet/frontend/screens/chats/chat/voice_record_controller.dart';
|
||||||
|
import 'package:komet/frontend/widgets/rich_message_controller.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
|
||||||
|
CachedMessage _message() => const CachedMessage(
|
||||||
|
id: '101',
|
||||||
|
accountId: 7,
|
||||||
|
chatId: 70,
|
||||||
|
senderId: 7,
|
||||||
|
text: 'Synthetic forwarded text',
|
||||||
|
time: 1000,
|
||||||
|
status: 'sent',
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('forward preview forces send mode and can be cancelled', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final forwards = ValueNotifier<List<CachedMessage>>([_message()]);
|
||||||
|
final reply = ValueNotifier<CachedMessage?>(null);
|
||||||
|
final hasText = ValueNotifier(false);
|
||||||
|
final uploadStatus = ValueNotifier(const UploadStatus());
|
||||||
|
final messageController = RichMessageController();
|
||||||
|
final focusNode = FocusNode();
|
||||||
|
final attachAnimation = AnimationController(
|
||||||
|
vsync: const TestVSync(),
|
||||||
|
duration: const Duration(milliseconds: 1),
|
||||||
|
);
|
||||||
|
late BuildContext composerContext;
|
||||||
|
var sendCount = 0;
|
||||||
|
var cancelCount = 0;
|
||||||
|
final voice = VoiceRecordController(
|
||||||
|
contextOf: () => composerContext,
|
||||||
|
isMounted: () => true,
|
||||||
|
myId: () => 7,
|
||||||
|
onRecorded: (File file, int durationMs, List<double> amplitudes) async {},
|
||||||
|
);
|
||||||
|
final note = VideoNoteController(
|
||||||
|
contextOf: () => composerContext,
|
||||||
|
isMounted: () => true,
|
||||||
|
onRecorded: (File file, int durationMs) async {},
|
||||||
|
formatElapsed: (milliseconds) => '$milliseconds',
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
composerContext = context;
|
||||||
|
return Scaffold(
|
||||||
|
bottomNavigationBar: ComposerInputBar(
|
||||||
|
chatType: 'CHAT',
|
||||||
|
chrome: ChatChromeStyle.color,
|
||||||
|
style: ComposerStyle.materialYou,
|
||||||
|
background: ComposerBackground.standard,
|
||||||
|
attachAnim: attachAnimation,
|
||||||
|
replyTo: reply,
|
||||||
|
forwardMessages: forwards,
|
||||||
|
myId: 7,
|
||||||
|
hasText: hasText,
|
||||||
|
uploadStatus: uploadStatus,
|
||||||
|
messageController: messageController,
|
||||||
|
messageFocusNode: focusNode,
|
||||||
|
voiceRec: voice,
|
||||||
|
note: note,
|
||||||
|
onToggleStickerPanel: () {},
|
||||||
|
onSendText: () => sendCount++,
|
||||||
|
onScheduleMessage: () {},
|
||||||
|
onOpenAttach: () {},
|
||||||
|
onOpenAttachScheduled: () {},
|
||||||
|
onSendHistory: (_) async {},
|
||||||
|
onCancelReply: () {},
|
||||||
|
onCancelForward: () {
|
||||||
|
cancelCount++;
|
||||||
|
forwards.value = const [];
|
||||||
|
},
|
||||||
|
formatElapsed: (milliseconds) => '$milliseconds',
|
||||||
|
contextMenuBuilder: (context, state) => const SizedBox.shrink(),
|
||||||
|
isMuted: false,
|
||||||
|
onToggleMute: () {},
|
||||||
|
showStickerButton: false,
|
||||||
|
showAttachButton: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('Пересылка от вас'), findsOneWidget);
|
||||||
|
expect(find.text('Synthetic forwarded text'), findsOneWidget);
|
||||||
|
expect(find.byIcon(Symbols.forward), findsOneWidget);
|
||||||
|
expect(find.byIcon(Symbols.send), findsOneWidget);
|
||||||
|
expect(find.byIcon(Symbols.mic), findsNothing);
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Symbols.send));
|
||||||
|
expect(sendCount, 1);
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(Symbols.close));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(cancelCount, 1);
|
||||||
|
expect(find.text('Пересылка от вас'), findsNothing);
|
||||||
|
expect(find.byIcon(Symbols.send), findsNothing);
|
||||||
|
expect(find.byIcon(Symbols.mic), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
|
forwards.dispose();
|
||||||
|
reply.dispose();
|
||||||
|
hasText.dispose();
|
||||||
|
uploadStatus.dispose();
|
||||||
|
messageController.dispose();
|
||||||
|
focusNode.dispose();
|
||||||
|
attachAnimation.dispose();
|
||||||
|
voice.dispose();
|
||||||
|
note.dispose();
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user