// ignore_for_file: unnecessary_getters_setters import 'dart:async'; import 'package:dio/dio.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/api/attachment_file_uploader.dart'; import 'package:stream_chat/src/core/api/requests.dart'; import 'package:stream_chat/src/core/api/responses.dart'; import 'package:stream_chat/src/core/api/stream_chat_api.dart'; import 'package:stream_chat/src/core/error/error.dart'; import 'package:stream_chat/src/core/http/connection_id_manager.dart'; import 'package:stream_chat/src/core/http/stream_http_client.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/attachment_file.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/own_user.dart'; import 'package:stream_chat/src/core/models/user.dart'; import 'package:stream_chat/src/core/platform_detector/platform_detector.dart'; import 'package:stream_chat/src/core/util/utils.dart'; import 'package:stream_chat/src/db/chat_persistence_client.dart'; import 'package:stream_chat/src/event_type.dart'; import 'package:stream_chat/src/location.dart'; import 'package:stream_chat/src/ws/connection_status.dart'; import 'package:stream_chat/src/ws/websocket.dart'; import 'package:stream_chat/version.dart'; /// Handler function used for logging records. Function requires a single /// [LogRecord] as the only parameter. typedef LogHandlerFunction = void Function(LogRecord record); final _levelEmojiMapper = { Level.INFO: 'â„šī¸', Level.WARNING: 'âš ī¸', Level.SEVERE: '🚨', }; final _userAgent = 'stream-chat-dart-client-' '${CurrentPlatform.name}-' '${PACKAGE_VERSION.split('+')[0]}'; /// 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( String apiKey, { this.logLevel = Level.WARNING, LogHandlerFunction? logHandlerFunction, RetryPolicy? retryPolicy, Location? location, @Deprecated('Use location to change baseUrl instead') String? baseURL, Duration connectTimeout = const Duration(seconds: 6), Duration receiveTimeout = const Duration(seconds: 6), StreamChatApi? chatApi, WebSocket? ws, AttachmentFileUploader? attachmentFileUploader, }) { this.logHandlerFunction = logHandlerFunction ?? _defaultLogHandler; logger.info('Initiating new StreamChatClient'); final options = StreamHttpClientOptions( baseUrl: baseURL, location: location, connectTimeout: connectTimeout, receiveTimeout: receiveTimeout, headers: {'X-Stream-Client': _userAgent}, ); _chatApi = chatApi ?? StreamChatApi( apiKey, options: options, tokenManager: _tokenManager, connectionIdManager: _connectionIdManager, attachmentFileUploader: attachmentFileUploader, logger: detachedLogger('đŸ•¸ī¸'), ); _ws = ws ?? WebSocket( apiKey: apiKey, baseUrl: options.baseUrl, tokenManager: _tokenManager, handler: handleEvent, logger: detachedLogger('🔌'), queryParameters: {'X-Stream-Client': _userAgent}, ); _retryPolicy = retryPolicy ?? RetryPolicy( shouldRetry: (_, attempt, __) => attempt < 5, retryTimeout: (_, attempt, __) => Duration(seconds: attempt), ); state = ClientState(this); } late final StreamChatApi _chatApi; late final WebSocket _ws; /// This client state late ClientState state; final _tokenManager = TokenManager(); final _connectionIdManager = ConnectionIdManager(); set chatPersistenceClient(ChatPersistenceClient? value) { _originalChatPersistenceClient = value; } ChatPersistenceClient? _originalChatPersistenceClient; /// Chat persistence client ChatPersistenceClient? get chatPersistenceClient => _chatPersistenceClient; ChatPersistenceClient? _chatPersistenceClient; /// Whether the chat persistence is available or not bool get persistenceEnabled => _chatPersistenceClient != null; late final RetryPolicy _retryPolicy; /// sync state of the channels present inside state, defaults to false bool _synced = false; /// the last dateTime at the which all the channels were synced DateTime? _lastSyncedAt; /// The retry policy options getter RetryPolicy get retryPolicy => _retryPolicy; /// 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. late final Logger logger = detachedLogger('📡'); /// 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; StreamSubscription? _connectionStatusSubscription; final _eventController = BehaviorSubject(); /// Stream of [Event] coming from [_ws] connection /// Listen to this or use the [on] method to filter specific event types Stream get eventStream => _eventController.stream; final _wsConnectionStatusController = BehaviorSubject.seeded(ConnectionStatus.disconnected); set _wsConnectionStatus(ConnectionStatus status) => _wsConnectionStatusController.add(status); /// The current status value of the [_ws] connection ConnectionStatus get wsConnectionStatus => _wsConnectionStatusController.value; /// This notifies the connection status of the [_ws] connection. /// Listen to this to get notified when the [_ws] tries to reconnect. Stream get wsConnectionStatusStream => _wsConnectionStatusController.stream.distinct(); LogHandlerFunction get _defaultLogHandler => (LogRecord record) { print( '${record.time} ' '${_levelEmojiMapper[record.level] ?? record.level.name} ' '${record.loggerName} ${record.message} ', ); if (record.error != null) print(record.error); if (record.stackTrace != null) print(record.stackTrace); }; /// Logger detachedLogger(String name) => Logger.detached(name) ..level = logLevel ..onRecord.listen(logHandlerFunction); /// Connects the current user, this triggers a connection to the API. /// It returns a [Future] that resolves when the connection is setup. /// Pass [connectWebSocket]: false, if you want to connect to websocket /// at a later stage or use the client in connection-less mode Future connectUser( User user, String token, { bool connectWebSocket = true, }) => _connectUser( user, token: Token.fromRawValue(token), connectWebSocket: connectWebSocket, ); /// 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, TokenProvider tokenProvider, { bool connectWebSocket = true, }) => _connectUser( user, provider: tokenProvider, connectWebSocket: connectWebSocket, ); /// 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({ bool connectWebSocket = true, }) async { final token = Token.anonymous(); final user = OwnUser(id: token.userId); return _connectUser( user, token: token, connectWebSocket: connectWebSocket, ); } /// 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, { bool connectWebSocket = true, }) async { final userId = user.id; final anonymousToken = Token.anonymous(userId: userId); // setting anonymous token so that getGuestUser works _tokenManager.setTokenOrProvider(userId, token: anonymousToken); final guestUser = await _chatApi.guest.getGuestUser(user); // resetting tokenManager after successful request _tokenManager.reset(); final guestUserToken = Token.fromRawValue(guestUser.accessToken); return _connectUser( guestUser.user, token: guestUserToken, connectWebSocket: connectWebSocket, ); } Future _connectUser( User user, { Token? token, TokenProvider? provider, bool connectWebSocket = true, }) async { if (_ws.connectionCompleter?.isCompleted == false) { throw const StreamChatError( 'User already getting connected, try calling `disconnectUser` ' 'before trying to connect again', ); } logger.info('setting user : ${user.id}'); await _tokenManager.setTokenOrProvider( user.id, token: token, provider: provider, ); final ownUser = OwnUser.fromUser(user); state.currentUser = ownUser; if (!connectWebSocket) return ownUser; try { if (_originalChatPersistenceClient != null) { _chatPersistenceClient = _originalChatPersistenceClient; await _chatPersistenceClient!.connect(ownUser.id); } final connectedUser = await openConnection(); return state.currentUser = connectedUser; } catch (e, stk) { if (e is StreamWebSocketError && e.isRetriable) { final event = await _chatPersistenceClient?.getConnectionInfo(); if (event != null) return ownUser.merge(event.me); } logger.severe('error connecting user : ${ownUser.id}', e, stk); rethrow; } } /// Creates a new WebSocket connection with the current user. Future openConnection() async { assert( state.currentUser != null, 'User is not set on client, ' 'use `connectUser` or `connectAnonymousUser` instead', ); final user = state.currentUser!; logger.info('Opening web-socket connection for ${user.id}'); if (wsConnectionStatus == ConnectionStatus.connecting) { throw StreamChatError('Connection already in progress for ${user.id}'); } if (wsConnectionStatus == ConnectionStatus.connected) { throw StreamChatError('Connection already available for ${user.id}'); } _wsConnectionStatus = ConnectionStatus.connecting; // skipping `ws` seed connection status -> ConnectionStatus.disconnected // otherwise `client.wsConnectionStatusStream` will emit in order // 1. ConnectionStatus.disconnected -> client seed status // 2. ConnectionStatus.connecting -> client connecting status // 3. ConnectionStatus.disconnected -> ws seed status _connectionStatusSubscription = _ws.connectionStatusStream.skip(1).listen(_connectionStatusHandler); try { final event = await _ws.connect(user); return user.merge(event.me); } catch (e, stk) { logger.severe('error connecting ws', e, stk); rethrow; } } /// Disconnects the [_ws] connection, /// without removing the user set on client. /// /// This will not trigger default auto-retry mechanism for reconnection. /// You need to call [openConnection] to reconnect to [_ws]. void closeConnection() { if (wsConnectionStatus == ConnectionStatus.disconnected) return; logger.info('Closing web-socket connection for ${state.currentUser?.id}'); _wsConnectionStatus = ConnectionStatus.disconnected; _connectionStatusSubscription?.cancel(); _connectionStatusSubscription = null; _ws.disconnect(); } void _handleHealthCheckEvent(Event event) { final connectionId = event.connectionId; if (connectionId != null) { _connectionIdManager.setConnectionId(connectionId); _chatPersistenceClient?.updateConnectionInfo(event); } } /// Method called to add a new event to the [_eventController]. void handleEvent(Event event) { if (event.type == EventType.healthCheck) { return _handleHealthCheckEvent(event); } if (!event.isLocal && _synced) { _lastSyncedAt = event.createdAt; _chatPersistenceClient?.updateLastSyncAt(event.createdAt); } state.updateUser(event.user); return _eventController.add(event); } void _connectionStatusHandler(ConnectionStatus status) async { final currentState = _wsConnectionStatus = status; handleEvent(Event( type: EventType.connectionChanged, online: status == ConnectionStatus.connected, )); if (currentState == ConnectionStatus.connected) { // connection recovered final cids = state.channels.keys.toList(growable: false); if (cids.isNotEmpty) { await queryChannelsOnline( filter: Filter.in_('cid', cids), paginationParams: const PaginationParams(limit: 30), ); if (persistenceEnabled) { await sync(cids: cids, lastSyncAt: _lastSyncedAt); } } handleEvent(Event( type: EventType.connectionRecovered, online: true, )); } else { _synced = false; } } /// Stream of [Event] coming from [_ws] 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, ]) { if (eventType == null) return eventStream; return eventStream.where((event) => event.type == eventType || event.type == eventType2 || event.type == eventType3 || event.type == eventType4); } /// Get the events missed while offline to sync the offline storage /// Will automatically fetch [cids] and [lastSyncedAt] if [persistenceEnabled] Future sync({List? cids, DateTime? lastSyncAt}) async { cids ??= await _chatPersistenceClient?.getChannelCids(); if (cids == null || cids.isEmpty) { _synced = true; return; } lastSyncAt ??= await _chatPersistenceClient?.getLastSyncAt(); if (lastSyncAt == null) { _synced = true; return; } try { final res = await _chatApi.general.sync(cids, lastSyncAt); final events = res.events ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); for (final event in events) { logger.fine('event.type: ${event.type}'); final messageText = event.message?.text; if (messageText != null) { logger.fine('event.message.text: $messageText'); } handleEvent(event); } _synced = true; final now = DateTime.now(); _lastSyncedAt = now; _chatPersistenceClient?.updateLastSyncAt(now); } catch (e, stk) { _synced = false; logger.severe('Error during sync', e, stk); } } final _queryChannelsStreams = >>{}; /// Requests channels with a given query. Stream> queryChannels({ Filter? filter, List>? sort, bool state = true, bool watch = true, bool presence = false, int? memberLimit, int? messageLimit, PaginationParams paginationParams = const PaginationParams(), bool waitForConnect = true, }) async* { if (!_connectionIdManager.hasConnectionId) { // ignore: parameter_assignments watch = false; } final hash = generateHash([ filter, sort, state, watch, presence, memberLimit, messageLimit, paginationParams, ]); 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, state: state, watch: watch, presence: presence, memberLimit: memberLimit, messageLimit: messageLimit, paginationParams: paginationParams, 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, bool state = true, bool watch = true, bool presence = false, int? memberLimit, int? messageLimit, bool waitForConnect = true, PaginationParams paginationParams = const PaginationParams(), }) async { if (waitForConnect) { if (_ws.connectionCompleter?.isCompleted == false) { logger.info('awaiting connection completer'); await _ws.connectionCompleter?.future; } if (wsConnectionStatus != ConnectionStatus.connected) { throw const StreamChatError( 'You cannot use queryChannels without an active connection. ' 'Please call `connectUser` to connect the client.', ); } } if (!_connectionIdManager.hasConnectionId) { // ignore: parameter_assignments watch = false; } logger.info('Query channel start'); final res = await _chatApi.channel.queryChannels( filter: filter, sort: sort, state: state, watch: watch, presence: presence, memberLimit: memberLimit, messageLimit: messageLimit, paginationParams: paginationParams, ); 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); this.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, ); this.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); } /// Requests users with a given query. Future queryUsers({ bool? presence, Filter? filter, List? sort, PaginationParams? pagination, }) async { final response = await _chatApi.user.queryUsers( presence: presence ?? _connectionIdManager.hasConnectionId, filter: filter, sort: sort, pagination: pagination, ); state.updateUsers(response.users); return response; } /// A message search. Future search( Filter filter, { String? query, List? sort, PaginationParams? paginationParams, Filter? messageFilters, }) => _chatApi.general.searchMessages( filter, query: query, sort: sort, pagination: paginationParams, messageFilters: messageFilters, ); /// Send a [file] to the [channelId] of type [channelType] Future sendFile( AttachmentFile file, String channelId, String channelType, { ProgressCallback? onSendProgress, CancelToken? cancelToken, }) => _chatApi.fileUploader.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, }) => _chatApi.fileUploader.sendImage( image, channelId, channelType, onSendProgress: onSendProgress, cancelToken: cancelToken, ); /// Delete a file from this channel Future deleteFile( String url, String channelId, String channelType, { CancelToken? cancelToken, }) => _chatApi.fileUploader.deleteFile( url, channelId, channelType, cancelToken: cancelToken, ); /// Delete an image from this channel Future deleteImage( String url, String channelId, String channelType, { CancelToken? cancelToken, }) => _chatApi.fileUploader.deleteImage( url, channelId, channelType, cancelToken: cancelToken, ); /// Replaces the [channelId] of type [ChannelType] data with [data]. /// /// Use [updateChannelPartial] for a partial update. Future updateChannel( String channelId, String channelType, Map data, { Message? message, }) => _chatApi.channel.updateChannel( channelId, channelType, data, message: message, ); /// Partial update for the [channelId] of type [ChannelType]. Sets the /// data provided in [set], and removes the attributes given in [unset]. /// /// Use [updateChannel] for a full update. Future updateChannelPartial( String channelId, String channelType, { Map? set, List? unset, }) => _chatApi.channel.updateChannelPartial( channelId, channelType, set: set, unset: unset, ); /// Add a device for Push Notifications. Future addDevice(String id, PushProvider pushProvider) => _chatApi.device.addDevice(id, pushProvider); /// Gets a list of user devices. Future getDevices() => _chatApi.device.getDevices(); /// Remove a user's device. Future removeDevice(String id) => _chatApi.device.removeDevice(id); /// Get a development token Token devToken(String userId) => Token.development(userId); /// Returns a channel client with the given type, id and custom data. Channel channel( String type, { String? id, Map? extraData, }) { if (id != null && state.channels.containsKey('$type:$id')) { return state.channels['$type:$id']!; } return Channel(this, type, id, extraData: extraData); } /// Creates a new channel Future createChannel( String channelType, { String? channelId, Map? channelData, }) => queryChannel( channelType, channelId: channelId, state: false, channelData: channelData, ); /// watches the provided channel /// Creates first if not yet created Future watchChannel( String channelType, { String? channelId, Map? channelData, }) => queryChannel( channelType, channelId: channelId, watch: true, channelData: channelData, ); /// Query the API, get messages, members or other channel fields /// Creates the channel first if not yet created Future queryChannel( String channelType, { bool state = true, bool watch = false, bool presence = false, String? channelId, Map? channelData, PaginationParams? messagesPagination, PaginationParams? membersPagination, PaginationParams? watchersPagination, }) => _chatApi.channel.queryChannel( channelType, channelId: channelId, channelData: channelData, state: state, watch: watch, presence: presence, messagesPagination: messagesPagination, membersPagination: membersPagination, watchersPagination: watchersPagination, ); /// Query channel members Future queryMembers( String channelType, { Filter? filter, String? channelId, List? members, List? sort, PaginationParams? pagination, }) => _chatApi.general.queryMembers( channelType, channelId: channelId, filter: filter, members: members, sort: sort, pagination: pagination, ); /// Hides the channel from [queryChannels] for the user /// until a message is added If [clearHistory] is set to true - all messages /// will be removed for the user Future hideChannel( String channelId, String channelType, { bool clearHistory = false, }) => _chatApi.channel.hideChannel( channelId, channelType, clearHistory: clearHistory, ); /// Removes the hidden status for the channel Future showChannel( String channelId, String channelType, ) => _chatApi.channel.showChannel( channelId, channelType, ); /// Delete this channel. Messages are permanently removed. Future deleteChannel( String channelId, String channelType, ) => _chatApi.channel.deleteChannel( channelId, channelType, ); /// Removes all messages from the channel Future truncateChannel( String channelId, String channelType, ) => _chatApi.channel.truncateChannel( channelId, channelType, ); /// Mutes the channel Future muteChannel( String channelCid, { Duration? expiration, }) => _chatApi.moderation.muteChannel( channelCid, expiration: expiration, ); /// Unmutes the channel Future unmuteChannel(String channelCid) => _chatApi.moderation.unmuteChannel(channelCid); /// Accept invitation to the channel Future acceptChannelInvite( String channelId, String channelType, { Message? message, }) => _chatApi.channel.acceptChannelInvite( channelId, channelType, message: message, ); /// Reject invitation to the channel Future rejectChannelInvite( String channelId, String channelType, { Message? message, }) => _chatApi.channel.rejectChannelInvite( channelId, channelType, message: message, ); /// Add members to the channel Future addChannelMembers( String channelId, String channelType, List memberIds, { Message? message, }) => _chatApi.channel.addMembers( channelId, channelType, memberIds, message: message, ); /// Remove members from the channel Future removeChannelMembers( String channelId, String channelType, List memberIds, { Message? message, }) => _chatApi.channel.removeMembers( channelId, channelType, memberIds, message: message, ); /// Invite members to the channel Future inviteChannelMembers( String channelId, String channelType, List memberIds, { Message? message, }) => _chatApi.channel.inviteChannelMembers( channelId, channelType, memberIds, message: message, ); /// Stop watching the channel Future stopChannelWatching( String channelId, String channelType, ) => _chatApi.channel.stopWatching( channelId, channelType, ); /// Send action for a specific message of this channel Future sendAction( String channelId, String channelType, String messageId, Map formData, ) => _chatApi.message.sendAction( channelId, channelType, messageId, formData, ); /// Mark [channelId] of type [channelType] all messages as read /// Optionally provide a [messageId] if you want to mark a /// particular message as read Future markChannelRead( String channelId, String channelType, { String? messageId, }) => _chatApi.channel.markRead( channelId, channelType, messageId: messageId, ); /// Update or Create the given user object. Future updateUser(User user) => updateUsers([user]); /// Batch update a list of users Future updateUsers(List users) => _chatApi.user.updateUsers(users); /// Bans a user from all channels Future banUser( String targetUserId, [ Map options = const {}, ]) => _chatApi.moderation.banUser( targetUserId, options: options, ); /// Remove global ban for a user Future unbanUser( String targetUserId, [ Map options = const {}, ]) => _chatApi.moderation.unbanUser( targetUserId, options: options, ); /// Shadow bans a user Future shadowBan( String targetID, [ Map options = const {}, ]) => banUser(targetID, { 'shadow': true, ...options, }); /// Removes shadow ban from a user Future removeShadowBan( String targetID, [ Map options = const {}, ]) => unbanUser(targetID, { 'shadow': true, ...options, }); /// Mutes a user Future muteUser(String userId) => _chatApi.moderation.muteUser(userId); /// Unmutes a user Future unmuteUser(String userId) => _chatApi.moderation.unmuteUser(userId); /// Flag a message Future flagMessage(String messageId) => _chatApi.moderation.flagMessage(messageId); /// Unflag a message Future unflagMessage(String messageId) => _chatApi.moderation.unflagMessage(messageId); /// Flag a user Future flagUser(String userId) => _chatApi.moderation.flagUser(userId); /// Unflag a message Future unflagUser(String userId) => _chatApi.moderation.unflagUser(userId); /// Mark all channels for this user as read Future markAllRead() => _chatApi.channel.markAllRead(); /// Send an event to a particular channel Future sendEvent( String channelId, String channelType, Event event, ) => _chatApi.channel.sendEvent( channelId, channelType, event, ); /// Send a [reactionType] for this [messageId] /// Set [enforceUnique] to true to remove the existing user reaction Future sendReaction( String messageId, String reactionType, { Map extraData = const {}, bool enforceUnique = false, }) => _chatApi.message.sendReaction( messageId, reactionType, extraData: extraData, enforceUnique: enforceUnique, ); /// Delete a [reactionType] from this [messageId] Future deleteReaction( String messageId, String reactionType, ) => _chatApi.message.deleteReaction( messageId, reactionType, ); /// Sends the message to the given channel Future sendMessage( Message message, String channelId, String channelType, { bool skipPush = false, }) => _chatApi.message.sendMessage( channelId, channelType, message, skipPush: skipPush, ); /// Lists all the message replies for the [parentId] Future getReplies( String parentId, { PaginationParams? options, }) => _chatApi.message.getReplies( parentId, options: options, ); /// Get all the reactions for a [messageId] Future getReactions( String messageId, { PaginationParams? pagination, }) => _chatApi.message.getReactions( messageId, pagination: pagination, ); /// Update the given message Future updateMessage(Message message) => _chatApi.message.updateMessage(message); /// Partially update the given [messageId] /// Use [set] to define values to be set /// Use [unset] to define values to be unset Future partialUpdateMessage( String messageId, { Map? set, List? unset, }) => _chatApi.message.partialUpdateMessage( messageId, set: set, unset: unset, ); /// Deletes the given message Future deleteMessage(String messageId) => _chatApi.message.deleteMessage(messageId); /// Get a message by [messageId] Future getMessage(String messageId) => _chatApi.message.getMessage(messageId); /// Retrieves a list of messages by [messageIDs] /// from the given [channelId] of type [channelType] Future getMessagesById( String channelId, String channelType, List messageIDs, ) => _chatApi.message.getMessagesById( channelId, channelType, messageIDs, ); /// Translates the [messageId] in provided [language] Future translateMessage( String messageId, String language, ) => _chatApi.message.translateMessage( messageId, language, ); /// Enables slow mode Future enableSlowdown( String channelId, String channelType, int cooldown, ) async => _chatApi.channel.enableSlowdown( channelId, channelType, cooldown, ); /// Disables slow mode Future disableSlowdown( String channelId, String channelType, ) async => _chatApi.channel.disableSlowdown( channelId, channelType, ); /// Pins provided message /// [timeoutOrExpirationDate] can either be a [DateTime] or a value in seconds /// to be added to [DateTime.now] Future pinMessage( String messageId, { Object? /*num|DateTime*/ timeoutOrExpirationDate, }) { assert(() { if (timeoutOrExpirationDate is! DateTime && timeoutOrExpirationDate != null && timeoutOrExpirationDate is! num) { throw ArgumentError('Invalid timeout or Expiration date'); } return true; }(), 'Check for invalid timeout or expiration date'); DateTime? pinExpires; if (timeoutOrExpirationDate is DateTime) { pinExpires = timeoutOrExpirationDate; } else if (timeoutOrExpirationDate is num) { pinExpires = DateTime.now().add( Duration(seconds: timeoutOrExpirationDate.toInt()), ); } return partialUpdateMessage( messageId, set: { 'pinned': true, 'pin_expires': pinExpires?.toUtc().toIso8601String(), }, ); } /// Unpins provided message Future unpinMessage(String messageId) => partialUpdateMessage( messageId, set: { 'pinned': false, }, ); /// Closes the [_ws] connection and resets the [state] /// If [flushChatPersistence] is true the client deletes all offline /// user's data. Future disconnectUser({bool flushChatPersistence = false}) async { logger.info('Disconnecting user : ${state.currentUser?.id}'); // resetting state state.dispose(); state = ClientState(this); // resetting credentials _tokenManager.reset(); _connectionIdManager.reset(); // disconnecting persistence client await _chatPersistenceClient?.disconnect(flush: flushChatPersistence); _chatPersistenceClient = null; // closing web-socket connection closeConnection(); } /// Call this function to dispose the client Future dispose() async { logger.info('Disposing new StreamChatClient'); // disposing state state.dispose(); // disconnecting persistence client await _chatPersistenceClient?.disconnect(); // closing web-socket connection closeConnection(); await _eventController.close(); await _wsConnectionStatusController.close(); } } /// 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 && event.type != EventType.healthCheck) .map((e) => e.me!) .listen((user) => currentUser = currentUser?.merge(user) ?? user), _client .on() .map((event) => event.unreadChannels) .whereType() .listen(_unreadChannelsController.add), _client .on() .map((event) => event.totalUnreadCount) .whereType() .listen(_totalUnreadCountController.add), ]); _listenChannelDeleted(); _listenChannelHidden(); _listenUserUpdated(); } final _subscriptions = []; /// Used internally for optimistic update of unread count set totalUnreadCount(int unreadCount) { _totalUnreadCountController.add(unreadCount); } 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 == currentUser!.id) { currentUser = 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; /// Sets the user currently interacting with the client /// note: this fully overrides the [currentUser] set currentUser(OwnUser? user) { _computeUnreadCounts(user); _currentUserController.add(user); } /// Update all the [users] with the provided [userList] void updateUsers(List userList) { final newUsers = { ...users, for (var user in userList) if (user != null) user.id: user, }; _usersController.add(newUsers); } /// Update the passed [user] in state void updateUser(User? user) => updateUsers([user]); /// The current user OwnUser? get currentUser => _currentUserController.valueOrNull; /// The current user as a stream Stream get currentUserStream => _currentUserController.stream; // coverage:ignore-start /// The current user @Deprecated('Use `.currentUser` instead, Will be removed in future releases') OwnUser? get user => _currentUserController.valueOrNull; /// The current user as a stream @Deprecated( 'Use `.currentUserStream` instead, Will be removed in future releases', ) Stream get userStream => _currentUserController.stream; // coverage:ignore-end /// 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.value; /// The current unread channels count as a stream Stream get unreadChannelsStream => _unreadChannelsController.stream; /// The current total unread messages count int get totalUnreadCount => _totalUnreadCountController.value; /// 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 channelMap) { final newChannels = {...channels, ...channelMap}; _channelsController.add(newChannels); } void _computeUnreadCounts(OwnUser? user) { final totalUnreadCount = user?.totalUnreadCount; if (totalUnreadCount != null) { _totalUnreadCountController.add(totalUnreadCount); } final unreadChannels = user?.unreadChannels; if (unreadChannels != null) { _unreadChannelsController.add(unreadChannels); } } final _channelsController = BehaviorSubject>.seeded({}); final _currentUserController = BehaviorSubject(); final _usersController = BehaviorSubject>.seeded({}); final _unreadChannelsController = BehaviorSubject.seeded(0); final _totalUnreadCountController = BehaviorSubject.seeded(0); /// Call this method to dispose this object void dispose() { _subscriptions.forEach((s) => s.cancel()); _currentUserController.close(); _unreadChannelsController.close(); _totalUnreadCountController.close(); channels.values.forEach((c) => c.dispose()); _channelsController.close(); } }