feat: / команды
This commit is contained in:
@@ -649,16 +649,19 @@ class MessagesModule {
|
|||||||
int chatId,
|
int chatId,
|
||||||
String messageId, {
|
String messageId, {
|
||||||
required String text,
|
required String text,
|
||||||
|
List<Map<String, dynamic>> elements = const [],
|
||||||
|
bool sendAttachments = false,
|
||||||
}) async {
|
}) async {
|
||||||
final id = int.tryParse(messageId);
|
final id = int.tryParse(messageId);
|
||||||
if (id == null) return false;
|
if (id == null) return false;
|
||||||
|
|
||||||
final payload = {
|
final payload = <String, dynamic>{
|
||||||
'messageId': id,
|
'messageId': id,
|
||||||
'chatId': chatId,
|
'chatId': chatId,
|
||||||
'elements': <dynamic>[],
|
'elements': elements,
|
||||||
'text': text,
|
'text': text,
|
||||||
};
|
};
|
||||||
|
if (sendAttachments) payload['attachments'] = const <dynamic>[];
|
||||||
|
|
||||||
final response = await _api.sendRequest(Opcode.msgEdit, payload);
|
final response = await _api.sendRequest(Opcode.msgEdit, payload);
|
||||||
return response.isOk;
|
return response.isOk;
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class AppCommands {
|
||||||
|
static const prefKey = 'dev_commands';
|
||||||
|
static const bool defaultValue = false;
|
||||||
|
|
||||||
|
static final ValueNotifier<bool> current = ValueNotifier(defaultValue);
|
||||||
|
|
||||||
|
static Future<bool> load() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(prefKey) ?? defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> save(bool value) async {
|
||||||
|
current.value = value;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(prefKey, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import 'crush_command.dart';
|
||||||
|
import 'info_command.dart';
|
||||||
|
import 'slash_command.dart';
|
||||||
|
|
||||||
|
const List<SlashCommand> kSlashCommands = [
|
||||||
|
SlashCommand('/test', '12345 test отображение'),
|
||||||
|
SlashCommand('/info', 'сводка данных о человеке', run: runInfo),
|
||||||
|
SlashCommand(
|
||||||
|
'/crush',
|
||||||
|
'Тест устойчивости веб клиента макса',
|
||||||
|
run: runCrush,
|
||||||
|
hidden: true,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
SlashCommand? findSlashCommand(String text) {
|
||||||
|
for (final c in kSlashCommands) {
|
||||||
|
if (c.name == text) return c;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'slash_command.dart';
|
||||||
|
|
||||||
|
const int _cycles = 40;
|
||||||
|
const Duration _delay = Duration(milliseconds: 400);
|
||||||
|
|
||||||
|
const String _text =
|
||||||
|
'burmaldaburmaldaburmaldaburmaldaburmaldaburmaldaburmaldaburmalda';
|
||||||
|
|
||||||
|
const List<Map<String, dynamic>> _elements = [
|
||||||
|
{'type': 'STRONG', 'from': 0, 'length': 1},
|
||||||
|
{'type': 'EMPHASIZED', 'from': 1, 'length': 1},
|
||||||
|
{'type': 'UNDERLINE', 'from': 2, 'length': 1},
|
||||||
|
{'type': 'STRIKETHROUGH', 'from': 3, 'length': 1},
|
||||||
|
{'type': 'STRONG', 'from': 4, 'length': 2},
|
||||||
|
{'type': 'STRONG', 'from': 6, 'length': 1},
|
||||||
|
{'type': 'EMPHASIZED', 'from': 6, 'length': 1},
|
||||||
|
{'type': 'STRONG', 'from': 7, 'length': 1},
|
||||||
|
{'type': 'EMPHASIZED', 'from': 7, 'length': 1},
|
||||||
|
{'type': 'STRIKETHROUGH', 'from': 7, 'length': 1},
|
||||||
|
{'type': 'STRONG', 'from': 8, 'length': 1},
|
||||||
|
{'type': 'EMPHASIZED', 'from': 8, 'length': 1},
|
||||||
|
{'type': 'UNDERLINE', 'from': 8, 'length': 1},
|
||||||
|
{'type': 'STRIKETHROUGH', 'from': 8, 'length': 1},
|
||||||
|
{'type': 'STRONG', 'from': 9, 'length': 1},
|
||||||
|
{'type': 'EMPHASIZED', 'from': 9, 'length': 1},
|
||||||
|
{'type': 'STRIKETHROUGH', 'from': 9, 'length': 1},
|
||||||
|
{'type': 'STRIKETHROUGH', 'from': 13, 'length': 3},
|
||||||
|
{'type': 'STRONG', 'from': 16, 'length': 2},
|
||||||
|
{'type': 'STRIKETHROUGH', 'from': 16, 'length': 2},
|
||||||
|
{'type': 'STRONG', 'from': 18, 'length': 5},
|
||||||
|
{'type': 'EMPHASIZED', 'from': 18, 'length': 5},
|
||||||
|
{'type': 'STRIKETHROUGH', 'from': 18, 'length': 5},
|
||||||
|
{'type': 'STRONG', 'from': 23, 'length': 2},
|
||||||
|
{'type': 'EMPHASIZED', 'from': 23, 'length': 2},
|
||||||
|
{'type': 'UNDERLINE', 'from': 23, 'length': 2},
|
||||||
|
{'type': 'STRIKETHROUGH', 'from': 23, 'length': 2},
|
||||||
|
{'type': 'STRONG', 'from': 25, 'length': 1},
|
||||||
|
{'type': 'EMPHASIZED', 'from': 25, 'length': 1},
|
||||||
|
{'type': 'UNDERLINE', 'from': 25, 'length': 1},
|
||||||
|
{'type': 'STRONG', 'from': 26, 'length': 2},
|
||||||
|
{'type': 'UNDERLINE', 'from': 26, 'length': 2},
|
||||||
|
{'type': 'STRONG', 'from': 28, 'length': 3},
|
||||||
|
{'type': 'EMPHASIZED', 'from': 28, 'length': 3},
|
||||||
|
{'type': 'UNDERLINE', 'from': 28, 'length': 3},
|
||||||
|
{'type': 'EMPHASIZED', 'from': 31, 'length': 2},
|
||||||
|
{'type': 'EMPHASIZED', 'from': 33, 'length': 2},
|
||||||
|
{'type': 'UNDERLINE', 'from': 33, 'length': 2},
|
||||||
|
{'type': 'STRONG', 'from': 35, 'length': 6},
|
||||||
|
{'type': 'EMPHASIZED', 'from': 35, 'length': 6},
|
||||||
|
{'type': 'UNDERLINE', 'from': 35, 'length': 6},
|
||||||
|
{'type': 'STRONG', 'from': 41, 'length': 2},
|
||||||
|
{'type': 'UNDERLINE', 'from': 41, 'length': 2},
|
||||||
|
{'type': 'STRONG', 'from': 43, 'length': 8},
|
||||||
|
{'type': 'UNDERLINE', 'from': 43, 'length': 8},
|
||||||
|
{'type': 'STRIKETHROUGH', 'from': 43, 'length': 8},
|
||||||
|
{'type': 'UNDERLINE', 'from': 51, 'length': 7},
|
||||||
|
{'type': 'STRIKETHROUGH', 'from': 51, 'length': 7},
|
||||||
|
{'type': 'STRIKETHROUGH', 'from': 58, 'length': 5},
|
||||||
|
{'type': 'MONOSPACED', 'from': 0, 'length': 4},
|
||||||
|
{'type': 'MONOSPACED', 'from': 9, 'length': 4},
|
||||||
|
{'type': 'MONOSPACED', 'from': 16, 'length': 7},
|
||||||
|
{'type': 'MONOSPACED', 'from': 25, 'length': 6},
|
||||||
|
{'type': 'MONOSPACED', 'from': 33, 'length': 8},
|
||||||
|
{'type': 'MONOSPACED', 'from': 43, 'length': 8},
|
||||||
|
{'type': 'MONOSPACED', 'from': 51, 'length': 7},
|
||||||
|
{'type': 'MONOSPACED', 'from': 58, 'length': 5},
|
||||||
|
{
|
||||||
|
'type': 'LINK',
|
||||||
|
'from': 0,
|
||||||
|
'length': 8,
|
||||||
|
'attributes': {'url': 'https://vk.com'},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'type': 'LINK',
|
||||||
|
'from': 24,
|
||||||
|
'length': 16,
|
||||||
|
'attributes': {'url': 'https://max.ru'},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'type': 'LINK',
|
||||||
|
'from': 48,
|
||||||
|
'length': 16,
|
||||||
|
'attributes': {'url': 'https://web.max.ru'},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
Future<void> runCrush(CommandContext ctx) async {
|
||||||
|
if (!ctx.isOnline()) {
|
||||||
|
ctx.notify('Нет соединения');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.notify('Crush: запуск ($_cycles циклов)');
|
||||||
|
for (var i = 0; i < _cycles; i++) {
|
||||||
|
if (!ctx.isActive()) return;
|
||||||
|
unawaited(_cycle(ctx));
|
||||||
|
await Future.delayed(_delay);
|
||||||
|
}
|
||||||
|
if (ctx.isActive()) ctx.notify('Crush: завершено');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _cycle(CommandContext ctx) async {
|
||||||
|
try {
|
||||||
|
final id = await ctx.messages.sendMessage(ctx.accountId, ctx.chatId, _text);
|
||||||
|
if (id.isEmpty) return;
|
||||||
|
await Future.wait([
|
||||||
|
ctx.messages.editMessage(
|
||||||
|
ctx.chatId,
|
||||||
|
id,
|
||||||
|
text: _text,
|
||||||
|
elements: _elements,
|
||||||
|
sendAttachments: true,
|
||||||
|
),
|
||||||
|
ctx.messages.deleteMessages(ctx.chatId, [id], forEveryone: true),
|
||||||
|
]);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import '../../core/cache/info_cache.dart';
|
||||||
|
import '../../core/utils/format.dart';
|
||||||
|
import 'slash_command.dart';
|
||||||
|
|
||||||
|
Future<void> runInfo(CommandContext ctx) async {
|
||||||
|
final targetId = ctx.otherUserId;
|
||||||
|
if (targetId == null) {
|
||||||
|
ctx.notify('Команда доступна только в диалоге');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ctx.isOnline()) {
|
||||||
|
ctx.notify('Нет соединения');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final messageId = await ctx.postMessage('сбор данных...');
|
||||||
|
if (messageId.isEmpty) return;
|
||||||
|
|
||||||
|
final contact = await ContactInfoFetch.get(targetId, forceRefresh: true);
|
||||||
|
if (!ctx.isActive()) return;
|
||||||
|
|
||||||
|
await ctx.updateMessage(
|
||||||
|
messageId,
|
||||||
|
contact == null ? 'Данные не получены' : _summary(contact, targetId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _summary(Map<String, dynamic> c, int targetId) {
|
||||||
|
final flags = (c['options'] as List?)?.whereType<String>().toList() ?? const [];
|
||||||
|
final region = (c['country'] as String?)?.trim();
|
||||||
|
|
||||||
|
return 'Никнейм: ${_nick(c)}\n'
|
||||||
|
'Дата регистрации: ${_date(c['registrationTime'])}\n'
|
||||||
|
'Дата последнего изменения профиля: ${_date(c['updateTime'])}\n'
|
||||||
|
'id: ${c['id'] ?? targetId}\n'
|
||||||
|
'Регион: ${region == null || region.isEmpty ? '—' : region}\n'
|
||||||
|
'Флаги: ${flags.isEmpty ? '—' : flags.join(', ')}\n'
|
||||||
|
'ip: not fetched';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _nick(Map<String, dynamic> c) {
|
||||||
|
final names = c['names'];
|
||||||
|
if (names is List && names.isNotEmpty && names.first is Map) {
|
||||||
|
final n = names.first as Map;
|
||||||
|
final name = (n['name'] as String?) ??
|
||||||
|
'${n['firstName'] ?? ''} ${n['lastName'] ?? ''}'.trim();
|
||||||
|
if (name.isNotEmpty) return name;
|
||||||
|
}
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _date(dynamic ms) {
|
||||||
|
if (ms is! int || ms <= 0) return '—';
|
||||||
|
return formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(ms));
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import '../../backend/modules/messages.dart';
|
||||||
|
|
||||||
|
class CommandContext {
|
||||||
|
final int accountId;
|
||||||
|
final int chatId;
|
||||||
|
final int? otherUserId;
|
||||||
|
final MessagesModule messages;
|
||||||
|
final bool Function() isOnline;
|
||||||
|
final bool Function() isActive;
|
||||||
|
final void Function(String message) notify;
|
||||||
|
final Future<String> Function(String text) postMessage;
|
||||||
|
final Future<void> Function(String id, String text) updateMessage;
|
||||||
|
|
||||||
|
const CommandContext({
|
||||||
|
required this.accountId,
|
||||||
|
required this.chatId,
|
||||||
|
required this.otherUserId,
|
||||||
|
required this.messages,
|
||||||
|
required this.isOnline,
|
||||||
|
required this.isActive,
|
||||||
|
required this.notify,
|
||||||
|
required this.postMessage,
|
||||||
|
required this.updateMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
typedef CommandRunner = Future<void> Function(CommandContext ctx);
|
||||||
|
|
||||||
|
class SlashCommand {
|
||||||
|
final String name;
|
||||||
|
final String description;
|
||||||
|
final CommandRunner? run;
|
||||||
|
final bool hidden;
|
||||||
|
|
||||||
|
const SlashCommand(
|
||||||
|
this.name,
|
||||||
|
this.description, {
|
||||||
|
this.run,
|
||||||
|
this.hidden = false,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -34,10 +34,14 @@ import '../../../core/config/app_cache_extent.dart';
|
|||||||
import '../../../core/config/app_message_actions_style.dart';
|
import '../../../core/config/app_message_actions_style.dart';
|
||||||
import '../../../core/config/app_swipe_back_desktop.dart';
|
import '../../../core/config/app_swipe_back_desktop.dart';
|
||||||
import '../../../core/config/app_pranks.dart';
|
import '../../../core/config/app_pranks.dart';
|
||||||
|
import '../../../core/config/app_commands.dart';
|
||||||
import '../../../core/config/app_visual_style.dart';
|
import '../../../core/config/app_visual_style.dart';
|
||||||
import '../../../core/config/komet_settings.dart';
|
import '../../../core/config/komet_settings.dart';
|
||||||
import '../../../models/attachment.dart';
|
import '../../../models/attachment.dart';
|
||||||
|
import '../../commands/command_registry.dart';
|
||||||
|
import '../../commands/slash_command.dart';
|
||||||
import '../../widgets/glossy_pill.dart';
|
import '../../widgets/glossy_pill.dart';
|
||||||
|
import '../../widgets/command_suggestions_panel.dart';
|
||||||
import '../../widgets/online_dot.dart';
|
import '../../widgets/online_dot.dart';
|
||||||
import '../../widgets/connection_status.dart';
|
import '../../widgets/connection_status.dart';
|
||||||
import '../../widgets/message_bubble.dart';
|
import '../../widgets/message_bubble.dart';
|
||||||
@@ -158,6 +162,8 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
final ValueNotifier<int> _otherReadTime = ValueNotifier(0);
|
final ValueNotifier<int> _otherReadTime = ValueNotifier(0);
|
||||||
int _tempIdCounter = 0;
|
int _tempIdCounter = 0;
|
||||||
late final AnimationController _attachAnim;
|
late final AnimationController _attachAnim;
|
||||||
|
late final AnimationController _commandAnim;
|
||||||
|
bool _commandPanelVisible = false;
|
||||||
|
|
||||||
String _nextTempId() =>
|
String _nextTempId() =>
|
||||||
'temp_${++_tempIdCounter}_${DateTime.now().microsecondsSinceEpoch}';
|
'temp_${++_tempIdCounter}_${DateTime.now().microsecondsSinceEpoch}';
|
||||||
@@ -201,6 +207,11 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
reverseDuration: const Duration(milliseconds: 240),
|
reverseDuration: const Duration(milliseconds: 240),
|
||||||
);
|
);
|
||||||
_showAttachmentPanel.addListener(_onAttachPanelToggle);
|
_showAttachmentPanel.addListener(_onAttachPanelToggle);
|
||||||
|
_commandAnim = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
);
|
||||||
|
AppCommands.current.addListener(_updateCommandPanel);
|
||||||
_pushSub = api.pushStream
|
_pushSub = api.pushStream
|
||||||
.where(
|
.where(
|
||||||
(p) =>
|
(p) =>
|
||||||
@@ -581,6 +592,8 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
_finishPrankReveal();
|
_finishPrankReveal();
|
||||||
_uploadStatus.dispose();
|
_uploadStatus.dispose();
|
||||||
_attachAnim.dispose();
|
_attachAnim.dispose();
|
||||||
|
AppCommands.current.removeListener(_updateCommandPanel);
|
||||||
|
_commandAnim.dispose();
|
||||||
_messageController.dispose();
|
_messageController.dispose();
|
||||||
_messageFocusNode.dispose();
|
_messageFocusNode.dispose();
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
@@ -594,6 +607,28 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
if (newHasText != _hasText.value) {
|
if (newHasText != _hasText.value) {
|
||||||
_hasText.value = newHasText;
|
_hasText.value = newHasText;
|
||||||
}
|
}
|
||||||
|
_updateCommandPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateCommandPanel() {
|
||||||
|
final show =
|
||||||
|
AppCommands.current.value && _messageController.text.startsWith('/');
|
||||||
|
if (show == _commandPanelVisible) return;
|
||||||
|
_commandPanelVisible = show;
|
||||||
|
if (show) {
|
||||||
|
_commandAnim.forward();
|
||||||
|
} else {
|
||||||
|
_commandAnim.reverse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onCommandSelected(SlashCommand c) {
|
||||||
|
final text = '${c.name} ';
|
||||||
|
_messageController.value = TextEditingValue(
|
||||||
|
text: text,
|
||||||
|
selection: TextSelection.collapsed(offset: text.length),
|
||||||
|
);
|
||||||
|
_messageFocusNode.requestFocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _restoreDraft() {
|
void _restoreDraft() {
|
||||||
@@ -620,6 +655,26 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildCommandPanel() {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _commandAnim,
|
||||||
|
builder: (context, _) {
|
||||||
|
final t = _commandAnim.value;
|
||||||
|
if (t == 0) return const SizedBox.shrink();
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||||||
|
child: IgnorePointer(
|
||||||
|
ignoring: t < 1,
|
||||||
|
child: Opacity(
|
||||||
|
opacity: t,
|
||||||
|
child: CommandSuggestionsPanel(onSelected: _onCommandSelected),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
int _computeOtherReadTime() {
|
int _computeOtherReadTime() {
|
||||||
final c = chat;
|
final c = chat;
|
||||||
if (c == null) return 0;
|
if (c == null) return 0;
|
||||||
@@ -1442,6 +1497,16 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
final text = _messageController.text.trim();
|
final text = _messageController.text.trim();
|
||||||
if (text.isEmpty || _myId == 0) return;
|
if (text.isEmpty || _myId == 0) return;
|
||||||
|
|
||||||
|
if (AppCommands.current.value) {
|
||||||
|
final command = findSlashCommand(text);
|
||||||
|
if (command?.run != null) {
|
||||||
|
_messageController.clear();
|
||||||
|
_hasText.value = false;
|
||||||
|
unawaited(command!.run!(_commandContext()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final tempId = _nextTempId();
|
final tempId = _nextTempId();
|
||||||
final now = DateTime.now().millisecondsSinceEpoch;
|
final now = DateTime.now().millisecondsSinceEpoch;
|
||||||
final online = api.state == SessionState.online;
|
final online = api.state == SessionState.online;
|
||||||
@@ -1550,6 +1615,111 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int? _resolveOtherId() {
|
||||||
|
if (widget.chatType != 'DIALOG' || _myId == 0) return null;
|
||||||
|
final id = widget.chatId ^ _myId;
|
||||||
|
return id > 0 ? id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
CachedMessage _replaceMessage(
|
||||||
|
int index, {
|
||||||
|
String? id,
|
||||||
|
String? text,
|
||||||
|
String? status,
|
||||||
|
}) {
|
||||||
|
final old = _messages[index];
|
||||||
|
final updated = CachedMessage(
|
||||||
|
id: id ?? old.id,
|
||||||
|
accountId: old.accountId,
|
||||||
|
chatId: old.chatId,
|
||||||
|
senderId: old.senderId,
|
||||||
|
text: text ?? old.text,
|
||||||
|
time: old.time,
|
||||||
|
status: status ?? old.status,
|
||||||
|
payload: old.payload,
|
||||||
|
attachments: old.attachments,
|
||||||
|
isControl: old.isControl,
|
||||||
|
);
|
||||||
|
_messages[index] = updated;
|
||||||
|
_bumpMessages();
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _postCommandMessage(String text) async {
|
||||||
|
if (!mounted || _myId == 0) return '';
|
||||||
|
final tempId = _nextTempId();
|
||||||
|
final now = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
final online = api.state == SessionState.online;
|
||||||
|
final composed = CachedMessage(
|
||||||
|
id: tempId,
|
||||||
|
accountId: _myId,
|
||||||
|
chatId: widget.chatId,
|
||||||
|
senderId: _myId,
|
||||||
|
text: text,
|
||||||
|
time: now,
|
||||||
|
status: online ? 'sending' : 'pending',
|
||||||
|
);
|
||||||
|
_messages.add(composed);
|
||||||
|
_bumpMessages();
|
||||||
|
_scrollToBottom();
|
||||||
|
unawaited(_persistOutgoing(composed));
|
||||||
|
unawaited(ChatsModule.applyOutgoing(
|
||||||
|
_myId,
|
||||||
|
widget.chatId,
|
||||||
|
messageId: tempId,
|
||||||
|
time: now,
|
||||||
|
text: text,
|
||||||
|
status: composed.status ?? 'sending',
|
||||||
|
));
|
||||||
|
if (!online) return tempId;
|
||||||
|
try {
|
||||||
|
final actualId = await messagesModule.sendMessage(_myId, widget.chatId, text);
|
||||||
|
final realId = actualId.isNotEmpty ? actualId : tempId;
|
||||||
|
final i = _messages.indexWhere((m) => m.id == tempId);
|
||||||
|
if (i != -1) {
|
||||||
|
final sent = _replaceMessage(i, id: realId, status: 'sent');
|
||||||
|
unawaited(_persistOutgoing(sent, removeId: tempId));
|
||||||
|
unawaited(ChatsModule.applyOutgoing(
|
||||||
|
_myId,
|
||||||
|
widget.chatId,
|
||||||
|
messageId: realId,
|
||||||
|
time: now,
|
||||||
|
text: text,
|
||||||
|
status: 'sent',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return realId;
|
||||||
|
} catch (_) {
|
||||||
|
return tempId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _updateCommandMessage(String id, String text) async {
|
||||||
|
if (id.isEmpty) return;
|
||||||
|
final i = _messages.indexWhere((m) => m.id == id);
|
||||||
|
if (i != -1) {
|
||||||
|
final edited = _replaceMessage(i, text: text, status: 'EDITED');
|
||||||
|
unawaited(_persistOutgoing(edited));
|
||||||
|
}
|
||||||
|
if (!id.startsWith('temp_')) {
|
||||||
|
await messagesModule.editMessage(widget.chatId, id, text: text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CommandContext _commandContext() => CommandContext(
|
||||||
|
accountId: _myId,
|
||||||
|
chatId: widget.chatId,
|
||||||
|
otherUserId: _resolveOtherId(),
|
||||||
|
messages: messagesModule,
|
||||||
|
isOnline: () => api.state == SessionState.online,
|
||||||
|
isActive: () => mounted,
|
||||||
|
notify: (message) {
|
||||||
|
if (mounted) showCustomNotification(context, message);
|
||||||
|
},
|
||||||
|
postMessage: _postCommandMessage,
|
||||||
|
updateMessage: _updateCommandMessage,
|
||||||
|
);
|
||||||
|
|
||||||
Future<void> _scheduleMessage() async {
|
Future<void> _scheduleMessage() async {
|
||||||
final text = _messageController.text.trim();
|
final text = _messageController.text.trim();
|
||||||
if (text.isEmpty || _myId == 0) return;
|
if (text.isEmpty || _myId == 0) return;
|
||||||
@@ -2066,9 +2236,21 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _isLoading && _messages.isEmpty
|
child: Stack(
|
||||||
? _buildShimmerLoading()
|
children: [
|
||||||
: _buildMessagesList(),
|
Positioned.fill(
|
||||||
|
child: _isLoading && _messages.isEmpty
|
||||||
|
? _buildShimmerLoading()
|
||||||
|
: _buildMessagesList(),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: _buildCommandPanel(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
AnimatedBuilder(
|
AnimatedBuilder(
|
||||||
animation: _attachAnim,
|
animation: _attachAnim,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import '../../../backend/modules/chats.dart';
|
|||||||
import '../../../core/config/app_swipe_back_desktop.dart';
|
import '../../../core/config/app_swipe_back_desktop.dart';
|
||||||
import '../../../core/config/app_pranks.dart';
|
import '../../../core/config/app_pranks.dart';
|
||||||
import '../../../core/config/app_stories.dart';
|
import '../../../core/config/app_stories.dart';
|
||||||
|
import '../../../core/config/app_commands.dart';
|
||||||
import '../../../core/config/app_link_preview.dart';
|
import '../../../core/config/app_link_preview.dart';
|
||||||
import '../../../core/config/app_digital_id_mode.dart';
|
import '../../../core/config/app_digital_id_mode.dart';
|
||||||
import '../../../core/config/app_media_cache.dart';
|
import '../../../core/config/app_media_cache.dart';
|
||||||
@@ -786,6 +787,65 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||||
|
child: ValueListenableBuilder<bool>(
|
||||||
|
valueListenable: AppCommands.current,
|
||||||
|
builder: (context, commandsOn, _) {
|
||||||
|
return GlossyPill(
|
||||||
|
color: cs.surfaceContainerHigh,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
depth: 6,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 20,
|
||||||
|
vertical: 17,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Symbols.terminal,
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
size: 22,
|
||||||
|
weight: 400,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Команды',
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurface,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'Панель команд по вводу «/» в строке сообщения',
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Switch(
|
||||||
|
value: commandsOn,
|
||||||
|
onChanged: (v) {
|
||||||
|
AppCommands.save(v);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../commands/command_registry.dart';
|
||||||
|
import '../commands/slash_command.dart';
|
||||||
|
|
||||||
|
class CommandSuggestionsPanel extends StatelessWidget {
|
||||||
|
final List<SlashCommand> commands;
|
||||||
|
final double maxHeight;
|
||||||
|
final ValueChanged<SlashCommand>? onSelected;
|
||||||
|
|
||||||
|
const CommandSuggestionsPanel({
|
||||||
|
super.key,
|
||||||
|
this.commands = kSlashCommands,
|
||||||
|
this.maxHeight = 220,
|
||||||
|
this.onSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
final visible = commands.where((c) => !c.hidden).toList(growable: false);
|
||||||
|
return Material(
|
||||||
|
type: MaterialType.transparency,
|
||||||
|
child: Container(
|
||||||
|
constraints: BoxConstraints(maxHeight: maxHeight),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: cs.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: cs.outlineVariant.withValues(alpha: 0.3)),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: ListView.separated(
|
||||||
|
shrinkWrap: true,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
|
itemCount: visible.length,
|
||||||
|
separatorBuilder: (_, _) => Divider(
|
||||||
|
height: 1,
|
||||||
|
thickness: 1,
|
||||||
|
indent: 14,
|
||||||
|
endIndent: 14,
|
||||||
|
color: cs.outlineVariant.withValues(alpha: 0.18),
|
||||||
|
),
|
||||||
|
itemBuilder: (context, i) {
|
||||||
|
final c = visible[i];
|
||||||
|
return InkWell(
|
||||||
|
onTap: onSelected == null ? null : () => onSelected!(c),
|
||||||
|
child: Padding(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 84,
|
||||||
|
child: Text(
|
||||||
|
c.name,
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.primary,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
c.description,
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurfaceVariant,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import 'core/config/app_message_actions_style.dart';
|
|||||||
import 'core/config/app_swipe_back_desktop.dart';
|
import 'core/config/app_swipe_back_desktop.dart';
|
||||||
import 'core/config/app_pranks.dart';
|
import 'core/config/app_pranks.dart';
|
||||||
import 'core/config/app_stories.dart';
|
import 'core/config/app_stories.dart';
|
||||||
|
import 'core/config/app_commands.dart';
|
||||||
import 'core/config/app_link_preview.dart';
|
import 'core/config/app_link_preview.dart';
|
||||||
import 'core/config/app_media_cache.dart';
|
import 'core/config/app_media_cache.dart';
|
||||||
import 'core/config/app_pill_gradient.dart';
|
import 'core/config/app_pill_gradient.dart';
|
||||||
@@ -112,6 +113,7 @@ void main() async {
|
|||||||
final swipeBackFuture = AppSwipeBackDesktop.load();
|
final swipeBackFuture = AppSwipeBackDesktop.load();
|
||||||
final pranksFuture = AppPranks.load();
|
final pranksFuture = AppPranks.load();
|
||||||
final storiesFuture = AppStories.load();
|
final storiesFuture = AppStories.load();
|
||||||
|
final commandsFuture = AppCommands.load();
|
||||||
final linkPreviewFuture = AppLinkPreview.load();
|
final linkPreviewFuture = AppLinkPreview.load();
|
||||||
final cacheLimitFuture = AppMediaCacheLimit.load();
|
final cacheLimitFuture = AppMediaCacheLimit.load();
|
||||||
final digitalIdNativeFuture = AppDigitalIdNative.load();
|
final digitalIdNativeFuture = AppDigitalIdNative.load();
|
||||||
@@ -150,6 +152,7 @@ void main() async {
|
|||||||
AppSwipeBackDesktop.current.value = await swipeBackFuture;
|
AppSwipeBackDesktop.current.value = await swipeBackFuture;
|
||||||
AppPranks.current.value = await pranksFuture;
|
AppPranks.current.value = await pranksFuture;
|
||||||
AppStories.current.value = await storiesFuture;
|
AppStories.current.value = await storiesFuture;
|
||||||
|
AppCommands.current.value = await commandsFuture;
|
||||||
AppLinkPreview.current.value = await linkPreviewFuture;
|
AppLinkPreview.current.value = await linkPreviewFuture;
|
||||||
AppMediaCacheLimit.current.value = await cacheLimitFuture;
|
AppMediaCacheLimit.current.value = await cacheLimitFuture;
|
||||||
AppDigitalIdNative.current.value = await digitalIdNativeFuture;
|
AppDigitalIdNative.current.value = await digitalIdNativeFuture;
|
||||||
|
|||||||
-72
@@ -1,72 +0,0 @@
|
|||||||
"""mitmproxy addon: dump okcdn call signaling (ws2 WebSocket + HTTP) to a log."""
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
|
|
||||||
from mitmproxy import http, ctx
|
|
||||||
|
|
||||||
LOG = r"C:\Users\klockky\Komet\docs\ws2_capture.log"
|
|
||||||
HOSTS = ("okcdn.ru", "videowebrtc")
|
|
||||||
|
|
||||||
|
|
||||||
def _interesting(host: str) -> bool:
|
|
||||||
return any(h in host for h in HOSTS)
|
|
||||||
|
|
||||||
|
|
||||||
def _w(line: str) -> None:
|
|
||||||
with open(LOG, "a", encoding="utf-8") as f:
|
|
||||||
f.write(line + "\n")
|
|
||||||
|
|
||||||
|
|
||||||
def _fmt(content: bytes) -> str:
|
|
||||||
try:
|
|
||||||
text = content.decode("utf-8")
|
|
||||||
try:
|
|
||||||
return json.dumps(json.loads(text), ensure_ascii=False, indent=2)
|
|
||||||
except Exception:
|
|
||||||
return text
|
|
||||||
except Exception:
|
|
||||||
return "HEX " + content.hex()
|
|
||||||
|
|
||||||
|
|
||||||
def websocket_start(flow: http.HTTPFlow) -> None:
|
|
||||||
if not _interesting(flow.request.pretty_host):
|
|
||||||
return
|
|
||||||
_w("=" * 70)
|
|
||||||
_w(f"# WS OPEN {flow.request.pretty_host} {flow.request.path}")
|
|
||||||
_w(f" headers: {dict(flow.request.headers)}")
|
|
||||||
_w("=" * 70)
|
|
||||||
|
|
||||||
|
|
||||||
def websocket_message(flow: http.HTTPFlow) -> None:
|
|
||||||
if not _interesting(flow.request.pretty_host):
|
|
||||||
return
|
|
||||||
msg = flow.websocket.messages[-1]
|
|
||||||
arrow = "TX (client->server)" if msg.from_client else "RX (server->client)"
|
|
||||||
ts = time.strftime("%H:%M:%S")
|
|
||||||
_w(f"\n--- {arrow} {ts} {len(msg.content)} B host={flow.request.pretty_host} ---")
|
|
||||||
_w(_fmt(msg.content))
|
|
||||||
|
|
||||||
|
|
||||||
def websocket_end(flow: http.HTTPFlow) -> None:
|
|
||||||
if not _interesting(flow.request.pretty_host):
|
|
||||||
return
|
|
||||||
_w(f"\n# WS CLOSE {flow.request.pretty_host}\n")
|
|
||||||
|
|
||||||
|
|
||||||
def response(flow: http.HTTPFlow) -> None:
|
|
||||||
host = flow.request.pretty_host
|
|
||||||
if not _interesting(host):
|
|
||||||
return
|
|
||||||
if flow.websocket is not None:
|
|
||||||
return
|
|
||||||
_w("\n" + "#" * 70)
|
|
||||||
_w(f"# HTTP {flow.request.method} {host}{flow.request.path} -> {flow.response.status_code}")
|
|
||||||
if flow.request.content:
|
|
||||||
_w(" REQ: " + _fmt(flow.request.content)[:2000])
|
|
||||||
if flow.response.content:
|
|
||||||
_w(" RES: " + _fmt(flow.response.content)[:2000])
|
|
||||||
|
|
||||||
|
|
||||||
def load(loader) -> None:
|
|
||||||
_w(f"\n\n########## capture session start {time.strftime('%Y-%m-%d %H:%M:%S')} ##########")
|
|
||||||
ctx.log.info("ws2_dump addon loaded")
|
|
||||||
Reference in New Issue
Block a user