feat(chat): удаление сообщений (у себя/у всех)

This commit is contained in:
klockky
2026-06-12 08:02:31 +00:00
parent 6fc7565aec
commit bd03409ebb
3 changed files with 246 additions and 13 deletions
+22
View File
@@ -467,6 +467,28 @@ class MessagesModule {
return '';
}
Future<bool> deleteMessages(
int chatId,
List<String> messageIds, {
bool forEveryone = false,
}) async {
final ids = messageIds
.map((id) => int.tryParse(id))
.whereType<int>()
.toList();
if (ids.isEmpty) return false;
final payload = {
'messageIds': ids,
'chatId': chatId,
'forMe': !forEveryone,
'itemType': 'REGULAR',
};
final response = await _api.sendRequest(Opcode.msgDelete, payload);
return response.isOk;
}
Future<TranscriptionResult> requestTranscription(
int chatId,
int messageId,
+213 -12
View File
@@ -156,6 +156,7 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
bool _historyKickedOff = false;
List<CachedMessage> _messages = [];
final ValueNotifier<int> _messagesRev = ValueNotifier(0);
final Set<String> _deletingIds = {};
List<Object>? _combinedItemsCache;
int? _combinedItemsKey;
bool _floatingDateScheduled = false;
@@ -622,6 +623,123 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
_messagesRev.value++;
}
Future<void> _confirmDeleteMessage(CachedMessage message, bool isMe) async {
final isLocalOnly = message.id.startsWith('temp_');
final canForEveryone = isMe && !isLocalOnly;
if (isLocalOnly) {
_startDeleteAnimation(message.id);
return;
}
final forEveryone = await _showDeleteMessageDialog(canForEveryone);
if (forEveryone == null || !mounted) return;
final ok = await messagesModule.deleteMessages(
widget.chatId,
[message.id],
forEveryone: forEveryone,
);
if (!mounted) return;
if (!ok) {
Haptics.error();
showCustomNotification(context, 'Не удалось удалить сообщение');
return;
}
_startDeleteAnimation(message.id);
}
void _startDeleteAnimation(String messageId) {
if (!_deletingIds.add(messageId)) return;
Haptics.tap();
_bumpMessages();
}
Future<void> _finalizeDelete(String messageId) async {
if (!mounted) return;
_deletingIds.remove(messageId);
final idx = _messages.indexWhere((m) => m.id == messageId);
if (idx != -1) {
_messages.removeAt(idx);
_reactionNotifiers.remove(messageId)?.dispose();
}
_bumpMessages();
try {
await AppDatabase.deleteMessage(_myId, widget.chatId, messageId);
} catch (_) {}
}
Future<bool?> _showDeleteMessageDialog(bool canForEveryone) {
final cs = Theme.of(context).colorScheme;
var alsoForEveryone = canForEveryone;
return showDialog<bool>(
context: context,
builder: (ctx) {
return StatefulBuilder(
builder: (ctx, setLocalState) {
return AlertDialog(
backgroundColor: cs.surfaceContainerHigh,
title: const Text('Удалить сообщение'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Вы точно хотите удалить это сообщение?',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15),
),
if (canForEveryone) ...[
const SizedBox(height: 16),
InkWell(
onTap: () => setLocalState(
() => alsoForEveryone = !alsoForEveryone,
),
borderRadius: BorderRadius.circular(8),
child: Row(
children: [
Checkbox(
value: alsoForEveryone,
onChanged: (v) => setLocalState(
() => alsoForEveryone = v ?? false,
),
),
Expanded(
child: Text(
'Также удалить для ${widget.name}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: cs.onSurface,
fontSize: 15,
),
),
),
],
),
),
],
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Отмена'),
),
TextButton(
onPressed: () => Navigator.pop(
ctx,
canForEveryone && alsoForEveryone,
),
child: Text('Удалить', style: TextStyle(color: cs.error)),
),
],
);
},
);
},
);
}
void _onMessageEvent(MessageEvent event) {
if (!mounted) return;
switch (event) {
@@ -1539,21 +1657,31 @@ class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
final pressable = _LongPressBubble(
message: message,
isMe: isMe,
onDelete: () => _confirmDeleteMessage(message, isMe),
child: bubble,
);
final Widget child = message.id == _lastSentId
? _SentMessageAnimation(
key: ValueKey('anim_${message.id}'),
onComplete: () {
if (mounted) {
_lastSentId = null;
_bumpMessages();
}
},
child: pressable,
)
: pressable;
final Widget child;
if (_deletingIds.contains(message.id)) {
child = _DeletingMessageAnimation(
key: ValueKey('del_${message.id}'),
onComplete: () => _finalizeDelete(message.id),
child: IgnorePointer(child: pressable),
);
} else if (message.id == _lastSentId) {
child = _SentMessageAnimation(
key: ValueKey('anim_${message.id}'),
onComplete: () {
if (mounted) {
_lastSentId = null;
_bumpMessages();
}
},
child: pressable,
);
} else {
child = pressable;
}
final builtItem = RepaintBoundary(
key: ValueKey('msg_${message.id}'),
@@ -2699,11 +2827,13 @@ class _LongPressBubble extends StatefulWidget {
final Widget child;
final CachedMessage message;
final bool isMe;
final VoidCallback onDelete;
const _LongPressBubble({
required this.child,
required this.message,
required this.isMe,
required this.onDelete,
});
@override
@@ -2754,6 +2884,7 @@ class _LongPressBubbleState extends State<_LongPressBubble> {
messageText: widget.message.text,
controller: controller,
style: AppMessageActionsStyle.current.value,
onDelete: widget.onDelete,
onDispose: () {
if (identical(_controller, controller)) {
_controller = null;
@@ -2785,6 +2916,7 @@ class _LongPressBubbleState extends State<_LongPressBubble> {
controller: controller,
style: MessageActionsStyle.list,
interaction: MessageActionsInteraction.click,
onDelete: widget.onDelete,
onDispose: () {
if (identical(_controller, controller)) {
_controller = null;
@@ -2814,6 +2946,75 @@ class _LongPressBubbleState extends State<_LongPressBubble> {
}
}
class _DeletingMessageAnimation extends StatefulWidget {
final Widget child;
final VoidCallback onComplete;
const _DeletingMessageAnimation({
super.key,
required this.child,
required this.onComplete,
});
@override
State<_DeletingMessageAnimation> createState() =>
_DeletingMessageAnimationState();
}
class _DeletingMessageAnimationState extends State<_DeletingMessageAnimation>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
late final Animation<double> _opacity;
late final Animation<double> _scale;
late final Animation<double> _collapse;
bool _fired = false;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 280),
);
_opacity = Tween<double>(begin: 1, end: 0).animate(
CurvedAnimation(parent: _ctrl, curve: const Interval(0.0, 0.6)),
);
_scale = Tween<double>(begin: 1, end: 0.82).animate(
CurvedAnimation(parent: _ctrl, curve: const Interval(0.0, 0.6)),
);
_collapse = Tween<double>(begin: 1, end: 0).animate(
CurvedAnimation(parent: _ctrl, curve: const Interval(0.35, 1.0, curve: Curves.easeInOut)),
);
_ctrl.forward().whenComplete(() {
if (_fired) return;
_fired = true;
widget.onComplete();
});
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizeTransition(
sizeFactor: _collapse,
alignment: Alignment.center,
child: FadeTransition(
opacity: _opacity,
child: ScaleTransition(
scale: _scale,
alignment: Alignment.center,
child: widget.child,
),
),
);
}
}
class _SentMessageAnimation extends StatefulWidget {
final Widget child;
final VoidCallback onComplete;
@@ -73,6 +73,7 @@ void showMessageActions({
required MessageActionsController controller,
required MessageActionsStyle style,
required VoidCallback onDispose,
VoidCallback? onDelete,
MessageActionsInteraction interaction = MessageActionsInteraction.dragAndRelease,
}) {
final overlay = Overlay.of(context, rootOverlay: true);
@@ -87,6 +88,7 @@ void showMessageActions({
controller: controller,
style: style,
interaction: interaction,
onDelete: onDelete,
onDismiss: () {
if (entry.mounted) entry.remove();
onDispose();
@@ -106,6 +108,7 @@ class _MessageActionsLayer extends StatefulWidget {
final MessageActionsStyle style;
final MessageActionsInteraction interaction;
final VoidCallback onDismiss;
final VoidCallback? onDelete;
const _MessageActionsLayer({
required this.snapshot,
@@ -117,6 +120,7 @@ class _MessageActionsLayer extends StatefulWidget {
required this.style,
required this.interaction,
required this.onDismiss,
this.onDelete,
});
@override
@@ -286,7 +290,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
_Action(
Symbols.delete,
'Удалить',
() => _stub('Удаление'),
_delete,
destructive: true,
),
];
@@ -346,6 +350,12 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
await _close();
}
Future<void> _delete() async {
final onDelete = widget.onDelete;
await _close();
onDelete?.call();
}
Future<void> _stub(String name) async {
if (!mounted) return;
showCustomNotification(context, '$name — пока в разработке');