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