From 69f7e3d518c5a4a8e214f2b555b4ffbe33ce1884 Mon Sep 17 00:00:00 2001 From: Talha Date: Tue, 29 Oct 2024 17:25:30 +0000 Subject: [PATCH 01/20] set up code for appflowy editor mentions plugin --- app/integration_test/tests/pins.dart | 2 +- .../widgets/edit_html_description_sheet.dart | 2 +- .../html_editor/components/mention_block.dart | 93 +++++ .../components/mention_content.dart | 50 +++ .../components/mention_handler.dart | 347 ++++++++++++++++++ .../html_editor/components/mention_menu.dart | 103 ++++++ .../components/room_mention_block.dart | 69 ++++ .../components/user_mention_block.dart | 71 ++++ .../{ => html_editor}/html_editor.dart | 0 .../services/mention_shortcuts.dart | 84 +++++ .../comments/widgets/create_comment.dart | 2 +- .../events/pages/create_event_page.dart | 2 +- .../features/news/pages/add_news_page.dart | 2 +- .../tasks/sheets/create_update_task_list.dart | 2 +- 14 files changed, 823 insertions(+), 6 deletions(-) create mode 100644 app/lib/common/widgets/html_editor/components/mention_block.dart create mode 100644 app/lib/common/widgets/html_editor/components/mention_content.dart create mode 100644 app/lib/common/widgets/html_editor/components/mention_handler.dart create mode 100644 app/lib/common/widgets/html_editor/components/mention_menu.dart create mode 100644 app/lib/common/widgets/html_editor/components/room_mention_block.dart create mode 100644 app/lib/common/widgets/html_editor/components/user_mention_block.dart rename app/lib/common/widgets/{ => html_editor}/html_editor.dart (100%) create mode 100644 app/lib/common/widgets/html_editor/services/mention_shortcuts.dart diff --git a/app/integration_test/tests/pins.dart b/app/integration_test/tests/pins.dart index dfb89fb21bf0..8220ac08cb14 100644 --- a/app/integration_test/tests/pins.dart +++ b/app/integration_test/tests/pins.dart @@ -1,5 +1,5 @@ import 'package:acter/common/utils/constants.dart'; -import 'package:acter/common/widgets/html_editor.dart'; +import 'package:acter/common/widgets/html_editor/html_editor.dart'; import 'package:acter/common/widgets/spaces/select_space_form_field.dart'; import 'package:acter/features/home/data/keys.dart'; import 'package:acter/features/pins/pages/create_pin_page.dart'; diff --git a/app/lib/common/widgets/edit_html_description_sheet.dart b/app/lib/common/widgets/edit_html_description_sheet.dart index 906e50c2bf64..052daadccdfe 100644 --- a/app/lib/common/widgets/edit_html_description_sheet.dart +++ b/app/lib/common/widgets/edit_html_description_sheet.dart @@ -1,6 +1,6 @@ import 'package:acter/common/themes/colors/color_scheme.dart'; import 'package:acter/common/toolkit/buttons/primary_action_button.dart'; -import 'package:acter/common/widgets/html_editor.dart'; +import 'package:acter/common/widgets/html_editor/html_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; diff --git a/app/lib/common/widgets/html_editor/components/mention_block.dart b/app/lib/common/widgets/html_editor/components/mention_block.dart new file mode 100644 index 000000000000..285447affa89 --- /dev/null +++ b/app/lib/common/widgets/html_editor/components/mention_block.dart @@ -0,0 +1,93 @@ +import 'package:acter/common/widgets/html_editor/components/room_mention_block.dart'; +import 'package:acter/common/widgets/html_editor/components/user_mention_block.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +enum MentionType { + user, + room; + + static MentionType fromString(String value) => switch (value) { + 'user' => user, + 'room' => room, + _ => throw UnimplementedError(), + }; +} + +class MentionBlockKeys { + const MentionBlockKeys._(); + static const mention = 'mention'; + static const type = 'type'; // MentionType, String + static const blockId = 'block_id'; + static const userId = 'user_id'; + static const roomId = 'room_id'; + static const displayName = 'display_name'; + static const userMentionChar = '@'; + static const roomMentionChar = '#'; +} + +class MentionBlock extends StatelessWidget { + const MentionBlock({ + super.key, + required this.editorState, + required this.mention, + required this.node, + required this.index, + required this.textStyle, + }); + + final EditorState editorState; + final Map mention; + final Node node; + final int index; + final TextStyle? textStyle; + + @override + Widget build(BuildContext context) { + final type = MentionType.fromString(mention[MentionBlockKeys.type]); + + switch (type) { + case MentionType.user: + final String? userId = mention[MentionBlockKeys.userId] as String?; + final String? displayName = + mention[MentionBlockKeys.displayName] as String?; + if (userId == null) { + return const SizedBox.shrink(); + } + + final String? blockId = mention[MentionBlockKeys.blockId] as String?; + + return UserMentionBlock( + key: ValueKey(userId), + editorState: editorState, + displayName: displayName, + userId: userId, + blockId: blockId, + node: node, + textStyle: textStyle, + index: index, + ); + case MentionType.room: + final String? roomId = mention[MentionBlockKeys.roomId] as String?; + final String? displayName = + mention[MentionBlockKeys.displayName] as String?; + if (roomId == null) { + return const SizedBox.shrink(); + } + final String? blockId = mention[MentionBlockKeys.blockId] as String?; + + return RoomMentionBlock( + key: ValueKey(roomId), + editorState: editorState, + displayName: displayName, + roomId: roomId, + blockId: blockId, + node: node, + textStyle: textStyle, + index: index, + ); + default: + return const SizedBox.shrink(); + } + } +} diff --git a/app/lib/common/widgets/html_editor/components/mention_content.dart b/app/lib/common/widgets/html_editor/components/mention_content.dart new file mode 100644 index 000000000000..757b17a2598a --- /dev/null +++ b/app/lib/common/widgets/html_editor/components/mention_content.dart @@ -0,0 +1,50 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +class MentionContentWidget extends StatelessWidget { + const MentionContentWidget({ + super.key, + required this.mentionId, + this.displayName, + required this.textStyle, + required this.editorState, + required this.node, + required this.index, + }); + + final String mentionId; + final String? displayName; + final TextStyle? textStyle; + final EditorState editorState; + final Node node; + final int index; + + @override + Widget build(BuildContext context) { + final baseTextStyle = textStyle?.copyWith( + color: Theme.of(context).colorScheme.primary, + leadingDistribution: TextLeadingDistribution.even, + ); + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (displayName != null) + Text( + displayName!, + style: baseTextStyle?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(width: 4), + Text( + mentionId, + style: baseTextStyle?.copyWith( + fontSize: (baseTextStyle.fontSize ?? 14.0) * 0.9, + color: Theme.of(context).hintColor, + ), + ), + ], + ); + } +} diff --git a/app/lib/common/widgets/html_editor/components/mention_handler.dart b/app/lib/common/widgets/html_editor/components/mention_handler.dart new file mode 100644 index 000000000000..618b0aab0598 --- /dev/null +++ b/app/lib/common/widgets/html_editor/components/mention_handler.dart @@ -0,0 +1,347 @@ +import 'package:acter/common/widgets/html_editor/components/mention_menu.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +const double kMentionMenuHeight = 300; +const double kMentionMenuWidth = 250; +const double kItemHeight = 60; +const double kContentHeight = 260; + +class MentionHandler extends ConsumerStatefulWidget { + const MentionHandler({ + super.key, + required this.editorState, + required this.items, + required this.mentionTrigger, + required this.onDismiss, + required this.onSelectionUpdate, + required this.style, + }); + + final EditorState editorState; + final List items; + final String mentionTrigger; + final VoidCallback onDismiss; + final VoidCallback onSelectionUpdate; + final MentionMenuStyle style; + + @override + ConsumerState createState() => _MentionHandlerState(); +} + +class _MentionHandlerState extends ConsumerState { + final _focusNode = FocusNode(debugLabel: 'mention_handler'); + final _scrollController = ScrollController(); + + late List filteredItems = widget.items; + int _selectedIndex = 0; + late int startOffset; + String _search = ''; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusNode.requestFocus(); + }); + startOffset = widget.editorState.selection?.endIndex ?? 0; + } + + @override + void dispose() { + _scrollController.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Focus( + focusNode: _focusNode, + onKeyEvent: _handleKeyEvent, + child: Container( + constraints: const BoxConstraints( + maxHeight: kMentionMenuHeight, + maxWidth: kMentionMenuWidth, + ), + decoration: BoxDecoration( + color: widget.style.backgroundColor, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + blurRadius: 5, + spreadRadius: 1, + color: Colors.black.withOpacity(0.1), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + widget.mentionTrigger == '@' ? 'Users' : 'Rooms', + style: TextStyle( + color: widget.style.textColor, + fontWeight: FontWeight.bold, + ), + ), + ), + const Divider(height: 1), + Flexible( + child: filteredItems.isEmpty + ? Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + 'No results found', + style: TextStyle(color: widget.style.hintColor), + ), + ) + : ListView.builder( + controller: _scrollController, + itemCount: filteredItems.length, + itemBuilder: (context, index) => _MentionListItem( + item: filteredItems[index], + isSelected: index == _selectedIndex, + style: widget.style, + onTap: () => _selectItem(filteredItems[index]), + ), + ), + ), + ], + ), + ), + ); + } + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + + switch (event.logicalKey) { + case LogicalKeyboardKey.escape: + widget.onDismiss(); + return KeyEventResult.handled; + + case LogicalKeyboardKey.enter: + if (filteredItems.isNotEmpty) { + _selectItem(filteredItems[_selectedIndex]); + } + return KeyEventResult.handled; + + case LogicalKeyboardKey.arrowUp: + setState(() { + _selectedIndex = + (_selectedIndex - 1).clamp(0, filteredItems.length - 1); + }); + _scrollToSelected(); + return KeyEventResult.handled; + + case LogicalKeyboardKey.arrowDown: + setState(() { + _selectedIndex = + (_selectedIndex + 1).clamp(0, filteredItems.length - 1); + }); + _scrollToSelected(); + return KeyEventResult.handled; + + case LogicalKeyboardKey.backspace: + if (_search.isEmpty) { + if (_canDeleteLastCharacter()) { + widget.editorState.deleteBackward(); + } else { + // Workaround for editor regaining focus + widget.editorState.apply( + widget.editorState.transaction + ..afterSelection = widget.editorState.selection, + ); + } + widget.onDismiss(); + } else { + widget.onSelectionUpdate(); + widget.editorState.deleteBackward(); + _deleteCharacterAtSelection(); + } + + return KeyEventResult.handled; + + default: + if (event.character != null && + !HardwareKeyboard.instance.isControlPressed && + !HardwareKeyboard.instance.isMetaPressed && + !HardwareKeyboard.instance.isAltPressed) { + widget.onSelectionUpdate(); + widget.editorState.insertTextAtCurrentSelection(event.character!); + _updateSearch(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + } + + void _updateSearch() { + final selection = widget.editorState.selection; + if (selection == null) return; + + final node = widget.editorState.getNodeAtPath(selection.end.path); + if (node == null) return; + + final text = node.delta?.toPlainText() ?? ''; + if (text.length < startOffset) return; + + _search = text.substring(startOffset); + _filterItems(_search); + } + + void _filterItems(String search) { + final searchLower = search.toLowerCase(); + setState(() { + filteredItems = widget.items + .where((item) => + item.id.toLowerCase().contains(searchLower) || + item.displayName.toLowerCase().contains(searchLower)) + .toList(); + _selectedIndex = 0; + }); + } + + void _selectItem(MentionItem item) { + final selection = widget.editorState.selection; + if (selection == null) return; + + final transaction = widget.editorState.transaction; + final node = widget.editorState.getNodeAtPath(selection.end.path); + if (node == null) return; + + // Delete the search text and trigger character + transaction.deleteText( + node, + startOffset - 1, + selection.end.offset - startOffset + 1, + ); + + // Insert the mention + transaction.insertText( + node, + startOffset - 1, + item.displayName, + attributes: { + MentionBlockKeys.mention: { + MentionBlockKeys.type: widget.mentionTrigger == '@' + ? MentionType.user.name + : MentionType.room.name, + if (widget.mentionTrigger == '@') + MentionBlockKeys.userId: item.id + else + MentionBlockKeys.roomId: item.id, + }, + }, + ); + + widget.editorState.apply(transaction); + widget.onDismiss(); + } + + void _scrollToSelected() { + final itemPosition = _selectedIndex * kItemHeight; + if (itemPosition < _scrollController.offset) { + _scrollController.animateTo( + itemPosition, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } else if (itemPosition + kItemHeight > + _scrollController.offset + + _scrollController.position.viewportDimension) { + _scrollController.animateTo( + itemPosition + + kItemHeight - + _scrollController.position.viewportDimension, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } + } + + void _deleteCharacterAtSelection() { + final selection = widget.editorState.selection; + if (selection == null || !selection.isCollapsed) { + return; + } + + final node = widget.editorState.getNodeAtPath(selection.end.path); + final delta = node?.delta; + if (node == null || delta == null) { + return; + } + + _search = delta.toPlainText().substring( + startOffset, + startOffset - 1 + _search.length, + ); + } + + bool _canDeleteLastCharacter() { + final selection = widget.editorState.selection; + if (selection == null || !selection.isCollapsed) { + return false; + } + + final delta = widget.editorState.getNodeAtPath(selection.start.path)?.delta; + if (delta == null) { + return false; + } + + return delta.isNotEmpty; + } +} + +class _MentionListItem extends StatelessWidget { + const _MentionListItem({ + required this.item, + required this.isSelected, + required this.style, + required this.onTap, + }); + + final MentionItem item; + final bool isSelected; + final MentionMenuStyle style; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Container( + height: kItemHeight, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + color: isSelected ? style.selectedColor : null, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + item.displayName, + style: TextStyle( + color: isSelected ? style.selectedTextColor : style.textColor, + fontWeight: FontWeight.w500, + ), + ), + Text( + item.id, + style: TextStyle( + fontSize: 12, + color: isSelected + ? style.selectedTextColor.withOpacity(0.7) + : style.hintColor, + ), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/common/widgets/html_editor/components/mention_menu.dart b/app/lib/common/widgets/html_editor/components/mention_menu.dart new file mode 100644 index 000000000000..b441d0bc84a0 --- /dev/null +++ b/app/lib/common/widgets/html_editor/components/mention_menu.dart @@ -0,0 +1,103 @@ +import 'package:acter/common/widgets/html_editor/components/mention_handler.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +// Style configuration for mention menu +class MentionMenuStyle { + const MentionMenuStyle({ + required this.backgroundColor, + required this.textColor, + required this.selectedColor, + required this.selectedTextColor, + required this.hintColor, + }); + + const MentionMenuStyle.light() + : backgroundColor = Colors.white, + textColor = const Color(0xFF333333), + selectedColor = const Color(0xFFE0F8FF), + selectedTextColor = const Color.fromARGB(255, 56, 91, 247), + hintColor = const Color(0xFF555555); + + const MentionMenuStyle.dark() + : backgroundColor = const Color(0xFF282E3A), + textColor = const Color(0xFFBBC3CD), + selectedColor = const Color(0xFF00BCF0), + selectedTextColor = const Color(0xFF131720), + hintColor = const Color(0xFFBBC3CD); + + final Color backgroundColor; + final Color textColor; + final Color selectedColor; + final Color selectedTextColor; + final Color hintColor; +} + +class MentionMenu { + MentionMenu({ + required this.context, + required this.editorState, + required this.items, + required this.style, + required this.mentionTrigger, + }); + + final BuildContext context; + final EditorState editorState; + final List items; + final MentionMenuStyle style; + final String mentionTrigger; + + OverlayEntry? _menuEntry; + bool selectionChangedByMenu = false; + + void dismiss() { + if (_menuEntry != null) { + editorState.service.keyboardService?.enable(); + editorState.service.scrollService?.enable(); + keepEditorFocusNotifier.decrease(); + } + + _menuEntry?.remove(); + _menuEntry = null; + } + + void _onSelectionUpdate() => selectionChangedByMenu = true; + + void show() { + WidgetsBinding.instance.addPostFrameCallback((_) => _show()); + } + + void _show() { + dismiss(); + + _menuEntry = OverlayEntry( + builder: (context) => Positioned( + left: 0, + bottom: 50, + child: Material( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: dismiss, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: MentionHandler( + editorState: editorState, + items: items, + onDismiss: dismiss, + onSelectionUpdate: _onSelectionUpdate, + style: style, + mentionTrigger: mentionTrigger, + ), + ), + ), + ), + ), + ); + + Overlay.of(context).insert(_menuEntry!); + + editorState.service.keyboardService?.disable(showCursor: true); + editorState.service.scrollService?.disable(); + } +} diff --git a/app/lib/common/widgets/html_editor/components/room_mention_block.dart b/app/lib/common/widgets/html_editor/components/room_mention_block.dart new file mode 100644 index 000000000000..acde7f668068 --- /dev/null +++ b/app/lib/common/widgets/html_editor/components/room_mention_block.dart @@ -0,0 +1,69 @@ +import 'package:acter/common/widgets/html_editor/components/mention_content.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +class RoomMentionBlock extends StatefulWidget { + const RoomMentionBlock({ + super.key, + required this.editorState, + required this.roomId, + required this.displayName, + required this.blockId, + required this.node, + required this.textStyle, + required this.index, + }); + + final EditorState editorState; + final String roomId; + final String? displayName; + final String? blockId; + final Node node; + final TextStyle? textStyle; + final int index; + + @override + State createState() => _RoomMentionBlockState(); +} + +class _RoomMentionBlockState extends State { + @override + Widget build(BuildContext context) { + final desktopPlatforms = [ + TargetPlatform.linux, + TargetPlatform.macOS, + TargetPlatform.windows, + ]; + + final Widget content = desktopPlatforms.contains(Theme.of(context).platform) + ? GestureDetector( + onTap: _handleRoomTap, + behavior: HitTestBehavior.opaque, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: MentionContentWidget( + mentionId: widget.roomId, + displayName: widget.displayName, + textStyle: widget.textStyle, + editorState: widget.editorState, + node: widget.node, + index: widget.index, + )), + ) + : GestureDetector( + onTap: _handleRoomTap, + behavior: HitTestBehavior.opaque, + child: MentionContentWidget( + mentionId: widget.roomId, + displayName: widget.displayName, + textStyle: widget.textStyle, + editorState: widget.editorState, + node: widget.node, + index: widget.index, + ), + ); + return content; + } + + void _handleRoomTap() async {} +} diff --git a/app/lib/common/widgets/html_editor/components/user_mention_block.dart b/app/lib/common/widgets/html_editor/components/user_mention_block.dart new file mode 100644 index 000000000000..a01d9c8430be --- /dev/null +++ b/app/lib/common/widgets/html_editor/components/user_mention_block.dart @@ -0,0 +1,71 @@ +import 'package:acter/common/widgets/html_editor/components/mention_content.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +class UserMentionBlock extends StatefulWidget { + const UserMentionBlock({ + super.key, + required this.editorState, + required this.userId, + required this.displayName, + required this.blockId, + required this.node, + required this.textStyle, + required this.index, + }); + + final EditorState editorState; + final String userId; + final String? displayName; + final String? blockId; + final Node node; + final TextStyle? textStyle; + final int index; + + @override + State createState() => _UserMentionBlockState(); +} + +class _UserMentionBlockState extends State { + @override + Widget build(BuildContext context) { + final desktopPlatforms = [ + TargetPlatform.linux, + TargetPlatform.macOS, + TargetPlatform.windows, + ]; + + final Widget content = desktopPlatforms.contains(Theme.of(context).platform) + ? GestureDetector( + onTap: _handleUserTap, + behavior: HitTestBehavior.opaque, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: MentionContentWidget( + mentionId: widget.userId, + displayName: widget.displayName, + textStyle: widget.textStyle, + editorState: widget.editorState, + node: widget.node, + index: widget.index, + )), + ) + : GestureDetector( + onTap: _handleUserTap, + behavior: HitTestBehavior.opaque, + child: MentionContentWidget( + mentionId: widget.userId, + displayName: widget.displayName, + textStyle: widget.textStyle, + editorState: widget.editorState, + node: widget.node, + index: widget.index, + ), + ); + return content; + } + + void _handleUserTap() { + // Implement user tap action (e.g., show profile, start chat) + } +} diff --git a/app/lib/common/widgets/html_editor.dart b/app/lib/common/widgets/html_editor/html_editor.dart similarity index 100% rename from app/lib/common/widgets/html_editor.dart rename to app/lib/common/widgets/html_editor/html_editor.dart diff --git a/app/lib/common/widgets/html_editor/services/mention_shortcuts.dart b/app/lib/common/widgets/html_editor/services/mention_shortcuts.dart new file mode 100644 index 000000000000..b5d3f47e70c4 --- /dev/null +++ b/app/lib/common/widgets/html_editor/services/mention_shortcuts.dart @@ -0,0 +1,84 @@ +import 'package:acter/common/models/types.dart'; +import 'package:acter/common/providers/chat_providers.dart'; +import 'package:acter/common/providers/room_providers.dart'; +import 'package:acter/common/widgets/html_editor/components/mention_menu.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +const userMentionTrigger = '@'; +const roomMentionTrigger = '#'; + +List getMentionShortcuts( + BuildContext context, + RoomQuery query, +) { + return [ + CharacterShortcutEvent( + character: userMentionTrigger, + handler: (editorState) => _handleMentionTrigger( + context: context, + editorState: editorState, + triggerChar: userMentionTrigger, + query: query, + ), + key: userMentionTrigger, + ), + CharacterShortcutEvent( + character: roomMentionTrigger, + handler: (editorState) => _handleMentionTrigger( + context: context, + editorState: editorState, + triggerChar: roomMentionTrigger, + query: query, + ), + key: roomMentionTrigger, + ), + ]; +} + +Future _handleMentionTrigger({ + required BuildContext context, + required EditorState editorState, + required String triggerChar, + required RoomQuery query, +}) async { + final selection = editorState.selection; + if (selection == null) return false; + + if (!selection.isCollapsed) { + await editorState.deleteSelection(selection); + } + // Insert the trigger character + await editorState.insertTextAtPosition( + triggerChar, + position: selection.start, + ); + + /// Riverpod code to fetch mention items here + List items = []; + if (context.mounted) { + final ref = ProviderScope.containerOf(context); + // users of room + if (triggerChar == '@') { + items = await ref.read(membersIdsProvider(query.roomId).future); + } + // rooms + if (triggerChar == '#') { + items = await ref.read(chatIdsProvider); + } + } + // Show menu + if (context.mounted) { + final menu = MentionMenu( + context: context, + editorState: editorState, + items: items, + mentionTrigger: triggerChar, + style: const MentionMenuStyle.dark(), + ); + + menu.show(); + } + return true; +} diff --git a/app/lib/features/comments/widgets/create_comment.dart b/app/lib/features/comments/widgets/create_comment.dart index 9ccfcb1061fb..4f3b54924554 100644 --- a/app/lib/features/comments/widgets/create_comment.dart +++ b/app/lib/features/comments/widgets/create_comment.dart @@ -1,5 +1,5 @@ import 'package:acter/common/toolkit/buttons/primary_action_button.dart'; -import 'package:acter/common/widgets/html_editor.dart'; +import 'package:acter/common/widgets/html_editor/html_editor.dart'; import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; diff --git a/app/lib/features/events/pages/create_event_page.dart b/app/lib/features/events/pages/create_event_page.dart index cd070b7fe37f..3547e1bbd60a 100644 --- a/app/lib/features/events/pages/create_event_page.dart +++ b/app/lib/features/events/pages/create_event_page.dart @@ -3,7 +3,7 @@ import 'package:acter/common/providers/space_providers.dart'; import 'package:acter/common/toolkit/buttons/primary_action_button.dart'; import 'package:acter/common/utils/routes.dart'; import 'package:acter/common/utils/utils.dart'; -import 'package:acter/common/widgets/html_editor.dart'; +import 'package:acter/common/widgets/html_editor/html_editor.dart'; import 'package:acter/common/widgets/spaces/select_space_form_field.dart'; import 'package:acter/common/widgets/spaces/space_selector_drawer.dart'; import 'package:acter/features/events/model/keys.dart'; diff --git a/app/lib/features/news/pages/add_news_page.dart b/app/lib/features/news/pages/add_news_page.dart index f632e709ba49..d4873fde01fd 100644 --- a/app/lib/features/news/pages/add_news_page.dart +++ b/app/lib/features/news/pages/add_news_page.dart @@ -3,7 +3,7 @@ import 'dart:io'; import 'package:acter/common/extensions/options.dart'; import 'package:acter/common/toolkit/buttons/danger_action_button.dart'; import 'package:acter/common/widgets/acter_video_player.dart'; -import 'package:acter/common/widgets/html_editor.dart'; +import 'package:acter/common/widgets/html_editor/html_editor.dart'; import 'package:acter/features/events/providers/event_type_provider.dart'; import 'package:acter/features/events/providers/event_providers.dart'; import 'package:acter/features/events/widgets/event_item.dart'; diff --git a/app/lib/features/tasks/sheets/create_update_task_list.dart b/app/lib/features/tasks/sheets/create_update_task_list.dart index 015443b41d5f..ae2ea2b8be27 100644 --- a/app/lib/features/tasks/sheets/create_update_task_list.dart +++ b/app/lib/features/tasks/sheets/create_update_task_list.dart @@ -4,7 +4,7 @@ import 'package:acter/common/providers/space_providers.dart'; import 'package:acter/common/toolkit/buttons/inline_text_button.dart'; import 'package:acter/common/widgets/acter_icon_picker/acter_icon_widget.dart'; import 'package:acter/common/widgets/acter_icon_picker/model/acter_icons.dart'; -import 'package:acter/common/widgets/html_editor.dart'; +import 'package:acter/common/widgets/html_editor/html_editor.dart'; import 'package:acter/common/widgets/spaces/select_space_form_field.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; From 191a8f8d961fe59e3c32450d2d4ff3f04a2b9ce3 Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 30 Oct 2024 13:38:53 +0000 Subject: [PATCH 02/20] implement mentions fetching logic based on query --- .../html_editor/components/mention_block.dart | 12 +- .../components/mention_handler.dart | 109 ++++++++++-------- .../html_editor/components/mention_menu.dart | 14 ++- .../services/mention_shortcuts.dart | 40 ++----- .../chat_room_messages_provider.dart | 39 +++++++ 5 files changed, 128 insertions(+), 86 deletions(-) diff --git a/app/lib/common/widgets/html_editor/components/mention_block.dart b/app/lib/common/widgets/html_editor/components/mention_block.dart index 285447affa89..47929fc72cc5 100644 --- a/app/lib/common/widgets/html_editor/components/mention_block.dart +++ b/app/lib/common/widgets/html_editor/components/mention_block.dart @@ -7,11 +7,15 @@ enum MentionType { user, room; - static MentionType fromString(String value) => switch (value) { - 'user' => user, - 'room' => room, + static MentionType fromStr(String str) => switch (str) { + '@' => user, + '#' => room, _ => throw UnimplementedError(), }; + static String toStr(MentionType type) => switch (type) { + MentionType.user => '@', + MentionType.room => '#', + }; } class MentionBlockKeys { @@ -44,7 +48,7 @@ class MentionBlock extends StatelessWidget { @override Widget build(BuildContext context) { - final type = MentionType.fromString(mention[MentionBlockKeys.type]); + final type = MentionType.fromStr(mention[MentionBlockKeys.type]); switch (type) { case MentionType.user: diff --git a/app/lib/common/widgets/html_editor/components/mention_handler.dart b/app/lib/common/widgets/html_editor/components/mention_handler.dart index 618b0aab0598..34dfe2e89562 100644 --- a/app/lib/common/widgets/html_editor/components/mention_handler.dart +++ b/app/lib/common/widgets/html_editor/components/mention_handler.dart @@ -1,4 +1,7 @@ +import 'package:acter/common/models/types.dart'; +import 'package:acter/common/widgets/html_editor/components/mention_block.dart'; import 'package:acter/common/widgets/html_editor/components/mention_menu.dart'; +import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; @@ -13,16 +16,16 @@ class MentionHandler extends ConsumerStatefulWidget { const MentionHandler({ super.key, required this.editorState, - required this.items, - required this.mentionTrigger, + required this.query, + required this.mentionType, required this.onDismiss, required this.onSelectionUpdate, required this.style, }); final EditorState editorState; - final List items; - final String mentionTrigger; + final RoomQuery query; + final MentionType mentionType; final VoidCallback onDismiss; final VoidCallback onSelectionUpdate; final MentionMenuStyle style; @@ -32,10 +35,9 @@ class MentionHandler extends ConsumerStatefulWidget { } class _MentionHandlerState extends ConsumerState { - final _focusNode = FocusNode(debugLabel: 'mention_handler'); + final _focusNode = FocusNode(); final _scrollController = ScrollController(); - late List filteredItems = widget.items; int _selectedIndex = 0; late int startOffset; String _search = ''; @@ -58,9 +60,22 @@ class _MentionHandlerState extends ConsumerState { @override Widget build(BuildContext context) { + final suggestions = ref.watch( + mentionSuggestionsProvider((widget.query.roomId, widget.mentionType)), + ); + + final filteredItems = suggestions.entries.where((entry) { + final normalizedId = entry.key.toLowerCase(); + final normalizedName = entry.value.toLowerCase(); + final normalizedQuery = widget.query.query.toLowerCase(); + + return normalizedId.contains(normalizedQuery) || + normalizedName.contains(normalizedQuery); + }).toList(); + return Focus( focusNode: _focusNode, - onKeyEvent: _handleKeyEvent, + onKeyEvent: (node, event) => _handleKeyEvent(node, event, filteredItems), child: Container( constraints: const BoxConstraints( maxHeight: kMentionMenuHeight, @@ -83,7 +98,7 @@ class _MentionHandlerState extends ConsumerState { Padding( padding: const EdgeInsets.all(8.0), child: Text( - widget.mentionTrigger == '@' ? 'Users' : 'Rooms', + widget.mentionType == MentionType.user ? 'Users' : 'Rooms', style: TextStyle( color: widget.style.textColor, fontWeight: FontWeight.bold, @@ -103,12 +118,16 @@ class _MentionHandlerState extends ConsumerState { : ListView.builder( controller: _scrollController, itemCount: filteredItems.length, - itemBuilder: (context, index) => _MentionListItem( - item: filteredItems[index], - isSelected: index == _selectedIndex, - style: widget.style, - onTap: () => _selectItem(filteredItems[index]), - ), + itemBuilder: (context, index) { + final item = filteredItems[index]; + return _MentionListItem( + userId: item.key, + displayName: item.value, + isSelected: index == _selectedIndex, + style: widget.style, + onTap: () => _selectItem(item.key, item.value), + ); + }, ), ), ], @@ -117,7 +136,11 @@ class _MentionHandlerState extends ConsumerState { ); } - KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + KeyEventResult _handleKeyEvent( + FocusNode node, + KeyEvent event, + List> filteredItems, + ) { if (event is! KeyDownEvent) return KeyEventResult.ignored; switch (event.logicalKey) { @@ -127,8 +150,10 @@ class _MentionHandlerState extends ConsumerState { case LogicalKeyboardKey.enter: if (filteredItems.isNotEmpty) { - _selectItem(filteredItems[_selectedIndex]); + final selectedItem = filteredItems[_selectedIndex]; + _selectItem(selectedItem.key, selectedItem.value); } + return KeyEventResult.handled; case LogicalKeyboardKey.arrowUp: @@ -191,23 +216,12 @@ class _MentionHandlerState extends ConsumerState { final text = node.delta?.toPlainText() ?? ''; if (text.length < startOffset) return; - _search = text.substring(startOffset); - _filterItems(_search); - } - - void _filterItems(String search) { - final searchLower = search.toLowerCase(); setState(() { - filteredItems = widget.items - .where((item) => - item.id.toLowerCase().contains(searchLower) || - item.displayName.toLowerCase().contains(searchLower)) - .toList(); - _selectedIndex = 0; + _search = text.substring(startOffset); }); } - void _selectItem(MentionItem item) { + void _selectItem(String id, String displayName) { final selection = widget.editorState.selection; if (selection == null) return; @@ -226,16 +240,14 @@ class _MentionHandlerState extends ConsumerState { transaction.insertText( node, startOffset - 1, - item.displayName, + displayName.isNotEmpty ? displayName : id, attributes: { MentionBlockKeys.mention: { - MentionBlockKeys.type: widget.mentionTrigger == '@' - ? MentionType.user.name - : MentionType.room.name, - if (widget.mentionTrigger == '@') - MentionBlockKeys.userId: item.id + MentionBlockKeys.type: widget.mentionType.name, + if (widget.mentionType == MentionType.user) + MentionBlockKeys.userId: id else - MentionBlockKeys.roomId: item.id, + MentionBlockKeys.roomId: id, }, }, ); @@ -300,13 +312,15 @@ class _MentionHandlerState extends ConsumerState { class _MentionListItem extends StatelessWidget { const _MentionListItem({ - required this.item, + required this.userId, + required this.displayName, required this.isSelected, required this.style, required this.onTap, }); - final MentionItem item; + final String userId; + final String displayName; final bool isSelected; final MentionMenuStyle style; final VoidCallback onTap; @@ -324,21 +338,22 @@ class _MentionListItem extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Text( - item.displayName, + displayName.isNotEmpty ? displayName : userId, style: TextStyle( color: isSelected ? style.selectedTextColor : style.textColor, fontWeight: FontWeight.w500, ), ), - Text( - item.id, - style: TextStyle( - fontSize: 12, - color: isSelected - ? style.selectedTextColor.withOpacity(0.7) - : style.hintColor, + if (displayName.isNotEmpty) + Text( + userId, + style: TextStyle( + fontSize: 12, + color: isSelected + ? style.selectedTextColor.withOpacity(0.7) + : style.hintColor, + ), ), - ), ], ), ), diff --git a/app/lib/common/widgets/html_editor/components/mention_menu.dart b/app/lib/common/widgets/html_editor/components/mention_menu.dart index b441d0bc84a0..0089df6ad723 100644 --- a/app/lib/common/widgets/html_editor/components/mention_menu.dart +++ b/app/lib/common/widgets/html_editor/components/mention_menu.dart @@ -1,3 +1,5 @@ +import 'package:acter/common/models/types.dart'; +import 'package:acter/common/widgets/html_editor/components/mention_block.dart'; import 'package:acter/common/widgets/html_editor/components/mention_handler.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; @@ -37,16 +39,16 @@ class MentionMenu { MentionMenu({ required this.context, required this.editorState, - required this.items, + required this.query, required this.style, - required this.mentionTrigger, + required this.mentionType, }); final BuildContext context; final EditorState editorState; - final List items; + final RoomQuery query; final MentionMenuStyle style; - final String mentionTrigger; + final MentionType mentionType; OverlayEntry? _menuEntry; bool selectionChangedByMenu = false; @@ -83,11 +85,11 @@ class MentionMenu { scrollDirection: Axis.horizontal, child: MentionHandler( editorState: editorState, - items: items, + query: query, onDismiss: dismiss, onSelectionUpdate: _onSelectionUpdate, style: style, - mentionTrigger: mentionTrigger, + mentionType: mentionType, ), ), ), diff --git a/app/lib/common/widgets/html_editor/services/mention_shortcuts.dart b/app/lib/common/widgets/html_editor/services/mention_shortcuts.dart index b5d3f47e70c4..58e9464cc648 100644 --- a/app/lib/common/widgets/html_editor/services/mention_shortcuts.dart +++ b/app/lib/common/widgets/html_editor/services/mention_shortcuts.dart @@ -1,13 +1,8 @@ import 'package:acter/common/models/types.dart'; -import 'package:acter/common/providers/chat_providers.dart'; -import 'package:acter/common/providers/room_providers.dart'; +import 'package:acter/common/widgets/html_editor/components/mention_block.dart'; import 'package:acter/common/widgets/html_editor/components/mention_menu.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -const userMentionTrigger = '@'; -const roomMentionTrigger = '#'; List getMentionShortcuts( BuildContext context, @@ -15,24 +10,24 @@ List getMentionShortcuts( ) { return [ CharacterShortcutEvent( - character: userMentionTrigger, + character: '@', handler: (editorState) => _handleMentionTrigger( context: context, editorState: editorState, - triggerChar: userMentionTrigger, + type: MentionType.user, query: query, ), - key: userMentionTrigger, + key: '@', ), CharacterShortcutEvent( - character: roomMentionTrigger, + character: '#', handler: (editorState) => _handleMentionTrigger( context: context, editorState: editorState, - triggerChar: roomMentionTrigger, + type: MentionType.room, query: query, ), - key: roomMentionTrigger, + key: '#', ), ]; } @@ -40,7 +35,7 @@ List getMentionShortcuts( Future _handleMentionTrigger({ required BuildContext context, required EditorState editorState, - required String triggerChar, + required MentionType type, required RoomQuery query, }) async { final selection = editorState.selection; @@ -51,30 +46,17 @@ Future _handleMentionTrigger({ } // Insert the trigger character await editorState.insertTextAtPosition( - triggerChar, + MentionType.toStr(type), position: selection.start, ); - /// Riverpod code to fetch mention items here - List items = []; - if (context.mounted) { - final ref = ProviderScope.containerOf(context); - // users of room - if (triggerChar == '@') { - items = await ref.read(membersIdsProvider(query.roomId).future); - } - // rooms - if (triggerChar == '#') { - items = await ref.read(chatIdsProvider); - } - } // Show menu if (context.mounted) { final menu = MentionMenu( context: context, editorState: editorState, - items: items, - mentionTrigger: triggerChar, + query: query, + mentionType: type, style: const MentionMenuStyle.dark(), ); diff --git a/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart b/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart index e8efaa8aadfe..ca9ae2d5a9bb 100644 --- a/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart +++ b/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart @@ -1,5 +1,9 @@ +import 'package:acter/common/providers/chat_providers.dart'; +import 'package:acter/common/providers/room_providers.dart'; +import 'package:acter/common/widgets/html_editor/components/mention_block.dart'; import 'package:acter/features/chat_ng/models/chat_room_state/chat_room_state.dart'; import 'package:acter/features/chat_ng/providers/notifiers/chat_room_messages_notifier.dart'; +import 'package:acter/features/home/providers/client_providers.dart'; import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart'; import 'package:flutter/widgets.dart'; import 'package:logging/logging.dart'; @@ -49,3 +53,38 @@ final renderableChatMessagesProvider = return _supportedTypes.contains(msg.eventItem()?.eventType()); }).toList(); }); + +final mentionSuggestionsProvider = + Provider.family, (String, MentionType)>((ref, params) { + final roomId = params.$1; + final mentionType = params.$2; + final client = ref.watch(alwaysClientProvider); + final userId = client.userId().toString(); + + switch (mentionType) { + case MentionType.user: + final members = ref.watch(membersIdsProvider(roomId)).valueOrNull; + if (members != null) { + return members.fold>({}, (map, uId) { + if (uId != userId) { + final displayName = ref.watch( + memberDisplayNameProvider( + (roomId: roomId, userId: uId), + ), + ); + map[uId] = displayName.valueOrNull ?? ''; + } + return map; + }); + } + + case MentionType.room: + final rooms = ref.watch(chatIdsProvider); + return rooms.fold>({}, (map, roomId) { + final displayName = ref.watch(roomDisplayNameProvider(roomId)); + map[roomId] = displayName.valueOrNull ?? ''; + return map; + }); + } + return {}; +}); From 7868d778a5e1f505328cc0b590e26d6e7323e0ad Mon Sep 17 00:00:00 2001 From: Talha Date: Thu, 31 Oct 2024 22:17:30 +0000 Subject: [PATCH 03/20] start from the basic UI setup --- .../components/mention_handler.dart | 10 +- .../html_editor/components/mention_menu.dart | 7 +- .../widgets/html_editor/html_editor.dart | 94 ++++-- .../services/mention_shortcuts.dart | 13 +- app/lib/features/chat_ng/pages/chat_room.dart | 8 +- .../features/chat_ng/widgets/chat_input.dart | 308 ++++++++++++++++++ .../example/pubspec.lock | 4 +- packages/shake_detector/example/pubspec.lock | 4 +- 8 files changed, 389 insertions(+), 59 deletions(-) create mode 100644 app/lib/features/chat_ng/widgets/chat_input.dart diff --git a/app/lib/common/widgets/html_editor/components/mention_handler.dart b/app/lib/common/widgets/html_editor/components/mention_handler.dart index 34dfe2e89562..e1267d395033 100644 --- a/app/lib/common/widgets/html_editor/components/mention_handler.dart +++ b/app/lib/common/widgets/html_editor/components/mention_handler.dart @@ -1,6 +1,6 @@ -import 'package:acter/common/models/types.dart'; import 'package:acter/common/widgets/html_editor/components/mention_block.dart'; import 'package:acter/common/widgets/html_editor/components/mention_menu.dart'; +import 'package:acter/common/widgets/html_editor/html_editor.dart'; import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -16,7 +16,7 @@ class MentionHandler extends ConsumerStatefulWidget { const MentionHandler({ super.key, required this.editorState, - required this.query, + required this.roomId, required this.mentionType, required this.onDismiss, required this.onSelectionUpdate, @@ -24,7 +24,7 @@ class MentionHandler extends ConsumerStatefulWidget { }); final EditorState editorState; - final RoomQuery query; + final String roomId; final MentionType mentionType; final VoidCallback onDismiss; final VoidCallback onSelectionUpdate; @@ -61,13 +61,13 @@ class _MentionHandlerState extends ConsumerState { @override Widget build(BuildContext context) { final suggestions = ref.watch( - mentionSuggestionsProvider((widget.query.roomId, widget.mentionType)), + mentionSuggestionsProvider((widget.roomId, widget.mentionType)), ); final filteredItems = suggestions.entries.where((entry) { final normalizedId = entry.key.toLowerCase(); final normalizedName = entry.value.toLowerCase(); - final normalizedQuery = widget.query.query.toLowerCase(); + final normalizedQuery = widget.editorState.intoMarkdown(); return normalizedId.contains(normalizedQuery) || normalizedName.contains(normalizedQuery); diff --git a/app/lib/common/widgets/html_editor/components/mention_menu.dart b/app/lib/common/widgets/html_editor/components/mention_menu.dart index 0089df6ad723..bab261376757 100644 --- a/app/lib/common/widgets/html_editor/components/mention_menu.dart +++ b/app/lib/common/widgets/html_editor/components/mention_menu.dart @@ -1,4 +1,3 @@ -import 'package:acter/common/models/types.dart'; import 'package:acter/common/widgets/html_editor/components/mention_block.dart'; import 'package:acter/common/widgets/html_editor/components/mention_handler.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; @@ -39,14 +38,14 @@ class MentionMenu { MentionMenu({ required this.context, required this.editorState, - required this.query, + required this.roomId, required this.style, required this.mentionType, }); final BuildContext context; final EditorState editorState; - final RoomQuery query; + final String roomId; final MentionMenuStyle style; final MentionType mentionType; @@ -85,7 +84,7 @@ class MentionMenu { scrollDirection: Axis.horizontal, child: MentionHandler( editorState: editorState, - query: query, + roomId: roomId, onDismiss: dismiss, onSelectionUpdate: _onSelectionUpdate, style: style, diff --git a/app/lib/common/widgets/html_editor/html_editor.dart b/app/lib/common/widgets/html_editor/html_editor.dart index d5bdb38fd7a3..9b445c79a029 100644 --- a/app/lib/common/widgets/html_editor/html_editor.dart +++ b/app/lib/common/widgets/html_editor/html_editor.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:acter/common/extensions/options.dart'; import 'package:acter/common/toolkit/buttons/primary_action_button.dart'; import 'package:acter/common/utils/constants.dart'; +import 'package:acter/common/widgets/html_editor/services/mention_shortcuts.dart'; import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; @@ -97,7 +98,7 @@ typedef ExportCallback = Function(String, String?); class HtmlEditor extends StatefulWidget { static const saveEditKey = Key('html-editor-save'); static const cancelEditKey = Key('html-editor-cancel'); - + final String? roomId; final Widget? header; final Widget? footer; final bool autoFocus; @@ -105,6 +106,7 @@ class HtmlEditor extends StatefulWidget { final bool shrinkWrap; final EditorState? editorState; final EdgeInsets? editorPadding; + final EditorScrollController? scrollController; final TextStyleConfiguration? textStyleConfiguration; final ExportCallback? onSave; final ExportCallback? onChanged; @@ -112,6 +114,7 @@ class HtmlEditor extends StatefulWidget { const HtmlEditor({ super.key, + this.roomId, this.editorState, this.onSave, this.onChanged, @@ -121,6 +124,7 @@ class HtmlEditor extends StatefulWidget { this.editable = false, this.shrinkWrap = false, this.editorPadding = const EdgeInsets.all(10), + this.scrollController, this.header, this.footer, }); @@ -186,7 +190,9 @@ class HtmlEditorState extends State { @override void dispose() { + editorState.selectionNotifier.dispose(); editorScrollController.dispose(); + _changeListener?.cancel(); super.dispose(); } @@ -272,10 +278,16 @@ class HtmlEditorState extends State { autoFocus: widget.autoFocus, header: widget.header, // local states - editorScrollController: editorScrollController, + editorScrollController: + widget.scrollController ?? editorScrollController, editorState: editorState, editorStyle: desktopEditorStyle(), footer: generateFooter(), + characterShortcutEvents: [ + ...standardCharacterShortcutEvents, + if (widget.roomId != null) + ...mentionShortcuts(context, widget.roomId!), + ], ), ), ); @@ -293,39 +305,52 @@ class HtmlEditorState extends State { linkMobileToolbarItem, quoteMobileToolbarItem, ], + toolbarHeight: 48, editorState: editorState, - child: MobileFloatingToolbar( - editorScrollController: editorScrollController, - editorState: editorState, - toolbarBuilder: (context, anchor, closeToolbar) { - return AdaptiveTextSelectionToolbar.editable( - clipboardStatus: ClipboardStatus.pasteable, - onCopy: () { - copyCommand.execute(editorState); - closeToolbar(); - }, - onCut: () => cutCommand.execute(editorState), - onPaste: () => pasteCommand.execute(editorState), - onSelectAll: () => selectAllCommand.execute(editorState), - onLiveTextInput: null, - onLookUp: null, - onSearchWeb: null, - onShare: null, - anchors: TextSelectionToolbarAnchors(primaryAnchor: anchor), - ); - }, - child: AppFlowyEditor( - // widget pass through - editable: widget.editable, - shrinkWrap: widget.shrinkWrap, - autoFocus: widget.autoFocus, - header: widget.header, - // local states - editorState: editorState, - editorScrollController: editorScrollController, - editorStyle: mobileEditorStyle(), - footer: generateFooter(), - ), + child: Column( + children: [ + Expanded( + child: MobileFloatingToolbar( + editorScrollController: editorScrollController, + editorState: editorState, + toolbarBuilder: (context, anchor, closeToolbar) { + return AdaptiveTextSelectionToolbar.editable( + clipboardStatus: ClipboardStatus.pasteable, + onCopy: () { + copyCommand.execute(editorState); + closeToolbar(); + }, + onCut: () => cutCommand.execute(editorState), + onPaste: () => pasteCommand.execute(editorState), + onSelectAll: () => selectAllCommand.execute(editorState), + onLiveTextInput: null, + onLookUp: null, + onSearchWeb: null, + onShare: null, + anchors: TextSelectionToolbarAnchors(primaryAnchor: anchor), + ); + }, + child: AppFlowyEditor( + // widget pass through + editable: widget.editable, + shrinkWrap: widget.shrinkWrap, + autoFocus: widget.autoFocus, + header: widget.header, + // local states + editorState: editorState, + editorScrollController: + widget.scrollController ?? editorScrollController, + editorStyle: mobileEditorStyle(), + footer: generateFooter(), + characterShortcutEvents: [ + ...standardCharacterShortcutEvents, + if (widget.roomId != null) + ...mentionShortcuts(context, widget.roomId!), + ], + ), + ), + ), + ], ), ); } @@ -357,6 +382,7 @@ class HtmlEditorState extends State { .bodySmall .expect('bodySmall style not available'), ), + mobileDragHandleBallSize: const Size(12, 12), ); } } diff --git a/app/lib/common/widgets/html_editor/services/mention_shortcuts.dart b/app/lib/common/widgets/html_editor/services/mention_shortcuts.dart index 58e9464cc648..da43409c6114 100644 --- a/app/lib/common/widgets/html_editor/services/mention_shortcuts.dart +++ b/app/lib/common/widgets/html_editor/services/mention_shortcuts.dart @@ -1,12 +1,11 @@ -import 'package:acter/common/models/types.dart'; import 'package:acter/common/widgets/html_editor/components/mention_block.dart'; import 'package:acter/common/widgets/html_editor/components/mention_menu.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; -List getMentionShortcuts( +List mentionShortcuts( BuildContext context, - RoomQuery query, + String roomId, ) { return [ CharacterShortcutEvent( @@ -15,7 +14,7 @@ List getMentionShortcuts( context: context, editorState: editorState, type: MentionType.user, - query: query, + roomId: roomId, ), key: '@', ), @@ -25,7 +24,7 @@ List getMentionShortcuts( context: context, editorState: editorState, type: MentionType.room, - query: query, + roomId: roomId, ), key: '#', ), @@ -36,7 +35,7 @@ Future _handleMentionTrigger({ required BuildContext context, required EditorState editorState, required MentionType type, - required RoomQuery query, + required String roomId, }) async { final selection = editorState.selection; if (selection == null) return false; @@ -55,7 +54,7 @@ Future _handleMentionTrigger({ final menu = MentionMenu( context: context, editorState: editorState, - query: query, + roomId: roomId, mentionType: type, style: const MentionMenuStyle.dark(), ); diff --git a/app/lib/features/chat_ng/pages/chat_room.dart b/app/lib/features/chat_ng/pages/chat_room.dart index 29849c213251..dfad78f60823 100644 --- a/app/lib/features/chat_ng/pages/chat_room.dart +++ b/app/lib/features/chat_ng/pages/chat_room.dart @@ -4,8 +4,8 @@ import 'package:acter/common/providers/room_providers.dart'; import 'package:acter/common/utils/routes.dart'; import 'package:acter/common/widgets/frost_effect.dart'; import 'package:acter/features/chat/providers/chat_providers.dart'; -import 'package:acter/features/chat/widgets/custom_input.dart'; import 'package:acter/features/chat/widgets/room_avatar.dart'; +import 'package:acter/features/chat_ng/widgets/chat_input.dart'; import 'package:acter/features/chat_ng/widgets/chat_messages.dart'; import 'package:acter/features/settings/providers/app_settings_provider.dart'; import 'package:flutter/material.dart'; @@ -109,9 +109,7 @@ class ChatRoomNgPage extends ConsumerWidget { body: Column( children: [ appBar(context, ref), - Expanded( - child: ChatMessages(roomId: roomId), - ), + Expanded(child: ChatMessages(roomId: roomId)), chatInput(context, ref), ], ), @@ -125,7 +123,7 @@ class ChatRoomNgPage extends ConsumerWidget { (settings) => settings.valueOrNull?.typingNotice() ?? false, ), ); - return CustomChatInput( + return ChatInput( key: Key('chat-input-$roomId'), roomId: roomId, onTyping: (typing) async { diff --git a/app/lib/features/chat_ng/widgets/chat_input.dart b/app/lib/features/chat_ng/widgets/chat_input.dart new file mode 100644 index 000000000000..a656796398c7 --- /dev/null +++ b/app/lib/features/chat_ng/widgets/chat_input.dart @@ -0,0 +1,308 @@ +import 'dart:async'; + +import 'package:acter/common/providers/keyboard_visbility_provider.dart'; +import 'package:acter/common/widgets/frost_effect.dart'; +import 'package:acter/common/widgets/html_editor/html_editor.dart'; +import 'package:acter/features/chat/models/chat_input_state/chat_input_state.dart'; +import 'package:acter/features/chat/providers/chat_providers.dart'; +import 'package:acter/features/chat/widgets/custom_input.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:atlas_icons/atlas_icons.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logging/logging.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; + +final _log = Logger('a3::chat::custom_input'); + +final _allowEdit = StateProvider.family.autoDispose( + (ref, roomId) => ref.watch( + chatInputProvider + .select((state) => state.sendingState == SendingState.preparing), + ), +); + +class ChatInput extends ConsumerWidget { + static const loadingKey = Key('chat-ng-loading'); + static const noAccessKey = Key('chat-ng-no-access'); + + final String roomId; + final void Function(bool)? onTyping; + + const ChatInput({super.key, required this.roomId, this.onTyping}); + + Widget _loadingWidget(BuildContext context) { + return Skeletonizer( + child: FrostEffect( + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + ), + child: Row( + children: [ + IconButton( + onPressed: () {}, + icon: const Icon(Icons.emoji_emotions, size: 20), + ), + Expanded( + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + margin: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: Theme.of(context) + .unselectedWidgetColor + .withOpacity(0.5), + borderRadius: BorderRadius.circular(12), + ), + child: SingleChildScrollView( + child: IntrinsicHeight( + child: HtmlEditor( + footer: null, + editable: true, + shrinkWrap: true, + editorPadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + onChanged: (body, html) {}, + ), + ), + ), + ), + ), + IconButton( + onPressed: () {}, + icon: const Icon( + Atlas.paperclip_attachment_thin, + size: 20, + ), + ), + ], + ), + ), + ), + ); + } + + Widget _noAccessWidget(BuildContext context) { + return FrostEffect( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 15), + decoration: BoxDecoration(color: Theme.of(context).colorScheme.surface), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Row( + children: [ + const SizedBox(width: 1), + const Icon( + Atlas.block_prohibited_thin, + size: 14, + color: Colors.grey, + ), + const SizedBox(width: 4), + Text( + key: ChatInput.noAccessKey, + L10n.of(context).chatMissingPermissionsToSend, + style: const TextStyle( + color: Colors.grey, + fontSize: 14, + ), + ), + ], + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final canSend = ref.watch(canSendProvider(roomId)).valueOrNull; + if (canSend == null) { + // we are still loading + return _loadingWidget(context); + } + if (canSend) { + return _InputWidget( + roomId: roomId, + onTyping: onTyping, + ); + } + // no permissions to send messages + return _noAccessWidget(context); + } +} + +class _InputWidget extends ConsumerStatefulWidget { + final String roomId; + final void Function(bool)? onTyping; + const _InputWidget({required this.roomId, this.onTyping}); + + @override + ConsumerState createState() => __InputWidgetState(); +} + +class __InputWidgetState extends ConsumerState<_InputWidget> { + EditorState textEditorState = EditorState.blank(); + late EditorScrollController scrollController; + FocusNode chatFocus = FocusNode(); + StreamSubscription<(TransactionTime, Transaction)>? _updateListener; + final ValueNotifier _isInputEmptyNotifier = ValueNotifier(true); + double _cHeight = 0.10; + Timer? _debounceTimer; + + @override + void initState() { + super.initState(); + scrollController = + EditorScrollController(editorState: textEditorState, shrinkWrap: true); + // listener for editor input state + _updateListener = textEditorState.transactionStream.listen((data) { + _inputUpdate(); + _updateHeight(); + }); + } + + @override + void dispose() { + _updateListener?.cancel(); + _debounceTimer?.cancel(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _InputWidget oldWidget) { + super.didUpdateWidget(oldWidget); + } + + void _inputUpdate() { + // check if actual document content is empty + final state = textEditorState.document.root.children + .every((node) => node.delta?.toPlainText().isEmpty ?? true); + _isInputEmptyNotifier.value = state; + _debounceTimer?.cancel(); + // delay operation to avoid excessive re-writes + _debounceTimer = Timer(const Duration(milliseconds: 300), () async { + // save composing draft + // await saveDraft(textController.text, widget.roomId, ref); + _log.info('compose draft saved for room: ${widget.roomId}'); + }); + } + + // handler for expanding editor field height + void _updateHeight() { + final text = textEditorState.intoMarkdown(); + final lineCount = '\n'.allMatches(text).length; + + // Calculate new height based on line count + // Start with 5% and increase by 2% per line up to 20% + setState(() { + _cHeight = (0.05 + (lineCount - 1) * 0.02).clamp(0.05, 0.15); + }); + } + + Widget _editorWidget() { + final widgetSize = MediaQuery.sizeOf(context); + return FrostEffect( + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + ), + child: Row( + children: [ + leadingButton(), + IntrinsicHeight( + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + height: widgetSize.height * _cHeight, + width: widgetSize.width * 0.75, + margin: const EdgeInsets.symmetric(vertical: 12), + + decoration: BoxDecoration( + color: + Theme.of(context).unselectedWidgetColor.withOpacity(0.5), + borderRadius: BorderRadius.circular(12), + ), + // constraints: BoxConstraints( + // maxHeight: widgetSize.height * 0.15, + // minHeight: widgetSize.height * 0.06, + // ), + child: SingleChildScrollView( + child: IntrinsicHeight( + child: Focus( + focusNode: chatFocus, + child: HtmlEditor( + footer: null, + roomId: widget.roomId, + editable: true, + shrinkWrap: true, + editorState: textEditorState, + scrollController: scrollController, + editorPadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + onChanged: (body, html) {}, + ), + ), + ), + ), + ), + ), + ValueListenableBuilder( + valueListenable: _isInputEmptyNotifier, + builder: (context, isEmpty, child) => + trailingButton(widget.roomId, isEmpty), + ), + ], + ), + ), + ); + } + + // emoji button + Widget leadingButton() { + return IconButton( + onPressed: () {}, + icon: const Icon(Icons.emoji_emotions, size: 20), + ); + } + + // attachment/send button + Widget trailingButton(String roomId, bool isEmpty) { + final allowEditing = ref.watch(_allowEdit(roomId)); + + if (allowEditing && !isEmpty) { + return IconButton.filled( + padding: const EdgeInsets.symmetric(horizontal: 8), + key: CustomChatInput.sendBtnKey, + iconSize: 20, + onPressed: () => {}, + icon: const Icon(Icons.send), + ); + } + + return IconButton( + onPressed: () {}, + icon: const Icon( + Atlas.paperclip_attachment_thin, + size: 20, + ), + ); + } + + @override + Widget build(BuildContext context) { + final isKeyboardVisible = ref.watch(keyboardVisibleProvider).valueOrNull; + final viewInsets = MediaQuery.viewInsetsOf(context).bottom; + return Column( + children: [ + _editorWidget(), + // adjust bottom viewport so toolbar doesn't obscure field when visible + if (isKeyboardVisible != null && isKeyboardVisible) + SizedBox(height: viewInsets + 50), + ], + ); + } +} diff --git a/packages/acter_trigger_auto_complete/example/pubspec.lock b/packages/acter_trigger_auto_complete/example/pubspec.lock index 56f5665062ab..c6b55440766e 100644 --- a/packages/acter_trigger_auto_complete/example/pubspec.lock +++ b/packages/acter_trigger_auto_complete/example/pubspec.lock @@ -339,10 +339,10 @@ packages: dependency: transitive description: name: vm_service - sha256: f652077d0bdf60abe4c1f6377448e8655008eef28f128bc023f7b5e8dfeb48fc + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" url: "https://pub.dev" source: hosted - version: "14.2.4" + version: "14.2.5" web: dependency: transitive description: diff --git a/packages/shake_detector/example/pubspec.lock b/packages/shake_detector/example/pubspec.lock index 0ad7b4641b7d..85404e0124ab 100644 --- a/packages/shake_detector/example/pubspec.lock +++ b/packages/shake_detector/example/pubspec.lock @@ -248,10 +248,10 @@ packages: dependency: transitive description: name: vm_service - sha256: f652077d0bdf60abe4c1f6377448e8655008eef28f128bc023f7b5e8dfeb48fc + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" url: "https://pub.dev" source: hosted - version: "14.2.4" + version: "14.2.5" sdks: dart: ">=3.3.0 <4.0.0" flutter: ">=3.19.0" From 01f7315790444499c97b8bbeecef5cd306bfde8e Mon Sep 17 00:00:00 2001 From: Talha Date: Fri, 1 Nov 2024 17:48:45 +0000 Subject: [PATCH 04/20] add send message functionality back --- .../features/chat_ng/widgets/chat_input.dart | 132 +++++++++++++++++- 1 file changed, 128 insertions(+), 4 deletions(-) diff --git a/app/lib/features/chat_ng/widgets/chat_input.dart b/app/lib/features/chat_ng/widgets/chat_input.dart index a656796398c7..0cffb1c0115e 100644 --- a/app/lib/features/chat_ng/widgets/chat_input.dart +++ b/app/lib/features/chat_ng/widgets/chat_input.dart @@ -1,18 +1,24 @@ import 'dart:async'; +import 'package:acter/common/providers/chat_providers.dart'; import 'package:acter/common/providers/keyboard_visbility_provider.dart'; import 'package:acter/common/widgets/frost_effect.dart'; import 'package:acter/common/widgets/html_editor/html_editor.dart'; import 'package:acter/features/chat/models/chat_input_state/chat_input_state.dart'; import 'package:acter/features/chat/providers/chat_providers.dart'; +import 'package:acter/features/chat/utils.dart'; import 'package:acter/features/chat/widgets/custom_input.dart'; +import 'package:acter/features/home/providers/client_providers.dart'; +import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' show MsgDraft; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:atlas_icons/atlas_icons.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:acter/common/extensions/options.dart'; final _log = Logger('a3::chat::custom_input'); @@ -162,6 +168,8 @@ class __InputWidgetState extends ConsumerState<_InputWidget> { _inputUpdate(); _updateHeight(); }); + + WidgetsBinding.instance.addPostFrameCallback((_) => _loadDraft); } @override @@ -174,6 +182,9 @@ class __InputWidgetState extends ConsumerState<_InputWidget> { @override void didUpdateWidget(covariant _InputWidget oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.roomId != widget.roomId) { + WidgetsBinding.instance.addPostFrameCallback((_) => _loadDraft()); + } } void _inputUpdate() { @@ -185,7 +196,7 @@ class __InputWidgetState extends ConsumerState<_InputWidget> { // delay operation to avoid excessive re-writes _debounceTimer = Timer(const Duration(milliseconds: 300), () async { // save composing draft - // await saveDraft(textController.text, widget.roomId, ref); + await saveDraft(textEditorState.intoMarkdown(), widget.roomId, ref); _log.info('compose draft saved for room: ${widget.roomId}'); }); } @@ -202,6 +213,112 @@ class __InputWidgetState extends ConsumerState<_InputWidget> { }); } + // composer draft load state handler + Future _loadDraft() async { + final draft = + await ref.read(chatComposerDraftProvider(widget.roomId).future); + + if (draft != null) { + final inputNotifier = ref.read(chatInputProvider.notifier); + inputNotifier.unsetSelectedMessage(); + draft.eventId().map((eventId) { + final draftType = draft.draftType(); + final m = ref + .read(chatMessagesProvider(widget.roomId)) + .firstWhere((x) => x.id == eventId); + if (draftType == 'edit') { + inputNotifier.setEditMessage(m); + } else if (draftType == 'reply') { + inputNotifier.setReplyToMessage(m); + } + }); + await draft.htmlText().mapAsync( + (html) async { + final doc = ActerDocumentHelpers.parse( + draft.plainText(), + htmlContent: draft.htmlText(), + ); + final transaction = textEditorState.transaction; + transaction.insertNodes([0], doc.root.children); + + textEditorState.apply(transaction); + }, + orElse: () { + final doc = ActerDocumentHelpers.parse( + draft.plainText(), + ); + final transaction = textEditorState.transaction; + transaction.insertNodes([0], doc.root.children); + textEditorState.apply(transaction); + }, + ); + _log.info('compose draft loaded for room: ${widget.roomId}'); + } + } + + Future onSendButtonPressed(String body, String? html) async { + final lang = L10n.of(context); + ref.read(chatInputProvider.notifier).startSending(); + + try { + // end the typing notification + widget.onTyping?.map((cb) => cb(false)); + + // make the actual draft + final client = ref.read(alwaysClientProvider); + late MsgDraft draft; + if (html != null) { + draft = client.textHtmlDraft(html, body); + } else { + draft = client.textMarkdownDraft(body); + } + + // actually send it out + final inputState = ref.read(chatInputProvider); + final stream = + await ref.read(timelineStreamProvider(widget.roomId).future); + + if (inputState.selectedMessageState == SelectedMessageState.replyTo) { + final remoteId = inputState.selectedMessage?.remoteId; + if (remoteId == null) throw 'remote id of sel msg not available'; + await stream.replyMessage(remoteId, draft); + } else if (inputState.selectedMessageState == SelectedMessageState.edit) { + final remoteId = inputState.selectedMessage?.remoteId; + if (remoteId == null) throw 'remote id of sel msg not available'; + await stream.editMessage(remoteId, draft); + } else { + await stream.sendMessage(draft); + } + + ref.read(chatInputProvider.notifier).messageSent(); + // TODO: currently issuing with the focus and transaction state + // need to approach differently + textEditorState = EditorState.blank(); + + // also clear composed state + final convo = await ref.read(chatProvider(widget.roomId).future); + if (convo != null) { + await convo.saveMsgDraft( + textEditorState.intoMarkdown(), + null, + 'new', + null, + ); + } + } catch (e, s) { + _log.severe('Sending chat message failed', e, s); + EasyLoading.showError( + lang.failedToSend(e), + duration: const Duration(seconds: 3), + ); + ref.read(chatInputProvider.notifier).sendingFailed(); + } + + if (!chatFocus.hasFocus) { + chatFocus.requestFocus(); + } + } + Widget _editorWidget() { final widgetSize = MediaQuery.sizeOf(context); return FrostEffect( @@ -243,7 +360,13 @@ class __InputWidgetState extends ConsumerState<_InputWidget> { horizontal: 10, vertical: 5, ), - onChanged: (body, html) {}, + onChanged: (body, html) { + if (html != null) { + widget.onTyping?.map((cb) => cb(html.isNotEmpty)); + } else { + widget.onTyping?.map((cb) => cb(body.isNotEmpty)); + } + }, ), ), ), @@ -272,13 +395,14 @@ class __InputWidgetState extends ConsumerState<_InputWidget> { // attachment/send button Widget trailingButton(String roomId, bool isEmpty) { final allowEditing = ref.watch(_allowEdit(roomId)); - + final body = textEditorState.intoMarkdown(); + final html = textEditorState.intoHtml(); if (allowEditing && !isEmpty) { return IconButton.filled( padding: const EdgeInsets.symmetric(horizontal: 8), key: CustomChatInput.sendBtnKey, iconSize: 20, - onPressed: () => {}, + onPressed: () => onSendButtonPressed(body, html), icon: const Icon(Icons.send), ); } From a16dd7f71c4d1ec3eb074328e2c3a9659f62cd7f Mon Sep 17 00:00:00 2001 From: Talha Date: Mon, 4 Nov 2024 18:31:18 +0000 Subject: [PATCH 05/20] fix editor focus when sending message --- .../features/chat_ng/widgets/chat_input.dart | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/app/lib/features/chat_ng/widgets/chat_input.dart b/app/lib/features/chat_ng/widgets/chat_input.dart index 0cffb1c0115e..81fc4de3aee8 100644 --- a/app/lib/features/chat_ng/widgets/chat_input.dart +++ b/app/lib/features/chat_ng/widgets/chat_input.dart @@ -163,12 +163,15 @@ class __InputWidgetState extends ConsumerState<_InputWidget> { super.initState(); scrollController = EditorScrollController(editorState: textEditorState, shrinkWrap: true); + _updateListener?.cancel(); // listener for editor input state _updateListener = textEditorState.transactionStream.listen((data) { _inputUpdate(); + // expand when user types more than one line upto exceed limit _updateHeight(); }); - + // have it call the first time to adjust height + _updateHeight(); WidgetsBinding.instance.addPostFrameCallback((_) => _loadDraft); } @@ -207,9 +210,9 @@ class __InputWidgetState extends ConsumerState<_InputWidget> { final lineCount = '\n'.allMatches(text).length; // Calculate new height based on line count - // Start with 5% and increase by 2% per line up to 20% + // Start with 5% and increase by 4% per line up to 20% setState(() { - _cHeight = (0.05 + (lineCount - 1) * 0.02).clamp(0.05, 0.15); + _cHeight = (0.05 + (lineCount - 1) * 0.04).clamp(0.05, 0.15); }); } @@ -291,9 +294,16 @@ class __InputWidgetState extends ConsumerState<_InputWidget> { } ref.read(chatInputProvider.notifier).messageSent(); - // TODO: currently issuing with the focus and transaction state - // need to approach differently - textEditorState = EditorState.blank(); + final transaction = textEditorState.transaction; + final nodes = transaction.document.root.children; + // delete all nodes of document (reset) + transaction.document.delete([0], nodes.length); + final delta = Delta()..insert(''); + // insert empty text node + transaction.document.insert([0], [paragraphNode(delta: delta)]); + await textEditorState.apply(transaction, withUpdateSelection: false); + // FIXME: works for single text, but doesn't get focus on multi-line + textEditorState.moveCursorForward(SelectionMoveRange.line); // also clear composed state final convo = await ref.read(chatProvider(widget.roomId).future); @@ -331,20 +341,15 @@ class __InputWidgetState extends ConsumerState<_InputWidget> { leadingButton(), IntrinsicHeight( child: AnimatedContainer( - duration: const Duration(milliseconds: 150), + duration: const Duration(milliseconds: 100), height: widgetSize.height * _cHeight, width: widgetSize.width * 0.75, margin: const EdgeInsets.symmetric(vertical: 12), - decoration: BoxDecoration( color: Theme.of(context).unselectedWidgetColor.withOpacity(0.5), borderRadius: BorderRadius.circular(12), ), - // constraints: BoxConstraints( - // maxHeight: widgetSize.height * 0.15, - // minHeight: widgetSize.height * 0.06, - // ), child: SingleChildScrollView( child: IntrinsicHeight( child: Focus( From 6fe1e5a12663e9ddaef81bd5c1d1fd5ae42178fc Mon Sep 17 00:00:00 2001 From: Talha Date: Mon, 4 Nov 2024 19:42:08 +0000 Subject: [PATCH 06/20] implement emoji picker logic --- .../features/chat_ng/widgets/chat_input.dart | 89 +++++++++++++++++-- 1 file changed, 84 insertions(+), 5 deletions(-) diff --git a/app/lib/features/chat_ng/widgets/chat_input.dart b/app/lib/features/chat_ng/widgets/chat_input.dart index 81fc4de3aee8..a7d3eade0ba5 100644 --- a/app/lib/features/chat_ng/widgets/chat_input.dart +++ b/app/lib/features/chat_ng/widgets/chat_input.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:acter/common/providers/chat_providers.dart'; import 'package:acter/common/providers/keyboard_visbility_provider.dart'; +import 'package:acter/common/widgets/emoji_picker_widget.dart'; import 'package:acter/common/widgets/frost_effect.dart'; import 'package:acter/common/widgets/html_editor/html_editor.dart'; import 'package:acter/features/chat/models/chat_input_state/chat_input_state.dart'; @@ -12,6 +13,7 @@ import 'package:acter/features/home/providers/client_providers.dart'; import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' show MsgDraft; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:atlas_icons/atlas_icons.dart'; +import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; import 'package:flutter/material.dart'; import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -329,7 +331,73 @@ class __InputWidgetState extends ConsumerState<_InputWidget> { } } - Widget _editorWidget() { + // UI handler for emoji picker widget + void onEmojiBtnTap(bool emojiPickerVisible) { + final chatInputNotifier = ref.read(chatInputProvider.notifier); + if (!emojiPickerVisible) { + //Hide soft keyboard and then show Emoji Picker + chatInputNotifier.emojiPickerVisible(true); + } else { + //Hide Emoji Picker + chatInputNotifier.emojiPickerVisible(false); + } + } + + void _handleEmojiSelected(Category? category, Emoji emoji) { + final selection = textEditorState.selection; + final transaction = textEditorState.transaction; + if (selection != null) { + if (selection.isCollapsed) { + final node = textEditorState.getNodeAtPath(selection.end.path); + if (node == null) return; + // we're at the start + transaction.insertText(node, selection.endIndex, emoji.emoji); + transaction.afterSelection = Selection.collapsed( + Position( + path: selection.end.path, + offset: selection.end.offset + emoji.emoji.length, + ), + ); + } else { + // we have selected some text part to replace with emoji + final startNode = textEditorState.getNodeAtPath(selection.start.path); + if (startNode == null) return; + transaction.deleteText( + startNode, + selection.startIndex, + selection.end.offset - selection.start.offset, + ); + transaction.insertText( + startNode, + selection.startIndex, + emoji.emoji, + ); + + transaction.afterSelection = Selection.collapsed( + Position( + path: selection.start.path, + offset: selection.start.offset + emoji.emoji.length, + ), + ); + } + + textEditorState.apply(transaction); + } + return; + } + + // editor picker widget backspace handling + void _handleBackspacePressed() { + final isEmpty = textEditorState.transaction.document.isEmpty; + if (isEmpty) { + // nothing left to clear, close the emoji picker + ref.read(chatInputProvider.notifier).emojiPickerVisible(false); + return; + } + textEditorState.deleteBackward(); + } + + Widget _editorWidget(bool emojiPickerVisible) { final widgetSize = MediaQuery.sizeOf(context); return FrostEffect( child: DecoratedBox( @@ -338,7 +406,7 @@ class __InputWidgetState extends ConsumerState<_InputWidget> { ), child: Row( children: [ - leadingButton(), + leadingButton(emojiPickerVisible), IntrinsicHeight( child: AnimatedContainer( duration: const Duration(milliseconds: 100), @@ -390,9 +458,9 @@ class __InputWidgetState extends ConsumerState<_InputWidget> { } // emoji button - Widget leadingButton() { + Widget leadingButton(bool emojiPickerVisible) { return IconButton( - onPressed: () {}, + onPressed: () => onEmojiBtnTap(emojiPickerVisible), icon: const Icon(Icons.emoji_emotions, size: 20), ); } @@ -424,10 +492,21 @@ class __InputWidgetState extends ConsumerState<_InputWidget> { @override Widget build(BuildContext context) { final isKeyboardVisible = ref.watch(keyboardVisibleProvider).valueOrNull; + final emojiPickerVisible = ref + .watch(chatInputProvider.select((value) => value.emojiPickerVisible)); + final screenSize = MediaQuery.sizeOf(context); final viewInsets = MediaQuery.viewInsetsOf(context).bottom; return Column( children: [ - _editorWidget(), + _editorWidget(emojiPickerVisible), + if (emojiPickerVisible) + EmojiPickerWidget( + size: Size(screenSize.width, screenSize.height / 3), + onEmojiSelected: _handleEmojiSelected, + onBackspacePressed: _handleBackspacePressed, + onClosePicker: () => + ref.read(chatInputProvider.notifier).emojiPickerVisible(false), + ), // adjust bottom viewport so toolbar doesn't obscure field when visible if (isKeyboardVisible != null && isKeyboardVisible) SizedBox(height: viewInsets + 50), From 7bc47faee3073fdc3dbf0f2ad926aef444c705ed Mon Sep 17 00:00:00 2001 From: Talha Date: Mon, 4 Nov 2024 19:44:21 +0000 Subject: [PATCH 07/20] fix ordering state --- app/lib/features/chat_ng/widgets/chat_messages.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/lib/features/chat_ng/widgets/chat_messages.dart b/app/lib/features/chat_ng/widgets/chat_messages.dart index a9a9e2966aa4..126687340f71 100644 --- a/app/lib/features/chat_ng/widgets/chat_messages.dart +++ b/app/lib/features/chat_ng/widgets/chat_messages.dart @@ -15,7 +15,7 @@ class ChatMessages extends ConsumerWidget { return AnimatedList( initialItemCount: messages.length, - reverse: true, + reverse: false, key: animatedListKey, itemBuilder: (_, index, animation) => ChatEventWidget( roomId: roomId, From f0ab8033a98ed6adb85cf04ccbf66eccb6427fa8 Mon Sep 17 00:00:00 2001 From: Talha Date: Mon, 4 Nov 2024 19:50:54 +0000 Subject: [PATCH 08/20] restore attachment button functionality --- .../features/chat_ng/widgets/chat_input.dart | 93 +++++++++++++++++-- 1 file changed, 86 insertions(+), 7 deletions(-) diff --git a/app/lib/features/chat_ng/widgets/chat_input.dart b/app/lib/features/chat_ng/widgets/chat_input.dart index a7d3eade0ba5..108170faeec7 100644 --- a/app/lib/features/chat_ng/widgets/chat_input.dart +++ b/app/lib/features/chat_ng/widgets/chat_input.dart @@ -1,10 +1,13 @@ import 'dart:async'; +import 'dart:io'; +import 'package:acter/common/models/types.dart'; import 'package:acter/common/providers/chat_providers.dart'; import 'package:acter/common/providers/keyboard_visbility_provider.dart'; import 'package:acter/common/widgets/emoji_picker_widget.dart'; import 'package:acter/common/widgets/frost_effect.dart'; import 'package:acter/common/widgets/html_editor/html_editor.dart'; +import 'package:acter/features/attachments/actions/select_attachment.dart'; import 'package:acter/features/chat/models/chat_input_state/chat_input_state.dart'; import 'package:acter/features/chat/providers/chat_providers.dart'; import 'package:acter/features/chat/utils.dart'; @@ -18,6 +21,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; +import 'package:mime/mime.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:acter/common/extensions/options.dart'; @@ -343,7 +347,7 @@ class __InputWidgetState extends ConsumerState<_InputWidget> { } } - void _handleEmojiSelected(Category? category, Emoji emoji) { + void handleEmojiSelected(Category? category, Emoji emoji) { final selection = textEditorState.selection; final transaction = textEditorState.transaction; if (selection != null) { @@ -387,7 +391,7 @@ class __InputWidgetState extends ConsumerState<_InputWidget> { } // editor picker widget backspace handling - void _handleBackspacePressed() { + void handleBackspacePressed() { final isEmpty = textEditorState.transaction.document.isEmpty; if (isEmpty) { // nothing left to clear, close the emoji picker @@ -397,6 +401,78 @@ class __InputWidgetState extends ConsumerState<_InputWidget> { textEditorState.deleteBackward(); } + // attachment upload handler + Future handleFileUpload( + List files, + AttachmentType attachmentType, + ) async { + final client = ref.read(alwaysClientProvider); + final inputState = ref.read(chatInputProvider); + final lang = L10n.of(context); + final stream = await ref.read(timelineStreamProvider(widget.roomId).future); + + try { + for (final file in files) { + String? mimeType = lookupMimeType(file.path); + if (mimeType == null) throw lang.failedToDetectMimeType; + final fileLen = file.lengthSync(); + if (mimeType.startsWith('image/') && + attachmentType == AttachmentType.image) { + final bytes = file.readAsBytesSync(); + final image = await decodeImageFromList(bytes); + final imageDraft = client + .imageDraft(file.path, mimeType) + .size(fileLen) + .width(image.width) + .height(image.height); + if (inputState.selectedMessageState == SelectedMessageState.replyTo) { + final remoteId = inputState.selectedMessage?.remoteId; + if (remoteId == null) throw 'remote id of sel msg not available'; + await stream.replyMessage(remoteId, imageDraft); + } else { + await stream.sendMessage(imageDraft); + } + } else if (mimeType.startsWith('audio/') && + attachmentType == AttachmentType.audio) { + final audioDraft = + client.audioDraft(file.path, mimeType).size(file.lengthSync()); + if (inputState.selectedMessageState == SelectedMessageState.replyTo) { + final remoteId = inputState.selectedMessage?.remoteId; + if (remoteId == null) throw 'remote id of sel msg not available'; + await stream.replyMessage(remoteId, audioDraft); + } else { + await stream.sendMessage(audioDraft); + } + } else if (mimeType.startsWith('video/') && + attachmentType == AttachmentType.video) { + final videoDraft = + client.videoDraft(file.path, mimeType).size(file.lengthSync()); + if (inputState.selectedMessageState == SelectedMessageState.replyTo) { + final remoteId = inputState.selectedMessage?.remoteId; + if (remoteId == null) throw 'remote id of sel msg not available'; + await stream.replyMessage(remoteId, videoDraft); + } else { + await stream.sendMessage(videoDraft); + } + } else { + final fileDraft = + client.fileDraft(file.path, mimeType).size(file.lengthSync()); + if (inputState.selectedMessageState == SelectedMessageState.replyTo) { + final remoteId = inputState.selectedMessage?.remoteId; + if (remoteId == null) throw 'remote id of sel msg not available'; + await stream.replyMessage(remoteId, fileDraft); + } else { + await stream.sendMessage(fileDraft); + } + } + } + } catch (e, s) { + _log.severe('error occurred', e, s); + } + + ref.read(chatInputProvider.notifier).unsetSelectedMessage(); + } + Widget _editorWidget(bool emojiPickerVisible) { final widgetSize = MediaQuery.sizeOf(context); return FrostEffect( @@ -449,7 +525,7 @@ class __InputWidgetState extends ConsumerState<_InputWidget> { ValueListenableBuilder( valueListenable: _isInputEmptyNotifier, builder: (context, isEmpty, child) => - trailingButton(widget.roomId, isEmpty), + trailingButton(context, widget.roomId, isEmpty), ), ], ), @@ -466,7 +542,7 @@ class __InputWidgetState extends ConsumerState<_InputWidget> { } // attachment/send button - Widget trailingButton(String roomId, bool isEmpty) { + Widget trailingButton(BuildContext context, String roomId, bool isEmpty) { final allowEditing = ref.watch(_allowEdit(roomId)); final body = textEditorState.intoMarkdown(); final html = textEditorState.intoHtml(); @@ -481,7 +557,10 @@ class __InputWidgetState extends ConsumerState<_InputWidget> { } return IconButton( - onPressed: () {}, + onPressed: () => selectAttachment( + context: context, + onSelected: handleFileUpload, + ), icon: const Icon( Atlas.paperclip_attachment_thin, size: 20, @@ -502,8 +581,8 @@ class __InputWidgetState extends ConsumerState<_InputWidget> { if (emojiPickerVisible) EmojiPickerWidget( size: Size(screenSize.width, screenSize.height / 3), - onEmojiSelected: _handleEmojiSelected, - onBackspacePressed: _handleBackspacePressed, + onEmojiSelected: handleEmojiSelected, + onBackspacePressed: handleBackspacePressed, onClosePicker: () => ref.read(chatInputProvider.notifier).emojiPickerVisible(false), ), From 71fcd4fbb70efb205554f3cc01ad29620fb6448d Mon Sep 17 00:00:00 2001 From: Talha Date: Mon, 4 Nov 2024 20:29:33 +0000 Subject: [PATCH 09/20] fix lint errors --- .../components/room_mention_block.dart | 19 ++++++++++--------- .../components/user_mention_block.dart | 19 ++++++++++--------- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/app/lib/common/widgets/html_editor/components/room_mention_block.dart b/app/lib/common/widgets/html_editor/components/room_mention_block.dart index acde7f668068..94b80268f743 100644 --- a/app/lib/common/widgets/html_editor/components/room_mention_block.dart +++ b/app/lib/common/widgets/html_editor/components/room_mention_block.dart @@ -40,15 +40,16 @@ class _RoomMentionBlockState extends State { onTap: _handleRoomTap, behavior: HitTestBehavior.opaque, child: MouseRegion( - cursor: SystemMouseCursors.click, - child: MentionContentWidget( - mentionId: widget.roomId, - displayName: widget.displayName, - textStyle: widget.textStyle, - editorState: widget.editorState, - node: widget.node, - index: widget.index, - )), + cursor: SystemMouseCursors.click, + child: MentionContentWidget( + mentionId: widget.roomId, + displayName: widget.displayName, + textStyle: widget.textStyle, + editorState: widget.editorState, + node: widget.node, + index: widget.index, + ), + ), ) : GestureDetector( onTap: _handleRoomTap, diff --git a/app/lib/common/widgets/html_editor/components/user_mention_block.dart b/app/lib/common/widgets/html_editor/components/user_mention_block.dart index a01d9c8430be..97569bb74056 100644 --- a/app/lib/common/widgets/html_editor/components/user_mention_block.dart +++ b/app/lib/common/widgets/html_editor/components/user_mention_block.dart @@ -40,15 +40,16 @@ class _UserMentionBlockState extends State { onTap: _handleUserTap, behavior: HitTestBehavior.opaque, child: MouseRegion( - cursor: SystemMouseCursors.click, - child: MentionContentWidget( - mentionId: widget.userId, - displayName: widget.displayName, - textStyle: widget.textStyle, - editorState: widget.editorState, - node: widget.node, - index: widget.index, - )), + cursor: SystemMouseCursors.click, + child: MentionContentWidget( + mentionId: widget.userId, + displayName: widget.displayName, + textStyle: widget.textStyle, + editorState: widget.editorState, + node: widget.node, + index: widget.index, + ), + ), ) : GestureDetector( onTap: _handleUserTap, From 5679cab030e1589d2df5e3531a8f0686f8c8fec9 Mon Sep 17 00:00:00 2001 From: Talha Date: Tue, 5 Nov 2024 12:00:54 +0000 Subject: [PATCH 10/20] add changelogs --- .changes/2332-chat-ng-editor.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 .changes/2332-chat-ng-editor.md diff --git a/.changes/2332-chat-ng-editor.md b/.changes/2332-chat-ng-editor.md new file mode 100644 index 000000000000..2302ad742572 --- /dev/null +++ b/.changes/2332-chat-ng-editor.md @@ -0,0 +1 @@ +- Chat-NG now supports Appflowy editor with proper markdown support. Some/Other features might be disabled or limited for now. From 0ef9aa30d689b4056bc63b37ffb7139027028458 Mon Sep 17 00:00:00 2001 From: Talha Date: Tue, 5 Nov 2024 14:10:15 +0000 Subject: [PATCH 11/20] make editor size more responsive on desktop --- .../features/chat_ng/widgets/chat_input.dart | 106 ++++++++++-------- 1 file changed, 59 insertions(+), 47 deletions(-) diff --git a/app/lib/features/chat_ng/widgets/chat_input.dart b/app/lib/features/chat_ng/widgets/chat_input.dart index 108170faeec7..9f765535c7d5 100644 --- a/app/lib/features/chat_ng/widgets/chat_input.dart +++ b/app/lib/features/chat_ng/widgets/chat_input.dart @@ -308,7 +308,7 @@ class __InputWidgetState extends ConsumerState<_InputWidget> { // insert empty text node transaction.document.insert([0], [paragraphNode(delta: delta)]); await textEditorState.apply(transaction, withUpdateSelection: false); - // FIXME: works for single text, but doesn't get focus on multi-line + // FIXME: works for single text, but doesn't get focus on multi-line (iOS) textEditorState.moveCursorForward(SelectionMoveRange.line); // also clear composed state @@ -483,39 +483,44 @@ class __InputWidgetState extends ConsumerState<_InputWidget> { child: Row( children: [ leadingButton(emojiPickerVisible), - IntrinsicHeight( - child: AnimatedContainer( - duration: const Duration(milliseconds: 100), - height: widgetSize.height * _cHeight, - width: widgetSize.width * 0.75, - margin: const EdgeInsets.symmetric(vertical: 12), - decoration: BoxDecoration( - color: - Theme.of(context).unselectedWidgetColor.withOpacity(0.5), - borderRadius: BorderRadius.circular(12), - ), - child: SingleChildScrollView( - child: IntrinsicHeight( - child: Focus( - focusNode: chatFocus, - child: HtmlEditor( - footer: null, - roomId: widget.roomId, - editable: true, - shrinkWrap: true, - editorState: textEditorState, - scrollController: scrollController, - editorPadding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 5, + Expanded( + child: IntrinsicHeight( + child: AnimatedContainer( + duration: const Duration(milliseconds: 100), + height: widgetSize.height * _cHeight, + margin: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 8, + ), + decoration: BoxDecoration( + color: Theme.of(context) + .unselectedWidgetColor + .withOpacity(0.5), + borderRadius: BorderRadius.circular(12), + ), + child: SingleChildScrollView( + child: IntrinsicHeight( + child: Focus( + focusNode: chatFocus, + child: HtmlEditor( + footer: null, + roomId: widget.roomId, + editable: true, + shrinkWrap: true, + editorState: textEditorState, + scrollController: scrollController, + editorPadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + onChanged: (body, html) { + if (html != null) { + widget.onTyping?.map((cb) => cb(html.isNotEmpty)); + } else { + widget.onTyping?.map((cb) => cb(body.isNotEmpty)); + } + }, ), - onChanged: (body, html) { - if (html != null) { - widget.onTyping?.map((cb) => cb(html.isNotEmpty)); - } else { - widget.onTyping?.map((cb) => cb(body.isNotEmpty)); - } - }, ), ), ), @@ -536,6 +541,7 @@ class __InputWidgetState extends ConsumerState<_InputWidget> { // emoji button Widget leadingButton(bool emojiPickerVisible) { return IconButton( + padding: const EdgeInsets.only(left: 8), onPressed: () => onEmojiBtnTap(emojiPickerVisible), icon: const Icon(Icons.emoji_emotions, size: 20), ); @@ -547,23 +553,29 @@ class __InputWidgetState extends ConsumerState<_InputWidget> { final body = textEditorState.intoMarkdown(); final html = textEditorState.intoHtml(); if (allowEditing && !isEmpty) { - return IconButton.filled( - padding: const EdgeInsets.symmetric(horizontal: 8), - key: CustomChatInput.sendBtnKey, - iconSize: 20, - onPressed: () => onSendButtonPressed(body, html), - icon: const Icon(Icons.send), + return Padding( + padding: const EdgeInsets.only(right: 8), + child: IconButton.filled( + alignment: Alignment.center, + key: CustomChatInput.sendBtnKey, + iconSize: 20, + onPressed: () => onSendButtonPressed(body, html), + icon: const Icon(Icons.send), + ), ); } - return IconButton( - onPressed: () => selectAttachment( - context: context, - onSelected: handleFileUpload, - ), - icon: const Icon( - Atlas.paperclip_attachment_thin, - size: 20, + return Padding( + padding: const EdgeInsets.only(right: 8), + child: IconButton( + onPressed: () => selectAttachment( + context: context, + onSelected: handleFileUpload, + ), + icon: const Icon( + Atlas.paperclip_attachment_thin, + size: 20, + ), ), ); } From 0be9f23f59f0af95d08330c6ee5868bb5c11cda7 Mon Sep 17 00:00:00 2001 From: Talha Date: Tue, 5 Nov 2024 17:57:52 +0000 Subject: [PATCH 12/20] fix and update compose draft state with editorState --- .../widgets/html_editor/html_editor.dart | 1 + app/lib/features/chat/utils.dart | 8 +- .../features/chat/widgets/custom_input.dart | 4 +- .../features/chat_ng/widgets/chat_input.dart | 104 ++++++++++-------- 4 files changed, 66 insertions(+), 51 deletions(-) diff --git a/app/lib/common/widgets/html_editor/html_editor.dart b/app/lib/common/widgets/html_editor/html_editor.dart index 9b445c79a029..3862eda7ba9e 100644 --- a/app/lib/common/widgets/html_editor/html_editor.dart +++ b/app/lib/common/widgets/html_editor/html_editor.dart @@ -288,6 +288,7 @@ class HtmlEditorState extends State { if (widget.roomId != null) ...mentionShortcuts(context, widget.roomId!), ], + commandShortcutEvents: [...standardCommandShortcutEvents], ), ), ); diff --git a/app/lib/features/chat/utils.dart b/app/lib/features/chat/utils.dart index 836373da7e9e..7c9ca4c5856a 100644 --- a/app/lib/features/chat/utils.dart +++ b/app/lib/features/chat/utils.dart @@ -337,13 +337,17 @@ Future parseUserMentionText( } // save composer draft object handler -Future saveDraft(String text, String roomId, WidgetRef ref) async { +Future saveDraft( + String text, + String? htmlText, + String roomId, + WidgetRef ref, +) async { // get the convo object to initiate draft final chat = await ref.read(chatProvider(roomId).future); final messageId = ref.read(chatInputProvider).selectedMessage?.id; final mentions = ref.read(chatInputProvider).mentions; final userMentions = []; - String? htmlText = text; if (mentions.isNotEmpty) { mentions.forEach((key, value) { userMentions.add(value); diff --git a/app/lib/features/chat/widgets/custom_input.dart b/app/lib/features/chat/widgets/custom_input.dart index c67697e05ea5..432853e73ee9 100644 --- a/app/lib/features/chat/widgets/custom_input.dart +++ b/app/lib/features/chat/widgets/custom_input.dart @@ -285,7 +285,7 @@ class __ChatInputState extends ConsumerState<_ChatInput> { // delay operation to avoid excessive re-writes _debounceTimer = Timer(const Duration(milliseconds: 300), () async { // save composing draft - await saveDraft(textController.text, widget.roomId, ref); + await saveDraft(textController.text, null, widget.roomId, ref); _log.info('compose draft saved for room: ${widget.roomId}'); }); } @@ -779,7 +779,7 @@ class _TextInputWidgetConsumerState extends ConsumerState<_TextInputWidget> { (next.selectedMessage != prev?.selectedMessage || prev?.selectedMessageState != next.selectedMessageState)) { // controller doesn’t update text so manually save draft state - saveDraft(widget.controller.text, widget.roomId, ref); + saveDraft(widget.controller.text, null, widget.roomId, ref); WidgetsBinding.instance.addPostFrameCallback((_) { widget.chatFocus.requestFocus(); }); diff --git a/app/lib/features/chat_ng/widgets/chat_input.dart b/app/lib/features/chat_ng/widgets/chat_input.dart index 9f765535c7d5..24045afebfe9 100644 --- a/app/lib/features/chat_ng/widgets/chat_input.dart +++ b/app/lib/features/chat_ng/widgets/chat_input.dart @@ -18,6 +18,7 @@ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:atlas_icons/atlas_icons.dart'; import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; @@ -67,17 +68,16 @@ class ChatInput extends ConsumerWidget { .withOpacity(0.5), borderRadius: BorderRadius.circular(12), ), - child: SingleChildScrollView( + child: const SingleChildScrollView( child: IntrinsicHeight( child: HtmlEditor( footer: null, editable: true, shrinkWrap: true, - editorPadding: const EdgeInsets.symmetric( + editorPadding: EdgeInsets.symmetric( horizontal: 10, vertical: 5, ), - onChanged: (body, html) {}, ), ), ), @@ -172,13 +172,13 @@ class __InputWidgetState extends ConsumerState<_InputWidget> { _updateListener?.cancel(); // listener for editor input state _updateListener = textEditorState.transactionStream.listen((data) { - _inputUpdate(); + _inputUpdate(data.$2); // expand when user types more than one line upto exceed limit _updateHeight(); }); // have it call the first time to adjust height _updateHeight(); - WidgetsBinding.instance.addPostFrameCallback((_) => _loadDraft); + WidgetsBinding.instance.addPostFrameCallback((_) => _loadDraft()); } @override @@ -196,16 +196,18 @@ class __InputWidgetState extends ConsumerState<_InputWidget> { } } - void _inputUpdate() { + void _inputUpdate(Transaction data) { // check if actual document content is empty - final state = textEditorState.document.root.children + final state = data.document.root.children .every((node) => node.delta?.toPlainText().isEmpty ?? true); _isInputEmptyNotifier.value = state; _debounceTimer?.cancel(); // delay operation to avoid excessive re-writes _debounceTimer = Timer(const Duration(milliseconds: 300), () async { // save composing draft - await saveDraft(textEditorState.intoMarkdown(), widget.roomId, ref); + final text = textEditorState.intoMarkdown(); + final htmlText = textEditorState.intoHtml(); + await saveDraft(text, htmlText, widget.roomId, ref); _log.info('compose draft saved for room: ${widget.roomId}'); }); } @@ -241,26 +243,18 @@ class __InputWidgetState extends ConsumerState<_InputWidget> { inputNotifier.setReplyToMessage(m); } }); - await draft.htmlText().mapAsync( - (html) async { - final doc = ActerDocumentHelpers.parse( - draft.plainText(), - htmlContent: draft.htmlText(), - ); - final transaction = textEditorState.transaction; - transaction.insertNodes([0], doc.root.children); - - textEditorState.apply(transaction); - }, - orElse: () { - final doc = ActerDocumentHelpers.parse( - draft.plainText(), - ); - final transaction = textEditorState.transaction; - transaction.insertNodes([0], doc.root.children); - textEditorState.apply(transaction); - }, + + final transaction = textEditorState.transaction; + final doc = ActerDocumentHelpers.parse( + draft.plainText(), + htmlContent: draft.htmlText(), ); + Node rootNode = doc.root; + transaction.document.insert([0], rootNode.children); + transaction.afterSelection = + Selection.single(path: rootNode.path, startOffset: 0); + textEditorState.apply(transaction); + _log.info('compose draft loaded for room: ${widget.roomId}'); } } @@ -308,7 +302,7 @@ class __InputWidgetState extends ConsumerState<_InputWidget> { // insert empty text node transaction.document.insert([0], [paragraphNode(delta: delta)]); await textEditorState.apply(transaction, withUpdateSelection: false); - // FIXME: works for single text, but doesn't get focus on multi-line (iOS) + // FIXME: works for single line text, but doesn't get focus on multi-line (iOS) textEditorState.moveCursorForward(SelectionMoveRange.line); // also clear composed state @@ -500,26 +494,42 @@ class __InputWidgetState extends ConsumerState<_InputWidget> { ), child: SingleChildScrollView( child: IntrinsicHeight( - child: Focus( - focusNode: chatFocus, - child: HtmlEditor( - footer: null, - roomId: widget.roomId, - editable: true, - shrinkWrap: true, - editorState: textEditorState, - scrollController: scrollController, - editorPadding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 5, - ), - onChanged: (body, html) { - if (html != null) { - widget.onTyping?.map((cb) => cb(html.isNotEmpty)); - } else { - widget.onTyping?.map((cb) => cb(body.isNotEmpty)); - } + // keyboard shortcuts (desktop) + child: CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.enter): () { + final body = textEditorState.intoMarkdown(); + final html = textEditorState.intoHtml(); + onSendButtonPressed(body, html); }, + LogicalKeySet( + LogicalKeyboardKey.enter, + LogicalKeyboardKey.shift, + ): () => textEditorState.insertNewLine(), + }, + child: Focus( + focusNode: chatFocus, + child: HtmlEditor( + footer: null, + roomId: widget.roomId, + editable: true, + shrinkWrap: true, + editorState: textEditorState, + scrollController: scrollController, + editorPadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + onChanged: (body, html) { + if (html != null) { + widget.onTyping + ?.map((cb) => cb(html.isNotEmpty)); + } else { + widget.onTyping + ?.map((cb) => cb(body.isNotEmpty)); + } + }, + ), ), ), ), From 3c5a6af4d3179d8d2a0fc6e44ddef116b1c22e27 Mon Sep 17 00:00:00 2001 From: Talha Date: Thu, 7 Nov 2024 12:14:09 +0000 Subject: [PATCH 13/20] Feedback review: clean up and organise chat editor --- .../chat/providers/chat_providers.dart | 16 + .../features/chat/widgets/custom_input.dart | 58 +- .../actions/attachment_upload_action.dart | 87 +++ .../chat_ng/actions/send_message_action.dart | 88 +++ app/lib/features/chat_ng/pages/chat_room.dart | 2 +- .../features/chat_ng/widgets/chat_input.dart | 617 ------------------ .../widgets/chat_input/chat_editor.dart | 306 +++++++++ .../chat_input/chat_editor_loading.dart | 57 ++ .../chat_input/chat_editor_no_access.dart | 31 + .../widgets/chat_input/chat_emoji_picker.dart | 78 +++ .../widgets/chat_input/chat_input.dart | 36 + app/test/features/chat/custom_input_test.dart | 10 +- 12 files changed, 725 insertions(+), 661 deletions(-) create mode 100644 app/lib/features/chat_ng/actions/attachment_upload_action.dart create mode 100644 app/lib/features/chat_ng/actions/send_message_action.dart delete mode 100644 app/lib/features/chat_ng/widgets/chat_input.dart create mode 100644 app/lib/features/chat_ng/widgets/chat_input/chat_editor.dart create mode 100644 app/lib/features/chat_ng/widgets/chat_input/chat_editor_loading.dart create mode 100644 app/lib/features/chat_ng/widgets/chat_input/chat_editor_no_access.dart create mode 100644 app/lib/features/chat_ng/widgets/chat_input/chat_emoji_picker.dart create mode 100644 app/lib/features/chat_ng/widgets/chat_input/chat_input.dart diff --git a/app/lib/features/chat/providers/chat_providers.dart b/app/lib/features/chat/providers/chat_providers.dart index 5b62b0d4de33..69f77e979df6 100644 --- a/app/lib/features/chat/providers/chat_providers.dart +++ b/app/lib/features/chat/providers/chat_providers.dart @@ -270,3 +270,19 @@ final subChatsListProvider = return subChatsList; }); + +// useful for disabling send button for short time while message is preparing to be sent +final allowSendInputProvider = StateProvider.family.autoDispose( + (ref, roomId) => ref.watch( + chatInputProvider + .select((state) => state.sendingState == SendingState.preparing), + ), +); + +// whether user has enough permissions to send message in room +final canSendMessageProvider = FutureProvider.family( + (ref, roomId) async { + final membership = ref.watch(roomMembershipProvider(roomId)); + return membership.valueOrNull?.canString('CanSendChatMessages'); + }, +); diff --git a/app/lib/features/chat/widgets/custom_input.dart b/app/lib/features/chat/widgets/custom_input.dart index 432853e73ee9..bc96a6756ad7 100644 --- a/app/lib/features/chat/widgets/custom_input.dart +++ b/app/lib/features/chat/widgets/custom_input.dart @@ -38,20 +38,6 @@ import 'package:skeletonizer/skeletonizer.dart'; final _log = Logger('a3::chat::custom_input'); -final _allowEdit = StateProvider.family.autoDispose( - (ref, roomId) => ref.watch( - chatInputProvider - .select((state) => state.sendingState == SendingState.preparing), - ), -); - -final canSendProvider = FutureProvider.family( - (ref, roomId) async { - final membership = ref.watch(roomMembershipProvider(roomId)); - return membership.valueOrNull?.canString('CanSendChatMessages'); - }, -); - class CustomChatInput extends ConsumerWidget { static const noAccessKey = Key('custom-chat-no-access'); static const loadingKey = Key('custom-chat-loading'); @@ -68,7 +54,7 @@ class CustomChatInput extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final canSend = ref.watch(canSendProvider(roomId)).valueOrNull; + final canSend = ref.watch(canSendMessageProvider(roomId)).valueOrNull; if (canSend == null) { // we are still loading return loadingState(context); @@ -82,29 +68,25 @@ class CustomChatInput extends ConsumerWidget { return FrostEffect( child: Container( - padding: const EdgeInsets.symmetric(vertical: 15), + padding: const EdgeInsets.symmetric(vertical: 15, horizontal: 10), decoration: BoxDecoration(color: Theme.of(context).colorScheme.surface), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10), - child: Row( - children: [ - const SizedBox(width: 1), - const Icon( - Atlas.block_prohibited_thin, - size: 14, - color: Colors.grey, - ), - const SizedBox(width: 4), - Text( - key: noAccessKey, - L10n.of(context).chatMissingPermissionsToSend, - style: const TextStyle( - color: Colors.grey, - fontSize: 14, - ), + child: Row( + children: [ + const SizedBox(width: 1), + Icon( + Atlas.block_prohibited_thin, + size: 14, + color: Theme.of(context).unselectedWidgetColor, + ), + const SizedBox(width: 4), + Text( + key: noAccessKey, + L10n.of(context).chatMissingPermissionsToSend, + style: TextStyle( + color: Theme.of(context).unselectedWidgetColor, ), - ], - ), + ), + ], ), ), ); @@ -435,7 +417,7 @@ class __ChatInputState extends ConsumerState<_ChatInput> { } Widget renderSendButton(BuildContext context, String roomId) { - final allowEditing = ref.watch(_allowEdit(roomId)); + final allowEditing = ref.watch(allowSendInputProvider(roomId)); if (allowEditing) { return IconButton.filled( @@ -880,7 +862,7 @@ class _TextInputWidgetConsumerState extends ConsumerState<_TextInputWidget> { controller: widget.controller, focusNode: chatFocus, textCapitalization: TextCapitalization.sentences, - enabled: ref.watch(_allowEdit(widget.roomId)), + enabled: ref.watch(allowSendInputProvider(widget.roomId)), onChanged: (String val) { // send typing notice widget.onTyping.map((cb) => cb(val.isNotEmpty)); diff --git a/app/lib/features/chat_ng/actions/attachment_upload_action.dart b/app/lib/features/chat_ng/actions/attachment_upload_action.dart new file mode 100644 index 000000000000..5dd1ac5f5e8a --- /dev/null +++ b/app/lib/features/chat_ng/actions/attachment_upload_action.dart @@ -0,0 +1,87 @@ +import 'dart:io'; + +import 'package:acter/common/models/types.dart'; +import 'package:acter/features/chat/models/chat_input_state/chat_input_state.dart'; +import 'package:acter/features/chat/providers/chat_providers.dart'; +import 'package:acter/features/home/providers/client_providers.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logging/logging.dart'; +import 'package:mime/mime.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; + +// upload and send file (as message) action +Future attachmentUploadAction({ + required String roomId, + required List files, + required AttachmentType attachmentType, + required WidgetRef ref, + required BuildContext context, + required Logger log, +}) async { + final client = ref.read(alwaysClientProvider); + final inputState = ref.read(chatInputProvider); + final lang = L10n.of(context); + final stream = await ref.read(timelineStreamProvider(roomId).future); + + try { + for (final file in files) { + String? mimeType = lookupMimeType(file.path); + if (mimeType == null) throw lang.failedToDetectMimeType; + final fileLen = file.lengthSync(); + if (mimeType.startsWith('image/') && + attachmentType == AttachmentType.image) { + final bytes = file.readAsBytesSync(); + final image = await decodeImageFromList(bytes); + final imageDraft = client + .imageDraft(file.path, mimeType) + .size(fileLen) + .width(image.width) + .height(image.height); + if (inputState.selectedMessageState == SelectedMessageState.replyTo) { + final remoteId = inputState.selectedMessage?.remoteId; + if (remoteId == null) throw 'remote id of sel msg not available'; + await stream.replyMessage(remoteId, imageDraft); + } else { + await stream.sendMessage(imageDraft); + } + } else if (mimeType.startsWith('audio/') && + attachmentType == AttachmentType.audio) { + final audioDraft = + client.audioDraft(file.path, mimeType).size(file.lengthSync()); + if (inputState.selectedMessageState == SelectedMessageState.replyTo) { + final remoteId = inputState.selectedMessage?.remoteId; + if (remoteId == null) throw 'remote id of sel msg not available'; + await stream.replyMessage(remoteId, audioDraft); + } else { + await stream.sendMessage(audioDraft); + } + } else if (mimeType.startsWith('video/') && + attachmentType == AttachmentType.video) { + final videoDraft = + client.videoDraft(file.path, mimeType).size(file.lengthSync()); + if (inputState.selectedMessageState == SelectedMessageState.replyTo) { + final remoteId = inputState.selectedMessage?.remoteId; + if (remoteId == null) throw 'remote id of sel msg not available'; + await stream.replyMessage(remoteId, videoDraft); + } else { + await stream.sendMessage(videoDraft); + } + } else { + final fileDraft = + client.fileDraft(file.path, mimeType).size(file.lengthSync()); + if (inputState.selectedMessageState == SelectedMessageState.replyTo) { + final remoteId = inputState.selectedMessage?.remoteId; + if (remoteId == null) throw 'remote id of sel msg not available'; + await stream.replyMessage(remoteId, fileDraft); + } else { + await stream.sendMessage(fileDraft); + } + } + } + } catch (e, s) { + log.severe('error occurred', e, s); + } + + ref.read(chatInputProvider.notifier).unsetSelectedMessage(); +} diff --git a/app/lib/features/chat_ng/actions/send_message_action.dart b/app/lib/features/chat_ng/actions/send_message_action.dart new file mode 100644 index 000000000000..792a006f7f65 --- /dev/null +++ b/app/lib/features/chat_ng/actions/send_message_action.dart @@ -0,0 +1,88 @@ +import 'package:acter/common/providers/chat_providers.dart'; +import 'package:acter/common/widgets/html_editor/html_editor.dart'; +import 'package:acter/features/chat/models/chat_input_state/chat_input_state.dart'; +import 'package:acter/features/chat/providers/chat_providers.dart'; +import 'package:acter/features/home/providers/client_providers.dart'; +import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' show MsgDraft; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:acter/common/extensions/options.dart'; +import 'package:logging/logging.dart'; + +// send chat message action +Future sendMessageAction({ + required EditorState textEditorState, + required String roomId, + required BuildContext context, + required WidgetRef ref, + void Function(bool)? onTyping, + required Logger log, +}) async { + final lang = L10n.of(context); + final body = textEditorState.intoMarkdown(); + final html = textEditorState.intoHtml(); + ref.read(chatInputProvider.notifier).startSending(); + + try { + // end the typing notification + onTyping?.map((cb) => cb(false)); + + // make the actual draft + final client = ref.read(alwaysClientProvider); + late MsgDraft draft; + if (html.isNotEmpty) { + draft = client.textHtmlDraft(html, body); + } else { + draft = client.textMarkdownDraft(body); + } + + // actually send it out + final inputState = ref.read(chatInputProvider); + final stream = await ref.read(timelineStreamProvider(roomId).future); + + if (inputState.selectedMessageState == SelectedMessageState.replyTo) { + final remoteId = inputState.selectedMessage?.remoteId; + if (remoteId == null) throw 'remote id of sel msg not available'; + await stream.replyMessage(remoteId, draft); + } else if (inputState.selectedMessageState == SelectedMessageState.edit) { + final remoteId = inputState.selectedMessage?.remoteId; + if (remoteId == null) throw 'remote id of sel msg not available'; + await stream.editMessage(remoteId, draft); + } else { + await stream.sendMessage(draft); + } + + ref.read(chatInputProvider.notifier).messageSent(); + final transaction = textEditorState.transaction; + final nodes = transaction.document.root.children; + // delete all nodes of document (reset) + transaction.document.delete([0], nodes.length); + final delta = Delta()..insert(''); + // insert empty text node + transaction.document.insert([0], [paragraphNode(delta: delta)]); + await textEditorState.apply(transaction, withUpdateSelection: false); + // FIXME: works for single line text, but doesn't get focus on multi-line (iOS) + textEditorState.moveCursorForward(SelectionMoveRange.line); + + // also clear composed state + final convo = await ref.read(chatProvider(roomId).future); + if (convo != null) { + await convo.saveMsgDraft( + textEditorState.intoMarkdown(), + null, + 'new', + null, + ); + } + } catch (e, s) { + log.severe('Sending chat message failed', e, s); + EasyLoading.showError( + lang.failedToSend(e), + duration: const Duration(seconds: 3), + ); + ref.read(chatInputProvider.notifier).sendingFailed(); + } +} diff --git a/app/lib/features/chat_ng/pages/chat_room.dart b/app/lib/features/chat_ng/pages/chat_room.dart index dfad78f60823..74b0a782490d 100644 --- a/app/lib/features/chat_ng/pages/chat_room.dart +++ b/app/lib/features/chat_ng/pages/chat_room.dart @@ -5,7 +5,7 @@ import 'package:acter/common/utils/routes.dart'; import 'package:acter/common/widgets/frost_effect.dart'; import 'package:acter/features/chat/providers/chat_providers.dart'; import 'package:acter/features/chat/widgets/room_avatar.dart'; -import 'package:acter/features/chat_ng/widgets/chat_input.dart'; +import 'package:acter/features/chat_ng/widgets/chat_input/chat_input.dart'; import 'package:acter/features/chat_ng/widgets/chat_messages.dart'; import 'package:acter/features/settings/providers/app_settings_provider.dart'; import 'package:flutter/material.dart'; diff --git a/app/lib/features/chat_ng/widgets/chat_input.dart b/app/lib/features/chat_ng/widgets/chat_input.dart deleted file mode 100644 index 24045afebfe9..000000000000 --- a/app/lib/features/chat_ng/widgets/chat_input.dart +++ /dev/null @@ -1,617 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:acter/common/models/types.dart'; -import 'package:acter/common/providers/chat_providers.dart'; -import 'package:acter/common/providers/keyboard_visbility_provider.dart'; -import 'package:acter/common/widgets/emoji_picker_widget.dart'; -import 'package:acter/common/widgets/frost_effect.dart'; -import 'package:acter/common/widgets/html_editor/html_editor.dart'; -import 'package:acter/features/attachments/actions/select_attachment.dart'; -import 'package:acter/features/chat/models/chat_input_state/chat_input_state.dart'; -import 'package:acter/features/chat/providers/chat_providers.dart'; -import 'package:acter/features/chat/utils.dart'; -import 'package:acter/features/chat/widgets/custom_input.dart'; -import 'package:acter/features/home/providers/client_providers.dart'; -import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' show MsgDraft; -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:atlas_icons/atlas_icons.dart'; -import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_easyloading/flutter_easyloading.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:logging/logging.dart'; -import 'package:mime/mime.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:acter/common/extensions/options.dart'; - -final _log = Logger('a3::chat::custom_input'); - -final _allowEdit = StateProvider.family.autoDispose( - (ref, roomId) => ref.watch( - chatInputProvider - .select((state) => state.sendingState == SendingState.preparing), - ), -); - -class ChatInput extends ConsumerWidget { - static const loadingKey = Key('chat-ng-loading'); - static const noAccessKey = Key('chat-ng-no-access'); - - final String roomId; - final void Function(bool)? onTyping; - - const ChatInput({super.key, required this.roomId, this.onTyping}); - - Widget _loadingWidget(BuildContext context) { - return Skeletonizer( - child: FrostEffect( - child: DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primaryContainer, - ), - child: Row( - children: [ - IconButton( - onPressed: () {}, - icon: const Icon(Icons.emoji_emotions, size: 20), - ), - Expanded( - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - margin: const EdgeInsets.symmetric(vertical: 12), - decoration: BoxDecoration( - color: Theme.of(context) - .unselectedWidgetColor - .withOpacity(0.5), - borderRadius: BorderRadius.circular(12), - ), - child: const SingleChildScrollView( - child: IntrinsicHeight( - child: HtmlEditor( - footer: null, - editable: true, - shrinkWrap: true, - editorPadding: EdgeInsets.symmetric( - horizontal: 10, - vertical: 5, - ), - ), - ), - ), - ), - ), - IconButton( - onPressed: () {}, - icon: const Icon( - Atlas.paperclip_attachment_thin, - size: 20, - ), - ), - ], - ), - ), - ), - ); - } - - Widget _noAccessWidget(BuildContext context) { - return FrostEffect( - child: Container( - padding: const EdgeInsets.symmetric(vertical: 15), - decoration: BoxDecoration(color: Theme.of(context).colorScheme.surface), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10), - child: Row( - children: [ - const SizedBox(width: 1), - const Icon( - Atlas.block_prohibited_thin, - size: 14, - color: Colors.grey, - ), - const SizedBox(width: 4), - Text( - key: ChatInput.noAccessKey, - L10n.of(context).chatMissingPermissionsToSend, - style: const TextStyle( - color: Colors.grey, - fontSize: 14, - ), - ), - ], - ), - ), - ), - ); - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - final canSend = ref.watch(canSendProvider(roomId)).valueOrNull; - if (canSend == null) { - // we are still loading - return _loadingWidget(context); - } - if (canSend) { - return _InputWidget( - roomId: roomId, - onTyping: onTyping, - ); - } - // no permissions to send messages - return _noAccessWidget(context); - } -} - -class _InputWidget extends ConsumerStatefulWidget { - final String roomId; - final void Function(bool)? onTyping; - const _InputWidget({required this.roomId, this.onTyping}); - - @override - ConsumerState createState() => __InputWidgetState(); -} - -class __InputWidgetState extends ConsumerState<_InputWidget> { - EditorState textEditorState = EditorState.blank(); - late EditorScrollController scrollController; - FocusNode chatFocus = FocusNode(); - StreamSubscription<(TransactionTime, Transaction)>? _updateListener; - final ValueNotifier _isInputEmptyNotifier = ValueNotifier(true); - double _cHeight = 0.10; - Timer? _debounceTimer; - - @override - void initState() { - super.initState(); - scrollController = - EditorScrollController(editorState: textEditorState, shrinkWrap: true); - _updateListener?.cancel(); - // listener for editor input state - _updateListener = textEditorState.transactionStream.listen((data) { - _inputUpdate(data.$2); - // expand when user types more than one line upto exceed limit - _updateHeight(); - }); - // have it call the first time to adjust height - _updateHeight(); - WidgetsBinding.instance.addPostFrameCallback((_) => _loadDraft()); - } - - @override - void dispose() { - _updateListener?.cancel(); - _debounceTimer?.cancel(); - super.dispose(); - } - - @override - void didUpdateWidget(covariant _InputWidget oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.roomId != widget.roomId) { - WidgetsBinding.instance.addPostFrameCallback((_) => _loadDraft()); - } - } - - void _inputUpdate(Transaction data) { - // check if actual document content is empty - final state = data.document.root.children - .every((node) => node.delta?.toPlainText().isEmpty ?? true); - _isInputEmptyNotifier.value = state; - _debounceTimer?.cancel(); - // delay operation to avoid excessive re-writes - _debounceTimer = Timer(const Duration(milliseconds: 300), () async { - // save composing draft - final text = textEditorState.intoMarkdown(); - final htmlText = textEditorState.intoHtml(); - await saveDraft(text, htmlText, widget.roomId, ref); - _log.info('compose draft saved for room: ${widget.roomId}'); - }); - } - - // handler for expanding editor field height - void _updateHeight() { - final text = textEditorState.intoMarkdown(); - final lineCount = '\n'.allMatches(text).length; - - // Calculate new height based on line count - // Start with 5% and increase by 4% per line up to 20% - setState(() { - _cHeight = (0.05 + (lineCount - 1) * 0.04).clamp(0.05, 0.15); - }); - } - - // composer draft load state handler - Future _loadDraft() async { - final draft = - await ref.read(chatComposerDraftProvider(widget.roomId).future); - - if (draft != null) { - final inputNotifier = ref.read(chatInputProvider.notifier); - inputNotifier.unsetSelectedMessage(); - draft.eventId().map((eventId) { - final draftType = draft.draftType(); - final m = ref - .read(chatMessagesProvider(widget.roomId)) - .firstWhere((x) => x.id == eventId); - if (draftType == 'edit') { - inputNotifier.setEditMessage(m); - } else if (draftType == 'reply') { - inputNotifier.setReplyToMessage(m); - } - }); - - final transaction = textEditorState.transaction; - final doc = ActerDocumentHelpers.parse( - draft.plainText(), - htmlContent: draft.htmlText(), - ); - Node rootNode = doc.root; - transaction.document.insert([0], rootNode.children); - transaction.afterSelection = - Selection.single(path: rootNode.path, startOffset: 0); - textEditorState.apply(transaction); - - _log.info('compose draft loaded for room: ${widget.roomId}'); - } - } - - Future onSendButtonPressed(String body, String? html) async { - final lang = L10n.of(context); - ref.read(chatInputProvider.notifier).startSending(); - - try { - // end the typing notification - widget.onTyping?.map((cb) => cb(false)); - - // make the actual draft - final client = ref.read(alwaysClientProvider); - late MsgDraft draft; - if (html != null) { - draft = client.textHtmlDraft(html, body); - } else { - draft = client.textMarkdownDraft(body); - } - - // actually send it out - final inputState = ref.read(chatInputProvider); - final stream = - await ref.read(timelineStreamProvider(widget.roomId).future); - - if (inputState.selectedMessageState == SelectedMessageState.replyTo) { - final remoteId = inputState.selectedMessage?.remoteId; - if (remoteId == null) throw 'remote id of sel msg not available'; - await stream.replyMessage(remoteId, draft); - } else if (inputState.selectedMessageState == SelectedMessageState.edit) { - final remoteId = inputState.selectedMessage?.remoteId; - if (remoteId == null) throw 'remote id of sel msg not available'; - await stream.editMessage(remoteId, draft); - } else { - await stream.sendMessage(draft); - } - - ref.read(chatInputProvider.notifier).messageSent(); - final transaction = textEditorState.transaction; - final nodes = transaction.document.root.children; - // delete all nodes of document (reset) - transaction.document.delete([0], nodes.length); - final delta = Delta()..insert(''); - // insert empty text node - transaction.document.insert([0], [paragraphNode(delta: delta)]); - await textEditorState.apply(transaction, withUpdateSelection: false); - // FIXME: works for single line text, but doesn't get focus on multi-line (iOS) - textEditorState.moveCursorForward(SelectionMoveRange.line); - - // also clear composed state - final convo = await ref.read(chatProvider(widget.roomId).future); - if (convo != null) { - await convo.saveMsgDraft( - textEditorState.intoMarkdown(), - null, - 'new', - null, - ); - } - } catch (e, s) { - _log.severe('Sending chat message failed', e, s); - EasyLoading.showError( - lang.failedToSend(e), - duration: const Duration(seconds: 3), - ); - ref.read(chatInputProvider.notifier).sendingFailed(); - } - - if (!chatFocus.hasFocus) { - chatFocus.requestFocus(); - } - } - - // UI handler for emoji picker widget - void onEmojiBtnTap(bool emojiPickerVisible) { - final chatInputNotifier = ref.read(chatInputProvider.notifier); - if (!emojiPickerVisible) { - //Hide soft keyboard and then show Emoji Picker - chatInputNotifier.emojiPickerVisible(true); - } else { - //Hide Emoji Picker - chatInputNotifier.emojiPickerVisible(false); - } - } - - void handleEmojiSelected(Category? category, Emoji emoji) { - final selection = textEditorState.selection; - final transaction = textEditorState.transaction; - if (selection != null) { - if (selection.isCollapsed) { - final node = textEditorState.getNodeAtPath(selection.end.path); - if (node == null) return; - // we're at the start - transaction.insertText(node, selection.endIndex, emoji.emoji); - transaction.afterSelection = Selection.collapsed( - Position( - path: selection.end.path, - offset: selection.end.offset + emoji.emoji.length, - ), - ); - } else { - // we have selected some text part to replace with emoji - final startNode = textEditorState.getNodeAtPath(selection.start.path); - if (startNode == null) return; - transaction.deleteText( - startNode, - selection.startIndex, - selection.end.offset - selection.start.offset, - ); - transaction.insertText( - startNode, - selection.startIndex, - emoji.emoji, - ); - - transaction.afterSelection = Selection.collapsed( - Position( - path: selection.start.path, - offset: selection.start.offset + emoji.emoji.length, - ), - ); - } - - textEditorState.apply(transaction); - } - return; - } - - // editor picker widget backspace handling - void handleBackspacePressed() { - final isEmpty = textEditorState.transaction.document.isEmpty; - if (isEmpty) { - // nothing left to clear, close the emoji picker - ref.read(chatInputProvider.notifier).emojiPickerVisible(false); - return; - } - textEditorState.deleteBackward(); - } - - // attachment upload handler - Future handleFileUpload( - List files, - AttachmentType attachmentType, - ) async { - final client = ref.read(alwaysClientProvider); - final inputState = ref.read(chatInputProvider); - final lang = L10n.of(context); - final stream = await ref.read(timelineStreamProvider(widget.roomId).future); - - try { - for (final file in files) { - String? mimeType = lookupMimeType(file.path); - if (mimeType == null) throw lang.failedToDetectMimeType; - final fileLen = file.lengthSync(); - if (mimeType.startsWith('image/') && - attachmentType == AttachmentType.image) { - final bytes = file.readAsBytesSync(); - final image = await decodeImageFromList(bytes); - final imageDraft = client - .imageDraft(file.path, mimeType) - .size(fileLen) - .width(image.width) - .height(image.height); - if (inputState.selectedMessageState == SelectedMessageState.replyTo) { - final remoteId = inputState.selectedMessage?.remoteId; - if (remoteId == null) throw 'remote id of sel msg not available'; - await stream.replyMessage(remoteId, imageDraft); - } else { - await stream.sendMessage(imageDraft); - } - } else if (mimeType.startsWith('audio/') && - attachmentType == AttachmentType.audio) { - final audioDraft = - client.audioDraft(file.path, mimeType).size(file.lengthSync()); - if (inputState.selectedMessageState == SelectedMessageState.replyTo) { - final remoteId = inputState.selectedMessage?.remoteId; - if (remoteId == null) throw 'remote id of sel msg not available'; - await stream.replyMessage(remoteId, audioDraft); - } else { - await stream.sendMessage(audioDraft); - } - } else if (mimeType.startsWith('video/') && - attachmentType == AttachmentType.video) { - final videoDraft = - client.videoDraft(file.path, mimeType).size(file.lengthSync()); - if (inputState.selectedMessageState == SelectedMessageState.replyTo) { - final remoteId = inputState.selectedMessage?.remoteId; - if (remoteId == null) throw 'remote id of sel msg not available'; - await stream.replyMessage(remoteId, videoDraft); - } else { - await stream.sendMessage(videoDraft); - } - } else { - final fileDraft = - client.fileDraft(file.path, mimeType).size(file.lengthSync()); - if (inputState.selectedMessageState == SelectedMessageState.replyTo) { - final remoteId = inputState.selectedMessage?.remoteId; - if (remoteId == null) throw 'remote id of sel msg not available'; - await stream.replyMessage(remoteId, fileDraft); - } else { - await stream.sendMessage(fileDraft); - } - } - } - } catch (e, s) { - _log.severe('error occurred', e, s); - } - - ref.read(chatInputProvider.notifier).unsetSelectedMessage(); - } - - Widget _editorWidget(bool emojiPickerVisible) { - final widgetSize = MediaQuery.sizeOf(context); - return FrostEffect( - child: DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primaryContainer, - ), - child: Row( - children: [ - leadingButton(emojiPickerVisible), - Expanded( - child: IntrinsicHeight( - child: AnimatedContainer( - duration: const Duration(milliseconds: 100), - height: widgetSize.height * _cHeight, - margin: const EdgeInsets.symmetric( - vertical: 12, - horizontal: 8, - ), - decoration: BoxDecoration( - color: Theme.of(context) - .unselectedWidgetColor - .withOpacity(0.5), - borderRadius: BorderRadius.circular(12), - ), - child: SingleChildScrollView( - child: IntrinsicHeight( - // keyboard shortcuts (desktop) - child: CallbackShortcuts( - bindings: { - const SingleActivator(LogicalKeyboardKey.enter): () { - final body = textEditorState.intoMarkdown(); - final html = textEditorState.intoHtml(); - onSendButtonPressed(body, html); - }, - LogicalKeySet( - LogicalKeyboardKey.enter, - LogicalKeyboardKey.shift, - ): () => textEditorState.insertNewLine(), - }, - child: Focus( - focusNode: chatFocus, - child: HtmlEditor( - footer: null, - roomId: widget.roomId, - editable: true, - shrinkWrap: true, - editorState: textEditorState, - scrollController: scrollController, - editorPadding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 5, - ), - onChanged: (body, html) { - if (html != null) { - widget.onTyping - ?.map((cb) => cb(html.isNotEmpty)); - } else { - widget.onTyping - ?.map((cb) => cb(body.isNotEmpty)); - } - }, - ), - ), - ), - ), - ), - ), - ), - ), - ValueListenableBuilder( - valueListenable: _isInputEmptyNotifier, - builder: (context, isEmpty, child) => - trailingButton(context, widget.roomId, isEmpty), - ), - ], - ), - ), - ); - } - - // emoji button - Widget leadingButton(bool emojiPickerVisible) { - return IconButton( - padding: const EdgeInsets.only(left: 8), - onPressed: () => onEmojiBtnTap(emojiPickerVisible), - icon: const Icon(Icons.emoji_emotions, size: 20), - ); - } - - // attachment/send button - Widget trailingButton(BuildContext context, String roomId, bool isEmpty) { - final allowEditing = ref.watch(_allowEdit(roomId)); - final body = textEditorState.intoMarkdown(); - final html = textEditorState.intoHtml(); - if (allowEditing && !isEmpty) { - return Padding( - padding: const EdgeInsets.only(right: 8), - child: IconButton.filled( - alignment: Alignment.center, - key: CustomChatInput.sendBtnKey, - iconSize: 20, - onPressed: () => onSendButtonPressed(body, html), - icon: const Icon(Icons.send), - ), - ); - } - - return Padding( - padding: const EdgeInsets.only(right: 8), - child: IconButton( - onPressed: () => selectAttachment( - context: context, - onSelected: handleFileUpload, - ), - icon: const Icon( - Atlas.paperclip_attachment_thin, - size: 20, - ), - ), - ); - } - - @override - Widget build(BuildContext context) { - final isKeyboardVisible = ref.watch(keyboardVisibleProvider).valueOrNull; - final emojiPickerVisible = ref - .watch(chatInputProvider.select((value) => value.emojiPickerVisible)); - final screenSize = MediaQuery.sizeOf(context); - final viewInsets = MediaQuery.viewInsetsOf(context).bottom; - return Column( - children: [ - _editorWidget(emojiPickerVisible), - if (emojiPickerVisible) - EmojiPickerWidget( - size: Size(screenSize.width, screenSize.height / 3), - onEmojiSelected: handleEmojiSelected, - onBackspacePressed: handleBackspacePressed, - onClosePicker: () => - ref.read(chatInputProvider.notifier).emojiPickerVisible(false), - ), - // adjust bottom viewport so toolbar doesn't obscure field when visible - if (isKeyboardVisible != null && isKeyboardVisible) - SizedBox(height: viewInsets + 50), - ], - ); - } -} diff --git a/app/lib/features/chat_ng/widgets/chat_input/chat_editor.dart b/app/lib/features/chat_ng/widgets/chat_input/chat_editor.dart new file mode 100644 index 000000000000..8227fe507c59 --- /dev/null +++ b/app/lib/features/chat_ng/widgets/chat_input/chat_editor.dart @@ -0,0 +1,306 @@ +import 'dart:async'; + +import 'package:acter/common/providers/keyboard_visbility_provider.dart'; +import 'package:acter/common/widgets/frost_effect.dart'; +import 'package:acter/common/widgets/html_editor/html_editor.dart'; +import 'package:acter/features/attachments/actions/select_attachment.dart'; +import 'package:acter/features/chat/providers/chat_providers.dart'; +import 'package:acter/features/chat/utils.dart'; +import 'package:acter/features/chat_ng/actions/attachment_upload_action.dart'; +import 'package:acter/features/chat_ng/actions/send_message_action.dart'; +import 'package:acter/features/chat_ng/widgets/chat_input/chat_emoji_picker.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:atlas_icons/atlas_icons.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:acter/common/extensions/options.dart'; +import 'package:logging/logging.dart'; + +// Chat Input Field Widget +final _log = Logger('a3::chat::chat_editor'); + +class ChatEditor extends ConsumerStatefulWidget { + static const sendBtnKey = Key('editor-send-button'); + final String roomId; + final void Function(bool)? onTyping; + const ChatEditor({super.key, required this.roomId, this.onTyping}); + + @override + ConsumerState createState() => _ChatEditorState(); +} + +class _ChatEditorState extends ConsumerState { + EditorState textEditorState = EditorState.blank(); + late EditorScrollController scrollController; + FocusNode chatFocus = FocusNode(); + StreamSubscription<(TransactionTime, Transaction)>? _updateListener; + final ValueNotifier _isInputEmptyNotifier = ValueNotifier(true); + double _cHeight = 0.10; + Timer? _debounceTimer; + + @override + void initState() { + super.initState(); + scrollController = + EditorScrollController(editorState: textEditorState, shrinkWrap: true); + _updateListener?.cancel(); + // listener for editor input state + _updateListener = textEditorState.transactionStream.listen((data) { + _editorUpdate(data.$2); + // expand when user types more than one line upto exceed limit + _updateHeight(); + }); + // have it call the first time to adjust height + _updateHeight(); + WidgetsBinding.instance.addPostFrameCallback((_) => _loadDraft()); + } + + @override + void dispose() { + _updateListener?.cancel(); + _debounceTimer?.cancel(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant ChatEditor oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.roomId != widget.roomId) { + WidgetsBinding.instance.addPostFrameCallback((_) => _loadDraft()); + } + } + + void _editorUpdate(Transaction data) { + // check if actual document content is empty + final state = data.document.root.children + .every((node) => node.delta?.toPlainText().isEmpty ?? true); + _isInputEmptyNotifier.value = state; + _debounceTimer?.cancel(); + // delay operation to avoid excessive re-writes + _debounceTimer = Timer(const Duration(milliseconds: 300), () async { + // save composing draft + final text = textEditorState.intoMarkdown(); + final htmlText = textEditorState.intoHtml(); + await saveDraft(text, htmlText, widget.roomId, ref); + _log.info('compose draft saved for room: ${widget.roomId}'); + }); + } + + // handler for expanding editor field height + void _updateHeight() { + final text = textEditorState.intoMarkdown(); + final lineCount = '\n'.allMatches(text).length; + + // Calculate new height based on line count + // Start with 5% and increase by 4% per line up to 20% + setState(() { + _cHeight = (0.05 + (lineCount - 1) * 0.04).clamp(0.05, 0.15); + }); + } + + // composer draft load state handler + Future _loadDraft() async { + final draft = + await ref.read(chatComposerDraftProvider(widget.roomId).future); + + if (draft != null) { + final inputNotifier = ref.read(chatInputProvider.notifier); + inputNotifier.unsetSelectedMessage(); + draft.eventId().map((eventId) { + final draftType = draft.draftType(); + final m = ref + .read(chatMessagesProvider(widget.roomId)) + .firstWhere((x) => x.id == eventId); + if (draftType == 'edit') { + inputNotifier.setEditMessage(m); + } else if (draftType == 'reply') { + inputNotifier.setReplyToMessage(m); + } + }); + + final transaction = textEditorState.transaction; + final doc = ActerDocumentHelpers.parse( + draft.plainText(), + htmlContent: draft.htmlText(), + ); + Node rootNode = doc.root; + transaction.document.insert([0], rootNode.children); + transaction.afterSelection = + Selection.single(path: rootNode.path, startOffset: 0); + textEditorState.apply(transaction); + + _log.info('compose draft loaded for room: ${widget.roomId}'); + } + } + + @override + Widget build(BuildContext context) { + final isKeyboardVisible = ref.watch(keyboardVisibleProvider).valueOrNull; + final emojiPickerVisible = ref + .watch(chatInputProvider.select((value) => value.emojiPickerVisible)); + final viewInsets = MediaQuery.viewInsetsOf(context).bottom; + return Column( + children: [ + renderEditorUI(emojiPickerVisible), + // Emoji Picker UI + if (emojiPickerVisible) ChatEmojiPicker(editorState: textEditorState), + // adjust bottom viewport so toolbar doesn't obscure field when visible + if (isKeyboardVisible != null && isKeyboardVisible) + SizedBox(height: viewInsets + 50), + ], + ); + } + + // chat editor UI + Widget renderEditorUI(bool emojiPickerVisible) { + return FrostEffect( + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + ), + child: Row( + children: [ + leadingBtn(emojiPickerVisible), + editorField(), + trailingBtn(), + ], + ), + ), + ); + } + + // emoji button + Widget leadingBtn(bool emojiPickerVisible) { + return IconButton( + padding: const EdgeInsets.only(left: 8), + onPressed: () => _toggleEmojiPicker(emojiPickerVisible), + icon: const Icon(Icons.emoji_emotions, size: 20), + ); + } + + void _toggleEmojiPicker(bool emojiPickerVisible) { + final chatInputNotifier = ref.read(chatInputProvider.notifier); + chatInputNotifier.emojiPickerVisible(!emojiPickerVisible); + } + + Widget editorField() { + final widgetSize = MediaQuery.sizeOf(context); + return Expanded( + child: IntrinsicHeight( + child: AnimatedContainer( + duration: const Duration(milliseconds: 100), + height: widgetSize.height * _cHeight, + margin: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 8, + ), + decoration: BoxDecoration( + color: Theme.of(context).unselectedWidgetColor.withOpacity(0.5), + borderRadius: BorderRadius.circular(12), + ), + child: SingleChildScrollView( + child: IntrinsicHeight( + // keyboard shortcuts (desktop) + child: CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.enter): () => + sendMessageAction( + roomId: widget.roomId, + textEditorState: textEditorState, + onTyping: widget.onTyping, + context: context, + ref: ref, + log: _log, + ), + LogicalKeySet( + LogicalKeyboardKey.enter, + LogicalKeyboardKey.shift, + ): () => textEditorState.insertNewLine(), + }, + child: _renderEditor(), + ), + ), + ), + ), + ), + ); + } + + Widget _renderEditor() => Focus( + focusNode: chatFocus, + child: HtmlEditor( + footer: null, + // if provided, will activate mentions + roomId: widget.roomId, + editable: true, + shrinkWrap: true, + editorState: textEditorState, + scrollController: scrollController, + editorPadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + onChanged: (body, html) { + if (html != null) { + widget.onTyping?.map((cb) => cb(html.isNotEmpty)); + } else { + widget.onTyping?.map((cb) => cb(body.isNotEmpty)); + } + }, + ), + ); + + // attachment/send button + Widget trailingBtn() { + final allowEditing = ref.watch(allowSendInputProvider(widget.roomId)); + return ValueListenableBuilder( + valueListenable: _isInputEmptyNotifier, + builder: (context, isEmpty, child) { + if (allowEditing && !isEmpty) { + return _renderSendBtn(); + } + return _renderAttachmentBtn(); + }, + ); + } + + Widget _renderSendBtn() => Padding( + padding: const EdgeInsets.only(right: 8), + child: IconButton.filled( + alignment: Alignment.center, + key: ChatEditor.sendBtnKey, + iconSize: 20, + onPressed: () => sendMessageAction( + textEditorState: textEditorState, + roomId: widget.roomId, + onTyping: widget.onTyping, + context: context, + ref: ref, + log: _log, + ), + icon: const Icon(Icons.send), + ), + ); + + Widget _renderAttachmentBtn() => Padding( + padding: const EdgeInsets.only(right: 8), + child: IconButton( + onPressed: () => selectAttachment( + context: context, + onSelected: (files, type) => attachmentUploadAction( + roomId: widget.roomId, + files: files, + attachmentType: type, + ref: ref, + context: context, + log: _log, + ), + ), + icon: const Icon( + Atlas.paperclip_attachment_thin, + size: 20, + ), + ), + ); +} diff --git a/app/lib/features/chat_ng/widgets/chat_input/chat_editor_loading.dart b/app/lib/features/chat_ng/widgets/chat_input/chat_editor_loading.dart new file mode 100644 index 000000000000..d1637c4c7106 --- /dev/null +++ b/app/lib/features/chat_ng/widgets/chat_input/chat_editor_loading.dart @@ -0,0 +1,57 @@ +import 'package:acter/common/widgets/html_editor/html_editor.dart'; +import 'package:atlas_icons/atlas_icons.dart'; +import 'package:flutter/material.dart'; +import 'package:skeletonizer/skeletonizer.dart'; + +// loading state widget +class ChatEditorLoading extends StatelessWidget { + const ChatEditorLoading({super.key}); + + @override + Widget build(BuildContext context) => Skeletonizer( + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + ), + child: Row( + children: [ + IconButton( + onPressed: () {}, + icon: const Icon(Icons.emoji_emotions, size: 20), + ), + Expanded( + child: Container( + margin: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: Theme.of(context) + .unselectedWidgetColor + .withOpacity(0.5), + borderRadius: BorderRadius.circular(12), + ), + child: const SingleChildScrollView( + child: IntrinsicHeight( + child: HtmlEditor( + footer: null, + editable: true, + shrinkWrap: true, + editorPadding: EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + ), + ), + ), + ), + ), + IconButton( + onPressed: () {}, + icon: const Icon( + Atlas.paperclip_attachment_thin, + size: 20, + ), + ), + ], + ), + ), + ); +} diff --git a/app/lib/features/chat_ng/widgets/chat_input/chat_editor_no_access.dart b/app/lib/features/chat_ng/widgets/chat_input/chat_editor_no_access.dart new file mode 100644 index 000000000000..da9c17e019d9 --- /dev/null +++ b/app/lib/features/chat_ng/widgets/chat_input/chat_editor_no_access.dart @@ -0,0 +1,31 @@ +import 'package:acter/features/chat_ng/widgets/chat_input/chat_input.dart'; +import 'package:atlas_icons/atlas_icons.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; + +// no permission state widget +class ChatEditorNoAccess extends StatelessWidget { + const ChatEditorNoAccess({super.key}); + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.symmetric(vertical: 15, horizontal: 10), + decoration: BoxDecoration(color: Theme.of(context).colorScheme.surface), + child: Row( + children: [ + const SizedBox(width: 1), + Icon( + Atlas.block_prohibited_thin, + size: 14, + color: Theme.of(context).unselectedWidgetColor, + ), + const SizedBox(width: 4), + Text( + key: ChatInput.noAccessKey, + L10n.of(context).chatMissingPermissionsToSend, + style: TextStyle(color: Theme.of(context).unselectedWidgetColor), + ), + ], + ), + ); +} diff --git a/app/lib/features/chat_ng/widgets/chat_input/chat_emoji_picker.dart b/app/lib/features/chat_ng/widgets/chat_input/chat_emoji_picker.dart new file mode 100644 index 000000000000..06f094ded2e5 --- /dev/null +++ b/app/lib/features/chat_ng/widgets/chat_input/chat_emoji_picker.dart @@ -0,0 +1,78 @@ +import 'package:acter/common/widgets/emoji_picker_widget.dart'; +import 'package:acter/features/chat/providers/chat_providers.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class ChatEmojiPicker extends ConsumerWidget { + final EditorState editorState; + const ChatEmojiPicker({super.key, required this.editorState}); + + // editor picker widget backspace handling + void handleBackspacePressed(WidgetRef ref) { + final isEmpty = editorState.transaction.document.isEmpty; + if (isEmpty) { + // nothing left to clear, close the emoji picker + ref.read(chatInputProvider.notifier).emojiPickerVisible(false); + return; + } + editorState.deleteBackward(); + } + + // select emoji handler for editor state + void handleEmojiSelected(Category? category, Emoji emoji) { + final selection = editorState.selection; + final transaction = editorState.transaction; + if (selection != null) { + if (selection.isCollapsed) { + final node = editorState.getNodeAtPath(selection.end.path); + if (node == null) return; + // we're at the start + transaction.insertText(node, selection.endIndex, emoji.emoji); + transaction.afterSelection = Selection.collapsed( + Position( + path: selection.end.path, + offset: selection.end.offset + emoji.emoji.length, + ), + ); + } else { + // we have selected some text part to replace with emoji + final startNode = editorState.getNodeAtPath(selection.start.path); + if (startNode == null) return; + transaction.deleteText( + startNode, + selection.startIndex, + selection.end.offset - selection.start.offset, + ); + transaction.insertText( + startNode, + selection.startIndex, + emoji.emoji, + ); + + transaction.afterSelection = Selection.collapsed( + Position( + path: selection.start.path, + offset: selection.start.offset + emoji.emoji.length, + ), + ); + } + + editorState.apply(transaction); + } + return; + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final screenSize = MediaQuery.sizeOf(context); + return EmojiPickerWidget( + size: Size(screenSize.width, screenSize.height * 0.3), + onEmojiSelected: handleEmojiSelected, + onBackspacePressed: () => handleBackspacePressed(ref), + onClosePicker: () => + ref.read(chatInputProvider.notifier).emojiPickerVisible(false), + ); + } +} diff --git a/app/lib/features/chat_ng/widgets/chat_input/chat_input.dart b/app/lib/features/chat_ng/widgets/chat_input/chat_input.dart new file mode 100644 index 000000000000..a5781b8d5379 --- /dev/null +++ b/app/lib/features/chat_ng/widgets/chat_input/chat_input.dart @@ -0,0 +1,36 @@ +import 'package:acter/features/chat/providers/chat_providers.dart'; +import 'package:acter/features/chat_ng/widgets/chat_input/chat_editor.dart'; +import 'package:acter/features/chat_ng/widgets/chat_input/chat_editor_loading.dart'; +import 'package:acter/features/chat_ng/widgets/chat_input/chat_editor_no_access.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class ChatInput extends ConsumerWidget { + static const loadingKey = Key('chat-ng-loading'); + static const noAccessKey = Key('chat-ng-no-access'); + + final String roomId; + final void Function(bool)? onTyping; + + const ChatInput({super.key, required this.roomId, this.onTyping}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final canSend = ref.watch(canSendMessageProvider(roomId)).valueOrNull; + + switch (canSend) { + // we're still loading + case null: + return const ChatEditorLoading(); + case true: + // we have permission, show editor field + return ChatEditor( + roomId: roomId, + onTyping: onTyping, + ); + case false: + // no permissions to send messages + return const ChatEditorNoAccess(); + } + } +} diff --git a/app/test/features/chat/custom_input_test.dart b/app/test/features/chat/custom_input_test.dart index c034164c0400..7fcf77cbdad7 100644 --- a/app/test/features/chat/custom_input_test.dart +++ b/app/test/features/chat/custom_input_test.dart @@ -24,7 +24,7 @@ void main() { ProviderScope( overrides: [ // Same as before - canSendProvider.overrideWith((ref, roomId) => false), + canSendMessageProvider.overrideWith((ref, roomId) => false), sdkProvider.overrideWith((ref) => MockActerSdk()), alwaysClientProvider.overrideWith((ref) => MockClient()), ], @@ -46,7 +46,7 @@ void main() { ProviderScope( overrides: [ // Same as before - canSendProvider + canSendMessageProvider .overrideWith((ref, roomId) => null), // null means loading sdkProvider.overrideWith((ref) => MockActerSdk()), ], @@ -68,7 +68,7 @@ void main() { ProviderScope( overrides: [ // Same as before - canSendProvider.overrideWith((ref, roomId) => true), + canSendMessageProvider.overrideWith((ref, roomId) => true), isRoomEncryptedProvider.overrideWith((ref, roomId) => true), sdkProvider.overrideWith((ref) => MockActerSdk()), alwaysClientProvider.overrideWith((ref) => MockClient()), @@ -91,7 +91,7 @@ void main() { group('Send button states', () { final overrides = [ - canSendProvider.overrideWith((ref, roomId) => true), + canSendMessageProvider.overrideWith((ref, roomId) => true), isRoomEncryptedProvider.overrideWith((ref, roomId) => true), sdkProvider.overrideWith((ref) => MockActerSdk()), alwaysClientProvider.overrideWith((ref) => MockClient()), @@ -192,7 +192,7 @@ void main() { 'roomId-2': buildMockDraft(''), }; final overrides = [ - canSendProvider.overrideWith((ref, roomId) => true), + canSendMessageProvider.overrideWith((ref, roomId) => true), isRoomEncryptedProvider.overrideWith((ref, roomId) => true), sdkProvider.overrideWith((ref) => MockActerSdk()), alwaysClientProvider.overrideWith((ref) => MockClient()), From def4f259431dccdbf9da6e466b0ee2df1437deec Mon Sep 17 00:00:00 2001 From: Talha Date: Thu, 7 Nov 2024 13:11:31 +0000 Subject: [PATCH 14/20] Feedback review: clean up and organise mention block --- .../html_editor/components/mention_block.dart | 99 ++++++++++------- .../components/mention_handler.dart | 32 +++--- .../html_editor/components/mention_menu.dart | 105 ++++++++++-------- .../components/room_mention_block.dart | 70 ------------ .../components/user_mention_block.dart | 72 ------------ .../models/mention_block_keys.dart | 11 ++ .../html_editor/models/mention_type.dart | 14 +++ .../services/mention_shortcuts.dart | 2 +- .../chat_room_messages_provider.dart | 7 +- 9 files changed, 165 insertions(+), 247 deletions(-) delete mode 100644 app/lib/common/widgets/html_editor/components/room_mention_block.dart delete mode 100644 app/lib/common/widgets/html_editor/components/user_mention_block.dart create mode 100644 app/lib/common/widgets/html_editor/models/mention_block_keys.dart create mode 100644 app/lib/common/widgets/html_editor/models/mention_type.dart diff --git a/app/lib/common/widgets/html_editor/components/mention_block.dart b/app/lib/common/widgets/html_editor/components/mention_block.dart index 47929fc72cc5..e18b747dabd4 100644 --- a/app/lib/common/widgets/html_editor/components/mention_block.dart +++ b/app/lib/common/widgets/html_editor/components/mention_block.dart @@ -1,35 +1,9 @@ -import 'package:acter/common/widgets/html_editor/components/room_mention_block.dart'; -import 'package:acter/common/widgets/html_editor/components/user_mention_block.dart'; +import 'package:acter/common/widgets/html_editor/components/mention_content.dart'; +import 'package:acter/common/widgets/html_editor/models/mention_block_keys.dart'; +import 'package:acter/common/widgets/html_editor/models/mention_type.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; -enum MentionType { - user, - room; - - static MentionType fromStr(String str) => switch (str) { - '@' => user, - '#' => room, - _ => throw UnimplementedError(), - }; - static String toStr(MentionType type) => switch (type) { - MentionType.user => '@', - MentionType.room => '#', - }; -} - -class MentionBlockKeys { - const MentionBlockKeys._(); - static const mention = 'mention'; - static const type = 'type'; // MentionType, String - static const blockId = 'block_id'; - static const userId = 'user_id'; - static const roomId = 'room_id'; - static const displayName = 'display_name'; - static const userMentionChar = '@'; - static const roomMentionChar = '#'; -} - class MentionBlock extends StatelessWidget { const MentionBlock({ super.key, @@ -53,45 +27,86 @@ class MentionBlock extends StatelessWidget { switch (type) { case MentionType.user: final String? userId = mention[MentionBlockKeys.userId] as String?; + final String? blockId = mention[MentionBlockKeys.blockId] as String?; final String? displayName = mention[MentionBlockKeys.displayName] as String?; + if (userId == null) { return const SizedBox.shrink(); } - final String? blockId = mention[MentionBlockKeys.blockId] as String?; - - return UserMentionBlock( - key: ValueKey(userId), + return _mentionContent( + context: context, + mentionId: userId, + blockId: blockId, editorState: editorState, displayName: displayName, - userId: userId, - blockId: blockId, node: node, - textStyle: textStyle, index: index, ); case MentionType.room: final String? roomId = mention[MentionBlockKeys.roomId] as String?; + final String? blockId = mention[MentionBlockKeys.blockId] as String?; final String? displayName = mention[MentionBlockKeys.displayName] as String?; + if (roomId == null) { return const SizedBox.shrink(); } - final String? blockId = mention[MentionBlockKeys.blockId] as String?; - return RoomMentionBlock( - key: ValueKey(roomId), + return _mentionContent( + context: context, + mentionId: roomId, + blockId: blockId, editorState: editorState, displayName: displayName, - roomId: roomId, - blockId: blockId, node: node, - textStyle: textStyle, index: index, ); default: return const SizedBox.shrink(); } } + + Widget _mentionContent({ + required BuildContext context, + required EditorState editorState, + required String mentionId, + String? blockId, + required String? displayName, + TextStyle? textStyle, + required Node node, + required int index, + }) { + final desktopPlatforms = [ + TargetPlatform.linux, + TargetPlatform.macOS, + TargetPlatform.windows, + ]; + final mentionContentWidget = MentionContentWidget( + mentionId: mentionId, + displayName: displayName, + textStyle: textStyle, + editorState: editorState, + node: node, + index: index, + ); + + final Widget content = GestureDetector( + onTap: _handleUserTap, + behavior: HitTestBehavior.opaque, + child: desktopPlatforms.contains(Theme.of(context).platform) + ? MouseRegion( + cursor: SystemMouseCursors.click, + child: mentionContentWidget, + ) + : mentionContentWidget, + ); + + return content; + } + + void _handleUserTap() { + // Implement user tap action (e.g., show profile, start chat) + } } diff --git a/app/lib/common/widgets/html_editor/components/mention_handler.dart b/app/lib/common/widgets/html_editor/components/mention_handler.dart index e1267d395033..5b043a0eba5f 100644 --- a/app/lib/common/widgets/html_editor/components/mention_handler.dart +++ b/app/lib/common/widgets/html_editor/components/mention_handler.dart @@ -1,6 +1,6 @@ -import 'package:acter/common/widgets/html_editor/components/mention_block.dart'; import 'package:acter/common/widgets/html_editor/components/mention_menu.dart'; -import 'package:acter/common/widgets/html_editor/html_editor.dart'; +import 'package:acter/common/widgets/html_editor/models/mention_block_keys.dart'; +import 'package:acter/common/widgets/html_editor/models/mention_type.dart'; import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -37,6 +37,7 @@ class MentionHandler extends ConsumerStatefulWidget { class _MentionHandlerState extends ConsumerState { final _focusNode = FocusNode(); final _scrollController = ScrollController(); + List> filteredItems = []; int _selectedIndex = 0; late int startOffset; @@ -45,6 +46,20 @@ class _MentionHandlerState extends ConsumerState { @override void initState() { super.initState(); + ref.listenManual( + mentionSuggestionsProvider((widget.roomId, widget.mentionType)), + (prev, next) { + if (next != null && next.isNotEmpty) { + filteredItems = next.entries.where((entry) { + final normalizedId = entry.key.toLowerCase(); + final normalizedName = entry.value.toLowerCase(); + final normalizedQuery = _search.toLowerCase(); + + return normalizedId.contains(normalizedQuery) || + normalizedName.contains(normalizedQuery); + }).toList(); + } + }); WidgetsBinding.instance.addPostFrameCallback((_) { _focusNode.requestFocus(); }); @@ -60,19 +75,6 @@ class _MentionHandlerState extends ConsumerState { @override Widget build(BuildContext context) { - final suggestions = ref.watch( - mentionSuggestionsProvider((widget.roomId, widget.mentionType)), - ); - - final filteredItems = suggestions.entries.where((entry) { - final normalizedId = entry.key.toLowerCase(); - final normalizedName = entry.value.toLowerCase(); - final normalizedQuery = widget.editorState.intoMarkdown(); - - return normalizedId.contains(normalizedQuery) || - normalizedName.contains(normalizedQuery); - }).toList(); - return Focus( focusNode: _focusNode, onKeyEvent: (node, event) => _handleKeyEvent(node, event, filteredItems), diff --git a/app/lib/common/widgets/html_editor/components/mention_menu.dart b/app/lib/common/widgets/html_editor/components/mention_menu.dart index bab261376757..867c463adc29 100644 --- a/app/lib/common/widgets/html_editor/components/mention_menu.dart +++ b/app/lib/common/widgets/html_editor/components/mention_menu.dart @@ -1,39 +1,8 @@ -import 'package:acter/common/widgets/html_editor/components/mention_block.dart'; import 'package:acter/common/widgets/html_editor/components/mention_handler.dart'; +import 'package:acter/common/widgets/html_editor/models/mention_type.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; -// Style configuration for mention menu -class MentionMenuStyle { - const MentionMenuStyle({ - required this.backgroundColor, - required this.textColor, - required this.selectedColor, - required this.selectedTextColor, - required this.hintColor, - }); - - const MentionMenuStyle.light() - : backgroundColor = Colors.white, - textColor = const Color(0xFF333333), - selectedColor = const Color(0xFFE0F8FF), - selectedTextColor = const Color.fromARGB(255, 56, 91, 247), - hintColor = const Color(0xFF555555); - - const MentionMenuStyle.dark() - : backgroundColor = const Color(0xFF282E3A), - textColor = const Color(0xFFBBC3CD), - selectedColor = const Color(0xFF00BCF0), - selectedTextColor = const Color(0xFF131720), - hintColor = const Color(0xFFBBC3CD); - - final Color backgroundColor; - final Color textColor; - final Color selectedColor; - final Color selectedTextColor; - final Color hintColor; -} - class MentionMenu { MentionMenu({ required this.context, @@ -72,23 +41,41 @@ class MentionMenu { void _show() { dismiss(); + // Get position of editor + final RenderBox? renderBox = context.findRenderObject() as RenderBox?; + if (renderBox == null) return; + + final Size size = renderBox.size; + final Offset position = renderBox.localToGlobal(Offset.zero); + _menuEntry = OverlayEntry( builder: (context) => Positioned( - left: 0, - bottom: 50, + // Position relative to input field + left: position.dx - 20, // Align with left edge of input + // Position above input with some padding + bottom: 70, + width: size.width, // Match input width child: Material( + elevation: 8, // Add some elevation for better visibility + borderRadius: BorderRadius.circular(8), child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: dismiss, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: MentionHandler( - editorState: editorState, - roomId: roomId, - onDismiss: dismiss, - onSelectionUpdate: _onSelectionUpdate, - style: style, - mentionType: mentionType, + child: Container( + constraints: BoxConstraints( + maxHeight: 200, // Limit maximum height + maxWidth: size.width, // Match input width + ), + child: SingleChildScrollView( + scrollDirection: Axis.vertical, // Changed to vertical scrolling + child: MentionHandler( + editorState: editorState, + roomId: roomId, + onDismiss: dismiss, + onSelectionUpdate: _onSelectionUpdate, + style: style, + mentionType: mentionType, + ), ), ), ), @@ -97,8 +84,38 @@ class MentionMenu { ); Overlay.of(context).insert(_menuEntry!); - editorState.service.keyboardService?.disable(showCursor: true); editorState.service.scrollService?.disable(); } } + +// Style configuration for mention menu +class MentionMenuStyle { + const MentionMenuStyle({ + required this.backgroundColor, + required this.textColor, + required this.selectedColor, + required this.selectedTextColor, + required this.hintColor, + }); + + const MentionMenuStyle.light() + : backgroundColor = Colors.white, + textColor = const Color(0xFF333333), + selectedColor = const Color(0xFFE0F8FF), + selectedTextColor = const Color.fromARGB(255, 56, 91, 247), + hintColor = const Color(0xFF555555); + + const MentionMenuStyle.dark() + : backgroundColor = const Color(0xFF282E3A), + textColor = const Color(0xFFBBC3CD), + selectedColor = const Color(0xFF00BCF0), + selectedTextColor = const Color(0xFF131720), + hintColor = const Color(0xFFBBC3CD); + + final Color backgroundColor; + final Color textColor; + final Color selectedColor; + final Color selectedTextColor; + final Color hintColor; +} diff --git a/app/lib/common/widgets/html_editor/components/room_mention_block.dart b/app/lib/common/widgets/html_editor/components/room_mention_block.dart deleted file mode 100644 index 94b80268f743..000000000000 --- a/app/lib/common/widgets/html_editor/components/room_mention_block.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'package:acter/common/widgets/html_editor/components/mention_content.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter/material.dart'; - -class RoomMentionBlock extends StatefulWidget { - const RoomMentionBlock({ - super.key, - required this.editorState, - required this.roomId, - required this.displayName, - required this.blockId, - required this.node, - required this.textStyle, - required this.index, - }); - - final EditorState editorState; - final String roomId; - final String? displayName; - final String? blockId; - final Node node; - final TextStyle? textStyle; - final int index; - - @override - State createState() => _RoomMentionBlockState(); -} - -class _RoomMentionBlockState extends State { - @override - Widget build(BuildContext context) { - final desktopPlatforms = [ - TargetPlatform.linux, - TargetPlatform.macOS, - TargetPlatform.windows, - ]; - - final Widget content = desktopPlatforms.contains(Theme.of(context).platform) - ? GestureDetector( - onTap: _handleRoomTap, - behavior: HitTestBehavior.opaque, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: MentionContentWidget( - mentionId: widget.roomId, - displayName: widget.displayName, - textStyle: widget.textStyle, - editorState: widget.editorState, - node: widget.node, - index: widget.index, - ), - ), - ) - : GestureDetector( - onTap: _handleRoomTap, - behavior: HitTestBehavior.opaque, - child: MentionContentWidget( - mentionId: widget.roomId, - displayName: widget.displayName, - textStyle: widget.textStyle, - editorState: widget.editorState, - node: widget.node, - index: widget.index, - ), - ); - return content; - } - - void _handleRoomTap() async {} -} diff --git a/app/lib/common/widgets/html_editor/components/user_mention_block.dart b/app/lib/common/widgets/html_editor/components/user_mention_block.dart deleted file mode 100644 index 97569bb74056..000000000000 --- a/app/lib/common/widgets/html_editor/components/user_mention_block.dart +++ /dev/null @@ -1,72 +0,0 @@ -import 'package:acter/common/widgets/html_editor/components/mention_content.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter/material.dart'; - -class UserMentionBlock extends StatefulWidget { - const UserMentionBlock({ - super.key, - required this.editorState, - required this.userId, - required this.displayName, - required this.blockId, - required this.node, - required this.textStyle, - required this.index, - }); - - final EditorState editorState; - final String userId; - final String? displayName; - final String? blockId; - final Node node; - final TextStyle? textStyle; - final int index; - - @override - State createState() => _UserMentionBlockState(); -} - -class _UserMentionBlockState extends State { - @override - Widget build(BuildContext context) { - final desktopPlatforms = [ - TargetPlatform.linux, - TargetPlatform.macOS, - TargetPlatform.windows, - ]; - - final Widget content = desktopPlatforms.contains(Theme.of(context).platform) - ? GestureDetector( - onTap: _handleUserTap, - behavior: HitTestBehavior.opaque, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: MentionContentWidget( - mentionId: widget.userId, - displayName: widget.displayName, - textStyle: widget.textStyle, - editorState: widget.editorState, - node: widget.node, - index: widget.index, - ), - ), - ) - : GestureDetector( - onTap: _handleUserTap, - behavior: HitTestBehavior.opaque, - child: MentionContentWidget( - mentionId: widget.userId, - displayName: widget.displayName, - textStyle: widget.textStyle, - editorState: widget.editorState, - node: widget.node, - index: widget.index, - ), - ); - return content; - } - - void _handleUserTap() { - // Implement user tap action (e.g., show profile, start chat) - } -} diff --git a/app/lib/common/widgets/html_editor/models/mention_block_keys.dart b/app/lib/common/widgets/html_editor/models/mention_block_keys.dart new file mode 100644 index 000000000000..56bc403b74e0 --- /dev/null +++ b/app/lib/common/widgets/html_editor/models/mention_block_keys.dart @@ -0,0 +1,11 @@ +class MentionBlockKeys { + const MentionBlockKeys._(); + static const mention = 'mention'; + static const type = 'type'; // MentionType, String + static const blockId = 'block_id'; + static const userId = 'user_id'; + static const roomId = 'room_id'; + static const displayName = 'display_name'; + static const userMentionChar = '@'; + static const roomMentionChar = '#'; +} diff --git a/app/lib/common/widgets/html_editor/models/mention_type.dart b/app/lib/common/widgets/html_editor/models/mention_type.dart new file mode 100644 index 000000000000..1353139fcd72 --- /dev/null +++ b/app/lib/common/widgets/html_editor/models/mention_type.dart @@ -0,0 +1,14 @@ +enum MentionType { + user, + room; + + static MentionType fromStr(String str) => switch (str) { + '@' => user, + '#' => room, + _ => throw UnimplementedError(), + }; + static String toStr(MentionType type) => switch (type) { + MentionType.user => '@', + MentionType.room => '#', + }; +} diff --git a/app/lib/common/widgets/html_editor/services/mention_shortcuts.dart b/app/lib/common/widgets/html_editor/services/mention_shortcuts.dart index da43409c6114..e048956eb721 100644 --- a/app/lib/common/widgets/html_editor/services/mention_shortcuts.dart +++ b/app/lib/common/widgets/html_editor/services/mention_shortcuts.dart @@ -1,5 +1,5 @@ -import 'package:acter/common/widgets/html_editor/components/mention_block.dart'; import 'package:acter/common/widgets/html_editor/components/mention_menu.dart'; +import 'package:acter/common/widgets/html_editor/models/mention_type.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; diff --git a/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart b/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart index ca9ae2d5a9bb..007f0d658454 100644 --- a/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart +++ b/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart @@ -1,6 +1,6 @@ import 'package:acter/common/providers/chat_providers.dart'; import 'package:acter/common/providers/room_providers.dart'; -import 'package:acter/common/widgets/html_editor/components/mention_block.dart'; +import 'package:acter/common/widgets/html_editor/models/mention_type.dart'; import 'package:acter/features/chat_ng/models/chat_room_state/chat_room_state.dart'; import 'package:acter/features/chat_ng/providers/notifiers/chat_room_messages_notifier.dart'; import 'package:acter/features/home/providers/client_providers.dart'; @@ -55,7 +55,8 @@ final renderableChatMessagesProvider = }); final mentionSuggestionsProvider = - Provider.family, (String, MentionType)>((ref, params) { + StateProvider.family?, (String, MentionType)>( + (ref, params) { final roomId = params.$1; final mentionType = params.$2; final client = ref.watch(alwaysClientProvider); @@ -86,5 +87,5 @@ final mentionSuggestionsProvider = return map; }); } - return {}; + return null; }); From 29f4ab3d4890ec49b0a7033fded979e80ec0cc02 Mon Sep 17 00:00:00 2001 From: Talha Date: Fri, 8 Nov 2024 19:17:38 +0000 Subject: [PATCH 15/20] fix and organise mentions structure --- .../html_editor/components/mention_block.dart | 96 ++--- .../components/mention_content.dart | 50 --- .../components/mention_handler.dart | 364 ------------------ .../html_editor/components/mention_item.dart | 42 ++ .../html_editor/components/mention_list.dart | 243 ++++++++++++ .../html_editor/components/mention_menu.dart | 53 +-- .../widgets/html_editor/html_editor.dart | 54 +++ .../models/mention_block_keys.dart | 5 +- .../html_editor/models/mention_type.dart | 5 - .../services/mention_shortcuts.dart | 1 - 10 files changed, 396 insertions(+), 517 deletions(-) delete mode 100644 app/lib/common/widgets/html_editor/components/mention_content.dart delete mode 100644 app/lib/common/widgets/html_editor/components/mention_handler.dart create mode 100644 app/lib/common/widgets/html_editor/components/mention_item.dart create mode 100644 app/lib/common/widgets/html_editor/components/mention_list.dart diff --git a/app/lib/common/widgets/html_editor/components/mention_block.dart b/app/lib/common/widgets/html_editor/components/mention_block.dart index e18b747dabd4..e1224a1b5d4c 100644 --- a/app/lib/common/widgets/html_editor/components/mention_block.dart +++ b/app/lib/common/widgets/html_editor/components/mention_block.dart @@ -1,67 +1,57 @@ -import 'package:acter/common/widgets/html_editor/components/mention_content.dart'; +import 'package:acter/common/providers/room_providers.dart'; import 'package:acter/common/widgets/html_editor/models/mention_block_keys.dart'; -import 'package:acter/common/widgets/html_editor/models/mention_type.dart'; +import 'package:acter_avatar/acter_avatar.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; -class MentionBlock extends StatelessWidget { +class MentionBlock extends ConsumerWidget { const MentionBlock({ super.key, - required this.editorState, - required this.mention, required this.node, required this.index, - required this.textStyle, + required this.mention, + required this.userRoomId, }); - final EditorState editorState; final Map mention; + final String userRoomId; final Node node; final int index; - final TextStyle? textStyle; - @override - Widget build(BuildContext context) { - final type = MentionType.fromStr(mention[MentionBlockKeys.type]); + Widget build(BuildContext context, WidgetRef ref) { + final String type = mention[MentionBlockKeys.type]; switch (type) { - case MentionType.user: - final String? userId = mention[MentionBlockKeys.userId] as String?; - final String? blockId = mention[MentionBlockKeys.blockId] as String?; - final String? displayName = - mention[MentionBlockKeys.displayName] as String?; + case 'user': + final String userId = mention[MentionBlockKeys.userId]; - if (userId == null) { - return const SizedBox.shrink(); - } + final String displayName = mention[MentionBlockKeys.displayName]; + + final avatarInfo = ref.watch( + memberAvatarInfoProvider((roomId: userRoomId, userId: userId)), + ); return _mentionContent( context: context, mentionId: userId, - blockId: blockId, - editorState: editorState, displayName: displayName, - node: node, - index: index, + avatar: avatarInfo, + ref: ref, ); - case MentionType.room: - final String? roomId = mention[MentionBlockKeys.roomId] as String?; - final String? blockId = mention[MentionBlockKeys.blockId] as String?; - final String? displayName = - mention[MentionBlockKeys.displayName] as String?; + case 'room': + final String roomId = mention[MentionBlockKeys.roomId]; + + final String displayName = mention[MentionBlockKeys.displayName]; - if (roomId == null) { - return const SizedBox.shrink(); - } + final avatarInfo = ref.watch(roomAvatarInfoProvider(roomId)); return _mentionContent( context: context, mentionId: roomId, - blockId: blockId, - editorState: editorState, displayName: displayName, - node: node, - index: index, + avatar: avatarInfo, + ref: ref, ); default: return const SizedBox.shrink(); @@ -70,26 +60,36 @@ class MentionBlock extends StatelessWidget { Widget _mentionContent({ required BuildContext context, - required EditorState editorState, required String mentionId, - String? blockId, - required String? displayName, - TextStyle? textStyle, - required Node node, - required int index, + required String displayName, + required WidgetRef ref, + required AvatarInfo avatar, }) { final desktopPlatforms = [ TargetPlatform.linux, TargetPlatform.macOS, TargetPlatform.windows, ]; - final mentionContentWidget = MentionContentWidget( - mentionId: mentionId, - displayName: displayName, - textStyle: textStyle, - editorState: editorState, - node: node, - index: index, + final name = displayName.isNotEmpty ? displayName : mentionId; + final mentionContentWidget = Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: Theme.of(context).unselectedWidgetColor, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ActerAvatar( + options: AvatarOptions( + avatar, + size: 16, + ), + ), + const SizedBox(width: 4), + Text(name, style: Theme.of(context).textTheme.bodyMedium), + ], + ), ); final Widget content = GestureDetector( diff --git a/app/lib/common/widgets/html_editor/components/mention_content.dart b/app/lib/common/widgets/html_editor/components/mention_content.dart deleted file mode 100644 index 757b17a2598a..000000000000 --- a/app/lib/common/widgets/html_editor/components/mention_content.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter/material.dart'; - -class MentionContentWidget extends StatelessWidget { - const MentionContentWidget({ - super.key, - required this.mentionId, - this.displayName, - required this.textStyle, - required this.editorState, - required this.node, - required this.index, - }); - - final String mentionId; - final String? displayName; - final TextStyle? textStyle; - final EditorState editorState; - final Node node; - final int index; - - @override - Widget build(BuildContext context) { - final baseTextStyle = textStyle?.copyWith( - color: Theme.of(context).colorScheme.primary, - leadingDistribution: TextLeadingDistribution.even, - ); - - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (displayName != null) - Text( - displayName!, - style: baseTextStyle?.copyWith( - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(width: 4), - Text( - mentionId, - style: baseTextStyle?.copyWith( - fontSize: (baseTextStyle.fontSize ?? 14.0) * 0.9, - color: Theme.of(context).hintColor, - ), - ), - ], - ); - } -} diff --git a/app/lib/common/widgets/html_editor/components/mention_handler.dart b/app/lib/common/widgets/html_editor/components/mention_handler.dart deleted file mode 100644 index 5b043a0eba5f..000000000000 --- a/app/lib/common/widgets/html_editor/components/mention_handler.dart +++ /dev/null @@ -1,364 +0,0 @@ -import 'package:acter/common/widgets/html_editor/components/mention_menu.dart'; -import 'package:acter/common/widgets/html_editor/models/mention_block_keys.dart'; -import 'package:acter/common/widgets/html_editor/models/mention_type.dart'; -import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -const double kMentionMenuHeight = 300; -const double kMentionMenuWidth = 250; -const double kItemHeight = 60; -const double kContentHeight = 260; - -class MentionHandler extends ConsumerStatefulWidget { - const MentionHandler({ - super.key, - required this.editorState, - required this.roomId, - required this.mentionType, - required this.onDismiss, - required this.onSelectionUpdate, - required this.style, - }); - - final EditorState editorState; - final String roomId; - final MentionType mentionType; - final VoidCallback onDismiss; - final VoidCallback onSelectionUpdate; - final MentionMenuStyle style; - - @override - ConsumerState createState() => _MentionHandlerState(); -} - -class _MentionHandlerState extends ConsumerState { - final _focusNode = FocusNode(); - final _scrollController = ScrollController(); - List> filteredItems = []; - - int _selectedIndex = 0; - late int startOffset; - String _search = ''; - - @override - void initState() { - super.initState(); - ref.listenManual( - mentionSuggestionsProvider((widget.roomId, widget.mentionType)), - (prev, next) { - if (next != null && next.isNotEmpty) { - filteredItems = next.entries.where((entry) { - final normalizedId = entry.key.toLowerCase(); - final normalizedName = entry.value.toLowerCase(); - final normalizedQuery = _search.toLowerCase(); - - return normalizedId.contains(normalizedQuery) || - normalizedName.contains(normalizedQuery); - }).toList(); - } - }); - WidgetsBinding.instance.addPostFrameCallback((_) { - _focusNode.requestFocus(); - }); - startOffset = widget.editorState.selection?.endIndex ?? 0; - } - - @override - void dispose() { - _scrollController.dispose(); - _focusNode.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Focus( - focusNode: _focusNode, - onKeyEvent: (node, event) => _handleKeyEvent(node, event, filteredItems), - child: Container( - constraints: const BoxConstraints( - maxHeight: kMentionMenuHeight, - maxWidth: kMentionMenuWidth, - ), - decoration: BoxDecoration( - color: widget.style.backgroundColor, - borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - blurRadius: 5, - spreadRadius: 1, - color: Colors.black.withOpacity(0.1), - ), - ], - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - widget.mentionType == MentionType.user ? 'Users' : 'Rooms', - style: TextStyle( - color: widget.style.textColor, - fontWeight: FontWeight.bold, - ), - ), - ), - const Divider(height: 1), - Flexible( - child: filteredItems.isEmpty - ? Padding( - padding: const EdgeInsets.all(16.0), - child: Text( - 'No results found', - style: TextStyle(color: widget.style.hintColor), - ), - ) - : ListView.builder( - controller: _scrollController, - itemCount: filteredItems.length, - itemBuilder: (context, index) { - final item = filteredItems[index]; - return _MentionListItem( - userId: item.key, - displayName: item.value, - isSelected: index == _selectedIndex, - style: widget.style, - onTap: () => _selectItem(item.key, item.value), - ); - }, - ), - ), - ], - ), - ), - ); - } - - KeyEventResult _handleKeyEvent( - FocusNode node, - KeyEvent event, - List> filteredItems, - ) { - if (event is! KeyDownEvent) return KeyEventResult.ignored; - - switch (event.logicalKey) { - case LogicalKeyboardKey.escape: - widget.onDismiss(); - return KeyEventResult.handled; - - case LogicalKeyboardKey.enter: - if (filteredItems.isNotEmpty) { - final selectedItem = filteredItems[_selectedIndex]; - _selectItem(selectedItem.key, selectedItem.value); - } - - return KeyEventResult.handled; - - case LogicalKeyboardKey.arrowUp: - setState(() { - _selectedIndex = - (_selectedIndex - 1).clamp(0, filteredItems.length - 1); - }); - _scrollToSelected(); - return KeyEventResult.handled; - - case LogicalKeyboardKey.arrowDown: - setState(() { - _selectedIndex = - (_selectedIndex + 1).clamp(0, filteredItems.length - 1); - }); - _scrollToSelected(); - return KeyEventResult.handled; - - case LogicalKeyboardKey.backspace: - if (_search.isEmpty) { - if (_canDeleteLastCharacter()) { - widget.editorState.deleteBackward(); - } else { - // Workaround for editor regaining focus - widget.editorState.apply( - widget.editorState.transaction - ..afterSelection = widget.editorState.selection, - ); - } - widget.onDismiss(); - } else { - widget.onSelectionUpdate(); - widget.editorState.deleteBackward(); - _deleteCharacterAtSelection(); - } - - return KeyEventResult.handled; - - default: - if (event.character != null && - !HardwareKeyboard.instance.isControlPressed && - !HardwareKeyboard.instance.isMetaPressed && - !HardwareKeyboard.instance.isAltPressed) { - widget.onSelectionUpdate(); - widget.editorState.insertTextAtCurrentSelection(event.character!); - _updateSearch(); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - } - } - - void _updateSearch() { - final selection = widget.editorState.selection; - if (selection == null) return; - - final node = widget.editorState.getNodeAtPath(selection.end.path); - if (node == null) return; - - final text = node.delta?.toPlainText() ?? ''; - if (text.length < startOffset) return; - - setState(() { - _search = text.substring(startOffset); - }); - } - - void _selectItem(String id, String displayName) { - final selection = widget.editorState.selection; - if (selection == null) return; - - final transaction = widget.editorState.transaction; - final node = widget.editorState.getNodeAtPath(selection.end.path); - if (node == null) return; - - // Delete the search text and trigger character - transaction.deleteText( - node, - startOffset - 1, - selection.end.offset - startOffset + 1, - ); - - // Insert the mention - transaction.insertText( - node, - startOffset - 1, - displayName.isNotEmpty ? displayName : id, - attributes: { - MentionBlockKeys.mention: { - MentionBlockKeys.type: widget.mentionType.name, - if (widget.mentionType == MentionType.user) - MentionBlockKeys.userId: id - else - MentionBlockKeys.roomId: id, - }, - }, - ); - - widget.editorState.apply(transaction); - widget.onDismiss(); - } - - void _scrollToSelected() { - final itemPosition = _selectedIndex * kItemHeight; - if (itemPosition < _scrollController.offset) { - _scrollController.animateTo( - itemPosition, - duration: const Duration(milliseconds: 200), - curve: Curves.easeOut, - ); - } else if (itemPosition + kItemHeight > - _scrollController.offset + - _scrollController.position.viewportDimension) { - _scrollController.animateTo( - itemPosition + - kItemHeight - - _scrollController.position.viewportDimension, - duration: const Duration(milliseconds: 200), - curve: Curves.easeOut, - ); - } - } - - void _deleteCharacterAtSelection() { - final selection = widget.editorState.selection; - if (selection == null || !selection.isCollapsed) { - return; - } - - final node = widget.editorState.getNodeAtPath(selection.end.path); - final delta = node?.delta; - if (node == null || delta == null) { - return; - } - - _search = delta.toPlainText().substring( - startOffset, - startOffset - 1 + _search.length, - ); - } - - bool _canDeleteLastCharacter() { - final selection = widget.editorState.selection; - if (selection == null || !selection.isCollapsed) { - return false; - } - - final delta = widget.editorState.getNodeAtPath(selection.start.path)?.delta; - if (delta == null) { - return false; - } - - return delta.isNotEmpty; - } -} - -class _MentionListItem extends StatelessWidget { - const _MentionListItem({ - required this.userId, - required this.displayName, - required this.isSelected, - required this.style, - required this.onTap, - }); - - final String userId; - final String displayName; - final bool isSelected; - final MentionMenuStyle style; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return InkWell( - onTap: onTap, - child: Container( - height: kItemHeight, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - color: isSelected ? style.selectedColor : null, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - displayName.isNotEmpty ? displayName : userId, - style: TextStyle( - color: isSelected ? style.selectedTextColor : style.textColor, - fontWeight: FontWeight.w500, - ), - ), - if (displayName.isNotEmpty) - Text( - userId, - style: TextStyle( - fontSize: 12, - color: isSelected - ? style.selectedTextColor.withOpacity(0.7) - : style.hintColor, - ), - ), - ], - ), - ), - ); - } -} diff --git a/app/lib/common/widgets/html_editor/components/mention_item.dart b/app/lib/common/widgets/html_editor/components/mention_item.dart new file mode 100644 index 000000000000..8d5771c8734a --- /dev/null +++ b/app/lib/common/widgets/html_editor/components/mention_item.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class MentionItem extends ConsumerWidget { + const MentionItem({ + super.key, + required this.userId, + required this.displayName, + required this.isSelected, + required this.onTap, + }); + + final String userId; + final String displayName; + final bool isSelected; + + final VoidCallback onTap; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return InkWell( + onTap: onTap, + child: Container( + height: 60, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + color: isSelected ? Theme.of(context).colorScheme.primary : null, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + displayName.isNotEmpty ? displayName : userId, + style: Theme.of(context).textTheme.bodyMedium, + ), + if (displayName.isNotEmpty) + Text(userId, style: Theme.of(context).textTheme.labelMedium), + ], + ), + ), + ); + } +} diff --git a/app/lib/common/widgets/html_editor/components/mention_list.dart b/app/lib/common/widgets/html_editor/components/mention_list.dart new file mode 100644 index 000000000000..9f161d79022a --- /dev/null +++ b/app/lib/common/widgets/html_editor/components/mention_list.dart @@ -0,0 +1,243 @@ +import 'package:acter/common/widgets/html_editor/components/mention_item.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:acter/common/widgets/html_editor/models/mention_block_keys.dart'; +import 'package:acter/common/widgets/html_editor/models/mention_type.dart'; +import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class MentionList extends ConsumerStatefulWidget { + const MentionList({ + super.key, + required this.editorState, + required this.roomId, + required this.mentionType, + required this.onDismiss, + required this.onSelectionUpdate, + }); + + final EditorState editorState; + final String roomId; + final MentionType mentionType; + final VoidCallback onDismiss; + final VoidCallback onSelectionUpdate; + + @override + ConsumerState createState() => _MentionHandlerState(); +} + +class _MentionHandlerState extends ConsumerState { + final _focusNode = FocusNode(); + final _scrollController = ScrollController(); + + int _selectedIndex = 0; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusNode.requestFocus(); + }); + } + + @override + void dispose() { + _scrollController.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // All suggestions list + final suggestions = ref + .watch(mentionSuggestionsProvider((widget.roomId, widget.mentionType))); + if (suggestions == null) { + return ErrorWidget(L10n.of(context).loadingFailed); + } + + return Focus( + focusNode: _focusNode, + onKeyEvent: (node, event) => _handleKeyEvent(node, event, suggestions), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildMenuHeader(), + const Divider(height: 1, endIndent: 5, indent: 5), + const SizedBox(height: 8), + _buildMenuList(suggestions), + ], + ), + ); + } + + Widget _buildMenuHeader() => Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + widget.mentionType == MentionType.user + ? L10n.of(context).foundUsers + : 'Rooms', + ), + ); + + Widget _buildMenuList(Map suggestions) { + return Flexible( + child: suggestions.isEmpty + ? const Padding( + padding: EdgeInsets.all(16.0), + child: Text( + 'No results found', + ), + ) + : ListView.builder( + shrinkWrap: true, + controller: _scrollController, + itemCount: suggestions.length, + itemBuilder: (context, index) { + final userId = suggestions.keys.elementAt(index); + final displayName = suggestions.values.elementAt(index); + return MentionItem( + userId: userId, + displayName: displayName, + isSelected: index == _selectedIndex, + onTap: () => _selectItem(userId, displayName), + ); + }, + ), + ); + } + + KeyEventResult _handleKeyEvent( + FocusNode node, + KeyEvent event, + Map suggestions, + ) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + + switch (event.logicalKey) { + case LogicalKeyboardKey.escape: + widget.onDismiss(); + return KeyEventResult.handled; + + case LogicalKeyboardKey.enter: + if (suggestions.isNotEmpty) { + final selectedItem = suggestions.entries.elementAt(_selectedIndex); + _selectItem(selectedItem.key, selectedItem.value); + } + widget.onDismiss(); + return KeyEventResult.handled; + + case LogicalKeyboardKey.arrowUp: + setState(() { + _selectedIndex = + (_selectedIndex - 1).clamp(0, suggestions.length - 1); + }); + _scrollToSelected(); + return KeyEventResult.handled; + + case LogicalKeyboardKey.arrowDown: + setState(() { + _selectedIndex = + (_selectedIndex + 1).clamp(0, suggestions.length - 1); + }); + _scrollToSelected(); + return KeyEventResult.handled; + + case LogicalKeyboardKey.backspace: + if (_canDeleteLastCharacter()) { + widget.editorState.deleteBackward(); + } else { + // Workaround for editor regaining focus + widget.editorState.apply( + widget.editorState.transaction + ..afterSelection = widget.editorState.selection, + ); + } + final isEmpty = widget.editorState.selection?.end.offset == 0; + + if (isEmpty) { + widget.onDismiss(); + } + return KeyEventResult.handled; + + default: + if (event.character != null && + !HardwareKeyboard.instance.isAltPressed && + !HardwareKeyboard.instance.isMetaPressed || + !HardwareKeyboard.instance.isShiftPressed) { + widget.onSelectionUpdate(); + widget.editorState.insertTextAtCurrentSelection(event.character!); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + } + + void _selectItem(String id, String displayName) { + final selection = widget.editorState.selection; + if (selection == null) return; + + final transaction = widget.editorState.transaction; + final node = widget.editorState.getNodeAtPath(selection.end.path); + final length = selection.end.offset - (selection.start.offset - 1); + + if (node == null) return; + + transaction.replaceText( + node, + selection.start.offset - 1, + length, + ' ', + attributes: { + MentionBlockKeys.mention: { + MentionBlockKeys.type: widget.mentionType.name, + if (widget.mentionType == MentionType.user) + MentionBlockKeys.userId: id + else + MentionBlockKeys.roomId: id, + MentionBlockKeys.displayName: displayName, + }, + }, + ); + + widget.editorState.apply(transaction); + widget.onDismiss(); + } + + void _scrollToSelected() { + const double kItemHeight = 60; + final itemPosition = _selectedIndex * kItemHeight; + if (itemPosition < _scrollController.offset) { + _scrollController.animateTo( + itemPosition, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } else if (itemPosition + kItemHeight > + _scrollController.offset + + _scrollController.position.viewportDimension) { + _scrollController.animateTo( + itemPosition + + kItemHeight - + _scrollController.position.viewportDimension, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } + } + + bool _canDeleteLastCharacter() { + final selection = widget.editorState.selection; + if (selection == null || !selection.isCollapsed) { + return false; + } + + final node = widget.editorState.getNodeAtPath(selection.start.path); + if (node?.delta == null) { + return false; + } + return selection.start.offset > 0; + } +} diff --git a/app/lib/common/widgets/html_editor/components/mention_menu.dart b/app/lib/common/widgets/html_editor/components/mention_menu.dart index 867c463adc29..340a505466c0 100644 --- a/app/lib/common/widgets/html_editor/components/mention_menu.dart +++ b/app/lib/common/widgets/html_editor/components/mention_menu.dart @@ -1,4 +1,4 @@ -import 'package:acter/common/widgets/html_editor/components/mention_handler.dart'; +import 'package:acter/common/widgets/html_editor/components/mention_list.dart'; import 'package:acter/common/widgets/html_editor/models/mention_type.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; @@ -8,14 +8,12 @@ class MentionMenu { required this.context, required this.editorState, required this.roomId, - required this.style, required this.mentionType, }); final BuildContext context; final EditorState editorState; final String roomId; - final MentionMenuStyle style; final MentionType mentionType; OverlayEntry? _menuEntry; @@ -51,7 +49,7 @@ class MentionMenu { _menuEntry = OverlayEntry( builder: (context) => Positioned( // Position relative to input field - left: position.dx - 20, // Align with left edge of input + left: position.dx + 20, // Align with left edge of input // Position above input with some padding bottom: 70, width: size.width, // Match input width @@ -66,16 +64,12 @@ class MentionMenu { maxHeight: 200, // Limit maximum height maxWidth: size.width, // Match input width ), - child: SingleChildScrollView( - scrollDirection: Axis.vertical, // Changed to vertical scrolling - child: MentionHandler( - editorState: editorState, - roomId: roomId, - onDismiss: dismiss, - onSelectionUpdate: _onSelectionUpdate, - style: style, - mentionType: mentionType, - ), + child: MentionList( + editorState: editorState, + roomId: roomId, + onDismiss: dismiss, + onSelectionUpdate: _onSelectionUpdate, + mentionType: mentionType, ), ), ), @@ -88,34 +82,3 @@ class MentionMenu { editorState.service.scrollService?.disable(); } } - -// Style configuration for mention menu -class MentionMenuStyle { - const MentionMenuStyle({ - required this.backgroundColor, - required this.textColor, - required this.selectedColor, - required this.selectedTextColor, - required this.hintColor, - }); - - const MentionMenuStyle.light() - : backgroundColor = Colors.white, - textColor = const Color(0xFF333333), - selectedColor = const Color(0xFFE0F8FF), - selectedTextColor = const Color.fromARGB(255, 56, 91, 247), - hintColor = const Color(0xFF555555); - - const MentionMenuStyle.dark() - : backgroundColor = const Color(0xFF282E3A), - textColor = const Color(0xFFBBC3CD), - selectedColor = const Color(0xFF00BCF0), - selectedTextColor = const Color(0xFF131720), - hintColor = const Color(0xFFBBC3CD); - - final Color backgroundColor; - final Color textColor; - final Color selectedColor; - final Color selectedTextColor; - final Color hintColor; -} diff --git a/app/lib/common/widgets/html_editor/html_editor.dart b/app/lib/common/widgets/html_editor/html_editor.dart index 3862eda7ba9e..caccf294a3a2 100644 --- a/app/lib/common/widgets/html_editor/html_editor.dart +++ b/app/lib/common/widgets/html_editor/html_editor.dart @@ -3,6 +3,9 @@ import 'dart:async'; import 'package:acter/common/extensions/options.dart'; import 'package:acter/common/toolkit/buttons/primary_action_button.dart'; import 'package:acter/common/utils/constants.dart'; +import 'package:acter/common/widgets/html_editor/components/mention_block.dart'; +import 'package:acter/common/widgets/html_editor/models/mention_block_keys.dart'; +import 'package:acter/common/widgets/html_editor/models/mention_type.dart'; import 'package:acter/common/widgets/html_editor/services/mention_shortcuts.dart'; import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; @@ -368,6 +371,8 @@ class HtmlEditorState extends State { .bodySmall .expect('bodySmall style not available'), ), + textSpanDecorator: + widget.roomId != null ? customizeAttributeDecorator : null, ); } @@ -384,6 +389,55 @@ class HtmlEditorState extends State { .expect('bodySmall style not available'), ), mobileDragHandleBallSize: const Size(12, 12), + textSpanDecorator: + widget.roomId != null ? customizeAttributeDecorator : null, + ); + } + + InlineSpan customizeAttributeDecorator( + BuildContext context, + Node node, + int index, + TextInsert text, + TextSpan before, + TextSpan after, + ) { + final attributes = text.attributes; + if (attributes == null) { + return before; + } + final roomId = widget.roomId; + // Inline Mentions + final mention = + attributes[MentionBlockKeys.mention] as Map?; + if (mention != null && roomId != null) { + final type = mention[MentionBlockKeys.type]; + return WidgetSpan( + alignment: PlaceholderAlignment.middle, + style: after.style, + child: MentionBlock( + key: ValueKey( + switch (type) { + MentionType.user => mention[MentionBlockKeys.userId], + MentionType.room => mention[MentionBlockKeys.roomId], + _ => MentionBlockKeys.mention, + }, + ), + userRoomId: roomId, + node: node, + index: index, + mention: mention, + ), + ); + } + + return defaultTextSpanDecoratorForAttribute( + context, + node, + index, + text, + before, + after, ); } } diff --git a/app/lib/common/widgets/html_editor/models/mention_block_keys.dart b/app/lib/common/widgets/html_editor/models/mention_block_keys.dart index 56bc403b74e0..10ba6133869d 100644 --- a/app/lib/common/widgets/html_editor/models/mention_block_keys.dart +++ b/app/lib/common/widgets/html_editor/models/mention_block_keys.dart @@ -2,10 +2,7 @@ class MentionBlockKeys { const MentionBlockKeys._(); static const mention = 'mention'; static const type = 'type'; // MentionType, String - static const blockId = 'block_id'; static const userId = 'user_id'; static const roomId = 'room_id'; - static const displayName = 'display_name'; - static const userMentionChar = '@'; - static const roomMentionChar = '#'; + static const displayName = ''; } diff --git a/app/lib/common/widgets/html_editor/models/mention_type.dart b/app/lib/common/widgets/html_editor/models/mention_type.dart index 1353139fcd72..a4429d160619 100644 --- a/app/lib/common/widgets/html_editor/models/mention_type.dart +++ b/app/lib/common/widgets/html_editor/models/mention_type.dart @@ -2,11 +2,6 @@ enum MentionType { user, room; - static MentionType fromStr(String str) => switch (str) { - '@' => user, - '#' => room, - _ => throw UnimplementedError(), - }; static String toStr(MentionType type) => switch (type) { MentionType.user => '@', MentionType.room => '#', diff --git a/app/lib/common/widgets/html_editor/services/mention_shortcuts.dart b/app/lib/common/widgets/html_editor/services/mention_shortcuts.dart index e048956eb721..f5bb6bb3b59f 100644 --- a/app/lib/common/widgets/html_editor/services/mention_shortcuts.dart +++ b/app/lib/common/widgets/html_editor/services/mention_shortcuts.dart @@ -56,7 +56,6 @@ Future _handleMentionTrigger({ editorState: editorState, roomId: roomId, mentionType: type, - style: const MentionMenuStyle.dark(), ); menu.show(); From 224e62bb7384af21112eddf20dc882e54569405f Mon Sep 17 00:00:00 2001 From: Talha Date: Fri, 8 Nov 2024 19:38:03 +0000 Subject: [PATCH 16/20] fix mention selection logic --- .../html_editor/components/mention_list.dart | 47 ++++++++++++++----- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/app/lib/common/widgets/html_editor/components/mention_list.dart b/app/lib/common/widgets/html_editor/components/mention_list.dart index 9f161d79022a..819d74d2a158 100644 --- a/app/lib/common/widgets/html_editor/components/mention_list.dart +++ b/app/lib/common/widgets/html_editor/components/mention_list.dart @@ -146,20 +146,30 @@ class _MentionHandlerState extends ConsumerState { return KeyEventResult.handled; case LogicalKeyboardKey.backspace: + final selection = widget.editorState.selection; + if (selection == null) return KeyEventResult.handled; + + final node = widget.editorState.getNodeAtPath(selection.end.path); + if (node == null) return KeyEventResult.handled; + + // Get text before cursor + final text = node.delta?.toPlainText() ?? ''; + final cursorPosition = selection.end.offset; + if (_canDeleteLastCharacter()) { + // Check if we're about to delete an mention symbol + if (cursorPosition > 0 && + text[cursorPosition - 1] == + MentionType.toStr(widget.mentionType)) { + widget.onDismiss(); // Dismiss menu when @ is deleted + } widget.editorState.deleteBackward(); } else { // Workaround for editor regaining focus widget.editorState.apply( - widget.editorState.transaction - ..afterSelection = widget.editorState.selection, + widget.editorState.transaction..afterSelection = selection, ); } - final isEmpty = widget.editorState.selection?.end.offset == 0; - - if (isEmpty) { - widget.onDismiss(); - } return KeyEventResult.handled; default: @@ -181,14 +191,29 @@ class _MentionHandlerState extends ConsumerState { final transaction = widget.editorState.transaction; final node = widget.editorState.getNodeAtPath(selection.end.path); - final length = selection.end.offset - (selection.start.offset - 1); - if (node == null) return; + final text = node.delta?.toPlainText() ?? ''; + final cursorPosition = selection.end.offset; + + // Find the trigger symbol position by searching backwards from cursor + int atSymbolPosition = -1; + for (int i = cursorPosition - 1; i >= 0; i--) { + if (text[i] == MentionType.toStr(widget.mentionType)) { + atSymbolPosition = i; + break; + } + } + + if (atSymbolPosition == -1) return; // No trigger found + + // Calculate length from trigger to cursor + final lengthToReplace = cursorPosition - atSymbolPosition; + transaction.replaceText( node, - selection.start.offset - 1, - length, + atSymbolPosition, // Start exactly from trigger + lengthToReplace, // Replace everything including trigger ' ', attributes: { MentionBlockKeys.mention: { From 01d17e4cec5bbad8d26fbb2fa26cbced607b9304 Mon Sep 17 00:00:00 2001 From: Talha Date: Fri, 8 Nov 2024 19:48:55 +0000 Subject: [PATCH 17/20] add L10n strings --- .../html_editor/components/mention_list.dart | 17 +++++++++-------- app/lib/l10n/app_en.arb | 1 + 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/app/lib/common/widgets/html_editor/components/mention_list.dart b/app/lib/common/widgets/html_editor/components/mention_list.dart index 819d74d2a158..fbf69f33779a 100644 --- a/app/lib/common/widgets/html_editor/components/mention_list.dart +++ b/app/lib/common/widgets/html_editor/components/mention_list.dart @@ -77,19 +77,20 @@ class _MentionHandlerState extends ConsumerState { padding: const EdgeInsets.all(8.0), child: Text( widget.mentionType == MentionType.user - ? L10n.of(context).foundUsers - : 'Rooms', + ? L10n.of(context).users + : L10n.of(context).chats, ), ); Widget _buildMenuList(Map suggestions) { + final String notFound = widget.mentionType == MentionType.user + ? L10n.of(context).noUserFoundTitle + : L10n.of(context).noChatsFound; return Flexible( child: suggestions.isEmpty - ? const Padding( - padding: EdgeInsets.all(16.0), - child: Text( - 'No results found', - ), + ? Padding( + padding: const EdgeInsets.all(16.0), + child: Text(notFound), ) : ListView.builder( shrinkWrap: true, @@ -161,7 +162,7 @@ class _MentionHandlerState extends ConsumerState { if (cursorPosition > 0 && text[cursorPosition - 1] == MentionType.toStr(widget.mentionType)) { - widget.onDismiss(); // Dismiss menu when @ is deleted + widget.onDismiss(); // Dismiss menu when is deleted } widget.editorState.deleteBackward(); } else { diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 675d6de0c781..b1dc7f099b20 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -1314,6 +1314,7 @@ "@usedTimes": {}, "userAddedToBlockList": "{user} added to block list. UI might take a bit too update", "@userAddedToBlockList": {}, + "users": "Users", "usersfoundDirectory": "Users found in public directory", "@usersfoundDirectory": {}, "username": "Username", From 4e172c0b76e8dee892bdca2a2f0e0a14c6a9af14 Mon Sep 17 00:00:00 2001 From: Talha Date: Fri, 8 Nov 2024 20:29:54 +0000 Subject: [PATCH 18/20] make inline mentions prettier --- .../html_editor/components/mention_block.dart | 62 +++++++------------ .../html_editor/components/mention_item.dart | 45 +++++++------- .../html_editor/components/mention_list.dart | 22 ++++++- .../html_editor/components/mention_menu.dart | 2 +- 4 files changed, 64 insertions(+), 67 deletions(-) diff --git a/app/lib/common/widgets/html_editor/components/mention_block.dart b/app/lib/common/widgets/html_editor/components/mention_block.dart index e1224a1b5d4c..f47b8504fb97 100644 --- a/app/lib/common/widgets/html_editor/components/mention_block.dart +++ b/app/lib/common/widgets/html_editor/components/mention_block.dart @@ -21,41 +21,26 @@ class MentionBlock extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final String type = mention[MentionBlockKeys.type]; + final String displayName = mention[MentionBlockKeys.displayName]; + final String mentionId = type == 'user' + ? mention[MentionBlockKeys.userId] + : mention[MentionBlockKeys.roomId]; + final avatarInfo = type == 'user' + ? ref.watch( + memberAvatarInfoProvider((roomId: userRoomId, userId: mentionId)), + ) + : ref.watch(roomAvatarInfoProvider(mentionId)); + final options = type == 'user' + ? AvatarOptions.DM(avatarInfo, size: 8) + : AvatarOptions(avatarInfo, size: 16); - switch (type) { - case 'user': - final String userId = mention[MentionBlockKeys.userId]; - - final String displayName = mention[MentionBlockKeys.displayName]; - - final avatarInfo = ref.watch( - memberAvatarInfoProvider((roomId: userRoomId, userId: userId)), - ); - - return _mentionContent( - context: context, - mentionId: userId, - displayName: displayName, - avatar: avatarInfo, - ref: ref, - ); - case 'room': - final String roomId = mention[MentionBlockKeys.roomId]; - - final String displayName = mention[MentionBlockKeys.displayName]; - - final avatarInfo = ref.watch(roomAvatarInfoProvider(roomId)); - - return _mentionContent( - context: context, - mentionId: roomId, - displayName: displayName, - avatar: avatarInfo, - ref: ref, - ); - default: - return const SizedBox.shrink(); - } + return _mentionContent( + context: context, + mentionId: mentionId, + displayName: displayName, + avatarOptions: options, + ref: ref, + ); } Widget _mentionContent({ @@ -63,7 +48,7 @@ class MentionBlock extends ConsumerWidget { required String mentionId, required String displayName, required WidgetRef ref, - required AvatarInfo avatar, + required AvatarOptions avatarOptions, }) { final desktopPlatforms = [ TargetPlatform.linux, @@ -80,12 +65,7 @@ class MentionBlock extends ConsumerWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - ActerAvatar( - options: AvatarOptions( - avatar, - size: 16, - ), - ), + ActerAvatar(options: avatarOptions), const SizedBox(width: 4), Text(name, style: Theme.of(context).textTheme.bodyMedium), ], diff --git a/app/lib/common/widgets/html_editor/components/mention_item.dart b/app/lib/common/widgets/html_editor/components/mention_item.dart index 8d5771c8734a..1e89f366a368 100644 --- a/app/lib/common/widgets/html_editor/components/mention_item.dart +++ b/app/lib/common/widgets/html_editor/components/mention_item.dart @@ -1,41 +1,42 @@ +import 'package:acter/common/widgets/html_editor/models/mention_type.dart'; +import 'package:acter_avatar/acter_avatar.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -class MentionItem extends ConsumerWidget { +class MentionItem extends StatelessWidget { const MentionItem({ super.key, - required this.userId, + required this.mentionId, + required this.mentionType, required this.displayName, + required this.avatarOptions, required this.isSelected, required this.onTap, }); - final String userId; + final String mentionId; + final MentionType mentionType; final String displayName; + final AvatarOptions avatarOptions; final bool isSelected; final VoidCallback onTap; @override - Widget build(BuildContext context, WidgetRef ref) { - return InkWell( - onTap: onTap, - child: Container( - height: 60, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - color: isSelected ? Theme.of(context).colorScheme.primary : null, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - displayName.isNotEmpty ? displayName : userId, - style: Theme.of(context).textTheme.bodyMedium, - ), - if (displayName.isNotEmpty) - Text(userId, style: Theme.of(context).textTheme.labelMedium), - ], + Widget build(BuildContext context) { + return Container( + height: 60, + color: isSelected ? Theme.of(context).colorScheme.primary : null, + child: ListTile( + onTap: onTap, + contentPadding: const EdgeInsets.symmetric(horizontal: 12), + leading: ActerAvatar(options: avatarOptions), + title: Text( + displayName.isNotEmpty ? displayName : mentionId, + style: Theme.of(context).textTheme.bodyMedium, ), + subtitle: displayName.isNotEmpty + ? Text(mentionId, style: Theme.of(context).textTheme.labelMedium) + : null, ), ); } diff --git a/app/lib/common/widgets/html_editor/components/mention_list.dart b/app/lib/common/widgets/html_editor/components/mention_list.dart index fbf69f33779a..e8bb182cefb7 100644 --- a/app/lib/common/widgets/html_editor/components/mention_list.dart +++ b/app/lib/common/widgets/html_editor/components/mention_list.dart @@ -1,4 +1,6 @@ +import 'package:acter/common/providers/room_providers.dart'; import 'package:acter/common/widgets/html_editor/components/mention_item.dart'; +import 'package:acter_avatar/acter_avatar.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:acter/common/widgets/html_editor/models/mention_block_keys.dart'; import 'package:acter/common/widgets/html_editor/models/mention_type.dart'; @@ -86,6 +88,7 @@ class _MentionHandlerState extends ConsumerState { final String notFound = widget.mentionType == MentionType.user ? L10n.of(context).noUserFoundTitle : L10n.of(context).noChatsFound; + return Flexible( child: suggestions.isEmpty ? Padding( @@ -97,13 +100,26 @@ class _MentionHandlerState extends ConsumerState { controller: _scrollController, itemCount: suggestions.length, itemBuilder: (context, index) { - final userId = suggestions.keys.elementAt(index); + final mentionId = suggestions.keys.elementAt(index); final displayName = suggestions.values.elementAt(index); + final avatar = widget.mentionType == MentionType.user + ? ref.watch( + memberAvatarInfoProvider( + (roomId: widget.roomId, userId: mentionId), + ), + ) + : ref.watch(roomAvatarInfoProvider(mentionId)); + final options = widget.mentionType == MentionType.user + ? AvatarOptions.DM(avatar, size: 18) + : AvatarOptions(avatar, size: 28); + return MentionItem( - userId: userId, + mentionId: mentionId, + mentionType: widget.mentionType, displayName: displayName, + avatarOptions: options, isSelected: index == _selectedIndex, - onTap: () => _selectItem(userId, displayName), + onTap: () => _selectItem(mentionId, displayName), ); }, ), diff --git a/app/lib/common/widgets/html_editor/components/mention_menu.dart b/app/lib/common/widgets/html_editor/components/mention_menu.dart index 340a505466c0..13d59c14a1c5 100644 --- a/app/lib/common/widgets/html_editor/components/mention_menu.dart +++ b/app/lib/common/widgets/html_editor/components/mention_menu.dart @@ -52,7 +52,7 @@ class MentionMenu { left: position.dx + 20, // Align with left edge of input // Position above input with some padding bottom: 70, - width: size.width, // Match input width + width: size.width * 0.75, child: Material( elevation: 8, // Add some elevation for better visibility borderRadius: BorderRadius.circular(8), From cd28e6751207cbd52aa4fb0c9f8128b6484ee46f Mon Sep 17 00:00:00 2001 From: Talha Date: Sun, 10 Nov 2024 21:51:20 +0000 Subject: [PATCH 19/20] keep keyboard events only listenable for desktop version --- .../html_editor/components/mention_item.dart | 9 ++++- .../html_editor/components/mention_list.dart | 36 ++++++++++--------- .../html_editor/components/mention_menu.dart | 11 ++---- 3 files changed, 31 insertions(+), 25 deletions(-) diff --git a/app/lib/common/widgets/html_editor/components/mention_item.dart b/app/lib/common/widgets/html_editor/components/mention_item.dart index 1e89f366a368..6c0cca35d0c0 100644 --- a/app/lib/common/widgets/html_editor/components/mention_item.dart +++ b/app/lib/common/widgets/html_editor/components/mention_item.dart @@ -1,3 +1,4 @@ +import 'package:acter/common/utils/constants.dart'; import 'package:acter/common/widgets/html_editor/models/mention_type.dart'; import 'package:acter_avatar/acter_avatar.dart'; import 'package:flutter/material.dart'; @@ -23,10 +24,16 @@ class MentionItem extends StatelessWidget { @override Widget build(BuildContext context) { + final isDesktop = desktopPlatforms.contains(Theme.of(context).platform); + return Container( height: 60, - color: isSelected ? Theme.of(context).colorScheme.primary : null, + // selection color is only for desktop with keyboard navigation + color: (isSelected && isDesktop) + ? Theme.of(context).colorScheme.primary + : null, child: ListTile( + dense: true, onTap: onTap, contentPadding: const EdgeInsets.symmetric(horizontal: 12), leading: ActerAvatar(options: avatarOptions), diff --git a/app/lib/common/widgets/html_editor/components/mention_list.dart b/app/lib/common/widgets/html_editor/components/mention_list.dart index e8bb182cefb7..ac397f779b72 100644 --- a/app/lib/common/widgets/html_editor/components/mention_list.dart +++ b/app/lib/common/widgets/html_editor/components/mention_list.dart @@ -1,4 +1,5 @@ import 'package:acter/common/providers/room_providers.dart'; +import 'package:acter/common/utils/constants.dart'; import 'package:acter/common/widgets/html_editor/components/mention_item.dart'; import 'package:acter_avatar/acter_avatar.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; @@ -17,14 +18,12 @@ class MentionList extends ConsumerStatefulWidget { required this.roomId, required this.mentionType, required this.onDismiss, - required this.onSelectionUpdate, }); final EditorState editorState; final String roomId; final MentionType mentionType; final VoidCallback onDismiss; - final VoidCallback onSelectionUpdate; @override ConsumerState createState() => _MentionHandlerState(); @@ -46,6 +45,7 @@ class _MentionHandlerState extends ConsumerState { @override void dispose() { + widget.onDismiss(); _scrollController.dispose(); _focusNode.dispose(); super.dispose(); @@ -53,26 +53,31 @@ class _MentionHandlerState extends ConsumerState { @override Widget build(BuildContext context) { + final isDesktop = desktopPlatforms.contains(Theme.of(context).platform); // All suggestions list final suggestions = ref .watch(mentionSuggestionsProvider((widget.roomId, widget.mentionType))); if (suggestions == null) { return ErrorWidget(L10n.of(context).loadingFailed); } - - return Focus( - focusNode: _focusNode, - onKeyEvent: (node, event) => _handleKeyEvent(node, event, suggestions), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _buildMenuHeader(), - const Divider(height: 1, endIndent: 5, indent: 5), - const SizedBox(height: 8), - _buildMenuList(suggestions), - ], - ), + final menuWidget = Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildMenuHeader(), + const Divider(height: 1, endIndent: 5, indent: 5), + const SizedBox(height: 8), + _buildMenuList(suggestions), + ], ); + if (isDesktop) { + // we only want to listen keyboard events on desktop + return Focus( + focusNode: _focusNode, + onKeyEvent: (node, event) => _handleKeyEvent(node, event, suggestions), + child: menuWidget, + ); + } + return menuWidget; } Widget _buildMenuHeader() => Padding( @@ -194,7 +199,6 @@ class _MentionHandlerState extends ConsumerState { !HardwareKeyboard.instance.isAltPressed && !HardwareKeyboard.instance.isMetaPressed || !HardwareKeyboard.instance.isShiftPressed) { - widget.onSelectionUpdate(); widget.editorState.insertTextAtCurrentSelection(event.character!); return KeyEventResult.handled; } diff --git a/app/lib/common/widgets/html_editor/components/mention_menu.dart b/app/lib/common/widgets/html_editor/components/mention_menu.dart index 13d59c14a1c5..6b2dda2c4950 100644 --- a/app/lib/common/widgets/html_editor/components/mention_menu.dart +++ b/app/lib/common/widgets/html_editor/components/mention_menu.dart @@ -20,18 +20,14 @@ class MentionMenu { bool selectionChangedByMenu = false; void dismiss() { - if (_menuEntry != null) { - editorState.service.keyboardService?.enable(); - editorState.service.scrollService?.enable(); - keepEditorFocusNotifier.decrease(); - } + editorState.service.keyboardService?.enable(); + editorState.service.scrollService?.enable(); + keepEditorFocusNotifier.decrease(); _menuEntry?.remove(); _menuEntry = null; } - void _onSelectionUpdate() => selectionChangedByMenu = true; - void show() { WidgetsBinding.instance.addPostFrameCallback((_) => _show()); } @@ -68,7 +64,6 @@ class MentionMenu { editorState: editorState, roomId: roomId, onDismiss: dismiss, - onSelectionUpdate: _onSelectionUpdate, mentionType: mentionType, ), ), From 081b08d10a9a35dd10fa18df4e2811469b92a02a Mon Sep 17 00:00:00 2001 From: Talha Date: Mon, 25 Nov 2024 23:21:08 +0500 Subject: [PATCH 20/20] Feedback review: use more stringent checks for hardware keyboard prescence and code improvements --- .changes/2332-chat-ng-editor.md | 2 +- .../html_editor/components/mention_block.dart | 48 +++++++++++-------- .../html_editor/components/mention_item.dart | 7 ++- .../html_editor/components/mention_list.dart | 34 +++++-------- .../widgets/html_editor/html_editor.dart | 19 +++----- .../models/mention_attributes.dart | 13 +++++ .../models/mention_block_keys.dart | 8 ---- .../html_editor/models/mention_type.dart | 3 ++ .../services/mention_shortcuts.dart | 8 ++-- 9 files changed, 70 insertions(+), 72 deletions(-) create mode 100644 app/lib/common/widgets/html_editor/models/mention_attributes.dart delete mode 100644 app/lib/common/widgets/html_editor/models/mention_block_keys.dart diff --git a/.changes/2332-chat-ng-editor.md b/.changes/2332-chat-ng-editor.md index 2302ad742572..26a65e8e998a 100644 --- a/.changes/2332-chat-ng-editor.md +++ b/.changes/2332-chat-ng-editor.md @@ -1 +1 @@ -- Chat-NG now supports Appflowy editor with proper markdown support. Some/Other features might be disabled or limited for now. +[Labs] Chat-NG now offers a fresh new WYSIWYG editor. More features upcoming. diff --git a/app/lib/common/widgets/html_editor/components/mention_block.dart b/app/lib/common/widgets/html_editor/components/mention_block.dart index f47b8504fb97..b9b4594c42ed 100644 --- a/app/lib/common/widgets/html_editor/components/mention_block.dart +++ b/app/lib/common/widgets/html_editor/components/mention_block.dart @@ -1,38 +1,44 @@ import 'package:acter/common/providers/room_providers.dart'; -import 'package:acter/common/widgets/html_editor/models/mention_block_keys.dart'; +import 'package:acter/common/widgets/html_editor/models/mention_attributes.dart'; +import 'package:acter/common/widgets/html_editor/models/mention_type.dart'; import 'package:acter_avatar/acter_avatar.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; class MentionBlock extends ConsumerWidget { + final MentionAttributes mentionAttributes; + final String userRoomId; + final Node node; + final int index; + const MentionBlock({ super.key, required this.node, required this.index, - required this.mention, + required this.mentionAttributes, required this.userRoomId, }); - final Map mention; - final String userRoomId; - final Node node; - final int index; @override Widget build(BuildContext context, WidgetRef ref) { - final String type = mention[MentionBlockKeys.type]; - final String displayName = mention[MentionBlockKeys.displayName]; - final String mentionId = type == 'user' - ? mention[MentionBlockKeys.userId] - : mention[MentionBlockKeys.roomId]; - final avatarInfo = type == 'user' - ? ref.watch( - memberAvatarInfoProvider((roomId: userRoomId, userId: mentionId)), - ) - : ref.watch(roomAvatarInfoProvider(mentionId)); - final options = type == 'user' - ? AvatarOptions.DM(avatarInfo, size: 8) - : AvatarOptions(avatarInfo, size: 16); + final MentionType type = mentionAttributes.type; + final String? displayName = mentionAttributes.displayName; + final String mentionId = mentionAttributes.mentionId; + + final avatarInfo = switch (type) { + MentionType.user => ref.watch( + memberAvatarInfoProvider( + (roomId: userRoomId, userId: mentionId), + ), + ), + MentionType.room => ref.watch(roomAvatarInfoProvider(mentionId)), + }; + + final options = switch (type) { + MentionType.user => AvatarOptions.DM(avatarInfo, size: 8), + MentionType.room => AvatarOptions(avatarInfo, size: 16), + }; return _mentionContent( context: context, @@ -46,16 +52,16 @@ class MentionBlock extends ConsumerWidget { Widget _mentionContent({ required BuildContext context, required String mentionId, - required String displayName, required WidgetRef ref, required AvatarOptions avatarOptions, + String? displayName, }) { final desktopPlatforms = [ TargetPlatform.linux, TargetPlatform.macOS, TargetPlatform.windows, ]; - final name = displayName.isNotEmpty ? displayName : mentionId; + final name = displayName ?? mentionId; final mentionContentWidget = Container( padding: const EdgeInsets.symmetric(horizontal: 12), decoration: BoxDecoration( diff --git a/app/lib/common/widgets/html_editor/components/mention_item.dart b/app/lib/common/widgets/html_editor/components/mention_item.dart index 6c0cca35d0c0..5f0d6cb76a77 100644 --- a/app/lib/common/widgets/html_editor/components/mention_item.dart +++ b/app/lib/common/widgets/html_editor/components/mention_item.dart @@ -1,4 +1,3 @@ -import 'package:acter/common/utils/constants.dart'; import 'package:acter/common/widgets/html_editor/models/mention_type.dart'; import 'package:acter_avatar/acter_avatar.dart'; import 'package:flutter/material.dart'; @@ -24,12 +23,12 @@ class MentionItem extends StatelessWidget { @override Widget build(BuildContext context) { - final isDesktop = desktopPlatforms.contains(Theme.of(context).platform); + final hasKeyboard = + MediaQuery.of(context).navigationMode == NavigationMode.directional; return Container( height: 60, - // selection color is only for desktop with keyboard navigation - color: (isSelected && isDesktop) + color: (isSelected && hasKeyboard) ? Theme.of(context).colorScheme.primary : null, child: ListTile( diff --git a/app/lib/common/widgets/html_editor/components/mention_list.dart b/app/lib/common/widgets/html_editor/components/mention_list.dart index ac397f779b72..7549bbcc5b77 100644 --- a/app/lib/common/widgets/html_editor/components/mention_list.dart +++ b/app/lib/common/widgets/html_editor/components/mention_list.dart @@ -1,9 +1,8 @@ import 'package:acter/common/providers/room_providers.dart'; -import 'package:acter/common/utils/constants.dart'; import 'package:acter/common/widgets/html_editor/components/mention_item.dart'; import 'package:acter_avatar/acter_avatar.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:acter/common/widgets/html_editor/models/mention_block_keys.dart'; +import 'package:acter/common/widgets/html_editor/models/mention_attributes.dart'; import 'package:acter/common/widgets/html_editor/models/mention_type.dart'; import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart'; import 'package:flutter/material.dart'; @@ -53,7 +52,6 @@ class _MentionHandlerState extends ConsumerState { @override Widget build(BuildContext context) { - final isDesktop = desktopPlatforms.contains(Theme.of(context).platform); // All suggestions list final suggestions = ref .watch(mentionSuggestionsProvider((widget.roomId, widget.mentionType))); @@ -69,15 +67,12 @@ class _MentionHandlerState extends ConsumerState { _buildMenuList(suggestions), ], ); - if (isDesktop) { - // we only want to listen keyboard events on desktop - return Focus( - focusNode: _focusNode, - onKeyEvent: (node, event) => _handleKeyEvent(node, event, suggestions), - child: menuWidget, - ); - } - return menuWidget; + + return KeyboardListener( + focusNode: _focusNode, + onKeyEvent: (event) => _handleKeyEvent(event, suggestions), + child: menuWidget, + ); } Widget _buildMenuHeader() => Padding( @@ -132,7 +127,6 @@ class _MentionHandlerState extends ConsumerState { } KeyEventResult _handleKeyEvent( - FocusNode node, KeyEvent event, Map suggestions, ) { @@ -230,6 +224,7 @@ class _MentionHandlerState extends ConsumerState { // Calculate length from trigger to cursor final lengthToReplace = cursorPosition - atSymbolPosition; + final mentionsKey = MentionType.toStr(widget.mentionType); transaction.replaceText( node, @@ -237,14 +232,11 @@ class _MentionHandlerState extends ConsumerState { lengthToReplace, // Replace everything including trigger ' ', attributes: { - MentionBlockKeys.mention: { - MentionBlockKeys.type: widget.mentionType.name, - if (widget.mentionType == MentionType.user) - MentionBlockKeys.userId: id - else - MentionBlockKeys.roomId: id, - MentionBlockKeys.displayName: displayName, - }, + mentionsKey: MentionAttributes( + type: widget.mentionType, + mentionId: id, + displayName: displayName, + ), }, ); diff --git a/app/lib/common/widgets/html_editor/html_editor.dart b/app/lib/common/widgets/html_editor/html_editor.dart index caccf294a3a2..b40b9c62c6f6 100644 --- a/app/lib/common/widgets/html_editor/html_editor.dart +++ b/app/lib/common/widgets/html_editor/html_editor.dart @@ -4,8 +4,7 @@ import 'package:acter/common/extensions/options.dart'; import 'package:acter/common/toolkit/buttons/primary_action_button.dart'; import 'package:acter/common/utils/constants.dart'; import 'package:acter/common/widgets/html_editor/components/mention_block.dart'; -import 'package:acter/common/widgets/html_editor/models/mention_block_keys.dart'; -import 'package:acter/common/widgets/html_editor/models/mention_type.dart'; +import 'package:acter/common/widgets/html_editor/models/mention_attributes.dart'; import 'package:acter/common/widgets/html_editor/services/mention_shortcuts.dart'; import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; @@ -408,25 +407,19 @@ class HtmlEditorState extends State { } final roomId = widget.roomId; // Inline Mentions - final mention = - attributes[MentionBlockKeys.mention] as Map?; + final mention = attributes.entries + .firstWhere((e) => e.value is MentionAttributes) + .value as MentionAttributes?; if (mention != null && roomId != null) { - final type = mention[MentionBlockKeys.type]; return WidgetSpan( alignment: PlaceholderAlignment.middle, style: after.style, child: MentionBlock( - key: ValueKey( - switch (type) { - MentionType.user => mention[MentionBlockKeys.userId], - MentionType.room => mention[MentionBlockKeys.roomId], - _ => MentionBlockKeys.mention, - }, - ), + key: ValueKey(mention.mentionId), userRoomId: roomId, node: node, index: index, - mention: mention, + mentionAttributes: mention, ), ); } diff --git a/app/lib/common/widgets/html_editor/models/mention_attributes.dart b/app/lib/common/widgets/html_editor/models/mention_attributes.dart new file mode 100644 index 000000000000..7b420d0ebd9b --- /dev/null +++ b/app/lib/common/widgets/html_editor/models/mention_attributes.dart @@ -0,0 +1,13 @@ +import 'package:acter/common/widgets/html_editor/models/mention_type.dart'; + +class MentionAttributes { + final String mentionId; + final String? displayName; + final MentionType type; + + const MentionAttributes({ + required this.mentionId, + required this.type, + this.displayName, + }); +} diff --git a/app/lib/common/widgets/html_editor/models/mention_block_keys.dart b/app/lib/common/widgets/html_editor/models/mention_block_keys.dart deleted file mode 100644 index 10ba6133869d..000000000000 --- a/app/lib/common/widgets/html_editor/models/mention_block_keys.dart +++ /dev/null @@ -1,8 +0,0 @@ -class MentionBlockKeys { - const MentionBlockKeys._(); - static const mention = 'mention'; - static const type = 'type'; // MentionType, String - static const userId = 'user_id'; - static const roomId = 'room_id'; - static const displayName = ''; -} diff --git a/app/lib/common/widgets/html_editor/models/mention_type.dart b/app/lib/common/widgets/html_editor/models/mention_type.dart index a4429d160619..9c21b2828549 100644 --- a/app/lib/common/widgets/html_editor/models/mention_type.dart +++ b/app/lib/common/widgets/html_editor/models/mention_type.dart @@ -1,3 +1,6 @@ +const String userMentionChar = '@'; +const String roomMentionChar = '#'; + enum MentionType { user, room; diff --git a/app/lib/common/widgets/html_editor/services/mention_shortcuts.dart b/app/lib/common/widgets/html_editor/services/mention_shortcuts.dart index f5bb6bb3b59f..6c20e139b21d 100644 --- a/app/lib/common/widgets/html_editor/services/mention_shortcuts.dart +++ b/app/lib/common/widgets/html_editor/services/mention_shortcuts.dart @@ -9,24 +9,24 @@ List mentionShortcuts( ) { return [ CharacterShortcutEvent( - character: '@', + character: userMentionChar, handler: (editorState) => _handleMentionTrigger( context: context, editorState: editorState, type: MentionType.user, roomId: roomId, ), - key: '@', + key: userMentionChar, ), CharacterShortcutEvent( - character: '#', + character: roomMentionChar, handler: (editorState) => _handleMentionTrigger( context: context, editorState: editorState, type: MentionType.room, roomId: roomId, ), - key: '#', + key: roomMentionChar, ), ]; }