diff --git a/lib/backend/modules/chats.dart b/lib/backend/modules/chats.dart index 3532edb..27a9344 100644 --- a/lib/backend/modules/chats.dart +++ b/lib/backend/modules/chats.dart @@ -538,6 +538,19 @@ class ChatsModule { mergedPayload[entry.key.toString()] = entry.value; } final newRow = Map.from(existing); + if (KometSettings.viewRedacted.value) { + final oldText = existing['text']?.toString(); + if ((oldText ?? '') != (msgText ?? '') && + oldText != null && + oldText.isNotEmpty) { + final history = CachedMessage.appendEditHistory( + CachedMessage.parseEditHistory(existing['edit_history']), + oldText, + DateTime.now().millisecondsSinceEpoch, + ); + newRow['edit_history'] = jsonEncode(history); + } + } newRow['text'] = msgText; newRow['status'] = status; newRow['payload'] = jsonEncode(mergedPayload); diff --git a/lib/backend/modules/complaints.dart b/lib/backend/modules/complaints.dart new file mode 100644 index 0000000..0d9fe2f --- /dev/null +++ b/lib/backend/modules/complaints.dart @@ -0,0 +1,79 @@ +import '../api.dart'; +import '../../core/protocol/opcode_map.dart'; + +class ComplaintReason { + final int reasonId; + final String reasonTitle; + + const ComplaintReason({required this.reasonId, required this.reasonTitle}); +} + +class ComplaintsModule { + static Map>? _cache; + + static Future>> fetchReasons(Api api) async { + final cached = _cache; + if (cached != null) return cached; + + final response = await api.sendRequest( + Opcode.complainReasonsGet, + {'complainSync': 0}, + ); + if (!response.isOk) return cached ?? const {}; + + final payload = response.payload; + if (payload is! Map) return const {}; + + final complains = payload['complains']; + final map = >{}; + if (complains is List) { + for (final entry in complains) { + if (entry is! Map) continue; + final typeId = entry['typeId']; + final reasons = entry['reasons']; + if (typeId is! int || reasons is! List) continue; + map[typeId] = reasons + .whereType() + .map( + (r) => ComplaintReason( + reasonId: r['reasonId'] is int ? r['reasonId'] as int : 0, + reasonTitle: r['reasonTitle']?.toString() ?? '', + ), + ) + .where((r) => r.reasonId != 0) + .toList(); + } + } + + _cache = map; + return map; + } + + static Future> reasonsFor(Api api, int typeId) async { + final map = await fetchReasons(api); + final forType = map[typeId]; + if (forType != null && forType.isNotEmpty) return forType; + for (final list in map.values) { + if (list.isNotEmpty) return list; + } + return const []; + } + + static Future sendComplaint( + Api api, { + required int reasonId, + required int typeId, + required List ids, + required int parentId, + }) async { + final response = await api.sendRequest(Opcode.complain, { + 'reasonId': reasonId, + 'typeId': typeId, + 'ids': ids, + 'parentId': parentId, + }); + if (!response.isOk) return false; + final payload = response.payload; + return payload is Map && payload['success'] == true; + } +} diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index dd8fab5..70b2262 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../api.dart'; +import '../../core/config/komet_settings.dart'; import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/packet.dart'; import '../../core/storage/app_database.dart'; @@ -365,6 +366,7 @@ class CachedMessage { final List? attachments; final bool isControl; final bool deleted; + final List>? editHistory; const CachedMessage({ required this.id, @@ -378,12 +380,14 @@ class CachedMessage { this.attachments, this.isControl = false, this.deleted = false, + this.editHistory, }); CachedMessage copyWith({ String? status, bool? deleted, List? attachments, + List>? editHistory, }) => CachedMessage( id: id, accountId: accountId, @@ -396,8 +400,39 @@ class CachedMessage { attachments: attachments ?? this.attachments, isControl: isControl, deleted: deleted ?? this.deleted, + editHistory: editHistory ?? this.editHistory, ); + static List>? parseEditHistory(dynamic raw) { + if (raw is! String || raw.isEmpty) return null; + try { + final decoded = jsonDecode(raw); + if (decoded is List) { + final list = decoded + .whereType() + .map((e) => Map.from(e)) + .toList(); + return list.isEmpty ? null : list; + } + } catch (_) {} + return null; + } + + static List> appendEditHistory( + List>? current, + String? oldText, + int time, + ) { + final list = current != null + ? List>.from(current) + : >[]; + if (list.isNotEmpty && (list.last['text'] as String?) == oldText) { + return list; + } + list.add({'text': oldText, 'time': time}); + return list; + } + factory CachedMessage.fromDbRow(Map row) { Map? payload; final payloadRaw = row['payload']; @@ -449,6 +484,7 @@ class CachedMessage { deleted: row['deleted'] is int ? row['deleted'] == 1 : row['deleted']?.toString() == '1', + editHistory: parseEditHistory(row['edit_history']), ); } @@ -488,6 +524,7 @@ class CachedMessage { 'status': status, 'payload': payload != null ? jsonEncode(payload) : null, 'deleted': deleted ? 1 : 0, + 'edit_history': editHistory != null ? jsonEncode(editHistory) : null, }; static CachedMessage fromPushPayload(int accountId, int chatId, Map msg) { @@ -551,7 +588,6 @@ class MessagesModule { if (messagesData is! List) return []; final List results = []; - final List> rows = []; for (var i = 0; i < messagesData.length; i++) { final m = messagesData[i]; @@ -560,7 +596,6 @@ class MessagesModule { final msg = _parseMessage(m.cast(), accountId, chatId); if (msg != null) { results.add(msg); - rows.add(msg.toDbRow()); } if (i > 0 && i % 20 == 0) { @@ -568,15 +603,57 @@ class MessagesModule { } } - if (rows.isNotEmpty) { + final toSave = KometSettings.viewRedacted.value && results.isNotEmpty + ? await _mergeEditHistory(accountId, chatId, results) + : results; + + if (toSave.isNotEmpty) { try { - await AppDatabase.saveMessages(rows); + await AppDatabase.saveMessages( + toSave.map((m) => m.toDbRow()).toList(), + ); } catch (e) { logger.e('saveMessages error: $e'); } } - return results; + return toSave; + } + + Future> _mergeEditHistory( + int accountId, + int chatId, + List serverMessages, + ) async { + final cachedRows = await AppDatabase.loadMessagesByIds( + accountId, + chatId, + serverMessages.map((m) => m.id).toList(), + ); + final byId = >{}; + for (final row in cachedRows) { + final id = row['id']?.toString(); + if (id != null) byId[id] = row; + } + + final now = DateTime.now().millisecondsSinceEpoch; + final out = []; + for (final msg in serverMessages) { + final existing = byId[msg.id]; + if (existing == null) { + out.add(msg); + continue; + } + var history = CachedMessage.parseEditHistory(existing['edit_history']); + final oldText = existing['text']?.toString(); + if ((oldText ?? '') != (msg.text ?? '') && + oldText != null && + oldText.isNotEmpty) { + history = CachedMessage.appendEditHistory(history, oldText, now); + } + out.add(history == null ? msg : msg.copyWith(editHistory: history)); + } + return out; } /// Загружает сообщения из локальной базы данных. diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 63640cf..a8ea7a6 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -190,7 +190,7 @@ class AppDatabase { await _migrateLegacyDb(target); return openDatabase( target, - version: 14, + version: 15, onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'), onCreate: (db, _) => _createTables(db), onUpgrade: (db, oldVersion, newVersion) async { @@ -244,6 +244,9 @@ class AppDatabase { db, 'chats_cache', 'in_list', 'INTEGER NOT NULL DEFAULT 1', ); } + if (oldVersion < 15) { + await _addColumnIfMissing(db, 'messages', 'edit_history', 'TEXT'); + } }, ); } @@ -359,6 +362,7 @@ class AppDatabase { status TEXT, payload TEXT, deleted INTEGER NOT NULL DEFAULT 0, + edit_history TEXT, PRIMARY KEY (id, account_id), FOREIGN KEY (chat_id, account_id) REFERENCES chats_cache (id, account_id) ON DELETE CASCADE ) @@ -780,6 +784,21 @@ class AppDatabase { ); } + static Future>> loadMessagesByIds( + int accountId, + int chatId, + List messageIds, + ) async { + if (messageIds.isEmpty) return const []; + final db = await _instance; + final placeholders = List.filled(messageIds.length, '?').join(','); + return db.query( + 'messages', + where: 'account_id = ? AND chat_id = ? AND id IN ($placeholders)', + whereArgs: [accountId, chatId, ...messageIds], + ); + } + static Future?> loadMessage( int accountId, int chatId, diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 5a7faae..2529985 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -29,6 +29,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../../main.dart'; import '../../../backend/api.dart'; import '../../../backend/modules/messages.dart'; +import '../../../backend/modules/complaints.dart'; import '../../../core/calls/call_controller.dart'; import '../calls/call_screen.dart'; import '../../../core/protocol/opcode_map.dart'; @@ -1561,6 +1562,13 @@ class _ChatScreenState extends State final idx = _messages.indexWhere((m) => m.id == message.id); if (idx != -1) { final old = _messages[idx]; + final newHistory = KometSettings.viewRedacted.value + ? CachedMessage.appendEditHistory( + old.editHistory, + old.text, + DateTime.now().millisecondsSinceEpoch, + ) + : old.editHistory; final edited = CachedMessage( id: old.id, accountId: old.accountId, @@ -1572,6 +1580,7 @@ class _ChatScreenState extends State payload: old.payload, attachments: old.attachments, isControl: old.isControl, + editHistory: newHistory, ); _messages[idx] = edited; _bumpMessages(); @@ -2743,6 +2752,49 @@ class _ChatScreenState extends State return id > 0 ? id : null; } + int _complaintTypeId(String type) { + switch (type) { + case 'CHANNEL': + return 5; + case 'CHAT': + return 4; + default: + return 3; + } + } + + Future> _loadReportReasons(int typeId) async { + final reasons = await ComplaintsModule.reasonsFor(api, typeId); + return reasons.map((r) => (id: r.reasonId, title: r.reasonTitle)).toList(); + } + + Future _reportMessage( + CachedMessage message, + int typeId, + int reasonId, + ) async { + final messageIdNum = int.tryParse(message.id); + if (messageIdNum == null) { + if (mounted) { + showCustomNotification(context, 'Не удалось отправить жалобу'); + } + return false; + } + final ok = await ComplaintsModule.sendComplaint( + api, + reasonId: reasonId, + typeId: typeId, + ids: [messageIdNum], + parentId: widget.chatId, + ); + if (!mounted) return ok; + showCustomNotification( + context, + ok ? 'Жалоба отправлена' : 'Не удалось отправить жалобу', + ); + return ok; + } + CachedMessage _replaceMessage( int index, { String? id, @@ -2761,6 +2813,7 @@ class _ChatScreenState extends State payload: old.payload, attachments: old.attachments, isControl: old.isControl, + editHistory: old.editHistory, ); _messages[index] = updated; _bumpMessages(); @@ -3373,6 +3426,11 @@ class _ChatScreenState extends State onAvatarTap: _openSenderProfile, ); + final canReport = !isMe && !message.isControl; + final reportTypeId = _complaintTypeId( + chat?.type ?? widget.chatType, + ); + final pressable = _SelectableMessageRow( message: message, isMe: isMe, @@ -3391,6 +3449,13 @@ class _ChatScreenState extends State onForward: message.isControl ? null : () => _forwardMessages([message]), + loadReportReasons: canReport + ? () => _loadReportReasons(reportTypeId) + : null, + onReport: canReport + ? (reasonId) => + _reportMessage(message, reportTypeId, reasonId) + : null, child: bubble, ); @@ -5825,6 +5890,8 @@ class _SelectableMessageRow extends StatefulWidget { final VoidCallback? onEdit; final VoidCallback? onReply; final VoidCallback? onForward; + final Future> Function()? loadReportReasons; + final Future Function(int reasonId)? onReport; const _SelectableMessageRow({ required this.child, @@ -5839,6 +5906,8 @@ class _SelectableMessageRow extends StatefulWidget { this.onEdit, this.onReply, this.onForward, + this.loadReportReasons, + this.onReport, }); @override @@ -5882,6 +5951,9 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> { controller: controller, style: AppMessageActionsStyle.current.value, interaction: MessageActionsInteraction.tap, + editHistory: widget.message.editHistory, + loadReportReasons: widget.loadReportReasons, + onReport: widget.onReport, onDelete: widget.onDelete, onEdit: widget.onEdit, onReply: widget.onReply, @@ -5909,6 +5981,9 @@ class _SelectableMessageRowState extends State<_SelectableMessageRow> { controller: controller, style: MessageActionsStyle.list, interaction: MessageActionsInteraction.click, + editHistory: widget.message.editHistory, + loadReportReasons: widget.loadReportReasons, + onReport: widget.onReport, onDelete: widget.onDelete, onEdit: widget.onEdit, onReply: widget.onReply, diff --git a/lib/frontend/widgets/message_actions_overlay.dart b/lib/frontend/widgets/message_actions_overlay.dart index 5ba6624..549dbd7 100644 --- a/lib/frontend/widgets/message_actions_overlay.dart +++ b/lib/frontend/widgets/message_actions_overlay.dart @@ -7,6 +7,7 @@ import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../core/config/app_message_actions_style.dart'; +import '../../core/utils/format.dart'; import '../../core/utils/haptics.dart'; import 'custom_notification.dart'; @@ -73,6 +74,9 @@ void showMessageActions({ required MessageActionsController controller, required MessageActionsStyle style, required VoidCallback onDispose, + List>? editHistory, + Future> Function()? loadReportReasons, + Future Function(int reasonId)? onReport, VoidCallback? onDelete, VoidCallback? onEdit, VoidCallback? onReply, @@ -91,6 +95,9 @@ void showMessageActions({ controller: controller, style: style, interaction: interaction, + editHistory: editHistory, + loadReportReasons: loadReportReasons, + onReport: onReport, onDelete: onDelete, onEdit: onEdit, onReply: onReply, @@ -114,6 +121,9 @@ class _MessageActionsLayer extends StatefulWidget { final MessageActionsStyle style; final MessageActionsInteraction interaction; final VoidCallback onDismiss; + final List>? editHistory; + final Future> Function()? loadReportReasons; + final Future Function(int reasonId)? onReport; final VoidCallback? onDelete; final VoidCallback? onEdit; final VoidCallback? onReply; @@ -129,6 +139,9 @@ class _MessageActionsLayer extends StatefulWidget { required this.style, required this.interaction, required this.onDismiss, + this.editHistory, + this.loadReportReasons, + this.onReport, this.onDelete, this.onEdit, this.onReply, @@ -161,6 +174,11 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> int _hoveredIndex = -1; bool _committedFired = false; + bool _showHistory = false; + bool _showReport = false; + bool _reportLoading = false; + bool _reportSending = false; + List<({int id, String title})>? _reasons; @override void initState() { @@ -303,6 +321,15 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> _Action(Symbols.reply, 'Ответить', _reply), if (widget.onForward != null) _Action(Symbols.forward, 'Переслать', _forward), + if (widget.editHistory != null && widget.editHistory!.isNotEmpty) + _Action(Symbols.history, 'История изменений', _showHistoryView), + if (widget.onReport != null && widget.loadReportReasons != null) + _Action( + Symbols.flag, + 'Пожаловаться', + _showReportView, + destructive: true, + ), _Action( Symbols.delete, 'Удалить', @@ -312,6 +339,47 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> ]; } + void _showHistoryView() { + if (!mounted) return; + setState(() => _showHistory = true); + } + + Future _showReportView() async { + if (!mounted) return; + setState(() { + _showReport = true; + _reportLoading = _reasons == null; + }); + if (_reasons != null) return; + final loaded = await widget.loadReportReasons?.call(); + if (!mounted) return; + setState(() { + _reasons = loaded ?? const []; + _reportLoading = false; + }); + } + + Future _submitReport(int reasonId) async { + if (_reportSending) return; + setState(() => _reportSending = true); + final report = widget.onReport; + final ok = report == null ? false : await report(reasonId); + if (!mounted) return; + if (ok) { + await _close(); + } else { + setState(() => _reportSending = false); + } + } + + void _backToMenu() { + if (!mounted) return; + setState(() { + _showHistory = false; + _showReport = false; + }); + } + void _onControllerUpdate() { if (!mounted) return; @@ -435,11 +503,43 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> ), ), ], - if (_effectiveStyle == MessageActionsStyle.radial) ...[ - ..._buildButtons(t), - _buildLabelBanner(size, t), - ] else - _buildListMenu(t), + Positioned.fill( + child: IgnorePointer( + ignoring: _showHistory || _showReport, + child: AnimatedOpacity( + opacity: (_showHistory || _showReport) ? 0.0 : 1.0, + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + child: Stack( + children: [ + if (_effectiveStyle == MessageActionsStyle.radial) ...[ + ..._buildButtons(t), + _buildLabelBanner(size, t), + ] else + _buildListMenu(t), + ], + ), + ), + ), + ), + Positioned.fill( + child: IgnorePointer( + ignoring: !(_showHistory || _showReport), + child: AnimatedOpacity( + opacity: (_showHistory || _showReport) ? 1.0 : 0.0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + child: Stack( + children: [ + if (_showReport) + _buildReportMenu() + else if (_showHistory) + _buildHistoryMenu(), + ], + ), + ), + ), + ), ], ), ); @@ -447,6 +547,216 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> ); } + Widget _buildAnchoredPanel({required String title, required Widget body}) { + final cs = Theme.of(context).colorScheme; + final size = MediaQuery.sizeOf(context); + const menuWidth = 220.0; + + double left; + double top; + if (_menuRect != Rect.zero) { + left = _menuRect.left; + top = _menuRect.top; + } else { + left = widget.isMe + ? widget.originRect.right - menuWidth + : widget.originRect.left; + top = _showBelow + ? widget.originRect.bottom + 10 + : widget.originRect.top - 10; + } + left = left.clamp(8.0, size.width - menuWidth - 8.0); + top = top.clamp(8.0, size.height - 160.0); + final maxHeight = math.min(size.height * 0.6, size.height - top - 8.0); + + return Positioned( + left: left, + top: top, + width: menuWidth, + child: GestureDetector( + onTap: () {}, + child: Material( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(16), + clipBehavior: Clip.antiAlias, + elevation: 8, + shadowColor: Colors.black.withValues(alpha: 0.4), + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxHeight), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 44, + child: Row( + children: [ + _panelBackButton(cs), + Expanded( + child: Text( + title, + style: TextStyle( + color: cs.onSurface, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 12), + ], + ), + ), + Divider( + height: 1, + color: cs.outlineVariant.withValues(alpha: 0.4), + ), + Flexible(child: body), + ], + ), + ), + ), + ), + ); + } + + Widget _panelBackButton(ColorScheme cs) => Material( + color: Colors.transparent, + shape: const CircleBorder(), + child: InkWell( + customBorder: const CircleBorder(), + onTap: () { + Haptics.tap(); + _backToMenu(); + }, + child: Padding( + padding: const EdgeInsets.all(10), + child: Icon(Symbols.arrow_back, color: cs.onSurface, size: 20), + ), + ), + ); + + Widget _buildHistoryMenu() { + final cs = Theme.of(context).colorScheme; + final history = widget.editHistory ?? const >[]; + + final rows = []; + for (var i = 0; i < history.length; i++) { + if (i > 0) rows.add(_historyDivider(cs)); + rows.add( + _historyRow( + cs, + history[i]['text'] as String?, + history[i]['time'], + current: false, + ), + ); + } + final currentTime = history.isNotEmpty ? history.last['time'] : null; + if (rows.isNotEmpty) rows.add(_historyDivider(cs)); + rows.add(_historyRow(cs, widget.messageText, currentTime, current: true)); + + return _buildAnchoredPanel( + title: 'История изменений', + body: SingleChildScrollView( + child: Column(mainAxisSize: MainAxisSize.min, children: rows), + ), + ); + } + + Widget _buildReportMenu() { + final cs = Theme.of(context).colorScheme; + final Widget body; + if (_reportLoading) { + body = const Padding( + padding: EdgeInsets.symmetric(vertical: 28), + child: Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2.4), + ), + ), + ); + } else { + final reasons = _reasons ?? const <({int id, String title})>[]; + if (reasons.isEmpty) { + body = Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18), + child: Text( + 'Не удалось загрузить причины', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ); + } else { + final rows = []; + for (var i = 0; i < reasons.length; i++) { + if (i > 0) rows.add(_historyDivider(cs)); + rows.add(_reasonRow(cs, reasons[i])); + } + body = SingleChildScrollView( + child: Column(mainAxisSize: MainAxisSize.min, children: rows), + ); + } + } + return _buildAnchoredPanel(title: 'Пожаловаться', body: body); + } + + Widget _reasonRow(ColorScheme cs, ({int id, String title}) reason) => + Material( + color: Colors.transparent, + child: InkWell( + onTap: _reportSending ? null : () => _submitReport(reason.id), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + child: Text( + reason.title, + style: TextStyle(color: cs.onSurface, fontSize: 14), + ), + ), + ), + ); + + Widget _historyDivider(ColorScheme cs) => Divider( + height: 1, + color: cs.outlineVariant.withValues(alpha: 0.25), + ); + + Widget _historyRow( + ColorScheme cs, + String? text, + dynamic time, { + required bool current, + }) { + final ms = time is int ? time : int.tryParse(time?.toString() ?? ''); + final dateStr = ms != null + ? formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(ms)) + : ''; + final label = current + ? (dateStr.isEmpty ? 'текущая версия' : 'текущая версия · $dateStr') + : dateStr; + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 11), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + text == null || text.isEmpty ? '(без текста)' : text, + style: TextStyle( + color: current ? cs.primary : cs.onSurface, + fontSize: 15, + height: 1.3, + ), + ), + const SizedBox(height: 4), + Text( + label, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11), + ), + ], + ), + ); + } + Widget _buildListMenu(double t) { final cs = Theme.of(context).colorScheme; final eased = Curves.easeOutCubic.transform(t);