diff --git a/packages/stream_chat_flutter/lib/src/extension.dart b/packages/stream_chat_flutter/lib/src/extension.dart index 19bf4523..9551a40d 100644 --- a/packages/stream_chat_flutter/lib/src/extension.dart +++ b/packages/stream_chat_flutter/lib/src/extension.dart @@ -1,4 +1,5 @@ import 'package:characters/characters.dart'; +import 'package:diacritic/diacritic.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/src/emoji/emoji.dart'; @@ -24,6 +25,12 @@ extension StringExtension on String { final characters = trim().characters; return characters.every(_emojiChars.contains); } + + /// Removes accents and diacritics from the given String. + String get diacriticsInsensitive => removeDiacritics(this); + + /// Levenshtein distance between this and [t]. + int levenshteinDistance(String t) => levenshtein(this, t); } /// List extension @@ -170,3 +177,49 @@ extension IconButtonX on IconButton { icon: icon ?? this.icon, ); } + +/// Extensions on List +extension UserListX on List { + /// It does an search on a list of [User] and returns users with + /// `id` or `name` containing the [query]. + /// + /// Results are returned sorted by their edit distance from the + /// searched string, distance is calculated using the [levenshtein] algorithm. + List search(String query) { + String normalize(String input) => input.toLowerCase().diacriticsInsensitive; + + final normalizedQuery = normalize(query); + + final matchingUsers = {}; // User:lDistance + + for (final user in this) { + final normalizedId = normalize(user.id); + final normalizedUserName = normalize(user.name); + final lDistance = normalizedUserName.levenshteinDistance(normalizedQuery); + final containsId = normalizedId.contains(normalizedQuery); + final containsName = normalizedUserName.contains(normalizedQuery); + if (lDistance < 3 || containsId || containsName) { + matchingUsers[user] = lDistance; + } + } + + final entries = matchingUsers.entries.toList(growable: false) + ..sort((prev, curr) { + bool containsQuery(User user) => + normalize(user.id).contains(normalizedQuery) || + normalize(user.name).contains(normalizedQuery); + + final containsInPrev = containsQuery(prev.key); + final containsInCurr = containsQuery(curr.key); + + if (containsInPrev && !containsInCurr) { + return -1; + } else if (!containsInPrev && containsInCurr) { + return 1; + } + return prev.value.compareTo(curr.value); + }); + + return entries.map((e) => e.key).toList(growable: false); + } +} diff --git a/packages/stream_chat_flutter/lib/src/mention_tile.dart b/packages/stream_chat_flutter/lib/src/mention_tile.dart index d9469215..c302ccb8 100644 --- a/packages/stream_chat_flutter/lib/src/mention_tile.dart +++ b/packages/stream_chat_flutter/lib/src/mention_tile.dart @@ -4,6 +4,7 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// This widget is used for showing user tiles for mentions /// Use [title], [subtitle], [leading], [trailing] for /// substituting widgets in respective positions +@Deprecated('Use `UserMentionTile` instead. Will be removed in future release') class MentionTile extends StatelessWidget { /// Constructor for creating a [MentionTile] widget const MentionTile( diff --git a/packages/stream_chat_flutter/lib/src/mentions_overlay.dart b/packages/stream_chat_flutter/lib/src/mentions_overlay.dart deleted file mode 100644 index 63c43585..00000000 --- a/packages/stream_chat_flutter/lib/src/mentions_overlay.dart +++ /dev/null @@ -1,100 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// Overlay for displaying users that can be mentioned -class MentionsOverlay extends StatelessWidget { - /// Constructor for creating a [MentionsOverlay] - const MentionsOverlay({ - required this.query, - required this.onMentionResult, - required this.size, - required this.channel, - this.mentionsTileBuilder, - Key? key, - }) : super(key: key); - - /// The size of the overlay - final Size size; - - /// Query for searching users - final String query; - - /// The channel to search for users - final Channel channel; - - /// Callback called when a user is selected - final ValueChanged onMentionResult; - - /// Customize the tile for the mentions overlay - final MentionTileBuilder? mentionsTileBuilder; - - @override - Widget build(BuildContext context) { - final _streamChatTheme = StreamChatTheme.of(context); - - Future>? queryMembers; - - if (query.isNotEmpty) { - queryMembers = channel - .queryMembers(filter: Filter.autoComplete('name', query)) - .then((res) => res.members); - } - - final members = channel.state?.members - .where((m) => m.user?.name.toLowerCase().contains(query) == true) - .toList() ?? - []; - - if (members.isEmpty) { - return const SizedBox(); - } - - return Card( - margin: const EdgeInsets.all(8), - elevation: 2, - color: _streamChatTheme.colorTheme.barsBg, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - clipBehavior: Clip.hardEdge, - child: Container( - constraints: BoxConstraints.loose(size), - decoration: BoxDecoration( - color: _streamChatTheme.colorTheme.barsBg, - ), - child: FutureBuilder>( - future: queryMembers ?? Future.value(members), - initialData: members, - builder: (context, snapshot) => ListView( - padding: const EdgeInsets.all(0), - shrinkWrap: true, - children: [ - const SizedBox( - height: 8, - ), - ...snapshot.data! - .where((it) => it.user != null) - .map( - (m) => Material( - color: _streamChatTheme.colorTheme.barsBg, - child: InkWell( - onTap: () { - onMentionResult(m.user); - }, - child: mentionsTileBuilder != null - ? mentionsTileBuilder!(context, m) - : MentionTile(m, key: ValueKey(m.user?.id)), - ), - ), - ) - .toList(), - const SizedBox( - height: 8, - ), - ], - ), - ), - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_input.dart b/packages/stream_chat_flutter/lib/src/message_input.dart index 73a211ff..97d8b2ac 100644 --- a/packages/stream_chat_flutter/lib/src/message_input.dart +++ b/packages/stream_chat_flutter/lib/src/message_input.dart @@ -16,7 +16,7 @@ import 'package:stream_chat_flutter/src/emoji/emoji.dart'; import 'package:stream_chat_flutter/src/emoji_overlay.dart'; import 'package:stream_chat_flutter/src/extension.dart'; import 'package:stream_chat_flutter/src/media_list_view.dart'; -import 'package:stream_chat_flutter/src/mentions_overlay.dart'; +import 'package:stream_chat_flutter/src/user_mentions_overlay.dart'; import 'package:stream_chat_flutter/src/message_list_view.dart'; import 'package:stream_chat_flutter/src/overlays.dart'; import 'package:stream_chat_flutter/src/quoted_message_widget.dart'; @@ -194,6 +194,7 @@ class MessageInput extends StatefulWidget { this.attachmentButtonBuilder, this.commandButtonBuilder, this.customOverlays = const [], + this.mentionAllAppUsers = false, }) : assert( initialMessage == null || editMessage == null, "Can't provide both `initialMessage` and `editMessage`", @@ -304,6 +305,11 @@ class MessageInput extends StatefulWidget { /// calling `.copyWith`. final ActionButtonBuilder? commandButtonBuilder; + /// When enabled mentions search users across the entire app. + /// + /// Defaults to false. + final bool mentionAllAppUsers; + @override MessageInputState createState() => MessageInputState(); @@ -1147,16 +1153,15 @@ class MessageInputState extends State { // ignore: cast_nullable_to_non_nullable final renderObject = context.findRenderObject() as RenderBox; - return MentionsOverlay( + return UserMentionsOverlay( + query: query, + mentionAllAppUsers: widget.mentionAllAppUsers, + client: StreamChat.of(context).client, channel: StreamChannel.of(context).channel, size: Size(renderObject.size.width - 16, 400), - query: query, - onMentionResult: (user) { - if (user != null) { - _mentionedUsers.add(user); - } - - splits[splits.length - 1] = user!.name; + onMentionUserTap: (user) { + _mentionedUsers.add(user); + splits[splits.length - 1] = user.name; final rejoin = splits.join('@'); textEditingController.value = TextEditingValue( @@ -1168,9 +1173,7 @@ class MessageInputState extends State { ), ); _onChangedDebounced.cancel(); - setState(() { - _showMentionsOverlay = false; - }); + setState(() => _showMentionsOverlay = false); }, ); } diff --git a/packages/stream_chat_flutter/lib/src/user_mention_tile.dart b/packages/stream_chat_flutter/lib/src/user_mention_tile.dart new file mode 100644 index 00000000..ff9e145b --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/user_mention_tile.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// This widget is used for showing user tiles for mentions +/// Use [title], [subtitle], [leading], [trailing] for +/// substituting widgets in respective positions +class UserMentionTile extends StatelessWidget { + /// Constructor for creating a [UserMentionTile] widget + const UserMentionTile( + this.user, { + Key? key, + this.title, + this.subtitle, + this.leading, + this.trailing, + }) : super(key: key); + + /// User to display in the tile + final User user; + + /// Widget to display as title + final Widget? title; + + /// Widget to display below [title] + final Widget? subtitle; + + /// Widget at the start of the tile + final Widget? leading; + + /// Widget at the end of tile + final Widget? trailing; + + @override + Widget build(BuildContext context) { + final chatThemeData = StreamChatTheme.of(context); + return SizedBox( + height: 56, + child: Row( + children: [ + const SizedBox( + width: 16, + ), + leading ?? + UserAvatar( + user: user, + constraints: BoxConstraints.tight(const Size(40, 40)), + ), + const SizedBox(width: 8), + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + title ?? + Text( + user.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: chatThemeData.textTheme.bodyBold, + ), + const SizedBox(height: 2), + subtitle ?? + Text( + '@${user.id}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: chatThemeData.textTheme.footnoteBold.copyWith( + color: chatThemeData.colorTheme.textLowEmphasis, + ), + ), + ], + ), + ), + ), + trailing ?? + Padding( + padding: const EdgeInsets.only( + right: 18, + left: 8, + ), + child: StreamSvgIcon.mentions( + color: chatThemeData.colorTheme.accentPrimary, + ), + ), + ], + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/user_mentions_overlay.dart b/packages/stream_chat_flutter/lib/src/user_mentions_overlay.dart new file mode 100644 index 00000000..e3830231 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/user_mentions_overlay.dart @@ -0,0 +1,191 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/extension.dart'; +import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; +import 'package:stream_chat_flutter/src/user_mention_tile.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +/// Builder function for building a mention tile. +/// +/// Use [UserMentionTile] for the default implementation. +typedef MentionTileBuilder = Widget Function( + BuildContext context, + User user, +); + +/// Overlay for displaying users that can be mentioned. +class UserMentionsOverlay extends StatefulWidget { + /// Constructor for creating a [UserMentionsOverlay]. + UserMentionsOverlay({ + Key? key, + required this.query, + required this.channel, + required this.size, + this.client, + this.limit = 10, + this.mentionAllAppUsers = false, + this.mentionsTileBuilder, + this.onMentionUserTap, + }) : assert( + channel.state != null, + 'Channel ${channel.cid} is not yet initialized', + ), + assert( + !mentionAllAppUsers || (mentionAllAppUsers && client != null), + 'StreamChatClient is required in order to use mentionAllAppUsers', + ), + super(key: key); + + /// Query for searching users. + final String query; + + /// Limit applied on user search results. + final int limit; + + /// The size of the overlay. + final Size size; + + /// The channel to search for users. + final Channel channel; + + /// The client to search for users in case [mentionAllAppUsers] is True. + final StreamChatClient? client; + + /// When enabled mentions search users across the entire app. + /// + /// Defaults to false. + final bool mentionAllAppUsers; + + /// Customize the tile for the mentions overlay. + final MentionTileBuilder? mentionsTileBuilder; + + /// Callback called when a user is selected. + final void Function(User user)? onMentionUserTap; + + @override + _UserMentionsOverlayState createState() => _UserMentionsOverlayState(); +} + +class _UserMentionsOverlayState extends State { + late Future> userMentionsFuture; + + @override + void initState() { + super.initState(); + userMentionsFuture = queryMentions(widget.query); + } + + @override + void didUpdateWidget(covariant UserMentionsOverlay oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.channel != oldWidget.channel || + widget.query != oldWidget.query || + widget.mentionAllAppUsers != oldWidget.mentionAllAppUsers || + widget.limit != oldWidget.limit) { + userMentionsFuture = queryMentions(widget.query); + } + } + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + return Card( + margin: const EdgeInsets.all(8), + elevation: 2, + color: theme.colorTheme.barsBg, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + clipBehavior: Clip.hardEdge, + child: Container( + constraints: BoxConstraints.loose(widget.size), + decoration: BoxDecoration(color: theme.colorTheme.barsBg), + child: FutureBuilder>( + future: userMentionsFuture, + builder: (context, snapshot) { + if (snapshot.hasError) return const Offstage(); + if (!snapshot.hasData) return const Offstage(); + final users = snapshot.data!; + return ListView.builder( + padding: const EdgeInsets.all(0), + shrinkWrap: true, + itemCount: users.length, + itemBuilder: (context, index) { + final user = users[index]; + return Material( + color: theme.colorTheme.barsBg, + child: InkWell( + onTap: () => widget.onMentionUserTap?.call(user), + child: widget.mentionsTileBuilder?.call(context, user) ?? + UserMentionTile(user), + ), + ); + }, + ); + }, + ), + ), + ); + } + + List get membersAndWatchers { + final state = widget.channel.state!; + return { + ...state.watchers, + ...state.members.map((it) => it.user), + }.whereType().toList(growable: false); + } + + Future> queryMentions(String query) async { + if (widget.mentionAllAppUsers) { + return _queryUsers(query); + } + + var channelState = widget.channel.state; + + channelState = channelState!; + final members = channelState.members; + + // By default, we return maximum 100 members via queryChannels api call. + // Thus it is safe to assume, that if number of members in channel.state + // is < 100, then all the members are already available on client side + // and we don't need to make any api call to queryMembers endpoint. + if (members.length < 100) { + final matchingUsers = membersAndWatchers.search(query); + return matchingUsers.toList(growable: false); + } + + final result = await _queryMembers(query); + return result + .map((it) => it.user) + .whereType() + .toList(growable: false); + } + + Future> _queryMembers(String query) async { + final response = await widget.channel.queryMembers( + pagination: PaginationParams(limit: widget.limit), + filter: query.isEmpty + ? const Filter.empty() + : Filter.autoComplete('name', query), + ); + return response.members; + } + + Future> _queryUsers(String query) async { + assert( + widget.client != null, + 'StreamChatClient is required in order to query all app users', + ); + final response = await widget.client!.queryUsers( + pagination: PaginationParams(limit: widget.limit), + filter: query.isEmpty + ? const Filter.empty() + : Filter.or([ + Filter.autoComplete('id', query), + Filter.autoComplete('name', query), + ]), + sort: [const SortOption('id', direction: SortOption.ASC)], + ); + return response.users; + } +} diff --git a/packages/stream_chat_flutter/lib/src/utils.dart b/packages/stream_chat_flutter/lib/src/utils.dart index 4dc9fee7..9b85e8c2 100644 --- a/packages/stream_chat_flutter/lib/src/utils.dart +++ b/packages/stream_chat_flutter/lib/src/utils.dart @@ -1,10 +1,11 @@ import 'dart:async'; +import 'dart:math' as math; import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/extension.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'package:url_launcher/url_launcher.dart'; -import 'package:stream_chat_flutter/src/extension.dart'; /// Launch URL Future launchURL(BuildContext context, String url) async { @@ -388,3 +389,39 @@ class Tuple2 { @override int get hashCode => item1.hashCode ^ item2.hashCode; } + +/// Levenshtein algorithm implementation based on: +/// http://en.wikipedia.org/wiki/Levenshtein_distance#Iterative_with_two_matrix_rows +int levenshtein(String s, String t, {bool caseSensitive = true}) { + if (!caseSensitive) { + // ignore: parameter_assignments + s = s.toLowerCase(); + // ignore: parameter_assignments + t = t.toLowerCase(); + } + if (s == t) return 0; + if (s.isEmpty) return t.length; + if (t.isEmpty) return s.length; + + final v0 = List.filled(t.length + 1, 0); + final v1 = List.filled(t.length + 1, 0); + + for (var i = 0; i < t.length + 1; i < i++) { + v0[i] = i; + } + + for (var i = 0; i < s.length; i++) { + v1[0] = i + 1; + + for (var j = 0; j < t.length; j++) { + final cost = (s[i] == t[j]) ? 0 : 1; + v1[j + 1] = math.min(v1[j] + 1, math.min(v0[j + 1] + 1, v0[j] + cost)); + } + + for (var j = 0; j < t.length + 1; j++) { + v0[j] = v1[j]; + } + } + + return v1[t.length]; +} diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index 42188fa0..0447fc68 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -44,5 +44,6 @@ export 'src/unread_indicator.dart'; export 'src/user_avatar.dart'; export 'src/user_item.dart'; export 'src/user_list_view.dart'; +export 'src/user_mention_tile.dart'; export 'src/utils.dart'; export 'src/visible_footnote.dart'; diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index 54888873..a792d67b 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -14,6 +14,7 @@ dependencies: characters: ^1.1.0 chewie: ^1.2.0 collection: ^1.15.0 + diacritic: ^0.1.3 dio: ^4.0.0 ezanimation: ^0.5.0 file_picker: ^3.0.1 diff --git a/packages/stream_chat_flutter/test/src/extension_test.dart b/packages/stream_chat_flutter/test/src/extension_test.dart new file mode 100644 index 00000000..9d8d92e4 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/extension_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/src/extension.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +void main() { + group('List.search', () { + test('should work fine', () { + final tommaso = User(id: 'tommaso', name: 'Tommaso'); + final thierry = User(id: 'thierry', name: 'Thierry'); + final users = [tommaso, thierry]; + + final a = users.search('Tom'); + expect(users.search('Tom'), [tommaso]); + expect(users.search('Thier'), [thierry]); + }); + + test('should search using UpperCased', () { + final tommaso = User(id: 'tommaso', name: 'Tommaso'); + final thierry = User(id: 'thierry', name: 'Thierry'); + final users = [tommaso, thierry]; + + expect(users.search('tom'), [tommaso]); + expect(users.search('thier'), [thierry]); + }); + + test('should search by .id or .name', () { + final user1 = User(id: 'searchingThis'); + final user2 = User(id: 'x', name: 'searchingThis'); + + expect([user1].search('sear'), [user1]); + expect([user2].search('sear'), [user2]); + }); + + test('should search transliterated', () { + final tommaso = User(id: 'tommaso', name: 'Tommaso'); + final thierry = User(id: 'thierry', name: 'Thierry'); + final users = [tommaso, thierry]; + + expect(users.search('tóm'), [tommaso]); + expect(users.search('thíer'), [thierry]); + }); + + test('search and sorted by distance', () { + final tommaso = User(id: 'tommaso', name: 'Tommaso'); + final tomas = User(id: 'tomas', name: 'Tomas'); + final users = [tommaso, tomas]; + + expect(users.search('tom'), [tomas, tommaso]); + }); + + test('should work fine with cyrillic diacritics', () { + final petyo = User(id: '42', name: 'Петьо'); + final anastasia = User(id: '13', name: 'Анастасiя'); + final dmitriy = User(id: '99', name: 'Дмитрий'); + final users = [petyo, anastasia, dmitriy]; + + expect(users.search('petyo'), []); + expect(users.search('Пе'), [petyo]); + expect(users.search('Ана'), [anastasia]); + expect(users.search('Дмитри'), [dmitriy]); + expect(users.search('Дмитрии'), [dmitriy]); + }); + + test('should work fine with french diacritics', () { + final user = User(id: 'fra', name: 'françois'); + + expect([user].search('françois'), [user]); + expect([user].search('franc'), [user]); + }); + }); +} diff --git a/packages/stream_chat_flutter_core/lib/src/stream_channel.dart b/packages/stream_chat_flutter_core/lib/src/stream_channel.dart index fb7b79f2..443529ac 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_channel.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_channel.dart @@ -152,13 +152,9 @@ class StreamChannelState extends State { int limit = 20, }) { if (direction == QueryDirection.top) { - return _queryTopMessages( - limit: limit, - ); + return _queryTopMessages(limit: limit); } - return _queryBottomMessages( - limit: limit, - ); + return _queryBottomMessages(limit: limit); } /// Calls [channel.getReplies] updating [queryMessage] stream @@ -311,6 +307,20 @@ class StreamChannelState extends State { return message; } + /// Query channel members. + Future> queryMembers({ + Filter? filter, + List? sort, + PaginationParams? pagination, + }) async { + final response = await channel.queryMembers( + filter: filter, + sort: sort, + pagination: pagination, + ); + return response.members; + } + /// Reloads the channel with latest message Future reloadChannel() => _queryAtMessage(before: 30);