feat: история изменения сообщений

This commit is contained in:
Jganenokk
2026-06-29 15:05:22 +07:00
parent 0abbc2b59d
commit cd1ebaee24
6 changed files with 584 additions and 11 deletions
+13
View File
@@ -538,6 +538,19 @@ class ChatsModule {
mergedPayload[entry.key.toString()] = entry.value;
}
final newRow = Map<String, dynamic>.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);
+79
View File
@@ -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<int, List<ComplaintReason>>? _cache;
static Future<Map<int, List<ComplaintReason>>> 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 = <int, List<ComplaintReason>>{};
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>()
.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<List<ComplaintReason>> 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<bool> sendComplaint(
Api api, {
required int reasonId,
required int typeId,
required List<int> 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;
}
}
+82 -5
View File
@@ -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<MessageAttachment>? attachments;
final bool isControl;
final bool deleted;
final List<Map<String, dynamic>>? 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<MessageAttachment>? attachments,
List<Map<String, dynamic>>? 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<Map<String, dynamic>>? 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>()
.map((e) => Map<String, dynamic>.from(e))
.toList();
return list.isEmpty ? null : list;
}
} catch (_) {}
return null;
}
static List<Map<String, dynamic>> appendEditHistory(
List<Map<String, dynamic>>? current,
String? oldText,
int time,
) {
final list = current != null
? List<Map<String, dynamic>>.from(current)
: <Map<String, dynamic>>[];
if (list.isNotEmpty && (list.last['text'] as String?) == oldText) {
return list;
}
list.add({'text': oldText, 'time': time});
return list;
}
factory CachedMessage.fromDbRow(Map<String, dynamic> row) {
Map<String, dynamic>? 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<CachedMessage> results = [];
final List<Map<String, dynamic>> rows = [];
for (var i = 0; i < messagesData.length; i++) {
final m = messagesData[i];
@@ -560,7 +596,6 @@ class MessagesModule {
final msg = _parseMessage(m.cast<dynamic, dynamic>(), 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<List<CachedMessage>> _mergeEditHistory(
int accountId,
int chatId,
List<CachedMessage> serverMessages,
) async {
final cachedRows = await AppDatabase.loadMessagesByIds(
accountId,
chatId,
serverMessages.map((m) => m.id).toList(),
);
final byId = <String, Map<String, dynamic>>{};
for (final row in cachedRows) {
final id = row['id']?.toString();
if (id != null) byId[id] = row;
}
final now = DateTime.now().millisecondsSinceEpoch;
final out = <CachedMessage>[];
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;
}
/// Загружает сообщения из локальной базы данных.
+20 -1
View File
@@ -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<List<Map<String, dynamic>>> loadMessagesByIds(
int accountId,
int chatId,
List<String> 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<Map<String, dynamic>?> loadMessage(
int accountId,
int chatId,
@@ -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<ChatScreen>
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<ChatScreen>
payload: old.payload,
attachments: old.attachments,
isControl: old.isControl,
editHistory: newHistory,
);
_messages[idx] = edited;
_bumpMessages();
@@ -2743,6 +2752,49 @@ class _ChatScreenState extends State<ChatScreen>
return id > 0 ? id : null;
}
int _complaintTypeId(String type) {
switch (type) {
case 'CHANNEL':
return 5;
case 'CHAT':
return 4;
default:
return 3;
}
}
Future<List<({int id, String title})>> _loadReportReasons(int typeId) async {
final reasons = await ComplaintsModule.reasonsFor(api, typeId);
return reasons.map((r) => (id: r.reasonId, title: r.reasonTitle)).toList();
}
Future<bool> _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<ChatScreen>
payload: old.payload,
attachments: old.attachments,
isControl: old.isControl,
editHistory: old.editHistory,
);
_messages[index] = updated;
_bumpMessages();
@@ -3373,6 +3426,11 @@ class _ChatScreenState extends State<ChatScreen>
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<ChatScreen>
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<List<({int id, String title})>> Function()? loadReportReasons;
final Future<bool> 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,
@@ -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<Map<String, dynamic>>? editHistory,
Future<List<({int id, String title})>> Function()? loadReportReasons,
Future<bool> 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<Map<String, dynamic>>? editHistory;
final Future<List<({int id, String title})>> Function()? loadReportReasons;
final Future<bool> 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<void> _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<void> _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 <Map<String, dynamic>>[];
final rows = <Widget>[];
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 = <Widget>[];
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);