Files
stream-chat-flutter/packages/stream_chat/lib/src/client.dart
T

1544 lines
46 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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> = T Function(Map<String, dynamic>);
/// 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);
/// 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<ConnectionStatus>? _connectionStatusSubscription;
Future<void> Function(ConnectionStatus)? _connectionStatusHandler;
final BehaviorSubject<Event> _controller = BehaviorSubject<Event>();
/// Stream of [Event] coming from websocket connection
/// Listen to this or use the [on] method to filter specific event types
Stream<Event> 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<ConnectionStatus> 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<void> _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<String, String?> 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<Event?> 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<Event?> 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<Event?> 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<Event?> 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<Event> 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<Event>? _connectCompleter;
/// Connect the client websocket
Future<Event?> 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<void> resync([List<String>? 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<SyncResponse>(
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 = <String, Future<List<Channel>>>{};
/// Requests channels with a given query.
Stream<List<Channel>> queryChannels({
Filter? filter,
List<SortOption<ChannelModel>>? sort,
Map<String, dynamic>? 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<List<Channel>> queryChannelsOnline({
Filter? filter,
List<SortOption<ChannelModel>>? sort,
Map<String, dynamic>? 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 = <String, dynamic>{
'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<QueryChannelsResponse>(
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 <Channel>[];
}
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<List<Channel>> queryChannelsOffline({
Filter? filter,
List<SortOption<ChannelModel>>? 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<Map<String, Channel>, List<Channel>> _mapChannelStateToChannel(
List<ChannelState> channelStates,
) {
final channels = {...state.channels};
final newChannels = <Channel>[];
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<Response<String>> get(
String path, {
Map<String, dynamic>? queryParameters,
}) async {
try {
final response = await httpClient.get<String>(
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<Response<String>> post(
String path, {
dynamic data,
ProgressCallback? onSendProgress,
CancelToken? cancelToken,
}) async {
try {
final response = await httpClient.post<String>(
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<Response<String>> delete(
String path, {
Map<String, dynamic>? queryParameters,
CancelToken? cancelToken,
}) async {
try {
final response = await httpClient.delete<String>(
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<Response<String>> patch(
String path, {
Map<String, dynamic>? queryParameters,
dynamic data,
}) async {
try {
final response = await httpClient.patch<String>(
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<Response<String>> put(
String path, {
Map<String, dynamic>? queryParameters,
dynamic data,
}) async {
try {
final response = await httpClient.put<String>(
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<T>(String? j, DecoderFunction<T> 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<String, String?> 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<Event?> 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<Event?> 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<Event?> 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<Event?> connectGuestUser(User user) async {
_anonymous = true;
final response = await post('/guest', data: {'user': user.toJson()})
.then((res) => decode<ConnectGuestUserResponse>(
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<void> 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<void> _disconnect() async {
logger.info('Client disconnecting');
await _ws.disconnect();
await _connectionStatusSubscription?.cancel();
}
/// Requests users with a given query.
Future<QueryUsersResponse> queryUsers({
Filter? filter,
List<SortOption>? sort,
Map<String, dynamic>? options,
PaginationParams? pagination,
}) async {
final defaultOptions = {
'presence': _hasConnectionId,
};
final payload = <String, dynamic>{
'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<QueryUsersResponse>(
rawRes.data,
QueryUsersResponse.fromJson,
);
state._updateUsers(response.users);
return response;
}
/// A message search.
Future<SearchMessagesResponse> search(
Filter filter, {
String? query,
List<SortOption>? 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<SearchMessagesResponse>(
response.data, SearchMessagesResponse.fromJson);
}
/// Send a [file] to the [channelId] of type [channelType]
Future<SendFileResponse> 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<SendImageResponse> 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<EmptyResponse> deleteFile(
String url,
String channelId,
String channelType, {
CancelToken? cancelToken,
}) =>
attachmentFileUploader!.deleteFile(
url,
channelId,
channelType,
cancelToken: cancelToken,
);
/// Delete an image from this channel
Future<EmptyResponse> deleteImage(
String url,
String channelId,
String channelType, {
CancelToken? cancelToken,
}) =>
attachmentFileUploader!.deleteImage(
url,
channelId,
channelType,
cancelToken: cancelToken,
);
/// Add a device for Push Notifications.
Future<EmptyResponse> addDevice(String id, PushProvider pushProvider) async {
final response = await post('/devices', data: {
'id': id,
'push_provider': pushProvider.name,
});
return decode<EmptyResponse>(response.data, EmptyResponse.fromJson);
}
/// Gets a list of user devices.
Future<ListDevicesResponse> getDevices() async {
final response = await get('/devices');
return decode<ListDevicesResponse>(
response.data, ListDevicesResponse.fromJson);
}
/// Remove a user's device.
Future<EmptyResponse> 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<String, Object?> 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<UpdateUsersResponse> updateUser(User user) async =>
updateUsers([user]);
/// Batch update a list of users
Future<UpdateUsersResponse> updateUsers(List<User> users) async {
final response = await post('/users', data: {
'users': users.asMap().map((_, u) => MapEntry(u.id, u.toJson())),
});
return decode<UpdateUsersResponse>(
response.data,
UpdateUsersResponse.fromJson,
);
}
/// Bans a user from all channels
Future<EmptyResponse> banUser(
String targetUserID, [
Map<String, dynamic> options = const {},
]) async {
final data = Map<String, dynamic>.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<EmptyResponse> unbanUser(
String targetUserID, [
Map<String, dynamic> options = const {},
]) async {
final data = Map<String, dynamic>.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<EmptyResponse> shadowBan(
String targetID, [
Map<String, dynamic> options = const {},
]) async =>
banUser(targetID, {
'shadow': true,
...options,
});
/// Removes shadow ban from a user
Future<EmptyResponse> removeShadowBan(
String targetID, [
Map<String, dynamic> options = const {},
]) async =>
unbanUser(targetID, {
'shadow': true,
...options,
});
/// Mutes a user
Future<EmptyResponse> muteUser(String targetID) async {
final response = await post('/moderation/mute', data: {
'target_id': targetID,
});
return decode(response.data, EmptyResponse.fromJson);
}
/// Unmutes a user
Future<EmptyResponse> unmuteUser(String targetID) async {
final response = await post('/moderation/unmute', data: {
'target_id': targetID,
});
return decode(response.data, EmptyResponse.fromJson);
}
/// Flag a message
Future<EmptyResponse> 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<EmptyResponse> 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<EmptyResponse> 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<EmptyResponse> 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<EmptyResponse> markAllRead() async {
final response = await post('/channels/read');
return decode(response.data, EmptyResponse.fromJson);
}
/// Sends the message to the given channel
Future<SendMessageResponse> 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<UpdateMessageResponse> 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<EmptyResponse> deleteMessage(Message message) async {
final response = await delete('/messages/${message.id}');
return decode(response.data, EmptyResponse.fromJson);
}
/// Get a message by id
Future<GetMessageResponse> 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<UpdateMessageResponse> 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<UpdateMessageResponse> 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 = <StreamSubscription>[];
/// 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<User?> 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<OwnUser?> get userStream => _userController.stream;
/// The current user
Map<String?, User?> get users => _usersController.value;
/// The current user as a stream
Stream<Map<String?, User?>> get usersStream => _usersController.stream;
/// The current unread channels count
int? get unreadChannels => _unreadChannelsController.valueOrNull;
/// The current unread channels count as a stream
Stream<int?> 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<int?> get totalUnreadCountStream => _totalUnreadCountController.stream;
/// The current list of channels in memory as a stream
Stream<Map<String?, Channel>?> get channelsStream =>
_channelsController.stream;
/// The current list of channels in memory
Map<String, Channel> get channels => _channelsController.value;
set channels(Map<String, Channel> v) {
_channelsController.add(v);
}
final BehaviorSubject<Map<String, Channel>> _channelsController =
BehaviorSubject.seeded({});
final BehaviorSubject<OwnUser?> _userController = BehaviorSubject();
final BehaviorSubject<Map<String?, User?>> _usersController =
BehaviorSubject.seeded({});
final BehaviorSubject<int?> _unreadChannelsController = BehaviorSubject();
final BehaviorSubject<int?> _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();
}
}