Merge pull request #462 from GetStream/ref/segregate-api-layer

refactor!: v2.0.0
This commit is contained in:
Salvatore Giordano
2021-06-22 14:37:20 +02:00
committed by GitHub
205 changed files with 16232 additions and 9523 deletions
@@ -1,38 +0,0 @@
import 'package:stream_chat/src/client.dart';
import 'package:stream_chat/src/exceptions.dart';
/// The retry options
class RetryPolicy {
/// Instantiate a new RetryPolicy
RetryPolicy({
required this.shouldRetry,
required this.retryTimeout,
this.attempt = 0,
});
/// The number of attempts tried so far
int attempt = 0;
/// This function evaluates if we should retry the failure
final bool Function(StreamChatClient client, int attempt, ApiError? apiError)
shouldRetry;
/// In the case that we want to retry a failed request the retryTimeout
/// method is called to determine the timeout
final Duration Function(
StreamChatClient client, int attempt, ApiError? apiError) retryTimeout;
/// Creates a copy of [RetryPolicy] with specified attributes overridden.
RetryPolicy copyWith({
bool Function(StreamChatClient client, int attempt, ApiError? apiError)?
shouldRetry,
Duration Function(StreamChatClient client, int attempt, ApiError? apiError)?
retryTimeout,
int? attempt,
}) =>
RetryPolicy(
retryTimeout: retryTimeout ?? this.retryTimeout,
shouldRetry: shouldRetry ?? this.shouldRetry,
attempt: attempt ?? this.attempt,
);
}
@@ -1,203 +0,0 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:logging/logging.dart';
import 'package:stream_chat/src/api/channel.dart';
import 'package:stream_chat/src/api/retry_policy.dart';
import 'package:stream_chat/src/event_type.dart';
import 'package:stream_chat/src/exceptions.dart';
import 'package:stream_chat/src/models/message.dart';
import 'package:stream_chat/stream_chat.dart';
/// The retry queue associated to a channel
class RetryQueue {
/// Instantiate a new RetryQueue object
RetryQueue({
required this.channel,
this.logger,
}) {
_retryPolicy = channel.client.retryPolicy;
_listenConnectionRecovered();
_listenFailedEvents();
}
/// The channel of this queue
final Channel channel;
/// The logger associated to this queue
final Logger? logger;
final _subscriptions = <StreamSubscription>[];
void _listenConnectionRecovered() {
_subscriptions
.add(channel.client.on(EventType.connectionRecovered).listen((event) {
if (!_isRetrying && event.online!) {
_startRetrying();
}
}));
}
final HeapPriorityQueue<Message> _messageQueue = HeapPriorityQueue(_byDate);
bool _isRetrying = false;
RetryPolicy? _retryPolicy;
/// Add a list of messages
void add(List<Message> messages) {
logger?.info('added ${messages.length} messages');
final messageList = _messageQueue.toList();
_messageQueue.addAll(messages
.where((element) => !messageList.any((m) => m.id == element.id)));
if (_messageQueue.isNotEmpty && !_isRetrying) {
_startRetrying();
}
}
Future<void> _startRetrying() async {
logger?.info('start retrying');
_isRetrying = true;
final retryPolicy = _retryPolicy!.copyWith(attempt: 0);
while (_messageQueue.isNotEmpty) {
final message = _messageQueue.first;
try {
logger?.info('retry attempt ${retryPolicy.attempt}');
await _sendMessage(message);
logger?.info('message sent - removing it from the queue');
_messageQueue.remove(message);
logger?.info('now ${_messageQueue.length} messages in the queue');
retryPolicy.attempt = 0;
} catch (error) {
ApiError? apiError;
if (error is DioError) {
if (error.type == DioErrorType.response) {
_messageQueue.remove(message);
return;
}
apiError = ApiError(
error.response?.data,
error.response?.statusCode,
);
} else if (error is ApiError) {
apiError = error;
if (apiError.status?.toString().startsWith('4') == true) {
_messageQueue.remove(message);
return;
}
}
if (!retryPolicy.shouldRetry(
channel.client,
retryPolicy.attempt,
apiError,
)) {
_messageQueue.toList().forEach(_sendFailedEvent);
_isRetrying = false;
return;
}
retryPolicy.attempt++;
final timeout = retryPolicy.retryTimeout(
channel.client,
retryPolicy.attempt,
apiError,
);
await Future.delayed(timeout);
}
}
_isRetrying = false;
}
void _sendFailedEvent(Message? message) {
final newStatus = message!.status == MessageSendingStatus.sending
? MessageSendingStatus.failed
: (message.status == MessageSendingStatus.updating
? MessageSendingStatus.failed_update
: MessageSendingStatus.failed_delete);
channel.state!.addMessage(message.copyWith(
status: newStatus,
));
}
Future<void> _sendMessage(Message message) async {
if (message.status == MessageSendingStatus.failed_update ||
message.status == MessageSendingStatus.updating) {
await channel.updateMessage(message);
} else if (message.status == MessageSendingStatus.failed ||
message.status == MessageSendingStatus.sending) {
await channel.sendMessage(message);
} else if (message.status == MessageSendingStatus.failed_delete ||
message.status == MessageSendingStatus.deleting) {
await channel.deleteMessage(message);
}
}
void _listenFailedEvents() {
_subscriptions.add(channel.on().listen((event) {
final messageList = _messageQueue.toList();
if (event.message != null) {
final messageIndex =
messageList.indexWhere((m) => m.id == event.message!.id);
if (messageIndex == -1 &&
[
MessageSendingStatus.failed_update,
MessageSendingStatus.failed,
MessageSendingStatus.failed_delete,
].contains(event.message!.status)) {
logger?.info('add message from events');
final m = event.message;
if (m != null) {
add([m]);
}
} else if (messageIndex != -1 &&
[
MessageSendingStatus.sent,
null,
].contains(event.message!.status)) {
_messageQueue.remove(messageList[messageIndex]);
}
}
}));
}
/// Call this method to dispose this object
void dispose() {
_messageQueue.clear();
_subscriptions.forEach((s) => s.cancel());
}
static int _byDate(Message m1, Message m2) {
final date1 = _getMessageDate(m1);
final date2 = _getMessageDate(m2);
if (date1 == null || date2 == null) {
return 0;
}
return date1.compareTo(date2);
}
static DateTime? _getMessageDate(Message m1) {
switch (m1.status) {
case MessageSendingStatus.failed_delete:
case MessageSendingStatus.deleting:
return m1.deletedAt;
case MessageSendingStatus.failed:
case MessageSendingStatus.sending:
return m1.createdAt;
case MessageSendingStatus.failed_update:
case MessageSendingStatus.updating:
return m1.updatedAt;
default:
return null;
}
}
}
@@ -1,7 +0,0 @@
import 'package:web_socket_channel/html.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
/// Html version of websocket implementation
/// Used in Flutter web version
WebSocketChannel connectWebSocket(String url, {Iterable<String>? protocols}) =>
HtmlWebSocketChannel.connect(url, protocols: protocols);
@@ -1,7 +0,0 @@
import 'package:web_socket_channel/io.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
/// IO version of websocket implementation
/// Used in Flutter mobile version
WebSocketChannel connectWebSocket(String url, {Iterable<String>? protocols}) =>
IOWebSocketChannel.connect(url, protocols: protocols);
@@ -1,9 +0,0 @@
import 'package:web_socket_channel/web_socket_channel.dart';
/// Stub version of websocket implementation
/// Used just for conditional library import
WebSocketChannel connectWebSocket(String url,
{Iterable<String>? protocols,
Map<String, dynamic>? headers,
Duration? pingInterval}) =>
throw UnimplementedError();
@@ -1,321 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:logging/logging.dart';
import 'package:meta/meta.dart';
import 'package:rxdart/rxdart.dart';
import 'package:stream_chat/src/api/connection_status.dart';
import 'package:stream_chat/src/models/event.dart';
import 'package:stream_chat/src/models/user.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
/// Typedef which exposes an [Event] as the only parameter.
typedef EventHandler = void Function(Event);
/// Typedef used for connecting to a websocket. Method returns a
/// [WebSocketChannel] and accepts a connection [url] and an optional
/// [Iterable] of `protocols`.
typedef ConnectWebSocket = WebSocketChannel Function(String? url,
{Iterable<String>? protocols});
// TODO: parse error even
// TODO: if parsing an error into an event fails we should not hide the
// TODO: original error
/// A WebSocket connection that reconnects upon failure.
class WebSocket {
/// Creates a new websocket
/// To connect the WS call [connect]
WebSocket({
required this.baseUrl,
required this.user,
required this.handler,
this.connectParams = const {},
this.connectPayload = const {},
this.logger,
this.connectFunc,
this.reconnectionMonitorInterval = 1,
this.healthCheckInterval = 20,
this.reconnectionMonitorTimeout = 40,
}) {
final qs = Map<String, String>.from(connectParams);
final data = Map<String, dynamic>.from(connectPayload);
data['user_details'] = user.toJson();
qs['json'] = json.encode(data);
if (baseUrl.startsWith('https')) {
_path = baseUrl.replaceFirst('https://', '');
_path = Uri.https(_path, 'connect', qs)
.toString()
.replaceFirst('https', 'wss');
} else if (baseUrl.startsWith('http')) {
_path = baseUrl.replaceFirst('http://', '');
_path =
Uri.http(_path, 'connect', qs).toString().replaceFirst('http', 'ws');
} else {
_path = Uri.https(baseUrl, 'connect', qs)
.toString()
.replaceFirst('https', 'wss');
}
}
/// WS base url
final String baseUrl;
/// User performing the WS connection
final User user;
/// Querystring connection parameters
final Map<String, String> connectParams;
/// WS connection payload
final Map<String, dynamic> connectPayload;
/// Functions that will be called every time a new event is received from the
/// connection
final EventHandler handler;
/// A WS specific logger instance
final Logger? logger;
/// Connection function
/// Used only for testing purpose
@visibleForTesting
final ConnectWebSocket? connectFunc;
/// Interval of the reconnection monitor timer
/// This checks that it received a new event in the last
/// [reconnectionMonitorTimeout] seconds, otherwise it considers the
/// connection unhealthy and reconnects the WS
final int reconnectionMonitorInterval;
/// Interval of the health event sending timer
/// This sends a health event every [healthCheckInterval] seconds in order to
/// make the server aware that the client is still listening
final int healthCheckInterval;
/// The timeout that uses the reconnection monitor timer to consider the
/// connection unhealthy
final int reconnectionMonitorTimeout;
final BehaviorSubject<ConnectionStatus> _connectionStatusController =
BehaviorSubject.seeded(ConnectionStatus.disconnected);
set _connectionStatus(ConnectionStatus status) =>
_connectionStatusController.add(status);
/// The current connection status value
ConnectionStatus? get connectionStatus => _connectionStatusController.value;
/// This notifies of connection status changes
Stream<ConnectionStatus> get connectionStatusStream =>
_connectionStatusController.stream;
late String _path;
int _retryAttempt = 1;
late WebSocketChannel _channel;
Timer? _healthCheck, _reconnectionMonitor;
DateTime? _lastEventAt;
bool _manuallyDisconnected = false;
bool _connecting = false;
bool _reconnecting = false;
Event _decodeEvent(String source) => Event.fromJson(json.decode(source));
Completer<Event?> _connectionCompleter = Completer<Event?>();
/// Connect the WS using the parameters passed in the constructor
Future<Event?> connect() async {
_manuallyDisconnected = false;
if (_connecting) {
logger?.severe('already connecting');
return null;
}
_connecting = true;
_connectionStatus = ConnectionStatus.connecting;
logger?.info('connecting to $_path');
_channel =
connectFunc?.call(_path) ?? WebSocketChannel.connect(Uri.parse(_path));
_channel.stream.listen(
(data) async {
final jsonData = json.decode(data);
if (jsonData['error'] != null) {
return _onConnectionError(jsonData['error']);
}
_onData(data);
},
onError: (error, stacktrace) {
_onConnectionError(error, stacktrace);
},
onDone: _onDone,
);
return _connectionCompleter.future;
}
void _onDone() {
_connecting = false;
if (_manuallyDisconnected) {
return;
}
logger?.info('connection closed | closeCode: ${_channel.closeCode} | '
'closedReason: ${_channel.closeReason}');
if (!_reconnecting) {
_reconnect();
}
}
void _onData(data) {
if (_manuallyDisconnected) {
return;
}
final event = _decodeEvent(data);
logger?.info('received new event: $data');
if (_lastEventAt == null) {
logger?.info('connection estabilished');
_connecting = false;
_reconnecting = false;
_lastEventAt = DateTime.now();
_connectionStatus = ConnectionStatus.connected;
_retryAttempt = 1;
if (!_connectionCompleter.isCompleted) {
_connectionCompleter.complete(event);
}
_startReconnectionMonitor();
_startHealthCheck();
}
handler(event);
_lastEventAt = DateTime.now();
}
Future<void> _onConnectionError(error, [stacktrace]) async {
logger?..severe('error connecting')..severe(error);
if (stacktrace != null) {
logger?.severe(stacktrace);
}
_connecting = false;
if (!_reconnecting) {
_connectionStatus = ConnectionStatus.disconnected;
}
if (!_connectionCompleter.isCompleted) {
_cancelTimers();
_connectionCompleter.completeError(error, stacktrace);
} else if (!_reconnecting) {
return _reconnect();
}
}
void _reconnectionTimer(_) {
final now = DateTime.now();
if (_lastEventAt != null &&
now.difference(_lastEventAt!).inSeconds > reconnectionMonitorTimeout) {
_channel.sink.close();
}
}
void _startReconnectionMonitor() {
_reconnectionMonitor = Timer.periodic(
Duration(seconds: reconnectionMonitorInterval),
_reconnectionTimer,
);
_reconnectionTimer(_reconnectionMonitor);
}
void _reconnectTimer() async {
if (!_reconnecting) {
return;
}
if (_connecting) {
logger?.info('already connecting');
return;
}
logger?.info('reconnecting..');
_cancelTimers();
try {
await connect();
} catch (e) {
logger?.log(Level.SEVERE, e.toString());
}
await Future.delayed(
Duration(seconds: min(_retryAttempt * 5, 25)),
() {
_reconnectTimer();
_retryAttempt++;
},
);
}
Future<void> _reconnect() async {
logger?.info('reconnect');
if (!_reconnecting) {
_reconnecting = true;
_connectionStatus = ConnectionStatus.connecting;
}
_reconnectTimer();
}
void _cancelTimers() {
_lastEventAt = null;
if (_healthCheck != null) {
_healthCheck!.cancel();
}
if (_reconnectionMonitor != null) {
_reconnectionMonitor!.cancel();
}
}
void _healthCheckTimer(_) {
logger?.info('sending health.check');
_channel.sink.add("{'type': 'health.check'}");
}
void _startHealthCheck() {
logger?.info('start health check monitor');
_healthCheck = Timer.periodic(
Duration(seconds: healthCheckInterval),
_healthCheckTimer,
);
_healthCheckTimer(_healthCheck);
}
/// Disconnects the WS and releases eventual resources
Future<void> disconnect() async {
_connecting = false;
if (!_connectionCompleter.isCompleted) {
_connectionCompleter.complete();
}
if (_manuallyDisconnected) {
return;
}
logger?.info('disconnecting');
_connectionCompleter = Completer();
_cancelTimers();
_reconnecting = false;
_manuallyDisconnected = true;
_connectionStatus = ConnectionStatus.disconnected;
await _connectionStatusController.close();
await _channel.sink.close();
}
}
File diff suppressed because it is too large Load Diff
@@ -1,18 +1,18 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:collection/collection.dart'
show IterableExtension, ListEquality;
import 'package:dio/dio.dart';
import 'package:logging/logging.dart';
import 'package:rate_limiter/rate_limiter.dart';
import 'package:rxdart/rxdart.dart';
import 'package:stream_chat/src/api/retry_queue.dart';
import 'package:stream_chat/src/client/retry_queue.dart';
import 'package:stream_chat/src/core/error/error.dart';
import 'package:stream_chat/src/core/models/attachment_file.dart';
import 'package:stream_chat/src/core/models/channel_state.dart';
import 'package:stream_chat/src/core/models/user.dart';
import 'package:stream_chat/src/core/util/utils.dart';
import 'package:stream_chat/src/event_type.dart';
import 'package:stream_chat/src/extensions/rate_limit.dart';
import 'package:stream_chat/src/models/attachment_file.dart';
import 'package:stream_chat/src/models/channel_state.dart';
import 'package:stream_chat/src/models/user.dart';
import 'package:stream_chat/stream_chat.dart';
/// This a the class that manages a specific channel.
@@ -22,9 +22,9 @@ class Channel {
this._client,
this._type,
this._id, {
Map<String, Object?> extraData = const {},
Map<String, Object?>? extraData,
}) : _cid = _id != null ? '$_type:$_id' : null,
_extraData = extraData {
_extraData = extraData ?? {} {
_client.logger.info('New Channel instance not initialized created');
}
@@ -51,9 +51,9 @@ class Channel {
String? _id;
String? _cid;
final Map<String, dynamic> _extraData;
final Map<String, Object?> _extraData;
set extraData(Map<String, dynamic> extraData) {
set extraData(Map<String, Object?> extraData) {
if (_initializedCompleter.isCompleted) {
throw StateError(
'Once the channel is initialized you should use channel.update '
@@ -202,8 +202,13 @@ class Channel {
}
/// Channel extra data
Map<String, dynamic> get extraData =>
state?._channelState.channel?.extraData ?? _extraData;
Map<String, Object?> get extraData {
var data = state?._channelState.channel?.extraData;
if (data == null || data.isEmpty) {
data = _extraData;
}
return data;
}
/// Channel extra data as a stream
Stream<Map<String, dynamic>> get extraDataStream {
@@ -217,8 +222,6 @@ class Channel {
StreamChatClient get client => _client;
final StreamChatClient _client;
String get _channelURL => '/channels/$type/$id';
final Completer<bool> _initializedCompleter = Completer();
/// True if this is initialized
@@ -239,12 +242,14 @@ class Channel {
}) {
final cancelToken = _cancelableAttachmentUploadRequest[attachmentId];
if (cancelToken == null) {
throw Exception(
"Upload request for this Attachment hasn't started yet or else "
throw const StreamChatError(
"Upload request for this Attachment hasn't started yet or maybe "
'Already completed',
);
}
if (cancelToken.isCancelled) throw Exception('Already cancelled');
if (cancelToken.isCancelled) {
throw const StreamChatError('Upload request already cancelled');
}
cancelToken.cancel(reason);
}
@@ -264,7 +269,7 @@ class Channel {
);
if (message == null) {
throw Exception('Error, Message not found');
throw const StreamChatError('Error, Message not found');
}
final attachments = message.attachments.where((it) {
@@ -396,7 +401,6 @@ class Channel {
_messageAttachmentsUploadCompleter[message.id] =
attachmentsUploadCompleter;
// ignore: unawaited_futures
_uploadAttachments(
message.id,
message.attachments.map((it) => it.id),
@@ -414,9 +418,9 @@ class Channel {
);
state!.addMessage(response.message);
return response;
} catch (error) {
if (error is DioError && error.type != DioErrorType.response) {
state!.retryQueue?.add([message]);
} catch (e) {
if (e is StreamChatNetworkError && e.isRetriable) {
state!._retryQueue.add([message]);
}
rethrow;
}
@@ -426,8 +430,7 @@ class Channel {
/// Waits for a [_messageAttachmentsUploadCompleter] to complete
/// before actually updating the message.
Future<UpdateMessageResponse> updateMessage(Message message) async {
final currentMessage =
state?.messages.firstWhere((e) => e.id == message.id);
final originalMessage = message;
// Cancelling previous completer in case it's called again in the process
// Eg. Updating the message while the previous call is in progress.
@@ -450,12 +453,11 @@ class Channel {
state?.addMessage(message);
try {
if (message.attachments.any((it) => !it.uploadState.isSuccess) == true) {
if (message.attachments.any((it) => !it.uploadState.isSuccess)) {
final attachmentsUploadCompleter = Completer<Message>();
_messageAttachmentsUploadCompleter[message.id] =
attachmentsUploadCompleter;
// ignore: unawaited_futures
_uploadAttachments(
message.id,
message.attachments.map((it) => it.id),
@@ -474,12 +476,12 @@ class Channel {
state?.addMessage(m);
return response;
} catch (error) {
if (error is DioError && error.type != DioErrorType.response) {
state?.retryQueue?.add([message]);
} else if (error is ApiError) {
if (currentMessage != null) {
state?.addMessage(currentMessage);
} catch (e) {
if (e is StreamChatNetworkError) {
if (e.isRetriable) {
state!._retryQueue.add([message]);
} else {
state?.addMessage(originalMessage);
}
}
rethrow;
@@ -487,21 +489,30 @@ class Channel {
}
/// Partially updates the [message] in this channel.
Future<UpdateMessageResponse> partiallyUpdateMessage(
Message message, Map data) async {
/// Use [set] to define values to be set
/// Use [unset] to define values to be unset
Future<UpdateMessageResponse> partialUpdateMessage(
Message message, {
Map<String, Object?>? set,
List<String>? unset,
}) async {
try {
final response = await _client.partiallyUpdateMessage(message.id, data);
final response = await _client.partialUpdateMessage(
message.id,
set: set,
unset: unset,
);
final m = response.message.copyWith(
final updatedMessage = response.message.copyWith(
ownReactions: message.ownReactions,
);
state?.addMessage(m);
state?.addMessage(updatedMessage);
return response;
} catch (error) {
if (error is DioError && error.type != DioErrorType.response) {
state?.retryQueue?.add([message]);
} catch (e) {
if (e is StreamChatNetworkError && e.isRetriable) {
state!._retryQueue.add([message]);
}
rethrow;
}
@@ -535,14 +546,14 @@ class Channel {
state?.addMessage(message);
final response = await _client.deleteMessage(message);
final response = await _client.deleteMessage(message.id);
state?.addMessage(message.copyWith(status: MessageSendingStatus.sent));
return response;
} catch (error) {
if (error is DioError && error.type != DioErrorType.response) {
state?.retryQueue?.add([message]);
} catch (e) {
if (e is StreamChatNetworkError && e.isRetriable) {
state!._retryQueue.add([message]);
}
rethrow;
}
@@ -550,9 +561,9 @@ class Channel {
/// Pins provided message
Future<UpdateMessageResponse> pinMessage(
Message message, [
Object? timeoutOrExpirationDate,
]) {
Message message, {
Object? /*num|DateTime*/ timeoutOrExpirationDate,
}) {
assert(() {
if (timeoutOrExpirationDate is! DateTime &&
timeoutOrExpirationDate != null &&
@@ -560,7 +571,7 @@ class Channel {
throw ArgumentError('Invalid timeout or Expiration date');
}
return true;
}(), 'Check whether timeout is valid');
}(), 'Check for invalid timeout or expiration date');
DateTime? pinExpires;
if (timeoutOrExpirationDate is DateTime) {
@@ -570,21 +581,23 @@ class Channel {
Duration(seconds: timeoutOrExpirationDate.toInt()),
);
}
return partiallyUpdateMessage(message, {
'set': {
return partialUpdateMessage(
message,
set: {
'pinned': true,
if (pinExpires != null) 'pin_expires': pinExpires.toIso8601String(),
}
});
'pin_expires': pinExpires?.toUtc().toIso8601String(),
},
);
}
/// Unpins provided message
Future<UpdateMessageResponse> unpinMessage(Message message) =>
partiallyUpdateMessage(message, {
'set': {
partialUpdateMessage(
message,
set: {
'pinned': false,
}
});
},
);
/// Send a file to this channel
Future<SendFileResponse> sendFile(
@@ -666,10 +679,7 @@ class Channel {
/// Send an event on this channel
Future<EmptyResponse> sendEvent(Event event) {
_checkInitialized();
return _client.post(
'$_channelURL/event',
data: {'event': event.toJson()},
).then((res) => _client.decode(res.data, EmptyResponse.fromJson)!);
return _client.sendEvent(id!, type, event);
}
/// Send a reaction to this channel
@@ -721,21 +731,13 @@ class Channel {
state?.addMessage(newMessage);
final data = Map<String, dynamic>.from(extraData)
..addAll({
'type': type,
});
try {
final res = await _client.post(
'/messages/$messageId/reaction',
data: {
'reaction': data,
'enforce_unique': enforceUnique,
},
final reactionResp = await _client.sendReaction(
messageId,
type,
extraData: extraData,
enforceUnique: enforceUnique,
);
final reactionResp =
_client.decode(res.data, SendReactionResponse.fromJson);
return reactionResp;
} catch (_) {
// Reset the message if the update fails
@@ -778,9 +780,11 @@ class Channel {
state?.addMessage(newMessage);
try {
final res = await client
.delete('/messages/${message.id}/reaction/${reaction.type}');
return _client.decode(res.data, EmptyResponse.fromJson);
final deleteResponse = await _client.deleteReaction(
message.id,
reaction.type,
);
return deleteResponse;
} catch (_) {
// Reset the message if the update fails
state?.addMessage(message);
@@ -790,48 +794,49 @@ class Channel {
/// Edit the channel custom data
Future<UpdateChannelResponse> update(
Map<String, dynamic> channelData, [
Map<String, Object?> channelData, [
Message? updateMessage,
]) async {
final response = await _client.post(_channelURL, data: {
if (updateMessage != null)
'message': updateMessage.copyWith(updatedAt: DateTime.now()).toJson(),
'data': channelData,
});
return _client.decode(response.data, UpdateChannelResponse.fromJson);
_checkInitialized();
return _client.updateChannel(
id!,
type,
channelData,
message: updateMessage,
);
}
/// Edit the channel custom data
Future<PartialUpdateChannelResponse> updatePartial(
Map<String, dynamic> channelData) async {
final response = await _client.patch(_channelURL, data: channelData);
return _client.decode(response.data, PartialUpdateChannelResponse.fromJson);
Future<PartialUpdateChannelResponse> updatePartial({
Map<String, Object?>? set,
List<String>? unset,
}) async {
_checkInitialized();
return _client.updateChannelPartial(id!, type, set: set, unset: unset);
}
/// Delete this channel. Messages are permanently removed.
Future<EmptyResponse> delete() async {
final response = await _client.delete(_channelURL);
return _client.decode(response.data, EmptyResponse.fromJson);
_checkInitialized();
return _client.deleteChannel(id!, type);
}
/// Removes all messages from the channel
Future<EmptyResponse> truncate() async {
final response = await _client.post('$_channelURL/truncate');
return _client.decode(response.data, EmptyResponse.fromJson);
_checkInitialized();
return _client.truncateChannel(id!, type);
}
/// Accept invitation to the channel
Future<AcceptInviteResponse> acceptInvite([Message? message]) async {
final res = await _client.post(_channelURL,
data: {'accept_invite': true, 'message': message?.toJson()});
return _client.decode(res.data, AcceptInviteResponse.fromJson);
_checkInitialized();
return _client.acceptChannelInvite(id!, type, message: message);
}
/// Reject invitation to the channel
Future<RejectInviteResponse> rejectInvite([Message? message]) async {
final res = await _client.post(_channelURL,
data: {'reject_invite': true, 'message': message?.toJson()});
return _client.decode(res.data, RejectInviteResponse.fromJson);
_checkInitialized();
return _client.rejectChannelInvite(id!, type, message: message);
}
/// Add members to the channel
@@ -839,11 +844,8 @@ class Channel {
List<String> memberIds, [
Message? message,
]) async {
final res = await _client.post(_channelURL, data: {
'add_members': memberIds,
'message': message?.toJson(),
});
return _client.decode(res.data, AddMembersResponse.fromJson);
_checkInitialized();
return _client.addChannelMembers(id!, type, memberIds, message: message);
}
/// Invite members to the channel
@@ -851,11 +853,8 @@ class Channel {
List<String> memberIds, [
Message? message,
]) async {
final res = await _client.post(_channelURL, data: {
'invites': memberIds,
'message': message?.toJson(),
});
return _client.decode(res.data, InviteMembersResponse.fromJson);
_checkInitialized();
return _client.inviteChannelMembers(id!, type, memberIds, message: message);
}
/// Remove members from the channel
@@ -863,11 +862,8 @@ class Channel {
List<String> memberIds, [
Message? message,
]) async {
final res = await _client.post(_channelURL, data: {
'remove_members': memberIds,
'message': message?.toJson(),
});
return _client.decode(res.data, RemoveMembersResponse.fromJson);
_checkInitialized();
return _client.removeChannelMembers(id!, type, memberIds, message: message);
}
/// Send action for a specific message of this channel
@@ -876,30 +872,27 @@ class Channel {
Map<String, dynamic> formData,
) async {
_checkInitialized();
final messageId = message.id;
final response = await _client.post('/messages/$messageId/action', data: {
'id': id,
'type': type,
'form_data': formData,
'message_id': messageId,
});
final res = _client.decode(response.data, SendActionResponse.fromJson);
final res = await _client.sendAction(id!, type, messageId, formData);
// update the passed message with response message
if (res.message != null) {
state!.addMessage(res.message!);
} else {
// remove the passed message if response does
// not contain message
final oldIndex = state!.messages.indexWhere((m) => m.id == messageId);
Message? oldMessage;
// remove regular message if present
if (oldIndex != -1) {
oldMessage = state!.messages[oldIndex];
final oldMessage = state!.messages[oldIndex];
state!.updateChannelState(state!._channelState.copyWith(
messages: state?.messages?..remove(oldMessage),
));
} else {
oldMessage = state!.threads.values
// remove thread message if present
// also reduces total reply count
final oldMessage = state!.threads.values
.expand((messages) => messages)
.firstWhereOrNull((m) => m.id == messageId);
if (oldMessage?.parentId != null) {
@@ -914,36 +907,28 @@ class Channel {
state!.threads[oldMessage.parentId!]!..remove(oldMessage));
}
}
await _client.chatPersistenceClient?.deleteMessageById(messageId);
}
return res;
}
/// Mark all channel messages as read
Future<EmptyResponse> markRead() async {
/// Mark all messages as read
/// Optionally provide a [messageId] if you want to mark a
/// particular message as read
Future<EmptyResponse> markRead({String? messageId}) async {
_checkInitialized();
client.state.totalUnreadCount = max(
0, (client.state.totalUnreadCount ?? 0) - (state!.unreadCount ?? 0));
state!._unreadCountController.add(0);
final response = await _client.post('$_channelURL/read', data: {});
return _client.decode(response.data, EmptyResponse.fromJson);
client.state.totalUnreadCount =
max(0, (client.state.totalUnreadCount) - (state!.unreadCount));
state!.unreadCount = 0;
return _client.markChannelRead(id!, type, messageId: messageId);
}
/// Loads the initial channel state and watches for changes
Future<ChannelState> watch([Map<String, dynamic> options = const {}]) async {
final watchOptions = Map<String, dynamic>.from({
'state': true,
'watch': true,
'presence': false,
})
..addAll(options);
Future<ChannelState> watch() async {
ChannelState response;
try {
response = await query(options: watchOptions);
response = await query(watch: true);
} catch (error, stackTrace) {
if (!_initializedCompleter.isCompleted) {
_initializedCompleter.completeError(error, stackTrace);
@@ -962,7 +947,7 @@ class Channel {
state = ChannelClientState(this, channelState);
if (cid != null) {
client.state.channels[cid!] = this;
client.state.channels = {cid!: this};
}
if (!_initializedCompleter.isCompleted) {
_initializedCompleter.complete(true);
@@ -971,19 +956,16 @@ class Channel {
/// Stop watching the channel
Future<EmptyResponse> stopWatching() async {
final response = await _client.post(
'$_channelURL/stop-watching',
data: {},
);
return _client.decode(response.data, EmptyResponse.fromJson);
_checkInitialized();
return _client.stopChannelWatching(id!, type);
}
/// List the message replies for a parent message
/// Set [preferOffline] to true to avoid the api call if the data is already
/// in the offline storage
Future<QueryRepliesResponse> getReplies(
String parentId,
PaginationParams options, {
String parentId, {
PaginationParams? options,
bool preferOffline = false,
}) async {
final cachedReplies = await _client.chatPersistenceClient?.getReplies(
@@ -996,50 +978,32 @@ class Channel {
return QueryRepliesResponse()..messages = cachedReplies;
}
}
final response = await _client.get('/messages/$parentId/replies',
queryParameters: options.toJson());
final repliesResponse = _client.decode<QueryRepliesResponse>(
response.data,
QueryRepliesResponse.fromJson,
final repliesResponse = await _client.getReplies(
parentId,
options: options,
);
state?.updateThreadInfo(parentId, repliesResponse.messages);
return repliesResponse;
}
/// List the reactions for a message in the channel
Future<QueryReactionsResponse> getReactions(
String messageID,
PaginationParams options,
) async {
final response = await _client.get(
'/messages/$messageID/reactions',
queryParameters: options.toJson(),
);
return _client.decode<QueryReactionsResponse>(
response.data, QueryReactionsResponse.fromJson);
}
String messageId, {
PaginationParams? options,
}) =>
_client.getReactions(
messageId,
options: options,
);
/// Retrieves a list of messages by ID
Future<GetMessagesByIdResponse> getMessagesById(
List<String> messageIDs) async {
final response = await _client.get(
'$_channelURL/messages',
queryParameters: {'ids': messageIDs.join(',')},
);
final res = _client.decode<GetMessagesByIdResponse>(
response.data,
GetMessagesByIdResponse.fromJson,
);
List<String> messageIDs,
) async {
_checkInitialized();
final res = await _client.getMessagesById(id!, type, messageIDs);
final messages = res.messages;
state?.updateChannelState(ChannelState(messages: messages));
return res;
}
@@ -1047,85 +1011,59 @@ class Channel {
Future<TranslateMessageResponse> translateMessage(
String messageId,
String language,
) async {
final response = await _client.post(
'/messages/$messageId/translate',
data: {
'language': language,
},
);
return _client.decode<TranslateMessageResponse>(
response.data,
TranslateMessageResponse.fromJson,
);
}
) =>
_client.translateMessage(
messageId,
language,
);
/// Creates a new channel
Future<ChannelState> create() async => query(options: {
'watch': false,
'state': false,
'presence': false,
});
Future<ChannelState> create() async => query(state: false);
/// Query the API, get messages, members or other channel fields
/// Set [preferOffline] to true to avoid the api call if the data is already
/// in the offline storage
Future<ChannelState> query({
Map<String, dynamic> options = const {},
bool state = true,
bool watch = false,
bool presence = false,
PaginationParams? messagesPagination,
PaginationParams? membersPagination,
PaginationParams? watchersPagination,
bool preferOffline = false,
}) async {
var path = '/channels/$type';
if (id != null) path = '$path/$id';
path = '$path/query';
final payload = Map<String, dynamic>.from({
'state': true,
})
..addAll(options);
if (_extraData.isNotEmpty) {
payload['data'] = _extraData;
}
if (messagesPagination != null) {
payload['messages'] = messagesPagination.toJson();
}
if (membersPagination != null) {
payload['members'] = membersPagination.toJson();
}
if (watchersPagination != null) {
payload['watchers'] = watchersPagination.toJson();
}
if (preferOffline && cid != null) {
final updatedState =
(await _client.chatPersistenceClient?.getChannelStateByCid(
cid!,
messagePagination: messagesPagination,
))!;
if (updatedState.messages.isNotEmpty) {
if (state == null) {
final updatedState = await _client.chatPersistenceClient
?.getChannelStateByCid(cid!, messagePagination: messagesPagination);
if (updatedState != null && updatedState.messages.isNotEmpty) {
if (this.state == null) {
_initState(updatedState);
} else {
state?.updateChannelState(updatedState);
this.state?.updateChannelState(updatedState);
}
return updatedState;
}
}
try {
final response = await _client.post(path, data: payload);
final updatedState = _client.decode(response.data, ChannelState.fromJson);
final updatedState = await _client.queryChannel(
type,
channelId: id,
channelData: _extraData,
state: state,
watch: watch,
presence: presence,
messagesPagination: messagesPagination,
membersPagination: membersPagination,
watchersPagination: watchersPagination,
);
if (_id == null) {
_id = updatedState.channel!.id;
_cid = updatedState.channel!.cid;
}
state?.updateChannelState(updatedState);
this.state?.updateChannelState(updatedState);
return updatedState;
} catch (e) {
if (!_client.persistenceEnabled) {
@@ -1143,45 +1081,26 @@ class Channel {
Filter? filter,
List<SortOption>? sort,
PaginationParams? pagination,
}) async {
final payload = <String, dynamic>{
'sort': sort,
'filter_conditions': filter ?? {},
'type': type,
};
if (pagination != null) {
payload.addAll(pagination.toJson());
}
if (id != null) {
payload['id'] = id;
} else if (state?.members.isNotEmpty == true) {
payload['members'] = state!.members;
}
final rawRes = await _client.get('/members', queryParameters: {
'payload': jsonEncode(payload),
});
final response = _client.decode(rawRes.data, QueryMembersResponse.fromJson);
return response;
}
}) =>
_client.queryMembers(
type,
channelId: id,
filter: filter,
members: state?.members,
sort: sort,
pagination: pagination,
);
/// Mutes the channel
Future<EmptyResponse> mute({Duration? expiration}) async {
final response = await _client.post('/moderation/mute/channel', data: {
'channel_cid': cid,
if (expiration != null) 'expiration': expiration.inMilliseconds,
});
return _client.decode(response.data, EmptyResponse.fromJson);
Future<EmptyResponse> mute({Duration? expiration}) {
_checkInitialized();
return _client.muteChannel(cid!, expiration: expiration);
}
/// Unmutes the channel
Future<EmptyResponse> unmute() async {
final response = await _client.post('/moderation/unmute/channel', data: {
'channel_cid': cid,
});
return _client.decode(response.data, EmptyResponse.fromJson);
Future<EmptyResponse> unmute() {
_checkInitialized();
return _client.unmuteChannel(cid!);
}
/// Bans a user from the channel
@@ -1235,9 +1154,11 @@ class Channel {
/// will be removed for the user
Future<EmptyResponse> hide({bool clearHistory = false}) async {
_checkInitialized();
final response = await _client
.post('$_channelURL/hide', data: {'clear_history': clearHistory});
final response = await _client.hideChannel(
id!,
type,
clearHistory: clearHistory,
);
if (clearHistory == true) {
state!.truncate();
final cid = _cid;
@@ -1245,15 +1166,13 @@ class Channel {
await _client.chatPersistenceClient?.deleteMessageByCid(cid);
}
}
return _client.decode(response.data, EmptyResponse.fromJson);
return response;
}
/// Removes the hidden status for the channel
Future<EmptyResponse> show() async {
_checkInitialized();
final response = await _client.post('$_channelURL/show');
return _client.decode(response.data, EmptyResponse.fromJson);
return _client.showChannel(id!, type);
}
/// Stream of [Event] coming from websocket connection specific for the
@@ -1335,9 +1254,11 @@ class ChannelClientState {
_channel._client.chatPersistenceClient
?.updateChannelState(state))
.debounced(const Duration(seconds: 1)) {
retryQueue = RetryQueue(
_retryQueue = RetryQueue(
channel: _channel,
logger: Logger('RETRY QUEUE ${_channel.cid}'),
logger: _channel.client.detachedLogger(
'⟳ (${generateHash([_channel.cid])})',
),
);
_checkExpiredAttachmentMessages(channelState);
@@ -1395,7 +1316,7 @@ class ChannelClientState {
(r) => r.user.id == _channel._client.state.user?.id,
);
if (userRead != null) {
_unreadCountController.add(userRead.unreadMessages);
unreadCount = userRead.unreadMessages;
}
}
@@ -1485,7 +1406,7 @@ class ChannelClientState {
BehaviorSubject.seeded(true);
/// The retry queue associated to this channel
RetryQueue? retryQueue;
late final RetryQueue _retryQueue;
/// Retry failed message
Future<void> retryFailedMessages() async {
@@ -1504,7 +1425,7 @@ class ChannelClientState {
)
.toList();
retryQueue!.add(failedMessages);
_retryQueue.add(failedMessages);
}
void _listenReactionDeleted() {
@@ -1575,7 +1496,7 @@ class ChannelClientState {
}
if (_countMessageAsUnread(message)) {
_unreadCountController.add(_unreadCountController.value + 1);
unreadCount += 1;
}
}));
}
@@ -1631,11 +1552,11 @@ class ChannelClientState {
if (userReadIndex != null && userReadIndex != -1) {
final userRead = readList.removeAt(userReadIndex);
if (userRead.user.id == _channel._client.state.user!.id) {
_unreadCountController.add(0);
unreadCount = 0;
}
readList.add(Read(
user: event.user!,
lastRead: event.createdAt!,
lastRead: event.createdAt,
unreadMessages: event.totalUnreadCount ?? 0,
));
_channelState = _channelState.copyWith(read: readList);
@@ -1711,11 +1632,13 @@ class ChannelClientState {
final BehaviorSubject<int> _unreadCountController = BehaviorSubject.seeded(0);
set unreadCount(int value) => _unreadCountController.add(value);
/// Unread count getter as a stream
Stream<int> get unreadCountStream => _unreadCountController.stream.distinct();
/// Unread count getter
int? get unreadCount => _unreadCountController.value;
int get unreadCount => _unreadCountController.value;
bool _countMessageAsUnread(Message message) {
final userId = _channel.client.state.user?.id;
@@ -1840,7 +1763,7 @@ class ChannelClientState {
BehaviorSubject.seeded({});
set _threads(Map<String, List<Message>> v) {
_channel._client.chatPersistenceClient?.updateMessages(
_channel.client.chatPersistenceClient?.updateMessages(
_channel.cid!,
v.values.expand((v) => v).toList(),
);
@@ -1919,7 +1842,7 @@ class ChannelClientState {
);
}
late Timer _cleaningTimer;
Timer? _cleaningTimer;
void _startCleaning() {
if (_channelState.channel?.config.typingEvents == false) {
@@ -1965,7 +1888,7 @@ class ChannelClientState {
void _clean() {
final now = DateTime.now();
_typings.forEach((user, event) {
if (now.difference(event.createdAt!).inSeconds > 7) {
if (now.difference(event.createdAt).inSeconds > 7) {
_channel.client.handleEvent(
Event(
type: EventType.typingStop,
@@ -1982,12 +1905,12 @@ class ChannelClientState {
void dispose() {
_debouncedUpdatePersistenceChannelState.cancel();
_unreadCountController.close();
retryQueue!.dispose();
_retryQueue.dispose();
_subscriptions.forEach((s) => s.cancel());
_channelStateController.close();
_isUpToDateController.close();
_threadsController.close();
_cleaningTimer.cancel();
_cleaningTimer?.cancel();
_pinnedMessagesTimer.cancel();
_typingEventsController.close();
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,37 @@
import 'package:stream_chat/src/client/client.dart';
import 'package:stream_chat/src/core/error/error.dart';
/// The retry options
/// When sending/updating/deleting a message any temporary error will trigger the retry policy
/// The retry policy exposes 2 methods
/// - shouldRetry: returns a boolean if the request should be retried
/// - retryTimeout: How many milliseconds to wait till the next attempt
///
/// maxRetryAttempts is a hard limit on maximum retry attempts before giving up
class RetryPolicy {
/// Instantiate a new RetryPolicy
RetryPolicy({
required this.shouldRetry,
required this.retryTimeout,
this.maxRetryAttempts = 6,
});
/// Hard limit on maximum retry attempts before giving up, defaults to 6
/// Resets once connection recovers.
final int maxRetryAttempts;
/// This function evaluates if we should retry the failure
final bool Function(
StreamChatClient client,
int attempt,
StreamChatError? error,
) shouldRetry;
/// In the case that we want to retry a failed request the retryTimeout
/// method is called to determine the timeout
final Duration Function(
StreamChatClient client,
int attempt,
StreamChatError? error,
) retryTimeout;
}
@@ -0,0 +1,241 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:logging/logging.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/core/error/error.dart';
import 'package:stream_chat/src/event_type.dart';
import 'package:stream_chat/src/core/models/message.dart';
import 'package:stream_chat/stream_chat.dart';
/// The retry queue associated to a channel
class RetryQueue {
/// Instantiate a new RetryQueue object
RetryQueue({
required this.channel,
this.logger,
}) : client = channel.client {
_retryPolicy = client.retryPolicy;
_listenConnectionRecovered();
_listenFailedEvents();
}
/// The channel of this queue
final Channel channel;
/// The client associated with this [channel]
final StreamChatClient client;
/// The logger associated to this queue
final Logger? logger;
late final RetryPolicy _retryPolicy;
final _compositeSubscription = CompositeSubscription();
final _messageQueue = HeapPriorityQueue(_byDate);
bool _isRetrying = false;
void _listenConnectionRecovered() {
client.on(EventType.connectionRecovered).listen((event) {
if (event.online == true) {
_startRetrying();
}
}).addTo(_compositeSubscription);
}
void _listenFailedEvents() {
channel.on().where((event) => event.message != null).listen((event) {
final message = event.message!;
final containsMessage = _messageQueue.containsMessage(message);
if (!containsMessage) return;
if (message.status == MessageSendingStatus.sent) {
logger?.info('Removing sent message from queue : ${message.id}');
_messageQueue.removeMessage(message);
return;
} else {
if ([
MessageSendingStatus.failed_update,
MessageSendingStatus.failed,
MessageSendingStatus.failed_delete,
].contains(message.status)) {
logger?.info('Adding failed message from event : ${event.type}');
add([message]);
}
}
}).addTo(_compositeSubscription);
}
/// Add a list of messages
void add(List<Message> messages) {
if (messages.isEmpty) return;
if (_messageQueue.containsAllMessage(messages)) return;
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();
}
Future<void> _startRetrying() async {
if (_isRetrying) return;
_isRetrying = true;
logger?.info('Started retrying failed messages');
while (_messageQueue.isNotEmpty) {
logger?.info('${_messageQueue.length} messages remaining in the queue');
final message = _messageQueue.first;
await _runAndRetry(message);
}
_isRetrying = false;
}
Future<void> _runAndRetry(Message message) async {
var attempt = 1;
final maxAttempt = _retryPolicy.maxRetryAttempts;
// early return in case maxAttempt is less than 0
if (attempt > maxAttempt) return;
// ignore: literal_only_boolean_expressions
while (true) {
try {
logger?.info('Message (${message.id}) retry attempt $attempt');
await _retryMessage(message);
logger?.info('Message (${message.id}) sent successfully');
_messageQueue.removeMessage(message);
break;
} on StreamChatError catch (e) {
// retry logic
final maxAttempt = _retryPolicy.maxRetryAttempts;
if (attempt < maxAttempt) {
final shouldRetry = _retryPolicy.shouldRetry(client, attempt, e);
if (shouldRetry) {
final timeout = _retryPolicy.retryTimeout(client, attempt, e);
// temporary failure, continue
logger?.info(
'API call failed (attempt $attempt), '
'retrying in ${timeout.inSeconds} seconds. Error was $e',
);
await Future.delayed(timeout);
attempt += 1;
} else {
logger?.info(
'API call failed (attempt $attempt). '
'Giving up for now, will retry when connection recovers. '
'Error was $e',
);
_sendFailedEvent(message);
break;
}
} else {
logger?.info(
'API call failed (attempt $attempt). '
'Exceeds maxRetryAttempt : $maxAttempt '
'Giving up for now, will retry when connection recovers. '
'Error was $e',
);
_sendFailedEvent(message);
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;
}
}
}
void _sendFailedEvent(Message message) {
final newStatus = message.status == MessageSendingStatus.sending
? MessageSendingStatus.failed
: message.status == MessageSendingStatus.updating
? MessageSendingStatus.failed_update
: MessageSendingStatus.failed_delete;
channel.state?.addMessage(message.copyWith(status: newStatus));
}
Future<void> _retryMessage(Message message) async {
if (message.status == MessageSendingStatus.failed_update ||
message.status == MessageSendingStatus.updating) {
await channel.updateMessage(message);
} else if (message.status == MessageSendingStatus.failed ||
message.status == MessageSendingStatus.sending) {
await channel.sendMessage(message);
} else if (message.status == MessageSendingStatus.failed_delete ||
message.status == MessageSendingStatus.deleting) {
await channel.deleteMessage(message);
}
}
/// Whether our [_messageQueue] has messages or not
bool get hasMessages => _messageQueue.isNotEmpty;
/// Call this method to dispose this object
void dispose() {
_messageQueue.clear();
_compositeSubscription.dispose();
}
static int _byDate(Message m1, Message m2) {
final date1 = _getMessageDate(m1);
final date2 = _getMessageDate(m2);
if (date1 == null || date2 == null) {
return 0;
}
return date1.compareTo(date2);
}
static DateTime? _getMessageDate(Message m1) {
switch (m1.status) {
case MessageSendingStatus.failed_delete:
case MessageSendingStatus.deleting:
return m1.deletedAt;
case MessageSendingStatus.failed:
case MessageSendingStatus.sending:
return m1.createdAt;
case MessageSendingStatus.failed_update:
case MessageSendingStatus.updating:
return m1.updatedAt;
default:
return null;
}
}
}
extension _MessageHeapPriorityQueue on HeapPriorityQueue<Message> {
void removeMessage(Message message) {
final list = toUnorderedList();
final index = list.indexWhere((it) => it.id == message.id);
if (index == -1) return;
final element = list[index];
remove(element);
}
bool containsMessage(Message message) {
final list = toUnorderedList();
final index = list.indexWhere((it) => it.id == message.id);
if (index == -1) return false;
return true;
}
bool containsAllMessage(List<Message> messages) {
if (isEmpty) return false;
final list = toUnorderedList();
final messageIds = messages.map((it) => it.id);
return list.every((it) => messageIds.contains(it.id));
}
}
@@ -1,8 +1,7 @@
import 'package:dio/dio.dart';
import 'package:stream_chat/src/api/responses.dart';
import 'package:stream_chat/src/client.dart';
import 'package:stream_chat/src/extensions/string_extension.dart';
import 'package:stream_chat/src/models/attachment_file.dart';
import 'package:stream_chat/src/core/api/responses.dart';
import 'package:stream_chat/src/core/http/stream_http_client.dart';
import 'package:stream_chat/src/core/models/attachment_file.dart';
/// Class responsible for uploading images and files to a given channel
abstract class AttachmentFileUploader {
@@ -60,7 +59,7 @@ class StreamAttachmentFileUploader implements AttachmentFileUploader {
/// Creates a new [StreamAttachmentFileUploader] instance.
const StreamAttachmentFileUploader(this._client);
final StreamChatClient _client;
final StreamHttpClient _client;
@override
Future<SendImageResponse> sendImage(
@@ -70,33 +69,14 @@ class StreamAttachmentFileUploader implements AttachmentFileUploader {
ProgressCallback? onSendProgress,
CancelToken? cancelToken,
}) async {
final filename = file.path?.split('/').last ?? file.name;
final mimeType = filename?.mimeType;
MultipartFile? multiPartFile;
if (file.path != null) {
multiPartFile = await MultipartFile.fromFile(
file.path!,
filename: filename,
contentType: mimeType,
);
} else if (file.bytes != null) {
multiPartFile = MultipartFile.fromBytes(
file.bytes!,
filename: filename,
contentType: mimeType,
);
}
final response = await _client.post(
final multiPartFile = await file.toMultipartFile();
final response = await _client.postFile(
'/channels/$channelType/$channelId/image',
data: FormData.fromMap({
'file': multiPartFile,
}),
multiPartFile,
onSendProgress: onSendProgress,
cancelToken: cancelToken,
);
return _client.decode(response.data, SendImageResponse.fromJson);
return SendImageResponse.fromJson(response.data);
}
@override
@@ -107,33 +87,14 @@ class StreamAttachmentFileUploader implements AttachmentFileUploader {
ProgressCallback? onSendProgress,
CancelToken? cancelToken,
}) async {
final filename = file.path?.split('/').last ?? file.name;
final mimeType = filename?.mimeType;
MultipartFile? multiPartFile;
if (file.path != null) {
multiPartFile = await MultipartFile.fromFile(
file.path!,
filename: filename,
contentType: mimeType,
);
} else if (file.bytes != null) {
multiPartFile = MultipartFile.fromBytes(
file.bytes!,
filename: filename,
contentType: mimeType,
);
}
final response = await _client.post(
final multiPartFile = await file.toMultipartFile();
final response = await _client.postFile(
'/channels/$channelType/$channelId/file',
data: FormData.fromMap({
'file': multiPartFile,
}),
multiPartFile,
onSendProgress: onSendProgress,
cancelToken: cancelToken,
);
return _client.decode(response.data, SendFileResponse.fromJson);
return SendFileResponse.fromJson(response.data);
}
@override
@@ -148,7 +109,7 @@ class StreamAttachmentFileUploader implements AttachmentFileUploader {
queryParameters: {'url': url},
cancelToken: cancelToken,
);
return _client.decode(response.data, EmptyResponse.fromJson);
return EmptyResponse.fromJson(response.data);
}
@override
@@ -163,6 +124,6 @@ class StreamAttachmentFileUploader implements AttachmentFileUploader {
queryParameters: {'url': url},
cancelToken: cancelToken,
);
return _client.decode(response.data, EmptyResponse.fromJson);
return EmptyResponse.fromJson(response.data);
}
}
@@ -0,0 +1,295 @@
import 'dart:convert';
import 'package:stream_chat/src/core/api/requests.dart';
import 'package:stream_chat/src/core/api/responses.dart';
import 'package:stream_chat/src/core/http/stream_http_client.dart';
import 'package:stream_chat/src/core/models/channel_model.dart';
import 'package:stream_chat/src/core/models/channel_state.dart';
import 'package:stream_chat/src/core/models/event.dart';
import 'package:stream_chat/src/core/models/filter.dart';
import 'package:stream_chat/src/core/models/message.dart';
/// Defines the api dedicated to channel operations
class ChannelApi {
/// Initialize a new channel api
ChannelApi(this._client);
final StreamHttpClient _client;
String _getChannelUrl(String channelId, String channelType) =>
'/channels/$channelType/$channelId';
/// Query the API, get messages, members or other channel fields
Future<ChannelState> queryChannel(
String channelType, {
bool state = true,
bool watch = false,
bool presence = false,
String? channelId,
Map<String, Object?>? channelData,
PaginationParams? messagesPagination,
PaginationParams? membersPagination,
PaginationParams? watchersPagination,
}) async {
var channelPath = '/channels/$channelType';
if (channelId != null) channelPath = '$channelPath/$channelId';
final response = await _client.post(
'$channelPath/query',
data: {
'state': state,
'watch': watch,
'presence': presence,
if (channelData != null) 'data': channelData,
if (messagesPagination != null) 'messages': messagesPagination,
if (membersPagination != null) 'members': membersPagination,
if (watchersPagination != null) 'watchers': watchersPagination,
},
);
return ChannelState.fromJson(response.data);
}
/// Requests channels with a given query from the API.
Future<QueryChannelsResponse> queryChannels({
Filter? filter,
List<SortOption<ChannelModel>>? sort,
int? memberLimit,
int? messageLimit,
bool state = true,
bool watch = true,
bool presence = false,
PaginationParams paginationParams = const PaginationParams(),
}) async {
final response = await _client.get(
'/channels',
queryParameters: {
'payload': jsonEncode({
// default options
'state': state,
'watch': watch,
'presence': presence,
// passed options
if (sort != null) 'sort': sort,
if (filter != null) 'filter_conditions': filter,
if (memberLimit != null) 'member_limit': memberLimit,
if (messageLimit != null) 'message_limit': messageLimit,
// pagination
...paginationParams.toJson()
})
},
);
return QueryChannelsResponse.fromJson(response.data);
}
/// Mark all channels for this user as read
Future<EmptyResponse> markAllRead() async {
final response = await _client.post('channels/read');
return EmptyResponse.fromJson(response.data);
}
/// Replaces the [channelId] of type [ChannelType] data with [data]
Future<UpdateChannelResponse> updateChannel(
String channelId,
String channelType,
Map<String, Object?> data, {
Message? message,
}) async {
final response = await _client.post(
_getChannelUrl(channelId, channelType),
data: {
'data': data,
if (message != null)
'message': message.copyWith(updatedAt: DateTime.now()),
},
);
return UpdateChannelResponse.fromJson(response.data);
}
/// Updates the [channelId] of type [ChannelType] data with [data]
Future<PartialUpdateChannelResponse> updateChannelPartial(
String channelId,
String channelType, {
Map<String, Object?>? set,
List<String>? unset,
}) async {
final response = await _client.patch(
_getChannelUrl(channelId, channelType),
data: {
if (set != null) 'set': set,
if (unset != null) 'unset': unset,
},
);
return PartialUpdateChannelResponse.fromJson(response.data);
}
/// Accept invitation to the channel
Future<AcceptInviteResponse> acceptChannelInvite(
String channelId,
String channelType, {
Message? message,
}) async {
final response = await _client.post(
_getChannelUrl(channelId, channelType),
data: {
'accept_invite': true,
'message': message,
},
);
return AcceptInviteResponse.fromJson(response.data);
}
/// Reject invitation to the channel
Future<RejectInviteResponse> rejectChannelInvite(
String channelId,
String channelType, {
Message? message,
}) async {
final response = await _client.post(
_getChannelUrl(channelId, channelType),
data: {
'reject_invite': true,
'message': message,
},
);
return RejectInviteResponse.fromJson(response.data);
}
/// Invite members to the channel
Future<InviteMembersResponse> inviteChannelMembers(
String channelId,
String channelType,
List<String> memberIds, {
Message? message,
}) async {
final response = await _client.post(
_getChannelUrl(channelId, channelType),
data: {
'invites': memberIds,
'message': message,
},
);
return InviteMembersResponse.fromJson(response.data);
}
/// Add members to the channel
Future<AddMembersResponse> addMembers(
String channelId,
String channelType,
List<String> memberIds, {
Message? message,
}) async {
final response = await _client.post(
_getChannelUrl(channelId, channelType),
data: {
'add_members': memberIds,
'message': message,
},
);
return AddMembersResponse.fromJson(response.data);
}
/// Remove members from the channel
Future<RemoveMembersResponse> removeMembers(
String channelId,
String channelType,
List<String> memberIds, {
Message? message,
}) async {
final response = await _client.post(
_getChannelUrl(channelId, channelType),
data: {
'remove_members': memberIds,
'message': message,
},
);
return RemoveMembersResponse.fromJson(response.data);
}
/// Send an event on this channel
Future<EmptyResponse> sendEvent(
String channelId,
String channelType,
Event event,
) async {
final response = await _client.post(
'${_getChannelUrl(channelId, channelType)}/event',
data: {'event': event},
);
return EmptyResponse.fromJson(response.data);
}
/// Delete this channel. Messages are permanently removed.
Future<EmptyResponse> deleteChannel(
String channelId,
String channelType,
) async {
final response = await _client.delete(
_getChannelUrl(channelId, channelType),
);
return EmptyResponse.fromJson(response.data);
}
/// Removes all messages from the channel
Future<EmptyResponse> truncateChannel(
String channelId,
String channelType,
) async {
final response = await _client.post(
'${_getChannelUrl(channelId, channelType)}/truncate',
);
return EmptyResponse.fromJson(response.data);
}
/// Hides the channel from [StreamChatClient.queryChannels] for the user
/// until a message is added If [clearHistory] is set to true - all messages
/// will be removed for the user
Future<EmptyResponse> hideChannel(
String channelId,
String channelType, {
bool clearHistory = false,
}) async {
final response = await _client.post(
'${_getChannelUrl(channelId, channelType)}/hide',
data: {'clear_history': clearHistory},
);
return EmptyResponse.fromJson(response.data);
}
/// Removes the hidden status for the channel
Future<EmptyResponse> showChannel(
String channelId,
String channelType,
) async {
final response = await _client.post(
'${_getChannelUrl(channelId, channelType)}/show',
);
return EmptyResponse.fromJson(response.data);
}
/// Mark [channelId] of type [channelType] all messages as read
/// Optionally provide a [messageId] if you want to mark a
/// particular message as read
Future<EmptyResponse> markRead(
String channelId,
String channelType, {
String? messageId,
}) async {
final response = await _client.post(
'${_getChannelUrl(channelId, channelType)}/read',
data: {if (messageId != null) 'message_id': messageId},
);
return EmptyResponse.fromJson(response.data);
}
/// Stop watching the channel
Future<EmptyResponse> stopWatching(
String channelId,
String channelType,
) async {
final response = await _client.post(
'${_getChannelUrl(channelId, channelType)}/stop-watching',
);
return EmptyResponse.fromJson(response.data);
}
}
@@ -0,0 +1,60 @@
import 'package:stream_chat/src/core/api/responses.dart';
import 'package:stream_chat/src/core/http/stream_http_client.dart';
/// Provider used to send push notifications.
enum PushProvider {
/// Send notifications using Google's Firebase Cloud Messaging
firebase,
/// Send notifications using Apple's Push Notification service
apn
}
/// Helper extension for [PushProvider]
extension PushProviderX on PushProvider {
/// Returns the string notion for [PushProvider].
String get name => {
PushProvider.apn: 'apn',
PushProvider.firebase: 'firebase',
}[this]!;
}
/// Defines the api dedicated to device operations
class DeviceApi {
/// Initialize a new device api
DeviceApi(this._client);
final StreamHttpClient _client;
/// Add a device for Push Notifications.
Future<EmptyResponse> addDevice(
String deviceId,
PushProvider pushProvider,
) async {
final response = await _client.post(
'/devices',
data: {
'id': deviceId,
'push_provider': pushProvider.name,
},
);
return EmptyResponse.fromJson(response.data);
}
/// Gets a list of user devices.
Future<ListDevicesResponse> getDevices() async {
final response = await _client.get('/devices');
return ListDevicesResponse.fromJson(response.data);
}
/// Remove a user's device.
Future<EmptyResponse> removeDevice(
String deviceId,
) async {
final response = await _client.delete(
'/devices',
queryParameters: {'id': deviceId},
);
return EmptyResponse.fromJson(response.data);
}
}
@@ -0,0 +1,94 @@
import 'dart:convert';
import 'package:stream_chat/src/core/api/requests.dart';
import 'package:stream_chat/src/core/api/responses.dart';
import 'package:stream_chat/src/core/http/stream_http_client.dart';
import 'package:stream_chat/src/core/models/filter.dart';
import 'package:stream_chat/src/core/models/member.dart';
/// Defines the api dedicated to general operations
class GeneralApi {
/// Initialize a new general api
GeneralApi(this._client);
final StreamHttpClient _client;
/// Get all the missed events
Future<SyncResponse> sync(
List<String> cids,
DateTime lastSyncAt,
) async {
final response = await _client.post(
'/sync',
data: {
'channel_cids': cids,
'last_sync_at': lastSyncAt.toUtc().toIso8601String(),
},
);
return SyncResponse.fromJson(response.data);
}
/// A message search.
Future<SearchMessagesResponse> searchMessages(
Filter filter, {
String? query,
List<SortOption>? sort,
PaginationParams? pagination,
Filter? messageFilters,
}) async {
assert(() {
if (query == null && messageFilters == null) {
throw ArgumentError('Provide at least `query` or `messageFilters`');
}
if (query != null && messageFilters != null) {
throw ArgumentError(
"Can't provide both `query` and `messageFilters` at the same time",
);
}
return true;
}(), 'Check incoming params.');
final response = await _client.get(
'/search',
queryParameters: {
'payload': jsonEncode({
'filter_conditions': filter,
if (sort != null) 'sort': sort,
if (query != null) 'query': query,
if (messageFilters != null)
'message_filter_conditions': messageFilters,
if (pagination != null) ...pagination.toJson(),
}),
},
);
return SearchMessagesResponse.fromJson(response.data);
}
/// Query channel members
Future<QueryMembersResponse> queryMembers(
String channelType, {
Filter? filter,
String? channelId,
List<Member>? members,
List<SortOption>? sort,
PaginationParams? pagination,
}) async {
final response = await _client.get(
'/members',
queryParameters: {
'payload': jsonEncode({
'type': channelType,
if (channelId != null)
'id': channelId
else if (members != null)
'members': members,
if (sort != null) 'sort': sort,
if (filter != null) 'filter': filter,
if (pagination != null) ...pagination.toJson(),
}),
},
);
return QueryMembersResponse.fromJson(response.data);
}
}
@@ -0,0 +1,20 @@
import 'package:stream_chat/src/core/api/responses.dart';
import 'package:stream_chat/src/core/http/stream_http_client.dart';
import 'package:stream_chat/src/core/models/user.dart';
/// Defines the api dedicated to guest users operations
class GuestApi {
/// Initialize a new guest api
GuestApi(this._client);
final StreamHttpClient _client;
/// Returns the information about guest user
Future<ConnectGuestUserResponse> getGuestUser(User user) async {
final response = await _client.post(
'/guest',
data: {'user': user},
);
return ConnectGuestUserResponse.fromJson(response.data);
}
}
@@ -0,0 +1,182 @@
import 'package:stream_chat/src/core/api/requests.dart';
import 'package:stream_chat/src/core/api/responses.dart';
import 'package:stream_chat/src/core/http/stream_http_client.dart';
import 'package:stream_chat/src/core/models/message.dart';
/// Defines the api dedicated to messages operations
class MessageApi {
/// Initialize a new message api
MessageApi(this._client);
final StreamHttpClient _client;
/// Sends the [message] to the given [channelId] of given [channelType]
Future<SendMessageResponse> sendMessage(
String channelId,
String channelType,
Message message, {
bool skipPush = false,
}) async {
final response = await _client.post(
'/channels/$channelType/$channelId/message',
data: {
'message': message,
'skip_push': skipPush,
},
);
return SendMessageResponse.fromJson(response.data);
}
/// Retrieves a list of messages by [messageIDs]
/// from the given [channelId] of type [channelType]
Future<GetMessagesByIdResponse> getMessagesById(
String channelId,
String channelType,
List<String> messageIDs,
) async {
final response = await _client.get(
'/channels/$channelType/$channelId/messages',
queryParameters: {'ids': messageIDs.join(',')},
);
return GetMessagesByIdResponse.fromJson(response.data);
}
/// Get a message by [messageId]
Future<GetMessageResponse> getMessage(String messageId) async {
final response = await _client.get(
'/messages/$messageId',
);
return GetMessageResponse.fromJson(response.data);
}
/// Updates the given [message]
Future<UpdateMessageResponse> updateMessage(
Message message,
) async {
final response = await _client.post(
'/messages/${message.id}',
data: {'message': message},
);
return UpdateMessageResponse.fromJson(response.data);
}
/// Partially update the given [messageId]
/// Use [set] to define values to be set
/// Use [unset] to define values to be unset
Future<UpdateMessageResponse> partialUpdateMessage(
String messageId, {
Map<String, Object?>? set,
List<String>? unset,
}) async {
final response = await _client.put(
'/messages/$messageId',
data: {
if (set != null) 'set': set,
if (unset != null) 'unset': unset,
},
);
return UpdateMessageResponse.fromJson(response.data);
}
/// Deletes the given [messageId]
Future<EmptyResponse> deleteMessage(
String messageId,
) async {
final response = await _client.delete(
'/messages/$messageId',
);
return EmptyResponse.fromJson(response.data);
}
/// Send action for a specific [messageId]
/// of the given [channelId] of given [channelType]
Future<SendActionResponse> sendAction(
String channelId,
String channelType,
String messageId,
Map<String, Object?> formData,
) async {
final response = await _client.post(
'/messages/$messageId/action',
data: {
'id': channelId,
'type': channelType,
'form_data': formData,
'message_id': messageId,
},
);
return SendActionResponse.fromJson(response.data);
}
/// Send a [reactionType] for this [messageId]
/// Set [enforceUnique] to true to remove the existing user reaction
Future<SendReactionResponse> sendReaction(
String messageId,
String reactionType, {
Map<String, Object?> extraData = const {},
bool enforceUnique = false,
}) async {
final reaction = Map<String, Object?>.from(extraData)
..addAll({'type': reactionType});
final response = await _client.post(
'/messages/$messageId/reaction',
data: {
'reaction': reaction,
'enforce_unique': enforceUnique,
},
);
return SendReactionResponse.fromJson(response.data);
}
/// Delete a [reactionType] from this [messageId]
Future<EmptyResponse> deleteReaction(
String messageId,
String reactionType,
) async {
final response = await _client.delete(
'/messages/$messageId/reaction/$reactionType',
);
return EmptyResponse.fromJson(response.data);
}
/// Get all the reactions for a [messageId]
Future<QueryReactionsResponse> getReactions(
String messageId, {
PaginationParams? options,
}) async {
final response = await _client.get(
'/messages/$messageId/reactions',
queryParameters: {
if (options != null) ...options.toJson(),
},
);
return QueryReactionsResponse.fromJson(response.data);
}
/// Translates the [messageId] in provided [language]
Future<TranslateMessageResponse> translateMessage(
String messageId,
String language,
) async {
final response = await _client.post(
'/messages/$messageId/translate',
data: {'language': language},
);
return TranslateMessageResponse.fromJson(response.data);
}
/// Lists all the message replies for the [parentId]
Future<QueryRepliesResponse> getReplies(
String parentId, {
PaginationParams? options,
}) async {
final response = await _client.get(
'/messages/$parentId/replies',
queryParameters: {
if (options != null) ...options.toJson(),
},
);
return QueryRepliesResponse.fromJson(response.data);
}
}
@@ -0,0 +1,128 @@
import 'package:stream_chat/src/core/api/responses.dart';
import 'package:stream_chat/src/core/http/stream_http_client.dart';
/// Defines the api dedicated to moderation operations
class ModerationApi {
/// Initialize a new moderation api
ModerationApi(this._client);
final StreamHttpClient _client;
/// Mutes a user
Future<EmptyResponse> muteUser(String userId) async {
final response = await _client.post(
'/moderation/mute',
data: {'target_id': userId},
);
return EmptyResponse.fromJson(response.data);
}
/// Unmutes a user
Future<EmptyResponse> unmuteUser(String userId) async {
final response = await _client.post(
'/moderation/unmute',
data: {'target_id': userId},
);
return EmptyResponse.fromJson(response.data);
}
/// Mutes the channel
Future<EmptyResponse> muteChannel(
String channelCid, {
Duration? expiration,
}) async {
final response = await _client.post(
'/moderation/mute/channel',
data: {
'channel_cid': channelCid,
if (expiration != null) 'expiration': expiration.inMilliseconds,
},
);
return EmptyResponse.fromJson(response.data);
}
/// Unmutes the channel
Future<EmptyResponse> unmuteChannel(
String channelCid,
) async {
final response = await _client.post(
'/moderation/unmute/channel',
data: {'channel_cid': channelCid},
);
return EmptyResponse.fromJson(response.data);
}
/// Flag a message
Future<EmptyResponse> flagMessage(
String messageId,
) async {
final response = await _client.post(
'/moderation/flag',
data: {'target_message_id': messageId},
);
return EmptyResponse.fromJson(response.data);
}
/// Unflag a message
Future<EmptyResponse> unflagMessage(
String messageId,
) async {
final response = await _client.post(
'/moderation/unflag',
data: {'target_message_id': messageId},
);
return EmptyResponse.fromJson(response.data);
}
/// Flag a user
Future<EmptyResponse> flagUser(
String userId,
) async {
final response = await _client.post(
'/moderation/flag',
data: {'target_user_id': userId},
);
return EmptyResponse.fromJson(response.data);
}
/// Unflag a user
Future<EmptyResponse> unflagUser(
String userId,
) async {
final response = await _client.post(
'/moderation/unflag',
data: {'target_user_id': userId},
);
return EmptyResponse.fromJson(response.data);
}
/// Bans a user from all channels
Future<EmptyResponse> banUser(
String targetUserId, {
Map<String, Object?>? options,
}) async {
final response = await _client.post(
'/moderation/ban',
data: {
'target_user_id': targetUserId,
if (options != null) ...options,
},
);
return EmptyResponse.fromJson(response.data);
}
/// Remove global ban for a user
Future<EmptyResponse> unbanUser(
String targetUserId, {
Map<String, Object?>? options,
}) async {
final response = await _client.delete(
'/moderation/ban',
queryParameters: {
'target_user_id': targetUserId,
if (options != null) ...options,
},
);
return EmptyResponse.fromJson(response.data);
}
}
@@ -1,9 +1,10 @@
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
part 'requests.g.dart';
/// Sorting options
@JsonSerializable(createFactory: false)
@JsonSerializable(includeIfNull: false)
class SortOption<T> {
/// Creates a new SortOption instance
///
@@ -18,6 +19,10 @@ class SortOption<T> {
this.comparator,
});
/// Create a new instance from a json
factory SortOption.fromJson(Map<String, dynamic> json) =>
_$SortOptionFromJson(json);
/// Ascending order
// ignore: constant_identifier_names
static const ASC = 1;
@@ -41,8 +46,8 @@ class SortOption<T> {
}
/// Pagination options.
@JsonSerializable(createFactory: false, includeIfNull: false)
class PaginationParams {
@JsonSerializable(includeIfNull: false)
class PaginationParams extends Equatable {
/// Creates a new PaginationParams instance
///
/// For example:
@@ -62,6 +67,10 @@ class PaginationParams {
this.lessThanOrEqual,
});
/// Create a new instance from a json
factory PaginationParams.fromJson(Map<String, dynamic> json) =>
_$PaginationParamsFromJson(json);
/// The amount of items requested from the APIs.
final int limit;
@@ -106,24 +115,12 @@ class PaginationParams {
);
@override
@JsonKey(ignore: true)
int get hashCode =>
runtimeType.hashCode ^
limit.hashCode ^
offset.hashCode ^
greaterThan.hashCode ^
greaterThanOrEqual.hashCode ^
lessThan.hashCode ^
lessThanOrEqual.hashCode;
@override
bool operator ==(covariant PaginationParams other) =>
identical(this, other) ||
runtimeType == other.runtimeType &&
limit == other.limit &&
offset == other.offset &&
greaterThan == other.greaterThan &&
greaterThanOrEqual == other.greaterThanOrEqual &&
lessThan == other.lessThan &&
lessThanOrEqual == other.lessThanOrEqual;
List<Object?> get props => [
limit,
offset,
greaterThan,
greaterThanOrEqual,
lessThan,
lessThanOrEqual,
];
}
@@ -6,12 +6,30 @@ part of 'requests.dart';
// JsonSerializableGenerator
// **************************************************************************
SortOption<T> _$SortOptionFromJson<T>(Map<String, dynamic> json) {
return SortOption<T>(
json['field'] as String,
direction: json['direction'] as int,
);
}
Map<String, dynamic> _$SortOptionToJson<T>(SortOption<T> instance) =>
<String, dynamic>{
'field': instance.field,
'direction': instance.direction,
};
PaginationParams _$PaginationParamsFromJson(Map<String, dynamic> json) {
return PaginationParams(
limit: json['limit'] as int,
offset: json['offset'] as int,
greaterThan: json['id_gt'] as String?,
greaterThanOrEqual: json['id_gte'] as String?,
lessThan: json['id_lt'] as String?,
lessThanOrEqual: json['id_lte'] as String?,
);
}
Map<String, dynamic> _$PaginationParamsToJson(PaginationParams instance) {
final val = <String, dynamic>{
'limit': instance.limit,
@@ -1,14 +1,15 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:stream_chat/src/client.dart';
import 'package:stream_chat/src/models/channel_model.dart';
import 'package:stream_chat/src/models/channel_state.dart';
import 'package:stream_chat/src/models/device.dart';
import 'package:stream_chat/src/models/event.dart';
import 'package:stream_chat/src/models/member.dart';
import 'package:stream_chat/src/models/message.dart';
import 'package:stream_chat/src/models/reaction.dart';
import 'package:stream_chat/src/models/read.dart';
import 'package:stream_chat/src/models/user.dart';
import 'package:stream_chat/src/client/client.dart';
import 'package:stream_chat/src/core/error/error.dart';
import 'package:stream_chat/src/core/models/channel_model.dart';
import 'package:stream_chat/src/core/models/channel_state.dart';
import 'package:stream_chat/src/core/models/device.dart';
import 'package:stream_chat/src/core/models/event.dart';
import 'package:stream_chat/src/core/models/member.dart';
import 'package:stream_chat/src/core/models/message.dart';
import 'package:stream_chat/src/core/models/reaction.dart';
import 'package:stream_chat/src/core/models/read.dart';
import 'package:stream_chat/src/core/models/user.dart';
part 'responses.g.dart';
@@ -16,7 +17,37 @@ class _BaseResponse {
String? duration;
}
/// Model response for [StreamChatClient.resync] api call
/// Model response for [StreamChatNetworkError] data
@JsonSerializable()
class ErrorResponse extends _BaseResponse {
/// The http error code
int? code;
/// The message associated to the error code
String? message;
/// The backend error code
@JsonKey(name: 'StatusCode')
int? statusCode;
/// A detailed message about the error
String? moreInfo;
/// Create a new instance from a json
static ErrorResponse fromJson(Map<String, dynamic> json) =>
_$ErrorResponseFromJson(json);
/// Serialize to json
Map<String, dynamic> toJson() => _$ErrorResponseToJson(this);
@override
String toString() => 'ErrorResponse(code: $code, '
'message: $message, '
'statusCode: $statusCode, '
'moreInfo: $moreInfo)';
}
/// Model response for [StreamChatClient.sync] api call
@JsonSerializable(createToJson: false)
class SyncResponse extends _BaseResponse {
/// The list of events
@@ -6,6 +6,24 @@ part of 'responses.dart';
// JsonSerializableGenerator
// **************************************************************************
ErrorResponse _$ErrorResponseFromJson(Map<String, dynamic> json) {
return ErrorResponse()
..duration = json['duration'] as String?
..code = json['code'] as int?
..message = json['message'] as String?
..statusCode = json['StatusCode'] as int?
..moreInfo = json['more_info'] as String?;
}
Map<String, dynamic> _$ErrorResponseToJson(ErrorResponse instance) =>
<String, dynamic>{
'duration': instance.duration,
'code': instance.code,
'message': instance.message,
'StatusCode': instance.statusCode,
'more_info': instance.moreInfo,
};
SyncResponse _$SyncResponseFromJson(Map<String, dynamic> json) {
return SyncResponse()
..duration = json['duration'] as String?
@@ -0,0 +1,79 @@
import 'package:logging/logging.dart';
import 'package:stream_chat/src/core/api/attachment_file_uploader.dart';
import 'package:stream_chat/src/core/api/channel_api.dart';
import 'package:stream_chat/src/core/api/device_api.dart';
import 'package:stream_chat/src/core/api/general_api.dart';
import 'package:stream_chat/src/core/api/guest_api.dart';
import 'package:stream_chat/src/core/api/message_api.dart';
import 'package:stream_chat/src/core/api/moderation_api.dart';
import 'package:stream_chat/src/core/api/user_api.dart';
import 'package:stream_chat/src/core/http/connection_id_manager.dart';
import 'package:stream_chat/src/core/http/stream_http_client.dart';
import 'package:stream_chat/src/core/http/token_manager.dart';
export 'device_api.dart' show PushProvider;
/// ApiClient that wraps every other specific api
class StreamChatApi {
/// Initialize a new stream chat api
StreamChatApi(
String apiKey, {
StreamHttpClient? client,
StreamHttpClientOptions? options,
TokenManager? tokenManager,
ConnectionIdManager? connectionIdManager,
AttachmentFileUploader? attachmentFileUploader,
Logger? logger,
}) : _fileUploader = attachmentFileUploader,
_client = client ??
StreamHttpClient(
apiKey,
options: options,
tokenManager: tokenManager,
connectionIdManager: connectionIdManager,
logger: logger,
);
final StreamHttpClient _client;
UserApi? _user;
/// Api dedicated to users operations
UserApi get user => _user ??= UserApi(_client);
GuestApi? _guest;
/// Api dedicated to guest operations
GuestApi get guest => _guest ??= GuestApi(_client);
MessageApi? _message;
/// Api dedicated to message operations
MessageApi get message => _message ??= MessageApi(_client);
ChannelApi? _channel;
/// Api dedicated to channel operations
ChannelApi get channel => _channel ??= ChannelApi(_client);
DeviceApi? _device;
/// Api dedicated to device operations
DeviceApi get device => _device ??= DeviceApi(_client);
ModerationApi? _moderation;
/// Api dedicated to moderation operations
ModerationApi get moderation => _moderation ??= ModerationApi(_client);
GeneralApi? _general;
/// Api dedicated to general operations
GeneralApi get general => _general ??= GeneralApi(_client);
AttachmentFileUploader? _fileUploader;
/// Class responsible for uploading images and files to a given channel
AttachmentFileUploader get fileUploader =>
_fileUploader ??= StreamAttachmentFileUploader(_client);
}
@@ -0,0 +1,49 @@
import 'dart:convert';
import 'package:stream_chat/src/core/api/requests.dart';
import 'package:stream_chat/src/core/api/responses.dart';
import 'package:stream_chat/src/core/http/stream_http_client.dart';
import 'package:stream_chat/src/core/models/filter.dart';
import 'package:stream_chat/src/core/models/user.dart';
/// Defines the api dedicated to users operations
class UserApi {
/// Initialize a new user api
UserApi(this._client);
final StreamHttpClient _client;
/// Requests users with a given query.
Future<QueryUsersResponse> queryUsers({
bool presence = false,
Filter? filter,
List<SortOption>? sort,
PaginationParams? pagination,
}) async {
final response = await _client.get(
'/users',
queryParameters: {
'payload': jsonEncode({
'presence': presence,
if (sort != null) 'sort': sort,
if (filter != null) 'filter_conditions': filter,
if (pagination != null) ...pagination.toJson(),
}),
},
);
return QueryUsersResponse.fromJson(response.data);
}
/// Batch update a list of users
Future<UpdateUsersResponse> updateUsers(
List<User> users,
) async {
final response = await _client.post(
'/users',
data: {
'users': {for (final user in users) user.id: user},
},
);
return UpdateUsersResponse.fromJson(response.data);
}
}
@@ -0,0 +1,166 @@
// ignore_for_file: lines_longer_than_80_chars
import 'package:collection/collection.dart';
/// Complete list of errors that are returned by the API
/// together with the description and API code.
enum ChatErrorCode {
// Client errors
/// Unauthenticated, token not defined
undefinedToken,
// Bad Request
/// Wrong data/parameter is sent to the API
inputError,
/// Duplicate username is sent while enforce_unique_usernames is enabled
duplicateUsername,
/// Message is too long
messageTooLong,
/// Event is not supported
eventNotSupported,
/// The feature is currently disabled
/// on the dashboard (i.e. Reactions & Replies)
channelFeatureNotSupported,
/// Multiple Levels Reply is not supported
/// the API only supports 1 level deep reply threads
multipleNestling,
/// Custom Command handler returned an error
customCommandEndpointCall,
/// App config does not have custom_action_handler_url
customCommandEndpointMissing,
// Unauthorised
/// Unauthenticated, problem with authentication
authenticationError,
/// Unauthenticated, token expired
tokenExpired,
/// Unauthenticated, token date incorrect
tokenBeforeIssuedAt,
/// Unauthenticated, token not valid yet
tokenNotValid,
/// Unauthenticated, token signature invalid
tokenSignatureInvalid,
/// Access Key invalid
accessKeyError,
// Forbidden
/// Unauthorised / forbidden to make request
notAllowed,
/// App suspended
appSuspended,
/// User tried to post a message during the cooldown period
cooldownError,
// Miscellaneous
/// Resource not found
doesNotExist,
/// Request timed out
requestTimeout,
/// Payload too big
payloadTooBig,
/// Too many requests in a certain time frame
rateLimitError,
/// Request headers are too large
maximumHeaderSizeExceeded,
/// Something goes wrong in the system
internalSystemError,
/// No access to requested channels
noAccessToChannels
}
const _errorCodeWithDescription = {
ChatErrorCode.undefinedToken:
MapEntry(1000, 'Unauthorised, token not defined'),
ChatErrorCode.inputError:
MapEntry(4, 'Wrong data/parameter is sent to the API'),
ChatErrorCode.duplicateUsername: MapEntry(6,
'Duplicate username is sent while enforce_unique_usernames is enabled'),
ChatErrorCode.messageTooLong: MapEntry(20, 'Message is too long'),
ChatErrorCode.eventNotSupported: MapEntry(18, 'Event is not supported'),
ChatErrorCode.channelFeatureNotSupported: MapEntry(19,
'The feature is currently disabled on the dashboard (i.e. Reactions & Replies)'),
ChatErrorCode.multipleNestling: MapEntry(21,
'Multiple Levels Reply is not supported - the API only supports 1 level deep reply threads'),
ChatErrorCode.customCommandEndpointCall:
MapEntry(45, 'Custom Command handler returned an error'),
ChatErrorCode.customCommandEndpointMissing:
MapEntry(44, 'App config does not have custom_action_handler_url'),
ChatErrorCode.authenticationError:
MapEntry(5, 'Unauthenticated, problem with authentication'),
ChatErrorCode.tokenExpired: MapEntry(40, 'Unauthenticated, token expired'),
ChatErrorCode.tokenBeforeIssuedAt:
MapEntry(42, 'Unauthenticated, token date incorrect'),
ChatErrorCode.tokenNotValid:
MapEntry(41, 'Unauthenticated, token not valid yet'),
ChatErrorCode.tokenSignatureInvalid:
MapEntry(43, 'Unauthenticated, token signature invalid'),
ChatErrorCode.accessKeyError: MapEntry(2, 'Access Key invalid'),
ChatErrorCode.notAllowed:
MapEntry(17, 'Unauthorised / forbidden to make request'),
ChatErrorCode.appSuspended: MapEntry(99, 'App suspended'),
ChatErrorCode.cooldownError:
MapEntry(60, 'User tried to post a message during the cooldown period'),
ChatErrorCode.doesNotExist: MapEntry(16, 'Resource not found'),
ChatErrorCode.requestTimeout: MapEntry(23, 'Request timed out'),
ChatErrorCode.payloadTooBig: MapEntry(22, 'Payload too big'),
ChatErrorCode.rateLimitError:
MapEntry(9, 'Too many requests in a certain time frame'),
ChatErrorCode.maximumHeaderSizeExceeded:
MapEntry(24, 'Request headers are too large'),
ChatErrorCode.internalSystemError:
MapEntry(-1, 'Something goes wrong in the system'),
ChatErrorCode.noAccessToChannels:
MapEntry(70, 'No access to requested channels'),
};
const _authenticationErrors = [
ChatErrorCode.undefinedToken,
ChatErrorCode.authenticationError,
ChatErrorCode.tokenExpired,
ChatErrorCode.tokenBeforeIssuedAt,
ChatErrorCode.tokenNotValid,
ChatErrorCode.tokenSignatureInvalid,
ChatErrorCode.accessKeyError,
ChatErrorCode.noAccessToChannels,
];
///
ChatErrorCode? chatErrorCodeFromCode(int code) => _errorCodeWithDescription.keys
.firstWhereOrNull((key) => _errorCodeWithDescription[key]!.key == code);
///
extension ChatErrorCodeX on ChatErrorCode {
///
String get message => _errorCodeWithDescription[this]!.value;
///
int get code => _errorCodeWithDescription[this]!.key;
///
bool get isAuthenticationError => _authenticationErrors.contains(this);
}
@@ -0,0 +1,2 @@
export 'chat_error_code.dart';
export 'stream_chat_error.dart';
@@ -0,0 +1,141 @@
import 'package:equatable/equatable.dart';
import 'package:stream_chat/src/core/error/chat_error_code.dart';
import 'package:stream_chat/stream_chat.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
///
class StreamChatError with EquatableMixin implements Exception {
///
const StreamChatError(this.message);
/// Error message
final String message;
@override
List<Object?> get props => [message];
@override
String toString() => 'StreamChatError(message: $message)';
}
///
class StreamWebSocketError extends StreamChatError {
///
const StreamWebSocketError(
String message, {
this.data,
}) : super(message);
///
factory StreamWebSocketError.fromStreamError(Map<String, Object?> error) {
final data = ErrorResponse.fromJson(error);
final message = data.message ?? '';
return StreamWebSocketError(message, data: data);
}
///
factory StreamWebSocketError.fromWebSocketChannelError(
WebSocketChannelException error) {
final message = error.message ?? '';
return StreamWebSocketError(message);
}
///
int? get code => data?.code;
///
ChatErrorCode? get errorCode {
final code = this.code;
if (code == null) return null;
return chatErrorCodeFromCode(code);
}
/// Response body. please refer to [ErrorResponse].
final ErrorResponse? data;
///
bool get isRetriable => data == null;
@override
List<Object?> get props => [...super.props, code];
@override
String toString() {
var params = 'message: $message';
if (data != null) params += ', data: $data';
return 'WebSocketError($params)';
}
}
///
class StreamChatNetworkError extends StreamChatError {
///
StreamChatNetworkError(
ChatErrorCode errorCode, {
int? statusCode,
this.data,
}) : code = errorCode.code,
statusCode = statusCode ?? data?.statusCode,
super(errorCode.message);
///
StreamChatNetworkError.raw({
required this.code,
required String message,
this.statusCode,
this.data,
}) : super(message);
///
factory StreamChatNetworkError.fromDioError(DioError error) {
final response = error.response;
ErrorResponse? errorResponse;
final data = response?.data;
if (data != null) {
errorResponse = ErrorResponse.fromJson(data);
}
return StreamChatNetworkError.raw(
code: errorResponse?.code ?? -1,
message:
errorResponse?.message ?? response?.statusMessage ?? error.message,
statusCode: errorResponse?.statusCode ?? response?.statusCode,
data: errorResponse,
)..stackTrace = error.stackTrace;
}
/// Error code
final int code;
/// HTTP status code
final int? statusCode;
/// Response body. please refer to [ErrorResponse].
final ErrorResponse? data;
StackTrace? _stackTrace;
///
set stackTrace(StackTrace? stack) => _stackTrace = stack;
///
ChatErrorCode? get errorCode => chatErrorCodeFromCode(code);
///
bool get isRetriable => data == null;
@override
List<Object?> get props => [...super.props, code, statusCode];
@override
String toString({bool printStackTrace = false}) {
var params = 'code: $code, message: $message';
if (statusCode != null) params += ', statusCode: $statusCode';
if (data != null) params += ', data: $data';
var msg = 'StreamChatNetworkError($params)';
if (printStackTrace && _stackTrace != null) {
msg += '\n$_stackTrace';
}
return msg;
}
}
@@ -0,0 +1,27 @@
// ignore_for_file: use_setters_to_change_properties
/// Handles the connection id of the websocket connection
class ConnectionIdManager {
/// Initialize a new connection id manager
ConnectionIdManager({
String? connectionId,
}) : _connectionId = connectionId;
String? _connectionId;
/// Get the current connection id
String? get connectionId => _connectionId;
/// True if there is a connection id
bool get hasConnectionId => _connectionId != null;
/// Set the connection id
void setConnectionId(String connectionId) {
_connectionId = connectionId;
}
/// Clear the connection id
void reset() {
_connectionId = null;
}
}
@@ -0,0 +1,91 @@
import 'package:dio/dio.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/stream_chat_dio_error.dart';
import 'package:stream_chat/src/core/http/stream_http_client.dart';
import 'package:stream_chat/src/core/http/token.dart';
import 'package:stream_chat/src/core/http/token_manager.dart';
/// Authentication interceptor that refreshes the token if
/// an auth error is received
class AuthInterceptor extends Interceptor {
/// Initialize a new auth interceptor
AuthInterceptor(this._client, this._tokenManager);
final StreamHttpClient _client;
/// The token manager used in the client
final TokenManager _tokenManager;
@override
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
late Token token;
try {
token = await _tokenManager.loadToken();
} catch (_) {
final error = StreamChatNetworkError(ChatErrorCode.undefinedToken);
final dioError = StreamChatDioError(
error: error,
requestOptions: options,
);
return handler.reject(dioError, true);
}
final params = {'user_id': token.userId};
final headers = {
'Authorization': token.rawValue,
'stream-auth-type': token.authType.raw,
};
options..queryParameters.addAll(params)..headers.addAll(headers);
return handler.next(options);
}
@override
void onError(
DioError err,
ErrorInterceptorHandler handler,
) async {
ErrorResponse? error;
final data = err.response?.data;
if (data != null) error = ErrorResponse.fromJson(data);
if (error?.code == ChatErrorCode.tokenExpired.code) {
if (_tokenManager.isStatic) return handler.next(err);
_client.lock();
await _tokenManager.loadToken(refresh: true);
_client.unlock();
try {
final options = err.requestOptions;
final response = await _client.request(
options.path,
cancelToken: options.cancelToken,
data: options.data,
onReceiveProgress: options.onReceiveProgress,
onSendProgress: options.onSendProgress,
queryParameters: options.queryParameters,
options: Options(
method: options.method,
sendTimeout: options.sendTimeout,
receiveTimeout: options.receiveTimeout,
extra: options.extra,
headers: options.headers,
responseType: options.responseType,
contentType: options.contentType,
validateStatus: options.validateStatus,
receiveDataWhenStatusError: options.receiveDataWhenStatusError,
followRedirects: options.followRedirects,
maxRedirects: options.maxRedirects,
requestEncoder: options.requestEncoder,
responseDecoder: options.responseDecoder,
listFormat: options.listFormat,
),
);
return handler.resolve(response);
} on DioError catch (error) {
return handler.next(error);
}
}
return handler.next(err);
}
}
@@ -0,0 +1,24 @@
import 'package:dio/dio.dart';
import 'package:stream_chat/src/core/http/connection_id_manager.dart';
/// Interceptor that injects the connection id in the request params
class ConnectionIdInterceptor extends Interceptor {
///
ConnectionIdInterceptor(this.connectionIdManager);
///
final ConnectionIdManager connectionIdManager;
@override
void onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
if (connectionIdManager.hasConnectionId) {
options.queryParameters.addAll({
'connection_id': connectionIdManager.connectionId,
});
}
handler.next(options);
}
}
@@ -0,0 +1,332 @@
// ignore_for_file: lines_longer_than_80_chars
// coverage:ignore-file
import 'dart:math' as math;
import 'package:dio/dio.dart';
/// Step where we're logging
enum InterceptStep {
/// Request
request,
/// Response
response,
/// Error
error,
}
/// Function used to print the log
typedef LogPrint = void Function(InterceptStep step, Object object);
void _defaultLogPrint(InterceptStep step, Object object) => print(object);
/// Interceptor dedicated to logging
class LoggingInterceptor extends Interceptor {
/// Initialize a new logging interceptor
LoggingInterceptor({
this.request = true,
this.requestHeader = false,
this.requestBody = true,
this.responseHeader = false,
this.responseBody = true,
this.error = true,
this.maxWidth = 120,
this.compact = true,
this.logPrint = _defaultLogPrint,
});
/// Print request [Options]
final bool request;
/// Print request header [Options.headers]
final bool requestHeader;
/// Print request data [Options.data]
final bool requestBody;
/// Print [Response.data]
final bool responseBody;
/// Print [Response.headers]
final bool responseHeader;
/// Print error message
final bool error;
/// InitialTab count to logPrint json response
static const int initialTab = 1;
/// 1 tab length
static const String tabStep = ' ';
/// Print compact json response
final bool compact;
/// Width size per logPrint
final int maxWidth;
/// Log printer; defaults logPrint log to console.
/// In flutter, you'd better use debugPrint.
/// you can also write log in a file.
void Function(InterceptStep step, Object object) logPrint;
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
if (request) {
_printRequestHeader(_logPrintRequest, options);
}
if (requestHeader) {
_printMapAsTable(
_logPrintRequest,
options.queryParameters,
header: 'Query Parameters',
);
final requestHeaders = <String, Object?>{...options.headers};
requestHeaders['contentType'] = options.contentType?.toString();
requestHeaders['responseType'] = options.responseType.toString();
requestHeaders['followRedirects'] = options.followRedirects;
requestHeaders['connectTimeout'] = options.connectTimeout;
requestHeaders['receiveTimeout'] = options.receiveTimeout;
_printMapAsTable(_logPrintRequest, requestHeaders, header: 'Headers');
_printMapAsTable(_logPrintRequest, options.extra, header: 'Extras');
}
if (requestBody && options.method != 'GET') {
final dynamic data = options.data;
if (data != null) {
if (data is Map) {
_printMapAsTable(
_logPrintRequest,
options.data as Map?,
header: 'Body',
);
} else if (data is FormData) {
final formDataMap = <String, dynamic>{}
..addEntries(data.fields)
..addEntries(data.files);
_printMapAsTable(_logPrintRequest, formDataMap,
header: 'Form data | ${data.boundary}');
} else {
_printBlock(_logPrintRequest, data.toString());
}
}
}
super.onRequest(options, handler);
}
@override
void onError(DioError err, ErrorInterceptorHandler handler) {
if (error) {
if (err.type == DioErrorType.response) {
final uri = err.response?.requestOptions.uri;
_printBoxed(
_logPrintError,
header:
'DioError ║ Status: ${err.response?.statusCode} ${err.response?.statusMessage}',
text: uri.toString(),
);
if (err.response != null && err.response?.data != null) {
_logPrintError('${err.type.toString()}');
_printResponse(_logPrintError, err.response!);
}
_printLine(_logPrintError, '');
_logPrintError('');
} else {
_printBoxed(
_logPrintError,
header: 'DioError ║ ${err.type}',
text: err.message,
);
_printRequestHeader(_logPrintError, err.requestOptions);
}
}
super.onError(err, handler);
}
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
_printResponseHeader(_logPrintResponse, response);
if (responseHeader) {
final responseHeaders = <String, String>{};
response.headers
.forEach((k, list) => responseHeaders[k] = list.toString());
_printMapAsTable(_logPrintResponse, responseHeaders, header: 'Headers');
}
if (responseBody) {
_logPrintResponse('╔ Body');
_logPrintResponse('');
_printResponse(_logPrintResponse, response);
_logPrintResponse('');
_printLine(_logPrintResponse, '');
}
super.onResponse(response, handler);
}
void _printBoxed(
void Function(Object) logPrint, {
String? header,
String? text,
}) {
logPrint('');
logPrint('╔╣ $header');
logPrint('$text');
_printLine(logPrint, '');
}
void _printResponse(void Function(Object) logPrint, Response response) {
if (response.data != null) {
if (response.data is Map) {
_printPrettyMap(logPrint, response.data as Map);
} else if (response.data is List) {
logPrint('${_indent()}[');
_printList(logPrint, response.data as List);
logPrint('${_indent()}[');
} else {
_printBlock(logPrint, response.data.toString());
}
}
}
void _printResponseHeader(void Function(Object) logPrint, Response response) {
final uri = response.requestOptions.uri;
final method = response.requestOptions.method;
_printBoxed(
logPrint,
header:
'Response ║ $method ║ Status: ${response.statusCode} ${response.statusMessage}',
text: uri.toString(),
);
}
void _printRequestHeader(
void Function(Object) logPrint, RequestOptions options) {
final uri = options.uri;
final method = options.method;
_printBoxed(logPrint, header: 'Request ║ $method ', text: uri.toString());
}
void _printLine(void Function(Object) logPrint,
[String pre = '', String suf = '']) =>
logPrint('$pre${'' * maxWidth}$suf');
void _printKV(void Function(Object) logPrint, String? key, Object? v) {
final pre = '$key: ';
final msg = v.toString();
if (pre.length + msg.length > maxWidth) {
logPrint(pre);
_printBlock(logPrint, msg);
} else {
logPrint('$pre$msg');
}
}
void _printBlock(void Function(Object) logPrint, String msg) {
final lines = (msg.length / maxWidth).ceil();
for (var i = 0; i < lines; ++i) {
logPrint((i >= 0 ? '' : '') +
msg.substring(i * maxWidth,
math.min<int>(i * maxWidth + maxWidth, msg.length)));
}
}
String _indent([int tabCount = initialTab]) => tabStep * tabCount;
void _printPrettyMap(
void Function(Object) logPrint,
Map data, {
int tabs = initialTab,
bool isListItem = false,
bool isLast = false,
}) {
var _tabs = tabs;
final isRoot = _tabs == initialTab;
final initialIndent = _indent(_tabs);
_tabs++;
if (isRoot || isListItem) logPrint('$initialIndent{');
data.keys.toList().asMap().forEach((index, dynamic key) {
final isLast = index == data.length - 1;
dynamic value = data[key];
if (value is String) {
value = '"${value.toString().replaceAll(RegExp(r'(\r|\n)+'), " ")}"';
}
if (value is Map) {
if (compact) {
logPrint('${_indent(_tabs)} $key: $value${!isLast ? ',' : ''}');
} else {
logPrint('${_indent(_tabs)} $key: {');
_printPrettyMap(logPrint, value, tabs: _tabs);
}
} else if (value is List) {
if (compact) {
logPrint('${_indent(_tabs)} $key: ${value.toString()}');
} else {
logPrint('${_indent(_tabs)} $key: [');
_printList(logPrint, value, tabs: _tabs);
logPrint('${_indent(_tabs)} ]${isLast ? '' : ','}');
}
} else {
final msg = value.toString().replaceAll('\n', '');
final indent = _indent(_tabs);
final linWidth = maxWidth - indent.length;
if (msg.length + indent.length > linWidth) {
final lines = (msg.length / linWidth).ceil();
for (var i = 0; i < lines; ++i) {
logPrint('${_indent(_tabs)} ${msg.substring(
i * linWidth,
math.min<int>(i * linWidth + linWidth, msg.length),
)}');
}
} else {
logPrint('${_indent(_tabs)} $key: $msg${!isLast ? ',' : ''}');
}
}
});
logPrint('$initialIndent}${isListItem && !isLast ? ',' : ''}');
}
void _printList(
void Function(Object) logPrint,
List list, {
int tabs = initialTab,
}) {
list.asMap().forEach((i, dynamic e) {
final isLast = i == list.length - 1;
if (e is Map) {
if (compact) {
logPrint('${_indent(tabs)} $e${!isLast ? ',' : ''}');
} else {
_printPrettyMap(logPrint, e,
tabs: tabs + 1, isListItem: true, isLast: isLast);
}
} else {
logPrint('${_indent(tabs + 2)} $e${isLast ? '' : ','}');
}
});
}
void _printMapAsTable(
void Function(Object) logPrint,
Map? map, {
String? header,
}) {
if (map == null || map.isEmpty) return;
logPrint('$header ');
map.forEach((dynamic key, dynamic value) =>
_printKV(logPrint, key.toString(), value));
_printLine(logPrint, '');
}
void _logPrintRequest(Object object) =>
logPrint(InterceptStep.request, object);
void _logPrintResponse(Object object) =>
logPrint(InterceptStep.response, object);
void _logPrintError(Object object) => logPrint(InterceptStep.error, object);
}
@@ -0,0 +1,21 @@
import 'package:dio/dio.dart';
import 'package:stream_chat/src/core/error/error.dart';
/// Error class specific to StreamChat and Dio
class StreamChatDioError extends DioError {
/// Initialize a stream chat dio error
StreamChatDioError({
required this.error,
required RequestOptions requestOptions,
Response? response,
DioErrorType type = DioErrorType.other,
}) : super(
error: error,
requestOptions: requestOptions,
response: response,
type: type,
);
@override
final StreamChatNetworkError error;
}
@@ -0,0 +1,282 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:logging/logging.dart';
import 'package:meta/meta.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/logging_interceptor.dart';
import 'package:stream_chat/src/core/http/stream_chat_dio_error.dart';
import 'package:stream_chat/src/core/http/token_manager.dart';
import 'package:stream_chat/src/core/error/error.dart';
import 'package:stream_chat/src/location.dart';
import 'package:stream_chat/src/core/platform_detector/platform_detector.dart';
import 'package:stream_chat/version.dart';
import 'package:stream_chat/src/core/http/connection_id_manager.dart';
part 'stream_http_client_options.dart';
/// This is where we configure the base url, headers,
/// query parameters and convenient methods for http verbs with error parsing.
class StreamHttpClient {
/// [StreamHttpClient] constructor
StreamHttpClient(
this.apiKey, {
Dio? dio,
StreamHttpClientOptions? options,
TokenManager? tokenManager,
ConnectionIdManager? connectionIdManager,
Logger? logger,
}) : _options = options ?? const StreamHttpClientOptions(),
httpClient = dio ?? Dio() {
httpClient
..options.baseUrl = _options.baseUrl
..options.receiveTimeout = _options.receiveTimeout.inMilliseconds
..options.connectTimeout = _options.connectTimeout.inMilliseconds
..options.queryParameters = {'api_key': apiKey}
..options.headers = {
'Content-Type': 'application/json',
'X-Stream-Client': _options.userAgent,
'Content-Encoding': 'application/gzip',
}
..interceptors.addAll([
if (tokenManager != null) AuthInterceptor(this, tokenManager),
if (connectionIdManager != null)
ConnectionIdInterceptor(connectionIdManager),
if (logger != null && logger.level != Level.OFF)
LoggingInterceptor(
requestHeader: true,
logPrint: (step, message) {
switch (step) {
case InterceptStep.request:
return logger.info(message);
case InterceptStep.response:
return logger.info(message);
case InterceptStep.error:
return logger.severe(message);
}
},
),
]);
}
/// Your project Stream Chat api key.
/// Find your API keys here https://getstream.io/dashboard/
final String apiKey;
/// Your project Stream Chat ClientOptions
final StreamHttpClientOptions _options;
/// [Dio] httpClient
/// It's been chosen because it's easy to use
/// and supports interesting features out of the box
/// (Interceptors, Global configuration, FormData, File downloading etc.)
@visibleForTesting
final Dio httpClient;
/// Lock the current [StreamHttpClient] instance.
///
/// [StreamHttpClient] will enqueue the incoming request tasks instead
/// send them directly when [interceptor.requestOptions] is locked.
void lock() => httpClient.lock();
/// Unlock the current [StreamHttpClient] instance.
///
/// [StreamHttpClient] instance dequeue the request task。
void unlock() => httpClient.unlock();
/// Clear the current [StreamHttpClient] instance waiting queue.
void clear() => httpClient.clear();
/// Shuts down the [StreamHttpClient].
///
/// If [force] is `false` the [StreamHttpClient] will be kept alive
/// until all active connections are done. If [force] is `true` any active
/// connections will be closed to immediately release all resources. These
/// closed connections will receive an error event to indicate that the client
/// was shut down. In both cases trying to establish a new connection after
/// calling [close] will throw an exception.
void close({bool force = false}) => httpClient.close(force: force);
StreamChatNetworkError _parseError(DioError err) {
StreamChatNetworkError error;
// locally thrown dio error
if (err is StreamChatDioError) {
error = err.error;
} else {
// real network request dio error
error = StreamChatNetworkError.fromDioError(err);
}
return error..stackTrace = err.stackTrace;
}
/// Handy method to make http GET request with error parsing.
Future<Response<T>> get<T>(
String path, {
Map<String, Object?>? queryParameters,
Map<String, Object?>? headers,
ProgressCallback? onReceiveProgress,
CancelToken? cancelToken,
}) async {
try {
final response = await httpClient.get<T>(
path,
queryParameters: queryParameters,
options: Options(headers: headers),
onReceiveProgress: onReceiveProgress,
cancelToken: cancelToken,
);
return response;
} on DioError catch (error) {
throw _parseError(error);
}
}
/// Handy method to make http POST request with error parsing.
Future<Response<T>> post<T>(
String path, {
Object? data,
Map<String, Object?>? queryParameters,
Map<String, Object?>? headers,
ProgressCallback? onSendProgress,
ProgressCallback? onReceiveProgress,
CancelToken? cancelToken,
}) async {
try {
final response = await httpClient.post<T>(
path,
queryParameters: queryParameters,
data: data,
options: Options(headers: headers),
onSendProgress: onSendProgress,
onReceiveProgress: onReceiveProgress,
cancelToken: cancelToken,
);
return response;
} on DioError catch (error) {
throw _parseError(error);
}
}
/// Handy method to make http DELETE request with error parsing.
Future<Response<T>> delete<T>(
String path, {
Map<String, Object?>? queryParameters,
Map<String, Object?>? headers,
CancelToken? cancelToken,
}) async {
try {
final response = await httpClient.delete<T>(
path,
queryParameters: queryParameters,
options: Options(headers: headers),
cancelToken: cancelToken,
);
return response;
} on DioError catch (error) {
throw _parseError(error);
}
}
/// Handy method to make http PATCH request with error parsing.
Future<Response<T>> patch<T>(
String path, {
Object? data,
Map<String, Object?>? queryParameters,
Map<String, Object?>? headers,
ProgressCallback? onSendProgress,
ProgressCallback? onReceiveProgress,
CancelToken? cancelToken,
}) async {
try {
final response = await httpClient.patch<T>(
path,
queryParameters: queryParameters,
data: data,
options: Options(headers: headers),
onSendProgress: onSendProgress,
onReceiveProgress: onReceiveProgress,
cancelToken: cancelToken,
);
return response;
} on DioError catch (error) {
throw _parseError(error);
}
}
/// Handy method to make http PUT request with error parsing.
Future<Response<T>> put<T>(
String path, {
Object? data,
Map<String, Object?>? queryParameters,
Map<String, Object?>? headers,
ProgressCallback? onSendProgress,
ProgressCallback? onReceiveProgress,
CancelToken? cancelToken,
}) async {
try {
final response = await httpClient.put<T>(
path,
queryParameters: queryParameters,
data: data,
options: Options(headers: headers),
onSendProgress: onSendProgress,
onReceiveProgress: onReceiveProgress,
cancelToken: cancelToken,
);
return response;
} on DioError catch (error) {
throw _parseError(error);
}
}
/// Handy method to post files with error parsing.
Future<Response<T>> postFile<T>(
String path,
MultipartFile file, {
Map<String, Object?>? queryParameters,
Map<String, Object?>? headers,
ProgressCallback? onSendProgress,
ProgressCallback? onReceiveProgress,
CancelToken? cancelToken,
}) async {
final formData = FormData.fromMap({'file': file});
final response = await post<T>(
path,
data: formData,
queryParameters: queryParameters,
headers: headers,
onSendProgress: onSendProgress,
onReceiveProgress: onReceiveProgress,
cancelToken: cancelToken,
);
return response;
}
/// Handy method to make generic http request with error parsing.
Future<Response<T>> request<T>(
String path, {
Object? data,
Map<String, Object?>? queryParameters,
Options? options,
ProgressCallback? onSendProgress,
ProgressCallback? onReceiveProgress,
CancelToken? cancelToken,
}) async {
try {
final response = await httpClient.request<T>(
path,
data: data,
queryParameters: queryParameters,
options: options,
onSendProgress: onSendProgress,
onReceiveProgress: onReceiveProgress,
cancelToken: cancelToken,
);
return response;
} on DioError catch (error) {
throw _parseError(error);
}
}
}
@@ -0,0 +1,39 @@
part of 'stream_http_client.dart';
const _defaultBaseURL = 'https://chat-us-east-1.stream-io-api.com';
/// Client options to modify [StreamHttpClient]
class StreamHttpClientOptions {
/// Instantiates a new [StreamHttpClientOptions]
const StreamHttpClientOptions({
String? baseUrl,
this.location,
this.connectTimeout = const Duration(seconds: 6),
this.receiveTimeout = const Duration(seconds: 6),
}) : _baseUrl = baseUrl ?? _defaultBaseURL;
final String _baseUrl;
/// base url to use with client.
String get baseUrl {
if (location == null) return _baseUrl;
const serviceName = 'chat';
final locationName = location!.name;
const baseDomainName = 'stream-io-api.com';
return 'https://$serviceName-proxy-$locationName.$baseDomainName';
}
/// data center to use with client
final Location? location;
/// connect timeout, default to 6s
final Duration connectTimeout;
/// received timeout, default to 6s
final Duration receiveTimeout;
/// Get the current user agent
String get userAgent => 'stream-chat-dart-client-'
'${CurrentPlatform.name}-'
'${PACKAGE_VERSION.split('+')[0]}';
}
@@ -0,0 +1,86 @@
import 'dart:convert';
import 'package:equatable/equatable.dart';
import 'package:jose/jose.dart';
import 'package:stream_chat/src/core/models/user.dart';
import 'package:stream_chat/src/core/util/utils.dart';
/// A function which can be used to request a Stream Chat API token from your
/// own backend server
typedef GuestTokenProvider = Future<String> Function(User user);
/// Authentication type
enum AuthType {
/// JWT token
jwt,
/// Anonymous user
anonymous,
}
/// Extension for returning the AuthType as a string
extension AuthTypeX on AuthType {
/// Returns the AuthType as a string
String get raw => {
AuthType.jwt: 'jwt',
AuthType.anonymous: 'anonymous',
}[this]!;
}
/// Token designed to store the JWT and the user it is related to.
class Token extends Equatable {
const Token._({
required this.rawValue,
required this.userId,
required this.authType,
});
/// The token that can be used when user is unknown.
/// Is used by `anonymous` token provider.
factory Token.anonymous({String? userId}) => Token._(
rawValue: '',
userId: userId ?? randomId(),
authType: AuthType.anonymous,
);
/// Creates a [Token] instance from the provided [rawValue] if it's valid.
factory Token.fromRawValue(String rawValue) {
final jwtBody = JsonWebToken.unverified(rawValue);
final userId = jwtBody.claims.getTyped<String>('user_id');
assert(
userId != null,
'Invalid `token`, It should contain `user_id`',
);
return Token._(rawValue: rawValue, userId: userId!, authType: AuthType.jwt);
}
/// The token which can be used during the development.
/// Is used by `development(userId:)` token provider.
factory Token.development(String userId) {
const devSignature = 'devtoken';
const header = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9';
final payload = json.encode({'user_id': userId});
final payloadBytes = utf8.encode(payload);
final payloadB64 = base64.encode(payloadBytes);
final jwt = '$header.$payloadB64.$devSignature';
return Token._(rawValue: jwt, userId: userId, authType: AuthType.jwt);
}
/// The token which designed to be used for guest users.
static Future<Token> guest(User user, GuestTokenProvider provider) async {
final rawToken = await provider(user);
return Token.fromRawValue(rawToken);
}
/// Authentication type of this token
final AuthType authType;
/// String value of the token
final String rawValue;
/// User id associated with this token
final String userId;
@override
List<Object?> get props => [authType, rawValue, userId];
}
@@ -0,0 +1,81 @@
import 'package:stream_chat/src/core/http/token.dart';
/// A function which can be used to request a Stream Chat API token from your
/// own backend server.
/// Function requires a single [userId].
typedef TokenProvider = Future<String> Function(String userId);
/// Handles common token operations
class TokenManager {
/// Initialize a new token manager
TokenManager({
String? userId,
Token? token,
TokenProvider? tokenProvider,
}) : _userId = userId,
_token = token,
_provider = tokenProvider;
String? _type;
Token? _token;
TokenProvider? _provider;
String? _userId;
/// User id to which this TokenManager is configured to
String? get userId => _userId;
/// True if it's a static token
bool get isStatic => _type == 'static';
/// Set a token or a token provider
Future<Token> setTokenOrProvider(
String userId, {
Token? token,
TokenProvider? provider,
}) async {
assert(() {
if (token == null && provider == null) {
throw AssertionError('Provide at-least token or provider');
}
if (token != null && provider != null) {
throw AssertionError("Can't set both token and provider");
}
return true;
}(), '');
_userId = userId;
if (token != null) {
_type = 'static';
_token = token;
}
if (provider != null) {
_type = 'provider';
_provider = provider;
}
return loadToken();
}
/// Returns the token refreshing the existing one if [refresh] is true
Future<Token> loadToken({bool refresh = false}) async {
assert(
_userId != null && _type != null,
'Please call `setTokenOrProvider` before calling `loadToken`',
);
if (refresh || _token == null) {
final rawValue = await _provider!(_userId!);
_token = Token.fromRawValue(rawValue);
}
return _token!;
}
/// Resets the token manager
void reset() {
_userId = null;
_token = null;
_provider = null;
}
}
@@ -2,9 +2,9 @@
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:stream_chat/src/models/action.dart';
import 'package:stream_chat/src/models/attachment_file.dart';
import 'package:stream_chat/src/models/serialization.dart';
import 'package:stream_chat/src/core/models/action.dart';
import 'package:stream_chat/src/core/models/attachment_file.dart';
import 'package:stream_chat/src/core/util/serializer.dart';
import 'package:uuid/uuid.dart';
part 'attachment.g.dart';
@@ -49,11 +49,11 @@ class Attachment extends Equatable {
/// Create a new instance from a json
factory Attachment.fromJson(Map<String, dynamic> json) =>
_$AttachmentFromJson(
Serialization.moveToExtraDataFromRoot(json, topLevelFields));
Serializer.moveToExtraDataFromRoot(json, topLevelFields));
/// Create a new instance from a db data
factory Attachment.fromData(Map<String, dynamic> json) =>
_$AttachmentFromJson(Serialization.moveToExtraDataFromRoot(
_$AttachmentFromJson(Serializer.moveToExtraDataFromRoot(
json, topLevelFields + dbSpecificTopLevelFields));
///The attachment type based on the URL resource. This can be: audio,
@@ -122,7 +122,7 @@ class Attachment extends Equatable {
final String id;
/// Known top level fields.
/// Useful for [Serialization] methods.
/// Useful for [Serializer] methods.
static const topLevelFields = [
'type',
'title_link',
@@ -145,7 +145,7 @@ class Attachment extends Equatable {
];
/// Known db specific top level fields.
/// Useful for [Serialization] methods.
/// Useful for [Serializer] methods.
static const dbSpecificTopLevelFields = [
'id',
'upload_state',
@@ -154,12 +154,12 @@ class Attachment extends Equatable {
/// Serialize to json
Map<String, dynamic> toJson() =>
Serialization.moveFromExtraDataToRoot(_$AttachmentToJson(this))
Serializer.moveFromExtraDataToRoot(_$AttachmentToJson(this))
..removeWhere((key, value) => dbSpecificTopLevelFields.contains(key));
/// Serialize to db data
Map<String, dynamic> toData() =>
Serialization.moveFromExtraDataToRoot(_$AttachmentToJson(this));
Serializer.moveFromExtraDataToRoot(_$AttachmentToJson(this));
Attachment copyWith({
String? id,
@@ -1,9 +1,13 @@
import 'dart:typed_data';
import 'package:dio/dio.dart' show MultipartFile;
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:meta/meta.dart';
import 'package:stream_chat/src/core/platform_detector/platform_detector.dart';
import 'package:stream_chat/src/core/util/extension.dart';
part 'attachment_file.freezed.dart';
part 'attachment_file.g.dart';
/// Union class to hold various [UploadState] of a attachment.
@@ -58,14 +62,18 @@ String? _toString(Uint8List? bytes) {
@JsonSerializable()
class AttachmentFile {
/// Creates a new [AttachmentFile] instance.
const AttachmentFile({
AttachmentFile({
required this.size,
this.path,
this.name,
this.bytes,
}) : assert(
}) : assert(
path != null || bytes != null,
'Either path or bytes should be != null',
),
assert(
!CurrentPlatform.isWeb || bytes != null,
'File by path is not supported in web, Please provide bytes',
);
/// Create a new instance from a json
@@ -95,4 +103,27 @@ class AttachmentFile {
/// Serialize to json
Map<String, dynamic> toJson() => _$AttachmentFileToJson(this);
/// Converts this into a [MultipartFile]
Future<MultipartFile> toMultipartFile() async {
final filename = path?.split('/').last ?? name;
final mimeType = filename?.mimeType;
late MultipartFile multiPartFile;
if (CurrentPlatform.isWeb) {
multiPartFile = MultipartFile.fromBytes(
bytes!,
filename: filename,
contentType: mimeType,
);
} else {
multiPartFile = await MultipartFile.fromFile(
path!,
filename: filename,
contentType: mimeType,
);
}
return multiPartFile;
}
}
@@ -1,5 +1,5 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:stream_chat/src/models/command.dart';
import 'package:stream_chat/src/core/models/command.dart';
part 'channel_config.g.dart';
@@ -1,7 +1,7 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:stream_chat/src/models/channel_config.dart';
import 'package:stream_chat/src/models/serialization.dart';
import 'package:stream_chat/src/models/user.dart';
import 'package:stream_chat/src/core/models/channel_config.dart';
import 'package:stream_chat/src/core/util/serializer.dart';
import 'package:stream_chat/src/core/models/user.dart';
part 'channel_model.g.dart';
@@ -37,7 +37,7 @@ class ChannelModel {
/// Create a new instance from a json
factory ChannelModel.fromJson(Map<String, dynamic> json) =>
_$ChannelModelFromJson(
Serialization.moveToExtraDataFromRoot(json, topLevelFields));
Serializer.moveToExtraDataFromRoot(json, topLevelFields));
/// The id of this channel
final String id;
@@ -46,15 +46,15 @@ class ChannelModel {
final String type;
/// The cid of this channel
@JsonKey(includeIfNull: false, toJson: Serialization.readOnly)
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final String cid;
/// The channel configuration data
@JsonKey(includeIfNull: false, toJson: Serialization.readOnly)
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final ChannelConfig config;
/// The user that created this channel
@JsonKey(includeIfNull: false, toJson: Serialization.readOnly)
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final User? createdBy;
/// True if this channel is frozen
@@ -62,24 +62,23 @@ class ChannelModel {
final bool frozen;
/// The date of the last message
@JsonKey(includeIfNull: false, toJson: Serialization.readOnly)
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final DateTime? lastMessageAt;
/// The date of channel creation
@JsonKey(includeIfNull: false, toJson: Serialization.readOnly)
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final DateTime createdAt;
/// The date of the last channel update
@JsonKey(includeIfNull: false, toJson: Serialization.readOnly)
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final DateTime updatedAt;
/// The date of channel deletion
@JsonKey(includeIfNull: false, toJson: Serialization.readOnly)
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final DateTime? deletedAt;
/// The count of this channel members
@JsonKey(
includeIfNull: false, toJson: Serialization.readOnly, defaultValue: 0)
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly, defaultValue: 0)
final int memberCount;
/// Map of custom channel extraData
@@ -90,11 +89,11 @@ class ChannelModel {
final Map<String, Object?> extraData;
/// The team the channel belongs to
@JsonKey(includeIfNull: false, toJson: Serialization.readOnly)
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final String? team;
/// Known top level fields.
/// Useful for [Serialization] methods.
/// Useful for [Serializer] methods.
static const topLevelFields = [
'id',
'type',
@@ -115,7 +114,7 @@ class ChannelModel {
extraData.containsKey('name') ? extraData['name']! as String : cid;
/// Serialize to json
Map<String, dynamic> toJson() => Serialization.moveFromExtraDataToRoot(
Map<String, dynamic> toJson() => Serializer.moveFromExtraDataToRoot(
_$ChannelModelToJson(this),
);
@@ -1,9 +1,9 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:stream_chat/src/models/channel_model.dart';
import 'package:stream_chat/src/models/member.dart';
import 'package:stream_chat/src/models/message.dart';
import 'package:stream_chat/src/models/read.dart';
import 'package:stream_chat/src/models/user.dart';
import 'package:stream_chat/src/core/models/channel_model.dart';
import 'package:stream_chat/src/core/models/member.dart';
import 'package:stream_chat/src/core/models/message.dart';
import 'package:stream_chat/src/core/models/read.dart';
import 'package:stream_chat/src/core/models/user.dart';
part 'channel_state.g.dart';
@@ -1,7 +1,7 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:stream_chat/src/models/channel_model.dart';
import 'package:stream_chat/src/models/message.dart';
import 'package:stream_chat/src/models/serialization.dart';
import 'package:stream_chat/src/core/models/channel_model.dart';
import 'package:stream_chat/src/core/models/message.dart';
import 'package:stream_chat/src/core/util/serializer.dart';
import 'package:stream_chat/stream_chat.dart';
part 'event.g.dart';
@@ -10,11 +10,11 @@ part 'event.g.dart';
@JsonSerializable()
class Event {
/// Constructor used for json serialization
const Event({
this.type,
Event({
this.type = 'local.event',
this.cid,
this.connectionId,
this.createdAt,
DateTime? createdAt,
this.me,
this.user,
this.message,
@@ -29,18 +29,18 @@ class Event {
this.parentId,
this.extraData = const {},
this.isLocal = true,
});
}) : createdAt = createdAt?.toUtc() ?? DateTime.now().toUtc();
/// Create a new instance from a json
factory Event.fromJson(Map<String, dynamic> json) =>
_$EventFromJson(Serialization.moveToExtraDataFromRoot(
_$EventFromJson(Serializer.moveToExtraDataFromRoot(
json,
topLevelFields,
));
/// The type of the event
/// [EventType] contains some predefined constant types
final String? type;
final String type;
/// The channel cid to which the event belongs
final String? cid;
@@ -55,7 +55,7 @@ class Event {
final String? connectionId;
/// The date of creation of the event
final DateTime? createdAt;
final DateTime createdAt;
/// User object of the health check user
final OwnUser? me;
@@ -96,7 +96,7 @@ class Event {
final Map<String, Object?> extraData;
/// Known top level fields.
/// Useful for [Serialization] methods.
/// Useful for [Serializer] methods.
static final topLevelFields = [
'type',
'cid',
@@ -118,7 +118,7 @@ class Event {
];
/// Serialize to json
Map<String, dynamic> toJson() => Serialization.moveFromExtraDataToRoot(
Map<String, dynamic> toJson() => Serializer.moveFromExtraDataToRoot(
_$EventToJson(this),
);
@@ -160,11 +160,14 @@ class Event {
channelType: channelType ?? this.channelType,
parentId: parentId ?? this.parentId,
extraData: extraData ?? this.extraData,
isLocal: isLocal,
);
}
/// The channel embedded in the event object
@JsonSerializable()
@JsonSerializable(
createToJson: false,
)
class EventChannel extends ChannelModel {
/// Constructor used for json serialization
EventChannel({
@@ -198,7 +201,7 @@ class EventChannel extends ChannelModel {
/// Create a new instance from a json
factory EventChannel.fromJson(Map<String, dynamic> json) =>
_$EventChannelFromJson(Serialization.moveToExtraDataFromRoot(
_$EventChannelFromJson(Serializer.moveToExtraDataFromRoot(
json,
topLevelFields,
));
@@ -207,15 +210,9 @@ class EventChannel extends ChannelModel {
final List<Member>? members;
/// Known top level fields.
/// Useful for [Serialization] methods.
/// Useful for [Serializer] methods.
static final topLevelFields = [
'members',
...ChannelModel.topLevelFields,
];
/// Serialize to json
@override
Map<String, dynamic> toJson() => Serialization.moveFromExtraDataToRoot(
_$EventChannelToJson(this),
);
}
@@ -8,7 +8,7 @@ part of 'event.dart';
Event _$EventFromJson(Map<String, dynamic> json) {
return Event(
type: json['type'] as String?,
type: json['type'] as String,
cid: json['cid'] as String?,
connectionId: json['connection_id'] as String?,
createdAt: json['created_at'] == null
@@ -49,7 +49,7 @@ Map<String, dynamic> _$EventToJson(Event instance) => <String, dynamic>{
'channel_id': instance.channelId,
'channel_type': instance.channelType,
'connection_id': instance.connectionId,
'created_at': instance.createdAt?.toIso8601String(),
'created_at': instance.createdAt.toIso8601String(),
'me': instance.me?.toJson(),
'user': instance.user?.toJson(),
'message': instance.message?.toJson(),
@@ -89,29 +89,3 @@ EventChannel _$EventChannelFromJson(Map<String, dynamic> json) {
extraData: json['extra_data'] as Map<String, dynamic>? ?? {},
);
}
Map<String, dynamic> _$EventChannelToJson(EventChannel instance) {
final val = <String, dynamic>{
'id': instance.id,
'type': instance.type,
};
void writeNotNull(String key, dynamic value) {
if (value != null) {
val[key] = value;
}
}
writeNotNull('cid', readonly(instance.cid));
writeNotNull('config', readonly(instance.config));
writeNotNull('created_by', readonly(instance.createdBy));
val['frozen'] = instance.frozen;
writeNotNull('last_message_at', readonly(instance.lastMessageAt));
writeNotNull('created_at', readonly(instance.createdAt));
writeNotNull('updated_at', readonly(instance.updatedAt));
writeNotNull('deleted_at', readonly(instance.deletedAt));
writeNotNull('member_count', readonly(instance.memberCount));
val['extra_data'] = instance.extraData;
val['members'] = instance.members?.map((e) => e.toJson()).toList();
return val;
}
@@ -1,6 +1,6 @@
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:stream_chat/src/models/user.dart';
import 'package:stream_chat/src/core/models/user.dart';
part 'member.g.dart';
@@ -1,9 +1,9 @@
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:stream_chat/src/models/attachment.dart';
import 'package:stream_chat/src/models/reaction.dart';
import 'package:stream_chat/src/models/serialization.dart';
import 'package:stream_chat/src/models/user.dart';
import 'package:stream_chat/src/core/models/attachment.dart';
import 'package:stream_chat/src/core/models/reaction.dart';
import 'package:stream_chat/src/core/util/serializer.dart';
import 'package:stream_chat/src/core/models/user.dart';
import 'package:uuid/uuid.dart';
part 'message.g.dart';
@@ -80,7 +80,7 @@ class Message extends Equatable {
/// Create a new instance from a json
factory Message.fromJson(Map<String, dynamic> json) => _$MessageFromJson(
Serialization.moveToExtraDataFromRoot(json, topLevelFields));
Serializer.moveToExtraDataFromRoot(json, topLevelFields));
/// The message ID. This is either created by Stream or set client side when
/// the message is added.
@@ -96,7 +96,7 @@ class Message extends Equatable {
/// The message type
@JsonKey(
includeIfNull: false,
toJson: Serialization.readOnly,
toJson: Serializer.readOnly,
defaultValue: 'regular',
)
final String type;
@@ -111,43 +111,43 @@ class Message extends Equatable {
/// The list of user mentioned in the message
@JsonKey(
toJson: Serialization.userIds,
toJson: User.toIds,
defaultValue: [],
)
final List<User> mentionedUsers;
/// A map describing the count of number of every reaction
@JsonKey(includeIfNull: false, toJson: Serialization.readOnly)
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final Map<String, int>? reactionCounts;
/// A map describing the count of score of every reaction
@JsonKey(includeIfNull: false, toJson: Serialization.readOnly)
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final Map<String, int>? reactionScores;
/// The latest reactions to the message created by any user.
@JsonKey(includeIfNull: false, toJson: Serialization.readOnly)
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final List<Reaction>? latestReactions;
/// The reactions added to the message by the current user.
@JsonKey(includeIfNull: false, toJson: Serialization.readOnly)
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final List<Reaction>? ownReactions;
/// The ID of the parent message, if the message is a thread reply.
final String? parentId;
/// A quoted reply message
@JsonKey(toJson: Serialization.readOnly)
@JsonKey(toJson: Serializer.readOnly)
final Message? quotedMessage;
/// The ID of the quoted message, if the message is a quoted reply.
final String? quotedMessageId;
/// Reserved field indicating the number of replies for this message.
@JsonKey(includeIfNull: false, toJson: Serialization.readOnly)
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final int? replyCount;
/// Reserved field indicating the thread participants for this message.
@JsonKey(includeIfNull: false, toJson: Serialization.readOnly)
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final List<User>? threadParticipants;
/// Check if this message needs to show in the channel.
@@ -160,25 +160,25 @@ class Message extends Equatable {
/// If true the message is shadowed
@JsonKey(
includeIfNull: false,
toJson: Serialization.readOnly,
toJson: Serializer.readOnly,
defaultValue: false,
)
final bool shadowed;
/// A used command name.
@JsonKey(includeIfNull: false, toJson: Serialization.readOnly)
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final String? command;
/// Reserved field indicating when the message was created.
@JsonKey(includeIfNull: false, toJson: Serialization.readOnly)
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final DateTime createdAt;
/// Reserved field indicating when the message was updated last time.
@JsonKey(includeIfNull: false, toJson: Serialization.readOnly)
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final DateTime updatedAt;
/// User who sent the message
@JsonKey(includeIfNull: false, toJson: Serialization.readOnly)
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final User? user;
/// If true the message is pinned
@@ -186,7 +186,7 @@ class Message extends Equatable {
final bool pinned;
/// Reserved field indicating when the message was pinned
@JsonKey(toJson: Serialization.readOnly)
@JsonKey(toJson: Serializer.readOnly)
final DateTime? pinnedAt;
/// Reserved field indicating when the message will expire
@@ -195,7 +195,7 @@ class Message extends Equatable {
final DateTime? pinExpires;
/// Reserved field indicating who pinned the message
@JsonKey(toJson: Serialization.readOnly)
@JsonKey(toJson: Serializer.readOnly)
final User? pinnedBy;
/// Message custom extraData
@@ -215,11 +215,11 @@ class Message extends Equatable {
bool get isEphemeral => type == 'ephemeral';
/// Reserved field indicating when the message was deleted.
@JsonKey(includeIfNull: false, toJson: Serialization.readOnly)
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final DateTime? deletedAt;
/// Known top level fields.
/// Useful for [Serialization] methods.
/// Useful for [Serializer] methods.
static const topLevelFields = [
'id',
'text',
@@ -251,7 +251,7 @@ class Message extends Equatable {
];
/// Serialize to json
Map<String, dynamic> toJson() => Serialization.moveFromExtraDataToRoot(
Map<String, dynamic> toJson() => Serializer.moveFromExtraDataToRoot(
_$MessageToJson(this),
);
@@ -403,14 +403,14 @@ class TranslatedMessage extends Message {
/// Create a new instance from a json
factory TranslatedMessage.fromJson(Map<String, dynamic> json) =>
_$TranslatedMessageFromJson(
Serialization.moveToExtraDataFromRoot(json, topLevelFields),
Serializer.moveToExtraDataFromRoot(json, topLevelFields),
);
/// A Map of
final Map<String, String>? i18n;
/// Known top level fields.
/// Useful for [Serialization] methods.
/// Useful for [Serializer] methods.
static final topLevelFields = [
'i18n',
...Message.topLevelFields,
@@ -418,7 +418,7 @@ class TranslatedMessage extends Message {
/// Serialize to json
@override
Map<String, dynamic> toJson() => Serialization.moveFromExtraDataToRoot(
Map<String, dynamic> toJson() => Serializer.moveFromExtraDataToRoot(
_$TranslatedMessageToJson(this),
);
}
@@ -84,7 +84,7 @@ Map<String, dynamic> _$MessageToJson(Message instance) {
writeNotNull('type', readonly(instance.type));
val['attachments'] = instance.attachments.map((e) => e.toJson()).toList();
val['mentioned_users'] = Serialization.userIds(instance.mentionedUsers);
val['mentioned_users'] = User.toIds(instance.mentionedUsers);
writeNotNull('reaction_counts', readonly(instance.reactionCounts));
writeNotNull('reaction_scores', readonly(instance.reactionScores));
writeNotNull('latest_reactions', readonly(instance.latestReactions));
@@ -1,12 +1,12 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:stream_chat/src/models/channel_model.dart';
import 'package:stream_chat/src/models/serialization.dart';
import 'package:stream_chat/src/models/user.dart';
import 'package:stream_chat/src/core/models/channel_model.dart';
import 'package:stream_chat/src/core/models/user.dart';
import 'package:stream_chat/src/core/util/serializer.dart';
part 'mute.g.dart';
/// The class that contains the information about a muted user
@JsonSerializable()
@JsonSerializable(createToJson: false)
class Mute {
/// Constructor used for json serialization
Mute({
@@ -20,21 +20,18 @@ class Mute {
factory Mute.fromJson(Map<String, dynamic> json) => _$MuteFromJson(json);
/// The user that performed the muting action
@JsonKey(includeIfNull: false, toJson: Serialization.readOnly)
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final User user;
/// The target user
@JsonKey(includeIfNull: false, toJson: Serialization.readOnly)
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final ChannelModel channel;
/// The date in which the use was muted
@JsonKey(includeIfNull: false, toJson: Serialization.readOnly)
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final DateTime createdAt;
/// The date of the last update
@JsonKey(includeIfNull: false, toJson: Serialization.readOnly)
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final DateTime updatedAt;
/// Serialize to json
Map<String, dynamic> toJson() => _$MuteToJson(this);
}
@@ -14,19 +14,3 @@ Mute _$MuteFromJson(Map<String, dynamic> json) {
updatedAt: DateTime.parse(json['updated_at'] as String),
);
}
Map<String, dynamic> _$MuteToJson(Mute instance) {
final val = <String, dynamic>{};
void writeNotNull(String key, dynamic value) {
if (value != null) {
val[key] = value;
}
}
writeNotNull('user', readonly(instance.user));
writeNotNull('channel', readonly(instance.channel));
writeNotNull('created_at', readonly(instance.createdAt));
writeNotNull('updated_at', readonly(instance.updatedAt));
return val;
}
@@ -1,14 +1,14 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:stream_chat/src/models/device.dart';
import 'package:stream_chat/src/models/mute.dart';
import 'package:stream_chat/src/models/serialization.dart';
import 'package:stream_chat/src/models/user.dart';
import 'package:stream_chat/src/core/models/device.dart';
import 'package:stream_chat/src/core/models/mute.dart';
import 'package:stream_chat/src/core/models/user.dart';
import 'package:stream_chat/src/core/util/serializer.dart';
part 'own_user.g.dart';
/// The class that defines the own user model
/// This object can be found in [Event]
@JsonSerializable()
@JsonSerializable(createToJson: false)
class OwnUser extends User {
/// Constructor used for json serialization
OwnUser({
@@ -38,40 +38,42 @@ class OwnUser extends User {
/// Create a new instance from a json
factory OwnUser.fromJson(Map<String, dynamic> json) => _$OwnUserFromJson(
Serialization.moveToExtraDataFromRoot(json, topLevelFields));
Serializer.moveToExtraDataFromRoot(json, topLevelFields));
/// Create a new instance from [User] object
factory OwnUser.fromUser(User user) => OwnUser(
id: user.id,
role: user.role,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
lastActive: user.lastActive,
online: user.online,
banned: user.banned,
extraData: user.extraData,
);
/// List of user devices
@JsonKey(
includeIfNull: false,
toJson: Serialization.readOnly,
defaultValue: <Device>[])
@JsonKey(includeIfNull: false, defaultValue: <Device>[])
final List<Device> devices;
/// List of users muted by the user
@JsonKey(
includeIfNull: false,
toJson: Serialization.readOnly,
defaultValue: <Mute>[])
@JsonKey(includeIfNull: false, defaultValue: <Mute>[])
final List<Mute> mutes;
/// List of users muted by the user
@JsonKey(
includeIfNull: false,
toJson: Serialization.readOnly,
defaultValue: <Mute>[])
@JsonKey(includeIfNull: false, defaultValue: <Mute>[])
final List<Mute> channelMutes;
/// Total unread messages by the user
@JsonKey(
includeIfNull: false, toJson: Serialization.readOnly, defaultValue: 0)
@JsonKey(includeIfNull: false, defaultValue: 0)
final int totalUnreadCount;
/// Total unread channels by the user
@JsonKey(includeIfNull: false, toJson: Serialization.readOnly)
@JsonKey(includeIfNull: false)
final int? unreadChannels;
/// Known top level fields.
/// Useful for [Serialization] methods.
/// Useful for [Serializer] methods.
static final topLevelFields = [
'devices',
'mutes',
@@ -80,10 +82,4 @@ class OwnUser extends User {
'channel_mutes',
...User.topLevelFields,
];
/// Serialize to json
@override
Map<String, dynamic> toJson() => Serialization.moveFromExtraDataToRoot(
_$OwnUserToJson(this),
);
}
@@ -38,29 +38,3 @@ OwnUser _$OwnUserFromJson(Map<String, dynamic> json) {
banned: json['banned'] as bool? ?? false,
);
}
Map<String, dynamic> _$OwnUserToJson(OwnUser instance) {
final val = <String, dynamic>{
'id': instance.id,
};
void writeNotNull(String key, dynamic value) {
if (value != null) {
val[key] = value;
}
}
writeNotNull('role', readonly(instance.role));
writeNotNull('created_at', readonly(instance.createdAt));
writeNotNull('updated_at', readonly(instance.updatedAt));
writeNotNull('last_active', readonly(instance.lastActive));
writeNotNull('online', readonly(instance.online));
writeNotNull('banned', readonly(instance.banned));
val['extra_data'] = instance.extraData;
writeNotNull('devices', readonly(instance.devices));
writeNotNull('mutes', readonly(instance.mutes));
writeNotNull('channel_mutes', readonly(instance.channelMutes));
writeNotNull('total_unread_count', readonly(instance.totalUnreadCount));
writeNotNull('unread_channels', readonly(instance.unreadChannels));
return val;
}
@@ -1,6 +1,6 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:stream_chat/src/models/serialization.dart';
import 'package:stream_chat/src/models/user.dart';
import 'package:stream_chat/src/core/util/serializer.dart';
import 'package:stream_chat/src/core/models/user.dart';
part 'reaction.g.dart';
@@ -21,7 +21,7 @@ class Reaction {
/// Create a new instance from a json
factory Reaction.fromJson(Map<String, dynamic> json) =>
_$ReactionFromJson(Serialization.moveToExtraDataFromRoot(
_$ReactionFromJson(Serializer.moveToExtraDataFromRoot(
json,
topLevelFields,
));
@@ -33,11 +33,11 @@ class Reaction {
final String type;
/// The date of the reaction
@JsonKey(includeIfNull: false, toJson: Serialization.readOnly)
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final DateTime createdAt;
/// The user that sent the reaction
@JsonKey(includeIfNull: false, toJson: Serialization.readOnly)
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final User? user;
/// The score of the reaction (ie. number of reactions sent)
@@ -45,7 +45,7 @@ class Reaction {
final int score;
/// The userId that sent the reaction
@JsonKey(includeIfNull: false, toJson: Serialization.readOnly)
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final String? userId;
/// Reaction custom extraData
@@ -66,7 +66,7 @@ class Reaction {
];
/// Serialize to json
Map<String, dynamic> toJson() => Serialization.moveFromExtraDataToRoot(
Map<String, dynamic> toJson() => Serializer.moveFromExtraDataToRoot(
_$ReactionToJson(this),
);
@@ -1,5 +1,5 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:stream_chat/src/models/user.dart';
import 'package:stream_chat/src/core/models/user.dart';
part 'read.g.dart';
@@ -1,6 +1,6 @@
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:stream_chat/src/models/serialization.dart';
import 'package:stream_chat/src/core/util/serializer.dart';
part 'user.g.dart';
@@ -22,11 +22,11 @@ class User extends Equatable {
updatedAt = updatedAt ?? DateTime.now();
/// Create a new instance from a json
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(
Serialization.moveToExtraDataFromRoot(json, topLevelFields));
factory User.fromJson(Map<String, dynamic> json) =>
_$UserFromJson(Serializer.moveToExtraDataFromRoot(json, topLevelFields));
/// Known top level fields.
/// Useful for [Serialization] methods.
/// Useful for [Serializer] methods.
static const topLevelFields = [
'id',
'role',
@@ -42,36 +42,36 @@ class User extends Equatable {
final String id;
/// User role
@JsonKey(includeIfNull: false, toJson: Serialization.readOnly)
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final String? role;
/// User role
@JsonKey(
includeIfNull: false,
toJson: Serialization.readOnly,
toJson: Serializer.readOnly,
defaultValue: <String>[])
final List<String> teams;
/// Date of user creation
@JsonKey(includeIfNull: false, toJson: Serialization.readOnly)
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final DateTime createdAt;
/// Date of last user update
@JsonKey(includeIfNull: false, toJson: Serialization.readOnly)
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final DateTime updatedAt;
/// Date of last user connection
@JsonKey(includeIfNull: false, toJson: Serialization.readOnly)
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final DateTime? lastActive;
/// True if user is online
@JsonKey(
includeIfNull: false, toJson: Serialization.readOnly, defaultValue: false)
includeIfNull: false, toJson: Serializer.readOnly, defaultValue: false)
final bool online;
/// True if user is banned from the chat
@JsonKey(
includeIfNull: false, toJson: Serialization.readOnly, defaultValue: false)
includeIfNull: false, toJson: Serializer.readOnly, defaultValue: false)
final bool banned;
/// Map of custom user extraData
@@ -93,13 +93,17 @@ class User extends Equatable {
return id;
}
/// List of users to list of userIds
static List<String>? toIds(List<User>? users) =>
users?.map((u) => u.id).toList();
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is User && runtimeType == other.runtimeType && id == other.id;
/// Serialize to json
Map<String, dynamic> toJson() => Serialization.moveFromExtraDataToRoot(
Map<String, dynamic> toJson() => Serializer.moveFromExtraDataToRoot(
_$UserToJson(this),
);
@@ -1,4 +1,4 @@
import 'package:stream_chat/src/platform_detector/platform_detector_stub.dart'
import 'package:stream_chat/src/core/platform_detector/platform_detector_stub.dart'
if (dart.library.html) 'platform_detector_web.dart'
if (dart.library.io) 'platform_detector_io.dart';
@@ -1,5 +1,5 @@
import 'dart:io';
import 'package:stream_chat/src/platform_detector/platform_detector.dart';
import 'package:stream_chat/src/core/platform_detector/platform_detector.dart';
/// Version running on native systems
PlatformType get currentPlatform {
@@ -1,4 +1,4 @@
import 'package:stream_chat/src/platform_detector/platform_detector.dart';
import 'package:stream_chat/src/core/platform_detector/platform_detector.dart';
/// Stub implementation
PlatformType get currentPlatform {
@@ -1,4 +1,4 @@
import 'package:stream_chat/src/platform_detector/platform_detector.dart';
import 'package:stream_chat/src/core/platform_detector/platform_detector.dart';
/// Version running on web
PlatformType get currentPlatform => PlatformType.web;
@@ -0,0 +1,33 @@
import 'package:http_parser/http_parser.dart';
import 'package:mime/mime.dart';
/// Useful extension functions for [Iterable]
extension IterableX<T> on Iterable<T?> {
/// Removes all the null values
/// and converts `Iterable<T?>` into `Iterable<T>`
Iterable<T> get withNullifyer => whereType();
}
/// Useful extension functions for [Map]
extension MapX<K, V> on Map<K?, V?> {
/// Returns a new map with null keys or values removed
Map<K, V> get nullProtected {
final nullProtected = {...this}
..removeWhere((key, value) => key == null || value == null);
return nullProtected.cast();
}
}
/// Useful extension functions for [String]
extension StringX on String {
/// returns the mime type from the passed file name.
MediaType? get mimeType {
if (toLowerCase().endsWith('heic')) {
return MediaType.parse('image/heic');
} else {
final mimeType = lookupMimeType(this);
if (mimeType == null) return null;
return MediaType.parse(mimeType);
}
}
}
@@ -1,18 +1,12 @@
import 'package:stream_chat/src/models/user.dart';
/// Used to avoid to serialize properties to json
// ignore: prefer_void_to_null
Null readonly(_) => null;
/// Helper class for serialization to and from json
class Serialization {
class Serializer {
/// Used to avoid to serialize properties to json
static const Function readOnly = readonly;
/// List of users to list of userIds
static List<String>? userIds(List<User>? users) =>
users?.map((u) => u.id).toList();
/// Takes unknown json keys and puts them in the `extra_data` key
static Map<String, dynamic> moveToExtraDataFromRoot(
Map<String, dynamic> json,
@@ -0,0 +1,25 @@
import 'dart:convert';
import 'dart:math' as math;
// This alphabet uses `A-Za-z0-9_-` symbols. The genetic algorithm helped
// optimize the gzip compression for this alphabet.
const _alphabet =
'ModuleSymbhasOwnPr-0123456789ABCDEFGHNRVfgctiUvz_KqYTJkLxpZXIjQW';
/// Generates a random String id
/// Adopted from: https://github.com/ai/nanoid/blob/main/non-secure/index.js
String randomId({int size = 21}) {
var id = '';
for (var i = 0; i < size; i++) {
id += _alphabet[(math.Random().nextDouble() * 64).floor() | 0];
}
return id;
}
/// Creates a hash string from the passed [objects]
String generateHash(List<Object?> objects) {
final payload = json.encode(objects);
final payloadBytes = utf8.encode(payload);
final payloadB64 = base64.encode(payloadBytes);
return payloadB64;
}
@@ -1,14 +1,14 @@
import 'package:stream_chat/src/api/requests.dart';
import 'package:stream_chat/src/models/channel_model.dart';
import 'package:stream_chat/src/models/channel_state.dart';
import 'package:stream_chat/src/models/event.dart';
import 'package:stream_chat/src/models/filter.dart';
import 'package:stream_chat/src/models/member.dart';
import 'package:stream_chat/src/models/message.dart';
import 'package:stream_chat/src/models/reaction.dart';
import 'package:stream_chat/src/models/read.dart';
import 'package:stream_chat/src/models/user.dart';
import 'package:stream_chat/src/extensions/iterable_extension.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/channel_state.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/member.dart';
import 'package:stream_chat/src/core/models/message.dart';
import 'package:stream_chat/src/core/models/reaction.dart';
import 'package:stream_chat/src/core/models/read.dart';
import 'package:stream_chat/src/core/models/user.dart';
import 'package:stream_chat/src/core/util/extension.dart';
/// A simple client used for persisting chat data locally.
abstract class ChatPersistenceClient {
@@ -3,6 +3,9 @@ class EventType {
/// Indicates any type of events
static const String any = '*';
///
static const String healthCheck = 'health.check';
/// Event sent when a user starts typing a message
static const String typingStart = 'typing.start';
@@ -1,53 +0,0 @@
import 'dart:convert';
/// Exception related to api calls
class ApiError extends Error {
/// Creates a new ApiError instance using the response body and status code
ApiError(this.body, this.status) : jsonData = _decode(body) {
if (jsonData != null && jsonData!.containsKey('code')) {
_code = jsonData!['code'];
}
}
/// Raw body of the response
final String? body;
/// Json parsed body
final Map<String, dynamic>? jsonData;
/// Http status code of the response
final int? status;
/// Stream specific error code
int? get code => _code;
int? _code;
static Map<String, dynamic>? _decode(String? body) {
try {
if (body == null) {
return null;
}
return json.decode(body);
} on FormatException {
return null;
}
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ApiError &&
runtimeType == other.runtimeType &&
body == other.body &&
jsonData == other.jsonData &&
status == other.status &&
_code == other._code;
@override
int get hashCode =>
body.hashCode ^ jsonData.hashCode ^ status.hashCode ^ _code.hashCode;
@override
String toString() => 'ApiError{body: $body, jsonData: $jsonData, '
'status: $status, code: $_code}';
}
@@ -1,9 +0,0 @@
/// Useful extension functions for [Iterable]
extension IterableX<T> on Iterable<T?> {
/// Removes all the null values
/// and converts `Iterable<T?>` into `Iterable<T>`
Iterable<T> get withNullifyer => [
for (final item in this)
if (item != null) item
];
}
@@ -1,6 +0,0 @@
/// Useful extension functions for [Map]
extension MapX<K, V> on Map<K, V> {
/// Returns a new map with null keys or values removed
Map<K, V> get nullProtected =>
Map.from(this)..removeWhere((key, value) => key == null || value == null);
}
@@ -1,335 +0,0 @@
// ignore_for_file: lines_longer_than_80_chars
import 'dart:async' show Timer;
import 'dart:math' as math;
/// Useful rate limiter extensions for [Function] class.
extension RateLimit on Function {
/// Converts this into a [Debounce] function.
Debounce debounced(
Duration wait, {
bool leading = false,
bool trailing = true,
Duration? maxWait,
}) =>
Debounce(
this,
wait,
leading: leading,
trailing: trailing,
maxWait: maxWait,
);
/// Converts this into a [Throttle] function.
Throttle throttled(
Duration wait, {
bool leading = true,
bool trailing = true,
}) =>
Throttle(
this,
wait,
leading: leading,
trailing: trailing,
);
}
/// TopLevel lambda to create [Debounce] functions.
Debounce debounce(
Function func,
Duration wait, {
bool leading = false,
bool trailing = true,
Duration? maxWait,
}) =>
Debounce(
func,
wait,
leading: leading,
trailing: trailing,
maxWait: maxWait,
);
/// TopLevel lambda to create [Throttle] functions.
Throttle throttle(
Function func,
Duration wait, {
bool leading = true,
bool trailing = true,
}) =>
Throttle(
func,
wait,
leading: leading,
trailing: trailing,
);
/// Creates a debounced function that delays invoking `func` until after `wait`
/// milliseconds have elapsed since the last time the debounced function was
/// invoked. The debounced function comes with a [Debounce.cancel] method to cancel
/// delayed `func` invocations and a [Debounce.flush] method to immediately invoke them.
/// Provide `leading` and/or `trailing` to indicate whether `func` should be
/// invoked on the `leading` and/or `trailing` edge of the `wait` interval.
/// The `func` is invoked with the last arguments provided to the [call]
/// function. Subsequent calls to the debounced function return the result of
/// the last `func` invocation.
///
/// **Note:** If `leading` and `trailing` options are `true`, `func` is
/// invoked on the trailing edge of the timeout only if the debounced function
/// is invoked more than once during the `wait` timeout.
///
/// If `wait` is [Duration.zero] and `leading` is `false`,
/// `func` invocation is deferred until the next tick.
///
/// See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/)
/// for details over the differences between [Debounce] and [Throttle].
///
/// Some examples:
///
/// Avoid calling costly network calls when user is typing something.
/// ```dart
/// void fetchData(String query) async {
/// final data = api.getData(query);
/// doSomethingWithTheData(data);
/// }
///
/// final debouncedFetchData = Debounce(
/// fetchData,
/// const Duration(milliseconds: 350),
/// );
///
/// void onSearchQueryChanged(query) {
/// debouncedFetchData(query);
/// }
/// ```
///
/// Cancel the trailing debounced invocation.
/// ```dart
/// void dispose() {
/// debounced.cancel();
/// }
/// ```
///
/// Check for pending invocations.
/// ```dart
/// final status = debounced.isPending ? "Pending..." : "Ready";
/// ```
class Debounce {
/// Creates a new instance of [Debounce].
Debounce(
this._func,
Duration wait, {
bool leading = false,
bool trailing = true,
Duration? maxWait,
}) : _leading = leading,
_trailing = trailing,
_wait = wait.inMilliseconds,
_maxing = maxWait != null {
if (_maxing) {
_maxWait = math.max(maxWait!.inMilliseconds, _wait);
}
}
final Function _func;
final bool _leading;
final bool _trailing;
final int _wait;
final bool _maxing;
late int _maxWait;
List<Object?>? _lastArgs;
Map<Symbol, Object>? _lastNamedArgs;
Timer? _timer;
int? _lastCallTime;
Object? _result;
int? _lastInvokeTime = 0;
Object? _invokeFunc(int? time) {
final args = _lastArgs;
final namedArgs = _lastNamedArgs;
_lastArgs = _lastNamedArgs = null;
_lastInvokeTime = time;
return _result = Function.apply(_func, args, namedArgs);
}
Timer _startTimer(Function pendingFunc, int wait) =>
Timer(Duration(milliseconds: wait), pendingFunc as void Function());
bool _shouldInvoke(int time) {
final timeSinceLastCall = time - (_lastCallTime ?? double.nan);
final timeSinceLastInvoke = time - _lastInvokeTime!;
// Either this is the first call, activity has stopped and we're at the
// trailing edge, the system time has gone backwards and we're treating
// it as the trailing edge, or we've hit the `maxWait` limit.
return _lastCallTime == null ||
(timeSinceLastCall >= _wait) ||
(timeSinceLastCall < 0) ||
(_maxing && timeSinceLastInvoke >= _maxWait);
}
Object? _trailingEdge(int time) {
_timer = null;
// Only invoke if we have `lastArgs` which means `func` has been
// debounced at least once.
if (_trailing && _lastArgs != null) {
return _invokeFunc(time);
}
_lastArgs = _lastNamedArgs = null;
return _result;
}
int _remainingWait(int time) {
final timeSinceLastCall = time - _lastCallTime!;
final timeSinceLastInvoke = time - _lastInvokeTime!;
final timeWaiting = _wait - timeSinceLastCall;
return _maxing
? math.min(timeWaiting, _maxWait - timeSinceLastInvoke)
: timeWaiting;
}
void _timerExpired() {
final time = DateTime.now().millisecondsSinceEpoch;
if (_shouldInvoke(time)) {
_trailingEdge(time);
} else {
// Restart the timer.
_timer = _startTimer(_timerExpired, _remainingWait(time));
}
}
Object? _leadingEdge(int? time) {
// Reset any `maxWait` timer.
_lastInvokeTime = time;
// Start the timer for the trailing edge.
_timer = _startTimer(_timerExpired, _wait);
// Invoke the leading edge.
return _leading ? _invokeFunc(time) : _result;
}
/// Cancels all the remaining delayed functions.
void cancel() {
_timer?.cancel();
_lastInvokeTime = 0;
_lastArgs = _lastNamedArgs = _lastCallTime = _timer = null;
}
/// Immediately invokes all the remaining delayed functions.
Object? flush() {
final now = DateTime.now().millisecondsSinceEpoch;
return _timer == null ? _result : _trailingEdge(now);
}
/// True if there are functions remaining to get invoked.
bool get isPending => _timer != null;
/// Calls/invokes this class like a function.
/// Pass [args] and [namedArgs] to be used while invoking [_func].
Object? call(
List<dynamic> args, {
Map<Symbol, dynamic>? namedArgs,
}) {
final time = DateTime.now().millisecondsSinceEpoch;
final isInvoking = _shouldInvoke(time);
_lastArgs = args;
_lastNamedArgs = namedArgs as Map<Symbol, Object>?;
_lastCallTime = time;
if (isInvoking) {
if (_timer == null) {
return _leadingEdge(_lastCallTime);
}
if (_maxing) {
// Handle invocations in a tight loop.
_timer = _startTimer(_timerExpired, _wait);
return _invokeFunc(_lastCallTime);
}
}
_timer ??= _startTimer(_timerExpired, _wait);
return _result;
}
}
/// Creates a throttled function that only invokes `func` at most once per
/// every `wait` milliseconds. The throttled function comes with a [Throttle.cancel]
/// method to cancel delayed `func` invocations and a [Throttle.flush] method to
/// immediately invoke them. Provide `leading` and/or `trailing` to indicate
/// whether `func` should be invoked on the `leading` and/or `trailing` edge of the `wait` timeout.
/// The `func` is invoked with the last arguments provided to the
/// throttled function. Subsequent calls to the throttled function return the
/// result of the last `func` invocation.
///
/// **Note:** If `leading` and `trailing` options are `true`, `func` is
/// invoked on the trailing edge of the timeout only if the throttled function
/// is invoked more than once during the `wait` timeout.
///
/// If `wait` is [Duration.zero] and `leading` is `false`, `func` invocation is deferred
/// until the next tick.
///
/// See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/)
/// for details over the differences between [Throttle] and [Debounce].
///
/// Some examples:
///
/// Avoid excessively rebuilding UI progress while uploading data to server.
/// ```dart
/// void updateUI(Data data) {
/// updateProgress(data);
/// }
///
/// final throttledUpdateUI = Throttle(
/// updateUI,
/// const Duration(milliseconds: 350),
/// );
///
/// void onUploadProgressChanged(progress) {
/// throttledUpdateUI(progress);
/// }
/// ```
///
/// Cancel the trailing throttled invocation.
/// ```dart
/// void dispose() {
/// throttled.cancel();
/// }
/// ```
///
/// Check for pending invocations.
/// ```dart
/// final status = throttled.isPending ? "Pending..." : "Ready";
/// ```
class Throttle {
/// Creates a new instance of [Throttle]
Throttle(
Function func,
Duration wait, {
bool leading = true,
bool trailing = true,
}) : _debounce = Debounce(
func,
wait,
leading: leading,
trailing: trailing,
maxWait: wait,
);
final Debounce _debounce;
/// Cancels all the remaining delayed functions.
void cancel() => _debounce.cancel();
/// Immediately invokes all the remaining delayed functions.
Object? flush() => _debounce.flush();
/// True if there are functions remaining to get invoked.
bool get isPending => _debounce.isPending;
/// Calls/invokes this class like a function.
/// Pass [args] and [namedArgs] to be used while invoking `func`.
Object? call(List<dynamic> args, {Map<Symbol, dynamic>? namedArgs}) =>
_debounce.call(args, namedArgs: namedArgs);
}
@@ -1,18 +0,0 @@
import 'package:http_parser/http_parser.dart' as http_parser;
import 'package:mime/mime.dart';
/// Useful extension functions for [String]
extension StringX on String {
/// Returns the mime type from the passed file name.
http_parser.MediaType? get mimeType {
if (toLowerCase().endsWith('heic')) {
return http_parser.MediaType.parse('image/heic');
} else {
final mimeType = lookupMimeType(this);
if (mimeType == null) {
return null;
}
return http_parser.MediaType.parse(mimeType);
}
}
}
@@ -0,0 +1,29 @@
///
enum Location {
///
usEast,
///
euWest,
///
mumbai,
///
sydney,
///
singapore,
}
///
extension LocationX on Location {
///
String get name => {
Location.usEast: 'us-east',
Location.euWest: 'dublin',
Location.mumbai: 'mumbai',
Location.sydney: 'sydney',
Location.singapore: 'singapore',
}[this]!;
}
@@ -0,0 +1,51 @@
import 'dart:async';
import 'package:uuid/uuid.dart';
///
class TimerHelper {
final _uuid = const Uuid();
late final _timers = <String, Timer>{};
///
String setTimer(
Duration duration,
void Function() callback, {
bool immediate = false,
}) {
final id = _uuid.v1();
final timer = Timer(duration, callback);
if (immediate) callback();
_timers[id] = timer;
return id;
}
///
String setPeriodicTimer(
Duration duration,
void Function(Timer) callback, {
bool immediate = false,
}) {
final id = _uuid.v1();
final timer = Timer.periodic(duration, callback);
if (immediate) callback.call(timer);
_timers[id] = timer;
return id;
}
///
void cancelTimer(String id) {
final timer = _timers.remove(id);
return timer?.cancel();
}
///
void cancelAllTimers() {
for (final t in _timers.values) {
t.cancel();
}
_timers.clear();
}
///
bool get hasTimers => _timers.isNotEmpty;
}
@@ -0,0 +1,425 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math' as math;
import 'package:logging/logging.dart';
import 'package:meta/meta.dart';
import 'package:rxdart/rxdart.dart';
import 'package:stream_chat/src/core/error/error.dart';
import 'package:stream_chat/src/ws/connection_status.dart';
import 'package:stream_chat/src/core/http/token.dart';
import 'package:stream_chat/src/core/http/token_manager.dart';
import 'package:stream_chat/src/core/models/event.dart';
import 'package:stream_chat/src/core/models/user.dart';
import 'package:stream_chat/src/event_type.dart';
import 'package:stream_chat/src/ws/timer_helper.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:web_socket_channel/status.dart' as status;
/// Typedef which exposes an [Event] as the only parameter.
typedef EventHandler = void Function(Event);
/// Typedef used for connecting to a websocket. Method returns a
/// [WebSocketChannel] and accepts a connection [url] and an optional
/// [Iterable] of `protocols`.
typedef WebSocketChannelProvider = WebSocketChannel Function(
Uri uri, {
Iterable<String>? protocols,
});
/// A WebSocket connection that reconnects upon failure.
class WebSocket with TimerHelper {
/// Creates a new websocket
/// To connect the WS call [connect]
WebSocket({
required this.apiKey,
required this.baseUrl,
required this.tokenManager,
this.handler,
Logger? logger,
this.webSocketChannelProvider,
this.reconnectionMonitorInterval = 10,
this.healthCheckInterval = 20,
this.reconnectionMonitorTimeout = 40,
}) : _logger = logger;
///
final String apiKey;
/// WS base url
final String baseUrl;
///
final TokenManager tokenManager;
/// Functions that will be called every time a new event is received from the
/// connection
final EventHandler? handler;
final Logger? _logger;
/// Connection function
/// Used only for testing purpose
@visibleForTesting
final WebSocketChannelProvider? webSocketChannelProvider;
/// Interval of the reconnection monitor timer
/// This checks that it received a new event in the last
/// [reconnectionMonitorTimeout] seconds, otherwise it considers the
/// connection unhealthy and reconnects the WS
final int reconnectionMonitorInterval;
/// Interval of the health event sending timer
/// This sends a health event every [healthCheckInterval] seconds in order to
/// make the server aware that the client is still listening
final int healthCheckInterval;
/// The timeout that uses the reconnection monitor timer to consider the
/// connection unhealthy
final int reconnectionMonitorTimeout;
User? _user;
String? _connectionId;
DateTime? _lastEventAt;
WebSocketChannel? _webSocketChannel;
StreamSubscription? _webSocketChannelSubscription;
///
Completer<Event>? connectionCompleter;
///
String? get connectionId => _connectionId;
final _connectionStatusController =
BehaviorSubject.seeded(ConnectionStatus.disconnected);
set _connectionStatus(ConnectionStatus status) =>
_connectionStatusController.add(status);
/// The current connection status value
ConnectionStatus get connectionStatus => _connectionStatusController.value;
/// This notifies of connection status changes
Stream<ConnectionStatus> get connectionStatusStream =>
_connectionStatusController.stream.distinct();
void _initWebSocketChannel(Uri uri) {
_logger?.info('Initiating connection with $baseUrl');
if (_webSocketChannel != null) {
_closeWebSocketChannel();
}
_webSocketChannel =
webSocketChannelProvider?.call(uri) ?? WebSocketChannel.connect(uri);
_subscribeToWebSocketChannel();
}
void _closeWebSocketChannel() {
_logger?.info('Closing connection with $baseUrl');
if (_webSocketChannel != null) {
_unsubscribeFromWebSocketChannel();
_webSocketChannel?.sink.close(status.goingAway);
_webSocketChannel = null;
}
}
void _subscribeToWebSocketChannel() {
_logger?.info('Started listening to $baseUrl');
if (_webSocketChannelSubscription != null) {
_unsubscribeFromWebSocketChannel();
}
_webSocketChannelSubscription = _webSocketChannel?.stream.listen(
_onDataReceived,
onError: _onConnectionError,
onDone: _onConnectionClosed,
);
}
void _unsubscribeFromWebSocketChannel() {
_logger?.info('Stopped listening to $baseUrl');
if (_webSocketChannelSubscription != null) {
_webSocketChannelSubscription?.cancel();
_webSocketChannelSubscription = null;
}
}
Future<Uri> _buildUri({bool refreshToken = false}) async {
final user = _user!;
final token = await tokenManager.loadToken(refresh: refreshToken);
final params = {
'user_id': user.id,
'user_details': user,
'user_token': token.rawValue,
'server_determines_connection_id': true,
};
final qs = {
'json': jsonEncode(params),
'api_key': apiKey,
'authorization': token.rawValue,
'stream-auth-type': token.authType.raw,
};
final scheme = baseUrl.startsWith('https') ? 'wss' : 'ws';
final host = baseUrl.replaceAll(RegExp(r'(^\w+:|^)\/\/'), '');
return Uri(
scheme: scheme,
host: host,
pathSegments: ['connect'],
queryParameters: qs,
);
}
bool _connectRequestInProgress = false;
/// Connect the WS using the parameters passed in the constructor
Future<Event> connect(User user) async {
if (_connectRequestInProgress) {
throw const StreamWebSocketError('''
You've called connect twice,
can only attempt 1 connection at the time,
''');
}
_connectRequestInProgress = true;
_manuallyClosed = false;
_user = user;
_connectionStatus = ConnectionStatus.connecting;
connectionCompleter = Completer<Event>();
final uri = await _buildUri();
_initWebSocketChannel(uri);
return connectionCompleter!.future;
}
int _reconnectAttempt = 0;
bool _reconnectRequestInProgress = false;
void _reconnect({bool refreshToken = false}) async {
_logger?.info('Retrying connection : $_reconnectAttempt');
if (_reconnectRequestInProgress) return;
_reconnectRequestInProgress = true;
_stopMonitoringEvents();
// Closing any previously opened web-socket
_closeWebSocketChannel();
_reconnectAttempt += 1;
_connectionStatus = ConnectionStatus.connecting;
final delay = _getReconnectInterval(_reconnectAttempt);
setTimer(
Duration(milliseconds: delay),
() async {
final uri = await _buildUri(refreshToken: refreshToken);
_initWebSocketChannel(uri);
},
);
}
// returns the reconnect interval based on `reconnectAttempt` in milliseconds
int _getReconnectInterval(int reconnectAttempt) {
// try to reconnect in 0.25-25 seconds
// (random to spread out the load from failures)
final max = math.min(500 + reconnectAttempt * 2000, 25000);
final min = math.min(
math.max(250, (reconnectAttempt - 1) * 2000),
25000,
);
return (math.Random().nextDouble() * (max - min) + min).floor();
}
void _startMonitoringEvents() {
_logger?.info('Starting monitoring events');
// cancel all previous timers
cancelAllTimers();
_startHealthCheck();
_startReconnectionMonitor();
}
void _stopMonitoringEvents() {
_logger?.info('Stopped monitoring events');
// reset lastEvent
_lastEventAt = null;
cancelAllTimers();
}
void _handleConnectedEvent(Event event) {
// updating connectionId and status
_connectionId = event.connectionId;
_connectionStatus = ConnectionStatus.connected;
_logger?.info('Connection successful: $_connectionId');
// notify user that connection is completed
final completer = connectionCompleter;
if (completer != null && !completer.isCompleted) {
completer.complete(event);
}
// start monitoring health-check events
_startMonitoringEvents();
}
void _handleHealthCheckEvent(Event event) {
_logger?.info('HealthCheck received : ${event.connectionId}');
_connectionId = event.connectionId;
_connectionStatus = ConnectionStatus.connected;
}
void _handleStreamError(Map<String, Object?> errorResponse) {
// resetting connect, reconnect request flag
_resetRequestFlags();
final error = StreamWebSocketError.fromStreamError(errorResponse);
final isTokenExpired = error.errorCode == ChatErrorCode.tokenExpired;
if (isTokenExpired && !tokenManager.isStatic) {
_logger?.warning('Connection failed, token expired');
return _reconnect(refreshToken: true);
}
_logger?.severe('Connection failed', error);
final completer = connectionCompleter;
// complete with error if not yet completed
if (completer != null && !completer.isCompleted) {
// complete the connection with error
completer.completeError(error);
// disconnect the web-socket connection
return disconnect();
}
return _reconnect();
}
void _onDataReceived(dynamic data) {
final jsonData = json.decode(data) as Map<String, Object?>;
final error = jsonData['error'] as Map<String, Object?>?;
if (error != null) return _handleStreamError(error);
// resetting connect, reconnect request flag
_resetRequestFlags(resetAttempts: true);
Event? event;
try {
event = Event.fromJson(jsonData);
} catch (_) {}
if (event == null) return;
_lastEventAt = DateTime.now();
_logger?.info('Event received: ${event.type}');
if (event.type == EventType.healthCheck) {
if (event.me != null) {
_handleConnectedEvent(event);
} else {
_handleHealthCheckEvent(event);
}
}
return handler?.call(event);
}
void _onConnectionError(error, [stacktrace]) {
_logger?.warning('Error occurred', error, stacktrace);
StreamWebSocketError wsError;
if (error is WebSocketChannelException) {
wsError = StreamWebSocketError.fromWebSocketChannelError(error);
} else {
wsError = StreamWebSocketError(error.toString());
}
final completer = connectionCompleter;
// complete with error if not yet completed
if (completer != null && !completer.isCompleted) {
// complete the connection with error
completer.completeError(wsError);
}
// resetting connect, reconnect request flag
_resetRequestFlags();
_reconnect();
}
bool _manuallyClosed = false;
void _onConnectionClosed() {
_logger?.warning('Connection closed : $connectionId');
// resetting connect, reconnect request flag
_resetRequestFlags();
// resetting connection
_connectionId = null;
// check if we manually closed the connection
if (_manuallyClosed) return;
_reconnect();
}
bool get _needsToReconnect {
final lastEventAt = _lastEventAt;
// means not yet connected or disconnected
if (lastEventAt == null) return false;
// means we missed a health check
final now = DateTime.now();
return now.difference(lastEventAt).inSeconds > reconnectionMonitorTimeout;
}
void _resetRequestFlags({bool resetAttempts = false}) {
_connectRequestInProgress = false;
_reconnectRequestInProgress = false;
if (resetAttempts) _reconnectAttempt = 0;
}
void _startReconnectionMonitor() {
_logger?.info('Starting reconnection monitor');
setPeriodicTimer(
Duration(seconds: reconnectionMonitorInterval),
(_) {
final needsToReconnect = _needsToReconnect;
_logger?.info('Needs to reconnect : $needsToReconnect');
if (needsToReconnect) _reconnect();
},
immediate: true,
);
}
void _startHealthCheck() {
_logger?.info('Starting health check monitor');
setPeriodicTimer(
Duration(seconds: healthCheckInterval),
(_) {
_logger?.info('Sending Event: ${EventType.healthCheck}');
final event = Event(
type: EventType.healthCheck,
connectionId: connectionId,
);
_webSocketChannel?.sink.add(jsonEncode(event));
},
immediate: true,
);
}
/// Disconnects the WS and releases eventual resources
void disconnect() {
if (connectionStatus == ConnectionStatus.disconnected) return;
_connectionStatus = ConnectionStatus.disconnected;
_logger?.info('Disconnecting web-socket connection');
// resetting user
_user = null;
connectionCompleter = null;
_stopMonitoringEvents();
_manuallyClosed = true;
_closeWebSocketChannel();
}
}
+29 -27
View File
@@ -6,33 +6,35 @@ export 'package:dio/src/multipart_file.dart';
export 'package:dio/src/options.dart';
export 'package:dio/src/options.dart' show ProgressCallback;
export 'package:logging/logging.dart' show Logger, Level;
export 'package:rate_limiter/rate_limiter.dart';
export './src/api/channel.dart';
export './src/api/connection_status.dart';
export './src/api/requests.dart';
export './src/api/requests.dart';
export './src/api/responses.dart';
export './src/attachment_file_uploader.dart' show AttachmentFileUploader;
export './src/client.dart';
export './src/core/api/attachment_file_uploader.dart'
show AttachmentFileUploader;
export './src/core/api/requests.dart';
export './src/core/api/requests.dart';
export './src/core/api/responses.dart';
export './src/core/error/error.dart';
export './src/core/models/action.dart';
export './src/core/models/attachment.dart';
export './src/core/models/attachment_file.dart';
export './src/core/models/channel_config.dart';
export './src/core/models/channel_model.dart';
export './src/core/models/channel_state.dart';
export './src/core/models/command.dart';
export './src/core/models/device.dart';
export './src/core/models/event.dart';
export './src/core/models/filter.dart' show Filter;
export './src/core/models/member.dart';
export './src/core/models/message.dart';
export './src/core/models/mute.dart';
export './src/core/models/own_user.dart';
export './src/core/models/reaction.dart';
export './src/core/models/read.dart';
export './src/core/models/user.dart';
export './src/core/util/extension.dart';
export './src/db/chat_persistence_client.dart';
export './src/event_type.dart';
export './src/exceptions.dart';
export './src/extensions/rate_limit.dart';
export './src/extensions/string_extension.dart';
export './src/models/action.dart';
export './src/models/attachment.dart';
export './src/models/attachment_file.dart';
export './src/models/channel_config.dart';
export './src/models/channel_model.dart';
export './src/models/channel_state.dart';
export './src/models/command.dart';
export './src/models/device.dart';
export './src/models/event.dart';
export './src/models/filter.dart' show Filter;
export './src/models/member.dart';
export './src/models/message.dart';
export './src/models/mute.dart';
export './src/models/own_user.dart';
export './src/models/reaction.dart';
export './src/models/read.dart';
export './src/models/user.dart';
export './src/location.dart';
export './src/ws/connection_status.dart';
export 'src/client/channel.dart';
export 'src/client/client.dart';
+1 -1
View File
@@ -1,4 +1,4 @@
import 'package:stream_chat/src/client.dart';
import 'package:stream_chat/src/client/client.dart';
/// Current package version
/// Used in [StreamChatClient] to build the `x-stream-client` header
+3 -1
View File
@@ -15,10 +15,12 @@ dependencies:
equatable: ^2.0.0
freezed_annotation: ^0.14.0
http_parser: ^4.0.0
jose: ^0.3.2
json_annotation: ^4.0.1
logging: ^1.0.1
meta: ^1.3.0
mime: ^1.0.0
rate_limiter: ^0.1.1
rxdart: ^0.27.0
uuid: ^3.0.4
web_socket_channel: ^2.0.0
@@ -28,4 +30,4 @@ dev_dependencies:
freezed: ^0.14.1+3
json_serializable: ^4.1.0
mocktail: ^0.1.1
test: ^1.17.7
test: ^1.17.7
@@ -0,0 +1,57 @@
%PDF-1.7
%µí®û
3 0 obj
<< /Length 4 0 R >>
stream
/DeviceRGB cs /DeviceRGB CS
0 0 0.972549 SC
21.68 194 136.64 26 re
10 10 m 20 20 l S
BT
/F0 24 Tf
25.68 200 Td
(Hello World!) Tj
ET
endstream
endobj
4 0 obj
132
endobj
5 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Times-Roman /Encoding /WinAnsiEncoding >>
endobj
6 0 obj
<< /Type /Page
/Parent 2 0 R
/Resources << /Font << /F0 5 0 R >> >>
/MediaBox [ 0 0 180 240 ]
/Contents 3 0 R
>>
endobj
2 0 obj
<< /Type /Pages
/Count 1
/Kids [ 6 0 R ]
>>
endobj
1 0 obj
<< /Type /Catalog
/Pages 2 0 R
>>
endobj
xref
0 7
0000000000 65535 f
0000000522 00000 n
0000000457 00000 n
0000000015 00000 n
0000000199 00000 n
0000000218 00000 n
0000000317 00000 n
trailer
<< /Size 7
/Root 1 0 R
>>
startxref
574
%%EOF
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

+7
View File
@@ -0,0 +1,7 @@
{
"name": "name",
"style": "style",
"text": "text",
"type": "type",
"value": "value"
}
+29
View File
@@ -0,0 +1,29 @@
{
"type": "giphy",
"title": "awesome",
"title_link": "https://giphy.com/gifs/nrkp3-dance-happy-3o7TKnCdBx5cMg0qti",
"thumb_url": "https://media0.giphy.com/media/3o7TKnCdBx5cMg0qti/giphy.gif",
"actions": [
{
"name": "image_action",
"text": "Send",
"style": "primary",
"type": "button",
"value": "send"
},
{
"name": "image_action",
"text": "Shuffle",
"style": "default",
"type": "button",
"value": "shuffle"
},
{
"name": "image_action",
"text": "Cancel",
"style": "default",
"type": "button",
"value": "cancel"
}
]
}
+7
View File
@@ -0,0 +1,7 @@
{
"id": "test",
"type": "livestream",
"cid": "livestream:test",
"cats": true,
"fruit": ["bananas", "apples"]
}
+832
View File
@@ -0,0 +1,832 @@
{
"channel": {
"id": "dev",
"type": "team",
"cid": "team:dev",
"last_message_at": "2020-01-30T13:43:41.062362Z",
"created_at": "2019-04-03T18:43:33.213373Z",
"updated_at": "2019-04-03T18:43:33.213374Z",
"team": "test",
"created_by": {
"id": "guido",
"role": "user",
"created_at": "2019-04-03T18:43:33.201036Z",
"updated_at": "2019-04-03T18:43:33.204713Z",
"banned": false,
"online": false,
"name": "Guido"
},
"frozen": true,
"config": {
"created_at": "2019-11-07T22:29:26.776526Z",
"updated_at": "2019-11-07T22:29:48.286746Z",
"name": "team",
"typing_events": true,
"read_events": true,
"connect_events": true,
"search": true,
"reactions": true,
"replies": true,
"mutes": true,
"uploads": true,
"url_enrichment": true,
"message_retention": "infinite",
"max_message_length": 5000,
"automod": "disabled",
"automod_behavior": "flag",
"commands": [
{
"name": "giphy",
"description": "Post a random gif to the channel",
"args": "[text]",
"set": "fun_set"
}
]
},
"name": "#dev",
"image": "https://cdn.chrisshort.net/testing-certificate-chains-in-go/GOPHER_MIC_DROP.png",
"example": 1
},
"messages": [
{
"id": "dry-meadow-0-2b73cc8b-cd86-4a01-8d40-bd82ad07a030",
"text": "fasdfa",
"type": "regular",
"status": "SENT",
"silent": false,
"user": {
"id": "dry-meadow-0",
"role": "user",
"created_at": "2019-03-27T17:40:17.155892Z",
"updated_at": "2020-01-29T03:22:47.641589Z",
"last_active": "2020-01-29T03:22:47.63613Z",
"banned": false,
"online": false,
"image": "https://getstream.io/random_svg/?name=Dry+meadow",
"name": "Dry meadow"
},
"attachments": [],
"latest_reactions": [],
"own_reactions": [],
"reaction_counts": {},
"reaction_scores": {},
"reply_count": 0,
"created_at": "2020-01-29T03:23:02.843948Z",
"updated_at": "2020-01-29T03:23:02.843949Z",
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "dry-meadow-0-e8e74482-b4cd-48db-9d1e-30e6c191786f",
"text": "test message",
"type": "regular",
"user": {
"id": "dry-meadow-0",
"role": "user",
"created_at": "2019-03-27T17:40:17.155892Z",
"updated_at": "2020-01-29T03:22:47.641589Z",
"last_active": "2020-01-29T03:22:47.63613Z",
"banned": false,
"online": false,
"image": "https://getstream.io/random_svg/?name=Dry+meadow",
"name": "Dry meadow"
},
"attachments": [],
"latest_reactions": [],
"own_reactions": [],
"reaction_counts": {},
"reaction_scores": {},
"reply_count": 0,
"created_at": "2020-01-29T03:23:07.981091Z",
"updated_at": "2020-01-29T03:23:07.981091Z",
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "dry-meadow-0-53e6299f-9b97-4a9c-a27e-7e2dde49b7e0",
"text": "test message",
"type": "regular",
"user": {
"id": "dry-meadow-0",
"role": "user",
"created_at": "2019-03-27T17:40:17.155892Z",
"updated_at": "2020-01-29T03:22:47.641589Z",
"last_active": "2020-01-29T03:22:47.63613Z",
"banned": false,
"online": false,
"image": "https://getstream.io/random_svg/?name=Dry+meadow",
"name": "Dry meadow"
},
"attachments": [],
"latest_reactions": [],
"own_reactions": [],
"reaction_counts": {},
"reaction_scores": {},
"reply_count": 0,
"created_at": "2020-01-29T03:23:11.568022Z",
"updated_at": "2020-01-29T03:23:11.568022Z",
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "dry-meadow-0-80925be0-786e-40a5-b225-486518dafd35",
"text": "asdfadf",
"type": "regular",
"user": {
"id": "dry-meadow-0",
"role": "user",
"created_at": "2019-03-27T17:40:17.155892Z",
"updated_at": "2020-01-29T03:22:47.641589Z",
"last_active": "2020-01-29T03:22:47.63613Z",
"banned": false,
"online": false,
"image": "https://getstream.io/random_svg/?name=Dry+meadow",
"name": "Dry meadow"
},
"attachments": [],
"latest_reactions": [],
"own_reactions": [],
"reaction_counts": {},
"reaction_scores": {},
"reply_count": 0,
"created_at": "2020-01-29T03:32:57.403566Z",
"updated_at": "2020-01-29T03:32:57.403566Z",
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "dry-meadow-0-64d7970f-ede8-4b31-9738-1bc1756d2bfe",
"text": "test",
"type": "regular",
"user": {
"id": "dry-meadow-0",
"role": "user",
"created_at": "2019-03-27T17:40:17.155892Z",
"updated_at": "2020-01-29T03:22:47.641589Z",
"last_active": "2020-01-29T03:22:47.63613Z",
"banned": false,
"online": false,
"image": "https://getstream.io/random_svg/?name=Dry+meadow",
"name": "Dry meadow"
},
"attachments": [],
"latest_reactions": [],
"own_reactions": [],
"reaction_counts": {},
"reaction_scores": {},
"reply_count": 0,
"created_at": "2020-01-29T03:33:35.294802Z",
"updated_at": "2020-01-29T03:33:35.294802Z",
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "withered-cell-0-84cbd760-cf55-4f7e-9207-c5f66cccc6dc",
"text": "hi",
"type": "regular",
"user": {
"id": "withered-cell-0",
"role": "user",
"created_at": "2020-01-29T03:34:01.698106Z",
"updated_at": "2020-01-29T03:34:01.708808Z",
"last_active": "2020-01-29T03:34:01.70353Z",
"banned": false,
"online": false,
"name": "Withered cell",
"image": "https://getstream.io/random_svg/?name=Withered+cell"
},
"attachments": [],
"latest_reactions": [],
"own_reactions": [],
"reaction_counts": {},
"reaction_scores": {},
"reply_count": 0,
"created_at": "2020-01-29T03:34:27.393296Z",
"updated_at": "2020-01-29T03:34:27.393296Z",
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "dry-meadow-0-e9203588-43c3-40b1-91f7-f217fc42aa53",
"text": "fantastic",
"type": "regular",
"user": {
"id": "dry-meadow-0",
"role": "user",
"created_at": "2019-03-27T17:40:17.155892Z",
"updated_at": "2020-01-29T03:22:47.641589Z",
"last_active": "2020-01-29T03:22:47.63613Z",
"banned": false,
"online": false,
"image": "https://getstream.io/random_svg/?name=Dry+meadow",
"name": "Dry meadow"
},
"attachments": [],
"latest_reactions": [],
"own_reactions": [],
"reaction_counts": {},
"reaction_scores": {},
"reply_count": 0,
"created_at": "2020-01-29T03:34:37.638376Z",
"updated_at": "2020-01-29T03:34:37.638376Z",
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "withered-cell-0-7e3552d7-7a0d-45f2-a856-e91b23a7e240",
"text": "nice to meet you",
"type": "regular",
"user": {
"id": "withered-cell-0",
"role": "user",
"created_at": "2020-01-29T03:34:01.698106Z",
"updated_at": "2020-01-29T03:34:01.708808Z",
"last_active": "2020-01-29T03:34:01.70353Z",
"banned": false,
"online": false,
"image": "https://getstream.io/random_svg/?name=Withered+cell",
"name": "Withered cell"
},
"attachments": [],
"latest_reactions": [],
"own_reactions": [],
"reaction_counts": {},
"reaction_scores": {},
"reply_count": 0,
"created_at": "2020-01-29T03:35:04.301566Z",
"updated_at": "2020-01-29T03:35:04.301566Z",
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "dry-meadow-0-1ffeafd4-e4fc-4c84-9394-9d7cb10fff42",
"text": "hey",
"type": "regular",
"user": {
"id": "dry-meadow-0",
"role": "user",
"created_at": "2019-03-27T17:40:17.155892Z",
"updated_at": "2020-01-29T03:22:47.641589Z",
"last_active": "2020-01-29T03:22:47.63613Z",
"banned": false,
"online": false,
"image": "https://getstream.io/random_svg/?name=Dry+meadow",
"name": "Dry meadow"
},
"attachments": [],
"latest_reactions": [],
"own_reactions": [],
"reaction_counts": {},
"reaction_scores": {},
"reply_count": 0,
"created_at": "2020-01-29T03:35:24.939084Z",
"updated_at": "2020-01-29T03:35:24.939085Z",
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "dry-meadow-0-3f147324-12c8-4b41-9fb5-2db88d065efa",
"text": "hello, everyone",
"type": "regular",
"user": {
"id": "dry-meadow-0",
"role": "user",
"created_at": "2019-03-27T17:40:17.155892Z",
"updated_at": "2020-01-29T03:22:47.641589Z",
"last_active": "2020-01-29T03:22:47.63613Z",
"banned": false,
"online": false,
"name": "Dry meadow",
"image": "https://getstream.io/random_svg/?name=Dry+meadow"
},
"attachments": [],
"latest_reactions": [],
"own_reactions": [],
"reaction_counts": {},
"reaction_scores": {},
"reply_count": 0,
"created_at": "2020-01-29T03:35:33.101566Z",
"updated_at": "2020-01-29T03:35:33.101566Z",
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "dry-meadow-0-51a348ae-0c0a-44de-a556-eac7891c0cf0",
"text": "who is there?",
"type": "regular",
"user": {
"id": "dry-meadow-0",
"role": "user",
"created_at": "2019-03-27T17:40:17.155892Z",
"updated_at": "2020-01-29T03:22:47.641589Z",
"last_active": "2020-01-29T03:22:47.63613Z",
"banned": false,
"online": false,
"name": "Dry meadow",
"image": "https://getstream.io/random_svg/?name=Dry+meadow"
},
"attachments": [],
"latest_reactions": [],
"own_reactions": [],
"reaction_counts": {},
"reaction_scores": {},
"reply_count": 0,
"created_at": "2020-01-29T03:35:45.458685Z",
"updated_at": "2020-01-29T03:35:45.458685Z",
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "icy-recipe-7-a29e237b-8d81-4a97-9bc8-d42bca3f1356",
"text": "하이",
"type": "regular",
"user": {
"id": "icy-recipe-7",
"role": "user",
"created_at": "2020-01-21T11:36:22.284503Z",
"updated_at": "2020-01-29T07:01:59.69882Z",
"last_active": "2020-01-29T07:01:59.693378Z",
"banned": false,
"online": false,
"image": "https://getstream.io/random_svg/?name=Icy+recipe",
"name": "Icy recipe"
},
"attachments": [],
"latest_reactions": [],
"own_reactions": [],
"reaction_counts": {},
"reaction_scores": {},
"reply_count": 0,
"created_at": "2020-01-29T07:02:11.535395Z",
"updated_at": "2020-01-29T07:02:11.535395Z",
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "icy-recipe-7-935c396e-ddf8-4a9a-951c-0a12fa5bf055",
"text": "what are you doing?",
"type": "regular",
"user": {
"id": "icy-recipe-7",
"role": "user",
"created_at": "2020-01-21T11:36:22.284503Z",
"updated_at": "2020-01-29T07:01:59.69882Z",
"last_active": "2020-01-29T07:01:59.693378Z",
"banned": false,
"online": false,
"image": "https://getstream.io/random_svg/?name=Icy+recipe",
"name": "Icy recipe"
},
"attachments": [],
"latest_reactions": [],
"own_reactions": [],
"reaction_counts": {},
"reaction_scores": {},
"reply_count": 0,
"created_at": "2020-01-29T07:02:22.485136Z",
"updated_at": "2020-01-29T07:02:22.485136Z",
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "throbbing-boat-5-1e4d5730-5ff0-4d25-9948-9f34ffda43e4",
"text": "👍",
"type": "regular",
"user": {
"id": "throbbing-boat-5",
"role": "user",
"created_at": "2019-07-30T06:29:53.060413Z",
"updated_at": "2020-01-29T14:11:27.80176Z",
"last_active": "2020-01-29T14:11:27.7963Z",
"banned": false,
"online": false,
"image": "https://getstream.io/random_svg/?name=Throbbing+boat",
"name": "Throbbing boat"
},
"attachments": [],
"latest_reactions": [],
"own_reactions": [],
"reaction_counts": {},
"reaction_scores": {},
"reply_count": 0,
"created_at": "2020-01-29T14:12:04.688552Z",
"updated_at": "2020-01-29T14:12:04.688552Z",
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "snowy-credit-3-3e0c1a0d-d22f-42ee-b2a1-f9f49477bf21",
"text": "sdasas",
"type": "regular",
"user": {
"id": "snowy-credit-3",
"role": "user",
"created_at": "2020-01-29T15:29:03.693312Z",
"updated_at": "2020-01-29T15:29:03.702648Z",
"last_active": "2020-01-29T15:29:03.696144Z",
"banned": false,
"online": false,
"image": "https://getstream.io/random_svg/?name=Snowy+credit",
"name": "Snowy credit"
},
"attachments": [],
"latest_reactions": [],
"own_reactions": [],
"reaction_counts": {},
"reaction_scores": {},
"reply_count": 0,
"created_at": "2020-01-29T15:29:36.011315Z",
"updated_at": "2020-01-29T15:29:36.011316Z",
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "snowy-credit-3-3319537e-2d0e-4876-8170-a54f046e4b7d",
"text": "cjshsa",
"type": "regular",
"user": {
"id": "snowy-credit-3",
"role": "user",
"created_at": "2020-01-29T15:29:03.693312Z",
"updated_at": "2020-01-29T15:29:03.702648Z",
"last_active": "2020-01-29T15:29:03.696144Z",
"banned": false,
"online": false,
"image": "https://getstream.io/random_svg/?name=Snowy+credit",
"name": "Snowy credit"
},
"attachments": [],
"latest_reactions": [],
"own_reactions": [],
"reaction_counts": {},
"reaction_scores": {},
"reply_count": 0,
"created_at": "2020-01-29T15:29:41.677819Z",
"updated_at": "2020-01-29T15:29:41.677819Z",
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "snowy-credit-3-cfaf0b46-1daa-49c5-947c-b16d6697487d",
"text": "nhisagdhsadz",
"type": "regular",
"user": {
"id": "snowy-credit-3",
"role": "user",
"created_at": "2020-01-29T15:29:03.693312Z",
"updated_at": "2020-01-29T15:29:03.702648Z",
"last_active": "2020-01-29T15:29:03.696144Z",
"banned": false,
"online": false,
"image": "https://getstream.io/random_svg/?name=Snowy+credit",
"name": "Snowy credit"
},
"attachments": [],
"latest_reactions": [],
"own_reactions": [],
"reaction_counts": {},
"reaction_scores": {},
"reply_count": 0,
"created_at": "2020-01-29T15:29:43.354177Z",
"updated_at": "2020-01-29T15:29:43.354177Z",
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "snowy-credit-3-cebe25a7-a3a3-49fc-9919-91c6725e81f3",
"text": "hvadhsahzd",
"type": "regular",
"user": {
"id": "snowy-credit-3",
"role": "user",
"created_at": "2020-01-29T15:29:03.693312Z",
"updated_at": "2020-01-29T15:29:03.702648Z",
"last_active": "2020-01-29T15:29:03.696144Z",
"banned": false,
"online": false,
"image": "https://getstream.io/random_svg/?name=Snowy+credit",
"name": "Snowy credit"
},
"attachments": [],
"latest_reactions": [],
"own_reactions": [],
"reaction_counts": {},
"reaction_scores": {},
"reply_count": 0,
"created_at": "2020-01-29T15:29:44.754713Z",
"updated_at": "2020-01-29T15:29:44.754713Z",
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "divine-glade-9-0cea9262-5766-48e9-8b22-311870aed3bf",
"text": "hello",
"type": "regular",
"user": {
"id": "divine-glade-9",
"role": "user",
"created_at": "2020-01-29T17:02:18.312524Z",
"updated_at": "2020-01-29T17:02:18.320187Z",
"last_active": "2020-01-29T17:02:18.315074Z",
"banned": false,
"online": false,
"image": "https://getstream.io/random_svg/?name=Divine+glade",
"name": "Divine glade"
},
"attachments": [],
"latest_reactions": [],
"own_reactions": [],
"reaction_counts": {},
"reaction_scores": {},
"reply_count": 0,
"created_at": "2020-01-29T17:02:36.933852Z",
"updated_at": "2020-01-29T17:02:36.933852Z",
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "red-firefly-9-c4e9007b-bb7d-4238-ae08-5f8e3cd03d73",
"text": "hello",
"type": "regular",
"user": {
"id": "red-firefly-9",
"role": "user",
"created_at": "2019-08-02T18:56:39.366516Z",
"updated_at": "2020-01-29T22:13:50.491769Z",
"last_active": "2020-01-29T22:13:50.450215Z",
"banned": false,
"online": false,
"image": "https://getstream.io/random_svg/?name=Red+firefly",
"name": "Red firefly"
},
"attachments": [],
"latest_reactions": [],
"own_reactions": [],
"reaction_counts": {},
"reaction_scores": {},
"reply_count": 0,
"created_at": "2020-01-29T22:14:08.54062Z",
"updated_at": "2020-01-29T22:14:08.54062Z",
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "bitter-glade-2-02aee4eb-4093-4736-808b-2de75820e854",
"text": "hello",
"type": "regular",
"user": {
"id": "bitter-glade-2",
"role": "user",
"created_at": "2020-01-30T13:08:56.190678Z",
"updated_at": "2020-01-30T13:08:56.200333Z",
"last_active": "2020-01-30T13:08:56.193882Z",
"banned": false,
"online": false,
"image": "https://getstream.io/random_svg/?name=Bitter+glade",
"name": "Bitter glade"
},
"attachments": [],
"latest_reactions": [],
"own_reactions": [],
"reaction_counts": {},
"reaction_scores": {},
"reply_count": 0,
"created_at": "2020-01-30T13:11:37.191293Z",
"updated_at": "2020-01-30T13:11:37.191293Z",
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "morning-sea-1-0c700bcb-46dd-4224-b590-e77bdbccc480",
"text": "http://jaeger.ui.gtstrm.com/",
"type": "regular",
"user": {
"id": "morning-sea-1",
"role": "user",
"created_at": "2019-07-22T09:19:07.505207Z",
"updated_at": "2020-01-30T13:33:05.831856Z",
"last_active": "2020-01-30T13:33:05.825369Z",
"banned": false,
"online": false,
"image": "https://getstream.io/random_svg/?name=Morning+sea",
"name": "Morning sea"
},
"attachments": [],
"latest_reactions": [],
"own_reactions": [],
"reaction_counts": {},
"reaction_scores": {},
"reply_count": 0,
"created_at": "2020-01-30T13:33:16.853116Z",
"updated_at": "2020-01-30T13:33:16.853116Z",
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "ancient-salad-0-53e8b4e6-5b7b-43ad-aeee-8bfb6a9ed0be",
"text": "hi",
"type": "regular",
"user": {
"id": "ancient-salad-0",
"role": "user",
"created_at": "2020-01-30T13:34:29.286813Z",
"updated_at": "2020-01-30T13:34:29.296196Z",
"last_active": "2020-01-30T13:34:29.289964Z",
"banned": false,
"online": true,
"image": "https://getstream.io/random_svg/?name=Ancient+salad",
"name": "Ancient salad"
},
"attachments": [],
"latest_reactions": [],
"own_reactions": [],
"reaction_counts": {},
"reaction_scores": {},
"reply_count": 0,
"created_at": "2020-01-30T13:36:52.749731Z",
"updated_at": "2020-01-30T13:36:52.749732Z",
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "ancient-salad-0-8c225075-bd4c-42e2-8024-530aae13cd40",
"text": "hi",
"type": "regular",
"user": {
"id": "ancient-salad-0",
"role": "user",
"created_at": "2020-01-30T13:34:29.286813Z",
"updated_at": "2020-01-30T13:34:29.296196Z",
"last_active": "2020-01-30T13:34:29.289964Z",
"banned": false,
"online": true,
"image": "https://getstream.io/random_svg/?name=Ancient+salad",
"name": "Ancient salad"
},
"attachments": [],
"latest_reactions": [],
"own_reactions": [],
"reaction_counts": {},
"reaction_scores": {},
"reply_count": 0,
"created_at": "2020-01-30T13:37:41.631056Z",
"updated_at": "2020-01-30T13:37:41.631056Z",
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "proud-sea-7-17802096-cbf8-4e3c-addd-4ee31f4c8b5c",
"text": "😃",
"type": "regular",
"user": {
"id": "proud-sea-7",
"role": "user",
"created_at": "2020-01-30T13:43:03.903006Z",
"updated_at": "2020-01-30T13:43:03.912307Z",
"last_active": "2020-01-30T13:43:03.906236Z",
"banned": false,
"online": false,
"image": "https://getstream.io/random_svg/?name=Proud+sea",
"name": "Proud sea"
},
"attachments": [],
"latest_reactions": [],
"own_reactions": [],
"reaction_counts": {},
"reaction_scores": {},
"reply_count": 0,
"created_at": "2020-01-30T13:43:41.062362Z",
"updated_at": "2020-01-30T13:43:41.062362Z",
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
}
],
"watcher_count": 5,
"members": []
}
@@ -0,0 +1,418 @@
{
"channel": {
"id": "dev",
"type": "team",
"frozen": true,
"name": "#dev",
"image": "https://cdn.chrisshort.net/testing-certificate-chains-in-go/GOPHER_MIC_DROP.png",
"example": 1
},
"watchers": [],
"read": [],
"messages": [
{
"id": "dry-meadow-0-2b73cc8b-cd86-4a01-8d40-bd82ad07a030",
"text": "fasdfa",
"attachments": [],
"parent_id": null,
"quoted_message": null,
"quoted_message_id": null,
"show_in_channel": null,
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "dry-meadow-0-e8e74482-b4cd-48db-9d1e-30e6c191786f",
"text": "test message",
"attachments": [],
"parent_id": null,
"quoted_message": null,
"quoted_message_id": null,
"show_in_channel": null,
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "dry-meadow-0-53e6299f-9b97-4a9c-a27e-7e2dde49b7e0",
"text": "test message",
"attachments": [],
"parent_id": null,
"quoted_message": null,
"quoted_message_id": null,
"show_in_channel": null,
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "dry-meadow-0-80925be0-786e-40a5-b225-486518dafd35",
"text": "asdfadf",
"attachments": [],
"parent_id": null,
"quoted_message": null,
"quoted_message_id": null,
"show_in_channel": null,
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "dry-meadow-0-64d7970f-ede8-4b31-9738-1bc1756d2bfe",
"text": "test",
"attachments": [],
"parent_id": null,
"quoted_message": null,
"quoted_message_id": null,
"show_in_channel": null,
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "withered-cell-0-84cbd760-cf55-4f7e-9207-c5f66cccc6dc",
"text": "hi",
"attachments": [],
"parent_id": null,
"quoted_message": null,
"quoted_message_id": null,
"show_in_channel": null,
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "dry-meadow-0-e9203588-43c3-40b1-91f7-f217fc42aa53",
"text": "fantastic",
"attachments": [],
"parent_id": null,
"quoted_message": null,
"quoted_message_id": null,
"show_in_channel": null,
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "withered-cell-0-7e3552d7-7a0d-45f2-a856-e91b23a7e240",
"text": "nice to meet you",
"attachments": [],
"parent_id": null,
"quoted_message": null,
"quoted_message_id": null,
"show_in_channel": null,
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "dry-meadow-0-1ffeafd4-e4fc-4c84-9394-9d7cb10fff42",
"text": "hey",
"attachments": [],
"parent_id": null,
"quoted_message": null,
"quoted_message_id": null,
"show_in_channel": null,
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "dry-meadow-0-3f147324-12c8-4b41-9fb5-2db88d065efa",
"text": "hello, everyone",
"attachments": [],
"parent_id": null,
"quoted_message": null,
"quoted_message_id": null,
"show_in_channel": null,
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "dry-meadow-0-51a348ae-0c0a-44de-a556-eac7891c0cf0",
"text": "who is there?",
"attachments": [],
"parent_id": null,
"quoted_message": null,
"quoted_message_id": null,
"show_in_channel": null,
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "icy-recipe-7-a29e237b-8d81-4a97-9bc8-d42bca3f1356",
"text": "하이",
"attachments": [],
"parent_id": null,
"quoted_message": null,
"quoted_message_id": null,
"show_in_channel": null,
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "icy-recipe-7-935c396e-ddf8-4a9a-951c-0a12fa5bf055",
"text": "what are you doing?",
"attachments": [],
"parent_id": null,
"quoted_message": null,
"quoted_message_id": null,
"show_in_channel": null,
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "throbbing-boat-5-1e4d5730-5ff0-4d25-9948-9f34ffda43e4",
"text": "👍",
"attachments": [],
"parent_id": null,
"quoted_message": null,
"quoted_message_id": null,
"show_in_channel": null,
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "snowy-credit-3-3e0c1a0d-d22f-42ee-b2a1-f9f49477bf21",
"text": "sdasas",
"attachments": [],
"parent_id": null,
"quoted_message": null,
"quoted_message_id": null,
"show_in_channel": null,
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "snowy-credit-3-3319537e-2d0e-4876-8170-a54f046e4b7d",
"text": "cjshsa",
"attachments": [],
"parent_id": null,
"quoted_message": null,
"quoted_message_id": null,
"show_in_channel": null,
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "snowy-credit-3-cfaf0b46-1daa-49c5-947c-b16d6697487d",
"text": "nhisagdhsadz",
"attachments": [],
"parent_id": null,
"quoted_message": null,
"quoted_message_id": null,
"show_in_channel": null,
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "snowy-credit-3-cebe25a7-a3a3-49fc-9919-91c6725e81f3",
"text": "hvadhsahzd",
"attachments": [],
"parent_id": null,
"quoted_message": null,
"quoted_message_id": null,
"show_in_channel": null,
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "divine-glade-9-0cea9262-5766-48e9-8b22-311870aed3bf",
"text": "hello",
"attachments": [],
"parent_id": null,
"quoted_message": null,
"quoted_message_id": null,
"show_in_channel": null,
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "red-firefly-9-c4e9007b-bb7d-4238-ae08-5f8e3cd03d73",
"text": "hello",
"attachments": [],
"parent_id": null,
"quoted_message": null,
"quoted_message_id": null,
"show_in_channel": null,
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "bitter-glade-2-02aee4eb-4093-4736-808b-2de75820e854",
"text": "hello",
"attachments": [],
"parent_id": null,
"quoted_message": null,
"quoted_message_id": null,
"show_in_channel": null,
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "morning-sea-1-0c700bcb-46dd-4224-b590-e77bdbccc480",
"text": "http://jaeger.ui.gtstrm.com/",
"attachments": [],
"parent_id": null,
"quoted_message": null,
"quoted_message_id": null,
"show_in_channel": null,
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "ancient-salad-0-53e8b4e6-5b7b-43ad-aeee-8bfb6a9ed0be",
"text": "hi",
"attachments": [],
"parent_id": null,
"quoted_message": null,
"quoted_message_id": null,
"show_in_channel": null,
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "ancient-salad-0-8c225075-bd4c-42e2-8024-530aae13cd40",
"text": "hi",
"attachments": [],
"parent_id": null,
"quoted_message": null,
"quoted_message_id": null,
"show_in_channel": null,
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
},
{
"id": "proud-sea-7-17802096-cbf8-4e3c-addd-4ee31f4c8b5c",
"text": "😃",
"attachments": [],
"parent_id": null,
"quoted_message": null,
"quoted_message_id": null,
"show_in_channel": null,
"mentioned_users": [],
"status": "SENT",
"silent": false,
"pinned": false,
"pinned_at": null,
"pin_expires": null,
"pinned_by": null
}
],
"pinned_messages": [],
"members": [],
"watcher_count": 5
}
+5
View File
@@ -0,0 +1,5 @@
{
"name": "giphy",
"description": "Post a random gif to the channel",
"args": "[text]"
}
+4
View File
@@ -0,0 +1,4 @@
{
"id": "device-id",
"push_provider": "push-provider"
}

Some files were not shown because too many files have changed in this diff Show More