Merge branch 'develop' into hotfix/messageListView
This commit is contained in:
@@ -2,7 +2,7 @@ name: stream_flutter_workflow
|
||||
|
||||
env:
|
||||
ACTIONS_ALLOW_UNSECURE_COMMANDS: 'true'
|
||||
flutter_version: "2.5.0"
|
||||
flutter_version: "2.5.1"
|
||||
|
||||
on:
|
||||
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.
|
||||
|
||||
```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
|
||||
|
||||
@@ -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
|
||||
|
||||
🛑️ Breaking Changes from `2.2.1`
|
||||
|
||||
@@ -351,7 +351,7 @@ class Channel {
|
||||
Future<bool> get initialized => _initializedCompleter.future;
|
||||
|
||||
final _cancelableAttachmentUploadRequest = <String, CancelToken>{};
|
||||
final _messageAttachmentsUploadCompleter = <String, Completer>{};
|
||||
final _messageAttachmentsUploadCompleter = <String, Completer<Message>>{};
|
||||
|
||||
/// Cancels [attachmentId] upload request. Throws exception if the request
|
||||
/// hasn't even started yet, Already completed or Already cancelled.
|
||||
@@ -382,12 +382,10 @@ class Channel {
|
||||
String messageId,
|
||||
Iterable<String> attachmentIds,
|
||||
) {
|
||||
final message = [
|
||||
var message = [
|
||||
...state!.messages,
|
||||
...state!.threads.values.expand((messages) => messages),
|
||||
].firstWhereOrNull(
|
||||
(it) => it.id == messageId,
|
||||
);
|
||||
].firstWhereOrNull((it) => it.id == messageId);
|
||||
|
||||
if (message == null) {
|
||||
throw const StreamChatError('Error, Message not found');
|
||||
@@ -409,11 +407,15 @@ class Channel {
|
||||
client.logger.info('Found ${attachments.length} attachments');
|
||||
|
||||
void updateAttachment(Attachment attachment) {
|
||||
final index =
|
||||
message.attachments.indexWhere((it) => it.id == attachment.id);
|
||||
final index = message!.attachments.indexWhere(
|
||||
(it) => it.id == attachment.id,
|
||||
);
|
||||
if (index != -1) {
|
||||
message.attachments[index] = attachment;
|
||||
state?.addMessage(message);
|
||||
final newAttachments = [...message!.attachments]..[index] = attachment;
|
||||
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);
|
||||
});
|
||||
})).whenComplete(() {
|
||||
if (message.attachments.every((it) => it.uploadState.isSuccess)) {
|
||||
if (message!.attachments.every((it) => it.uploadState.isSuccess)) {
|
||||
_messageAttachmentsUploadCompleter.remove(messageId)?.complete(message);
|
||||
}
|
||||
});
|
||||
@@ -1076,33 +1078,7 @@ class Channel {
|
||||
} else {
|
||||
// remove the passed message if response does
|
||||
// not contain message
|
||||
final oldIndex = state!.messages.indexWhere((m) => m.id == messageId);
|
||||
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
state!.removeMessage(message);
|
||||
await _client.chatPersistenceClient?.deleteMessageById(messageId);
|
||||
}
|
||||
return res;
|
||||
@@ -1521,7 +1497,7 @@ class ChannelClientState {
|
||||
}
|
||||
}
|
||||
|
||||
void _checkExpiredAttachmentMessages(ChannelState channelState) {
|
||||
void _checkExpiredAttachmentMessages(ChannelState channelState) async {
|
||||
final expiredAttachmentMessagesId = channelState.messages
|
||||
.where((m) =>
|
||||
!_updatedMessagesIds.contains(m.id) &&
|
||||
@@ -1532,20 +1508,24 @@ class ChannelClientState {
|
||||
return false;
|
||||
}
|
||||
final uri = Uri.parse(url);
|
||||
if (uri.host != 'stream-io-cdn.com' ||
|
||||
if (!uri.host.endsWith('stream-io-cdn.com') ||
|
||||
uri.queryParameters['Expires'] == null) {
|
||||
return false;
|
||||
}
|
||||
final expiration =
|
||||
DateTime.parse(uri.queryParameters['Expires']!);
|
||||
final secondsFromEpoch =
|
||||
int.parse(uri.queryParameters['Expires']!);
|
||||
final expiration = DateTime.fromMillisecondsSinceEpoch(
|
||||
secondsFromEpoch * 1000);
|
||||
return expiration.isBefore(DateTime.now());
|
||||
}) ==
|
||||
true)
|
||||
.map((e) => e.id)
|
||||
.toList();
|
||||
|
||||
if (expiredAttachmentMessagesId.isNotEmpty == true) {
|
||||
_channel.getMessagesById(expiredAttachmentMessagesId);
|
||||
await _channel._initializedCompleter.future;
|
||||
_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) {
|
||||
if (message.parentId == null || message.showInChannel == true) {
|
||||
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() {
|
||||
if (_channelState.channel?.config.readEvents == false) {
|
||||
return;
|
||||
@@ -1862,15 +1871,11 @@ class ChannelClientState {
|
||||
|
||||
if (newThreads.containsKey(parentId)) {
|
||||
newThreads[parentId] = [
|
||||
...newThreads[parentId]
|
||||
?.where(
|
||||
(newMessage) => !messages.any((m) => m.id == newMessage.id))
|
||||
.toList() ??
|
||||
[],
|
||||
...messages,
|
||||
];
|
||||
|
||||
newThreads[parentId]!.sort(_sortByCreatedAt);
|
||||
...newThreads[parentId]!.where(
|
||||
(newMessage) => !messages.any((m) => m.id == newMessage.id),
|
||||
),
|
||||
]..sort(_sortByCreatedAt);
|
||||
} else {
|
||||
newThreads[parentId] = messages;
|
||||
}
|
||||
|
||||
@@ -46,12 +46,16 @@ class Token extends Equatable {
|
||||
/// Creates a [Token] instance from the provided [rawValue] if it's valid.
|
||||
factory Token.fromRawValue(String rawValue) {
|
||||
final jwtBody = JsonWebToken.unverified(rawValue);
|
||||
final userId = jwtBody.claims.getTyped<String>('user_id');
|
||||
final userId = jwtBody.claims.getTyped('user_id');
|
||||
assert(
|
||||
userId != null,
|
||||
'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.
|
||||
|
||||
@@ -167,6 +167,9 @@ class Filter extends Equatable {
|
||||
factory Filter.exists(String key, {bool exists = true}) =>
|
||||
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
|
||||
factory Filter.contains(String key, Object value) =>
|
||||
Filter._(operator: FilterOperator.contains, key: key, value: value);
|
||||
|
||||
@@ -334,20 +334,87 @@ void main() {
|
||||
expectLater(
|
||||
// skipping first seed message list -> [] messages
|
||||
channel.state?.messagesStream.skip(1),
|
||||
emitsInOrder([
|
||||
emitsInOrder(
|
||||
[
|
||||
isSameMessageAs(
|
||||
message.copyWith(status: MessageSendingStatus.sending),
|
||||
matchSendingStatus: true,
|
||||
),
|
||||
// preparing attachments to upload
|
||||
[
|
||||
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);
|
||||
@@ -472,20 +539,87 @@ void main() {
|
||||
expectLater(
|
||||
// skipping first seed message list -> [] messages
|
||||
channel.state?.messagesStream.skip(1),
|
||||
emitsInOrder([
|
||||
emitsInOrder(
|
||||
[
|
||||
isSameMessageAs(
|
||||
message.copyWith(status: MessageSendingStatus.updating),
|
||||
matchSendingStatus: true,
|
||||
),
|
||||
// preparing attachments to upload
|
||||
[
|
||||
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);
|
||||
|
||||
@@ -114,7 +114,7 @@ void main() {
|
||||
|
||||
test('notExists', () {
|
||||
const key = 'testKey';
|
||||
final filter = Filter.exists(key, exists: false);
|
||||
final filter = Filter.notExists(key);
|
||||
expect(filter.key, key);
|
||||
expect(filter.value, isFalse);
|
||||
expect(filter.operator, FilterOperator.exists.rawValue);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:dio/dio.dart' show MultipartFile;
|
||||
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/event.dart';
|
||||
import 'package:stream_chat/src/core/models/message.dart';
|
||||
@@ -46,12 +47,16 @@ Matcher isSameMessageAs(
|
||||
bool matchText = false,
|
||||
bool matchReactions = false,
|
||||
bool matchSendingStatus = false,
|
||||
bool matchAttachments = false,
|
||||
bool matchAttachmentsUploadState = false,
|
||||
}) =>
|
||||
_IsSameMessageAs(
|
||||
targetMessage: targetMessage,
|
||||
matchText: matchText,
|
||||
matchReactions: matchReactions,
|
||||
matchSendingStatus: matchSendingStatus,
|
||||
matchAttachments: matchAttachments,
|
||||
matchAttachmentsUploadState: matchAttachmentsUploadState,
|
||||
);
|
||||
|
||||
class _IsSameMessageAs extends Matcher {
|
||||
@@ -60,12 +65,16 @@ class _IsSameMessageAs extends Matcher {
|
||||
this.matchText = false,
|
||||
this.matchReactions = false,
|
||||
this.matchSendingStatus = false,
|
||||
this.matchAttachments = false,
|
||||
this.matchAttachmentsUploadState = false,
|
||||
});
|
||||
|
||||
final Message targetMessage;
|
||||
final bool matchText;
|
||||
final bool matchReactions;
|
||||
final bool matchSendingStatus;
|
||||
final bool matchAttachments;
|
||||
final bool matchAttachmentsUploadState;
|
||||
|
||||
@override
|
||||
Description describe(Description description) =>
|
||||
@@ -96,6 +105,56 @@ class _IsSameMessageAs extends Matcher {
|
||||
?.map((it) => '${it.type}-${it.messageId}')
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
✅ 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
|
||||
|
||||
- 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
|
||||
there are no actions available to show.
|
||||
- [[#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.
|
||||
- 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.
|
||||
- Removed default padding in `DateDivider` in `MessageListView`
|
||||
|
||||
✅ Added
|
||||
|
||||
- Added `MessageInput.customPortalOptions` property to add custom overlays to the `MessageInput`.
|
||||
|
||||
## 2.2.1
|
||||
|
||||
⚠️ Deprecated
|
||||
|
||||
@@ -21,6 +21,6 @@
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>8.0</string>
|
||||
<string>9.0</string>
|
||||
</dict>
|
||||
</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: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<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
|
||||
/// 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(
|
||||
|
||||
@@ -2,27 +2,30 @@ import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:photo_manager/photo_manager.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_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/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/stream_chat_theme.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_thumbnail_image.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.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';
|
||||
|
||||
export 'package:video_compress/video_compress.dart' show VideoQuality;
|
||||
@@ -53,13 +56,20 @@ typedef AttachmentThumbnailBuilder = Widget Function(
|
||||
Attachment,
|
||||
);
|
||||
|
||||
/// Builder function for building a mention tile
|
||||
/// Use [MentionTile] for the default implementation
|
||||
/// Builder function for building a mention tile.
|
||||
typedef MentionTileBuilder = Widget Function(
|
||||
BuildContext context,
|
||||
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.
|
||||
///
|
||||
/// [defaultActionButton] is the default [IconButton] configuration,
|
||||
@@ -169,7 +179,7 @@ class MessageInput extends StatefulWidget {
|
||||
this.disableAttachments = false,
|
||||
this.initialMessage,
|
||||
this.textEditingController,
|
||||
this.actions,
|
||||
this.actions = const [],
|
||||
this.actionsLocation = ActionsLocation.left,
|
||||
this.attachmentThumbnailBuilders,
|
||||
this.focusNode,
|
||||
@@ -181,7 +191,9 @@ class MessageInput extends StatefulWidget {
|
||||
this.idleSendButton,
|
||||
this.activeSendButton,
|
||||
this.showCommandsButton = true,
|
||||
this.mentionsTileBuilder,
|
||||
@Deprecated('''Use `userMentionsTileBuilder` instead. Will be removed in future release''')
|
||||
this.mentionsTileBuilder,
|
||||
this.userMentionsTileBuilder,
|
||||
this.maxAttachmentSize = _kDefaultMaxAttachmentSize,
|
||||
this.compressedVideoQuality = VideoQuality.DefaultQuality,
|
||||
this.compressedVideoFrameRate = 30,
|
||||
@@ -190,12 +202,17 @@ class MessageInput extends StatefulWidget {
|
||||
this.onAttachmentLimitExceed,
|
||||
this.attachmentButtonBuilder,
|
||||
this.commandButtonBuilder,
|
||||
this.customOverlays = const [],
|
||||
this.mentionAllAppUsers = false,
|
||||
}) : assert(
|
||||
initialMessage == null || editMessage == null,
|
||||
"Can't provide both `initialMessage` and `editMessage`",
|
||||
),
|
||||
super(key: key);
|
||||
|
||||
/// List of options for showing overlays
|
||||
final List<OverlayOptions> customOverlays;
|
||||
|
||||
/// Message to edit
|
||||
final Message? editMessage;
|
||||
|
||||
@@ -242,7 +259,7 @@ class MessageInput extends StatefulWidget {
|
||||
final TextEditingController? textEditingController;
|
||||
|
||||
/// List of action widgets
|
||||
final List<Widget>? actions;
|
||||
final List<Widget> actions;
|
||||
|
||||
/// The location of the custom actions
|
||||
final ActionsLocation actionsLocation;
|
||||
@@ -271,9 +288,12 @@ class MessageInput extends StatefulWidget {
|
||||
/// Send button widget in an active state
|
||||
final Widget? activeSendButton;
|
||||
|
||||
/// Customize the tile for the mentions overlay
|
||||
/// Customize the tile for the mentions overlay.
|
||||
final MentionTileBuilder? mentionsTileBuilder;
|
||||
|
||||
/// Customize the tile for the mentions overlay.
|
||||
final UserMentionTileBuilder? userMentionsTileBuilder;
|
||||
|
||||
/// A callback for error reporting
|
||||
final ErrorListener? onError;
|
||||
|
||||
@@ -297,6 +317,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();
|
||||
|
||||
@@ -318,11 +343,11 @@ class MessageInputState extends State<MessageInput> {
|
||||
final List<User> _mentionedUsers = [];
|
||||
|
||||
final _imagePicker = ImagePicker();
|
||||
late final FocusNode _focusNode;
|
||||
late final _focusNode = widget.focusNode ?? FocusNode();
|
||||
bool _inputEnabled = true;
|
||||
bool _commandEnabled = false;
|
||||
OverlayEntry? _commandsOverlay, _mentionsOverlay, _emojiOverlay;
|
||||
late Iterable<String> _emojiNames;
|
||||
bool _showCommandsOverlay = false;
|
||||
bool _showMentionsOverlay = false;
|
||||
|
||||
Command? _chosenCommand;
|
||||
bool _actionsShrunk = false;
|
||||
@@ -330,10 +355,9 @@ class MessageInputState extends State<MessageInput> {
|
||||
bool _openFilePickerSection = false;
|
||||
int _filePickerIndex = 0;
|
||||
|
||||
final _keyboardVisibilityController = KeyboardVisibilityController();
|
||||
|
||||
/// The editing controller passed to the input TextField
|
||||
late final TextEditingController textEditingController;
|
||||
late final TextEditingController textEditingController =
|
||||
widget.textEditingController ?? TextEditingController();
|
||||
|
||||
late StreamChatThemeData _streamChatTheme;
|
||||
late MessageInputThemeData _messageInputTheme;
|
||||
@@ -345,29 +369,10 @@ class MessageInputState extends State<MessageInput> {
|
||||
@override
|
||||
void 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) {
|
||||
_parseExistingMessage(widget.editMessage ?? widget.initialMessage!);
|
||||
}
|
||||
|
||||
textEditingController.addListener(() {
|
||||
_onChanged(context, textEditingController.text);
|
||||
});
|
||||
|
||||
textEditingController.addListener(_onChangedDebounced);
|
||||
_focusNode.addListener(() {
|
||||
if (_focusNode.hasFocus) {
|
||||
_openFilePickerSection = false;
|
||||
@@ -472,7 +477,35 @@ class MessageInputState extends State<MessageInput> {
|
||||
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(
|
||||
@@ -604,7 +637,7 @@ class MessageInputState extends State<MessageInput> {
|
||||
),
|
||||
secondChild: widget.disableAttachments &&
|
||||
!widget.showCommandsButton &&
|
||||
widget.actions?.isNotEmpty != true
|
||||
widget.actions.isNotEmpty != true
|
||||
? const Offstage()
|
||||
: Wrap(
|
||||
children: <Widget>[
|
||||
@@ -615,7 +648,7 @@ class MessageInputState extends State<MessageInput> {
|
||||
channel.state != null &&
|
||||
channel.config?.commands.isNotEmpty == true)
|
||||
_buildCommandButton(context),
|
||||
...widget.actions ?? [],
|
||||
...widget.actions,
|
||||
].insertBetween(const SizedBox(width: 8)),
|
||||
),
|
||||
duration: const Duration(milliseconds: 300),
|
||||
@@ -783,51 +816,32 @@ class MessageInputState extends State<MessageInput> {
|
||||
).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) {
|
||||
if (s == _previousValue) {
|
||||
return;
|
||||
}
|
||||
_previousValue = s;
|
||||
var actionsLength = widget.actions.length;
|
||||
if (widget.showCommandsButton) actionsLength += 1;
|
||||
if (!widget.disableAttachments) actionsLength += 1;
|
||||
|
||||
if (_debounce?.isActive == true) _debounce!.cancel();
|
||||
_debounce = Timer(
|
||||
const Duration(milliseconds: 350),
|
||||
() {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
StreamChannel.of(context)
|
||||
.channel
|
||||
.keyStroke(widget.parentMessage?.id)
|
||||
.catchError((e) {});
|
||||
setState(() {
|
||||
_actionsShrunk = value.isNotEmpty && actionsLength > 1;
|
||||
});
|
||||
|
||||
setState(() {
|
||||
_actionsShrunk = s.trim().isNotEmpty &&
|
||||
((widget.actions?.length ?? 0) +
|
||||
(widget.showCommandsButton ? 1 : 0) +
|
||||
(widget.disableAttachments ? 0 : 1) >
|
||||
1);
|
||||
});
|
||||
|
||||
_commandsOverlay?.remove();
|
||||
_commandsOverlay = null;
|
||||
_mentionsOverlay?.remove();
|
||||
_mentionsOverlay = null;
|
||||
_emojiOverlay?.remove();
|
||||
_emojiOverlay = null;
|
||||
|
||||
_checkCommands(s.trim(), context);
|
||||
|
||||
_checkMentions(s, context);
|
||||
|
||||
_checkEmoji(s, context);
|
||||
},
|
||||
);
|
||||
}
|
||||
_checkCommands(value, context);
|
||||
_checkMentions(value, context);
|
||||
_checkEmoji(value, context);
|
||||
},
|
||||
const Duration(milliseconds: 350),
|
||||
leading: true,
|
||||
);
|
||||
|
||||
String _getHint(BuildContext context) {
|
||||
if (_commandEnabled && _chosenCommand!.name == 'giphy') {
|
||||
@@ -847,10 +861,7 @@ class MessageInputState extends State<MessageInput> {
|
||||
if (s.isNotEmpty &&
|
||||
textEditingController.selection.baseOffset > 0 &&
|
||||
textEditingController.text
|
||||
.substring(
|
||||
0,
|
||||
textEditingController.selection.baseOffset,
|
||||
)
|
||||
.substring(0, textEditingController.selection.baseOffset)
|
||||
.contains(':')) {
|
||||
final textToSelection = textEditingController.text
|
||||
.substring(0, textEditingController.value.selection.start);
|
||||
@@ -860,12 +871,6 @@ class MessageInputState extends State<MessageInput> {
|
||||
|
||||
if (textToSelection.endsWith(':') && emoji != null) {
|
||||
_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(' ')
|
||||
.last
|
||||
.contains('@')) {
|
||||
_mentionsOverlay = _buildMentionsOverlayEntry();
|
||||
if (_mentionsOverlay != null) {
|
||||
Overlay.of(context)!.insert(_mentionsOverlay!);
|
||||
if (!_showMentionsOverlay) {
|
||||
setState(() {
|
||||
_showMentionsOverlay = true;
|
||||
});
|
||||
}
|
||||
} else if (_showMentionsOverlay) {
|
||||
setState(() {
|
||||
_showMentionsOverlay = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _checkCommands(String s, BuildContext context) {
|
||||
if (s.startsWith('/')) {
|
||||
final matchedCommandsList = StreamChannel.of(context)
|
||||
.channel
|
||||
.config
|
||||
?.commands
|
||||
.where((element) => element.name == s.substring(1))
|
||||
.toList() ??
|
||||
[];
|
||||
|
||||
if (matchedCommandsList.length == 1) {
|
||||
_chosenCommand = matchedCommandsList[0];
|
||||
textEditingController.clear();
|
||||
final allCommands = StreamChannel.of(context).channel.config?.commands;
|
||||
final command =
|
||||
allCommands?.firstWhereOrNull((it) => it.name == s.substring(1));
|
||||
if (command != null) {
|
||||
return _setCommand(command);
|
||||
} else if (!_showCommandsOverlay) {
|
||||
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 commands = StreamChannel.of(context)
|
||||
.channel
|
||||
.config
|
||||
?.commands
|
||||
.where((c) => c.name.contains(text.replaceFirst('/', '')))
|
||||
.toList() ??
|
||||
[];
|
||||
|
||||
if (commands.isEmpty) {
|
||||
return null;
|
||||
final renderObject = context.findRenderObject() as RenderBox?;
|
||||
if (renderObject == null) {
|
||||
return const Offstage();
|
||||
}
|
||||
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
final renderBox = context.findRenderObject() as RenderBox;
|
||||
final size = renderBox.size;
|
||||
|
||||
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 CommandsOverlay(
|
||||
channel: StreamChannel.of(context).channel,
|
||||
size: Size(renderObject.size.width - 16, 400),
|
||||
text: text,
|
||||
onCommandResult: _setCommand,
|
||||
);
|
||||
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() {
|
||||
@@ -1087,7 +976,9 @@ class MessageInputState extends State<MessageInput> {
|
||||
}
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
duration: _openFilePickerSection
|
||||
? const Duration(milliseconds: 300)
|
||||
: const Duration(),
|
||||
curve: Curves.easeOut,
|
||||
height: _openFilePickerSection ? _kMinMediaPickerSize : 0,
|
||||
child: SingleChildScrollView(
|
||||
@@ -1261,300 +1152,78 @@ class MessageInputState extends State<MessageInput> {
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildCommandIcon(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,
|
||||
),
|
||||
);
|
||||
Widget _buildMentionsOverlayEntry() {
|
||||
if (textEditingController.value.selection.start < 0) {
|
||||
return const Offstage();
|
||||
}
|
||||
}
|
||||
|
||||
OverlayEntry? _buildMentionsOverlayEntry() {
|
||||
final splits = textEditingController.text
|
||||
.substring(0, textEditingController.value.selection.start)
|
||||
.split('@');
|
||||
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
|
||||
final renderBox = 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!);
|
||||
}
|
||||
final renderObject = context.findRenderObject() as RenderBox;
|
||||
|
||||
splits[splits.length - 1] = m.user!.name;
|
||||
final rejoin = splits.join('@');
|
||||
var tileBuilder = widget.userMentionsTileBuilder;
|
||||
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(
|
||||
text: rejoin +
|
||||
textEditingController.text.substring(
|
||||
textEditingController.selection.start),
|
||||
selection: TextSelection.collapsed(
|
||||
offset: rejoin.length,
|
||||
),
|
||||
);
|
||||
_debounce!.cancel();
|
||||
_mentionsOverlay?.remove();
|
||||
_mentionsOverlay = null;
|
||||
},
|
||||
child: widget.mentionsTileBuilder != null
|
||||
? widget.mentionsTileBuilder!(context, m)
|
||||
: MentionTile(m),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
],
|
||||
return UserMentionsOverlay(
|
||||
query: query,
|
||||
mentionAllAppUsers: widget.mentionAllAppUsers,
|
||||
client: StreamChat.of(context).client,
|
||||
channel: StreamChannel.of(context).channel,
|
||||
size: Size(renderObject.size.width - 16, 400),
|
||||
mentionsTileBuilder: tileBuilder,
|
||||
onMentionUserTap: (user) {
|
||||
_mentionedUsers.add(user);
|
||||
splits[splits.length - 1] = user.name;
|
||||
final rejoin = splits.join('@');
|
||||
|
||||
textEditingController.value = TextEditingValue(
|
||||
text: rejoin +
|
||||
textEditingController.text
|
||||
.substring(textEditingController.selection.start),
|
||||
selection: TextSelection.collapsed(
|
||||
offset: rejoin.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
_onChangedDebounced.cancel();
|
||||
setState(() => _showMentionsOverlay = false);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
OverlayEntry? _buildEmojiOverlay() {
|
||||
Widget _buildEmojiOverlay() {
|
||||
if (textEditingController.value.selection.baseOffset < 0) {
|
||||
return const Offstage();
|
||||
}
|
||||
|
||||
final splits = textEditingController.text
|
||||
.substring(0, textEditingController.value.selection.start)
|
||||
.substring(0, textEditingController.value.selection.baseOffset)
|
||||
.split(':');
|
||||
|
||||
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
|
||||
final renderBox = context.findRenderObject() as RenderBox;
|
||||
final size = renderBox.size;
|
||||
final renderObject = context.findRenderObject() as RenderBox;
|
||||
|
||||
return OverlayEntry(
|
||||
builder: (context) => Positioned(
|
||||
bottom: size.height + MediaQuery.of(context).viewInsets.bottom,
|
||||
left: 0,
|
||||
right: 0,
|
||||
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);
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
));
|
||||
return EmojiOverlay(
|
||||
size: Size(renderObject.size.width - 16, 200),
|
||||
query: query,
|
||||
onEmojiResult: (emoji) {
|
||||
_chooseEmoji(splits, emoji);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _chooseEmoji(List<String> splits, Emoji emoji) {
|
||||
@@ -1568,9 +1237,6 @@ class MessageInputState extends State<MessageInput> {
|
||||
offset: rejoin.length,
|
||||
),
|
||||
);
|
||||
|
||||
_emojiOverlay?.remove();
|
||||
_emojiOverlay = null;
|
||||
}
|
||||
|
||||
void _setCommand(Command c) {
|
||||
@@ -1578,9 +1244,8 @@ class MessageInputState extends State<MessageInput> {
|
||||
setState(() {
|
||||
_chosenCommand = c;
|
||||
_commandEnabled = true;
|
||||
_showCommandsOverlay = false;
|
||||
});
|
||||
_commandsOverlay?.remove();
|
||||
_commandsOverlay = null;
|
||||
}
|
||||
|
||||
Widget _buildReplyToMessage() {
|
||||
@@ -1773,7 +1438,7 @@ class MessageInputState extends State<MessageInput> {
|
||||
icon: StreamSvgIcon.lightning(
|
||||
color: s.isNotEmpty
|
||||
? _streamChatTheme.colorTheme.disabled
|
||||
: (_commandsOverlay != null
|
||||
: (_showCommandsOverlay
|
||||
? _messageInputTheme.actionButtonColor
|
||||
: _messageInputTheme.actionButtonIdleColor),
|
||||
),
|
||||
@@ -1789,19 +1454,9 @@ class MessageInputState extends State<MessageInput> {
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
}
|
||||
|
||||
if (_commandsOverlay == null) {
|
||||
setState(() {
|
||||
_commandsOverlay = _buildCommandsOverlayEntry();
|
||||
if (_commandsOverlay != null) {
|
||||
Overlay.of(context)!.insert(_commandsOverlay!);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_commandsOverlay?.remove();
|
||||
_commandsOverlay = null;
|
||||
});
|
||||
}
|
||||
setState(() {
|
||||
_showCommandsOverlay = !_showCommandsOverlay;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1823,12 +1478,8 @@ class MessageInputState extends State<MessageInput> {
|
||||
),
|
||||
splashRadius: 24,
|
||||
onPressed: () async {
|
||||
_emojiOverlay?.remove();
|
||||
_emojiOverlay = null;
|
||||
_commandsOverlay?.remove();
|
||||
_commandsOverlay = null;
|
||||
_mentionsOverlay?.remove();
|
||||
_mentionsOverlay = null;
|
||||
_showCommandsOverlay = false;
|
||||
_showMentionsOverlay = false;
|
||||
|
||||
if (_openFilePickerSection) {
|
||||
setState(() => _openFilePickerSection = false);
|
||||
@@ -2114,11 +1765,6 @@ class MessageInputState extends State<MessageInput> {
|
||||
_commandEnabled = false;
|
||||
});
|
||||
|
||||
_commandsOverlay?.remove();
|
||||
_commandsOverlay = null;
|
||||
_mentionsOverlay?.remove();
|
||||
_mentionsOverlay = null;
|
||||
|
||||
Message message;
|
||||
if (widget.editMessage != null) {
|
||||
message = widget.editMessage!.copyWith(
|
||||
@@ -2185,8 +1831,6 @@ class MessageInputState extends State<MessageInput> {
|
||||
}
|
||||
}
|
||||
|
||||
StreamSubscription? _keyboardListener;
|
||||
|
||||
void _showErrorAlert(String description) {
|
||||
showModalBottomSheet(
|
||||
backgroundColor: _streamChatTheme.colorTheme.barsBg,
|
||||
@@ -2260,12 +1904,9 @@ class MessageInputState extends State<MessageInput> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_commandsOverlay?.remove();
|
||||
_emojiOverlay?.remove();
|
||||
_mentionsOverlay?.remove();
|
||||
_keyboardListener?.cancel();
|
||||
textEditingController.dispose();
|
||||
_stopSlowMode();
|
||||
_onChangedDebounced.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
@@ -580,11 +580,12 @@ class _MessageWidgetState extends State<MessageWidget>
|
||||
|
||||
bool get isOnlyEmoji => widget.message.text?.isOnlyEmoji == true;
|
||||
|
||||
bool get hasNonUrlAttachments =>
|
||||
widget.message.attachments.where((it) => it.titleLink == null).isNotEmpty;
|
||||
bool get hasNonUrlAttachments => widget.message.attachments
|
||||
.where((it) => it.titleLink == null || it.type == 'giphy')
|
||||
.isNotEmpty;
|
||||
|
||||
bool get hasUrlAttachments =>
|
||||
widget.message.attachments.any((it) => it.titleLink != null) == true;
|
||||
bool get hasUrlAttachments => widget.message.attachments
|
||||
.any((it) => it.titleLink != null && it.type != 'giphy');
|
||||
|
||||
bool get showBottomRow =>
|
||||
showThreadReplyIndicator ||
|
||||
@@ -1156,7 +1157,9 @@ class _MessageWidgetState extends State<MessageWidget>
|
||||
final attachmentGroups = <String, List<Attachment>>{};
|
||||
|
||||
widget.message.attachments
|
||||
.where((element) => element.titleLink == null && element.type != null)
|
||||
.where((element) =>
|
||||
(element.titleLink == null && element.type != null) ||
|
||||
element.type == 'giphy')
|
||||
.forEach((e) {
|
||||
if (attachmentGroups[e.type] == null) {
|
||||
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 =
|
||||
this.placeholder ?? streamChatTheme.placeholderUserImage;
|
||||
|
||||
final backupGradientAvatar = ClipRRect(
|
||||
borderRadius: borderRadius ??
|
||||
streamChatTheme.ownMessageTheme.avatarTheme?.borderRadius,
|
||||
child: streamChatTheme.defaultUserImage(context, user),
|
||||
);
|
||||
|
||||
Widget avatar = FittedBox(
|
||||
fit: BoxFit.cover,
|
||||
child: Container(
|
||||
@@ -76,8 +82,7 @@ class UserAvatar extends StatelessWidget {
|
||||
fit: BoxFit.cover,
|
||||
filterQuality: FilterQuality.high,
|
||||
imageUrl: user.image!,
|
||||
errorWidget: (context, __, ___) =>
|
||||
streamChatTheme.defaultUserImage(context, user),
|
||||
errorWidget: (context, __, ___) => backupGradientAvatar,
|
||||
placeholder: placeholder != null
|
||||
? (context, __) => placeholder(context, user)
|
||||
: null,
|
||||
@@ -91,11 +96,7 @@ class UserAvatar extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
)
|
||||
: ClipRRect(
|
||||
borderRadius: borderRadius ??
|
||||
streamChatTheme.ownMessageTheme.avatarTheme?.borderRadius,
|
||||
child: streamChatTheme.defaultUserImage(context, user),
|
||||
),
|
||||
: backupGradientAvatar,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -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:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:stream_chat_flutter/src/extension.dart';
|
||||
@@ -388,3 +389,39 @@ class Tuple2<T1, T2> {
|
||||
@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<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_item.dart';
|
||||
export 'src/user_list_view.dart';
|
||||
export 'src/user_mention_tile.dart';
|
||||
export 'src/utils.dart';
|
||||
export 'src/visible_footnote.dart';
|
||||
|
||||
@@ -14,12 +14,12 @@ 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
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_keyboard_visibility: ^5.0.1
|
||||
flutter_markdown: ^0.6.1
|
||||
flutter_portal: ^0.4.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 clientState = MockClientState();
|
||||
final channel = MockChannel();
|
||||
final channelState = MockChannelState();
|
||||
|
||||
when(() => client.state).thenReturn(clientState);
|
||||
when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id'));
|
||||
when(() => channel.state).thenReturn(channelState);
|
||||
|
||||
final themeData = ThemeData();
|
||||
final streamTheme = StreamChatThemeData.fromTheme(themeData);
|
||||
|
||||
@@ -152,13 +152,9 @@ class StreamChannelState extends State<StreamChannel> {
|
||||
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<StreamChannel> {
|
||||
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
|
||||
Future<void> reloadChannel() => _queryAtMessage(before: 30);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user