diff --git a/assets/translations/en.json b/assets/translations/en.json index 74807be..4ae8a2a 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -160,5 +160,8 @@ "realmDelete": "Delete realm {}", "realmDeleteDescription": "Are you sure you want to delete this realm? This operation is irreversible, all resources (posts, chat channels, publishers, etc) belonging to this realm will be permanently deleted. Be careful and think twice!", "fieldChatMessage": "Message in {}", - "eventResourceTag": "Event {}" + "eventResourceTag": "Event {}", + "messageDelete": "Delete message {}", + "messageDeleteDescription": "Are you sure you want to delete this message? This operation is irreversible. You will leave a record of the deleted message.", + "messageDeleted": "Message {} has been deleted" } diff --git a/assets/translations/zh.json b/assets/translations/zh.json index 8df7661..2ac8996 100644 --- a/assets/translations/zh.json +++ b/assets/translations/zh.json @@ -160,5 +160,8 @@ "realmDelete": "删除领域 {}", "realmDeleteDescription": "你确定要删除这个领域吗?该操作不可撤销,其隶属于该领域的所有资源(帖子、聊天频道、发布者、制品等)都将被永久删除。三思而后行!", "fieldChatMessage": "在 {} 中发消息", - "eventResourceTag": "消息 {}" + "eventResourceTag": "消息 {}", + "messageDelete": "删除消息 {}", + "messageDeleteDescription": "你确定要删除这个消息吗?该操作不可撤销。同时您将留下一条删除消息的记录。", + "messageDeleted": "消息 {} 已被删除" } diff --git a/lib/controllers/chat_message_controller.dart b/lib/controllers/chat_message_controller.dart index 685993a..df691fc 100644 --- a/lib/controllers/chat_message_controller.dart +++ b/lib/controllers/chat_message_controller.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:math' as math; +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:hive/hive.dart'; import 'package:provider/provider.dart'; @@ -176,9 +177,9 @@ class ChatMessageController extends ChangeNotifier { switch (message.type) { case 'messages.edit': - final body = message.body; - if (body['related_event'] != null) { - final idx = messages.indexWhere((x) => x.id == body['related_event']); + if (message.relatedEventId != null) { + final idx = + messages.indexWhere((x) => x.id == message.relatedEventId); if (idx != -1) { final newBody = message.body; newBody.remove('related_event'); @@ -186,17 +187,16 @@ class ChatMessageController extends ChangeNotifier { body: newBody, updatedAt: message.updatedAt, ); - if (_box!.containsKey(body['related_event'])) { - await _box!.put(body['related_event'], messages[idx]); + if (_box!.containsKey(message.relatedEventId)) { + await _box!.put(message.relatedEventId, messages[idx]); } } } case 'messages.delete': - final body = message.body; - if (body['related_event'] != null) { - messages.removeWhere((x) => x.id == body['related_event']); - if (_box!.containsKey(body['related_event'])) { - await _box!.delete(body['related_event']); + if (message.relatedEventId != null) { + messages.removeWhere((x) => x.id == message.relatedEventId); + if (_box!.containsKey(message.relatedEventId)) { + await _box!.delete(message.relatedEventId); } } } @@ -208,6 +208,7 @@ class ChatMessageController extends ChangeNotifier { int? quoteId, int? relatedId, List? attachments, + SnChatMessage? editingMessage, }) async { if (channel == null) return; const uuid = Uuid(); @@ -216,7 +217,7 @@ class ChatMessageController extends ChangeNotifier { 'text': content, 'algorithm': 'plain', if (quoteId != null) 'quote_event': quoteId, - if (relatedId != null) 'quote_event': relatedId, + if (relatedId != null) 'related_event': relatedId, if (attachments != null && attachments.isNotEmpty) 'attachments': attachments, }; @@ -235,21 +236,40 @@ class ChatMessageController extends ChangeNotifier { sender: profile!, senderId: profile!.id, quoteEventId: quoteId, + relatedEventId: relatedId, ); _addUnconfirmedMessage(message); // Send to server try { - await _sn.client.post( - '/cgi/im/channels/${channel!.keyPath}/messages', + await _sn.client.request( + editingMessage != null + ? '/cgi/im/channels/${channel!.keyPath}/messages/${editingMessage.id}' + : '/cgi/im/channels/${channel!.keyPath}/messages', data: { 'type': type, 'uuid': nonce, 'body': body, }, + options: Options( + method: editingMessage != null ? 'PUT' : 'POST', + ), ); } catch (err) { - print(err); + // ignore + } + } + + Future deleteMessage(SnChatMessage message) async { + if (message.channelId != channel?.id) return; + + try { + await _sn.client.delete( + '/cgi/im/channels/${channel!.keyPath}/messages/${message.id}', + ); + messages.removeWhere((x) => x.id == message.id); + } catch (err) { + // ignore } } @@ -376,7 +396,11 @@ class ChatMessageController extends ChangeNotifier { } // Preload sender accounts - await _ud.listAccount(out.map((ele) => ele.sender.accountId).toSet()); + final accountId = out + .where((ele) => ele.sender.accountId >= 0) + .map((ele) => ele.sender.accountId) + .toSet(); + await _ud.listAccount(accountId); return out; } diff --git a/lib/screens/chat/room.dart b/lib/screens/chat/room.dart index 4e8472d..de35843 100644 --- a/lib/screens/chat/room.dart +++ b/lib/screens/chat/room.dart @@ -121,8 +121,12 @@ class _ChatRoomScreenState extends State { onReply: (value) { _inputGlobalKey.currentState?.setReply(value); }, - onEdit: (value) {}, - onDelete: (value) {}, + onEdit: (value) { + _inputGlobalKey.currentState?.setEdit(value); + }, + onDelete: (value) { + _inputGlobalKey.currentState?.deleteMessage(value); + }, ); }, ), diff --git a/lib/types/chat.dart b/lib/types/chat.dart index 04d58dd..6e214f5 100644 --- a/lib/types/chat.dart +++ b/lib/types/chat.dart @@ -74,13 +74,14 @@ class SnChatMessage with _$SnChatMessage { @HiveField(2) required DateTime updatedAt, @HiveField(3) required DateTime? deletedAt, @HiveField(4) required String uuid, - @HiveField(5) required Map body, + @HiveField(5) @Default({}) Map body, @HiveField(6) required String type, @HiveField(7) required SnChannel channel, @HiveField(8) required SnChannelMember sender, @HiveField(9) required int channelId, @HiveField(10) required int senderId, @HiveField(11) required int? quoteEventId, + @HiveField(12) required int? relatedEventId, SnChatMessagePreload? preload, }) = _SnChatMessage; diff --git a/lib/types/chat.freezed.dart b/lib/types/chat.freezed.dart index 70ac9a0..ee9ddc4 100644 --- a/lib/types/chat.freezed.dart +++ b/lib/types/chat.freezed.dart @@ -1085,6 +1085,8 @@ mixin _$SnChatMessage { int get senderId => throw _privateConstructorUsedError; @HiveField(11) int? get quoteEventId => throw _privateConstructorUsedError; + @HiveField(12) + int? get relatedEventId => throw _privateConstructorUsedError; SnChatMessagePreload? get preload => throw _privateConstructorUsedError; /// Serializes this SnChatMessage to a JSON map. @@ -1116,6 +1118,7 @@ abstract class $SnChatMessageCopyWith<$Res> { @HiveField(9) int channelId, @HiveField(10) int senderId, @HiveField(11) int? quoteEventId, + @HiveField(12) int? relatedEventId, SnChatMessagePreload? preload}); $SnChannelCopyWith<$Res> get channel; @@ -1150,6 +1153,7 @@ class _$SnChatMessageCopyWithImpl<$Res, $Val extends SnChatMessage> Object? channelId = null, Object? senderId = null, Object? quoteEventId = freezed, + Object? relatedEventId = freezed, Object? preload = freezed, }) { return _then(_value.copyWith( @@ -1201,6 +1205,10 @@ class _$SnChatMessageCopyWithImpl<$Res, $Val extends SnChatMessage> ? _value.quoteEventId : quoteEventId // ignore: cast_nullable_to_non_nullable as int?, + relatedEventId: freezed == relatedEventId + ? _value.relatedEventId + : relatedEventId // ignore: cast_nullable_to_non_nullable + as int?, preload: freezed == preload ? _value.preload : preload // ignore: cast_nullable_to_non_nullable @@ -1264,6 +1272,7 @@ abstract class _$$SnChatMessageImplCopyWith<$Res> @HiveField(9) int channelId, @HiveField(10) int senderId, @HiveField(11) int? quoteEventId, + @HiveField(12) int? relatedEventId, SnChatMessagePreload? preload}); @override @@ -1299,6 +1308,7 @@ class __$$SnChatMessageImplCopyWithImpl<$Res> Object? channelId = null, Object? senderId = null, Object? quoteEventId = freezed, + Object? relatedEventId = freezed, Object? preload = freezed, }) { return _then(_$SnChatMessageImpl( @@ -1350,6 +1360,10 @@ class __$$SnChatMessageImplCopyWithImpl<$Res> ? _value.quoteEventId : quoteEventId // ignore: cast_nullable_to_non_nullable as int?, + relatedEventId: freezed == relatedEventId + ? _value.relatedEventId + : relatedEventId // ignore: cast_nullable_to_non_nullable + as int?, preload: freezed == preload ? _value.preload : preload // ignore: cast_nullable_to_non_nullable @@ -1368,13 +1382,14 @@ class _$SnChatMessageImpl extends _SnChatMessage { @HiveField(2) required this.updatedAt, @HiveField(3) required this.deletedAt, @HiveField(4) required this.uuid, - @HiveField(5) required final Map body, + @HiveField(5) final Map body = const {}, @HiveField(6) required this.type, @HiveField(7) required this.channel, @HiveField(8) required this.sender, @HiveField(9) required this.channelId, @HiveField(10) required this.senderId, @HiveField(11) required this.quoteEventId, + @HiveField(12) required this.relatedEventId, this.preload}) : _body = body, super._(); @@ -1399,6 +1414,7 @@ class _$SnChatMessageImpl extends _SnChatMessage { final String uuid; final Map _body; @override + @JsonKey() @HiveField(5) Map get body { if (_body is EqualUnmodifiableMapView) return _body; @@ -1425,11 +1441,14 @@ class _$SnChatMessageImpl extends _SnChatMessage { @HiveField(11) final int? quoteEventId; @override + @HiveField(12) + final int? relatedEventId; + @override final SnChatMessagePreload? preload; @override String toString() { - return 'SnChatMessage(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, uuid: $uuid, body: $body, type: $type, channel: $channel, sender: $sender, channelId: $channelId, senderId: $senderId, quoteEventId: $quoteEventId, preload: $preload)'; + return 'SnChatMessage(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, uuid: $uuid, body: $body, type: $type, channel: $channel, sender: $sender, channelId: $channelId, senderId: $senderId, quoteEventId: $quoteEventId, relatedEventId: $relatedEventId, preload: $preload)'; } @override @@ -1455,6 +1474,8 @@ class _$SnChatMessageImpl extends _SnChatMessage { other.senderId == senderId) && (identical(other.quoteEventId, quoteEventId) || other.quoteEventId == quoteEventId) && + (identical(other.relatedEventId, relatedEventId) || + other.relatedEventId == relatedEventId) && (identical(other.preload, preload) || other.preload == preload)); } @@ -1474,6 +1495,7 @@ class _$SnChatMessageImpl extends _SnChatMessage { channelId, senderId, quoteEventId, + relatedEventId, preload); /// Create a copy of SnChatMessage @@ -1499,13 +1521,14 @@ abstract class _SnChatMessage extends SnChatMessage { @HiveField(2) required final DateTime updatedAt, @HiveField(3) required final DateTime? deletedAt, @HiveField(4) required final String uuid, - @HiveField(5) required final Map body, + @HiveField(5) final Map body, @HiveField(6) required final String type, @HiveField(7) required final SnChannel channel, @HiveField(8) required final SnChannelMember sender, @HiveField(9) required final int channelId, @HiveField(10) required final int senderId, @HiveField(11) required final int? quoteEventId, + @HiveField(12) required final int? relatedEventId, final SnChatMessagePreload? preload}) = _$SnChatMessageImpl; const _SnChatMessage._() : super._(); @@ -1549,6 +1572,9 @@ abstract class _SnChatMessage extends SnChatMessage { @HiveField(11) int? get quoteEventId; @override + @HiveField(12) + int? get relatedEventId; + @override SnChatMessagePreload? get preload; /// Create a copy of SnChatMessage diff --git a/lib/types/chat.g.dart b/lib/types/chat.g.dart index 4bacdf1..405a07a 100644 --- a/lib/types/chat.g.dart +++ b/lib/types/chat.g.dart @@ -163,13 +163,14 @@ class SnChatMessageImplAdapter extends TypeAdapter<_$SnChatMessageImpl> { channelId: fields[9] as int, senderId: fields[10] as int, quoteEventId: fields[11] as int?, + relatedEventId: fields[12] as int?, ); } @override void write(BinaryWriter writer, _$SnChatMessageImpl obj) { writer - ..writeByte(12) + ..writeByte(13) ..writeByte(0) ..write(obj.id) ..writeByte(1) @@ -192,6 +193,8 @@ class SnChatMessageImplAdapter extends TypeAdapter<_$SnChatMessageImpl> { ..write(obj.senderId) ..writeByte(11) ..write(obj.quoteEventId) + ..writeByte(12) + ..write(obj.relatedEventId) ..writeByte(5) ..write(obj.body); } @@ -306,13 +309,14 @@ _$SnChatMessageImpl _$$SnChatMessageImplFromJson(Map json) => ? null : DateTime.parse(json['deleted_at'] as String), uuid: json['uuid'] as String, - body: json['body'] as Map, + body: json['body'] as Map? ?? const {}, type: json['type'] as String, channel: SnChannel.fromJson(json['channel'] as Map), sender: SnChannelMember.fromJson(json['sender'] as Map), channelId: (json['channel_id'] as num).toInt(), senderId: (json['sender_id'] as num).toInt(), quoteEventId: (json['quote_event_id'] as num?)?.toInt(), + relatedEventId: (json['related_event_id'] as num?)?.toInt(), preload: json['preload'] == null ? null : SnChatMessagePreload.fromJson( @@ -333,6 +337,7 @@ Map _$$SnChatMessageImplToJson(_$SnChatMessageImpl instance) => 'channel_id': instance.channelId, 'sender_id': instance.senderId, 'quote_event_id': instance.quoteEventId, + 'related_event_id': instance.relatedEventId, 'preload': instance.preload?.toJson(), }; diff --git a/lib/widgets/chat/chat_message.dart b/lib/widgets/chat/chat_message.dart index e53d15b..ebe4478 100644 --- a/lib/widgets/chat/chat_message.dart +++ b/lib/widgets/chat/chat_message.dart @@ -147,6 +147,18 @@ class ChatMessage extends StatelessWidget { content: data.body['text'], isAutoWarp: true, ), + if (data.type == 'messages.delete' && + data.relatedEventId != null) + Row( + children: [ + const Icon(Symbols.delete), + const Gap(8), + Text( + 'messageDeleted' + .tr(args: ['#${data.relatedEventId}']), + ), + ], + ), ], ), ) diff --git a/lib/widgets/chat/chat_message_input.dart b/lib/widgets/chat/chat_message_input.dart index e665086..c40fe73 100644 --- a/lib/widgets/chat/chat_message_input.dart +++ b/lib/widgets/chat/chat_message_input.dart @@ -25,7 +25,7 @@ class ChatMessageInputState extends State { bool _isBusy = false; double? _progress; - SnChatMessage? _replyingMessage; + SnChatMessage? _replyingMessage, _editingMessage; final TextEditingController _contentController = TextEditingController(); final FocusNode _focusNode = FocusNode(); @@ -34,6 +34,26 @@ class ChatMessageInputState extends State { setState(() => _replyingMessage = value); } + void setEdit(SnChatMessage? value) { + setState(() => _editingMessage = value); + } + + Future deleteMessage(SnChatMessage message) async { + final confirm = await context.showConfirmDialog( + 'messageDelete'.tr(args: ['#${message.id}']), + 'messageDeleteDescription'.tr(), + ); + if (!confirm) return; + + if (!mounted) return; + setState(() => _isBusy = true); + + await widget.controller.deleteMessage(message); + + if (!mounted) return; + setState(() => _isBusy = false); + } + Future _sendMessage() async { if (_isBusy) return; @@ -89,10 +109,13 @@ class ChatMessageInputState extends State { .where((e) => e.attachment != null) .map((e) => e.attachment!.rid) .toList(), + relatedId: _editingMessage?.id, quoteId: _replyingMessage?.id, + editingMessage: _editingMessage, ); _contentController.clear(); _attachments.clear(); + _editingMessage = null; _replyingMessage = null; setState(() => _isBusy = false); @@ -184,6 +207,42 @@ class ChatMessageInputState extends State { ), ).height(_replyingMessage != null ? 54 + 8 : 0, animate: true).animate( const Duration(milliseconds: 300), Curves.fastEaseInToSlowEaseOut), + SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + child: Padding( + padding: _editingMessage != null + ? const EdgeInsets.only(top: 8) + : EdgeInsets.zero, + child: _editingMessage != null + ? MaterialBanner( + padding: const EdgeInsets.only(left: 16.0), + leading: const Icon(Symbols.edit), + content: SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_editingMessage?.body['text'] != null) + MarkdownTextContent( + content: _editingMessage?.body['text'], + ), + ], + ), + ), + actions: [ + TextButton( + child: Text('cancel'.tr()), + onPressed: () { + setState(() => _editingMessage = null); + }, + ), + ], + ) + : const SizedBox.shrink(), + ), + ).height(_editingMessage != null ? 54 + 8 : 0, animate: true).animate( + const Duration(milliseconds: 300), Curves.fastEaseInToSlowEaseOut), SizedBox( height: 56, child: Row(