Merge branch 'develop' into hotfix/messageListView
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user