// ignore_for_file: unnecessary_getters_setters import 'dart:async'; import 'dart:convert'; import 'package:dio/dio.dart'; import 'package:logging/logging.dart'; import 'package:meta/meta.dart'; import 'package:rxdart/rxdart.dart'; import 'package:stream_chat/src/api/channel.dart'; import 'package:stream_chat/src/api/connection_status.dart'; import 'package:stream_chat/src/api/requests.dart'; import 'package:stream_chat/src/api/responses.dart'; import 'package:stream_chat/src/api/retry_policy.dart'; import 'package:stream_chat/src/api/websocket.dart'; import 'package:stream_chat/src/attachment_file_uploader.dart'; import 'package:stream_chat/src/db/chat_persistence_client.dart'; import 'package:stream_chat/src/event_type.dart'; import 'package:stream_chat/src/exceptions.dart'; import 'package:stream_chat/src/extensions/map_extension.dart'; import 'package:stream_chat/src/models/attachment_file.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/message.dart'; import 'package:stream_chat/src/models/own_user.dart'; import 'package:stream_chat/src/models/user.dart'; import 'package:stream_chat/src/platform_detector/platform_detector.dart'; import 'package:stream_chat/version.dart'; import 'package:uuid/uuid.dart'; /// Handler function used for logging records. Function requires a single /// [LogRecord] as the only parameter. typedef LogHandlerFunction = void Function(LogRecord record); /// Used for decoding [Map] data to a generic type `T`. typedef DecoderFunction = T Function(Map); /// 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 Function(String? userId); /// 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 } extension on PushProvider { /// Returns the string notion for [PushProvider]. String get name { if (this == PushProvider.apn) { return 'apn'; } else { return 'firebase'; } } } /// The official Dart client for Stream Chat, /// a service for building chat applications. /// This library can be used on any Dart project and on both mobile and web apps /// with Flutter. /// /// You can sign up for a Stream account at https://getstream.io/chat/ /// /// The Chat client will manage API call, event handling and manage the /// websocket connection to Stream Chat servers. /// /// ```dart /// final client = StreamChatClient("stream-chat-api-key"); /// ``` class StreamChatClient { /// Create a client instance with default options. /// You should only create the client once and re-use it across your /// application. StreamChatClient( this.apiKey, { this.tokenProvider, this.baseURL = _defaultBaseURL, this.logLevel = Level.WARNING, LogHandlerFunction? logHandlerFunction, Duration connectTimeout = const Duration(seconds: 6), Duration receiveTimeout = const Duration(seconds: 6), Dio? httpClient, RetryPolicy? retryPolicy, this.attachmentFileUploader, }) { _retryPolicy = retryPolicy ?? RetryPolicy( retryTimeout: (StreamChatClient client, int attempt, ApiError? error) => Duration(seconds: 1 * attempt), shouldRetry: (StreamChatClient client, int attempt, ApiError? error) => attempt < 5, ); attachmentFileUploader ??= StreamAttachmentFileUploader(this); state = ClientState(this); _setupLogger(logHandlerFunction); _setupDio(httpClient, receiveTimeout, connectTimeout); logger.info('instantiating new client'); } set chatPersistenceClient(ChatPersistenceClient? value) { _originalChatPersistenceClient = value; } ChatPersistenceClient? _originalChatPersistenceClient; /// Chat persistence client ChatPersistenceClient? get chatPersistenceClient => _chatPersistenceClient; ChatPersistenceClient? _chatPersistenceClient; /// Attachment uploader AttachmentFileUploader? attachmentFileUploader; /// Whether the chat persistence is available or not bool get persistenceEnabled => _chatPersistenceClient != null; RetryPolicy? _retryPolicy; bool _synced = false; /// The retry policy options getter RetryPolicy? get retryPolicy => _retryPolicy; /// This client state late ClientState state; /// By default the Chat client will write all messages with level Warn or /// Error to stdout. /// /// During development you might want to enable more logging information, /// you can change the default log level when constructing the client. /// /// ```dart /// final client = StreamChatClient("stream-chat-api-key", /// logLevel: Level.INFO); /// ``` final Level logLevel; /// Client specific logger instance. /// Refer to the class [Logger] to learn more about the specific /// implementation. final Logger logger = Logger.detached('📡'); /// A function that has a parameter of type [LogRecord]. /// This is called on every new log record. /// By default the client will use the handler returned by /// [_getDefaultLogHandler]. /// Setting it you can handle the log messages directly instead of have them /// written to stdout, /// this is very convenient if you use an error tracking tool or if you want /// to centralize your logs into one facility. /// /// ```dart /// myLogHandlerFunction = (LogRecord record) { /// // do something with the record (ie. send it to Sentry or Fabric) /// } /// /// final client = StreamChatClient("stream-chat-api-key", /// logHandlerFunction: myLogHandlerFunction); ///``` late LogHandlerFunction logHandlerFunction; /// Your project Stream Chat api key. /// Find your API keys here https://getstream.io/dashboard/ String apiKey; /// Your project Stream Chat base url. final String baseURL; /// A function in which you send a request to your own backend to get a Stream /// Chat API token. /// /// The token will be the return value of the function. /// It's used by the client to refresh the token once expired or to connect /// the user without a predefined token using [connectUserWithProvider]. final TokenProvider? tokenProvider; /// [Dio] httpClient /// It's be chosen because it's easy to use and supports interesting features /// out of the box (Interceptors, Global configuration, FormData, /// File downloading etc.) @visibleForTesting Dio httpClient = Dio(); static const _defaultBaseURL = 'chat-us-east-1.stream-io-api.com'; static const _tokenExpiredErrorCode = 40; StreamSubscription? _connectionStatusSubscription; Future Function(ConnectionStatus)? _connectionStatusHandler; final BehaviorSubject _controller = BehaviorSubject(); /// Stream of [Event] coming from websocket connection /// Listen to this or use the [on] method to filter specific event types Stream get stream => _controller.stream; final _wsConnectionStatusController = BehaviorSubject.seeded(ConnectionStatus.disconnected); set _wsConnectionStatus(ConnectionStatus status) => _wsConnectionStatusController.add(status); /// The current status value of the websocket connection ConnectionStatus get wsConnectionStatus => _wsConnectionStatusController.value; /// This notifies the connection status of the websocket connection. /// Listen to this to get notified when the websocket tries to reconnect. Stream get wsConnectionStatusStream => _wsConnectionStatusController.stream.distinct(); /// The current user token String? token; /// The id of the current websocket connection String? get connectionId => _connectionId; bool _anonymous = false; String? _connectionId; late WebSocket _ws; bool get _hasConnectionId => _connectionId != null; void _setupDio( Dio? httpClient, Duration receiveTimeout, Duration connectTimeout, ) { logger.info('http client setup'); this.httpClient = httpClient ?? Dio(); String url; if (!baseURL.startsWith('https') && !baseURL.startsWith('http')) { url = Uri.https(baseURL, '').toString(); } else { url = baseURL; } this.httpClient.options.baseUrl = url; this.httpClient.options.receiveTimeout = receiveTimeout.inMilliseconds; this.httpClient.options.connectTimeout = connectTimeout.inMilliseconds; this.httpClient.interceptors.add( InterceptorsWrapper( onRequest: (options, handler) async { options.queryParameters.addAll(_commonQueryParams); options.headers.addAll(_httpHeaders); if (_connectionId != null && (options.data is Map || options.data == null)) { options.data = { 'connection_id': _connectionId, ...options.data ?? {}, }; } var stringData = options.data.toString(); if (options.data is FormData) { final multiPart = (options.data as FormData).files[0].value; stringData = '${multiPart.filename} - ${multiPart.contentType}'; } logger.info(''' method: ${options.method} url: ${options.uri} headers: ${options.headers} data: $stringData '''); handler.next(options); }, onError: _tokenExpiredInterceptor, ), ); } Future _tokenExpiredInterceptor( DioError err, ErrorInterceptorHandler handler, ) async { final apiError = ApiError( err.response?.data, err.response?.statusCode, ); if (apiError.code == _tokenExpiredErrorCode) { logger.info('token expired'); if (tokenProvider != null) { httpClient.lock(); final userId = state.user!.id; await _disconnect(); final newToken = await tokenProvider!(userId); await Future.delayed(const Duration(seconds: 4)); token = newToken; httpClient.unlock(); await connectUser(User(id: userId), newToken); try { handler.resolve( await httpClient.request( err.requestOptions.path, cancelToken: err.requestOptions.cancelToken, data: err.requestOptions.data, onReceiveProgress: err.requestOptions.onReceiveProgress, onSendProgress: err.requestOptions.onSendProgress, queryParameters: err.requestOptions.queryParameters, options: Options( method: err.requestOptions.method, sendTimeout: err.requestOptions.sendTimeout, receiveTimeout: err.requestOptions.receiveTimeout, extra: err.requestOptions.extra, headers: err.requestOptions.headers, responseType: err.requestOptions.responseType, contentType: err.requestOptions.contentType, validateStatus: err.requestOptions.validateStatus, receiveDataWhenStatusError: err.requestOptions.receiveDataWhenStatusError, followRedirects: err.requestOptions.followRedirects, maxRedirects: err.requestOptions.maxRedirects, requestEncoder: err.requestOptions.requestEncoder, responseDecoder: err.requestOptions.responseDecoder, listFormat: err.requestOptions.listFormat, ), ), ); } on DioError { handler.reject(err); } } } } LogHandlerFunction _getDefaultLogHandler() { final levelEmojiMapper = { Level.INFO.name: 'â„šī¸', Level.WARNING.name: 'âš ī¸', Level.SEVERE.name: '🚨', }; return (LogRecord record) { print( '(${record.time}) ' '${levelEmojiMapper[record.level.name] ?? record.level.name} ' '${record.loggerName} ${record.message}', ); if (record.stackTrace != null) { print(record.stackTrace); } }; } Logger _detachedLogger( String name, ) => Logger.detached(name) ..level = logLevel ..onRecord.listen(logHandlerFunction); void _setupLogger(LogHandlerFunction? logHandlerFunction) { logger.level = logLevel; this.logHandlerFunction = logHandlerFunction ?? _getDefaultLogHandler(); logger.onRecord.listen(this.logHandlerFunction); logger.info('logger setup'); } /// Call this function to dispose the client void dispose() async { await _chatPersistenceClient?.disconnect(); await _disconnect(); httpClient.close(); await _controller.close(); state.dispose(); await _wsConnectionStatusController.close(); } Map get _httpHeaders => { 'Authorization': token, 'stream-auth-type': _authType, 'X-Stream-Client': _userAgent, 'Content-Encoding': 'gzip', }; /// Set the current user, this triggers a connection to the API. /// It returns a [Future] that resolves when the connection is setup. @Deprecated('Use `connectUser` instead. Will be removed in Future releases') Future setUser(User user, String token) => connectUser(user, token); /// Connects the current user, this triggers a connection to the API. /// It returns a [Future] that resolves when the connection is setup. Future connectUser(User? user, String? token) async { if (_connectCompleter != null && !_connectCompleter!.isCompleted) { logger.warning('Already connecting'); throw Exception('Already connecting'); } _connectCompleter = Completer(); logger.info('connect user'); if (user == null) { final e = Error(); _connectCompleter! .completeError(e, StackTrace.fromString('No user provided.')); throw e; } state.user = OwnUser.fromJson(user.toJson()); this.token = token; _anonymous = false; return connect().then((event) { _connectCompleter!.complete(event); return event; }).catchError((e, s) { _connectCompleter!.completeError(e, s); throw e; }); } /// Set the current user using the [tokenProvider] to fetch the token. /// It returns a [Future] that resolves when the connection is setup. @Deprecated( 'Use `connectUserWithProvider` instead. Will be removed in Future releases', ) Future setUserWithProvider(User user) => connectUserWithProvider(user); /// Connects the current user using the [tokenProvider] to fetch the token. /// It returns a [Future] that resolves when the connection is setup. Future connectUserWithProvider(User user) async { if (tokenProvider == null) { throw Exception(''' TokenProvider must be provided in the constructor in order to use `connectUserWithProvider` method. Use `connectUser` providing a token. '''); } final token = await tokenProvider!(user.id); return connectUser(user, token); } /// Stream of [Event] coming from websocket connection /// Pass an eventType as parameter in order to filter just a type of event Stream on([ String? eventType, String? eventType2, String? eventType3, String? eventType4, ]) => stream.where((event) => eventType == null || (event.type != null && (event.type == eventType || event.type == eventType2 || event.type == eventType3 || event.type == eventType4))); /// Method called to add a new event to the [_controller]. void handleEvent(Event event) async { logger.info('handle new event: ${event.toJson()}'); if (event.connectionId != null) { _connectionId = event.connectionId; } if (!event.isLocal) { final createdAt = event.createdAt; if (_synced && createdAt != null) { await _chatPersistenceClient?.updateConnectionInfo(event); await _chatPersistenceClient?.updateLastSyncAt(createdAt); } } if (event.user != null) { state._updateUser(event.user); } if (event.me != null) { state.user = event.me; } _controller.add(event); } Completer? _connectCompleter; /// Connect the client websocket Future connect() async { logger.info('connecting'); if (wsConnectionStatus == ConnectionStatus.connecting) { logger.warning('Already connecting'); throw Exception('Already connecting'); } if (wsConnectionStatus == ConnectionStatus.connected) { logger.warning('Already connected'); throw Exception('Already connected'); } _wsConnectionStatus = ConnectionStatus.connecting; if (_originalChatPersistenceClient != null) { _chatPersistenceClient = _originalChatPersistenceClient; await _chatPersistenceClient!.connect(state.user!.id); } _ws = WebSocket( baseUrl: baseURL, user: state.user!, connectParams: { 'api_key': apiKey, 'authorization': token!, 'stream-auth-type': _authType, 'X-Stream-Client': _userAgent, }, connectPayload: { 'user_id': state.user!.id, 'server_determines_connection_id': true, }, handler: handleEvent, logger: _detachedLogger('🔌'), ); _connectionStatusHandler = (ConnectionStatus status) async { _wsConnectionStatus = status; handleEvent( Event( type: EventType.connectionChanged, online: status == ConnectionStatus.connected, ), ); if (status == ConnectionStatus.connected) { handleEvent(const Event( type: EventType.connectionRecovered, online: true, )); if (state.channels.isNotEmpty == true) { // ignore: unawaited_futures queryChannelsOnline( filter: Filter.in_('cid', state.channels.keys.toList()), ).then( (_) async { await resync(); }, ); } else { _synced = false; } } }; _connectionStatusSubscription = _ws.connectionStatusStream.listen(_connectionStatusHandler); var event = await _chatPersistenceClient?.getConnectionInfo(); await _ws.connect().then((e) async { if (e != null) { _chatPersistenceClient?.updateConnectionInfo(e); event = e; } resync(); }).catchError((err, stacktrace) { logger.severe('error connecting ws', err, stacktrace); if (err is Map) { // ignore: only_throw_errors throw err; } }); return event; } /// Get the events missed while offline to sync the offline storage Future resync([List? cids]) async { final lastSyncAt = await _chatPersistenceClient?.getLastSyncAt(); if (lastSyncAt == null) { _synced = true; return; } cids ??= await _chatPersistenceClient?.getChannelCids(); if (cids?.isEmpty == true) { return; } try { final rawRes = await post('/sync', data: { 'channel_cids': cids, 'last_sync_at': lastSyncAt.toUtc().toIso8601String(), }); logger.fine('rawRes: $rawRes'); final res = decode( rawRes.data, SyncResponse.fromJson, ); res.events.sort((a, b) => a.createdAt!.compareTo(b.createdAt!)); res.events.forEach((element) { logger ..fine('element.type: ${element.type}') ..fine('element.message.text: ${element.message?.text}'); }); res.events.forEach(handleEvent); await _chatPersistenceClient?.updateLastSyncAt(DateTime.now()); _synced = true; } catch (error) { logger.severe('Error during resync $error'); } } String? _asMap(sort) => sort?.map((s) => s.toJson().toString())?.join(''); final _queryChannelsStreams = >>{}; /// Requests channels with a given query. Stream> queryChannels({ Filter? filter, List>? sort, Map? options, PaginationParams paginationParams = const PaginationParams(), int? messageLimit, bool waitForConnect = true, }) async* { final hash = base64.encode(utf8.encode( '$filter${_asMap(sort)}$options${paginationParams.toJson()}' '$messageLimit', )); if (_queryChannelsStreams.containsKey(hash)) { yield await _queryChannelsStreams[hash]!; } else { final channels = await queryChannelsOffline( filter: filter, sort: sort, paginationParams: paginationParams, ); if (channels.isNotEmpty) yield channels; try { final newQueryChannelsFuture = queryChannelsOnline( filter: filter, sort: sort, options: options, paginationParams: paginationParams, messageLimit: messageLimit, waitForConnect: waitForConnect, ).whenComplete(() { _queryChannelsStreams.remove(hash); }); _queryChannelsStreams[hash] = newQueryChannelsFuture; yield await newQueryChannelsFuture; } catch (_) { if (channels.isEmpty) rethrow; } } } /// Requests channels with a given query from the API. Future> queryChannelsOnline({ Filter? filter, List>? sort, Map? options, int? messageLimit, PaginationParams paginationParams = const PaginationParams(), bool waitForConnect = true, }) async { if (waitForConnect) { if (_connectCompleter != null && !_connectCompleter!.isCompleted) { logger.info('awaiting connection completer'); await _connectCompleter!.future; } if (wsConnectionStatus != ConnectionStatus.connected) { throw Exception( 'You cannot use queryChannels without an active connection.' ' Please call `connectUser` to connect the client.', ); } } logger.info('Query channel start'); final defaultOptions = { 'state': true, 'watch': true, 'presence': false, }; final payload = { 'filter_conditions': filter, 'sort': sort, }; if (messageLimit != null) { payload['message_limit'] = messageLimit; } payload.addAll(defaultOptions); if (options != null) { payload.addAll(options); } payload.addAll(paginationParams.toJson()); final response = await get( '/channels', queryParameters: { 'payload': jsonEncode(payload), }, ); final res = decode( response.data, QueryChannelsResponse.fromJson, ); if (res.channels.isEmpty && paginationParams.offset == 0) { logger.warning( ''' We could not find any channel for this query. Please make sure to take a look at the Flutter tutorial: https://getstream.io/chat/flutter/tutorial If your application already has users and channels, you might need to adjust your query channel as explained in the docs https://getstream.io/chat/docs/query_channels/?language=dart''', ); return []; } final channels = res.channels; final users = channels .expand((it) => it.members) .map((it) => it.user) .toList(growable: false); state._updateUsers(users); logger.info('Got ${res.channels.length} channels from api'); final updateData = _mapChannelStateToChannel(channels); await _chatPersistenceClient?.updateChannelQueries( filter, channels.map((c) => c.channel!.cid).toList(), clearQueryCache: paginationParams.offset == 0, ); state.channels = updateData.key; return updateData.value; } /// Requests channels with a given query from the Persistence client. Future> queryChannelsOffline({ Filter? filter, List>? sort, PaginationParams paginationParams = const PaginationParams(), }) async { final offlineChannels = (await _chatPersistenceClient?.getChannelStates( filter: filter, sort: sort, paginationParams: paginationParams, )) ?? []; final updatedData = _mapChannelStateToChannel(offlineChannels); state.channels = updatedData.key; return updatedData.value; } MapEntry, List> _mapChannelStateToChannel( List channelStates, ) { final channels = {...state.channels}; final newChannels = []; for (final channelState in channelStates) { final channel = channels[channelState.channel!.cid]; if (channel != null) { channel.state?.updateChannelState(channelState); newChannels.add(channel); } else { final newChannel = Channel.fromState(this, channelState); if (newChannel.cid != null) { channels[newChannel.cid!] = newChannel; } newChannels.add(newChannel); } } return MapEntry(channels, newChannels); } Object _parseError(DioError error) { if (error.type == DioErrorType.response) { final apiError = ApiError(error.response?.data, error.response?.statusCode); logger.severe('apiError: ${apiError.toString()}'); return apiError; } return error; } /// Handy method to make http GET request with error parsing. Future> get( String path, { Map? queryParameters, }) async { try { final response = await httpClient.get( path, queryParameters: queryParameters, ); return response; } on DioError catch (error) { // ignore: only_throw_errors throw _parseError(error); } } /// Handy method to make http POST request with error parsing. Future> post( String path, { dynamic data, ProgressCallback? onSendProgress, CancelToken? cancelToken, }) async { try { final response = await httpClient.post( path, data: data, onSendProgress: onSendProgress, cancelToken: cancelToken, ); return response; } on DioError catch (error) { // ignore: only_throw_errors throw _parseError(error); } } /// Handy method to make http DELETE request with error parsing. Future> delete( String path, { Map? queryParameters, CancelToken? cancelToken, }) async { try { final response = await httpClient.delete( path, queryParameters: queryParameters, cancelToken: cancelToken, ); return response; } on DioError catch (error) { // ignore: only_throw_errors throw _parseError(error); } } /// Handy method to make http PATCH request with error parsing. Future> patch( String path, { Map? queryParameters, dynamic data, }) async { try { final response = await httpClient.patch( path, queryParameters: queryParameters, data: data, ); return response; } on DioError catch (error) { // ignore: only_throw_errors throw _parseError(error); } } /// Handy method to make http PUT request with error parsing. Future> put( String path, { Map? queryParameters, dynamic data, }) async { try { final response = await httpClient.put( path, queryParameters: queryParameters, data: data, ); return response; } on DioError catch (error) { // ignore: only_throw_errors throw _parseError(error); } } /// Used to log errors and stacktrace in case of bad json deserialization T decode(String? j, DecoderFunction decoderFunction) { try { final data = j ?? '{}'; return decoderFunction(json.decode(data)); } catch (error, stacktrace) { logger.severe('Error decoding response', error, stacktrace); rethrow; } } String get _authType => _anonymous ? 'anonymous' : 'jwt'; String get _userAgent => 'stream-chat-dart-client-${CurrentPlatform.name}-' '${PACKAGE_VERSION.split('+')[0]}'; Map get _commonQueryParams => { 'user_id': state.user?.id, 'api_key': apiKey, 'connection_id': _connectionId, }; /// Set the current user with an anonymous id, this triggers a connection to /// the API. It returns a [Future] that resolves when the connection is setup. @Deprecated( 'Use `connectAnonymousUser` instead. Will be removed in Future releases') Future setAnonymousUser() => connectAnonymousUser(); /// Connects the current user with an anonymous id, this triggers a connection /// to the API. It returns a [Future] that resolves when the connection is /// setup. Future connectAnonymousUser() async { if (_connectCompleter != null && !_connectCompleter!.isCompleted) { logger.warning('Already connecting'); throw Exception('Already connecting'); } _connectCompleter = Completer(); _anonymous = true; const uuid = Uuid(); state.user = OwnUser(id: uuid.v4()); return connect().then((event) { _connectCompleter!.complete(event); return event; }).catchError((e, s) { _connectCompleter!.completeError(e, s); throw e; }); } /// Set the current user as guest, this triggers a connection to the API. /// It returns a [Future] that resolves when the connection is setup. @Deprecated( 'Use `connectGuestUser` instead. Will be removed in Future releases') Future setGuestUser(User user) => connectGuestUser(user); /// Connects the current user as guest, this triggers a connection to the API. /// It returns a [Future] that resolves when the connection is setup. Future connectGuestUser(User user) async { _anonymous = true; final response = await post('/guest', data: {'user': user.toJson()}) .then((res) => decode( res.data, ConnectGuestUserResponse.fromJson)) .whenComplete(() => _anonymous = false); return connectUser( response.user, response.accessToken, ); } /// Closes the websocket connection and resets the client /// If [flushChatPersistence] is true the client deletes all offline /// user's data. If [clearUser] is true the client unsets the current user Future disconnect({ bool flushChatPersistence = false, bool clearUser = false, }) async { logger.info('Disconnecting flushOfflineStorage: $flushChatPersistence; ' 'clearUser: $clearUser'); await _chatPersistenceClient?.disconnect(flush: flushChatPersistence); _chatPersistenceClient = null; _connectCompleter = null; if (clearUser == true) { state.dispose(); state = ClientState(this); } await _disconnect(); } Future _disconnect() async { logger.info('Client disconnecting'); await _ws.disconnect(); await _connectionStatusSubscription?.cancel(); } /// Requests users with a given query. Future queryUsers({ Filter? filter, List? sort, Map? options, PaginationParams? pagination, }) async { final defaultOptions = { 'presence': _hasConnectionId, }; final payload = { 'filter_conditions': filter, 'sort': sort, }..addAll(defaultOptions); if (pagination != null) { payload.addAll(pagination.toJson()); } if (options != null) { payload.addAll(options); } final rawRes = await get( '/users', queryParameters: { 'payload': jsonEncode(payload), }, ); final response = decode( rawRes.data, QueryUsersResponse.fromJson, ); state._updateUsers(response.users); return response; } /// A message search. Future search( Filter filter, { String? query, List? sort, PaginationParams? paginationParams, 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 payload = { 'filter_conditions': filter, 'message_filter_conditions': messageFilters, 'query': query, 'sort': sort, if (paginationParams != null) ...paginationParams.toJson(), }.nullProtected; final response = await get('/search', queryParameters: { 'payload': json.encode(payload), }); return decode( response.data, SearchMessagesResponse.fromJson); } /// Send a [file] to the [channelId] of type [channelType] Future sendFile( AttachmentFile file, String channelId, String channelType, { ProgressCallback? onSendProgress, CancelToken? cancelToken, }) => attachmentFileUploader!.sendFile( file, channelId, channelType, onSendProgress: onSendProgress, cancelToken: cancelToken, ); /// Send a [image] to the [channelId] of type [channelType] Future sendImage( AttachmentFile image, String channelId, String channelType, { ProgressCallback? onSendProgress, CancelToken? cancelToken, }) => attachmentFileUploader!.sendImage( image, channelId, channelType, onSendProgress: onSendProgress, cancelToken: cancelToken, ); /// Delete a file from this channel Future deleteFile( String url, String channelId, String channelType, { CancelToken? cancelToken, }) => attachmentFileUploader!.deleteFile( url, channelId, channelType, cancelToken: cancelToken, ); /// Delete an image from this channel Future deleteImage( String url, String channelId, String channelType, { CancelToken? cancelToken, }) => attachmentFileUploader!.deleteImage( url, channelId, channelType, cancelToken: cancelToken, ); /// Add a device for Push Notifications. Future addDevice(String id, PushProvider pushProvider) async { final response = await post('/devices', data: { 'id': id, 'push_provider': pushProvider.name, }); return decode(response.data, EmptyResponse.fromJson); } /// Gets a list of user devices. Future getDevices() async { final response = await get('/devices'); return decode( response.data, ListDevicesResponse.fromJson); } /// Remove a user's device. Future removeDevice(String id) async { final response = await delete('/devices', queryParameters: { 'id': id, }); return decode(response.data, EmptyResponse.fromJson); } /// Get a development token String devToken(String userId) { final payload = json.encode({'user_id': userId}); final payloadBytes = utf8.encode(payload); final payloadB64 = base64.encode(payloadBytes); return 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.$payloadB64.devtoken'; } /// Returns a channel client with the given type, id and custom data. Channel channel( String type, { String? id, Map extraData = const {}, }) { if (id != null && state.channels.containsKey('$type:$id')) { return state.channels['$type:$id']!; } return Channel(this, type, id, extraData: extraData); } /// Update or Create the given user object. Future updateUser(User user) async => updateUsers([user]); /// Batch update a list of users Future updateUsers(List users) async { final response = await post('/users', data: { 'users': users.asMap().map((_, u) => MapEntry(u.id, u.toJson())), }); return decode( response.data, UpdateUsersResponse.fromJson, ); } /// Bans a user from all channels Future banUser( String targetUserID, [ Map options = const {}, ]) async { final data = Map.from(options) ..addAll({ 'target_user_id': targetUserID, }); final response = await post( '/moderation/ban', data: data, ); return decode(response.data, EmptyResponse.fromJson); } /// Remove global ban for a user Future unbanUser( String targetUserID, [ Map options = const {}, ]) async { final data = Map.from(options) ..addAll({ 'target_user_id': targetUserID, }); final response = await delete( '/moderation/ban', queryParameters: data, ); return decode(response.data, EmptyResponse.fromJson); } /// Shadow bans a user Future shadowBan( String targetID, [ Map options = const {}, ]) async => banUser(targetID, { 'shadow': true, ...options, }); /// Removes shadow ban from a user Future removeShadowBan( String targetID, [ Map options = const {}, ]) async => unbanUser(targetID, { 'shadow': true, ...options, }); /// Mutes a user Future muteUser(String targetID) async { final response = await post('/moderation/mute', data: { 'target_id': targetID, }); return decode(response.data, EmptyResponse.fromJson); } /// Unmutes a user Future unmuteUser(String targetID) async { final response = await post('/moderation/unmute', data: { 'target_id': targetID, }); return decode(response.data, EmptyResponse.fromJson); } /// Flag a message Future flagMessage(String messageID) async { final response = await post('/moderation/flag', data: { 'target_message_id': messageID, }); return decode(response.data, EmptyResponse.fromJson); } /// Unflag a message Future unflagMessage(String messageId) async { final response = await post('/moderation/unflag', data: { 'target_message_id': messageId, }); return decode(response.data, EmptyResponse.fromJson); } /// Flag a user Future flagUser(String userId) async { final response = await post('/moderation/flag', data: { 'target_user_id': userId, }); return decode(response.data, EmptyResponse.fromJson); } /// Unflag a message Future unflagUser(String userId) async { final response = await post('/moderation/unflag', data: { 'target_user_id': userId, }); return decode(response.data, EmptyResponse.fromJson); } /// Mark all channels for this user as read Future markAllRead() async { final response = await post('/channels/read'); return decode(response.data, EmptyResponse.fromJson); } /// Sends the message to the given channel Future sendMessage( Message message, String channelId, String channelType, ) async { final response = await post( '/channels/$channelType/$channelId/message', data: {'message': message.toJson()}, ); return decode(response.data, SendMessageResponse.fromJson); } /// Update the given message Future updateMessage(Message message) async { final response = await post( '/messages/${message.id}', data: {'message': message.toJson()}, ); return decode(response.data, UpdateMessageResponse.fromJson); } /// Deletes the given message Future deleteMessage(Message message) async { final response = await delete('/messages/${message.id}'); return decode(response.data, EmptyResponse.fromJson); } /// Get a message by id Future getMessage(String messageId) async { final response = await get('/messages/$messageId'); return decode(response.data, GetMessageResponse.fromJson); } /// Pins provided message /// [timeoutOrExpirationDate] can either be a [DateTime] or a value in seconds /// to be added to [DateTime.now] Future pinMessage( Message message, Object timeoutOrExpirationDate, ) { assert(() { if (timeoutOrExpirationDate is! DateTime && timeoutOrExpirationDate is! num) { throw ArgumentError('Invalid timeout or Expiration date'); } return true; }(), 'Check whether time out is valid'); DateTime? pinExpires; if (timeoutOrExpirationDate is DateTime) { pinExpires = timeoutOrExpirationDate.toUtc(); } else if (timeoutOrExpirationDate is num) { pinExpires = DateTime.now() .add( Duration(seconds: timeoutOrExpirationDate.toInt()), ) .toUtc(); } return updateMessage( message.copyWith(pinned: true, pinExpires: pinExpires), ); } /// Unpins provided message Future unpinMessage(Message message) => updateMessage(message.copyWith(pinned: false)); } /// The class that handles the state of the channel listening to the events class ClientState { /// Creates a new instance listening to events and updating the state ClientState(this._client) { _subscriptions.addAll([ _client .on() .where((event) => event.me != null) .map((e) => e.me) .listen((user) { _userController.add(user); if (user?.totalUnreadCount != null) { _totalUnreadCountController.add(user?.totalUnreadCount); } if (user?.unreadChannels != null) { _unreadChannelsController.add(user?.unreadChannels); } }), _client .on() .where((event) => event.unreadChannels != null) .map((e) => e.unreadChannels) .listen(_unreadChannelsController.add), _client .on() .where((event) => event.totalUnreadCount != null) .map((e) => e.totalUnreadCount) .listen(_totalUnreadCountController.add), ]); _listenChannelDeleted(); _listenChannelHidden(); _listenUserUpdated(); } final _subscriptions = []; /// Used internally for optimistic update of unread count set totalUnreadCount(int? unreadCount) { _totalUnreadCountController.add(unreadCount ?? 0); } void _listenChannelHidden() { _subscriptions.add(_client.on(EventType.channelHidden).listen((event) { final cid = event.cid; if (cid != null) { _client.chatPersistenceClient?.deleteChannels([cid]); } channels = channels..removeWhere((cid, ch) => cid == event.cid); })); } void _listenUserUpdated() { _subscriptions.add(_client.on(EventType.userUpdated).listen((event) { if (event.user!.id == user!.id) { user = OwnUser.fromJson(event.user!.toJson()); } _updateUser(event.user); })); } void _listenChannelDeleted() { _subscriptions.add(_client .on( EventType.channelDeleted, EventType.notificationRemovedFromChannel, EventType.notificationChannelDeleted, ) .listen((Event event) async { final eventChannel = event.channel!; await _client.chatPersistenceClient?.deleteChannels([eventChannel.cid]); channels = channels..remove(eventChannel.cid); })); } final StreamChatClient _client; /// Update user information set user(OwnUser? user) { _userController.add(user); } void _updateUsers(List userList) { final newUsers = { ...users, for (var user in userList) user!.id: user, }; _usersController.add(newUsers); } void _updateUser(User? user) => _updateUsers([user]); /// The current user OwnUser? get user => _userController.valueOrNull; /// The current user as a stream Stream get userStream => _userController.stream; /// The current user Map get users => _usersController.value; /// The current user as a stream Stream> get usersStream => _usersController.stream; /// The current unread channels count int? get unreadChannels => _unreadChannelsController.valueOrNull; /// The current unread channels count as a stream Stream get unreadChannelsStream => _unreadChannelsController.stream; /// The current total unread messages count int? get totalUnreadCount => _totalUnreadCountController.valueOrNull; /// The current total unread messages count as a stream Stream get totalUnreadCountStream => _totalUnreadCountController.stream; /// The current list of channels in memory as a stream Stream?> get channelsStream => _channelsController.stream; /// The current list of channels in memory Map get channels => _channelsController.value; set channels(Map v) { _channelsController.add(v); } final BehaviorSubject> _channelsController = BehaviorSubject.seeded({}); final BehaviorSubject _userController = BehaviorSubject(); final BehaviorSubject> _usersController = BehaviorSubject.seeded({}); final BehaviorSubject _unreadChannelsController = BehaviorSubject(); final BehaviorSubject _totalUnreadCountController = BehaviorSubject(); /// Call this method to dispose this object void dispose() { _subscriptions.forEach((s) => s.cancel()); _userController.close(); _unreadChannelsController.close(); _totalUnreadCountController.close(); channels.values.forEach((c) => c.dispose()); _channelsController.close(); } }