Edit, delete and reply messages!

This commit is contained in:
LittleSheep 2024-04-18 23:39:48 +08:00
parent 1a1a3a5155
commit b257378a82
11 changed files with 352 additions and 73 deletions

View File

@ -15,6 +15,7 @@
"action": "Action", "action": "Action",
"cancel": "Cancel", "cancel": "Cancel",
"report": "Report", "report": "Report",
"reply": "Reply",
"reaction": "Reaction", "reaction": "Reaction",
"reactVerb": "React", "reactVerb": "React",
"post": "Post", "post": "Post",
@ -35,5 +36,8 @@
"postEditNotify": "You are about editing a post that already published.", "postEditNotify": "You are about editing a post that already published.",
"reactionAdded": "Your reaction has been added.", "reactionAdded": "Your reaction has been added.",
"reactionRemoved": "Your reaction has been removed.", "reactionRemoved": "Your reaction has been removed.",
"chatMessagePlaceholder": "Write a message..." "chatMessagePlaceholder": "Write a message...",
"chatMessageEditNotify": "You are about editing a message.",
"chatMessageReplyNotify": "You are about replying a message.",
"chatMessageDeleteConfirm": "Are you sure you want to delete this message? This operation cannot be revert and no local history is saved!"
} }

View File

@ -15,6 +15,7 @@
"action": "操作", "action": "操作",
"cancel": "取消", "cancel": "取消",
"report": "举报", "report": "举报",
"reply": "回复",
"reaction": "反应", "reaction": "反应",
"reactVerb": "作出反应", "reactVerb": "作出反应",
"post": "帖子", "post": "帖子",
@ -35,5 +36,8 @@
"postEditNotify": "你正在修改一个已经发布了的帖子。", "postEditNotify": "你正在修改一个已经发布了的帖子。",
"reactionAdded": "你的反应已被添加。", "reactionAdded": "你的反应已被添加。",
"reactionRemoved": "你的反应已被移除。", "reactionRemoved": "你的反应已被移除。",
"chatMessagePlaceholder": "发条消息……" "chatMessagePlaceholder": "发条消息……",
"chatMessageEditNotify": "你正在编辑信息中……",
"chatMessageReplyNotify": "你正在回复消息中……",
"chatMessageDeleteConfirm": "你确定要删除这条消息吗?这条消息将永远的从所有人的视图中消失,并且不会有本地消息记录保存!"
} }

View File

@ -10,6 +10,7 @@ import 'package:solian/providers/auth.dart';
import 'package:solian/utils/service_url.dart'; import 'package:solian/utils/service_url.dart';
import 'package:solian/widgets/chat/maintainer.dart'; import 'package:solian/widgets/chat/maintainer.dart';
import 'package:solian/widgets/chat/message.dart'; import 'package:solian/widgets/chat/message.dart';
import 'package:solian/widgets/chat/message_action.dart';
import 'package:solian/widgets/chat/message_editor.dart'; import 'package:solian/widgets/chat/message_editor.dart';
import 'package:solian/widgets/indent_wrapper.dart'; import 'package:solian/widgets/indent_wrapper.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
@ -49,7 +50,7 @@ class _ChatScreenState extends State<ChatScreen> {
if (!await auth.isAuthorized()) return; if (!await auth.isAuthorized()) return;
final offset = pageKey; final offset = pageKey;
const take = 5; const take = 10;
var uri = getRequestUri( var uri = getRequestUri(
'messaging', 'messaging',
@ -96,6 +97,25 @@ class _ChatScreenState extends State<ChatScreen> {
}); });
} }
Message? _editingItem;
Message? _replyingItem;
void viewActions(Message item) {
showModalBottomSheet(
context: context,
builder: (context) => ChatMessageAction(
channel: widget.alias,
item: item,
onEdit: () => setState(() {
_editingItem = item;
}),
onReply: () => setState(() {
_replyingItem = item;
}),
),
);
}
@override @override
void initState() { void initState() {
Future.delayed(Duration.zero, () { Future.delayed(Duration.zero, () {
@ -128,24 +148,35 @@ class _ChatScreenState extends State<ChatScreen> {
if (index + 1 < (_pagingController.itemList?.length ?? 0)) { if (index + 1 < (_pagingController.itemList?.length ?? 0)) {
isMerged = getMessageMergeable(item, _pagingController.itemList?[index + 1]); isMerged = getMessageMergeable(item, _pagingController.itemList?[index + 1]);
} }
return Container( return InkWell(
padding: EdgeInsets.only( child: Container(
top: !isMerged ? 8 : 0, padding: EdgeInsets.only(
bottom: !hasMerged ? 8 : 0, top: !isMerged ? 8 : 0,
left: 12, bottom: !hasMerged ? 8 : 0,
right: 12, left: 12,
), right: 12,
child: ChatMessage( ),
key: Key('m${item.id}'), child: ChatMessage(
item: item, key: Key('m${item.id}'),
underMerged: isMerged, item: item,
underMerged: isMerged,
),
), ),
onLongPress: () => viewActions(item),
); );
}, },
), ),
), ),
), ),
ChatMessageEditor(channel: widget.alias), ChatMessageEditor(
channel: widget.alias,
editing: _editingItem,
replying: _replyingItem,
onReset: () => setState(() {
_editingItem = null;
_replyingItem = null;
}),
),
], ],
), ),
onInsertMessage: (message) => addMessage(message), onInsertMessage: (message) => addMessage(message),

View File

@ -75,11 +75,9 @@ class _ExploreScreenState extends State<ExploreScreen> {
pagingController: _pagingController, pagingController: _pagingController,
separatorBuilder: (context, index) => const Divider(thickness: 0.3), separatorBuilder: (context, index) => const Divider(thickness: 0.3),
builderDelegate: PagedChildBuilderDelegate<Post>( builderDelegate: PagedChildBuilderDelegate<Post>(
itemBuilder: (context, item, index) => GestureDetector( itemBuilder: (context, item, index) => PostItem(
child: PostItem( item: item,
item: item, onUpdate: () => _pagingController.refresh(),
onUpdate: () => _pagingController.refresh(),
),
onTap: () { onTap: () {
router.pushNamed( router.pushNamed(
'posts.screen', 'posts.screen',

View File

@ -60,6 +60,7 @@ class _PostScreenState extends State<PostScreen> {
child: PostItem( child: PostItem(
item: snapshot.data!, item: snapshot.data!,
brief: false, brief: false,
ripple: false,
), ),
), ),
SliverToBoxAdapter( SliverToBoxAdapter(

View File

@ -0,0 +1,109 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/message.dart';
import 'package:solian/models/post.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/screens/posts/comment_editor.dart';
import 'package:solian/widgets/chat/message_deletion.dart';
import 'package:solian/widgets/posts/item_deletion.dart';
class ChatMessageAction extends StatelessWidget {
final String channel;
final Message item;
final Function? onEdit;
final Function? onReply;
const ChatMessageAction({
super.key,
required this.channel,
required this.item,
this.onEdit,
this.onReply,
});
@override
Widget build(BuildContext context) {
final auth = context.read<AuthProvider>();
return SizedBox(
height: 320,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.only(left: 20, top: 20, bottom: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context)!.action,
style: Theme.of(context).textTheme.headlineSmall,
),
Text(
'Message ID #${item.id.toString().padLeft(8, '0')}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
Expanded(
child: FutureBuilder(
future: auth.getProfiles(),
builder: (context, snapshot) {
if (snapshot.hasData) {
final authorizedItems = [
ListTile(
leading: const Icon(Icons.edit),
title: Text(AppLocalizations.of(context)!.edit),
onTap: () {
if (onEdit != null) onEdit!();
if (Navigator.canPop(context)) Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.delete),
title: Text(AppLocalizations.of(context)!.delete),
onTap: () {
showDialog(
context: context,
builder: (context) => ChatMessageDeletionDialog(
item: item,
channel: channel,
),
).then((did) {
if (did == true && Navigator.canPop(context)) {
Navigator.pop(context);
}
});
},
)
];
return ListView(
children: [
...(snapshot.data['id'] == item.sender.accountId ? authorizedItems : List.empty()),
ListTile(
leading: const Icon(Icons.reply),
title: Text(AppLocalizations.of(context)!.reply),
onTap: () {
if (onReply != null) onReply!();
if (Navigator.canPop(context)) Navigator.pop(context);
},
)
],
);
} else {
return const Center(
child: CircularProgressIndicator(),
);
}
},
),
),
],
),
);
}
}

View File

@ -0,0 +1,64 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/message.dart';
import 'package:solian/models/post.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/utils/service_url.dart';
class ChatMessageDeletionDialog extends StatefulWidget {
final String channel;
final Message item;
const ChatMessageDeletionDialog({
super.key,
required this.item,
required this.channel,
});
@override
State<ChatMessageDeletionDialog> createState() => _ChatMessageDeletionDialogState();
}
class _ChatMessageDeletionDialogState extends State<ChatMessageDeletionDialog> {
bool _isSubmitting = false;
void doDeletion(BuildContext context) async {
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) return;
final uri = getRequestUri('messaging', '/api/channels/${widget.channel}/messages/${widget.item.id}');
setState(() => _isSubmitting = true);
final res = await auth.client!.delete(uri);
if (res.statusCode != 200) {
var message = utf8.decode(res.bodyBytes);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Something went wrong... $message")),
);
setState(() => _isSubmitting = false);
} else {
Navigator.pop(context, true);
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(AppLocalizations.of(context)!.confirmation),
content: Text(AppLocalizations.of(context)!.chatMessageDeleteConfirm),
actions: <Widget>[
TextButton(
onPressed: _isSubmitting ? null : () => Navigator.pop(context, false),
child: Text(AppLocalizations.of(context)!.confirmCancel),
),
TextButton(
onPressed: _isSubmitting ? null : () => doDeletion(context),
child: Text(AppLocalizations.of(context)!.confirmOkay),
),
],
);
}
}

View File

@ -11,8 +11,10 @@ import 'package:solian/utils/service_url.dart';
class ChatMessageEditor extends StatefulWidget { class ChatMessageEditor extends StatefulWidget {
final String channel; final String channel;
final Message? editing; final Message? editing;
final Message? replying;
final Function? onReset;
const ChatMessageEditor({super.key, required this.channel, this.editing}); const ChatMessageEditor({super.key, required this.channel, this.editing, this.replying, this.onReset});
@override @override
State<ChatMessageEditor> createState() => _ChatMessageEditorState(); State<ChatMessageEditor> createState() => _ChatMessageEditorState();
@ -37,6 +39,7 @@ class _ChatMessageEditorState extends State<ChatMessageEditor> {
req.headers['Content-Type'] = 'application/json'; req.headers['Content-Type'] = 'application/json';
req.body = jsonEncode(<String, dynamic>{ req.body = jsonEncode(<String, dynamic>{
'content': _textController.value.text, 'content': _textController.value.text,
'reply_to': widget.replying?.id,
}); });
setState(() => _isSubmitting = true); setState(() => _isSubmitting = true);
@ -54,41 +57,96 @@ class _ChatMessageEditorState extends State<ChatMessageEditor> {
void reset() { void reset() {
_textController.clear(); _textController.clear();
if (widget.onReset != null) widget.onReset!();
}
void syncWidget() {
if (widget.editing != null) {
setState(() {
_textController.text = widget.editing!.content;
});
}
}
@override
void initState() {
super.initState();
}
@override
void didUpdateWidget(oldWidget) {
syncWidget();
super.didUpdateWidget(oldWidget);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( final editingBanner = MaterialBanner(
height: 56, padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 20),
padding: const EdgeInsets.all(12), leading: const Icon(Icons.edit_note),
decoration: const BoxDecoration( backgroundColor: const Color(0xFFE0E0E0),
border: Border( dividerColor: const Color.fromARGB(1, 0, 0, 0),
top: BorderSide(width: 0.3, color: Color(0xffdedede)), content: Text(AppLocalizations.of(context)!.chatMessageEditNotify),
actions: [
TextButton(
child: Text(AppLocalizations.of(context)!.cancel),
onPressed: () => reset(),
), ),
), ],
child: Row( );
mainAxisAlignment: MainAxisAlignment.start,
children: [ final replyingBanner = MaterialBanner(
Expanded( padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 20),
child: TextField( leading: const Icon(Icons.reply),
controller: _textController, backgroundColor: const Color(0xFFE0E0E0),
maxLines: null, dividerColor: const Color.fromARGB(1, 0, 0, 0),
autofocus: true, content: Text(AppLocalizations.of(context)!.chatMessageReplyNotify),
autocorrect: true, actions: [
keyboardType: TextInputType.text, TextButton(
decoration: InputDecoration.collapsed( child: Text(AppLocalizations.of(context)!.cancel),
hintText: AppLocalizations.of(context)!.chatMessagePlaceholder, onPressed: () => reset(),
), ),
onSubmitted: (_) => sendMessage(context), ],
);
return Column(
children: [
widget.editing != null ? editingBanner : Container(),
widget.replying != null ? replyingBanner : Container(),
Container(
height: 56,
padding: const EdgeInsets.only(top: 4, left: 16, right: 8),
decoration: const BoxDecoration(
border: Border(
top: BorderSide(width: 0.3, color: Color(0xffdedede)),
), ),
), ),
TextButton( child: Row(
style: TextButton.styleFrom(shape: const CircleBorder(), padding: const EdgeInsets.all(4)), mainAxisAlignment: MainAxisAlignment.start,
onPressed: !_isSubmitting ? () => sendMessage(context) : null, children: [
child: const Icon(Icons.send), Expanded(
) child: TextField(
], controller: _textController,
), maxLines: null,
autofocus: true,
autocorrect: true,
keyboardType: TextInputType.text,
decoration: InputDecoration.collapsed(
hintText: AppLocalizations.of(context)!.chatMessagePlaceholder,
),
onSubmitted: (_) => sendMessage(context),
),
),
TextButton(
style: TextButton.styleFrom(shape: const CircleBorder(), padding: const EdgeInsets.all(4)),
onPressed: !_isSubmitting ? () => sendMessage(context) : null,
child: const Icon(Icons.send),
)
],
),
),
],
); );
} }
} }

View File

@ -13,15 +13,19 @@ import 'package:timeago/timeago.dart' as timeago;
class PostItem extends StatefulWidget { class PostItem extends StatefulWidget {
final Post item; final Post item;
final bool? brief; final bool? brief;
final bool? ripple;
final Function? onUpdate; final Function? onUpdate;
final Function? onDelete; final Function? onDelete;
final Function? onTap;
const PostItem({ const PostItem({
super.key, super.key,
required this.item, required this.item,
this.brief, this.brief,
this.ripple,
this.onUpdate, this.onUpdate,
this.onDelete, this.onDelete,
this.onTap,
}); });
@override @override
@ -31,7 +35,7 @@ class PostItem extends StatefulWidget {
class _PostItemState extends State<PostItem> { class _PostItemState extends State<PostItem> {
Map<String, dynamic>? reactionList; Map<String, dynamic>? reactionList;
void viewActions(BuildContext context) { void viewActions() {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
builder: (context) => PostItemAction( builder: (context) => PostItemAction(
@ -41,9 +45,8 @@ class _PostItemState extends State<PostItem> {
); );
} }
void viewComments(BuildContext context) { void viewComments() {
final PagingController<int, Post> commentPaging = final PagingController<int, Post> commentPaging = PagingController(firstPageKey: 0);
PagingController(firstPageKey: 0);
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
@ -83,8 +86,7 @@ class _PostItemState extends State<PostItem> {
Widget renderAttachments() { Widget renderAttachments() {
if (widget.item.modelType == 'article') return Container(); if (widget.item.modelType == 'article') return Container();
if (widget.item.attachments != null && if (widget.item.attachments != null && widget.item.attachments!.isNotEmpty) {
widget.item.attachments!.isNotEmpty) {
return Padding( return Padding(
padding: const EdgeInsets.only(top: 8), padding: const EdgeInsets.only(top: 8),
child: AttachmentList(items: widget.item.attachments!, provider: 'interactive'), child: AttachmentList(items: widget.item.attachments!, provider: 'interactive'),
@ -105,7 +107,7 @@ class _PostItemState extends State<PostItem> {
avatar: const Icon(Icons.comment), avatar: const Icon(Icons.comment),
label: Text(widget.item.commentCount.toString()), label: Text(widget.item.commentCount.toString()),
tooltip: AppLocalizations.of(context)!.comment, tooltip: AppLocalizations.of(context)!.comment,
onPressed: () => viewComments(context), onPressed: () => viewComments(),
), ),
const VerticalDivider(thickness: 0.3, indent: 8, endIndent: 8), const VerticalDivider(thickness: 0.3, indent: 8, endIndent: 8),
Expanded( Expanded(
@ -127,9 +129,8 @@ class _PostItemState extends State<PostItem> {
); );
} }
String getAuthorDescribe() => widget.item.author.description.isNotEmpty String getAuthorDescribe() =>
? widget.item.author.description widget.item.author.description.isNotEmpty ? widget.item.author.description : 'No description yet.';
: 'No description yet.';
@override @override
void initState() { void initState() {
@ -174,8 +175,7 @@ class _PostItemState extends State<PostItem> {
children: [ children: [
...headingParts, ...headingParts,
Padding( Padding(
padding: padding: const EdgeInsets.only(left: 12, right: 12, top: 4),
const EdgeInsets.only(left: 12, right: 12, top: 4),
child: renderContent(), child: renderContent(),
), ),
renderAttachments(), renderAttachments(),
@ -240,11 +240,24 @@ class _PostItemState extends State<PostItem> {
); );
} }
return GestureDetector( final ripple = widget.ripple ?? true;
child: content,
onLongPress: () { if (ripple) {
viewActions(context); return InkWell(
}, child: content,
); onTap: () {
if (widget.onTap != null) widget.onTap!();
},
onLongPress: () => viewActions(),
);
} else {
return GestureDetector(
child: content,
onTap: () {
if (widget.onTap != null) widget.onTap!();
},
onLongPress: () => viewActions(),
);
}
} }
} }

View File

@ -81,11 +81,10 @@ class PostItemAction extends StatelessWidget {
builder: (context) => ItemDeletionDialog( builder: (context) => ItemDeletionDialog(
item: item, item: item,
dataset: dataset, dataset: dataset,
onDelete: (did) {
if (did == true && onDelete != null) onDelete!();
},
), ),
); ).then((did) {
if (did == true && onDelete != null) onDelete!();
});
}, },
) )
]; ];

View File

@ -10,13 +10,11 @@ import 'package:solian/utils/service_url.dart';
class ItemDeletionDialog extends StatefulWidget { class ItemDeletionDialog extends StatefulWidget {
final Post item; final Post item;
final String dataset; final String dataset;
final Function? onDelete;
const ItemDeletionDialog({ const ItemDeletionDialog({
super.key, super.key,
required this.item, required this.item,
required this.dataset, required this.dataset,
this.onDelete,
}); });
@override @override