From b6d14ff7b0ea3e2ea4a5891ec4b939f976775c03 Mon Sep 17 00:00:00 2001 From: ION606 Date: Thu, 9 Apr 2026 10:51:00 -0700 Subject: [PATCH 1/2] feat: add standard markdown shortcuts (ctrl+i, ctrl+a, ctrl+b, etc) --- lib/pages/chat/chat.dart | 75 +++++++++++++++++++++++++++ lib/pages/chat/chat_emoji_picker.dart | 1 + lib/pages/chat/chat_input_row.dart | 1 + lib/pages/chat/input_bar.dart | 3 ++ 4 files changed, 80 insertions(+) diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index a89fdbc81d..b037ce45c3 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -173,6 +173,10 @@ class ChatController extends State bool showEmojiPicker = false; + int emojiPickerInitialTab = 0; + + final UndoHistoryController undoController = UndoHistoryController(); + String? get threadLastEventId { final threadId = activeThreadId; if (threadId == null) return null; @@ -308,7 +312,77 @@ class ChatController extends State ); } + void _toggleEmojiPickerTab(int tab) { + if (showEmojiPicker && emojiPickerInitialTab == tab) { + emojiPickerAction(); + } else if (showEmojiPicker) { + // Already open on a different tab — close and reopen on the new tab + setState(() { + showEmojiPicker = false; + emojiPickerInitialTab = tab; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + emojiPickerAction(); + }); + } else { + emojiPickerInitialTab = tab; + emojiPickerAction(); + } + } + + void _wrapSelectedText(String prefix, String suffix) { + final selection = sendController.selection; + final text = sendController.text; + final selectedText = selection.textInside(text); + + final replacement = '$prefix$selectedText$suffix'; + final newText = text.replaceRange(selection.start, selection.end, replacement); + final cursorOffset = selectedText.isEmpty + ? selection.start + prefix.length + : selection.start + replacement.length; + + sendController.value = TextEditingValue( + text: newText, + selection: TextSelection.collapsed(offset: cursorOffset), + ); + } + KeyEventResult _customEnterKeyHandling(FocusNode node, KeyEvent evt) { + if (evt is KeyDownEvent && + (HardwareKeyboard.instance.isControlPressed || + HardwareKeyboard.instance.isMetaPressed) && + !PlatformInfos.isMobile) { + if (evt.logicalKey == LogicalKeyboardKey.keyB) { + _wrapSelectedText('**', '**'); + return KeyEventResult.handled; + } + if (evt.logicalKey == LogicalKeyboardKey.keyI) { + _wrapSelectedText('*', '*'); + return KeyEventResult.handled; + } + if (evt.logicalKey == LogicalKeyboardKey.keyE) { + _toggleEmojiPickerTab(0); + return KeyEventResult.handled; + } + if (evt.logicalKey == LogicalKeyboardKey.keyS) { + _toggleEmojiPickerTab(1); + return KeyEventResult.handled; + } + if (evt.logicalKey == LogicalKeyboardKey.keyY) { + undoController.redo(); + return KeyEventResult.handled; + } + } + + if (evt is KeyDownEvent && + evt.logicalKey == LogicalKeyboardKey.escape) { + if (showEmojiPicker) { + hideEmojiPicker(); + inputFocus.requestFocus(); + return KeyEventResult.handled; + } + } + if (!HardwareKeyboard.instance.isShiftPressed && evt.logicalKey.keyLabel == 'Enter' && AppSettings.sendOnEnter.value) { @@ -559,6 +633,7 @@ class ChatController extends State timeline?.cancelSubscriptions(); timeline = null; inputFocus.removeListener(_inputFocusListener); + undoController.dispose(); if (currentlyTyping) room.setTyping(false); super.dispose(); } diff --git a/lib/pages/chat/chat_emoji_picker.dart b/lib/pages/chat/chat_emoji_picker.dart index 8173734c3f..40e58b208b 100644 --- a/lib/pages/chat/chat_emoji_picker.dart +++ b/lib/pages/chat/chat_emoji_picker.dart @@ -24,6 +24,7 @@ class ChatEmojiPicker extends StatelessWidget { : 0, child: controller.showEmojiPicker ? DefaultTabController( + initialIndex: controller.emojiPickerInitialTab, length: 2, child: Column( children: [ diff --git a/lib/pages/chat/chat_input_row.dart b/lib/pages/chat/chat_input_row.dart index c0a664f6a0..7e9f25a486 100644 --- a/lib/pages/chat/chat_input_row.dart +++ b/lib/pages/chat/chat_input_row.dart @@ -322,6 +322,7 @@ class ChatInputRow extends StatelessWidget { filled: false, ), onChanged: controller.onInputBarChanged, + undoController: controller.undoController, suggestionEmojis: getDefaultEmojiLocale( AppSettings.emojiSuggestionLocale.value.isNotEmpty diff --git a/lib/pages/chat/input_bar.dart b/lib/pages/chat/input_bar.dart index 22672b516d..e2fe46cfdd 100644 --- a/lib/pages/chat/input_bar.dart +++ b/lib/pages/chat/input_bar.dart @@ -28,6 +28,7 @@ class InputBar extends StatelessWidget { final bool? autofocus; final bool readOnly; final List suggestionEmojis; + final UndoHistoryController? undoController; const InputBar({ required this.room, @@ -44,6 +45,7 @@ class InputBar extends StatelessWidget { this.textInputAction, this.readOnly = false, required this.suggestionEmojis, + this.undoController, super.key, }); @@ -391,6 +393,7 @@ class InputBar extends StatelessWidget { fieldViewBuilder: (context, controller, focusNode, _) => TextField( controller: controller, focusNode: focusNode, + undoController: undoController, readOnly: readOnly, onEditingComplete: () { // To not lose focus on iOS: From 2ec9ccfca1e0034ef9a8e139bdf0f61fd91b7ba1 Mon Sep 17 00:00:00 2001 From: ION606 Date: Sat, 11 Apr 2026 08:37:46 -0700 Subject: [PATCH 2/2] test: add keyboard shortcut combination tests and remove OS lock --- .../flows/keyboard_shortcuts.dart | 51 +++++++++++++++++++ integration_test/mobile_test.dart | 2 + .../utils/fluffy_chat_tester.dart | 13 +++++ lib/pages/chat/chat.dart | 3 +- 4 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 integration_test/flows/keyboard_shortcuts.dart diff --git a/integration_test/flows/keyboard_shortcuts.dart b/integration_test/flows/keyboard_shortcuts.dart new file mode 100644 index 0000000000..7e752e1c49 --- /dev/null +++ b/integration_test/flows/keyboard_shortcuts.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../utils/fluffy_chat_tester.dart'; +import 'auth_flows.dart'; +import 'chat_flows.dart'; + +Future keyboardShortcuts(WidgetTester widgetTester) => widgetTester + .startFluffyChatTest() + .then((tester) => tester._keyboardShortcuts()); + +extension on FluffyChatTester { + Future _keyboardShortcuts() async { + await ensureLoggedIn(); + await ensureGroupChatCreated(); + await tapOn(ChatFlows.groupChatName); + + // Focus the input field and enter text + final inputField = find.byKey(const Key('chat_input_field')); + await tester.tap(inputField); + await tester.pumpAndSettle(); + await tester.enterText(inputField, 'hello'); + await tester.pumpAndSettle(); + + // Select all text with Ctrl+A + await sendKeyCombo(LogicalKeyboardKey.control, LogicalKeyboardKey.keyA); + + // Bold with Ctrl+B + await sendKeyCombo(LogicalKeyboardKey.control, LogicalKeyboardKey.keyB); + + // Verify the text field now contains **hello** + final textField = tester.widget( + find.descendant(of: inputField, matching: find.byType(EditableText)), + ); + expect(textField.controller.text, '**hello**'); + + // Clear and test italic: type new text, select, Ctrl+I + await tester.enterText(inputField, 'world'); + await tester.pumpAndSettle(); + await sendKeyCombo(LogicalKeyboardKey.control, LogicalKeyboardKey.keyA); + await sendKeyCombo(LogicalKeyboardKey.control, LogicalKeyboardKey.keyI); + expect(textField.controller.text, '*world*'); + + // Clear and test with no selection (cursor only): Ctrl+B inserts **** + await tester.enterText(inputField, ''); + await tester.pumpAndSettle(); + await sendKeyCombo(LogicalKeyboardKey.control, LogicalKeyboardKey.keyB); + expect(textField.controller.text, '****'); + } +} diff --git a/integration_test/mobile_test.dart b/integration_test/mobile_test.dart index 8da68b0add..af801ef51b 100644 --- a/integration_test/mobile_test.dart +++ b/integration_test/mobile_test.dart @@ -4,6 +4,7 @@ import 'package:integration_test/integration_test.dart'; import 'flows/auth_flows.dart'; import 'flows/basic_messaging.dart'; import 'flows/chat_flows.dart'; +import 'flows/keyboard_shortcuts.dart'; import 'flows/login_and_chat_backup.dart'; void main() { @@ -12,6 +13,7 @@ void main() { group('FluffyChat Integration Tests', () { testWidgets('Login and logout flow', loginAndChatBackup); testWidgets('Basic Messaging', basicMessaging); + testWidgets('Keyboard shortcuts', keyboardShortcuts); testWidgets('Archive chats', archiveChats); testWidgets('Final logout', finalLogout); }); diff --git a/integration_test/utils/fluffy_chat_tester.dart b/integration_test/utils/fluffy_chat_tester.dart index 03fa7da764..2ad1f6a9c2 100644 --- a/integration_test/utils/fluffy_chat_tester.dart +++ b/integration_test/utils/fluffy_chat_tester.dart @@ -1,4 +1,5 @@ import 'package:fluffychat/main.dart' as app; +import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -91,6 +92,18 @@ extension type FluffyChatTester(WidgetTester tester) { ); await tester.pumpAndSettle(); } + + Future sendKeyCombo( + LogicalKeyboardKey modifier, + LogicalKeyboardKey key, + ) async { + _print('➕ Sending ${modifier.keyLabel}+${key.keyLabel}'); + await tester.sendKeyDownEvent(modifier); + await tester.sendKeyDownEvent(key); + await tester.sendKeyUpEvent(key); + await tester.sendKeyUpEvent(modifier); + await tester.pumpAndSettle(); + } } extension on Object { diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index b037ce45c3..393f513f65 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -350,8 +350,7 @@ class ChatController extends State KeyEventResult _customEnterKeyHandling(FocusNode node, KeyEvent evt) { if (evt is KeyDownEvent && (HardwareKeyboard.instance.isControlPressed || - HardwareKeyboard.instance.isMetaPressed) && - !PlatformInfos.isMobile) { + HardwareKeyboard.instance.isMetaPressed)) { if (evt.logicalKey == LogicalKeyboardKey.keyB) { _wrapSelectedText('**', '**'); return KeyEventResult.handled;