diff --git a/lib/i18n/app_en.arb b/lib/i18n/app_en.arb index aa25cda..8b81cb7 100644 --- a/lib/i18n/app_en.arb +++ b/lib/i18n/app_en.arb @@ -15,6 +15,7 @@ "action": "Action", "cancel": "Cancel", "report": "Report", + "reply": "Reply", "reaction": "Reaction", "reactVerb": "React", "post": "Post", @@ -35,5 +36,8 @@ "postEditNotify": "You are about editing a post that already published.", "reactionAdded": "Your reaction has been added.", "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!" } diff --git a/lib/i18n/app_zh.arb b/lib/i18n/app_zh.arb index 3ad1ee8..3e453d6 100644 --- a/lib/i18n/app_zh.arb +++ b/lib/i18n/app_zh.arb @@ -15,6 +15,7 @@ "action": "操作", "cancel": "取消", "report": "举报", + "reply": "回复", "reaction": "反应", "reactVerb": "作出反应", "post": "帖子", @@ -35,5 +36,8 @@ "postEditNotify": "你正在修改一个已经发布了的帖子。", "reactionAdded": "你的反应已被添加。", "reactionRemoved": "你的反应已被移除。", - "chatMessagePlaceholder": "发条消息……" + "chatMessagePlaceholder": "发条消息……", + "chatMessageEditNotify": "你正在编辑信息中……", + "chatMessageReplyNotify": "你正在回复消息中……", + "chatMessageDeleteConfirm": "你确定要删除这条消息吗?这条消息将永远的从所有人的视图中消失,并且不会有本地消息记录保存!" } \ No newline at end of file diff --git a/lib/screens/chat/chat.dart b/lib/screens/chat/chat.dart index 318688f..2e3bb90 100644 --- a/lib/screens/chat/chat.dart +++ b/lib/screens/chat/chat.dart @@ -10,6 +10,7 @@ import 'package:solian/providers/auth.dart'; import 'package:solian/utils/service_url.dart'; import 'package:solian/widgets/chat/maintainer.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/indent_wrapper.dart'; import 'package:http/http.dart' as http; @@ -49,7 +50,7 @@ class _ChatScreenState extends State { if (!await auth.isAuthorized()) return; final offset = pageKey; - const take = 5; + const take = 10; var uri = getRequestUri( 'messaging', @@ -96,6 +97,25 @@ class _ChatScreenState extends State { }); } + 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 void initState() { Future.delayed(Duration.zero, () { @@ -128,24 +148,35 @@ class _ChatScreenState extends State { if (index + 1 < (_pagingController.itemList?.length ?? 0)) { isMerged = getMessageMergeable(item, _pagingController.itemList?[index + 1]); } - return Container( - padding: EdgeInsets.only( - top: !isMerged ? 8 : 0, - bottom: !hasMerged ? 8 : 0, - left: 12, - right: 12, - ), - child: ChatMessage( - key: Key('m${item.id}'), - item: item, - underMerged: isMerged, + return InkWell( + child: Container( + padding: EdgeInsets.only( + top: !isMerged ? 8 : 0, + bottom: !hasMerged ? 8 : 0, + left: 12, + right: 12, + ), + child: ChatMessage( + key: Key('m${item.id}'), + 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), diff --git a/lib/screens/explore.dart b/lib/screens/explore.dart index 5f2bd88..f7a53a7 100644 --- a/lib/screens/explore.dart +++ b/lib/screens/explore.dart @@ -75,11 +75,9 @@ class _ExploreScreenState extends State { pagingController: _pagingController, separatorBuilder: (context, index) => const Divider(thickness: 0.3), builderDelegate: PagedChildBuilderDelegate( - itemBuilder: (context, item, index) => GestureDetector( - child: PostItem( - item: item, - onUpdate: () => _pagingController.refresh(), - ), + itemBuilder: (context, item, index) => PostItem( + item: item, + onUpdate: () => _pagingController.refresh(), onTap: () { router.pushNamed( 'posts.screen', diff --git a/lib/screens/posts/screen.dart b/lib/screens/posts/screen.dart index 2eb645a..d041e31 100644 --- a/lib/screens/posts/screen.dart +++ b/lib/screens/posts/screen.dart @@ -60,6 +60,7 @@ class _PostScreenState extends State { child: PostItem( item: snapshot.data!, brief: false, + ripple: false, ), ), SliverToBoxAdapter( diff --git a/lib/widgets/chat/message_action.dart b/lib/widgets/chat/message_action.dart new file mode 100644 index 0000000..1bf8c82 --- /dev/null +++ b/lib/widgets/chat/message_action.dart @@ -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(); + + 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(), + ); + } + }, + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/chat/message_deletion.dart b/lib/widgets/chat/message_deletion.dart new file mode 100644 index 0000000..4ab5811 --- /dev/null +++ b/lib/widgets/chat/message_deletion.dart @@ -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 createState() => _ChatMessageDeletionDialogState(); +} + +class _ChatMessageDeletionDialogState extends State { + bool _isSubmitting = false; + + void doDeletion(BuildContext context) async { + final auth = context.read(); + 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: [ + 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), + ), + ], + ); + } +} diff --git a/lib/widgets/chat/message_editor.dart b/lib/widgets/chat/message_editor.dart index 7b2f5af..3b7d10d 100644 --- a/lib/widgets/chat/message_editor.dart +++ b/lib/widgets/chat/message_editor.dart @@ -11,8 +11,10 @@ import 'package:solian/utils/service_url.dart'; class ChatMessageEditor extends StatefulWidget { final String channel; 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 State createState() => _ChatMessageEditorState(); @@ -37,6 +39,7 @@ class _ChatMessageEditorState extends State { req.headers['Content-Type'] = 'application/json'; req.body = jsonEncode({ 'content': _textController.value.text, + 'reply_to': widget.replying?.id, }); setState(() => _isSubmitting = true); @@ -54,41 +57,96 @@ class _ChatMessageEditorState extends State { void reset() { _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 Widget build(BuildContext context) { - return Container( - height: 56, - padding: const EdgeInsets.all(12), - decoration: const BoxDecoration( - border: Border( - top: BorderSide(width: 0.3, color: Color(0xffdedede)), + final editingBanner = MaterialBanner( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 20), + leading: const Icon(Icons.edit_note), + backgroundColor: const Color(0xFFE0E0E0), + dividerColor: const Color.fromARGB(1, 0, 0, 0), + content: Text(AppLocalizations.of(context)!.chatMessageEditNotify), + actions: [ + TextButton( + child: Text(AppLocalizations.of(context)!.cancel), + onPressed: () => reset(), ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - 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), + ], + ); + + final replyingBanner = MaterialBanner( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 20), + leading: const Icon(Icons.reply), + backgroundColor: const Color(0xFFE0E0E0), + dividerColor: const Color.fromARGB(1, 0, 0, 0), + content: Text(AppLocalizations.of(context)!.chatMessageReplyNotify), + actions: [ + TextButton( + child: Text(AppLocalizations.of(context)!.cancel), + onPressed: () => reset(), + ), + ], + ); + + 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( - style: TextButton.styleFrom(shape: const CircleBorder(), padding: const EdgeInsets.all(4)), - onPressed: !_isSubmitting ? () => sendMessage(context) : null, - child: const Icon(Icons.send), - ) - ], - ), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + 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), + ) + ], + ), + ), + ], ); } } diff --git a/lib/widgets/posts/item.dart b/lib/widgets/posts/item.dart index d2d18db..e9c43d0 100644 --- a/lib/widgets/posts/item.dart +++ b/lib/widgets/posts/item.dart @@ -13,15 +13,19 @@ import 'package:timeago/timeago.dart' as timeago; class PostItem extends StatefulWidget { final Post item; final bool? brief; + final bool? ripple; final Function? onUpdate; final Function? onDelete; + final Function? onTap; const PostItem({ super.key, required this.item, this.brief, + this.ripple, this.onUpdate, this.onDelete, + this.onTap, }); @override @@ -31,7 +35,7 @@ class PostItem extends StatefulWidget { class _PostItemState extends State { Map? reactionList; - void viewActions(BuildContext context) { + void viewActions() { showModalBottomSheet( context: context, builder: (context) => PostItemAction( @@ -41,9 +45,8 @@ class _PostItemState extends State { ); } - void viewComments(BuildContext context) { - final PagingController commentPaging = - PagingController(firstPageKey: 0); + void viewComments() { + final PagingController commentPaging = PagingController(firstPageKey: 0); showModalBottomSheet( context: context, @@ -83,8 +86,7 @@ class _PostItemState extends State { Widget renderAttachments() { if (widget.item.modelType == 'article') return Container(); - if (widget.item.attachments != null && - widget.item.attachments!.isNotEmpty) { + if (widget.item.attachments != null && widget.item.attachments!.isNotEmpty) { return Padding( padding: const EdgeInsets.only(top: 8), child: AttachmentList(items: widget.item.attachments!, provider: 'interactive'), @@ -105,7 +107,7 @@ class _PostItemState extends State { avatar: const Icon(Icons.comment), label: Text(widget.item.commentCount.toString()), tooltip: AppLocalizations.of(context)!.comment, - onPressed: () => viewComments(context), + onPressed: () => viewComments(), ), const VerticalDivider(thickness: 0.3, indent: 8, endIndent: 8), Expanded( @@ -127,9 +129,8 @@ class _PostItemState extends State { ); } - String getAuthorDescribe() => widget.item.author.description.isNotEmpty - ? widget.item.author.description - : 'No description yet.'; + String getAuthorDescribe() => + widget.item.author.description.isNotEmpty ? widget.item.author.description : 'No description yet.'; @override void initState() { @@ -174,8 +175,7 @@ class _PostItemState extends State { children: [ ...headingParts, Padding( - padding: - const EdgeInsets.only(left: 12, right: 12, top: 4), + padding: const EdgeInsets.only(left: 12, right: 12, top: 4), child: renderContent(), ), renderAttachments(), @@ -240,11 +240,24 @@ class _PostItemState extends State { ); } - return GestureDetector( - child: content, - onLongPress: () { - viewActions(context); - }, - ); + final ripple = widget.ripple ?? true; + + if (ripple) { + 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(), + ); + } } } diff --git a/lib/widgets/posts/item_action.dart b/lib/widgets/posts/item_action.dart index 31e2e74..0c84c10 100644 --- a/lib/widgets/posts/item_action.dart +++ b/lib/widgets/posts/item_action.dart @@ -81,11 +81,10 @@ class PostItemAction extends StatelessWidget { builder: (context) => ItemDeletionDialog( item: item, dataset: dataset, - onDelete: (did) { - if (did == true && onDelete != null) onDelete!(); - }, ), - ); + ).then((did) { + if (did == true && onDelete != null) onDelete!(); + }); }, ) ]; diff --git a/lib/widgets/posts/item_deletion.dart b/lib/widgets/posts/item_deletion.dart index 8bcce57..085fc76 100644 --- a/lib/widgets/posts/item_deletion.dart +++ b/lib/widgets/posts/item_deletion.dart @@ -10,13 +10,11 @@ import 'package:solian/utils/service_url.dart'; class ItemDeletionDialog extends StatefulWidget { final Post item; final String dataset; - final Function? onDelete; const ItemDeletionDialog({ super.key, required this.item, required this.dataset, - this.onDelete, }); @override