Merge branch 'message-input-controller' of https://github.com/GetStream/stream-chat-flutter into message-input-controller

This commit is contained in:
Deven Joshi
2021-12-13 22:00:41 +05:30
86 changed files with 1568 additions and 911 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
flutter-version: ${{ env.flutter_version }} flutter-version: ${{ env.flutter_version }}
- name: "Install Tools" - name: "Install Tools"
run: flutter pub global activate melos 1.0.0-dev.3 run: flutter pub global activate melos 1.0.0-dev.10
- name: "Bootstrap Workspace" - name: "Bootstrap Workspace"
run: melos bootstrap run: melos bootstrap
@@ -3,6 +3,7 @@ name: stream_flutter_workflow
env: env:
ACTIONS_ALLOW_UNSECURE_COMMANDS: 'true' ACTIONS_ALLOW_UNSECURE_COMMANDS: 'true'
flutter_version: "2.5.1" flutter_version: "2.5.1"
melos_version: "1.0.0-dev.10"
on: on:
pull_request: pull_request:
@@ -31,7 +32,7 @@ jobs:
flutter-version: ${{ env.flutter_version }} flutter-version: ${{ env.flutter_version }}
- name: "Install Tools" - name: "Install Tools"
run: | run: |
flutter pub global activate melos 1.0.0-dev.3 flutter pub global activate melos ${{ env.melos_version }}
- name: "Bootstrap Workspace" - name: "Bootstrap Workspace"
run: melos bootstrap run: melos bootstrap
- name: "Dart Analyze" - name: "Dart Analyze"
@@ -60,7 +61,8 @@ jobs:
with: with:
flutter-version: ${{ env.flutter_version }} flutter-version: ${{ env.flutter_version }}
- name: "Install Tools" - name: "Install Tools"
run: flutter pub global activate melos run: |
flutter pub global activate melos ${{ env.melos_version }}
- name: "Bootstrap Workspace" - name: "Bootstrap Workspace"
run: melos bootstrap run: melos bootstrap
- name: "Melos Format" - name: "Melos Format"
@@ -71,7 +73,7 @@ jobs:
test: test:
runs-on: macos-latest runs-on: macos-latest
timeout-minutes: 15 timeout-minutes: 20
steps: steps:
- name: "Git Checkout" - name: "Git Checkout"
uses: actions/checkout@v2 uses: actions/checkout@v2
@@ -88,7 +90,7 @@ jobs:
flutter-version: ${{ env.flutter_version }} flutter-version: ${{ env.flutter_version }}
- name: "Install Tools" - name: "Install Tools"
run: | run: |
flutter pub global activate melos flutter pub global activate melos ${{ env.melos_version }}
pub global activate remove_from_coverage pub global activate remove_from_coverage
- name: "Bootstrap Workspace" - name: "Bootstrap Workspace"
run: melos bootstrap run: melos bootstrap
-12
View File
@@ -154,18 +154,6 @@ dart_code_metrics:
# Dart Specific # Dart Specific
- binary-expression-operand-order - binary-expression-operand-order
- double-literal-format - double-literal-format
- prefer-match-file-name:
exclude:
- packages/*/test/**
- packages/*/example/**
- packages/**/util/**
- packages/**/utils.dart
- packages/stream_chat/lib/src/client/client.dart
- packages/stream_chat/lib/src/core/api/responses.dart
- packages/stream_chat/lib/src/core/api/requests.dart
- packages/stream_chat/lib/src/core/platform_detector/**
- packages/stream_chat_persistence/lib/src/db/shared/**
- packages/stream_chat_localizations/lib/src/stream_chat_localizations.dart
- no-boolean-literal-compare - no-boolean-literal-compare
- no-equal-then-else - no-equal-then-else
- no-empty-block: - no-empty-block:
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 259 KiB

@@ -10,7 +10,7 @@ Customizing Message Actions
Message actions pop up in message overlay, when you long-press a message. Message actions pop up in message overlay, when you long-press a message.
![](../assets/message_actions.png) ![](../assets/message_actions.jpg)
We have provided granular control over these actions. We have provided granular control over these actions.
+28
View File
@@ -1,3 +1,31 @@
## Upcoming
✅ Added
- Added `client.enrichUrl` endpoint for enriching URLs with metadata.
## 3.3.1
🐞 Fixed
- [[#799]](https://github.com/GetStream/stream-chat-flutter/issues/799) Fixed `totalUnreadCount` is not updating when
app is resumed from background mode.
- Fix retry mechanism failing in some cases.
## 3.3.0
✅ Added
- Extra properties added to `PaginationParams` to aid in fetching messages.
- Added hard delete functionality.
🐞 Fixed
- `closeConnection()` now uses `normalClosure` status when closing websocket.
- Fixed local unread count indicator increasing for thread replies.
- Fixed user presence indicator not updating correctly.
- `ChannelEvent.membersCount` defaults to 0 avoiding parsing errors due to missing `members_count` field.
## 3.2.0 ## 3.2.0
🐞 Fixed 🐞 Fixed
+2 -2
View File
@@ -233,10 +233,10 @@ class _MessageViewState extends State<MessageView> {
), ),
), ),
), ),
) ),
], ],
), ),
) ),
], ],
); );
} }
@@ -648,7 +648,7 @@ class Channel {
} }
/// Deletes the [message] from the channel. /// Deletes the [message] from the channel.
Future<EmptyResponse> deleteMessage(Message message) async { Future<EmptyResponse> deleteMessage(Message message, {bool? hard}) async {
// Directly deleting the local messages which are not yet sent to server // Directly deleting the local messages which are not yet sent to server
if (message.status == MessageSendingStatus.sending || if (message.status == MessageSendingStatus.sending ||
message.status == MessageSendingStatus.failed) { message.status == MessageSendingStatus.failed) {
@@ -675,7 +675,7 @@ class Channel {
state?.addMessage(message); state?.addMessage(message);
final response = await _client.deleteMessage(message.id); final response = await _client.deleteMessage(message.id, hard: hard);
state?.addMessage(message.copyWith(status: MessageSendingStatus.sent)); state?.addMessage(message.copyWith(status: MessageSendingStatus.sent));
@@ -1466,8 +1466,6 @@ class ChannelClientState {
_listenMemberRemoved(); _listenMemberRemoved();
_computeUnread();
_startCleaning(); _startCleaning();
_startCleaningPinnedMessages(); _startCleaningPinnedMessages();
@@ -1490,15 +1488,6 @@ class ChannelClientState {
final _subscriptions = <StreamSubscription>[]; final _subscriptions = <StreamSubscription>[];
void _computeUnread() {
final userRead = channelState.read.firstWhereOrNull(
(r) => r.user.id == _channel._client.state.currentUser?.id,
);
if (userRead != null && userRead.unreadMessages > 0) {
unreadCount = userRead.unreadMessages;
}
}
void _checkExpiredAttachmentMessages(ChannelState channelState) async { void _checkExpiredAttachmentMessages(ChannelState channelState) async {
final expiredAttachmentMessagesId = channelState.messages final expiredAttachmentMessagesId = channelState.messages
.where((m) => .where((m) =>
@@ -1603,7 +1592,7 @@ class ChannelClientState {
message.createdAt.isBefore( message.createdAt.isBefore(
DateTime.now().subtract( DateTime.now().subtract(
const Duration( const Duration(
seconds: 1, seconds: 5,
), ),
), ),
), ),
@@ -1663,7 +1652,11 @@ class ChannelClientState {
void _listenMessageDeleted() { void _listenMessageDeleted() {
_subscriptions.add(_channel.on(EventType.messageDeleted).listen((event) { _subscriptions.add(_channel.on(EventType.messageDeleted).listen((event) {
final message = event.message!; final message = event.message!;
addMessage(message); if (event.hardDelete == true) {
removeMessage(message, hardDelete: true);
} else {
addMessage(message);
}
})); }));
} }
@@ -1705,7 +1698,7 @@ class ChannelClientState {
} }
_channelState = _channelState.copyWith( _channelState = _channelState.copyWith(
messages: newMessages, messages: newMessages..sort(_sortByCreatedAt),
channel: _channelState.channel?.copyWith( channel: _channelState.channel?.copyWith(
lastMessageAt: message.createdAt, lastMessageAt: message.createdAt,
), ),
@@ -1718,7 +1711,7 @@ class ChannelClientState {
} }
/// Remove a [message] from this [channelState]. /// Remove a [message] from this [channelState].
void removeMessage(Message message) { void removeMessage(Message message, {bool hardDelete = false}) {
final parentId = message.parentId; final parentId = message.parentId;
// i.e. it's a thread message // i.e. it's a thread message
// 1. Remove the thread message // 1. Remove the thread message
@@ -1740,7 +1733,10 @@ class ChannelClientState {
} else { } else {
// Remove regular message // Remove regular message
final allMessages = [...messages]; final allMessages = [...messages];
if (allMessages.remove(message)) { if (hardDelete) {
allMessages.removeWhere((e) => e.id == message.id);
_channelState = _channelState.copyWith(messages: allMessages);
} else if (allMessages.remove(message)) {
_channelState = _channelState.copyWith(messages: allMessages); _channelState = _channelState.copyWith(messages: allMessages);
} }
} }
@@ -1843,15 +1839,34 @@ class ChannelClientState {
/// Channel read list as a stream. /// Channel read list as a stream.
Stream<List<Read>> get readStream => channelStateStream.map((cs) => cs.read); Stream<List<Read>> get readStream => channelStateStream.map((cs) => cs.read);
final BehaviorSubject<int> _unreadCountController = BehaviorSubject.seeded(0); bool _isCurrentUserRead(Read read) =>
read.user.id == _channel._client.state.currentUser!.id;
set unreadCount(int value) => _unreadCountController.add(value); /// Channel read for the logged in user.
Read? get currentUserRead => read.firstWhereOrNull(_isCurrentUserRead);
/// Channel read for the logged in user as a stream.
Stream<Read?> get currentUserReadStream =>
readStream.map((read) => read.firstWhereOrNull(_isCurrentUserRead));
/// Unread count getter as a stream. /// Unread count getter as a stream.
Stream<int> get unreadCountStream => _unreadCountController.stream.distinct(); Stream<int> get unreadCountStream =>
currentUserReadStream.map((read) => read?.unreadMessages ?? 0);
/// Unread count getter. /// Unread count getter.
int get unreadCount => _unreadCountController.value; int get unreadCount => currentUserRead?.unreadMessages ?? 0;
/// Setter for unread count.
set unreadCount(int count) {
final reads = [..._channelState.read];
final currentUserReadIndex = reads.indexWhere(_isCurrentUserRead);
if (currentUserReadIndex < 0) return;
reads[currentUserReadIndex] =
reads[currentUserReadIndex].copyWith(unreadMessages: count);
_channelState = _channelState.copyWith(read: reads);
}
bool _countMessageAsUnread(Message message) { bool _countMessageAsUnread(Message message) {
final userId = _channel.client.state.currentUser?.id; final userId = _channel.client.state.currentUser?.id;
@@ -1860,10 +1875,13 @@ class ChannelClientState {
(m) => m.user.id == message.user?.id, (m) => m.user.id == message.user?.id,
) != ) !=
null; null;
final isThreadMessage = message.parentId != null;
return !message.silent && return !message.silent &&
!message.shadowed && !message.shadowed &&
message.user?.id != userId && message.user?.id != userId &&
!userIsMuted; !userIsMuted &&
!isThreadMessage;
} }
/// Update threads with updated information about messages. /// Update threads with updated information about messages.
@@ -2108,7 +2126,6 @@ class ChannelClientState {
/// Call this method to dispose this object. /// Call this method to dispose this object.
void dispose() { void dispose() {
_debouncedUpdatePersistenceChannelState.cancel(); _debouncedUpdatePersistenceChannelState.cancel();
_unreadCountController.close();
_retryQueue.dispose(); _retryQueue.dispose();
_subscriptions.forEach((s) => s.cancel()); _subscriptions.forEach((s) => s.cancel());
_channelStateController.close(); _channelStateController.close();
@@ -44,10 +44,6 @@ final _levelEmojiMapper = {
Level.SEVERE: '🚨', Level.SEVERE: '🚨',
}; };
final _userAgent = 'stream-chat-dart-client-'
'${CurrentPlatform.name}-'
'${PACKAGE_VERSION.split('+')[0]}';
/// The official Dart client for Stream Chat, /// The official Dart client for Stream Chat,
/// a service for building chat applications. /// a service for building chat applications.
/// This library can be used on any Dart project and on both mobile and web apps /// This library can be used on any Dart project and on both mobile and web apps
@@ -86,7 +82,7 @@ class StreamChatClient {
location: location, location: location,
connectTimeout: connectTimeout, connectTimeout: connectTimeout,
receiveTimeout: receiveTimeout, receiveTimeout: receiveTimeout,
headers: {'X-Stream-Client': _userAgent}, headers: {'X-Stream-Client': defaultUserAgent},
); );
_chatApi = chatApi ?? _chatApi = chatApi ??
@@ -106,7 +102,7 @@ class StreamChatClient {
tokenManager: _tokenManager, tokenManager: _tokenManager,
handler: handleEvent, handler: handleEvent,
logger: detachedLogger('🔌'), logger: detachedLogger('🔌'),
queryParameters: {'X-Stream-Client': _userAgent}, queryParameters: {'X-Stream-Client': defaultUserAgent},
); );
_retryPolicy = retryPolicy ?? _retryPolicy = retryPolicy ??
@@ -131,6 +127,14 @@ class StreamChatClient {
_originalChatPersistenceClient = value; _originalChatPersistenceClient = value;
} }
/// Default user agent for all requests
static String defaultUserAgent = 'stream-chat-dart-client-'
'${CurrentPlatform.name}-'
'${PACKAGE_VERSION.split('+')[0]}';
/// Additionals headers for all requests
static Map<String, Object?> additionalHeaders = {};
ChatPersistenceClient? _originalChatPersistenceClient; ChatPersistenceClient? _originalChatPersistenceClient;
/// Chat persistence client /// Chat persistence client
@@ -391,6 +395,9 @@ class StreamChatClient {
} }
void _handleHealthCheckEvent(Event event) { void _handleHealthCheckEvent(Event event) {
final user = event.me;
if (user != null) state.currentUser = user;
final connectionId = event.connectionId; final connectionId = event.connectionId;
if (connectionId != null) { if (connectionId != null) {
_connectionIdManager.setConnectionId(connectionId); _connectionIdManager.setConnectionId(connectionId);
@@ -1209,8 +1216,14 @@ class StreamChatClient {
); );
/// Deletes the given message /// Deletes the given message
Future<EmptyResponse> deleteMessage(String messageId) => Future<EmptyResponse> deleteMessage(String messageId, {bool? hard}) async {
_chatApi.message.deleteMessage(messageId); final response =
await _chatApi.message.deleteMessage(messageId, hard: hard);
if (hard == true) {
await _chatPersistenceClient?.deleteMessageById(messageId);
}
return response;
}
/// Get a message by [messageId] /// Get a message by [messageId]
Future<GetMessageResponse> getMessage(String messageId) => Future<GetMessageResponse> getMessage(String messageId) =>
@@ -1303,6 +1316,10 @@ class StreamChatClient {
}, },
); );
/// Get OpenGraph data of the given [url].
Future<OGAttachmentResponse> enrichUrl(String url) =>
_chatApi.general.enrichUrl(url);
/// Closes the [_ws] connection and resets the [state] /// Closes the [_ws] connection and resets the [state]
/// If [flushChatPersistence] is true the client deletes all offline /// If [flushChatPersistence] is true the client deletes all offline
/// user's data. /// user's data.
@@ -1428,6 +1445,7 @@ class ClientState {
.listen((Event event) async { .listen((Event event) async {
final eventChannel = event.channel!; final eventChannel = event.channel!;
await _client.chatPersistenceClient?.deleteChannels([eventChannel.cid]); await _client.chatPersistenceClient?.deleteChannels([eventChannel.cid]);
channels[eventChannel.cid]?.dispose();
channels = channels..remove(eventChannel.cid); channels = channels..remove(eventChannel.cid);
})); }));
} }
@@ -1,18 +1,13 @@
import 'dart:async'; import 'dart:async';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:logging/logging.dart';
import 'package:rxdart/rxdart.dart'; import 'package:rxdart/rxdart.dart';
import 'package:stream_chat/src/client/channel.dart';
import 'package:stream_chat/src/client/retry_policy.dart'; import 'package:stream_chat/src/client/retry_policy.dart';
import 'package:stream_chat/src/core/error/error.dart';
import 'package:stream_chat/src/core/models/message.dart';
import 'package:stream_chat/src/event_type.dart';
import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat/stream_chat.dart';
/// The retry queue associated to a channel /// The retry queue associated to a channel.
class RetryQueue { class RetryQueue {
/// Instantiate a new RetryQueue object /// Instantiate a new RetryQueue object.
RetryQueue({ RetryQueue({
required this.channel, required this.channel,
this.logger, this.logger,
@@ -22,13 +17,13 @@ class RetryQueue {
_listenFailedEvents(); _listenFailedEvents();
} }
/// The channel of this queue /// The channel of this queue.
final Channel channel; final Channel channel;
/// The client associated with this [channel] /// The client associated with this [channel].
final StreamChatClient client; final StreamChatClient client;
/// The logger associated to this queue /// The logger associated to this queue.
final Logger? logger; final Logger? logger;
late final RetryPolicy _retryPolicy; late final RetryPolicy _retryPolicy;
@@ -68,17 +63,18 @@ class RetryQueue {
}).addTo(_compositeSubscription); }).addTo(_compositeSubscription);
} }
/// Add a list of messages /// Add a list of messages.
void add(List<Message> messages) { void add(List<Message> messages) {
if (messages.isEmpty) return; if (messages.isEmpty) return;
if (_messageQueue.containsAllMessage(messages)) return; if (!_messageQueue.containsAllMessage(messages)) {
logger?.info('Adding ${messages.length} messages');
final messageList = _messageQueue.toList();
// we should not add message if already available in the queue
_messageQueue.addAll(messages.where(
(it) => !messageList.any((m) => m.id == it.id),
));
}
logger?.info('Adding ${messages.length} messages');
final messageList = _messageQueue.toList();
// we should not add message if already available in the queue
_messageQueue.addAll(messages.where(
(it) => !messageList.any((m) => m.id == it.id),
));
_startRetrying(); _startRetrying();
} }
@@ -90,17 +86,21 @@ class RetryQueue {
while (_messageQueue.isNotEmpty) { while (_messageQueue.isNotEmpty) {
logger?.info('${_messageQueue.length} messages remaining in the queue'); logger?.info('${_messageQueue.length} messages remaining in the queue');
final message = _messageQueue.first; final message = _messageQueue.first;
await _runAndRetry(message); final succeeded = await _runAndRetry(message);
if (!succeeded) {
_messageQueue.toList().forEach(_sendFailedEvent);
break;
}
} }
_isRetrying = false; _isRetrying = false;
} }
Future<void> _runAndRetry(Message message) async { Future<bool> _runAndRetry(Message message) async {
var attempt = 1; var attempt = 1;
final maxAttempt = _retryPolicy.maxRetryAttempts; final maxAttempt = _retryPolicy.maxRetryAttempts;
// early return in case maxAttempt is less than 0 // early return in case maxAttempt is less than 0
if (attempt > maxAttempt) return; if (attempt > maxAttempt) return false;
// ignore: literal_only_boolean_expressions // ignore: literal_only_boolean_expressions
while (true) { while (true) {
@@ -109,8 +109,13 @@ class RetryQueue {
await _retryMessage(message); await _retryMessage(message);
logger?.info('Message (${message.id}) sent successfully'); logger?.info('Message (${message.id}) sent successfully');
_messageQueue.removeMessage(message); _messageQueue.removeMessage(message);
break; return true;
} on StreamChatError catch (e) { } catch (e) {
if (e is! StreamChatNetworkError || !e.isRetriable) {
_messageQueue.removeMessage(message);
_sendFailedEvent(message);
return true;
}
// retry logic // retry logic
final maxAttempt = _retryPolicy.maxRetryAttempts; final maxAttempt = _retryPolicy.maxRetryAttempts;
if (attempt < maxAttempt) { if (attempt < maxAttempt) {
@@ -143,16 +148,9 @@ class RetryQueue {
_sendFailedEvent(message); _sendFailedEvent(message);
break; break;
} }
} catch (e) {
logger?.info(
'API call failed due to unknown error (attempt $attempt). '
'Giving up for now, will retry when connection recovers. '
'Error was $e',
);
_sendFailedEvent(message);
break;
} }
} }
return false;
} }
void _sendFailedEvent(Message message) { void _sendFailedEvent(Message message) {
@@ -177,10 +175,10 @@ class RetryQueue {
} }
} }
/// Whether our [_messageQueue] has messages or not /// Whether our [_messageQueue] has messages or not.
bool get hasMessages => _messageQueue.isNotEmpty; bool get hasMessages => _messageQueue.isNotEmpty;
/// Call this method to dispose this object /// Call this method to dispose this object.
void dispose() { void dispose() {
_messageQueue.clear(); _messageQueue.clear();
_compositeSubscription.dispose(); _compositeSubscription.dispose();
@@ -96,4 +96,16 @@ class GeneralApi {
return QueryMembersResponse.fromJson(response.data); return QueryMembersResponse.fromJson(response.data);
} }
/// Get OpenGraph data of the given [url].
Future<OGAttachmentResponse> enrichUrl(String url) async {
final response = await _client.get(
'/og',
queryParameters: {
'url': url,
},
);
return OGAttachmentResponse.fromJson(response.data);
}
} }
@@ -80,10 +80,16 @@ class MessageApi {
/// Deletes the given [messageId] /// Deletes the given [messageId]
Future<EmptyResponse> deleteMessage( Future<EmptyResponse> deleteMessage(
String messageId, String messageId, {
) async { bool? hard,
}) async {
final response = await _client.delete( final response = await _client.delete(
'/messages/$messageId', '/messages/$messageId',
queryParameters: hard != null
? {
'hard': hard,
}
: null,
); );
return EmptyResponse.fromJson(response.data); return EmptyResponse.fromJson(response.data);
} }
@@ -60,8 +60,11 @@ class PaginationParams extends Equatable {
/// ``` /// ```
const PaginationParams({ const PaginationParams({
this.limit = 10, this.limit = 10,
this.before = 10,
this.after = 10,
this.offset, this.offset,
this.next, this.next,
this.idAround,
this.greaterThan, this.greaterThan,
this.greaterThanOrEqual, this.greaterThanOrEqual,
this.lessThan, this.lessThan,
@@ -78,12 +81,22 @@ class PaginationParams extends Equatable {
/// The amount of items requested from the APIs. /// The amount of items requested from the APIs.
final int limit; final int limit;
/// The amount of items requested before message ID from the APIs.
final int before;
/// The amount of items requested after message ID from the APIs.
final int after;
/// The offset of requesting items. /// The offset of requesting items.
final int? offset; final int? offset;
/// A key used to paginate. /// A key used to paginate.
final String? next; final String? next;
/// Message ID to fetch messages around
@JsonKey(name: 'id_around')
final String? idAround;
/// Filter on ids greater than the given value. /// Filter on ids greater than the given value.
@JsonKey(name: 'id_gt') @JsonKey(name: 'id_gt')
final String? greaterThan; final String? greaterThan;
@@ -106,7 +119,10 @@ class PaginationParams extends Equatable {
/// Creates a copy of [PaginationParams] with specified attributes overridden. /// Creates a copy of [PaginationParams] with specified attributes overridden.
PaginationParams copyWith({ PaginationParams copyWith({
int? limit, int? limit,
int? before,
int? after,
int? offset, int? offset,
String? idAround,
String? next, String? next,
String? greaterThan, String? greaterThan,
String? greaterThanOrEqual, String? greaterThanOrEqual,
@@ -115,7 +131,10 @@ class PaginationParams extends Equatable {
}) => }) =>
PaginationParams( PaginationParams(
limit: limit ?? this.limit, limit: limit ?? this.limit,
before: before ?? this.before,
after: limit ?? this.after,
offset: offset ?? this.offset, offset: offset ?? this.offset,
idAround: idAround ?? this.idAround,
next: next ?? this.next, next: next ?? this.next,
greaterThan: greaterThan ?? this.greaterThan, greaterThan: greaterThan ?? this.greaterThan,
greaterThanOrEqual: greaterThanOrEqual ?? this.greaterThanOrEqual, greaterThanOrEqual: greaterThanOrEqual ?? this.greaterThanOrEqual,
@@ -126,8 +145,11 @@ class PaginationParams extends Equatable {
@override @override
List<Object?> get props => [ List<Object?> get props => [
limit, limit,
before,
after,
offset, offset,
next, next,
idAround,
greaterThan, greaterThan,
greaterThanOrEqual, greaterThanOrEqual,
lessThan, lessThan,
@@ -21,8 +21,11 @@ Map<String, dynamic> _$SortOptionToJson<T>(SortOption<T> instance) =>
PaginationParams _$PaginationParamsFromJson(Map<String, dynamic> json) => PaginationParams _$PaginationParamsFromJson(Map<String, dynamic> json) =>
PaginationParams( PaginationParams(
limit: json['limit'] as int? ?? 10, limit: json['limit'] as int? ?? 10,
before: json['before'] as int? ?? 10,
after: json['after'] as int? ?? 10,
offset: json['offset'] as int?, offset: json['offset'] as int?,
next: json['next'] as String?, next: json['next'] as String?,
idAround: json['id_around'] as String?,
greaterThan: json['id_gt'] as String?, greaterThan: json['id_gt'] as String?,
greaterThanOrEqual: json['id_gte'] as String?, greaterThanOrEqual: json['id_gte'] as String?,
lessThan: json['id_lt'] as String?, lessThan: json['id_lt'] as String?,
@@ -32,6 +35,8 @@ PaginationParams _$PaginationParamsFromJson(Map<String, dynamic> json) =>
Map<String, dynamic> _$PaginationParamsToJson(PaginationParams instance) { Map<String, dynamic> _$PaginationParamsToJson(PaginationParams instance) {
final val = <String, dynamic>{ final val = <String, dynamic>{
'limit': instance.limit, 'limit': instance.limit,
'before': instance.before,
'after': instance.after,
}; };
void writeNotNull(String key, dynamic value) { void writeNotNull(String key, dynamic value) {
@@ -42,6 +47,7 @@ Map<String, dynamic> _$PaginationParamsToJson(PaginationParams instance) {
writeNotNull('offset', instance.offset); writeNotNull('offset', instance.offset);
writeNotNull('next', instance.next); writeNotNull('next', instance.next);
writeNotNull('id_around', instance.idAround);
writeNotNull('id_gt', instance.greaterThan); writeNotNull('id_gt', instance.greaterThan);
writeNotNull('id_gte', instance.greaterThanOrEqual); writeNotNull('id_gte', instance.greaterThanOrEqual);
writeNotNull('id_lt', instance.lessThan); writeNotNull('id_lt', instance.lessThan);
@@ -442,3 +442,43 @@ class ChannelStateResponse extends _BaseResponse {
static ChannelStateResponse fromJson(Map<String, dynamic> json) => static ChannelStateResponse fromJson(Map<String, dynamic> json) =>
_$ChannelStateResponseFromJson(json); _$ChannelStateResponseFromJson(json);
} }
/// Model response for [Client.enrichUrl] api call.
@JsonSerializable(createToJson: false)
class OGAttachmentResponse extends _BaseResponse {
/// The URL of the page that was scraped.
late String ogScrapeUrl;
/// The URL of the asset.
String? assetUrl;
/// The URL of the author.
String? authorLink;
/// The name of the author.
String? authorName;
/// The URL of the image.
String? imageUrl;
/// The text of the attachment.
String? text;
/// The URL of the thumbnail.
String? thumbUrl;
/// The title of the attachment.
String? title;
/// The URL of the title.
String? titleLink;
/// The type of the attachment.
///
/// 'video' | 'audio' | 'image'
String? type;
/// Create a new instance from a [json].
static OGAttachmentResponse fromJson(Map<String, dynamic> json) =>
_$OGAttachmentResponseFromJson(json);
}
@@ -273,3 +273,18 @@ ChannelStateResponse _$ChannelStateResponseFromJson(
?.map((e) => Read.fromJson(e as Map<String, dynamic>)) ?.map((e) => Read.fromJson(e as Map<String, dynamic>))
.toList() ?? .toList() ??
[]; [];
OGAttachmentResponse _$OGAttachmentResponseFromJson(
Map<String, dynamic> json) =>
OGAttachmentResponse()
..duration = json['duration'] as String?
..ogScrapeUrl = json['og_scrape_url'] as String
..assetUrl = json['asset_url'] as String?
..authorLink = json['author_link'] as String?
..authorName = json['author_name'] as String?
..imageUrl = json['image_url'] as String?
..text = json['text'] as String?
..thumbUrl = json['thumb_url'] as String?
..title = json['title'] as String?
..titleLink = json['title_link'] as String?
..type = json['type'] as String?;
@@ -0,0 +1,17 @@
import 'package:dio/dio.dart';
import 'package:stream_chat/stream_chat.dart';
/// Interceptor that sets additional headers for all requests.
class AdditionalHeadersInterceptor extends Interceptor {
@override
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
options.headers = {
...options.headers,
...StreamChatClient.additionalHeaders,
};
return handler.next(options);
}
}
@@ -5,6 +5,7 @@ import 'package:logging/logging.dart';
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
import 'package:stream_chat/src/core/error/error.dart'; import 'package:stream_chat/src/core/error/error.dart';
import 'package:stream_chat/src/core/http/connection_id_manager.dart'; import 'package:stream_chat/src/core/http/connection_id_manager.dart';
import 'package:stream_chat/src/core/http/interceptor/additional_headers_interceptor.dart';
import 'package:stream_chat/src/core/http/interceptor/auth_interceptor.dart'; import 'package:stream_chat/src/core/http/interceptor/auth_interceptor.dart';
import 'package:stream_chat/src/core/http/interceptor/connection_id_interceptor.dart'; import 'package:stream_chat/src/core/http/interceptor/connection_id_interceptor.dart';
import 'package:stream_chat/src/core/http/interceptor/logging_interceptor.dart'; import 'package:stream_chat/src/core/http/interceptor/logging_interceptor.dart';
@@ -41,6 +42,7 @@ class StreamHttpClient {
..._options.headers, ..._options.headers,
} }
..interceptors.addAll([ ..interceptors.addAll([
AdditionalHeadersInterceptor(),
if (tokenManager != null) AuthInterceptor(this, tokenManager), if (tokenManager != null) AuthInterceptor(this, tokenManager),
if (connectionIdManager != null) if (connectionIdManager != null)
ConnectionIdInterceptor(connectionIdManager), ConnectionIdInterceptor(connectionIdManager),
@@ -14,7 +14,7 @@ final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more informations: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more informations: https://github.com/rrousselGit/freezed#custom-getters-and-methods');
UploadState _$UploadStateFromJson(Map<String, dynamic> json) { UploadState _$UploadStateFromJson(Map<String, dynamic> json) {
switch (json['runtimeType'] as String?) { switch (json['runtimeType']) {
case 'preparing': case 'preparing':
return Preparing.fromJson(json); return Preparing.fromJson(json);
case 'inProgress': case 'inProgress':
@@ -55,7 +55,7 @@ class _$UploadStateTearOff {
); );
} }
UploadState fromJson(Map<String, Object> json) { UploadState fromJson(Map<String, Object?> json) {
return UploadState.fromJson(json); return UploadState.fromJson(json);
} }
} }
@@ -153,11 +153,14 @@ class _$PreparingCopyWithImpl<$Res> extends _$UploadStateCopyWithImpl<$Res>
/// @nodoc /// @nodoc
@JsonSerializable() @JsonSerializable()
class _$Preparing implements Preparing { class _$Preparing implements Preparing {
const _$Preparing(); const _$Preparing({String? $type}) : $type = $type ?? 'preparing';
factory _$Preparing.fromJson(Map<String, dynamic> json) => factory _$Preparing.fromJson(Map<String, dynamic> json) =>
_$$PreparingFromJson(json); _$$PreparingFromJson(json);
@JsonKey(name: 'runtimeType')
final String $type;
@override @override
String toString() { String toString() {
return 'UploadState.preparing()'; return 'UploadState.preparing()';
@@ -165,7 +168,8 @@ class _$Preparing implements Preparing {
@override @override
bool operator ==(dynamic other) { bool operator ==(dynamic other) {
return identical(this, other) || (other is Preparing); return identical(this, other) ||
(other.runtimeType == runtimeType && other is Preparing);
} }
@override @override
@@ -247,7 +251,7 @@ class _$Preparing implements Preparing {
@override @override
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return _$$PreparingToJson(this)..['runtimeType'] = 'preparing'; return _$$PreparingToJson(this);
} }
} }
@@ -295,7 +299,9 @@ class _$InProgressCopyWithImpl<$Res> extends _$UploadStateCopyWithImpl<$Res>
/// @nodoc /// @nodoc
@JsonSerializable() @JsonSerializable()
class _$InProgress implements InProgress { class _$InProgress implements InProgress {
const _$InProgress({required this.uploaded, required this.total}); const _$InProgress(
{required this.uploaded, required this.total, String? $type})
: $type = $type ?? 'inProgress';
factory _$InProgress.fromJson(Map<String, dynamic> json) => factory _$InProgress.fromJson(Map<String, dynamic> json) =>
_$$InProgressFromJson(json); _$$InProgressFromJson(json);
@@ -305,6 +311,9 @@ class _$InProgress implements InProgress {
@override @override
final int total; final int total;
@JsonKey(name: 'runtimeType')
final String $type;
@override @override
String toString() { String toString() {
return 'UploadState.inProgress(uploaded: $uploaded, total: $total)'; return 'UploadState.inProgress(uploaded: $uploaded, total: $total)';
@@ -313,19 +322,15 @@ class _$InProgress implements InProgress {
@override @override
bool operator ==(dynamic other) { bool operator ==(dynamic other) {
return identical(this, other) || return identical(this, other) ||
(other is InProgress && (other.runtimeType == runtimeType &&
other is InProgress &&
(identical(other.uploaded, uploaded) || (identical(other.uploaded, uploaded) ||
const DeepCollectionEquality() other.uploaded == uploaded) &&
.equals(other.uploaded, uploaded)) && (identical(other.total, total) || other.total == total));
(identical(other.total, total) ||
const DeepCollectionEquality().equals(other.total, total)));
} }
@override @override
int get hashCode => int get hashCode => Object.hash(runtimeType, uploaded, total);
runtimeType.hashCode ^
const DeepCollectionEquality().hash(uploaded) ^
const DeepCollectionEquality().hash(total);
@JsonKey(ignore: true) @JsonKey(ignore: true)
@override @override
@@ -408,7 +413,7 @@ class _$InProgress implements InProgress {
@override @override
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return _$$InProgressToJson(this)..['runtimeType'] = 'inProgress'; return _$$InProgressToJson(this);
} }
} }
@@ -419,8 +424,8 @@ abstract class InProgress implements UploadState {
factory InProgress.fromJson(Map<String, dynamic> json) = factory InProgress.fromJson(Map<String, dynamic> json) =
_$InProgress.fromJson; _$InProgress.fromJson;
int get uploaded => throw _privateConstructorUsedError; int get uploaded;
int get total => throw _privateConstructorUsedError; int get total;
@JsonKey(ignore: true) @JsonKey(ignore: true)
$InProgressCopyWith<InProgress> get copyWith => $InProgressCopyWith<InProgress> get copyWith =>
throw _privateConstructorUsedError; throw _privateConstructorUsedError;
@@ -445,11 +450,14 @@ class _$SuccessCopyWithImpl<$Res> extends _$UploadStateCopyWithImpl<$Res>
/// @nodoc /// @nodoc
@JsonSerializable() @JsonSerializable()
class _$Success implements Success { class _$Success implements Success {
const _$Success(); const _$Success({String? $type}) : $type = $type ?? 'success';
factory _$Success.fromJson(Map<String, dynamic> json) => factory _$Success.fromJson(Map<String, dynamic> json) =>
_$$SuccessFromJson(json); _$$SuccessFromJson(json);
@JsonKey(name: 'runtimeType')
final String $type;
@override @override
String toString() { String toString() {
return 'UploadState.success()'; return 'UploadState.success()';
@@ -457,7 +465,8 @@ class _$Success implements Success {
@override @override
bool operator ==(dynamic other) { bool operator ==(dynamic other) {
return identical(this, other) || (other is Success); return identical(this, other) ||
(other.runtimeType == runtimeType && other is Success);
} }
@override @override
@@ -539,7 +548,7 @@ class _$Success implements Success {
@override @override
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return _$$SuccessToJson(this)..['runtimeType'] = 'success'; return _$$SuccessToJson(this);
} }
} }
@@ -581,7 +590,8 @@ class _$FailedCopyWithImpl<$Res> extends _$UploadStateCopyWithImpl<$Res>
/// @nodoc /// @nodoc
@JsonSerializable() @JsonSerializable()
class _$Failed implements Failed { class _$Failed implements Failed {
const _$Failed({required this.error}); const _$Failed({required this.error, String? $type})
: $type = $type ?? 'failed';
factory _$Failed.fromJson(Map<String, dynamic> json) => factory _$Failed.fromJson(Map<String, dynamic> json) =>
_$$FailedFromJson(json); _$$FailedFromJson(json);
@@ -589,6 +599,9 @@ class _$Failed implements Failed {
@override @override
final String error; final String error;
@JsonKey(name: 'runtimeType')
final String $type;
@override @override
String toString() { String toString() {
return 'UploadState.failed(error: $error)'; return 'UploadState.failed(error: $error)';
@@ -597,14 +610,13 @@ class _$Failed implements Failed {
@override @override
bool operator ==(dynamic other) { bool operator ==(dynamic other) {
return identical(this, other) || return identical(this, other) ||
(other is Failed && (other.runtimeType == runtimeType &&
(identical(other.error, error) || other is Failed &&
const DeepCollectionEquality().equals(other.error, error))); (identical(other.error, error) || other.error == error));
} }
@override @override
int get hashCode => int get hashCode => Object.hash(runtimeType, error);
runtimeType.hashCode ^ const DeepCollectionEquality().hash(error);
@JsonKey(ignore: true) @JsonKey(ignore: true)
@override @override
@@ -687,7 +699,7 @@ class _$Failed implements Failed {
@override @override
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return _$$FailedToJson(this)..['runtimeType'] = 'failed'; return _$$FailedToJson(this);
} }
} }
@@ -696,7 +708,7 @@ abstract class Failed implements UploadState {
factory Failed.fromJson(Map<String, dynamic> json) = _$Failed.fromJson; factory Failed.fromJson(Map<String, dynamic> json) = _$Failed.fromJson;
String get error => throw _privateConstructorUsedError; String get error;
@JsonKey(ignore: true) @JsonKey(ignore: true)
$FailedCopyWith<Failed> get copyWith => throw _privateConstructorUsedError; $FailedCopyWith<Failed> get copyWith => throw _privateConstructorUsedError;
} }
@@ -22,31 +22,42 @@ Map<String, dynamic> _$AttachmentFileToJson(AttachmentFile instance) =>
'size': instance.size, 'size': instance.size,
}; };
_$Preparing _$$PreparingFromJson(Map<String, dynamic> json) => _$Preparing(); _$Preparing _$$PreparingFromJson(Map<String, dynamic> json) => _$Preparing(
$type: json['runtimeType'] as String?,
);
Map<String, dynamic> _$$PreparingToJson(_$Preparing instance) => Map<String, dynamic> _$$PreparingToJson(_$Preparing instance) =>
<String, dynamic>{}; <String, dynamic>{
'runtimeType': instance.$type,
};
_$InProgress _$$InProgressFromJson(Map<String, dynamic> json) => _$InProgress( _$InProgress _$$InProgressFromJson(Map<String, dynamic> json) => _$InProgress(
uploaded: json['uploaded'] as int, uploaded: json['uploaded'] as int,
total: json['total'] as int, total: json['total'] as int,
$type: json['runtimeType'] as String?,
); );
Map<String, dynamic> _$$InProgressToJson(_$InProgress instance) => Map<String, dynamic> _$$InProgressToJson(_$InProgress instance) =>
<String, dynamic>{ <String, dynamic>{
'uploaded': instance.uploaded, 'uploaded': instance.uploaded,
'total': instance.total, 'total': instance.total,
'runtimeType': instance.$type,
}; };
_$Success _$$SuccessFromJson(Map<String, dynamic> json) => _$Success(); _$Success _$$SuccessFromJson(Map<String, dynamic> json) => _$Success(
$type: json['runtimeType'] as String?,
);
Map<String, dynamic> _$$SuccessToJson(_$Success instance) => Map<String, dynamic> _$$SuccessToJson(_$Success instance) => <String, dynamic>{
<String, dynamic>{}; 'runtimeType': instance.$type,
};
_$Failed _$$FailedFromJson(Map<String, dynamic> json) => _$Failed( _$Failed _$$FailedFromJson(Map<String, dynamic> json) => _$Failed(
error: json['error'] as String, error: json['error'] as String,
$type: json['runtimeType'] as String?,
); );
Map<String, dynamic> _$$FailedToJson(_$Failed instance) => <String, dynamic>{ Map<String, dynamic> _$$FailedToJson(_$Failed instance) => <String, dynamic>{
'error': instance.error, 'error': instance.error,
'runtimeType': instance.$type,
}; };
@@ -27,6 +27,7 @@ class Event {
this.channelId, this.channelId,
this.channelType, this.channelType,
this.parentId, this.parentId,
this.hardDelete,
this.extraData = const {}, this.extraData = const {},
this.isLocal = true, this.isLocal = true,
}) : createdAt = createdAt?.toUtc() ?? DateTime.now().toUtc(); }) : createdAt = createdAt?.toUtc() ?? DateTime.now().toUtc();
@@ -91,6 +92,10 @@ class Event {
@JsonKey(defaultValue: false) @JsonKey(defaultValue: false)
final bool isLocal; final bool isLocal;
/// This is true if the message has been hard deleted
@JsonKey(includeIfNull: false)
final bool? hardDelete;
/// Map of custom channel extraData /// Map of custom channel extraData
final Map<String, Object?> extraData; final Map<String, Object?> extraData;
@@ -113,6 +118,7 @@ class Event {
'channel_id', 'channel_id',
'channel_type', 'channel_type',
'parent_id', 'parent_id',
'hard_delete',
'is_local', 'is_local',
]; ];
@@ -139,6 +145,7 @@ class Event {
int? unreadChannels, int? unreadChannels,
bool? online, bool? online,
String? parentId, String? parentId,
bool? hardDelete,
Map<String, Object?>? extraData, Map<String, Object?>? extraData,
}) => }) =>
Event( Event(
@@ -158,6 +165,7 @@ class Event {
channelId: channelId ?? this.channelId, channelId: channelId ?? this.channelId,
channelType: channelType ?? this.channelType, channelType: channelType ?? this.channelType,
parentId: parentId ?? this.parentId, parentId: parentId ?? this.parentId,
hardDelete: hardDelete ?? this.hardDelete,
extraData: extraData ?? this.extraData, extraData: extraData ?? this.extraData,
isLocal: isLocal, isLocal: isLocal,
); );
@@ -181,7 +189,7 @@ class EventChannel extends ChannelModel {
required DateTime createdAt, required DateTime createdAt,
required DateTime updatedAt, required DateTime updatedAt,
DateTime? deletedAt, DateTime? deletedAt,
required int memberCount, int memberCount = 0,
Map<String, Object?>? extraData, Map<String, Object?>? extraData,
int cooldown = 0, int cooldown = 0,
String? team, String? team,
@@ -37,30 +37,42 @@ Event _$EventFromJson(Map<String, dynamic> json) => Event(
channelId: json['channel_id'] as String?, channelId: json['channel_id'] as String?,
channelType: json['channel_type'] as String?, channelType: json['channel_type'] as String?,
parentId: json['parent_id'] as String?, parentId: json['parent_id'] as String?,
hardDelete: json['hard_delete'] as bool?,
extraData: json['extra_data'] as Map<String, dynamic>? ?? const {}, extraData: json['extra_data'] as Map<String, dynamic>? ?? const {},
isLocal: json['is_local'] as bool? ?? false, isLocal: json['is_local'] as bool? ?? false,
); );
Map<String, dynamic> _$EventToJson(Event instance) => <String, dynamic>{ Map<String, dynamic> _$EventToJson(Event instance) {
'type': instance.type, final val = <String, dynamic>{
'cid': instance.cid, 'type': instance.type,
'channel_id': instance.channelId, 'cid': instance.cid,
'channel_type': instance.channelType, 'channel_id': instance.channelId,
'connection_id': instance.connectionId, 'channel_type': instance.channelType,
'created_at': instance.createdAt.toIso8601String(), 'connection_id': instance.connectionId,
'me': instance.me?.toJson(), 'created_at': instance.createdAt.toIso8601String(),
'user': instance.user?.toJson(), 'me': instance.me?.toJson(),
'message': instance.message?.toJson(), 'user': instance.user?.toJson(),
'channel': instance.channel?.toJson(), 'message': instance.message?.toJson(),
'member': instance.member?.toJson(), 'channel': instance.channel?.toJson(),
'reaction': instance.reaction?.toJson(), 'member': instance.member?.toJson(),
'total_unread_count': instance.totalUnreadCount, 'reaction': instance.reaction?.toJson(),
'unread_channels': instance.unreadChannels, 'total_unread_count': instance.totalUnreadCount,
'online': instance.online, 'unread_channels': instance.unreadChannels,
'parent_id': instance.parentId, 'online': instance.online,
'is_local': instance.isLocal, 'parent_id': instance.parentId,
'extra_data': instance.extraData, 'is_local': instance.isLocal,
}; };
void writeNotNull(String key, dynamic value) {
if (value != null) {
val[key] = value;
}
}
writeNotNull('hard_delete', instance.hardDelete);
val['extra_data'] = instance.extraData;
return val;
}
EventChannel _$EventChannelFromJson(Map<String, dynamic> json) => EventChannel( EventChannel _$EventChannelFromJson(Map<String, dynamic> json) => EventChannel(
members: (json['members'] as List<dynamic>?) members: (json['members'] as List<dynamic>?)
@@ -82,7 +94,7 @@ EventChannel _$EventChannelFromJson(Map<String, dynamic> json) => EventChannel(
deletedAt: json['deleted_at'] == null deletedAt: json['deleted_at'] == null
? null ? null
: DateTime.parse(json['deleted_at'] as String), : DateTime.parse(json['deleted_at'] as String),
memberCount: json['member_count'] as int, memberCount: json['member_count'] as int? ?? 0,
extraData: json['extra_data'] as Map<String, dynamic>?, extraData: json['extra_data'] as Map<String, dynamic>?,
cooldown: json['cooldown'] as int? ?? 0, cooldown: json['cooldown'] as int? ?? 0,
team: json['team'] as String?, team: json['team'] as String?,
@@ -14,7 +14,7 @@ class _PinExpires {
const _pinExpires = _PinExpires(); const _pinExpires = _PinExpires();
/// Enum defining the status of a sending message /// Enum defining the status of a sending message.
enum MessageSendingStatus { enum MessageSendingStatus {
/// Message is being sent /// Message is being sent
sending, sending,
@@ -40,10 +40,10 @@ enum MessageSendingStatus {
sent, sent,
} }
/// The class that contains the information about a message /// The class that contains the information about a message.
@JsonSerializable() @JsonSerializable()
class Message extends Equatable { class Message extends Equatable {
/// Constructor used for json serialization /// Constructor used for json serialization.
Message({ Message({
String? id, String? id,
this.text, this.text,
@@ -58,44 +58,47 @@ class Message extends Equatable {
this.ownReactions, this.ownReactions,
this.parentId, this.parentId,
this.quotedMessage, this.quotedMessage,
this.quotedMessageId, String? quotedMessageId,
this.replyCount = 0, this.replyCount = 0,
this.threadParticipants, this.threadParticipants,
this.showInChannel, this.showInChannel,
this.command, this.command,
DateTime? createdAt, DateTime? createdAt,
DateTime? updatedAt, DateTime? updatedAt,
this.deletedAt,
this.user, this.user,
this.pinned = false, this.pinned = false,
this.pinnedAt, this.pinnedAt,
DateTime? pinExpires, DateTime? pinExpires,
this.pinnedBy, this.pinnedBy,
this.extraData = const {}, this.extraData = const {},
this.deletedAt, this.status = MessageSendingStatus.sending,
this.status = MessageSendingStatus.sent,
this.i18n, this.i18n,
}) : id = id ?? const Uuid().v4(), }) : id = id ?? const Uuid().v4(),
pinExpires = pinExpires?.toUtc(), pinExpires = pinExpires?.toUtc(),
createdAt = createdAt ?? DateTime.now(), _createdAt = createdAt,
updatedAt = updatedAt ?? DateTime.now(); _updatedAt = updatedAt,
_quotedMessageId = quotedMessageId;
/// Create a new instance from a json /// Create a new instance from JSON.
factory Message.fromJson(Map<String, dynamic> json) => _$MessageFromJson( factory Message.fromJson(Map<String, dynamic> json) => _$MessageFromJson(
Serializer.moveToExtraDataFromRoot(json, topLevelFields), Serializer.moveToExtraDataFromRoot(json, topLevelFields),
).copyWith(
status: MessageSendingStatus.sent,
); );
/// The message ID. This is either created by Stream or set client side when /// The message ID. This is either created by Stream or set client side when
/// the message is added. /// the message is added.
final String id; final String id;
/// The text of this message /// The text of this message.
final String? text; final String? text;
/// The status of a sending message /// The status of a sending message.
@JsonKey(ignore: true) @JsonKey(ignore: true)
final MessageSendingStatus status; final MessageSendingStatus status;
/// The message type /// The message type.
@JsonKey( @JsonKey(
includeIfNull: false, includeIfNull: false,
toJson: Serializer.readOnly, toJson: Serializer.readOnly,
@@ -107,15 +110,15 @@ class Message extends Equatable {
@JsonKey(includeIfNull: false) @JsonKey(includeIfNull: false)
final List<Attachment> attachments; final List<Attachment> attachments;
/// The list of user mentioned in the message /// The list of user mentioned in the message.
@JsonKey(toJson: User.toIds) @JsonKey(toJson: User.toIds)
final List<User> mentionedUsers; final List<User> mentionedUsers;
/// A map describing the count of number of every reaction /// A map describing the count of number of every reaction.
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly) @JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final Map<String, int>? reactionCounts; final Map<String, int>? reactionCounts;
/// A map describing the count of score of every reaction /// A map describing the count of score of every reaction.
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly) @JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final Map<String, int>? reactionScores; final Map<String, int>? reactionScores;
@@ -130,12 +133,14 @@ class Message extends Equatable {
/// The ID of the parent message, if the message is a thread reply. /// The ID of the parent message, if the message is a thread reply.
final String? parentId; final String? parentId;
/// A quoted reply message /// A quoted reply message.
@JsonKey(toJson: Serializer.readOnly) @JsonKey(toJson: Serializer.readOnly)
final Message? quotedMessage; final Message? quotedMessage;
final String? _quotedMessageId;
/// The ID of the quoted message, if the message is a quoted reply. /// The ID of the quoted message, if the message is a quoted reply.
final String? quotedMessageId; String? get quotedMessageId => _quotedMessageId ?? quotedMessage?.id;
/// Reserved field indicating the number of replies for this message. /// Reserved field indicating the number of replies for this message.
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly) @JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
@@ -148,10 +153,10 @@ class Message extends Equatable {
/// Check if this message needs to show in the channel. /// Check if this message needs to show in the channel.
final bool? showInChannel; final bool? showInChannel;
/// If true the message is silent /// If true the message is silent.
final bool silent; final bool silent;
/// If true the message is shadowed /// If true the message is shadowed.
@JsonKey( @JsonKey(
includeIfNull: false, includeIfNull: false,
toJson: Serializer.readOnly, toJson: Serializer.readOnly,
@@ -162,56 +167,61 @@ class Message extends Equatable {
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly) @JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final String? command; final String? command;
/// Reserved field indicating when the message was created. final DateTime? _createdAt;
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final DateTime createdAt;
/// Reserved field indicating when the message was updated last time.
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final DateTime updatedAt;
/// User who sent the message
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final User? user;
/// If true the message is pinned
final bool pinned;
/// Reserved field indicating when the message was pinned
@JsonKey(toJson: Serializer.readOnly)
final DateTime? pinnedAt;
/// Reserved field indicating when the message will expire
///
/// if `null` message has no expiry
final DateTime? pinExpires;
/// Reserved field indicating who pinned the message
@JsonKey(toJson: Serializer.readOnly)
final User? pinnedBy;
/// Message custom extraData
@JsonKey(includeIfNull: false)
final Map<String, Object?> extraData;
/// True if the message is a system info
bool get isSystem => type == 'system';
/// True if the message has been deleted
bool get isDeleted => type == 'deleted';
/// True if the message is ephemeral
bool get isEphemeral => type == 'ephemeral';
/// Reserved field indicating when the message was deleted. /// Reserved field indicating when the message was deleted.
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly) @JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final DateTime? deletedAt; final DateTime? deletedAt;
/// Reserved field indicating when the message was created.
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
DateTime get createdAt => _createdAt ?? DateTime.now();
final DateTime? _updatedAt;
/// Reserved field indicating when the message was updated last time.
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
DateTime get updatedAt => _updatedAt ?? DateTime.now();
/// User who sent the message.
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final User? user;
/// If true the message is pinned.
final bool pinned;
/// Reserved field indicating when the message was pinned.
@JsonKey(toJson: Serializer.readOnly)
final DateTime? pinnedAt;
/// Reserved field indicating when the message will expire.
///
/// If `null` message has no expiry.
final DateTime? pinExpires;
/// Reserved field indicating who pinned the message.
@JsonKey(toJson: Serializer.readOnly)
final User? pinnedBy;
/// Message custom extraData.
@JsonKey(includeIfNull: false)
final Map<String, Object?> extraData;
/// True if the message is a system info.
bool get isSystem => type == 'system';
/// True if the message has been deleted.
bool get isDeleted => type == 'deleted';
/// True if the message is ephemeral.
bool get isEphemeral => type == 'ephemeral';
/// A Map of translations. /// A Map of translations.
@JsonKey(includeIfNull: false) @JsonKey(includeIfNull: false)
final Map<String, String>? i18n; final Map<String, String>? i18n;
/// Known top level fields. /// Known top level fields.
///
/// Useful for [Serializer] methods. /// Useful for [Serializer] methods.
static const topLevelFields = [ static const topLevelFields = [
'id', 'id',
@@ -244,7 +254,7 @@ class Message extends Equatable {
'i18n', 'i18n',
]; ];
/// Serialize to json /// Serialize to json.
Map<String, dynamic> toJson() => Serializer.moveFromExtraDataToRoot( Map<String, dynamic> toJson() => Serializer.moveFromExtraDataToRoot(
_$MessageToJson(this), _$MessageToJson(this),
); );
@@ -256,6 +266,8 @@ class Message extends Equatable {
String? type, String? type,
List<Attachment>? attachments, List<Attachment>? attachments,
List<User>? mentionedUsers, List<User>? mentionedUsers,
bool? silent,
bool? shadowed,
Map<String, int>? reactionCounts, Map<String, int>? reactionCounts,
Map<String, int>? reactionScores, Map<String, int>? reactionScores,
List<Reaction>? latestReactions, List<Reaction>? latestReactions,
@@ -266,8 +278,6 @@ class Message extends Equatable {
int? replyCount, int? replyCount,
List<User>? threadParticipants, List<User>? threadParticipants,
bool? showInChannel, bool? showInChannel,
bool? shadowed,
bool? silent,
String? command, String? command,
DateTime? createdAt, DateTime? createdAt,
DateTime? updatedAt, DateTime? updatedAt,
@@ -295,30 +305,30 @@ class Message extends Equatable {
type: type ?? this.type, type: type ?? this.type,
attachments: attachments ?? this.attachments, attachments: attachments ?? this.attachments,
mentionedUsers: mentionedUsers ?? this.mentionedUsers, mentionedUsers: mentionedUsers ?? this.mentionedUsers,
silent: silent ?? this.silent,
shadowed: shadowed ?? this.shadowed,
reactionCounts: reactionCounts ?? this.reactionCounts, reactionCounts: reactionCounts ?? this.reactionCounts,
reactionScores: reactionScores ?? this.reactionScores, reactionScores: reactionScores ?? this.reactionScores,
latestReactions: latestReactions ?? this.latestReactions, latestReactions: latestReactions ?? this.latestReactions,
ownReactions: ownReactions ?? this.ownReactions, ownReactions: ownReactions ?? this.ownReactions,
parentId: parentId ?? this.parentId, parentId: parentId ?? this.parentId,
quotedMessage: quotedMessage ?? this.quotedMessage, quotedMessage: quotedMessage ?? this.quotedMessage,
quotedMessageId: quotedMessageId ?? this.quotedMessageId, quotedMessageId: quotedMessageId ?? _quotedMessageId,
replyCount: replyCount ?? this.replyCount, replyCount: replyCount ?? this.replyCount,
threadParticipants: threadParticipants ?? this.threadParticipants, threadParticipants: threadParticipants ?? this.threadParticipants,
showInChannel: showInChannel ?? this.showInChannel, showInChannel: showInChannel ?? this.showInChannel,
command: command ?? this.command, command: command ?? this.command,
createdAt: createdAt ?? this.createdAt, createdAt: createdAt ?? _createdAt,
silent: silent ?? this.silent, updatedAt: updatedAt ?? _updatedAt,
extraData: extraData ?? this.extraData,
user: user ?? this.user,
shadowed: shadowed ?? this.shadowed,
updatedAt: updatedAt ?? this.updatedAt,
deletedAt: deletedAt ?? this.deletedAt, deletedAt: deletedAt ?? this.deletedAt,
status: status ?? this.status, user: user ?? this.user,
pinned: pinned ?? this.pinned, pinned: pinned ?? this.pinned,
pinnedAt: pinnedAt ?? this.pinnedAt, pinnedAt: pinnedAt ?? this.pinnedAt,
pinnedBy: pinnedBy ?? this.pinnedBy,
pinExpires: pinExpires:
pinExpires == _pinExpires ? this.pinExpires : pinExpires as DateTime?, pinExpires == _pinExpires ? this.pinExpires : pinExpires as DateTime?,
pinnedBy: pinnedBy ?? this.pinnedBy,
extraData: extraData ?? this.extraData,
status: status ?? this.status,
i18n: i18n ?? this.i18n, i18n: i18n ?? this.i18n,
); );
} }
@@ -331,6 +341,8 @@ class Message extends Equatable {
type: other.type, type: other.type,
attachments: other.attachments, attachments: other.attachments,
mentionedUsers: other.mentionedUsers, mentionedUsers: other.mentionedUsers,
silent: other.silent,
shadowed: other.shadowed,
reactionCounts: other.reactionCounts, reactionCounts: other.reactionCounts,
reactionScores: other.reactionScores, reactionScores: other.reactionScores,
latestReactions: other.latestReactions, latestReactions: other.latestReactions,
@@ -343,17 +355,15 @@ class Message extends Equatable {
showInChannel: other.showInChannel, showInChannel: other.showInChannel,
command: other.command, command: other.command,
createdAt: other.createdAt, createdAt: other.createdAt,
silent: other.silent,
extraData: other.extraData,
user: other.user,
shadowed: other.shadowed,
updatedAt: other.updatedAt, updatedAt: other.updatedAt,
deletedAt: other.deletedAt, deletedAt: other.deletedAt,
status: other.status, user: other.user,
pinned: other.pinned, pinned: other.pinned,
pinnedAt: other.pinnedAt, pinnedAt: other.pinnedAt,
pinExpires: other.pinExpires, pinExpires: other.pinExpires,
pinnedBy: other.pinnedBy, pinnedBy: other.pinnedBy,
extraData: other.extraData,
status: other.status,
i18n: other.i18n, i18n: other.i18n,
); );
@@ -1,3 +1,4 @@
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:stream_chat/src/core/models/user.dart'; import 'package:stream_chat/src/core/models/user.dart';
@@ -5,9 +6,9 @@ part 'read.g.dart';
/// The class that defines a read event /// The class that defines a read event
@JsonSerializable() @JsonSerializable()
class Read { class Read extends Equatable {
/// Constructor used for json serialization /// Constructor used for json serialization
Read({ const Read({
required this.lastRead, required this.lastRead,
required this.user, required this.user,
this.unreadMessages = 0, this.unreadMessages = 0,
@@ -39,4 +40,11 @@ class Read {
user: user ?? this.user, user: user ?? this.user,
unreadMessages: unreadMessages ?? this.unreadMessages, unreadMessages: unreadMessages ?? this.unreadMessages,
); );
@override
List<Object?> get props => [
lastRead,
user,
unreadMessages,
];
} }
@@ -179,5 +179,14 @@ class User extends Equatable {
); );
@override @override
List<Object?> get props => [id, role]; List<Object?> get props => [
id,
role,
lastActive,
online,
extraData,
banned,
teams,
language,
];
} }
@@ -82,7 +82,6 @@ abstract class ChatPersistenceClient {
members: data[0] as List<Member>, members: data[0] as List<Member>,
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable
read: data[1] as List<Read>, read: data[1] as List<Read>,
// ignore: cast_nullable_to_non_nullable
channel: data[2] as ChannelModel?, channel: data[2] as ChannelModel?,
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable
messages: data[3] as List<Message>, messages: data[3] as List<Message>,
@@ -121,7 +121,8 @@ class WebSocket with TimerHelper {
_logger?.info('Closing connection with $baseUrl'); _logger?.info('Closing connection with $baseUrl');
if (_webSocketChannel != null) { if (_webSocketChannel != null) {
_unsubscribeFromWebSocketChannel(); _unsubscribeFromWebSocketChannel();
_webSocketChannel?.sink.close(status.goingAway); _webSocketChannel?.sink
.close(_manuallyClosed ? status.normalClosure : status.goingAway);
_webSocketChannel = null; _webSocketChannel = null;
} }
} }
@@ -309,7 +310,10 @@ class WebSocket with TimerHelper {
Event? event; Event? event;
try { try {
event = Event.fromJson(jsonData); event = Event.fromJson(jsonData);
} catch (_) {} } catch (e, stk) {
_logger?.warning('Error parsing an event: $e');
_logger?.warning('Stack trace: $stk');
}
if (event == null) return; if (event == null) return;
@@ -7,6 +7,7 @@ export 'package:dio/src/options.dart';
export 'package:dio/src/options.dart' show ProgressCallback; export 'package:dio/src/options.dart' show ProgressCallback;
export 'package:logging/logging.dart' show Logger, Level; export 'package:logging/logging.dart' show Logger, Level;
export 'package:rate_limiter/rate_limiter.dart'; export 'package:rate_limiter/rate_limiter.dart';
export 'package:uuid/uuid.dart';
export './src/core/api/attachment_file_uploader.dart' export './src/core/api/attachment_file_uploader.dart'
show AttachmentFileUploader; show AttachmentFileUploader;
+1 -1
View File
@@ -3,4 +3,4 @@ import 'package:stream_chat/src/client/client.dart';
/// Current package version /// Current package version
/// Used in [StreamChatClient] to build the `x-stream-client` header /// Used in [StreamChatClient] to build the `x-stream-client` header
// ignore: constant_identifier_names // ignore: constant_identifier_names
const PACKAGE_VERSION = '3.2.0'; const PACKAGE_VERSION = '3.3.1';
+4 -4
View File
@@ -1,7 +1,7 @@
name: stream_chat name: stream_chat
homepage: https://getstream.io/ homepage: https://getstream.io/
description: The official Dart client for Stream Chat, a service for building chat applications. description: The official Dart client for Stream Chat, a service for building chat applications.
version: 3.2.0 version: 3.3.1
repository: https://github.com/GetStream/stream-chat-flutter repository: https://github.com/GetStream/stream-chat-flutter
issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
@@ -13,10 +13,10 @@ dependencies:
collection: ^1.15.0 collection: ^1.15.0
dio: ^4.0.0 dio: ^4.0.0
equatable: ^2.0.0 equatable: ^2.0.0
freezed_annotation: ^0.15.0 freezed_annotation: ^1.0.0
http_parser: ^4.0.0 http_parser: ^4.0.0
jose: ^0.3.2 jose: ^0.3.2
json_annotation: ^4.0.1 json_annotation: ^4.3.0
logging: ^1.0.1 logging: ^1.0.1
meta: ^1.3.0 meta: ^1.3.0
mime: ^1.0.0 mime: ^1.0.0
@@ -28,7 +28,7 @@ dependencies:
dev_dependencies: dev_dependencies:
build_runner: ^2.0.1 build_runner: ^2.0.1
dart_code_metrics: ^4.4.0 dart_code_metrics: ^4.4.0
freezed: ^0.15.0+1 freezed: ^1.0.0
json_serializable: ^6.0.1 json_serializable: ^6.0.1
mocktail: ^0.2.0 mocktail: ^0.2.0
test: ^1.17.12 test: ^1.17.12
@@ -244,9 +244,13 @@ void main() {
group('`.sendMessage`', () { group('`.sendMessage`', () {
test('should work fine', () async { test('should work fine', () async {
final message = Message(id: 'test-message-id'); final message = Message(
id: 'test-message-id',
user: client.state.currentUser,
);
final sendMessageResponse = SendMessageResponse()..message = message; final sendMessageResponse = SendMessageResponse()
..message = message.copyWith(status: MessageSendingStatus.sent);
when(() => client.sendMessage( when(() => client.sendMessage(
any(that: isSameMessageAs(message)), any(that: isSameMessageAs(message)),
@@ -329,6 +333,7 @@ void main() {
.map((it) => .map((it) =>
it.copyWith(uploadState: const UploadState.success())) it.copyWith(uploadState: const UploadState.success()))
.toList(growable: false), .toList(growable: false),
status: MessageSendingStatus.sent,
)); ));
expectLater( expectLater(
@@ -455,7 +460,10 @@ void main() {
group('`.updateMessage`', () { group('`.updateMessage`', () {
test('should work fine', () async { test('should work fine', () async {
final message = Message(id: 'test-message-id'); final message = Message(
id: 'test-message-id',
status: MessageSendingStatus.sent,
);
final updateMessageResponse = UpdateMessageResponse() final updateMessageResponse = UpdateMessageResponse()
..message = message; ..message = message;
@@ -530,6 +538,7 @@ void main() {
any(that: isSameMessageAs(message)), any(that: isSameMessageAs(message)),
)).thenAnswer((_) async => UpdateMessageResponse() )).thenAnswer((_) async => UpdateMessageResponse()
..message = message.copyWith( ..message = message.copyWith(
status: MessageSendingStatus.sent,
attachments: attachments attachments: attachments
.map((it) => .map((it) =>
it.copyWith(uploadState: const UploadState.success())) it.copyWith(uploadState: const UploadState.success()))
@@ -678,7 +687,7 @@ void main() {
[ [
isSameMessageAs( isSameMessageAs(
updateMessageResponse.message.copyWith( updateMessageResponse.message.copyWith(
status: MessageSendingStatus.sent, status: MessageSendingStatus.sending,
), ),
matchText: true, matchText: true,
matchSendingStatus: true, matchSendingStatus: true,
@@ -707,7 +716,10 @@ void main() {
group('`.deleteMessage`', () { group('`.deleteMessage`', () {
test('should work fine', () async { test('should work fine', () async {
const messageId = 'test-message-id'; const messageId = 'test-message-id';
final message = Message(id: messageId); final message = Message(
id: messageId,
status: MessageSendingStatus.sent,
);
when(() => client.deleteMessage(messageId)) when(() => client.deleteMessage(messageId))
.thenAnswer((_) async => EmptyResponse()); .thenAnswer((_) async => EmptyResponse());
@@ -1077,7 +1089,10 @@ void main() {
group('`.sendReaction`', () { group('`.sendReaction`', () {
test('should work fine', () async { test('should work fine', () async {
const type = 'test-reaction-type'; const type = 'test-reaction-type';
final message = Message(id: 'test-message-id'); final message = Message(
id: 'test-message-id',
status: MessageSendingStatus.sent,
);
final reaction = Reaction(type: type, messageId: message.id); final reaction = Reaction(type: type, messageId: message.id);
@@ -1120,7 +1135,10 @@ void main() {
'should restore previous message if `client.sendReaction` throws', 'should restore previous message if `client.sendReaction` throws',
() async { () async {
const type = 'test-reaction-type'; const type = 'test-reaction-type';
final message = Message(id: 'test-message-id'); final message = Message(
id: 'test-message-id',
status: MessageSendingStatus.sent,
);
final reaction = Reaction(type: type, messageId: message.id); final reaction = Reaction(type: type, messageId: message.id);
@@ -1181,6 +1199,7 @@ void main() {
latestReactions: [prevReaction], latestReactions: [prevReaction],
reactionScores: const {prevType: 1}, reactionScores: const {prevType: 1},
reactionCounts: const {prevType: 1}, reactionCounts: const {prevType: 1},
status: MessageSendingStatus.sent,
); );
const type = 'test-reaction-type-2'; const type = 'test-reaction-type-2';
@@ -1212,7 +1231,7 @@ void main() {
emitsInOrder([ emitsInOrder([
[ [
isSameMessageAs( isSameMessageAs(
newMessage.copyWith(status: MessageSendingStatus.sent), newMessage,
matchReactions: true, matchReactions: true,
matchSendingStatus: true, matchSendingStatus: true,
), ),
@@ -1255,6 +1274,7 @@ void main() {
latestReactions: [reaction], latestReactions: [reaction],
reactionScores: const {type: 1}, reactionScores: const {type: 1},
reactionCounts: const {type: 1}, reactionCounts: const {type: 1},
status: MessageSendingStatus.sent,
); );
when(() => client.deleteReaction(messageId, type)) when(() => client.deleteReaction(messageId, type))
@@ -1302,6 +1322,7 @@ void main() {
latestReactions: [reaction], latestReactions: [reaction],
reactionScores: const {type: 1}, reactionScores: const {type: 1},
reactionCounts: const {type: 1}, reactionCounts: const {type: 1},
status: MessageSendingStatus.sent,
); );
when(() => client.deleteReaction(messageId, type)) when(() => client.deleteReaction(messageId, type))
@@ -1,20 +1,7 @@
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
import 'package:stream_chat/src/client/client.dart';
import 'package:stream_chat/src/core/api/device_api.dart'; import 'package:stream_chat/src/core/api/device_api.dart';
import 'package:stream_chat/src/core/api/requests.dart';
import 'package:stream_chat/src/core/api/responses.dart';
import 'package:stream_chat/src/core/error/error.dart';
import 'package:stream_chat/src/core/http/token.dart'; import 'package:stream_chat/src/core/http/token.dart';
import 'package:stream_chat/src/core/models/channel_model.dart';
import 'package:stream_chat/src/core/models/event.dart';
import 'package:stream_chat/src/core/models/filter.dart';
import 'package:stream_chat/src/core/models/message.dart';
import 'package:stream_chat/src/core/models/own_user.dart';
import 'package:stream_chat/src/core/models/user.dart';
import 'package:stream_chat/src/event_type.dart';
import 'package:stream_chat/src/ws/connection_status.dart';
import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat/stream_chat.dart';
import 'package:test/scaffolding.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
import '../fakes.dart'; import '../fakes.dart';
@@ -2314,6 +2301,33 @@ void main() {
verifyNoMoreInteractions(api.message); verifyNoMoreInteractions(api.message);
}); });
test('`.enrichUrl`', () async {
const url =
'https://www.techyourchance.com/finite-state-machine-with-unit-tests-real-world-example';
when(() => api.general.enrichUrl(url)).thenAnswer(
(_) async => OGAttachmentResponse()
..type = 'image'
..ogScrapeUrl = url
..authorName = 'TechYourChance'
..title = 'Finite State Machine with Unit Tests: Real World Example',
);
final res = await client.enrichUrl(url);
expect(res, isNotNull);
expect(res.type, 'image');
expect(res.ogScrapeUrl, url);
expect(res.authorName, 'TechYourChance');
expect(
res.title,
'Finite State Machine with Unit Tests: Real World Example',
);
verify(() => api.general.enrichUrl(url)).called(1);
verifyNoMoreInteractions(api.general);
});
test( test(
'''setting the `currentUser` should also compute and update the unreadCounts''', '''setting the `currentUser` should also compute and update the unreadCounts''',
() { () {
@@ -3,10 +3,6 @@ import 'dart:convert';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
import 'package:stream_chat/src/core/api/general_api.dart'; import 'package:stream_chat/src/core/api/general_api.dart';
import 'package:stream_chat/src/core/api/requests.dart';
import 'package:stream_chat/src/core/models/channel_model.dart';
import 'package:stream_chat/src/core/models/event.dart';
import 'package:stream_chat/src/core/models/filter.dart';
import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat/stream_chat.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
@@ -281,4 +277,39 @@ void main() {
verifyNoMoreInteractions(client); verifyNoMoreInteractions(client);
}); });
}); });
test('enrichUrl', () async {
const path = '/og';
const url =
'https://www.techyourchance.com/finite-state-machine-with-unit-tests-real-world-example';
when(() => client.get(
path,
queryParameters: {'url': url},
)).thenAnswer((_) async => successResponse(path, data: {
'type': 'image',
'og_scrape_url': url,
'author_name': 'TechYourChance',
'title': 'Finite State Machine with Unit Tests: Real World Example',
}));
final res = await generalApi.enrichUrl(url);
expect(res, isNotNull);
expect(res.type, 'image');
expect(res.ogScrapeUrl, url);
expect(res.authorName, 'TechYourChance');
expect(
res.title,
'Finite State Machine with Unit Tests: Real World Example',
);
verify(
() => client.get(
path,
queryParameters: {'url': url},
),
).called(1);
verifyNoMoreInteractions(client);
});
} }
@@ -0,0 +1,29 @@
import 'package:dio/dio.dart';
import 'package:stream_chat/src/core/http/interceptor/additional_headers_interceptor.dart';
import 'package:stream_chat/stream_chat.dart';
import 'package:test/test.dart';
void main() {
late AdditionalHeadersInterceptor additionalHeadersInterceptor;
setUp(() {
additionalHeadersInterceptor = AdditionalHeadersInterceptor();
});
test(
'`onRequest` should add additional headers in the request',
() async {
final options = RequestOptions(path: 'test-path');
final handler = RequestInterceptorHandler();
StreamChatClient.additionalHeaders = {'test-header': 'test-value'};
additionalHeadersInterceptor.onRequest(options, handler);
final updatedOptions = (await handler.future).data as RequestOptions;
final updateHeaders = updatedOptions.headers;
expect(updateHeaders.containsKey('test-header'), isTrue);
expect(updateHeaders['test-header'], 'test-value');
},
);
}
@@ -4,6 +4,7 @@ import 'package:mocktail/mocktail.dart';
import 'package:stream_chat/src/core/api/responses.dart'; import 'package:stream_chat/src/core/api/responses.dart';
import 'package:stream_chat/src/core/error/error.dart'; import 'package:stream_chat/src/core/error/error.dart';
import 'package:stream_chat/src/core/http/connection_id_manager.dart'; import 'package:stream_chat/src/core/http/connection_id_manager.dart';
import 'package:stream_chat/src/core/http/interceptor/additional_headers_interceptor.dart';
import 'package:stream_chat/src/core/http/interceptor/auth_interceptor.dart'; import 'package:stream_chat/src/core/http/interceptor/auth_interceptor.dart';
import 'package:stream_chat/src/core/http/interceptor/connection_id_interceptor.dart'; import 'package:stream_chat/src/core/http/interceptor/connection_id_interceptor.dart';
import 'package:stream_chat/src/core/http/interceptor/logging_interceptor.dart'; import 'package:stream_chat/src/core/http/interceptor/logging_interceptor.dart';
@@ -48,12 +49,23 @@ void main() {
return dioError; return dioError;
} }
test('UserAgentInterceptor should be added', () {
const apiKey = 'api-key';
final client = StreamHttpClient(apiKey);
expect(
client.httpClient.interceptors
.whereType<AdditionalHeadersInterceptor>()
.length,
1);
});
test('AuthInterceptor should be added if tokenManager is provided', () { test('AuthInterceptor should be added if tokenManager is provided', () {
const apiKey = 'api-key'; const apiKey = 'api-key';
final client = StreamHttpClient(apiKey, tokenManager: TokenManager()); final client = StreamHttpClient(apiKey, tokenManager: TokenManager());
expect(client.httpClient.interceptors.length, 1); expect(
expect(client.httpClient.interceptors.first, isA<AuthInterceptor>()); client.httpClient.interceptors.whereType<AuthInterceptor>().length, 1);
}); });
test( test(
@@ -65,10 +77,11 @@ void main() {
connectionIdManager: ConnectionIdManager(), connectionIdManager: ConnectionIdManager(),
); );
expect(client.httpClient.interceptors.length, 1);
expect( expect(
client.httpClient.interceptors.first, client.httpClient.interceptors
isA<ConnectionIdInterceptor>(), .whereType<ConnectionIdInterceptor>()
.length,
1,
); );
}, },
); );
@@ -80,10 +93,9 @@ void main() {
logger: Logger('test-logger'), logger: Logger('test-logger'),
); );
expect(client.httpClient.interceptors.length, 1);
expect( expect(
client.httpClient.interceptors.first, client.httpClient.interceptors.whereType<LoggingInterceptor>().length,
isA<LoggingInterceptor>(), 1,
); );
}); });
@@ -111,27 +123,6 @@ void main() {
verify(() => logger.severe(any())).called(greaterThan(0)); verify(() => logger.severe(any())).called(greaterThan(0));
}); });
test('`.lock` should lock the dio client', () async {
final client = StreamHttpClient('api-key');
expect(client.httpClient.interceptors.requestLock.locked, isFalse);
client.lock();
expect(client.httpClient.interceptors.requestLock.locked, isTrue);
});
test('`.unlock` should unlock the dio client', () async {
final client = StreamHttpClient('api-key');
expect(client.httpClient.interceptors.requestLock.locked, isFalse);
client.lock();
expect(client.httpClient.interceptors.requestLock.locked, isTrue);
client.unlock();
expect(client.httpClient.interceptors.requestLock.locked, isFalse);
});
test('`.clear` should clear and unlock the dio client', () async {
final client = StreamHttpClient('api-key')..clear();
expect(client.httpClient.interceptors.requestLock.locked, isFalse);
});
test('`.close` should close the dio client', () async { test('`.close` should close the dio client', () async {
final client = StreamHttpClient('api-key')..close(force: true); final client = StreamHttpClient('api-key')..close(force: true);
try { try {
@@ -70,13 +70,19 @@ void main() {
expect( expect(
newReaction.extraData, {'updated_at': '2020-01-28T22:17:31.108742Z'}); newReaction.extraData, {'updated_at': '2020-01-28T22:17:31.108742Z'});
final newUserCreateTime = DateTime.now();
newReaction = reaction.copyWith( newReaction = reaction.copyWith(
type: 'lol', type: 'lol',
createdAt: DateTime.parse('2021-01-28T22:17:31.108742Z'), createdAt: DateTime.parse('2021-01-28T22:17:31.108742Z'),
extraData: {}, extraData: {},
messageId: 'test', messageId: 'test',
score: 2, score: 2,
user: User(id: 'test'), user: User(
id: 'test',
createdAt: newUserCreateTime,
updatedAt: newUserCreateTime,
),
userId: 'test', userId: 'test',
); );
@@ -88,12 +94,21 @@ void main() {
expect(newReaction.extraData, {}); expect(newReaction.extraData, {});
expect(newReaction.messageId, 'test'); expect(newReaction.messageId, 'test');
expect(newReaction.score, 2); expect(newReaction.score, 2);
expect(newReaction.user, User(id: 'test')); expect(
newReaction.user,
User(
id: 'test',
createdAt: newUserCreateTime,
updatedAt: newUserCreateTime,
),
);
expect(newReaction.userId, 'test'); expect(newReaction.userId, 'test');
}); });
test('merge', () { test('merge', () {
final reaction = Reaction.fromJson(jsonFixture('reaction.json')); final reaction = Reaction.fromJson(jsonFixture('reaction.json'));
final newUserCreateTime = DateTime.now();
final newReaction = reaction.merge( final newReaction = reaction.merge(
Reaction( Reaction(
type: 'lol', type: 'lol',
@@ -101,7 +116,11 @@ void main() {
extraData: {}, extraData: {},
messageId: 'test', messageId: 'test',
score: 2, score: 2,
user: User(id: 'test'), user: User(
id: 'test',
createdAt: newUserCreateTime,
updatedAt: newUserCreateTime,
),
userId: 'test', userId: 'test',
), ),
); );
@@ -114,7 +133,14 @@ void main() {
expect(newReaction.extraData, {}); expect(newReaction.extraData, {});
expect(newReaction.messageId, 'test'); expect(newReaction.messageId, 'test');
expect(newReaction.score, 2); expect(newReaction.score, 2);
expect(newReaction.user, User(id: 'test')); expect(
newReaction.user,
User(
id: 'test',
createdAt: newUserCreateTime,
updatedAt: newUserCreateTime,
),
);
expect(newReaction.userId, 'test'); expect(newReaction.userId, 'test');
}); });
}); });
+25 -3
View File
@@ -6,18 +6,40 @@
- `MessageInput` now works with a `MessageInputController` instead of a `TextEditingController` - `MessageInput` now works with a `MessageInputController` instead of a `TextEditingController`
## 3.3.2
- Updated `stream_chat_flutter_core` dependency to [`3.3.1`](https://pub.dev/packages/stream_chat_flutter_core/changelog).
## 3.3.1
✅ Added
- `MessageListView` now allows more better control over spacing after messages using `spacingWidgetBuilder`.
- `StreamChannel` can now fetch messages around a message ID with the `queryAroundMessage` call.
- Added `MessageListView.keyboardDismissBehavior` property.
🐞 Fixed
- [[#766]]`AttachmentActionsModal` now has customisation options for actions.
- Fixed `MessageWidget` null errors associated with `channel.memberCount`.
- Fixed adding attachments on web.
- [[#767]](https://github.com/GetStream/stream-chat-flutter/issues/767): Fix `MessageInput` focus behaviour when sending messages.
- Fixed user presence indicator not updating correctly.
- Do not use `withData: true` in `FilePicker` calls.
- Fixed read indicator not updating correctly in specific situations.
## 3.2.0 ## 3.2.0
- Updated Dart SDK constraints to `>=2.14.0 <3.0.0` - Updated Dart SDK constraints to `>=2.14.0 <3.0.0`.
- Updated `stream_chat_flutter_core` dependency to [`3.2.0`](https://pub.dev/packages/stream_chat_flutter_core/changelog). - Updated `stream_chat_flutter_core` dependency to [`3.2.0`](https://pub.dev/packages/stream_chat_flutter_core/changelog).
🐞 Fixed 🐞 Fixed
- Fixed message highlight animation alignment in `MessageListView` - Fixed message highlight animation alignment in `MessageListView`.
- [[#491]](https://github.com/GetStream/stream-chat-flutter/issues/491): Fix `MediaListView` showing media in wrong order. - [[#491]](https://github.com/GetStream/stream-chat-flutter/issues/491): Fix `MediaListView` showing media in wrong order.
- Fixed `MessageListView` initialIndex not working in some cases. - Fixed `MessageListView` initialIndex not working in some cases.
- Improved `MessageListView` rendering in case of reordering. - Improved `MessageListView` rendering in case of reordering.
- Fix image thumbnail generation when using Stream CDN - Fix image thumbnail generation when using Stream CDN.
✅ Added ✅ Added
@@ -126,7 +126,9 @@ class ThreadPage extends StatelessWidget {
), ),
), ),
MessageInput( MessageInput(
parentMessage: parent, messageInputController: MessageInputController(
message: Message(parentId: parent!.id),
),
), ),
], ],
), ),
@@ -64,11 +64,12 @@ class MyApp extends StatelessWidget {
), ),
), ),
messageListViewTheme: const MessageListViewThemeData( messageListViewTheme: const MessageListViewThemeData(
backgroundColor: Colors.grey, backgroundColor: Colors.grey,
backgroundImage: DecorationImage( backgroundImage: DecorationImage(
image: AssetImage('assets/background_doodle.png'), image: AssetImage('assets/background_doodle.png'),
fit: BoxFit.cover, fit: BoxFit.cover,
)), ),
),
otherMessageTheme: MessageThemeData( otherMessageTheme: MessageThemeData(
messageBackgroundColor: colorTheme.textHighEmphasis, messageBackgroundColor: colorTheme.textHighEmphasis,
messageTextStyle: TextStyle( messageTextStyle: TextStyle(
@@ -165,7 +166,9 @@ class ThreadPage extends StatelessWidget {
), ),
), ),
MessageInput( MessageInput(
parentMessage: parent, messageInputController: MessageInputController(
message: Message(parentId: parent!.id),
),
), ),
], ],
), ),
@@ -27,9 +27,12 @@ dependencies:
cupertino_icons: ^1.0.3 cupertino_icons: ^1.0.3
flutter: flutter:
sdk: flutter sdk: flutter
stream_chat_flutter: ^2.2.1 stream_chat_flutter:
stream_chat_localizations: ^1.1.0 path: ../
stream_chat_persistence: ^2.2.0 stream_chat_localizations:
path: ../../stream_chat_localizations
stream_chat_persistence:
path: ../../stream_chat_persistence
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -45,6 +45,7 @@ class PositionedList extends StatefulWidget {
this.addSemanticIndexes = true, this.addSemanticIndexes = true,
this.addRepaintBoundaries = true, this.addRepaintBoundaries = true,
this.addAutomaticKeepAlives = true, this.addAutomaticKeepAlives = true,
this.keyboardDismissBehavior,
}) : assert((positionedIndex == 0) || (positionedIndex < itemCount), }) : assert((positionedIndex == 0) || (positionedIndex < itemCount),
'positionedIndex cannot be 0 and must be smaller than itemCount'), 'positionedIndex cannot be 0 and must be smaller than itemCount'),
super(key: key); super(key: key);
@@ -134,6 +135,10 @@ class PositionedList extends StatefulWidget {
/// See [SliverChildBuilderDelegate.addAutomaticKeepAlives]. /// See [SliverChildBuilderDelegate.addAutomaticKeepAlives].
final bool addAutomaticKeepAlives; final bool addAutomaticKeepAlives;
/// [ScrollViewKeyboardDismissBehavior] the defines how this [PositionedList] will
/// dismiss the keyboard automatically.
final ScrollViewKeyboardDismissBehavior? keyboardDismissBehavior;
@override @override
State<StatefulWidget> createState() => _PositionedListState(); State<StatefulWidget> createState() => _PositionedListState();
} }
@@ -173,6 +178,7 @@ class _PositionedListState extends State<PositionedList> {
anchor: widget.alignment, anchor: widget.alignment,
center: _centerKey, center: _centerKey,
controller: scrollController, controller: scrollController,
keyboardDismissBehavior: widget.keyboardDismissBehavior,
scrollDirection: widget.scrollDirection, scrollDirection: widget.scrollDirection,
reverse: widget.reverse, reverse: widget.reverse,
cacheExtent: widget.cacheExtent, cacheExtent: widget.cacheExtent,
@@ -28,9 +28,12 @@ class UnboundedCustomScrollView extends CustomScrollView {
List<Widget> slivers = const <Widget>[], List<Widget> slivers = const <Widget>[],
int? semanticChildCount, int? semanticChildCount,
DragStartBehavior dragStartBehavior = DragStartBehavior.start, DragStartBehavior dragStartBehavior = DragStartBehavior.start,
ScrollViewKeyboardDismissBehavior? keyboardDismissBehavior,
}) : _anchor = anchor, }) : _anchor = anchor,
super( super(
key: key, key: key,
keyboardDismissBehavior: keyboardDismissBehavior ??
ScrollViewKeyboardDismissBehavior.manual,
scrollDirection: scrollDirection, scrollDirection: scrollDirection,
reverse: reverse, reverse: reverse,
controller: controller, controller: controller,
@@ -52,6 +52,7 @@ class ScrollablePositionedList extends StatefulWidget {
this.addRepaintBoundaries = true, this.addRepaintBoundaries = true,
this.minCacheExtent, this.minCacheExtent,
this.findChildIndexCallback, this.findChildIndexCallback,
this.keyboardDismissBehavior,
}) : itemPositionsNotifier = itemPositionsListener as ItemPositionsNotifier?, }) : itemPositionsNotifier = itemPositionsListener as ItemPositionsNotifier?,
separatorBuilder = null, separatorBuilder = null,
super(key: key); super(key: key);
@@ -77,6 +78,7 @@ class ScrollablePositionedList extends StatefulWidget {
this.addRepaintBoundaries = true, this.addRepaintBoundaries = true,
this.minCacheExtent, this.minCacheExtent,
this.findChildIndexCallback, this.findChildIndexCallback,
this.keyboardDismissBehavior,
}) : assert(separatorBuilder != null, 'seperatorBuilder cannot be null'), }) : assert(separatorBuilder != null, 'seperatorBuilder cannot be null'),
itemPositionsNotifier = itemPositionsListener as ItemPositionsNotifier?, itemPositionsNotifier = itemPositionsListener as ItemPositionsNotifier?,
super(key: key); super(key: key);
@@ -92,6 +94,10 @@ class ScrollablePositionedList extends StatefulWidget {
/// index of the child element with that associated key, or null if not found. /// index of the child element with that associated key, or null if not found.
final ChildIndexGetter? findChildIndexCallback; final ChildIndexGetter? findChildIndexCallback;
/// [ScrollViewKeyboardDismissBehavior] the defines how this [PositionedList] will
/// dismiss the keyboard automatically.
final ScrollViewKeyboardDismissBehavior? keyboardDismissBehavior;
/// Number of items the [itemBuilder] can produce. /// Number of items the [itemBuilder] can produce.
final int itemCount; final int itemCount;
@@ -344,6 +350,7 @@ class _ScrollablePositionedListState extends State<ScrollablePositionedList>
child: NotificationListener<ScrollNotification>( child: NotificationListener<ScrollNotification>(
onNotification: (_) => _isTransitioning, onNotification: (_) => _isTransitioning,
child: PositionedList( child: PositionedList(
keyboardDismissBehavior: widget.keyboardDismissBehavior,
itemBuilder: widget.itemBuilder, itemBuilder: widget.itemBuilder,
separatorBuilder: widget.separatorBuilder, separatorBuilder: widget.separatorBuilder,
itemCount: widget.itemCount, itemCount: widget.itemCount,
@@ -374,6 +381,8 @@ class _ScrollablePositionedListState extends State<ScrollablePositionedList>
child: NotificationListener<ScrollNotification>( child: NotificationListener<ScrollNotification>(
onNotification: (_) => false, onNotification: (_) => false,
child: PositionedList( child: PositionedList(
keyboardDismissBehavior:
widget.keyboardDismissBehavior,
itemBuilder: widget.itemBuilder, itemBuilder: widget.itemBuilder,
separatorBuilder: widget.separatorBuilder, separatorBuilder: widget.separatorBuilder,
itemCount: widget.itemCount, itemCount: widget.itemCount,
@@ -24,6 +24,11 @@ class AttachmentActionsModal extends StatelessWidget {
this.onShowMessage, this.onShowMessage,
this.imageDownloader, this.imageDownloader,
this.fileDownloader, this.fileDownloader,
this.showReply = true,
this.showShowInChat = true,
this.showSave = true,
this.showDelete = true,
this.customActions = const [],
}) : super(key: key); }) : super(key: key);
/// The message containing the attachments /// The message containing the attachments
@@ -41,6 +46,49 @@ class AttachmentActionsModal extends StatelessWidget {
/// Callback to provide download files /// Callback to provide download files
final AttachmentDownloader? fileDownloader; final AttachmentDownloader? fileDownloader;
/// Show reply option
final bool showReply;
/// Show show in chat option
final bool showShowInChat;
/// Show save option
final bool showSave;
/// Show delete option
final bool showDelete;
/// List of custom actions
final List<AttachmentAction> customActions;
/// Creates a copy of [MessageWidget] with specified attributes overridden.
AttachmentActionsModal copyWith({
Key? key,
int? currentIndex,
Message? message,
VoidCallback? onShowMessage,
AttachmentDownloader? imageDownloader,
AttachmentDownloader? fileDownloader,
bool? showReply,
bool? showShowInChat,
bool? showSave,
bool? showDelete,
List<AttachmentAction>? customActions,
}) =>
AttachmentActionsModal(
key: key ?? this.key,
currentIndex: currentIndex ?? this.currentIndex,
message: message ?? this.message,
onShowMessage: onShowMessage ?? this.onShowMessage,
imageDownloader: imageDownloader ?? this.imageDownloader,
fileDownloader: fileDownloader ?? this.fileDownloader,
showReply: showReply ?? this.showReply,
showShowInChat: showShowInChat ?? this.showShowInChat,
showSave: showSave ?? this.showSave,
showDelete: showDelete ?? this.showDelete,
customActions: customActions ?? this.customActions,
);
@override @override
Widget build(BuildContext context) => GestureDetector( Widget build(BuildContext context) => GestureDetector(
behavior: HitTestBehavior.translucent, behavior: HitTestBehavior.translucent,
@@ -67,82 +115,86 @@ class AttachmentActionsModal extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
_buildButton( if (showReply)
context, _buildButton(
context.translations.replyLabel, context,
StreamSvgIcon.iconCurveLineLeftUp( context.translations.replyLabel,
size: 24, StreamSvgIcon.iconCurveLineLeftUp(
color: theme.colorTheme.textLowEmphasis, size: 24,
color: theme.colorTheme.textLowEmphasis,
),
() {
Navigator.pop(context, ReturnActionType.reply);
},
), ),
() { if (showShowInChat)
Navigator.pop(context, ReturnActionType.reply); _buildButton(
}, context,
), context.translations.showInChatLabel,
_buildButton( StreamSvgIcon.eye(
context, size: 24,
context.translations.showInChatLabel, color: theme.colorTheme.textHighEmphasis,
StreamSvgIcon.eye( ),
size: 24, onShowMessage,
color: theme.colorTheme.textHighEmphasis,
), ),
onShowMessage, if (showSave)
), _buildButton(
_buildButton( context,
context, message.attachments[currentIndex].type == 'video'
message.attachments[currentIndex].type == 'video' ? context.translations.saveVideoLabel
? context.translations.saveVideoLabel : context.translations.saveImageLabel,
: context.translations.saveImageLabel, StreamSvgIcon.iconSave(
StreamSvgIcon.iconSave( size: 24,
size: 24, color: theme.colorTheme.textLowEmphasis,
color: theme.colorTheme.textLowEmphasis, ),
() {
final attachment = message.attachments[currentIndex];
final isImage = attachment.type == 'image';
final Future<String?> Function(
Attachment, {
void Function(int, int) progressCallback,
}) saveFile = fileDownloader ?? _downloadAttachment;
final Future<String?> Function(
Attachment, {
void Function(int, int) progressCallback,
}) saveImage = imageDownloader ?? _downloadAttachment;
final downloader = isImage ? saveImage : saveFile;
final progressNotifier =
ValueNotifier<_DownloadProgress?>(
_DownloadProgress.initial(),
);
downloader(
attachment,
progressCallback: (received, total) {
progressNotifier.value = _DownloadProgress(
total,
received,
);
},
).catchError((e, stk) {
progressNotifier.value = null;
});
// Closing attachment actions modal before opening
// attachment download dialog
Navigator.pop(context);
showDialog(
barrierDismissible: false,
context: context,
barrierColor: theme.colorTheme.overlay,
builder: (context) => _buildDownloadProgressDialog(
context,
progressNotifier,
),
);
},
), ),
() {
final attachment = message.attachments[currentIndex];
final isImage = attachment.type == 'image';
final Future<String?> Function(
Attachment, {
void Function(int, int) progressCallback,
}) saveFile = fileDownloader ?? _downloadAttachment;
final Future<String?> Function(
Attachment, {
void Function(int, int) progressCallback,
}) saveImage = imageDownloader ?? _downloadAttachment;
final downloader = isImage ? saveImage : saveFile;
final progressNotifier =
ValueNotifier<_DownloadProgress?>(
_DownloadProgress.initial(),
);
downloader(
attachment,
progressCallback: (received, total) {
progressNotifier.value = _DownloadProgress(
total,
received,
);
},
).catchError((e, stk) {
progressNotifier.value = null;
});
// Closing attachment actions modal before opening
// attachment download dialog
Navigator.pop(context);
showDialog(
barrierDismissible: false,
context: context,
barrierColor: theme.colorTheme.overlay,
builder: (context) => _buildDownloadProgressDialog(
context,
progressNotifier,
),
);
},
),
if (StreamChat.of(context).currentUser?.id == if (StreamChat.of(context).currentUser?.id ==
message.user?.id) message.user?.id &&
showDelete)
_buildButton( _buildButton(
context, context,
context.translations.deleteLabel.capitalize(), context.translations.deleteLabel.capitalize(),
@@ -171,6 +223,16 @@ class AttachmentActionsModal extends StatelessWidget {
}, },
color: theme.colorTheme.accentError, color: theme.colorTheme.accentError,
), ),
...customActions
.map(
(e) => _buildButton(
context,
e.actionTitle,
e.icon,
e.onTap,
),
)
.toList(),
] ]
.map<Widget>((e) => Align( .map<Widget>((e) => Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
@@ -193,7 +255,7 @@ class AttachmentActionsModal extends StatelessWidget {
Widget _buildButton( Widget _buildButton(
context, context,
String title, String title,
StreamSvgIcon icon, Widget icon,
VoidCallback? onTap, { VoidCallback? onTap, {
Color? color, Color? color,
Key? key, Key? key,
@@ -331,3 +393,22 @@ class _DownloadProgress {
int get toPercentage => (received * 100) ~/ total; int get toPercentage => (received * 100) ~/ total;
} }
/// Class for custom attachment action
class AttachmentAction {
/// Constructor for custom attachment action
AttachmentAction({
required this.actionTitle,
required this.icon,
required this.onTap,
});
/// Title for the attachment action
String actionTitle;
/// Icon for the attachment action
Widget icon;
/// Callback for when the action is tapped
VoidCallback onTap;
}
@@ -59,9 +59,10 @@ class ChannelInfo extends StatelessWidget {
final memberCount = channel.memberCount; final memberCount = channel.memberCount;
if (memberCount != null && memberCount > 2) { if (memberCount != null && memberCount > 2) {
var text = context.translations.membersCountText(memberCount); var text = context.translations.membersCountText(memberCount);
final watcherCount = channel.state?.watcherCount ?? 0; final onlineCount =
if (watcherCount > 0) { members?.where((m) => m.user?.online == true).length ?? 0;
text += ' ${context.translations.watchersCountText(watcherCount)}'; if (onlineCount > 0) {
text += ', ${context.translations.watchersCountText(onlineCount)}';
} }
alternativeWidget = Text( alternativeWidget = Text(
text, text,
@@ -126,16 +126,26 @@ class ChannelPreview extends StatelessWidget {
streamChatState.currentUser?.id) { streamChatState.currentUser?.id) {
return Padding( return Padding(
padding: const EdgeInsets.only(right: 4), padding: const EdgeInsets.only(right: 4),
child: SendingIndicator( child: BetterStreamBuilder<List<Read>>(
message: lastMessage!, stream: channel.state?.readStream,
size: channelPreviewTheme.indicatorIconSize, initialData: channel.state?.read,
isMessageRead: channel.state!.read builder: (context, data) {
.where((element) => final readList = data.where((it) =>
element.user.id != it.user.id !=
channel.client.state.currentUser!.id) channel.client.state.currentUser?.id &&
.where((element) => element.lastRead (it.lastRead
.isAfter(lastMessage.createdAt)) .isAfter(lastMessage!.createdAt) ||
.isNotEmpty, it.lastRead.isAtSameMomentAs(
lastMessage.createdAt,
)));
final isMessageRead = readList.length >=
(channel.memberCount ?? 0) - 1;
return SendingIndicator(
message: lastMessage!,
size: channelPreviewTheme.indicatorIconSize,
isMessageRead: isMessageRead,
);
},
), ),
); );
} }
@@ -1,6 +1,7 @@
import 'package:characters/characters.dart'; import 'package:characters/characters.dart';
import 'package:diacritic/diacritic.dart'; import 'package:diacritic/diacritic.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.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';
import 'package:stream_chat_flutter/src/localization/translations.dart'; import 'package:stream_chat_flutter/src/localization/translations.dart';
@@ -46,7 +47,7 @@ extension IterableX<T> on Iterable<T> {
extension PlatformFileX on PlatformFile { extension PlatformFileX on PlatformFile {
/// Converts the [PlatformFile] into [AttachmentFile] /// Converts the [PlatformFile] into [AttachmentFile]
AttachmentFile get toAttachmentFile => AttachmentFile( AttachmentFile get toAttachmentFile => AttachmentFile(
path: path, path: kIsWeb ? null : path,
name: name, name: name,
bytes: bytes, bytes: bytes,
size: size, size: size,
@@ -33,6 +33,7 @@ class FullScreenMedia extends StatefulWidget {
this.startIndex = 0, this.startIndex = 0,
String? userName, String? userName,
this.onShowMessage, this.onShowMessage,
this.attachmentActionsModalBuilder,
}) : userName = userName ?? '', }) : userName = userName ?? '',
super(key: key); super(key: key);
@@ -51,6 +52,11 @@ class FullScreenMedia extends StatefulWidget {
/// Callback for when show message is tapped /// Callback for when show message is tapped
final ShowMessageCallback? onShowMessage; final ShowMessageCallback? onShowMessage;
/// Widget builder for attachment actions modal
/// [defaultActionsModal] is the default [AttachmentActionsModal] config
/// Use [defaultActionsModal.copyWith] to easily customize it
final AttachmentActionsBuilder? attachmentActionsModalBuilder;
@override @override
_FullScreenMediaState createState() => _FullScreenMediaState(); _FullScreenMediaState createState() => _FullScreenMediaState();
} }
@@ -196,6 +202,8 @@ class _FullScreenMediaState extends State<FullScreenMedia>
StreamChannel.of(context).channel, StreamChannel.of(context).channel,
); );
}, },
attachmentActionsModalBuilder:
widget.attachmentActionsModalBuilder,
), ),
if (!widget.message.isEphemeral) if (!widget.message.isEphemeral)
GalleryFooter( GalleryFooter(
@@ -1,7 +1,6 @@
import 'dart:io'; import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
@@ -6,6 +6,15 @@ import 'package:stream_chat_flutter/src/stream_svg_icon.dart';
import 'package:stream_chat_flutter/src/theme/themes.dart'; import 'package:stream_chat_flutter/src/theme/themes.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
/// Widget builder for attachment actions modal
/// [defaultActionsModal] is the default [AttachmentActionsModal] config
/// Use [defaultActionsModal.copyWith] to easily customize it
typedef AttachmentActionsBuilder = Widget Function(
BuildContext context,
Attachment attachment,
AttachmentActionsModal defaultActionsModal,
);
/// Header/AppBar widget for media display screen /// Header/AppBar widget for media display screen
class GalleryHeader extends StatelessWidget implements PreferredSizeWidget { class GalleryHeader extends StatelessWidget implements PreferredSizeWidget {
/// Creates a channel header /// Creates a channel header
@@ -21,6 +30,7 @@ class GalleryHeader extends StatelessWidget implements PreferredSizeWidget {
this.userName = '', this.userName = '',
this.sentAt = '', this.sentAt = '',
this.backgroundColor, this.backgroundColor,
this.attachmentActionsModalBuilder,
}) : preferredSize = const Size.fromHeight(kToolbarHeight), }) : preferredSize = const Size.fromHeight(kToolbarHeight),
super(key: key); super(key: key);
@@ -55,6 +65,11 @@ class GalleryHeader extends StatelessWidget implements PreferredSizeWidget {
/// The background color of this [GalleryHeader]. /// The background color of this [GalleryHeader].
final Color? backgroundColor; final Color? backgroundColor;
/// Widget builder for attachment actions modal
/// [defaultActionsModal] is the default [AttachmentActionsModal] config
/// Use [defaultActionsModal.copyWith] to easily customize it
final AttachmentActionsBuilder? attachmentActionsModalBuilder;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final galleryHeaderThemeData = GalleryHeaderTheme.of(context); final galleryHeaderThemeData = GalleryHeaderTheme.of(context);
@@ -123,17 +138,26 @@ class GalleryHeader extends StatelessWidget implements PreferredSizeWidget {
final galleryHeaderThemeData = final galleryHeaderThemeData =
StreamChatTheme.of(context).galleryHeaderTheme; StreamChatTheme.of(context).galleryHeaderTheme;
final defaultModal = AttachmentActionsModal(
message: message,
currentIndex: currentIndex,
onShowMessage: onShowMessage,
);
final effectiveModal = attachmentActionsModalBuilder?.call(
context,
message.attachments[currentIndex],
defaultModal,
) ??
defaultModal;
final result = await showDialog( final result = await showDialog(
useRootNavigator: false, useRootNavigator: false,
context: context, context: context,
barrierColor: galleryHeaderThemeData.bottomSheetBarrierColor, barrierColor: galleryHeaderThemeData.bottomSheetBarrierColor,
builder: (context) => StreamChannel( builder: (context) => StreamChannel(
channel: channel, channel: channel,
child: AttachmentActionsModal( child: effectiveModal,
message: message,
currentIndex: currentIndex,
onShowMessage: onShowMessage,
),
), ),
); );
@@ -81,6 +81,7 @@ class GroupAvatar extends StatelessWidget {
), ),
initialData: member, initialData: member,
builder: (context, member) => UserAvatar( builder: (context, member) => UserAvatar(
showOnlineStatus: false,
user: member.user!, user: member.user!,
borderRadius: BorderRadius.zero, borderRadius: BorderRadius.zero,
), ),
@@ -118,6 +119,7 @@ class GroupAvatar extends StatelessWidget {
), ),
initialData: member, initialData: member,
builder: (context, member) => UserAvatar( builder: (context, member) => UserAvatar(
showOnlineStatus: false,
user: member.user!, user: member.user!,
borderRadius: BorderRadius.zero, borderRadius: BorderRadius.zero,
), ),
@@ -1,6 +1,6 @@
import 'package:jiffy/jiffy.dart'; import 'package:jiffy/jiffy.dart';
import 'package:stream_chat_flutter/src/connection_status_builder.dart'; import 'package:stream_chat_flutter/src/connection_status_builder.dart';
import 'package:stream_chat_flutter/src/message_input.dart'; import 'package:stream_chat_flutter/src/message_input/message_input.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/message_search_list_view.dart'; import 'package:stream_chat_flutter/src/message_search_list_view.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart' import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'
@@ -610,7 +610,9 @@ class _MessageActionsModalState extends State<MessageActionsModal> {
widget.editMessageInputBuilder!(context, widget.message) widget.editMessageInputBuilder!(context, widget.message)
else else
MessageInput( MessageInput(
editMessage: widget.message, messageInputController: MessageInputController(
message: widget.message,
),
preMessageSending: (m) { preMessageSending: (m) {
FocusScope.of(context).unfocus(); FocusScope.of(context).unfocus();
Navigator.pop(context); Navigator.pop(context);
@@ -4,7 +4,6 @@ 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: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/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
@@ -14,20 +13,19 @@ 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/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/message_list_view.dart';
import 'package:stream_chat_flutter/src/multi_overlay.dart'; import 'package:stream_chat_flutter/src/multi_overlay.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_svg_icon.dart';
import 'package:stream_chat_flutter/src/user_mentions_overlay.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: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;
/// A function that returns true if the message is valid and can be sent.
typedef MessageValidator = bool Function(Message message);
/// A callback that can be passed to [MessageInput.onError]. /// A callback that can be passed to [MessageInput.onError].
/// ///
/// This callback should not throw. /// This callback should not throw.
@@ -42,13 +40,14 @@ typedef ErrorListener = void Function(
/// ///
/// This callback should not throw. /// This callback should not throw.
/// ///
/// It exists merely for showing custom error, and should not be used otherwise. /// It exists merely for showing a custom error, and should not be used
/// otherwise.
typedef AttachmentLimitExceedListener = void Function( typedef AttachmentLimitExceedListener = void Function(
int limit, int limit,
String error, String error,
); );
/// Builder for attachment thumbnails /// Builder for attachment thumbnails.
typedef AttachmentThumbnailBuilder = Widget Function( typedef AttachmentThumbnailBuilder = Widget Function(
BuildContext, BuildContext,
Attachment, Attachment,
@@ -77,8 +76,8 @@ typedef ActionButtonBuilder = Widget Function(
IconButton defaultActionButton, IconButton defaultActionButton,
); );
/// Widget builder for widgets that require may required data from the /// Widget builder for widgets that may require data from the
/// [MessageInputController] /// [MessageInputController].
typedef MessageRelatedBuilder = Widget Function( typedef MessageRelatedBuilder = Widget Function(
BuildContext context, BuildContext context,
MessageInputController messageInputController, MessageInputController messageInputController,
@@ -91,7 +90,7 @@ typedef AttachmentsPickerBuilder = Widget Function(
StreamAttachmentPicker defaultPicker, StreamAttachmentPicker defaultPicker,
); );
/// Location for actions on the [MessageInput] /// Location for actions on the [MessageInput].
enum ActionsLocation { enum ActionsLocation {
/// Align to left /// Align to left
left, left,
@@ -106,7 +105,7 @@ enum ActionsLocation {
rightInside, rightInside,
} }
/// Default attachments for widget /// Default attachments for widget.
enum DefaultAttachmentTypes { enum DefaultAttachmentTypes {
/// Image Attachment /// Image Attachment
image, image,
@@ -118,7 +117,7 @@ enum DefaultAttachmentTypes {
file, file,
} }
/// Available locations for the sendMessage button relative to the textField /// Available locations for the `sendMessage` button relative to the textField.
enum SendButtonLocation { enum SendButtonLocation {
/// inside the textField /// inside the textField
inside, inside,
@@ -131,17 +130,17 @@ const _kMinMediaPickerSize = 360.0;
const _kDefaultMaxAttachmentSize = 20971520; // 20MB in Bytes const _kDefaultMaxAttachmentSize = 20971520; // 20MB in Bytes
/// Inactive state /// Inactive state:
/// ///
/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/message_input.png) /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/message_input.png)
/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/message_input_paint.png) /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/message_input_paint.png)
/// ///
/// Focused state /// Focused state:
/// ///
/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/message_input2.png) /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/message_input2.png)
/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/message_input2_paint.png) /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/message_input2_paint.png)
/// ///
/// Widget used to enter the message and add attachments /// Widget used to enter a message and add attachments:
/// ///
/// ```dart /// ```dart
/// class ChannelPage extends StatelessWidget { /// class ChannelPage extends StatelessWidget {
@@ -176,20 +175,16 @@ const _kDefaultMaxAttachmentSize = 20971520; // 20MB in Bytes
/// as the bottom widget. /// as the bottom widget.
/// ///
/// The widget renders the ui based on the first ancestor of /// The widget renders the ui based on the first ancestor of
/// type [StreamChatTheme]. /// type [StreamChatTheme]. Modify it to change the widget appearance.
/// Modify it to change the widget appearance.
class MessageInput extends StatefulWidget { class MessageInput extends StatefulWidget {
/// Instantiate a new MessageInput /// Instantiate a new MessageInput
const MessageInput({ const MessageInput({
Key? key, Key? key,
this.onMessageSent, this.onMessageSent,
this.preMessageSending, this.preMessageSending,
this.parentMessage,
this.editMessage,
this.maxHeight = 150, this.maxHeight = 150,
this.keyboardType = TextInputType.multiline, this.keyboardType = TextInputType.multiline,
this.disableAttachments = false, this.disableAttachments = false,
this.initialMessage,
this.messageInputController, this.messageInputController,
this.actions = const [], this.actions = const [],
this.actionsLocation = ActionsLocation.left, this.actionsLocation = ActionsLocation.left,
@@ -218,76 +213,67 @@ class MessageInput extends StatefulWidget {
this.mentionAllAppUsers = false, this.mentionAllAppUsers = false,
this.attachmentsPickerBuilder, this.attachmentsPickerBuilder,
this.sendButtonBuilder, this.sendButtonBuilder,
}) : assert( this.shouldKeepFocusAfterMessage,
initialMessage == null || editMessage == null, this.validator = _defaultValidator,
"Can't provide both `initialMessage` and `editMessage`", this.restorationId,
), }) : super(key: key);
super(key: key);
/// List of options for showing overlays /// List of options for showing overlays.
final List<OverlayOptions> customOverlays; final List<OverlayOptions> customOverlays;
/// Message to edit /// Video quality to use when compressing the videos.
final Message? editMessage;
/// Video quality to use when compressing the videos
final VideoQuality compressedVideoQuality; final VideoQuality compressedVideoQuality;
/// Frame rate to use when compressing the videos /// Frame rate to use when compressing the videos.
final int compressedVideoFrameRate; final int compressedVideoFrameRate;
/// Max attachment size in bytes /// Max attachment size in bytes:
/// Defaults to 20 MB /// - Defaults to 20 MB
/// do not set it if you're using our default CDN /// - Do not set it if you're using our default CDN
final int maxAttachmentSize; final int maxAttachmentSize;
/// Message to start with /// Function called after sending the message.
final Message? initialMessage;
/// Function called after sending the message
final void Function(Message)? onMessageSent; final void Function(Message)? onMessageSent;
/// Function called right before sending the message /// Function called right before sending the message.
/// Use this to transform the message ///
/// Use this to transform the message.
final FutureOr<Message> Function(Message)? preMessageSending; final FutureOr<Message> Function(Message)? preMessageSending;
/// Parent message in case of a thread /// Maximum Height for the TextField to grow before it starts scrolling.
final Message? parentMessage;
/// Maximum Height for the TextField to grow before it starts scrolling
final double maxHeight; final double maxHeight;
/// The keyboard type assigned to the TextField /// The keyboard type assigned to the TextField.
final TextInputType keyboardType; final TextInputType keyboardType;
/// If true the attachments button will not be displayed /// If true the attachments button will not be displayed.
final bool disableAttachments; final bool disableAttachments;
/// Use this property to hide/show the commands button /// Use this property to hide/show the commands button.
final bool showCommandsButton; final bool showCommandsButton;
/// Hide send as dm checkbox /// Hide send as dm checkbox.
final bool hideSendAsDm; final bool hideSendAsDm;
/// The text controller of the TextField /// The text controller of the TextField.
final MessageInputController? messageInputController; final MessageInputController? messageInputController;
/// 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;
/// Map that defines a thumbnail builder for an attachment type /// Map that defines a thumbnail builder for an attachment type.
final Map<String, AttachmentThumbnailBuilder>? attachmentThumbnailBuilders; final Map<String, AttachmentThumbnailBuilder>? attachmentThumbnailBuilders;
/// The focus node associated to the TextField /// The focus node associated to the TextField.
final FocusNode? focusNode; final FocusNode? focusNode;
/// /// The message that is being quoted.
final Message? quotedMessage; final Message? quotedMessage;
/// /// Callback invoked when the quoted message is cleared.
final VoidCallback? onQuotedMessageCleared; final VoidCallback? onQuotedMessageCleared;
/// The location of the send button /// The location of the send button
@@ -342,6 +328,19 @@ class MessageInput extends StatefulWidget {
/// Builder for creating send button /// Builder for creating send button
final MessageRelatedBuilder? sendButtonBuilder; final MessageRelatedBuilder? sendButtonBuilder;
/// Defines if the [MessageInput] loses focuses after a message is sent.
/// The default behaviour keeps focus until a command is enabled.
final bool? shouldKeepFocusAfterMessage;
/// A callback function that validates the message.
final MessageValidator validator;
/// Restoration ID to save and restore the state of the MessageInput.
final String? restorationId;
static bool _defaultValidator(Message message) =>
message.text?.isNotEmpty == true || message.attachments.isNotEmpty;
@override @override
MessageInputState createState() => MessageInputState(); MessageInputState createState() => MessageInputState();
@@ -358,40 +357,77 @@ class MessageInput extends StatefulWidget {
} }
/// State of [MessageInput] /// State of [MessageInput]
class MessageInputState extends State<MessageInput> { class MessageInputState extends State<MessageInput>
with RestorationMixin<MessageInput> {
final _imagePicker = ImagePicker(); final _imagePicker = ImagePicker();
late final _focusNode = widget.focusNode ?? FocusNode(); late final _focusNode = widget.focusNode ?? FocusNode();
bool _inputEnabled = true; bool _inputEnabled = true;
bool _commandEnabled = false; bool get _commandEnabled => _effectiveController.value.command != null;
bool _showCommandsOverlay = false; bool _showCommandsOverlay = false;
bool _showMentionsOverlay = false; bool _showMentionsOverlay = false;
Command? _chosenCommand;
bool _actionsShrunk = false; bool _actionsShrunk = false;
bool _openFilePickerSection = false; bool _openFilePickerSection = false;
/// The editing controller passed to the input TextField
late final MessageInputController messageInputController =
widget.messageInputController ?? MessageInputController();
late StreamChatThemeData _streamChatTheme; late StreamChatThemeData _streamChatTheme;
late MessageInputThemeData _messageInputTheme; late MessageInputThemeData _messageInputTheme;
bool get _hasQuotedMessage => widget.quotedMessage != null; bool get _hasQuotedMessage =>
_effectiveController.value.quotedMessage != null;
bool get _messageIsPresent => messageInputController.text.trim().isNotEmpty; bool get _isEditing =>
_effectiveController.value.status != MessageSendingStatus.sending;
RestorableMessageInputController? _controller;
MessageInputController get _effectiveController =>
widget.messageInputController ?? _controller!.value;
void _createLocalController([Message? message]) {
assert(_controller == null, '');
_controller = RestorableMessageInputController(message: message);
print('_controller?.value: ${_controller?.value}');
}
void _registerController() {
assert(_controller != null, '');
registerForRestoration(_controller!, 'messageInputController');
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
if (widget.editMessage != null || widget.initialMessage != null) { if (widget.messageInputController == null) {
_parseExistingMessage(widget.editMessage ?? widget.initialMessage!); _createLocalController();
print('_controller?.value: ${_controller?.value}');
} }
messageInputController.textEditingController _effectiveController.textEditingController.addListener(_onChangedDebounced);
.addListener(_onChangedDebounced);
_focusNode.addListener(_focusNodeListener); _focusNode.addListener(_focusNodeListener);
} }
@override
void didUpdateWidget(covariant MessageInput oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.messageInputController == null &&
oldWidget.messageInputController != null) {
_createLocalController(oldWidget.messageInputController!.value);
} else if (widget.messageInputController != null &&
oldWidget.messageInputController == null) {
unregisterFromRestoration(_controller!);
_controller!.dispose();
_controller = null;
}
}
@override
void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
if (_controller != null) {
_registerController();
}
}
@override
String? get restorationId => widget.restorationId;
void _focusNodeListener() { void _focusNodeListener() {
if (_focusNode.hasFocus) { if (_focusNode.hasFocus) {
_openFilePickerSection = false; _openFilePickerSection = false;
@@ -430,9 +466,9 @@ class MessageInputState extends State<MessageInput> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Widget child = ValueListenableBuilder<Message>( Widget child = MessageValueListenableBuilder(
valueListenable: messageInputController, valueListenable: _effectiveController,
builder: (context, value, wid) => DecoratedBox( builder: (context, value, _) => DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: _messageInputTheme.inputBackgroundColor, color: _messageInputTheme.inputBackgroundColor,
), ),
@@ -479,7 +515,8 @@ class MessageInputState extends State<MessageInput> {
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
child: _buildTextField(context), child: _buildTextField(context),
), ),
if (widget.parentMessage != null && !widget.hideSendAsDm) if (_effectiveController.value.parentId != null &&
!widget.hideSendAsDm)
Padding( Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
right: 12, right: 12,
@@ -495,7 +532,7 @@ class MessageInputState extends State<MessageInput> {
), ),
), ),
); );
if (widget.editMessage == null) { if (_isEditing) {
child = Material( child = Material(
elevation: 8, elevation: 8,
child: child, child: child,
@@ -512,12 +549,12 @@ class MessageInputState extends State<MessageInput> {
), ),
OverlayOptions( OverlayOptions(
visible: _focusNode.hasFocus && visible: _focusNode.hasFocus &&
messageInputController.text.isNotEmpty && _effectiveController.text.isNotEmpty &&
messageInputController.baseOffset > 0 && _effectiveController.baseOffset > 0 &&
messageInputController.text _effectiveController.text
.substring( .substring(
0, 0,
messageInputController.baseOffset, _effectiveController.baseOffset,
) )
.contains(':'), .contains(':'),
widget: _buildEmojiOverlay(), widget: _buildEmojiOverlay(),
@@ -553,7 +590,7 @@ class MessageInputState extends State<MessageInput> {
height: 16, height: 16,
width: 16, width: 16,
foregroundDecoration: BoxDecoration( foregroundDecoration: BoxDecoration(
border: messageInputController.showInChannel border: _effectiveController.showInChannel
? null ? null
: Border.all( : Border.all(
color: _streamChatTheme.colorTheme.textHighEmphasis color: _streamChatTheme.colorTheme.textHighEmphasis
@@ -565,20 +602,18 @@ class MessageInputState extends State<MessageInput> {
child: Center( child: Center(
child: Material( child: Material(
borderRadius: BorderRadius.circular(3), borderRadius: BorderRadius.circular(3),
color: messageInputController.showInChannel color: _effectiveController.showInChannel
? _streamChatTheme.colorTheme.accentPrimary ? _streamChatTheme.colorTheme.accentPrimary
: _streamChatTheme.colorTheme.barsBg, : _streamChatTheme.colorTheme.barsBg,
child: InkWell( child: InkWell(
onTap: () { onTap: () {
setState(() { _effectiveController.showInChannel =
messageInputController.showInChannel = !_effectiveController.showInChannel;
!messageInputController.showInChannel;
});
}, },
child: AnimatedCrossFade( child: AnimatedCrossFade(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
reverseDuration: const Duration(milliseconds: 300), reverseDuration: const Duration(milliseconds: 300),
crossFadeState: messageInputController.showInChannel crossFadeState: _effectiveController.showInChannel
? CrossFadeState.showFirst ? CrossFadeState.showFirst
: CrossFadeState.showSecond, : CrossFadeState.showSecond,
firstChild: StreamSvgIcon.check( firstChild: StreamSvgIcon.check(
@@ -609,14 +644,14 @@ class MessageInputState extends State<MessageInput> {
Widget _buildSendButton(BuildContext context) { Widget _buildSendButton(BuildContext context) {
if (widget.sendButtonBuilder != null) { if (widget.sendButtonBuilder != null) {
return widget.sendButtonBuilder!(context, messageInputController); return widget.sendButtonBuilder!(context, _effectiveController);
} }
return StreamMessageSendButton( return StreamMessageSendButton(
onSendMessage: sendMessage, onSendMessage: sendMessage,
timeOut: _timeOut, timeOut: _timeOut,
isIdle: !_messageIsPresent && messageInputController.attachments.isEmpty, isIdle: !widget.validator(_effectiveController.message),
isEditEnabled: widget.editMessage != null, isEditEnabled: _isEditing,
idleSendButton: widget.idleSendButton, idleSendButton: widget.idleSendButton,
activeSendButton: widget.activeSendButton, activeSendButton: widget.activeSendButton,
); );
@@ -663,7 +698,7 @@ class MessageInputState extends State<MessageInput> {
if (!widget.disableAttachments) if (!widget.disableAttachments)
_buildAttachmentButton(context), _buildAttachmentButton(context),
if (widget.showCommandsButton && if (widget.showCommandsButton &&
widget.editMessage == null && !_isEditing &&
channel.state != null && channel.state != null &&
channel.config?.commands.isNotEmpty == true) channel.config?.commands.isNotEmpty == true)
_buildCommandButton(context), _buildCommandButton(context),
@@ -714,7 +749,7 @@ class MessageInputState extends State<MessageInput> {
maxLines: null, maxLines: null,
onSubmitted: (_) => sendMessage(), onSubmitted: (_) => sendMessage(),
keyboardType: widget.keyboardType, keyboardType: widget.keyboardType,
controller: messageInputController, controller: _effectiveController,
focusNode: _focusNode, focusNode: _focusNode,
style: _messageInputTheme.inputTextStyle, style: _messageInputTheme.inputTextStyle,
autofocus: widget.autofocus, autofocus: widget.autofocus,
@@ -786,7 +821,7 @@ class MessageInputState extends State<MessageInput> {
size: 16, size: 16,
), ),
Text( Text(
_chosenCommand?.name.toUpperCase() ?? '', _effectiveController.value.command!.toUpperCase(),
style: style:
_streamChatTheme.textTheme.footnoteBold.copyWith( _streamChatTheme.textTheme.footnoteBold.copyWith(
color: Colors.white, color: Colors.white,
@@ -820,9 +855,7 @@ class MessageInputState extends State<MessageInput> {
height: 24, height: 24,
width: 24, width: 24,
), ),
onPressed: () { onPressed: _effectiveController.clear,
setState(() => _commandEnabled = false);
},
), ),
), ),
if (!_commandEnabled && if (!_commandEnabled &&
@@ -837,14 +870,16 @@ class MessageInputState extends State<MessageInput> {
late final _onChangedDebounced = debounce( late final _onChangedDebounced = debounce(
() { () {
var value = messageInputController.text; var value = _effectiveController.text;
if (!mounted) return; if (!mounted) return;
value = value.trim(); value = value.trim();
final channel = StreamChannel.of(context).channel; final channel = StreamChannel.of(context).channel;
if (value.isNotEmpty) { if (value.isNotEmpty) {
// ignore: no-empty-block channel
channel.keyStroke(widget.parentMessage?.id).catchError((e) {}); .keyStroke(_effectiveController.value.parentId)
// ignore: no-empty-block
.catchError((e) {});
} }
var actionsLength = widget.actions.length; var actionsLength = widget.actions.length;
@@ -864,10 +899,10 @@ class MessageInputState extends State<MessageInput> {
); );
String _getHint(BuildContext context) { String _getHint(BuildContext context) {
if (_commandEnabled && _chosenCommand!.name == 'giphy') { if (_commandEnabled && _effectiveController.value.command == 'giphy') {
return context.translations.searchGifLabel; return context.translations.searchGifLabel;
} }
if (messageInputController.attachments.isNotEmpty) { if (_effectiveController.attachments.isNotEmpty) {
return context.translations.addACommentOrSendLabel; return context.translations.addACommentOrSendLabel;
} }
if (_timeOut != 0) { if (_timeOut != 0) {
@@ -879,16 +914,16 @@ class MessageInputState extends State<MessageInput> {
void _checkEmoji(String s, BuildContext context) { void _checkEmoji(String s, BuildContext context) {
if (s.isNotEmpty && if (s.isNotEmpty &&
messageInputController.baseOffset > 0 && _effectiveController.baseOffset > 0 &&
messageInputController.text _effectiveController.text
.substring( .substring(
0, 0,
messageInputController.baseOffset, _effectiveController.baseOffset,
) )
.contains(':')) { .contains(':')) {
final textToSelection = messageInputController.text.substring( final textToSelection = _effectiveController.text.substring(
0, 0,
messageInputController.selectionStart, _effectiveController.selectionStart,
); );
final splits = textToSelection.split(':'); final splits = textToSelection.split(':');
final query = splits[splits.length - 2].toLowerCase(); final query = splits[splits.length - 2].toLowerCase();
@@ -902,11 +937,11 @@ class MessageInputState extends State<MessageInput> {
void _checkMentions(String s, BuildContext context) { void _checkMentions(String s, BuildContext context) {
if (s.isNotEmpty && if (s.isNotEmpty &&
messageInputController.baseOffset > 0 && _effectiveController.baseOffset > 0 &&
messageInputController.text _effectiveController.text
.substring( .substring(
0, 0,
messageInputController.baseOffset, _effectiveController.baseOffset,
) )
.split(' ') .split(' ')
.last .last
@@ -943,7 +978,7 @@ class MessageInputState extends State<MessageInput> {
} }
Widget _buildCommandsOverlayEntry() { Widget _buildCommandsOverlayEntry() {
final text = messageInputController.text.trimLeft(); final text = _effectiveController.text.trimLeft();
final renderObject = context.findRenderObject() as RenderBox?; final renderObject = context.findRenderObject() as RenderBox?;
if (renderObject == null) { if (renderObject == null) {
@@ -959,7 +994,7 @@ class MessageInputState extends State<MessageInput> {
Widget _buildFilePickerSection() { Widget _buildFilePickerSection() {
final picker = StreamAttachmentPicker( final picker = StreamAttachmentPicker(
messageInputController: messageInputController, messageInputController: _effectiveController,
onFilePicked: pickFile, onFilePicked: pickFile,
isOpen: _openFilePickerSection, isOpen: _openFilePickerSection,
pickerSize: _openFilePickerSection ? _kMinMediaPickerSize : 0, pickerSize: _openFilePickerSection ? _kMinMediaPickerSize : 0,
@@ -968,18 +1003,13 @@ class MessageInputState extends State<MessageInput> {
maxAttachmentSize: widget.maxAttachmentSize, maxAttachmentSize: widget.maxAttachmentSize,
compressedVideoQuality: widget.compressedVideoQuality, compressedVideoQuality: widget.compressedVideoQuality,
compressedVideoFrameRate: widget.compressedVideoFrameRate, compressedVideoFrameRate: widget.compressedVideoFrameRate,
onChangeInputState: (val) {
setState(() {
_inputEnabled = val;
});
},
onError: _showErrorAlert, onError: _showErrorAlert,
); );
if (_openFilePickerSection && widget.attachmentsPickerBuilder != null) { if (_openFilePickerSection && widget.attachmentsPickerBuilder != null) {
return widget.attachmentsPickerBuilder!( return widget.attachmentsPickerBuilder!(
context, context,
messageInputController, _effectiveController,
picker, picker,
); );
} }
@@ -988,14 +1018,15 @@ class MessageInputState extends State<MessageInput> {
} }
Widget _buildMentionsOverlayEntry() { Widget _buildMentionsOverlayEntry() {
if (messageInputController.selectionStart < 0) { final channel = StreamChannel.of(context).channel;
if (_effectiveController.selectionStart < 0 || channel.state == null) {
return const Offstage(); return const Offstage();
} }
final splits = messageInputController.text final splits = _effectiveController.text
.substring( .substring(
0, 0,
messageInputController.selectionStart, _effectiveController.selectionStart,
) )
.split('@'); .split('@');
final query = splits.last.toLowerCase(); final query = splits.last.toLowerCase();
@@ -1020,23 +1051,19 @@ class MessageInputState extends State<MessageInput> {
query: query, query: query,
mentionAllAppUsers: widget.mentionAllAppUsers, mentionAllAppUsers: widget.mentionAllAppUsers,
client: StreamChat.of(context).client, client: StreamChat.of(context).client,
channel: StreamChannel.of(context).channel, channel: channel,
size: Size(renderObject.size.width - 16, 400), size: Size(renderObject.size.width - 16, 400),
mentionsTileBuilder: tileBuilder, mentionsTileBuilder: tileBuilder,
onMentionUserTap: (user) { onMentionUserTap: (user) {
messageInputController.addMentionedUser(user); _effectiveController.addMentionedUser(user);
splits[splits.length - 1] = user.name; splits[splits.length - 1] = user.name;
final rejoin = splits.join('@'); final rejoin = splits.join('@');
messageInputController.textEditingController.value = TextEditingValue( _effectiveController.text = rejoin +
text: rejoin + _effectiveController.text.substring(
messageInputController.text.substring( _effectiveController.selectionStart,
messageInputController.selectionStart, );
),
selection: TextSelection.collapsed(
offset: rejoin.length,
),
);
_onChangedDebounced.cancel(); _onChangedDebounced.cancel();
setState(() => _showMentionsOverlay = false); setState(() => _showMentionsOverlay = false);
}, },
@@ -1044,14 +1071,14 @@ class MessageInputState extends State<MessageInput> {
} }
Widget _buildEmojiOverlay() { Widget _buildEmojiOverlay() {
if (messageInputController.baseOffset < 0) { if (_effectiveController.baseOffset < 0) {
return const Offstage(); return const Offstage();
} }
final splits = messageInputController.text final splits = _effectiveController.text
.substring( .substring(
0, 0,
messageInputController.baseOffset, _effectiveController.baseOffset,
) )
.split(':'); .split(':');
@@ -1071,22 +1098,17 @@ class MessageInputState extends State<MessageInput> {
void _chooseEmoji(List<String> splits, Emoji emoji) { void _chooseEmoji(List<String> splits, Emoji emoji) {
final rejoin = splits.sublist(0, splits.length - 1).join(':') + emoji.char!; final rejoin = splits.sublist(0, splits.length - 1).join(':') + emoji.char!;
messageInputController.textEditingController.value = TextEditingValue( _effectiveController.text = rejoin +
text: rejoin + _effectiveController.text.substring(
messageInputController.text.substring( _effectiveController.selectionStart,
messageInputController.selectionStart, );
),
selection: TextSelection.collapsed(
offset: rejoin.length,
),
);
} }
void _setCommand(Command c) { void _setCommand(Command c) {
messageInputController.clear(); _effectiveController
..clear()
..command = c;
setState(() { setState(() {
_chosenCommand = c;
_commandEnabled = true;
_showCommandsOverlay = false; _showCommandsOverlay = false;
}); });
} }
@@ -1105,11 +1127,11 @@ class MessageInputState extends State<MessageInput> {
} }
Widget _buildAttachments() { Widget _buildAttachments() {
if (messageInputController.attachments.isEmpty) return const Offstage(); if (_effectiveController.attachments.isEmpty) return const Offstage();
final fileAttachments = messageInputController.attachments final fileAttachments = _effectiveController.attachments
.where((it) => it.type == 'file') .where((it) => it.type == 'file')
.toList(growable: false); .toList(growable: false);
final remainingAttachments = messageInputController.attachments final remainingAttachments = _effectiveController.attachments
.where((it) => it.type != 'file') .where((it) => it.type != 'file')
.toList(growable: false); .toList(growable: false);
return Column( return Column(
@@ -1127,9 +1149,7 @@ class MessageInputState extends State<MessageInput> {
(e) => ClipRRect( (e) => ClipRRect(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
child: FileAttachment( child: FileAttachment(
message: Message( message: Message(), // dummy message
status: MessageSendingStatus.sending,
), // dummy message
attachment: e, attachment: e,
size: Size( size: Size(
MediaQuery.of(context).size.width * 0.65, MediaQuery.of(context).size.width * 0.65,
@@ -1196,9 +1216,10 @@ class MessageInputState extends State<MessageInput> {
focusElevation: 0, focusElevation: 0,
hoverElevation: 0, hoverElevation: 0,
onPressed: () { onPressed: () {
setState( _effectiveController.value = _effectiveController.value.copyWith(
() => messageInputController.attachments attachments: _effectiveController.attachments
.removeWhere((e) => e.id == attachment.id), .where((it) => it.id != attachment.id)
.toList(),
); );
}, },
fillColor: fillColor:
@@ -1278,7 +1299,7 @@ class MessageInputState extends State<MessageInput> {
} }
Widget _buildCommandButton(BuildContext context) { Widget _buildCommandButton(BuildContext context) {
final s = messageInputController.text.trim(); final s = _effectiveController.text.trim();
final defaultButton = IconButton( final defaultButton = IconButton(
icon: StreamSvgIcon.lightning( icon: StreamSvgIcon.lightning(
color: s.isNotEmpty color: s.isNotEmpty
@@ -1406,14 +1427,13 @@ class MessageInputState extends State<MessageInput> {
/// ///
/// Note: Only meant to be used from outside the state. /// Note: Only meant to be used from outside the state.
void addAttachment(Attachment attachment) { void addAttachment(Attachment attachment) {
setState(() => _addAttachments([attachment])); _addAttachments([attachment]);
} }
/// Adds an attachment to the [messageInputController.attachments] map /// Adds an attachment to the [messageInputController.attachments] map
void _addAttachments(Iterable<Attachment> attachments) { void _addAttachments(Iterable<Attachment> attachments) {
final limit = widget.attachmentLimit; final limit = widget.attachmentLimit;
final length = final length = _effectiveController.attachments.length + attachments.length;
messageInputController.attachments.length + attachments.length;
if (length > limit) { if (length > limit) {
final onAttachmentLimitExceed = widget.onAttachmentLimitExceed; final onAttachmentLimitExceed = widget.onAttachmentLimitExceed;
if (onAttachmentLimitExceed != null) { if (onAttachmentLimitExceed != null) {
@@ -1427,7 +1447,7 @@ class MessageInputState extends State<MessageInput> {
); );
} }
for (final attachment in attachments) { for (final attachment in attachments) {
messageInputController.addAttachment(attachment); _effectiveController.addAttachment(attachment);
} }
} }
@@ -1476,7 +1496,6 @@ class MessageInputState extends State<MessageInput> {
} }
final res = await FilePicker.platform.pickFiles( final res = await FilePicker.platform.pickFiles(
type: type, type: type,
withData: true,
); );
if (res?.files.isNotEmpty == true) { if (res?.files.isNotEmpty == true) {
file = res!.files.single.toAttachmentFile; file = res!.files.single.toAttachmentFile;
@@ -1534,70 +1553,26 @@ class MessageInputState extends State<MessageInput> {
} }
} }
setState(() { _addAttachments([
_addAttachments([ attachment.copyWith(
attachment.copyWith( file: file,
file: file, extraData: {...attachment.extraData}
extraData: {...attachment.extraData} ..update('file_size', ((_) => file!.size!)),
..update('file_size', ((_) => file!.size!)), ),
), ]);
]);
});
} }
/// Sends the current message /// Sends the current message
Future<void> sendMessage() async { Future<void> sendMessage() async {
var text = messageInputController.text.trim(); var message = _effectiveController.value;
final attachments = messageInputController.attachments;
if (text.isEmpty && attachments.isEmpty) { var shouldKeepFocus = widget.shouldKeepFocusAfterMessage;
return;
}
final shouldUnfocus = _commandEnabled; shouldKeepFocus ??= !_commandEnabled;
if (_commandEnabled) { _effectiveController.reset();
text = '${'/${_chosenCommand!.name} '}$text';
}
messageInputController
..text = ''
..clearAttachments();
widget.onQuotedMessageCleared?.call(); widget.onQuotedMessageCleared?.call();
setState(() {
_commandEnabled = false;
});
Message message;
if (widget.editMessage != null) {
message = widget.editMessage!.copyWith(
text: text,
attachments: attachments,
mentionedUsers: messageInputController.mentionedUsers
.where((u) => text.contains('@${u.name}'))
.toList(),
);
} else {
message = (widget.initialMessage ?? Message()).copyWith(
parentId: widget.parentMessage?.id,
text: text,
attachments: attachments,
mentionedUsers: messageInputController.mentionedUsers
.where((u) => text.contains('@${u.name}'))
.toList(),
showInChannel: widget.parentMessage != null
? messageInputController.showInChannel
: null,
);
}
if (widget.quotedMessage != null) {
message = message.copyWith(
quotedMessageId: widget.quotedMessage!.id,
);
}
if (widget.preMessageSending != null) { if (widget.preMessageSending != null) {
message = await widget.preMessageSending!(message); message = await widget.preMessageSending!(message);
} }
@@ -1608,25 +1583,23 @@ class MessageInputState extends State<MessageInput> {
await streamChannel.reloadChannel(); await streamChannel.reloadChannel();
} }
messageInputController.clearMentionedUsers();
try { try {
Future sendingFuture; Future sendingFuture;
if (widget.editMessage == null || if (!_isEditing) {
widget.editMessage!.status == MessageSendingStatus.failed ||
widget.editMessage!.status == MessageSendingStatus.sending) {
sendingFuture = channel.sendMessage(message); sendingFuture = channel.sendMessage(message);
} else { } else {
sendingFuture = channel.updateMessage(message); sendingFuture = channel.updateMessage(message);
} }
if (!shouldUnfocus) { if (shouldKeepFocus) {
FocusScope.of(context).requestFocus(_focusNode); FocusScope.of(context).requestFocus(_focusNode);
} else {
FocusScope.of(context).unfocus();
} }
final resp = await sendingFuture; final resp = await sendingFuture;
if (resp.message?.type == 'error') { if (resp.message?.type == 'error') {
_parseExistingMessage(message); _effectiveController.value = message;
} }
_startSlowMode(); _startSlowMode();
widget.onMessageSent?.call(resp.message); widget.onMessageSent?.call(resp.message);
@@ -1705,36 +1678,23 @@ class MessageInputState extends State<MessageInput> {
); );
} }
void _parseExistingMessage(Message message) {
final messageText = message.text;
if (messageText != null) messageInputController.text = messageText;
_addAttachments(message.attachments);
}
@override @override
void dispose() { void dispose() {
messageInputController.textEditingController _effectiveController.textEditingController
.removeListener(_onChangedDebounced); .removeListener(_onChangedDebounced);
messageInputController.dispose(); _controller?.dispose();
_focusNode.removeListener(_focusNodeListener); _focusNode.removeListener(_focusNodeListener);
_stopSlowMode(); _stopSlowMode();
_onChangedDebounced.cancel(); _onChangedDebounced.cancel();
super.dispose(); super.dispose();
} }
bool _initialized = false;
@override @override
void didChangeDependencies() { void didChangeDependencies() {
_streamChatTheme = StreamChatTheme.of(context); _streamChatTheme = StreamChatTheme.of(context);
_messageInputTheme = MessageInputTheme.of(context); _messageInputTheme = MessageInputTheme.of(context);
if (widget.editMessage == null && _timeOut <= 0) _startSlowMode(); if (!_isEditing && _timeOut <= 0) _startSlowMode();
if ((widget.editMessage != null || widget.initialMessage != null) &&
!_initialized) {
FocusScope.of(context).requestFocus(_focusNode);
_initialized = true;
}
super.didChangeDependencies(); super.didChangeDependencies();
} }
} }
@@ -21,30 +21,41 @@ typedef CustomAttachmentIconBuilder = Widget Function(
bool active, bool active,
); );
/// /// A widget that allows to pick an attachment.
class StreamAttachmentPicker extends StatefulWidget { class StreamAttachmentPicker extends StatefulWidget {
/// True if the picker is open.
final bool isOpen; final bool isOpen;
/// The picker size in height.
final double pickerSize; final double pickerSize;
/// The [MessageInputController] linked to this picker.
final MessageInputController messageInputController; final MessageInputController messageInputController;
/// The limit of attachments that can be picked.
final int attachmentLimit; final int attachmentLimit;
/// The callback for when the attachment limit is exceeded.
final AttachmentLimitExceedListener? onAttachmentLimitExceeded; final AttachmentLimitExceedListener? onAttachmentLimitExceeded;
final ValueChanged<bool>? onChangeInputState;
final ValueChanged<String>? onError; final ValueChanged<String>? onError;
final FilePickerCallback onFilePicked; final FilePickerCallback onFilePicked;
/// Video quality to use when compressing the videos /// Video quality to use when compressing the videos.
final VideoQuality compressedVideoQuality; final VideoQuality compressedVideoQuality;
/// Frame rate to use when compressing the videos /// Frame rate to use when compressing the videos.
final int compressedVideoFrameRate; final int compressedVideoFrameRate;
/// Max attachment size in bytes /// Max attachment size in bytes:
/// Defaults to 20 MB /// - Defaults to 20 MB
/// do not set it if you're using our default CDN /// - Do not set it if you're using our default CDN
final int maxAttachmentSize; final int maxAttachmentSize;
/// The list of attachment types that can be picked.
final List<DefaultAttachmentTypes> allowedAttachmentTypes; final List<DefaultAttachmentTypes> allowedAttachmentTypes;
/// The list of custom attachment types that can be picked.
final List<CustomAttachmentType> customAttachmentTypes; final List<CustomAttachmentType> customAttachmentTypes;
const StreamAttachmentPicker({ const StreamAttachmentPicker({
@@ -58,7 +69,6 @@ class StreamAttachmentPicker extends StatefulWidget {
this.maxAttachmentSize = 20971520, this.maxAttachmentSize = 20971520,
this.compressedVideoQuality = VideoQuality.DefaultQuality, this.compressedVideoQuality = VideoQuality.DefaultQuality,
this.compressedVideoFrameRate = 30, this.compressedVideoFrameRate = 30,
this.onChangeInputState,
this.onError, this.onError,
this.allowedAttachmentTypes = const [ this.allowedAttachmentTypes = const [
DefaultAttachmentTypes.image, DefaultAttachmentTypes.image,
@@ -99,7 +109,6 @@ class StreamAttachmentPicker extends StatefulWidget {
compressedVideoQuality ?? this.compressedVideoQuality, compressedVideoQuality ?? this.compressedVideoQuality,
compressedVideoFrameRate: compressedVideoFrameRate:
compressedVideoFrameRate ?? this.compressedVideoFrameRate, compressedVideoFrameRate ?? this.compressedVideoFrameRate,
onChangeInputState: onChangeInputState ?? this.onChangeInputState,
onError: onError ?? this.onError, onError: onError ?? this.onError,
allowedAttachmentTypes: allowedAttachmentTypes:
allowedAttachmentTypes ?? this.allowedAttachmentTypes, allowedAttachmentTypes ?? this.allowedAttachmentTypes,
@@ -116,8 +125,8 @@ class _StreamAttachmentPickerState extends State<StreamAttachmentPicker> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var _streamChatTheme = StreamChatTheme.of(context); final _streamChatTheme = StreamChatTheme.of(context);
var messageInputController = widget.messageInputController; final messageInputController = widget.messageInputController;
final _attachmentContainsImage = final _attachmentContainsImage =
messageInputController.attachments.any((it) => it.type == 'image'); messageInputController.attachments.any((it) => it.type == 'image');
@@ -1,15 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// A widget that displays a sending button.
class StreamMessageSendButton extends StatelessWidget { class StreamMessageSendButton extends StatelessWidget {
final int timeOut; /// Returns a [StreamMessageSendButton] with the given [timeOut], [isIdle],
final bool isIdle; /// [isCommandEnabled], [isEditEnabled], [idleSendButton], [activeSendButton],
final bool isCommandEnabled; /// [onSendMessage].
final bool isEditEnabled;
final Widget? idleSendButton;
final Widget? activeSendButton;
final VoidCallback onSendMessage;
const StreamMessageSendButton({ const StreamMessageSendButton({
Key? key, Key? key,
this.timeOut = 0, this.timeOut = 0,
@@ -21,9 +17,30 @@ class StreamMessageSendButton extends StatelessWidget {
required this.onSendMessage, required this.onSendMessage,
}) : super(key: key); }) : super(key: key);
/// Time out related to slow mode.
final int timeOut;
/// If true the button will be disabled.
final bool isIdle;
/// True if a command is being sent.
final bool isCommandEnabled;
/// True if in editing mode.
final bool isEditEnabled;
/// The widget to display when the button is disabled.
final Widget? idleSendButton;
/// The widget to display when the button is enabled.
final Widget? activeSendButton;
/// The callback to call when the button is pressed.
final VoidCallback onSendMessage;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var _streamChatTheme = StreamChatTheme.of(context); final _streamChatTheme = StreamChatTheme.of(context);
late Widget sendButton; late Widget sendButton;
if (timeOut > 0) { if (timeOut > 0) {
@@ -46,7 +63,7 @@ class StreamMessageSendButton extends StatelessWidget {
} }
Widget _buildIdleSendButton(BuildContext context) { Widget _buildIdleSendButton(BuildContext context) {
var _messageInputTheme = MessageInputTheme.of(context); final _messageInputTheme = MessageInputTheme.of(context);
return Padding( return Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
@@ -58,7 +75,7 @@ class StreamMessageSendButton extends StatelessWidget {
} }
Widget _buildSendButton(BuildContext context) { Widget _buildSendButton(BuildContext context) {
var _messageInputTheme = MessageInputTheme.of(context); final _messageInputTheme = MessageInputTheme.of(context);
return Padding( return Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
@@ -1,3 +1,5 @@
// ignore_for_file: prefer-trailing-comma, cascade_invocations
import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle; import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle;
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
@@ -16,11 +18,12 @@ export 'package:flutter/services.dart'
SmartQuotesType, SmartQuotesType,
SmartDashesType; SmartDashesType;
/// A widget the wraps the [TextField] and adds some StreamChat specifics.
class StreamMessageTextField extends StatefulWidget { class StreamMessageTextField extends StatefulWidget {
/// Creates a Material Design text field. /// Creates a Material Design text field.
/// ///
/// If [decoration] is non-null (which is the default), the text field requires /// If [decoration] is non-null (which is the default), the text field
/// one of its ancestors to be a [Material] widget. /// requires one of its ancestors to be a [Material] widget.
/// ///
/// To remove the decoration entirely (including the extra padding introduced /// To remove the decoration entirely (including the extra padding introduced
/// by the decoration to save space for the labels), set the [decoration] to /// by the decoration to save space for the labels), set the [decoration] to
@@ -45,10 +48,6 @@ class StreamMessageTextField extends StatefulWidget {
/// which is evaluated after the supplied [inputFormatters], if any. /// which is evaluated after the supplied [inputFormatters], if any.
/// The [maxLength] value must be either null or greater than zero. /// The [maxLength] value must be either null or greater than zero.
/// ///
/// If [maxLengthEnforced] is set to false, then more than [maxLength]
/// characters may be entered, and the error counter and divider will
/// switch to the [decoration].errorStyle when the limit is exceeded.
///
/// The text cursor is not shown if [showCursor] is false or if [showCursor] /// The text cursor is not shown if [showCursor] is false or if [showCursor]
/// is null (the default) and [readOnly] is true. /// is null (the default) and [readOnly] is true.
/// ///
@@ -58,7 +57,7 @@ class StreamMessageTextField extends StatefulWidget {
/// must not be null. /// must not be null.
/// ///
/// The [textAlign], [autofocus], [obscureText], [readOnly], [autocorrect], /// The [textAlign], [autofocus], [obscureText], [readOnly], [autocorrect],
/// [maxLengthEnforced], [scrollPadding], [maxLines], [maxLength], /// [scrollPadding], [maxLines], [maxLength],
/// [selectionHeightStyle], [selectionWidthStyle], [enableSuggestions], and /// [selectionHeightStyle], [selectionWidthStyle], [enableSuggestions], and
/// [enableIMEPersonalizedLearning] arguments must not be null. /// [enableIMEPersonalizedLearning] arguments must not be null.
/// ///
@@ -113,7 +112,7 @@ class StreamMessageTextField extends StatefulWidget {
this.selectionHeightStyle = ui.BoxHeightStyle.tight, this.selectionHeightStyle = ui.BoxHeightStyle.tight,
this.selectionWidthStyle = ui.BoxWidthStyle.tight, this.selectionWidthStyle = ui.BoxWidthStyle.tight,
this.keyboardAppearance, this.keyboardAppearance,
this.scrollPadding = const EdgeInsets.all(20.0), this.scrollPadding = const EdgeInsets.all(20),
this.dragStartBehavior = DragStartBehavior.start, this.dragStartBehavior = DragStartBehavior.start,
this.enableInteractiveSelection = true, this.enableInteractiveSelection = true,
this.selectionControls, this.selectionControls,
@@ -125,51 +124,44 @@ class StreamMessageTextField extends StatefulWidget {
this.autofillHints, this.autofillHints,
this.restorationId, this.restorationId,
this.enableIMEPersonalizedLearning = true, this.enableIMEPersonalizedLearning = true,
}) : assert(textAlign != null), }) : assert(obscuringCharacter.length == 1,
assert(readOnly != null), '`obscuringCharacter.length` must be 1'),
assert(autofocus != null),
assert(obscuringCharacter != null && obscuringCharacter.length == 1),
assert(obscureText != null),
assert(autocorrect != null),
smartDashesType = smartDashesType ?? smartDashesType = smartDashesType ??
(obscureText ? SmartDashesType.disabled : SmartDashesType.enabled), (obscureText ? SmartDashesType.disabled : SmartDashesType.enabled),
smartQuotesType = smartQuotesType ?? smartQuotesType = smartQuotesType ??
(obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled), (obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled),
assert(enableSuggestions != null),
assert(enableInteractiveSelection != null),
assert(maxLengthEnforced != null),
assert( assert(
maxLengthEnforced || maxLengthEnforcement == null, maxLengthEnforced || maxLengthEnforcement == null,
'maxLengthEnforced is deprecated, use only maxLengthEnforcement', 'maxLengthEnforced is deprecated, use only maxLengthEnforcement',
), ),
assert(scrollPadding != null), assert(maxLines == null || maxLines > 0,
assert(dragStartBehavior != null), '`maxLines` needs to be left as null or bigger than 0'),
assert(selectionHeightStyle != null), assert(minLines == null || minLines > 0,
assert(selectionWidthStyle != null), '`minLines` needs to be left as null or bigger than 0'),
assert(maxLines == null || maxLines > 0),
assert(minLines == null || minLines > 0),
assert( assert(
(maxLines == null) || (minLines == null) || (maxLines >= minLines), (maxLines == null) || (minLines == null) || (maxLines >= minLines),
"minLines can't be greater than maxLines", "minLines can't be greater than maxLines",
), ),
assert(expands != null),
assert( assert(
!expands || (maxLines == null && minLines == null), !expands || (maxLines == null && minLines == null),
'minLines and maxLines must be null when expands is true.', 'minLines and maxLines must be null when expands is true.',
), ),
assert(!obscureText || maxLines == 1, assert(!obscureText || maxLines == 1,
'Obscured fields cannot be multiline.'), 'Obscured fields cannot be multiline.'),
assert(maxLength == null || assert(
maxLength == TextField.noMaxLength || maxLength == null ||
maxLength > 0), maxLength == TextField.noMaxLength ||
// Assert the following instead of setting it directly to avoid surprising the user by silently changing the value they set. maxLength > 0,
'`maxLength` needs to be null or a positive integer'),
// Assert the following instead of setting it directly to avoid
// surprising the user by silently changing the value they set.
assert( assert(
!identical(textInputAction, TextInputAction.newline) || !identical(textInputAction, TextInputAction.newline) ||
maxLines == 1 || maxLines == 1 ||
!identical(keyboardType, TextInputType.text), !identical(keyboardType, TextInputType.text),
'Use keyboardType TextInputType.multiline when using TextInputAction.newline on a multiline TextField.', '''Use keyboardType TextInputType.multiline when using TextInputAction.newline on a multiline TextField.''',
), ),
assert(enableIMEPersonalizedLearning != null),
keyboardType = keyboardType ?? keyboardType = keyboardType ??
(maxLines == 1 ? TextInputType.text : TextInputType.multiline), (maxLines == 1 ? TextInputType.text : TextInputType.multiline),
toolbarOptions = toolbarOptions ?? toolbarOptions = toolbarOptions ??
@@ -228,7 +220,8 @@ class StreamMessageTextField extends StatefulWidget {
/// cause the focus to change, and will not make the keyboard visible. /// cause the focus to change, and will not make the keyboard visible.
/// ///
/// This widget builds an [EditableText] and will ensure that the keyboard is /// This widget builds an [EditableText] and will ensure that the keyboard is
/// showing when it is tapped by calling [EditableTextState.requestKeyboard()]. /// showing when it is tapped by calling
/// [EditableTextState.requestKeyboard()].
final FocusNode? focusNode; final FocusNode? focusNode;
/// The decoration to show around the text field. /// The decoration to show around the text field.
@@ -328,16 +321,20 @@ class StreamMessageTextField extends StatefulWidget {
/// If set, a character counter will be displayed below the /// If set, a character counter will be displayed below the
/// field showing how many characters have been entered. If set to a number /// field showing how many characters have been entered. If set to a number
/// greater than 0, it will also display the maximum number allowed. If set /// greater than 0, it will also display the maximum number allowed. If set
/// to [TextField.noMaxLength] then only the current character count is displayed. /// to [TextField.noMaxLength] then only the current character count is
/// displayed.
/// ///
/// After [maxLength] characters have been input, additional input /// After [maxLength] characters have been input, additional input
/// is ignored, unless [maxLengthEnforcement] is set to /// is ignored, unless [maxLengthEnforcement] is set to
/// [MaxLengthEnforcement.none]. /// [MaxLengthEnforcement.none].
/// ///
/// The text field enforces the length with a [LengthLimitingTextInputFormatter], /// The text field enforces the length with a
/// which is evaluated after the supplied [inputFormatters], if any. /// [LengthLimitingTextInputFormatter], which is evaluated after the supplied
/// [inputFormatters], if any.
///
/// This value must be either null, [TextField.noMaxLength], or greater than
/// 0.
/// ///
/// This value must be either null, [TextField.noMaxLength], or greater than 0.
/// If null (the default) then there is no limit to the number of characters /// If null (the default) then there is no limit to the number of characters
/// that can be entered. If set to [TextField.noMaxLength], then no limit will /// that can be entered. If set to [TextField.noMaxLength], then no limit will
/// be enforced, but the number of characters entered will still be displayed. /// be enforced, but the number of characters entered will still be displayed.
@@ -345,12 +342,6 @@ class StreamMessageTextField extends StatefulWidget {
/// Whitespace characters (e.g. newline, space, tab) are included in the /// Whitespace characters (e.g. newline, space, tab) are included in the
/// character count. /// character count.
/// ///
/// If [maxLengthEnforced] is set to false or [maxLengthEnforcement] is
/// [MaxLengthEnforcement.none], then more than [maxLength]
/// characters may be entered, but the error counter and divider will switch
/// to the [decoration]'s [InputDecoration.errorStyle] when the limit is
/// exceeded.
///
/// {@macro flutter.services.lengthLimitingTextInputFormatter.maxLength} /// {@macro flutter.services.lengthLimitingTextInputFormatter.maxLength}
final int? maxLength; final int? maxLength;
@@ -444,7 +435,8 @@ class StreamMessageTextField extends StatefulWidget {
/// ///
/// This setting is only honored on iOS devices. /// This setting is only honored on iOS devices.
/// ///
/// If unset, defaults to the brightness of [ThemeData.primaryColorBrightness]. /// If unset, defaults to the brightness of
/// [ThemeData.primaryColorBrightness].
final Brightness? keyboardAppearance; final Brightness? keyboardAppearance;
/// {@macro flutter.widgets.editableText.scrollPadding} /// {@macro flutter.widgets.editableText.scrollPadding}
@@ -488,14 +480,16 @@ class StreamMessageTextField extends StatefulWidget {
/// widget. /// widget.
/// ///
/// If [mouseCursor] is a [MaterialStateProperty<MouseCursor>], /// If [mouseCursor] is a [MaterialStateProperty<MouseCursor>],
/// [MaterialStateProperty.resolve] is used for the following [MaterialState]s: /// [MaterialStateProperty.resolve] is used for the following
/// [MaterialState]s:
/// ///
/// * [MaterialState.error]. /// * [MaterialState.error].
/// * [MaterialState.hovered]. /// * [MaterialState.hovered].
/// * [MaterialState.focused]. /// * [MaterialState.focused].
/// * [MaterialState.disabled]. /// * [MaterialState.disabled].
/// ///
/// If this property is null, [MaterialStateMouseCursor.textable] will be used. /// If this property is null, [MaterialStateMouseCursor.textable] will be
/// used.
/// ///
/// The [mouseCursor] is the only property of [TextField] that controls the /// The [mouseCursor] is the only property of [TextField] that controls the
/// appearance of the mouse pointer. All other properties related to "cursor" /// appearance of the mouse pointer. All other properties related to "cursor"
@@ -610,10 +604,6 @@ class StreamMessageTextField extends StatefulWidget {
properties.add( properties.add(
DiagnosticsProperty<bool>('expands', expands, defaultValue: false)); DiagnosticsProperty<bool>('expands', expands, defaultValue: false));
properties.add(IntProperty('maxLength', maxLength, defaultValue: null)); properties.add(IntProperty('maxLength', maxLength, defaultValue: null));
properties.add(FlagProperty('maxLengthEnforced',
value: maxLengthEnforced,
defaultValue: true,
ifFalse: 'maxLength not enforced'));
properties.add(EnumProperty<MaxLengthEnforcement>( properties.add(EnumProperty<MaxLengthEnforcement>(
'maxLengthEnforcement', maxLengthEnforcement, 'maxLengthEnforcement', maxLengthEnforcement,
defaultValue: null)); defaultValue: null));
@@ -741,7 +731,6 @@ class _StreamMessageTextFieldState extends State<StreamMessageTextField>
minLines: widget.minLines, minLines: widget.minLines,
expands: widget.expands, expands: widget.expands,
maxLength: widget.maxLength, maxLength: widget.maxLength,
maxLengthEnforced: widget.maxLengthEnforced,
maxLengthEnforcement: widget.maxLengthEnforcement, maxLengthEnforcement: widget.maxLengthEnforcement,
onEditingComplete: widget.onEditingComplete, onEditingComplete: widget.onEditingComplete,
onSubmitted: widget.onSubmitted, onSubmitted: widget.onSubmitted,
@@ -58,6 +58,46 @@ typedef OnMessageTap = void Function(Message);
/// Callback on reply tapped /// Callback on reply tapped
typedef ReplyTapCallback = void Function(Message); typedef ReplyTapCallback = void Function(Message);
/// Spacing Types (These are properties of a message to help inform the decision
/// of how much space / which widget to build after it)
enum SpacingType {
/// Message is a thread
thread,
/// There is a >1s time diff between current and last message
timeDiff,
/// Next message is by a different user
otherUser,
/// Message is deleted
deleted,
/// No other conditions are valid, default spacing (This will likely be the
/// only rule in the list provided)
defaultSpacing,
}
/// Builder for building certain spacing after widgets.
/// This spacing can be in form of any widgets you like.
/// A List of [SpacingType] is provided to help inform the decision of
/// what to build after the message.
///
/// As an example:
/// MessageListView(
/// spacingWidgetBuilder: (context, list) {
/// if(list.contains(SpacingType.defaultSpacing)) {
/// return SizedBox(height: 2.0,);
/// } else {
/// return SizedBox(height: 8.0,);
/// }
/// },
/// ),
typedef SpacingWidgetBuilder = Widget Function(
BuildContext context,
List<SpacingType> spacingTypes,
);
/// Class for message details /// Class for message details
// ignore: prefer-match-file-name // ignore: prefer-match-file-name
class MessageDetails { class MessageDetails {
@@ -171,8 +211,14 @@ class MessageListView extends StatefulWidget {
this.reverse = true, this.reverse = true,
this.paginationLimit = 20, this.paginationLimit = 20,
this.paginationLoadingIndicatorBuilder, this.paginationLoadingIndicatorBuilder,
this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.onDrag,
this.spacingWidgetBuilder,
}) : super(key: key); }) : super(key: key);
/// [ScrollViewKeyboardDismissBehavior] the defines how this [PositionedList] will
/// dismiss the keyboard automatically.
final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior;
/// Function used to build a custom message widget /// Function used to build a custom message widget
final MessageBuilder? messageBuilder; final MessageBuilder? messageBuilder;
@@ -289,6 +335,12 @@ class MessageListView extends StatefulWidget {
/// Builder used to build the loading indicator shown while paginating. /// Builder used to build the loading indicator shown while paginating.
final WidgetBuilder? paginationLoadingIndicatorBuilder; final WidgetBuilder? paginationLoadingIndicatorBuilder;
/// This allows a user to customise the space after a message
/// A List of [SpacingType] is provided to provide more data about the
/// type of message (thread, difference in time between current and last
/// message, default spacing, etc)
final SpacingWidgetBuilder? spacingWidgetBuilder;
@override @override
_MessageListViewState createState() => _MessageListViewState(); _MessageListViewState createState() => _MessageListViewState();
} }
@@ -443,9 +495,6 @@ class _MessageListViewState extends State<MessageListView> {
childAnchor: Alignment.topCenter, childAnchor: Alignment.topCenter,
message: statusString, message: statusString,
child: LazyLoadScrollView( child: LazyLoadScrollView(
onPageScrollStart: () {
FocusScope.of(context).unfocus();
},
onStartOfPage: () async { onStartOfPage: () async {
_inBetweenList = false; _inBetweenList = false;
if (!_upToDate) { if (!_upToDate) {
@@ -471,6 +520,7 @@ class _MessageListViewState extends State<MessageListView> {
key: (initialIndex != 0 && initialAlignment != 0) key: (initialIndex != 0 && initialAlignment != 0)
? ValueKey('$initialIndex-$initialAlignment') ? ValueKey('$initialIndex-$initialAlignment')
: null, : null,
keyboardDismissBehavior: widget.keyboardDismissBehavior,
itemPositionsListener: _itemPositionListener, itemPositionsListener: _itemPositionListener,
initialScrollIndex: initialIndex, initialScrollIndex: initialIndex,
initialAlignment: initialAlignment, initialAlignment: initialAlignment,
@@ -564,17 +614,38 @@ class _MessageListViewState extends State<MessageListView> {
Units.MINUTE, Units.MINUTE,
); );
final spacingRules = <SpacingType>[];
final isNextUserSame = final isNextUserSame =
message.user!.id == nextMessage.user?.id; message.user!.id == nextMessage.user?.id;
final isThread = message.replyCount! > 0; final isThread = message.replyCount! > 0;
final isDeleted = message.isDeleted; final isDeleted = message.isDeleted;
if (timeDiff >= 1 || final hasTimeDiff = timeDiff >= 1;
!isNextUserSame ||
isThread || if (hasTimeDiff) {
isDeleted) { spacingRules.add(SpacingType.timeDiff);
return const SizedBox(height: 8);
} }
return const SizedBox(height: 2);
if (!isNextUserSame) {
spacingRules.add(SpacingType.otherUser);
}
if (isThread) {
spacingRules.add(SpacingType.thread);
}
if (isDeleted) {
spacingRules.add(SpacingType.deleted);
}
if (spacingRules.isNotEmpty) {
return widget.spacingWidgetBuilder
?.call(context, spacingRules) ??
const SizedBox(height: 8);
}
return widget.spacingWidgetBuilder
?.call(context, [SpacingType.defaultSpacing]) ??
const SizedBox(height: 2);
}, },
itemBuilder: (context, i) { itemBuilder: (context, i) {
if (i == itemCount - 1) { if (i == itemCount - 1) {
@@ -1003,15 +1074,6 @@ class _MessageListViewState extends State<MessageListView> {
); );
} }
final channel = streamChannel!.channel;
final readList = channel.state?.read.where((read) {
if (read.user.id == userId) return false;
return read.lastRead.isAfter(message.createdAt) ||
read.lastRead.isAtSameMomentAs(message.createdAt);
}).toList() ??
[];
final allRead = readList.length >= (channel.memberCount ?? 0) - 1;
final hasFileAttachment = final hasFileAttachment =
message.attachments.any((it) => it.type == 'file'); message.attachments.any((it) => it.type == 'file');
@@ -1140,8 +1202,6 @@ class _MessageListViewState extends State<MessageListView> {
messageTheme: isMyMessage messageTheme: isMyMessage
? _streamTheme.ownMessageTheme ? _streamTheme.ownMessageTheme
: _streamTheme.otherMessageTheme, : _streamTheme.otherMessageTheme,
readList: readList,
allRead: allRead,
onReturnAction: (action) { onReturnAction: (action) {
switch (action) { switch (action) {
case ReturnActionType.none: case ReturnActionType.none:
@@ -97,14 +97,20 @@ class MessageWidget extends StatefulWidget {
this.deletedBottomRowBuilder, this.deletedBottomRowBuilder,
this.onReturnAction, this.onReturnAction,
this.customAttachmentBuilders, this.customAttachmentBuilders,
this.readList,
this.padding, this.padding,
this.textPadding = const EdgeInsets.symmetric( this.textPadding = const EdgeInsets.symmetric(
horizontal: 16, horizontal: 16,
vertical: 8, vertical: 8,
), ),
this.attachmentPadding = EdgeInsets.zero, this.attachmentPadding = EdgeInsets.zero,
this.allRead = false, @Deprecated('''
allRead is now deprecated and it will be removed in future releases.
The MessageWidget now listens for read events on its own.
''') this.allRead = false,
@Deprecated('''
readList is now deprecated and it will be removed in future releases.
The MessageWidget now listens for read events on its own.
''') this.readList,
this.onQuotedMessageTap, this.onQuotedMessageTap,
this.customActions = const [], this.customActions = const [],
this.onAttachmentTap, this.onAttachmentTap,
@@ -508,7 +514,6 @@ class MessageWidget extends StatefulWidget {
showUserAvatar: showUserAvatar ?? this.showUserAvatar, showUserAvatar: showUserAvatar ?? this.showUserAvatar,
showSendingIndicator: showSendingIndicator ?? this.showSendingIndicator, showSendingIndicator: showSendingIndicator ?? this.showSendingIndicator,
showReactions: showReactions ?? this.showReactions, showReactions: showReactions ?? this.showReactions,
allRead: allRead ?? this.allRead,
showThreadReplyIndicator: showThreadReplyIndicator:
showThreadReplyIndicator ?? this.showThreadReplyIndicator, showThreadReplyIndicator ?? this.showThreadReplyIndicator,
showInChannelIndicator: showInChannelIndicator:
@@ -517,7 +522,6 @@ class MessageWidget extends StatefulWidget {
onLinkTap: onLinkTap ?? this.onLinkTap, onLinkTap: onLinkTap ?? this.onLinkTap,
showReactionPickerIndicator: showReactionPickerIndicator:
showReactionPickerIndicator ?? this.showReactionPickerIndicator, showReactionPickerIndicator ?? this.showReactionPickerIndicator,
readList: readList ?? this.readList,
onShowMessage: onShowMessage ?? this.onShowMessage, onShowMessage: onShowMessage ?? this.onShowMessage,
onReturnAction: onReturnAction ?? this.onReturnAction, onReturnAction: onReturnAction ?? this.onReturnAction,
showUsername: showUsername ?? this.showUsername, showUsername: showUsername ?? this.showUsername,
@@ -558,8 +562,6 @@ class _MessageWidgetState extends State<MessageWidget>
bool get showTimeStamp => widget.showTimestamp; bool get showTimeStamp => widget.showTimestamp;
bool get isMessageRead => widget.readList?.isNotEmpty == true;
bool get showInChannel => widget.showInChannelIndicator; bool get showInChannel => widget.showInChannelIndicator;
bool get hasQuotedMessage => widget.message.quotedMessage != null; bool get hasQuotedMessage => widget.message.quotedMessage != null;
@@ -830,8 +832,8 @@ class _MessageWidgetState extends State<MessageWidget>
), ),
if (isFailedState) if (isFailedState)
Positioned( Positioned(
left: widget.reverse ? 0 : null, right: widget.reverse ? 0 : null,
right: widget.reverse ? null : 0, left: widget.reverse ? null : 0,
bottom: showBottomRow ? 18 : -2, bottom: showBottomRow ? 18 : -2,
child: StreamSvgIcon.error(size: 20), child: StreamSvgIcon.error(size: 20),
), ),
@@ -1230,6 +1232,7 @@ class _MessageWidgetState extends State<MessageWidget>
Widget _buildSendingIndicator() { Widget _buildSendingIndicator() {
final style = widget.messageTheme.createdAtStyle; final style = widget.messageTheme.createdAtStyle;
final message = widget.message; final message = widget.message;
final memberCount = StreamChannel.of(context).channel.memberCount ?? 0;
if (hasNonUrlAttachments && if (hasNonUrlAttachments &&
(message.status == MessageSendingStatus.sending || (message.status == MessageSendingStatus.sending ||
@@ -1252,27 +1255,40 @@ class _MessageWidgetState extends State<MessageWidget>
); );
} }
Widget child = SendingIndicator( final channel = StreamChannel.of(context).channel;
message: message,
isMessageRead: isMessageRead, return BetterStreamBuilder<List<Read>>(
size: style!.fontSize, stream: channel.state?.readStream,
initialData: channel.state?.read,
builder: (context, data) {
final readList = data.where((it) =>
it.user.id != _streamChat.currentUser?.id &&
(it.lastRead.isAfter(message.createdAt) ||
it.lastRead.isAtSameMomentAs(message.createdAt)));
final isMessageRead = readList.length >= (channel.memberCount ?? 0) - 1;
Widget child = SendingIndicator(
message: message,
isMessageRead: isMessageRead,
size: style!.fontSize,
);
if (isMessageRead) {
child = Row(
children: [
if (memberCount > 2)
Text(
readList.length.toString(),
style: style.copyWith(
color: _streamChatTheme.colorTheme.accentPrimary,
),
),
const SizedBox(width: 2),
child,
],
);
}
return child;
},
); );
if (isMessageRead) {
child = Row(
children: [
if (StreamChannel.of(context).channel.memberCount! > 2)
Text(
widget.readList!.length.toString(),
style: style.copyWith(
color: _streamChatTheme.colorTheme.accentPrimary,
),
),
const SizedBox(width: 2),
child,
],
);
}
return child;
} }
Widget _buildUserAvatar() => Transform.translate( Widget _buildUserAvatar() => Transform.translate(
@@ -110,7 +110,15 @@ class StreamChatState extends State<StreamChat> {
onBackgroundEventReceived: widget.onBackgroundEventReceived, onBackgroundEventReceived: widget.onBackgroundEventReceived,
backgroundKeepAlive: widget.backgroundKeepAlive, backgroundKeepAlive: widget.backgroundKeepAlive,
connectivityStream: widget.connectivityStream, connectivityStream: widget.connectivityStream,
child: widget.child ?? const Offstage(), child: Builder(
builder: (context) {
StreamChatClient.additionalHeaders = {
'X-Stream-Client':
'${StreamChatClient.defaultUserAgent}-ui',
};
return widget.child ?? const Offstage();
},
),
), ),
); );
}, },
@@ -2,7 +2,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart' hide TextTheme; import 'package:flutter/material.dart' hide TextTheme;
import 'package:stream_chat_flutter/src/channel_preview.dart'; import 'package:stream_chat_flutter/src/channel_preview.dart';
import 'package:stream_chat_flutter/src/gradient_avatar.dart'; import 'package:stream_chat_flutter/src/gradient_avatar.dart';
import 'package:stream_chat_flutter/src/message_input.dart'; import 'package:stream_chat_flutter/src/message_input/message_input.dart';
import 'package:stream_chat_flutter/src/reaction_icon.dart'; import 'package:stream_chat_flutter/src/reaction_icon.dart';
import 'package:stream_chat_flutter/src/theme/themes.dart'; import 'package:stream_chat_flutter/src/theme/themes.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart';
@@ -2,6 +2,7 @@ export 'package:jiffy/jiffy.dart';
export 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; export 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
export 'src/attachment/attachment.dart'; export 'src/attachment/attachment.dart';
export 'src/attachment_actions_modal.dart';
export 'src/back_button.dart'; export 'src/back_button.dart';
export 'src/channel_avatar.dart'; export 'src/channel_avatar.dart';
export 'src/channel_header.dart'; export 'src/channel_header.dart';
@@ -22,16 +23,16 @@ export 'src/localization/stream_chat_localizations.dart';
export 'src/localization/translations.dart' show DefaultTranslations; export 'src/localization/translations.dart' show DefaultTranslations;
export 'src/mention_tile.dart'; export 'src/mention_tile.dart';
export 'src/message_action.dart'; export 'src/message_action.dart';
export 'src/message_input.dart'; export 'src/message_input/message_input.dart';
export 'src/message_list_view.dart'; export 'src/message_list_view.dart';
export 'src/message_search_item.dart'; export 'src/message_search_item.dart';
export 'src/message_search_list_view.dart'; export 'src/message_search_list_view.dart';
export 'src/message_text.dart'; export 'src/message_text.dart';
export 'src/message_widget.dart'; export 'src/message_widget.dart';
export 'src/mip/countdown_button.dart'; export 'src/message_input/countdown_button.dart';
export 'src/mip/stream_attachment_picker.dart'; export 'src/message_input/stream_attachment_picker.dart';
export 'src/mip/stream_message_send_button.dart'; export 'src/message_input/stream_message_send_button.dart';
export 'src/mip/stream_message_text_field.dart'; export 'src/message_input/stream_message_text_field.dart';
export 'src/option_list_tile.dart'; export 'src/option_list_tile.dart';
export 'src/reaction_icon.dart'; export 'src/reaction_icon.dart';
export 'src/reaction_picker.dart'; export 'src/reaction_picker.dart';
+2 -2
View File
@@ -1,7 +1,7 @@
name: stream_chat_flutter name: stream_chat_flutter
homepage: https://github.com/GetStream/stream-chat-flutter homepage: https://github.com/GetStream/stream-chat-flutter
description: Stream Chat official Flutter SDK. Build your own chat experience using Dart and Flutter. description: Stream Chat official Flutter SDK. Build your own chat experience using Dart and Flutter.
version: 3.2.0 version: 3.3.2
repository: https://github.com/GetStream/stream-chat-flutter repository: https://github.com/GetStream/stream-chat-flutter
issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
@@ -36,7 +36,7 @@ dependencies:
rxdart: ^0.27.0 rxdart: ^0.27.0
share_plus: ^3.0.4 share_plus: ^3.0.4
shimmer: ^2.0.0 shimmer: ^2.0.0
stream_chat_flutter_core: ^3.2.0 stream_chat_flutter_core: ^3.3.1
substring_highlight: ^1.0.26 substring_highlight: ^1.0.26
synchronized: ^3.0.0 synchronized: ^3.0.0
url_launcher: ^6.0.3 url_launcher: ^6.0.3
@@ -1,7 +1,17 @@
# Upcoming
✅ Added ✅ Added
- Added `MessageInputController` to hold `Message` related data. - Added `MessageInputController` to hold `Message` related data.
## 3.3.1
- Updated `stream_chat` dependency to [`3.3.1`](https://pub.dev/packages/stream_chat/changelog).
## 3.3.0
- Updated `stream_chat` dependency to [`3.3.0`](https://pub.dev/packages/stream_chat/changelog).
## 3.2.0 ## 3.2.0
- Updated `stream_chat` dependency to [`3.2.0`](https://pub.dev/packages/stream_chat/changelog). - Updated `stream_chat` dependency to [`3.2.0`](https://pub.dev/packages/stream_chat/changelog).
@@ -83,9 +83,12 @@ class HomeScreen extends StatelessWidget {
channelListController: channelListController, channelListController: channelListController,
filter: Filter.and([ filter: Filter.and([
Filter.equal('type', 'messaging'), Filter.equal('type', 'messaging'),
Filter.in_('members', [ Filter.in_(
StreamChatCore.of(context).currentUser!.id, 'members',
]) [
StreamChatCore.of(context).currentUser!.id,
],
)
]), ]),
emptyBuilder: (BuildContext context) => const Center( emptyBuilder: (BuildContext context) => const Center(
child: Text('Looks like you are not in any channels'), child: Text('Looks like you are not in any channels'),
@@ -120,7 +123,7 @@ class HomeScreen extends StatelessWidget {
itemBuilder: (BuildContext context, int index) { itemBuilder: (BuildContext context, int index) {
final _item = channels[index]; final _item = channels[index];
return ListTile( return ListTile(
title: Text(_item.name!), title: Text(_item.name ?? ''),
subtitle: StreamBuilder<Message?>( subtitle: StreamBuilder<Message?>(
stream: _item.state!.lastMessageStream, stream: _item.state!.lastMessageStream,
initialData: _item.state!.lastMessage, initialData: _item.state!.lastMessage,
@@ -318,10 +321,10 @@ class _MessageScreenState extends State<MessageScreen> {
), ),
), ),
), ),
) ),
], ],
), ),
) ),
], ],
), ),
), ),
@@ -157,7 +157,7 @@ class ChannelListCoreState extends State<ChannelListCore> {
presence: widget.presence, presence: widget.presence,
memberLimit: widget.memberLimit, memberLimit: widget.memberLimit,
messageLimit: widget.messageLimit, messageLimit: widget.messageLimit,
paginationParams: PaginationParams(limit: widget.limit), paginationParams: PaginationParams(limit: widget.limit, offset: 0),
); );
/// Fetches more channels with updated pagination and updates the widget /// Fetches more channels with updated pagination and updates the widget
@@ -1,6 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:rxdart/rxdart.dart'; import 'package:rxdart/rxdart.dart';
import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat/stream_chat.dart';
@@ -4,142 +4,193 @@ import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat/stream_chat.dart';
/// A value listenable builder related to a [Message].
///
/// Pass in a [MessageInputController] as the `valueListenable`.
typedef MessageValueListenableBuilder = ValueListenableBuilder<Message>;
/// Controller for storing and mutating a [Message] value. /// Controller for storing and mutating a [Message] value.
class MessageInputController extends ValueNotifier<Message> { class MessageInputController extends ValueNotifier<Message> {
/// Creates a controller for an editable text field. /// Creates a controller for an editable text field.
/// ///
/// This constructor treats a null [message] argument as if it were the empty /// This constructor treats a null [message] argument as if it were the empty
/// message. /// message.
factory MessageInputController({Message? message}) => factory MessageInputController({
MessageInputController._(message ?? Message()); Message? message,
}) =>
MessageInputController._(
initialMessage: message ?? Message(),
);
/// Creates a controller for an editable text field from an initial [text]. /// Creates a controller for an editable text field from an initial [text].
factory MessageInputController.fromText(String? text) => factory MessageInputController.fromText(String? text) =>
MessageInputController._(Message(text: text)); MessageInputController._(
initialMessage: Message(text: text),
);
/// Creates a controller for an editable text field from an initial /// Creates a controller for an editable text field from initial
/// [attachments]. /// [attachments].
factory MessageInputController.fromAttachments( factory MessageInputController.fromAttachments(
List<Attachment> attachments, List<Attachment> attachments,
) => ) =>
MessageInputController._(Message(attachments: attachments)); MessageInputController._(
initialMessage: Message(attachments: attachments),
);
MessageInputController._(Message message) MessageInputController._({
: _textEditingController = TextEditingController(text: message.text), required Message initialMessage,
super(message); }) : _textEditingController =
TextEditingController(text: initialMessage.text),
_initialMessage = initialMessage,
super(initialMessage) {
addListener(_textEditingSyncer);
}
/// void _textEditingSyncer() {
final cleanText = value.command == null
? value.text
: value.text?.replaceFirst(
'/${value.command} ',
'',
);
if (cleanText != _textEditingController.text) {
final previousOffset = _textEditingController.value.selection.start;
final previousText = _textEditingController.text;
final diff = (cleanText?.length ?? 0) - previousText.length;
_textEditingController
..text = cleanText ?? ''
..selection = TextSelection.collapsed(
offset: previousOffset + diff,
);
}
}
/// Returns the current message associated with this controller.
Message get message => value;
/// Returns the controller of the text field linked to this controller.
TextEditingController get textEditingController => _textEditingController; TextEditingController get textEditingController => _textEditingController;
final TextEditingController _textEditingController; final TextEditingController _textEditingController;
/// /// Returns the text of the message.
String get text => _textEditingController.text; String get text => _textEditingController.text;
/// Message _initialMessage;
/// Sets the message.
set message(Message message) { set message(Message message) {
value = message; value = message;
} }
/// Sets a command for the message.
set command(Command command) {
value = value.copyWith(
command: command.name,
text: '/${command.name} ',
);
}
/// Sets the text of the message.
set text(String newText) { set text(String newText) {
value = value.copyWith(text: newText); var newTextWithCommand = newText;
_textEditingController if (value.command != null) {
..text = newText if (!newText.startsWith('/${value.command}')) {
..selection = TextSelection.fromPosition( newTextWithCommand = '/${value.command} $newText';
TextPosition(offset: _textEditingController.text.length), }
); }
value = value.copyWith(text: newTextWithCommand);
} }
/// /// Returns the baseOffset of the text field.
set textEditingValue(TextEditingValue newValue) {
_textEditingController.value = newValue;
value = value.copyWith(text: _textEditingController.text);
}
///
int get baseOffset => textEditingController.selection.baseOffset; int get baseOffset => textEditingController.selection.baseOffset;
/// /// Returns the start of the selection of the text field.
int get selectionStart => textEditingController.selection.start; int get selectionStart => textEditingController.selection.start;
/// Sets the [showInChannel] flag of the message.
set showInChannel(bool newValue) { set showInChannel(bool newValue) {
value = value.copyWith(showInChannel: newValue); value = value.copyWith(showInChannel: newValue);
} }
/// /// Returns true if the message is in a thread and
/// should be shown in the main channel as well.
bool get showInChannel => value.showInChannel ?? false; bool get showInChannel => value.showInChannel ?? false;
/// /// Returns the attachments of the message.
List<Attachment> get attachments => value.attachments; List<Attachment> get attachments => value.attachments;
/// Sets the list of [attachments] for the message.
set attachments(List<Attachment> attachments) { set attachments(List<Attachment> attachments) {
value = value.copyWith(attachments: attachments); value = value.copyWith(attachments: attachments);
} }
/// /// Adds a new attachment to the message.
void addAttachment(Attachment attachment) { void addAttachment(Attachment attachment) {
attachments = [...attachments, attachment]; attachments = [...attachments, attachment];
} }
/// /// Adds a new attachment at the specified [index].
void addAttachmentAt(int index, Attachment attachment) { void addAttachmentAt(int index, Attachment attachment) {
attachments = [...attachments]..insert(index, attachment); attachments = [...attachments]..insert(index, attachment);
} }
/// /// Removes the specified [attachment] from the message.
void removeAttachment(Attachment attachment) { void removeAttachment(Attachment attachment) {
attachments = [...attachments]..remove(attachment); attachments = [...attachments]..remove(attachment);
} }
/// /// Remove the attachment with the given [attachmentId].
void removeAttachmentById(String attachmentId) { void removeAttachmentById(String attachmentId) {
attachments = [...attachments]..removeWhere((it) => it.id == attachmentId); attachments = [...attachments]..removeWhere((it) => it.id == attachmentId);
} }
/// /// Removes the attachment at the given [index].
void removeAttachmentAt(int index) { void removeAttachmentAt(int index) {
attachments = [...attachments]..removeAt(index); attachments = [...attachments]..removeAt(index);
} }
/// /// Clears the message attachments.
void clearAttachments() { void clearAttachments() {
attachments = []; attachments = [];
} }
/// /// Returns the list of mentioned users in the message.
List<User> get mentionedUsers => value.mentionedUsers; List<User> get mentionedUsers => value.mentionedUsers;
/// Sets the mentioned users.
set mentionedUsers(List<User> users) { set mentionedUsers(List<User> users) {
value = value.copyWith(mentionedUsers: users); value = value.copyWith(mentionedUsers: users);
} }
/// /// Adds a user to the list of mentioned users.
void addMentionedUser(User user) { void addMentionedUser(User user) {
mentionedUsers = [...mentionedUsers, user]; mentionedUsers = [...mentionedUsers, user];
} }
/// /// Removes the specified [user] from the mentioned users list.
void removeMentionedUser(User user) { void removeMentionedUser(User user) {
mentionedUsers = [...mentionedUsers]..remove(user); mentionedUsers = [...mentionedUsers]..remove(user);
} }
/// /// Removes the mentioned user with the given [userId].
void removeMentionedUserById(String userId) { void removeMentionedUserById(String userId) {
mentionedUsers = [...mentionedUsers]..removeWhere((it) => it.id == userId); mentionedUsers = [...mentionedUsers]..removeWhere((it) => it.id == userId);
} }
/// /// Removes all mentioned users from the message.
void clearMentionedUsers() { void clearMentionedUsers() {
mentionedUsers = []; mentionedUsers = [];
} }
/// Set the [value] to empty. /// Sets the [message], or [value], to empty.
/// ///
/// After calling this function, [text], [attachments] and [mentionedUsers] /// After calling this function, [text], [attachments] and [mentionedUsers]
/// all will be empty. /// will all be empty.
/// ///
/// Calling this will notify all the listeners of this /// Calling this will notify all the listeners of this
/// [MessageInputController] that they need to update /// [MessageInputController] that they need to update
/// (it calls [notifyListeners]). For this reason, /// (calls [notifyListeners]). For this reason,
/// this method should only be called between frames, e.g. in response to user /// this method should only be called between frames, e.g. in response to user
/// actions, not during the build, layout, or paint phases. /// actions, not during the build, layout, or paint phases.
void clear() { void clear() {
@@ -147,10 +198,21 @@ class MessageInputController extends ValueNotifier<Message> {
_textEditingController.clear(); _textEditingController.clear();
} }
/// Sets the [value] to the initial [Message] value.
void reset({bool resetId = true}) {
if (resetId) {
_initialMessage = _initialMessage.copyWith(
id: const Uuid().v4(),
);
}
value = _initialMessage;
}
@override @override
void dispose() { void dispose() {
super.dispose(); removeListener(_textEditingSyncer);
_textEditingController.dispose(); _textEditingController.dispose();
super.dispose();
} }
} }
@@ -165,16 +227,13 @@ class RestorableMessageInputController
extends RestorableChangeNotifier<MessageInputController> { extends RestorableChangeNotifier<MessageInputController> {
/// Creates a [RestorableMessageInputController]. /// Creates a [RestorableMessageInputController].
/// ///
/// This constructor treats a null `text` argument as if it were the empty /// This constructor creates a default [Message] when no `message` argument
/// string. /// is supplied.
RestorableMessageInputController({Message? message}) RestorableMessageInputController({Message? message})
: _initialValue = message ?? Message(); : _initialValue = message ?? Message();
/// Creates a [RestorableMessageInputController] from an initial /// Creates a [RestorableMessageInputController] from an initial
/// [TextEditingValue]. /// [text] value.
///
/// This constructor treats a null `value` argument as if it were
/// [TextEditingValue.empty].
factory RestorableMessageInputController.fromText(String? text) => factory RestorableMessageInputController.fromText(String? text) =>
RestorableMessageInputController(message: Message(text: text)); RestorableMessageInputController(message: Message(text: text));
@@ -2,7 +2,6 @@ import 'dart:async';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat/stream_chat.dart';
import 'package:stream_chat_flutter_core/src/better_stream_builder.dart'; import 'package:stream_chat_flutter_core/src/better_stream_builder.dart';
@@ -227,13 +227,13 @@ class StreamChannelState extends State<StreamChannel> {
preferOffline: preferOffline, preferOffline: preferOffline,
); );
Future<List<ChannelState>> _queryAtMessage({ Future<ChannelState?> _queryAtMessage({
String? messageId, String? messageId,
int before = 20, int before = 20,
int after = 20, int after = 20,
bool preferOffline = false, bool preferOffline = false,
}) async { }) async {
if (channel.state == null) return []; if (channel.state == null) return null;
channel.state!.isUpToDate = false; channel.state!.isUpToDate = false;
channel.state!.truncate(); channel.state!.truncate();
@@ -245,23 +245,33 @@ class StreamChannelState extends State<StreamChannel> {
preferOffline: preferOffline, preferOffline: preferOffline,
); );
channel.state!.isUpToDate = true; channel.state!.isUpToDate = true;
return []; return null;
} }
return Future.wait([ return queryAroundMessage(
queryBeforeMessage( messageId,
messageId, before: before,
limit: before, after: after,
preferOffline: preferOffline, preferOffline: preferOffline,
), );
queryAfterMessage(
messageId,
limit: after,
preferOffline: preferOffline,
),
]);
} }
///
Future<ChannelState> queryAroundMessage(
String messageId, {
int before = 20,
int after = 20,
bool preferOffline = false,
}) =>
channel.query(
messagesPagination: PaginationParams(
idAround: messageId,
before: before,
after: after,
),
preferOffline: preferOffline,
);
/// ///
Future<ChannelState> queryBeforeMessage( Future<ChannelState> queryBeforeMessage(
String messageId, { String messageId, {
@@ -95,7 +95,12 @@ class StreamChatCoreState extends State<StreamChatCore>
Timer? _disconnectTimer; Timer? _disconnectTimer;
@override @override
Widget build(BuildContext context) => widget.child; Widget build(BuildContext context) {
StreamChatClient.additionalHeaders = {
'X-Stream-Client': '${StreamChatClient.defaultUserAgent}-core',
};
return widget.child;
}
// coverage:ignore-start // coverage:ignore-start
@@ -1,22 +1,22 @@
name: stream_chat_flutter_core name: stream_chat_flutter_core
homepage: https://github.com/GetStream/stream-chat-flutter homepage: https://github.com/GetStream/stream-chat-flutter
description: Stream Chat official Flutter SDK Core. Build your own chat experience using Dart and Flutter. description: Stream Chat official Flutter SDK Core. Build your own chat experience using Dart and Flutter.
version: 3.2.0 version: 3.3.1
repository: https://github.com/GetStream/stream-chat-flutter repository: https://github.com/GetStream/stream-chat-flutter
issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
environment: environment:
sdk: '>=2.12.0 <3.0.0' sdk: '>=2.14.0 <3.0.0'
flutter: ">=1.17.0" flutter: ">=1.17.0"
dependencies: dependencies:
collection: ^1.15.0 collection: ^1.15.0
connectivity_plus: ^2.0.2 connectivity_plus: ^2.1.0
flutter: flutter:
sdk: flutter sdk: flutter
meta: ^1.3.0 meta: ^1.3.0
rxdart: ^0.27.0 rxdart: ^0.27.0
stream_chat: ^3.2.0 stream_chat: ^3.3.1
dev_dependencies: dev_dependencies:
dart_code_metrics: ^4.4.0 dart_code_metrics: ^4.4.0
@@ -9,7 +9,7 @@ import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'mocks.dart'; import 'mocks.dart';
void main() { void main() {
const pagination = PaginationParams(limit: 3); const pagination = PaginationParams(limit: 3, offset: 0);
List<Channel> _generateChannels( List<Channel> _generateChannels(
StreamChatClient client, { StreamChatClient client, {
@@ -476,7 +476,7 @@ void main() {
_stateSetter?.call(() => limit = 6); _stateSetter?.call(() => limit = 6);
final updatedChannels = _generateChannels(mockClient, count: limit); final updatedChannels = _generateChannels(mockClient, count: limit);
final updatedPagination = pagination.copyWith(limit: limit); final updatedPagination = PaginationParams(limit: limit, offset: 0);
when(() => mockClient.queryChannels( when(() => mockClient.queryChannels(
filter: any(named: 'filter'), filter: any(named: 'filter'),
sort: any(named: 'sort'), sort: any(named: 'sort'),
@@ -0,0 +1,30 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
void main() {
testWidgets(
'should instantiate a new MessageInputController with default validator'
' and empty message',
(tester) async {
final controller = MessageInputController();
expect(controller.isValid, false);
controller.text = 'test';
expect(controller.isValid, true);
},
);
testWidgets(
'should instantiate a new MessageInputController with default validator'
' and specified message',
(tester) async {
final message = Message(text: 'test');
final controller = MessageInputController(
message: message,
);
expect(controller.message, message);
expect(controller.isValid, true);
},
);
}
@@ -518,7 +518,7 @@ void main() {
_stateSetter?.call(() => limit = 6); _stateSetter?.call(() => limit = 6);
final updatedMessageResponseList = _generateMessages(count: limit); final updatedMessageResponseList = _generateMessages(count: limit);
final updatedPagination = pagination.copyWith(limit: limit); final updatedPagination = PaginationParams(limit: limit);
when(() => mockClient.search( when(() => mockClient.search(
testFilter, testFilter,
query: any(named: 'query'), query: any(named: 'query'),
@@ -189,9 +189,7 @@ void main() {
membersPagination: any(named: 'membersPagination'), membersPagination: any(named: 'membersPagination'),
watchersPagination: any(named: 'watchersPagination'), watchersPagination: any(named: 'watchersPagination'),
preferOffline: any(named: 'preferOffline'), preferOffline: any(named: 'preferOffline'),
)).called( )).called(1);
2, // Fetching After messages + Fetching Before messages,
);
}, },
); );
@@ -214,14 +212,10 @@ void main() {
child: const Offstage(key: childKey), child: const Offstage(key: childKey),
); );
final beforePagination = PaginationParams( final paginationParams = PaginationParams(
lessThan: initialMessageId, idAround: initialMessageId,
limit: 20, after: 20,
); before: 20,
final afterPagination = PaginationParams(
greaterThanOrEqual: initialMessageId,
limit: 20,
); );
when(() => mockChannel.initialized).thenAnswer((_) async => true); when(() => mockChannel.initialized).thenAnswer((_) async => true);
@@ -232,17 +226,7 @@ void main() {
state: any(named: 'state'), state: any(named: 'state'),
watch: any(named: 'watch'), watch: any(named: 'watch'),
presence: any(named: 'presence'), presence: any(named: 'presence'),
messagesPagination: beforePagination, messagesPagination: paginationParams,
membersPagination: any(named: 'membersPagination'),
watchersPagination: any(named: 'watchersPagination'),
preferOffline: any(named: 'preferOffline'),
)).thenAnswer((_) async => ChannelState(messages: messages));
when(() => mockChannel.query(
state: any(named: 'state'),
watch: any(named: 'watch'),
presence: any(named: 'presence'),
messagesPagination: afterPagination,
membersPagination: any(named: 'membersPagination'), membersPagination: any(named: 'membersPagination'),
watchersPagination: any(named: 'watchersPagination'), watchersPagination: any(named: 'watchersPagination'),
preferOffline: any(named: 'preferOffline'), preferOffline: any(named: 'preferOffline'),
@@ -267,17 +251,7 @@ void main() {
state: any(named: 'state'), state: any(named: 'state'),
watch: any(named: 'watch'), watch: any(named: 'watch'),
presence: any(named: 'presence'), presence: any(named: 'presence'),
messagesPagination: beforePagination, messagesPagination: paginationParams,
membersPagination: any(named: 'membersPagination'),
watchersPagination: any(named: 'watchersPagination'),
preferOffline: any(named: 'preferOffline'),
)).called(1);
verify(() => mockChannel.query(
state: any(named: 'state'),
watch: any(named: 'watch'),
presence: any(named: 'presence'),
messagesPagination: afterPagination,
membersPagination: any(named: 'membersPagination'), membersPagination: any(named: 'membersPagination'),
watchersPagination: any(named: 'watchersPagination'), watchersPagination: any(named: 'watchersPagination'),
preferOffline: any(named: 'preferOffline'), preferOffline: any(named: 'preferOffline'),
@@ -285,29 +259,15 @@ void main() {
_stateSetter?.call(() => initialMessageId = 'testInitialMessageId2'); _stateSetter?.call(() => initialMessageId = 'testInitialMessageId2');
final updatedBeforePagination = beforePagination.copyWith( final updatedPaginationParams = paginationParams.copyWith(
lessThan: initialMessageId, idAround: initialMessageId,
);
final updatedAfterPagination = afterPagination.copyWith(
greaterThanOrEqual: initialMessageId,
); );
when(() => mockChannel.query( when(() => mockChannel.query(
state: any(named: 'state'), state: any(named: 'state'),
watch: any(named: 'watch'), watch: any(named: 'watch'),
presence: any(named: 'presence'), presence: any(named: 'presence'),
messagesPagination: updatedBeforePagination, messagesPagination: updatedPaginationParams,
membersPagination: any(named: 'membersPagination'),
watchersPagination: any(named: 'watchersPagination'),
preferOffline: any(named: 'preferOffline'),
)).thenAnswer((_) async => ChannelState(messages: messages));
when(() => mockChannel.query(
state: any(named: 'state'),
watch: any(named: 'watch'),
presence: any(named: 'presence'),
messagesPagination: updatedAfterPagination,
membersPagination: any(named: 'membersPagination'), membersPagination: any(named: 'membersPagination'),
watchersPagination: any(named: 'watchersPagination'), watchersPagination: any(named: 'watchersPagination'),
preferOffline: any(named: 'preferOffline'), preferOffline: any(named: 'preferOffline'),
@@ -319,17 +279,7 @@ void main() {
state: any(named: 'state'), state: any(named: 'state'),
watch: any(named: 'watch'), watch: any(named: 'watch'),
presence: any(named: 'presence'), presence: any(named: 'presence'),
messagesPagination: updatedBeforePagination, messagesPagination: updatedPaginationParams,
membersPagination: any(named: 'membersPagination'),
watchersPagination: any(named: 'watchersPagination'),
preferOffline: any(named: 'preferOffline'),
)).called(1);
verify(() => mockChannel.query(
state: any(named: 'state'),
watch: any(named: 'watch'),
presence: any(named: 'presence'),
messagesPagination: updatedAfterPagination,
membersPagination: any(named: 'membersPagination'), membersPagination: any(named: 'membersPagination'),
watchersPagination: any(named: 'watchersPagination'), watchersPagination: any(named: 'watchersPagination'),
preferOffline: any(named: 'preferOffline'), preferOffline: any(named: 'preferOffline'),
@@ -496,7 +496,7 @@ void main() {
_stateSetter?.call(() => limit = 6); _stateSetter?.call(() => limit = 6);
final updatedUsers = _generateUsers(count: limit); final updatedUsers = _generateUsers(count: limit);
final updatedPagination = pagination.copyWith(limit: limit); final updatedPagination = PaginationParams(limit: limit);
when(() => mockClient.queryUsers( when(() => mockClient.queryUsers(
filter: any(named: 'filter'), filter: any(named: 'filter'),
sort: any(named: 'sort'), sort: any(named: 'sort'),
@@ -374,8 +374,10 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations {
String get youText => 'You'; String get youText => 'You';
@override @override
String galleryPaginationText( String galleryPaginationText({
{required int currentPage, required int totalPages}) => required int currentPage,
required int totalPages,
}) =>
'$currentPage of $totalPages'; '$currentPage of $totalPages';
@override @override
@@ -242,7 +242,7 @@ class _MessageViewState extends State<MessageView> {
), ),
), ),
), ),
) ),
], ],
), ),
) )
@@ -45,7 +45,6 @@ void main() {
role: 'testRole', role: 'testRole',
createdAt: DateTime.now(), createdAt: DateTime.now(),
updatedAt: DateTime.now(), updatedAt: DateTime.now(),
lastActive: DateTime.now(),
online: math.Random().nextBool(), online: math.Random().nextBool(),
banned: math.Random().nextBool(), banned: math.Random().nextBool(),
); );
@@ -157,7 +157,6 @@ void main() {
(prev, curr) => (prev, curr) =>
prev?..update(curr.type, (value) => value + 1, ifAbsent: () => 1), prev?..update(curr.type, (value) => value + 1, ifAbsent: () => 1),
), ),
status: MessageSendingStatus.sending,
updatedAt: DateTime.now(), updatedAt: DateTime.now(),
extraData: const {'extra_test_data': 'extraData'}, extraData: const {'extra_test_data': 'extraData'},
user: user, user: user,
@@ -147,7 +147,6 @@ void main() {
(prev, curr) => (prev, curr) =>
prev?..update(curr.type, (value) => value + 1, ifAbsent: () => 1), prev?..update(curr.type, (value) => value + 1, ifAbsent: () => 1),
), ),
status: MessageSendingStatus.sending,
updatedAt: DateTime.now(), updatedAt: DateTime.now(),
extraData: const {'extra_test_data': 'extraData'}, extraData: const {'extra_test_data': 'extraData'},
user: user, user: user,