Skip to content

@-mention autocomplete UI #207

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Jul 20, 2023
4 changes: 2 additions & 2 deletions lib/model/autocomplete.dart
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ class MentionAutocompleteView extends ChangeNotifier {
}
}
}
return results;
return results; // TODO sort for most relevant first
}
}

Expand Down Expand Up @@ -331,7 +331,7 @@ class AutocompleteDataCache {
}
}

abstract class MentionAutocompleteResult {}
sealed class MentionAutocompleteResult {}

class UserMentionAutocompleteResult extends MentionAutocompleteResult {
UserMentionAutocompleteResult({required this.userId});
Expand Down
2 changes: 1 addition & 1 deletion lib/model/compose.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<int, User>? 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}' : ''}**';
}
Expand Down
174 changes: 174 additions & 0 deletions lib/widgets/autocomplete.dart
Original file line number Diff line number Diff line change
@@ -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<ComposeAutocomplete> createState() => _ComposeAutocompleteState();
}

class _ComposeAutocompleteState extends State<ComposeAutocomplete> {
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<MentionAutocompleteResult> _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<MentionAutocompleteResult>(
textEditingController: widget.controller,
focusNode: widget.focusNode,
optionsBuilder: (_) => _resultsToDisplay,
optionsViewOpenDirection: OptionsViewOpenDirection.up,
// RawAutocomplete passes these when it calls optionsViewBuilder:
// AutocompleteOnSelected<T> onSelected,
// Iterable<T> 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:
// <https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/autocomplete.20UI/near/1599994>)
fieldViewBuilder: (context, _, __, ___) => widget.fieldViewBuilder(context),
);
}
}
68 changes: 16 additions & 52 deletions lib/widgets/compose_box.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -263,7 +263,7 @@ class ComposeContentController extends ComposeController<ContentValidationError>
}
}

class _ContentInput extends StatefulWidget {
class _ContentInput extends StatelessWidget {
const _ContentInput({
required this.narrow,
required this.controller,
Expand All @@ -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;
Expand All @@ -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,
);
}),
));
}
}

Expand Down
2 changes: 1 addition & 1 deletion test/model/compose_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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', () {
Expand Down
Loading