diff --git a/.changes/2332-chat-ng-editor.md b/.changes/2332-chat-ng-editor.md new file mode 100644 index 000000000000..26a65e8e998a --- /dev/null +++ b/.changes/2332-chat-ng-editor.md @@ -0,0 +1 @@ +[Labs] Chat-NG now offers a fresh new WYSIWYG editor. More features upcoming. 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..b9b4594c42ed --- /dev/null +++ b/app/lib/common/widgets/html_editor/components/mention_block.dart @@ -0,0 +1,98 @@ +import 'package:acter/common/providers/room_providers.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.mentionAttributes, + required this.userRoomId, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + 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, + mentionId: mentionId, + displayName: displayName, + avatarOptions: options, + ref: ref, + ); + } + + Widget _mentionContent({ + required BuildContext context, + required String mentionId, + required WidgetRef ref, + required AvatarOptions avatarOptions, + String? displayName, + }) { + final desktopPlatforms = [ + TargetPlatform.linux, + TargetPlatform.macOS, + TargetPlatform.windows, + ]; + final name = 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), + const SizedBox(width: 4), + Text(name, style: Theme.of(context).textTheme.bodyMedium), + ], + ), + ); + + 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_item.dart b/app/lib/common/widgets/html_editor/components/mention_item.dart new file mode 100644 index 000000000000..5f0d6cb76a77 --- /dev/null +++ b/app/lib/common/widgets/html_editor/components/mention_item.dart @@ -0,0 +1,49 @@ +import 'package:acter/common/widgets/html_editor/models/mention_type.dart'; +import 'package:acter_avatar/acter_avatar.dart'; +import 'package:flutter/material.dart'; + +class MentionItem extends StatelessWidget { + const MentionItem({ + super.key, + required this.mentionId, + required this.mentionType, + required this.displayName, + required this.avatarOptions, + required this.isSelected, + required this.onTap, + }); + + final String mentionId; + final MentionType mentionType; + final String displayName; + final AvatarOptions avatarOptions; + final bool isSelected; + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final hasKeyboard = + MediaQuery.of(context).navigationMode == NavigationMode.directional; + + return Container( + height: 60, + color: (isSelected && hasKeyboard) + ? Theme.of(context).colorScheme.primary + : null, + child: ListTile( + dense: true, + 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 new file mode 100644 index 000000000000..7549bbcc5b77 --- /dev/null +++ b/app/lib/common/widgets/html_editor/components/mention_list.dart @@ -0,0 +1,281 @@ +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_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'; +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, + }); + + final EditorState editorState; + final String roomId; + final MentionType mentionType; + final VoidCallback onDismiss; + + @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() { + widget.onDismiss(); + _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); + } + final menuWidget = Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildMenuHeader(), + const Divider(height: 1, endIndent: 5, indent: 5), + const SizedBox(height: 8), + _buildMenuList(suggestions), + ], + ); + + return KeyboardListener( + focusNode: _focusNode, + onKeyEvent: (event) => _handleKeyEvent(event, suggestions), + child: menuWidget, + ); + } + + Widget _buildMenuHeader() => Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + widget.mentionType == MentionType.user + ? 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 + ? Padding( + padding: const EdgeInsets.all(16.0), + child: Text(notFound), + ) + : ListView.builder( + shrinkWrap: true, + controller: _scrollController, + itemCount: suggestions.length, + itemBuilder: (context, 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( + mentionId: mentionId, + mentionType: widget.mentionType, + displayName: displayName, + avatarOptions: options, + isSelected: index == _selectedIndex, + onTap: () => _selectItem(mentionId, displayName), + ); + }, + ), + ); + } + + KeyEventResult _handleKeyEvent( + 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: + 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 = selection, + ); + } + return KeyEventResult.handled; + + default: + if (event.character != null && + !HardwareKeyboard.instance.isAltPressed && + !HardwareKeyboard.instance.isMetaPressed || + !HardwareKeyboard.instance.isShiftPressed) { + 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); + 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; + final mentionsKey = MentionType.toStr(widget.mentionType); + + transaction.replaceText( + node, + atSymbolPosition, // Start exactly from trigger + lengthToReplace, // Replace everything including trigger + ' ', + attributes: { + mentionsKey: MentionAttributes( + type: widget.mentionType, + mentionId: id, + 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 new file mode 100644 index 000000000000..6b2dda2c4950 --- /dev/null +++ b/app/lib/common/widgets/html_editor/components/mention_menu.dart @@ -0,0 +1,79 @@ +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'; + +class MentionMenu { + MentionMenu({ + required this.context, + required this.editorState, + required this.roomId, + required this.mentionType, + }); + + final BuildContext context; + final EditorState editorState; + final String roomId; + final MentionType mentionType; + + OverlayEntry? _menuEntry; + bool selectionChangedByMenu = false; + + void dismiss() { + editorState.service.keyboardService?.enable(); + editorState.service.scrollService?.enable(); + keepEditorFocusNotifier.decrease(); + + _menuEntry?.remove(); + _menuEntry = null; + } + + void show() { + WidgetsBinding.instance.addPostFrameCallback((_) => _show()); + } + + 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( + // 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 * 0.75, + child: Material( + elevation: 8, // Add some elevation for better visibility + borderRadius: BorderRadius.circular(8), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: dismiss, + child: Container( + constraints: BoxConstraints( + maxHeight: 200, // Limit maximum height + maxWidth: size.width, // Match input width + ), + child: MentionList( + editorState: editorState, + roomId: roomId, + onDismiss: dismiss, + mentionType: mentionType, + ), + ), + ), + ), + ), + ); + + Overlay.of(context).insert(_menuEntry!); + editorState.service.keyboardService?.disable(showCursor: true); + editorState.service.scrollService?.disable(); + } +} diff --git a/app/lib/common/widgets/html_editor.dart b/app/lib/common/widgets/html_editor/html_editor.dart similarity index 69% rename from app/lib/common/widgets/html_editor.dart rename to app/lib/common/widgets/html_editor/html_editor.dart index d5bdb38fd7a3..b40b9c62c6f6 100644 --- a/app/lib/common/widgets/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_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'; import 'package:flutter/material.dart'; @@ -97,7 +100,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 +108,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 +116,7 @@ class HtmlEditor extends StatefulWidget { const HtmlEditor({ super.key, + this.roomId, this.editorState, this.onSave, this.onChanged, @@ -121,6 +126,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 +192,9 @@ class HtmlEditorState extends State { @override void dispose() { + editorState.selectionNotifier.dispose(); editorScrollController.dispose(); + _changeListener?.cancel(); super.dispose(); } @@ -272,10 +280,17 @@ 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!), + ], + commandShortcutEvents: [...standardCommandShortcutEvents], ), ), ); @@ -293,39 +308,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!), + ], + ), + ), + ), + ], ), ); } @@ -342,6 +370,8 @@ class HtmlEditorState extends State { .bodySmall .expect('bodySmall style not available'), ), + textSpanDecorator: + widget.roomId != null ? customizeAttributeDecorator : null, ); } @@ -357,6 +387,50 @@ class HtmlEditorState extends State { .bodySmall .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.entries + .firstWhere((e) => e.value is MentionAttributes) + .value as MentionAttributes?; + if (mention != null && roomId != null) { + return WidgetSpan( + alignment: PlaceholderAlignment.middle, + style: after.style, + child: MentionBlock( + key: ValueKey(mention.mentionId), + userRoomId: roomId, + node: node, + index: index, + mentionAttributes: mention, + ), + ); + } + + return defaultTextSpanDecoratorForAttribute( + context, + node, + index, + text, + before, + after, ); } } 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_type.dart b/app/lib/common/widgets/html_editor/models/mention_type.dart new file mode 100644 index 000000000000..9c21b2828549 --- /dev/null +++ b/app/lib/common/widgets/html_editor/models/mention_type.dart @@ -0,0 +1,12 @@ +const String userMentionChar = '@'; +const String roomMentionChar = '#'; + +enum MentionType { + user, + room; + + 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 new file mode 100644 index 000000000000..6c20e139b21d --- /dev/null +++ b/app/lib/common/widgets/html_editor/services/mention_shortcuts.dart @@ -0,0 +1,64 @@ +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'; + +List mentionShortcuts( + BuildContext context, + String roomId, +) { + return [ + CharacterShortcutEvent( + character: userMentionChar, + handler: (editorState) => _handleMentionTrigger( + context: context, + editorState: editorState, + type: MentionType.user, + roomId: roomId, + ), + key: userMentionChar, + ), + CharacterShortcutEvent( + character: roomMentionChar, + handler: (editorState) => _handleMentionTrigger( + context: context, + editorState: editorState, + type: MentionType.room, + roomId: roomId, + ), + key: roomMentionChar, + ), + ]; +} + +Future _handleMentionTrigger({ + required BuildContext context, + required EditorState editorState, + required MentionType type, + required String roomId, +}) async { + final selection = editorState.selection; + if (selection == null) return false; + + if (!selection.isCollapsed) { + await editorState.deleteSelection(selection); + } + // Insert the trigger character + await editorState.insertTextAtPosition( + MentionType.toStr(type), + position: selection.start, + ); + + // Show menu + if (context.mounted) { + final menu = MentionMenu( + context: context, + editorState: editorState, + roomId: roomId, + mentionType: type, + ); + + menu.show(); + } + return true; +} 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/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..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, ), - ], - ), + ), + ], ), ), ); @@ -285,7 +267,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}'); }); } @@ -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( @@ -779,7 +761,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(); }); @@ -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 29849c213251..74b0a782490d 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/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/providers/chat_room_messages_provider.dart b/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart index e8efaa8aadfe..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,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/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'; import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart'; import 'package:flutter/widgets.dart'; import 'package:logging/logging.dart'; @@ -49,3 +53,39 @@ final renderableChatMessagesProvider = return _supportedTypes.contains(msg.eventItem()?.eventType()); }).toList(); }); + +final mentionSuggestionsProvider = + StateProvider.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 null; +}); 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/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, diff --git a/app/lib/features/events/pages/create_event_page.dart b/app/lib/features/events/pages/create_event_page.dart index bf7b8f605030..bbdb6e4082b3 100644 --- a/app/lib/features/events/pages/create_event_page.dart +++ b/app/lib/features/events/pages/create_event_page.dart @@ -4,7 +4,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/features/events/model/keys.dart'; import 'package:acter/features/events/utils/events_utils.dart'; diff --git a/app/lib/features/news/pages/add_news_page.dart b/app/lib/features/news/pages/add_news_page.dart index 8cdd9c10704f..0291534d6b4a 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/news/actions/submit_news.dart'; import 'package:acter/features/news/model/keys.dart'; import 'package:acter/features/news/model/news_slide_model.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'; diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 4854eb508932..0bd1f3ab0634 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -1333,6 +1333,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", 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()), 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"