Merge branch 'develop' into hotfix/messageListView

This commit is contained in:
Salvatore Giordano
2021-10-01 12:29:24 +02:00
committed by GitHub
27 changed files with 1377 additions and 644 deletions
@@ -2,7 +2,7 @@ name: stream_flutter_workflow
env: env:
ACTIONS_ALLOW_UNSECURE_COMMANDS: 'true' ACTIONS_ALLOW_UNSECURE_COMMANDS: 'true'
flutter_version: "2.5.0" flutter_version: "2.5.1"
on: on:
pull_request: pull_request:
@@ -104,7 +104,16 @@ Filter.autoComplete('name', 'demo')
The 'exists' filter matches values that exist, or don't exist, based on the specified boolean value. The 'exists' filter matches values that exist, or don't exist, based on the specified boolean value.
```dart ```dart
Filter.exists('name', true) Filter.exists('name')
```
#### Filter.notExists
The 'notExists' filter checks if the specified key doesn't exist. This is a simplified call to `Filter.exists`
with the value set to false.
```dart
Filter.notExists('name')
``` ```
#### Filter.contains #### Filter.contains
+11
View File
@@ -1,3 +1,14 @@
## Upcoming
✅ Added
- Added `Filter.notExists`.
🐞 Fixed
- [[#710]](https://github.com/GetStream/stream-chat-flutter/issues/710) Fixed JWT requiring using `String` as id.
- Fixed expired CDN attachment links not updating correctly.
## 3.0.0 ## 3.0.0
🛑️ Breaking Changes from `2.2.1` 🛑️ Breaking Changes from `2.2.1`
@@ -351,7 +351,7 @@ class Channel {
Future<bool> get initialized => _initializedCompleter.future; Future<bool> get initialized => _initializedCompleter.future;
final _cancelableAttachmentUploadRequest = <String, CancelToken>{}; final _cancelableAttachmentUploadRequest = <String, CancelToken>{};
final _messageAttachmentsUploadCompleter = <String, Completer>{}; final _messageAttachmentsUploadCompleter = <String, Completer<Message>>{};
/// Cancels [attachmentId] upload request. Throws exception if the request /// Cancels [attachmentId] upload request. Throws exception if the request
/// hasn't even started yet, Already completed or Already cancelled. /// hasn't even started yet, Already completed or Already cancelled.
@@ -382,12 +382,10 @@ class Channel {
String messageId, String messageId,
Iterable<String> attachmentIds, Iterable<String> attachmentIds,
) { ) {
final message = [ var message = [
...state!.messages, ...state!.messages,
...state!.threads.values.expand((messages) => messages), ...state!.threads.values.expand((messages) => messages),
].firstWhereOrNull( ].firstWhereOrNull((it) => it.id == messageId);
(it) => it.id == messageId,
);
if (message == null) { if (message == null) {
throw const StreamChatError('Error, Message not found'); throw const StreamChatError('Error, Message not found');
@@ -409,11 +407,15 @@ class Channel {
client.logger.info('Found ${attachments.length} attachments'); client.logger.info('Found ${attachments.length} attachments');
void updateAttachment(Attachment attachment) { void updateAttachment(Attachment attachment) {
final index = final index = message!.attachments.indexWhere(
message.attachments.indexWhere((it) => it.id == attachment.id); (it) => it.id == attachment.id,
);
if (index != -1) { if (index != -1) {
message.attachments[index] = attachment; final newAttachments = [...message!.attachments]..[index] = attachment;
state?.addMessage(message); final updatedMessage = message!.copyWith(attachments: newAttachments);
state?.addMessage(updatedMessage);
// updating original message for next iteration
message = message!.merge(updatedMessage);
} }
} }
@@ -476,7 +478,7 @@ class Channel {
_cancelableAttachmentUploadRequest.remove(it.id); _cancelableAttachmentUploadRequest.remove(it.id);
}); });
})).whenComplete(() { })).whenComplete(() {
if (message.attachments.every((it) => it.uploadState.isSuccess)) { if (message!.attachments.every((it) => it.uploadState.isSuccess)) {
_messageAttachmentsUploadCompleter.remove(messageId)?.complete(message); _messageAttachmentsUploadCompleter.remove(messageId)?.complete(message);
} }
}); });
@@ -1076,33 +1078,7 @@ class Channel {
} else { } else {
// remove the passed message if response does // remove the passed message if response does
// not contain message // not contain message
final oldIndex = state!.messages.indexWhere((m) => m.id == messageId); state!.removeMessage(message);
// remove regular message if present
if (oldIndex != -1) {
final oldMessage = state!.messages[oldIndex];
state!.updateChannelState(state!._channelState.copyWith(
messages: state?.messages?..remove(oldMessage),
channel: state?._channelState.channel,
));
} else {
// remove thread message if present
// also reduces total reply count
final oldMessage = state!.threads.values
.expand((messages) => messages)
.firstWhereOrNull((m) => m.id == messageId);
if (oldMessage?.parentId != null) {
final parentMessage = state!.messages.firstWhereOrNull(
(element) => element.id == oldMessage!.parentId,
);
if (parentMessage != null) {
state!.addMessage(parentMessage.copyWith(
replyCount: parentMessage.replyCount! - 1));
}
state!.updateThreadInfo(oldMessage!.parentId!,
state!.threads[oldMessage.parentId!]!..remove(oldMessage));
}
}
await _client.chatPersistenceClient?.deleteMessageById(messageId); await _client.chatPersistenceClient?.deleteMessageById(messageId);
} }
return res; return res;
@@ -1521,7 +1497,7 @@ class ChannelClientState {
} }
} }
void _checkExpiredAttachmentMessages(ChannelState channelState) { void _checkExpiredAttachmentMessages(ChannelState channelState) async {
final expiredAttachmentMessagesId = channelState.messages final expiredAttachmentMessagesId = channelState.messages
.where((m) => .where((m) =>
!_updatedMessagesIds.contains(m.id) && !_updatedMessagesIds.contains(m.id) &&
@@ -1532,20 +1508,24 @@ class ChannelClientState {
return false; return false;
} }
final uri = Uri.parse(url); final uri = Uri.parse(url);
if (uri.host != 'stream-io-cdn.com' || if (!uri.host.endsWith('stream-io-cdn.com') ||
uri.queryParameters['Expires'] == null) { uri.queryParameters['Expires'] == null) {
return false; return false;
} }
final expiration = final secondsFromEpoch =
DateTime.parse(uri.queryParameters['Expires']!); int.parse(uri.queryParameters['Expires']!);
final expiration = DateTime.fromMillisecondsSinceEpoch(
secondsFromEpoch * 1000);
return expiration.isBefore(DateTime.now()); return expiration.isBefore(DateTime.now());
}) == }) ==
true) true)
.map((e) => e.id) .map((e) => e.id)
.toList(); .toList();
if (expiredAttachmentMessagesId.isNotEmpty == true) { if (expiredAttachmentMessagesId.isNotEmpty == true) {
_channel.getMessagesById(expiredAttachmentMessagesId); await _channel._initializedCompleter.future;
_updatedMessagesIds.addAll(expiredAttachmentMessagesId); _updatedMessagesIds.addAll(expiredAttachmentMessagesId);
_channel.getMessagesById(expiredAttachmentMessagesId);
} }
} }
@@ -1704,7 +1684,7 @@ class ChannelClientState {
})); }));
} }
/// Add a message to this channel. /// Add a [message] to this [channelState].
void addMessage(Message message) { void addMessage(Message message) {
if (message.parentId == null || message.showInChannel == true) { if (message.parentId == null || message.showInChannel == true) {
final newMessages = List<Message>.from(_channelState.messages); final newMessages = List<Message>.from(_channelState.messages);
@@ -1735,6 +1715,35 @@ class ChannelClientState {
} }
} }
/// Remove a [message] from this [channelState].
void removeMessage(Message message) {
final parentId = message.parentId;
// i.e. it's a thread message
// 1. Remove the thread message
// 2. Reduce total reply count of parent message
if (parentId != null) {
final allMessages = [...messages];
final parentMessage = allMessages.firstWhereOrNull(
(it) => it.id == parentId,
);
// return if message not available in the memory
if (parentMessage == null) return;
final replyCount = parentMessage.replyCount;
// return if reply count is null or zero
if (replyCount == null || replyCount == 0) return;
addMessage(parentMessage.copyWith(replyCount: replyCount - 1));
updateThreadInfo(parentId, threads[parentId]!..remove(message));
} else {
// Remove regular message
final allMessages = [...messages];
if (allMessages.remove(message)) {
_channelState = _channelState.copyWith(messages: allMessages);
}
}
}
void _listenReadEvents() { void _listenReadEvents() {
if (_channelState.channel?.config.readEvents == false) { if (_channelState.channel?.config.readEvents == false) {
return; return;
@@ -1862,15 +1871,11 @@ class ChannelClientState {
if (newThreads.containsKey(parentId)) { if (newThreads.containsKey(parentId)) {
newThreads[parentId] = [ newThreads[parentId] = [
...newThreads[parentId]
?.where(
(newMessage) => !messages.any((m) => m.id == newMessage.id))
.toList() ??
[],
...messages, ...messages,
]; ...newThreads[parentId]!.where(
(newMessage) => !messages.any((m) => m.id == newMessage.id),
newThreads[parentId]!.sort(_sortByCreatedAt); ),
]..sort(_sortByCreatedAt);
} else { } else {
newThreads[parentId] = messages; newThreads[parentId] = messages;
} }
@@ -46,12 +46,16 @@ class Token extends Equatable {
/// Creates a [Token] instance from the provided [rawValue] if it's valid. /// Creates a [Token] instance from the provided [rawValue] if it's valid.
factory Token.fromRawValue(String rawValue) { factory Token.fromRawValue(String rawValue) {
final jwtBody = JsonWebToken.unverified(rawValue); final jwtBody = JsonWebToken.unverified(rawValue);
final userId = jwtBody.claims.getTyped<String>('user_id'); final userId = jwtBody.claims.getTyped('user_id');
assert( assert(
userId != null, userId != null,
'Invalid `token`, It should contain `user_id`', 'Invalid `token`, It should contain `user_id`',
); );
return Token._(rawValue: rawValue, userId: userId!, authType: AuthType.jwt); return Token._(
rawValue: rawValue,
userId: userId!.toString(),
authType: AuthType.jwt,
);
} }
/// The token which can be used during the development. /// The token which can be used during the development.
@@ -167,6 +167,9 @@ class Filter extends Equatable {
factory Filter.exists(String key, {bool exists = true}) => factory Filter.exists(String key, {bool exists = true}) =>
Filter._(operator: FilterOperator.exists, key: key, value: exists); Filter._(operator: FilterOperator.exists, key: key, value: exists);
/// Matches values that don't exist.
factory Filter.notExists(String key) => Filter.exists(key, exists: false);
/// Matches any list that contains the specified values /// Matches any list that contains the specified values
factory Filter.contains(String key, Object value) => factory Filter.contains(String key, Object value) =>
Filter._(operator: FilterOperator.contains, key: key, value: value); Filter._(operator: FilterOperator.contains, key: key, value: value);
@@ -334,20 +334,87 @@ void main() {
expectLater( expectLater(
// skipping first seed message list -> [] messages // skipping first seed message list -> [] messages
channel.state?.messagesStream.skip(1), channel.state?.messagesStream.skip(1),
emitsInOrder([ emitsInOrder(
[ [
isSameMessageAs( // preparing attachments to upload
message.copyWith(status: MessageSendingStatus.sending), [
matchSendingStatus: true, isSameMessageAs(
), message.copyWith(
status: MessageSendingStatus.sending,
attachments: [
...attachments.map((it) => it.copyWith(
uploadState: const UploadState.preparing()))
],
),
matchSendingStatus: true,
matchAttachments: true,
matchAttachmentsUploadState: true,
),
],
// 0th attachment is successfully uploaded
[
isSameMessageAs(
message.copyWith(
status: MessageSendingStatus.sending,
attachments: [...attachments]..[0] =
attachments[0].copyWith(
uploadState: const UploadState.success(),
),
),
matchSendingStatus: true,
matchAttachments: true,
matchAttachmentsUploadState: true,
),
],
// 0th and 1st attachment is successfully uploaded
[
isSameMessageAs(
message.copyWith(
status: MessageSendingStatus.sending,
attachments: [...attachments]
..[0] = attachments[0].copyWith(
uploadState: const UploadState.success(),
)
..[1] = attachments[1].copyWith(
uploadState: const UploadState.success(),
),
),
matchSendingStatus: true,
matchAttachments: true,
matchAttachmentsUploadState: true,
),
],
// all the attachments are successfully uploaded
[
isSameMessageAs(
message.copyWith(
status: MessageSendingStatus.sending,
attachments: [
...attachments.map((it) =>
it.copyWith(uploadState: const UploadState.success()))
],
),
matchSendingStatus: true,
matchAttachments: true,
matchAttachmentsUploadState: true,
),
],
[
isSameMessageAs(
message.copyWith(
status: MessageSendingStatus.sent,
attachments: [
...attachments.map((it) =>
it.copyWith(uploadState: const UploadState.success()))
],
),
matchSendingStatus: true,
matchAttachments: true,
matchAttachmentsUploadState: true,
),
],
], ],
[ ),
isSameMessageAs(
message.copyWith(status: MessageSendingStatus.sent),
matchSendingStatus: true,
),
],
]),
); );
final res = await channel.sendMessage(message); final res = await channel.sendMessage(message);
@@ -472,20 +539,87 @@ void main() {
expectLater( expectLater(
// skipping first seed message list -> [] messages // skipping first seed message list -> [] messages
channel.state?.messagesStream.skip(1), channel.state?.messagesStream.skip(1),
emitsInOrder([ emitsInOrder(
[ [
isSameMessageAs( // preparing attachments to upload
message.copyWith(status: MessageSendingStatus.updating), [
matchSendingStatus: true, isSameMessageAs(
), message.copyWith(
status: MessageSendingStatus.updating,
attachments: [
...attachments.map((it) => it.copyWith(
uploadState: const UploadState.preparing()))
],
),
matchSendingStatus: true,
matchAttachments: true,
matchAttachmentsUploadState: true,
),
],
// 0th attachment is successfully uploaded
[
isSameMessageAs(
message.copyWith(
status: MessageSendingStatus.updating,
attachments: [...attachments]..[0] =
attachments[0].copyWith(
uploadState: const UploadState.success(),
),
),
matchSendingStatus: true,
matchAttachments: true,
matchAttachmentsUploadState: true,
),
],
// 0th and 1st attachment is successfully uploaded
[
isSameMessageAs(
message.copyWith(
status: MessageSendingStatus.updating,
attachments: [...attachments]
..[0] = attachments[0].copyWith(
uploadState: const UploadState.success(),
)
..[1] = attachments[1].copyWith(
uploadState: const UploadState.success(),
),
),
matchSendingStatus: true,
matchAttachments: true,
matchAttachmentsUploadState: true,
),
],
// all the attachments are successfully uploaded
[
isSameMessageAs(
message.copyWith(
status: MessageSendingStatus.updating,
attachments: [
...attachments.map((it) =>
it.copyWith(uploadState: const UploadState.success()))
],
),
matchSendingStatus: true,
matchAttachments: true,
matchAttachmentsUploadState: true,
),
],
[
isSameMessageAs(
message.copyWith(
status: MessageSendingStatus.sent,
attachments: [
...attachments.map((it) =>
it.copyWith(uploadState: const UploadState.success()))
],
),
matchSendingStatus: true,
matchAttachments: true,
matchAttachmentsUploadState: true,
),
],
], ],
[ ),
isSameMessageAs(
message.copyWith(status: MessageSendingStatus.sent),
matchSendingStatus: true,
),
],
]),
); );
final res = await channel.updateMessage(message); final res = await channel.updateMessage(message);
@@ -114,7 +114,7 @@ void main() {
test('notExists', () { test('notExists', () {
const key = 'testKey'; const key = 'testKey';
final filter = Filter.exists(key, exists: false); final filter = Filter.notExists(key);
expect(filter.key, key); expect(filter.key, key);
expect(filter.value, isFalse); expect(filter.value, isFalse);
expect(filter.operator, FilterOperator.exists.rawValue); expect(filter.operator, FilterOperator.exists.rawValue);
@@ -1,6 +1,7 @@
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:dio/dio.dart' show MultipartFile; import 'package:dio/dio.dart' show MultipartFile;
import 'package:stream_chat/src/client/channel.dart'; import 'package:stream_chat/src/client/channel.dart';
import 'package:stream_chat/src/core/models/attachment.dart';
import 'package:stream_chat/src/core/models/channel_state.dart'; import 'package:stream_chat/src/core/models/channel_state.dart';
import 'package:stream_chat/src/core/models/event.dart'; import 'package:stream_chat/src/core/models/event.dart';
import 'package:stream_chat/src/core/models/message.dart'; import 'package:stream_chat/src/core/models/message.dart';
@@ -46,12 +47,16 @@ Matcher isSameMessageAs(
bool matchText = false, bool matchText = false,
bool matchReactions = false, bool matchReactions = false,
bool matchSendingStatus = false, bool matchSendingStatus = false,
bool matchAttachments = false,
bool matchAttachmentsUploadState = false,
}) => }) =>
_IsSameMessageAs( _IsSameMessageAs(
targetMessage: targetMessage, targetMessage: targetMessage,
matchText: matchText, matchText: matchText,
matchReactions: matchReactions, matchReactions: matchReactions,
matchSendingStatus: matchSendingStatus, matchSendingStatus: matchSendingStatus,
matchAttachments: matchAttachments,
matchAttachmentsUploadState: matchAttachmentsUploadState,
); );
class _IsSameMessageAs extends Matcher { class _IsSameMessageAs extends Matcher {
@@ -60,12 +65,16 @@ class _IsSameMessageAs extends Matcher {
this.matchText = false, this.matchText = false,
this.matchReactions = false, this.matchReactions = false,
this.matchSendingStatus = false, this.matchSendingStatus = false,
this.matchAttachments = false,
this.matchAttachmentsUploadState = false,
}); });
final Message targetMessage; final Message targetMessage;
final bool matchText; final bool matchText;
final bool matchReactions; final bool matchReactions;
final bool matchSendingStatus; final bool matchSendingStatus;
final bool matchAttachments;
final bool matchAttachmentsUploadState;
@override @override
Description describe(Description description) => Description describe(Description description) =>
@@ -96,6 +105,56 @@ class _IsSameMessageAs extends Matcher {
?.map((it) => '${it.type}-${it.messageId}') ?.map((it) => '${it.type}-${it.messageId}')
.toList()); .toList());
} }
if (matchAttachments) {
bool matchAttachments() {
final attachments = message.attachments;
final targetAttachments = targetMessage.attachments;
if (identical(attachments, targetAttachments)) return true;
final length = attachments.length;
if (length != targetAttachments.length) return false;
for (var i = 0; i < length; i++) {
if (!isSameAttachmentAs(
attachments[i],
matchUploadState: matchAttachmentsUploadState,
).matches(targetAttachments[i], matchState)) return false;
}
return true;
}
matches &= matchAttachments();
}
return matches;
}
}
Matcher isSameAttachmentAs(
Attachment targetAttachment, {
bool matchUploadState = false,
}) =>
_IsSameAttachmentAs(
targetAttachment: targetAttachment,
matchUploadState: matchUploadState,
);
class _IsSameAttachmentAs extends Matcher {
const _IsSameAttachmentAs({
required this.targetAttachment,
this.matchUploadState = false,
});
final Attachment targetAttachment;
final bool matchUploadState;
@override
Description describe(Description description) =>
description.add('is same attachment as $targetAttachment');
@override
bool matches(covariant Attachment attachment, Map matchState) {
var matches = attachment.id == targetAttachment.id;
if (matchUploadState) {
matches &= attachment.uploadState == targetAttachment.uploadState;
}
return matches; return matches;
} }
} }
+16
View File
@@ -4,6 +4,17 @@
-[[#687]](https://github.com/GetStream/stream-chat-flutter/issues/687): Fix Users losing their place in the conversation after replying in threads. -[[#687]](https://github.com/GetStream/stream-chat-flutter/issues/687): Fix Users losing their place in the conversation after replying in threads.
✅ Added
- Added `MessageInput.customOverlays` property to add custom overlays to the message input.
- Added `MessageInput.mentionAllAppUsers` property to mention all app users in the message input.
- The `MessageInput` now supports local search for channels with less than 100 members.
⚠️ Deprecated
- `MessageInput.mentionsTileBuilder` is now deprecated in favor of `MessageInput.userMentionsTileBuilder`.
- `MentionTile` is now deprecated in favor of `UserMentionsTile`.
## 3.0.0 ## 3.0.0
- Updated `stream_chat_flutter_core` dependency to [`3.0.0`](https://pub.dev/packages/stream_chat_flutter_core/changelog). - Updated `stream_chat_flutter_core` dependency to [`3.0.0`](https://pub.dev/packages/stream_chat_flutter_core/changelog).
@@ -17,6 +28,7 @@
- [[#668]](https://github.com/GetStream/stream-chat-flutter/issues/668): Fix `MessageInput` rendering errors in case - [[#668]](https://github.com/GetStream/stream-chat-flutter/issues/668): Fix `MessageInput` rendering errors in case
there are no actions available to show. there are no actions available to show.
- [[#349]](https://github.com/GetStream/stream-chat-flutter/issues/349): Fix `MessageInput` attachment render overflow error. - [[#349]](https://github.com/GetStream/stream-chat-flutter/issues/349): Fix `MessageInput` attachment render overflow error.
- `MessageInput` overlays now follow the `MessageInput` focus.
- [[#674]](https://github.com/GetStream/stream-chat-flutter/issues/674): Check scrollController is attached before calling jump in MessageListView. - [[#674]](https://github.com/GetStream/stream-chat-flutter/issues/674): Check scrollController is attached before calling jump in MessageListView.
- Fixed `MessageListView` header and footer when `reverse: false`. - Fixed `MessageListView` header and footer when `reverse: false`.
@@ -25,6 +37,10 @@
- Animation curves changed from default `Curves.linear` to `Curves.easeOut` and `Curves.easeIn` for attachment controls. - Animation curves changed from default `Curves.linear` to `Curves.easeOut` and `Curves.easeIn` for attachment controls.
- Removed default padding in `DateDivider` in `MessageListView` - Removed default padding in `DateDivider` in `MessageListView`
✅ Added
- Added `MessageInput.customPortalOptions` property to add custom overlays to the `MessageInput`.
## 2.2.1 ## 2.2.1
⚠️ Deprecated ⚠️ Deprecated
@@ -21,6 +21,6 @@
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1.0</string> <string>1.0</string>
<key>MinimumOSVersion</key> <key>MinimumOSVersion</key>
<string>8.0</string> <string>9.0</string>
</dict> </dict>
</plist> </plist>
@@ -0,0 +1,210 @@
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// Overlay for displaying commands that can be used
class CommandsOverlay extends StatelessWidget {
/// Constructor for creating a [CommandsOverlay]
const CommandsOverlay({
required this.text,
required this.onCommandResult,
required this.size,
required this.channel,
Key? key,
}) : super(key: key);
/// The size of the overlay
final Size size;
/// Query for searching commands
final String text;
/// The channel to search for users
final Channel channel;
/// Callback called when a command is selected
final ValueChanged<Command> onCommandResult;
@override
Widget build(BuildContext context) {
final _streamChatTheme = StreamChatTheme.of(context);
final commands = channel.config?.commands
.where((c) => c.name.contains(text.replaceFirst('/', '')))
.toList() ??
[];
if (commands.isEmpty) {
return const SizedBox();
}
return Padding(
padding: const EdgeInsets.all(4),
child: Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
color: _streamChatTheme.colorTheme.barsBg,
clipBehavior: Clip.hardEdge,
child: Container(
constraints: BoxConstraints.loose(size),
decoration: BoxDecoration(
color: _streamChatTheme.colorTheme.barsBg,
borderRadius: BorderRadius.circular(8)),
child: ListView(
padding: const EdgeInsets.all(0),
shrinkWrap: true,
children: [
if (commands.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
),
child: StreamSvgIcon.lightning(
color: _streamChatTheme.colorTheme.accentPrimary,
),
),
Text(
context.translations.instantCommandsLabel,
style: TextStyle(
color: _streamChatTheme.colorTheme.textHighEmphasis
.withOpacity(.5),
),
)
],
),
),
const SizedBox(
height: 10,
),
...commands
.map(
(c) => InkWell(
onTap: () {
onCommandResult(c);
},
child: SizedBox(
height: 40,
child: Row(
children: [
const SizedBox(
width: 16,
),
_buildCommandIcon(_streamChatTheme, c.name),
const SizedBox(
width: 8,
),
Text.rich(
TextSpan(
text: c.name.capitalize(),
style: const TextStyle(
fontWeight: FontWeight.bold),
children: [
TextSpan(
text: ' /${c.name} ${c.args}',
style: _streamChatTheme.textTheme.body
.copyWith(
// ignore: lines_longer_than_80_chars
color: _streamChatTheme
// ignore: lines_longer_than_80_chars
.colorTheme
.textLowEmphasis,
),
),
],
),
),
],
),
),
),
)
.toList(),
],
),
),
),
);
}
Widget _buildCommandIcon(
StreamChatThemeData _streamChatTheme, String iconType) {
switch (iconType) {
case 'giphy':
return CircleAvatar(
radius: 12,
child: StreamSvgIcon.giphyIcon(
size: 24,
),
);
case 'ban':
return CircleAvatar(
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
radius: 12,
child: StreamSvgIcon.iconUserDelete(
size: 16,
color: Colors.white,
),
);
case 'flag':
return CircleAvatar(
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
radius: 12,
child: StreamSvgIcon.flag(
size: 14,
color: Colors.white,
),
);
case 'imgur':
return CircleAvatar(
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
radius: 12,
child: ClipOval(
child: StreamSvgIcon.imgur(
size: 24,
),
),
);
case 'mute':
return CircleAvatar(
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
radius: 12,
child: StreamSvgIcon.mute(
size: 16,
color: Colors.white,
),
);
case 'unban':
return CircleAvatar(
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
radius: 12,
child: StreamSvgIcon.userAdd(
size: 16,
color: Colors.white,
),
);
case 'unmute':
return CircleAvatar(
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
radius: 12,
child: StreamSvgIcon.volumeUp(
size: 16,
color: Colors.white,
),
);
default:
return CircleAvatar(
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
radius: 12,
child: StreamSvgIcon.lightning(
size: 16,
color: Colors.white,
),
);
}
}
}
@@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/emoji/emoji.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:substring_highlight/substring_highlight.dart';
/// Overlay for displaying emoji that can be used
class EmojiOverlay extends StatelessWidget {
/// Constructor for creating a [EmojiOverlay]
const EmojiOverlay({
required this.query,
required this.onEmojiResult,
required this.size,
Key? key,
}) : super(key: key);
/// The size of the overlay
final Size size;
/// Query for searching emoji
final String query;
/// Callback called when an emoji is selected
final ValueChanged<Emoji> onEmojiResult;
@override
Widget build(BuildContext context) {
final _streamChatTheme = StreamChatTheme.of(context);
final _emojiNames =
Emoji.all().where((it) => it.name != null).map((e) => e.name!);
final emojis = _emojiNames
.where((e) => e.contains(query))
.map(Emoji.byName)
.where((e) => e != null);
if (emojis.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(
boxShadow: const [
BoxShadow(
spreadRadius: -8,
blurRadius: 5,
offset: Offset(0, -4),
),
],
color: _streamChatTheme.colorTheme.barsBg,
),
child: ListView.builder(
padding: const EdgeInsets.all(0),
shrinkWrap: true,
itemCount: emojis.length + 1,
itemBuilder: (context, i) {
if (i == 0) {
return Padding(
padding: const EdgeInsets.only(left: 8, top: 8),
child: Row(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: StreamSvgIcon.smile(
color: _streamChatTheme.colorTheme.accentPrimary,
),
),
Flexible(
child: Text(
context.translations.emojiMatchingQueryText(
query,
),
style: TextStyle(
color: _streamChatTheme.colorTheme.textHighEmphasis
.withOpacity(.5),
),
),
)
],
),
);
}
final emoji = emojis.elementAt(i - 1)!;
final themeData = Theme.of(context);
return ListTile(
title: SubstringHighlight(
text:
// ignore: lines_longer_than_80_chars
"${emoji.char} ${emoji.name!.replaceAll('_', ' ')}",
term: query,
textStyleHighlight: themeData.textTheme.headline6!.copyWith(
fontSize: 14.5,
fontWeight: FontWeight.bold,
),
textStyle: themeData.textTheme.headline6!.copyWith(
fontSize: 14.5,
),
),
onTap: () {
onEmojiResult(emoji);
},
);
},
),
),
);
}
}
@@ -1,4 +1,5 @@
import 'package:characters/characters.dart'; import 'package:characters/characters.dart';
import 'package:diacritic/diacritic.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/emoji/emoji.dart'; import 'package:stream_chat_flutter/src/emoji/emoji.dart';
@@ -24,6 +25,12 @@ extension StringExtension on String {
final characters = trim().characters; final characters = trim().characters;
return characters.every(_emojiChars.contains); 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 /// List extension
@@ -170,3 +177,49 @@ extension IconButtonX on IconButton {
icon: icon ?? this.icon, icon: icon ?? this.icon,
); );
} }
/// Extensions on List<User>
extension UserListX on List<User> {
/// 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<User> search(String query) {
String normalize(String input) => input.toLowerCase().diacriticsInsensitive;
final normalizedQuery = normalize(query);
final matchingUsers = <User, int>{}; // 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);
}
}
@@ -4,6 +4,7 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// This widget is used for showing user tiles for mentions /// This widget is used for showing user tiles for mentions
/// Use [title], [subtitle], [leading], [trailing] for /// Use [title], [subtitle], [leading], [trailing] for
/// substituting widgets in respective positions /// substituting widgets in respective positions
@Deprecated('Use `UserMentionTile` instead. Will be removed in future release')
class MentionTile extends StatelessWidget { class MentionTile extends StatelessWidget {
/// Constructor for creating a [MentionTile] widget /// Constructor for creating a [MentionTile] widget
const MentionTile( const MentionTile(
@@ -2,27 +2,30 @@ import 'dart:async';
import 'dart:math'; import 'dart:math';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:collection/collection.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
import 'package:shimmer/shimmer.dart'; import 'package:shimmer/shimmer.dart';
import 'package:stream_chat_flutter/src/commands_overlay.dart';
import 'package:stream_chat_flutter/src/emoji/emoji.dart'; 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/extension.dart';
import 'package:stream_chat_flutter/src/media_list_view.dart'; import 'package:stream_chat_flutter/src/media_list_view.dart';
import 'package:stream_chat_flutter/src/message_list_view.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'; import 'package:stream_chat_flutter/src/quoted_message_widget.dart';
import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; import 'package:stream_chat_flutter/src/stream_chat_theme.dart';
import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; import 'package:stream_chat_flutter/src/stream_svg_icon.dart';
import 'package:stream_chat_flutter/src/user_mentions_overlay.dart';
import 'package:stream_chat_flutter/src/video_service.dart'; import 'package:stream_chat_flutter/src/video_service.dart';
import 'package:stream_chat_flutter/src/video_thumbnail_image.dart'; import 'package:stream_chat_flutter/src/video_thumbnail_image.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'package:substring_highlight/substring_highlight.dart';
import 'package:video_compress/video_compress.dart'; import 'package:video_compress/video_compress.dart';
export 'package:video_compress/video_compress.dart' show VideoQuality; export 'package:video_compress/video_compress.dart' show VideoQuality;
@@ -53,13 +56,20 @@ typedef AttachmentThumbnailBuilder = Widget Function(
Attachment, Attachment,
); );
/// Builder function for building a mention tile /// Builder function for building a mention tile.
/// Use [MentionTile] for the default implementation
typedef MentionTileBuilder = Widget Function( typedef MentionTileBuilder = Widget Function(
BuildContext context, BuildContext context,
Member member, Member member,
); );
/// Builder function for building a user mention tile.
///
/// Use [UserMentionTile] for the default implementation.
typedef UserMentionTileBuilder = Widget Function(
BuildContext context,
User user,
);
/// Widget builder for action button. /// Widget builder for action button.
/// ///
/// [defaultActionButton] is the default [IconButton] configuration, /// [defaultActionButton] is the default [IconButton] configuration,
@@ -169,7 +179,7 @@ class MessageInput extends StatefulWidget {
this.disableAttachments = false, this.disableAttachments = false,
this.initialMessage, this.initialMessage,
this.textEditingController, this.textEditingController,
this.actions, this.actions = const [],
this.actionsLocation = ActionsLocation.left, this.actionsLocation = ActionsLocation.left,
this.attachmentThumbnailBuilders, this.attachmentThumbnailBuilders,
this.focusNode, this.focusNode,
@@ -181,7 +191,9 @@ class MessageInput extends StatefulWidget {
this.idleSendButton, this.idleSendButton,
this.activeSendButton, this.activeSendButton,
this.showCommandsButton = true, this.showCommandsButton = true,
this.mentionsTileBuilder, @Deprecated('''Use `userMentionsTileBuilder` instead. Will be removed in future release''')
this.mentionsTileBuilder,
this.userMentionsTileBuilder,
this.maxAttachmentSize = _kDefaultMaxAttachmentSize, this.maxAttachmentSize = _kDefaultMaxAttachmentSize,
this.compressedVideoQuality = VideoQuality.DefaultQuality, this.compressedVideoQuality = VideoQuality.DefaultQuality,
this.compressedVideoFrameRate = 30, this.compressedVideoFrameRate = 30,
@@ -190,12 +202,17 @@ class MessageInput extends StatefulWidget {
this.onAttachmentLimitExceed, this.onAttachmentLimitExceed,
this.attachmentButtonBuilder, this.attachmentButtonBuilder,
this.commandButtonBuilder, this.commandButtonBuilder,
this.customOverlays = const [],
this.mentionAllAppUsers = false,
}) : assert( }) : assert(
initialMessage == null || editMessage == null, initialMessage == null || editMessage == null,
"Can't provide both `initialMessage` and `editMessage`", "Can't provide both `initialMessage` and `editMessage`",
), ),
super(key: key); super(key: key);
/// List of options for showing overlays
final List<OverlayOptions> customOverlays;
/// Message to edit /// Message to edit
final Message? editMessage; final Message? editMessage;
@@ -242,7 +259,7 @@ class MessageInput extends StatefulWidget {
final TextEditingController? textEditingController; final TextEditingController? textEditingController;
/// List of action widgets /// List of action widgets
final List<Widget>? actions; final List<Widget> actions;
/// The location of the custom actions /// The location of the custom actions
final ActionsLocation actionsLocation; final ActionsLocation actionsLocation;
@@ -271,9 +288,12 @@ class MessageInput extends StatefulWidget {
/// Send button widget in an active state /// Send button widget in an active state
final Widget? activeSendButton; final Widget? activeSendButton;
/// Customize the tile for the mentions overlay /// Customize the tile for the mentions overlay.
final MentionTileBuilder? mentionsTileBuilder; final MentionTileBuilder? mentionsTileBuilder;
/// Customize the tile for the mentions overlay.
final UserMentionTileBuilder? userMentionsTileBuilder;
/// A callback for error reporting /// A callback for error reporting
final ErrorListener? onError; final ErrorListener? onError;
@@ -297,6 +317,11 @@ class MessageInput extends StatefulWidget {
/// calling `.copyWith`. /// calling `.copyWith`.
final ActionButtonBuilder? commandButtonBuilder; final ActionButtonBuilder? commandButtonBuilder;
/// When enabled mentions search users across the entire app.
///
/// Defaults to false.
final bool mentionAllAppUsers;
@override @override
MessageInputState createState() => MessageInputState(); MessageInputState createState() => MessageInputState();
@@ -318,11 +343,11 @@ class MessageInputState extends State<MessageInput> {
final List<User> _mentionedUsers = []; final List<User> _mentionedUsers = [];
final _imagePicker = ImagePicker(); final _imagePicker = ImagePicker();
late final FocusNode _focusNode; late final _focusNode = widget.focusNode ?? FocusNode();
bool _inputEnabled = true; bool _inputEnabled = true;
bool _commandEnabled = false; bool _commandEnabled = false;
OverlayEntry? _commandsOverlay, _mentionsOverlay, _emojiOverlay; bool _showCommandsOverlay = false;
late Iterable<String> _emojiNames; bool _showMentionsOverlay = false;
Command? _chosenCommand; Command? _chosenCommand;
bool _actionsShrunk = false; bool _actionsShrunk = false;
@@ -330,10 +355,9 @@ class MessageInputState extends State<MessageInput> {
bool _openFilePickerSection = false; bool _openFilePickerSection = false;
int _filePickerIndex = 0; int _filePickerIndex = 0;
final _keyboardVisibilityController = KeyboardVisibilityController();
/// The editing controller passed to the input TextField /// The editing controller passed to the input TextField
late final TextEditingController textEditingController; late final TextEditingController textEditingController =
widget.textEditingController ?? TextEditingController();
late StreamChatThemeData _streamChatTheme; late StreamChatThemeData _streamChatTheme;
late MessageInputThemeData _messageInputTheme; late MessageInputThemeData _messageInputTheme;
@@ -345,29 +369,10 @@ class MessageInputState extends State<MessageInput> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_focusNode = widget.focusNode ?? FocusNode();
_emojiNames =
Emoji.all().where((it) => it.name != null).map((e) => e.name!);
if (!kIsWeb) {
_keyboardListener =
_keyboardVisibilityController.onChange.listen((visible) {
if (_focusNode.hasFocus) {
_onChanged(context, textEditingController.text);
}
});
}
textEditingController =
widget.textEditingController ?? TextEditingController();
if (widget.editMessage != null || widget.initialMessage != null) { if (widget.editMessage != null || widget.initialMessage != null) {
_parseExistingMessage(widget.editMessage ?? widget.initialMessage!); _parseExistingMessage(widget.editMessage ?? widget.initialMessage!);
} }
textEditingController.addListener(_onChangedDebounced);
textEditingController.addListener(() {
_onChanged(context, textEditingController.text);
});
_focusNode.addListener(() { _focusNode.addListener(() {
if (_focusNode.hasFocus) { if (_focusNode.hasFocus) {
_openFilePickerSection = false; _openFilePickerSection = false;
@@ -472,7 +477,35 @@ class MessageInputState extends State<MessageInput> {
child: child, child: child,
); );
} }
return child;
return MultiOverlay(
childAnchor: Alignment.topCenter,
overlayAnchor: Alignment.bottomCenter,
overlayOptions: [
OverlayOptions(
visible: _showCommandsOverlay,
widget: _buildCommandsOverlayEntry(),
),
OverlayOptions(
visible: _focusNode.hasFocus &&
textEditingController.text.isNotEmpty &&
textEditingController.selection.baseOffset > 0 &&
textEditingController.text
.substring(
0,
textEditingController.selection.baseOffset,
)
.contains(':'),
widget: _buildEmojiOverlay(),
),
OverlayOptions(
visible: _showMentionsOverlay,
widget: _buildMentionsOverlayEntry(),
),
...widget.customOverlays,
],
child: child,
);
} }
Flex _buildTextField(BuildContext context) => Flex( Flex _buildTextField(BuildContext context) => Flex(
@@ -604,7 +637,7 @@ class MessageInputState extends State<MessageInput> {
), ),
secondChild: widget.disableAttachments && secondChild: widget.disableAttachments &&
!widget.showCommandsButton && !widget.showCommandsButton &&
widget.actions?.isNotEmpty != true widget.actions.isNotEmpty != true
? const Offstage() ? const Offstage()
: Wrap( : Wrap(
children: <Widget>[ children: <Widget>[
@@ -615,7 +648,7 @@ class MessageInputState extends State<MessageInput> {
channel.state != null && channel.state != null &&
channel.config?.commands.isNotEmpty == true) channel.config?.commands.isNotEmpty == true)
_buildCommandButton(context), _buildCommandButton(context),
...widget.actions ?? [], ...widget.actions,
].insertBetween(const SizedBox(width: 8)), ].insertBetween(const SizedBox(width: 8)),
), ),
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
@@ -783,51 +816,32 @@ class MessageInputState extends State<MessageInput> {
).merge(passedDecoration); ).merge(passedDecoration);
} }
Timer? _debounce; late final _onChangedDebounced = debounce(
() {
var value = textEditingController.text;
if (!mounted) return;
value = value.trim();
String? _previousValue; final channel = StreamChannel.of(context).channel;
if (value.isNotEmpty) {
channel.keyStroke(widget.parentMessage?.id).catchError((e) {});
}
void _onChanged(BuildContext context, String s) { var actionsLength = widget.actions.length;
if (s == _previousValue) { if (widget.showCommandsButton) actionsLength += 1;
return; if (!widget.disableAttachments) actionsLength += 1;
}
_previousValue = s;
if (_debounce?.isActive == true) _debounce!.cancel(); setState(() {
_debounce = Timer( _actionsShrunk = value.isNotEmpty && actionsLength > 1;
const Duration(milliseconds: 350), });
() {
if (!mounted) {
return;
}
StreamChannel.of(context)
.channel
.keyStroke(widget.parentMessage?.id)
.catchError((e) {});
setState(() { _checkCommands(value, context);
_actionsShrunk = s.trim().isNotEmpty && _checkMentions(value, context);
((widget.actions?.length ?? 0) + _checkEmoji(value, context);
(widget.showCommandsButton ? 1 : 0) + },
(widget.disableAttachments ? 0 : 1) > const Duration(milliseconds: 350),
1); leading: true,
}); );
_commandsOverlay?.remove();
_commandsOverlay = null;
_mentionsOverlay?.remove();
_mentionsOverlay = null;
_emojiOverlay?.remove();
_emojiOverlay = null;
_checkCommands(s.trim(), context);
_checkMentions(s, context);
_checkEmoji(s, context);
},
);
}
String _getHint(BuildContext context) { String _getHint(BuildContext context) {
if (_commandEnabled && _chosenCommand!.name == 'giphy') { if (_commandEnabled && _chosenCommand!.name == 'giphy') {
@@ -847,10 +861,7 @@ class MessageInputState extends State<MessageInput> {
if (s.isNotEmpty && if (s.isNotEmpty &&
textEditingController.selection.baseOffset > 0 && textEditingController.selection.baseOffset > 0 &&
textEditingController.text textEditingController.text
.substring( .substring(0, textEditingController.selection.baseOffset)
0,
textEditingController.selection.baseOffset,
)
.contains(':')) { .contains(':')) {
final textToSelection = textEditingController.text final textToSelection = textEditingController.text
.substring(0, textEditingController.value.selection.start); .substring(0, textEditingController.value.selection.start);
@@ -860,12 +871,6 @@ class MessageInputState extends State<MessageInput> {
if (textToSelection.endsWith(':') && emoji != null) { if (textToSelection.endsWith(':') && emoji != null) {
_chooseEmoji(splits.sublist(0, splits.length - 1), emoji); _chooseEmoji(splits.sublist(0, splits.length - 1), emoji);
} else {
_emojiOverlay = _buildEmojiOverlay();
if (_emojiOverlay != null) {
Overlay.of(context)!.insert(_emojiOverlay!);
}
} }
} }
} }
@@ -878,166 +883,50 @@ class MessageInputState extends State<MessageInput> {
.split(' ') .split(' ')
.last .last
.contains('@')) { .contains('@')) {
_mentionsOverlay = _buildMentionsOverlayEntry(); if (!_showMentionsOverlay) {
if (_mentionsOverlay != null) { setState(() {
Overlay.of(context)!.insert(_mentionsOverlay!); _showMentionsOverlay = true;
});
} }
} else if (_showMentionsOverlay) {
setState(() {
_showMentionsOverlay = false;
});
} }
} }
void _checkCommands(String s, BuildContext context) { void _checkCommands(String s, BuildContext context) {
if (s.startsWith('/')) { if (s.startsWith('/')) {
final matchedCommandsList = StreamChannel.of(context) final allCommands = StreamChannel.of(context).channel.config?.commands;
.channel final command =
.config allCommands?.firstWhereOrNull((it) => it.name == s.substring(1));
?.commands if (command != null) {
.where((element) => element.name == s.substring(1)) return _setCommand(command);
.toList() ?? } else if (!_showCommandsOverlay) {
[];
if (matchedCommandsList.length == 1) {
_chosenCommand = matchedCommandsList[0];
textEditingController.clear();
setState(() { setState(() {
_commandEnabled = true; _showCommandsOverlay = true;
}); });
_commandsOverlay?.remove();
_commandsOverlay = null;
} else {
_commandsOverlay = _buildCommandsOverlayEntry();
if (_commandsOverlay != null) {
Overlay.of(context)!.insert(_commandsOverlay!);
}
} }
} else if (_showCommandsOverlay) {
setState(() {
_showCommandsOverlay = false;
});
} }
} }
OverlayEntry? _buildCommandsOverlayEntry() { Widget _buildCommandsOverlayEntry() {
final text = textEditingController.text.trimLeft(); final text = textEditingController.text.trimLeft();
final commands = StreamChannel.of(context)
.channel
.config
?.commands
.where((c) => c.name.contains(text.replaceFirst('/', '')))
.toList() ??
[];
if (commands.isEmpty) { final renderObject = context.findRenderObject() as RenderBox?;
return null; if (renderObject == null) {
return const Offstage();
} }
return CommandsOverlay(
// ignore: cast_nullable_to_non_nullable channel: StreamChannel.of(context).channel,
final renderBox = context.findRenderObject() as RenderBox; size: Size(renderObject.size.width - 16, 400),
final size = renderBox.size; text: text,
onCommandResult: _setCommand,
final child = Padding(
padding: const EdgeInsets.all(8),
child: Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
color: _streamChatTheme.colorTheme.barsBg,
clipBehavior: Clip.hardEdge,
child: Container(
constraints: BoxConstraints.loose(const Size.fromHeight(400)),
decoration: BoxDecoration(
color: _streamChatTheme.colorTheme.barsBg,
borderRadius: BorderRadius.circular(8)),
child: ListView(
padding: const EdgeInsets.all(0),
shrinkWrap: true,
children: [
if (commands.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
),
child: StreamSvgIcon.lightning(
color: _streamChatTheme.colorTheme.accentPrimary,
),
),
Text(
context.translations.instantCommandsLabel,
style: TextStyle(
color: _streamChatTheme.colorTheme.textHighEmphasis
.withOpacity(.5),
),
)
],
),
),
const SizedBox(
height: 10,
),
...commands
.map(
(c) => InkWell(
onTap: () {
_setCommand(c);
},
child: SizedBox(
height: 40,
child: Row(
children: [
const SizedBox(
width: 16,
),
_buildCommandIcon(c.name),
const SizedBox(
width: 8,
),
Text.rich(
TextSpan(
text: c.name.capitalize(),
style: const TextStyle(
fontWeight: FontWeight.bold),
children: [
TextSpan(
text: ' /${c.name} ${c.args}',
style: _streamChatTheme.textTheme.body
.copyWith(
// ignore: lines_longer_than_80_chars
color: _streamChatTheme
// ignore: lines_longer_than_80_chars
.colorTheme
.textLowEmphasis,
),
),
],
),
),
],
),
),
),
)
.toList(),
],
),
),
),
); );
return OverlayEntry(
builder: (context) => Positioned(
bottom: size.height + MediaQuery.of(context).viewInsets.bottom,
left: 0,
right: 0,
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: 1),
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOutExpo,
builder: (context, val, child) => Transform.scale(
scale: val,
child: child,
),
child: child,
),
));
} }
Widget _buildFilePickerSection() { Widget _buildFilePickerSection() {
@@ -1087,7 +976,9 @@ class MessageInputState extends State<MessageInput> {
} }
return AnimatedContainer( return AnimatedContainer(
duration: const Duration(milliseconds: 300), duration: _openFilePickerSection
? const Duration(milliseconds: 300)
: const Duration(),
curve: Curves.easeOut, curve: Curves.easeOut,
height: _openFilePickerSection ? _kMinMediaPickerSize : 0, height: _openFilePickerSection ? _kMinMediaPickerSize : 0,
child: SingleChildScrollView( child: SingleChildScrollView(
@@ -1261,300 +1152,78 @@ class MessageInputState extends State<MessageInput> {
}); });
} }
Widget _buildCommandIcon(String iconType) { Widget _buildMentionsOverlayEntry() {
switch (iconType) { if (textEditingController.value.selection.start < 0) {
case 'giphy': return const Offstage();
return CircleAvatar(
radius: 12,
child: StreamSvgIcon.giphyIcon(
size: 24,
),
);
case 'ban':
return CircleAvatar(
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
radius: 12,
child: StreamSvgIcon.iconUserDelete(
size: 16,
color: Colors.white,
),
);
case 'flag':
return CircleAvatar(
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
radius: 12,
child: StreamSvgIcon.flag(
size: 14,
color: Colors.white,
),
);
case 'imgur':
return CircleAvatar(
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
radius: 12,
child: ClipOval(
child: StreamSvgIcon.imgur(
size: 24,
),
),
);
case 'mute':
return CircleAvatar(
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
radius: 12,
child: StreamSvgIcon.mute(
size: 16,
color: Colors.white,
),
);
case 'unban':
return CircleAvatar(
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
radius: 12,
child: StreamSvgIcon.userAdd(
size: 16,
color: Colors.white,
),
);
case 'unmute':
return CircleAvatar(
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
radius: 12,
child: StreamSvgIcon.volumeUp(
size: 16,
color: Colors.white,
),
);
default:
return CircleAvatar(
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
radius: 12,
child: StreamSvgIcon.lightning(
size: 16,
color: Colors.white,
),
);
} }
}
OverlayEntry? _buildMentionsOverlayEntry() {
final splits = textEditingController.text final splits = textEditingController.text
.substring(0, textEditingController.value.selection.start) .substring(0, textEditingController.value.selection.start)
.split('@'); .split('@');
final query = splits.last.toLowerCase(); final query = splits.last.toLowerCase();
Future<List<Member>>? queryMembers;
final channelState = StreamChannel.of(context);
if (query.isNotEmpty) {
queryMembers = channelState.channel
.queryMembers(filter: Filter.autoComplete('name', query))
.then((res) => res.members);
}
final members = channelState.channel.state?.members
.where((m) => m.user?.name.toLowerCase().contains(query) == true)
.toList() ??
[];
if (members.isEmpty) {
return null;
}
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable
final renderBox = context.findRenderObject() as RenderBox; final renderObject = context.findRenderObject() as RenderBox;
final size = renderBox.size;
final child = 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(const Size.fromHeight(240)),
decoration: BoxDecoration(
color: _streamChatTheme.colorTheme.barsBg,
),
child: FutureBuilder<List<Member>>(
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: () {
if (m.user != null) {
_mentionedUsers.add(m.user!);
}
splits[splits.length - 1] = m.user!.name; var tileBuilder = widget.userMentionsTileBuilder;
final rejoin = splits.join('@'); if (tileBuilder == null && widget.mentionsTileBuilder != null) {
tileBuilder = (context, user) {
final member = Member(
user: user,
userId: user.id,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
);
return widget.mentionsTileBuilder!(context, member);
};
}
textEditingController.value = TextEditingValue( return UserMentionsOverlay(
text: rejoin + query: query,
textEditingController.text.substring( mentionAllAppUsers: widget.mentionAllAppUsers,
textEditingController.selection.start), client: StreamChat.of(context).client,
selection: TextSelection.collapsed( channel: StreamChannel.of(context).channel,
offset: rejoin.length, size: Size(renderObject.size.width - 16, 400),
), mentionsTileBuilder: tileBuilder,
); onMentionUserTap: (user) {
_debounce!.cancel(); _mentionedUsers.add(user);
_mentionsOverlay?.remove(); splits[splits.length - 1] = user.name;
_mentionsOverlay = null; final rejoin = splits.join('@');
},
child: widget.mentionsTileBuilder != null textEditingController.value = TextEditingValue(
? widget.mentionsTileBuilder!(context, m) text: rejoin +
: MentionTile(m), textEditingController.text
), .substring(textEditingController.selection.start),
), selection: TextSelection.collapsed(
) offset: rejoin.length,
.toList(),
const SizedBox(
height: 8,
),
],
), ),
), );
), _onChangedDebounced.cancel();
); setState(() => _showMentionsOverlay = false);
return OverlayEntry( },
builder: (context) => Positioned(
bottom: size.height + MediaQuery.of(context).viewInsets.bottom,
left: 0,
right: 0,
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: 1),
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOutExpo,
builder: (context, val, child) => Transform.scale(
scale: val,
child: child,
),
child: child,
),
),
); );
} }
OverlayEntry? _buildEmojiOverlay() { Widget _buildEmojiOverlay() {
if (textEditingController.value.selection.baseOffset < 0) {
return const Offstage();
}
final splits = textEditingController.text final splits = textEditingController.text
.substring(0, textEditingController.value.selection.start) .substring(0, textEditingController.value.selection.baseOffset)
.split(':'); .split(':');
final query = splits.last.toLowerCase(); final query = splits.last.toLowerCase();
if (query.isEmpty) {
return null;
}
final emojis = _emojiNames
.where((e) => e.contains(query))
.map(Emoji.byName)
.where((e) => e != null);
if (emojis.isEmpty) {
return null;
}
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable
final renderBox = context.findRenderObject() as RenderBox; final renderObject = context.findRenderObject() as RenderBox;
final size = renderBox.size;
return OverlayEntry( return EmojiOverlay(
builder: (context) => Positioned( size: Size(renderObject.size.width - 16, 200),
bottom: size.height + MediaQuery.of(context).viewInsets.bottom, query: query,
left: 0, onEmojiResult: (emoji) {
right: 0, _chooseEmoji(splits, emoji);
child: 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(const Size.fromHeight(200)),
decoration: BoxDecoration(
boxShadow: const [
BoxShadow(
spreadRadius: -8,
blurRadius: 5,
offset: Offset(0, -4),
),
],
color: _streamChatTheme.colorTheme.barsBg,
),
child: ListView.builder(
padding: const EdgeInsets.all(0),
shrinkWrap: true,
itemCount: emojis.length + 1,
itemBuilder: (context, i) {
if (i == 0) {
return Padding(
padding: const EdgeInsets.only(left: 8, top: 8),
child: Row(
children: [
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 8),
child: StreamSvgIcon.smile(
color: _streamChatTheme
.colorTheme.accentPrimary,
),
),
Flexible(
child: Text(
context.translations.emojiMatchingQueryText(
query,
),
style: TextStyle(
color: _streamChatTheme
.colorTheme.textHighEmphasis
.withOpacity(.5),
),
),
)
],
),
);
}
final emoji = emojis.elementAt(i - 1)!;
final themeData = Theme.of(context);
return ListTile(
title: SubstringHighlight(
text:
// ignore: lines_longer_than_80_chars
"${emoji.char} ${emoji.name!.replaceAll('_', ' ')}",
term: query,
textStyleHighlight:
themeData.textTheme.headline6!.copyWith(
fontSize: 14.5,
fontWeight: FontWeight.bold,
),
textStyle: themeData.textTheme.headline6!.copyWith(
fontSize: 14.5,
),
),
onTap: () {
_chooseEmoji(splits, emoji);
},
);
}),
),
),
));
} }
void _chooseEmoji(List<String> splits, Emoji emoji) { void _chooseEmoji(List<String> splits, Emoji emoji) {
@@ -1568,9 +1237,6 @@ class MessageInputState extends State<MessageInput> {
offset: rejoin.length, offset: rejoin.length,
), ),
); );
_emojiOverlay?.remove();
_emojiOverlay = null;
} }
void _setCommand(Command c) { void _setCommand(Command c) {
@@ -1578,9 +1244,8 @@ class MessageInputState extends State<MessageInput> {
setState(() { setState(() {
_chosenCommand = c; _chosenCommand = c;
_commandEnabled = true; _commandEnabled = true;
_showCommandsOverlay = false;
}); });
_commandsOverlay?.remove();
_commandsOverlay = null;
} }
Widget _buildReplyToMessage() { Widget _buildReplyToMessage() {
@@ -1773,7 +1438,7 @@ class MessageInputState extends State<MessageInput> {
icon: StreamSvgIcon.lightning( icon: StreamSvgIcon.lightning(
color: s.isNotEmpty color: s.isNotEmpty
? _streamChatTheme.colorTheme.disabled ? _streamChatTheme.colorTheme.disabled
: (_commandsOverlay != null : (_showCommandsOverlay
? _messageInputTheme.actionButtonColor ? _messageInputTheme.actionButtonColor
: _messageInputTheme.actionButtonIdleColor), : _messageInputTheme.actionButtonIdleColor),
), ),
@@ -1789,19 +1454,9 @@ class MessageInputState extends State<MessageInput> {
await Future.delayed(const Duration(milliseconds: 300)); await Future.delayed(const Duration(milliseconds: 300));
} }
if (_commandsOverlay == null) { setState(() {
setState(() { _showCommandsOverlay = !_showCommandsOverlay;
_commandsOverlay = _buildCommandsOverlayEntry(); });
if (_commandsOverlay != null) {
Overlay.of(context)!.insert(_commandsOverlay!);
}
});
} else {
setState(() {
_commandsOverlay?.remove();
_commandsOverlay = null;
});
}
}, },
); );
@@ -1823,12 +1478,8 @@ class MessageInputState extends State<MessageInput> {
), ),
splashRadius: 24, splashRadius: 24,
onPressed: () async { onPressed: () async {
_emojiOverlay?.remove(); _showCommandsOverlay = false;
_emojiOverlay = null; _showMentionsOverlay = false;
_commandsOverlay?.remove();
_commandsOverlay = null;
_mentionsOverlay?.remove();
_mentionsOverlay = null;
if (_openFilePickerSection) { if (_openFilePickerSection) {
setState(() => _openFilePickerSection = false); setState(() => _openFilePickerSection = false);
@@ -2114,11 +1765,6 @@ class MessageInputState extends State<MessageInput> {
_commandEnabled = false; _commandEnabled = false;
}); });
_commandsOverlay?.remove();
_commandsOverlay = null;
_mentionsOverlay?.remove();
_mentionsOverlay = null;
Message message; Message message;
if (widget.editMessage != null) { if (widget.editMessage != null) {
message = widget.editMessage!.copyWith( message = widget.editMessage!.copyWith(
@@ -2185,8 +1831,6 @@ class MessageInputState extends State<MessageInput> {
} }
} }
StreamSubscription? _keyboardListener;
void _showErrorAlert(String description) { void _showErrorAlert(String description) {
showModalBottomSheet( showModalBottomSheet(
backgroundColor: _streamChatTheme.colorTheme.barsBg, backgroundColor: _streamChatTheme.colorTheme.barsBg,
@@ -2260,12 +1904,9 @@ class MessageInputState extends State<MessageInput> {
@override @override
void dispose() { void dispose() {
_commandsOverlay?.remove();
_emojiOverlay?.remove();
_mentionsOverlay?.remove();
_keyboardListener?.cancel();
textEditingController.dispose(); textEditingController.dispose();
_stopSlowMode(); _stopSlowMode();
_onChangedDebounced.cancel();
super.dispose(); super.dispose();
} }
@@ -580,11 +580,12 @@ class _MessageWidgetState extends State<MessageWidget>
bool get isOnlyEmoji => widget.message.text?.isOnlyEmoji == true; bool get isOnlyEmoji => widget.message.text?.isOnlyEmoji == true;
bool get hasNonUrlAttachments => bool get hasNonUrlAttachments => widget.message.attachments
widget.message.attachments.where((it) => it.titleLink == null).isNotEmpty; .where((it) => it.titleLink == null || it.type == 'giphy')
.isNotEmpty;
bool get hasUrlAttachments => bool get hasUrlAttachments => widget.message.attachments
widget.message.attachments.any((it) => it.titleLink != null) == true; .any((it) => it.titleLink != null && it.type != 'giphy');
bool get showBottomRow => bool get showBottomRow =>
showThreadReplyIndicator || showThreadReplyIndicator ||
@@ -1156,7 +1157,9 @@ class _MessageWidgetState extends State<MessageWidget>
final attachmentGroups = <String, List<Attachment>>{}; final attachmentGroups = <String, List<Attachment>>{};
widget.message.attachments widget.message.attachments
.where((element) => element.titleLink == null && element.type != null) .where((element) =>
(element.titleLink == null && element.type != null) ||
element.type == 'giphy')
.forEach((e) { .forEach((e) {
if (attachmentGroups[e.type] == null) { if (attachmentGroups[e.type] == null) {
attachmentGroups[e.type!] = []; attachmentGroups[e.type!] = [];
@@ -0,0 +1,63 @@
import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_portal/flutter_portal.dart';
/// Class that contains the parameters for building an overlay entry
class OverlayOptions {
/// Constructs a new overlay options object
/// [visible] - the visibility of the overlay
/// [widget] - the widget to be displayed
OverlayOptions({
required this.visible,
required this.widget,
});
/// the visibility of the overlay
final bool visible;
/// the widget to be displayed
final Widget widget;
}
/// Widget that renders a single overlay widget from a list of [overlayOptions]
/// It shows the first one that is visible
class MultiOverlay extends StatelessWidget {
/// Constructs a new MultiOverlay widget
/// [overlayOptions] - the list of overlay options
/// [overlayAnchor] - the anchor relative to the overlay
/// [childAnchor] - the anchor relative to the child
/// [child] - the child widget
const MultiOverlay({
Key? key,
required this.overlayOptions,
required this.child,
required this.overlayAnchor,
required this.childAnchor,
}) : super(key: key);
/// The list of overlay options
final List<OverlayOptions> overlayOptions;
/// The child widget
final Widget child;
/// The anchor relative to the overlay
final Alignment? overlayAnchor;
/// The anchor relative to the child
final Alignment? childAnchor;
@override
Widget build(BuildContext context) {
final visibleOverlay =
overlayOptions.firstWhereOrNull((element) => element.visible);
return PortalEntry(
childAnchor: childAnchor,
portalAnchor: overlayAnchor,
visible: visibleOverlay != null,
portal: visibleOverlay?.widget,
child: child,
);
}
}
@@ -66,6 +66,12 @@ class UserAvatar extends StatelessWidget {
final placeholder = final placeholder =
this.placeholder ?? streamChatTheme.placeholderUserImage; this.placeholder ?? streamChatTheme.placeholderUserImage;
final backupGradientAvatar = ClipRRect(
borderRadius: borderRadius ??
streamChatTheme.ownMessageTheme.avatarTheme?.borderRadius,
child: streamChatTheme.defaultUserImage(context, user),
);
Widget avatar = FittedBox( Widget avatar = FittedBox(
fit: BoxFit.cover, fit: BoxFit.cover,
child: Container( child: Container(
@@ -76,8 +82,7 @@ class UserAvatar extends StatelessWidget {
fit: BoxFit.cover, fit: BoxFit.cover,
filterQuality: FilterQuality.high, filterQuality: FilterQuality.high,
imageUrl: user.image!, imageUrl: user.image!,
errorWidget: (context, __, ___) => errorWidget: (context, __, ___) => backupGradientAvatar,
streamChatTheme.defaultUserImage(context, user),
placeholder: placeholder != null placeholder: placeholder != null
? (context, __) => placeholder(context, user) ? (context, __) => placeholder(context, user)
: null, : null,
@@ -91,11 +96,7 @@ class UserAvatar extends StatelessWidget {
), ),
), ),
) )
: ClipRRect( : backupGradientAvatar,
borderRadius: borderRadius ??
streamChatTheme.ownMessageTheme.avatarTheme?.borderRadius,
child: streamChatTheme.defaultUserImage(context, user),
),
), ),
); );
@@ -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,
),
),
],
),
);
}
}
@@ -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<UserMentionsOverlay> {
late Future<List<User>> 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<List<User>>(
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<User> get membersAndWatchers {
final state = widget.channel.state!;
return {
...state.watchers,
...state.members.map((it) => it.user),
}.whereType<User>().toList(growable: false);
}
Future<List<User>> 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<User>()
.toList(growable: false);
}
Future<List<Member>> _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<List<User>> _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;
}
}
@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/extension.dart'; import 'package:stream_chat_flutter/src/extension.dart';
@@ -388,3 +389,39 @@ class Tuple2<T1, T2> {
@override @override
int get hashCode => item1.hashCode ^ item2.hashCode; 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<int>.filled(t.length + 1, 0);
final v1 = List<int>.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];
}
@@ -44,5 +44,6 @@ export 'src/unread_indicator.dart';
export 'src/user_avatar.dart'; export 'src/user_avatar.dart';
export 'src/user_item.dart'; export 'src/user_item.dart';
export 'src/user_list_view.dart'; export 'src/user_list_view.dart';
export 'src/user_mention_tile.dart';
export 'src/utils.dart'; export 'src/utils.dart';
export 'src/visible_footnote.dart'; export 'src/visible_footnote.dart';
+1 -1
View File
@@ -14,12 +14,12 @@ dependencies:
characters: ^1.1.0 characters: ^1.1.0
chewie: ^1.2.0 chewie: ^1.2.0
collection: ^1.15.0 collection: ^1.15.0
diacritic: ^0.1.3
dio: ^4.0.0 dio: ^4.0.0
ezanimation: ^0.5.0 ezanimation: ^0.5.0
file_picker: ^3.0.1 file_picker: ^3.0.1
flutter: flutter:
sdk: flutter sdk: flutter
flutter_keyboard_visibility: ^5.0.1
flutter_markdown: ^0.6.1 flutter_markdown: ^0.6.1
flutter_portal: ^0.4.0 flutter_portal: ^0.4.0
flutter_slidable: ^0.6.0 flutter_slidable: ^0.6.0
@@ -0,0 +1,70 @@
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<User>.search', () {
test('should work fine', () {
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 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]);
});
});
}
@@ -263,9 +263,11 @@ void main() {
final client = MockClient(); final client = MockClient();
final clientState = MockClientState(); final clientState = MockClientState();
final channel = MockChannel(); final channel = MockChannel();
final channelState = MockChannelState();
when(() => client.state).thenReturn(clientState); when(() => client.state).thenReturn(clientState);
when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id'));
when(() => channel.state).thenReturn(channelState);
final themeData = ThemeData(); final themeData = ThemeData();
final streamTheme = StreamChatThemeData.fromTheme(themeData); final streamTheme = StreamChatThemeData.fromTheme(themeData);
@@ -152,13 +152,9 @@ class StreamChannelState extends State<StreamChannel> {
int limit = 20, int limit = 20,
}) { }) {
if (direction == QueryDirection.top) { if (direction == QueryDirection.top) {
return _queryTopMessages( return _queryTopMessages(limit: limit);
limit: limit,
);
} }
return _queryBottomMessages( return _queryBottomMessages(limit: limit);
limit: limit,
);
} }
/// Calls [channel.getReplies] updating [queryMessage] stream /// Calls [channel.getReplies] updating [queryMessage] stream
@@ -311,6 +307,20 @@ class StreamChannelState extends State<StreamChannel> {
return message; return message;
} }
/// Query channel members.
Future<List<Member>> queryMembers({
Filter? filter,
List<SortOption>? sort,
PaginationParams? pagination,
}) async {
final response = await channel.queryMembers(
filter: filter,
sort: sort,
pagination: pagination,
);
return response.members;
}
/// Reloads the channel with latest message /// Reloads the channel with latest message
Future<void> reloadChannel() => _queryAtMessage(before: 30); Future<void> reloadChannel() => _queryAtMessage(before: 30);