суки
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user