From 8bacd7cf5ab0286fa033ea1273c01888a2c92f26 Mon Sep 17 00:00:00 2001 From: Alex Yocom-Piatt Date: Tue, 15 Oct 2024 12:24:04 -0400 Subject: [PATCH] bruig: Add attachment and emojis to comments and replies --- .../lib/components/feed/comment_input.dart | 281 ++++++++++++++++++ bruig/flutterui/bruig/lib/models/menus.dart | 5 +- bruig/flutterui/bruig/lib/screens/feed.dart | 18 +- .../bruig/lib/screens/feed/post_content.dart | 123 ++++---- 4 files changed, 347 insertions(+), 80 deletions(-) create mode 100644 bruig/flutterui/bruig/lib/components/feed/comment_input.dart diff --git a/bruig/flutterui/bruig/lib/components/feed/comment_input.dart b/bruig/flutterui/bruig/lib/components/feed/comment_input.dart new file mode 100644 index 00000000..d39bdaac --- /dev/null +++ b/bruig/flutterui/bruig/lib/components/feed/comment_input.dart @@ -0,0 +1,281 @@ +import 'dart:math'; + +import 'package:bruig/components/attach_file.dart'; +import 'package:bruig/models/emoji.dart'; +import 'package:bruig/models/uistate.dart'; +import 'package:bruig/screens/chats.dart'; +import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; +import 'package:flutter/material.dart'; +import 'package:bruig/components/chat/types.dart'; +import 'package:bruig/theme_manager.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:super_clipboard/super_clipboard.dart'; + +class CommentInput extends StatefulWidget { + final SendMsg commentReply; + final String label; + final String hintText; + final CustomInputFocusNode inputFocusNode; + const CommentInput( + this.commentReply, this.label, this.hintText, this.inputFocusNode, + {super.key}); + + @override + State createState() => _CommentInputState(); +} + +class _CommentInputState extends State { + final controller = TextEditingController(); + + List embeds = []; + bool isAttaching = false; + Uint8List? initialAttachData; + String? initialAttachMime; + + void replaceTextSelection(String s) { + var sel = controller.selection.copyWith(); + if (controller.selection.start == -1 && controller.selection.end == -1) { + controller.text = controller.text + s; + } else if (sel.isCollapsed) { + controller.text = controller.text.substring(0, sel.start) + + s + + controller.text.substring(min(controller.text.length, sel.start)); + var newPos = sel.baseOffset + s.length; + controller.selection = + sel.copyWith(baseOffset: newPos, extentOffset: newPos); + } else { + controller.text = + controller.text.substring(0, controller.selection.start) + + s + + controller.text.substring(controller.selection.end); + var newPos = sel.baseOffset + s.length; + controller.selection = + sel.copyWith(baseOffset: newPos, extentOffset: newPos); + } + } + + Future pasteEvent() async { + final clip = SystemClipboard.instance; + if (clip == null) { + // Clipboard API is not supported on this platform. Use the standard. + replaceTextSelection(Clipboard.kTextPlain); + return; + } + final reader = await clip.read(); + + /// Binary formats need to be read as streams + if (reader.canProvide(Formats.png)) { + reader.getFile(Formats.png, (file) async { + final stream = await file.readAll(); + setState(() { + initialAttachData = stream; + initialAttachMime = "image/png"; + isAttaching = true; + }); + }); + return; + } + + // Automatically convert to markdown? + // if (reader.canProvide(Formats.htmlText)) { + // final html = await reader.readValue(Formats.htmlText); + // print("XXXX clip is html $html"); + // } + + if (reader.canProvide(Formats.plainText)) { + final text = await reader.readValue(Formats.plainText); + replaceTextSelection(text ?? ""); + return; + } + } + + @override + void initState() { + super.initState(); + widget.inputFocusNode.noModEnterKeyHandler = sendMsg; + widget.inputFocusNode.pasteEventHandler = pasteEvent; + widget.inputFocusNode.addEmojiHandler = addEmoji; + } + + @override + void didUpdateWidget(CommentInput oldWidget) { + super.didUpdateWidget(oldWidget); + var workingMsg = controller.text; + if (workingMsg != controller.text) { + isAttaching = false; + controller.text = workingMsg; + controller.selection = TextSelection( + baseOffset: workingMsg.length, extentOffset: workingMsg.length); + widget.inputFocusNode.inputFocusNode.requestFocus(); + } + oldWidget.inputFocusNode.pasteEventHandler = null; + widget.inputFocusNode.pasteEventHandler = pasteEvent; + oldWidget.inputFocusNode.addEmojiHandler = null; + widget.inputFocusNode.addEmojiHandler = addEmoji; + } + + @override + void dispose() { + widget.inputFocusNode.noModEnterKeyHandler = null; + widget.inputFocusNode.pasteEventHandler = null; + widget.inputFocusNode.addEmojiHandler = null; + super.dispose(); + } + + void sendAttachment(String msg) { + setState(() { + isAttaching = false; + initialAttachData = null; + initialAttachMime = null; + }); + widget.commentReply(msg); + } + + void sendMsg() { + final messageWithoutNewLine = controller.text.trim(); + controller.value = const TextEditingValue( + text: "", selection: TextSelection.collapsed(offset: 0)); + final String withEmbeds = + embeds.fold(messageWithoutNewLine, (s, e) => e.replaceInString(s)); + /* + if (withEmbeds.length > 1024 * 1024) { + showErrorSnackbar(context, + "Message is larger than maximum allowed (limit: 1MiB)"); + return; + } + */ + if (withEmbeds != "") { + widget.commentReply(withEmbeds); + setState(() { + embeds = []; + }); + } + + Provider.of(context, listen: false).clearSelection(); + } + + void addEmoji(Emoji? e) { + if (e != null) { + // Selected emoji from panel eidget. + var oldPos = controller.selection.start; + var newText = controller.selection.textBefore(controller.text) + + e.emoji + + controller.selection.textAfter(controller.text); + controller.value = TextEditingValue( + text: newText, + selection: TextSelection.collapsed(offset: oldPos + e.emoji.length)); + return; + } + + // Selected emoji from typing panel. + var typingEmoji = Provider.of(context, listen: false); + var newText = typingEmoji.replaceTypedEmojiCode(controller); + if (newText == "") return; + + var oldPos = + controller.selection.start - typingEmoji.lastEmojiCode.length + 1; + controller.value = TextEditingValue( + text: newText, selection: TextSelection.collapsed(offset: oldPos)); + } + + void attachFile() { + setState(() { + isAttaching = true; + }); + } + + void cancelAttach() { + setState(() { + isAttaching = false; + initialAttachData = null; + initialAttachMime = null; + widget.inputFocusNode.inputFocusNode.requestFocus(); + }); + } + + @override + Widget build(BuildContext context) { + bool isScreenSmall = checkIsScreenSmall(context); + return Consumer( + builder: (context, theme, _) => isAttaching + ? Column(children: [ + Row(mainAxisAlignment: MainAxisAlignment.start, children: [ + IconButton( + padding: const EdgeInsets.all(0), + iconSize: 25, + onPressed: cancelAttach, + icon: const Icon(Icons.keyboard_arrow_left_outlined)) + ]), + AttachFileScreen( + sendAttachment, initialAttachData, initialAttachMime) + ]) + : Row(children: [ + IconButton( + padding: const EdgeInsets.all(0), + iconSize: 25, + onPressed: attachFile, + icon: const Icon(Icons.add_outlined)), + const SizedBox(width: 5), + IconButton( + padding: const EdgeInsets.all(0), + iconSize: 25, + onPressed: () { + var emojiModel = + TypingEmojiSelModel.of(context, listen: false); + emojiModel.showAddEmojiPanel.value = + !emojiModel.showAddEmojiPanel.value; + }, + icon: const Icon(Icons.emoji_emotions_outlined)), + const SizedBox(width: 5), + Expanded( + child: TextField( + onChanged: (value) { + // Check if user is typing an emoji code (:foo:). + TypingEmojiSelModel.of(context, listen: false) + .maybeSelectEmojis(controller); + }, + autofocus: isScreenSmall ? false : true, + focusNode: widget.inputFocusNode.inputFocusNode, + controller: controller, + minLines: 1, + maxLines: null, + contextMenuBuilder: (BuildContext context, + EditableTextState editableTextState) => + AdaptiveTextSelectionToolbar.editable( + anchors: editableTextState.contextMenuAnchors, + clipboardStatus: ClipboardStatus.pasteable, + onCopy: null, + onCut: null, + onLiveTextInput: null, + onLookUp: null, + onSearchWeb: null, + onSelectAll: null, + onShare: null, + onPaste: pasteEvent, + ), + style: theme.textStyleFor(context, TextSize.medium, null), + keyboardType: TextInputType.multiline, + decoration: InputDecoration( + isDense: true, + border: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(30.0)), + borderSide: BorderSide(width: 2.0), + ), + hintText: widget.hintText, + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + IconButton( + tooltip: widget.label, + padding: const EdgeInsets.all(0), + iconSize: 20, + onPressed: sendMsg, + icon: const Icon(Icons.send)) + ]), + ), + )), + ])); + } +} diff --git a/bruig/flutterui/bruig/lib/models/menus.dart b/bruig/flutterui/bruig/lib/models/menus.dart index 4baae0e5..0afefd30 100644 --- a/bruig/flutterui/bruig/lib/models/menus.dart +++ b/bruig/flutterui/bruig/lib/models/menus.dart @@ -84,8 +84,9 @@ final List mainMenu = [ MainMenuItem( "Feed", FeedScreen.routeName, - (context) => Consumer( - builder: (context, menu, child) => FeedScreen(menu)), + (context) => Consumer2( + builder: (context, menu, typingEmoji, child) => + FeedScreen(menu, typingEmoji)), (context) => const FeedScreenTitle(), const SidebarSvgIcon("assets/icons/icons-menu-news.svg"), feedScreenSub), diff --git a/bruig/flutterui/bruig/lib/screens/feed.dart b/bruig/flutterui/bruig/lib/screens/feed.dart index d54bd544..e68532a2 100644 --- a/bruig/flutterui/bruig/lib/screens/feed.dart +++ b/bruig/flutterui/bruig/lib/screens/feed.dart @@ -17,6 +17,7 @@ import 'package:bruig/screens/feed/post_lists.dart'; import 'package:bruig/components/empty_widget.dart'; import 'package:bruig/models/menus.dart'; import 'package:bruig/theme_manager.dart'; +import 'package:bruig/models/emoji.dart'; class FeedScreenTitle extends StatelessWidget { const FeedScreenTitle({super.key}); @@ -51,7 +52,9 @@ class FeedScreen extends StatefulWidget { final int tabIndex; final MainMenuModel mainMenu; - const FeedScreen(this.mainMenu, {super.key, this.tabIndex = 0}); + final TypingEmojiSelModel typingEmoji; + const FeedScreen(this.mainMenu, this.typingEmoji, + {super.key, this.tabIndex = 0}); @override State createState() => _FeedScreenState(); @@ -61,6 +64,7 @@ class _FeedScreenState extends State { ChatModel? userPostList; int tabIndex = 0; PostContentScreenArgs? showPost; + GlobalKey navKey = GlobalKey(debugLabel: "overview nav key"); Widget activeTab() { @@ -71,8 +75,8 @@ class _FeedScreenState extends State { builder: (context, feed, client, child) => FeedPosts(feed, client, onItemChanged, false)); } else { - return PostContentScreen( - showPost as PostContentScreenArgs, onItemChanged); + return PostContentScreen(showPost as PostContentScreenArgs, + onItemChanged, widget.typingEmoji); } case 1: if (showPost == null) { @@ -81,8 +85,8 @@ class _FeedScreenState extends State { FeedPosts(feed, client, onItemChanged, true), ); } else { - return PostContentScreen( - showPost as PostContentScreenArgs, onItemChanged); + return PostContentScreen(showPost as PostContentScreenArgs, + onItemChanged, widget.typingEmoji); } case 2: return Consumer( @@ -96,8 +100,8 @@ class _FeedScreenState extends State { builder: (context, feed, client, child) => UserPosts(userPostList!, feed, client, onItemChanged)); } else if (showPost != null) { - return PostContentScreen( - showPost as PostContentScreenArgs, onItemChanged); + return PostContentScreen(showPost as PostContentScreenArgs, + onItemChanged, widget.typingEmoji); } else { return Text("Active tab $tabIndex without post or userPostList"); } diff --git a/bruig/flutterui/bruig/lib/screens/feed/post_content.dart b/bruig/flutterui/bruig/lib/screens/feed/post_content.dart index 6e1b6201..9bb4d5d1 100644 --- a/bruig/flutterui/bruig/lib/screens/feed/post_content.dart +++ b/bruig/flutterui/bruig/lib/screens/feed/post_content.dart @@ -1,4 +1,5 @@ -import 'package:bruig/components/buttons.dart'; +import 'package:bruig/components/feed/comment_input.dart'; +import 'package:bruig/components/typing_emoji_panel.dart'; import 'package:bruig/components/containers.dart'; import 'package:bruig/components/empty_widget.dart'; import 'package:bruig/components/icons.dart'; @@ -11,6 +12,7 @@ import 'package:bruig/models/feed.dart'; import 'package:bruig/models/snackbar.dart'; import 'package:bruig/models/uistate.dart'; import 'package:bruig/screens/overview.dart'; +import 'package:bruig/screens/chats.dart'; import 'package:bruig/util.dart'; import 'package:flutter/material.dart'; import 'package:golib_plugin/golib_plugin.dart'; @@ -18,6 +20,7 @@ import 'package:golib_plugin/definitions.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:bruig/theme_manager.dart'; +import 'package:bruig/models/emoji.dart'; class PostContentScreenArgs { final FeedPostModel post; @@ -27,13 +30,15 @@ class PostContentScreenArgs { class PostContentScreen extends StatelessWidget { final PostContentScreenArgs args; final Function tabChange; - const PostContentScreen(this.args, this.tabChange, {super.key}); + final TypingEmojiSelModel typingEmoji; + const PostContentScreen(this.args, this.tabChange, this.typingEmoji, + {super.key}); @override Widget build(BuildContext context) { return Consumer2( - builder: (context, client, feed, child) => - _PostContentScreenForArgs(args, client, tabChange, feed)); + builder: (context, client, feed, child) => _PostContentScreenForArgs( + args, client, tabChange, feed, typingEmoji)); } } @@ -42,8 +47,9 @@ class _PostContentScreenForArgs extends StatefulWidget { final ClientModel client; final Function tabChange; final FeedModel feed; + final TypingEmojiSelModel typingEmoji; const _PostContentScreenForArgs( - this.args, this.client, this.tabChange, this.feed); + this.args, this.client, this.tabChange, this.feed, this.typingEmoji); @override State<_PostContentScreenForArgs> createState() => @@ -83,8 +89,9 @@ class _CommentW extends StatefulWidget { final ClientModel client; final ShowingReplyCB showReply; final bool canComment; + final CustomInputFocusNode inputFocusNode; const _CommentW(this.post, this.comment, this.sendReply, this.client, - this.showReply, this.canComment); + this.showReply, this.canComment, this.inputFocusNode); @override State<_CommentW> createState() => _CommentWState(); @@ -105,13 +112,13 @@ class _CommentWState extends State<_CommentW> { bool sendingReply = false; bool showChildren = true; - void sendReply() async { + void sendReply(String msg) async { replying = false; setState(() { sendingReply = true; }); try { - await widget.sendReply(widget.comment, reply); + await widget.sendReply(widget.comment, msg); } finally { setState(() { sendingReply = false; @@ -304,34 +311,21 @@ class _CommentWState extends State<_CommentW> { Expanded(child: MarkdownArea(widget.comment.comment, false)) ])), replying && !sendingReply - ? Column( - children: [ - const SizedBox(height: 20), - Box( - padding: const EdgeInsets.all(10), - color: SurfaceColor.surfaceContainer, - child: TextField( - minLines: 3, - keyboardType: TextInputType.multiline, - maxLines: null, - onChanged: (v) => reply = v, - )), - const SizedBox(height: 20), - Align( - alignment: Alignment.centerLeft, - child: Wrap( - alignment: WrapAlignment.start, - runSpacing: 10, - spacing: 10, - children: [ - TextButton( - onPressed: sendReply, - child: const Text("Reply")), - CancelButton(onPressed: () => replying = false) - ], - )) - ], - ) + ? Column(children: [ + Consumer( + builder: (context, typingEmoji, child) => + TypingEmojiPanel( + model: typingEmoji, + focusNode: widget.inputFocusNode, + )), + const SizedBox(height: 5), + Container( + padding: const EdgeInsets.only( + left: 13, right: 13, top: 11, bottom: 11), + margin: const EdgeInsets.symmetric(horizontal: 10), + child: CommentInput(sendReply, "Reply", + "Reply to this comment", widget.inputFocusNode)) + ]) : const Text(""), commentRRs != null ? const SizedBox(height: 10) : const Empty(), commentRRs != null @@ -382,7 +376,8 @@ class _CommentWState extends State<_CommentW> { widget.sendReply, widget.client, widget.showReply, - widget.canComment)) + widget.canComment, + widget.inputFocusNode)) ]), ) : const Empty(), @@ -394,12 +389,12 @@ class _PostContentScreenForArgsState extends State<_PostContentScreenForArgs> { bool loading = false; String markdownData = ""; Iterable comments = []; - TextEditingController newCommentCtrl = TextEditingController(); bool knowsAuthor = false; bool isKXSearchingAuthor = false; bool sentSubscribeAttempt = false; bool showingReply = false; List postRRs = []; + late CustomInputFocusNode inputFocusNode; void loadContent() async { var snackbar = SnackBarModel.of(context); @@ -491,15 +486,11 @@ class _PostContentScreenForArgsState extends State<_PostContentScreenForArgs> { widget.args.post.summ.id, reply, comment.id); } - Future addComment() async { + void addComment(String msg) async { replying = false; - var newComment = newCommentCtrl.text; - setState(() { - newCommentCtrl.clear(); - }); - widget.args.post.addNewComment(newComment); + widget.args.post.addNewComment(msg); await Golib.commentPost( - widget.args.post.summ.from, widget.args.post.summ.id, newComment, null); + widget.args.post.summ.from, widget.args.post.summ.id, msg, null); } void kxSearchAuthor() async { @@ -529,6 +520,7 @@ class _PostContentScreenForArgsState extends State<_PostContentScreenForArgs> { void initState() { super.initState(); widget.args.post.addListener(postUpdated); + inputFocusNode = CustomInputFocusNode(widget.typingEmoji); var authorID = widget.args.post.summ.authorID; widget.client.getExistingChat(authorID)?.addListener(authorUpdated); loadContent(); @@ -671,33 +663,22 @@ class _PostContentScreenForArgsState extends State<_PostContentScreenForArgs> { child: const Txt.S("Subscribe to User's Posts")) : const Empty(), ])) - : Container( - padding: const EdgeInsets.only( - left: 13, right: 13, top: 11, bottom: 11), - margin: const EdgeInsets.symmetric(horizontal: 30), - child: Column(children: [ - Box( - padding: const EdgeInsets.all(10), - color: SurfaceColor.surfaceContainer, - child: TextField( - minLines: 3, - controller: newCommentCtrl, - keyboardType: TextInputType.multiline, - maxLines: null, - )), - const SizedBox(height: 20), - Row( - children: [ - OutlinedButton( - onPressed: addComment, - child: const Text("Add Comment")), - const SizedBox(width: 20), - CancelButton(onPressed: () => replying = false) - ], - ) - ])), + : Column(children: [ + Consumer( + builder: (context, typingEmoji, child) => TypingEmojiPanel( + model: typingEmoji, + focusNode: inputFocusNode, + )), + const SizedBox(height: 5), + Container( + padding: const EdgeInsets.only( + left: 13, right: 13, top: 11, bottom: 11), + margin: const EdgeInsets.symmetric(horizontal: 30), + child: CommentInput(addComment, "Add Comment", + "Add a comment to this post", inputFocusNode)), + ]), ...comments.map((e) => _CommentW(widget.args.post, e, sendReply, - widget.client, showingReplyCB, canComment)), + widget.client, showingReplyCB, canComment, inputFocusNode)), const SizedBox(height: 20), newComments.isNotEmpty ? Column(children: [