diff --git a/lib/model/autocomplete.dart b/lib/model/autocomplete.dart index c58e639b48..f06fb5bc85 100644 --- a/lib/model/autocomplete.dart +++ b/lib/model/autocomplete.dart @@ -267,7 +267,7 @@ class MentionAutocompleteView extends ChangeNotifier { } } } - return results; + return results; // TODO sort for most relevant first } } @@ -331,7 +331,7 @@ class AutocompleteDataCache { } } -abstract class MentionAutocompleteResult {} +sealed class MentionAutocompleteResult {} class UserMentionAutocompleteResult extends MentionAutocompleteResult { UserMentionAutocompleteResult({required this.userId}); diff --git a/lib/model/compose.dart b/lib/model/compose.dart index c5510583c4..cec844e162 100644 --- a/lib/model/compose.dart +++ b/lib/model/compose.dart @@ -184,7 +184,7 @@ Uri narrowLink(PerAccountStore store, Narrow narrow, {int? nearMessageId}) { /// through all users; avoid it in performance-sensitive codepaths. String mention(User user, {bool silent = false, Map? users}) { bool includeUserId = users == null - || users.values.takeWhile((u) => u.fullName == user.fullName).take(2).length == 2; + || users.values.where((u) => u.fullName == user.fullName).take(2).length == 2; return '@${silent ? '_' : ''}**${user.fullName}${includeUserId ? '|${user.userId}' : ''}**'; } diff --git a/lib/widgets/autocomplete.dart b/lib/widgets/autocomplete.dart new file mode 100644 index 0000000000..74bd341ab3 --- /dev/null +++ b/lib/widgets/autocomplete.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; + +import 'store.dart'; +import '../model/autocomplete.dart'; +import '../model/compose.dart'; +import '../model/narrow.dart'; +import 'compose_box.dart'; + +class ComposeAutocomplete extends StatefulWidget { + const ComposeAutocomplete({ + super.key, + required this.narrow, + required this.controller, + required this.focusNode, + required this.fieldViewBuilder, + }); + + /// The message list's narrow. + final Narrow narrow; + + final ComposeContentController controller; + final FocusNode focusNode; + final WidgetBuilder fieldViewBuilder; + + @override + State createState() => _ComposeAutocompleteState(); +} + +class _ComposeAutocompleteState extends State { + MentionAutocompleteView? _viewModel; // TODO different autocomplete view types + + void _composeContentChanged() { + final newAutocompleteIntent = widget.controller.autocompleteIntent(); + if (newAutocompleteIntent != null) { + final store = PerAccountStoreWidget.of(context); + _viewModel ??= MentionAutocompleteView.init(store: store, narrow: widget.narrow) + ..addListener(_viewModelChanged); + _viewModel!.query = newAutocompleteIntent.query; + } else { + if (_viewModel != null) { + _viewModel!.dispose(); // removes our listener + _viewModel = null; + _resultsToDisplay = []; + } + } + } + + @override + void initState() { + super.initState(); + widget.controller.addListener(_composeContentChanged); + } + + @override + void didUpdateWidget(covariant ComposeAutocomplete oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller != oldWidget.controller) { + oldWidget.controller.removeListener(_composeContentChanged); + widget.controller.addListener(_composeContentChanged); + } + } + + @override + void dispose() { + widget.controller.removeListener(_composeContentChanged); + _viewModel?.dispose(); // removes our listener + super.dispose(); + } + + List _resultsToDisplay = []; + + void _viewModelChanged() { + setState(() { + _resultsToDisplay = _viewModel!.results.toList(); + }); + } + + void _onTapOption(MentionAutocompleteResult option) { + // Probably the same intent that brought up the option that was tapped. + // If not, it still shouldn't be off by more than the time it takes + // to compute the autocomplete results, which we do asynchronously. + final intent = widget.controller.autocompleteIntent(); + if (intent == null) { + return; // Shrug. + } + + final store = PerAccountStoreWidget.of(context); + final String replacementString; + switch (option) { + case UserMentionAutocompleteResult(:var userId): + // TODO(i18n) language-appropriate space character; check active keyboard? + // (maybe handle centrally in `widget.controller`) + replacementString = '${mention(store.users[userId]!, silent: intent.query.silent, users: store.users)} '; + case WildcardMentionAutocompleteResult(): + replacementString = '[unimplemented]'; // TODO + case UserGroupMentionAutocompleteResult(): + replacementString = '[unimplemented]'; // TODO + } + + widget.controller.value = intent.textEditingValue.replaced( + TextRange( + start: intent.syntaxStart, + end: intent.textEditingValue.selection.end), + replacementString, + ); + } + + Widget _buildItem(BuildContext _, int index) { + final option = _resultsToDisplay[index]; + String label; + switch (option) { + case UserMentionAutocompleteResult(:var userId): + // TODO avatar + label = PerAccountStoreWidget.of(context).users[userId]!.fullName; + case WildcardMentionAutocompleteResult(): + label = '[unimplemented]'; // TODO + case UserGroupMentionAutocompleteResult(): + label = '[unimplemented]'; // TODO + } + return InkWell( + onTap: () { + _onTapOption(option); + }, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text(label))); + } + + @override + Widget build(BuildContext context) { + return RawAutocomplete( + textEditingController: widget.controller, + focusNode: widget.focusNode, + optionsBuilder: (_) => _resultsToDisplay, + optionsViewOpenDirection: OptionsViewOpenDirection.up, + // RawAutocomplete passes these when it calls optionsViewBuilder: + // AutocompleteOnSelected onSelected, + // Iterable options, + // + // We ignore them: + // - `onSelected` would cause some behavior we don't want, + // such as moving the cursor to the end of the compose-input text. + // - `options` would be needed if we were delegating to RawAutocomplete + // the work of creating the list of options. We're not; the + // `optionsBuilder` we pass is just a function that returns + // _resultsToDisplay, which is computed with lots of help from + // MentionAutocompleteView. + optionsViewBuilder: (context, _, __) { + return Align( + alignment: Alignment.bottomLeft, + child: Material( + elevation: 4.0, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 300), // TODO not hard-coded + child: ListView.builder( + padding: EdgeInsets.zero, + shrinkWrap: true, + itemCount: _resultsToDisplay.length, + itemBuilder: _buildItem)))); + }, + // RawAutocomplete passes these when it calls fieldViewBuilder: + // TextEditingController textEditingController, + // FocusNode focusNode, + // VoidCallback onFieldSubmitted, + // + // We ignore them. For the first two, we've opted out of having + // RawAutocomplete create them for us; we create and manage them ourselves. + // The third isn't helpful; it lets us opt into behavior we don't actually + // want (see discussion: + // ) + fieldViewBuilder: (context, _, __, ___) => widget.fieldViewBuilder(context), + ); + } +} diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 356a2c028f..e91f8d1f2f 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -6,10 +6,10 @@ import 'package:image_picker/image_picker.dart'; import '../api/model/model.dart'; import '../api/route/messages.dart'; -import '../model/autocomplete.dart'; import '../model/compose.dart'; import '../model/narrow.dart'; import '../model/store.dart'; +import 'autocomplete.dart'; import 'dialog.dart'; import 'store.dart'; @@ -263,7 +263,7 @@ class ComposeContentController extends ComposeController } } -class _ContentInput extends StatefulWidget { +class _ContentInput extends StatelessWidget { const _ContentInput({ required this.narrow, required this.controller, @@ -276,49 +276,6 @@ class _ContentInput extends StatefulWidget { final FocusNode focusNode; final String hintText; - @override - State<_ContentInput> createState() => _ContentInputState(); -} - -class _ContentInputState extends State<_ContentInput> { - MentionAutocompleteView? _mentionAutocompleteView; // TODO different autocomplete view types - - _changed() { - final newAutocompleteIntent = widget.controller.autocompleteIntent(); - if (newAutocompleteIntent != null) { - final store = PerAccountStoreWidget.of(context); - _mentionAutocompleteView ??= MentionAutocompleteView.init( - store: store, narrow: widget.narrow); - _mentionAutocompleteView!.query = newAutocompleteIntent.query; - } else { - if (_mentionAutocompleteView != null) { - _mentionAutocompleteView!.dispose(); - _mentionAutocompleteView = null; - } - } - } - - @override - void initState() { - super.initState(); - widget.controller.addListener(_changed); - } - - @override - void didUpdateWidget(covariant _ContentInput oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.controller != oldWidget.controller) { - oldWidget.controller.removeListener(_changed); - widget.controller.addListener(_changed); - } - } - - @override - void dispose() { - widget.controller.removeListener(_changed); - super.dispose(); - } - @override Widget build(BuildContext context) { ColorScheme colorScheme = Theme.of(context).colorScheme; @@ -332,13 +289,20 @@ class _ContentInputState extends State<_ContentInput> { // TODO constrain this adaptively (i.e. not hard-coded 200) maxHeight: 200, ), - child: TextField( - controller: widget.controller, - focusNode: widget.focusNode, - style: TextStyle(color: colorScheme.onSurface), - decoration: InputDecoration.collapsed(hintText: widget.hintText), - maxLines: null, - ))); + child: ComposeAutocomplete( + narrow: narrow, + controller: controller, + focusNode: focusNode, + fieldViewBuilder: (context) { + return TextField( + controller: controller, + focusNode: focusNode, + style: TextStyle(color: colorScheme.onSurface), + decoration: InputDecoration.collapsed(hintText: hintText), + maxLines: null, + ); + }), + )); } } diff --git a/test/model/compose_test.dart b/test/model/compose_test.dart index 57e7167515..4f22334778 100644 --- a/test/model/compose_test.dart +++ b/test/model/compose_test.dart @@ -311,7 +311,7 @@ hello }); test('`users` passed; has two users with same fullName', () { final store = eg.store(); - store.addUsers([user, eg.user(userId: 234, fullName: user.fullName)]); + store.addUsers([user, eg.user(userId: 5), eg.user(userId: 234, fullName: user.fullName)]); check(mention(user, silent: true, users: store.users)).equals('@_**Full Name|123**'); }); test('`users` passed; user has unique fullName', () { diff --git a/test/widgets/autocomplete_test.dart b/test/widgets/autocomplete_test.dart new file mode 100644 index 0000000000..311a56ba91 --- /dev/null +++ b/test/widgets/autocomplete_test.dart @@ -0,0 +1,107 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/api/route/messages.dart'; +import 'package:zulip/model/compose.dart'; +import 'package:zulip/model/narrow.dart'; +import 'package:zulip/widgets/message_list.dart'; +import 'package:zulip/widgets/store.dart'; + +import '../api/fake_api.dart'; +import '../example_data.dart' as eg; +import '../model/binding.dart'; +import '../model/test_store.dart'; + +/// Simulates loading a [MessageListPage] and tapping to focus the compose input. +/// +/// Also adds [users] to the [PerAccountStore], +/// so they can show up in autocomplete. +Future setupToComposeInput(WidgetTester tester, { + required List users, +}) async { + addTearDown(TestZulipBinding.instance.reset); + await TestZulipBinding.instance.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + final store = await TestZulipBinding.instance.globalStore.perAccount(eg.selfAccount.id); + store.addUsers([eg.selfUser, eg.otherUser]); + store.addUsers(users); + final connection = store.connection as FakeApiConnection; + + // prepare message list data + final message = eg.dmMessage(from: eg.selfUser, to: [eg.otherUser]); + connection.prepare(json: GetMessagesResult( + anchor: message.id, + foundNewest: true, + foundOldest: true, + foundAnchor: true, + historyLimited: false, + messages: [message], + ).toJson()); + + await tester.pumpWidget( + MaterialApp( + home: GlobalStoreWidget( + child: PerAccountStoreWidget( + accountId: eg.selfAccount.id, + child: MessageListPage( + narrow: DmNarrow( + allRecipientIds: [eg.selfUser.userId, eg.otherUser.userId], + selfUserId: eg.selfUser.userId, + )))))); + + // global store, per-account store, and message list get loaded + await tester.pumpAndSettle(); + + // (hint text of compose input in a 1:1 DM) + final finder = find.widgetWithText(TextField, 'Message @${eg.otherUser.fullName}'); + check(finder.evaluate()).isNotEmpty(); + return finder; +} + +void main() { + TestZulipBinding.ensureInitialized(); + + group('ComposeAutocomplete', () { + testWidgets('options appear, disappear, and change correctly', (WidgetTester tester) async { + final user1 = eg.user(userId: 1, fullName: 'User One'); + final user2 = eg.user(userId: 2, fullName: 'User Two'); + final user3 = eg.user(userId: 3, fullName: 'User Three'); + final composeInputFinder = await setupToComposeInput(tester, users: [user1, user2, user3]); + final store = await TestZulipBinding.instance.globalStore.perAccount(eg.selfAccount.id); + + // Options are filtered correctly for query + // TODO(#226): Remove this extra edit when this bug is fixed. + await tester.enterText(composeInputFinder, 'hello @user '); + await tester.enterText(composeInputFinder, 'hello @user t'); + await tester.pumpAndSettle(); // async computation; options appear + // "User Two" and "User Three" appear, but not "User One" + check(tester.widgetList(find.text('User One'))).isEmpty(); + tester.widget(find.text('User Two')); + tester.widget(find.text('User Three')); + + // Finishing autocomplete updates compose box; causes options to disappear + await tester.tap(find.text('User Three')); + await tester.pump(); + check(tester.widget(composeInputFinder).controller!.text) + .contains(mention(user3, users: store.users)); + check(tester.widgetList(find.text('User One'))).isEmpty(); + check(tester.widgetList(find.text('User Two'))).isEmpty(); + check(tester.widgetList(find.text('User Three'))).isEmpty(); + + // Then a new autocomplete intent brings up options again + // TODO(#226): Remove this extra edit when this bug is fixed. + await tester.enterText(composeInputFinder, 'hello @user tw'); + await tester.enterText(composeInputFinder, 'hello @user two'); + await tester.pumpAndSettle(); // async computation; options appear + tester.widget(find.text('User Two')); + + // Removing autocomplete intent causes options to disappear + // TODO(#226): Remove one of these edits when this bug is fixed. + await tester.enterText(composeInputFinder, ''); + await tester.enterText(composeInputFinder, ' '); + check(tester.widgetList(find.text('User One'))).isEmpty(); + check(tester.widgetList(find.text('User Two'))).isEmpty(); + check(tester.widgetList(find.text('User Three'))).isEmpty(); + }); + }); +}